From 8e96d78bf96fb81ede1e5cea433a0e7462d52b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Wed, 25 Mar 2026 11:16:16 +0100 Subject: [PATCH 01/51] feat: Adds ConceptRecord, ElaborationTask, and ConceptConversation entities and use cases. --- .gitignore | 2 +- Clean CaDET Tutor.slnx | 6 + .../Internal/IOwnershipValidator.cs | 1 + .../Internal/ITokenSpendingService.cs | 10 + .../Domain/TokenWallet/AiFeatureType.cs | 3 +- .../UseCases/Authoring/OwnedCourseService.cs | 5 + .../Management/TokenSpendingService.cs | 28 ++- .../ConceptRecords/BoundaryConditionDto.cs | 9 + .../ConceptRecords/CommonMisconceptionDto.cs | 9 + .../Dtos/ConceptRecords/ConceptRecordDto.cs | 12 ++ .../Dtos/ConceptRecords/KeyPropositionDto.cs | 9 + .../Conversations/ConversationAttemptDto.cs | 12 ++ .../Dtos/Conversations/ConversationTurnDto.cs | 11 ++ .../Dtos/Conversations/ElaborationTaskDto.cs | 11 ++ .../Conversations/SubmitTurnRequestDto.cs | 6 + .../Conversations/SubmitTurnResponseDto.cs | 7 + .../Internal/IElaborationTaskQuerier.cs | 6 + .../Public/Authoring/IConceptRecordService.cs | 13 ++ .../Authoring/IElaborationTaskService.cs | 12 ++ .../Public/IAccessServices.cs | 8 + .../Public/Learning/IConversationService.cs | 13 ++ .../Tutor.Elaborations.API.csproj | 13 ++ .../ConceptRecords/BoundaryCondition.cs | 11 ++ .../ConceptRecords/CommonMisconception.cs | 11 ++ .../Domain/ConceptRecords/ConceptRecord.cs | 39 ++++ .../IConceptRecordRepository.cs | 8 + .../Domain/ConceptRecords/KeyProposition.cs | 11 ++ .../Domain/ConceptRecords/PropositionLevel.cs | 8 + .../Domain/Conversations/AttemptStatus.cs | 10 + .../Conversations/ConversationAttempt.cs | 91 +++++++++ .../Domain/Conversations/ConversationTurn.cs | 27 +++ .../IConversationAttemptRepository.cs | 10 + .../Domain/Conversations/TurnEvaluation.cs | 33 ++++ .../Domain/Conversations/TurnRole.cs | 7 + .../ElaborationTasks/ElaborationTask.cs | 12 ++ .../IElaborationTaskRepository.cs | 8 + .../Mappers/ConceptRecordProfile.cs | 18 ++ .../Mappers/ConversationProfile.cs | 19 ++ .../Tutor.Elaborations.Core.csproj | 14 ++ .../UseCases/AccessServices.cs | 32 +++ .../Authoring/ConceptRecordService.cs | 65 ++++++ .../Authoring/ElaborationTaskService.cs | 56 ++++++ .../UseCases/IElaborationsUnitOfWork.cs | 5 + .../UseCases/Learning/ConversationService.cs | 187 ++++++++++++++++++ .../Orchestration/ConversationState.cs | 8 + .../Orchestration/EvaluationResult.cs | 5 + .../Learning/Orchestration/IDialogueAgent.cs | 11 ++ .../Orchestration/IEvaluationAgent.cs | 12 ++ .../Learning/Orchestration/ISummaryAgent.cs | 11 ++ .../Orchestration/TurnOrchestrator.cs | 40 ++++ .../Monitoring/ElaborationTaskQuerier.cs | 19 ++ .../Agents/DialogueAgent.cs | 43 ++++ .../Agents/EvaluationAgent.cs | 78 ++++++++ .../Agents/Prompts/DialoguePromptBuilder.cs | 63 ++++++ .../Agents/Prompts/EvaluationPromptBuilder.cs | 73 +++++++ .../Agents/Prompts/SummaryPromptBuilder.cs | 40 ++++ .../Agents/SummaryAgent.cs | 32 +++ .../Database/ElaborationsContext.cs | 80 ++++++++ .../Database/ElaborationsUnitOfWork.cs | 9 + .../ConceptRecordDatabaseRepository.cs | 30 +++ .../ConversationAttemptDatabaseRepository.cs | 46 +++++ .../ElaborationTaskDatabaseRepository.cs | 18 ++ .../ElaborationsStartup.cs | 70 +++++++ .../Tutor.Elaborations.Infrastructure.csproj | 15 ++ .../Tutor.Elaborations.Tests.csproj | 29 +++ .../Controllers/BaseApiController.cs | 1 + .../Elaboration/ConceptRecordController.cs | 57 ++++++ .../Elaboration/ElaborationTaskController.cs | 50 +++++ .../Elaboration/ConversationController.cs | 60 ++++++ src/Tutor.API/Startup/ModulesConfiguration.cs | 4 +- src/Tutor.API/Tutor.API.csproj | 2 + 71 files changed, 1790 insertions(+), 4 deletions(-) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/BoundaryConditionDto.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/CommonMisconceptionDto.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/ConceptRecordDto.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyPropositionDto.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationTurnDto.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDto.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnRequestDto.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnResponseDto.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Internal/IElaborationTaskQuerier.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptRecordService.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IElaborationTaskService.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Public/IAccessServices.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Tutor.Elaborations.API.csproj create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/IConceptRecordRepository.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/PropositionLevel.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/IConversationAttemptRepository.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnRole.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/ElaborationTask.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/IElaborationTaskRepository.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConversationProfile.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Tutor.Elaborations.Core.csproj create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/AccessServices.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptRecordService.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/IElaborationsUnitOfWork.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ConversationState.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/EvaluationResult.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ISummaryAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnOrchestrator.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Monitoring/ElaborationTaskQuerier.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/SummaryPromptBuilder.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/SummaryAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsUnitOfWork.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ElaborationTaskDatabaseRepository.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Tutor.Elaborations.Infrastructure.csproj create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj create mode 100644 src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptRecordController.cs create mode 100644 src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ElaborationTaskController.cs create mode 100644 src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs 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/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/Modules/Courses/Tutor.Courses.API/Internal/IOwnershipValidator.cs b/src/Modules/Courses/Tutor.Courses.API/Internal/IOwnershipValidator.cs index 5ff0c744f..ca8e1dfc1 100644 --- a/src/Modules/Courses/Tutor.Courses.API/Internal/IOwnershipValidator.cs +++ b/src/Modules/Courses/Tutor.Courses.API/Internal/IOwnershipValidator.cs @@ -2,5 +2,6 @@ public interface IOwnershipValidator { + bool IsCourseOwner(int courseId, int instructorId); bool IsUnitOwner(int unitId, int instructorId); } \ No newline at end of file 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/Authoring/OwnedCourseService.cs b/src/Modules/Courses/Tutor.Courses.Core/UseCases/Authoring/OwnedCourseService.cs index 412169553..fa401053c 100644 --- a/src/Modules/Courses/Tutor.Courses.Core/UseCases/Authoring/OwnedCourseService.cs +++ b/src/Modules/Courses/Tutor.Courses.Core/UseCases/Authoring/OwnedCourseService.cs @@ -49,6 +49,11 @@ public Result Update(CourseDto course, int instructorId) return MapToDto(updatedCourse); } + public bool IsCourseOwner(int courseId, int instructorId) + { + return _ownedCourseRepository.IsCourseOwner(courseId, instructorId); + } + public bool IsUnitOwner(int unitId, int instructorId) { return _ownedCourseRepository.IsUnitOwner(unitId, instructorId); 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/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/BoundaryConditionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/BoundaryConditionDto.cs new file mode 100644 index 000000000..c989c0075 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/BoundaryConditionDto.cs @@ -0,0 +1,9 @@ +namespace Tutor.Elaborations.API.Dtos.ConceptRecords; + +public class BoundaryConditionDto +{ + public int Id { get; set; } + public string Statement { get; set; } = string.Empty; + public string Level { get; set; } = string.Empty; + public int Order { get; set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/CommonMisconceptionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/CommonMisconceptionDto.cs new file mode 100644 index 000000000..1cd9cec1b --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/CommonMisconceptionDto.cs @@ -0,0 +1,9 @@ +namespace Tutor.Elaborations.API.Dtos.ConceptRecords; + +public class CommonMisconceptionDto +{ + public int Id { get; set; } + public string Description { get; set; } = string.Empty; + public string Correction { get; set; } = string.Empty; + public int Order { get; set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/ConceptRecordDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/ConceptRecordDto.cs new file mode 100644 index 000000000..185f07abe --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/ConceptRecordDto.cs @@ -0,0 +1,12 @@ +namespace Tutor.Elaborations.API.Dtos.ConceptRecords; + +public class ConceptRecordDto +{ + public int Id { get; set; } + public int CourseId { get; set; } + public string Title { get; set; } = string.Empty; + public string CanonicalDefinition { get; set; } = string.Empty; + public List KeyPropositions { get; set; } = new(); + public List BoundaryConditions { get; set; } = new(); + public List CommonMisconceptions { get; set; } = new(); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyPropositionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyPropositionDto.cs new file mode 100644 index 000000000..30c7753d9 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyPropositionDto.cs @@ -0,0 +1,9 @@ +namespace Tutor.Elaborations.API.Dtos.ConceptRecords; + +public class KeyPropositionDto +{ + public int Id { get; set; } + public string Statement { get; set; } = string.Empty; + public string Level { get; set; } = string.Empty; + public int Order { 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..e2d140d31 --- /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 ElaborationTaskId { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public string? Summary { get; set; } + public List Turns { get; set; } = new(); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationTurnDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationTurnDto.cs new file mode 100644 index 000000000..4e39a6cff --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationTurnDto.cs @@ -0,0 +1,11 @@ +namespace Tutor.Elaborations.API.Dtos.Conversations; + +public class ConversationTurnDto +{ + public int Id { get; set; } + public string Role { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public bool IsSubstantive { get; set; } + public int Order { get; set; } + public DateTime Timestamp { get; set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDto.cs new file mode 100644 index 000000000..2434c1c46 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDto.cs @@ -0,0 +1,11 @@ +namespace Tutor.Elaborations.API.Dtos.Conversations; + +public class ElaborationTaskDto +{ + public int Id { get; set; } + public int ConceptRecordId { get; set; } + public int UnitId { get; set; } + public string ExpectedLevel { get; set; } = string.Empty; + public int Order { get; set; } + public string? ConceptRecordTitle { get; set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnRequestDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnRequestDto.cs new file mode 100644 index 000000000..4e2d96fc8 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnRequestDto.cs @@ -0,0 +1,6 @@ +namespace Tutor.Elaborations.API.Dtos.Conversations; + +public class SubmitTurnRequestDto +{ + public string Content { get; set; } = string.Empty; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnResponseDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnResponseDto.cs new file mode 100644 index 000000000..d313e1b6e --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnResponseDto.cs @@ -0,0 +1,7 @@ +namespace Tutor.Elaborations.API.Dtos.Conversations; + +public class SubmitTurnResponseDto +{ + public string Status { get; set; } = string.Empty; + public string? Summary { get; set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Internal/IElaborationTaskQuerier.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Internal/IElaborationTaskQuerier.cs new file mode 100644 index 000000000..122497945 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Internal/IElaborationTaskQuerier.cs @@ -0,0 +1,6 @@ +namespace Tutor.Elaborations.API.Internal; + +public interface IElaborationTaskQuerier +{ + int CountByUnit(int unitId); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptRecordService.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptRecordService.cs new file mode 100644 index 000000000..6b2519984 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptRecordService.cs @@ -0,0 +1,13 @@ +using FluentResults; +using Tutor.Elaborations.API.Dtos.ConceptRecords; + +namespace Tutor.Elaborations.API.Public.Authoring; + +public interface IConceptRecordService +{ + Result Get(int id, int courseId, int instructorId); + Result> GetByCourse(int courseId, int instructorId); + Result Create(ConceptRecordDto conceptRecord, int instructorId); + Result Update(ConceptRecordDto conceptRecord, int instructorId); + Result Delete(int id, int courseId, int instructorId); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IElaborationTaskService.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IElaborationTaskService.cs new file mode 100644 index 000000000..d45e6be75 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IElaborationTaskService.cs @@ -0,0 +1,12 @@ +using FluentResults; +using Tutor.Elaborations.API.Dtos.Conversations; + +namespace Tutor.Elaborations.API.Public.Authoring; + +public interface IElaborationTaskService +{ + Result> GetByUnit(int unitId, int instructorId); + Result Create(ElaborationTaskDto task, int instructorId); + Result Update(ElaborationTaskDto 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..b43a2e9ea --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/IAccessServices.cs @@ -0,0 +1,8 @@ +namespace Tutor.Elaborations.API.Public; + +public interface IAccessServices +{ + bool IsCourseOwner(int courseId, int instructorId); + 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..a271c32c3 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs @@ -0,0 +1,13 @@ +using FluentResults; +using Tutor.Elaborations.API.Dtos.Conversations; + +namespace Tutor.Elaborations.API.Public.Learning; + +public interface IConversationService +{ + Result> GetTasksForUnit(int unitId, int learnerId); + IAsyncEnumerable SubmitTurnAsync(int taskId, string content, int learnerId, CancellationToken ct); + Result AbandonAttempt(int attemptId, int learnerId); + Result GetAttempt(int attemptId, int learnerId); + Result> GetAttempts(int taskId, 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/ConceptRecords/BoundaryCondition.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs new file mode 100644 index 000000000..a57ef20e9 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs @@ -0,0 +1,11 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptRecords; + +public class BoundaryCondition : Entity +{ + public int ConceptRecordId { get; private set; } + public string Statement { get; private set; } = string.Empty; + public PropositionLevel Level { get; private set; } + public int Order { get; private set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs new file mode 100644 index 000000000..f3eb4db94 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs @@ -0,0 +1,11 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptRecords; + +public class CommonMisconception : Entity +{ + public int ConceptRecordId { get; private set; } + public string Description { get; private set; } = string.Empty; + public string Correction { get; private set; } = string.Empty; + public int Order { get; private set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs new file mode 100644 index 000000000..a69f99b5d --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs @@ -0,0 +1,39 @@ +using Tutor.BuildingBlocks.Core.Domain; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.Domain.ConceptRecords; + +public class ConceptRecord : AggregateRoot +{ + public int CourseId { get; private set; } + public string Title { get; private set; } = string.Empty; + public string CanonicalDefinition { get; private set; } = string.Empty; + public List KeyPropositions { get; private set; } = new(); + public List BoundaryConditions { get; private set; } = new(); + public List CommonMisconceptions { get; private set; } = new(); + + public ConceptRecord DeriveForLevel(PropositionLevel level) + { + return new ConceptRecord + { + Id = Id, + CourseId = CourseId, + Title = Title, + CanonicalDefinition = CanonicalDefinition, + KeyPropositions = KeyPropositions + .Where(kp => kp.Level <= level) + .OrderBy(kp => kp.Order).ToList(), + BoundaryConditions = BoundaryConditions + .Where(bc => bc.Level <= level) + .OrderBy(bc => bc.Order).ToList(), + CommonMisconceptions = CommonMisconceptions + .OrderBy(cm => cm.Order).ToList() + }; + } + + public bool AreAllPropositionsCovered(ConversationAttempt attempt) + { + var coveredIds = attempt.GetCoveredPropositionIds(); + return KeyPropositions.All(kp => coveredIds.Contains(kp.Id)); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/IConceptRecordRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/IConceptRecordRepository.cs new file mode 100644 index 000000000..7c5e81df9 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/IConceptRecordRepository.cs @@ -0,0 +1,8 @@ +using Tutor.BuildingBlocks.Core.UseCases; + +namespace Tutor.Elaborations.Core.Domain.ConceptRecords; + +public interface IConceptRecordRepository : ICrudRepository +{ + List GetByCourse(int courseId); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs new file mode 100644 index 000000000..0ebe16864 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs @@ -0,0 +1,11 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptRecords; + +public class KeyProposition : Entity +{ + public int ConceptRecordId { get; private set; } + public string Statement { get; private set; } = string.Empty; + public PropositionLevel Level { get; private set; } + public int Order { get; private set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/PropositionLevel.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/PropositionLevel.cs new file mode 100644 index 000000000..8ec661ae4 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/PropositionLevel.cs @@ -0,0 +1,8 @@ +namespace Tutor.Elaborations.Core.Domain.ConceptRecords; + +public enum PropositionLevel +{ + Beginner, + Intermediate, + Advanced +} 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..c42c94d12 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs @@ -0,0 +1,10 @@ +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public enum AttemptStatus +{ + InProgress, + Completed, + Abandoned, + Expired, + Blocked +} 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..ea57b34cb --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -0,0 +1,91 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public class ConversationAttempt : AggregateRoot +{ + private const int SoftCapSubstantiveTurns = 6; + private const int HardCapTotalTurns = 10; + + public int ElaborationTaskId { 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 string? Summary { get; private set; } + public List Turns { get; private set; } = new(); + + private ConversationAttempt() { } + + public ConversationAttempt(int elaborationTaskId, int learnerId) + { + ElaborationTaskId = elaborationTaskId; + LearnerId = learnerId; + Status = AttemptStatus.InProgress; + StartedAt = DateTime.UtcNow; + } + + public ISet GetCoveredPropositionIds() + { + return Turns + .Where(t => t.Evaluation != null) + .SelectMany(t => t.Evaluation!.PropositionsCoveredIds) + .ToHashSet(); + } + + public int CountSubstantiveLearnerTurns() + { + return Turns.Count(t => t.Role == TurnRole.Learner && t.IsSubstantive); + } + + public int CountTotalLearnerTurns() + { + return Turns.Count(t => t.Role == TurnRole.Learner); + } + + public bool IsSoftCapReached() => CountSubstantiveLearnerTurns() >= SoftCapSubstantiveTurns; + + public bool IsHardCapReached() => CountTotalLearnerTurns() >= HardCapTotalTurns; + + public ConversationTurn AddLearnerTurn(string content, + bool isSubstantive, TurnEvaluation? evaluation) + { + var turn = new ConversationTurn(TurnRole.Learner, content, + isSubstantive, Turns.Count, evaluation); + Turns.Add(turn); + return turn; + } + + public ConversationTurn AddSystemTurn(string content) + { + var turn = new ConversationTurn(TurnRole.System, content, true, Turns.Count); + Turns.Add(turn); + return turn; + } + + public void Complete(string? summary) + { + Status = AttemptStatus.Completed; + CompletedAt = DateTime.UtcNow; + Summary = summary; + } + + public void Abandon() + { + Status = AttemptStatus.Abandoned; + CompletedAt = DateTime.UtcNow; + } + + public void Expire(string? summary) + { + Status = AttemptStatus.Expired; + CompletedAt = DateTime.UtcNow; + Summary = summary; + } + + public void Block() + { + Status = AttemptStatus.Blocked; + CompletedAt = DateTime.UtcNow; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs new file mode 100644 index 000000000..f023d509b --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs @@ -0,0 +1,27 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public class ConversationTurn : Entity +{ + public int ConversationAttemptId { get; private set; } + public TurnRole Role { get; private set; } + public string Content { get; private set; } = string.Empty; + public bool IsSubstantive { get; private set; } + public int Order { get; private set; } + public DateTime Timestamp { get; private set; } + public TurnEvaluation? Evaluation { get; private set; } + + private ConversationTurn() { } + + internal ConversationTurn(TurnRole role, string content, + bool isSubstantive, int order, TurnEvaluation? evaluation = null) + { + Role = role; + Content = content; + IsSubstantive = isSubstantive; + Order = order; + Timestamp = DateTime.UtcNow; + Evaluation = evaluation; + } +} 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..d066e2633 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/IConversationAttemptRepository.cs @@ -0,0 +1,10 @@ +using Tutor.BuildingBlocks.Core.UseCases; + +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public interface IConversationAttemptRepository : ICrudRepository +{ + ConversationAttempt? GetActiveAttempt(int elaborationTaskId, int learnerId); + List GetByTaskAndLearner(int elaborationTaskId, int learnerId); + int CountRecentAttempts(int elaborationTaskId, int learnerId, DateTime since); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs new file mode 100644 index 000000000..f31254b0f --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs @@ -0,0 +1,33 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public class TurnEvaluation : Entity +{ + public int ConversationTurnId { get; private set; } + public int CorrectnessScore { get; private set; } + public int CompletenessScore { get; private set; } + public int PrecisionScore { get; private set; } + public int ConcisenessScore { get; private set; } + public string Justification { get; private set; } = string.Empty; + public string? NovelMisconceptions { get; private set; } + public List PropositionsCoveredIds { get; private set; } = new(); + public List MisconceptionsTriggeredIds { get; private set; } = new(); + + private TurnEvaluation() { } + + public TurnEvaluation(int correctnessScore, int completenessScore, + int precisionScore, int concisenessScore, string justification, + string? novelMisconceptions, List propositionsCoveredIds, + List misconceptionsTriggeredIds) + { + CorrectnessScore = correctnessScore; + CompletenessScore = completenessScore; + PrecisionScore = precisionScore; + ConcisenessScore = concisenessScore; + Justification = justification; + NovelMisconceptions = novelMisconceptions; + PropositionsCoveredIds = propositionsCoveredIds; + MisconceptionsTriggeredIds = misconceptionsTriggeredIds; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnRole.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnRole.cs new file mode 100644 index 000000000..19c08ff18 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnRole.cs @@ -0,0 +1,7 @@ +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public enum TurnRole +{ + Learner, + System +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/ElaborationTask.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/ElaborationTask.cs new file mode 100644 index 000000000..f971780e7 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/ElaborationTask.cs @@ -0,0 +1,12 @@ +using Tutor.BuildingBlocks.Core.Domain; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.Domain.ElaborationTasks; + +public class ElaborationTask : Entity +{ + public int ConceptRecordId { get; private set; } + public int UnitId { get; internal set; } + public PropositionLevel ExpectedLevel { get; private set; } + public int Order { get; private set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/IElaborationTaskRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/IElaborationTaskRepository.cs new file mode 100644 index 000000000..8fdcd177f --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/IElaborationTaskRepository.cs @@ -0,0 +1,8 @@ +using Tutor.BuildingBlocks.Core.UseCases; + +namespace Tutor.Elaborations.Core.Domain.ElaborationTasks; + +public interface IElaborationTaskRepository : ICrudRepository +{ + List GetByUnit(int unitId); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs new file mode 100644 index 000000000..bbc6a02a0 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs @@ -0,0 +1,18 @@ +using AutoMapper; +using Tutor.Elaborations.API.Dtos.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.Mappers; + +public class ConceptRecordProfile : Profile +{ + public ConceptRecordProfile() + { + CreateMap().ReverseMap(); + CreateMap().ReverseMap() + .ForMember(d => d.Level, opt => opt.MapFrom(s => s.Level.ToString())); + CreateMap().ReverseMap() + .ForMember(d => d.Level, opt => opt.MapFrom(s => s.Level.ToString())); + 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..e89be8648 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConversationProfile.cs @@ -0,0 +1,19 @@ +using AutoMapper; +using Tutor.Elaborations.API.Dtos.Conversations; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.Domain.ElaborationTasks; + +namespace Tutor.Elaborations.Core.Mappers; + +public class ConversationProfile : Profile +{ + public ConversationProfile() + { + CreateMap().ReverseMap() + .ForMember(d => d.ExpectedLevel, opt => opt.MapFrom(s => s.ExpectedLevel.ToString())); + CreateMap().ReverseMap() + .ForMember(d => d.Status, opt => opt.MapFrom(s => s.Status.ToString())); + CreateMap().ReverseMap() + .ForMember(d => d.Role, opt => opt.MapFrom(s => s.Role.ToString())); + } +} 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..10b7f6cf1 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Tutor.Elaborations.Core.csproj @@ -0,0 +1,14 @@ + + + + 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..11b1026ff --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/AccessServices.cs @@ -0,0 +1,32 @@ +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 IsCourseOwner(int courseId, int instructorId) + { + return _ownershipValidator.IsCourseOwner(courseId, instructorId); + } + + 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/ConceptRecordService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptRecordService.cs new file mode 100644 index 000000000..a5796ee21 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptRecordService.cs @@ -0,0 +1,65 @@ +using AutoMapper; +using FluentResults; +using Tutor.BuildingBlocks.Core.UseCases; +using Tutor.Elaborations.API.Dtos.ConceptRecords; +using Tutor.Elaborations.API.Public; +using Tutor.Elaborations.API.Public.Authoring; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.UseCases.Authoring; + +public class ConceptRecordService : CrudService, IConceptRecordService +{ + private readonly IConceptRecordRepository _conceptRecordRepository; + private readonly IAccessServices _accessServices; + + public ConceptRecordService(IConceptRecordRepository repository, + IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, + IMapper mapper) : base(repository, unitOfWork, mapper) + { + _conceptRecordRepository = repository; + _accessServices = accessServices; + } + + public Result Get(int id, int courseId, int instructorId) + { + if (!_accessServices.IsCourseOwner(courseId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + var record = _conceptRecordRepository.Get(id); + if (record == null || record.CourseId != courseId) + return Result.Fail(FailureCode.NotFound); + return MapToDto(record); + } + + public Result> GetByCourse(int courseId, int instructorId) + { + if (!_accessServices.IsCourseOwner(courseId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + var records = _conceptRecordRepository.GetByCourse(courseId); + return MapToDto(records); + } + + public Result Create(ConceptRecordDto conceptRecord, int instructorId) + { + if (!_accessServices.IsCourseOwner(conceptRecord.CourseId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + return Create(conceptRecord); + } + + public Result Update(ConceptRecordDto conceptRecord, int instructorId) + { + if (!_accessServices.IsCourseOwner(conceptRecord.CourseId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + return Update(conceptRecord); + } + + public Result Delete(int id, int courseId, int instructorId) + { + if (!_accessServices.IsCourseOwner(courseId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + var record = _conceptRecordRepository.Get(id); + if (record == null || record.CourseId != courseId) + return Result.Fail(FailureCode.NotFound); + return Delete(id); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs new file mode 100644 index 000000000..5b1cc4b3b --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs @@ -0,0 +1,56 @@ +using AutoMapper; +using FluentResults; +using Tutor.BuildingBlocks.Core.UseCases; +using Tutor.Elaborations.API.Dtos.Conversations; +using Tutor.Elaborations.API.Public; +using Tutor.Elaborations.API.Public.Authoring; +using Tutor.Elaborations.Core.Domain.ElaborationTasks; + +namespace Tutor.Elaborations.Core.UseCases.Authoring; + +public class ElaborationTaskService : CrudService, IElaborationTaskService +{ + private readonly IElaborationTaskRepository _taskRepository; + private readonly IAccessServices _accessServices; + + public ElaborationTaskService(IElaborationTaskRepository taskRepository, + IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, + IMapper mapper) : base(taskRepository, unitOfWork, mapper) + { + _taskRepository = taskRepository; + _accessServices = accessServices; + } + + public Result> GetByUnit(int unitId, int instructorId) + { + if (!_accessServices.IsUnitOwner(unitId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + + var tasks = _taskRepository.GetByUnit(unitId); + return MapToDto(tasks); + } + + public Result Create(ElaborationTaskDto task, int instructorId) + { + if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + + return Create(task); + } + + public Result Update(ElaborationTaskDto task, int instructorId) + { + if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + + return Update(task); + } + + public Result Delete(int id, int unitId, int instructorId) + { + if (!_accessServices.IsUnitOwner(unitId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + + return Delete(id); + } +} 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..e714e431f --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -0,0 +1,187 @@ +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.Conversations; +using Tutor.Elaborations.API.Public; +using Tutor.Elaborations.API.Public.Learning; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.Domain.ElaborationTasks; +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 IElaborationTaskRepository _taskRepo; + private readonly Domain.ConceptRecords.IConceptRecordRepository _conceptRecordRepo; + private readonly TurnOrchestrator _turnOrchestrator; + private readonly ITokenSpendingService _tokenSpendingService; + private readonly IAccessServices _accessServices; + private readonly IElaborationsUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public ConversationService(IConversationAttemptRepository attemptRepo, + IElaborationTaskRepository taskRepo, + Domain.ConceptRecords.IConceptRecordRepository conceptRecordRepo, + TurnOrchestrator turnOrchestrator, ITokenSpendingService tokenSpendingService, + IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, IMapper mapper) + { + _attemptRepo = attemptRepo; + _taskRepo = taskRepo; + _conceptRecordRepo = conceptRecordRepo; + _turnOrchestrator = turnOrchestrator; + _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); + return Result.Ok(tasks.Select(t => _mapper.Map(t)).ToList()); + } + + public async IAsyncEnumerable SubmitTurnAsync(int taskId, string content, + int learnerId, [EnumeratorCancellation] CancellationToken ct) + { + var task = _taskRepo.Get(taskId); + if (task == null) { yield return BuildErrorChunk("Task not found.", 404); yield break; } + + if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) + { yield return BuildErrorChunk("Not enrolled in unit.", 403); yield break; } + + var conceptRecord = _conceptRecordRepo.Get(task.ConceptRecordId); + if (conceptRecord == null) { yield return BuildErrorChunk("Concept record not found.", 404); yield break; } + + var balanceCheck = _tokenSpendingService.HasSufficientBalanceForUnit( + learnerId, task.UnitId, content.Length); + if (balanceCheck.IsFailed) + { yield return BuildErrorChunk("Insufficient token balance. Contact your administrator.", 402); yield break; } + + var attempt = _attemptRepo.GetActiveAttempt(taskId, learnerId); + if (attempt == null) + { + 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; } + + attempt = new ConversationAttempt(taskId, learnerId); + _attemptRepo.Create(attempt); + } + + var levelRecord = conceptRecord.DeriveForLevel(task.ExpectedLevel); + + // Synchronous phase: evaluate + var evalResult = await _turnOrchestrator.EvaluateAsync( + content, attempt.Turns.ToList(), levelRecord, ct); + if (evalResult.IsFailed) { yield return BuildErrorChunk("Evaluation failed. Please try again.", 500); yield break; } + + var evaluation = evalResult.Value.Evaluation; + attempt.AddLearnerTurn(content, evalResult.Value.IsSubstantive, evaluation); + + var isCompleted = levelRecord.AreAllPropositionsCovered(attempt); + var state = new ConversationState + { + IsCompleted = isCompleted, + IsSoftCapReached = attempt.IsSoftCapReached(), + IsHardCapReached = attempt.IsHardCapReached() + }; + + // Partial save: protects against stream interruption + _unitOfWork.Save(); + + // Streaming phase: dialogue + var fullResponse = new System.Text.StringBuilder(); + await foreach (var token in _turnOrchestrator.StreamDialogueAsync( + evaluation, attempt.Turns.ToList(), levelRecord, state, ct)) + { + fullResponse.Append(token); + yield return token; + } + + // Post-stream persistence + attempt.AddSystemTurn(fullResponse.ToString()); + + string? summary = null; + if (isCompleted) + { + var summaryResult = await _turnOrchestrator.SummarizeAsync(attempt, levelRecord, ct); + summary = summaryResult.IsSuccess ? summaryResult.Value : null; + attempt.Complete(summary); + } + else if (state.IsHardCapReached) + { + var summaryResult = await _turnOrchestrator.SummarizeAsync(attempt, levelRecord, ct); + summary = summaryResult.IsSuccess ? summaryResult.Value : null; + attempt.Expire(summary); + } + + _unitOfWork.Save(); + + // Spend tokens — estimate from content lengths + var totalChars = content.Length + fullResponse.Length; + _tokenSpendingService.SpendTokensForUnit(new TokenSpendingRequestDto + { + LearnerId = learnerId, + UnitId = task.UnitId, + PromptTokens = content.Length / 4, + CompletionTokens = fullResponse.Length / 4, + FeatureType = "Elaboration", + EntityId = taskId, + PromptSummary = "Concept conversation turn" + }); + + // Final metadata chunk + yield return JsonSerializer.Serialize(new SubmitTurnResponseDto + { + Status = attempt.Status.ToString(), + Summary = summary + }); + } + + 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)); + } + + public Result GetAttempt(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); + + return Result.Ok(_mapper.Map(attempt)); + } + + public Result> GetAttempts(int taskId, int learnerId) + { + var attempts = _attemptRepo.GetByTaskAndLearner(taskId, learnerId); + return Result.Ok(attempts.Select(a => _mapper.Map(a)).ToList()); + } + + private static string BuildErrorChunk(string message, int code) + { + return JsonSerializer.Serialize(new { error = message, code }); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ConversationState.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ConversationState.cs new file mode 100644 index 000000000..3a582e5bf --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ConversationState.cs @@ -0,0 +1,8 @@ +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public class ConversationState +{ + public bool IsCompleted { get; set; } + public bool IsSoftCapReached { get; set; } + public bool IsHardCapReached { get; set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/EvaluationResult.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/EvaluationResult.cs new file mode 100644 index 000000000..7065afe47 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/EvaluationResult.cs @@ -0,0 +1,5 @@ +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public record EvaluationResult(TurnEvaluation Evaluation, bool IsSubstantive); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs new file mode 100644 index 000000000..2c3ca7924 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs @@ -0,0 +1,11 @@ +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public interface IDialogueAgent +{ + IAsyncEnumerable StreamAsync(TurnEvaluation evaluation, + List history, ConceptRecord conceptRecord, + ConversationState state, CancellationToken ct); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs new file mode 100644 index 000000000..11b41df37 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs @@ -0,0 +1,12 @@ +using FluentResults; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public interface IEvaluationAgent +{ + Task> EvaluateAsync(string content, + List history, ConceptRecord conceptRecord, + CancellationToken ct); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ISummaryAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ISummaryAgent.cs new file mode 100644 index 000000000..baa3ed246 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ISummaryAgent.cs @@ -0,0 +1,11 @@ +using FluentResults; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public interface ISummaryAgent +{ + Task> SummarizeAsync(ConversationAttempt attempt, + ConceptRecord conceptRecord, CancellationToken ct); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnOrchestrator.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnOrchestrator.cs new file mode 100644 index 000000000..81c0de07f --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnOrchestrator.cs @@ -0,0 +1,40 @@ +using FluentResults; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public class TurnOrchestrator +{ + private readonly IEvaluationAgent _evaluationAgent; + private readonly IDialogueAgent _dialogueAgent; + private readonly ISummaryAgent _summaryAgent; + + public TurnOrchestrator(IEvaluationAgent evaluationAgent, + IDialogueAgent dialogueAgent, ISummaryAgent summaryAgent) + { + _evaluationAgent = evaluationAgent; + _dialogueAgent = dialogueAgent; + _summaryAgent = summaryAgent; + } + + public async Task> EvaluateAsync(string content, + List history, ConceptRecord conceptRecord, + CancellationToken ct) + { + return await _evaluationAgent.EvaluateAsync(content, history, conceptRecord, ct); + } + + public IAsyncEnumerable StreamDialogueAsync(TurnEvaluation evaluation, + List history, ConceptRecord conceptRecord, + ConversationState state, CancellationToken ct) + { + return _dialogueAgent.StreamAsync(evaluation, history, conceptRecord, state, ct); + } + + public async Task> SummarizeAsync(ConversationAttempt attempt, + ConceptRecord conceptRecord, CancellationToken ct) + { + return await _summaryAgent.SummarizeAsync(attempt, conceptRecord, ct); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Monitoring/ElaborationTaskQuerier.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Monitoring/ElaborationTaskQuerier.cs new file mode 100644 index 000000000..882a19e95 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Monitoring/ElaborationTaskQuerier.cs @@ -0,0 +1,19 @@ +using Tutor.Elaborations.API.Internal; +using Tutor.Elaborations.Core.Domain.ElaborationTasks; + +namespace Tutor.Elaborations.Core.UseCases.Monitoring; + +public class ElaborationTaskQuerier : IElaborationTaskQuerier +{ + private readonly IElaborationTaskRepository _taskRepository; + + public ElaborationTaskQuerier(IElaborationTaskRepository taskRepository) + { + _taskRepository = taskRepository; + } + + public int CountByUnit(int unitId) + { + return _taskRepository.GetByUnit(unitId).Count; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs new file mode 100644 index 000000000..270bac3bc --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs @@ -0,0 +1,43 @@ +using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +using Tutor.Elaborations.Infrastructure.Agents.Prompts; + +namespace Tutor.Elaborations.Infrastructure.Agents; + +public class DialogueAgent : IDialogueAgent +{ + private readonly IAiChatService _chatService; + + public DialogueAgent(IAiChatService chatService) + { + _chatService = chatService; + } + + public async IAsyncEnumerable StreamAsync(TurnEvaluation evaluation, + List history, ConceptRecord conceptRecord, + ConversationState state, [EnumeratorCancellation] CancellationToken ct) + { + var systemPrompt = DialoguePromptBuilder.BuildSystemPrompt(conceptRecord, state); + var messageData = DialoguePromptBuilder.BuildMessages(history); + + var evalSummary = $"[Evaluation: correctness={evaluation.CorrectnessScore}, " + + $"completeness={evaluation.CompletenessScore}, " + + $"precision={evaluation.PrecisionScore}, " + + $"conciseness={evaluation.ConcisenessScore}. " + + $"Justification: {evaluation.Justification}]"; + messageData.Add(("user", evalSummary)); + + var messages = messageData.Select(m => + m.role == "user" ? ChatMessage.FromUser(m.content) : ChatMessage.FromAssistant(m.content)); + + var request = CompletionRequest.Create(messages, systemPrompt, maxTokens: 512, temperature: 0.7); + + await foreach (var token in _chatService.StreamAsync(request, ct)) + { + yield return token; + } + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs new file mode 100644 index 000000000..19795bdf6 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using FluentResults; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +using Tutor.Elaborations.Infrastructure.Agents.Prompts; + +namespace Tutor.Elaborations.Infrastructure.Agents; + +public class EvaluationAgent : IEvaluationAgent +{ + private readonly IAiChatService _chatService; + + public EvaluationAgent(IAiChatService chatService) + { + _chatService = chatService; + } + + public async Task> EvaluateAsync(string content, + List history, ConceptRecord conceptRecord, + CancellationToken ct) + { + var systemPrompt = EvaluationPromptBuilder.BuildSystemPrompt(conceptRecord); + var messageData = EvaluationPromptBuilder.BuildMessages(content, history); + + var messages = messageData.Select(m => + m.role == "user" ? ChatMessage.FromUser(m.content) : ChatMessage.FromAssistant(m.content)); + + var request = CompletionRequest.Create(messages, systemPrompt, maxTokens: 1024, temperature: 0.1); + + for (var attempt = 0; attempt < 2; attempt++) + { + var result = await _chatService.CompleteAsync(request, ct); + if (result.IsFailed) continue; + + var parsed = TryParseResponse(result.Value.Content); + if (parsed == null) continue; + + var evaluation = new TurnEvaluation( + parsed.CorrectnessScore, parsed.CompletenessScore, + parsed.PrecisionScore, parsed.ConcisenessScore, + parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, + parsed.PropositionsCoveredIds ?? new List(), + parsed.MisconceptionsTriggeredIds ?? new List()); + + return Result.Ok(new EvaluationResult(evaluation, parsed.IsSubstantive)); + } + + return Result.Fail("Failed to parse evaluation response after retries."); + } + + private static EvaluationResponse? TryParseResponse(string json) + { + try + { + return JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + } + catch + { + return null; + } + } + + private class EvaluationResponse + { + public int CorrectnessScore { get; set; } + public int CompletenessScore { get; set; } + public int PrecisionScore { get; set; } + public int ConcisenessScore { get; set; } + public string? Justification { get; set; } + public List? PropositionsCoveredIds { get; set; } + public List? MisconceptionsTriggeredIds { get; set; } + public string? NovelMisconceptions { get; set; } + public bool IsSubstantive { get; set; } + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs new file mode 100644 index 000000000..49c86ef49 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs @@ -0,0 +1,63 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +namespace Tutor.Elaborations.Infrastructure.Agents.Prompts; + +public static class DialoguePromptBuilder +{ + public static string BuildSystemPrompt(ConceptRecord record, ConversationState state) + { + var sb = new StringBuilder(); + sb.AppendLine("You are a Socratic dialogue agent for a tutoring system. You speak Serbian."); + sb.AppendLine("Your role: guide the learner to explain a concept by asking targeted questions."); + sb.AppendLine(); + sb.AppendLine("## Rules:"); + sb.AppendLine("- NEVER provide answers, definitions, or explanations."); + sb.AppendLine("- NEVER reveal key propositions, boundary conditions, or misconception text."); + sb.AppendLine("- Every response: acknowledge → identify gap → ask targeted question."); + sb.AppendLine("- Use concise language. Respect cognitive load."); + sb.AppendLine("- For non-substantive turns, gently redirect without penalty."); + sb.AppendLine("- Allow productive divergence within the concept space."); + sb.AppendLine(); + + sb.AppendLine($"## Concept: {record.Title}"); + sb.AppendLine($"Definition: {record.CanonicalDefinition}"); + sb.AppendLine(); + + sb.AppendLine("## Key Propositions (for your reference only, never reveal):"); + foreach (var kp in record.KeyPropositions) + sb.AppendLine($"- [KP-{kp.Id}] {kp.Statement}"); + sb.AppendLine(); + + if (state.IsCompleted) + { + sb.AppendLine("## The learner has covered all required propositions."); + sb.AppendLine("Provide a brief closing acknowledgment. Do not ask more questions."); + } + else if (state.IsHardCapReached) + { + sb.AppendLine("## The conversation has reached its maximum length."); + sb.AppendLine("Provide a brief closing summary. Do not ask more questions."); + } + else if (state.IsSoftCapReached) + { + sb.AppendLine("## The learner is approaching the end of the conversation."); + sb.AppendLine("Suggest wrapping up. Focus on the most important uncovered proposition."); + } + + return sb.ToString(); + } + + public static List<(string role, string content)> BuildMessages(List history) + { + var messages = new List<(string role, string content)>(); + foreach (var turn in history) + { + var role = turn.Role == TurnRole.Learner ? "user" : "assistant"; + messages.Add((role, turn.Content)); + } + return messages; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs new file mode 100644 index 000000000..4b6bf9a1b --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs @@ -0,0 +1,73 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Infrastructure.Agents.Prompts; + +public static class EvaluationPromptBuilder +{ + public static string BuildSystemPrompt(ConceptRecord record) + { + var sb = new StringBuilder(); + sb.AppendLine("You are an evaluation agent for a Socratic tutoring system."); + sb.AppendLine("Your task: evaluate the learner's latest response against the concept rubric below."); + sb.AppendLine(); + sb.AppendLine($"## Concept: {record.Title}"); + sb.AppendLine($"Definition: {record.CanonicalDefinition}"); + sb.AppendLine(); + + sb.AppendLine("## Key Propositions:"); + foreach (var kp in record.KeyPropositions) + sb.AppendLine($"- [KP-{kp.Id}] {kp.Statement}"); + sb.AppendLine(); + + sb.AppendLine("## Boundary Conditions:"); + foreach (var bc in record.BoundaryConditions) + sb.AppendLine($"- [BC-{bc.Id}] {bc.Statement}"); + sb.AppendLine(); + + sb.AppendLine("## Common Misconceptions:"); + foreach (var cm in record.CommonMisconceptions.Take(8)) + sb.AppendLine($"- [CM-{cm.Id}] {cm.Description} → Correction: {cm.Correction}"); + sb.AppendLine(); + + sb.AppendLine("## Scoring Rules:"); + sb.AppendLine("- Correctness (1-3): Are stated claims true? Check against KPs and BCs."); + sb.AppendLine("- Completeness (1-3): Are essential KPs covered?"); + sb.AppendLine("- Precision (1-3): Does explanation exclude what it should? Check BCs."); + sb.AppendLine("- Conciseness (1-3): Unnecessary material, hedging, redundancy?"); + sb.AppendLine("- Evaluate concepts, not language. Grammar and style must not reduce scores."); + sb.AppendLine("- Resist sycophancy. Evaluate strictly against rubric."); + sb.AppendLine(); + + sb.AppendLine("## Output Format (JSON only, no other text):"); + sb.AppendLine(""" +{ + "correctnessScore": 1-3, + "completenessScore": 1-3, + "precisionScore": 1-3, + "concisenessScore": 1-3, + "justification": "brief explanation of scores", + "propositionsCoveredIds": [list of KP IDs covered in this turn], + "misconceptionsTriggeredIds": [list of CM IDs triggered], + "novelMisconceptions": "any misconceptions not in the list, or null", + "isSubstantive": true/false +} +"""); + + return sb.ToString(); + } + + public static List<(string role, string content)> BuildMessages( + string learnerContent, List history) + { + var messages = new List<(string role, string content)>(); + foreach (var turn in history) + { + var role = turn.Role == TurnRole.Learner ? "user" : "assistant"; + messages.Add((role, turn.Content)); + } + messages.Add(("user", learnerContent)); + return messages; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/SummaryPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/SummaryPromptBuilder.cs new file mode 100644 index 000000000..6873d355c --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/SummaryPromptBuilder.cs @@ -0,0 +1,40 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Infrastructure.Agents.Prompts; + +public static class SummaryPromptBuilder +{ + public static string BuildSystemPrompt(ConversationAttempt attempt, ConceptRecord record) + { + var sb = new StringBuilder(); + sb.AppendLine("You are a summary agent. Write a brief natural-language summary of the conversation."); + sb.AppendLine("Paraphrase what the learner demonstrated understanding of. Never quote proposition statements verbatim."); + sb.AppendLine("Write in Serbian. Keep the summary to 2-4 sentences."); + sb.AppendLine(); + sb.AppendLine($"Concept: {record.Title}"); + + var coveredIds = attempt.GetCoveredPropositionIds(); + var covered = record.KeyPropositions.Where(kp => coveredIds.Contains(kp.Id)).ToList(); + if (covered.Count > 0) + { + sb.AppendLine("Propositions the learner covered (paraphrase, do not quote):"); + foreach (var kp in covered) + sb.AppendLine($"- {kp.Statement}"); + } + + return sb.ToString(); + } + + public static string BuildTranscript(ConversationAttempt attempt) + { + var sb = new StringBuilder(); + foreach (var turn in attempt.Turns.OrderBy(t => t.Order)) + { + var role = turn.Role == TurnRole.Learner ? "Learner" : "System"; + sb.AppendLine($"{role}: {turn.Content}"); + } + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/SummaryAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/SummaryAgent.cs new file mode 100644 index 000000000..105827136 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/SummaryAgent.cs @@ -0,0 +1,32 @@ +using FluentResults; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +using Tutor.Elaborations.Infrastructure.Agents.Prompts; + +namespace Tutor.Elaborations.Infrastructure.Agents; + +public class SummaryAgent : ISummaryAgent +{ + private readonly IAiChatService _chatService; + + public SummaryAgent(IAiChatService chatService) + { + _chatService = chatService; + } + + public async Task> SummarizeAsync(ConversationAttempt attempt, + ConceptRecord conceptRecord, CancellationToken ct) + { + var systemPrompt = SummaryPromptBuilder.BuildSystemPrompt(attempt, conceptRecord); + var transcript = SummaryPromptBuilder.BuildTranscript(attempt); + + var request = CompletionRequest.SingleMessage(transcript, systemPrompt, maxTokens: 256, temperature: 0.5); + + var result = await _chatService.CompleteAsync(request, ct); + return result.IsSuccess + ? Result.Ok(result.Value.Content) + : Result.Fail("Summary generation failed."); + } +} 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..994139918 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.Domain.ElaborationTasks; + +namespace Tutor.Elaborations.Infrastructure.Database; + +public class ElaborationsContext : DbContext +{ + public DbSet ConceptRecords { get; set; } + public DbSet KeyPropositions { get; set; } + public DbSet BoundaryConditions { get; set; } + public DbSet CommonMisconceptions { get; set; } + public DbSet ElaborationTasks { get; set; } + public DbSet ConversationAttempts { get; set; } + public DbSet ConversationTurns { get; set; } + public DbSet TurnEvaluations { get; set; } + + public ElaborationsContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("elaborations"); + + ConfigureConceptRecords(modelBuilder); + ConfigureElaborationTasks(modelBuilder); + ConfigureConversations(modelBuilder); + } + + private static void ConfigureConceptRecords(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasMany(cr => cr.KeyPropositions) + .WithOne() + .HasForeignKey(kp => kp.ConceptRecordId); + + modelBuilder.Entity() + .HasMany(cr => cr.BoundaryConditions) + .WithOne() + .HasForeignKey(bc => bc.ConceptRecordId); + + modelBuilder.Entity() + .HasMany(cr => cr.CommonMisconceptions) + .WithOne() + .HasForeignKey(cm => cm.ConceptRecordId); + } + + private static void ConfigureElaborationTasks(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasIndex(et => new { et.UnitId, et.Order }); + } + + private static void ConfigureConversations(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasMany(ca => ca.Turns) + .WithOne() + .HasForeignKey(ct => ct.ConversationAttemptId); + + modelBuilder.Entity() + .HasIndex(ca => new { ca.ElaborationTaskId, ca.LearnerId }); + + modelBuilder.Entity() + .HasOne(ct => ct.Evaluation) + .WithOne() + .HasForeignKey(te => te.ConversationTurnId); + + modelBuilder.Entity() + .HasIndex(ct => new { ct.ConversationAttemptId, ct.Order }); + + modelBuilder.Entity(entity => + { + entity.Property(te => te.PropositionsCoveredIds) + .HasColumnType("jsonb"); + entity.Property(te => te.MisconceptionsTriggeredIds) + .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/ConceptRecordDatabaseRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs new file mode 100644 index 000000000..f0ac24f5a --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Tutor.BuildingBlocks.Infrastructure.Database; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Infrastructure.Database.Repositories; + +public class ConceptRecordDatabaseRepository : + CrudDatabaseRepository, IConceptRecordRepository +{ + public ConceptRecordDatabaseRepository(ElaborationsContext dbContext) : base(dbContext) { } + + public new ConceptRecord? Get(int id) + { + return DbContext.ConceptRecords + .Include(cr => cr.KeyPropositions.OrderBy(kp => kp.Order)) + .Include(cr => cr.BoundaryConditions.OrderBy(bc => bc.Order)) + .Include(cr => cr.CommonMisconceptions.OrderBy(cm => cm.Order)) + .FirstOrDefault(cr => cr.Id == id); + } + + public List GetByCourse(int courseId) + { + return DbContext.ConceptRecords + .Include(cr => cr.KeyPropositions.OrderBy(kp => kp.Order)) + .Include(cr => cr.BoundaryConditions.OrderBy(bc => bc.Order)) + .Include(cr => cr.CommonMisconceptions.OrderBy(cm => cm.Order)) + .Where(cr => cr.CourseId == courseId) + .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..6e954f88d --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs @@ -0,0 +1,46 @@ +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.Turns.OrderBy(t => t.Order)) + .ThenInclude(t => t.Evaluation) + .FirstOrDefault(ca => ca.Id == id); + } + + public ConversationAttempt? GetActiveAttempt(int elaborationTaskId, int learnerId) + { + return DbContext.ConversationAttempts + .Include(ca => ca.Turns.OrderBy(t => t.Order)) + .ThenInclude(t => t.Evaluation) + .FirstOrDefault(ca => ca.ElaborationTaskId == elaborationTaskId + && ca.LearnerId == learnerId + && ca.Status == AttemptStatus.InProgress); + } + + public List GetByTaskAndLearner(int elaborationTaskId, int learnerId) + { + return DbContext.ConversationAttempts + .Include(ca => ca.Turns.OrderBy(t => t.Order)) + .Where(ca => ca.ElaborationTaskId == elaborationTaskId && ca.LearnerId == learnerId) + .OrderByDescending(ca => ca.StartedAt) + .ToList(); + } + + public int CountRecentAttempts(int elaborationTaskId, int learnerId, DateTime since) + { + return DbContext.ConversationAttempts + .Count(ca => ca.ElaborationTaskId == elaborationTaskId + && ca.LearnerId == learnerId + && ca.StartedAt >= since); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ElaborationTaskDatabaseRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ElaborationTaskDatabaseRepository.cs new file mode 100644 index 000000000..c4a193c8a --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ElaborationTaskDatabaseRepository.cs @@ -0,0 +1,18 @@ +using Tutor.BuildingBlocks.Infrastructure.Database; +using Tutor.Elaborations.Core.Domain.ElaborationTasks; + +namespace Tutor.Elaborations.Infrastructure.Database.Repositories; + +public class ElaborationTaskDatabaseRepository : + CrudDatabaseRepository, IElaborationTaskRepository +{ + public ElaborationTaskDatabaseRepository(ElaborationsContext dbContext) : base(dbContext) { } + + public List GetByUnit(int unitId) + { + return DbContext.ElaborationTasks + .Where(et => et.UnitId == unitId) + .OrderBy(et => et.Order) + .ToList(); + } +} 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..68386d73d --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs @@ -0,0 +1,70 @@ +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.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.Domain.ElaborationTasks; +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.Agents; +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(ConceptRecordProfile).Assembly); + } + + private static void SetupCore(IServiceCollection services) + { + services.AddProxiedScoped(); + services.AddProxiedScoped(); + services.AddProxiedScoped(); + services.AddProxiedScoped(); + services.AddProxiedScoped(); + services.AddScoped(); + } + + private static void SetupInfrastructure(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + 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/Tutor.Elaborations.Tests.csproj b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj new file mode 100644 index 000000000..fd5e60541 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + 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/ConceptRecordController.cs b/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptRecordController.cs new file mode 100644 index 000000000..07c33d9a5 --- /dev/null +++ b/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptRecordController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tutor.Elaborations.API.Dtos.ConceptRecords; +using Tutor.Elaborations.API.Public.Authoring; +using Tutor.Stakeholders.Infrastructure.Authentication; + +namespace Tutor.API.Controllers.Instructor.Authoring.Elaboration; + +[Authorize(Policy = "instructorPolicy")] +[Route("api/authoring/courses/{courseId:int}/concept-records")] +public class ConceptRecordController : BaseApiController +{ + private readonly IConceptRecordService _conceptRecordService; + + public ConceptRecordController(IConceptRecordService conceptRecordService) + { + _conceptRecordService = conceptRecordService; + } + + [HttpGet] + public ActionResult> GetByCourse(int courseId) + { + var result = _conceptRecordService.GetByCourse(courseId, User.InstructorId()); + return CreateResponse(result); + } + + [HttpGet("{id:int}")] + public ActionResult Get(int courseId, int id) + { + var result = _conceptRecordService.Get(id, courseId, User.InstructorId()); + return CreateResponse(result); + } + + [HttpPost] + public ActionResult Create(int courseId, [FromBody] ConceptRecordDto dto) + { + dto.CourseId = courseId; + var result = _conceptRecordService.Create(dto, User.InstructorId()); + return CreateResponse(result); + } + + [HttpPut("{id:int}")] + public ActionResult Update(int courseId, int id, [FromBody] ConceptRecordDto dto) + { + dto.Id = id; + dto.CourseId = courseId; + var result = _conceptRecordService.Update(dto, User.InstructorId()); + return CreateResponse(result); + } + + [HttpDelete("{id:int}")] + public ActionResult Delete(int courseId, int id) + { + var result = _conceptRecordService.Delete(id, courseId, User.InstructorId()); + return CreateResponse(result); + } +} diff --git a/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ElaborationTaskController.cs b/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ElaborationTaskController.cs new file mode 100644 index 000000000..b7f2996da --- /dev/null +++ b/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ElaborationTaskController.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tutor.Elaborations.API.Dtos.Conversations; +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}/elaboration-tasks")] +public class ElaborationTaskController : BaseApiController +{ + private readonly IElaborationTaskService _elaborationTaskService; + + public ElaborationTaskController(IElaborationTaskService elaborationTaskService) + { + _elaborationTaskService = elaborationTaskService; + } + + [HttpGet] + public ActionResult> GetByUnit(int unitId) + { + var result = _elaborationTaskService.GetByUnit(unitId, User.InstructorId()); + return CreateResponse(result); + } + + [HttpPost] + public ActionResult Create(int unitId, [FromBody] ElaborationTaskDto dto) + { + dto.UnitId = unitId; + var result = _elaborationTaskService.Create(dto, User.InstructorId()); + return CreateResponse(result); + } + + [HttpPut("{id:int}")] + public ActionResult Update(int unitId, int id, [FromBody] ElaborationTaskDto dto) + { + dto.Id = id; + dto.UnitId = unitId; + var result = _elaborationTaskService.Update(dto, User.InstructorId()); + return CreateResponse(result); + } + + [HttpDelete("{id:int}")] + public ActionResult Delete(int unitId, int id) + { + var result = _elaborationTaskService.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..c0fa86072 --- /dev/null +++ b/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs @@ -0,0 +1,60 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +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}/elaboration-tasks")] + public ActionResult> GetTasksForUnit(int unitId) + { + var result = _conversationService.GetTasksForUnit(unitId, User.LearnerId()); + return CreateResponse(result); + } + + [HttpPost("elaboration-tasks/{taskId:int}/turns")] + public async IAsyncEnumerable SubmitTurn(int taskId, + [FromBody] SubmitTurnRequestDto dto, + [EnumeratorCancellation] CancellationToken ct) + { + await foreach (var token in _conversationService.SubmitTurnAsync( + taskId, dto.Content, User.LearnerId(), ct)) + { + yield return token; + } + } + + [HttpPost("elaboration-tasks/attempts/{attemptId:int}/abandon")] + public ActionResult AbandonAttempt(int attemptId) + { + var result = _conversationService.AbandonAttempt(attemptId, User.LearnerId()); + return CreateResponse(result); + } + + [HttpGet("elaboration-tasks/attempts/{attemptId:int}")] + public ActionResult GetAttempt(int attemptId) + { + var result = _conversationService.GetAttempt(attemptId, User.LearnerId()); + return CreateResponse(result); + } + + [HttpGet("elaboration-tasks/{taskId:int}/attempts")] + public ActionResult> GetAttempts(int taskId) + { + var result = _conversationService.GetAttempts(taskId, 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 @@ + + From c7563628ed0f31591eb30f8ed091233318215f02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Wed, 25 Mar 2026 11:26:54 +0100 Subject: [PATCH 02/51] fix: Resolves subtle bug and mildly improves performance for learner login. --- .../UseCases/Management/StakeholderService.cs | 4 ++-- .../Database/StakeholdersContext.cs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) 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 From c40960125131229a80f8cea2b7d19e617de8dabe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Wed, 25 Mar 2026 12:02:12 +0100 Subject: [PATCH 03/51] tests: Adds test suites for Elaboration use cases. --- .../BaseElaborationsIntegrationTest.cs | 8 + .../ElaborationsTestFactory.cs | 107 ++++++++ .../Authoring/ConceptRecordCommandTests.cs | 183 +++++++++++++ .../Authoring/ConceptRecordQueryTests.cs | 112 ++++++++ .../Authoring/ElaborationTaskCommandTests.cs | 138 ++++++++++ .../Authoring/ElaborationTaskQueryTests.cs | 51 ++++ .../Learning/ConversationAttemptTests.cs | 78 ++++++ .../Learning/ConversationQueryTests.cs | 105 ++++++++ .../Learning/ConversationTurnTests.cs | 245 ++++++++++++++++++ .../TestData/a-delete.sql | 16 ++ .../TestData/b-courses.sql | 53 ++++ .../TestData/c-concept-records.sql | 34 +++ .../TestData/d-elaboration-tasks.sql | 15 ++ .../TestData/e-conversation-attempts.sql | 140 ++++++++++ .../TestData/f-daily-limit-attempts.sql | 7 + .../Tutor.Elaborations.Tests.csproj | 1 + .../Tutor.Elaborations.Tests/Usings.cs | 1 + 17 files changed, 1294 insertions(+) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/BaseElaborationsIntegrationTest.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskCommandTests.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskQueryTests.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationAttemptTests.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/b-courses.sql create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/f-daily-limit-attempts.sql create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Usings.cs 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..b55dbb7b2 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs @@ -0,0 +1,107 @@ +using System.Runtime.CompilerServices; +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 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 SetupDefaultMocks() + { + SetupEvaluationMock(); + SetupDialogueMock(); + SetupSummaryMock(); + } + + public void SetupEvaluationMock(List? propositionsCoveredIds = null, bool isSubstantive = true) + { + var coveredIds = propositionsCoveredIds != null && propositionsCoveredIds.Count > 0 + ? string.Join(",", propositionsCoveredIds) + : ""; + + var evalJson = $$""" + { + "correctnessScore": 2, + "completenessScore": 2, + "precisionScore": 2, + "concisenessScore": 2, + "justification": "Good explanation of the concept.", + "propositionsCoveredIds": [{{coveredIds}}], + "misconceptionsTriggeredIds": [], + "novelMisconceptions": null, + "isSubstantive": {{isSubstantive.ToString().ToLower()}} + } + """; + + MockChatService.Setup(x => x.CompleteAsync( + It.Is(r => r.MaxTokens == 1024), It.IsAny())) + .ReturnsAsync(Result.Ok(new CompletionResponse + { + Content = evalJson, + Usage = new TokenUsage(100, 50) + })); + } + + public void SetupDialogueMock(params string[] tokens) + { + var mockTokens = tokens.Length > 0 ? tokens : ["Mock ", "response."]; + MockChatService.Setup(x => x.StreamAsync( + It.IsAny(), It.IsAny())) + .Returns(MockStream(mockTokens)); + } + + public void SetupSummaryMock(string summary = "Test summary of the conversation.") + { + MockChatService.Setup(x => x.CompleteAsync( + It.Is(r => r.MaxTokens == 256), It.IsAny())) + .ReturnsAsync(Result.Ok(new CompletionResponse + { + Content = summary, + Usage = new TokenUsage(80, 40) + })); + } + + 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/ConceptRecordCommandTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs new file mode 100644 index 000000000..eb96b3254 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs @@ -0,0 +1,183 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tutor.API.Controllers.Instructor.Authoring.Elaboration; +using Tutor.Elaborations.API.Dtos.ConceptRecords; +using Tutor.Elaborations.API.Public.Authoring; +using Tutor.Elaborations.Infrastructure.Database; + +namespace Tutor.Elaborations.Tests.Integration.Authoring; + +[Collection("Sequential")] +public class ConceptRecordCommandTests : BaseElaborationsIntegrationTest +{ + public ConceptRecordCommandTests(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 ConceptRecordDto + { + CourseId = -1, + Title = "New Concept", + CanonicalDefinition = "A new concept definition.", + KeyPropositions = new List + { + new() { Statement = "First proposition", Level = "Beginner", Order = 1 }, + new() { Statement = "Second proposition", Level = "Intermediate", Order = 2 } + }, + BoundaryConditions = new List + { + new() { Statement = "A boundary condition", Level = "Beginner", Order = 1 } + }, + CommonMisconceptions = new List + { + new() { Description = "A misconception", Correction = "The correction", Order = 1 } + } + }; + dbContext.Database.BeginTransaction(); + + var actionResult = controller.Create(-1, newEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.Title.ShouldBe(newEntity.Title); + result.CourseId.ShouldBe(-1); + result.KeyPropositions.Count.ShouldBe(2); + result.KeyPropositions[0].Level.ShouldBe("Beginner"); + result.BoundaryConditions.Count.ShouldBe(1); + result.CommonMisconceptions.Count.ShouldBe(1); + } + + [Fact] + public void Updates() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var updatedEntity = new ConceptRecordDto + { + Id = -1, + CourseId = -1, + Title = "Updated Encapsulation", + CanonicalDefinition = "Updated definition.", + KeyPropositions = new List + { + new() { Statement = "Updated proposition", Level = "Beginner", Order = 1 } + }, + BoundaryConditions = new List(), + CommonMisconceptions = new List() + }; + dbContext.Database.BeginTransaction(); + + var actionResult = controller.Update(-1, -1, updatedEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.Id.ShouldBe(-1); + result.Title.ShouldBe("Updated Encapsulation"); + result.KeyPropositions.Count.ShouldBe(1); + result.KeyPropositions[0].Statement.ShouldBe("Updated proposition"); + } + + [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(-1, -2); + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.StatusCode.ShouldBe(200); + var stored = dbContext.ConceptRecords.FirstOrDefault(cr => cr.Id == -2); + stored.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 ConceptRecordDto + { + CourseId = -2, + Title = "Should Fail", + CanonicalDefinition = "Fail", + KeyPropositions = new List(), + BoundaryConditions = new List(), + CommonMisconceptions = new List() + }; + + var actionResult = controller.Create(-2, 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 ConceptRecordDto + { + Id = -3, + CourseId = -2, + Title = "Should Fail", + CanonicalDefinition = "Fail", + KeyPropositions = new List(), + BoundaryConditions = new List(), + CommonMisconceptions = new List() + }; + + var actionResult = controller.Update(-2, -3, 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(-2, -3); + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(403); + } + + private static ConceptRecordController CreateController(IServiceScope scope) + { + return new ConceptRecordController(scope.ServiceProvider.GetRequiredService()) + { + ControllerContext = BuildContext("-51", "instructor") + }; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs new file mode 100644 index 000000000..d00acd16c --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs @@ -0,0 +1,112 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tutor.API.Controllers.Instructor.Authoring.Elaboration; +using Tutor.Elaborations.API.Dtos.ConceptRecords; +using Tutor.Elaborations.API.Public.Authoring; + +namespace Tutor.Elaborations.Tests.Integration.Authoring; + +[Collection("Sequential")] +public class ConceptRecordQueryTests : BaseElaborationsIntegrationTest +{ + public ConceptRecordQueryTests(ElaborationsTestFactory factory) : base(factory) { } + + [Fact] + public void Gets_by_id() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + + var actionResult = controller.Get(-1, -1).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + + result.ShouldNotBeNull(); + result.Id.ShouldBe(-1); + result.CourseId.ShouldBe(-1); + result.Title.ShouldBe("Encapsulation"); + result.KeyPropositions.Count.ShouldBe(3); + result.KeyPropositions[0].Statement.ShouldBe("Data and methods are bundled in a class"); + result.KeyPropositions[0].Level.ShouldBe("Beginner"); + result.BoundaryConditions.Count.ShouldBe(2); + result.CommonMisconceptions.Count.ShouldBe(2); + } + + [Fact] + public void Gets_by_course() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + + var actionResult = controller.GetByCourse(-1).Result; + var result = (actionResult as OkObjectResult)?.Value as List; + + result.ShouldNotBeNull(); + result.Count.ShouldBe(2); + } + + [Fact] + public void Non_owner_fails_to_get() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + + var actionResult = controller.Get(-2, -3).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(403); + } + + [Fact] + public void Non_owner_fails_to_get_by_course() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + + var actionResult = controller.GetByCourse(-2).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(403); + } + + [Fact] + public void Fails_to_get_nonexistent() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + + var actionResult = controller.Get(-1, -999).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(404); + } + + [Fact] + public void Fails_to_get_record_from_wrong_course() + { + using var scope = Factory.Services.CreateScope(); + // Instructor -52 owns course -2, but CR -1 belongs to course -1 + var controller = new ConceptRecordController( + scope.ServiceProvider.GetRequiredService()) + { + ControllerContext = BuildContext("-52", "instructor") + }; + + var actionResult = controller.Get(-2, -1).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(404); + } + + private static ConceptRecordController CreateController(IServiceScope scope) + { + return new ConceptRecordController(scope.ServiceProvider.GetRequiredService()) + { + ControllerContext = BuildContext("-51", "instructor") + }; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskCommandTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskCommandTests.cs new file mode 100644 index 000000000..975ede24b --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskCommandTests.cs @@ -0,0 +1,138 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tutor.API.Controllers.Instructor.Authoring.Elaboration; +using Tutor.Elaborations.API.Dtos.Conversations; +using Tutor.Elaborations.API.Public.Authoring; +using Tutor.Elaborations.Infrastructure.Database; + +namespace Tutor.Elaborations.Tests.Integration.Authoring; + +[Collection("Sequential")] +public class ElaborationTaskCommandTests : BaseElaborationsIntegrationTest +{ + public ElaborationTaskCommandTests(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 ElaborationTaskDto + { + ConceptRecordId = -1, + UnitId = -1, + ExpectedLevel = "Beginner", + Order = 10 + }; + dbContext.Database.BeginTransaction(); + + var actionResult = controller.Create(-1, newEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ElaborationTaskDto; + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.ConceptRecordId.ShouldBe(-1); + result.UnitId.ShouldBe(-1); + result.ExpectedLevel.ShouldBe("Beginner"); + result.Order.ShouldBe(10); + } + + [Fact] + public void Updates() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var updatedEntity = new ElaborationTaskDto + { + Id = -1, + ConceptRecordId = -1, + UnitId = -1, + ExpectedLevel = "Intermediate", + Order = 1 + }; + dbContext.Database.BeginTransaction(); + + var actionResult = controller.Update(-1, -1, updatedEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ElaborationTaskDto; + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.Id.ShouldBe(-1); + result.ExpectedLevel.ShouldBe("Intermediate"); + } + + [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(-1, -2); + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.StatusCode.ShouldBe(200); + var stored = dbContext.ElaborationTasks.FirstOrDefault(t => t.Id == -2); + stored.ShouldBeNull(); + } + + [Fact] + public void Non_owner_fails_to_create() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + var newEntity = new ElaborationTaskDto + { + ConceptRecordId = -2, UnitId = -3, ExpectedLevel = "Beginner", Order = 1 + }; + + 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 ElaborationTaskDto + { + Id = -4, ConceptRecordId = -2, UnitId = -3, ExpectedLevel = "Beginner", Order = 1 + }; + + 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 ElaborationTaskController CreateController(IServiceScope scope) + { + return new ElaborationTaskController(scope.ServiceProvider.GetRequiredService()) + { + ControllerContext = BuildContext("-51", "instructor") + }; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskQueryTests.cs new file mode 100644 index 000000000..f69e3a5b1 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskQueryTests.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tutor.API.Controllers.Instructor.Authoring.Elaboration; +using Tutor.Elaborations.API.Dtos.Conversations; +using Tutor.Elaborations.API.Public.Authoring; + +namespace Tutor.Elaborations.Tests.Integration.Authoring; + +[Collection("Sequential")] +public class ElaborationTaskQueryTests : BaseElaborationsIntegrationTest +{ + public ElaborationTaskQueryTests(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].UnitId.ShouldBe(-1); + result[0].Order.ShouldBe(1); + result[1].Order.ShouldBe(2); + } + + [Fact] + public void Non_owner_fails_to_get() + { + 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 ElaborationTaskController CreateController(IServiceScope scope) + { + return new ElaborationTaskController(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..86a03ed30 --- /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(-3).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..1af40a5e5 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs @@ -0,0 +1,105 @@ +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; + +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); + } + + [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_attempt() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + + var actionResult = controller.GetAttempt(-1).Result; + var result = (actionResult as OkObjectResult)?.Value as ConversationAttemptDto; + + result.ShouldNotBeNull(); + result.Id.ShouldBe(-1); + result.Status.ShouldBe("Completed"); + result.Summary.ShouldNotBeNullOrEmpty(); + result.Turns.Count.ShouldBe(3); + } + + [Fact] + public void Gets_attempts_for_task() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + + var actionResult = controller.GetAttempts(-1).Result; + var result = (actionResult as OkObjectResult)?.Value as List; + + result.ShouldNotBeNull(); + result.Count.ShouldBe(2); + result.Any(a => a.Status == "Completed").ShouldBeTrue(); + result.Any(a => a.Status == "Abandoned").ShouldBeTrue(); + } + + [Fact] + public void Fails_to_get_nonexistent_attempt() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + + var actionResult = controller.GetAttempt(-999).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(404); + } + + [Fact] + public void Wrong_learner_fails_to_get_attempt() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-3"); + + var actionResult = controller.GetAttempt(-1).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(403); + } + + 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..2abc20a07 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -0,0 +1,245 @@ +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: +// Task -1: Encapsulation/Beginner, Unit -1 (1 KP in scope: -11) +// Task -2: Encapsulation/Intermediate, Unit -1 (2 KPs: -11, -12) +// Task -3: Encapsulation/Beginner, Unit -2 +// Learner -2: enrolled in Units -1, -2 | Learner -3: enrolled in Units -1, -2 +// Learner -1: NOT enrolled | Learner -4: exhausted wallet +// Attempt -4: Learner -3, Task -2, InProgress (KP -11 covered — completion test) +// Attempt -5: Learner -2, Task -2, InProgress (9 learner turns — hard cap seed) +// Attempt -6: Learner -3, Task -3, InProgress (5 substantive turns — soft cap seed) +[Collection("Sequential")] +public class ConversationTurnTests : BaseElaborationsIntegrationTest +{ + public ConversationTurnTests(ElaborationsTestFactory factory) : base(factory) { } + + [Fact] + public async Task Submits_first_turn() + { + Factory.MockChatService.Reset(); + Factory.SetupDefaultMocks(); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + var dto = new SubmitTurnRequestDto { Content = "Encapsulation bundles data and methods." }; + + var tokens = await CollectStreamAsync(controller.SubmitTurn(-1, dto, CancellationToken.None)); + + tokens.Count.ShouldBeGreaterThan(1); + var metadata = JsonSerializer.Deserialize(tokens.Last()); + metadata.ShouldNotBeNull(); + metadata.Status.ShouldBe("InProgress"); + Factory.MockChatService.Verify(x => x.CompleteAsync( + It.Is(r => r.MaxTokens == 1024), It.IsAny()), Times.Once); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.ChangeTracker.Clear(); + var attempt = dbContext.ConversationAttempts.Include(a => a.Turns) + .FirstOrDefault(a => a.ElaborationTaskId == -1 && a.LearnerId == -2 && a.Status == 0); + attempt.ShouldNotBeNull(); + attempt.Turns.Count.ShouldBeGreaterThanOrEqualTo(2); + } + + [Fact] + public async Task All_propositions_covered_completes() + { + Factory.MockChatService.Reset(); + Factory.SetupEvaluationMock([-11, -12]); + Factory.SetupDialogueMock(); + Factory.SetupSummaryMock("Completed conversation summary."); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-3"); + var dto = new SubmitTurnRequestDto { Content = "Access modifiers control visibility of members." }; + + var tokens = await CollectStreamAsync(controller.SubmitTurn(-2, dto, CancellationToken.None)); + + var metadata = JsonSerializer.Deserialize(tokens.Last()); + metadata.ShouldNotBeNull(); + metadata.Status.ShouldBe("Completed"); + metadata.Summary.ShouldNotBeNullOrEmpty(); + Factory.MockChatService.Verify(x => x.CompleteAsync( + It.Is(r => r.MaxTokens == 256), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Hard_cap_reached_expires() + { + Factory.MockChatService.Reset(); + Factory.SetupEvaluationMock(propositionsCoveredIds: []); + Factory.SetupDialogueMock(); + Factory.SetupSummaryMock("Expired due to hard cap."); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + var dto = new SubmitTurnRequestDto { Content = "Final turn attempt." }; + + var tokens = await CollectStreamAsync(controller.SubmitTurn(-2, dto, CancellationToken.None)); + + var metadata = JsonSerializer.Deserialize(tokens.Last()); + metadata.ShouldNotBeNull(); + metadata.Status.ShouldBe("Expired"); + metadata.Summary.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public async Task Soft_cap_reached_continues() + { + Factory.MockChatService.Reset(); + Factory.SetupEvaluationMock(propositionsCoveredIds: []); + Factory.SetupDialogueMock(); + Factory.SetupSummaryMock(); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-3"); + var dto = new SubmitTurnRequestDto { Content = "Sixth substantive turn." }; + + var tokens = await CollectStreamAsync(controller.SubmitTurn(-3, dto, CancellationToken.None)); + + tokens.Count.ShouldBeGreaterThan(1); + var metadata = JsonSerializer.Deserialize(tokens.Last()); + metadata.ShouldNotBeNull(); + metadata.Status.ShouldBe("InProgress"); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.ChangeTracker.Clear(); + var attempt = dbContext.ConversationAttempts.Include(a => a.Turns) + .First(a => a.Id == -6); + attempt.Turns.Count(t => t.Role == 0 && t.IsSubstantive).ShouldBe(6); + } + + [Fact] + public async Task Unenrolled_fails() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-1"); + var dto = new SubmitTurnRequestDto { Content = "Should fail." }; + + var tokens = await CollectStreamAsync(controller.SubmitTurn(-1, dto, CancellationToken.None)); + + tokens.Count.ShouldBe(1); + var error = JsonSerializer.Deserialize(tokens[0]); + error.GetProperty("code").GetInt32().ShouldBe(403); + } + + [Fact] + public async Task Insufficient_tokens_fails() + { + Factory.MockChatService.Reset(); + Factory.SetupDefaultMocks(); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-4"); + var dto = new SubmitTurnRequestDto { Content = "Should fail due to exhausted wallet." }; + + var tokens = await CollectStreamAsync(controller.SubmitTurn(-1, dto, CancellationToken.None)); + + tokens.Count.ShouldBe(1); + var error = JsonSerializer.Deserialize(tokens[0]); + error.GetProperty("code").GetInt32().ShouldBe(402); + } + + [Fact] + public async Task Max_daily_attempts_fails() + { + Factory.MockChatService.Reset(); + Factory.SetupDefaultMocks(); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + var dto = new SubmitTurnRequestDto { Content = "Should fail due to daily limit." }; + + var tokens = await CollectStreamAsync(controller.SubmitTurn(-3, dto, CancellationToken.None)); + + tokens.Count.ShouldBe(1); + var error = JsonSerializer.Deserialize(tokens[0]); + error.GetProperty("code").GetInt32().ShouldBe(429); + } + + [Fact] + public async Task 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 SubmitTurnRequestDto { Content = "Should trigger eval failure." }; + + var tokens = await CollectStreamAsync(controller.SubmitTurn(-1, 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 Nonexistent_task_fails() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + var dto = new SubmitTurnRequestDto { Content = "Task does not exist." }; + + var tokens = await CollectStreamAsync(controller.SubmitTurn(-999, dto, CancellationToken.None)); + + tokens.Count.ShouldBe(1); + var error = JsonSerializer.Deserialize(tokens[0]); + error.GetProperty("code").GetInt32().ShouldBe(404); + } + + [Fact] + public async Task Follow_up_turn_reuses_attempt() + { + // Submit first turn to create a fresh attempt (self-contained, no dependency on seeded attempts) + Factory.MockChatService.Reset(); + Factory.SetupDefaultMocks(); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-3"); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var firstDto = new SubmitTurnRequestDto { Content = "First turn for reuse test." }; + await CollectStreamAsync(controller.SubmitTurn(-1, firstDto, CancellationToken.None)); + + dbContext.ChangeTracker.Clear(); + var createdAttempt = dbContext.ConversationAttempts.Include(a => a.Turns) + .First(a => a.ElaborationTaskId == -1 && a.LearnerId == -3 && a.Status == 0); + var turnCountAfterFirst = createdAttempt.Turns.Count; + + // Submit second turn — should reuse the same attempt + Factory.MockChatService.Reset(); + Factory.SetupDefaultMocks(); + var secondDto = new SubmitTurnRequestDto { Content = "Second turn for reuse test." }; + var tokens = await CollectStreamAsync(controller.SubmitTurn(-1, secondDto, CancellationToken.None)); + + dbContext.ChangeTracker.Clear(); + var metadata = JsonSerializer.Deserialize(tokens.Last()); + metadata.ShouldNotBeNull(); + metadata.Status.ShouldBe("InProgress"); + var reusedAttempt = dbContext.ConversationAttempts.Include(a => a.Turns) + .First(a => a.Id == createdAttempt.Id); + reusedAttempt.Turns.Count.ShouldBe(turnCountAfterFirst + 2); + } + + 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..f8e12d66e --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql @@ -0,0 +1,16 @@ +DELETE FROM elaborations."TurnEvaluations"; +DELETE FROM elaborations."ConversationTurns"; +DELETE FROM elaborations."ConversationAttempts"; +DELETE FROM elaborations."ElaborationTasks"; +DELETE FROM elaborations."BoundaryConditions"; +DELETE FROM elaborations."CommonMisconceptions"; +DELETE FROM elaborations."KeyPropositions"; +DELETE FROM elaborations."ConceptRecords"; + +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-records.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql new file mode 100644 index 000000000..5b1e9626a --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql @@ -0,0 +1,34 @@ +-- ConceptRecord -1: "Encapsulation" (Course -1) +INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") +VALUES (-1, -1, 'Encapsulation', 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.'); + +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") +VALUES (-11, -1, 'Data and methods are bundled in a class', 0, 1); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") +VALUES (-12, -1, 'Access modifiers control visibility of members', 1, 2); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") +VALUES (-13, -1, 'Internal invariants are protected from external corruption', 2, 3); + +INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptRecordId", "Statement", "Level", "Order") +VALUES (-11, -1, 'Does not mean hiding all data', 0, 1); +INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptRecordId", "Statement", "Level", "Order") +VALUES (-12, -1, 'Public interfaces are part of encapsulation', 1, 2); + +INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptRecordId", "Description", "Correction", "Order") +VALUES (-11, -1, 'Encapsulation means making everything private', 'Encapsulation is about controlled access, not total hiding', 1); +INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptRecordId", "Description", "Correction", "Order") +VALUES (-12, -1, 'Getters and setters are always good encapsulation', 'Blind getters/setters can break encapsulation by exposing internals', 2); + +-- ConceptRecord -2: "Inheritance" (Course -1, minimal) +INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") +VALUES (-2, -1, 'Inheritance', 'Inheritance allows a class to derive behavior from another class.'); + +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") +VALUES (-21, -2, 'Child class inherits parent behavior', 0, 1); + +-- ConceptRecord -3: "Polymorphism" (Course -2, for non-owner tests) +INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") +VALUES (-3, -2, 'Polymorphism', 'Polymorphism enables objects to be treated as instances of their parent type.'); + +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") +VALUES (-31, -3, 'Objects can take multiple forms', 0, 1); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql new file mode 100644 index 000000000..68a48374b --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql @@ -0,0 +1,15 @@ +-- Task -1: Encapsulation at Beginner, Unit -1 (owned by Instructor -51). 1 KP in scope: -11 +INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") +VALUES (-1, -1, -1, 0, 1); + +-- Task -2: Encapsulation at Intermediate, Unit -1. 2 KPs in scope: -11, -12 +INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") +VALUES (-2, -1, -1, 1, 2); + +-- Task -3: Encapsulation at Beginner, Unit -2 (owned by Instructor -51) +INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") +VALUES (-3, -1, -2, 0, 1); + +-- Task -4: Inheritance at Beginner, Unit -3 (owned ONLY by Instructor -52, NOT -51) +INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") +VALUES (-4, -2, -3, 0, 1); 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..2a4d3476c --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -0,0 +1,140 @@ +-- Attempt -1: Learner -2, Task -1, Completed with 3 turns (for query tests) +INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +VALUES (-1, -1, -2, 1, '2024-06-01 10:00:00+00', '2024-06-01 10:15:00+00', 'Good understanding of encapsulation basics.'); + +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-1, -1, 0, 'Encapsulation bundles data and methods in a class.', true, 0, '2024-06-01 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-2, -1, 1, 'Good start! Can you tell me more about access modifiers?', true, 1, '2024-06-01 10:01:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-3, -1, 0, 'Access modifiers like public and private control visibility.', true, 2, '2024-06-01 10:02:00+00'); + +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-1, -1, 2, 2, 2, 2, 'Accurate basic description', null, '[-11]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-3, -3, 2, 2, 2, 2, 'Good description of access modifiers', null, '[-12]'::jsonb, '[]'::jsonb); + +-- Attempt -2: Learner -2, Task -1, Abandoned (for query tests) +INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +VALUES (-2, -1, -2, 2, '2024-06-02 10:00:00+00', '2024-06-02 10:05:00+00', null); + +-- Attempt -3: Learner -3, Task -1, InProgress with 2 turns (for abandon + follow-up tests) +INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +VALUES (-3, -1, -3, 0, '2024-06-03 10:00:00+00', null, null); + +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-4, -3, 0, 'Encapsulation is about data hiding.', true, 0, '2024-06-03 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', true, 1, '2024-06-03 10:01:05+00'); + +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-4, -4, 1, 1, 1, 2, 'Partially correct but incomplete', null, '[]'::jsonb, '[-11]'::jsonb); + +-- Attempt -4: Learner -3, Task -2, InProgress (for completion test: KP -11 already covered, submit to cover -12) +INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +VALUES (-4, -2, -3, 0, '2024-06-04 10:00:00+00', null, null); + +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-6, -4, 0, 'Encapsulation bundles data and methods together.', true, 0, '2024-06-04 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-7, -4, 1, 'Good. What about access control?', true, 1, '2024-06-04 10:01:05+00'); + +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-6, -6, 2, 2, 2, 2, 'Covers bundling proposition', null, '[-11]'::jsonb, '[]'::jsonb); + +-- Attempt -5: Learner -2, Task -2, InProgress with 9 learner + 9 system turns (for hard cap test) +INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +VALUES (-5, -2, -2, 0, '2024-06-05 10:00:00+00', null, null); + +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-50, -5, 0, 'Turn 1', true, 0, '2024-06-05 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-51, -5, 1, 'Response 1', true, 1, '2024-06-05 10:01:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-52, -5, 0, 'Turn 2', true, 2, '2024-06-05 10:02:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-53, -5, 1, 'Response 2', true, 3, '2024-06-05 10:02:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-54, -5, 0, 'Turn 3', true, 4, '2024-06-05 10:03:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-55, -5, 1, 'Response 3', true, 5, '2024-06-05 10:03:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-56, -5, 0, 'Turn 4', true, 6, '2024-06-05 10:04:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-57, -5, 1, 'Response 4', true, 7, '2024-06-05 10:04:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-58, -5, 0, 'Turn 5', true, 8, '2024-06-05 10:05:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-59, -5, 1, 'Response 5', true, 9, '2024-06-05 10:05:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-60, -5, 0, 'Turn 6', true, 10, '2024-06-05 10:06:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-61, -5, 1, 'Response 6', true, 11, '2024-06-05 10:06:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-62, -5, 0, 'Turn 7', true, 12, '2024-06-05 10:07:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-63, -5, 1, 'Response 7', true, 13, '2024-06-05 10:07:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-64, -5, 0, 'Turn 8', true, 14, '2024-06-05 10:08:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-65, -5, 1, 'Response 8', true, 15, '2024-06-05 10:08:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-66, -5, 0, 'Turn 9', true, 16, '2024-06-05 10:09:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-67, -5, 1, 'Response 9', true, 17, '2024-06-05 10:09:05+00'); + +-- Evaluations for the 9 learner turns (all with empty propositions - never completes) +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-50, -50, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-52, -52, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-54, -54, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-56, -56, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-58, -58, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-60, -60, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-62, -62, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-64, -64, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-66, -66, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); + +-- Attempt -6: Learner -3, Task -3, InProgress with 5 substantive learner + 5 system turns (for soft cap test) +INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +VALUES (-6, -3, -3, 0, '2024-06-06 10:00:00+00', null, null); + +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-70, -6, 0, 'Turn 1', true, 0, '2024-06-06 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-71, -6, 1, 'Response 1', true, 1, '2024-06-06 10:01:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-72, -6, 0, 'Turn 2', true, 2, '2024-06-06 10:02:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-73, -6, 1, 'Response 2', true, 3, '2024-06-06 10:02:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-74, -6, 0, 'Turn 3', true, 4, '2024-06-06 10:03:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-75, -6, 1, 'Response 3', true, 5, '2024-06-06 10:03:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-76, -6, 0, 'Turn 4', true, 6, '2024-06-06 10:04:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-77, -6, 1, 'Response 4', true, 7, '2024-06-06 10:04:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-78, -6, 0, 'Turn 5', true, 8, '2024-06-06 10:05:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") +VALUES (-79, -6, 1, 'Response 5', true, 9, '2024-06-06 10:05:05+00'); + +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-70, -70, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-72, -72, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-74, -74, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-76, -76, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") +VALUES (-78, -78, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); 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..06e1ae046 --- /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 Task -3 (triggers MaxAttemptsPerDay=3 limit) +INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +VALUES (-10, -3, -2, 1, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '2 hours', 'Daily limit attempt 1'); +INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +VALUES (-11, -3, -2, 1, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour', 'Daily limit attempt 2'); +INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +VALUES (-12, -3, -2, 2, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '30 minutes', null); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj index fd5e60541..c50607ccd 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj @@ -10,6 +10,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive 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; From 860606af4ec320857106a94f90a5a397351d925e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Thu, 26 Mar 2026 10:14:41 +0100 Subject: [PATCH 04/51] tests: Improves test infrastructure to avoid recreating the DB any time we change the data model. This change will improve agentic workflow. --- .../BaseTestFactory.cs | 89 +++++++++++++++---- .../Tutor.Courses.Tests/CoursesTestFactory.cs | 12 +++ .../ElaborationsTestFactory.cs | 3 + .../KnowledgeComponentsTestFactory.cs | 9 ++ .../LearningTasksTestFactory.cs | 9 ++ 5 files changed, 106 insertions(+), 16 deletions(-) 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.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.Tests/ElaborationsTestFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs index b55dbb7b2..ac91ee069 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs @@ -16,6 +16,9 @@ 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); 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)); From b0cc885ff7870b63c9bb95d51bc5be19893d5d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Thu, 26 Mar 2026 10:40:59 +0100 Subject: [PATCH 05/51] fix: Resolves minor security concerns for elaboration authoring. --- .../Domain/ConceptRecords/ConceptRecord.cs | 9 +++++++++ .../Domain/ElaborationTasks/ElaborationTask.cs | 7 +++++++ .../UseCases/Authoring/ConceptRecordService.cs | 6 +++++- .../UseCases/Authoring/ElaborationTaskService.cs | 11 ++++++++--- .../Database/ElaborationsContext.cs | 6 ++++++ .../Authoring/ConceptRecordCommandTests.cs | 4 ++-- .../Integration/Authoring/ConceptRecordQueryTests.cs | 2 +- .../TestData/c-concept-records.sql | 4 ++++ 8 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs index a69f99b5d..be56cdc83 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs @@ -12,6 +12,15 @@ public class ConceptRecord : AggregateRoot public List BoundaryConditions { get; private set; } = new(); public List CommonMisconceptions { get; private set; } = new(); + public void Update(ConceptRecord conceptRecord) + { + Title = conceptRecord.Title; + CanonicalDefinition = conceptRecord.CanonicalDefinition; + KeyPropositions = conceptRecord.KeyPropositions; + BoundaryConditions = conceptRecord.BoundaryConditions; + CommonMisconceptions = conceptRecord.CommonMisconceptions; + } + public ConceptRecord DeriveForLevel(PropositionLevel level) { return new ConceptRecord diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/ElaborationTask.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/ElaborationTask.cs index f971780e7..d082f6672 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/ElaborationTask.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/ElaborationTask.cs @@ -9,4 +9,11 @@ public class ElaborationTask : Entity public int UnitId { get; internal set; } public PropositionLevel ExpectedLevel { get; private set; } public int Order { get; private set; } + + public void Update(ElaborationTask task) + { + ConceptRecordId = task.ConceptRecordId; + ExpectedLevel = task.ExpectedLevel; + Order = task.Order; + } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptRecordService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptRecordService.cs index a5796ee21..6fb8f6ded 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptRecordService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptRecordService.cs @@ -50,7 +50,11 @@ public Result Update(ConceptRecordDto conceptRecord, int instr { if (!_accessServices.IsCourseOwner(conceptRecord.CourseId, instructorId)) return Result.Fail(FailureCode.Forbidden); - return Update(conceptRecord); + var existing = _conceptRecordRepository.Get(conceptRecord.Id); + if (existing == null || existing.CourseId != conceptRecord.CourseId) + return Result.Fail(FailureCode.NotFound); + existing.Update(MapToDomain(conceptRecord)); + return Update(existing); } public Result Delete(int id, int courseId, int instructorId) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs index 5b1cc4b3b..192e9594b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs @@ -42,15 +42,20 @@ public Result Update(ElaborationTaskDto task, int instructor { if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) return Result.Fail(FailureCode.Forbidden); - - return Update(task); + var existing = _taskRepository.Get(task.Id); + if (existing == null || existing.UnitId != task.UnitId) + return Result.Fail(FailureCode.NotFound); + existing.Update(MapToDomain(task)); + return Update(existing); } public Result Delete(int id, int unitId, int instructorId) { if (!_accessServices.IsUnitOwner(unitId, instructorId)) return Result.Fail(FailureCode.Forbidden); - + var existing = _taskRepository.Get(id); + if (existing == null || existing.UnitId != unitId) + return Result.Fail(FailureCode.NotFound); return Delete(id); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs index 994139918..2d4af6bcf 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -47,6 +47,12 @@ private static void ConfigureConceptRecords(ModelBuilder modelBuilder) private static void ConfigureElaborationTasks(ModelBuilder modelBuilder) { + modelBuilder.Entity() + .HasOne() + .WithMany() + .HasForeignKey(et => et.ConceptRecordId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() .HasIndex(et => new { et.UnitId, et.Order }); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs index eb96b3254..bd39ae30e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs @@ -93,12 +93,12 @@ public void Deletes() var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.Database.BeginTransaction(); - var result = (OkResult)controller.Delete(-1, -2); + var result = (OkResult)controller.Delete(-1, -4); dbContext.ChangeTracker.Clear(); result.ShouldNotBeNull(); result.StatusCode.ShouldBe(200); - var stored = dbContext.ConceptRecords.FirstOrDefault(cr => cr.Id == -2); + var stored = dbContext.ConceptRecords.FirstOrDefault(cr => cr.Id == -4); stored.ShouldBeNull(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs index d00acd16c..888307e2d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs @@ -42,7 +42,7 @@ public void Gets_by_course() var result = (actionResult as OkObjectResult)?.Value as List; result.ShouldNotBeNull(); - result.Count.ShouldBe(2); + result.Count.ShouldBe(3); } [Fact] diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql index 5b1e9626a..ae6fed925 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql @@ -26,6 +26,10 @@ VALUES (-2, -1, 'Inheritance', 'Inheritance allows a class to derive behavior fr INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") VALUES (-21, -2, 'Child class inherits parent behavior', 0, 1); +-- ConceptRecord -4: "Abstraction" (Course -1, no task references, for delete test) +INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") +VALUES (-4, -1, 'Abstraction', 'Abstraction focuses on essential qualities rather than specific details.'); + -- ConceptRecord -3: "Polymorphism" (Course -2, for non-owner tests) INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") VALUES (-3, -2, 'Polymorphism', 'Polymorphism enables objects to be treated as instances of their parent type.'); From 400a508e7573aae46ca781469002f25d093666c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Thu, 26 Mar 2026 11:21:41 +0100 Subject: [PATCH 06/51] feat: Enhances capabilities for learning through conversation. --- .../Conversations/ElaborationTaskDetailDto.cs | 11 ++ .../Dtos/Conversations/ElaborationTaskDto.cs | 1 + .../Conversations/SubmitTurnResponseDto.cs | 1 + .../Public/Learning/IConversationService.cs | 8 +- .../IConversationAttemptRepository.cs | 1 + .../UseCases/Learning/ConversationService.cs | 150 ++++++++++++------ .../ConversationAttemptDatabaseRepository.cs | 11 ++ .../Learning/ConversationAttemptTests.cs | 2 +- .../Learning/ConversationQueryTests.cs | 45 +++--- .../Learning/ConversationTurnTests.cs | 115 +++++++++++--- .../TestData/d-elaboration-tasks.sql | 8 + .../TestData/e-conversation-attempts.sql | 4 + .../Elaboration/ConversationController.cs | 38 +++-- 13 files changed, 283 insertions(+), 112 deletions(-) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDetailDto.cs diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDetailDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDetailDto.cs new file mode 100644 index 000000000..358c954d3 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDetailDto.cs @@ -0,0 +1,11 @@ +namespace Tutor.Elaborations.API.Dtos.Conversations; + +public class ElaborationTaskDetailDto +{ + public int Id { get; set; } + public string ExpectedLevel { get; set; } = string.Empty; + public int Order { get; set; } + public string ConceptTitle { get; set; } = string.Empty; + public string ConceptDefinition { get; set; } = string.Empty; + public List Attempts { get; set; } = new(); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDto.cs index 2434c1c46..fe4c1470e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDto.cs @@ -8,4 +8,5 @@ public class ElaborationTaskDto public string ExpectedLevel { get; set; } = string.Empty; public int Order { get; set; } public string? ConceptRecordTitle { get; set; } + public bool HasCompletedAttempt { get; set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnResponseDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnResponseDto.cs index d313e1b6e..d90262b05 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnResponseDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnResponseDto.cs @@ -2,6 +2,7 @@ namespace Tutor.Elaborations.API.Dtos.Conversations; public class SubmitTurnResponseDto { + public int AttemptId { get; set; } public string Status { get; set; } = string.Empty; public string? Summary { get; set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs index a271c32c3..307ed0732 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs @@ -6,8 +6,10 @@ namespace Tutor.Elaborations.API.Public.Learning; public interface IConversationService { Result> GetTasksForUnit(int unitId, int learnerId); - IAsyncEnumerable SubmitTurnAsync(int taskId, string content, int learnerId, CancellationToken ct); + Result GetTaskDetail(int taskId, int learnerId); + IAsyncEnumerable StartConversationAsync( + int taskId, string content, int learnerId, CancellationToken ct); + IAsyncEnumerable SubmitTurnAsync( + int attemptId, string content, int learnerId, CancellationToken ct); Result AbandonAttempt(int attemptId, int learnerId); - Result GetAttempt(int attemptId, int learnerId); - Result> GetAttempts(int taskId, int learnerId); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/IConversationAttemptRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/IConversationAttemptRepository.cs index d066e2633..50c11512b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/IConversationAttemptRepository.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/IConversationAttemptRepository.cs @@ -7,4 +7,5 @@ public interface IConversationAttemptRepository : ICrudRepository GetByTaskAndLearner(int elaborationTaskId, int learnerId); int CountRecentAttempts(int elaborationTaskId, int learnerId, DateTime since); + HashSet GetTaskIdsWithCompletedAttempts(List taskIds, int learnerId); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index e714e431f..c06878c7c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -8,6 +8,7 @@ using Tutor.Elaborations.API.Dtos.Conversations; using Tutor.Elaborations.API.Public; using Tutor.Elaborations.API.Public.Learning; +using Tutor.Elaborations.Core.Domain.ConceptRecords; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.Domain.ElaborationTasks; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; @@ -20,7 +21,7 @@ public class ConversationService : IConversationService private readonly IConversationAttemptRepository _attemptRepo; private readonly IElaborationTaskRepository _taskRepo; - private readonly Domain.ConceptRecords.IConceptRecordRepository _conceptRecordRepo; + private readonly IConceptRecordRepository _conceptRecordRepo; private readonly TurnOrchestrator _turnOrchestrator; private readonly ITokenSpendingService _tokenSpendingService; private readonly IAccessServices _accessServices; @@ -29,7 +30,7 @@ public class ConversationService : IConversationService public ConversationService(IConversationAttemptRepository attemptRepo, IElaborationTaskRepository taskRepo, - Domain.ConceptRecords.IConceptRecordRepository conceptRecordRepo, + IConceptRecordRepository conceptRecordRepo, TurnOrchestrator turnOrchestrator, ITokenSpendingService tokenSpendingService, IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, IMapper mapper) { @@ -49,10 +50,41 @@ public Result> GetTasksForUnit(int unitId, int learnerI return Result.Fail(FailureCode.Forbidden); var tasks = _taskRepo.GetByUnit(unitId); - return Result.Ok(tasks.Select(t => _mapper.Map(t)).ToList()); + var taskDtos = tasks.Select(t => _mapper.Map(t)).ToList(); + var taskIds = taskDtos.Select(t => t.Id).ToList(); + var completedTaskIds = _attemptRepo.GetTaskIdsWithCompletedAttempts(taskIds, learnerId); + + foreach (var dto in taskDtos) + dto.HasCompletedAttempt = completedTaskIds.Contains(dto.Id); + + return Result.Ok(taskDtos); + } + + public Result GetTaskDetail(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 conceptRecord = _conceptRecordRepo.Get(task.ConceptRecordId); + if (conceptRecord == null) return Result.Fail(FailureCode.NotFound); + + var attempts = _attemptRepo.GetByTaskAndLearner(taskId, learnerId); + + return Result.Ok(new ElaborationTaskDetailDto + { + Id = task.Id, + ExpectedLevel = task.ExpectedLevel.ToString(), + Order = task.Order, + ConceptTitle = conceptRecord.Title, + ConceptDefinition = conceptRecord.CanonicalDefinition, + Attempts = attempts.Select(a => _mapper.Map(a)).ToList() + }); } - public async IAsyncEnumerable SubmitTurnAsync(int taskId, string content, + public async IAsyncEnumerable StartConversationAsync(int taskId, string content, int learnerId, [EnumeratorCancellation] CancellationToken ct) { var task = _taskRepo.Get(taskId); @@ -69,20 +101,71 @@ public async IAsyncEnumerable SubmitTurnAsync(int taskId, string content if (balanceCheck.IsFailed) { yield return BuildErrorChunk("Insufficient token balance. Contact your administrator.", 402); yield break; } - var attempt = _attemptRepo.GetActiveAttempt(taskId, learnerId); - if (attempt == null) - { - 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 existing = _attemptRepo.GetActiveAttempt(taskId, learnerId); + if (existing != null) + { yield return BuildErrorChunk("An active conversation already exists.", 409, existing.Id); yield break; } - attempt = new ConversationAttempt(taskId, learnerId); - _attemptRepo.Create(attempt); - } + 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); + _attemptRepo.Create(attempt); var levelRecord = conceptRecord.DeriveForLevel(task.ExpectedLevel); + await foreach (var token in RunTurnPipelineAsync(attempt, task, levelRecord, content, ct)) + yield return token; + } + + public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string content, + 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 = _taskRepo.Get(attempt.ElaborationTaskId); + if (task == null) { yield return BuildErrorChunk("Task not found.", 404); yield break; } + + if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) + { yield return BuildErrorChunk("Not enrolled in unit.", 403); yield break; } + + var conceptRecord = _conceptRecordRepo.Get(task.ConceptRecordId); + if (conceptRecord == null) { yield return BuildErrorChunk("Concept record not found.", 404); yield break; } + + var balanceCheck = _tokenSpendingService.HasSufficientBalanceForUnit( + learnerId, task.UnitId, content.Length); + if (balanceCheck.IsFailed) + { yield return BuildErrorChunk("Insufficient token balance. Contact your administrator.", 402); yield break; } + + var levelRecord = conceptRecord.DeriveForLevel(task.ExpectedLevel); + + await foreach (var token in RunTurnPipelineAsync(attempt, task, levelRecord, content, 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 RunTurnPipelineAsync( + ConversationAttempt attempt, ElaborationTask task, + ConceptRecord levelRecord, string content, + [EnumeratorCancellation] CancellationToken ct) + { // Synchronous phase: evaluate var evalResult = await _turnOrchestrator.EvaluateAsync( content, attempt.Turns.ToList(), levelRecord, ct); @@ -131,57 +214,30 @@ public async IAsyncEnumerable SubmitTurnAsync(int taskId, string content _unitOfWork.Save(); // Spend tokens — estimate from content lengths - var totalChars = content.Length + fullResponse.Length; _tokenSpendingService.SpendTokensForUnit(new TokenSpendingRequestDto { - LearnerId = learnerId, + LearnerId = attempt.LearnerId, UnitId = task.UnitId, PromptTokens = content.Length / 4, CompletionTokens = fullResponse.Length / 4, FeatureType = "Elaboration", - EntityId = taskId, + EntityId = task.Id, PromptSummary = "Concept conversation turn" }); // Final metadata chunk yield return JsonSerializer.Serialize(new SubmitTurnResponseDto { + AttemptId = attempt.Id, Status = attempt.Status.ToString(), Summary = summary }); } - 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)); - } - - public Result GetAttempt(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); - - return Result.Ok(_mapper.Map(attempt)); - } - - public Result> GetAttempts(int taskId, int learnerId) - { - var attempts = _attemptRepo.GetByTaskAndLearner(taskId, learnerId); - return Result.Ok(attempts.Select(a => _mapper.Map(a)).ToList()); - } - - private static string BuildErrorChunk(string message, int code) + 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.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs index 6e954f88d..9c4586cd3 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs @@ -43,4 +43,15 @@ public int CountRecentAttempts(int elaborationTaskId, int learnerId, DateTime si && ca.LearnerId == learnerId && ca.StartedAt >= since); } + + public HashSet GetTaskIdsWithCompletedAttempts(List taskIds, int learnerId) + { + return DbContext.ConversationAttempts + .Where(ca => taskIds.Contains(ca.ElaborationTaskId) + && ca.LearnerId == learnerId + && ca.Status == AttemptStatus.Completed) + .Select(ca => ca.ElaborationTaskId) + .Distinct() + .ToHashSet(); + } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationAttemptTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationAttemptTests.cs index 86a03ed30..399431d3a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationAttemptTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationAttemptTests.cs @@ -20,7 +20,7 @@ public void Abandons_in_progress() var controller = CreateController(scope, "-3"); var dbContext = scope.ServiceProvider.GetRequiredService(); - var actionResult = controller.AbandonAttempt(-3).Result; + var actionResult = controller.AbandonAttempt(-7).Result; var result = (actionResult as OkObjectResult)?.Value as ConversationAttemptDto; dbContext.ChangeTracker.Clear(); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs index 1af40a5e5..955946ed3 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs @@ -23,6 +23,8 @@ public void Gets_tasks_for_enrolled_unit() result.ShouldNotBeNull(); result.Count.ShouldBe(2); + result.First(t => t.Id == -1).HasCompletedAttempt.ShouldBeTrue(); + result.First(t => t.Id == -2).HasCompletedAttempt.ShouldBeFalse(); } [Fact] @@ -39,60 +41,61 @@ public void Unenrolled_fails_to_get_tasks() } [Fact] - public void Gets_attempt() + public void Gets_task_detail() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-2"); - var actionResult = controller.GetAttempt(-1).Result; - var result = (actionResult as OkObjectResult)?.Value as ConversationAttemptDto; + var actionResult = controller.GetTaskDetail(-1).Result; + var result = (actionResult as OkObjectResult)?.Value as ElaborationTaskDetailDto; result.ShouldNotBeNull(); result.Id.ShouldBe(-1); - result.Status.ShouldBe("Completed"); - result.Summary.ShouldNotBeNullOrEmpty(); - result.Turns.Count.ShouldBe(3); + result.ConceptTitle.ShouldNotBeNullOrEmpty(); + result.ConceptDefinition.ShouldNotBeNullOrEmpty(); + result.ExpectedLevel.ShouldBe("Beginner"); + 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_attempts_for_task() + public void Gets_task_detail_with_active_attempt() { using var scope = Factory.Services.CreateScope(); - var controller = CreateController(scope, "-2"); + var controller = CreateController(scope, "-3"); - var actionResult = controller.GetAttempts(-1).Result; - var result = (actionResult as OkObjectResult)?.Value as List; + var actionResult = controller.GetTaskDetail(-2).Result; + var result = (actionResult as OkObjectResult)?.Value as ElaborationTaskDetailDto; result.ShouldNotBeNull(); - result.Count.ShouldBe(2); - result.Any(a => a.Status == "Completed").ShouldBeTrue(); - result.Any(a => a.Status == "Abandoned").ShouldBeTrue(); + result.Attempts.Any(a => a.Status == "InProgress").ShouldBeTrue(); } [Fact] - public void Fails_to_get_nonexistent_attempt() + public void Gets_task_detail_unenrolled_fails() { using var scope = Factory.Services.CreateScope(); - var controller = CreateController(scope, "-2"); + var controller = CreateController(scope, "-1"); - var actionResult = controller.GetAttempt(-999).Result; + var actionResult = controller.GetTaskDetail(-1).Result; var objectResult = actionResult as ObjectResult; objectResult.ShouldNotBeNull(); - objectResult.StatusCode.ShouldBe(404); + objectResult.StatusCode.ShouldBe(403); } [Fact] - public void Wrong_learner_fails_to_get_attempt() + public void Gets_task_detail_nonexistent_fails() { using var scope = Factory.Services.CreateScope(); - var controller = CreateController(scope, "-3"); + var controller = CreateController(scope, "-2"); - var actionResult = controller.GetAttempt(-1).Result; + var actionResult = controller.GetTaskDetail(-999).Result; var objectResult = actionResult as ObjectResult; objectResult.ShouldNotBeNull(); - objectResult.StatusCode.ShouldBe(403); + objectResult.StatusCode.ShouldBe(404); } private static ConversationController CreateController(IServiceScope scope, string learnerId) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs index 2abc20a07..e3f62bc96 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -16,18 +16,22 @@ namespace Tutor.Elaborations.Tests.Integration.Learning; // Task -1: Encapsulation/Beginner, Unit -1 (1 KP in scope: -11) // Task -2: Encapsulation/Intermediate, Unit -1 (2 KPs: -11, -12) // Task -3: Encapsulation/Beginner, Unit -2 +// Task -5: Encapsulation/Intermediate, Unit -2 (isolated for StartConversation) +// Task -6: Encapsulation/Advanced, Unit -2 (isolated for Start+Submit flow) // Learner -2: enrolled in Units -1, -2 | Learner -3: enrolled in Units -1, -2 // Learner -1: NOT enrolled | Learner -4: exhausted wallet +// Attempt -3: Learner -3, Task -1, InProgress (2 turns — for conflict + eval failure tests) // Attempt -4: Learner -3, Task -2, InProgress (KP -11 covered — completion test) // Attempt -5: Learner -2, Task -2, InProgress (9 learner turns — hard cap seed) // Attempt -6: Learner -3, Task -3, InProgress (5 substantive turns — soft cap seed) +// Attempt -7: Learner -3, Task -5, InProgress (isolated for abandon test) [Collection("Sequential")] public class ConversationTurnTests : BaseElaborationsIntegrationTest { public ConversationTurnTests(ElaborationsTestFactory factory) : base(factory) { } [Fact] - public async Task Submits_first_turn() + public async Task Starts_conversation_with_first_turn() { Factory.MockChatService.Reset(); Factory.SetupDefaultMocks(); @@ -35,19 +39,20 @@ public async Task Submits_first_turn() var controller = CreateController(scope, "-2"); var dto = new SubmitTurnRequestDto { Content = "Encapsulation bundles data and methods." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-1, dto, CancellationToken.None)); + 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); var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.ChangeTracker.Clear(); var attempt = dbContext.ConversationAttempts.Include(a => a.Turns) - .FirstOrDefault(a => a.ElaborationTaskId == -1 && a.LearnerId == -2 && a.Status == 0); + .FirstOrDefault(a => a.ElaborationTaskId == -5 && a.LearnerId == -2 && a.Status == 0); attempt.ShouldNotBeNull(); attempt.Turns.Count.ShouldBeGreaterThanOrEqualTo(2); } @@ -63,7 +68,7 @@ public async Task All_propositions_covered_completes() var controller = CreateController(scope, "-3"); var dto = new SubmitTurnRequestDto { Content = "Access modifiers control visibility of members." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-2, dto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.SubmitTurn(-4, dto, CancellationToken.None)); var metadata = JsonSerializer.Deserialize(tokens.Last()); metadata.ShouldNotBeNull(); @@ -84,7 +89,7 @@ public async Task Hard_cap_reached_expires() var controller = CreateController(scope, "-2"); var dto = new SubmitTurnRequestDto { Content = "Final turn attempt." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-2, dto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.SubmitTurn(-5, dto, CancellationToken.None)); var metadata = JsonSerializer.Deserialize(tokens.Last()); metadata.ShouldNotBeNull(); @@ -103,7 +108,7 @@ public async Task Soft_cap_reached_continues() var controller = CreateController(scope, "-3"); var dto = new SubmitTurnRequestDto { Content = "Sixth substantive turn." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-3, dto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.SubmitTurn(-6, dto, CancellationToken.None)); tokens.Count.ShouldBeGreaterThan(1); var metadata = JsonSerializer.Deserialize(tokens.Last()); @@ -118,13 +123,13 @@ public async Task Soft_cap_reached_continues() } [Fact] - public async Task Unenrolled_fails() + public async Task Start_unenrolled_fails() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-1"); var dto = new SubmitTurnRequestDto { Content = "Should fail." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-1, dto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.StartConversation(-1, dto, CancellationToken.None)); tokens.Count.ShouldBe(1); var error = JsonSerializer.Deserialize(tokens[0]); @@ -132,7 +137,7 @@ public async Task Unenrolled_fails() } [Fact] - public async Task Insufficient_tokens_fails() + public async Task Start_insufficient_tokens_fails() { Factory.MockChatService.Reset(); Factory.SetupDefaultMocks(); @@ -140,7 +145,7 @@ public async Task Insufficient_tokens_fails() var controller = CreateController(scope, "-4"); var dto = new SubmitTurnRequestDto { Content = "Should fail due to exhausted wallet." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-1, dto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.StartConversation(-1, dto, CancellationToken.None)); tokens.Count.ShouldBe(1); var error = JsonSerializer.Deserialize(tokens[0]); @@ -148,7 +153,7 @@ public async Task Insufficient_tokens_fails() } [Fact] - public async Task Max_daily_attempts_fails() + public async Task Start_max_daily_attempts_fails() { Factory.MockChatService.Reset(); Factory.SetupDefaultMocks(); @@ -156,7 +161,7 @@ public async Task Max_daily_attempts_fails() var controller = CreateController(scope, "-2"); var dto = new SubmitTurnRequestDto { Content = "Should fail due to daily limit." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-3, dto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.StartConversation(-3, dto, CancellationToken.None)); tokens.Count.ShouldBe(1); var error = JsonSerializer.Deserialize(tokens[0]); @@ -164,7 +169,7 @@ public async Task Max_daily_attempts_fails() } [Fact] - public async Task Evaluation_failure_returns_error() + public async Task Submit_evaluation_failure_returns_error() { Factory.MockChatService.Reset(); Factory.MockChatService.Setup(x => x.CompleteAsync( @@ -174,7 +179,7 @@ public async Task Evaluation_failure_returns_error() var controller = CreateController(scope, "-3"); var dto = new SubmitTurnRequestDto { Content = "Should trigger eval failure." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-1, dto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.SubmitTurn(-3, dto, CancellationToken.None)); tokens.Count.ShouldBe(1, $"Got: [{string.Join("|", tokens)}]"); var error = JsonSerializer.Deserialize(tokens[0]); @@ -182,13 +187,13 @@ public async Task Evaluation_failure_returns_error() } [Fact] - public async Task Nonexistent_task_fails() + public async Task Start_nonexistent_task_fails() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-2"); var dto = new SubmitTurnRequestDto { Content = "Task does not exist." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-999, dto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.StartConversation(-999, dto, CancellationToken.None)); tokens.Count.ShouldBe(1); var error = JsonSerializer.Deserialize(tokens[0]); @@ -196,37 +201,99 @@ public async Task Nonexistent_task_fails() } [Fact] - public async Task Follow_up_turn_reuses_attempt() + public async Task Start_then_submit_adds_turns_to_same_attempt() { - // Submit first turn to create a fresh attempt (self-contained, no dependency on seeded attempts) Factory.MockChatService.Reset(); Factory.SetupDefaultMocks(); using var scope = Factory.Services.CreateScope(); - var controller = CreateController(scope, "-3"); + var controller = CreateController(scope, "-2"); var dbContext = scope.ServiceProvider.GetRequiredService(); var firstDto = new SubmitTurnRequestDto { Content = "First turn for reuse test." }; - await CollectStreamAsync(controller.SubmitTurn(-1, firstDto, CancellationToken.None)); + var firstTokens = await CollectStreamAsync(controller.StartConversation(-6, firstDto, CancellationToken.None)); + + var firstMetadata = JsonSerializer.Deserialize(firstTokens.Last()); + firstMetadata.ShouldNotBeNull(); + var attemptId = firstMetadata.AttemptId; + attemptId.ShouldBeGreaterThan(0); dbContext.ChangeTracker.Clear(); var createdAttempt = dbContext.ConversationAttempts.Include(a => a.Turns) - .First(a => a.ElaborationTaskId == -1 && a.LearnerId == -3 && a.Status == 0); + .First(a => a.Id == attemptId); var turnCountAfterFirst = createdAttempt.Turns.Count; - // Submit second turn — should reuse the same attempt + // Submit second turn — should add to the same attempt Factory.MockChatService.Reset(); Factory.SetupDefaultMocks(); var secondDto = new SubmitTurnRequestDto { Content = "Second turn for reuse test." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-1, secondDto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.SubmitTurn(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.Turns) - .First(a => a.Id == createdAttempt.Id); + .First(a => a.Id == attemptId); reusedAttempt.Turns.Count.ShouldBe(turnCountAfterFirst + 2); } + [Fact] + public async Task Start_with_active_attempt_returns_conflict() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-3"); + var dto = new SubmitTurnRequestDto { Content = "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 SubmitTurnRequestDto { Content = "Attempt does not exist." }; + + var tokens = await CollectStreamAsync(controller.SubmitTurn(-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 SubmitTurnRequestDto { Content = "Not my attempt." }; + + var tokens = await CollectStreamAsync(controller.SubmitTurn(-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 SubmitTurnRequestDto { Content = "Attempt already done." }; + + var tokens = await CollectStreamAsync(controller.SubmitTurn(-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()) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql index 68a48374b..a588fa704 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql @@ -13,3 +13,11 @@ VALUES (-3, -1, -2, 0, 1); -- Task -4: Inheritance at Beginner, Unit -3 (owned ONLY by Instructor -52, NOT -51) INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") VALUES (-4, -2, -3, 0, 1); + +-- Task -5: Encapsulation at Intermediate, Unit -2 (isolated for StartConversation tests) +INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") +VALUES (-5, -1, -2, 1, 2); + +-- Task -6: Encapsulation at Advanced, Unit -2 (isolated for Start+Submit flow test) +INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") +VALUES (-6, -1, -2, 2, 3); 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 index 2a4d3476c..c9adc51e6 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -138,3 +138,7 @@ INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Correctn VALUES (-76, -76, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") VALUES (-78, -78, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); + +-- Attempt -7: Learner -3, Task -5, InProgress (isolated for abandon test — no other test touches this) +INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +VALUES (-7, -5, -3, 0, '2024-06-07 10:00:00+00', null, null); diff --git a/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs b/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs index c0fa86072..3a7523aee 100644 --- a/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs +++ b/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs @@ -25,36 +25,42 @@ public ActionResult> GetTasksForUnit(int unitId) return CreateResponse(result); } - [HttpPost("elaboration-tasks/{taskId:int}/turns")] - public async IAsyncEnumerable SubmitTurn(int taskId, + [HttpGet("elaboration-tasks/{taskId:int}")] + public ActionResult GetTaskDetail(int taskId) + { + var result = _conversationService.GetTaskDetail(taskId, User.LearnerId()); + return CreateResponse(result); + } + + [HttpPost("elaboration-tasks/{taskId:int}/conversations")] + public async IAsyncEnumerable StartConversation(int taskId, [FromBody] SubmitTurnRequestDto dto, [EnumeratorCancellation] CancellationToken ct) { - await foreach (var token in _conversationService.SubmitTurnAsync( + await foreach (var token in _conversationService.StartConversationAsync( taskId, dto.Content, User.LearnerId(), ct)) { yield return token; } } - [HttpPost("elaboration-tasks/attempts/{attemptId:int}/abandon")] - public ActionResult AbandonAttempt(int attemptId) + [HttpPost("elaboration-tasks/attempts/{attemptId:int}/turns")] + public async IAsyncEnumerable SubmitTurn(int attemptId, + [FromBody] SubmitTurnRequestDto dto, + [EnumeratorCancellation] CancellationToken ct) { - var result = _conversationService.AbandonAttempt(attemptId, User.LearnerId()); - return CreateResponse(result); + await foreach (var token in _conversationService.SubmitTurnAsync( + attemptId, dto.Content, User.LearnerId(), ct)) + { + yield return token; + } } - [HttpGet("elaboration-tasks/attempts/{attemptId:int}")] - public ActionResult GetAttempt(int attemptId) + [HttpPost("elaboration-tasks/attempts/{attemptId:int}/abandon")] + public ActionResult AbandonAttempt(int attemptId) { - var result = _conversationService.GetAttempt(attemptId, User.LearnerId()); + var result = _conversationService.AbandonAttempt(attemptId, User.LearnerId()); return CreateResponse(result); } - [HttpGet("elaboration-tasks/{taskId:int}/attempts")] - public ActionResult> GetAttempts(int taskId) - { - var result = _conversationService.GetAttempts(taskId, User.LearnerId()); - return CreateResponse(result); - } } From 09ab494c512652c87f05ed88ba56293802af0c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Mon, 6 Apr 2026 11:37:06 +0200 Subject: [PATCH 07/51] feat: Expand concept record to include relationships between KPs --- .../Dtos/ConceptRecords/ConceptRecordDto.cs | 1 + .../Dtos/ConceptRecords/KeyRelationDto.cs | 11 ++ .../Domain/ConceptRecords/ConceptRecord.cs | 30 +++- .../Domain/ConceptRecords/KeyRelation.cs | 13 ++ .../Conversations/ConversationAttempt.cs | 8 + .../Domain/Conversations/TurnEvaluation.cs | 17 +- .../Mappers/ConceptRecordProfile.cs | 2 + .../UseCases/Learning/ConversationService.cs | 12 +- .../Orchestration/ConversationState.cs | 2 + .../Agents/DialogueAgent.cs | 14 +- .../Agents/EvaluationAgent.cs | 10 +- .../Agents/Prompts/DialoguePromptBuilder.cs | 35 +++- .../Agents/Prompts/EvaluationPromptBuilder.cs | 80 ++++++--- .../Database/ElaborationsContext.cs | 8 + .../ConceptRecordDatabaseRepository.cs | 2 + .../ElaborationsTestFactory.cs | 15 +- .../Authoring/ConceptRecordCommandTests.cs | 61 ++++++- .../Authoring/ConceptRecordQueryTests.cs | 25 ++- .../Learning/ConversationTurnTests.cs | 51 ++++++ .../TestData/c-concept-records.sql | 12 ++ .../TestData/d-elaboration-tasks.sql | 4 + .../TestData/e-conversation-attempts.sql | 72 ++++----- .../Unit/ConceptRecordTests.cs | 153 ++++++++++++++++++ 23 files changed, 545 insertions(+), 93 deletions(-) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/ConceptRecordDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/ConceptRecordDto.cs index 185f07abe..d2a6df4b4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/ConceptRecordDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/ConceptRecordDto.cs @@ -9,4 +9,5 @@ public class ConceptRecordDto public List KeyPropositions { get; set; } = new(); public List BoundaryConditions { 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/ConceptRecords/KeyRelationDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs new file mode 100644 index 000000000..da587f0c1 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs @@ -0,0 +1,11 @@ +namespace Tutor.Elaborations.API.Dtos.ConceptRecords; + +public class KeyRelationDto +{ + public int Id { get; set; } + public int SourceKeyPropositionId { get; set; } + public int TargetKeyPropositionId { get; set; } + public string Mechanism { get; set; } = string.Empty; + public string Level { get; set; } = string.Empty; + public int Order { get; set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs index be56cdc83..df1ec584f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs @@ -11,6 +11,7 @@ public class ConceptRecord : AggregateRoot public List KeyPropositions { get; private set; } = new(); public List BoundaryConditions { get; private set; } = new(); public List CommonMisconceptions { get; private set; } = new(); + public List KeyRelations { get; private set; } = new(); public void Update(ConceptRecord conceptRecord) { @@ -19,24 +20,33 @@ public void Update(ConceptRecord conceptRecord) KeyPropositions = conceptRecord.KeyPropositions; BoundaryConditions = conceptRecord.BoundaryConditions; CommonMisconceptions = conceptRecord.CommonMisconceptions; + KeyRelations = conceptRecord.KeyRelations; } public ConceptRecord DeriveForLevel(PropositionLevel level) { + var filteredKPs = KeyPropositions + .Where(kp => kp.Level <= level) + .OrderBy(kp => kp.Order).ToList(); + var filteredKPIds = filteredKPs.Select(kp => kp.Id).ToHashSet(); + return new ConceptRecord { Id = Id, CourseId = CourseId, Title = Title, CanonicalDefinition = CanonicalDefinition, - KeyPropositions = KeyPropositions - .Where(kp => kp.Level <= level) - .OrderBy(kp => kp.Order).ToList(), + KeyPropositions = filteredKPs, BoundaryConditions = BoundaryConditions .Where(bc => bc.Level <= level) .OrderBy(bc => bc.Order).ToList(), CommonMisconceptions = CommonMisconceptions - .OrderBy(cm => cm.Order).ToList() + .OrderBy(cm => cm.Order).ToList(), + KeyRelations = KeyRelations + .Where(kr => kr.Level <= level) + .Where(kr => filteredKPIds.Contains(kr.SourceKeyPropositionId) + && filteredKPIds.Contains(kr.TargetKeyPropositionId)) + .OrderBy(kr => kr.Order).ToList() }; } @@ -45,4 +55,16 @@ public bool AreAllPropositionsCovered(ConversationAttempt attempt) var coveredIds = attempt.GetCoveredPropositionIds(); return KeyPropositions.All(kp => coveredIds.Contains(kp.Id)); } + + public bool AreAllKeyRelationsArticulated(ConversationAttempt attempt) + { + if (KeyRelations.Count == 0) return true; + var articulatedIds = attempt.GetArticulatedRelationIds(); + return KeyRelations.All(kr => articulatedIds.Contains(kr.Id)); + } + + public bool IsAttemptComplete(ConversationAttempt attempt) + { + return AreAllPropositionsCovered(attempt) && AreAllKeyRelationsArticulated(attempt); + } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs new file mode 100644 index 000000000..ce3e7f746 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs @@ -0,0 +1,13 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptRecords; + +public class KeyRelation : Entity +{ + public int ConceptRecordId { get; private set; } + public int SourceKeyPropositionId { get; private set; } + public int TargetKeyPropositionId { get; private set; } + public string Mechanism { get; private set; } = string.Empty; + public PropositionLevel Level { get; private set; } + public int Order { get; private set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index ea57b34cb..479c04b8e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -33,6 +33,14 @@ public ISet GetCoveredPropositionIds() .ToHashSet(); } + public ISet GetArticulatedRelationIds() + { + return Turns + .Where(t => t.Evaluation != null) + .SelectMany(t => t.Evaluation!.RelationsArticulatedIds) + .ToHashSet(); + } + public int CountSubstantiveLearnerTurns() { return Turns.Count(t => t.Role == TurnRole.Learner && t.IsSubstantive); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs index f31254b0f..8a612eb24 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs @@ -7,27 +7,30 @@ public class TurnEvaluation : Entity public int ConversationTurnId { get; private set; } public int CorrectnessScore { get; private set; } public int CompletenessScore { get; private set; } - public int PrecisionScore { get; private set; } - public int ConcisenessScore { get; private set; } + public int? DiscriminationScore { get; private set; } + public int? IntegrationScore { get; private set; } public string Justification { get; private set; } = string.Empty; public string? NovelMisconceptions { get; private set; } public List PropositionsCoveredIds { get; private set; } = new(); public List MisconceptionsTriggeredIds { get; private set; } = new(); + public List RelationsArticulatedIds { get; private set; } = new(); private TurnEvaluation() { } public TurnEvaluation(int correctnessScore, int completenessScore, - int precisionScore, int concisenessScore, string justification, - string? novelMisconceptions, List propositionsCoveredIds, - List misconceptionsTriggeredIds) + int? discriminationScore, int? integrationScore, + string justification, string? novelMisconceptions, + List propositionsCoveredIds, List misconceptionsTriggeredIds, + List relationsArticulatedIds) { CorrectnessScore = correctnessScore; CompletenessScore = completenessScore; - PrecisionScore = precisionScore; - ConcisenessScore = concisenessScore; + DiscriminationScore = discriminationScore; + IntegrationScore = integrationScore; Justification = justification; NovelMisconceptions = novelMisconceptions; PropositionsCoveredIds = propositionsCoveredIds; MisconceptionsTriggeredIds = misconceptionsTriggeredIds; + RelationsArticulatedIds = relationsArticulatedIds; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs index bbc6a02a0..e416db7c4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs @@ -14,5 +14,7 @@ public ConceptRecordProfile() CreateMap().ReverseMap() .ForMember(d => d.Level, opt => opt.MapFrom(s => s.Level.ToString())); CreateMap().ReverseMap(); + CreateMap().ReverseMap() + .ForMember(d => d.Level, opt => opt.MapFrom(s => s.Level.ToString())); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index c06878c7c..c474ba8ed 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -174,12 +174,20 @@ private async IAsyncEnumerable RunTurnPipelineAsync( var evaluation = evalResult.Value.Evaluation; attempt.AddLearnerTurn(content, evalResult.Value.IsSubstantive, evaluation); - var isCompleted = levelRecord.AreAllPropositionsCovered(attempt); + var isCompleted = levelRecord.IsAttemptComplete(attempt); + var coveredKpIds = attempt.GetCoveredPropositionIds(); + var articulatedRelationIds = attempt.GetArticulatedRelationIds(); var state = new ConversationState { IsCompleted = isCompleted, IsSoftCapReached = attempt.IsSoftCapReached(), - IsHardCapReached = attempt.IsHardCapReached() + IsHardCapReached = attempt.IsHardCapReached(), + UncoveredKeyPropositionIds = levelRecord.KeyPropositions + .Where(kp => !coveredKpIds.Contains(kp.Id)) + .Select(kp => kp.Id).ToList(), + UnarticulatedKeyRelationIds = levelRecord.KeyRelations + .Where(kr => !articulatedRelationIds.Contains(kr.Id)) + .Select(kr => kr.Id).ToList() }; // Partial save: protects against stream interruption diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ConversationState.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ConversationState.cs index 3a582e5bf..2843bca4f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ConversationState.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ConversationState.cs @@ -5,4 +5,6 @@ public class ConversationState public bool IsCompleted { get; set; } public bool IsSoftCapReached { get; set; } public bool IsHardCapReached { get; set; } + public List UncoveredKeyPropositionIds { get; set; } = new(); + public List UnarticulatedKeyRelationIds { get; set; } = new(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs index 270bac3bc..97664403d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs @@ -23,10 +23,16 @@ public async IAsyncEnumerable StreamAsync(TurnEvaluation evaluation, var systemPrompt = DialoguePromptBuilder.BuildSystemPrompt(conceptRecord, state); var messageData = DialoguePromptBuilder.BuildMessages(history); - var evalSummary = $"[Evaluation: correctness={evaluation.CorrectnessScore}, " + - $"completeness={evaluation.CompletenessScore}, " + - $"precision={evaluation.PrecisionScore}, " + - $"conciseness={evaluation.ConcisenessScore}. " + + var summaryParts = new List + { + $"correctness={evaluation.CorrectnessScore}", + $"completeness={evaluation.CompletenessScore}" + }; + if (evaluation.DiscriminationScore.HasValue) + summaryParts.Add($"discrimination={evaluation.DiscriminationScore.Value}"); + if (evaluation.IntegrationScore.HasValue) + summaryParts.Add($"integration={evaluation.IntegrationScore.Value}"); + var evalSummary = $"[Evaluation: {string.Join(", ", summaryParts)}. " + $"Justification: {evaluation.Justification}]"; messageData.Add(("user", evalSummary)); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs index 19795bdf6..4df33b756 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs @@ -39,10 +39,11 @@ public async Task> EvaluateAsync(string content, var evaluation = new TurnEvaluation( parsed.CorrectnessScore, parsed.CompletenessScore, - parsed.PrecisionScore, parsed.ConcisenessScore, + parsed.DiscriminationScore, parsed.IntegrationScore, parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, parsed.PropositionsCoveredIds ?? new List(), - parsed.MisconceptionsTriggeredIds ?? new List()); + parsed.MisconceptionsTriggeredIds ?? new List(), + parsed.RelationsArticulatedIds ?? new List()); return Result.Ok(new EvaluationResult(evaluation, parsed.IsSubstantive)); } @@ -67,11 +68,12 @@ private class EvaluationResponse { public int CorrectnessScore { get; set; } public int CompletenessScore { get; set; } - public int PrecisionScore { get; set; } - public int ConcisenessScore { get; set; } + public int? DiscriminationScore { get; set; } + public int? IntegrationScore { get; set; } public string? Justification { get; set; } public List? PropositionsCoveredIds { get; set; } public List? MisconceptionsTriggeredIds { get; set; } + public List? RelationsArticulatedIds { get; set; } public string? NovelMisconceptions { get; set; } public bool IsSubstantive { get; set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs index 49c86ef49..978470c03 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs @@ -31,9 +31,22 @@ public static string BuildSystemPrompt(ConceptRecord record, ConversationState s sb.AppendLine($"- [KP-{kp.Id}] {kp.Statement}"); sb.AppendLine(); + if (record.KeyRelations.Any()) + { + sb.AppendLine("## Key Relations (for your reference only, never reveal the mechanism text):"); + var kpById = record.KeyPropositions.ToDictionary(kp => kp.Id, kp => kp.Statement); + foreach (var kr in record.KeyRelations) + { + var sourceText = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, $"KP-{kr.SourceKeyPropositionId}"); + var targetText = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, $"KP-{kr.TargetKeyPropositionId}"); + sb.AppendLine($"- [KR-{kr.Id}] {sourceText} → {targetText}. Mechanism: {kr.Mechanism}"); + } + sb.AppendLine(); + } + if (state.IsCompleted) { - sb.AppendLine("## The learner has covered all required propositions."); + sb.AppendLine("## The learner has covered all required propositions and articulated all required relations."); sb.AppendLine("Provide a brief closing acknowledgment. Do not ask more questions."); } else if (state.IsHardCapReached) @@ -41,10 +54,24 @@ public static string BuildSystemPrompt(ConceptRecord record, ConversationState s sb.AppendLine("## The conversation has reached its maximum length."); sb.AppendLine("Provide a brief closing summary. Do not ask more questions."); } - else if (state.IsSoftCapReached) + else { - sb.AppendLine("## The learner is approaching the end of the conversation."); - sb.AppendLine("Suggest wrapping up. Focus on the most important uncovered proposition."); + if (state.UncoveredKeyPropositionIds.Any() || state.UnarticulatedKeyRelationIds.Any()) + { + sb.AppendLine("## Focus areas for the next question:"); + if (state.UncoveredKeyPropositionIds.Any()) + sb.AppendLine($"- Uncovered key propositions: {string.Join(", ", state.UncoveredKeyPropositionIds.Select(id => $"KP-{id}"))}"); + if (state.UnarticulatedKeyRelationIds.Any()) + sb.AppendLine($"- Unarticulated key relations: {string.Join(", ", state.UnarticulatedKeyRelationIds.Select(id => $"KR-{id}"))}"); + sb.AppendLine("Pick the most important gap and probe it. Never reveal the underlying statement or mechanism text."); + sb.AppendLine(); + } + + if (state.IsSoftCapReached) + { + sb.AppendLine("## The learner is approaching the end of the conversation."); + sb.AppendLine("Suggest wrapping up. Focus on the most important uncovered gap above."); + } } return sb.ToString(); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs index 4b6bf9a1b..bd0895207 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs @@ -8,6 +8,10 @@ public static class EvaluationPromptBuilder { public static string BuildSystemPrompt(ConceptRecord record) { + var hasBoundaryConditions = record.BoundaryConditions.Any(); + var hasCommonMisconceptions = record.CommonMisconceptions.Any(); + var hasKeyRelations = record.KeyRelations.Any(); + var sb = new StringBuilder(); sb.AppendLine("You are an evaluation agent for a Socratic tutoring system."); sb.AppendLine("Your task: evaluate the learner's latest response against the concept rubric below."); @@ -21,39 +25,67 @@ public static string BuildSystemPrompt(ConceptRecord record) sb.AppendLine($"- [KP-{kp.Id}] {kp.Statement}"); sb.AppendLine(); - sb.AppendLine("## Boundary Conditions:"); - foreach (var bc in record.BoundaryConditions) - sb.AppendLine($"- [BC-{bc.Id}] {bc.Statement}"); - sb.AppendLine(); + if (hasBoundaryConditions) + { + sb.AppendLine("## Boundary Conditions:"); + foreach (var bc in record.BoundaryConditions) + sb.AppendLine($"- [BC-{bc.Id}] {bc.Statement}"); + sb.AppendLine(); + } - sb.AppendLine("## Common Misconceptions:"); - foreach (var cm in record.CommonMisconceptions.Take(8)) - sb.AppendLine($"- [CM-{cm.Id}] {cm.Description} → Correction: {cm.Correction}"); - sb.AppendLine(); + if (hasCommonMisconceptions) + { + sb.AppendLine("## Common Misconceptions:"); + foreach (var cm in record.CommonMisconceptions.Take(8)) + sb.AppendLine($"- [CM-{cm.Id}] {cm.Description} → Correction: {cm.Correction}"); + sb.AppendLine(); + } + + if (hasKeyRelations) + { + sb.AppendLine("## Key Relations:"); + var kpById = record.KeyPropositions.ToDictionary(kp => kp.Id, kp => kp.Statement); + foreach (var kr in record.KeyRelations) + { + var sourceText = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, $"KP-{kr.SourceKeyPropositionId}"); + var targetText = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, $"KP-{kr.TargetKeyPropositionId}"); + sb.AppendLine($"- [KR-{kr.Id}] {sourceText} → {targetText}. Mechanism: {kr.Mechanism}"); + } + sb.AppendLine(); + } sb.AppendLine("## Scoring Rules:"); - sb.AppendLine("- Correctness (1-3): Are stated claims true? Check against KPs and BCs."); + var correctnessLine = hasBoundaryConditions + ? "- Correctness (1-3): Are stated claims true? Check against KPs and BCs." + : "- Correctness (1-3): Are stated claims true? Check against KPs."; + sb.AppendLine(correctnessLine); sb.AppendLine("- Completeness (1-3): Are essential KPs covered?"); - sb.AppendLine("- Precision (1-3): Does explanation exclude what it should? Check BCs."); - sb.AppendLine("- Conciseness (1-3): Unnecessary material, hedging, redundancy?"); + if (hasBoundaryConditions) + sb.AppendLine("- Discrimination (1-3): Does the explanation correctly exclude non-examples? Check BCs."); + if (hasKeyRelations) + sb.AppendLine("- Integration (1-3): Did the learner articulate the key relations *with mechanism*? Score 1 if no relation articulated, 2 if relations mentioned without mechanism, 3 if relations articulated with explicit mechanism matching the authored description."); sb.AppendLine("- Evaluate concepts, not language. Grammar and style must not reduce scores."); sb.AppendLine("- Resist sycophancy. Evaluate strictly against rubric."); sb.AppendLine(); sb.AppendLine("## Output Format (JSON only, no other text):"); - sb.AppendLine(""" -{ - "correctnessScore": 1-3, - "completenessScore": 1-3, - "precisionScore": 1-3, - "concisenessScore": 1-3, - "justification": "brief explanation of scores", - "propositionsCoveredIds": [list of KP IDs covered in this turn], - "misconceptionsTriggeredIds": [list of CM IDs triggered], - "novelMisconceptions": "any misconceptions not in the list, or null", - "isSubstantive": true/false -} -"""); + sb.AppendLine("{"); + sb.AppendLine(" \"correctnessScore\": 1-3,"); + sb.AppendLine(" \"completenessScore\": 1-3,"); + if (hasBoundaryConditions) + sb.AppendLine(" \"discriminationScore\": 1-3,"); + if (hasKeyRelations) + sb.AppendLine(" \"integrationScore\": 1-3,"); + sb.AppendLine(" \"justification\": \"brief explanation of scores\","); + sb.AppendLine(" \"propositionsCoveredIds\": [list of KP IDs covered in this turn],"); + if (hasCommonMisconceptions) + sb.AppendLine(" \"misconceptionsTriggeredIds\": [list of CM IDs triggered],"); + if (hasKeyRelations) + sb.AppendLine(" \"relationsArticulatedIds\": [list of KR IDs articulated with mechanism this turn],"); + if (hasCommonMisconceptions) + sb.AppendLine(" \"novelMisconceptions\": \"any misconceptions not in the list, or null\","); + sb.AppendLine(" \"isSubstantive\": true/false"); + sb.AppendLine("}"); return sb.ToString(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs index 2d4af6bcf..47a3c796f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -11,6 +11,7 @@ public class ElaborationsContext : DbContext public DbSet KeyPropositions { get; set; } public DbSet BoundaryConditions { get; set; } public DbSet CommonMisconceptions { get; set; } + public DbSet KeyRelations { get; set; } public DbSet ElaborationTasks { get; set; } public DbSet ConversationAttempts { get; set; } public DbSet ConversationTurns { get; set; } @@ -43,6 +44,11 @@ private static void ConfigureConceptRecords(ModelBuilder modelBuilder) .HasMany(cr => cr.CommonMisconceptions) .WithOne() .HasForeignKey(cm => cm.ConceptRecordId); + + modelBuilder.Entity() + .HasMany(cr => cr.KeyRelations) + .WithOne() + .HasForeignKey(kr => kr.ConceptRecordId); } private static void ConfigureElaborationTasks(ModelBuilder modelBuilder) @@ -81,6 +87,8 @@ private static void ConfigureConversations(ModelBuilder modelBuilder) .HasColumnType("jsonb"); entity.Property(te => te.MisconceptionsTriggeredIds) .HasColumnType("jsonb"); + entity.Property(te => te.RelationsArticulatedIds) + .HasColumnType("jsonb"); }); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs index f0ac24f5a..9264f0d96 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs @@ -15,6 +15,7 @@ public ConceptRecordDatabaseRepository(ElaborationsContext dbContext) : base(dbC .Include(cr => cr.KeyPropositions.OrderBy(kp => kp.Order)) .Include(cr => cr.BoundaryConditions.OrderBy(bc => bc.Order)) .Include(cr => cr.CommonMisconceptions.OrderBy(cm => cm.Order)) + .Include(cr => cr.KeyRelations.OrderBy(kr => kr.Order)) .FirstOrDefault(cr => cr.Id == id); } @@ -24,6 +25,7 @@ public List GetByCourse(int courseId) .Include(cr => cr.KeyPropositions.OrderBy(kp => kp.Order)) .Include(cr => cr.BoundaryConditions.OrderBy(bc => bc.Order)) .Include(cr => cr.CommonMisconceptions.OrderBy(cm => cm.Order)) + .Include(cr => cr.KeyRelations.OrderBy(kr => kr.Order)) .Where(cr => cr.CourseId == courseId) .ToList(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs index ac91ee069..837de7987 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs @@ -50,21 +50,30 @@ public void SetupDefaultMocks() SetupSummaryMock(); } - public void SetupEvaluationMock(List? propositionsCoveredIds = null, bool isSubstantive = true) + public void SetupEvaluationMock(List? propositionsCoveredIds = null, + List? relationsArticulatedIds = null, + int? discriminationScore = 2, int? integrationScore = null, + bool isSubstantive = true) { var coveredIds = propositionsCoveredIds != null && propositionsCoveredIds.Count > 0 ? string.Join(",", propositionsCoveredIds) : ""; + var articulatedIds = relationsArticulatedIds != null && relationsArticulatedIds.Count > 0 + ? string.Join(",", relationsArticulatedIds) + : ""; + var discriminationJson = discriminationScore.HasValue ? discriminationScore.Value.ToString() : "null"; + var integrationJson = integrationScore.HasValue ? integrationScore.Value.ToString() : "null"; var evalJson = $$""" { "correctnessScore": 2, "completenessScore": 2, - "precisionScore": 2, - "concisenessScore": 2, + "discriminationScore": {{discriminationJson}}, + "integrationScore": {{integrationJson}}, "justification": "Good explanation of the concept.", "propositionsCoveredIds": [{{coveredIds}}], "misconceptionsTriggeredIds": [], + "relationsArticulatedIds": [{{articulatedIds}}], "novelMisconceptions": null, "isSubstantive": {{isSubstantive.ToString().ToLower()}} } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs index bd39ae30e..0bf1a15be 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs @@ -36,7 +36,8 @@ public void Creates() CommonMisconceptions = new List { new() { Description = "A misconception", Correction = "The correction", Order = 1 } - } + }, + KeyRelations = new List() }; dbContext.Database.BeginTransaction(); @@ -51,6 +52,7 @@ public void Creates() result.KeyPropositions[0].Level.ShouldBe("Beginner"); result.BoundaryConditions.Count.ShouldBe(1); result.CommonMisconceptions.Count.ShouldBe(1); + result.KeyRelations.Count.ShouldBe(0); } [Fact] @@ -70,7 +72,8 @@ public void Updates() new() { Statement = "Updated proposition", Level = "Beginner", Order = 1 } }, BoundaryConditions = new List(), - CommonMisconceptions = new List() + CommonMisconceptions = new List(), + KeyRelations = new List() }; dbContext.Database.BeginTransaction(); @@ -85,6 +88,54 @@ public void Updates() result.KeyPropositions[0].Statement.ShouldBe("Updated proposition"); } + [Fact] + public void Updates_relations_round_trip() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + var dbContext = scope.ServiceProvider.GetRequiredService(); + // CR -5 has KPs -50 and -51 already; add a second relation alongside the seeded -100. + var updatedEntity = new ConceptRecordDto + { + Id = -5, + CourseId = -1, + Title = "Polymorphism Mechanics", + CanonicalDefinition = "Polymorphism resolves method calls at runtime via dynamic dispatch.", + KeyPropositions = new List + { + new() { Id = -50, Statement = "A subclass can override a parent method", Level = "Beginner", Order = 1 }, + new() { Id = -51, Statement = "The runtime selects the implementation by the actual type", Level = "Beginner", Order = 2 } + }, + BoundaryConditions = new List(), + CommonMisconceptions = new List(), + KeyRelations = new List + { + new() + { + SourceKeyPropositionId = -50, TargetKeyPropositionId = -51, + Mechanism = "Override matters because dispatch happens at runtime", + Level = "Beginner", Order = 1 + }, + new() + { + SourceKeyPropositionId = -51, TargetKeyPropositionId = -50, + Mechanism = "Runtime type lookup is what makes the override observable", + Level = "Beginner", Order = 2 + } + } + }; + dbContext.Database.BeginTransaction(); + + var actionResult = controller.Update(-1, -5, updatedEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.KeyRelations.Count.ShouldBe(2); + result.KeyRelations[0].Mechanism.ShouldContain("dispatch happens at runtime"); + result.KeyRelations[0].Level.ShouldBe("Beginner"); + } + [Fact] public void Deletes() { @@ -127,7 +178,8 @@ public void Non_owner_fails_to_create() CanonicalDefinition = "Fail", KeyPropositions = new List(), BoundaryConditions = new List(), - CommonMisconceptions = new List() + CommonMisconceptions = new List(), + KeyRelations = new List() }; var actionResult = controller.Create(-2, newEntity).Result; @@ -150,7 +202,8 @@ public void Non_owner_fails_to_update() CanonicalDefinition = "Fail", KeyPropositions = new List(), BoundaryConditions = new List(), - CommonMisconceptions = new List() + CommonMisconceptions = new List(), + KeyRelations = new List() }; var actionResult = controller.Update(-2, -3, updatedEntity).Result; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs index 888307e2d..f1083a011 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs @@ -42,7 +42,30 @@ public void Gets_by_course() var result = (actionResult as OkObjectResult)?.Value as List; result.ShouldNotBeNull(); - result.Count.ShouldBe(3); + result.Count.ShouldBe(4); + var withRelations = result.SingleOrDefault(cr => cr.Id == -5); + withRelations.ShouldNotBeNull(); + withRelations.KeyRelations.Count.ShouldBe(1); + withRelations.KeyRelations[0].SourceKeyPropositionId.ShouldBe(-50); + withRelations.KeyRelations[0].TargetKeyPropositionId.ShouldBe(-51); + withRelations.KeyRelations[0].Mechanism.ShouldContain("dispatch happens at runtime"); + } + + [Fact] + public void Gets_record_with_relations() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + + var actionResult = controller.Get(-1, -5).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + + result.ShouldNotBeNull(); + result.KeyPropositions.Count.ShouldBe(2); + result.BoundaryConditions.Count.ShouldBe(0); + result.CommonMisconceptions.Count.ShouldBe(0); + result.KeyRelations.Count.ShouldBe(1); + result.KeyRelations[0].Level.ShouldBe("Beginner"); } [Fact] diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs index e3f62bc96..eba359de3 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -280,6 +280,57 @@ public async Task Submit_wrong_learner_fails() error.GetProperty("code").GetInt32().ShouldBe(403); } + [Fact] + public async Task Concept_record_with_relations_completes_when_relations_articulated() + { + // Task -7 → CR -5 (KPs -50, -51 + KR -100). Strict completion: covering both KPs is not enough. + Factory.MockChatService.Reset(); + Factory.SetupEvaluationMock( + propositionsCoveredIds: [-50, -51], + relationsArticulatedIds: [-100], + integrationScore: 3); + Factory.SetupDialogueMock(); + Factory.SetupSummaryMock("Polymorphism mechanics summary."); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-3"); + var dto = new SubmitTurnRequestDto + { + Content = "Override matters because the runtime picks the actual type's implementation." + }; + + var tokens = await CollectStreamAsync(controller.StartConversation(-7, dto, CancellationToken.None)); + + var metadata = JsonSerializer.Deserialize(tokens.Last()); + metadata.ShouldNotBeNull(); + metadata.Status.ShouldBe("Completed"); + metadata.Summary.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public async Task Concept_record_with_relations_does_not_complete_when_only_KPs_covered() + { + // Task -7 → CR -5. Covering KPs but NOT articulating the relation should NOT complete. + Factory.MockChatService.Reset(); + Factory.SetupEvaluationMock( + propositionsCoveredIds: [-50, -51], + relationsArticulatedIds: [], + integrationScore: 1); + Factory.SetupDialogueMock(); + Factory.SetupSummaryMock(); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-3"); + var dto = new SubmitTurnRequestDto + { + Content = "Override is a thing and runtime types exist, but I won't say how they connect." + }; + + var tokens = await CollectStreamAsync(controller.StartConversation(-7, dto, CancellationToken.None)); + + var metadata = JsonSerializer.Deserialize(tokens.Last()); + metadata.ShouldNotBeNull(); + metadata.Status.ShouldBe("InProgress"); + } + [Fact] public async Task Submit_completed_attempt_fails() { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql index ae6fed925..c2121038a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql @@ -36,3 +36,15 @@ VALUES (-3, -2, 'Polymorphism', 'Polymorphism enables objects to be treated as i INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") VALUES (-31, -3, 'Objects can take multiple forms', 0, 1); + +-- ConceptRecord -5: "Polymorphism Mechanics" (Course -1, KPs + KR only — exercises relations and minimal-prompt path) +INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") +VALUES (-5, -1, 'Polymorphism Mechanics', 'Polymorphism resolves method calls at runtime via dynamic dispatch.'); + +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") +VALUES (-50, -5, 'A subclass can override a parent method', 0, 1); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") +VALUES (-51, -5, 'The runtime selects the implementation by the actual type', 0, 2); + +INSERT INTO elaborations."KeyRelations"("Id", "ConceptRecordId", "SourceKeyPropositionId", "TargetKeyPropositionId", "Mechanism", "Level", "Order") +VALUES (-100, -5, -50, -51, 'Override matters because dispatch happens at runtime, not compile time', 0, 1); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql index a588fa704..a6c4f3bc4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql @@ -21,3 +21,7 @@ VALUES (-5, -1, -2, 1, 2); -- Task -6: Encapsulation at Advanced, Unit -2 (isolated for Start+Submit flow test) INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") VALUES (-6, -1, -2, 2, 3); + +-- Task -7: Polymorphism Mechanics (CR -5) at Beginner, Unit -2 (isolated, has KeyRelation -100) +INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") +VALUES (-7, -5, -2, 0, 4); 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 index c9adc51e6..8408c8667 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -9,10 +9,10 @@ VALUES (-2, -1, 1, 'Good start! Can you tell me more about access modifiers?', t INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") VALUES (-3, -1, 0, 'Access modifiers like public and private control visibility.', true, 2, '2024-06-01 10:02:00+00'); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-1, -1, 2, 2, 2, 2, 'Accurate basic description', null, '[-11]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-3, -3, 2, 2, 2, 2, 'Good description of access modifiers', null, '[-12]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-1, -1, 2, 2, 2, null, 'Accurate basic description', null, '[-11]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-3, -3, 2, 2, 2, null, 'Good description of access modifiers', null, '[-12]'::jsonb, '[]'::jsonb, '[]'::jsonb); -- Attempt -2: Learner -2, Task -1, Abandoned (for query tests) INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -27,8 +27,8 @@ VALUES (-4, -3, 0, 'Encapsulation is about data hiding.', true, 0, '2024-06-03 1 INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', true, 1, '2024-06-03 10:01:05+00'); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-4, -4, 1, 1, 1, 2, 'Partially correct but incomplete', null, '[]'::jsonb, '[-11]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-4, -4, 1, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '[-11]'::jsonb, '[]'::jsonb); -- Attempt -4: Learner -3, Task -2, InProgress (for completion test: KP -11 already covered, submit to cover -12) INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -39,8 +39,8 @@ VALUES (-6, -4, 0, 'Encapsulation bundles data and methods together.', true, 0, INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") VALUES (-7, -4, 1, 'Good. What about access control?', true, 1, '2024-06-04 10:01:05+00'); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-6, -6, 2, 2, 2, 2, 'Covers bundling proposition', null, '[-11]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-6, -6, 2, 2, 2, null, 'Covers bundling proposition', null, '[-11]'::jsonb, '[]'::jsonb, '[]'::jsonb); -- Attempt -5: Learner -2, Task -2, InProgress with 9 learner + 9 system turns (for hard cap test) INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -84,24 +84,24 @@ INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Rol VALUES (-67, -5, 1, 'Response 9', true, 17, '2024-06-05 10:09:05+00'); -- Evaluations for the 9 learner turns (all with empty propositions - never completes) -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-50, -50, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-52, -52, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-54, -54, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-56, -56, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-58, -58, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-60, -60, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-62, -62, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-64, -64, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-66, -66, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-50, -50, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-52, -52, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-54, -54, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-56, -56, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-58, -58, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-60, -60, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-62, -62, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-64, -64, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-66, -66, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -- Attempt -6: Learner -3, Task -3, InProgress with 5 substantive learner + 5 system turns (for soft cap test) INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -128,16 +128,16 @@ VALUES (-78, -6, 0, 'Turn 5', true, 8, '2024-06-06 10:05:00+00'); INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") VALUES (-79, -6, 1, 'Response 5', true, 9, '2024-06-06 10:05:05+00'); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-70, -70, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-72, -72, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-74, -74, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-76, -76, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "PrecisionScore", "ConcisenessScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds") -VALUES (-78, -78, 1, 1, 1, 1, 'Vague', null, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-70, -70, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-72, -72, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-74, -74, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-76, -76, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-78, -78, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -- Attempt -7: Learner -3, Task -5, InProgress (isolated for abandon test — no other test touches this) INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs new file mode 100644 index 000000000..4280db5cd --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs @@ -0,0 +1,153 @@ +using System.Reflection; +using Shouldly; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; +using Xunit; + +namespace Tutor.Elaborations.Tests.Unit; + +public class ConceptRecordTests +{ + [Fact] + public void DeriveForLevel_filters_relations_whose_endpoints_are_above_level() + { + var beginnerKp = MakeKp(1, PropositionLevel.Beginner); + var advancedKp = MakeKp(2, PropositionLevel.Advanced); + var record = MakeRecord( + kps: [beginnerKp, advancedKp], + relations: [MakeRelation(10, beginnerKp.Id, advancedKp.Id, PropositionLevel.Beginner)]); + + var derived = record.DeriveForLevel(PropositionLevel.Beginner); + + derived.KeyPropositions.Count.ShouldBe(1); + derived.KeyPropositions[0].Id.ShouldBe(beginnerKp.Id); + derived.KeyRelations.Count.ShouldBe(0, + "relation references an Advanced KP that was filtered out"); + } + + [Fact] + public void DeriveForLevel_filters_relations_above_level_even_when_endpoints_survive() + { + var kp1 = MakeKp(1, PropositionLevel.Beginner); + var kp2 = MakeKp(2, PropositionLevel.Beginner); + var record = MakeRecord( + kps: [kp1, kp2], + relations: [MakeRelation(10, kp1.Id, kp2.Id, PropositionLevel.Advanced)]); + + var derived = record.DeriveForLevel(PropositionLevel.Beginner); + + derived.KeyPropositions.Count.ShouldBe(2); + derived.KeyRelations.Count.ShouldBe(0, + "relation itself is Advanced and should be filtered out"); + } + + [Fact] + public void DeriveForLevel_keeps_relations_at_or_below_level_with_surviving_endpoints() + { + var kp1 = MakeKp(1, PropositionLevel.Beginner); + var kp2 = MakeKp(2, PropositionLevel.Beginner); + var record = MakeRecord( + kps: [kp1, kp2], + relations: [MakeRelation(10, kp1.Id, kp2.Id, PropositionLevel.Beginner)]); + + var derived = record.DeriveForLevel(PropositionLevel.Beginner); + + derived.KeyRelations.Count.ShouldBe(1); + derived.KeyRelations[0].Id.ShouldBe(10); + } + + [Fact] + public void IsAttemptComplete_returns_true_when_no_relations_and_all_KPs_covered() + { + var kp = MakeKp(1, PropositionLevel.Beginner); + var record = MakeRecord(kps: [kp], relations: []); + var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpIds: [1], articulatedRelationIds: []); + + record.IsAttemptComplete(attempt).ShouldBeTrue(); + } + + [Fact] + public void IsAttemptComplete_returns_false_when_relations_exist_but_not_articulated() + { + var kp1 = MakeKp(1, PropositionLevel.Beginner); + var kp2 = MakeKp(2, PropositionLevel.Beginner); + var record = MakeRecord( + kps: [kp1, kp2], + relations: [MakeRelation(10, 1, 2, PropositionLevel.Beginner)]); + var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpIds: [1, 2], articulatedRelationIds: []); + + record.IsAttemptComplete(attempt).ShouldBeFalse(); + } + + [Fact] + public void IsAttemptComplete_returns_true_when_all_KPs_covered_and_all_relations_articulated() + { + var kp1 = MakeKp(1, PropositionLevel.Beginner); + var kp2 = MakeKp(2, PropositionLevel.Beginner); + var record = MakeRecord( + kps: [kp1, kp2], + relations: [MakeRelation(10, 1, 2, PropositionLevel.Beginner)]); + var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpIds: [1, 2], articulatedRelationIds: [10]); + + record.IsAttemptComplete(attempt).ShouldBeTrue(); + } + + private static KeyProposition MakeKp(int id, PropositionLevel level) + { + var kp = new KeyProposition(); + SetProp(kp, "Id", id); + SetProp(kp, "Level", level); + return kp; + } + + private static KeyRelation MakeRelation(int id, int sourceKpId, int targetKpId, PropositionLevel level) + { + var kr = new KeyRelation(); + SetProp(kr, "Id", id); + SetProp(kr, "SourceKeyPropositionId", sourceKpId); + SetProp(kr, "TargetKeyPropositionId", targetKpId); + SetProp(kr, "Level", level); + return kr; + } + + private static ConceptRecord MakeRecord(List kps, List relations) + { + var record = new ConceptRecord(); + SetProp(record, "KeyPropositions", kps); + SetProp(record, "BoundaryConditions", new List()); + SetProp(record, "CommonMisconceptions", new List()); + SetProp(record, "KeyRelations", relations); + return record; + } + + private static ConversationAttempt MakeAttemptWithCoveredAndArticulated( + List coveredKpIds, List articulatedRelationIds) + { + var ctor = typeof(ConversationAttempt) + .GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, Type.EmptyTypes)!; + var attempt = (ConversationAttempt)ctor.Invoke(null); + + var evalCtor = typeof(TurnEvaluation).GetConstructors().First(c => c.GetParameters().Length > 0); + var evaluation = (TurnEvaluation)evalCtor.Invoke([ + 2, 2, (int?)null, (int?)null, "test", null, + coveredKpIds, new List(), articulatedRelationIds + ]); + + var turnCtor = typeof(ConversationTurn).GetConstructors( + BindingFlags.NonPublic | BindingFlags.Instance) + .First(c => c.GetParameters().Length > 0); + var turn = (ConversationTurn)turnCtor.Invoke([TurnRole.Learner, "x", true, 0, evaluation]); + + SetProp(attempt, "Turns", new List { turn }); + return attempt; + } + + private static void SetProp(object instance, string propName, object? value) + { + var prop = instance.GetType().GetProperty(propName, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (prop == null) + throw new InvalidOperationException($"Property {propName} not found on {instance.GetType().Name}"); + prop.SetValue(instance, value); + } +} From 0f6d9e3828cb15006834ca447512f74392a0938c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Fri, 10 Apr 2026 09:32:13 +0200 Subject: [PATCH 08/51] fix: Resolves issue with elaboration task authoring and missing data for elaboration task title. --- .../Authoring/ElaborationTaskService.cs | 37 +++++++++++- .../UseCases/Learning/ConversationService.cs | 56 +++++++++++++++---- 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs index 192e9594b..a25ffbb5d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs @@ -4,6 +4,7 @@ using Tutor.Elaborations.API.Dtos.Conversations; using Tutor.Elaborations.API.Public; using Tutor.Elaborations.API.Public.Authoring; +using Tutor.Elaborations.Core.Domain.ConceptRecords; using Tutor.Elaborations.Core.Domain.ElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Authoring; @@ -11,13 +12,16 @@ namespace Tutor.Elaborations.Core.UseCases.Authoring; public class ElaborationTaskService : CrudService, IElaborationTaskService { private readonly IElaborationTaskRepository _taskRepository; + private readonly IConceptRecordRepository _conceptRecordRepository; private readonly IAccessServices _accessServices; public ElaborationTaskService(IElaborationTaskRepository taskRepository, + IConceptRecordRepository conceptRecordRepository, IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, IMapper mapper) : base(taskRepository, unitOfWork, mapper) { _taskRepository = taskRepository; + _conceptRecordRepository = conceptRecordRepository; _accessServices = accessServices; } @@ -27,7 +31,21 @@ public Result> GetByUnit(int unitId, int instructorId) return Result.Fail(FailureCode.Forbidden); var tasks = _taskRepository.GetByUnit(unitId); - return MapToDto(tasks); + + return Result.Ok(MapToDtos(tasks)); + } + + private List MapToDtos(List tasks) + { + var taskDtos = tasks.Select(MapToDto).ToList(); + + var crIds = taskDtos.Select(t => t.ConceptRecordId).Distinct().ToList(); + var titleMap = _conceptRecordRepository.GetMany(crIds) + .ToDictionary(cr => cr.Id, cr => cr.Title); + foreach (var dto in taskDtos) + if (titleMap.TryGetValue(dto.ConceptRecordId, out var title)) + dto.ConceptRecordTitle = title; + return taskDtos; } public Result Create(ElaborationTaskDto task, int instructorId) @@ -35,6 +53,9 @@ public Result Create(ElaborationTaskDto task, int instructor if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) return Result.Fail(FailureCode.Forbidden); + var validation = ValidateConceptRecordOwnership(task.ConceptRecordId, instructorId); + if (validation.IsFailed) return validation.ToResult(); + return Create(task); } @@ -42,6 +63,10 @@ public Result Update(ElaborationTaskDto task, int instructor { if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) return Result.Fail(FailureCode.Forbidden); + + var validation = ValidateConceptRecordOwnership(task.ConceptRecordId, instructorId); + if (validation.IsFailed) return validation.ToResult(); + var existing = _taskRepository.Get(task.Id); if (existing == null || existing.UnitId != task.UnitId) return Result.Fail(FailureCode.NotFound); @@ -58,4 +83,14 @@ public Result Delete(int id, int unitId, int instructorId) return Result.Fail(FailureCode.NotFound); return Delete(id); } + + private Result ValidateConceptRecordOwnership(int conceptRecordId, int instructorId) + { + var conceptRecord = _conceptRecordRepository.Get(conceptRecordId); + if (conceptRecord == null) + return Result.Fail(FailureCode.NotFound); + if (!_accessServices.IsCourseOwner(conceptRecord.CourseId, instructorId)) + return Result.Fail(FailureCode.NotFound); + return Result.Ok(); + } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index c474ba8ed..ea6e741ec 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -50,14 +50,28 @@ public Result> GetTasksForUnit(int unitId, int learnerI return Result.Fail(FailureCode.Forbidden); var tasks = _taskRepo.GetByUnit(unitId); - var taskDtos = tasks.Select(t => _mapper.Map(t)).ToList(); - var taskIds = taskDtos.Select(t => t.Id).ToList(); + + return Result.Ok(PopulateDtos(learnerId, tasks)); + } + + private List PopulateDtos(int learnerId, List tasks) + { + var taskIds = tasks.Select(t => t.Id).ToList(); var completedTaskIds = _attemptRepo.GetTaskIdsWithCompletedAttempts(taskIds, learnerId); + var crIds = tasks.Select(t => t.ConceptRecordId).Distinct().ToList(); + var titleMap = _conceptRecordRepo.GetMany(crIds) + .ToDictionary(cr => cr.Id, cr => cr.Title); + + var taskDtos = tasks.Select(t => _mapper.Map(t)).ToList(); foreach (var dto in taskDtos) + { dto.HasCompletedAttempt = completedTaskIds.Contains(dto.Id); + if (titleMap.TryGetValue(dto.ConceptRecordId, out var title)) + dto.ConceptRecordTitle = title; + } - return Result.Ok(taskDtos); + return taskDtos; } public Result GetTaskDetail(int taskId, int learnerId) @@ -91,7 +105,10 @@ public async IAsyncEnumerable StartConversationAsync(int taskId, string if (task == null) { yield return BuildErrorChunk("Task not found.", 404); yield break; } if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) - { yield return BuildErrorChunk("Not enrolled in unit.", 403); yield break; } + { + yield return BuildErrorChunk("Not enrolled in unit.", 403); + yield break; + } var conceptRecord = _conceptRecordRepo.Get(task.ConceptRecordId); if (conceptRecord == null) { yield return BuildErrorChunk("Concept record not found.", 404); yield break; } @@ -99,16 +116,25 @@ public async IAsyncEnumerable StartConversationAsync(int taskId, string var balanceCheck = _tokenSpendingService.HasSufficientBalanceForUnit( learnerId, task.UnitId, content.Length); if (balanceCheck.IsFailed) - { yield return BuildErrorChunk("Insufficient token balance. Contact your administrator.", 402); yield break; } + { + yield return BuildErrorChunk("Insufficient token balance. Contact your administrator.", 402); + yield break; + } var existing = _attemptRepo.GetActiveAttempt(taskId, learnerId); if (existing != null) - { yield return BuildErrorChunk("An active conversation already exists.", 409, existing.Id); yield break; } + { + 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; } + { + yield return BuildErrorChunk("You've practiced this concept recently. Come back tomorrow for another attempt.", 429); + yield break; + } var attempt = new ConversationAttempt(taskId, learnerId); _attemptRepo.Create(attempt); @@ -131,15 +157,25 @@ public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string cont if (task == null) { yield return BuildErrorChunk("Task not found.", 404); yield break; } if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) - { yield return BuildErrorChunk("Not enrolled in unit.", 403); yield break; } + { + yield return BuildErrorChunk("Not enrolled in unit.", 403); + yield break; + } var conceptRecord = _conceptRecordRepo.Get(task.ConceptRecordId); - if (conceptRecord == null) { yield return BuildErrorChunk("Concept record not found.", 404); yield break; } + if (conceptRecord == null) + { + yield return BuildErrorChunk("Concept record not found.", 404); + yield break; + } var balanceCheck = _tokenSpendingService.HasSufficientBalanceForUnit( learnerId, task.UnitId, content.Length); if (balanceCheck.IsFailed) - { yield return BuildErrorChunk("Insufficient token balance. Contact your administrator.", 402); yield break; } + { + yield return BuildErrorChunk("Insufficient token balance. Contact your administrator.", 402); + yield break; + } var levelRecord = conceptRecord.DeriveForLevel(task.ExpectedLevel); From dc469e02f712962b746c03c934bf8c768f7f2450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Fri, 10 Apr 2026 10:12:14 +0200 Subject: [PATCH 09/51] fix: Removes order from ConceptRecord child entities. --- .../ConceptRecords/BoundaryConditionDto.cs | 1 - .../ConceptRecords/CommonMisconceptionDto.cs | 1 - .../Dtos/ConceptRecords/KeyPropositionDto.cs | 1 - .../Dtos/ConceptRecords/KeyRelationDto.cs | 1 - .../ConceptRecords/BoundaryCondition.cs | 1 - .../ConceptRecords/CommonMisconception.cs | 1 - .../Domain/ConceptRecords/ConceptRecord.cs | 11 ++-- .../Domain/ConceptRecords/KeyProposition.cs | 1 - .../Domain/ConceptRecords/KeyRelation.cs | 1 - .../ConceptRecordDatabaseRepository.cs | 16 +++--- .../Authoring/ConceptRecordCommandTests.cs | 18 +++---- .../Authoring/ConceptRecordQueryTests.cs | 3 +- .../TestData/c-concept-records.sql | 52 +++++++++---------- 13 files changed, 48 insertions(+), 60 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/BoundaryConditionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/BoundaryConditionDto.cs index c989c0075..4b197bdeb 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/BoundaryConditionDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/BoundaryConditionDto.cs @@ -5,5 +5,4 @@ public class BoundaryConditionDto public int Id { get; set; } public string Statement { get; set; } = string.Empty; public string Level { get; set; } = string.Empty; - public int Order { get; set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/CommonMisconceptionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/CommonMisconceptionDto.cs index 1cd9cec1b..52165f025 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/CommonMisconceptionDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/CommonMisconceptionDto.cs @@ -5,5 +5,4 @@ public class CommonMisconceptionDto public int Id { get; set; } public string Description { get; set; } = string.Empty; public string Correction { get; set; } = string.Empty; - public int Order { get; set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyPropositionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyPropositionDto.cs index 30c7753d9..5b4d0b70c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyPropositionDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyPropositionDto.cs @@ -5,5 +5,4 @@ public class KeyPropositionDto public int Id { get; set; } public string Statement { get; set; } = string.Empty; public string Level { get; set; } = string.Empty; - public int Order { get; set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs index da587f0c1..777d0d7cb 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs @@ -7,5 +7,4 @@ public class KeyRelationDto public int TargetKeyPropositionId { get; set; } public string Mechanism { get; set; } = string.Empty; public string Level { get; set; } = string.Empty; - public int Order { get; set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs index a57ef20e9..b2fe8cf61 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs @@ -7,5 +7,4 @@ public class BoundaryCondition : Entity public int ConceptRecordId { get; private set; } public string Statement { get; private set; } = string.Empty; public PropositionLevel Level { get; private set; } - public int Order { get; private set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs index f3eb4db94..8132e34bb 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs @@ -7,5 +7,4 @@ public class CommonMisconception : Entity public int ConceptRecordId { get; private set; } public string Description { get; private set; } = string.Empty; public string Correction { get; private set; } = string.Empty; - public int Order { get; private set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs index df1ec584f..8e74290a2 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs @@ -26,8 +26,7 @@ public void Update(ConceptRecord conceptRecord) public ConceptRecord DeriveForLevel(PropositionLevel level) { var filteredKPs = KeyPropositions - .Where(kp => kp.Level <= level) - .OrderBy(kp => kp.Order).ToList(); + .Where(kp => kp.Level <= level).ToList(); var filteredKPIds = filteredKPs.Select(kp => kp.Id).ToHashSet(); return new ConceptRecord @@ -38,15 +37,13 @@ public ConceptRecord DeriveForLevel(PropositionLevel level) CanonicalDefinition = CanonicalDefinition, KeyPropositions = filteredKPs, BoundaryConditions = BoundaryConditions - .Where(bc => bc.Level <= level) - .OrderBy(bc => bc.Order).ToList(), - CommonMisconceptions = CommonMisconceptions - .OrderBy(cm => cm.Order).ToList(), + .Where(bc => bc.Level <= level).ToList(), + CommonMisconceptions = CommonMisconceptions.ToList(), KeyRelations = KeyRelations .Where(kr => kr.Level <= level) .Where(kr => filteredKPIds.Contains(kr.SourceKeyPropositionId) && filteredKPIds.Contains(kr.TargetKeyPropositionId)) - .OrderBy(kr => kr.Order).ToList() + .ToList() }; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs index 0ebe16864..490abe8c6 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs @@ -7,5 +7,4 @@ public class KeyProposition : Entity public int ConceptRecordId { get; private set; } public string Statement { get; private set; } = string.Empty; public PropositionLevel Level { get; private set; } - public int Order { get; private set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs index ce3e7f746..80c4cb812 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs @@ -9,5 +9,4 @@ public class KeyRelation : Entity public int TargetKeyPropositionId { get; private set; } public string Mechanism { get; private set; } = string.Empty; public PropositionLevel Level { get; private set; } - public int Order { get; private set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs index 9264f0d96..723861520 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs @@ -12,20 +12,20 @@ public ConceptRecordDatabaseRepository(ElaborationsContext dbContext) : base(dbC public new ConceptRecord? Get(int id) { return DbContext.ConceptRecords - .Include(cr => cr.KeyPropositions.OrderBy(kp => kp.Order)) - .Include(cr => cr.BoundaryConditions.OrderBy(bc => bc.Order)) - .Include(cr => cr.CommonMisconceptions.OrderBy(cm => cm.Order)) - .Include(cr => cr.KeyRelations.OrderBy(kr => kr.Order)) + .Include(cr => cr.KeyPropositions) + .Include(cr => cr.BoundaryConditions) + .Include(cr => cr.CommonMisconceptions) + .Include(cr => cr.KeyRelations) .FirstOrDefault(cr => cr.Id == id); } public List GetByCourse(int courseId) { return DbContext.ConceptRecords - .Include(cr => cr.KeyPropositions.OrderBy(kp => kp.Order)) - .Include(cr => cr.BoundaryConditions.OrderBy(bc => bc.Order)) - .Include(cr => cr.CommonMisconceptions.OrderBy(cm => cm.Order)) - .Include(cr => cr.KeyRelations.OrderBy(kr => kr.Order)) + .Include(cr => cr.KeyPropositions) + .Include(cr => cr.BoundaryConditions) + .Include(cr => cr.CommonMisconceptions) + .Include(cr => cr.KeyRelations) .Where(cr => cr.CourseId == courseId) .ToList(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs index 0bf1a15be..e9cc31471 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs @@ -26,16 +26,16 @@ public void Creates() CanonicalDefinition = "A new concept definition.", KeyPropositions = new List { - new() { Statement = "First proposition", Level = "Beginner", Order = 1 }, - new() { Statement = "Second proposition", Level = "Intermediate", Order = 2 } + new() { Statement = "First proposition", Level = "Beginner" }, + new() { Statement = "Second proposition", Level = "Intermediate" } }, BoundaryConditions = new List { - new() { Statement = "A boundary condition", Level = "Beginner", Order = 1 } + new() { Statement = "A boundary condition", Level = "Beginner" } }, CommonMisconceptions = new List { - new() { Description = "A misconception", Correction = "The correction", Order = 1 } + new() { Description = "A misconception", Correction = "The correction" } }, KeyRelations = new List() }; @@ -69,7 +69,7 @@ public void Updates() CanonicalDefinition = "Updated definition.", KeyPropositions = new List { - new() { Statement = "Updated proposition", Level = "Beginner", Order = 1 } + new() { Statement = "Updated proposition", Level = "Beginner" } }, BoundaryConditions = new List(), CommonMisconceptions = new List(), @@ -103,8 +103,8 @@ public void Updates_relations_round_trip() CanonicalDefinition = "Polymorphism resolves method calls at runtime via dynamic dispatch.", KeyPropositions = new List { - new() { Id = -50, Statement = "A subclass can override a parent method", Level = "Beginner", Order = 1 }, - new() { Id = -51, Statement = "The runtime selects the implementation by the actual type", Level = "Beginner", Order = 2 } + new() { Id = -50, Statement = "A subclass can override a parent method", Level = "Beginner" }, + new() { Id = -51, Statement = "The runtime selects the implementation by the actual type", Level = "Beginner" } }, BoundaryConditions = new List(), CommonMisconceptions = new List(), @@ -114,13 +114,13 @@ public void Updates_relations_round_trip() { SourceKeyPropositionId = -50, TargetKeyPropositionId = -51, Mechanism = "Override matters because dispatch happens at runtime", - Level = "Beginner", Order = 1 + Level = "Beginner" }, new() { SourceKeyPropositionId = -51, TargetKeyPropositionId = -50, Mechanism = "Runtime type lookup is what makes the override observable", - Level = "Beginner", Order = 2 + Level = "Beginner" } } }; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs index f1083a011..5bcdddc10 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs @@ -26,8 +26,7 @@ public void Gets_by_id() result.CourseId.ShouldBe(-1); result.Title.ShouldBe("Encapsulation"); result.KeyPropositions.Count.ShouldBe(3); - result.KeyPropositions[0].Statement.ShouldBe("Data and methods are bundled in a class"); - result.KeyPropositions[0].Level.ShouldBe("Beginner"); + result.KeyPropositions.ShouldContain(kp => kp.Statement == "Data and methods are bundled in a class" && kp.Level == "Beginner"); result.BoundaryConditions.Count.ShouldBe(2); result.CommonMisconceptions.Count.ShouldBe(2); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql index c2121038a..592490843 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql @@ -2,29 +2,29 @@ INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") VALUES (-1, -1, 'Encapsulation', 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.'); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") -VALUES (-11, -1, 'Data and methods are bundled in a class', 0, 1); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") -VALUES (-12, -1, 'Access modifiers control visibility of members', 1, 2); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") -VALUES (-13, -1, 'Internal invariants are protected from external corruption', 2, 3); - -INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptRecordId", "Statement", "Level", "Order") -VALUES (-11, -1, 'Does not mean hiding all data', 0, 1); -INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptRecordId", "Statement", "Level", "Order") -VALUES (-12, -1, 'Public interfaces are part of encapsulation', 1, 2); - -INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptRecordId", "Description", "Correction", "Order") -VALUES (-11, -1, 'Encapsulation means making everything private', 'Encapsulation is about controlled access, not total hiding', 1); -INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptRecordId", "Description", "Correction", "Order") -VALUES (-12, -1, 'Getters and setters are always good encapsulation', 'Blind getters/setters can break encapsulation by exposing internals', 2); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") +VALUES (-11, -1, 'Data and methods are bundled in a class', 0); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") +VALUES (-12, -1, 'Access modifiers control visibility of members', 1); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") +VALUES (-13, -1, 'Internal invariants are protected from external corruption', 2); + +INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptRecordId", "Statement", "Level") +VALUES (-11, -1, 'Does not mean hiding all data', 0); +INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptRecordId", "Statement", "Level") +VALUES (-12, -1, 'Public interfaces are part of encapsulation', 1); + +INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptRecordId", "Description", "Correction") +VALUES (-11, -1, 'Encapsulation means making everything private', 'Encapsulation is about controlled access, not total hiding'); +INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptRecordId", "Description", "Correction") +VALUES (-12, -1, 'Getters and setters are always good encapsulation', 'Blind getters/setters can break encapsulation by exposing internals'); -- ConceptRecord -2: "Inheritance" (Course -1, minimal) INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") VALUES (-2, -1, 'Inheritance', 'Inheritance allows a class to derive behavior from another class.'); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") -VALUES (-21, -2, 'Child class inherits parent behavior', 0, 1); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") +VALUES (-21, -2, 'Child class inherits parent behavior', 0); -- ConceptRecord -4: "Abstraction" (Course -1, no task references, for delete test) INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") @@ -34,17 +34,17 @@ VALUES (-4, -1, 'Abstraction', 'Abstraction focuses on essential qualities rathe INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") VALUES (-3, -2, 'Polymorphism', 'Polymorphism enables objects to be treated as instances of their parent type.'); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") -VALUES (-31, -3, 'Objects can take multiple forms', 0, 1); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") +VALUES (-31, -3, 'Objects can take multiple forms', 0); -- ConceptRecord -5: "Polymorphism Mechanics" (Course -1, KPs + KR only — exercises relations and minimal-prompt path) INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") VALUES (-5, -1, 'Polymorphism Mechanics', 'Polymorphism resolves method calls at runtime via dynamic dispatch.'); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") -VALUES (-50, -5, 'A subclass can override a parent method', 0, 1); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level", "Order") -VALUES (-51, -5, 'The runtime selects the implementation by the actual type', 0, 2); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") +VALUES (-50, -5, 'A subclass can override a parent method', 0); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") +VALUES (-51, -5, 'The runtime selects the implementation by the actual type', 0); -INSERT INTO elaborations."KeyRelations"("Id", "ConceptRecordId", "SourceKeyPropositionId", "TargetKeyPropositionId", "Mechanism", "Level", "Order") -VALUES (-100, -5, -50, -51, 'Override matters because dispatch happens at runtime, not compile time', 0, 1); +INSERT INTO elaborations."KeyRelations"("Id", "ConceptRecordId", "SourceKeyPropositionId", "TargetKeyPropositionId", "Mechanism", "Level") +VALUES (-100, -5, -50, -51, 'Override matters because dispatch happens at runtime, not compile time', 0); From 9c766a6b3e31f530aaff74f8ee7c201f58e39d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Sat, 11 Apr 2026 07:19:58 +0200 Subject: [PATCH 10/51] fix: Improves KeyRelation model to better support authoring. --- .../Dtos/ConceptRecords/KeyRelationDto.cs | 2 + .../Domain/ConceptRecords/KeyRelation.cs | 6 +- .../Mappers/ConceptRecordProfile.cs | 28 +++++- .../Database/ElaborationsContext.cs | 12 +++ .../Authoring/ConceptRecordCommandTests.cs | 95 +++++++++++++++++-- .../TestData/a-delete.sql | 1 + 6 files changed, 130 insertions(+), 14 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs index 777d0d7cb..7d3269bae 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs @@ -5,6 +5,8 @@ public class KeyRelationDto public int Id { get; set; } public int SourceKeyPropositionId { get; set; } public int TargetKeyPropositionId { get; set; } + public int? SourceKeyPropositionIndex { get; set; } + public int? TargetKeyPropositionIndex { get; set; } public string Mechanism { get; set; } = string.Empty; public string Level { get; set; } = string.Empty; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs index 80c4cb812..9adbee608 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs @@ -5,8 +5,10 @@ namespace Tutor.Elaborations.Core.Domain.ConceptRecords; public class KeyRelation : Entity { public int ConceptRecordId { get; private set; } - public int SourceKeyPropositionId { get; private set; } - public int TargetKeyPropositionId { get; private set; } + public int SourceKeyPropositionId { get; internal set; } + public int TargetKeyPropositionId { get; internal set; } + public KeyProposition? SourceKeyProposition { get; internal set; } + public KeyProposition? TargetKeyProposition { get; internal set; } public string Mechanism { get; private set; } = string.Empty; public PropositionLevel Level { get; private set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs index e416db7c4..1e8275730 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs @@ -8,13 +8,35 @@ public class ConceptRecordProfile : Profile { public ConceptRecordProfile() { - CreateMap().ReverseMap(); + CreateMap() + .AfterMap((src, dest) => + { + for (var i = 0; i < src.KeyRelations.Count; i++) + { + var krDto = src.KeyRelations[i]; + if (!krDto.SourceKeyPropositionIndex.HasValue + || !krDto.TargetKeyPropositionIndex.HasValue) continue; + var kr = dest.KeyRelations[i]; + var source = dest.KeyPropositions[krDto.SourceKeyPropositionIndex.Value]; + var target = dest.KeyPropositions[krDto.TargetKeyPropositionIndex.Value]; + if (source.Id == 0) kr.SourceKeyProposition = source; + else kr.SourceKeyPropositionId = source.Id; + if (target.Id == 0) kr.TargetKeyProposition = target; + else kr.TargetKeyPropositionId = target.Id; + } + }) + .ReverseMap(); CreateMap().ReverseMap() .ForMember(d => d.Level, opt => opt.MapFrom(s => s.Level.ToString())); CreateMap().ReverseMap() .ForMember(d => d.Level, opt => opt.MapFrom(s => s.Level.ToString())); CreateMap().ReverseMap(); - CreateMap().ReverseMap() - .ForMember(d => d.Level, opt => opt.MapFrom(s => s.Level.ToString())); + CreateMap() + .ForMember(d => d.SourceKeyProposition, opt => opt.Ignore()) + .ForMember(d => d.TargetKeyProposition, opt => opt.Ignore()) + .ReverseMap() + .ForMember(d => d.Level, opt => opt.MapFrom(s => s.Level.ToString())) + .ForMember(d => d.SourceKeyPropositionIndex, opt => opt.Ignore()) + .ForMember(d => d.TargetKeyPropositionIndex, opt => opt.Ignore()); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs index 47a3c796f..568bf9fed 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -49,6 +49,18 @@ private static void ConfigureConceptRecords(ModelBuilder modelBuilder) .HasMany(cr => cr.KeyRelations) .WithOne() .HasForeignKey(kr => kr.ConceptRecordId); + + modelBuilder.Entity() + .HasOne(kr => kr.SourceKeyProposition) + .WithMany() + .HasForeignKey(kr => kr.SourceKeyPropositionId) + .OnDelete(DeleteBehavior.ClientNoAction); + + modelBuilder.Entity() + .HasOne(kr => kr.TargetKeyProposition) + .WithMany() + .HasForeignKey(kr => kr.TargetKeyPropositionId) + .OnDelete(DeleteBehavior.ClientNoAction); } private static void ConfigureElaborationTasks(ModelBuilder modelBuilder) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs index e9cc31471..9168f212f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs @@ -55,6 +55,49 @@ public void Creates() result.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 ConceptRecordDto + { + CourseId = -1, + Title = "Concept With Relations", + CanonicalDefinition = "A concept created with KPs and KRs in one request.", + KeyPropositions = new List + { + new() { Statement = "First proposition", Level = "Beginner" }, + new() { Statement = "Second proposition", Level = "Beginner" } + }, + BoundaryConditions = new List(), + CommonMisconceptions = new List(), + KeyRelations = new List + { + new() + { + SourceKeyPropositionIndex = 0, TargetKeyPropositionIndex = 1, + Mechanism = "First enables second", Level = "Beginner" + } + } + }; + dbContext.Database.BeginTransaction(); + + var actionResult = controller.Create(-1, newEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.KeyPropositions.Count.ShouldBe(2); + result.KeyRelations.Count.ShouldBe(1); + result.KeyRelations[0].Mechanism.ShouldBe("First enables second"); + result.KeyRelations[0].SourceKeyPropositionId.ShouldNotBe(0); + result.KeyRelations[0].TargetKeyPropositionId.ShouldNotBe(0); + result.KeyRelations[0].SourceKeyPropositionId.ShouldNotBe( + result.KeyRelations[0].TargetKeyPropositionId); + } + [Fact] public void Updates() { @@ -89,12 +132,11 @@ public void Updates() } [Fact] - public void Updates_relations_round_trip() + public void Updates_relations_with_indices() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope); var dbContext = scope.ServiceProvider.GetRequiredService(); - // CR -5 has KPs -50 and -51 already; add a second relation alongside the seeded -100. var updatedEntity = new ConceptRecordDto { Id = -5, @@ -104,7 +146,8 @@ public void Updates_relations_round_trip() KeyPropositions = new List { new() { Id = -50, Statement = "A subclass can override a parent method", Level = "Beginner" }, - new() { Id = -51, Statement = "The runtime selects the implementation by the actual type", Level = "Beginner" } + new() { Id = -51, Statement = "The runtime selects the implementation by the actual type", Level = "Beginner" }, + new() { Statement = "Dispatch table resolves virtual calls", Level = "Intermediate" } }, BoundaryConditions = new List(), CommonMisconceptions = new List(), @@ -112,15 +155,15 @@ public void Updates_relations_round_trip() { new() { - SourceKeyPropositionId = -50, TargetKeyPropositionId = -51, + SourceKeyPropositionIndex = 0, TargetKeyPropositionIndex = 1, Mechanism = "Override matters because dispatch happens at runtime", Level = "Beginner" }, new() { - SourceKeyPropositionId = -51, TargetKeyPropositionId = -50, - Mechanism = "Runtime type lookup is what makes the override observable", - Level = "Beginner" + SourceKeyPropositionIndex = 1, TargetKeyPropositionIndex = 2, + Mechanism = "Runtime dispatch uses vtable lookup", + Level = "Intermediate" } } }; @@ -131,9 +174,43 @@ public void Updates_relations_round_trip() dbContext.ChangeTracker.Clear(); result.ShouldNotBeNull(); + result.KeyPropositions.Count.ShouldBe(3); result.KeyRelations.Count.ShouldBe(2); - result.KeyRelations[0].Mechanism.ShouldContain("dispatch happens at runtime"); - result.KeyRelations[0].Level.ShouldBe("Beginner"); + result.KeyRelations.ShouldContain(kr => kr.Mechanism.Contains("dispatch happens at runtime")); + result.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(); + // CR -5 has KP -50, KP -51, and KR -100 (source=-50, target=-51). + // Remove KR and KP -51, keeping only KP -50. + var updatedEntity = new ConceptRecordDto + { + Id = -5, + CourseId = -1, + Title = "Polymorphism Mechanics", + CanonicalDefinition = "Polymorphism resolves method calls at runtime via dynamic dispatch.", + KeyPropositions = new List + { + new() { Id = -50, Statement = "A subclass can override a parent method", Level = "Beginner" } + }, + BoundaryConditions = new List(), + CommonMisconceptions = new List(), + KeyRelations = new List() + }; + dbContext.Database.BeginTransaction(); + + var actionResult = controller.Update(-1, -5, updatedEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.KeyPropositions.Count.ShouldBe(1); + result.KeyRelations.Count.ShouldBe(0); } [Fact] diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql index f8e12d66e..38ae8c07c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql @@ -2,6 +2,7 @@ DELETE FROM elaborations."TurnEvaluations"; DELETE FROM elaborations."ConversationTurns"; DELETE FROM elaborations."ConversationAttempts"; DELETE FROM elaborations."ElaborationTasks"; +DELETE FROM elaborations."KeyRelations"; DELETE FROM elaborations."BoundaryConditions"; DELETE FROM elaborations."CommonMisconceptions"; DELETE FROM elaborations."KeyPropositions"; From c251a465d7a265d728579927eac9d27918806777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Sat, 11 Apr 2026 09:36:49 +0200 Subject: [PATCH 11/51] refactor: Merges ConceptRecord and ElaborationTask into a single aggregate. --- .../BoundaryConditionDto.cs | 3 +- .../CommonMisconceptionDto.cs | 2 +- .../ConceptElaborationTaskDto.cs} | 10 +- .../ConceptElaborationTaskSummaryDto.cs | 10 ++ .../KeyPropositionDto.cs | 3 +- .../KeyRelationDto.cs | 3 +- .../Conversations/ConversationAttemptDto.cs | 2 +- .../Conversations/ElaborationTaskDetailDto.cs | 11 -- .../Dtos/Conversations/ElaborationTaskDto.cs | 12 -- ...r.cs => IConceptElaborationTaskQuerier.cs} | 2 +- .../IConceptElaborationTaskService.cs | 13 ++ .../Public/Authoring/IConceptRecordService.cs | 13 -- .../Authoring/IElaborationTaskService.cs | 12 -- .../Public/IAccessServices.cs | 1 - .../Public/Learning/IConversationService.cs | 5 +- .../BoundaryCondition.cs | 9 ++ .../CommonMisconception.cs | 4 +- .../ConceptElaborationTask.cs | 45 ++++++ .../IConceptElaborationTaskRepository.cs | 8 + .../ConceptElaborationTasks/KeyProposition.cs | 9 ++ .../KeyRelation.cs | 5 +- .../ConceptRecords/BoundaryCondition.cs | 10 -- .../Domain/ConceptRecords/ConceptRecord.cs | 67 -------- .../IConceptRecordRepository.cs | 8 - .../Domain/ConceptRecords/KeyProposition.cs | 10 -- .../Domain/ConceptRecords/PropositionLevel.cs | 8 - .../Conversations/ConversationAttempt.cs | 6 +- .../IConversationAttemptRepository.cs | 6 +- .../ElaborationTasks/ElaborationTask.cs | 19 --- .../IElaborationTaskRepository.cs | 8 - ...le.cs => ConceptElaborationTaskProfile.cs} | 20 ++- .../Mappers/ConversationProfile.cs | 3 - .../UseCases/AccessServices.cs | 5 - .../ConceptElaborationTaskService.cs | 81 ++++++++++ .../Authoring/ConceptRecordService.cs | 69 -------- .../Authoring/ElaborationTaskService.cs | 96 ----------- .../UseCases/Learning/ConversationService.cs | 92 ++++------- .../Learning/Orchestration/IDialogueAgent.cs | 4 +- .../Orchestration/IEvaluationAgent.cs | 4 +- .../Learning/Orchestration/ISummaryAgent.cs | 4 +- .../Orchestration/TurnOrchestrator.cs | 14 +- .../ConceptElaborationTaskQuerier.cs | 19 +++ .../Monitoring/ElaborationTaskQuerier.cs | 19 --- .../Agents/DialogueAgent.cs | 6 +- .../Agents/EvaluationAgent.cs | 6 +- .../Agents/Prompts/DialoguePromptBuilder.cs | 16 +- .../Agents/Prompts/EvaluationPromptBuilder.cs | 24 +-- .../Agents/Prompts/SummaryPromptBuilder.cs | 8 +- .../Agents/SummaryAgent.cs | 6 +- .../Database/ElaborationsContext.cs | 52 +++--- ...onceptElaborationTaskDatabaseRepository.cs | 33 ++++ .../ConceptRecordDatabaseRepository.cs | 32 ---- .../ConversationAttemptDatabaseRepository.cs | 16 +- .../ElaborationTaskDatabaseRepository.cs | 18 --- .../ElaborationsStartup.cs | 13 +- ... => ConceptElaborationTaskCommandTests.cs} | 112 +++++++------ ...cs => ConceptElaborationTaskQueryTests.cs} | 68 ++++---- .../Authoring/ElaborationTaskCommandTests.cs | 138 ---------------- .../Authoring/ElaborationTaskQueryTests.cs | 51 ------ .../Learning/ConversationQueryTests.cs | 15 +- .../Learning/ConversationTurnTests.cs | 42 ++--- .../TestData/a-delete.sql | 3 +- .../TestData/c-concept-elaboration-tasks.sql | 77 +++++++++ .../TestData/c-concept-records.sql | 50 ------ .../TestData/d-elaboration-tasks.sql | 27 ---- .../TestData/e-conversation-attempts.sql | 36 ++--- .../TestData/f-daily-limit-attempts.sql | 8 +- .../Unit/ConceptElaborationTaskTests.cs | 103 ++++++++++++ .../Unit/ConceptRecordTests.cs | 153 ------------------ .../ConceptElaborationTaskController.cs | 57 +++++++ .../Elaboration/ConceptRecordController.cs | 57 ------- .../Elaboration/ElaborationTaskController.cs | 50 ------ .../Elaboration/ConversationController.cs | 16 +- 73 files changed, 757 insertions(+), 1290 deletions(-) rename src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/{ConceptRecords => ConceptElaborationTasks}/BoundaryConditionDto.cs (54%) rename src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/{ConceptRecords => ConceptElaborationTasks}/CommonMisconceptionDto.cs (75%) rename src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/{ConceptRecords/ConceptRecordDto.cs => ConceptElaborationTasks/ConceptElaborationTaskDto.cs} (61%) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskSummaryDto.cs rename src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/{ConceptRecords => ConceptElaborationTasks}/KeyPropositionDto.cs (53%) rename src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/{ConceptRecords => ConceptElaborationTasks}/KeyRelationDto.cs (75%) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDetailDto.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDto.cs rename src/Modules/Elaborations/Tutor.Elaborations.API/Internal/{IElaborationTaskQuerier.cs => IConceptElaborationTaskQuerier.cs} (62%) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptElaborationTaskService.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptRecordService.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IElaborationTaskService.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/BoundaryCondition.cs rename src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/{ConceptRecords => ConceptElaborationTasks}/CommonMisconception.cs (63%) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/IConceptElaborationTaskRepository.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyProposition.cs rename src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/{ConceptRecords => ConceptElaborationTasks}/KeyRelation.cs (71%) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/IConceptRecordRepository.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/PropositionLevel.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/ElaborationTask.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/IElaborationTaskRepository.cs rename src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/{ConceptRecordProfile.cs => ConceptElaborationTaskProfile.cs} (74%) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptRecordService.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Monitoring/ConceptElaborationTaskQuerier.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Monitoring/ElaborationTaskQuerier.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptElaborationTaskDatabaseRepository.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ElaborationTaskDatabaseRepository.cs rename src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/{ConceptRecordCommandTests.cs => ConceptElaborationTaskCommandTests.cs} (77%) rename src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/{ConceptRecordQueryTests.cs => ConceptElaborationTaskQueryTests.cs} (57%) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskCommandTests.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskQueryTests.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-elaboration-tasks.sql delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs create mode 100644 src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptElaborationTaskController.cs delete mode 100644 src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptRecordController.cs delete mode 100644 src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ElaborationTaskController.cs diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/BoundaryConditionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/BoundaryConditionDto.cs similarity index 54% rename from src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/BoundaryConditionDto.cs rename to src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/BoundaryConditionDto.cs index 4b197bdeb..995d05260 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/BoundaryConditionDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/BoundaryConditionDto.cs @@ -1,8 +1,7 @@ -namespace Tutor.Elaborations.API.Dtos.ConceptRecords; +namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; public class BoundaryConditionDto { public int Id { get; set; } public string Statement { get; set; } = string.Empty; - public string Level { get; set; } = string.Empty; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/CommonMisconceptionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/CommonMisconceptionDto.cs similarity index 75% rename from src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/CommonMisconceptionDto.cs rename to src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/CommonMisconceptionDto.cs index 52165f025..874f4452f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/CommonMisconceptionDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/CommonMisconceptionDto.cs @@ -1,4 +1,4 @@ -namespace Tutor.Elaborations.API.Dtos.ConceptRecords; +namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; public class CommonMisconceptionDto { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/ConceptRecordDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs similarity index 61% rename from src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/ConceptRecordDto.cs rename to src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs index d2a6df4b4..3f46db65a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/ConceptRecordDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs @@ -1,13 +1,17 @@ -namespace Tutor.Elaborations.API.Dtos.ConceptRecords; +using Tutor.Elaborations.API.Dtos.Conversations; -public class ConceptRecordDto +namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; + +public class ConceptElaborationTaskDto { public int Id { get; set; } - public int CourseId { get; set; } + public int UnitId { get; set; } + public int Order { get; set; } public string Title { get; set; } = string.Empty; public string CanonicalDefinition { get; set; } = string.Empty; public List KeyPropositions { get; set; } = new(); public List BoundaryConditions { get; set; } = new(); public List CommonMisconceptions { get; set; } = new(); public List KeyRelations { get; set; } = new(); + public List? Attempts { get; set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskSummaryDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskSummaryDto.cs new file mode 100644 index 000000000..5d6a44454 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskSummaryDto.cs @@ -0,0 +1,10 @@ +namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; + +public class ConceptElaborationTaskSummaryDto +{ + 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/ConceptRecords/KeyPropositionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyPropositionDto.cs similarity index 53% rename from src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyPropositionDto.cs rename to src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyPropositionDto.cs index 5b4d0b70c..99070bb62 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyPropositionDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyPropositionDto.cs @@ -1,8 +1,7 @@ -namespace Tutor.Elaborations.API.Dtos.ConceptRecords; +namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; public class KeyPropositionDto { public int Id { get; set; } public string Statement { get; set; } = string.Empty; - public string Level { get; set; } = string.Empty; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyRelationDto.cs similarity index 75% rename from src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs rename to src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyRelationDto.cs index 7d3269bae..e6694eab9 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptRecords/KeyRelationDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyRelationDto.cs @@ -1,4 +1,4 @@ -namespace Tutor.Elaborations.API.Dtos.ConceptRecords; +namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; public class KeyRelationDto { @@ -8,5 +8,4 @@ public class KeyRelationDto public int? SourceKeyPropositionIndex { get; set; } public int? TargetKeyPropositionIndex { get; set; } public string Mechanism { get; set; } = string.Empty; - public string Level { get; set; } = string.Empty; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs index e2d140d31..97b4ade23 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs @@ -3,7 +3,7 @@ namespace Tutor.Elaborations.API.Dtos.Conversations; public class ConversationAttemptDto { public int Id { get; set; } - public int ElaborationTaskId { get; set; } + public int ConceptElaborationTaskId { get; set; } public string Status { get; set; } = string.Empty; public DateTime StartedAt { get; set; } public DateTime? CompletedAt { get; set; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDetailDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDetailDto.cs deleted file mode 100644 index 358c954d3..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDetailDto.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Tutor.Elaborations.API.Dtos.Conversations; - -public class ElaborationTaskDetailDto -{ - public int Id { get; set; } - public string ExpectedLevel { get; set; } = string.Empty; - public int Order { get; set; } - public string ConceptTitle { get; set; } = string.Empty; - public string ConceptDefinition { get; set; } = string.Empty; - public List Attempts { get; set; } = new(); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDto.cs deleted file mode 100644 index fe4c1470e..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ElaborationTaskDto.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Tutor.Elaborations.API.Dtos.Conversations; - -public class ElaborationTaskDto -{ - public int Id { get; set; } - public int ConceptRecordId { get; set; } - public int UnitId { get; set; } - public string ExpectedLevel { get; set; } = string.Empty; - public int Order { get; set; } - public string? ConceptRecordTitle { get; set; } - public bool HasCompletedAttempt { get; set; } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Internal/IElaborationTaskQuerier.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Internal/IConceptElaborationTaskQuerier.cs similarity index 62% rename from src/Modules/Elaborations/Tutor.Elaborations.API/Internal/IElaborationTaskQuerier.cs rename to src/Modules/Elaborations/Tutor.Elaborations.API/Internal/IConceptElaborationTaskQuerier.cs index 122497945..ed8455719 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Internal/IElaborationTaskQuerier.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Internal/IConceptElaborationTaskQuerier.cs @@ -1,6 +1,6 @@ namespace Tutor.Elaborations.API.Internal; -public interface IElaborationTaskQuerier +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..17e70faff --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptElaborationTaskService.cs @@ -0,0 +1,13 @@ +using FluentResults; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; + +namespace Tutor.Elaborations.API.Public.Authoring; + +public interface IConceptElaborationTaskService +{ + Result Get(int id, int unitId, int instructorId); + 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/Authoring/IConceptRecordService.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptRecordService.cs deleted file mode 100644 index 6b2519984..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptRecordService.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FluentResults; -using Tutor.Elaborations.API.Dtos.ConceptRecords; - -namespace Tutor.Elaborations.API.Public.Authoring; - -public interface IConceptRecordService -{ - Result Get(int id, int courseId, int instructorId); - Result> GetByCourse(int courseId, int instructorId); - Result Create(ConceptRecordDto conceptRecord, int instructorId); - Result Update(ConceptRecordDto conceptRecord, int instructorId); - Result Delete(int id, int courseId, int instructorId); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IElaborationTaskService.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IElaborationTaskService.cs deleted file mode 100644 index d45e6be75..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IElaborationTaskService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FluentResults; -using Tutor.Elaborations.API.Dtos.Conversations; - -namespace Tutor.Elaborations.API.Public.Authoring; - -public interface IElaborationTaskService -{ - Result> GetByUnit(int unitId, int instructorId); - Result Create(ElaborationTaskDto task, int instructorId); - Result Update(ElaborationTaskDto 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 index b43a2e9ea..5bf5a17d5 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/IAccessServices.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/IAccessServices.cs @@ -2,7 +2,6 @@ namespace Tutor.Elaborations.API.Public; public interface IAccessServices { - bool IsCourseOwner(int courseId, int instructorId); 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 index 307ed0732..762e09104 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs @@ -1,12 +1,13 @@ 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 GetTaskDetail(int taskId, int learnerId); + Result> GetTasksForUnit(int unitId, int learnerId); + Result GetTaskDetail(int taskId, int learnerId); IAsyncEnumerable StartConversationAsync( int taskId, string content, int learnerId, CancellationToken ct); IAsyncEnumerable SubmitTurnAsync( diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/BoundaryCondition.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/BoundaryCondition.cs new file mode 100644 index 000000000..ee83b381a --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/BoundaryCondition.cs @@ -0,0 +1,9 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +public class BoundaryCondition : Entity +{ + public int ConceptElaborationTaskId { get; private set; } + public string Statement { get; private set; } = string.Empty; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/CommonMisconception.cs similarity index 63% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/CommonMisconception.cs index 8132e34bb..2a49f23a4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/CommonMisconception.cs @@ -1,10 +1,10 @@ using Tutor.BuildingBlocks.Core.Domain; -namespace Tutor.Elaborations.Core.Domain.ConceptRecords; +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; public class CommonMisconception : Entity { - public int ConceptRecordId { get; private set; } + public int ConceptElaborationTaskId { get; private set; } public string Description { get; private set; } = string.Empty; public string Correction { get; private set; } = string.Empty; } 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..137aa9fbb --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs @@ -0,0 +1,45 @@ +using Tutor.BuildingBlocks.Core.Domain; +using Tutor.Elaborations.Core.Domain.Conversations; + +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 CanonicalDefinition { get; private set; } = string.Empty; + public List KeyPropositions { get; private set; } = new(); + public List BoundaryConditions { get; private set; } = new(); + public List CommonMisconceptions { get; private set; } = new(); + public List KeyRelations { get; private set; } = new(); + + public void Update(ConceptElaborationTask incoming) + { + Title = incoming.Title; + CanonicalDefinition = incoming.CanonicalDefinition; + Order = incoming.Order; + KeyPropositions = incoming.KeyPropositions; + BoundaryConditions = incoming.BoundaryConditions; + CommonMisconceptions = incoming.CommonMisconceptions; + KeyRelations = incoming.KeyRelations; + } + + public bool AreAllPropositionsCovered(ConversationAttempt attempt) + { + var coveredIds = attempt.GetCoveredPropositionIds(); + return KeyPropositions.All(kp => coveredIds.Contains(kp.Id)); + } + + public bool AreAllKeyRelationsArticulated(ConversationAttempt attempt) + { + if (KeyRelations.Count == 0) return true; + var articulatedIds = attempt.GetArticulatedRelationIds(); + return KeyRelations.All(kr => articulatedIds.Contains(kr.Id)); + } + + public bool IsAttemptComplete(ConversationAttempt attempt) + { + return AreAllPropositionsCovered(attempt) && AreAllKeyRelationsArticulated(attempt); + } +} 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..32ff1ce66 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/IConceptElaborationTaskRepository.cs @@ -0,0 +1,8 @@ +using Tutor.BuildingBlocks.Core.UseCases; + +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +public interface IConceptElaborationTaskRepository : ICrudRepository +{ + List GetByUnit(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..4bd41b4a3 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyProposition.cs @@ -0,0 +1,9 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +public class KeyProposition : Entity +{ + public int ConceptElaborationTaskId { get; private set; } + public string Statement { get; private set; } = string.Empty; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyRelation.cs similarity index 71% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyRelation.cs index 9adbee608..6d03b0498 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyRelation.cs @@ -1,14 +1,13 @@ using Tutor.BuildingBlocks.Core.Domain; -namespace Tutor.Elaborations.Core.Domain.ConceptRecords; +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; public class KeyRelation : Entity { - public int ConceptRecordId { get; private set; } + public int ConceptElaborationTaskId { get; private set; } public int SourceKeyPropositionId { get; internal set; } public int TargetKeyPropositionId { get; internal set; } public KeyProposition? SourceKeyProposition { get; internal set; } public KeyProposition? TargetKeyProposition { get; internal set; } public string Mechanism { get; private set; } = string.Empty; - public PropositionLevel Level { get; private set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs deleted file mode 100644 index b2fe8cf61..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Tutor.BuildingBlocks.Core.Domain; - -namespace Tutor.Elaborations.Core.Domain.ConceptRecords; - -public class BoundaryCondition : Entity -{ - public int ConceptRecordId { get; private set; } - public string Statement { get; private set; } = string.Empty; - public PropositionLevel Level { get; private set; } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs deleted file mode 100644 index 8e74290a2..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Tutor.BuildingBlocks.Core.Domain; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.Domain.ConceptRecords; - -public class ConceptRecord : AggregateRoot -{ - public int CourseId { get; private set; } - public string Title { get; private set; } = string.Empty; - public string CanonicalDefinition { get; private set; } = string.Empty; - public List KeyPropositions { get; private set; } = new(); - public List BoundaryConditions { get; private set; } = new(); - public List CommonMisconceptions { get; private set; } = new(); - public List KeyRelations { get; private set; } = new(); - - public void Update(ConceptRecord conceptRecord) - { - Title = conceptRecord.Title; - CanonicalDefinition = conceptRecord.CanonicalDefinition; - KeyPropositions = conceptRecord.KeyPropositions; - BoundaryConditions = conceptRecord.BoundaryConditions; - CommonMisconceptions = conceptRecord.CommonMisconceptions; - KeyRelations = conceptRecord.KeyRelations; - } - - public ConceptRecord DeriveForLevel(PropositionLevel level) - { - var filteredKPs = KeyPropositions - .Where(kp => kp.Level <= level).ToList(); - var filteredKPIds = filteredKPs.Select(kp => kp.Id).ToHashSet(); - - return new ConceptRecord - { - Id = Id, - CourseId = CourseId, - Title = Title, - CanonicalDefinition = CanonicalDefinition, - KeyPropositions = filteredKPs, - BoundaryConditions = BoundaryConditions - .Where(bc => bc.Level <= level).ToList(), - CommonMisconceptions = CommonMisconceptions.ToList(), - KeyRelations = KeyRelations - .Where(kr => kr.Level <= level) - .Where(kr => filteredKPIds.Contains(kr.SourceKeyPropositionId) - && filteredKPIds.Contains(kr.TargetKeyPropositionId)) - .ToList() - }; - } - - public bool AreAllPropositionsCovered(ConversationAttempt attempt) - { - var coveredIds = attempt.GetCoveredPropositionIds(); - return KeyPropositions.All(kp => coveredIds.Contains(kp.Id)); - } - - public bool AreAllKeyRelationsArticulated(ConversationAttempt attempt) - { - if (KeyRelations.Count == 0) return true; - var articulatedIds = attempt.GetArticulatedRelationIds(); - return KeyRelations.All(kr => articulatedIds.Contains(kr.Id)); - } - - public bool IsAttemptComplete(ConversationAttempt attempt) - { - return AreAllPropositionsCovered(attempt) && AreAllKeyRelationsArticulated(attempt); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/IConceptRecordRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/IConceptRecordRepository.cs deleted file mode 100644 index 7c5e81df9..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/IConceptRecordRepository.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Tutor.BuildingBlocks.Core.UseCases; - -namespace Tutor.Elaborations.Core.Domain.ConceptRecords; - -public interface IConceptRecordRepository : ICrudRepository -{ - List GetByCourse(int courseId); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs deleted file mode 100644 index 490abe8c6..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Tutor.BuildingBlocks.Core.Domain; - -namespace Tutor.Elaborations.Core.Domain.ConceptRecords; - -public class KeyProposition : Entity -{ - public int ConceptRecordId { get; private set; } - public string Statement { get; private set; } = string.Empty; - public PropositionLevel Level { get; private set; } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/PropositionLevel.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/PropositionLevel.cs deleted file mode 100644 index 8ec661ae4..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/PropositionLevel.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Tutor.Elaborations.Core.Domain.ConceptRecords; - -public enum PropositionLevel -{ - Beginner, - Intermediate, - Advanced -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index 479c04b8e..1b2a1213a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -7,7 +7,7 @@ public class ConversationAttempt : AggregateRoot private const int SoftCapSubstantiveTurns = 6; private const int HardCapTotalTurns = 10; - public int ElaborationTaskId { get; private set; } + public int ConceptElaborationTaskId { get; private set; } public int LearnerId { get; private set; } public AttemptStatus Status { get; private set; } public DateTime StartedAt { get; private set; } @@ -17,9 +17,9 @@ public class ConversationAttempt : AggregateRoot private ConversationAttempt() { } - public ConversationAttempt(int elaborationTaskId, int learnerId) + public ConversationAttempt(int conceptElaborationTaskId, int learnerId) { - ElaborationTaskId = elaborationTaskId; + ConceptElaborationTaskId = conceptElaborationTaskId; LearnerId = learnerId; Status = AttemptStatus.InProgress; StartedAt = DateTime.UtcNow; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/IConversationAttemptRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/IConversationAttemptRepository.cs index 50c11512b..c00576bf0 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/IConversationAttemptRepository.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/IConversationAttemptRepository.cs @@ -4,8 +4,8 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; public interface IConversationAttemptRepository : ICrudRepository { - ConversationAttempt? GetActiveAttempt(int elaborationTaskId, int learnerId); - List GetByTaskAndLearner(int elaborationTaskId, int learnerId); - int CountRecentAttempts(int elaborationTaskId, int learnerId, DateTime since); + 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/ElaborationTasks/ElaborationTask.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/ElaborationTask.cs deleted file mode 100644 index d082f6672..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/ElaborationTask.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Tutor.BuildingBlocks.Core.Domain; -using Tutor.Elaborations.Core.Domain.ConceptRecords; - -namespace Tutor.Elaborations.Core.Domain.ElaborationTasks; - -public class ElaborationTask : Entity -{ - public int ConceptRecordId { get; private set; } - public int UnitId { get; internal set; } - public PropositionLevel ExpectedLevel { get; private set; } - public int Order { get; private set; } - - public void Update(ElaborationTask task) - { - ConceptRecordId = task.ConceptRecordId; - ExpectedLevel = task.ExpectedLevel; - Order = task.Order; - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/IElaborationTaskRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/IElaborationTaskRepository.cs deleted file mode 100644 index 8fdcd177f..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ElaborationTasks/IElaborationTaskRepository.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Tutor.BuildingBlocks.Core.UseCases; - -namespace Tutor.Elaborations.Core.Domain.ElaborationTasks; - -public interface IElaborationTaskRepository : ICrudRepository -{ - List GetByUnit(int unitId); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs similarity index 74% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs index 1e8275730..eccd5606e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptRecordProfile.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs @@ -1,14 +1,14 @@ using AutoMapper; -using Tutor.Elaborations.API.Dtos.ConceptRecords; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.Mappers; -public class ConceptRecordProfile : Profile +public class ConceptElaborationTaskProfile : Profile { - public ConceptRecordProfile() + public ConceptElaborationTaskProfile() { - CreateMap() + CreateMap() .AfterMap((src, dest) => { for (var i = 0; i < src.KeyRelations.Count; i++) @@ -25,17 +25,15 @@ public ConceptRecordProfile() else kr.TargetKeyPropositionId = target.Id; } }) - .ReverseMap(); - CreateMap().ReverseMap() - .ForMember(d => d.Level, opt => opt.MapFrom(s => s.Level.ToString())); - CreateMap().ReverseMap() - .ForMember(d => d.Level, opt => opt.MapFrom(s => s.Level.ToString())); + .ReverseMap() + .ForMember(d => d.Attempts, opt => opt.Ignore()); + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); CreateMap().ReverseMap(); CreateMap() .ForMember(d => d.SourceKeyProposition, opt => opt.Ignore()) .ForMember(d => d.TargetKeyProposition, opt => opt.Ignore()) .ReverseMap() - .ForMember(d => d.Level, opt => opt.MapFrom(s => s.Level.ToString())) .ForMember(d => d.SourceKeyPropositionIndex, opt => opt.Ignore()) .ForMember(d => d.TargetKeyPropositionIndex, opt => opt.Ignore()); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConversationProfile.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConversationProfile.cs index e89be8648..28620e781 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConversationProfile.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConversationProfile.cs @@ -1,7 +1,6 @@ using AutoMapper; using Tutor.Elaborations.API.Dtos.Conversations; using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.Domain.ElaborationTasks; namespace Tutor.Elaborations.Core.Mappers; @@ -9,8 +8,6 @@ public class ConversationProfile : Profile { public ConversationProfile() { - CreateMap().ReverseMap() - .ForMember(d => d.ExpectedLevel, opt => opt.MapFrom(s => s.ExpectedLevel.ToString())); CreateMap().ReverseMap() .ForMember(d => d.Status, opt => opt.MapFrom(s => s.Status.ToString())); CreateMap().ReverseMap() diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/AccessServices.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/AccessServices.cs index 11b1026ff..6cb35afbb 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/AccessServices.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/AccessServices.cs @@ -15,11 +15,6 @@ public AccessServices(IOwnershipValidator ownershipValidator, _enrollmentValidator = enrollmentValidator; } - public bool IsCourseOwner(int courseId, int instructorId) - { - return _ownershipValidator.IsCourseOwner(courseId, instructorId); - } - public bool IsUnitOwner(int unitId, int instructorId) { return _ownershipValidator.IsUnitOwner(unitId, instructorId); 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..b6bfdd18a --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs @@ -0,0 +1,81 @@ +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 : + CrudService, IConceptElaborationTaskService +{ + private readonly IConceptElaborationTaskRepository _taskRepository; + private readonly IAccessServices _accessServices; + + public ConceptElaborationTaskService(IConceptElaborationTaskRepository repository, + IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, + IMapper mapper) : base(repository, unitOfWork, mapper) + { + _taskRepository = repository; + _accessServices = accessServices; + } + + public Result Get(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); + return MapToDto(task); + } + + public Result> GetByUnit(int unitId, int instructorId) + { + if (!_accessServices.IsUnitOwner(unitId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + var tasks = _taskRepository.GetByUnit(unitId); + return Result.Ok(tasks.Select(ToSummary).ToList()); + } + + public Result Create(ConceptElaborationTaskDto task, int instructorId) + { + if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + return Create(task); + } + + public Result Update(ConceptElaborationTaskDto task, int instructorId) + { + if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + var existing = _taskRepository.Get(task.Id); + if (existing == null || existing.UnitId != task.UnitId) + return Result.Fail(FailureCode.NotFound); + existing.Update(MapToDomain(task)); + return Update(existing); + } + + 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); + return Delete(id); + } + + private static ConceptElaborationTaskSummaryDto ToSummary(ConceptElaborationTask task) + { + return new ConceptElaborationTaskSummaryDto + { + Id = task.Id, + UnitId = task.UnitId, + Order = task.Order, + Title = task.Title + }; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptRecordService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptRecordService.cs deleted file mode 100644 index 6fb8f6ded..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptRecordService.cs +++ /dev/null @@ -1,69 +0,0 @@ -using AutoMapper; -using FluentResults; -using Tutor.BuildingBlocks.Core.UseCases; -using Tutor.Elaborations.API.Dtos.ConceptRecords; -using Tutor.Elaborations.API.Public; -using Tutor.Elaborations.API.Public.Authoring; -using Tutor.Elaborations.Core.Domain.ConceptRecords; - -namespace Tutor.Elaborations.Core.UseCases.Authoring; - -public class ConceptRecordService : CrudService, IConceptRecordService -{ - private readonly IConceptRecordRepository _conceptRecordRepository; - private readonly IAccessServices _accessServices; - - public ConceptRecordService(IConceptRecordRepository repository, - IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, - IMapper mapper) : base(repository, unitOfWork, mapper) - { - _conceptRecordRepository = repository; - _accessServices = accessServices; - } - - public Result Get(int id, int courseId, int instructorId) - { - if (!_accessServices.IsCourseOwner(courseId, instructorId)) - return Result.Fail(FailureCode.Forbidden); - var record = _conceptRecordRepository.Get(id); - if (record == null || record.CourseId != courseId) - return Result.Fail(FailureCode.NotFound); - return MapToDto(record); - } - - public Result> GetByCourse(int courseId, int instructorId) - { - if (!_accessServices.IsCourseOwner(courseId, instructorId)) - return Result.Fail(FailureCode.Forbidden); - var records = _conceptRecordRepository.GetByCourse(courseId); - return MapToDto(records); - } - - public Result Create(ConceptRecordDto conceptRecord, int instructorId) - { - if (!_accessServices.IsCourseOwner(conceptRecord.CourseId, instructorId)) - return Result.Fail(FailureCode.Forbidden); - return Create(conceptRecord); - } - - public Result Update(ConceptRecordDto conceptRecord, int instructorId) - { - if (!_accessServices.IsCourseOwner(conceptRecord.CourseId, instructorId)) - return Result.Fail(FailureCode.Forbidden); - var existing = _conceptRecordRepository.Get(conceptRecord.Id); - if (existing == null || existing.CourseId != conceptRecord.CourseId) - return Result.Fail(FailureCode.NotFound); - existing.Update(MapToDomain(conceptRecord)); - return Update(existing); - } - - public Result Delete(int id, int courseId, int instructorId) - { - if (!_accessServices.IsCourseOwner(courseId, instructorId)) - return Result.Fail(FailureCode.Forbidden); - var record = _conceptRecordRepository.Get(id); - if (record == null || record.CourseId != courseId) - return Result.Fail(FailureCode.NotFound); - return Delete(id); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs deleted file mode 100644 index a25ffbb5d..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ElaborationTaskService.cs +++ /dev/null @@ -1,96 +0,0 @@ -using AutoMapper; -using FluentResults; -using Tutor.BuildingBlocks.Core.UseCases; -using Tutor.Elaborations.API.Dtos.Conversations; -using Tutor.Elaborations.API.Public; -using Tutor.Elaborations.API.Public.Authoring; -using Tutor.Elaborations.Core.Domain.ConceptRecords; -using Tutor.Elaborations.Core.Domain.ElaborationTasks; - -namespace Tutor.Elaborations.Core.UseCases.Authoring; - -public class ElaborationTaskService : CrudService, IElaborationTaskService -{ - private readonly IElaborationTaskRepository _taskRepository; - private readonly IConceptRecordRepository _conceptRecordRepository; - private readonly IAccessServices _accessServices; - - public ElaborationTaskService(IElaborationTaskRepository taskRepository, - IConceptRecordRepository conceptRecordRepository, - IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, - IMapper mapper) : base(taskRepository, unitOfWork, mapper) - { - _taskRepository = taskRepository; - _conceptRecordRepository = conceptRecordRepository; - _accessServices = accessServices; - } - - public Result> GetByUnit(int unitId, int instructorId) - { - if (!_accessServices.IsUnitOwner(unitId, instructorId)) - return Result.Fail(FailureCode.Forbidden); - - var tasks = _taskRepository.GetByUnit(unitId); - - return Result.Ok(MapToDtos(tasks)); - } - - private List MapToDtos(List tasks) - { - var taskDtos = tasks.Select(MapToDto).ToList(); - - var crIds = taskDtos.Select(t => t.ConceptRecordId).Distinct().ToList(); - var titleMap = _conceptRecordRepository.GetMany(crIds) - .ToDictionary(cr => cr.Id, cr => cr.Title); - foreach (var dto in taskDtos) - if (titleMap.TryGetValue(dto.ConceptRecordId, out var title)) - dto.ConceptRecordTitle = title; - return taskDtos; - } - - public Result Create(ElaborationTaskDto task, int instructorId) - { - if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) - return Result.Fail(FailureCode.Forbidden); - - var validation = ValidateConceptRecordOwnership(task.ConceptRecordId, instructorId); - if (validation.IsFailed) return validation.ToResult(); - - return Create(task); - } - - public Result Update(ElaborationTaskDto task, int instructorId) - { - if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) - return Result.Fail(FailureCode.Forbidden); - - var validation = ValidateConceptRecordOwnership(task.ConceptRecordId, instructorId); - if (validation.IsFailed) return validation.ToResult(); - - var existing = _taskRepository.Get(task.Id); - if (existing == null || existing.UnitId != task.UnitId) - return Result.Fail(FailureCode.NotFound); - existing.Update(MapToDomain(task)); - return Update(existing); - } - - public Result Delete(int id, int unitId, int instructorId) - { - if (!_accessServices.IsUnitOwner(unitId, instructorId)) - return Result.Fail(FailureCode.Forbidden); - var existing = _taskRepository.Get(id); - if (existing == null || existing.UnitId != unitId) - return Result.Fail(FailureCode.NotFound); - return Delete(id); - } - - private Result ValidateConceptRecordOwnership(int conceptRecordId, int instructorId) - { - var conceptRecord = _conceptRecordRepository.Get(conceptRecordId); - if (conceptRecord == null) - return Result.Fail(FailureCode.NotFound); - if (!_accessServices.IsCourseOwner(conceptRecord.CourseId, instructorId)) - return Result.Fail(FailureCode.NotFound); - return Result.Ok(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index ea6e741ec..a5c9acbb8 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -5,12 +5,12 @@ 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.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.Domain.ElaborationTasks; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; namespace Tutor.Elaborations.Core.UseCases.Learning; @@ -20,8 +20,7 @@ public class ConversationService : IConversationService private const int MaxAttemptsPerDay = 3; private readonly IConversationAttemptRepository _attemptRepo; - private readonly IElaborationTaskRepository _taskRepo; - private readonly IConceptRecordRepository _conceptRecordRepo; + private readonly IConceptElaborationTaskRepository _taskRepo; private readonly TurnOrchestrator _turnOrchestrator; private readonly ITokenSpendingService _tokenSpendingService; private readonly IAccessServices _accessServices; @@ -29,14 +28,12 @@ public class ConversationService : IConversationService private readonly IMapper _mapper; public ConversationService(IConversationAttemptRepository attemptRepo, - IElaborationTaskRepository taskRepo, - IConceptRecordRepository conceptRecordRepo, + IConceptElaborationTaskRepository taskRepo, TurnOrchestrator turnOrchestrator, ITokenSpendingService tokenSpendingService, IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, IMapper mapper) { _attemptRepo = attemptRepo; _taskRepo = taskRepo; - _conceptRecordRepo = conceptRecordRepo; _turnOrchestrator = turnOrchestrator; _tokenSpendingService = tokenSpendingService; _accessServices = accessServices; @@ -44,37 +41,26 @@ public ConversationService(IConversationAttemptRepository attemptRepo, _mapper = mapper; } - public Result> GetTasksForUnit(int unitId, int learnerId) + public Result> GetTasksForUnit(int unitId, int learnerId) { if (!_accessServices.IsEnrolledInUnit(unitId, learnerId)) return Result.Fail(FailureCode.Forbidden); var tasks = _taskRepo.GetByUnit(unitId); - - return Result.Ok(PopulateDtos(learnerId, tasks)); - } - - private List PopulateDtos(int learnerId, List tasks) - { var taskIds = tasks.Select(t => t.Id).ToList(); var completedTaskIds = _attemptRepo.GetTaskIdsWithCompletedAttempts(taskIds, learnerId); - var crIds = tasks.Select(t => t.ConceptRecordId).Distinct().ToList(); - var titleMap = _conceptRecordRepo.GetMany(crIds) - .ToDictionary(cr => cr.Id, cr => cr.Title); - - var taskDtos = tasks.Select(t => _mapper.Map(t)).ToList(); - foreach (var dto in taskDtos) + return Result.Ok(tasks.Select(t => new ConceptElaborationTaskSummaryDto { - dto.HasCompletedAttempt = completedTaskIds.Contains(dto.Id); - if (titleMap.TryGetValue(dto.ConceptRecordId, out var title)) - dto.ConceptRecordTitle = title; - } - - return taskDtos; + Id = t.Id, + UnitId = t.UnitId, + Order = t.Order, + Title = t.Title, + HasCompletedAttempt = completedTaskIds.Contains(t.Id) + }).ToList()); } - public Result GetTaskDetail(int taskId, int learnerId) + public Result GetTaskDetail(int taskId, int learnerId) { var task = _taskRepo.Get(taskId); if (task == null) return Result.Fail(FailureCode.NotFound); @@ -82,20 +68,11 @@ public Result GetTaskDetail(int taskId, int learnerId) if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) return Result.Fail(FailureCode.Forbidden); - var conceptRecord = _conceptRecordRepo.Get(task.ConceptRecordId); - if (conceptRecord == null) return Result.Fail(FailureCode.NotFound); - var attempts = _attemptRepo.GetByTaskAndLearner(taskId, learnerId); - return Result.Ok(new ElaborationTaskDetailDto - { - Id = task.Id, - ExpectedLevel = task.ExpectedLevel.ToString(), - Order = task.Order, - ConceptTitle = conceptRecord.Title, - ConceptDefinition = conceptRecord.CanonicalDefinition, - Attempts = attempts.Select(a => _mapper.Map(a)).ToList() - }); + 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 content, @@ -110,9 +87,6 @@ public async IAsyncEnumerable StartConversationAsync(int taskId, string yield break; } - var conceptRecord = _conceptRecordRepo.Get(task.ConceptRecordId); - if (conceptRecord == null) { yield return BuildErrorChunk("Concept record not found.", 404); yield break; } - var balanceCheck = _tokenSpendingService.HasSufficientBalanceForUnit( learnerId, task.UnitId, content.Length); if (balanceCheck.IsFailed) @@ -139,9 +113,7 @@ public async IAsyncEnumerable StartConversationAsync(int taskId, string var attempt = new ConversationAttempt(taskId, learnerId); _attemptRepo.Create(attempt); - var levelRecord = conceptRecord.DeriveForLevel(task.ExpectedLevel); - - await foreach (var token in RunTurnPipelineAsync(attempt, task, levelRecord, content, ct)) + await foreach (var token in RunTurnPipelineAsync(attempt, task, content, ct)) yield return token; } @@ -153,7 +125,7 @@ public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string cont 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 = _taskRepo.Get(attempt.ElaborationTaskId); + var task = _taskRepo.Get(attempt.ConceptElaborationTaskId); if (task == null) { yield return BuildErrorChunk("Task not found.", 404); yield break; } if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) @@ -162,13 +134,6 @@ public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string cont yield break; } - var conceptRecord = _conceptRecordRepo.Get(task.ConceptRecordId); - if (conceptRecord == null) - { - yield return BuildErrorChunk("Concept record not found.", 404); - yield break; - } - var balanceCheck = _tokenSpendingService.HasSufficientBalanceForUnit( learnerId, task.UnitId, content.Length); if (balanceCheck.IsFailed) @@ -177,9 +142,7 @@ public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string cont yield break; } - var levelRecord = conceptRecord.DeriveForLevel(task.ExpectedLevel); - - await foreach (var token in RunTurnPipelineAsync(attempt, task, levelRecord, content, ct)) + await foreach (var token in RunTurnPipelineAsync(attempt, task, content, ct)) yield return token; } @@ -198,19 +161,18 @@ public Result AbandonAttempt(int attemptId, int learnerI } private async IAsyncEnumerable RunTurnPipelineAsync( - ConversationAttempt attempt, ElaborationTask task, - ConceptRecord levelRecord, string content, + ConversationAttempt attempt, ConceptElaborationTask task, string content, [EnumeratorCancellation] CancellationToken ct) { // Synchronous phase: evaluate var evalResult = await _turnOrchestrator.EvaluateAsync( - content, attempt.Turns.ToList(), levelRecord, ct); + content, attempt.Turns.ToList(), task, ct); if (evalResult.IsFailed) { yield return BuildErrorChunk("Evaluation failed. Please try again.", 500); yield break; } var evaluation = evalResult.Value.Evaluation; attempt.AddLearnerTurn(content, evalResult.Value.IsSubstantive, evaluation); - var isCompleted = levelRecord.IsAttemptComplete(attempt); + var isCompleted = task.IsAttemptComplete(attempt); var coveredKpIds = attempt.GetCoveredPropositionIds(); var articulatedRelationIds = attempt.GetArticulatedRelationIds(); var state = new ConversationState @@ -218,10 +180,10 @@ private async IAsyncEnumerable RunTurnPipelineAsync( IsCompleted = isCompleted, IsSoftCapReached = attempt.IsSoftCapReached(), IsHardCapReached = attempt.IsHardCapReached(), - UncoveredKeyPropositionIds = levelRecord.KeyPropositions + UncoveredKeyPropositionIds = task.KeyPropositions .Where(kp => !coveredKpIds.Contains(kp.Id)) .Select(kp => kp.Id).ToList(), - UnarticulatedKeyRelationIds = levelRecord.KeyRelations + UnarticulatedKeyRelationIds = task.KeyRelations .Where(kr => !articulatedRelationIds.Contains(kr.Id)) .Select(kr => kr.Id).ToList() }; @@ -232,7 +194,7 @@ private async IAsyncEnumerable RunTurnPipelineAsync( // Streaming phase: dialogue var fullResponse = new System.Text.StringBuilder(); await foreach (var token in _turnOrchestrator.StreamDialogueAsync( - evaluation, attempt.Turns.ToList(), levelRecord, state, ct)) + evaluation, attempt.Turns.ToList(), task, state, ct)) { fullResponse.Append(token); yield return token; @@ -244,13 +206,13 @@ private async IAsyncEnumerable RunTurnPipelineAsync( string? summary = null; if (isCompleted) { - var summaryResult = await _turnOrchestrator.SummarizeAsync(attempt, levelRecord, ct); + var summaryResult = await _turnOrchestrator.SummarizeAsync(attempt, task, ct); summary = summaryResult.IsSuccess ? summaryResult.Value : null; attempt.Complete(summary); } else if (state.IsHardCapReached) { - var summaryResult = await _turnOrchestrator.SummarizeAsync(attempt, levelRecord, ct); + var summaryResult = await _turnOrchestrator.SummarizeAsync(attempt, task, ct); summary = summaryResult.IsSuccess ? summaryResult.Value : null; attempt.Expire(summary); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs index 2c3ca7924..23d3525fd 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs @@ -1,4 +1,4 @@ -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; @@ -6,6 +6,6 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public interface IDialogueAgent { IAsyncEnumerable StreamAsync(TurnEvaluation evaluation, - List history, ConceptRecord conceptRecord, + List history, ConceptElaborationTask task, ConversationState state, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs index 11b41df37..7396de06b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs @@ -1,5 +1,5 @@ using FluentResults; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; @@ -7,6 +7,6 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public interface IEvaluationAgent { Task> EvaluateAsync(string content, - List history, ConceptRecord conceptRecord, + List history, ConceptElaborationTask task, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ISummaryAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ISummaryAgent.cs index baa3ed246..c9c6ab4b6 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ISummaryAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ISummaryAgent.cs @@ -1,5 +1,5 @@ using FluentResults; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; @@ -7,5 +7,5 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public interface ISummaryAgent { Task> SummarizeAsync(ConversationAttempt attempt, - ConceptRecord conceptRecord, CancellationToken ct); + ConceptElaborationTask task, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnOrchestrator.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnOrchestrator.cs index 81c0de07f..e864b9556 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnOrchestrator.cs @@ -1,5 +1,5 @@ using FluentResults; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; @@ -19,22 +19,22 @@ public TurnOrchestrator(IEvaluationAgent evaluationAgent, } public async Task> EvaluateAsync(string content, - List history, ConceptRecord conceptRecord, + List history, ConceptElaborationTask task, CancellationToken ct) { - return await _evaluationAgent.EvaluateAsync(content, history, conceptRecord, ct); + return await _evaluationAgent.EvaluateAsync(content, history, task, ct); } public IAsyncEnumerable StreamDialogueAsync(TurnEvaluation evaluation, - List history, ConceptRecord conceptRecord, + List history, ConceptElaborationTask task, ConversationState state, CancellationToken ct) { - return _dialogueAgent.StreamAsync(evaluation, history, conceptRecord, state, ct); + return _dialogueAgent.StreamAsync(evaluation, history, task, state, ct); } public async Task> SummarizeAsync(ConversationAttempt attempt, - ConceptRecord conceptRecord, CancellationToken ct) + ConceptElaborationTask task, CancellationToken ct) { - return await _summaryAgent.SummarizeAsync(attempt, conceptRecord, ct); + return await _summaryAgent.SummarizeAsync(attempt, task, ct); } } 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.Core/UseCases/Monitoring/ElaborationTaskQuerier.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Monitoring/ElaborationTaskQuerier.cs deleted file mode 100644 index 882a19e95..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Monitoring/ElaborationTaskQuerier.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Tutor.Elaborations.API.Internal; -using Tutor.Elaborations.Core.Domain.ElaborationTasks; - -namespace Tutor.Elaborations.Core.UseCases.Monitoring; - -public class ElaborationTaskQuerier : IElaborationTaskQuerier -{ - private readonly IElaborationTaskRepository _taskRepository; - - public ElaborationTaskQuerier(IElaborationTaskRepository taskRepository) - { - _taskRepository = taskRepository; - } - - public int CountByUnit(int unitId) - { - return _taskRepository.GetByUnit(unitId).Count; - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs index 97664403d..9c587ef91 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs @@ -1,6 +1,6 @@ using System.Runtime.CompilerServices; using Tutor.BuildingBlocks.AI.Core.Conversations; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; using Tutor.Elaborations.Infrastructure.Agents.Prompts; @@ -17,10 +17,10 @@ public DialogueAgent(IAiChatService chatService) } public async IAsyncEnumerable StreamAsync(TurnEvaluation evaluation, - List history, ConceptRecord conceptRecord, + List history, ConceptElaborationTask task, ConversationState state, [EnumeratorCancellation] CancellationToken ct) { - var systemPrompt = DialoguePromptBuilder.BuildSystemPrompt(conceptRecord, state); + var systemPrompt = DialoguePromptBuilder.BuildSystemPrompt(task, state); var messageData = DialoguePromptBuilder.BuildMessages(history); var summaryParts = new List diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs index 4df33b756..b9c5e9847 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs @@ -1,7 +1,7 @@ using System.Text.Json; using FluentResults; using Tutor.BuildingBlocks.AI.Core.Conversations; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; using Tutor.Elaborations.Infrastructure.Agents.Prompts; @@ -18,10 +18,10 @@ public EvaluationAgent(IAiChatService chatService) } public async Task> EvaluateAsync(string content, - List history, ConceptRecord conceptRecord, + List history, ConceptElaborationTask task, CancellationToken ct) { - var systemPrompt = EvaluationPromptBuilder.BuildSystemPrompt(conceptRecord); + var systemPrompt = EvaluationPromptBuilder.BuildSystemPrompt(task); var messageData = EvaluationPromptBuilder.BuildMessages(content, history); var messages = messageData.Select(m => diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs index 978470c03..e0893e18c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs @@ -1,5 +1,5 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; @@ -7,7 +7,7 @@ namespace Tutor.Elaborations.Infrastructure.Agents.Prompts; public static class DialoguePromptBuilder { - public static string BuildSystemPrompt(ConceptRecord record, ConversationState state) + public static string BuildSystemPrompt(ConceptElaborationTask task, ConversationState state) { var sb = new StringBuilder(); sb.AppendLine("You are a Socratic dialogue agent for a tutoring system. You speak Serbian."); @@ -22,20 +22,20 @@ public static string BuildSystemPrompt(ConceptRecord record, ConversationState s sb.AppendLine("- Allow productive divergence within the concept space."); sb.AppendLine(); - sb.AppendLine($"## Concept: {record.Title}"); - sb.AppendLine($"Definition: {record.CanonicalDefinition}"); + sb.AppendLine($"## Concept: {task.Title}"); + sb.AppendLine($"Definition: {task.CanonicalDefinition}"); sb.AppendLine(); sb.AppendLine("## Key Propositions (for your reference only, never reveal):"); - foreach (var kp in record.KeyPropositions) + foreach (var kp in task.KeyPropositions) sb.AppendLine($"- [KP-{kp.Id}] {kp.Statement}"); sb.AppendLine(); - if (record.KeyRelations.Any()) + if (task.KeyRelations.Any()) { sb.AppendLine("## Key Relations (for your reference only, never reveal the mechanism text):"); - var kpById = record.KeyPropositions.ToDictionary(kp => kp.Id, kp => kp.Statement); - foreach (var kr in record.KeyRelations) + var kpById = task.KeyPropositions.ToDictionary(kp => kp.Id, kp => kp.Statement); + foreach (var kr in task.KeyRelations) { var sourceText = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, $"KP-{kr.SourceKeyPropositionId}"); var targetText = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, $"KP-{kr.TargetKeyPropositionId}"); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs index bd0895207..2b9df3dd7 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs @@ -1,34 +1,34 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Infrastructure.Agents.Prompts; public static class EvaluationPromptBuilder { - public static string BuildSystemPrompt(ConceptRecord record) + public static string BuildSystemPrompt(ConceptElaborationTask task) { - var hasBoundaryConditions = record.BoundaryConditions.Any(); - var hasCommonMisconceptions = record.CommonMisconceptions.Any(); - var hasKeyRelations = record.KeyRelations.Any(); + var hasBoundaryConditions = task.BoundaryConditions.Any(); + var hasCommonMisconceptions = task.CommonMisconceptions.Any(); + var hasKeyRelations = task.KeyRelations.Any(); var sb = new StringBuilder(); sb.AppendLine("You are an evaluation agent for a Socratic tutoring system."); sb.AppendLine("Your task: evaluate the learner's latest response against the concept rubric below."); sb.AppendLine(); - sb.AppendLine($"## Concept: {record.Title}"); - sb.AppendLine($"Definition: {record.CanonicalDefinition}"); + sb.AppendLine($"## Concept: {task.Title}"); + sb.AppendLine($"Definition: {task.CanonicalDefinition}"); sb.AppendLine(); sb.AppendLine("## Key Propositions:"); - foreach (var kp in record.KeyPropositions) + foreach (var kp in task.KeyPropositions) sb.AppendLine($"- [KP-{kp.Id}] {kp.Statement}"); sb.AppendLine(); if (hasBoundaryConditions) { sb.AppendLine("## Boundary Conditions:"); - foreach (var bc in record.BoundaryConditions) + foreach (var bc in task.BoundaryConditions) sb.AppendLine($"- [BC-{bc.Id}] {bc.Statement}"); sb.AppendLine(); } @@ -36,7 +36,7 @@ public static string BuildSystemPrompt(ConceptRecord record) if (hasCommonMisconceptions) { sb.AppendLine("## Common Misconceptions:"); - foreach (var cm in record.CommonMisconceptions.Take(8)) + foreach (var cm in task.CommonMisconceptions.Take(8)) sb.AppendLine($"- [CM-{cm.Id}] {cm.Description} → Correction: {cm.Correction}"); sb.AppendLine(); } @@ -44,8 +44,8 @@ public static string BuildSystemPrompt(ConceptRecord record) if (hasKeyRelations) { sb.AppendLine("## Key Relations:"); - var kpById = record.KeyPropositions.ToDictionary(kp => kp.Id, kp => kp.Statement); - foreach (var kr in record.KeyRelations) + var kpById = task.KeyPropositions.ToDictionary(kp => kp.Id, kp => kp.Statement); + foreach (var kr in task.KeyRelations) { var sourceText = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, $"KP-{kr.SourceKeyPropositionId}"); var targetText = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, $"KP-{kr.TargetKeyPropositionId}"); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/SummaryPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/SummaryPromptBuilder.cs index 6873d355c..8bdc340d9 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/SummaryPromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/SummaryPromptBuilder.cs @@ -1,22 +1,22 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Infrastructure.Agents.Prompts; public static class SummaryPromptBuilder { - public static string BuildSystemPrompt(ConversationAttempt attempt, ConceptRecord record) + public static string BuildSystemPrompt(ConversationAttempt attempt, ConceptElaborationTask task) { var sb = new StringBuilder(); sb.AppendLine("You are a summary agent. Write a brief natural-language summary of the conversation."); sb.AppendLine("Paraphrase what the learner demonstrated understanding of. Never quote proposition statements verbatim."); sb.AppendLine("Write in Serbian. Keep the summary to 2-4 sentences."); sb.AppendLine(); - sb.AppendLine($"Concept: {record.Title}"); + sb.AppendLine($"Concept: {task.Title}"); var coveredIds = attempt.GetCoveredPropositionIds(); - var covered = record.KeyPropositions.Where(kp => coveredIds.Contains(kp.Id)).ToList(); + var covered = task.KeyPropositions.Where(kp => coveredIds.Contains(kp.Id)).ToList(); if (covered.Count > 0) { sb.AppendLine("Propositions the learner covered (paraphrase, do not quote):"); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/SummaryAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/SummaryAgent.cs index 105827136..9780c8802 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/SummaryAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/SummaryAgent.cs @@ -1,6 +1,6 @@ using FluentResults; using Tutor.BuildingBlocks.AI.Core.Conversations; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; using Tutor.Elaborations.Infrastructure.Agents.Prompts; @@ -17,9 +17,9 @@ public SummaryAgent(IAiChatService chatService) } public async Task> SummarizeAsync(ConversationAttempt attempt, - ConceptRecord conceptRecord, CancellationToken ct) + ConceptElaborationTask task, CancellationToken ct) { - var systemPrompt = SummaryPromptBuilder.BuildSystemPrompt(attempt, conceptRecord); + var systemPrompt = SummaryPromptBuilder.BuildSystemPrompt(attempt, task); var transcript = SummaryPromptBuilder.BuildTranscript(attempt); var request = CompletionRequest.SingleMessage(transcript, systemPrompt, maxTokens: 256, temperature: 0.5); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs index 568bf9fed..4f323f125 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -1,18 +1,16 @@ using Microsoft.EntityFrameworkCore; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.Domain.ElaborationTasks; namespace Tutor.Elaborations.Infrastructure.Database; public class ElaborationsContext : DbContext { - public DbSet ConceptRecords { get; set; } + public DbSet ConceptElaborationTasks { get; set; } public DbSet KeyPropositions { get; set; } public DbSet BoundaryConditions { get; set; } public DbSet CommonMisconceptions { get; set; } public DbSet KeyRelations { get; set; } - public DbSet ElaborationTasks { get; set; } public DbSet ConversationAttempts { get; set; } public DbSet ConversationTurns { get; set; } public DbSet TurnEvaluations { get; set; } @@ -23,32 +21,34 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasDefaultSchema("elaborations"); - ConfigureConceptRecords(modelBuilder); - ConfigureElaborationTasks(modelBuilder); + ConfigureConceptElaborationTasks(modelBuilder); ConfigureConversations(modelBuilder); } - private static void ConfigureConceptRecords(ModelBuilder modelBuilder) + private static void ConfigureConceptElaborationTasks(ModelBuilder modelBuilder) { - modelBuilder.Entity() - .HasMany(cr => cr.KeyPropositions) + modelBuilder.Entity() + .HasMany(cet => cet.KeyPropositions) .WithOne() - .HasForeignKey(kp => kp.ConceptRecordId); + .HasForeignKey(kp => kp.ConceptElaborationTaskId); - modelBuilder.Entity() - .HasMany(cr => cr.BoundaryConditions) + modelBuilder.Entity() + .HasMany(cet => cet.BoundaryConditions) .WithOne() - .HasForeignKey(bc => bc.ConceptRecordId); + .HasForeignKey(bc => bc.ConceptElaborationTaskId); - modelBuilder.Entity() - .HasMany(cr => cr.CommonMisconceptions) + modelBuilder.Entity() + .HasMany(cet => cet.CommonMisconceptions) .WithOne() - .HasForeignKey(cm => cm.ConceptRecordId); + .HasForeignKey(cm => cm.ConceptElaborationTaskId); - modelBuilder.Entity() - .HasMany(cr => cr.KeyRelations) + modelBuilder.Entity() + .HasMany(cet => cet.KeyRelations) .WithOne() - .HasForeignKey(kr => kr.ConceptRecordId); + .HasForeignKey(kr => kr.ConceptElaborationTaskId); + + modelBuilder.Entity() + .HasIndex(cet => new { cet.UnitId, cet.Order }); modelBuilder.Entity() .HasOne(kr => kr.SourceKeyProposition) @@ -63,18 +63,6 @@ private static void ConfigureConceptRecords(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.ClientNoAction); } - private static void ConfigureElaborationTasks(ModelBuilder modelBuilder) - { - modelBuilder.Entity() - .HasOne() - .WithMany() - .HasForeignKey(et => et.ConceptRecordId) - .OnDelete(DeleteBehavior.Restrict); - - modelBuilder.Entity() - .HasIndex(et => new { et.UnitId, et.Order }); - } - private static void ConfigureConversations(ModelBuilder modelBuilder) { modelBuilder.Entity() @@ -83,7 +71,7 @@ private static void ConfigureConversations(ModelBuilder modelBuilder) .HasForeignKey(ct => ct.ConversationAttemptId); modelBuilder.Entity() - .HasIndex(ca => new { ca.ElaborationTaskId, ca.LearnerId }); + .HasIndex(ca => new { ca.ConceptElaborationTaskId, ca.LearnerId }); modelBuilder.Entity() .HasOne(ct => ct.Evaluation) 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..99f45f02b --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptElaborationTaskDatabaseRepository.cs @@ -0,0 +1,33 @@ +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 new ConceptElaborationTask? Get(int id) + { + return DbContext.ConceptElaborationTasks + .Include(cet => cet.KeyPropositions) + .Include(cet => cet.BoundaryConditions) + .Include(cet => cet.CommonMisconceptions) + .Include(cet => cet.KeyRelations) + .FirstOrDefault(cet => cet.Id == id); + } + + public List GetByUnit(int unitId) + { + return DbContext.ConceptElaborationTasks + .Include(cet => cet.KeyPropositions) + .Include(cet => cet.BoundaryConditions) + .Include(cet => cet.CommonMisconceptions) + .Include(cet => cet.KeyRelations) + .Where(cet => cet.UnitId == unitId) + .OrderBy(cet => cet.Order) + .ToList(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs deleted file mode 100644 index 723861520..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptRecordDatabaseRepository.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Tutor.BuildingBlocks.Infrastructure.Database; -using Tutor.Elaborations.Core.Domain.ConceptRecords; - -namespace Tutor.Elaborations.Infrastructure.Database.Repositories; - -public class ConceptRecordDatabaseRepository : - CrudDatabaseRepository, IConceptRecordRepository -{ - public ConceptRecordDatabaseRepository(ElaborationsContext dbContext) : base(dbContext) { } - - public new ConceptRecord? Get(int id) - { - return DbContext.ConceptRecords - .Include(cr => cr.KeyPropositions) - .Include(cr => cr.BoundaryConditions) - .Include(cr => cr.CommonMisconceptions) - .Include(cr => cr.KeyRelations) - .FirstOrDefault(cr => cr.Id == id); - } - - public List GetByCourse(int courseId) - { - return DbContext.ConceptRecords - .Include(cr => cr.KeyPropositions) - .Include(cr => cr.BoundaryConditions) - .Include(cr => cr.CommonMisconceptions) - .Include(cr => cr.KeyRelations) - .Where(cr => cr.CourseId == courseId) - .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 index 9c4586cd3..7635cd923 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs @@ -17,29 +17,29 @@ public ConversationAttemptDatabaseRepository(ElaborationsContext dbContext) : ba .FirstOrDefault(ca => ca.Id == id); } - public ConversationAttempt? GetActiveAttempt(int elaborationTaskId, int learnerId) + public ConversationAttempt? GetActiveAttempt(int conceptElaborationTaskId, int learnerId) { return DbContext.ConversationAttempts .Include(ca => ca.Turns.OrderBy(t => t.Order)) .ThenInclude(t => t.Evaluation) - .FirstOrDefault(ca => ca.ElaborationTaskId == elaborationTaskId + .FirstOrDefault(ca => ca.ConceptElaborationTaskId == conceptElaborationTaskId && ca.LearnerId == learnerId && ca.Status == AttemptStatus.InProgress); } - public List GetByTaskAndLearner(int elaborationTaskId, int learnerId) + public List GetByTaskAndLearner(int conceptElaborationTaskId, int learnerId) { return DbContext.ConversationAttempts .Include(ca => ca.Turns.OrderBy(t => t.Order)) - .Where(ca => ca.ElaborationTaskId == elaborationTaskId && ca.LearnerId == learnerId) + .Where(ca => ca.ConceptElaborationTaskId == conceptElaborationTaskId && ca.LearnerId == learnerId) .OrderByDescending(ca => ca.StartedAt) .ToList(); } - public int CountRecentAttempts(int elaborationTaskId, int learnerId, DateTime since) + public int CountRecentAttempts(int conceptElaborationTaskId, int learnerId, DateTime since) { return DbContext.ConversationAttempts - .Count(ca => ca.ElaborationTaskId == elaborationTaskId + .Count(ca => ca.ConceptElaborationTaskId == conceptElaborationTaskId && ca.LearnerId == learnerId && ca.StartedAt >= since); } @@ -47,10 +47,10 @@ public int CountRecentAttempts(int elaborationTaskId, int learnerId, DateTime si public HashSet GetTaskIdsWithCompletedAttempts(List taskIds, int learnerId) { return DbContext.ConversationAttempts - .Where(ca => taskIds.Contains(ca.ElaborationTaskId) + .Where(ca => taskIds.Contains(ca.ConceptElaborationTaskId) && ca.LearnerId == learnerId && ca.Status == AttemptStatus.Completed) - .Select(ca => ca.ElaborationTaskId) + .Select(ca => ca.ConceptElaborationTaskId) .Distinct() .ToHashSet(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ElaborationTaskDatabaseRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ElaborationTaskDatabaseRepository.cs deleted file mode 100644 index c4a193c8a..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ElaborationTaskDatabaseRepository.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Tutor.BuildingBlocks.Infrastructure.Database; -using Tutor.Elaborations.Core.Domain.ElaborationTasks; - -namespace Tutor.Elaborations.Infrastructure.Database.Repositories; - -public class ElaborationTaskDatabaseRepository : - CrudDatabaseRepository, IElaborationTaskRepository -{ - public ElaborationTaskDatabaseRepository(ElaborationsContext dbContext) : base(dbContext) { } - - public List GetByUnit(int unitId) - { - return DbContext.ElaborationTasks - .Where(et => et.UnitId == unitId) - .OrderBy(et => et.Order) - .ToList(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs index 68386d73d..f90897367 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs @@ -7,9 +7,8 @@ using Tutor.Elaborations.API.Public; using Tutor.Elaborations.API.Public.Authoring; using Tutor.Elaborations.API.Public.Learning; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.Domain.ElaborationTasks; using Tutor.Elaborations.Core.Mappers; using Tutor.Elaborations.Core.UseCases; using Tutor.Elaborations.Core.UseCases.Authoring; @@ -34,23 +33,21 @@ public static IServiceCollection ConfigureElaborationsModule(this IServiceCollec private static void SetupAutoMapper(IServiceCollection services) { - services.AddAutoMapper(typeof(ConceptRecordProfile).Assembly); + services.AddAutoMapper(typeof(ConceptElaborationTaskProfile).Assembly); } private static void SetupCore(IServiceCollection services) { - services.AddProxiedScoped(); - services.AddProxiedScoped(); + services.AddProxiedScoped(); services.AddProxiedScoped(); services.AddProxiedScoped(); - services.AddProxiedScoped(); + services.AddProxiedScoped(); services.AddScoped(); } private static void SetupInfrastructure(IServiceCollection services) { - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs similarity index 77% rename from src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs index 9168f212f..9c60cca12 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordCommandTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs @@ -2,16 +2,16 @@ using Microsoft.Extensions.DependencyInjection; using Shouldly; using Tutor.API.Controllers.Instructor.Authoring.Elaboration; -using Tutor.Elaborations.API.Dtos.ConceptRecords; +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 ConceptRecordCommandTests : BaseElaborationsIntegrationTest +public class ConceptElaborationTaskCommandTests : BaseElaborationsIntegrationTest { - public ConceptRecordCommandTests(ElaborationsTestFactory factory) : base(factory) { } + public ConceptElaborationTaskCommandTests(ElaborationsTestFactory factory) : base(factory) { } [Fact] public void Creates() @@ -19,19 +19,20 @@ public void Creates() using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope); var dbContext = scope.ServiceProvider.GetRequiredService(); - var newEntity = new ConceptRecordDto + var newEntity = new ConceptElaborationTaskDto { - CourseId = -1, + UnitId = -1, + Order = 10, Title = "New Concept", CanonicalDefinition = "A new concept definition.", KeyPropositions = new List { - new() { Statement = "First proposition", Level = "Beginner" }, - new() { Statement = "Second proposition", Level = "Intermediate" } + new() { Statement = "First proposition" }, + new() { Statement = "Second proposition" } }, BoundaryConditions = new List { - new() { Statement = "A boundary condition", Level = "Beginner" } + new() { Statement = "A boundary condition" } }, CommonMisconceptions = new List { @@ -42,14 +43,14 @@ public void Creates() dbContext.Database.BeginTransaction(); var actionResult = controller.Create(-1, newEntity).Result; - var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; dbContext.ChangeTracker.Clear(); result.ShouldNotBeNull(); result.Title.ShouldBe(newEntity.Title); - result.CourseId.ShouldBe(-1); + result.UnitId.ShouldBe(-1); + result.Order.ShouldBe(10); result.KeyPropositions.Count.ShouldBe(2); - result.KeyPropositions[0].Level.ShouldBe("Beginner"); result.BoundaryConditions.Count.ShouldBe(1); result.CommonMisconceptions.Count.ShouldBe(1); result.KeyRelations.Count.ShouldBe(0); @@ -61,15 +62,16 @@ public void Creates_with_relations() using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope); var dbContext = scope.ServiceProvider.GetRequiredService(); - var newEntity = new ConceptRecordDto + var newEntity = new ConceptElaborationTaskDto { - CourseId = -1, + UnitId = -1, + Order = 11, Title = "Concept With Relations", CanonicalDefinition = "A concept created with KPs and KRs in one request.", KeyPropositions = new List { - new() { Statement = "First proposition", Level = "Beginner" }, - new() { Statement = "Second proposition", Level = "Beginner" } + new() { Statement = "First proposition" }, + new() { Statement = "Second proposition" } }, BoundaryConditions = new List(), CommonMisconceptions = new List(), @@ -78,14 +80,14 @@ public void Creates_with_relations() new() { SourceKeyPropositionIndex = 0, TargetKeyPropositionIndex = 1, - Mechanism = "First enables second", Level = "Beginner" + Mechanism = "First enables second" } } }; dbContext.Database.BeginTransaction(); var actionResult = controller.Create(-1, newEntity).Result; - var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; dbContext.ChangeTracker.Clear(); result.ShouldNotBeNull(); @@ -104,15 +106,16 @@ public void Updates() using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope); var dbContext = scope.ServiceProvider.GetRequiredService(); - var updatedEntity = new ConceptRecordDto + var updatedEntity = new ConceptElaborationTaskDto { Id = -1, - CourseId = -1, + UnitId = -1, + Order = 1, Title = "Updated Encapsulation", CanonicalDefinition = "Updated definition.", KeyPropositions = new List { - new() { Statement = "Updated proposition", Level = "Beginner" } + new() { Statement = "Updated proposition" } }, BoundaryConditions = new List(), CommonMisconceptions = new List(), @@ -121,7 +124,7 @@ public void Updates() dbContext.Database.BeginTransaction(); var actionResult = controller.Update(-1, -1, updatedEntity).Result; - var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; dbContext.ChangeTracker.Clear(); result.ShouldNotBeNull(); @@ -137,17 +140,19 @@ public void Updates_relations_with_indices() using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope); var dbContext = scope.ServiceProvider.GetRequiredService(); - var updatedEntity = new ConceptRecordDto + // CET -7 has KPs -70, -71 and KR -370 (source=-70, target=-71). + var updatedEntity = new ConceptElaborationTaskDto { - Id = -5, - CourseId = -1, + Id = -7, + UnitId = -2, + Order = 4, Title = "Polymorphism Mechanics", CanonicalDefinition = "Polymorphism resolves method calls at runtime via dynamic dispatch.", KeyPropositions = new List { - new() { Id = -50, Statement = "A subclass can override a parent method", Level = "Beginner" }, - new() { Id = -51, Statement = "The runtime selects the implementation by the actual type", Level = "Beginner" }, - new() { Statement = "Dispatch table resolves virtual calls", Level = "Intermediate" } + new() { Id = -70, Statement = "A subclass can override a parent method" }, + new() { Id = -71, Statement = "The runtime selects the implementation by the actual type" }, + new() { Statement = "Dispatch table resolves virtual calls" } }, BoundaryConditions = new List(), CommonMisconceptions = new List(), @@ -156,21 +161,19 @@ public void Updates_relations_with_indices() new() { SourceKeyPropositionIndex = 0, TargetKeyPropositionIndex = 1, - Mechanism = "Override matters because dispatch happens at runtime", - Level = "Beginner" + Mechanism = "Override matters because dispatch happens at runtime" }, new() { SourceKeyPropositionIndex = 1, TargetKeyPropositionIndex = 2, - Mechanism = "Runtime dispatch uses vtable lookup", - Level = "Intermediate" + Mechanism = "Runtime dispatch uses vtable lookup" } } }; dbContext.Database.BeginTransaction(); - var actionResult = controller.Update(-1, -5, updatedEntity).Result; - var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + var actionResult = controller.Update(-2, -7, updatedEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; dbContext.ChangeTracker.Clear(); result.ShouldNotBeNull(); @@ -186,17 +189,17 @@ public void Removes_relation_and_referenced_kp() using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope); var dbContext = scope.ServiceProvider.GetRequiredService(); - // CR -5 has KP -50, KP -51, and KR -100 (source=-50, target=-51). - // Remove KR and KP -51, keeping only KP -50. - var updatedEntity = new ConceptRecordDto + // CET -7 has KPs -70, -71 and KR -370. Remove KR and KP -71, keeping only KP -70. + var updatedEntity = new ConceptElaborationTaskDto { - Id = -5, - CourseId = -1, + Id = -7, + UnitId = -2, + Order = 4, Title = "Polymorphism Mechanics", CanonicalDefinition = "Polymorphism resolves method calls at runtime via dynamic dispatch.", KeyPropositions = new List { - new() { Id = -50, Statement = "A subclass can override a parent method", Level = "Beginner" } + new() { Id = -70, Statement = "A subclass can override a parent method" } }, BoundaryConditions = new List(), CommonMisconceptions = new List(), @@ -204,8 +207,8 @@ public void Removes_relation_and_referenced_kp() }; dbContext.Database.BeginTransaction(); - var actionResult = controller.Update(-1, -5, updatedEntity).Result; - var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + var actionResult = controller.Update(-2, -7, updatedEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; dbContext.ChangeTracker.Clear(); result.ShouldNotBeNull(); @@ -221,12 +224,12 @@ public void Deletes() var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.Database.BeginTransaction(); - var result = (OkResult)controller.Delete(-1, -4); + var result = (OkResult)controller.Delete(-2, -7); dbContext.ChangeTracker.Clear(); result.ShouldNotBeNull(); result.StatusCode.ShouldBe(200); - var stored = dbContext.ConceptRecords.FirstOrDefault(cr => cr.Id == -4); + var stored = dbContext.ConceptElaborationTasks.FirstOrDefault(cet => cet.Id == -7); stored.ShouldBeNull(); } @@ -248,9 +251,10 @@ public void Non_owner_fails_to_create() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope); - var newEntity = new ConceptRecordDto + var newEntity = new ConceptElaborationTaskDto { - CourseId = -2, + UnitId = -3, + Order = 99, Title = "Should Fail", CanonicalDefinition = "Fail", KeyPropositions = new List(), @@ -259,7 +263,7 @@ public void Non_owner_fails_to_create() KeyRelations = new List() }; - var actionResult = controller.Create(-2, newEntity).Result; + var actionResult = controller.Create(-3, newEntity).Result; var objectResult = actionResult as ObjectResult; objectResult.ShouldNotBeNull(); @@ -271,10 +275,11 @@ public void Non_owner_fails_to_update() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope); - var updatedEntity = new ConceptRecordDto + var updatedEntity = new ConceptElaborationTaskDto { - Id = -3, - CourseId = -2, + Id = -4, + UnitId = -3, + Order = 1, Title = "Should Fail", CanonicalDefinition = "Fail", KeyPropositions = new List(), @@ -283,7 +288,7 @@ public void Non_owner_fails_to_update() KeyRelations = new List() }; - var actionResult = controller.Update(-2, -3, updatedEntity).Result; + var actionResult = controller.Update(-3, -4, updatedEntity).Result; var objectResult = actionResult as ObjectResult; objectResult.ShouldNotBeNull(); @@ -296,16 +301,17 @@ public void Non_owner_fails_to_delete() using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope); - var actionResult = controller.Delete(-2, -3); + var actionResult = controller.Delete(-3, -4); var objectResult = actionResult as ObjectResult; objectResult.ShouldNotBeNull(); objectResult.StatusCode.ShouldBe(403); } - private static ConceptRecordController CreateController(IServiceScope scope) + private static ConceptElaborationTaskController CreateController(IServiceScope scope) { - return new ConceptRecordController(scope.ServiceProvider.GetRequiredService()) + return new ConceptElaborationTaskController( + scope.ServiceProvider.GetRequiredService()) { ControllerContext = BuildContext("-51", "instructor") }; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskQueryTests.cs similarity index 57% rename from src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskQueryTests.cs index 5bcdddc10..ef617c90a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptRecordQueryTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskQueryTests.cs @@ -2,15 +2,15 @@ using Microsoft.Extensions.DependencyInjection; using Shouldly; using Tutor.API.Controllers.Instructor.Authoring.Elaboration; -using Tutor.Elaborations.API.Dtos.ConceptRecords; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; using Tutor.Elaborations.API.Public.Authoring; namespace Tutor.Elaborations.Tests.Integration.Authoring; [Collection("Sequential")] -public class ConceptRecordQueryTests : BaseElaborationsIntegrationTest +public class ConceptElaborationTaskQueryTests : BaseElaborationsIntegrationTest { - public ConceptRecordQueryTests(ElaborationsTestFactory factory) : base(factory) { } + public ConceptElaborationTaskQueryTests(ElaborationsTestFactory factory) : base(factory) { } [Fact] public void Gets_by_id() @@ -19,52 +19,51 @@ public void Gets_by_id() var controller = CreateController(scope); var actionResult = controller.Get(-1, -1).Result; - var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; result.ShouldNotBeNull(); result.Id.ShouldBe(-1); - result.CourseId.ShouldBe(-1); - result.Title.ShouldBe("Encapsulation"); - result.KeyPropositions.Count.ShouldBe(3); - result.KeyPropositions.ShouldContain(kp => kp.Statement == "Data and methods are bundled in a class" && kp.Level == "Beginner"); - result.BoundaryConditions.Count.ShouldBe(2); - result.CommonMisconceptions.Count.ShouldBe(2); + result.UnitId.ShouldBe(-1); + result.Title.ShouldBe("Encapsulation (Basics)"); + result.KeyPropositions.Count.ShouldBe(1); + result.KeyPropositions.ShouldContain(kp => kp.Statement == "Data and methods are bundled in a class"); + result.BoundaryConditions.Count.ShouldBe(1); + result.CommonMisconceptions.Count.ShouldBe(1); } [Fact] - public void Gets_by_course() + public void Gets_by_unit() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope); - var actionResult = controller.GetByCourse(-1).Result; - var result = (actionResult as OkObjectResult)?.Value as List; + var actionResult = controller.GetByUnit(-1).Result; + var result = (actionResult as OkObjectResult)?.Value as List; result.ShouldNotBeNull(); - result.Count.ShouldBe(4); - var withRelations = result.SingleOrDefault(cr => cr.Id == -5); - withRelations.ShouldNotBeNull(); - withRelations.KeyRelations.Count.ShouldBe(1); - withRelations.KeyRelations[0].SourceKeyPropositionId.ShouldBe(-50); - withRelations.KeyRelations[0].TargetKeyPropositionId.ShouldBe(-51); - withRelations.KeyRelations[0].Mechanism.ShouldContain("dispatch happens at runtime"); + 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 Gets_record_with_relations() + public void Gets_task_with_relations() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope); - var actionResult = controller.Get(-1, -5).Result; - var result = (actionResult as OkObjectResult)?.Value as ConceptRecordDto; + var actionResult = controller.Get(-2, -7).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; result.ShouldNotBeNull(); result.KeyPropositions.Count.ShouldBe(2); result.BoundaryConditions.Count.ShouldBe(0); result.CommonMisconceptions.Count.ShouldBe(0); result.KeyRelations.Count.ShouldBe(1); - result.KeyRelations[0].Level.ShouldBe("Beginner"); + result.KeyRelations[0].SourceKeyPropositionId.ShouldBe(-70); + result.KeyRelations[0].TargetKeyPropositionId.ShouldBe(-71); + result.KeyRelations[0].Mechanism.ShouldContain("dispatch happens at runtime"); } [Fact] @@ -73,7 +72,7 @@ public void Non_owner_fails_to_get() using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope); - var actionResult = controller.Get(-2, -3).Result; + var actionResult = controller.Get(-3, -4).Result; var objectResult = actionResult as ObjectResult; objectResult.ShouldNotBeNull(); @@ -81,12 +80,12 @@ public void Non_owner_fails_to_get() } [Fact] - public void Non_owner_fails_to_get_by_course() + public void Non_owner_fails_to_get_by_unit() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope); - var actionResult = controller.GetByCourse(-2).Result; + var actionResult = controller.GetByUnit(-3).Result; var objectResult = actionResult as ObjectResult; objectResult.ShouldNotBeNull(); @@ -107,15 +106,11 @@ public void Fails_to_get_nonexistent() } [Fact] - public void Fails_to_get_record_from_wrong_course() + public void Fails_to_get_task_from_wrong_unit() { using var scope = Factory.Services.CreateScope(); - // Instructor -52 owns course -2, but CR -1 belongs to course -1 - var controller = new ConceptRecordController( - scope.ServiceProvider.GetRequiredService()) - { - ControllerContext = BuildContext("-52", "instructor") - }; + // Instructor -51 owns Unit -2, but CET -1 belongs to Unit -1 + var controller = CreateController(scope); var actionResult = controller.Get(-2, -1).Result; var objectResult = actionResult as ObjectResult; @@ -124,9 +119,10 @@ public void Fails_to_get_record_from_wrong_course() objectResult.StatusCode.ShouldBe(404); } - private static ConceptRecordController CreateController(IServiceScope scope) + private static ConceptElaborationTaskController CreateController(IServiceScope scope) { - return new ConceptRecordController(scope.ServiceProvider.GetRequiredService()) + return new ConceptElaborationTaskController( + scope.ServiceProvider.GetRequiredService()) { ControllerContext = BuildContext("-51", "instructor") }; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskCommandTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskCommandTests.cs deleted file mode 100644 index 975ede24b..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskCommandTests.cs +++ /dev/null @@ -1,138 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Shouldly; -using Tutor.API.Controllers.Instructor.Authoring.Elaboration; -using Tutor.Elaborations.API.Dtos.Conversations; -using Tutor.Elaborations.API.Public.Authoring; -using Tutor.Elaborations.Infrastructure.Database; - -namespace Tutor.Elaborations.Tests.Integration.Authoring; - -[Collection("Sequential")] -public class ElaborationTaskCommandTests : BaseElaborationsIntegrationTest -{ - public ElaborationTaskCommandTests(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 ElaborationTaskDto - { - ConceptRecordId = -1, - UnitId = -1, - ExpectedLevel = "Beginner", - Order = 10 - }; - dbContext.Database.BeginTransaction(); - - var actionResult = controller.Create(-1, newEntity).Result; - var result = (actionResult as OkObjectResult)?.Value as ElaborationTaskDto; - - dbContext.ChangeTracker.Clear(); - result.ShouldNotBeNull(); - result.ConceptRecordId.ShouldBe(-1); - result.UnitId.ShouldBe(-1); - result.ExpectedLevel.ShouldBe("Beginner"); - result.Order.ShouldBe(10); - } - - [Fact] - public void Updates() - { - using var scope = Factory.Services.CreateScope(); - var controller = CreateController(scope); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var updatedEntity = new ElaborationTaskDto - { - Id = -1, - ConceptRecordId = -1, - UnitId = -1, - ExpectedLevel = "Intermediate", - Order = 1 - }; - dbContext.Database.BeginTransaction(); - - var actionResult = controller.Update(-1, -1, updatedEntity).Result; - var result = (actionResult as OkObjectResult)?.Value as ElaborationTaskDto; - - dbContext.ChangeTracker.Clear(); - result.ShouldNotBeNull(); - result.Id.ShouldBe(-1); - result.ExpectedLevel.ShouldBe("Intermediate"); - } - - [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(-1, -2); - - dbContext.ChangeTracker.Clear(); - result.ShouldNotBeNull(); - result.StatusCode.ShouldBe(200); - var stored = dbContext.ElaborationTasks.FirstOrDefault(t => t.Id == -2); - stored.ShouldBeNull(); - } - - [Fact] - public void Non_owner_fails_to_create() - { - using var scope = Factory.Services.CreateScope(); - var controller = CreateController(scope); - var newEntity = new ElaborationTaskDto - { - ConceptRecordId = -2, UnitId = -3, ExpectedLevel = "Beginner", Order = 1 - }; - - 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 ElaborationTaskDto - { - Id = -4, ConceptRecordId = -2, UnitId = -3, ExpectedLevel = "Beginner", Order = 1 - }; - - 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 ElaborationTaskController CreateController(IServiceScope scope) - { - return new ElaborationTaskController(scope.ServiceProvider.GetRequiredService()) - { - ControllerContext = BuildContext("-51", "instructor") - }; - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskQueryTests.cs deleted file mode 100644 index f69e3a5b1..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ElaborationTaskQueryTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Shouldly; -using Tutor.API.Controllers.Instructor.Authoring.Elaboration; -using Tutor.Elaborations.API.Dtos.Conversations; -using Tutor.Elaborations.API.Public.Authoring; - -namespace Tutor.Elaborations.Tests.Integration.Authoring; - -[Collection("Sequential")] -public class ElaborationTaskQueryTests : BaseElaborationsIntegrationTest -{ - public ElaborationTaskQueryTests(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].UnitId.ShouldBe(-1); - result[0].Order.ShouldBe(1); - result[1].Order.ShouldBe(2); - } - - [Fact] - public void Non_owner_fails_to_get() - { - 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 ElaborationTaskController CreateController(IServiceScope scope) - { - return new ElaborationTaskController(scope.ServiceProvider.GetRequiredService()) - { - ControllerContext = BuildContext("-51", "instructor") - }; - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs index 955946ed3..8c813fba9 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Shouldly; using Tutor.API.Controllers.Learner.Learning.Elaboration; -using Tutor.Elaborations.API.Dtos.Conversations; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; using Tutor.Elaborations.API.Public.Learning; namespace Tutor.Elaborations.Tests.Integration.Learning; @@ -19,7 +19,7 @@ public void Gets_tasks_for_enrolled_unit() var controller = CreateController(scope, "-2"); var actionResult = controller.GetTasksForUnit(-1).Result; - var result = (actionResult as OkObjectResult)?.Value as List; + var result = (actionResult as OkObjectResult)?.Value as List; result.ShouldNotBeNull(); result.Count.ShouldBe(2); @@ -47,13 +47,13 @@ public void Gets_task_detail() var controller = CreateController(scope, "-2"); var actionResult = controller.GetTaskDetail(-1).Result; - var result = (actionResult as OkObjectResult)?.Value as ElaborationTaskDetailDto; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; result.ShouldNotBeNull(); result.Id.ShouldBe(-1); - result.ConceptTitle.ShouldNotBeNullOrEmpty(); - result.ConceptDefinition.ShouldNotBeNullOrEmpty(); - result.ExpectedLevel.ShouldBe("Beginner"); + result.Title.ShouldNotBeNullOrEmpty(); + result.CanonicalDefinition.ShouldNotBeNullOrEmpty(); + result.Attempts.ShouldNotBeNull(); result.Attempts.Count.ShouldBe(2); result.Attempts.Any(a => a.Status == "Completed").ShouldBeTrue(); result.Attempts.Any(a => a.Status == "Abandoned").ShouldBeTrue(); @@ -66,9 +66,10 @@ public void Gets_task_detail_with_active_attempt() var controller = CreateController(scope, "-3"); var actionResult = controller.GetTaskDetail(-2).Result; - var result = (actionResult as OkObjectResult)?.Value as ElaborationTaskDetailDto; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; result.ShouldNotBeNull(); + result.Attempts.ShouldNotBeNull(); result.Attempts.Any(a => a.Status == "InProgress").ShouldBeTrue(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs index eba359de3..43cec879a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -13,18 +13,19 @@ namespace Tutor.Elaborations.Tests.Integration.Learning; // Test data layout: -// Task -1: Encapsulation/Beginner, Unit -1 (1 KP in scope: -11) -// Task -2: Encapsulation/Intermediate, Unit -1 (2 KPs: -11, -12) -// Task -3: Encapsulation/Beginner, Unit -2 -// Task -5: Encapsulation/Intermediate, Unit -2 (isolated for StartConversation) -// Task -6: Encapsulation/Advanced, Unit -2 (isolated for Start+Submit flow) +// CET -1: Encapsulation (Basics), Unit -1 (1 KP: -10) +// CET -2: Encapsulation (Members), Unit -1 (2 KPs: -20, -21) +// CET -3: Encapsulation (Basics — Unit 2), Unit -2 (1 KP: -30) +// CET -5: Encapsulation (Members — Unit 2), Unit -2 (2 KPs: -50, -51) — isolated for StartConversation +// CET -6: Encapsulation (Invariants), Unit -2 (3 KPs: -60, -61, -62) — isolated for Start+Submit flow +// CET -7: Polymorphism Mechanics, Unit -2 (2 KPs: -70, -71 + KR -370) — isolated // Learner -2: enrolled in Units -1, -2 | Learner -3: enrolled in Units -1, -2 // Learner -1: NOT enrolled | Learner -4: exhausted wallet -// Attempt -3: Learner -3, Task -1, InProgress (2 turns — for conflict + eval failure tests) -// Attempt -4: Learner -3, Task -2, InProgress (KP -11 covered — completion test) -// Attempt -5: Learner -2, Task -2, InProgress (9 learner turns — hard cap seed) -// Attempt -6: Learner -3, Task -3, InProgress (5 substantive turns — soft cap seed) -// Attempt -7: Learner -3, Task -5, InProgress (isolated for abandon test) +// Attempt -3: Learner -3, CET -1, InProgress (2 turns — for conflict + eval failure tests) +// Attempt -4: Learner -3, CET -2, InProgress (KP -20 covered — completion test) +// Attempt -5: Learner -2, CET -2, InProgress (9 learner turns — hard cap seed) +// Attempt -6: Learner -3, CET -3, InProgress (5 substantive turns — soft cap seed) +// Attempt -7: Learner -3, CET -5, InProgress (isolated for abandon test) [Collection("Sequential")] public class ConversationTurnTests : BaseElaborationsIntegrationTest { @@ -52,7 +53,7 @@ public async Task Starts_conversation_with_first_turn() var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.ChangeTracker.Clear(); var attempt = dbContext.ConversationAttempts.Include(a => a.Turns) - .FirstOrDefault(a => a.ElaborationTaskId == -5 && a.LearnerId == -2 && a.Status == 0); + .FirstOrDefault(a => a.ConceptElaborationTaskId == -5 && a.LearnerId == -2 && a.Status == 0); attempt.ShouldNotBeNull(); attempt.Turns.Count.ShouldBeGreaterThanOrEqualTo(2); } @@ -61,7 +62,7 @@ public async Task Starts_conversation_with_first_turn() public async Task All_propositions_covered_completes() { Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock([-11, -12]); + Factory.SetupEvaluationMock([-20, -21]); Factory.SetupDialogueMock(); Factory.SetupSummaryMock("Completed conversation summary."); using var scope = Factory.Services.CreateScope(); @@ -281,13 +282,13 @@ public async Task Submit_wrong_learner_fails() } [Fact] - public async Task Concept_record_with_relations_completes_when_relations_articulated() + public async Task Concept_with_relations_completes_when_relations_articulated() { - // Task -7 → CR -5 (KPs -50, -51 + KR -100). Strict completion: covering both KPs is not enough. + // CET -7 (KPs -70, -71 + KR -370). Strict completion: covering both KPs is not enough. Factory.MockChatService.Reset(); Factory.SetupEvaluationMock( - propositionsCoveredIds: [-50, -51], - relationsArticulatedIds: [-100], + propositionsCoveredIds: [-70, -71], + relationsArticulatedIds: [-370], integrationScore: 3); Factory.SetupDialogueMock(); Factory.SetupSummaryMock("Polymorphism mechanics summary."); @@ -307,18 +308,19 @@ public async Task Concept_record_with_relations_completes_when_relations_articul } [Fact] - public async Task Concept_record_with_relations_does_not_complete_when_only_KPs_covered() + public async Task Concept_with_relations_does_not_complete_when_only_KPs_covered() { - // Task -7 → CR -5. Covering KPs but NOT articulating the relation should NOT complete. + // CET -7. Covering KPs but NOT articulating the relation should NOT complete. + // Uses learner -2 so test doesn't collide with the "completes" test (also on CET -7). Factory.MockChatService.Reset(); Factory.SetupEvaluationMock( - propositionsCoveredIds: [-50, -51], + propositionsCoveredIds: [-70, -71], relationsArticulatedIds: [], integrationScore: 1); Factory.SetupDialogueMock(); Factory.SetupSummaryMock(); using var scope = Factory.Services.CreateScope(); - var controller = CreateController(scope, "-3"); + var controller = CreateController(scope, "-2"); var dto = new SubmitTurnRequestDto { Content = "Override is a thing and runtime types exist, but I won't say how they connect." diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql index 38ae8c07c..0d24590d8 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql @@ -1,12 +1,11 @@ DELETE FROM elaborations."TurnEvaluations"; DELETE FROM elaborations."ConversationTurns"; DELETE FROM elaborations."ConversationAttempts"; -DELETE FROM elaborations."ElaborationTasks"; DELETE FROM elaborations."KeyRelations"; DELETE FROM elaborations."BoundaryConditions"; DELETE FROM elaborations."CommonMisconceptions"; DELETE FROM elaborations."KeyPropositions"; -DELETE FROM elaborations."ConceptRecords"; +DELETE FROM elaborations."ConceptElaborationTasks"; DELETE FROM courses."CourseOwnerships"; DELETE FROM courses."UnitEnrollments"; 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..f4d5b7687 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-elaboration-tasks.sql @@ -0,0 +1,77 @@ +-- CET -1: Encapsulation (Basics), Unit -1, Order 1 (owned by Instructor -51 via Course -1) +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "CanonicalDefinition") +VALUES (-1, -1, 1, 'Encapsulation (Basics)', 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.'); + +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-10, -1, 'Data and methods are bundled in a class'); + +INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-110, -1, 'Does not mean hiding all data'); + +INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptElaborationTaskId", "Description", "Correction") +VALUES (-210, -1, 'Encapsulation means making everything private', 'Encapsulation is about controlled access, not total hiding'); + +-- CET -2: Encapsulation (Members), Unit -1, Order 2 +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "CanonicalDefinition") +VALUES (-2, -1, 2, 'Encapsulation (Members)', 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.'); + +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-20, -2, 'Data and methods are bundled in a class'); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-21, -2, 'Access modifiers control visibility of members'); + +INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-120, -2, 'Does not mean hiding all data'); +INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-121, -2, 'Public interfaces are part of encapsulation'); + +INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptElaborationTaskId", "Description", "Correction") +VALUES (-220, -2, 'Encapsulation means making everything private', 'Encapsulation is about controlled access, not total hiding'); +INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptElaborationTaskId", "Description", "Correction") +VALUES (-221, -2, 'Getters and setters are always good encapsulation', 'Blind getters/setters can break encapsulation by exposing internals'); + +-- CET -3: Encapsulation (Basics — Unit 2), Unit -2, Order 1 (owned by Instructor -51) +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "CanonicalDefinition") +VALUES (-3, -2, 1, 'Encapsulation (Basics — Unit 2)', 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.'); + +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-30, -3, 'Data and methods are bundled in a class'); + +-- CET -4: Inheritance, Unit -3, Order 1 (owned ONLY by Instructor -52, NOT -51) +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "CanonicalDefinition") +VALUES (-4, -3, 1, 'Inheritance', 'Inheritance allows a class to derive behavior from another class.'); + +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-40, -4, 'Child class inherits parent behavior'); + +-- CET -5: Encapsulation (Members — Unit 2), Unit -2, Order 2 (isolated for StartConversation tests) +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "CanonicalDefinition") +VALUES (-5, -2, 2, 'Encapsulation (Members — Unit 2)', 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.'); + +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-50, -5, 'Data and methods are bundled in a class'); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-51, -5, 'Access modifiers control visibility of members'); + +-- CET -6: Encapsulation (Invariants), Unit -2, Order 3 (isolated for Start+Submit flow test) +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "CanonicalDefinition") +VALUES (-6, -2, 3, 'Encapsulation (Invariants)', 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.'); + +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-60, -6, 'Data and methods are bundled in a class'); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-61, -6, 'Access modifiers control visibility of members'); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-62, -6, 'Internal invariants are protected from external corruption'); + +-- CET -7: Polymorphism Mechanics, Unit -2, Order 4 (isolated, has KeyRelation) +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "CanonicalDefinition") +VALUES (-7, -2, 4, 'Polymorphism Mechanics', 'Polymorphism resolves method calls at runtime via dynamic dispatch.'); + +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-70, -7, 'A subclass can override a parent method'); +INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") +VALUES (-71, -7, 'The runtime selects the implementation by the actual type'); + +INSERT INTO elaborations."KeyRelations"("Id", "ConceptElaborationTaskId", "SourceKeyPropositionId", "TargetKeyPropositionId", "Mechanism") +VALUES (-370, -7, -70, -71, 'Override matters because dispatch happens at runtime, not compile time'); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql deleted file mode 100644 index 592490843..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-records.sql +++ /dev/null @@ -1,50 +0,0 @@ --- ConceptRecord -1: "Encapsulation" (Course -1) -INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") -VALUES (-1, -1, 'Encapsulation', 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.'); - -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") -VALUES (-11, -1, 'Data and methods are bundled in a class', 0); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") -VALUES (-12, -1, 'Access modifiers control visibility of members', 1); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") -VALUES (-13, -1, 'Internal invariants are protected from external corruption', 2); - -INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptRecordId", "Statement", "Level") -VALUES (-11, -1, 'Does not mean hiding all data', 0); -INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptRecordId", "Statement", "Level") -VALUES (-12, -1, 'Public interfaces are part of encapsulation', 1); - -INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptRecordId", "Description", "Correction") -VALUES (-11, -1, 'Encapsulation means making everything private', 'Encapsulation is about controlled access, not total hiding'); -INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptRecordId", "Description", "Correction") -VALUES (-12, -1, 'Getters and setters are always good encapsulation', 'Blind getters/setters can break encapsulation by exposing internals'); - --- ConceptRecord -2: "Inheritance" (Course -1, minimal) -INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") -VALUES (-2, -1, 'Inheritance', 'Inheritance allows a class to derive behavior from another class.'); - -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") -VALUES (-21, -2, 'Child class inherits parent behavior', 0); - --- ConceptRecord -4: "Abstraction" (Course -1, no task references, for delete test) -INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") -VALUES (-4, -1, 'Abstraction', 'Abstraction focuses on essential qualities rather than specific details.'); - --- ConceptRecord -3: "Polymorphism" (Course -2, for non-owner tests) -INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") -VALUES (-3, -2, 'Polymorphism', 'Polymorphism enables objects to be treated as instances of their parent type.'); - -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") -VALUES (-31, -3, 'Objects can take multiple forms', 0); - --- ConceptRecord -5: "Polymorphism Mechanics" (Course -1, KPs + KR only — exercises relations and minimal-prompt path) -INSERT INTO elaborations."ConceptRecords"("Id", "CourseId", "Title", "CanonicalDefinition") -VALUES (-5, -1, 'Polymorphism Mechanics', 'Polymorphism resolves method calls at runtime via dynamic dispatch.'); - -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") -VALUES (-50, -5, 'A subclass can override a parent method', 0); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptRecordId", "Statement", "Level") -VALUES (-51, -5, 'The runtime selects the implementation by the actual type', 0); - -INSERT INTO elaborations."KeyRelations"("Id", "ConceptRecordId", "SourceKeyPropositionId", "TargetKeyPropositionId", "Mechanism", "Level") -VALUES (-100, -5, -50, -51, 'Override matters because dispatch happens at runtime, not compile time', 0); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql deleted file mode 100644 index a6c4f3bc4..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/d-elaboration-tasks.sql +++ /dev/null @@ -1,27 +0,0 @@ --- Task -1: Encapsulation at Beginner, Unit -1 (owned by Instructor -51). 1 KP in scope: -11 -INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") -VALUES (-1, -1, -1, 0, 1); - --- Task -2: Encapsulation at Intermediate, Unit -1. 2 KPs in scope: -11, -12 -INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") -VALUES (-2, -1, -1, 1, 2); - --- Task -3: Encapsulation at Beginner, Unit -2 (owned by Instructor -51) -INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") -VALUES (-3, -1, -2, 0, 1); - --- Task -4: Inheritance at Beginner, Unit -3 (owned ONLY by Instructor -52, NOT -51) -INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") -VALUES (-4, -2, -3, 0, 1); - --- Task -5: Encapsulation at Intermediate, Unit -2 (isolated for StartConversation tests) -INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") -VALUES (-5, -1, -2, 1, 2); - --- Task -6: Encapsulation at Advanced, Unit -2 (isolated for Start+Submit flow test) -INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") -VALUES (-6, -1, -2, 2, 3); - --- Task -7: Polymorphism Mechanics (CR -5) at Beginner, Unit -2 (isolated, has KeyRelation -100) -INSERT INTO elaborations."ElaborationTasks"("Id", "ConceptRecordId", "UnitId", "ExpectedLevel", "Order") -VALUES (-7, -5, -2, 0, 4); 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 index 8408c8667..fddc6ee44 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -1,5 +1,5 @@ --- Attempt -1: Learner -2, Task -1, Completed with 3 turns (for query tests) -INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +-- Attempt -1: Learner -2, CET -1, Completed with 3 turns (for query tests) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-1, -1, -2, 1, '2024-06-01 10:00:00+00', '2024-06-01 10:15:00+00', 'Good understanding of encapsulation basics.'); INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") @@ -10,16 +10,16 @@ INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Rol VALUES (-3, -1, 0, 'Access modifiers like public and private control visibility.', true, 2, '2024-06-01 10:02:00+00'); INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-1, -1, 2, 2, 2, null, 'Accurate basic description', null, '[-11]'::jsonb, '[]'::jsonb, '[]'::jsonb); +VALUES (-1, -1, 2, 2, 2, null, 'Accurate basic description', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb); INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-3, -3, 2, 2, 2, null, 'Good description of access modifiers', null, '[-12]'::jsonb, '[]'::jsonb, '[]'::jsonb); +VALUES (-3, -3, 2, 2, 2, null, 'Good description of access modifiers', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb); --- Attempt -2: Learner -2, Task -1, Abandoned (for query tests) -INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +-- Attempt -2: Learner -2, CET -1, Abandoned (for query tests) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-2, -1, -2, 2, '2024-06-02 10:00:00+00', '2024-06-02 10:05:00+00', null); --- Attempt -3: Learner -3, Task -1, InProgress with 2 turns (for abandon + follow-up tests) -INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +-- Attempt -3: Learner -3, CET -1, InProgress with 2 turns (for abandon + follow-up tests) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-3, -1, -3, 0, '2024-06-03 10:00:00+00', null, null); INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") @@ -28,10 +28,10 @@ INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Rol VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', true, 1, '2024-06-03 10:01:05+00'); INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-4, -4, 1, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '[-11]'::jsonb, '[]'::jsonb); +VALUES (-4, -4, 1, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '[-210]'::jsonb, '[]'::jsonb); --- Attempt -4: Learner -3, Task -2, InProgress (for completion test: KP -11 already covered, submit to cover -12) -INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +-- Attempt -4: Learner -3, CET -2, InProgress (for completion test: KP -20 already covered, submit to cover -21) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-4, -2, -3, 0, '2024-06-04 10:00:00+00', null, null); INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") @@ -40,10 +40,10 @@ INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Rol VALUES (-7, -4, 1, 'Good. What about access control?', true, 1, '2024-06-04 10:01:05+00'); INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-6, -6, 2, 2, 2, null, 'Covers bundling proposition', null, '[-11]'::jsonb, '[]'::jsonb, '[]'::jsonb); +VALUES (-6, -6, 2, 2, 2, null, 'Covers bundling proposition', null, '[-20]'::jsonb, '[]'::jsonb, '[]'::jsonb); --- Attempt -5: Learner -2, Task -2, InProgress with 9 learner + 9 system turns (for hard cap test) -INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +-- Attempt -5: Learner -2, CET -2, InProgress with 9 learner + 9 system turns (for hard cap test) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-5, -2, -2, 0, '2024-06-05 10:00:00+00', null, null); INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") @@ -103,8 +103,8 @@ VALUES (-64, -64, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]':: INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") VALUES (-66, -66, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); --- Attempt -6: Learner -3, Task -3, InProgress with 5 substantive learner + 5 system turns (for soft cap test) -INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +-- Attempt -6: Learner -3, CET -3, InProgress with 5 substantive learner + 5 system turns (for soft cap test) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-6, -3, -3, 0, '2024-06-06 10:00:00+00', null, null); INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") @@ -139,6 +139,6 @@ VALUES (-76, -76, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]':: INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") VALUES (-78, -78, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); --- Attempt -7: Learner -3, Task -5, InProgress (isolated for abandon test — no other test touches this) -INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +-- Attempt -7: Learner -3, CET -5, InProgress (isolated for abandon test — no other test touches this) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-7, -5, -3, 0, '2024-06-07 10:00:00+00', null, null); 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 index 06e1ae046..a26969eca 100644 --- 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 @@ -1,7 +1,7 @@ --- 3 recent attempts for Learner -2 on Task -3 (triggers MaxAttemptsPerDay=3 limit) -INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +-- 3 recent attempts for Learner -2 on CET -3 (triggers MaxAttemptsPerDay=3 limit) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-10, -3, -2, 1, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '2 hours', 'Daily limit attempt 1'); -INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-11, -3, -2, 1, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour', 'Daily limit attempt 2'); -INSERT INTO elaborations."ConversationAttempts"("Id", "ElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-12, -3, -2, 2, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '30 minutes', null); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs new file mode 100644 index 000000000..e8e9943e2 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs @@ -0,0 +1,103 @@ +using System.Reflection; +using Shouldly; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; +using Xunit; + +namespace Tutor.Elaborations.Tests.Unit; + +public class ConceptElaborationTaskTests +{ + [Fact] + public void IsAttemptComplete_returns_true_when_no_relations_and_all_KPs_covered() + { + var kp = MakeKp(1); + var task = MakeTask(kps: [kp], relations: []); + var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpIds: [1], articulatedRelationIds: []); + + task.IsAttemptComplete(attempt).ShouldBeTrue(); + } + + [Fact] + public void IsAttemptComplete_returns_false_when_relations_exist_but_not_articulated() + { + var kp1 = MakeKp(1); + var kp2 = MakeKp(2); + var task = MakeTask( + kps: [kp1, kp2], + relations: [MakeRelation(10, 1, 2)]); + var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpIds: [1, 2], articulatedRelationIds: []); + + task.IsAttemptComplete(attempt).ShouldBeFalse(); + } + + [Fact] + public void IsAttemptComplete_returns_true_when_all_KPs_covered_and_all_relations_articulated() + { + var kp1 = MakeKp(1); + var kp2 = MakeKp(2); + var task = MakeTask( + kps: [kp1, kp2], + relations: [MakeRelation(10, 1, 2)]); + var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpIds: [1, 2], articulatedRelationIds: [10]); + + task.IsAttemptComplete(attempt).ShouldBeTrue(); + } + + private static KeyProposition MakeKp(int id) + { + var kp = new KeyProposition(); + SetProp(kp, "Id", id); + return kp; + } + + private static KeyRelation MakeRelation(int id, int sourceKpId, int targetKpId) + { + var kr = new KeyRelation(); + SetProp(kr, "Id", id); + SetProp(kr, "SourceKeyPropositionId", sourceKpId); + SetProp(kr, "TargetKeyPropositionId", targetKpId); + return kr; + } + + private static ConceptElaborationTask MakeTask(List kps, List relations) + { + var task = new ConceptElaborationTask(); + SetProp(task, "KeyPropositions", kps); + SetProp(task, "BoundaryConditions", new List()); + SetProp(task, "CommonMisconceptions", new List()); + SetProp(task, "KeyRelations", relations); + return task; + } + + private static ConversationAttempt MakeAttemptWithCoveredAndArticulated( + List coveredKpIds, List articulatedRelationIds) + { + var ctor = typeof(ConversationAttempt) + .GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, Type.EmptyTypes)!; + var attempt = (ConversationAttempt)ctor.Invoke(null); + + var evalCtor = typeof(TurnEvaluation).GetConstructors().First(c => c.GetParameters().Length > 0); + var evaluation = (TurnEvaluation)evalCtor.Invoke([ + 2, 2, (int?)null, (int?)null, "test", null, + coveredKpIds, new List(), articulatedRelationIds + ]); + + var turnCtor = typeof(ConversationTurn).GetConstructors( + BindingFlags.NonPublic | BindingFlags.Instance) + .First(c => c.GetParameters().Length > 0); + var turn = (ConversationTurn)turnCtor.Invoke([TurnRole.Learner, "x", true, 0, evaluation]); + + SetProp(attempt, "Turns", new List { turn }); + return attempt; + } + + private static void SetProp(object instance, string propName, object? value) + { + var prop = instance.GetType().GetProperty(propName, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (prop == null) + throw new InvalidOperationException($"Property {propName} not found on {instance.GetType().Name}"); + prop.SetValue(instance, value); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs deleted file mode 100644 index 4280db5cd..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System.Reflection; -using Shouldly; -using Tutor.Elaborations.Core.Domain.ConceptRecords; -using Tutor.Elaborations.Core.Domain.Conversations; -using Xunit; - -namespace Tutor.Elaborations.Tests.Unit; - -public class ConceptRecordTests -{ - [Fact] - public void DeriveForLevel_filters_relations_whose_endpoints_are_above_level() - { - var beginnerKp = MakeKp(1, PropositionLevel.Beginner); - var advancedKp = MakeKp(2, PropositionLevel.Advanced); - var record = MakeRecord( - kps: [beginnerKp, advancedKp], - relations: [MakeRelation(10, beginnerKp.Id, advancedKp.Id, PropositionLevel.Beginner)]); - - var derived = record.DeriveForLevel(PropositionLevel.Beginner); - - derived.KeyPropositions.Count.ShouldBe(1); - derived.KeyPropositions[0].Id.ShouldBe(beginnerKp.Id); - derived.KeyRelations.Count.ShouldBe(0, - "relation references an Advanced KP that was filtered out"); - } - - [Fact] - public void DeriveForLevel_filters_relations_above_level_even_when_endpoints_survive() - { - var kp1 = MakeKp(1, PropositionLevel.Beginner); - var kp2 = MakeKp(2, PropositionLevel.Beginner); - var record = MakeRecord( - kps: [kp1, kp2], - relations: [MakeRelation(10, kp1.Id, kp2.Id, PropositionLevel.Advanced)]); - - var derived = record.DeriveForLevel(PropositionLevel.Beginner); - - derived.KeyPropositions.Count.ShouldBe(2); - derived.KeyRelations.Count.ShouldBe(0, - "relation itself is Advanced and should be filtered out"); - } - - [Fact] - public void DeriveForLevel_keeps_relations_at_or_below_level_with_surviving_endpoints() - { - var kp1 = MakeKp(1, PropositionLevel.Beginner); - var kp2 = MakeKp(2, PropositionLevel.Beginner); - var record = MakeRecord( - kps: [kp1, kp2], - relations: [MakeRelation(10, kp1.Id, kp2.Id, PropositionLevel.Beginner)]); - - var derived = record.DeriveForLevel(PropositionLevel.Beginner); - - derived.KeyRelations.Count.ShouldBe(1); - derived.KeyRelations[0].Id.ShouldBe(10); - } - - [Fact] - public void IsAttemptComplete_returns_true_when_no_relations_and_all_KPs_covered() - { - var kp = MakeKp(1, PropositionLevel.Beginner); - var record = MakeRecord(kps: [kp], relations: []); - var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpIds: [1], articulatedRelationIds: []); - - record.IsAttemptComplete(attempt).ShouldBeTrue(); - } - - [Fact] - public void IsAttemptComplete_returns_false_when_relations_exist_but_not_articulated() - { - var kp1 = MakeKp(1, PropositionLevel.Beginner); - var kp2 = MakeKp(2, PropositionLevel.Beginner); - var record = MakeRecord( - kps: [kp1, kp2], - relations: [MakeRelation(10, 1, 2, PropositionLevel.Beginner)]); - var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpIds: [1, 2], articulatedRelationIds: []); - - record.IsAttemptComplete(attempt).ShouldBeFalse(); - } - - [Fact] - public void IsAttemptComplete_returns_true_when_all_KPs_covered_and_all_relations_articulated() - { - var kp1 = MakeKp(1, PropositionLevel.Beginner); - var kp2 = MakeKp(2, PropositionLevel.Beginner); - var record = MakeRecord( - kps: [kp1, kp2], - relations: [MakeRelation(10, 1, 2, PropositionLevel.Beginner)]); - var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpIds: [1, 2], articulatedRelationIds: [10]); - - record.IsAttemptComplete(attempt).ShouldBeTrue(); - } - - private static KeyProposition MakeKp(int id, PropositionLevel level) - { - var kp = new KeyProposition(); - SetProp(kp, "Id", id); - SetProp(kp, "Level", level); - return kp; - } - - private static KeyRelation MakeRelation(int id, int sourceKpId, int targetKpId, PropositionLevel level) - { - var kr = new KeyRelation(); - SetProp(kr, "Id", id); - SetProp(kr, "SourceKeyPropositionId", sourceKpId); - SetProp(kr, "TargetKeyPropositionId", targetKpId); - SetProp(kr, "Level", level); - return kr; - } - - private static ConceptRecord MakeRecord(List kps, List relations) - { - var record = new ConceptRecord(); - SetProp(record, "KeyPropositions", kps); - SetProp(record, "BoundaryConditions", new List()); - SetProp(record, "CommonMisconceptions", new List()); - SetProp(record, "KeyRelations", relations); - return record; - } - - private static ConversationAttempt MakeAttemptWithCoveredAndArticulated( - List coveredKpIds, List articulatedRelationIds) - { - var ctor = typeof(ConversationAttempt) - .GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, Type.EmptyTypes)!; - var attempt = (ConversationAttempt)ctor.Invoke(null); - - var evalCtor = typeof(TurnEvaluation).GetConstructors().First(c => c.GetParameters().Length > 0); - var evaluation = (TurnEvaluation)evalCtor.Invoke([ - 2, 2, (int?)null, (int?)null, "test", null, - coveredKpIds, new List(), articulatedRelationIds - ]); - - var turnCtor = typeof(ConversationTurn).GetConstructors( - BindingFlags.NonPublic | BindingFlags.Instance) - .First(c => c.GetParameters().Length > 0); - var turn = (ConversationTurn)turnCtor.Invoke([TurnRole.Learner, "x", true, 0, evaluation]); - - SetProp(attempt, "Turns", new List { turn }); - return attempt; - } - - private static void SetProp(object instance, string propName, object? value) - { - var prop = instance.GetType().GetProperty(propName, - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - if (prop == null) - throw new InvalidOperationException($"Property {propName} not found on {instance.GetType().Name}"); - prop.SetValue(instance, value); - } -} 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..bee6bdf0b --- /dev/null +++ b/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptElaborationTaskController.cs @@ -0,0 +1,57 @@ +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); + } + + [HttpGet("{id:int}")] + public ActionResult Get(int unitId, int id) + { + var result = _service.Get(id, 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/Instructor/Authoring/Elaboration/ConceptRecordController.cs b/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptRecordController.cs deleted file mode 100644 index 07c33d9a5..000000000 --- a/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptRecordController.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Tutor.Elaborations.API.Dtos.ConceptRecords; -using Tutor.Elaborations.API.Public.Authoring; -using Tutor.Stakeholders.Infrastructure.Authentication; - -namespace Tutor.API.Controllers.Instructor.Authoring.Elaboration; - -[Authorize(Policy = "instructorPolicy")] -[Route("api/authoring/courses/{courseId:int}/concept-records")] -public class ConceptRecordController : BaseApiController -{ - private readonly IConceptRecordService _conceptRecordService; - - public ConceptRecordController(IConceptRecordService conceptRecordService) - { - _conceptRecordService = conceptRecordService; - } - - [HttpGet] - public ActionResult> GetByCourse(int courseId) - { - var result = _conceptRecordService.GetByCourse(courseId, User.InstructorId()); - return CreateResponse(result); - } - - [HttpGet("{id:int}")] - public ActionResult Get(int courseId, int id) - { - var result = _conceptRecordService.Get(id, courseId, User.InstructorId()); - return CreateResponse(result); - } - - [HttpPost] - public ActionResult Create(int courseId, [FromBody] ConceptRecordDto dto) - { - dto.CourseId = courseId; - var result = _conceptRecordService.Create(dto, User.InstructorId()); - return CreateResponse(result); - } - - [HttpPut("{id:int}")] - public ActionResult Update(int courseId, int id, [FromBody] ConceptRecordDto dto) - { - dto.Id = id; - dto.CourseId = courseId; - var result = _conceptRecordService.Update(dto, User.InstructorId()); - return CreateResponse(result); - } - - [HttpDelete("{id:int}")] - public ActionResult Delete(int courseId, int id) - { - var result = _conceptRecordService.Delete(id, courseId, User.InstructorId()); - return CreateResponse(result); - } -} diff --git a/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ElaborationTaskController.cs b/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ElaborationTaskController.cs deleted file mode 100644 index b7f2996da..000000000 --- a/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ElaborationTaskController.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Tutor.Elaborations.API.Dtos.Conversations; -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}/elaboration-tasks")] -public class ElaborationTaskController : BaseApiController -{ - private readonly IElaborationTaskService _elaborationTaskService; - - public ElaborationTaskController(IElaborationTaskService elaborationTaskService) - { - _elaborationTaskService = elaborationTaskService; - } - - [HttpGet] - public ActionResult> GetByUnit(int unitId) - { - var result = _elaborationTaskService.GetByUnit(unitId, User.InstructorId()); - return CreateResponse(result); - } - - [HttpPost] - public ActionResult Create(int unitId, [FromBody] ElaborationTaskDto dto) - { - dto.UnitId = unitId; - var result = _elaborationTaskService.Create(dto, User.InstructorId()); - return CreateResponse(result); - } - - [HttpPut("{id:int}")] - public ActionResult Update(int unitId, int id, [FromBody] ElaborationTaskDto dto) - { - dto.Id = id; - dto.UnitId = unitId; - var result = _elaborationTaskService.Update(dto, User.InstructorId()); - return CreateResponse(result); - } - - [HttpDelete("{id:int}")] - public ActionResult Delete(int unitId, int id) - { - var result = _elaborationTaskService.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 index 3a7523aee..827aa2317 100644 --- a/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs +++ b/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs @@ -1,6 +1,7 @@ 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; @@ -18,21 +19,21 @@ public ConversationController(IConversationService conversationService) _conversationService = conversationService; } - [HttpGet("units/{unitId:int}/elaboration-tasks")] - public ActionResult> GetTasksForUnit(int unitId) + [HttpGet("units/{unitId:int}/concept-elaborations")] + public ActionResult> GetTasksForUnit(int unitId) { var result = _conversationService.GetTasksForUnit(unitId, User.LearnerId()); return CreateResponse(result); } - [HttpGet("elaboration-tasks/{taskId:int}")] - public ActionResult GetTaskDetail(int taskId) + [HttpGet("concept-elaborations/{taskId:int}")] + public ActionResult GetTaskDetail(int taskId) { var result = _conversationService.GetTaskDetail(taskId, User.LearnerId()); return CreateResponse(result); } - [HttpPost("elaboration-tasks/{taskId:int}/conversations")] + [HttpPost("concept-elaborations/{taskId:int}/conversations")] public async IAsyncEnumerable StartConversation(int taskId, [FromBody] SubmitTurnRequestDto dto, [EnumeratorCancellation] CancellationToken ct) @@ -44,7 +45,7 @@ public async IAsyncEnumerable StartConversation(int taskId, } } - [HttpPost("elaboration-tasks/attempts/{attemptId:int}/turns")] + [HttpPost("concept-elaborations/attempts/{attemptId:int}/turns")] public async IAsyncEnumerable SubmitTurn(int attemptId, [FromBody] SubmitTurnRequestDto dto, [EnumeratorCancellation] CancellationToken ct) @@ -56,11 +57,10 @@ public async IAsyncEnumerable SubmitTurn(int attemptId, } } - [HttpPost("elaboration-tasks/attempts/{attemptId:int}/abandon")] + [HttpPost("concept-elaborations/attempts/{attemptId:int}/abandon")] public ActionResult AbandonAttempt(int attemptId) { var result = _conversationService.AbandonAttempt(attemptId, User.LearnerId()); return CreateResponse(result); } - } From ed059b1aaceb409cfbe62c778f2f0228611a41f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Sun, 12 Apr 2026 07:35:11 +0200 Subject: [PATCH 12/51] fix: Simplifies HTTP data flow for concept authoring. --- .../Internal/IOwnershipValidator.cs | 1 - .../UseCases/Authoring/OwnedCourseService.cs | 5 -- ...Dto.cs => LearnerElaborationSummaryDto.cs} | 2 +- .../IConceptElaborationTaskService.cs | 3 +- .../Public/Learning/IConversationService.cs | 2 +- .../ConceptElaborationTaskService.cs | 25 +----- .../UseCases/Learning/ConversationService.cs | 4 +- .../ConceptElaborationTaskQueryTests.cs | 80 +------------------ .../Learning/ConversationQueryTests.cs | 2 +- .../ConceptElaborationTaskController.cs | 9 +-- .../Elaboration/ConversationController.cs | 2 +- 11 files changed, 11 insertions(+), 124 deletions(-) rename src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/{ConceptElaborationTaskSummaryDto.cs => LearnerElaborationSummaryDto.cs} (85%) diff --git a/src/Modules/Courses/Tutor.Courses.API/Internal/IOwnershipValidator.cs b/src/Modules/Courses/Tutor.Courses.API/Internal/IOwnershipValidator.cs index ca8e1dfc1..5ff0c744f 100644 --- a/src/Modules/Courses/Tutor.Courses.API/Internal/IOwnershipValidator.cs +++ b/src/Modules/Courses/Tutor.Courses.API/Internal/IOwnershipValidator.cs @@ -2,6 +2,5 @@ public interface IOwnershipValidator { - bool IsCourseOwner(int courseId, int instructorId); bool IsUnitOwner(int unitId, int instructorId); } \ No newline at end of file diff --git a/src/Modules/Courses/Tutor.Courses.Core/UseCases/Authoring/OwnedCourseService.cs b/src/Modules/Courses/Tutor.Courses.Core/UseCases/Authoring/OwnedCourseService.cs index fa401053c..412169553 100644 --- a/src/Modules/Courses/Tutor.Courses.Core/UseCases/Authoring/OwnedCourseService.cs +++ b/src/Modules/Courses/Tutor.Courses.Core/UseCases/Authoring/OwnedCourseService.cs @@ -49,11 +49,6 @@ public Result Update(CourseDto course, int instructorId) return MapToDto(updatedCourse); } - public bool IsCourseOwner(int courseId, int instructorId) - { - return _ownedCourseRepository.IsCourseOwner(courseId, instructorId); - } - public bool IsUnitOwner(int unitId, int instructorId) { return _ownedCourseRepository.IsUnitOwner(unitId, instructorId); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskSummaryDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/LearnerElaborationSummaryDto.cs similarity index 85% rename from src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskSummaryDto.cs rename to src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/LearnerElaborationSummaryDto.cs index 5d6a44454..24e28e8be 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskSummaryDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/LearnerElaborationSummaryDto.cs @@ -1,6 +1,6 @@ namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; -public class ConceptElaborationTaskSummaryDto +public class LearnerElaborationSummaryDto { public int Id { get; set; } public int UnitId { get; set; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptElaborationTaskService.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptElaborationTaskService.cs index 17e70faff..0ea7a197c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptElaborationTaskService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptElaborationTaskService.cs @@ -5,8 +5,7 @@ namespace Tutor.Elaborations.API.Public.Authoring; public interface IConceptElaborationTaskService { - Result Get(int id, int unitId, int instructorId); - Result> GetByUnit(int unitId, int instructorId); + 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/Learning/IConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs index 762e09104..44a436e87 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs @@ -6,7 +6,7 @@ namespace Tutor.Elaborations.API.Public.Learning; public interface IConversationService { - Result> GetTasksForUnit(int unitId, int learnerId); + Result> GetTasksForUnit(int unitId, int learnerId); Result GetTaskDetail(int taskId, int learnerId); IAsyncEnumerable StartConversationAsync( int taskId, string content, int learnerId, CancellationToken ct); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs index b6bfdd18a..24a1302f1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs @@ -22,22 +22,12 @@ public ConceptElaborationTaskService(IConceptElaborationTaskRepository repositor _accessServices = accessServices; } - public Result Get(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); - return MapToDto(task); - } - - public Result> GetByUnit(int unitId, int instructorId) + public Result> GetByUnit(int unitId, int instructorId) { if (!_accessServices.IsUnitOwner(unitId, instructorId)) return Result.Fail(FailureCode.Forbidden); var tasks = _taskRepository.GetByUnit(unitId); - return Result.Ok(tasks.Select(ToSummary).ToList()); + return MapToDto(tasks); } public Result Create(ConceptElaborationTaskDto task, int instructorId) @@ -67,15 +57,4 @@ public Result Delete(int id, int unitId, int instructorId) return Result.Fail(FailureCode.NotFound); return Delete(id); } - - private static ConceptElaborationTaskSummaryDto ToSummary(ConceptElaborationTask task) - { - return new ConceptElaborationTaskSummaryDto - { - Id = task.Id, - UnitId = task.UnitId, - Order = task.Order, - Title = task.Title - }; - } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index a5c9acbb8..b17b9a930 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -41,7 +41,7 @@ public ConversationService(IConversationAttemptRepository attemptRepo, _mapper = mapper; } - public Result> GetTasksForUnit(int unitId, int learnerId) + public Result> GetTasksForUnit(int unitId, int learnerId) { if (!_accessServices.IsEnrolledInUnit(unitId, learnerId)) return Result.Fail(FailureCode.Forbidden); @@ -50,7 +50,7 @@ public Result> GetTasksForUnit(int unitId var taskIds = tasks.Select(t => t.Id).ToList(); var completedTaskIds = _attemptRepo.GetTaskIdsWithCompletedAttempts(taskIds, learnerId); - return Result.Ok(tasks.Select(t => new ConceptElaborationTaskSummaryDto + return Result.Ok(tasks.Select(t => new LearnerElaborationSummaryDto { Id = t.Id, UnitId = t.UnitId, diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskQueryTests.cs index ef617c90a..c41c04edc 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskQueryTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskQueryTests.cs @@ -12,25 +12,6 @@ public class ConceptElaborationTaskQueryTests : BaseElaborationsIntegrationTest { public ConceptElaborationTaskQueryTests(ElaborationsTestFactory factory) : base(factory) { } - [Fact] - public void Gets_by_id() - { - using var scope = Factory.Services.CreateScope(); - var controller = CreateController(scope); - - var actionResult = controller.Get(-1, -1).Result; - var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; - - result.ShouldNotBeNull(); - result.Id.ShouldBe(-1); - result.UnitId.ShouldBe(-1); - result.Title.ShouldBe("Encapsulation (Basics)"); - result.KeyPropositions.Count.ShouldBe(1); - result.KeyPropositions.ShouldContain(kp => kp.Statement == "Data and methods are bundled in a class"); - result.BoundaryConditions.Count.ShouldBe(1); - result.CommonMisconceptions.Count.ShouldBe(1); - } - [Fact] public void Gets_by_unit() { @@ -38,7 +19,7 @@ public void Gets_by_unit() var controller = CreateController(scope); var actionResult = controller.GetByUnit(-1).Result; - var result = (actionResult as OkObjectResult)?.Value as List; + var result = (actionResult as OkObjectResult)?.Value as List; result.ShouldNotBeNull(); result.Count.ShouldBe(2); @@ -47,38 +28,6 @@ public void Gets_by_unit() result.ShouldContain(s => s.Id == -2 && s.Title == "Encapsulation (Members)"); } - [Fact] - public void Gets_task_with_relations() - { - using var scope = Factory.Services.CreateScope(); - var controller = CreateController(scope); - - var actionResult = controller.Get(-2, -7).Result; - var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; - - result.ShouldNotBeNull(); - result.KeyPropositions.Count.ShouldBe(2); - result.BoundaryConditions.Count.ShouldBe(0); - result.CommonMisconceptions.Count.ShouldBe(0); - result.KeyRelations.Count.ShouldBe(1); - result.KeyRelations[0].SourceKeyPropositionId.ShouldBe(-70); - result.KeyRelations[0].TargetKeyPropositionId.ShouldBe(-71); - result.KeyRelations[0].Mechanism.ShouldContain("dispatch happens at runtime"); - } - - [Fact] - public void Non_owner_fails_to_get() - { - using var scope = Factory.Services.CreateScope(); - var controller = CreateController(scope); - - var actionResult = controller.Get(-3, -4).Result; - var objectResult = actionResult as ObjectResult; - - objectResult.ShouldNotBeNull(); - objectResult.StatusCode.ShouldBe(403); - } - [Fact] public void Non_owner_fails_to_get_by_unit() { @@ -92,33 +41,6 @@ public void Non_owner_fails_to_get_by_unit() objectResult.StatusCode.ShouldBe(403); } - [Fact] - public void Fails_to_get_nonexistent() - { - using var scope = Factory.Services.CreateScope(); - var controller = CreateController(scope); - - var actionResult = controller.Get(-1, -999).Result; - var objectResult = actionResult as ObjectResult; - - objectResult.ShouldNotBeNull(); - objectResult.StatusCode.ShouldBe(404); - } - - [Fact] - public void Fails_to_get_task_from_wrong_unit() - { - using var scope = Factory.Services.CreateScope(); - // Instructor -51 owns Unit -2, but CET -1 belongs to Unit -1 - var controller = CreateController(scope); - - var actionResult = controller.Get(-2, -1).Result; - var objectResult = actionResult as ObjectResult; - - objectResult.ShouldNotBeNull(); - objectResult.StatusCode.ShouldBe(404); - } - private static ConceptElaborationTaskController CreateController(IServiceScope scope) { return new ConceptElaborationTaskController( diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs index 8c813fba9..c3574b931 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs @@ -19,7 +19,7 @@ public void Gets_tasks_for_enrolled_unit() var controller = CreateController(scope, "-2"); var actionResult = controller.GetTasksForUnit(-1).Result; - var result = (actionResult as OkObjectResult)?.Value as List; + var result = (actionResult as OkObjectResult)?.Value as List; result.ShouldNotBeNull(); result.Count.ShouldBe(2); diff --git a/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptElaborationTaskController.cs b/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptElaborationTaskController.cs index bee6bdf0b..fd038c23e 100644 --- a/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptElaborationTaskController.cs +++ b/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptElaborationTaskController.cs @@ -18,19 +18,12 @@ public ConceptElaborationTaskController(IConceptElaborationTaskService service) } [HttpGet] - public ActionResult> GetByUnit(int unitId) + public ActionResult> GetByUnit(int unitId) { var result = _service.GetByUnit(unitId, User.InstructorId()); return CreateResponse(result); } - [HttpGet("{id:int}")] - public ActionResult Get(int unitId, int id) - { - var result = _service.Get(id, unitId, User.InstructorId()); - return CreateResponse(result); - } - [HttpPost] public ActionResult Create(int unitId, [FromBody] ConceptElaborationTaskDto dto) { diff --git a/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs b/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs index 827aa2317..744d3998b 100644 --- a/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs +++ b/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs @@ -20,7 +20,7 @@ public ConversationController(IConversationService conversationService) } [HttpGet("units/{unitId:int}/concept-elaborations")] - public ActionResult> GetTasksForUnit(int unitId) + public ActionResult> GetTasksForUnit(int unitId) { var result = _conversationService.GetTasksForUnit(unitId, User.LearnerId()); return CreateResponse(result); From 818c7126f1e255e832b7ac9d2a6412b01e2b35c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Wed, 15 Apr 2026 16:33:00 +0200 Subject: [PATCH 13/51] refactor: Mild naming and logic reorganization. --- .../Public/Learning/IConversationService.cs | 2 +- .../Domain/Conversations/AttemptStatus.cs | 3 +- .../Conversations/ConversationAttempt.cs | 6 --- .../UseCases/Learning/ConversationService.cs | 44 ++++++++-------- .../Agents/EvaluationAgent.cs | 50 +++++++++++-------- .../Learning/ConversationQueryTests.cs | 8 +-- .../Elaboration/ConversationController.cs | 4 +- 7 files changed, 61 insertions(+), 56 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs index 44a436e87..ce89af8c4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs @@ -7,7 +7,7 @@ namespace Tutor.Elaborations.API.Public.Learning; public interface IConversationService { Result> GetTasksForUnit(int unitId, int learnerId); - Result GetTaskDetail(int taskId, int learnerId); + Result GetTaskWithAttempts(int taskId, int learnerId); IAsyncEnumerable StartConversationAsync( int taskId, string content, int learnerId, CancellationToken ct); IAsyncEnumerable SubmitTurnAsync( diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs index c42c94d12..46cd76d69 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs @@ -5,6 +5,5 @@ public enum AttemptStatus InProgress, Completed, Abandoned, - Expired, - Blocked + 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 index 1b2a1213a..b2f630005 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -90,10 +90,4 @@ public void Expire(string? summary) CompletedAt = DateTime.UtcNow; Summary = summary; } - - public void Block() - { - Status = AttemptStatus.Blocked; - CompletedAt = DateTime.UtcNow; - } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index b17b9a930..fda475df4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Text; using System.Text.Json; using AutoMapper; using FluentResults; @@ -60,7 +61,7 @@ public Result> GetTasksForUnit(int unitId, in }).ToList()); } - public Result GetTaskDetail(int taskId, int learnerId) + public Result GetTaskWithAttempts(int taskId, int learnerId) { var task = _taskRepo.Get(taskId); if (task == null) return Result.Fail(FailureCode.NotFound); @@ -172,29 +173,14 @@ private async IAsyncEnumerable RunTurnPipelineAsync( var evaluation = evalResult.Value.Evaluation; attempt.AddLearnerTurn(content, evalResult.Value.IsSubstantive, evaluation); - var isCompleted = task.IsAttemptComplete(attempt); - var coveredKpIds = attempt.GetCoveredPropositionIds(); - var articulatedRelationIds = attempt.GetArticulatedRelationIds(); - var state = new ConversationState - { - IsCompleted = isCompleted, - IsSoftCapReached = attempt.IsSoftCapReached(), - IsHardCapReached = attempt.IsHardCapReached(), - UncoveredKeyPropositionIds = task.KeyPropositions - .Where(kp => !coveredKpIds.Contains(kp.Id)) - .Select(kp => kp.Id).ToList(), - UnarticulatedKeyRelationIds = task.KeyRelations - .Where(kr => !articulatedRelationIds.Contains(kr.Id)) - .Select(kr => kr.Id).ToList() - }; - // Partial save: protects against stream interruption _unitOfWork.Save(); // Streaming phase: dialogue - var fullResponse = new System.Text.StringBuilder(); + var fullResponse = new StringBuilder(); + var state = CreateConversationState(attempt, task); await foreach (var token in _turnOrchestrator.StreamDialogueAsync( - evaluation, attempt.Turns.ToList(), task, state, ct)) + evaluation, attempt.Turns.ToList(), task, state, ct)) { fullResponse.Append(token); yield return token; @@ -204,7 +190,7 @@ private async IAsyncEnumerable RunTurnPipelineAsync( attempt.AddSystemTurn(fullResponse.ToString()); string? summary = null; - if (isCompleted) + if (state.IsCompleted) { var summaryResult = await _turnOrchestrator.SummarizeAsync(attempt, task, ct); summary = summaryResult.IsSuccess ? summaryResult.Value : null; @@ -240,6 +226,24 @@ private async IAsyncEnumerable RunTurnPipelineAsync( }); } + private static ConversationState CreateConversationState(ConversationAttempt attempt, ConceptElaborationTask task) + { + var coveredKpIds = attempt.GetCoveredPropositionIds(); + var articulatedRelationIds = attempt.GetArticulatedRelationIds(); + return new ConversationState + { + IsCompleted = task.IsAttemptComplete(attempt), + IsSoftCapReached = attempt.IsSoftCapReached(), + IsHardCapReached = attempt.IsHardCapReached(), + UncoveredKeyPropositionIds = task.KeyPropositions + .Where(kp => !coveredKpIds.Contains(kp.Id)) + .Select(kp => kp.Id).ToList(), + UnarticulatedKeyRelationIds = task.KeyRelations + .Where(kr => !articulatedRelationIds.Contains(kr.Id)) + .Select(kr => kr.Id).ToList() + }; + } + private static string BuildErrorChunk(string message, int code, int? attemptId = null) { if (attemptId.HasValue) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs index b9c5e9847..79144d582 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs @@ -10,6 +10,7 @@ namespace Tutor.Elaborations.Infrastructure.Agents; public class EvaluationAgent : IEvaluationAgent { + private const int MaxReattempts = 2; private readonly IAiChatService _chatService; public EvaluationAgent(IAiChatService chatService) @@ -18,8 +19,25 @@ public EvaluationAgent(IAiChatService chatService) } public async Task> EvaluateAsync(string content, - List history, ConceptElaborationTask task, - CancellationToken ct) + List history, ConceptElaborationTask task, CancellationToken ct) + { + var request = CreateRequestWithPromptAndParams(content, history, task); + + for (var attempt = 0; attempt < MaxReattempts; attempt++) + { + var result = await _chatService.CompleteAsync(request, ct); + if (result.IsFailed) continue; + + var evaluation = TryParseResponse(result.Value.Content); + if (evaluation == null) continue; + + return evaluation; + } + + return Result.Fail("Failed to parse evaluation response after retries."); + } + + private static CompletionRequest CreateRequestWithPromptAndParams(string content, List history, ConceptElaborationTask task) { var systemPrompt = EvaluationPromptBuilder.BuildSystemPrompt(task); var messageData = EvaluationPromptBuilder.BuildMessages(content, history); @@ -27,15 +45,16 @@ public async Task> EvaluateAsync(string content, var messages = messageData.Select(m => m.role == "user" ? ChatMessage.FromUser(m.content) : ChatMessage.FromAssistant(m.content)); - var request = CompletionRequest.Create(messages, systemPrompt, maxTokens: 1024, temperature: 0.1); + return CompletionRequest.Create(messages, systemPrompt, maxTokens: 1024, temperature: 0.1); + } - for (var attempt = 0; attempt < 2; attempt++) + private static EvaluationResult? TryParseResponse(string json) + { + try { - var result = await _chatService.CompleteAsync(request, ct); - if (result.IsFailed) continue; - - var parsed = TryParseResponse(result.Value.Content); - if (parsed == null) continue; + var parsed = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if(parsed == null) return null; var evaluation = new TurnEvaluation( parsed.CorrectnessScore, parsed.CompletenessScore, @@ -45,18 +64,7 @@ public async Task> EvaluateAsync(string content, parsed.MisconceptionsTriggeredIds ?? new List(), parsed.RelationsArticulatedIds ?? new List()); - return Result.Ok(new EvaluationResult(evaluation, parsed.IsSubstantive)); - } - - return Result.Fail("Failed to parse evaluation response after retries."); - } - - private static EvaluationResponse? TryParseResponse(string json) - { - try - { - return JsonSerializer.Deserialize(json, - new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + return new EvaluationResult(evaluation, parsed.IsSubstantive); } catch { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs index c3574b931..c63718e4e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs @@ -46,7 +46,7 @@ public void Gets_task_detail() using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-2"); - var actionResult = controller.GetTaskDetail(-1).Result; + var actionResult = controller.GetTaskWithAttempts(-1).Result; var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; result.ShouldNotBeNull(); @@ -65,7 +65,7 @@ public void Gets_task_detail_with_active_attempt() using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-3"); - var actionResult = controller.GetTaskDetail(-2).Result; + var actionResult = controller.GetTaskWithAttempts(-2).Result; var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; result.ShouldNotBeNull(); @@ -79,7 +79,7 @@ public void Gets_task_detail_unenrolled_fails() using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-1"); - var actionResult = controller.GetTaskDetail(-1).Result; + var actionResult = controller.GetTaskWithAttempts(-1).Result; var objectResult = actionResult as ObjectResult; objectResult.ShouldNotBeNull(); @@ -92,7 +92,7 @@ public void Gets_task_detail_nonexistent_fails() using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-2"); - var actionResult = controller.GetTaskDetail(-999).Result; + var actionResult = controller.GetTaskWithAttempts(-999).Result; var objectResult = actionResult as ObjectResult; objectResult.ShouldNotBeNull(); diff --git a/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs b/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs index 744d3998b..01941102f 100644 --- a/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs +++ b/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs @@ -27,9 +27,9 @@ public ActionResult> GetTasksForUnit(int unit } [HttpGet("concept-elaborations/{taskId:int}")] - public ActionResult GetTaskDetail(int taskId) + public ActionResult GetTaskWithAttempts(int taskId) { - var result = _conversationService.GetTaskDetail(taskId, User.LearnerId()); + var result = _conversationService.GetTaskWithAttempts(taskId, User.LearnerId()); return CreateResponse(result); } From 153777574da82edf91a059ecb4f10fd4a95e6278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Wed, 15 Apr 2026 16:36:42 +0200 Subject: [PATCH 14/51] refactor: Removes TurnOrchestrator as it was just delegating to agents. --- .../UseCases/Learning/ConversationService.cs | 21 ++++++---- .../Orchestration/TurnOrchestrator.cs | 40 ------------------- .../ElaborationsStartup.cs | 1 - 3 files changed, 13 insertions(+), 49 deletions(-) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnOrchestrator.cs diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index fda475df4..079e08e61 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -22,7 +22,9 @@ public class ConversationService : IConversationService private readonly IConversationAttemptRepository _attemptRepo; private readonly IConceptElaborationTaskRepository _taskRepo; - private readonly TurnOrchestrator _turnOrchestrator; + private readonly IEvaluationAgent _evaluationAgent; + private readonly IDialogueAgent _dialogueAgent; + private readonly ISummaryAgent _summaryAgent; private readonly ITokenSpendingService _tokenSpendingService; private readonly IAccessServices _accessServices; private readonly IElaborationsUnitOfWork _unitOfWork; @@ -30,12 +32,15 @@ public class ConversationService : IConversationService public ConversationService(IConversationAttemptRepository attemptRepo, IConceptElaborationTaskRepository taskRepo, - TurnOrchestrator turnOrchestrator, ITokenSpendingService tokenSpendingService, - IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, IMapper mapper) + IEvaluationAgent evaluationAgent, IDialogueAgent dialogueAgent, ISummaryAgent summaryAgent, + ITokenSpendingService tokenSpendingService, IAccessServices accessServices, + IElaborationsUnitOfWork unitOfWork, IMapper mapper) { _attemptRepo = attemptRepo; _taskRepo = taskRepo; - _turnOrchestrator = turnOrchestrator; + _evaluationAgent = evaluationAgent; + _dialogueAgent = dialogueAgent; + _summaryAgent = summaryAgent; _tokenSpendingService = tokenSpendingService; _accessServices = accessServices; _unitOfWork = unitOfWork; @@ -166,7 +171,7 @@ private async IAsyncEnumerable RunTurnPipelineAsync( [EnumeratorCancellation] CancellationToken ct) { // Synchronous phase: evaluate - var evalResult = await _turnOrchestrator.EvaluateAsync( + var evalResult = await _evaluationAgent.EvaluateAsync( content, attempt.Turns.ToList(), task, ct); if (evalResult.IsFailed) { yield return BuildErrorChunk("Evaluation failed. Please try again.", 500); yield break; } @@ -179,7 +184,7 @@ private async IAsyncEnumerable RunTurnPipelineAsync( // Streaming phase: dialogue var fullResponse = new StringBuilder(); var state = CreateConversationState(attempt, task); - await foreach (var token in _turnOrchestrator.StreamDialogueAsync( + await foreach (var token in _dialogueAgent.StreamAsync( evaluation, attempt.Turns.ToList(), task, state, ct)) { fullResponse.Append(token); @@ -192,13 +197,13 @@ private async IAsyncEnumerable RunTurnPipelineAsync( string? summary = null; if (state.IsCompleted) { - var summaryResult = await _turnOrchestrator.SummarizeAsync(attempt, task, ct); + var summaryResult = await _summaryAgent.SummarizeAsync(attempt, task, ct); summary = summaryResult.IsSuccess ? summaryResult.Value : null; attempt.Complete(summary); } else if (state.IsHardCapReached) { - var summaryResult = await _turnOrchestrator.SummarizeAsync(attempt, task, ct); + var summaryResult = await _summaryAgent.SummarizeAsync(attempt, task, ct); summary = summaryResult.IsSuccess ? summaryResult.Value : null; attempt.Expire(summary); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnOrchestrator.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnOrchestrator.cs deleted file mode 100644 index e864b9556..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnOrchestrator.cs +++ /dev/null @@ -1,40 +0,0 @@ -using FluentResults; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -public class TurnOrchestrator -{ - private readonly IEvaluationAgent _evaluationAgent; - private readonly IDialogueAgent _dialogueAgent; - private readonly ISummaryAgent _summaryAgent; - - public TurnOrchestrator(IEvaluationAgent evaluationAgent, - IDialogueAgent dialogueAgent, ISummaryAgent summaryAgent) - { - _evaluationAgent = evaluationAgent; - _dialogueAgent = dialogueAgent; - _summaryAgent = summaryAgent; - } - - public async Task> EvaluateAsync(string content, - List history, ConceptElaborationTask task, - CancellationToken ct) - { - return await _evaluationAgent.EvaluateAsync(content, history, task, ct); - } - - public IAsyncEnumerable StreamDialogueAsync(TurnEvaluation evaluation, - List history, ConceptElaborationTask task, - ConversationState state, CancellationToken ct) - { - return _dialogueAgent.StreamAsync(evaluation, history, task, state, ct); - } - - public async Task> SummarizeAsync(ConversationAttempt attempt, - ConceptElaborationTask task, CancellationToken ct) - { - return await _summaryAgent.SummarizeAsync(attempt, task, ct); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs index f90897367..443f4124a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs @@ -42,7 +42,6 @@ private static void SetupCore(IServiceCollection services) services.AddProxiedScoped(); services.AddProxiedScoped(); services.AddProxiedScoped(); - services.AddScoped(); } private static void SetupInfrastructure(IServiceCollection services) From e39347c254dfa871acdcd144341bb35d8b0c92ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Thu, 16 Apr 2026 07:51:05 +0200 Subject: [PATCH 15/51] refactor: Mildly reworks TurnEvaluation and ConversationTurn model. --- .../Dtos/Conversations/ConversationTurnDto.cs | 1 - .../Conversations/ConversationAttempt.cs | 10 +- .../Domain/Conversations/ConversationTurn.cs | 5 +- .../Domain/Conversations/TurnEvaluation.cs | 9 +- .../UseCases/Learning/ConversationService.cs | 4 +- .../Orchestration/EvaluationResult.cs | 5 - .../Orchestration/IEvaluationAgent.cs | 2 +- .../Agents/EvaluationAgent.cs | 8 +- .../Learning/ConversationTurnTests.cs | 4 +- .../TestData/e-conversation-attempts.sql | 216 +++++++++--------- .../Unit/ConceptElaborationTaskTests.cs | 4 +- 11 files changed, 127 insertions(+), 141 deletions(-) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/EvaluationResult.cs diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationTurnDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationTurnDto.cs index 4e39a6cff..182cab8c1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationTurnDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationTurnDto.cs @@ -5,7 +5,6 @@ public class ConversationTurnDto public int Id { get; set; } public string Role { get; set; } = string.Empty; public string Content { get; set; } = string.Empty; - public bool IsSubstantive { get; set; } public int Order { get; set; } public DateTime Timestamp { get; set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index b2f630005..8812dfb0f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -43,7 +43,7 @@ public ISet GetArticulatedRelationIds() public int CountSubstantiveLearnerTurns() { - return Turns.Count(t => t.Role == TurnRole.Learner && t.IsSubstantive); + return Turns.Count(t => t.Role == TurnRole.Learner && t.Evaluation?.IsSubstantive == true); } public int CountTotalLearnerTurns() @@ -55,18 +55,16 @@ public int CountTotalLearnerTurns() public bool IsHardCapReached() => CountTotalLearnerTurns() >= HardCapTotalTurns; - public ConversationTurn AddLearnerTurn(string content, - bool isSubstantive, TurnEvaluation? evaluation) + public ConversationTurn AddLearnerTurn(string content, TurnEvaluation? evaluation) { - var turn = new ConversationTurn(TurnRole.Learner, content, - isSubstantive, Turns.Count, evaluation); + var turn = new ConversationTurn(TurnRole.Learner, content, Turns.Count, evaluation); Turns.Add(turn); return turn; } public ConversationTurn AddSystemTurn(string content) { - var turn = new ConversationTurn(TurnRole.System, content, true, Turns.Count); + var turn = new ConversationTurn(TurnRole.System, content, Turns.Count); Turns.Add(turn); return turn; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs index f023d509b..be73ef0b8 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs @@ -7,19 +7,16 @@ public class ConversationTurn : Entity public int ConversationAttemptId { get; private set; } public TurnRole Role { get; private set; } public string Content { get; private set; } = string.Empty; - public bool IsSubstantive { get; private set; } public int Order { get; private set; } public DateTime Timestamp { get; private set; } public TurnEvaluation? Evaluation { get; private set; } private ConversationTurn() { } - internal ConversationTurn(TurnRole role, string content, - bool isSubstantive, int order, TurnEvaluation? evaluation = null) + internal ConversationTurn(TurnRole role, string content, int order, TurnEvaluation? evaluation = null) { Role = role; Content = content; - IsSubstantive = isSubstantive; Order = order; Timestamp = DateTime.UtcNow; Evaluation = evaluation; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs index 8a612eb24..b826f5ede 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs @@ -5,6 +5,7 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; public class TurnEvaluation : Entity { public int ConversationTurnId { get; private set; } + public bool IsSubstantive { get; private set; } public int CorrectnessScore { get; private set; } public int CompletenessScore { get; private set; } public int? DiscriminationScore { get; private set; } @@ -17,12 +18,10 @@ public class TurnEvaluation : Entity private TurnEvaluation() { } - public TurnEvaluation(int correctnessScore, int completenessScore, - int? discriminationScore, int? integrationScore, - string justification, string? novelMisconceptions, - List propositionsCoveredIds, List misconceptionsTriggeredIds, - List relationsArticulatedIds) + public TurnEvaluation(bool isSubstantive, int correctnessScore, int completenessScore, int? discriminationScore, int? integrationScore, + string justification, string? novelMisconceptions, List propositionsCoveredIds, List misconceptionsTriggeredIds, List relationsArticulatedIds) { + IsSubstantive = isSubstantive; CorrectnessScore = correctnessScore; CompletenessScore = completenessScore; DiscriminationScore = discriminationScore; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index 079e08e61..38e2b49ce 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -175,8 +175,8 @@ private async IAsyncEnumerable RunTurnPipelineAsync( content, attempt.Turns.ToList(), task, ct); if (evalResult.IsFailed) { yield return BuildErrorChunk("Evaluation failed. Please try again.", 500); yield break; } - var evaluation = evalResult.Value.Evaluation; - attempt.AddLearnerTurn(content, evalResult.Value.IsSubstantive, evaluation); + var evaluation = evalResult.Value; + attempt.AddLearnerTurn(content, evaluation); // Partial save: protects against stream interruption _unitOfWork.Save(); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/EvaluationResult.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/EvaluationResult.cs deleted file mode 100644 index 7065afe47..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/EvaluationResult.cs +++ /dev/null @@ -1,5 +0,0 @@ -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -public record EvaluationResult(TurnEvaluation Evaluation, bool IsSubstantive); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs index 7396de06b..61c27a979 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs @@ -6,7 +6,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public interface IEvaluationAgent { - Task> EvaluateAsync(string content, + Task> EvaluateAsync(string content, List history, ConceptElaborationTask task, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs index 79144d582..1060e8f29 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs @@ -18,7 +18,7 @@ public EvaluationAgent(IAiChatService chatService) _chatService = chatService; } - public async Task> EvaluateAsync(string content, + public async Task> EvaluateAsync(string content, List history, ConceptElaborationTask task, CancellationToken ct) { var request = CreateRequestWithPromptAndParams(content, history, task); @@ -48,7 +48,7 @@ private static CompletionRequest CreateRequestWithPromptAndParams(string content return CompletionRequest.Create(messages, systemPrompt, maxTokens: 1024, temperature: 0.1); } - private static EvaluationResult? TryParseResponse(string json) + private static TurnEvaluation? TryParseResponse(string json) { try { @@ -56,15 +56,13 @@ private static CompletionRequest CreateRequestWithPromptAndParams(string content new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); if(parsed == null) return null; - var evaluation = new TurnEvaluation( + return new TurnEvaluation(parsed.IsSubstantive, parsed.CorrectnessScore, parsed.CompletenessScore, parsed.DiscriminationScore, parsed.IntegrationScore, parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, parsed.PropositionsCoveredIds ?? new List(), parsed.MisconceptionsTriggeredIds ?? new List(), parsed.RelationsArticulatedIds ?? new List()); - - return new EvaluationResult(evaluation, parsed.IsSubstantive); } catch { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs index 43cec879a..22507c557 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -119,8 +119,8 @@ public async Task Soft_cap_reached_continues() var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.ChangeTracker.Clear(); var attempt = dbContext.ConversationAttempts.Include(a => a.Turns) - .First(a => a.Id == -6); - attempt.Turns.Count(t => t.Role == 0 && t.IsSubstantive).ShouldBe(6); + .ThenInclude(t => t.Evaluation).First(a => a.Id == -6); + attempt.Turns.Count(t => t.Role == 0 && t.Evaluation?.IsSubstantive == true).ShouldBe(6); } [Fact] 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 index fddc6ee44..aca1b19ea 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -2,17 +2,17 @@ INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-1, -1, -2, 1, '2024-06-01 10:00:00+00', '2024-06-01 10:15:00+00', 'Good understanding of encapsulation basics.'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-1, -1, 0, 'Encapsulation bundles data and methods in a class.', true, 0, '2024-06-01 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-2, -1, 1, 'Good start! Can you tell me more about access modifiers?', true, 1, '2024-06-01 10:01:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-3, -1, 0, 'Access modifiers like public and private control visibility.', true, 2, '2024-06-01 10:02:00+00'); - -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-1, -1, 2, 2, 2, null, 'Accurate basic description', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-3, -3, 2, 2, 2, null, 'Good description of access modifiers', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-1, -1, 0, 'Encapsulation bundles data and methods in a class.', 0, '2024-06-01 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-2, -1, 1, 'Good start! Can you tell me more about access modifiers?', 1, '2024-06-01 10:01:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-3, -1, 0, 'Access modifiers like public and private control visibility.', 2, '2024-06-01 10:02:00+00'); + +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-1, -1, true, 2, 2, 2, null, 'Accurate basic description', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-3, -3, true, 2, 2, 2, null, 'Good description of access modifiers', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb); -- Attempt -2: Learner -2, CET -1, Abandoned (for query tests) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -22,122 +22,122 @@ VALUES (-2, -1, -2, 2, '2024-06-02 10:00:00+00', '2024-06-02 10:05:00+00', null) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-3, -1, -3, 0, '2024-06-03 10:00:00+00', null, null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-4, -3, 0, 'Encapsulation is about data hiding.', true, 0, '2024-06-03 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', true, 1, '2024-06-03 10:01:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-4, -3, 0, 'Encapsulation is about data hiding.', 0, '2024-06-03 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', 1, '2024-06-03 10:01:05+00'); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-4, -4, 1, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '[-210]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-4, -4, true, 1, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '[-210]'::jsonb, '[]'::jsonb); -- Attempt -4: Learner -3, CET -2, InProgress (for completion test: KP -20 already covered, submit to cover -21) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-4, -2, -3, 0, '2024-06-04 10:00:00+00', null, null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-6, -4, 0, 'Encapsulation bundles data and methods together.', true, 0, '2024-06-04 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-7, -4, 1, 'Good. What about access control?', true, 1, '2024-06-04 10:01:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-6, -4, 0, 'Encapsulation bundles data and methods together.', 0, '2024-06-04 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-7, -4, 1, 'Good. What about access control?', 1, '2024-06-04 10:01:05+00'); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-6, -6, 2, 2, 2, null, 'Covers bundling proposition', null, '[-20]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-6, -6, true, 2, 2, 2, null, 'Covers bundling proposition', null, '[-20]'::jsonb, '[]'::jsonb, '[]'::jsonb); -- Attempt -5: Learner -2, CET -2, InProgress with 9 learner + 9 system turns (for hard cap test) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-5, -2, -2, 0, '2024-06-05 10:00:00+00', null, null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-50, -5, 0, 'Turn 1', true, 0, '2024-06-05 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-51, -5, 1, 'Response 1', true, 1, '2024-06-05 10:01:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-52, -5, 0, 'Turn 2', true, 2, '2024-06-05 10:02:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-53, -5, 1, 'Response 2', true, 3, '2024-06-05 10:02:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-54, -5, 0, 'Turn 3', true, 4, '2024-06-05 10:03:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-55, -5, 1, 'Response 3', true, 5, '2024-06-05 10:03:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-56, -5, 0, 'Turn 4', true, 6, '2024-06-05 10:04:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-57, -5, 1, 'Response 4', true, 7, '2024-06-05 10:04:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-58, -5, 0, 'Turn 5', true, 8, '2024-06-05 10:05:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-59, -5, 1, 'Response 5', true, 9, '2024-06-05 10:05:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-60, -5, 0, 'Turn 6', true, 10, '2024-06-05 10:06:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-61, -5, 1, 'Response 6', true, 11, '2024-06-05 10:06:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-62, -5, 0, 'Turn 7', true, 12, '2024-06-05 10:07:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-63, -5, 1, 'Response 7', true, 13, '2024-06-05 10:07:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-64, -5, 0, 'Turn 8', true, 14, '2024-06-05 10:08:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-65, -5, 1, 'Response 8', true, 15, '2024-06-05 10:08:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-66, -5, 0, 'Turn 9', true, 16, '2024-06-05 10:09:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-67, -5, 1, 'Response 9', true, 17, '2024-06-05 10:09:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-50, -5, 0, 'Turn 1', 0, '2024-06-05 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-51, -5, 1, 'Response 1', 1, '2024-06-05 10:01:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-52, -5, 0, 'Turn 2', 2, '2024-06-05 10:02:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-53, -5, 1, 'Response 2', 3, '2024-06-05 10:02:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-54, -5, 0, 'Turn 3', 4, '2024-06-05 10:03:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-55, -5, 1, 'Response 3', 5, '2024-06-05 10:03:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-56, -5, 0, 'Turn 4', 6, '2024-06-05 10:04:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-57, -5, 1, 'Response 4', 7, '2024-06-05 10:04:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-58, -5, 0, 'Turn 5', 8, '2024-06-05 10:05:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-59, -5, 1, 'Response 5', 9, '2024-06-05 10:05:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-60, -5, 0, 'Turn 6', 10, '2024-06-05 10:06:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-61, -5, 1, 'Response 6', 11, '2024-06-05 10:06:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-62, -5, 0, 'Turn 7', 12, '2024-06-05 10:07:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-63, -5, 1, 'Response 7', 13, '2024-06-05 10:07:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-64, -5, 0, 'Turn 8', 14, '2024-06-05 10:08:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-65, -5, 1, 'Response 8', 15, '2024-06-05 10:08:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-66, -5, 0, 'Turn 9', 16, '2024-06-05 10:09:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-67, -5, 1, 'Response 9', 17, '2024-06-05 10:09:05+00'); -- Evaluations for the 9 learner turns (all with empty propositions - never completes) -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-50, -50, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-52, -52, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-54, -54, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-56, -56, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-58, -58, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-60, -60, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-62, -62, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-64, -64, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-66, -66, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-50, -50, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-52, -52, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-54, -54, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-56, -56, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-58, -58, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-60, -60, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-62, -62, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-64, -64, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-66, -66, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -- Attempt -6: Learner -3, CET -3, InProgress with 5 substantive learner + 5 system turns (for soft cap test) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-6, -3, -3, 0, '2024-06-06 10:00:00+00', null, null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-70, -6, 0, 'Turn 1', true, 0, '2024-06-06 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-71, -6, 1, 'Response 1', true, 1, '2024-06-06 10:01:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-72, -6, 0, 'Turn 2', true, 2, '2024-06-06 10:02:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-73, -6, 1, 'Response 2', true, 3, '2024-06-06 10:02:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-74, -6, 0, 'Turn 3', true, 4, '2024-06-06 10:03:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-75, -6, 1, 'Response 3', true, 5, '2024-06-06 10:03:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-76, -6, 0, 'Turn 4', true, 6, '2024-06-06 10:04:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-77, -6, 1, 'Response 4', true, 7, '2024-06-06 10:04:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-78, -6, 0, 'Turn 5', true, 8, '2024-06-06 10:05:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "IsSubstantive", "Order", "Timestamp") -VALUES (-79, -6, 1, 'Response 5', true, 9, '2024-06-06 10:05:05+00'); - -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-70, -70, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-72, -72, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-74, -74, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-76, -76, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-78, -78, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-70, -6, 0, 'Turn 1', 0, '2024-06-06 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-71, -6, 1, 'Response 1', 1, '2024-06-06 10:01:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-72, -6, 0, 'Turn 2', 2, '2024-06-06 10:02:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-73, -6, 1, 'Response 2', 3, '2024-06-06 10:02:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-74, -6, 0, 'Turn 3', 4, '2024-06-06 10:03:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-75, -6, 1, 'Response 3', 5, '2024-06-06 10:03:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-76, -6, 0, 'Turn 4', 6, '2024-06-06 10:04:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-77, -6, 1, 'Response 4', 7, '2024-06-06 10:04:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-78, -6, 0, 'Turn 5', 8, '2024-06-06 10:05:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-79, -6, 1, 'Response 5', 9, '2024-06-06 10:05:05+00'); + +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-70, -70, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-72, -72, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-74, -74, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-76, -76, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-78, -78, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -- Attempt -7: Learner -3, CET -5, InProgress (isolated for abandon test — no other test touches this) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs index e8e9943e2..430ed44af 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs @@ -79,14 +79,14 @@ private static ConversationAttempt MakeAttemptWithCoveredAndArticulated( var evalCtor = typeof(TurnEvaluation).GetConstructors().First(c => c.GetParameters().Length > 0); var evaluation = (TurnEvaluation)evalCtor.Invoke([ - 2, 2, (int?)null, (int?)null, "test", null, + true, 2, 2, (int?)null, (int?)null, "test", null, coveredKpIds, new List(), articulatedRelationIds ]); var turnCtor = typeof(ConversationTurn).GetConstructors( BindingFlags.NonPublic | BindingFlags.Instance) .First(c => c.GetParameters().Length > 0); - var turn = (ConversationTurn)turnCtor.Invoke([TurnRole.Learner, "x", true, 0, evaluation]); + var turn = (ConversationTurn)turnCtor.Invoke([TurnRole.Learner, "x", 0, evaluation]); SetProp(attempt, "Turns", new List { turn }); return attempt; From ae2fbc07837b1db0c635eef6ae75c7d3df9ba9b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Thu, 16 Apr 2026 08:21:16 +0200 Subject: [PATCH 16/51] refactor: Removes ConversationState dto to simplify code. --- .../ConceptElaborationTask.cs | 12 +++++++ .../UseCases/Learning/ConversationService.cs | 26 ++------------- .../Orchestration/ConversationState.cs | 10 ------ .../Learning/Orchestration/IDialogueAgent.cs | 4 +-- .../Agents/DialogueAgent.cs | 8 ++--- .../Agents/Prompts/DialoguePromptBuilder.cs | 32 ++++++++++++------- .../Agents/Prompts/EvaluationPromptBuilder.cs | 15 ++++++--- 7 files changed, 52 insertions(+), 55 deletions(-) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ConversationState.cs diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs index 137aa9fbb..71f29dd5e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs @@ -42,4 +42,16 @@ public bool IsAttemptComplete(ConversationAttempt attempt) { return AreAllPropositionsCovered(attempt) && AreAllKeyRelationsArticulated(attempt); } + + public List GetUncoveredPropositionIds(ConversationAttempt attempt) + { + var coveredIds = attempt.GetCoveredPropositionIds(); + return KeyPropositions.Where(kp => !coveredIds.Contains(kp.Id)).Select(kp => kp.Id).ToList(); + } + + public List GetUnarticulatedRelationIds(ConversationAttempt attempt) + { + var articulatedIds = attempt.GetArticulatedRelationIds(); + return KeyRelations.Where(kr => !articulatedIds.Contains(kr.Id)).Select(kr => kr.Id).ToList(); + } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index 38e2b49ce..5f19fa73b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -183,9 +183,7 @@ private async IAsyncEnumerable RunTurnPipelineAsync( // Streaming phase: dialogue var fullResponse = new StringBuilder(); - var state = CreateConversationState(attempt, task); - await foreach (var token in _dialogueAgent.StreamAsync( - evaluation, attempt.Turns.ToList(), task, state, ct)) + await foreach (var token in _dialogueAgent.StreamAsync(evaluation, attempt, task, ct)) { fullResponse.Append(token); yield return token; @@ -195,13 +193,13 @@ private async IAsyncEnumerable RunTurnPipelineAsync( attempt.AddSystemTurn(fullResponse.ToString()); string? summary = null; - if (state.IsCompleted) + if (task.IsAttemptComplete(attempt)) { var summaryResult = await _summaryAgent.SummarizeAsync(attempt, task, ct); summary = summaryResult.IsSuccess ? summaryResult.Value : null; attempt.Complete(summary); } - else if (state.IsHardCapReached) + else if (attempt.IsHardCapReached()) { var summaryResult = await _summaryAgent.SummarizeAsync(attempt, task, ct); summary = summaryResult.IsSuccess ? summaryResult.Value : null; @@ -231,24 +229,6 @@ private async IAsyncEnumerable RunTurnPipelineAsync( }); } - private static ConversationState CreateConversationState(ConversationAttempt attempt, ConceptElaborationTask task) - { - var coveredKpIds = attempt.GetCoveredPropositionIds(); - var articulatedRelationIds = attempt.GetArticulatedRelationIds(); - return new ConversationState - { - IsCompleted = task.IsAttemptComplete(attempt), - IsSoftCapReached = attempt.IsSoftCapReached(), - IsHardCapReached = attempt.IsHardCapReached(), - UncoveredKeyPropositionIds = task.KeyPropositions - .Where(kp => !coveredKpIds.Contains(kp.Id)) - .Select(kp => kp.Id).ToList(), - UnarticulatedKeyRelationIds = task.KeyRelations - .Where(kr => !articulatedRelationIds.Contains(kr.Id)) - .Select(kr => kr.Id).ToList() - }; - } - private static string BuildErrorChunk(string message, int code, int? attemptId = null) { if (attemptId.HasValue) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ConversationState.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ConversationState.cs deleted file mode 100644 index 2843bca4f..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ConversationState.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -public class ConversationState -{ - public bool IsCompleted { get; set; } - public bool IsSoftCapReached { get; set; } - public bool IsHardCapReached { get; set; } - public List UncoveredKeyPropositionIds { get; set; } = new(); - public List UnarticulatedKeyRelationIds { get; set; } = new(); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs index 23d3525fd..a4fa2931a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs @@ -6,6 +6,6 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public interface IDialogueAgent { IAsyncEnumerable StreamAsync(TurnEvaluation evaluation, - List history, ConceptElaborationTask task, - ConversationState state, CancellationToken ct); + ConversationAttempt attempt, ConceptElaborationTask task, + CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs index 9c587ef91..fb7f8bc10 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs @@ -17,11 +17,11 @@ public DialogueAgent(IAiChatService chatService) } public async IAsyncEnumerable StreamAsync(TurnEvaluation evaluation, - List history, ConceptElaborationTask task, - ConversationState state, [EnumeratorCancellation] CancellationToken ct) + ConversationAttempt attempt, ConceptElaborationTask task, + [EnumeratorCancellation] CancellationToken ct) { - var systemPrompt = DialoguePromptBuilder.BuildSystemPrompt(task, state); - var messageData = DialoguePromptBuilder.BuildMessages(history); + var systemPrompt = DialoguePromptBuilder.BuildSystemPrompt(task, attempt); + var messageData = DialoguePromptBuilder.BuildMessages(attempt.Turns.ToList()); var summaryParts = new List { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs index e0893e18c..60bad534f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs @@ -1,13 +1,12 @@ using System.Text; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; namespace Tutor.Elaborations.Infrastructure.Agents.Prompts; public static class DialoguePromptBuilder { - public static string BuildSystemPrompt(ConceptElaborationTask task, ConversationState state) + public static string BuildSystemPrompt(ConceptElaborationTask task, ConversationAttempt attempt) { var sb = new StringBuilder(); sb.AppendLine("You are a Socratic dialogue agent for a tutoring system. You speak Serbian."); @@ -31,7 +30,7 @@ public static string BuildSystemPrompt(ConceptElaborationTask task, Conversation sb.AppendLine($"- [KP-{kp.Id}] {kp.Statement}"); sb.AppendLine(); - if (task.KeyRelations.Any()) + if (task.KeyRelations.Count != 0) { sb.AppendLine("## Key Relations (for your reference only, never reveal the mechanism text):"); var kpById = task.KeyPropositions.ToDictionary(kp => kp.Id, kp => kp.Statement); @@ -44,36 +43,45 @@ public static string BuildSystemPrompt(ConceptElaborationTask task, Conversation sb.AppendLine(); } - if (state.IsCompleted) + sb.AppendLine(CreateClosing(task, attempt)); + + return sb.ToString(); + } + + private static string CreateClosing(ConceptElaborationTask task, ConversationAttempt attempt) + { + var sb = new StringBuilder(); + if (task.IsAttemptComplete(attempt)) { sb.AppendLine("## The learner has covered all required propositions and articulated all required relations."); sb.AppendLine("Provide a brief closing acknowledgment. Do not ask more questions."); } - else if (state.IsHardCapReached) + else if (attempt.IsHardCapReached()) { sb.AppendLine("## The conversation has reached its maximum length."); sb.AppendLine("Provide a brief closing summary. Do not ask more questions."); } else { - if (state.UncoveredKeyPropositionIds.Any() || state.UnarticulatedKeyRelationIds.Any()) + var uncoveredKpIds = task.GetUncoveredPropositionIds(attempt); + var unarticulatedKrIds = task.GetUnarticulatedRelationIds(attempt); + if (uncoveredKpIds.Any() || unarticulatedKrIds.Any()) { sb.AppendLine("## Focus areas for the next question:"); - if (state.UncoveredKeyPropositionIds.Any()) - sb.AppendLine($"- Uncovered key propositions: {string.Join(", ", state.UncoveredKeyPropositionIds.Select(id => $"KP-{id}"))}"); - if (state.UnarticulatedKeyRelationIds.Any()) - sb.AppendLine($"- Unarticulated key relations: {string.Join(", ", state.UnarticulatedKeyRelationIds.Select(id => $"KR-{id}"))}"); + if (uncoveredKpIds.Any()) + sb.AppendLine($"- Uncovered key propositions: {string.Join(", ", uncoveredKpIds.Select(id => $"KP-{id}"))}"); + if (unarticulatedKrIds.Any()) + sb.AppendLine($"- Unarticulated key relations: {string.Join(", ", unarticulatedKrIds.Select(id => $"KR-{id}"))}"); sb.AppendLine("Pick the most important gap and probe it. Never reveal the underlying statement or mechanism text."); sb.AppendLine(); } - if (state.IsSoftCapReached) + if (attempt.IsSoftCapReached()) { sb.AppendLine("## The learner is approaching the end of the conversation."); sb.AppendLine("Suggest wrapping up. Focus on the most important uncovered gap above."); } } - return sb.ToString(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs index 2b9df3dd7..5e7e2bd62 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs @@ -8,9 +8,9 @@ public static class EvaluationPromptBuilder { public static string BuildSystemPrompt(ConceptElaborationTask task) { - var hasBoundaryConditions = task.BoundaryConditions.Any(); - var hasCommonMisconceptions = task.CommonMisconceptions.Any(); - var hasKeyRelations = task.KeyRelations.Any(); + var hasBoundaryConditions = task.BoundaryConditions.Count != 0; + var hasCommonMisconceptions = task.CommonMisconceptions.Count != 0; + var hasKeyRelations = task.KeyRelations.Count != 0; var sb = new StringBuilder(); sb.AppendLine("You are an evaluation agent for a Socratic tutoring system."); @@ -54,6 +54,14 @@ public static string BuildSystemPrompt(ConceptElaborationTask task) sb.AppendLine(); } + sb.AppendLine(CreateScoringRules(hasBoundaryConditions, hasKeyRelations, hasCommonMisconceptions)); + + return sb.ToString(); + } + + private static string CreateScoringRules(bool hasBoundaryConditions, bool hasKeyRelations, bool hasCommonMisconceptions) + { + var sb = new StringBuilder(); sb.AppendLine("## Scoring Rules:"); var correctnessLine = hasBoundaryConditions ? "- Correctness (1-3): Are stated claims true? Check against KPs and BCs." @@ -86,7 +94,6 @@ public static string BuildSystemPrompt(ConceptElaborationTask task) sb.AppendLine(" \"novelMisconceptions\": \"any misconceptions not in the list, or null\","); sb.AppendLine(" \"isSubstantive\": true/false"); sb.AppendLine("}"); - return sb.ToString(); } From 038b4e3ceeb6a2f09c9ba7848cae46323b85d042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Mon, 20 Apr 2026 13:36:55 +0300 Subject: [PATCH 17/51] fix: LLM can now answer clarifying questions instead of treating every message as something that needs to be evaluated. --- .../Public/Learning/IConversationService.cs | 6 +- .../Conversations/ConversationAttempt.cs | 6 +- .../Domain/Conversations/ConversationTurn.cs | 5 +- .../Domain/Conversations/TurnEvaluation.cs | 8 +- .../Domain/Conversations/TurnIntent.cs | 8 + .../UseCases/Learning/ConversationService.cs | 25 +- .../Learning/Orchestration/IDialogueAgent.cs | 2 +- .../Orchestration/IEvaluationAgent.cs | 2 +- .../Learning/Orchestration/TurnAnalysis.cs | 10 + .../Agents/DialogueAgent.cs | 32 +-- .../Agents/EvaluationAgent.cs | 58 +++-- .../Agents/Prompts/DialoguePromptBuilder.cs | 69 +++++- .../Agents/Prompts/EvaluationPromptBuilder.cs | 93 +++++--- .../ElaborationsTestFactory.cs | 32 ++- .../Learning/ConversationTurnTests.cs | 3 +- .../TestData/e-conversation-attempts.sql | 216 +++++++++--------- .../Unit/ConceptElaborationTaskTests.cs | 4 +- 17 files changed, 360 insertions(+), 219 deletions(-) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnAnalysis.cs diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs index ce89af8c4..9af9bc921 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs @@ -8,9 +8,7 @@ public interface IConversationService { Result> GetTasksForUnit(int unitId, int learnerId); Result GetTaskWithAttempts(int taskId, int learnerId); - IAsyncEnumerable StartConversationAsync( - int taskId, string content, int learnerId, CancellationToken ct); - IAsyncEnumerable SubmitTurnAsync( - int attemptId, string content, int learnerId, CancellationToken ct); + IAsyncEnumerable StartConversationAsync(int taskId, string content, int learnerId, CancellationToken ct); + IAsyncEnumerable SubmitTurnAsync(int attemptId, string content, int learnerId, CancellationToken ct); Result AbandonAttempt(int attemptId, int learnerId); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index 8812dfb0f..d3ce2819d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -43,7 +43,7 @@ public ISet GetArticulatedRelationIds() public int CountSubstantiveLearnerTurns() { - return Turns.Count(t => t.Role == TurnRole.Learner && t.Evaluation?.IsSubstantive == true); + return Turns.Count(t => t.Role == TurnRole.Learner && t.Intent == TurnIntent.Substantive); } public int CountTotalLearnerTurns() @@ -55,9 +55,9 @@ public int CountTotalLearnerTurns() public bool IsHardCapReached() => CountTotalLearnerTurns() >= HardCapTotalTurns; - public ConversationTurn AddLearnerTurn(string content, TurnEvaluation? evaluation) + public ConversationTurn AddLearnerTurn(string content, TurnIntent intent, TurnEvaluation? evaluation) { - var turn = new ConversationTurn(TurnRole.Learner, content, Turns.Count, evaluation); + var turn = new ConversationTurn(TurnRole.Learner, content, Turns.Count, intent, evaluation); Turns.Add(turn); return turn; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs index be73ef0b8..f7fa19163 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs @@ -9,16 +9,19 @@ public class ConversationTurn : Entity public string Content { get; private set; } = string.Empty; public int Order { get; private set; } public DateTime Timestamp { get; private set; } + public TurnIntent? Intent { get; private set; } public TurnEvaluation? Evaluation { get; private set; } private ConversationTurn() { } - internal ConversationTurn(TurnRole role, string content, int order, TurnEvaluation? evaluation = null) + internal ConversationTurn(TurnRole role, string content, int order, + TurnIntent? intent = null, TurnEvaluation? evaluation = null) { Role = role; Content = content; Order = order; Timestamp = DateTime.UtcNow; + Intent = intent; Evaluation = evaluation; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs index b826f5ede..82e8d4481 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs @@ -5,7 +5,6 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; public class TurnEvaluation : Entity { public int ConversationTurnId { get; private set; } - public bool IsSubstantive { get; private set; } public int CorrectnessScore { get; private set; } public int CompletenessScore { get; private set; } public int? DiscriminationScore { get; private set; } @@ -18,10 +17,11 @@ public class TurnEvaluation : Entity private TurnEvaluation() { } - public TurnEvaluation(bool isSubstantive, int correctnessScore, int completenessScore, int? discriminationScore, int? integrationScore, - string justification, string? novelMisconceptions, List propositionsCoveredIds, List misconceptionsTriggeredIds, List relationsArticulatedIds) + public TurnEvaluation(int correctnessScore, int completenessScore, + int? discriminationScore, int? integrationScore, + string justification, string? novelMisconceptions, + List propositionsCoveredIds, List misconceptionsTriggeredIds, List relationsArticulatedIds) { - IsSubstantive = isSubstantive; CorrectnessScore = correctnessScore; CompletenessScore = completenessScore; DiscriminationScore = discriminationScore; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs new file mode 100644 index 000000000..932db9562 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs @@ -0,0 +1,8 @@ +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public enum TurnIntent +{ + Substantive, + Clarification, + OffTopic +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index 5f19fa73b..b2b08f0f0 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -30,8 +30,7 @@ public class ConversationService : IConversationService private readonly IElaborationsUnitOfWork _unitOfWork; private readonly IMapper _mapper; - public ConversationService(IConversationAttemptRepository attemptRepo, - IConceptElaborationTaskRepository taskRepo, + public ConversationService(IConversationAttemptRepository attemptRepo, IConceptElaborationTaskRepository taskRepo, IEvaluationAgent evaluationAgent, IDialogueAgent dialogueAgent, ISummaryAgent summaryAgent, ITokenSpendingService tokenSpendingService, IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, IMapper mapper) @@ -81,8 +80,7 @@ public Result GetTaskWithAttempts(int taskId, int lea return Result.Ok(dto); } - public async IAsyncEnumerable StartConversationAsync(int taskId, string content, - int learnerId, [EnumeratorCancellation] CancellationToken ct) + public async IAsyncEnumerable StartConversationAsync(int taskId, string content, int learnerId, [EnumeratorCancellation] CancellationToken ct) { var task = _taskRepo.Get(taskId); if (task == null) { yield return BuildErrorChunk("Task not found.", 404); yield break; } @@ -123,8 +121,7 @@ public async IAsyncEnumerable StartConversationAsync(int taskId, string yield return token; } - public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string content, - int learnerId, [EnumeratorCancellation] CancellationToken ct) + public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string content, int learnerId, [EnumeratorCancellation] CancellationToken ct) { var attempt = _attemptRepo.Get(attemptId); if (attempt == null) { yield return BuildErrorChunk("Attempt not found.", 404); yield break; } @@ -166,24 +163,22 @@ public Result AbandonAttempt(int attemptId, int learnerI return Result.Ok(_mapper.Map(attempt)); } - private async IAsyncEnumerable RunTurnPipelineAsync( - ConversationAttempt attempt, ConceptElaborationTask task, string content, - [EnumeratorCancellation] CancellationToken ct) + private async IAsyncEnumerable RunTurnPipelineAsync(ConversationAttempt attempt, ConceptElaborationTask task, string content, [EnumeratorCancellation] CancellationToken ct) { - // Synchronous phase: evaluate - var evalResult = await _evaluationAgent.EvaluateAsync( + // Synchronous phase: classify + evaluate + var analysisResult = await _evaluationAgent.AnalyzeAsync( content, attempt.Turns.ToList(), task, ct); - if (evalResult.IsFailed) { yield return BuildErrorChunk("Evaluation failed. Please try again.", 500); yield break; } + if (analysisResult.IsFailed) { yield return BuildErrorChunk("Evaluation failed. Please try again.", 500); yield break; } - var evaluation = evalResult.Value; - attempt.AddLearnerTurn(content, evaluation); + var analysis = analysisResult.Value; + attempt.AddLearnerTurn(content, analysis.Intent, analysis.Evaluation); // Partial save: protects against stream interruption _unitOfWork.Save(); // Streaming phase: dialogue var fullResponse = new StringBuilder(); - await foreach (var token in _dialogueAgent.StreamAsync(evaluation, attempt, task, ct)) + await foreach (var token in _dialogueAgent.StreamAsync(analysis, attempt, task, ct)) { fullResponse.Append(token); yield return token; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs index a4fa2931a..e1266d44e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs @@ -5,7 +5,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public interface IDialogueAgent { - IAsyncEnumerable StreamAsync(TurnEvaluation evaluation, + IAsyncEnumerable StreamAsync(TurnAnalysis analysis, ConversationAttempt attempt, ConceptElaborationTask task, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs index 61c27a979..3e1960665 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs @@ -6,7 +6,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public interface IEvaluationAgent { - Task> EvaluateAsync(string content, + Task> AnalyzeAsync(string content, List history, ConceptElaborationTask task, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnAnalysis.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnAnalysis.cs new file mode 100644 index 000000000..09f1f5b60 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnAnalysis.cs @@ -0,0 +1,10 @@ +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public record TurnAnalysis(TurnIntent Intent, TurnEvaluation? Evaluation) +{ + public static TurnAnalysis Substantive(TurnEvaluation evaluation) => new(TurnIntent.Substantive, evaluation); + public static TurnAnalysis Clarification() => new(TurnIntent.Clarification, null); + public static TurnAnalysis OffTopic() => new(TurnIntent.OffTopic, null); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs index fb7f8bc10..86a3c9ed5 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs @@ -16,25 +16,15 @@ public DialogueAgent(IAiChatService chatService) _chatService = chatService; } - public async IAsyncEnumerable StreamAsync(TurnEvaluation evaluation, + public async IAsyncEnumerable StreamAsync(TurnAnalysis analysis, ConversationAttempt attempt, ConceptElaborationTask task, [EnumeratorCancellation] CancellationToken ct) { - var systemPrompt = DialoguePromptBuilder.BuildSystemPrompt(task, attempt); + var systemPrompt = DialoguePromptBuilder.BuildSystemPrompt(task, attempt, analysis.Intent); var messageData = DialoguePromptBuilder.BuildMessages(attempt.Turns.ToList()); - var summaryParts = new List - { - $"correctness={evaluation.CorrectnessScore}", - $"completeness={evaluation.CompletenessScore}" - }; - if (evaluation.DiscriminationScore.HasValue) - summaryParts.Add($"discrimination={evaluation.DiscriminationScore.Value}"); - if (evaluation.IntegrationScore.HasValue) - summaryParts.Add($"integration={evaluation.IntegrationScore.Value}"); - var evalSummary = $"[Evaluation: {string.Join(", ", summaryParts)}. " + - $"Justification: {evaluation.Justification}]"; - messageData.Add(("user", evalSummary)); + if (analysis.Intent == TurnIntent.Substantive && analysis.Evaluation != null) + messageData.Add(("user", BuildEvaluationSummary(analysis.Evaluation))); var messages = messageData.Select(m => m.role == "user" ? ChatMessage.FromUser(m.content) : ChatMessage.FromAssistant(m.content)); @@ -46,4 +36,18 @@ public async IAsyncEnumerable StreamAsync(TurnEvaluation evaluation, yield return token; } } + + private static string BuildEvaluationSummary(TurnEvaluation evaluation) + { + var summaryParts = new List + { + $"correctness={evaluation.CorrectnessScore}", + $"completeness={evaluation.CompletenessScore}" + }; + if (evaluation.DiscriminationScore.HasValue) + summaryParts.Add($"discrimination={evaluation.DiscriminationScore.Value}"); + if (evaluation.IntegrationScore.HasValue) + summaryParts.Add($"integration={evaluation.IntegrationScore.Value}"); + return $"[Evaluation: {string.Join(", ", summaryParts)}. Justification: {evaluation.Justification}]"; + } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs index 1060e8f29..bd15aaaf3 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs @@ -1,5 +1,6 @@ using System.Text.Json; using FluentResults; +using Microsoft.Extensions.Logging; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; @@ -10,68 +11,90 @@ namespace Tutor.Elaborations.Infrastructure.Agents; public class EvaluationAgent : IEvaluationAgent { - private const int MaxReattempts = 2; + private const int MaxAttempts = 2; private readonly IAiChatService _chatService; + private readonly ILogger _logger; - public EvaluationAgent(IAiChatService chatService) + public EvaluationAgent(IAiChatService chatService, ILogger logger) { _chatService = chatService; + _logger = logger; } - public async Task> EvaluateAsync(string content, + public async Task> AnalyzeAsync(string content, List history, ConceptElaborationTask task, CancellationToken ct) { var request = CreateRequestWithPromptAndParams(content, history, task); - for (var attempt = 0; attempt < MaxReattempts; attempt++) + for (var attempt = 0; attempt < MaxAttempts; attempt++) { var result = await _chatService.CompleteAsync(request, ct); if (result.IsFailed) continue; - var evaluation = TryParseResponse(result.Value.Content); - if (evaluation == null) continue; + var analysis = TryParseResponse(result.Value.Content, task); + if (analysis == null) continue; - return evaluation; + return analysis; } return Result.Fail("Failed to parse evaluation response after retries."); } - private static CompletionRequest CreateRequestWithPromptAndParams(string content, List history, ConceptElaborationTask task) + private static CompletionRequest CreateRequestWithPromptAndParams( + string content, List history, ConceptElaborationTask task) { var systemPrompt = EvaluationPromptBuilder.BuildSystemPrompt(task); - var messageData = EvaluationPromptBuilder.BuildMessages(content, history); + var userMessage = EvaluationPromptBuilder.BuildUserMessage(content, history); - var messages = messageData.Select(m => - m.role == "user" ? ChatMessage.FromUser(m.content) : ChatMessage.FromAssistant(m.content)); - - return CompletionRequest.Create(messages, systemPrompt, maxTokens: 1024, temperature: 0.1); + return CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 1024, temperature: 0.0); } - private static TurnEvaluation? TryParseResponse(string json) + private TurnAnalysis? TryParseResponse(string json, ConceptElaborationTask task) { try { var parsed = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - if(parsed == null) return null; + if (parsed == null || string.IsNullOrWhiteSpace(parsed.Intent)) return null; + + if (!Enum.TryParse(parsed.Intent, ignoreCase: true, out var intent)) + return null; + + if (intent != TurnIntent.Substantive) + return new TurnAnalysis(intent, null); + + if (parsed.CorrectnessScore is < 1 or > 3) return null; + if (parsed.CompletenessScore is < 1 or > 3) return null; + if (parsed.DiscriminationScore is not null and (< 1 or > 3)) return null; + if (parsed.IntegrationScore is not null and (< 1 or > 3)) return null; + + var validKpIds = task.KeyPropositions.Select(kp => kp.Id).ToHashSet(); + var validKrIds = task.KeyRelations.Select(kr => kr.Id).ToHashSet(); + var validCmIds = task.CommonMisconceptions.Select(cm => cm.Id).ToHashSet(); + + if (parsed.PropositionsCoveredIds?.Any(id => !validKpIds.Contains(id)) == true) return null; + if (parsed.RelationsArticulatedIds?.Any(id => !validKrIds.Contains(id)) == true) return null; + if (parsed.MisconceptionsTriggeredIds?.Any(id => !validCmIds.Contains(id)) == true) return null; - return new TurnEvaluation(parsed.IsSubstantive, + var evaluation = new TurnEvaluation( parsed.CorrectnessScore, parsed.CompletenessScore, parsed.DiscriminationScore, parsed.IntegrationScore, parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, parsed.PropositionsCoveredIds ?? new List(), parsed.MisconceptionsTriggeredIds ?? new List(), parsed.RelationsArticulatedIds ?? new List()); + return TurnAnalysis.Substantive(evaluation); } - catch + catch (Exception ex) { + _logger.LogWarning(ex, "TryParseResponse failed to parse evaluation JSON."); return null; } } private class EvaluationResponse { + public string? Intent { get; set; } public int CorrectnessScore { get; set; } public int CompletenessScore { get; set; } public int? DiscriminationScore { get; set; } @@ -81,6 +104,5 @@ private class EvaluationResponse public List? MisconceptionsTriggeredIds { get; set; } public List? RelationsArticulatedIds { get; set; } public string? NovelMisconceptions { get; set; } - public bool IsSubstantive { get; set; } } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs index 60bad534f..96b90d682 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs @@ -6,7 +6,17 @@ namespace Tutor.Elaborations.Infrastructure.Agents.Prompts; public static class DialoguePromptBuilder { - public static string BuildSystemPrompt(ConceptElaborationTask task, ConversationAttempt attempt) + public static string BuildSystemPrompt(ConceptElaborationTask task, ConversationAttempt attempt, TurnIntent intent) + { + return intent switch + { + TurnIntent.Clarification => BuildClarificationPrompt(task), + TurnIntent.OffTopic => BuildOffTopicPrompt(task), + _ => BuildSubstantivePrompt(task, attempt) + }; + } + + private static string BuildSubstantivePrompt(ConceptElaborationTask task, ConversationAttempt attempt) { var sb = new StringBuilder(); sb.AppendLine("You are a Socratic dialogue agent for a tutoring system. You speak Serbian."); @@ -17,10 +27,51 @@ public static string BuildSystemPrompt(ConceptElaborationTask task, Conversation sb.AppendLine("- NEVER reveal key propositions, boundary conditions, or misconception text."); sb.AppendLine("- Every response: acknowledge → identify gap → ask targeted question."); sb.AppendLine("- Use concise language. Respect cognitive load."); - sb.AppendLine("- For non-substantive turns, gently redirect without penalty."); sb.AppendLine("- Allow productive divergence within the concept space."); sb.AppendLine(); + AppendConceptReference(sb, task); + sb.AppendLine(BuildSubstantiveClosing(task, attempt)); + return sb.ToString(); + } + + private static string BuildClarificationPrompt(ConceptElaborationTask task) + { + var sb = new StringBuilder(); + sb.AppendLine("You are a tutoring assistant helping a learner during a Socratic elaboration task. You speak Serbian."); + sb.AppendLine("The learner has asked a clarifying question. Answer it directly using the reference material below."); + sb.AppendLine(); + sb.AppendLine("## Rules:"); + sb.AppendLine("- Keep the answer brief and focused on the learner's question."); + sb.AppendLine("- You MAY use the definition, boundary conditions, and general framing to answer."); + sb.AppendLine("- NEVER directly reveal key propositions or key-relation mechanism text — those are what the learner must articulate themselves."); + sb.AppendLine("- After answering, invite the learner to resume their elaboration with one short prompt."); + sb.AppendLine(); + + AppendConceptReference(sb, task); + return sb.ToString(); + } + + private static string BuildOffTopicPrompt(ConceptElaborationTask task) + { + var sb = new StringBuilder(); + sb.AppendLine("You are a tutoring assistant helping a learner during a Socratic elaboration task. You speak Serbian."); + sb.AppendLine("The learner's message is off-topic for this task. This includes small talk, jokes, personal questions, and refusals or disengagement (e.g., \"I don't feel like it\", \"this is boring\")."); + sb.AppendLine(); + sb.AppendLine("## Rules:"); + sb.AppendLine("- Acknowledge very briefly (one short clause) without engaging with the off-topic content."); + sb.AppendLine("- Firmly but kindly redirect the learner back to elaborating the concept. End with a concrete prompt tied to the concept."); + sb.AppendLine("- Do NOT answer off-topic questions, comment on unrelated material, or validate the off-topic direction."); + sb.AppendLine("- Do NOT offer to change the topic, discuss something else, or pause the session. If the learner wants to stop, they can use the abandon option themselves — do not suggest it."); + sb.AppendLine("- Do NOT ask how the learner is feeling or explore their mood."); + sb.AppendLine(); + sb.AppendLine($"## Concept: {task.Title}"); + sb.AppendLine("Remind the learner of the concept they are elaborating and give them a concrete, small next step on it."); + return sb.ToString(); + } + + private static void AppendConceptReference(StringBuilder sb, ConceptElaborationTask task) + { sb.AppendLine($"## Concept: {task.Title}"); sb.AppendLine($"Definition: {task.CanonicalDefinition}"); sb.AppendLine(); @@ -30,6 +81,14 @@ public static string BuildSystemPrompt(ConceptElaborationTask task, Conversation sb.AppendLine($"- [KP-{kp.Id}] {kp.Statement}"); sb.AppendLine(); + if (task.BoundaryConditions.Count != 0) + { + sb.AppendLine("## Boundary Conditions (non-examples you may reference when clarifying):"); + foreach (var bc in task.BoundaryConditions) + sb.AppendLine($"- [BC-{bc.Id}] {bc.Statement}"); + sb.AppendLine(); + } + if (task.KeyRelations.Count != 0) { sb.AppendLine("## Key Relations (for your reference only, never reveal the mechanism text):"); @@ -42,13 +101,9 @@ public static string BuildSystemPrompt(ConceptElaborationTask task, Conversation } sb.AppendLine(); } - - sb.AppendLine(CreateClosing(task, attempt)); - - return sb.ToString(); } - private static string CreateClosing(ConceptElaborationTask task, ConversationAttempt attempt) + private static string BuildSubstantiveClosing(ConceptElaborationTask task, ConversationAttempt attempt) { var sb = new StringBuilder(); if (task.IsAttemptComplete(attempt)) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs index 5e7e2bd62..b1e4a532c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs @@ -14,7 +14,7 @@ public static string BuildSystemPrompt(ConceptElaborationTask task) var sb = new StringBuilder(); sb.AppendLine("You are an evaluation agent for a Socratic tutoring system."); - sb.AppendLine("Your task: evaluate the learner's latest response against the concept rubric below."); + sb.AppendLine("Your task: classify the learner's latest message, then (only if Substantive) score it against the concept rubric. Output JSON. DO NOT OUTPUT ANYTHING ELSE."); sb.AppendLine(); sb.AppendLine($"## Concept: {task.Title}"); sb.AppendLine($"Definition: {task.CanonicalDefinition}"); @@ -22,14 +22,14 @@ public static string BuildSystemPrompt(ConceptElaborationTask task) sb.AppendLine("## Key Propositions:"); foreach (var kp in task.KeyPropositions) - sb.AppendLine($"- [KP-{kp.Id}] {kp.Statement}"); + sb.AppendLine($"ID={kp.Id} {kp.Statement}"); sb.AppendLine(); if (hasBoundaryConditions) { sb.AppendLine("## Boundary Conditions:"); foreach (var bc in task.BoundaryConditions) - sb.AppendLine($"- [BC-{bc.Id}] {bc.Statement}"); + sb.AppendLine($"ID={bc.Id} {bc.Statement}"); sb.AppendLine(); } @@ -37,7 +37,7 @@ public static string BuildSystemPrompt(ConceptElaborationTask task) { sb.AppendLine("## Common Misconceptions:"); foreach (var cm in task.CommonMisconceptions.Take(8)) - sb.AppendLine($"- [CM-{cm.Id}] {cm.Description} → Correction: {cm.Correction}"); + sb.AppendLine($"ID={cm.Id} {cm.Description} → Correction: {cm.Correction}"); sb.AppendLine(); } @@ -49,20 +49,37 @@ public static string BuildSystemPrompt(ConceptElaborationTask task) { var sourceText = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, $"KP-{kr.SourceKeyPropositionId}"); var targetText = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, $"KP-{kr.TargetKeyPropositionId}"); - sb.AppendLine($"- [KR-{kr.Id}] {sourceText} → {targetText}. Mechanism: {kr.Mechanism}"); + sb.AppendLine($"ID={kr.Id} {sourceText} → {targetText}. Mechanism: {kr.Mechanism}"); } sb.AppendLine(); } + sb.AppendLine(CreateIntentRules()); sb.AppendLine(CreateScoringRules(hasBoundaryConditions, hasKeyRelations, hasCommonMisconceptions)); return sb.ToString(); } + private static string CreateIntentRules() + { + var sb = new StringBuilder(); + sb.AppendLine("## Intent Classification (decide first):"); + sb.AppendLine("- Substantive: the learner attempts to explain, define, relate, or apply the concept. Even a weak or partial attempt counts."); + sb.AppendLine("- Clarification: the learner asks a genuine information-seeking question about the task, the concept, or a previous tutor message. Must be a direct question (what / why / how / can you give an example / what do you mean by ...?). A message is Clarification ONLY if removing the rest and keeping just the question still makes sense."); + sb.AppendLine("- OffTopic: everything else. This includes small talk, jokes, personal questions, refusals or disengagement (\"I don't want to\", \"I'm not in the mood\", \"this is boring\", \"can we do something else\"), meta-comments about the conversation, and any message that is neither a concept explanation nor an information-seeking question. When in doubt between Clarification and OffTopic, choose OffTopic."); + sb.AppendLine(); + sb.AppendLine("## Echo rule (apply before scoring)"); + sb.AppendLine("If the message to score is a verbatim or near-verbatim repetition of any [TUTOR] line in the conversation so far, classify intent as OffTopic. Credit for a Key Proposition or Key Relation requires the learner to articulate it in their own words, not repeat the tutor."); + sb.AppendLine(); + sb.AppendLine("## Scope rule"); + sb.AppendLine("Score only the message demarcated as '## Message to score'. Do not attribute content from [TUTOR] lines to the learner. If the learner's message expresses agreement with, approval of, or deference to something the tutor said (e.g. 'I bet you'd explain it well', 'that's right', 'you said it'), that is not articulation of the concept — classify as OffTopic."); + return sb.ToString(); + } + private static string CreateScoringRules(bool hasBoundaryConditions, bool hasKeyRelations, bool hasCommonMisconceptions) { var sb = new StringBuilder(); - sb.AppendLine("## Scoring Rules:"); + sb.AppendLine("## Scoring Rules (apply only when intent is Substantive):"); var correctnessLine = hasBoundaryConditions ? "- Correctness (1-3): Are stated claims true? Check against KPs and BCs." : "- Correctness (1-3): Are stated claims true? Check against KPs."; @@ -77,36 +94,56 @@ private static string CreateScoringRules(bool hasBoundaryConditions, bool hasKey sb.AppendLine(); sb.AppendLine("## Output Format (JSON only, no other text):"); + sb.AppendLine("If intent is Clarification or OffTopic, output exactly:"); + sb.AppendLine("{ \"intent\": \"Clarification\" } // or \"OffTopic\""); + sb.AppendLine(); + sb.AppendLine("If intent is Substantive, output:"); + var fields = new List + { + "\"intent\": \"Substantive\"", + "\"correctnessScore\": 1-3", + "\"completenessScore\": 1-3" + }; + if (hasBoundaryConditions) fields.Add("\"discriminationScore\": 1-3"); + if (hasKeyRelations) fields.Add("\"integrationScore\": 1-3"); + fields.Add("\"justification\": \"brief explanation of scores\""); + fields.Add("\"propositionsCoveredIds\": [number list of KP IDs covered in this turn]"); + if (hasCommonMisconceptions) fields.Add("\"misconceptionsTriggeredIds\": [number list of CM IDs triggered]"); + if (hasKeyRelations) fields.Add("\"relationsArticulatedIds\": [number list of KR IDs articulated with mechanism this turn]"); + if (hasCommonMisconceptions) fields.Add("\"novelMisconceptions\": \"any misconceptions not in the list, or null\""); + sb.AppendLine("{"); - sb.AppendLine(" \"correctnessScore\": 1-3,"); - sb.AppendLine(" \"completenessScore\": 1-3,"); - if (hasBoundaryConditions) - sb.AppendLine(" \"discriminationScore\": 1-3,"); - if (hasKeyRelations) - sb.AppendLine(" \"integrationScore\": 1-3,"); - sb.AppendLine(" \"justification\": \"brief explanation of scores\","); - sb.AppendLine(" \"propositionsCoveredIds\": [list of KP IDs covered in this turn],"); - if (hasCommonMisconceptions) - sb.AppendLine(" \"misconceptionsTriggeredIds\": [list of CM IDs triggered],"); - if (hasKeyRelations) - sb.AppendLine(" \"relationsArticulatedIds\": [list of KR IDs articulated with mechanism this turn],"); - if (hasCommonMisconceptions) - sb.AppendLine(" \"novelMisconceptions\": \"any misconceptions not in the list, or null\","); - sb.AppendLine(" \"isSubstantive\": true/false"); + sb.AppendLine(string.Join(",\n", fields.Select(f => " " + f))); sb.AppendLine("}"); return sb.ToString(); } - public static List<(string role, string content)> BuildMessages( + public static string BuildUserMessage( string learnerContent, List history) { - var messages = new List<(string role, string content)>(); - foreach (var turn in history) + var sb = new StringBuilder(); + + sb.AppendLine("## Conversation so far (for context only — DO NOT score this)"); + if (history.Count == 0) { - var role = turn.Role == TurnRole.Learner ? "user" : "assistant"; - messages.Add((role, turn.Content)); + sb.AppendLine("(no prior turns)"); + } + else + { + foreach (var turn in history) + { + var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; + sb.AppendLine($"[{label}]: {turn.Content}"); + } } - messages.Add(("user", learnerContent)); - return messages; + sb.AppendLine(); + + sb.AppendLine("## Message to score (this is the ONLY message you are scoring)"); + sb.AppendLine($"[LEARNER]: {learnerContent}"); + sb.AppendLine(); + + sb.AppendLine("Score only the final [LEARNER] message under '## Message to score'. Do not credit the learner for content that appears in [TUTOR] lines or that the learner has only repeated from a preceding [TUTOR] line."); + + return sb.ToString(); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs index 837de7987..025df95ab 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs @@ -53,7 +53,23 @@ public void SetupDefaultMocks() public void SetupEvaluationMock(List? propositionsCoveredIds = null, List? relationsArticulatedIds = null, int? discriminationScore = 2, int? integrationScore = null, - bool isSubstantive = true) + string intent = "Substantive") + { + var evalJson = intent == "Substantive" + ? BuildSubstantiveEvalJson(propositionsCoveredIds, relationsArticulatedIds, discriminationScore, integrationScore) + : $$"""{ "intent": "{{intent}}" }"""; + + MockChatService.Setup(x => x.CompleteAsync( + It.Is(r => r.MaxTokens == 1024), It.IsAny())) + .ReturnsAsync(Result.Ok(new CompletionResponse + { + Content = evalJson, + Usage = new TokenUsage(100, 50) + })); + } + + private static string BuildSubstantiveEvalJson(List? propositionsCoveredIds, + List? relationsArticulatedIds, int? discriminationScore, int? integrationScore) { var coveredIds = propositionsCoveredIds != null && propositionsCoveredIds.Count > 0 ? string.Join(",", propositionsCoveredIds) @@ -64,8 +80,9 @@ public void SetupEvaluationMock(List? propositionsCoveredIds = null, var discriminationJson = discriminationScore.HasValue ? discriminationScore.Value.ToString() : "null"; var integrationJson = integrationScore.HasValue ? integrationScore.Value.ToString() : "null"; - var evalJson = $$""" + return $$""" { + "intent": "Substantive", "correctnessScore": 2, "completenessScore": 2, "discriminationScore": {{discriminationJson}}, @@ -74,18 +91,9 @@ public void SetupEvaluationMock(List? propositionsCoveredIds = null, "propositionsCoveredIds": [{{coveredIds}}], "misconceptionsTriggeredIds": [], "relationsArticulatedIds": [{{articulatedIds}}], - "novelMisconceptions": null, - "isSubstantive": {{isSubstantive.ToString().ToLower()}} + "novelMisconceptions": null } """; - - MockChatService.Setup(x => x.CompleteAsync( - It.Is(r => r.MaxTokens == 1024), It.IsAny())) - .ReturnsAsync(Result.Ok(new CompletionResponse - { - Content = evalJson, - Usage = new TokenUsage(100, 50) - })); } public void SetupDialogueMock(params string[] tokens) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs index 22507c557..419ca4f0c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -8,6 +8,7 @@ using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.API.Dtos.Conversations; using Tutor.Elaborations.API.Public.Learning; +using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Infrastructure.Database; namespace Tutor.Elaborations.Tests.Integration.Learning; @@ -120,7 +121,7 @@ public async Task Soft_cap_reached_continues() dbContext.ChangeTracker.Clear(); var attempt = dbContext.ConversationAttempts.Include(a => a.Turns) .ThenInclude(t => t.Evaluation).First(a => a.Id == -6); - attempt.Turns.Count(t => t.Role == 0 && t.Evaluation?.IsSubstantive == true).ShouldBe(6); + attempt.Turns.Count(t => t.Role == TurnRole.Learner && t.Intent == TurnIntent.Substantive).ShouldBe(6); } [Fact] 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 index aca1b19ea..f8d04aee0 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -2,17 +2,17 @@ INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-1, -1, -2, 1, '2024-06-01 10:00:00+00', '2024-06-01 10:15:00+00', 'Good understanding of encapsulation basics.'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-1, -1, 0, 'Encapsulation bundles data and methods in a class.', 0, '2024-06-01 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-2, -1, 1, 'Good start! Can you tell me more about access modifiers?', 1, '2024-06-01 10:01:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-3, -1, 0, 'Access modifiers like public and private control visibility.', 2, '2024-06-01 10:02:00+00'); - -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-1, -1, true, 2, 2, 2, null, 'Accurate basic description', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-3, -3, true, 2, 2, 2, null, 'Good description of access modifiers', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-1, -1, 0, 'Encapsulation bundles data and methods in a class.', 0, '2024-06-01 10:01:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-2, -1, 1, 'Good start! Can you tell me more about access modifiers?', 1, '2024-06-01 10:01:05+00', null); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-3, -1, 0, 'Access modifiers like public and private control visibility.', 2, '2024-06-01 10:02:00+00', 0); + +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-1, -1, 2, 2, 2, null, 'Accurate basic description', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-3, -3, 2, 2, 2, null, 'Good description of access modifiers', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb); -- Attempt -2: Learner -2, CET -1, Abandoned (for query tests) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -22,122 +22,122 @@ VALUES (-2, -1, -2, 2, '2024-06-02 10:00:00+00', '2024-06-02 10:05:00+00', null) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-3, -1, -3, 0, '2024-06-03 10:00:00+00', null, null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-4, -3, 0, 'Encapsulation is about data hiding.', 0, '2024-06-03 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', 1, '2024-06-03 10:01:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-4, -3, 0, 'Encapsulation is about data hiding.', 0, '2024-06-03 10:01:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', 1, '2024-06-03 10:01:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-4, -4, true, 1, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '[-210]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-4, -4, 1, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '[-210]'::jsonb, '[]'::jsonb); -- Attempt -4: Learner -3, CET -2, InProgress (for completion test: KP -20 already covered, submit to cover -21) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-4, -2, -3, 0, '2024-06-04 10:00:00+00', null, null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-6, -4, 0, 'Encapsulation bundles data and methods together.', 0, '2024-06-04 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-7, -4, 1, 'Good. What about access control?', 1, '2024-06-04 10:01:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-6, -4, 0, 'Encapsulation bundles data and methods together.', 0, '2024-06-04 10:01:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-7, -4, 1, 'Good. What about access control?', 1, '2024-06-04 10:01:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-6, -6, true, 2, 2, 2, null, 'Covers bundling proposition', null, '[-20]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-6, -6, 2, 2, 2, null, 'Covers bundling proposition', null, '[-20]'::jsonb, '[]'::jsonb, '[]'::jsonb); -- Attempt -5: Learner -2, CET -2, InProgress with 9 learner + 9 system turns (for hard cap test) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-5, -2, -2, 0, '2024-06-05 10:00:00+00', null, null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-50, -5, 0, 'Turn 1', 0, '2024-06-05 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-51, -5, 1, 'Response 1', 1, '2024-06-05 10:01:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-52, -5, 0, 'Turn 2', 2, '2024-06-05 10:02:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-53, -5, 1, 'Response 2', 3, '2024-06-05 10:02:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-54, -5, 0, 'Turn 3', 4, '2024-06-05 10:03:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-55, -5, 1, 'Response 3', 5, '2024-06-05 10:03:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-56, -5, 0, 'Turn 4', 6, '2024-06-05 10:04:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-57, -5, 1, 'Response 4', 7, '2024-06-05 10:04:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-58, -5, 0, 'Turn 5', 8, '2024-06-05 10:05:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-59, -5, 1, 'Response 5', 9, '2024-06-05 10:05:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-60, -5, 0, 'Turn 6', 10, '2024-06-05 10:06:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-61, -5, 1, 'Response 6', 11, '2024-06-05 10:06:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-62, -5, 0, 'Turn 7', 12, '2024-06-05 10:07:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-63, -5, 1, 'Response 7', 13, '2024-06-05 10:07:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-64, -5, 0, 'Turn 8', 14, '2024-06-05 10:08:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-65, -5, 1, 'Response 8', 15, '2024-06-05 10:08:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-66, -5, 0, 'Turn 9', 16, '2024-06-05 10:09:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-67, -5, 1, 'Response 9', 17, '2024-06-05 10:09:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-50, -5, 0, 'Turn 1', 0, '2024-06-05 10:01:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-51, -5, 1, 'Response 1', 1, '2024-06-05 10:01:05+00', null); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-52, -5, 0, 'Turn 2', 2, '2024-06-05 10:02:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-53, -5, 1, 'Response 2', 3, '2024-06-05 10:02:05+00', null); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-54, -5, 0, 'Turn 3', 4, '2024-06-05 10:03:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-55, -5, 1, 'Response 3', 5, '2024-06-05 10:03:05+00', null); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-56, -5, 0, 'Turn 4', 6, '2024-06-05 10:04:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-57, -5, 1, 'Response 4', 7, '2024-06-05 10:04:05+00', null); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-58, -5, 0, 'Turn 5', 8, '2024-06-05 10:05:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-59, -5, 1, 'Response 5', 9, '2024-06-05 10:05:05+00', null); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-60, -5, 0, 'Turn 6', 10, '2024-06-05 10:06:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-61, -5, 1, 'Response 6', 11, '2024-06-05 10:06:05+00', null); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-62, -5, 0, 'Turn 7', 12, '2024-06-05 10:07:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-63, -5, 1, 'Response 7', 13, '2024-06-05 10:07:05+00', null); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-64, -5, 0, 'Turn 8', 14, '2024-06-05 10:08:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-65, -5, 1, 'Response 8', 15, '2024-06-05 10:08:05+00', null); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-66, -5, 0, 'Turn 9', 16, '2024-06-05 10:09:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-67, -5, 1, 'Response 9', 17, '2024-06-05 10:09:05+00', null); -- Evaluations for the 9 learner turns (all with empty propositions - never completes) -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-50, -50, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-52, -52, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-54, -54, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-56, -56, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-58, -58, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-60, -60, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-62, -62, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-64, -64, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-66, -66, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-50, -50, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-52, -52, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-54, -54, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-56, -56, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-58, -58, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-60, -60, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-62, -62, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-64, -64, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-66, -66, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -- Attempt -6: Learner -3, CET -3, InProgress with 5 substantive learner + 5 system turns (for soft cap test) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-6, -3, -3, 0, '2024-06-06 10:00:00+00', null, null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-70, -6, 0, 'Turn 1', 0, '2024-06-06 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-71, -6, 1, 'Response 1', 1, '2024-06-06 10:01:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-72, -6, 0, 'Turn 2', 2, '2024-06-06 10:02:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-73, -6, 1, 'Response 2', 3, '2024-06-06 10:02:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-74, -6, 0, 'Turn 3', 4, '2024-06-06 10:03:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-75, -6, 1, 'Response 3', 5, '2024-06-06 10:03:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-76, -6, 0, 'Turn 4', 6, '2024-06-06 10:04:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-77, -6, 1, 'Response 4', 7, '2024-06-06 10:04:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-78, -6, 0, 'Turn 5', 8, '2024-06-06 10:05:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-79, -6, 1, 'Response 5', 9, '2024-06-06 10:05:05+00'); - -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-70, -70, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-72, -72, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-74, -74, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-76, -76, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "IsSubstantive", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-78, -78, true, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-70, -6, 0, 'Turn 1', 0, '2024-06-06 10:01:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-71, -6, 1, 'Response 1', 1, '2024-06-06 10:01:05+00', null); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-72, -6, 0, 'Turn 2', 2, '2024-06-06 10:02:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-73, -6, 1, 'Response 2', 3, '2024-06-06 10:02:05+00', null); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-74, -6, 0, 'Turn 3', 4, '2024-06-06 10:03:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-75, -6, 1, 'Response 3', 5, '2024-06-06 10:03:05+00', null); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-76, -6, 0, 'Turn 4', 6, '2024-06-06 10:04:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-77, -6, 1, 'Response 4', 7, '2024-06-06 10:04:05+00', null); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-78, -6, 0, 'Turn 5', 8, '2024-06-06 10:05:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") +VALUES (-79, -6, 1, 'Response 5', 9, '2024-06-06 10:05:05+00', null); + +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-70, -70, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-72, -72, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-74, -74, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-76, -76, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") +VALUES (-78, -78, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -- Attempt -7: Learner -3, CET -5, InProgress (isolated for abandon test — no other test touches this) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs index 430ed44af..28474367a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs @@ -79,14 +79,14 @@ private static ConversationAttempt MakeAttemptWithCoveredAndArticulated( var evalCtor = typeof(TurnEvaluation).GetConstructors().First(c => c.GetParameters().Length > 0); var evaluation = (TurnEvaluation)evalCtor.Invoke([ - true, 2, 2, (int?)null, (int?)null, "test", null, + 2, 2, (int?)null, (int?)null, "test", null, coveredKpIds, new List(), articulatedRelationIds ]); var turnCtor = typeof(ConversationTurn).GetConstructors( BindingFlags.NonPublic | BindingFlags.Instance) .First(c => c.GetParameters().Length > 0); - var turn = (ConversationTurn)turnCtor.Invoke([TurnRole.Learner, "x", 0, evaluation]); + var turn = (ConversationTurn)turnCtor.Invoke([TurnRole.Learner, "x", 0, (TurnIntent?)TurnIntent.Substantive, evaluation]); SetProp(attempt, "Turns", new List { turn }); return attempt; From 184bc3b0324b56e95cd2cbd71a9b5024e32a509d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Mon, 20 Apr 2026 19:18:13 +0300 Subject: [PATCH 18/51] fix: Improves elaboration conversation UX. --- .../Learning/Orchestration/TurnAnalysis.cs | 5 +- .../Agents/DialogueAgent.cs | 26 +-- .../Agents/EvaluationAgent.cs | 3 +- .../Agents/Prompts/DialoguePromptBuilder.cs | 218 +++++++++++++----- .../Agents/Prompts/EvaluationPromptBuilder.cs | 11 + 5 files changed, 176 insertions(+), 87 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnAnalysis.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnAnalysis.cs index 09f1f5b60..4bebd9b99 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnAnalysis.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnAnalysis.cs @@ -2,9 +2,10 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; -public record TurnAnalysis(TurnIntent Intent, TurnEvaluation? Evaluation) +public record TurnAnalysis(TurnIntent Intent, TurnEvaluation? Evaluation, bool HasMultipleConcerns = false) { - public static TurnAnalysis Substantive(TurnEvaluation evaluation) => new(TurnIntent.Substantive, evaluation); + public static TurnAnalysis Substantive(TurnEvaluation evaluation, bool hasMultipleConcerns) => + new(TurnIntent.Substantive, evaluation, hasMultipleConcerns); public static TurnAnalysis Clarification() => new(TurnIntent.Clarification, null); public static TurnAnalysis OffTopic() => new(TurnIntent.OffTopic, null); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs index 86a3c9ed5..4f500a8eb 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs @@ -20,34 +20,14 @@ public async IAsyncEnumerable StreamAsync(TurnAnalysis analysis, ConversationAttempt attempt, ConceptElaborationTask task, [EnumeratorCancellation] CancellationToken ct) { - var systemPrompt = DialoguePromptBuilder.BuildSystemPrompt(task, attempt, analysis.Intent); - var messageData = DialoguePromptBuilder.BuildMessages(attempt.Turns.ToList()); + var systemPrompt = DialoguePromptBuilder.BuildSystemPrompt(task, attempt, analysis); + var userMessage = DialoguePromptBuilder.BuildUserMessage(attempt.Turns.ToList(), analysis); - if (analysis.Intent == TurnIntent.Substantive && analysis.Evaluation != null) - messageData.Add(("user", BuildEvaluationSummary(analysis.Evaluation))); - - var messages = messageData.Select(m => - m.role == "user" ? ChatMessage.FromUser(m.content) : ChatMessage.FromAssistant(m.content)); - - var request = CompletionRequest.Create(messages, systemPrompt, maxTokens: 512, temperature: 0.7); + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 512, temperature: 0.7); await foreach (var token in _chatService.StreamAsync(request, ct)) { yield return token; } } - - private static string BuildEvaluationSummary(TurnEvaluation evaluation) - { - var summaryParts = new List - { - $"correctness={evaluation.CorrectnessScore}", - $"completeness={evaluation.CompletenessScore}" - }; - if (evaluation.DiscriminationScore.HasValue) - summaryParts.Add($"discrimination={evaluation.DiscriminationScore.Value}"); - if (evaluation.IntegrationScore.HasValue) - summaryParts.Add($"integration={evaluation.IntegrationScore.Value}"); - return $"[Evaluation: {string.Join(", ", summaryParts)}. Justification: {evaluation.Justification}]"; - } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs index bd15aaaf3..b9c9306da 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs @@ -83,7 +83,7 @@ private static CompletionRequest CreateRequestWithPromptAndParams( parsed.PropositionsCoveredIds ?? new List(), parsed.MisconceptionsTriggeredIds ?? new List(), parsed.RelationsArticulatedIds ?? new List()); - return TurnAnalysis.Substantive(evaluation); + return TurnAnalysis.Substantive(evaluation, parsed.HasMultipleConcerns ?? false); } catch (Exception ex) { @@ -104,5 +104,6 @@ private class EvaluationResponse public List? MisconceptionsTriggeredIds { get; set; } public List? RelationsArticulatedIds { get; set; } public string? NovelMisconceptions { get; set; } + public bool? HasMultipleConcerns { get; set; } } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs index 96b90d682..48a9878b5 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs @@ -1,72 +1,148 @@ using System.Text; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; namespace Tutor.Elaborations.Infrastructure.Agents.Prompts; public static class DialoguePromptBuilder { - public static string BuildSystemPrompt(ConceptElaborationTask task, ConversationAttempt attempt, TurnIntent intent) + public static string BuildSystemPrompt(ConceptElaborationTask task, ConversationAttempt attempt, TurnAnalysis analysis) { - return intent switch + return analysis.Intent switch { TurnIntent.Clarification => BuildClarificationPrompt(task), TurnIntent.OffTopic => BuildOffTopicPrompt(task), - _ => BuildSubstantivePrompt(task, attempt) + _ => analysis.HasMultipleConcerns + ? BuildFixModePrompt(task, attempt) + : BuildProbeModePrompt(task, attempt) }; } - private static string BuildSubstantivePrompt(ConceptElaborationTask task, ConversationAttempt attempt) + private static string BuildFixModePrompt(ConceptElaborationTask task, ConversationAttempt attempt) { var sb = new StringBuilder(); - sb.AppendLine("You are a Socratic dialogue agent for a tutoring system. You speak Serbian."); - sb.AppendLine("Your role: guide the learner to explain a concept by asking targeted questions."); + sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); + sb.AppendLine("The learner's latest answer has multiple concerns. Your job is to surface them so the learner can consolidate their existing answer before moving on."); sb.AppendLine(); sb.AppendLine("## Rules:"); - sb.AppendLine("- NEVER provide answers, definitions, or explanations."); - sb.AppendLine("- NEVER reveal key propositions, boundary conditions, or misconception text."); - sb.AppendLine("- Every response: acknowledge → identify gap → ask targeted question."); - sb.AppendLine("- Use concise language. Respect cognitive load."); - sb.AppendLine("- Allow productive divergence within the concept space."); + sb.AppendLine("- NEVER provide answers, definitions, or explanations, and NEVER reveal key proposition, boundary condition, or misconception text."); + sb.AppendLine("- Respond with ONLY a short bulleted list pushing back on each concern in the learner's latest answer — inaccuracies, triggered or novel misconceptions, and vague or hand-wavy claims. Close the bullets with a brief invitation to address them."); + sb.AppendLine("- Do NOT ask a new question about an uncovered key proposition or key relation — the learner must consolidate what they said before expanding."); + sb.AppendLine("- Silence on an error reads as agreement, so surface every concern."); + sb.AppendLine("- Concise language. Respect cognitive load."); sb.AppendLine(); AppendConceptReference(sb, task); - sb.AppendLine(BuildSubstantiveClosing(task, attempt)); + + if (attempt.IsSoftCapReached()) + { + sb.AppendLine("## The learner is approaching the end of the conversation."); + sb.AppendLine("Close the bullets with a note that you'll wrap up once these concerns are addressed."); + } return sb.ToString(); } - private static string BuildClarificationPrompt(ConceptElaborationTask task) + private static string BuildProbeModePrompt(ConceptElaborationTask task, ConversationAttempt attempt) { + if (task.IsAttemptComplete(attempt)) + return BuildClosedDialoguePrompt(task, + "## The learner has covered all required propositions and articulated all required relations.", + "Acknowledge completion in general terms only (e.g., \"dobro si obuhvatio koncept\")."); + + if (attempt.IsHardCapReached()) + return BuildClosedDialoguePrompt(task, + "## The conversation has reached its maximum length.", + "Acknowledge that the conversation is ending in general terms only."); + var sb = new StringBuilder(); - sb.AppendLine("You are a tutoring assistant helping a learner during a Socratic elaboration task. You speak Serbian."); - sb.AppendLine("The learner has asked a clarifying question. Answer it directly using the reference material below."); + sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); + sb.AppendLine("Guide the learner to explain the concept by asking targeted questions."); sb.AppendLine(); sb.AppendLine("## Rules:"); - sb.AppendLine("- Keep the answer brief and focused on the learner's question."); - sb.AppendLine("- You MAY use the definition, boundary conditions, and general framing to answer."); - sb.AppendLine("- NEVER directly reveal key propositions or key-relation mechanism text — those are what the learner must articulate themselves."); - sb.AppendLine("- After answering, invite the learner to resume their elaboration with one short prompt."); + sb.AppendLine("- NEVER provide answers, definitions, or explanations, and NEVER reveal key proposition, boundary condition, or misconception text."); + sb.AppendLine("- If the evaluation reports a single concern (inaccuracy, triggered or novel misconception, or vague claim), push back on it in one short line BEFORE your Socratic question. If there is no concern, skip the pushback."); + sb.AppendLine("- Ask ONE Socratic question about a single uncovered key proposition or unarticulated key relation. Do not list remaining gaps."); + sb.AppendLine("- Concise language. Respect cognitive load. Allow productive divergence within the concept space."); sb.AppendLine(); AppendConceptReference(sb, task); + + var uncoveredKpIds = task.GetUncoveredPropositionIds(attempt); + var unarticulatedKrIds = task.GetUnarticulatedRelationIds(attempt); + var hasGaps = uncoveredKpIds.Any() || unarticulatedKrIds.Any(); + + if (hasGaps) + { + sb.AppendLine("## Uncovered ground (pick ONE to probe):"); + if (uncoveredKpIds.Any()) + sb.AppendLine($"- Uncovered key propositions: {string.Join(", ", uncoveredKpIds.Select(id => $"KP-{id}"))}"); + if (unarticulatedKrIds.Any()) + sb.AppendLine($"- Unarticulated key relations: {string.Join(", ", unarticulatedKrIds.Select(id => $"KR-{id}"))}"); + sb.AppendLine("Never reveal the underlying statement or mechanism text."); + sb.AppendLine(); + } + + if (attempt.IsSoftCapReached()) + { + sb.AppendLine("## The learner is approaching the end of the conversation."); + sb.AppendLine(hasGaps + ? "Suggest wrapping up. Focus the Socratic question on the most important uncovered gap above." + : "Suggest wrapping up. Use the Socratic question to briefly probe whatever feels least articulated so far."); + } + return sb.ToString(); + } + + private static string BuildClosedDialoguePrompt(ConceptElaborationTask task, string header, string acknowledgmentLine) + { + var sb = new StringBuilder(); + sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); + sb.AppendLine(); + sb.AppendLine(header); + sb.AppendLine("Produce a closing turn with exactly these properties:"); + sb.AppendLine("- Two sentences maximum."); + sb.AppendLine($"- {acknowledgmentLine}"); + sb.AppendLine("- DO NOT summarize the learner's explanation or the concept."); + sb.AppendLine("- DO NOT list, restate, or paraphrase any key proposition, boundary condition, or relation — not even ones already articulated."); + sb.AppendLine("- DO NOT use bullet points or structured lists."); + sb.AppendLine("- DO NOT ask further questions."); + sb.AppendLine("The summary agent will write the summary separately; your job here is only to close the dialogue."); + sb.AppendLine(); + sb.AppendLine($"## Concept: {task.Title}"); + return sb.ToString(); + } + + private static string BuildClarificationPrompt(ConceptElaborationTask task) + { + var sb = new StringBuilder(); + sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); + sb.AppendLine("The learner has asked a clarifying question. Answer it using ONLY the reference below."); + sb.AppendLine(); + sb.AppendLine("## Rules:"); + sb.AppendLine("- Answer is MAX three sentences. Address only the specific thing asked — do not expand."); + sb.AppendLine("- If the learner asks for a summary, \"the answer\", an explanation, or anything that would require producing the concept's content, REFUSE and redirect. Example: \"Rezime mora da dođe od tebe — to je ono što vežbamo. Pokušaj da formulišeš u svojim rečima.\""); + sb.AppendLine("- NEVER produce a list or multi-sentence breakdown."); + sb.AppendLine("- After answering, invite the learner to resume elaboration with one short prompt."); + sb.AppendLine(); + sb.AppendLine($"## Concept: {task.Title}"); + sb.AppendLine($"Question: {task.CanonicalDefinition}"); + sb.AppendLine(); + return sb.ToString(); } private static string BuildOffTopicPrompt(ConceptElaborationTask task) { var sb = new StringBuilder(); - sb.AppendLine("You are a tutoring assistant helping a learner during a Socratic elaboration task. You speak Serbian."); - sb.AppendLine("The learner's message is off-topic for this task. This includes small talk, jokes, personal questions, and refusals or disengagement (e.g., \"I don't feel like it\", \"this is boring\")."); + sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); + sb.AppendLine("The learner's last message is off-topic — small talk, jokes, personal content, refusals, or disengagement."); sb.AppendLine(); sb.AppendLine("## Rules:"); - sb.AppendLine("- Acknowledge very briefly (one short clause) without engaging with the off-topic content."); - sb.AppendLine("- Firmly but kindly redirect the learner back to elaborating the concept. End with a concrete prompt tied to the concept."); - sb.AppendLine("- Do NOT answer off-topic questions, comment on unrelated material, or validate the off-topic direction."); - sb.AppendLine("- Do NOT offer to change the topic, discuss something else, or pause the session. If the learner wants to stop, they can use the abandon option themselves — do not suggest it."); - sb.AppendLine("- Do NOT ask how the learner is feeling or explore their mood."); + sb.AppendLine("- Acknowledge in one short clause without engaging with the off-topic content."); + sb.AppendLine("- Firmly but kindly redirect to the concept. End with a concrete, small next step on it."); + sb.AppendLine("- Do NOT answer off-topic questions, validate the off-topic direction, offer to change topic, suggest pausing or abandoning, or ask about the learner's mood."); sb.AppendLine(); sb.AppendLine($"## Concept: {task.Title}"); - sb.AppendLine("Remind the learner of the concept they are elaborating and give them a concrete, small next step on it."); return sb.ToString(); } @@ -75,23 +151,33 @@ private static void AppendConceptReference(StringBuilder sb, ConceptElaborationT sb.AppendLine($"## Concept: {task.Title}"); sb.AppendLine($"Definition: {task.CanonicalDefinition}"); sb.AppendLine(); + sb.AppendLine("The reference blocks below are for your use only. Never reveal any statement or mechanism text verbatim or paraphrased."); + sb.AppendLine(); - sb.AppendLine("## Key Propositions (for your reference only, never reveal):"); + sb.AppendLine("## Key Propositions:"); foreach (var kp in task.KeyPropositions) sb.AppendLine($"- [KP-{kp.Id}] {kp.Statement}"); sb.AppendLine(); if (task.BoundaryConditions.Count != 0) { - sb.AppendLine("## Boundary Conditions (non-examples you may reference when clarifying):"); + sb.AppendLine("## Boundary Conditions:"); foreach (var bc in task.BoundaryConditions) sb.AppendLine($"- [BC-{bc.Id}] {bc.Statement}"); sb.AppendLine(); } + if (task.CommonMisconceptions.Count != 0) + { + sb.AppendLine("## Common Misconceptions:"); + foreach (var cm in task.CommonMisconceptions) + sb.AppendLine($"- [CM-{cm.Id}] {cm.Description} (correction: {cm.Correction})"); + sb.AppendLine(); + } + if (task.KeyRelations.Count != 0) { - sb.AppendLine("## Key Relations (for your reference only, never reveal the mechanism text):"); + sb.AppendLine("## Key Relations:"); var kpById = task.KeyPropositions.ToDictionary(kp => kp.Id, kp => kp.Statement); foreach (var kr in task.KeyRelations) { @@ -103,51 +189,61 @@ private static void AppendConceptReference(StringBuilder sb, ConceptElaborationT } } - private static string BuildSubstantiveClosing(ConceptElaborationTask task, ConversationAttempt attempt) + public static string BuildUserMessage(List history, TurnAnalysis analysis) { var sb = new StringBuilder(); - if (task.IsAttemptComplete(attempt)) - { - sb.AppendLine("## The learner has covered all required propositions and articulated all required relations."); - sb.AppendLine("Provide a brief closing acknowledgment. Do not ask more questions."); - } - else if (attempt.IsHardCapReached()) + + sb.AppendLine("## Conversation so far"); + if (history.Count == 0) { - sb.AppendLine("## The conversation has reached its maximum length."); - sb.AppendLine("Provide a brief closing summary. Do not ask more questions."); + sb.AppendLine("(no prior turns)"); } else { - var uncoveredKpIds = task.GetUncoveredPropositionIds(attempt); - var unarticulatedKrIds = task.GetUnarticulatedRelationIds(attempt); - if (uncoveredKpIds.Any() || unarticulatedKrIds.Any()) + foreach (var turn in history) { - sb.AppendLine("## Focus areas for the next question:"); - if (uncoveredKpIds.Any()) - sb.AppendLine($"- Uncovered key propositions: {string.Join(", ", uncoveredKpIds.Select(id => $"KP-{id}"))}"); - if (unarticulatedKrIds.Any()) - sb.AppendLine($"- Unarticulated key relations: {string.Join(", ", unarticulatedKrIds.Select(id => $"KR-{id}"))}"); - sb.AppendLine("Pick the most important gap and probe it. Never reveal the underlying statement or mechanism text."); - sb.AppendLine(); + var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; + sb.AppendLine($"[{label}]: {turn.Content}"); } + } + sb.AppendLine(); - if (attempt.IsSoftCapReached()) - { - sb.AppendLine("## The learner is approaching the end of the conversation."); - sb.AppendLine("Suggest wrapping up. Focus on the most important uncovered gap above."); - } + if (analysis.Intent == TurnIntent.Substantive && analysis.Evaluation != null) + { + sb.AppendLine("## Evaluation of the latest LEARNER turn (from the evaluation agent — NOT from the learner)"); + sb.AppendLine(BuildEvaluationSummaryBody(analysis.Evaluation)); + sb.AppendLine(); } + + sb.AppendLine(analysis.Intent switch + { + TurnIntent.Clarification => "Produce the TUTOR's next turn per the system prompt. The latest LEARNER turn is a clarifying question.", + TurnIntent.OffTopic => "Produce the TUTOR's next turn per the system prompt. The latest LEARNER turn is off-topic and must be redirected.", + _ => "Produce the TUTOR's next turn per the system prompt, responding to the latest LEARNER turn." + }); + return sb.ToString(); } - public static List<(string role, string content)> BuildMessages(List history) + private static string BuildEvaluationSummaryBody(TurnEvaluation evaluation) { - var messages = new List<(string role, string content)>(); - foreach (var turn in history) + var parts = new List { - var role = turn.Role == TurnRole.Learner ? "user" : "assistant"; - messages.Add((role, turn.Content)); - } - return messages; + $"correctness={evaluation.CorrectnessScore}", + $"completeness={evaluation.CompletenessScore}" + }; + if (evaluation.DiscriminationScore.HasValue) + parts.Add($"discrimination={evaluation.DiscriminationScore.Value}"); + if (evaluation.IntegrationScore.HasValue) + parts.Add($"integration={evaluation.IntegrationScore.Value}"); + + var sb = new StringBuilder(); + sb.AppendLine($"Scores: {string.Join(", ", parts)}"); + sb.AppendLine($"Justification: {evaluation.Justification}"); + if (evaluation.MisconceptionsTriggeredIds.Count != 0) + sb.AppendLine($"Triggered misconceptions: {string.Join(", ", evaluation.MisconceptionsTriggeredIds.Select(id => $"CM-{id}"))}"); + if (!string.IsNullOrWhiteSpace(evaluation.NovelMisconceptions)) + sb.AppendLine($"Novel misconceptions: {evaluation.NovelMisconceptions}"); + return sb.ToString().TrimEnd(); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs index b1e4a532c..e49713eb5 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs @@ -92,6 +92,16 @@ private static string CreateScoringRules(bool hasBoundaryConditions, bool hasKey sb.AppendLine("- Evaluate concepts, not language. Grammar and style must not reduce scores."); sb.AppendLine("- Resist sycophancy. Evaluate strictly against rubric."); sb.AppendLine(); + sb.AppendLine("## Concern count (used to route the dialogue agent):"); + sb.AppendLine("Count distinct concerns in the message. A concern is any of:"); + sb.AppendLine(" - a stated inaccuracy (a claim that contradicts a KP or BC);"); + if (hasCommonMisconceptions) + sb.AppendLine(" - a triggered known misconception or a novel misconception;"); + else + sb.AppendLine(" - a novel misconception (none are pre-catalogued for this concept);"); + sb.AppendLine(" - a vague or hand-wavy claim that references a KP without articulating it."); + sb.AppendLine("Output hasMultipleConcerns=true if the count is two or more; false otherwise. A clean or single-concern answer is false."); + sb.AppendLine(); sb.AppendLine("## Output Format (JSON only, no other text):"); sb.AppendLine("If intent is Clarification or OffTopic, output exactly:"); @@ -111,6 +121,7 @@ private static string CreateScoringRules(bool hasBoundaryConditions, bool hasKey if (hasCommonMisconceptions) fields.Add("\"misconceptionsTriggeredIds\": [number list of CM IDs triggered]"); if (hasKeyRelations) fields.Add("\"relationsArticulatedIds\": [number list of KR IDs articulated with mechanism this turn]"); if (hasCommonMisconceptions) fields.Add("\"novelMisconceptions\": \"any misconceptions not in the list, or null\""); + fields.Add("\"hasMultipleConcerns\": true|false"); sb.AppendLine("{"); sb.AppendLine(string.Join(",\n", fields.Select(f => " " + f))); From 3732a0a90a398572b14407239e3a3db30492b96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Tue, 21 Apr 2026 17:08:53 +0300 Subject: [PATCH 19/51] feat: Introduces multiple new agents to support more reliable and cost-efficient LLM use. --- .../Conversations/ConversationAttempt.cs | 7 +- .../Domain/Conversations/ConversationTurn.cs | 13 +- .../Domain/Conversations/TurnEvaluation.cs | 8 +- .../Domain/Conversations/TurnIntent.cs | 4 +- .../UseCases/Learning/ConversationService.cs | 112 ++++---- .../Orchestration/AgentOrchestratorService.cs | 241 +++++++++++++++++ .../IClarificationAgent.cs} | 8 +- .../Orchestration/Agents/IClosingAgent.cs | 11 + .../Orchestration/Agents/ICritiqueAgent.cs | 11 + .../Orchestration/Agents/IIntentClassifier.cs | 12 + .../Orchestration/Agents/IMetaHelpAgent.cs | 10 + .../Orchestration/Agents/IProbeAgent.cs | 11 + .../Orchestration/Agents/IRedirectAgent.cs | 8 + .../Orchestration/Agents/IScaffoldingAgent.cs | 11 + .../IScorer.cs} | 10 +- .../{ => Agents}/ISummaryAgent.cs | 2 +- .../IAgentOrchestratorService.cs | 11 + .../Orchestration/OrchestratorChunk.cs | 18 ++ .../Learning/Orchestration/ProbeDirective.cs | 3 + .../Learning/Orchestration/ProbeTargetType.cs | 7 + .../Learning/Orchestration/TurnAnalysis.cs | 11 - .../Clarification/ClarificationAgent.cs | 33 +++ .../ClarificationPromptBuilder.cs | 68 +++++ .../Agents/Closing/ClosingAgent.cs | 29 ++ .../Agents/Closing/ClosingPromptBuilder.cs | 37 +++ .../Agents/Critique/CritiqueAgent.cs | 30 +++ .../Agents/Critique/CritiquePromptBuilder.cs | 111 ++++++++ .../IntentClassifier/IntentClassifierAgent.cs | 61 +++++ .../IntentClassifier/IntentPromptBuilder.cs | 56 ++++ .../Agents/MetaHelp/MetaHelpAgent.cs | 29 ++ .../Agents/MetaHelp/MetaHelpPromptBuilder.cs | 56 ++++ .../Agents/Probe/ProbeAgent.cs | 30 +++ .../Agents/Probe/ProbePromptBuilder.cs | 83 ++++++ .../Agents/Prompts/DialoguePromptBuilder.cs | 249 ------------------ .../Agents/Redirect/RedirectAgent.cs | 28 ++ .../Agents/Redirect/RedirectPromptBuilder.cs | 26 ++ .../ScaffoldingAgent.cs} | 19 +- .../Scaffolding/ScaffoldingPromptBuilder.cs | 66 +++++ .../ScorerAgent.cs} | 59 ++--- .../ScorerPromptBuilder.cs} | 70 ++--- .../Agents/{ => Summary}/SummaryAgent.cs | 4 +- .../SummaryPromptBuilder.cs | 2 +- .../ElaborationsStartup.cs | 26 +- .../ElaborationsTestFactory.cs | 20 +- .../TestData/e-conversation-attempts.sql | 72 ++--- .../Unit/ConceptElaborationTaskTests.cs | 6 +- 46 files changed, 1309 insertions(+), 490 deletions(-) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/{IDialogueAgent.cs => Agents/IClarificationAgent.cs} (62%) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClosingAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ICritiqueAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IIntentClassifier.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IMetaHelpAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IProbeAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IRedirectAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScaffoldingAgent.cs rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/{IEvaluationAgent.cs => Agents/IScorer.cs} (50%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/{ => Agents}/ISummaryAgent.cs (97%) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestratorService.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeTargetType.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnAnalysis.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationPromptBuilder.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingPromptBuilder.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiquePromptBuilder.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentClassifierAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentPromptBuilder.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpPromptBuilder.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbePromptBuilder.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectPromptBuilder.cs rename src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/{DialogueAgent.cs => Scaffolding/ScaffoldingAgent.cs} (54%) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingPromptBuilder.cs rename src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/{EvaluationAgent.cs => Scorer/ScorerAgent.cs} (58%) rename src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/{Prompts/EvaluationPromptBuilder.cs => Scorer/ScorerPromptBuilder.cs} (55%) rename src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/{ => Summary}/SummaryAgent.cs (89%) rename src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/{Prompts => Summary}/SummaryPromptBuilder.cs (96%) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index d3ce2819d..0426bc1fc 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -1,4 +1,5 @@ using Tutor.BuildingBlocks.Core.Domain; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; namespace Tutor.Elaborations.Core.Domain.Conversations; @@ -62,9 +63,11 @@ public ConversationTurn AddLearnerTurn(string content, TurnIntent intent, TurnEv return turn; } - public ConversationTurn AddSystemTurn(string content) + public ConversationTurn AddSystemTurn(string content, ProbeDirective? probeDirective = null) { - var turn = new ConversationTurn(TurnRole.System, content, Turns.Count); + var turn = new ConversationTurn( + TurnRole.System, content, Turns.Count, + intent: null, evaluation: null, probeDirective: probeDirective); Turns.Add(turn); return turn; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs index f7fa19163..47c507972 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs @@ -1,4 +1,5 @@ using Tutor.BuildingBlocks.Core.Domain; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; namespace Tutor.Elaborations.Core.Domain.Conversations; @@ -11,11 +12,16 @@ public class ConversationTurn : Entity public DateTime Timestamp { get; private set; } public TurnIntent? Intent { get; private set; } public TurnEvaluation? Evaluation { get; private set; } + public ProbeTargetType? ProbeTargetType { get; private set; } + public int? ProbeTargetId { get; private set; } + public int? ProbeLevel { get; private set; } private ConversationTurn() { } - internal ConversationTurn(TurnRole role, string content, int order, - TurnIntent? intent = null, TurnEvaluation? evaluation = null) + internal ConversationTurn( + TurnRole role, string content, int order, + TurnIntent? intent = null, TurnEvaluation? evaluation = null, + ProbeDirective? probeDirective = null) { Role = role; Content = content; @@ -23,5 +29,8 @@ internal ConversationTurn(TurnRole role, string content, int order, Timestamp = DateTime.UtcNow; Intent = intent; Evaluation = evaluation; + ProbeTargetType = probeDirective?.TargetType; + ProbeTargetId = probeDirective?.TargetId; + ProbeLevel = probeDirective?.Level; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs index 82e8d4481..c4b1c671f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs @@ -14,13 +14,16 @@ public class TurnEvaluation : Entity public List PropositionsCoveredIds { get; private set; } = new(); public List MisconceptionsTriggeredIds { get; private set; } = new(); public List RelationsArticulatedIds { get; private set; } = new(); + public bool HasMultipleConcerns { get; private set; } private TurnEvaluation() { } - public TurnEvaluation(int correctnessScore, int completenessScore, + public TurnEvaluation( + int correctnessScore, int completenessScore, int? discriminationScore, int? integrationScore, string justification, string? novelMisconceptions, - List propositionsCoveredIds, List misconceptionsTriggeredIds, List relationsArticulatedIds) + List propositionsCoveredIds, List misconceptionsTriggeredIds, + List relationsArticulatedIds, bool hasMultipleConcerns) { CorrectnessScore = correctnessScore; CompletenessScore = completenessScore; @@ -31,5 +34,6 @@ public TurnEvaluation(int correctnessScore, int completenessScore, PropositionsCoveredIds = propositionsCoveredIds; MisconceptionsTriggeredIds = misconceptionsTriggeredIds; RelationsArticulatedIds = relationsArticulatedIds; + HasMultipleConcerns = hasMultipleConcerns; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs index 932db9562..09671aa32 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs @@ -4,5 +4,7 @@ public enum TurnIntent { Substantive, Clarification, - OffTopic + OffTopic, + Stuck, + MetaHelp } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index b2b08f0f0..0e0fa4189 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using System.Text; using System.Text.Json; using AutoMapper; using FluentResults; @@ -22,24 +21,20 @@ public class ConversationService : IConversationService private readonly IConversationAttemptRepository _attemptRepo; private readonly IConceptElaborationTaskRepository _taskRepo; - private readonly IEvaluationAgent _evaluationAgent; - private readonly IDialogueAgent _dialogueAgent; - private readonly ISummaryAgent _summaryAgent; + private readonly IAgentOrchestratorService _orchestrator; private readonly ITokenSpendingService _tokenSpendingService; private readonly IAccessServices _accessServices; private readonly IElaborationsUnitOfWork _unitOfWork; private readonly IMapper _mapper; - public ConversationService(IConversationAttemptRepository attemptRepo, IConceptElaborationTaskRepository taskRepo, - IEvaluationAgent evaluationAgent, IDialogueAgent dialogueAgent, ISummaryAgent summaryAgent, - ITokenSpendingService tokenSpendingService, IAccessServices accessServices, - IElaborationsUnitOfWork unitOfWork, IMapper mapper) + public ConversationService( + IConversationAttemptRepository attemptRepo, IConceptElaborationTaskRepository taskRepo, + IAgentOrchestratorService orchestrator, ITokenSpendingService tokenSpendingService, + IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, IMapper mapper) { _attemptRepo = attemptRepo; _taskRepo = taskRepo; - _evaluationAgent = evaluationAgent; - _dialogueAgent = dialogueAgent; - _summaryAgent = summaryAgent; + _orchestrator = orchestrator; _tokenSpendingService = tokenSpendingService; _accessServices = accessServices; _unitOfWork = unitOfWork; @@ -163,65 +158,50 @@ public Result AbandonAttempt(int attemptId, int learnerI return Result.Ok(_mapper.Map(attempt)); } - private async IAsyncEnumerable RunTurnPipelineAsync(ConversationAttempt attempt, ConceptElaborationTask task, string content, [EnumeratorCancellation] CancellationToken ct) + private async IAsyncEnumerable RunTurnPipelineAsync( + ConversationAttempt attempt, ConceptElaborationTask task, string content, + [EnumeratorCancellation] CancellationToken ct) { - // Synchronous phase: classify + evaluate - var analysisResult = await _evaluationAgent.AnalyzeAsync( - content, attempt.Turns.ToList(), task, ct); - if (analysisResult.IsFailed) { yield return BuildErrorChunk("Evaluation failed. Please try again.", 500); yield break; } + var completionLength = 0; - var analysis = analysisResult.Value; - attempt.AddLearnerTurn(content, analysis.Intent, analysis.Evaluation); - - // Partial save: protects against stream interruption - _unitOfWork.Save(); - - // Streaming phase: dialogue - var fullResponse = new StringBuilder(); - await foreach (var token in _dialogueAgent.StreamAsync(analysis, attempt, task, ct)) - { - fullResponse.Append(token); - yield return token; - } - - // Post-stream persistence - attempt.AddSystemTurn(fullResponse.ToString()); - - string? summary = null; - if (task.IsAttemptComplete(attempt)) + await foreach (var chunk in _orchestrator.ProcessTurnAsync(attempt, task, content, ct)) { - var summaryResult = await _summaryAgent.SummarizeAsync(attempt, task, ct); - summary = summaryResult.IsSuccess ? summaryResult.Value : null; - attempt.Complete(summary); + switch (chunk) + { + case TokenChunk token: + completionLength += token.Token.Length; + yield return token.Token; + break; + + case CheckpointChunk: + _unitOfWork.Save(); + 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 = content.Length / 4, + CompletionTokens = completionLength / 4, + FeatureType = "Elaboration", + EntityId = task.Id, + PromptSummary = "Concept conversation turn" + }); + yield return JsonSerializer.Serialize(new SubmitTurnResponseDto + { + AttemptId = final.AttemptId, + Status = final.Status.ToString(), + Summary = final.Summary + }); + yield break; + } } - else if (attempt.IsHardCapReached()) - { - var summaryResult = await _summaryAgent.SummarizeAsync(attempt, task, ct); - summary = summaryResult.IsSuccess ? summaryResult.Value : null; - attempt.Expire(summary); - } - - _unitOfWork.Save(); - - // Spend tokens — estimate from content lengths - _tokenSpendingService.SpendTokensForUnit(new TokenSpendingRequestDto - { - LearnerId = attempt.LearnerId, - UnitId = task.UnitId, - PromptTokens = content.Length / 4, - CompletionTokens = fullResponse.Length / 4, - FeatureType = "Elaboration", - EntityId = task.Id, - PromptSummary = "Concept conversation turn" - }); - - // Final metadata chunk - yield return JsonSerializer.Serialize(new SubmitTurnResponseDto - { - AttemptId = attempt.Id, - Status = attempt.Status.ToString(), - Summary = summary - }); } private static string BuildErrorChunk(string message, int code, int? attemptId = null) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs new file mode 100644 index 000000000..26de42a4a --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs @@ -0,0 +1,241 @@ +using System.Runtime.CompilerServices; +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public class AgentOrchestratorService : IAgentOrchestratorService +{ + private const int ScaffoldingLevel = 4; + + private readonly IIntentClassifier _classifier; + private readonly IScorer _scorer; + private readonly IProbeAgent _probeAgent; + private readonly ICritiqueAgent _critiqueAgent; + private readonly IClarificationAgent _clarificationAgent; + private readonly IRedirectAgent _redirectAgent; + private readonly IMetaHelpAgent _metaHelpAgent; + private readonly IScaffoldingAgent _scaffoldingAgent; + private readonly IClosingAgent _closingAgent; + private readonly ISummaryAgent _summaryAgent; + + public AgentOrchestratorService( + IIntentClassifier classifier, IScorer scorer, + IProbeAgent probeAgent, ICritiqueAgent critiqueAgent, + IClarificationAgent clarificationAgent, IRedirectAgent redirectAgent, + IMetaHelpAgent metaHelpAgent, IScaffoldingAgent scaffoldingAgent, + IClosingAgent closingAgent, ISummaryAgent summaryAgent) + { + _classifier = classifier; + _scorer = scorer; + _probeAgent = probeAgent; + _critiqueAgent = critiqueAgent; + _clarificationAgent = clarificationAgent; + _redirectAgent = redirectAgent; + _metaHelpAgent = metaHelpAgent; + _scaffoldingAgent = scaffoldingAgent; + _closingAgent = closingAgent; + _summaryAgent = summaryAgent; + } + + public async IAsyncEnumerable ProcessTurnAsync( + ConversationAttempt attempt, ConceptElaborationTask task, + string learnerContent, [EnumeratorCancellation] CancellationToken ct) + { + var history = attempt.Turns.ToList(); + + var intentResult = await _classifier.ClassifyAsync(learnerContent, history, task, ct); + if (intentResult.IsFailed) + { + yield return new ErrorChunk("Intent classification failed.", 500); + yield break; + } + + var intent = intentResult.Value; + TurnEvaluation? evaluation = null; + if (intent == TurnIntent.Substantive) + { + var scoreResult = await _scorer.ScoreAsync(learnerContent, history, task, ct); + if (scoreResult.IsFailed) + { + yield return new ErrorChunk("Scoring failed.", 500); + yield break; + } + evaluation = scoreResult.Value; + } + + attempt.AddLearnerTurn(learnerContent, intent, evaluation); + yield return new CheckpointChunk(); + + var route = DecideRoute(attempt, task, intent, evaluation); + + var fullResponse = new StringBuilder(); + await foreach (var token in Stream(route, attempt, task, ct)) + { + fullResponse.Append(token); + yield return new TokenChunk(token); + } + + attempt.AddSystemTurn(fullResponse.ToString(), route.ProbeDirective); + + string? summary = null; + if (route.Closing == ClosingReason.AllCovered) + { + var summaryResult = await _summaryAgent.SummarizeAsync(attempt, task, ct); + summary = summaryResult.IsSuccess ? summaryResult.Value : null; + attempt.Complete(summary); + } + else if (route.Closing == ClosingReason.HardCapReached) + { + var summaryResult = await _summaryAgent.SummarizeAsync(attempt, task, ct); + summary = summaryResult.IsSuccess ? summaryResult.Value : null; + attempt.Expire(summary); + } + + yield return new FinalChunk( + attempt.Id, attempt.Status, intent, summary, route.ProbeDirective); + } + + private IAsyncEnumerable Stream( + RouteDecision route, ConversationAttempt attempt, + ConceptElaborationTask task, CancellationToken ct) + { + return route.Kind switch + { + RouteKind.Probe => _probeAgent.StreamAsync(route.ProbeDirective!, attempt, task, ct), + RouteKind.Scaffold => _scaffoldingAgent.StreamAsync(route.ProbeDirective!, attempt, task, ct), + RouteKind.Critique => _critiqueAgent.StreamAsync(route.Evaluation!, attempt, task, ct), + RouteKind.Clarification => _clarificationAgent.StreamAsync(attempt, task, route.LastProbe, ct), + RouteKind.Redirect => _redirectAgent.StreamAsync(task, ct), + RouteKind.MetaHelp => _metaHelpAgent.StreamAsync(task, route.ProgressLine!, route.NextTarget, ct), + RouteKind.Closing => _closingAgent.StreamAsync(task, route.Closing!.Value, ct), + _ => throw new InvalidOperationException($"Unknown route kind: {route.Kind}") + }; + } + + private RouteDecision DecideRoute( + ConversationAttempt attempt, ConceptElaborationTask task, + TurnIntent intent, TurnEvaluation? evaluation) + { + if (task.IsAttemptComplete(attempt)) + return RouteDecision.Close(ClosingReason.AllCovered); + + if (attempt.IsHardCapReached()) + return RouteDecision.Close(ClosingReason.HardCapReached); + + switch (intent) + { + case TurnIntent.Clarification: + return RouteDecision.Clarify(FindLastProbe(attempt)); + + case TurnIntent.OffTopic: + return RouteDecision.Redirect(); + + case TurnIntent.MetaHelp: + { + var progressLine = RenderProgressLine(attempt, task); + var next = PickNextTarget(attempt, task); + return RouteDecision.Meta(progressLine, next); + } + + case TurnIntent.Stuck: + { + var next = PickNextTarget(attempt, task); + if (next == null) return RouteDecision.Close(ClosingReason.AllCovered); + var level = DeriveLevel(attempt, next); + var directive = new ProbeDirective(next.Value.Type, next.Value.Id, Math.Max(level, ScaffoldingLevel)); + return RouteDecision.Scaffold(directive); + } + + case TurnIntent.Substantive: + { + if (evaluation is { HasMultipleConcerns: true }) + return RouteDecision.CritiqueFor(evaluation); + + var next = PickNextTarget(attempt, task); + if (next == null) return RouteDecision.Close(ClosingReason.AllCovered); + var level = DeriveLevel(attempt, next); + var directive = new ProbeDirective(next.Value.Type, next.Value.Id, level); + + return level >= ScaffoldingLevel + ? RouteDecision.Scaffold(directive) + : RouteDecision.Probe(directive); + } + + default: + return RouteDecision.Redirect(); + } + } + + private static (ProbeTargetType Type, int Id)? PickNextTarget( + ConversationAttempt attempt, ConceptElaborationTask task) + { + var uncoveredKp = task.GetUncoveredPropositionIds(attempt); + if (uncoveredKp.Count > 0) + return (ProbeTargetType.KeyProposition, uncoveredKp.Min()); + + var unarticulatedKr = task.GetUnarticulatedRelationIds(attempt); + return unarticulatedKr.Count > 0 + ? (ProbeTargetType.KeyRelation, unarticulatedKr.Min()) + : null; + } + + private static int DeriveLevel( + ConversationAttempt attempt, (ProbeTargetType Type, int Id)? target) + { + if (target == null) return 1; + var priorProbes = attempt.Turns.Count(t => + t.Role == TurnRole.System && + t.ProbeTargetType == target.Value.Type && + t.ProbeTargetId == target.Value.Id); + return priorProbes + 1; + } + + private static ProbeDirective? FindLastProbe(ConversationAttempt attempt) + { + var last = attempt.Turns + .Where(t => t.Role == TurnRole.System && t.ProbeTargetType.HasValue) + .OrderByDescending(t => t.Order) + .FirstOrDefault(); + if (last == null) return null; + return new ProbeDirective(last.ProbeTargetType!.Value, last.ProbeTargetId!.Value, last.ProbeLevel!.Value); + } + + private static string RenderProgressLine(ConversationAttempt attempt, ConceptElaborationTask task) + { + var coveredKp = attempt.GetCoveredPropositionIds().Count; + var totalKp = task.KeyPropositions.Count; + var articulatedKr = attempt.GetArticulatedRelationIds().Count; + var totalKr = task.KeyRelations.Count; + + return totalKr > 0 + ? $"Dosadašnji napredak: pokriveno {coveredKp}/{totalKp} ključnih izjava i {articulatedKr}/{totalKr} ključnih veza." + : $"Dosadašnji napredak: pokriveno {coveredKp}/{totalKp} ključnih izjava."; + } + + private enum RouteKind { Probe, Scaffold, Critique, Clarification, Redirect, MetaHelp, Closing } + + private sealed record RouteDecision( + RouteKind Kind, + ProbeDirective? ProbeDirective = null, + TurnEvaluation? Evaluation = null, + ProbeDirective? LastProbe = null, + string? ProgressLine = null, + ProbeDirective? NextTarget = null, + ClosingReason? Closing = null) + { + public static RouteDecision Probe(ProbeDirective d) => new(RouteKind.Probe, ProbeDirective: d); + public static RouteDecision Scaffold(ProbeDirective d) => new(RouteKind.Scaffold, ProbeDirective: d); + public static RouteDecision CritiqueFor(TurnEvaluation e) => new(RouteKind.Critique, Evaluation: e); + public static RouteDecision Clarify(ProbeDirective? last) => new(RouteKind.Clarification, LastProbe: last); + public static RouteDecision Redirect() => new(RouteKind.Redirect); + public static RouteDecision Meta(string progressLine, (ProbeTargetType Type, int Id)? next) + { + var nextDirective = next == null ? null : new ProbeDirective(next.Value.Type, next.Value.Id, 1); + return new RouteDecision(RouteKind.MetaHelp, ProgressLine: progressLine, NextTarget: nextDirective); + } + public static RouteDecision Close(ClosingReason reason) => new(RouteKind.Closing, Closing: reason); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClarificationAgent.cs similarity index 62% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClarificationAgent.cs index e1266d44e..3275553a8 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IDialogueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClarificationAgent.cs @@ -1,11 +1,11 @@ using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; -public interface IDialogueAgent +public interface IClarificationAgent { - IAsyncEnumerable StreamAsync(TurnAnalysis analysis, + IAsyncEnumerable StreamAsync( ConversationAttempt attempt, ConceptElaborationTask task, - CancellationToken ct); + ProbeDirective? lastProbe, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClosingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClosingAgent.cs new file mode 100644 index 000000000..d4bbc93fc --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClosingAgent.cs @@ -0,0 +1,11 @@ +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +public enum ClosingReason { AllCovered, HardCapReached } + +public interface IClosingAgent +{ + IAsyncEnumerable StreamAsync( + ConceptElaborationTask task, ClosingReason reason, CancellationToken ct); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ICritiqueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ICritiqueAgent.cs new file mode 100644 index 000000000..9e45e7ab2 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ICritiqueAgent.cs @@ -0,0 +1,11 @@ +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +public interface ICritiqueAgent +{ + IAsyncEnumerable StreamAsync( + TurnEvaluation evaluation, ConversationAttempt attempt, + ConceptElaborationTask task, CancellationToken ct); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IIntentClassifier.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IIntentClassifier.cs new file mode 100644 index 000000000..262254b33 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IIntentClassifier.cs @@ -0,0 +1,12 @@ +using FluentResults; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +public interface IIntentClassifier +{ + Task> ClassifyAsync( + string content, List history, + ConceptElaborationTask task, CancellationToken ct); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IMetaHelpAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IMetaHelpAgent.cs new file mode 100644 index 000000000..7b32bccbb --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IMetaHelpAgent.cs @@ -0,0 +1,10 @@ +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +public interface IMetaHelpAgent +{ + IAsyncEnumerable StreamAsync( + ConceptElaborationTask task, string progressLine, + ProbeDirective? nextTarget, CancellationToken ct); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IProbeAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IProbeAgent.cs new file mode 100644 index 000000000..8668d20fb --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IProbeAgent.cs @@ -0,0 +1,11 @@ +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +public interface IProbeAgent +{ + IAsyncEnumerable StreamAsync( + ProbeDirective directive, ConversationAttempt attempt, + ConceptElaborationTask task, CancellationToken ct); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IRedirectAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IRedirectAgent.cs new file mode 100644 index 000000000..4b2c6d472 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IRedirectAgent.cs @@ -0,0 +1,8 @@ +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +public interface IRedirectAgent +{ + IAsyncEnumerable StreamAsync(ConceptElaborationTask task, CancellationToken ct); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScaffoldingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScaffoldingAgent.cs new file mode 100644 index 000000000..cb3cd5ca8 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScaffoldingAgent.cs @@ -0,0 +1,11 @@ +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +public interface IScaffoldingAgent +{ + IAsyncEnumerable StreamAsync( + ProbeDirective target, ConversationAttempt attempt, + ConceptElaborationTask task, CancellationToken ct); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScorer.cs similarity index 50% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScorer.cs index 3e1960665..d8f588e33 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IEvaluationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScorer.cs @@ -2,11 +2,11 @@ using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; -public interface IEvaluationAgent +public interface IScorer { - Task> AnalyzeAsync(string content, - List history, ConceptElaborationTask task, - CancellationToken ct); + Task> ScoreAsync( + string content, List history, + ConceptElaborationTask task, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ISummaryAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ISummaryAgent.cs similarity index 97% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ISummaryAgent.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ISummaryAgent.cs index c9c6ab4b6..b74357d2c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ISummaryAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ISummaryAgent.cs @@ -2,7 +2,7 @@ using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; public interface ISummaryAgent { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestratorService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestratorService.cs new file mode 100644 index 000000000..c9f26f1dd --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestratorService.cs @@ -0,0 +1,11 @@ +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public interface IAgentOrchestratorService +{ + IAsyncEnumerable ProcessTurnAsync( + ConversationAttempt attempt, ConceptElaborationTask task, + string learnerContent, 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..f8493ed5a --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs @@ -0,0 +1,18 @@ +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 CheckpointChunk : OrchestratorChunk; + +public sealed record FinalChunk( + int AttemptId, + AttemptStatus Status, + TurnIntent Intent, + string? Summary, + ProbeDirective? ProbeDirective) : OrchestratorChunk; + +public sealed record ErrorChunk(string Message, int Code) : OrchestratorChunk; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs new file mode 100644 index 000000000..3668b066b --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs @@ -0,0 +1,3 @@ +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public record ProbeDirective(ProbeTargetType TargetType, int TargetId, int Level); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeTargetType.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeTargetType.cs new file mode 100644 index 000000000..1b6f2f8bc --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeTargetType.cs @@ -0,0 +1,7 @@ +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public enum ProbeTargetType +{ + KeyProposition, + KeyRelation +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnAnalysis.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnAnalysis.cs deleted file mode 100644 index 4bebd9b99..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/TurnAnalysis.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -public record TurnAnalysis(TurnIntent Intent, TurnEvaluation? Evaluation, bool HasMultipleConcerns = false) -{ - public static TurnAnalysis Substantive(TurnEvaluation evaluation, bool hasMultipleConcerns) => - new(TurnIntent.Substantive, evaluation, hasMultipleConcerns); - public static TurnAnalysis Clarification() => new(TurnIntent.Clarification, null); - public static TurnAnalysis OffTopic() => new(TurnIntent.OffTopic, null); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs new file mode 100644 index 000000000..b1dba0030 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs @@ -0,0 +1,33 @@ +using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +namespace Tutor.Elaborations.Infrastructure.Agents.Clarification; + +public class ClarificationAgent : IClarificationAgent +{ + private readonly IAiChatService _chatService; + + public ClarificationAgent(IAiChatService chatService) + { + _chatService = chatService; + } + + public async IAsyncEnumerable StreamAsync( + ConversationAttempt attempt, ConceptElaborationTask task, ProbeDirective? lastProbe, + [EnumeratorCancellation] CancellationToken ct) + { + var history = attempt.Turns.ToList(); + var learnerContent = history.LastOrDefault(t => t.Role == TurnRole.Learner)?.Content ?? string.Empty; + + var systemPrompt = ClarificationPromptBuilder.BuildSystemPrompt(task, lastProbe); + var userMessage = ClarificationPromptBuilder.BuildUserMessage(history, learnerContent); + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 256, temperature: 0.5); + + await foreach (var token in _chatService.StreamAsync(request, ct)) + yield return token; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationPromptBuilder.cs new file mode 100644 index 000000000..9c69c5d01 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationPromptBuilder.cs @@ -0,0 +1,68 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +namespace Tutor.Elaborations.Infrastructure.Agents.Clarification; + +public static class ClarificationPromptBuilder +{ + public static string BuildSystemPrompt(ConceptElaborationTask task, ProbeDirective? lastProbe) + { + var sb = new StringBuilder(); + sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); + sb.AppendLine("The learner has asked a clarifying question. Rephrase or clarify the TUTOR's prior question in simpler language. Do NOT answer it — answering defeats the whole exercise."); + sb.AppendLine(); + sb.AppendLine($"## Concept: {task.Title}"); + sb.AppendLine(); + sb.AppendLine("## Rules:"); + sb.AppendLine("- Max three sentences. Address only the specific thing asked."); + sb.AppendLine("- If the learner says they don't understand, rephrase the TUTOR's prior question in simpler terms. Do not answer the question under the guise of rephrasing."); + sb.AppendLine("- If the learner asks for a summary, \"the answer\", an explanation, or anything that would require producing the concept's content, REFUSE and redirect: \"Rezime i objašnjenje moraju doći od tebe — to je ono što vežbamo. Pokušaj da formulišeš svojim rečima.\""); + sb.AppendLine("- NEVER state or paraphrase the target below. The whole point is that the learner articulates it."); + sb.AppendLine("- NEVER produce a list or multi-sentence breakdown."); + sb.AppendLine("- After clarifying, invite the learner to resume with one short prompt."); + + if (lastProbe != null) + { + var targetText = ResolveTargetStatement(task, lastProbe); + sb.AppendLine(); + sb.AppendLine("## What the TUTOR was probing (INTERNAL — NEVER reveal this text or a paraphrase close enough to give away the answer):"); + sb.AppendLine($"[{lastProbe.TargetType}-{lastProbe.TargetId}] {targetText}"); + } + + return sb.ToString(); + } + + public static string BuildUserMessage(List history, string learnerContent) + { + var sb = new StringBuilder(); + sb.AppendLine("## Conversation so far"); + foreach (var turn in history) + { + var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; + sb.AppendLine($"[{label}]: {turn.Content}"); + } + sb.AppendLine(); + sb.AppendLine("## Latest LEARNER message (the clarification request)"); + sb.AppendLine(learnerContent); + sb.AppendLine(); + sb.AppendLine("Produce the TUTOR's clarification per the system prompt."); + return sb.ToString(); + } + + private static string ResolveTargetStatement(ConceptElaborationTask task, ProbeDirective directive) + { + if (directive.TargetType == ProbeTargetType.KeyProposition) + { + var kp = task.KeyPropositions.FirstOrDefault(p => p.Id == directive.TargetId); + return kp?.Statement ?? "(unknown)"; + } + var kr = task.KeyRelations.FirstOrDefault(r => r.Id == directive.TargetId); + if (kr == null) return "(unknown)"; + var kpById = task.KeyPropositions.ToDictionary(p => p.Id, p => p.Statement); + var source = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, "?"); + var target = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, "?"); + return $"{source} → {target}. Mechanism: {kr.Mechanism}"; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs new file mode 100644 index 000000000..aac42d6eb --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs @@ -0,0 +1,29 @@ +using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +namespace Tutor.Elaborations.Infrastructure.Agents.Closing; + +public class ClosingAgent : IClosingAgent +{ + private readonly IAiChatService _chatService; + + public ClosingAgent(IAiChatService chatService) + { + _chatService = chatService; + } + + public async IAsyncEnumerable StreamAsync( + ConceptElaborationTask task, ClosingReason reason, + [EnumeratorCancellation] CancellationToken ct) + { + var systemPrompt = ClosingPromptBuilder.BuildSystemPrompt(task, reason); + var userMessage = ClosingPromptBuilder.BuildUserMessage(); + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 128, temperature: 0.5); + + await foreach (var token in _chatService.StreamAsync(request, ct)) + yield return token; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingPromptBuilder.cs new file mode 100644 index 000000000..b970d5acb --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingPromptBuilder.cs @@ -0,0 +1,37 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +namespace Tutor.Elaborations.Infrastructure.Agents.Closing; + +public static class ClosingPromptBuilder +{ + public static string BuildSystemPrompt(ConceptElaborationTask task, ClosingReason reason) + { + var header = reason == ClosingReason.AllCovered + ? "## The learner has covered all required propositions and articulated all required relations." + : "## The conversation has reached its maximum length."; + var ack = reason == ClosingReason.AllCovered + ? "Acknowledge completion in general terms only (e.g., \"dobro si obuhvatio koncept\")." + : "Acknowledge that the conversation is ending in general terms only."; + + var sb = new StringBuilder(); + sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); + sb.AppendLine(); + sb.AppendLine(header); + sb.AppendLine("Produce a closing turn with exactly these properties:"); + sb.AppendLine("- Two sentences maximum."); + sb.AppendLine($"- {ack}"); + sb.AppendLine("- DO NOT summarize the learner's explanation or the concept."); + sb.AppendLine("- DO NOT list, restate, or paraphrase any key proposition, boundary condition, or relation — not even ones already articulated."); + sb.AppendLine("- DO NOT use bullet points or structured lists."); + sb.AppendLine("- DO NOT ask further questions."); + sb.AppendLine("The summary agent writes the summary separately; your job here is only to close the dialogue."); + sb.AppendLine(); + sb.AppendLine($"## Concept: {task.Title}"); + return sb.ToString(); + } + + public static string BuildUserMessage() => + "Produce the TUTOR's closing turn per the system prompt."; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs new file mode 100644 index 000000000..94ce5535c --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs @@ -0,0 +1,30 @@ +using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +namespace Tutor.Elaborations.Infrastructure.Agents.Critique; + +public class CritiqueAgent : ICritiqueAgent +{ + private readonly IAiChatService _chatService; + + public CritiqueAgent(IAiChatService chatService) + { + _chatService = chatService; + } + + public async IAsyncEnumerable StreamAsync( + TurnEvaluation evaluation, ConversationAttempt attempt, ConceptElaborationTask task, + [EnumeratorCancellation] CancellationToken ct) + { + var systemPrompt = CritiquePromptBuilder.BuildSystemPrompt(task, attempt, attempt.IsSoftCapReached()); + var userMessage = CritiquePromptBuilder.BuildUserMessage(evaluation, attempt.Turns.ToList()); + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 512, temperature: 0.7); + + await foreach (var token in _chatService.StreamAsync(request, ct)) + yield return token; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiquePromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiquePromptBuilder.cs new file mode 100644 index 000000000..ea7809be9 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiquePromptBuilder.cs @@ -0,0 +1,111 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Infrastructure.Agents.Critique; + +public static class CritiquePromptBuilder +{ + public static string BuildSystemPrompt( + ConceptElaborationTask task, ConversationAttempt attempt, bool isSoftCapReached) + { + var coveredKpIds = attempt.GetCoveredPropositionIds(); + var articulatedKrIds = attempt.GetArticulatedRelationIds(); + + var sb = new StringBuilder(); + sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); + sb.AppendLine("The learner's latest answer has multiple concerns. Your job is to surface them as a short bulleted list so the learner can consolidate their existing answer before moving on."); + sb.AppendLine(); + sb.AppendLine($"## Concept: {task.Title}"); + sb.AppendLine($"Definition: {task.CanonicalDefinition}"); + sb.AppendLine(); + + sb.AppendLine("## Key Propositions (internal reference — never quote verbatim):"); + foreach (var kp in task.KeyPropositions) + { + var marker = coveredKpIds.Contains(kp.Id) ? " [ALREADY ARTICULATED IN PRIOR TURN — DO NOT RAISE]" : ""; + sb.AppendLine($"- [KP-{kp.Id}]{marker} {kp.Statement}"); + } + sb.AppendLine(); + + if (task.BoundaryConditions.Count > 0) + { + sb.AppendLine("## Boundary Conditions (internal reference):"); + foreach (var bc in task.BoundaryConditions) + sb.AppendLine($"- [BC-{bc.Id}] {bc.Statement}"); + sb.AppendLine(); + } + + if (task.CommonMisconceptions.Count > 0) + { + sb.AppendLine("## Common Misconceptions (internal reference):"); + foreach (var cm in task.CommonMisconceptions) + sb.AppendLine($"- [CM-{cm.Id}] {cm.Description} (correction: {cm.Correction})"); + sb.AppendLine(); + } + + if (task.KeyRelations.Count > 0) + { + sb.AppendLine("## Key Relations (internal reference — never quote verbatim):"); + var kpById = task.KeyPropositions.ToDictionary(kp => kp.Id, kp => kp.Statement); + foreach (var kr in task.KeyRelations) + { + var marker = articulatedKrIds.Contains(kr.Id) ? " [ALREADY ARTICULATED IN PRIOR TURN — DO NOT RAISE]" : ""; + var source = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, "?"); + var target = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, "?"); + sb.AppendLine($"- [KR-{kr.Id}]{marker} {source} → {target}. Mechanism: {kr.Mechanism}"); + } + sb.AppendLine(); + } + + sb.AppendLine("## Rules:"); + sb.AppendLine("- Respond with a short bulleted list of pushback points on concerns in THE LATEST LEARNER TURN ONLY — inaccuracies, triggered or novel misconceptions, vague or hand-wavy claims."); + sb.AppendLine("- NEVER raise a KP or KR marked ALREADY ARTICULATED. The learner has already said those in earlier turns; re-raising them reads as not listening."); + sb.AppendLine("- NEVER provide answers, definitions, or explanations. NEVER reveal any KP/BC/CM/KR text verbatim or paraphrased."); + sb.AppendLine("- Close the bullets with a brief invitation to address them. Do not ask a new Socratic question — the learner must consolidate first."); + sb.AppendLine("- Silence on an error reads as agreement, so surface every in-turn concern."); + sb.AppendLine("- Concise language. Respect cognitive load."); + + if (isSoftCapReached) + { + sb.AppendLine(); + sb.AppendLine("## The learner is approaching the end of the conversation. Signal that you'll wrap up once these are addressed."); + } + + return sb.ToString(); + } + + public static string BuildUserMessage( + TurnEvaluation evaluation, List history) + { + var sb = new StringBuilder(); + sb.AppendLine("## Conversation so far"); + foreach (var turn in history) + { + var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; + sb.AppendLine($"[{label}]: {turn.Content}"); + } + sb.AppendLine(); + + sb.AppendLine("## Evaluation of the latest LEARNER turn (from the scoring agent — NOT from the learner)"); + var parts = new List + { + $"correctness={evaluation.CorrectnessScore}", + $"completeness={evaluation.CompletenessScore}" + }; + if (evaluation.DiscriminationScore.HasValue) + parts.Add($"discrimination={evaluation.DiscriminationScore.Value}"); + if (evaluation.IntegrationScore.HasValue) + parts.Add($"integration={evaluation.IntegrationScore.Value}"); + sb.AppendLine($"Scores: {string.Join(", ", parts)}"); + sb.AppendLine($"Justification: {evaluation.Justification}"); + if (evaluation.MisconceptionsTriggeredIds.Count != 0) + sb.AppendLine($"Triggered misconceptions: {string.Join(", ", evaluation.MisconceptionsTriggeredIds.Select(id => $"CM-{id}"))}"); + if (!string.IsNullOrWhiteSpace(evaluation.NovelMisconceptions)) + sb.AppendLine($"Novel misconceptions: {evaluation.NovelMisconceptions}"); + sb.AppendLine(); + + sb.AppendLine("Produce the TUTOR's critique of the latest LEARNER turn per the system prompt."); + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentClassifierAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentClassifierAgent.cs new file mode 100644 index 000000000..9e000888a --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentClassifierAgent.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using FluentResults; +using Microsoft.Extensions.Logging; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +namespace Tutor.Elaborations.Infrastructure.Agents.IntentClassifier; + +public class IntentClassifierAgent : IIntentClassifier +{ + private const int MaxAttempts = 2; + private readonly IAiChatService _chatService; + private readonly ILogger _logger; + + public IntentClassifierAgent(IAiChatService chatService, ILogger logger) + { + _chatService = chatService; + _logger = logger; + } + + public async Task> ClassifyAsync( + string content, List history, + ConceptElaborationTask task, CancellationToken ct) + { + var systemPrompt = IntentPromptBuilder.BuildSystemPrompt(task); + var userMessage = IntentPromptBuilder.BuildUserMessage(content, history); + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 64, temperature: 0.0); + + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + var result = await _chatService.CompleteAsync(request, ct); + if (result.IsFailed) continue; + + var intent = TryParse(result.Value.Content); + if (intent.HasValue) return intent.Value; + } + + return Result.Fail("Intent classification failed."); + } + + private TurnIntent? TryParse(string json) + { + try + { + var parsed = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + if (parsed?.Intent is null) return null; + return Enum.TryParse(parsed.Intent, ignoreCase: true, out var intent) ? intent : null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "IntentClassifierAgent failed to parse response."); + return null; + } + } + + private class IntentResponse { public string? Intent { get; set; } } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentPromptBuilder.cs new file mode 100644 index 000000000..874dc20b6 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentPromptBuilder.cs @@ -0,0 +1,56 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Infrastructure.Agents.IntentClassifier; + +public static class IntentPromptBuilder +{ + public static string BuildSystemPrompt(ConceptElaborationTask task) + { + var sb = new StringBuilder(); + sb.AppendLine("You are an intent classifier for a Socratic tutoring conversation."); + sb.AppendLine("Classify the learner's latest message into exactly one intent. Output JSON only, no other text."); + sb.AppendLine(); + sb.AppendLine($"## Concept under study: {task.Title}"); + sb.AppendLine(); + sb.AppendLine("## Intent categories:"); + sb.AppendLine("- **Substantive**: the learner attempts to explain, define, relate, or apply the concept. Even a weak or partial attempt counts."); + sb.AppendLine("- **Clarification**: the learner asks a genuine information-seeking question about the task or the tutor's last message (what / why / how / what do you mean by …?). Must be a direct question — if removing the rest and keeping just the question still makes sense."); + sb.AppendLine("- **Stuck**: the learner signals confusion, inability, or not-knowing without asking a question — e.g. \"ne znam\", \"ne razumem\", \"nisam siguran\", \"teško mi je\". Not a refusal of the task, just a stall."); + sb.AppendLine("- **MetaHelp**: the learner asks a procedural/meta question about the conversation itself — e.g. \"rezimiraj šta sam rekao\", \"koliko mi je ostalo\", \"objasni mi još jednom šta tražiš\"."); + sb.AppendLine("- **OffTopic**: everything else — small talk, greetings, jokes, personal content, refusals (\"ne želim\", \"dosadno mi je\"), meta-comments about the conversation, deference or agreement without articulation (\"da, u pravu si\")."); + sb.AppendLine(); + sb.AppendLine("## Disambiguation rules:"); + sb.AppendLine("- If the message is a verbatim or near-verbatim echo of a previous [TUTOR] line, classify as OffTopic."); + sb.AppendLine("- Between Clarification and Stuck: if the message is a question, Clarification; if it is a statement of not-knowing, Stuck."); + sb.AppendLine("- Between Clarification and MetaHelp: MetaHelp is about the conversation/progress itself; Clarification is about the concept or the tutor's last probe."); + sb.AppendLine("- When in doubt between Clarification and OffTopic, choose OffTopic."); + sb.AppendLine(); + sb.AppendLine("## Output format (JSON, no other text):"); + sb.AppendLine("{ \"intent\": \"Substantive\" | \"Clarification\" | \"Stuck\" | \"MetaHelp\" | \"OffTopic\" }"); + return sb.ToString(); + } + + public static string BuildUserMessage(string learnerContent, List history) + { + var sb = new StringBuilder(); + sb.AppendLine("## Conversation so far (for context — do NOT classify these)"); + if (history.Count == 0) + { + sb.AppendLine("(no prior turns)"); + } + else + { + foreach (var turn in history.TakeLast(6)) + { + var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; + sb.AppendLine($"[{label}]: {turn.Content}"); + } + } + sb.AppendLine(); + sb.AppendLine("## Message to classify (this is the ONLY message to classify)"); + sb.AppendLine($"[LEARNER]: {learnerContent}"); + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs new file mode 100644 index 000000000..c03373bfd --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs @@ -0,0 +1,29 @@ +using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +namespace Tutor.Elaborations.Infrastructure.Agents.MetaHelp; + +public class MetaHelpAgent : IMetaHelpAgent +{ + private readonly IAiChatService _chatService; + + public MetaHelpAgent(IAiChatService chatService) + { + _chatService = chatService; + } + + public async IAsyncEnumerable StreamAsync( + ConceptElaborationTask task, string progressLine, ProbeDirective? nextTarget, + [EnumeratorCancellation] CancellationToken ct) + { + var systemPrompt = MetaHelpPromptBuilder.BuildSystemPrompt(task, progressLine, nextTarget); + var userMessage = MetaHelpPromptBuilder.BuildUserMessage(); + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 256, temperature: 0.5); + + await foreach (var token in _chatService.StreamAsync(request, ct)) + yield return token; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpPromptBuilder.cs new file mode 100644 index 000000000..6c2e7d2ca --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpPromptBuilder.cs @@ -0,0 +1,56 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +namespace Tutor.Elaborations.Infrastructure.Agents.MetaHelp; + +public static class MetaHelpPromptBuilder +{ + public static string BuildSystemPrompt( + ConceptElaborationTask task, string progressLine, ProbeDirective? nextTarget) + { + var sb = new StringBuilder(); + sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); + sb.AppendLine("The learner asked a meta/procedural question about the conversation itself (e.g. \"rezimiraj šta sam rekao\", \"koliko mi je ostalo\")."); + sb.AppendLine(); + sb.AppendLine("## Rules:"); + sb.AppendLine("- Open with the pre-rendered progress line exactly as given. Do not invent numbers or substitute it."); + sb.AppendLine("- Then invite the learner to address the one remaining gap with a short, narrow question or cue."); + sb.AppendLine("- Three sentences maximum."); + sb.AppendLine("- NEVER restate the concept, enumerate covered points, or reveal any KP/KR text."); + sb.AppendLine("- Do not produce a list or a summary of what the learner said — only the progress line plus a pivot."); + sb.AppendLine(); + sb.AppendLine($"## Concept: {task.Title}"); + sb.AppendLine(); + sb.AppendLine("## Pre-rendered progress line (use verbatim as the opener):"); + sb.AppendLine(progressLine); + + if (nextTarget != null) + { + var targetText = ResolveTargetStatement(task, nextTarget); + sb.AppendLine(); + sb.AppendLine("## Next target (INTERNAL — never reveal this text or a paraphrase):"); + sb.AppendLine($"[{nextTarget.TargetType}-{nextTarget.TargetId}] {targetText}"); + } + + return sb.ToString(); + } + + public static string BuildUserMessage() => + "Produce the TUTOR's meta-help response per the system prompt."; + + private static string ResolveTargetStatement(ConceptElaborationTask task, ProbeDirective directive) + { + if (directive.TargetType == ProbeTargetType.KeyProposition) + { + var kp = task.KeyPropositions.FirstOrDefault(p => p.Id == directive.TargetId); + return kp?.Statement ?? "(unknown)"; + } + var kr = task.KeyRelations.FirstOrDefault(r => r.Id == directive.TargetId); + if (kr == null) return "(unknown)"; + var kpById = task.KeyPropositions.ToDictionary(p => p.Id, p => p.Statement); + var source = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, "?"); + var target = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, "?"); + return $"{source} → {target}. Mechanism: {kr.Mechanism}"; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs new file mode 100644 index 000000000..096d5a9c1 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs @@ -0,0 +1,30 @@ +using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +namespace Tutor.Elaborations.Infrastructure.Agents.Probe; + +public class ProbeAgent : IProbeAgent +{ + private readonly IAiChatService _chatService; + + public ProbeAgent(IAiChatService chatService) + { + _chatService = chatService; + } + + public async IAsyncEnumerable StreamAsync( + ProbeDirective directive, ConversationAttempt attempt, ConceptElaborationTask task, + [EnumeratorCancellation] CancellationToken ct) + { + var systemPrompt = ProbePromptBuilder.BuildSystemPrompt(task, directive, attempt.IsSoftCapReached()); + var userMessage = ProbePromptBuilder.BuildUserMessage(attempt.Turns.ToList()); + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 256, temperature: 0.7); + + await foreach (var token in _chatService.StreamAsync(request, ct)) + yield return token; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbePromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbePromptBuilder.cs new file mode 100644 index 000000000..824f22541 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbePromptBuilder.cs @@ -0,0 +1,83 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +namespace Tutor.Elaborations.Infrastructure.Agents.Probe; + +public static class ProbePromptBuilder +{ + public static string BuildSystemPrompt( + ConceptElaborationTask task, ProbeDirective directive, bool isSoftCapReached) + { + var targetText = ResolveTargetStatement(task, directive); + + var sb = new StringBuilder(); + sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); + sb.AppendLine("Ask ONE question that probes the single target below. Do not reveal the target text, do not list alternatives, do not summarize what the learner has said."); + sb.AppendLine(); + sb.AppendLine($"## Concept: {task.Title}"); + sb.AppendLine($"Definition: {task.CanonicalDefinition}"); + sb.AppendLine(); + sb.AppendLine("## Target (INTERNAL — never reveal this text or a paraphrase):"); + sb.AppendLine($"[{directive.TargetType}-{directive.TargetId}] {targetText}"); + sb.AppendLine(); + sb.AppendLine($"## Escalation level: L{directive.Level}"); + sb.AppendLine(directive.Level switch + { + 1 => "L1 — open \"why\" or \"what\" question that invites the learner to articulate the target. Broad enough to let them arrive at the idea themselves.", + 2 => "L2 — the learner has already failed an L1 probe on this target. Ask a narrower, connective question: \"how is X tied to Y\", \"what role does X play when Y\". Still no hints to the target statement.", + 3 => "L3 — the learner has failed twice. Offer a sentence-completion scaffold: \"Dopuni rečenicu: '…zato što…'\" or \"Dovrši misao: …\". The blank must not contain the target text.", + _ => "Open question at the lowest level." + }); + sb.AppendLine(); + sb.AppendLine("## Rules:"); + sb.AppendLine("- Output ONE question (or one sentence-completion prompt at L3). No preamble, no bullet list."); + sb.AppendLine("- NEVER reveal the target statement or a paraphrase close enough to give away the answer."); + sb.AppendLine("- NEVER list multiple options or enumerate gaps."); + sb.AppendLine("- Concise. Respect cognitive load."); + if (isSoftCapReached) + { + sb.AppendLine(); + sb.AppendLine("## The learner is approaching the end of the conversation. Signal that you'll wrap up once this is addressed."); + } + return sb.ToString(); + } + + public static string BuildUserMessage(List history) + { + var sb = new StringBuilder(); + sb.AppendLine("## Conversation so far"); + if (history.Count == 0) + { + sb.AppendLine("(no prior turns)"); + } + else + { + foreach (var turn in history) + { + var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; + sb.AppendLine($"[{label}]: {turn.Content}"); + } + } + sb.AppendLine(); + sb.AppendLine("Produce the TUTOR's next probe question per the system prompt."); + return sb.ToString(); + } + + private static string ResolveTargetStatement(ConceptElaborationTask task, ProbeDirective directive) + { + if (directive.TargetType == ProbeTargetType.KeyProposition) + { + var kp = task.KeyPropositions.FirstOrDefault(p => p.Id == directive.TargetId); + return kp?.Statement ?? "(unknown proposition)"; + } + + var kr = task.KeyRelations.FirstOrDefault(r => r.Id == directive.TargetId); + if (kr == null) return "(unknown relation)"; + var kpById = task.KeyPropositions.ToDictionary(p => p.Id, p => p.Statement); + var source = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, "?"); + var target = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, "?"); + return $"{source} → {target}. Mechanism: {kr.Mechanism}"; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs deleted file mode 100644 index 48a9878b5..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/DialoguePromptBuilder.cs +++ /dev/null @@ -1,249 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -namespace Tutor.Elaborations.Infrastructure.Agents.Prompts; - -public static class DialoguePromptBuilder -{ - public static string BuildSystemPrompt(ConceptElaborationTask task, ConversationAttempt attempt, TurnAnalysis analysis) - { - return analysis.Intent switch - { - TurnIntent.Clarification => BuildClarificationPrompt(task), - TurnIntent.OffTopic => BuildOffTopicPrompt(task), - _ => analysis.HasMultipleConcerns - ? BuildFixModePrompt(task, attempt) - : BuildProbeModePrompt(task, attempt) - }; - } - - private static string BuildFixModePrompt(ConceptElaborationTask task, ConversationAttempt attempt) - { - var sb = new StringBuilder(); - sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); - sb.AppendLine("The learner's latest answer has multiple concerns. Your job is to surface them so the learner can consolidate their existing answer before moving on."); - sb.AppendLine(); - sb.AppendLine("## Rules:"); - sb.AppendLine("- NEVER provide answers, definitions, or explanations, and NEVER reveal key proposition, boundary condition, or misconception text."); - sb.AppendLine("- Respond with ONLY a short bulleted list pushing back on each concern in the learner's latest answer — inaccuracies, triggered or novel misconceptions, and vague or hand-wavy claims. Close the bullets with a brief invitation to address them."); - sb.AppendLine("- Do NOT ask a new question about an uncovered key proposition or key relation — the learner must consolidate what they said before expanding."); - sb.AppendLine("- Silence on an error reads as agreement, so surface every concern."); - sb.AppendLine("- Concise language. Respect cognitive load."); - sb.AppendLine(); - - AppendConceptReference(sb, task); - - if (attempt.IsSoftCapReached()) - { - sb.AppendLine("## The learner is approaching the end of the conversation."); - sb.AppendLine("Close the bullets with a note that you'll wrap up once these concerns are addressed."); - } - return sb.ToString(); - } - - private static string BuildProbeModePrompt(ConceptElaborationTask task, ConversationAttempt attempt) - { - if (task.IsAttemptComplete(attempt)) - return BuildClosedDialoguePrompt(task, - "## The learner has covered all required propositions and articulated all required relations.", - "Acknowledge completion in general terms only (e.g., \"dobro si obuhvatio koncept\")."); - - if (attempt.IsHardCapReached()) - return BuildClosedDialoguePrompt(task, - "## The conversation has reached its maximum length.", - "Acknowledge that the conversation is ending in general terms only."); - - var sb = new StringBuilder(); - sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); - sb.AppendLine("Guide the learner to explain the concept by asking targeted questions."); - sb.AppendLine(); - sb.AppendLine("## Rules:"); - sb.AppendLine("- NEVER provide answers, definitions, or explanations, and NEVER reveal key proposition, boundary condition, or misconception text."); - sb.AppendLine("- If the evaluation reports a single concern (inaccuracy, triggered or novel misconception, or vague claim), push back on it in one short line BEFORE your Socratic question. If there is no concern, skip the pushback."); - sb.AppendLine("- Ask ONE Socratic question about a single uncovered key proposition or unarticulated key relation. Do not list remaining gaps."); - sb.AppendLine("- Concise language. Respect cognitive load. Allow productive divergence within the concept space."); - sb.AppendLine(); - - AppendConceptReference(sb, task); - - var uncoveredKpIds = task.GetUncoveredPropositionIds(attempt); - var unarticulatedKrIds = task.GetUnarticulatedRelationIds(attempt); - var hasGaps = uncoveredKpIds.Any() || unarticulatedKrIds.Any(); - - if (hasGaps) - { - sb.AppendLine("## Uncovered ground (pick ONE to probe):"); - if (uncoveredKpIds.Any()) - sb.AppendLine($"- Uncovered key propositions: {string.Join(", ", uncoveredKpIds.Select(id => $"KP-{id}"))}"); - if (unarticulatedKrIds.Any()) - sb.AppendLine($"- Unarticulated key relations: {string.Join(", ", unarticulatedKrIds.Select(id => $"KR-{id}"))}"); - sb.AppendLine("Never reveal the underlying statement or mechanism text."); - sb.AppendLine(); - } - - if (attempt.IsSoftCapReached()) - { - sb.AppendLine("## The learner is approaching the end of the conversation."); - sb.AppendLine(hasGaps - ? "Suggest wrapping up. Focus the Socratic question on the most important uncovered gap above." - : "Suggest wrapping up. Use the Socratic question to briefly probe whatever feels least articulated so far."); - } - return sb.ToString(); - } - - private static string BuildClosedDialoguePrompt(ConceptElaborationTask task, string header, string acknowledgmentLine) - { - var sb = new StringBuilder(); - sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); - sb.AppendLine(); - sb.AppendLine(header); - sb.AppendLine("Produce a closing turn with exactly these properties:"); - sb.AppendLine("- Two sentences maximum."); - sb.AppendLine($"- {acknowledgmentLine}"); - sb.AppendLine("- DO NOT summarize the learner's explanation or the concept."); - sb.AppendLine("- DO NOT list, restate, or paraphrase any key proposition, boundary condition, or relation — not even ones already articulated."); - sb.AppendLine("- DO NOT use bullet points or structured lists."); - sb.AppendLine("- DO NOT ask further questions."); - sb.AppendLine("The summary agent will write the summary separately; your job here is only to close the dialogue."); - sb.AppendLine(); - sb.AppendLine($"## Concept: {task.Title}"); - return sb.ToString(); - } - - private static string BuildClarificationPrompt(ConceptElaborationTask task) - { - var sb = new StringBuilder(); - sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); - sb.AppendLine("The learner has asked a clarifying question. Answer it using ONLY the reference below."); - sb.AppendLine(); - sb.AppendLine("## Rules:"); - sb.AppendLine("- Answer is MAX three sentences. Address only the specific thing asked — do not expand."); - sb.AppendLine("- If the learner asks for a summary, \"the answer\", an explanation, or anything that would require producing the concept's content, REFUSE and redirect. Example: \"Rezime mora da dođe od tebe — to je ono što vežbamo. Pokušaj da formulišeš u svojim rečima.\""); - sb.AppendLine("- NEVER produce a list or multi-sentence breakdown."); - sb.AppendLine("- After answering, invite the learner to resume elaboration with one short prompt."); - sb.AppendLine(); - sb.AppendLine($"## Concept: {task.Title}"); - sb.AppendLine($"Question: {task.CanonicalDefinition}"); - sb.AppendLine(); - - return sb.ToString(); - } - - private static string BuildOffTopicPrompt(ConceptElaborationTask task) - { - var sb = new StringBuilder(); - sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); - sb.AppendLine("The learner's last message is off-topic — small talk, jokes, personal content, refusals, or disengagement."); - sb.AppendLine(); - sb.AppendLine("## Rules:"); - sb.AppendLine("- Acknowledge in one short clause without engaging with the off-topic content."); - sb.AppendLine("- Firmly but kindly redirect to the concept. End with a concrete, small next step on it."); - sb.AppendLine("- Do NOT answer off-topic questions, validate the off-topic direction, offer to change topic, suggest pausing or abandoning, or ask about the learner's mood."); - sb.AppendLine(); - sb.AppendLine($"## Concept: {task.Title}"); - return sb.ToString(); - } - - private static void AppendConceptReference(StringBuilder sb, ConceptElaborationTask task) - { - sb.AppendLine($"## Concept: {task.Title}"); - sb.AppendLine($"Definition: {task.CanonicalDefinition}"); - sb.AppendLine(); - sb.AppendLine("The reference blocks below are for your use only. Never reveal any statement or mechanism text verbatim or paraphrased."); - sb.AppendLine(); - - sb.AppendLine("## Key Propositions:"); - foreach (var kp in task.KeyPropositions) - sb.AppendLine($"- [KP-{kp.Id}] {kp.Statement}"); - sb.AppendLine(); - - if (task.BoundaryConditions.Count != 0) - { - sb.AppendLine("## Boundary Conditions:"); - foreach (var bc in task.BoundaryConditions) - sb.AppendLine($"- [BC-{bc.Id}] {bc.Statement}"); - sb.AppendLine(); - } - - if (task.CommonMisconceptions.Count != 0) - { - sb.AppendLine("## Common Misconceptions:"); - foreach (var cm in task.CommonMisconceptions) - sb.AppendLine($"- [CM-{cm.Id}] {cm.Description} (correction: {cm.Correction})"); - sb.AppendLine(); - } - - if (task.KeyRelations.Count != 0) - { - sb.AppendLine("## Key Relations:"); - var kpById = task.KeyPropositions.ToDictionary(kp => kp.Id, kp => kp.Statement); - foreach (var kr in task.KeyRelations) - { - var sourceText = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, $"KP-{kr.SourceKeyPropositionId}"); - var targetText = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, $"KP-{kr.TargetKeyPropositionId}"); - sb.AppendLine($"- [KR-{kr.Id}] {sourceText} → {targetText}. Mechanism: {kr.Mechanism}"); - } - sb.AppendLine(); - } - } - - public static string BuildUserMessage(List history, TurnAnalysis analysis) - { - var sb = new StringBuilder(); - - sb.AppendLine("## Conversation so far"); - if (history.Count == 0) - { - sb.AppendLine("(no prior turns)"); - } - else - { - foreach (var turn in history) - { - var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; - sb.AppendLine($"[{label}]: {turn.Content}"); - } - } - sb.AppendLine(); - - if (analysis.Intent == TurnIntent.Substantive && analysis.Evaluation != null) - { - sb.AppendLine("## Evaluation of the latest LEARNER turn (from the evaluation agent — NOT from the learner)"); - sb.AppendLine(BuildEvaluationSummaryBody(analysis.Evaluation)); - sb.AppendLine(); - } - - sb.AppendLine(analysis.Intent switch - { - TurnIntent.Clarification => "Produce the TUTOR's next turn per the system prompt. The latest LEARNER turn is a clarifying question.", - TurnIntent.OffTopic => "Produce the TUTOR's next turn per the system prompt. The latest LEARNER turn is off-topic and must be redirected.", - _ => "Produce the TUTOR's next turn per the system prompt, responding to the latest LEARNER turn." - }); - - return sb.ToString(); - } - - private static string BuildEvaluationSummaryBody(TurnEvaluation evaluation) - { - var parts = new List - { - $"correctness={evaluation.CorrectnessScore}", - $"completeness={evaluation.CompletenessScore}" - }; - if (evaluation.DiscriminationScore.HasValue) - parts.Add($"discrimination={evaluation.DiscriminationScore.Value}"); - if (evaluation.IntegrationScore.HasValue) - parts.Add($"integration={evaluation.IntegrationScore.Value}"); - - var sb = new StringBuilder(); - sb.AppendLine($"Scores: {string.Join(", ", parts)}"); - sb.AppendLine($"Justification: {evaluation.Justification}"); - if (evaluation.MisconceptionsTriggeredIds.Count != 0) - sb.AppendLine($"Triggered misconceptions: {string.Join(", ", evaluation.MisconceptionsTriggeredIds.Select(id => $"CM-{id}"))}"); - if (!string.IsNullOrWhiteSpace(evaluation.NovelMisconceptions)) - sb.AppendLine($"Novel misconceptions: {evaluation.NovelMisconceptions}"); - return sb.ToString().TrimEnd(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs new file mode 100644 index 000000000..9cf0f1f85 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs @@ -0,0 +1,28 @@ +using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +namespace Tutor.Elaborations.Infrastructure.Agents.Redirect; + +public class RedirectAgent : IRedirectAgent +{ + private readonly IAiChatService _chatService; + + public RedirectAgent(IAiChatService chatService) + { + _chatService = chatService; + } + + public async IAsyncEnumerable StreamAsync( + ConceptElaborationTask task, [EnumeratorCancellation] CancellationToken ct) + { + var systemPrompt = RedirectPromptBuilder.BuildSystemPrompt(task); + var userMessage = RedirectPromptBuilder.BuildUserMessage(); + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 128, temperature: 0.7); + + await foreach (var token in _chatService.StreamAsync(request, ct)) + yield return token; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectPromptBuilder.cs new file mode 100644 index 000000000..11bfd71a2 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectPromptBuilder.cs @@ -0,0 +1,26 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +namespace Tutor.Elaborations.Infrastructure.Agents.Redirect; + +public static class RedirectPromptBuilder +{ + public static string BuildSystemPrompt(ConceptElaborationTask task) + { + var sb = new StringBuilder(); + sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); + sb.AppendLine("The learner's last message is off-topic — small talk, jokes, personal content, refusals, or disengagement."); + sb.AppendLine(); + sb.AppendLine("## Rules:"); + sb.AppendLine("- Acknowledge in one short clause without engaging with the off-topic content."); + sb.AppendLine("- Firmly but kindly redirect to the concept. End with a concrete, small next step on it."); + sb.AppendLine("- Do NOT answer off-topic questions, validate the off-topic direction, offer to change topic, suggest pausing or abandoning, or ask about the learner's mood."); + sb.AppendLine("- Two sentences maximum. No bullet list."); + sb.AppendLine(); + sb.AppendLine($"## Concept: {task.Title}"); + return sb.ToString(); + } + + public static string BuildUserMessage() => + "Produce the TUTOR's redirect per the system prompt."; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs similarity index 54% rename from src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs index 4f500a8eb..c4a19245a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/DialogueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs @@ -3,31 +3,28 @@ using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; -using Tutor.Elaborations.Infrastructure.Agents.Prompts; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; -namespace Tutor.Elaborations.Infrastructure.Agents; +namespace Tutor.Elaborations.Infrastructure.Agents.Scaffolding; -public class DialogueAgent : IDialogueAgent +public class ScaffoldingAgent : IScaffoldingAgent { private readonly IAiChatService _chatService; - public DialogueAgent(IAiChatService chatService) + public ScaffoldingAgent(IAiChatService chatService) { _chatService = chatService; } - public async IAsyncEnumerable StreamAsync(TurnAnalysis analysis, - ConversationAttempt attempt, ConceptElaborationTask task, + public async IAsyncEnumerable StreamAsync( + ProbeDirective target, ConversationAttempt attempt, ConceptElaborationTask task, [EnumeratorCancellation] CancellationToken ct) { - var systemPrompt = DialoguePromptBuilder.BuildSystemPrompt(task, attempt, analysis); - var userMessage = DialoguePromptBuilder.BuildUserMessage(attempt.Turns.ToList(), analysis); - + var systemPrompt = ScaffoldingPromptBuilder.BuildSystemPrompt(task, target); + var userMessage = ScaffoldingPromptBuilder.BuildUserMessage(attempt); var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 512, temperature: 0.7); await foreach (var token in _chatService.StreamAsync(request, ct)) - { yield return token; - } } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingPromptBuilder.cs new file mode 100644 index 000000000..da2f523c4 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingPromptBuilder.cs @@ -0,0 +1,66 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +namespace Tutor.Elaborations.Infrastructure.Agents.Scaffolding; + +public static class ScaffoldingPromptBuilder +{ + public static string BuildSystemPrompt( + ConceptElaborationTask task, ProbeDirective target) + { + var targetText = ResolveTargetStatement(task, target); + + var sb = new StringBuilder(); + sb.AppendLine("You are a Socratic tutoring scaffolding agent. Speak Serbian."); + sb.AppendLine("The learner has stalled after repeated probes on one target. Provide a concrete structural scaffold that gives them a foothold WITHOUT stating the target."); + sb.AppendLine(); + sb.AppendLine($"## Concept: {task.Title}"); + sb.AppendLine($"Definition: {task.CanonicalDefinition}"); + sb.AppendLine(); + sb.AppendLine("## Target (INTERNAL — NEVER reveal or paraphrase this text; it must come from the learner):"); + sb.AppendLine($"[{target.TargetType}-{target.TargetId}] {targetText}"); + sb.AppendLine(); + sb.AppendLine("## Scaffolding options (pick ONE that fits best):"); + sb.AppendLine("1. **Forced choice** — offer two options, exactly one of which points toward the target, both phrased at the same level of abstraction. Ask the learner to choose and justify."); + sb.AppendLine("2. **Code skeleton with labeled blanks** — a minimal pseudo-code / test skeleton with `// ___` blanks labeled by role (e.g. `// pripremi očekivano`). Ask the learner to fill one blank."); + sb.AppendLine("3. **Analogy** — map the target to a simpler, non-technical domain. Present the analogy's structure and ask the learner to translate it back to the concept."); + sb.AppendLine(); + sb.AppendLine("## Rules:"); + sb.AppendLine("- Keep it short. A code skeleton with 3-4 labeled lines, or a two-option forced choice, or a 2-sentence analogy."); + sb.AppendLine("- NEVER state the target text or a paraphrase close enough to give it away. The scaffold must make the learner do the articulation."); + sb.AppendLine("- End with one concrete, narrow request (\"Koja opcija i zašto?\" / \"Šta ide na mestu ___?\" / \"Prevedi ovu analogiju na naš koncept.\")."); + sb.AppendLine("- No bullet lists beyond what the scaffold structurally requires."); + return sb.ToString(); + } + + public static string BuildUserMessage(ConversationAttempt attempt) + { + var sb = new StringBuilder(); + sb.AppendLine("## Conversation so far (so you can see the learner's prior attempts on this target)"); + foreach (var turn in attempt.Turns) + { + var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; + sb.AppendLine($"[{label}]: {turn.Content}"); + } + sb.AppendLine(); + sb.AppendLine("Produce the TUTOR's scaffold per the system prompt."); + return sb.ToString(); + } + + private static string ResolveTargetStatement(ConceptElaborationTask task, ProbeDirective directive) + { + if (directive.TargetType == ProbeTargetType.KeyProposition) + { + var kp = task.KeyPropositions.FirstOrDefault(p => p.Id == directive.TargetId); + return kp?.Statement ?? "(unknown)"; + } + var kr = task.KeyRelations.FirstOrDefault(r => r.Id == directive.TargetId); + if (kr == null) return "(unknown)"; + var kpById = task.KeyPropositions.ToDictionary(p => p.Id, p => p.Statement); + var source = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, "?"); + var target = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, "?"); + return $"{source} → {target}. Mechanism: {kr.Mechanism}"; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerAgent.cs similarity index 58% rename from src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerAgent.cs index b9c9306da..444dc8e60 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/EvaluationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerAgent.cs @@ -5,63 +5,49 @@ using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; -using Tutor.Elaborations.Infrastructure.Agents.Prompts; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; -namespace Tutor.Elaborations.Infrastructure.Agents; +namespace Tutor.Elaborations.Infrastructure.Agents.Scorer; -public class EvaluationAgent : IEvaluationAgent +public class ScorerAgent : IScorer { private const int MaxAttempts = 2; private readonly IAiChatService _chatService; - private readonly ILogger _logger; + private readonly ILogger _logger; - public EvaluationAgent(IAiChatService chatService, ILogger logger) + public ScorerAgent(IAiChatService chatService, ILogger logger) { _chatService = chatService; _logger = logger; } - public async Task> AnalyzeAsync(string content, - List history, ConceptElaborationTask task, CancellationToken ct) + public async Task> ScoreAsync( + string content, List history, + ConceptElaborationTask task, CancellationToken ct) { - var request = CreateRequestWithPromptAndParams(content, history, task); + var systemPrompt = ScorerPromptBuilder.BuildSystemPrompt(task); + var userMessage = ScorerPromptBuilder.BuildUserMessage(content, history); + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 1024, temperature: 0.0); for (var attempt = 0; attempt < MaxAttempts; attempt++) { var result = await _chatService.CompleteAsync(request, ct); if (result.IsFailed) continue; - var analysis = TryParseResponse(result.Value.Content, task); - if (analysis == null) continue; - - return analysis; + var evaluation = TryParse(result.Value.Content, task); + if (evaluation != null) return evaluation; } - return Result.Fail("Failed to parse evaluation response after retries."); + return Result.Fail("Scoring failed."); } - private static CompletionRequest CreateRequestWithPromptAndParams( - string content, List history, ConceptElaborationTask task) - { - var systemPrompt = EvaluationPromptBuilder.BuildSystemPrompt(task); - var userMessage = EvaluationPromptBuilder.BuildUserMessage(content, history); - - return CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 1024, temperature: 0.0); - } - - private TurnAnalysis? TryParseResponse(string json, ConceptElaborationTask task) + private TurnEvaluation? TryParse(string json, ConceptElaborationTask task) { try { - var parsed = JsonSerializer.Deserialize(json, + var parsed = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - if (parsed == null || string.IsNullOrWhiteSpace(parsed.Intent)) return null; - - if (!Enum.TryParse(parsed.Intent, ignoreCase: true, out var intent)) - return null; - - if (intent != TurnIntent.Substantive) - return new TurnAnalysis(intent, null); + if (parsed == null) return null; if (parsed.CorrectnessScore is < 1 or > 3) return null; if (parsed.CompletenessScore is < 1 or > 3) return null; @@ -76,25 +62,24 @@ private static CompletionRequest CreateRequestWithPromptAndParams( if (parsed.RelationsArticulatedIds?.Any(id => !validKrIds.Contains(id)) == true) return null; if (parsed.MisconceptionsTriggeredIds?.Any(id => !validCmIds.Contains(id)) == true) return null; - var evaluation = new TurnEvaluation( + return new TurnEvaluation( parsed.CorrectnessScore, parsed.CompletenessScore, parsed.DiscriminationScore, parsed.IntegrationScore, parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, parsed.PropositionsCoveredIds ?? new List(), parsed.MisconceptionsTriggeredIds ?? new List(), - parsed.RelationsArticulatedIds ?? new List()); - return TurnAnalysis.Substantive(evaluation, parsed.HasMultipleConcerns ?? false); + parsed.RelationsArticulatedIds ?? new List(), + parsed.HasMultipleConcerns ?? false); } catch (Exception ex) { - _logger.LogWarning(ex, "TryParseResponse failed to parse evaluation JSON."); + _logger.LogWarning(ex, "ScorerAgent failed to parse response."); return null; } } - private class EvaluationResponse + private class ScorerResponse { - public string? Intent { get; set; } public int CorrectnessScore { get; set; } public int CompletenessScore { get; set; } public int? DiscriminationScore { get; set; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerPromptBuilder.cs similarity index 55% rename from src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerPromptBuilder.cs index e49713eb5..7d320ef35 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/EvaluationPromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerPromptBuilder.cs @@ -2,9 +2,9 @@ using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; -namespace Tutor.Elaborations.Infrastructure.Agents.Prompts; +namespace Tutor.Elaborations.Infrastructure.Agents.Scorer; -public static class EvaluationPromptBuilder +public static class ScorerPromptBuilder { public static string BuildSystemPrompt(ConceptElaborationTask task) { @@ -13,8 +13,8 @@ public static string BuildSystemPrompt(ConceptElaborationTask task) var hasKeyRelations = task.KeyRelations.Count != 0; var sb = new StringBuilder(); - sb.AppendLine("You are an evaluation agent for a Socratic tutoring system."); - sb.AppendLine("Your task: classify the learner's latest message, then (only if Substantive) score it against the concept rubric. Output JSON. DO NOT OUTPUT ANYTHING ELSE."); + sb.AppendLine("You are a scoring agent for a Socratic tutoring system."); + sb.AppendLine("The learner's latest message is known to be Substantive (an attempt at explanation). Score it against the concept rubric and tag which propositions/relations/misconceptions it hits. Output JSON only, no other text."); sb.AppendLine(); sb.AppendLine($"## Concept: {task.Title}"); sb.AppendLine($"Definition: {task.CanonicalDefinition}"); @@ -54,63 +54,36 @@ public static string BuildSystemPrompt(ConceptElaborationTask task) sb.AppendLine(); } - sb.AppendLine(CreateIntentRules()); - sb.AppendLine(CreateScoringRules(hasBoundaryConditions, hasKeyRelations, hasCommonMisconceptions)); - - return sb.ToString(); - } - - private static string CreateIntentRules() - { - var sb = new StringBuilder(); - sb.AppendLine("## Intent Classification (decide first):"); - sb.AppendLine("- Substantive: the learner attempts to explain, define, relate, or apply the concept. Even a weak or partial attempt counts."); - sb.AppendLine("- Clarification: the learner asks a genuine information-seeking question about the task, the concept, or a previous tutor message. Must be a direct question (what / why / how / can you give an example / what do you mean by ...?). A message is Clarification ONLY if removing the rest and keeping just the question still makes sense."); - sb.AppendLine("- OffTopic: everything else. This includes small talk, jokes, personal questions, refusals or disengagement (\"I don't want to\", \"I'm not in the mood\", \"this is boring\", \"can we do something else\"), meta-comments about the conversation, and any message that is neither a concept explanation nor an information-seeking question. When in doubt between Clarification and OffTopic, choose OffTopic."); - sb.AppendLine(); - sb.AppendLine("## Echo rule (apply before scoring)"); - sb.AppendLine("If the message to score is a verbatim or near-verbatim repetition of any [TUTOR] line in the conversation so far, classify intent as OffTopic. Credit for a Key Proposition or Key Relation requires the learner to articulate it in their own words, not repeat the tutor."); - sb.AppendLine(); sb.AppendLine("## Scope rule"); - sb.AppendLine("Score only the message demarcated as '## Message to score'. Do not attribute content from [TUTOR] lines to the learner. If the learner's message expresses agreement with, approval of, or deference to something the tutor said (e.g. 'I bet you'd explain it well', 'that's right', 'you said it'), that is not articulation of the concept — classify as OffTopic."); - return sb.ToString(); - } + sb.AppendLine("Score only the message demarcated as '## Message to score'. Do not credit the learner for content that appears in [TUTOR] lines or that the learner has only repeated from a preceding [TUTOR] line."); + sb.AppendLine(); - private static string CreateScoringRules(bool hasBoundaryConditions, bool hasKeyRelations, bool hasCommonMisconceptions) - { - var sb = new StringBuilder(); - sb.AppendLine("## Scoring Rules (apply only when intent is Substantive):"); - var correctnessLine = hasBoundaryConditions + sb.AppendLine("## Rubric:"); + sb.AppendLine(hasBoundaryConditions ? "- Correctness (1-3): Are stated claims true? Check against KPs and BCs." - : "- Correctness (1-3): Are stated claims true? Check against KPs."; - sb.AppendLine(correctnessLine); - sb.AppendLine("- Completeness (1-3): Are essential KPs covered?"); + : "- Correctness (1-3): Are stated claims true? Check against KPs."); + sb.AppendLine("- Completeness (1-3): Are essential KPs covered in THIS message?"); if (hasBoundaryConditions) sb.AppendLine("- Discrimination (1-3): Does the explanation correctly exclude non-examples? Check BCs."); if (hasKeyRelations) - sb.AppendLine("- Integration (1-3): Did the learner articulate the key relations *with mechanism*? Score 1 if no relation articulated, 2 if relations mentioned without mechanism, 3 if relations articulated with explicit mechanism matching the authored description."); + sb.AppendLine("- Integration (1-3): Did the learner articulate key relations *with mechanism*? 1=no relation, 2=relation without mechanism, 3=relation with mechanism matching the authored description."); sb.AppendLine("- Evaluate concepts, not language. Grammar and style must not reduce scores."); sb.AppendLine("- Resist sycophancy. Evaluate strictly against rubric."); sb.AppendLine(); - sb.AppendLine("## Concern count (used to route the dialogue agent):"); + + sb.AppendLine("## Concern count (used by the orchestrator to route to critique vs probe):"); sb.AppendLine("Count distinct concerns in the message. A concern is any of:"); sb.AppendLine(" - a stated inaccuracy (a claim that contradicts a KP or BC);"); - if (hasCommonMisconceptions) - sb.AppendLine(" - a triggered known misconception or a novel misconception;"); - else - sb.AppendLine(" - a novel misconception (none are pre-catalogued for this concept);"); + sb.AppendLine(hasCommonMisconceptions + ? " - a triggered known misconception or a novel misconception;" + : " - a novel misconception (none are pre-catalogued for this concept);"); sb.AppendLine(" - a vague or hand-wavy claim that references a KP without articulating it."); - sb.AppendLine("Output hasMultipleConcerns=true if the count is two or more; false otherwise. A clean or single-concern answer is false."); + sb.AppendLine("Set hasMultipleConcerns=true if the count is two or more; false otherwise."); sb.AppendLine(); sb.AppendLine("## Output Format (JSON only, no other text):"); - sb.AppendLine("If intent is Clarification or OffTopic, output exactly:"); - sb.AppendLine("{ \"intent\": \"Clarification\" } // or \"OffTopic\""); - sb.AppendLine(); - sb.AppendLine("If intent is Substantive, output:"); var fields = new List { - "\"intent\": \"Substantive\"", "\"correctnessScore\": 1-3", "\"completenessScore\": 1-3" }; @@ -129,11 +102,9 @@ private static string CreateScoringRules(bool hasBoundaryConditions, bool hasKey return sb.ToString(); } - public static string BuildUserMessage( - string learnerContent, List history) + public static string BuildUserMessage(string learnerContent, List history) { var sb = new StringBuilder(); - sb.AppendLine("## Conversation so far (for context only — DO NOT score this)"); if (history.Count == 0) { @@ -148,13 +119,10 @@ public static string BuildUserMessage( } } sb.AppendLine(); - sb.AppendLine("## Message to score (this is the ONLY message you are scoring)"); sb.AppendLine($"[LEARNER]: {learnerContent}"); sb.AppendLine(); - - sb.AppendLine("Score only the final [LEARNER] message under '## Message to score'. Do not credit the learner for content that appears in [TUTOR] lines or that the learner has only repeated from a preceding [TUTOR] line."); - + sb.AppendLine("Score only the final [LEARNER] message under '## Message to score'."); return sb.ToString(); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/SummaryAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs similarity index 89% rename from src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/SummaryAgent.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs index 9780c8802..0bb134c4a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/SummaryAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs @@ -3,9 +3,9 @@ using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; -using Tutor.Elaborations.Infrastructure.Agents.Prompts; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; -namespace Tutor.Elaborations.Infrastructure.Agents; +namespace Tutor.Elaborations.Infrastructure.Agents.Summary; public class SummaryAgent : ISummaryAgent { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/SummaryPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryPromptBuilder.cs similarity index 96% rename from src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/SummaryPromptBuilder.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryPromptBuilder.cs index 8bdc340d9..9e815320b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Prompts/SummaryPromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryPromptBuilder.cs @@ -2,7 +2,7 @@ using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; -namespace Tutor.Elaborations.Infrastructure.Agents.Prompts; +namespace Tutor.Elaborations.Infrastructure.Agents.Summary; public static class SummaryPromptBuilder { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs index 443f4124a..7e3a2e869 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs @@ -14,8 +14,18 @@ using Tutor.Elaborations.Core.UseCases.Authoring; using Tutor.Elaborations.Core.UseCases.Learning; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; using Tutor.Elaborations.Core.UseCases.Monitoring; -using Tutor.Elaborations.Infrastructure.Agents; +using Tutor.Elaborations.Infrastructure.Agents.Clarification; +using Tutor.Elaborations.Infrastructure.Agents.Closing; +using Tutor.Elaborations.Infrastructure.Agents.Critique; +using Tutor.Elaborations.Infrastructure.Agents.IntentClassifier; +using Tutor.Elaborations.Infrastructure.Agents.MetaHelp; +using Tutor.Elaborations.Infrastructure.Agents.Probe; +using Tutor.Elaborations.Infrastructure.Agents.Redirect; +using Tutor.Elaborations.Infrastructure.Agents.Scaffolding; +using Tutor.Elaborations.Infrastructure.Agents.Scorer; +using Tutor.Elaborations.Infrastructure.Agents.Summary; using Tutor.Elaborations.Infrastructure.Database; using Tutor.Elaborations.Infrastructure.Database.Repositories; @@ -49,8 +59,18 @@ private static void SetupInfrastructure(IServiceCollection services) services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs index 025df95ab..c294ddb22 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs @@ -55,19 +55,31 @@ public void SetupEvaluationMock(List? propositionsCoveredIds = null, int? discriminationScore = 2, int? integrationScore = null, string intent = "Substantive") { - var evalJson = intent == "Substantive" - ? BuildSubstantiveEvalJson(propositionsCoveredIds, relationsArticulatedIds, discriminationScore, integrationScore) - : $$"""{ "intent": "{{intent}}" }"""; + SetupIntentMock(intent); + if (intent != "Substantive") return; + + var scorerJson = BuildSubstantiveEvalJson(propositionsCoveredIds, relationsArticulatedIds, discriminationScore, integrationScore); MockChatService.Setup(x => x.CompleteAsync( It.Is(r => r.MaxTokens == 1024), It.IsAny())) .ReturnsAsync(Result.Ok(new CompletionResponse { - Content = evalJson, + Content = scorerJson, Usage = new TokenUsage(100, 50) })); } + public void SetupIntentMock(string intent = "Substantive") + { + MockChatService.Setup(x => x.CompleteAsync( + It.Is(r => r.MaxTokens == 64), It.IsAny())) + .ReturnsAsync(Result.Ok(new CompletionResponse + { + Content = $$"""{ "intent": "{{intent}}" }""", + Usage = new TokenUsage(30, 5) + })); + } + private static string BuildSubstantiveEvalJson(List? propositionsCoveredIds, List? relationsArticulatedIds, int? discriminationScore, int? integrationScore) { 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 index f8d04aee0..d50630e4a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -9,10 +9,10 @@ VALUES (-2, -1, 1, 'Good start! Can you tell me more about access modifiers?', 1 INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-3, -1, 0, 'Access modifiers like public and private control visibility.', 2, '2024-06-01 10:02:00+00', 0); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-1, -1, 2, 2, 2, null, 'Accurate basic description', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-3, -3, 2, 2, 2, null, 'Good description of access modifiers', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-1, -1, 2, 2, 2, null, 'Accurate basic description', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-3, -3, 2, 2, 2, null, 'Good description of access modifiers', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -2: Learner -2, CET -1, Abandoned (for query tests) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -27,8 +27,8 @@ VALUES (-4, -3, 0, 'Encapsulation is about data hiding.', 0, '2024-06-03 10:01:0 INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', 1, '2024-06-03 10:01:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-4, -4, 1, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '[-210]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-4, -4, 1, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '[-210]'::jsonb, '[]'::jsonb, false); -- Attempt -4: Learner -3, CET -2, InProgress (for completion test: KP -20 already covered, submit to cover -21) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -39,8 +39,8 @@ VALUES (-6, -4, 0, 'Encapsulation bundles data and methods together.', 0, '2024- INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-7, -4, 1, 'Good. What about access control?', 1, '2024-06-04 10:01:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-6, -6, 2, 2, 2, null, 'Covers bundling proposition', null, '[-20]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-6, -6, 2, 2, 2, null, 'Covers bundling proposition', null, '[-20]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -5: Learner -2, CET -2, InProgress with 9 learner + 9 system turns (for hard cap test) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -84,24 +84,24 @@ INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Rol VALUES (-67, -5, 1, 'Response 9', 17, '2024-06-05 10:09:05+00', null); -- Evaluations for the 9 learner turns (all with empty propositions - never completes) -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-50, -50, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-52, -52, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-54, -54, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-56, -56, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-58, -58, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-60, -60, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-62, -62, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-64, -64, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-66, -66, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-50, -50, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-52, -52, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-54, -54, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-56, -56, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-58, -58, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-60, -60, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-62, -62, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-64, -64, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-66, -66, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -6: Learner -3, CET -3, InProgress with 5 substantive learner + 5 system turns (for soft cap test) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -128,16 +128,16 @@ VALUES (-78, -6, 0, 'Turn 5', 8, '2024-06-06 10:05:00+00', 0); INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-79, -6, 1, 'Response 5', 9, '2024-06-06 10:05:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-70, -70, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-72, -72, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-74, -74, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-76, -76, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds") -VALUES (-78, -78, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-70, -70, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-72, -72, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-74, -74, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-76, -76, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +VALUES (-78, -78, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -7: Learner -3, CET -5, InProgress (isolated for abandon test — no other test touches this) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs index 28474367a..c6fe0e3fb 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs @@ -80,13 +80,15 @@ private static ConversationAttempt MakeAttemptWithCoveredAndArticulated( var evalCtor = typeof(TurnEvaluation).GetConstructors().First(c => c.GetParameters().Length > 0); var evaluation = (TurnEvaluation)evalCtor.Invoke([ 2, 2, (int?)null, (int?)null, "test", null, - coveredKpIds, new List(), articulatedRelationIds + coveredKpIds, new List(), articulatedRelationIds, false ]); var turnCtor = typeof(ConversationTurn).GetConstructors( BindingFlags.NonPublic | BindingFlags.Instance) .First(c => c.GetParameters().Length > 0); - var turn = (ConversationTurn)turnCtor.Invoke([TurnRole.Learner, "x", 0, (TurnIntent?)TurnIntent.Substantive, evaluation]); + var turn = (ConversationTurn)turnCtor.Invoke([ + TurnRole.Learner, "x", 0, (TurnIntent?)TurnIntent.Substantive, evaluation, null! + ]); SetProp(attempt, "Turns", new List { turn }); return attempt; From 265acfdc41e8dfe2de2630da5b883bfbf222e90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Tue, 21 Apr 2026 20:05:47 +0300 Subject: [PATCH 20/51] refactor: Extracts Streaming and Structured Agent base clases to enforce consistent behavior across agent implementations and reduce duplication. --- .../Agents/StreamingAgent.cs | 28 ++++++ .../Agents/StructuredAgent.cs | 83 ++++++++++++++++++ .../Clarification/ClarificationAgent.cs | 20 ++--- .../Agents/Closing/ClosingAgent.cs | 22 ++--- .../Agents/Critique/CritiqueAgent.cs | 21 ++--- .../IntentClassifier/IntentClassifierAgent.cs | 50 +++-------- .../Agents/MetaHelp/MetaHelpAgent.cs | 20 ++--- .../Agents/Probe/ProbeAgent.cs | 20 ++--- .../Agents/Redirect/RedirectAgent.cs | 20 ++--- .../Agents/Scaffolding/ScaffoldingAgent.cs | 20 ++--- .../Agents/Scorer/ScorerAgent.cs | 85 +++++++------------ .../Agents/Summary/SummaryAgent.cs | 27 +++--- .../Unit/ConceptElaborationTaskTests.cs | 1 - 13 files changed, 204 insertions(+), 213 deletions(-) create mode 100644 src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs create mode 100644 src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs new file mode 100644 index 000000000..e9f6814d5 --- /dev/null +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs @@ -0,0 +1,28 @@ +using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Conversations; + +namespace Tutor.BuildingBlocks.AI.Core.Agents; + +/// +/// Base class for agents that stream a single-message LLM completion token by token. +/// Derived agents build their own system and user prompts and delegate the network plumbing here. +/// +public abstract class StreamingAgent +{ + protected IAiChatService ChatService { get; } + + protected StreamingAgent(IAiChatService chatService) + { + ChatService = chatService; + } + + protected async IAsyncEnumerable StreamAsync( + string systemPrompt, string userMessage, + int maxTokens, double temperature, + [EnumeratorCancellation] CancellationToken ct) + { + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature); + await foreach (var token in ChatService.StreamAsync(request, ct)) + yield return token; + } +} diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs new file mode 100644 index 000000000..b85684a65 --- /dev/null +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using FluentResults; +using Microsoft.Extensions.Logging; +using Tutor.BuildingBlocks.AI.Core.Conversations; + +namespace Tutor.BuildingBlocks.AI.Core.Agents; + +/// +/// Base class for agents that consume a full (non-streamed) LLM completion, optionally parsing JSON into a typed result. +/// Handles the retry loop, deserialization, and logging so derived agents only define their DTO and mapping rules. +/// +public abstract class StructuredAgent +{ + private const int MaxAttempts = 2; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + protected IAiChatService ChatService { get; } + private readonly ILogger _logger; + + protected StructuredAgent(IAiChatService chatService, ILogger logger) + { + ChatService = chatService; + _logger = logger; + } + + /// + /// Runs the request, deserializes the response as , and maps/validates it + /// through . Retries on LLM failure, malformed JSON, or a failed validation Result. + /// + protected async Task> CompleteJsonAsync( + string systemPrompt, string userMessage, + int maxTokens, double temperature, + Func> validateAndMap, + string failureMessage, + CancellationToken ct) where TResponse : class + { + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature); + + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + var completion = await ChatService.CompleteAsync(request, ct); + if (completion.IsFailed) continue; + + var parsed = TryDeserialize(completion.Value.Content); + if (parsed is null) continue; + + var mapped = validateAndMap(parsed); + if (mapped.IsSuccess) return mapped; + } + + return Result.Fail(failureMessage); + } + + protected async Task> CompleteTextAsync( + string systemPrompt, string userMessage, + int maxTokens, double temperature, + string failureMessage, + CancellationToken ct) + { + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature); + var result = await ChatService.CompleteAsync(request, ct); + return result.IsSuccess + ? Result.Ok(result.Value.Content) + : Result.Fail(failureMessage); + } + + private TResponse? TryDeserialize(string json) where TResponse : class + { + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "{Agent} failed to parse LLM response.", GetType().Name); + return null; + } + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs index b1dba0030..07739da93 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; @@ -7,27 +7,19 @@ namespace Tutor.Elaborations.Infrastructure.Agents.Clarification; -public class ClarificationAgent : IClarificationAgent +public class ClarificationAgent : StreamingAgent, IClarificationAgent { - private readonly IAiChatService _chatService; + public ClarificationAgent(IAiChatService chatService) : base(chatService) { } - public ClarificationAgent(IAiChatService chatService) - { - _chatService = chatService; - } - - public async IAsyncEnumerable StreamAsync( + public IAsyncEnumerable StreamAsync( ConversationAttempt attempt, ConceptElaborationTask task, ProbeDirective? lastProbe, - [EnumeratorCancellation] CancellationToken ct) + CancellationToken ct) { var history = attempt.Turns.ToList(); var learnerContent = history.LastOrDefault(t => t.Role == TurnRole.Learner)?.Content ?? string.Empty; var systemPrompt = ClarificationPromptBuilder.BuildSystemPrompt(task, lastProbe); var userMessage = ClarificationPromptBuilder.BuildUserMessage(history, learnerContent); - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 256, temperature: 0.5); - - await foreach (var token in _chatService.StreamAsync(request, ct)) - yield return token; + return StreamAsync(systemPrompt, userMessage, maxTokens: 256, temperature: 0.5, ct); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs index aac42d6eb..2a363d823 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs @@ -1,29 +1,19 @@ -using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; namespace Tutor.Elaborations.Infrastructure.Agents.Closing; -public class ClosingAgent : IClosingAgent +public class ClosingAgent : StreamingAgent, IClosingAgent { - private readonly IAiChatService _chatService; + public ClosingAgent(IAiChatService chatService) : base(chatService) { } - public ClosingAgent(IAiChatService chatService) - { - _chatService = chatService; - } - - public async IAsyncEnumerable StreamAsync( - ConceptElaborationTask task, ClosingReason reason, - [EnumeratorCancellation] CancellationToken ct) + public IAsyncEnumerable StreamAsync( + ConceptElaborationTask task, ClosingReason reason, CancellationToken ct) { var systemPrompt = ClosingPromptBuilder.BuildSystemPrompt(task, reason); var userMessage = ClosingPromptBuilder.BuildUserMessage(); - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 128, temperature: 0.5); - - await foreach (var token in _chatService.StreamAsync(request, ct)) - yield return token; + return StreamAsync(systemPrompt, userMessage, maxTokens: 128, temperature: 0.5, ct); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs index 94ce5535c..672331bf1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs @@ -1,30 +1,21 @@ -using System.Runtime.CompilerServices; +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.Orchestration; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; namespace Tutor.Elaborations.Infrastructure.Agents.Critique; -public class CritiqueAgent : ICritiqueAgent +public class CritiqueAgent : StreamingAgent, ICritiqueAgent { - private readonly IAiChatService _chatService; + public CritiqueAgent(IAiChatService chatService) : base(chatService) { } - public CritiqueAgent(IAiChatService chatService) - { - _chatService = chatService; - } - - public async IAsyncEnumerable StreamAsync( + public IAsyncEnumerable StreamAsync( TurnEvaluation evaluation, ConversationAttempt attempt, ConceptElaborationTask task, - [EnumeratorCancellation] CancellationToken ct) + CancellationToken ct) { var systemPrompt = CritiquePromptBuilder.BuildSystemPrompt(task, attempt, attempt.IsSoftCapReached()); var userMessage = CritiquePromptBuilder.BuildUserMessage(evaluation, attempt.Turns.ToList()); - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 512, temperature: 0.7); - - await foreach (var token in _chatService.StreamAsync(request, ct)) - yield return token; + return StreamAsync(systemPrompt, userMessage, maxTokens: 512, temperature: 0.7, ct); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentClassifierAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentClassifierAgent.cs index 9e000888a..4617c788b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentClassifierAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentClassifierAgent.cs @@ -1,60 +1,32 @@ -using System.Text.Json; 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.Orchestration; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; namespace Tutor.Elaborations.Infrastructure.Agents.IntentClassifier; -public class IntentClassifierAgent : IIntentClassifier +public class IntentClassifierAgent : StructuredAgent, IIntentClassifier { - private const int MaxAttempts = 2; - private readonly IAiChatService _chatService; - private readonly ILogger _logger; - public IntentClassifierAgent(IAiChatService chatService, ILogger logger) - { - _chatService = chatService; - _logger = logger; - } + : base(chatService, logger) { } - public async Task> ClassifyAsync( + public Task> ClassifyAsync( string content, List history, ConceptElaborationTask task, CancellationToken ct) { var systemPrompt = IntentPromptBuilder.BuildSystemPrompt(task); var userMessage = IntentPromptBuilder.BuildUserMessage(content, history); - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 64, temperature: 0.0); - - for (var attempt = 0; attempt < MaxAttempts; attempt++) - { - var result = await _chatService.CompleteAsync(request, ct); - if (result.IsFailed) continue; - var intent = TryParse(result.Value.Content); - if (intent.HasValue) return intent.Value; - } - - return Result.Fail("Intent classification failed."); - } - - private TurnIntent? TryParse(string json) - { - try - { - var parsed = JsonSerializer.Deserialize(json, - new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - if (parsed?.Intent is null) return null; - return Enum.TryParse(parsed.Intent, ignoreCase: true, out var intent) ? intent : null; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "IntentClassifierAgent failed to parse response."); - return null; - } + return CompleteJsonAsync( + systemPrompt, userMessage, maxTokens: 64, temperature: 0.0, + validateAndMap: r => Enum.TryParse(r.Intent, ignoreCase: true, out var intent) + ? Result.Ok(intent) + : Result.Fail("Unrecognized intent."), + failureMessage: "Intent classification failed.", + ct); } private class IntentResponse { public string? Intent { get; set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs index c03373bfd..18fb9ef67 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; @@ -6,24 +6,16 @@ namespace Tutor.Elaborations.Infrastructure.Agents.MetaHelp; -public class MetaHelpAgent : IMetaHelpAgent +public class MetaHelpAgent : StreamingAgent, IMetaHelpAgent { - private readonly IAiChatService _chatService; + public MetaHelpAgent(IAiChatService chatService) : base(chatService) { } - public MetaHelpAgent(IAiChatService chatService) - { - _chatService = chatService; - } - - public async IAsyncEnumerable StreamAsync( + public IAsyncEnumerable StreamAsync( ConceptElaborationTask task, string progressLine, ProbeDirective? nextTarget, - [EnumeratorCancellation] CancellationToken ct) + CancellationToken ct) { var systemPrompt = MetaHelpPromptBuilder.BuildSystemPrompt(task, progressLine, nextTarget); var userMessage = MetaHelpPromptBuilder.BuildUserMessage(); - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 256, temperature: 0.5); - - await foreach (var token in _chatService.StreamAsync(request, ct)) - yield return token; + return StreamAsync(systemPrompt, userMessage, maxTokens: 256, temperature: 0.5, ct); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs index 096d5a9c1..85440554d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; @@ -7,24 +7,16 @@ namespace Tutor.Elaborations.Infrastructure.Agents.Probe; -public class ProbeAgent : IProbeAgent +public class ProbeAgent : StreamingAgent, IProbeAgent { - private readonly IAiChatService _chatService; + public ProbeAgent(IAiChatService chatService) : base(chatService) { } - public ProbeAgent(IAiChatService chatService) - { - _chatService = chatService; - } - - public async IAsyncEnumerable StreamAsync( + public IAsyncEnumerable StreamAsync( ProbeDirective directive, ConversationAttempt attempt, ConceptElaborationTask task, - [EnumeratorCancellation] CancellationToken ct) + CancellationToken ct) { var systemPrompt = ProbePromptBuilder.BuildSystemPrompt(task, directive, attempt.IsSoftCapReached()); var userMessage = ProbePromptBuilder.BuildUserMessage(attempt.Turns.ToList()); - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 256, temperature: 0.7); - - await foreach (var token in _chatService.StreamAsync(request, ct)) - yield return token; + return StreamAsync(systemPrompt, userMessage, maxTokens: 256, temperature: 0.7, ct); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs index 9cf0f1f85..d6027339e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs @@ -1,28 +1,18 @@ -using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; namespace Tutor.Elaborations.Infrastructure.Agents.Redirect; -public class RedirectAgent : IRedirectAgent +public class RedirectAgent : StreamingAgent, IRedirectAgent { - private readonly IAiChatService _chatService; + public RedirectAgent(IAiChatService chatService) : base(chatService) { } - public RedirectAgent(IAiChatService chatService) - { - _chatService = chatService; - } - - public async IAsyncEnumerable StreamAsync( - ConceptElaborationTask task, [EnumeratorCancellation] CancellationToken ct) + public IAsyncEnumerable StreamAsync(ConceptElaborationTask task, CancellationToken ct) { var systemPrompt = RedirectPromptBuilder.BuildSystemPrompt(task); var userMessage = RedirectPromptBuilder.BuildUserMessage(); - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 128, temperature: 0.7); - - await foreach (var token in _chatService.StreamAsync(request, ct)) - yield return token; + return StreamAsync(systemPrompt, userMessage, maxTokens: 128, temperature: 0.7, ct); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs index c4a19245a..4d768acc8 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; @@ -7,24 +7,16 @@ namespace Tutor.Elaborations.Infrastructure.Agents.Scaffolding; -public class ScaffoldingAgent : IScaffoldingAgent +public class ScaffoldingAgent : StreamingAgent, IScaffoldingAgent { - private readonly IAiChatService _chatService; + public ScaffoldingAgent(IAiChatService chatService) : base(chatService) { } - public ScaffoldingAgent(IAiChatService chatService) - { - _chatService = chatService; - } - - public async IAsyncEnumerable StreamAsync( + public IAsyncEnumerable StreamAsync( ProbeDirective target, ConversationAttempt attempt, ConceptElaborationTask task, - [EnumeratorCancellation] CancellationToken ct) + CancellationToken ct) { var systemPrompt = ScaffoldingPromptBuilder.BuildSystemPrompt(task, target); var userMessage = ScaffoldingPromptBuilder.BuildUserMessage(attempt); - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 512, temperature: 0.7); - - await foreach (var token in _chatService.StreamAsync(request, ct)) - yield return token; + return StreamAsync(systemPrompt, userMessage, maxTokens: 512, temperature: 0.7, ct); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerAgent.cs index 444dc8e60..11b56d74f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerAgent.cs @@ -1,81 +1,58 @@ -using System.Text.Json; 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.Orchestration; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; namespace Tutor.Elaborations.Infrastructure.Agents.Scorer; -public class ScorerAgent : IScorer +public class ScorerAgent : StructuredAgent, IScorer { - private const int MaxAttempts = 2; - private readonly IAiChatService _chatService; - private readonly ILogger _logger; - public ScorerAgent(IAiChatService chatService, ILogger logger) - { - _chatService = chatService; - _logger = logger; - } + : base(chatService, logger) { } - public async Task> ScoreAsync( + public Task> ScoreAsync( string content, List history, ConceptElaborationTask task, CancellationToken ct) { var systemPrompt = ScorerPromptBuilder.BuildSystemPrompt(task); var userMessage = ScorerPromptBuilder.BuildUserMessage(content, history); - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens: 1024, temperature: 0.0); - for (var attempt = 0; attempt < MaxAttempts; attempt++) - { - var result = await _chatService.CompleteAsync(request, ct); - if (result.IsFailed) continue; - - var evaluation = TryParse(result.Value.Content, task); - if (evaluation != null) return evaluation; - } - - return Result.Fail("Scoring failed."); + return CompleteJsonAsync( + systemPrompt, userMessage, maxTokens: 1024, temperature: 0.0, + validateAndMap: r => MapToEvaluation(r, task), + failureMessage: "Scoring failed.", + ct); } - private TurnEvaluation? TryParse(string json, ConceptElaborationTask task) + private static Result MapToEvaluation(ScorerResponse parsed, ConceptElaborationTask task) { - try - { - var parsed = JsonSerializer.Deserialize(json, - new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - if (parsed == null) return null; - - if (parsed.CorrectnessScore is < 1 or > 3) return null; - if (parsed.CompletenessScore is < 1 or > 3) return null; - if (parsed.DiscriminationScore is not null and (< 1 or > 3)) return null; - if (parsed.IntegrationScore is not null and (< 1 or > 3)) return null; + if (parsed.CorrectnessScore is < 1 or > 3) return Result.Fail("Correctness out of range."); + if (parsed.CompletenessScore is < 1 or > 3) return Result.Fail("Completeness out of range."); + if (parsed.DiscriminationScore is not null and (< 1 or > 3)) return Result.Fail("Discrimination out of range."); + if (parsed.IntegrationScore is not null and (< 1 or > 3)) return Result.Fail("Integration out of range."); - var validKpIds = task.KeyPropositions.Select(kp => kp.Id).ToHashSet(); - var validKrIds = task.KeyRelations.Select(kr => kr.Id).ToHashSet(); - var validCmIds = task.CommonMisconceptions.Select(cm => cm.Id).ToHashSet(); + var validKpIds = task.KeyPropositions.Select(kp => kp.Id).ToHashSet(); + var validKrIds = task.KeyRelations.Select(kr => kr.Id).ToHashSet(); + var validCmIds = task.CommonMisconceptions.Select(cm => cm.Id).ToHashSet(); - if (parsed.PropositionsCoveredIds?.Any(id => !validKpIds.Contains(id)) == true) return null; - if (parsed.RelationsArticulatedIds?.Any(id => !validKrIds.Contains(id)) == true) return null; - if (parsed.MisconceptionsTriggeredIds?.Any(id => !validCmIds.Contains(id)) == true) return null; + if (parsed.PropositionsCoveredIds?.Any(id => !validKpIds.Contains(id)) == true) + return Result.Fail("Unknown proposition id."); + if (parsed.RelationsArticulatedIds?.Any(id => !validKrIds.Contains(id)) == true) + return Result.Fail("Unknown relation id."); + if (parsed.MisconceptionsTriggeredIds?.Any(id => !validCmIds.Contains(id)) == true) + return Result.Fail("Unknown misconception id."); - return new TurnEvaluation( - parsed.CorrectnessScore, parsed.CompletenessScore, - parsed.DiscriminationScore, parsed.IntegrationScore, - parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, - parsed.PropositionsCoveredIds ?? new List(), - parsed.MisconceptionsTriggeredIds ?? new List(), - parsed.RelationsArticulatedIds ?? new List(), - parsed.HasMultipleConcerns ?? false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "ScorerAgent failed to parse response."); - return null; - } + return new TurnEvaluation( + parsed.CorrectnessScore, parsed.CompletenessScore, + parsed.DiscriminationScore, parsed.IntegrationScore, + parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, + parsed.PropositionsCoveredIds ?? new List(), + parsed.MisconceptionsTriggeredIds ?? new List(), + parsed.RelationsArticulatedIds ?? new List(), + parsed.HasMultipleConcerns ?? false); } private class ScorerResponse diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs index 0bb134c4a..42225b14d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs @@ -1,32 +1,25 @@ 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.Orchestration; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; namespace Tutor.Elaborations.Infrastructure.Agents.Summary; -public class SummaryAgent : ISummaryAgent +public class SummaryAgent : StructuredAgent, ISummaryAgent { - private readonly IAiChatService _chatService; + public SummaryAgent(IAiChatService chatService, ILogger logger) + : base(chatService, logger) { } - public SummaryAgent(IAiChatService chatService) - { - _chatService = chatService; - } - - public async Task> SummarizeAsync(ConversationAttempt attempt, - ConceptElaborationTask task, CancellationToken ct) + public Task> SummarizeAsync( + ConversationAttempt attempt, ConceptElaborationTask task, CancellationToken ct) { var systemPrompt = SummaryPromptBuilder.BuildSystemPrompt(attempt, task); var transcript = SummaryPromptBuilder.BuildTranscript(attempt); - - var request = CompletionRequest.SingleMessage(transcript, systemPrompt, maxTokens: 256, temperature: 0.5); - - var result = await _chatService.CompleteAsync(request, ct); - return result.IsSuccess - ? Result.Ok(result.Value.Content) - : Result.Fail("Summary generation failed."); + return CompleteTextAsync( + systemPrompt, transcript, maxTokens: 256, temperature: 0.5, + failureMessage: "Summary generation failed.", ct); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs index c6fe0e3fb..c7155c65e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs @@ -2,7 +2,6 @@ using Shouldly; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; -using Xunit; namespace Tutor.Elaborations.Tests.Unit; From 6db6d063492f4180eae2e0bf00850c2d5a278a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Wed, 22 Apr 2026 11:26:46 +0300 Subject: [PATCH 21/51] fix: Improves streaming agent reliability and issue with saving broken conversations. --- .../Agents/StreamOutput.cs | 10 ++++ .../Agents/StreamingAgent.cs | 52 +++++++++++++++++-- .../Agents/StructuredAgent.cs | 13 ----- .../Tutor.Elaborations.Core.csproj | 1 + .../UseCases/Learning/ConversationService.cs | 5 +- .../Orchestration/AgentOrchestratorService.cs | 23 ++++++-- .../Agents/IClarificationAgent.cs | 3 +- .../Orchestration/Agents/IClosingAgent.cs | 3 +- .../Orchestration/Agents/ICritiqueAgent.cs | 3 +- .../Orchestration/Agents/IMetaHelpAgent.cs | 3 +- .../Orchestration/Agents/IProbeAgent.cs | 3 +- .../Orchestration/Agents/IRedirectAgent.cs | 3 +- .../Orchestration/Agents/IScaffoldingAgent.cs | 3 +- .../Orchestration/OrchestratorChunk.cs | 2 - .../Clarification/ClarificationAgent.cs | 2 +- .../Agents/Closing/ClosingAgent.cs | 2 +- .../Agents/Critique/CritiqueAgent.cs | 2 +- .../Agents/MetaHelp/MetaHelpAgent.cs | 2 +- .../Agents/Probe/ProbeAgent.cs | 2 +- .../Agents/Redirect/RedirectAgent.cs | 2 +- .../Agents/Scaffolding/ScaffoldingAgent.cs | 2 +- .../Agents/Summary/SummaryAgent.cs | 29 ++++++++--- .../ElaborationsTestFactory.cs | 11 ++-- .../Learning/ConversationTurnTests.cs | 2 +- 24 files changed, 126 insertions(+), 57 deletions(-) create mode 100644 src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamOutput.cs 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/Agents/StreamingAgent.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs index e9f6814d5..c34f7e9e3 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs @@ -6,6 +6,8 @@ namespace Tutor.BuildingBlocks.AI.Core.Agents; /// /// Base class for agents that stream a single-message LLM completion token by token. /// Derived agents build their own system and user prompts and delegate the network plumbing here. +/// Yields per content chunk and a terminal +/// on provider exception or empty response. /// public abstract class StreamingAgent { @@ -16,13 +18,57 @@ protected StreamingAgent(IAiChatService chatService) ChatService = chatService; } - protected async IAsyncEnumerable StreamAsync( + protected async IAsyncEnumerable StreamAsync( string systemPrompt, string userMessage, int maxTokens, double temperature, [EnumeratorCancellation] CancellationToken ct) { var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature); - await foreach (var token in ChatService.StreamAsync(request, ct)) - yield return token; + var tokenCount = 0; + + 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) + { + throw; + } + catch (Exception ex) + { + failure = $"Streaming call failed: {ex.Message}"; + } + + if (failure != null) + { + yield return new StreamFailure(failure); + yield break; + } + if (!moved) break; + + if (!string.IsNullOrEmpty(token)) + { + tokenCount++; + yield return new StreamToken(token); + } + } + } + finally + { + await enumerator.DisposeAsync(); + } + + if (tokenCount == 0) + yield return new StreamFailure("Empty response from LLM."); } } diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs index b85684a65..ed91730a1 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs @@ -55,19 +55,6 @@ protected async Task> CompleteJsonAsync( return Result.Fail(failureMessage); } - protected async Task> CompleteTextAsync( - string systemPrompt, string userMessage, - int maxTokens, double temperature, - string failureMessage, - CancellationToken ct) - { - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature); - var result = await ChatService.CompleteAsync(request, ct); - return result.IsSuccess - ? Result.Ok(result.Value.Content) - : Result.Fail(failureMessage); - } - private TResponse? TryDeserialize(string json) where TResponse : class { try diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Tutor.Elaborations.Core.csproj b/src/Modules/Elaborations/Tutor.Elaborations.Core/Tutor.Elaborations.Core.csproj index 10b7f6cf1..7662f8fa1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Tutor.Elaborations.Core.csproj +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Tutor.Elaborations.Core.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index 0e0fa4189..27c2e2dae 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -111,6 +111,7 @@ public async IAsyncEnumerable StartConversationAsync(int taskId, string var attempt = new ConversationAttempt(taskId, learnerId); _attemptRepo.Create(attempt); + _unitOfWork.Save(); await foreach (var token in RunTurnPipelineAsync(attempt, task, content, ct)) yield return token; @@ -173,10 +174,6 @@ private async IAsyncEnumerable RunTurnPipelineAsync( yield return token.Token; break; - case CheckpointChunk: - _unitOfWork.Save(); - break; - case ErrorChunk error: yield return BuildErrorChunk(error.Message, error.Code); yield break; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs index 26de42a4a..faad1c65e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Text; +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; @@ -67,15 +68,27 @@ public async IAsyncEnumerable ProcessTurnAsync( } attempt.AddLearnerTurn(learnerContent, intent, evaluation); - yield return new CheckpointChunk(); var route = DecideRoute(attempt, task, intent, evaluation); var fullResponse = new StringBuilder(); - await foreach (var token in Stream(route, attempt, task, ct)) + StreamFailure? streamFailure = null; + await foreach (var chunk in Stream(route, attempt, task, ct)) { - fullResponse.Append(token); - yield return new TokenChunk(token); + if (chunk is StreamFailure failure) + { + streamFailure = failure; + break; + } + var content = ((StreamToken)chunk).Content; + fullResponse.Append(content); + yield return new TokenChunk(content); + } + + if (streamFailure != null) + { + yield return new ErrorChunk(streamFailure.Reason, 500); + yield break; } attempt.AddSystemTurn(fullResponse.ToString(), route.ProbeDirective); @@ -98,7 +111,7 @@ public async IAsyncEnumerable ProcessTurnAsync( attempt.Id, attempt.Status, intent, summary, route.ProbeDirective); } - private IAsyncEnumerable Stream( + private IAsyncEnumerable Stream( RouteDecision route, ConversationAttempt attempt, ConceptElaborationTask task, CancellationToken ct) { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClarificationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClarificationAgent.cs index 3275553a8..95f409814 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClarificationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClarificationAgent.cs @@ -1,3 +1,4 @@ +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; @@ -5,7 +6,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; public interface IClarificationAgent { - IAsyncEnumerable StreamAsync( + IAsyncEnumerable StreamAsync( ConversationAttempt attempt, ConceptElaborationTask task, ProbeDirective? lastProbe, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClosingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClosingAgent.cs index d4bbc93fc..c9858ee7c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClosingAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClosingAgent.cs @@ -1,3 +1,4 @@ +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; @@ -6,6 +7,6 @@ public enum ClosingReason { AllCovered, HardCapReached } public interface IClosingAgent { - IAsyncEnumerable StreamAsync( + IAsyncEnumerable StreamAsync( ConceptElaborationTask task, ClosingReason reason, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ICritiqueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ICritiqueAgent.cs index 9e45e7ab2..bc50c49fe 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ICritiqueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ICritiqueAgent.cs @@ -1,3 +1,4 @@ +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; @@ -5,7 +6,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; public interface ICritiqueAgent { - IAsyncEnumerable StreamAsync( + IAsyncEnumerable StreamAsync( TurnEvaluation evaluation, ConversationAttempt attempt, ConceptElaborationTask task, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IMetaHelpAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IMetaHelpAgent.cs index 7b32bccbb..a57ded8ae 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IMetaHelpAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IMetaHelpAgent.cs @@ -1,10 +1,11 @@ +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; public interface IMetaHelpAgent { - IAsyncEnumerable StreamAsync( + IAsyncEnumerable StreamAsync( ConceptElaborationTask task, string progressLine, ProbeDirective? nextTarget, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IProbeAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IProbeAgent.cs index 8668d20fb..04f540db8 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IProbeAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IProbeAgent.cs @@ -1,3 +1,4 @@ +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; @@ -5,7 +6,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; public interface IProbeAgent { - IAsyncEnumerable StreamAsync( + IAsyncEnumerable StreamAsync( ProbeDirective directive, ConversationAttempt attempt, ConceptElaborationTask task, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IRedirectAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IRedirectAgent.cs index 4b2c6d472..be1e81c0d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IRedirectAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IRedirectAgent.cs @@ -1,8 +1,9 @@ +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; public interface IRedirectAgent { - IAsyncEnumerable StreamAsync(ConceptElaborationTask task, CancellationToken ct); + IAsyncEnumerable StreamAsync(ConceptElaborationTask task, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScaffoldingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScaffoldingAgent.cs index cb3cd5ca8..dfffa406a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScaffoldingAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScaffoldingAgent.cs @@ -1,3 +1,4 @@ +using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; @@ -5,7 +6,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; public interface IScaffoldingAgent { - IAsyncEnumerable StreamAsync( + IAsyncEnumerable StreamAsync( ProbeDirective target, ConversationAttempt attempt, ConceptElaborationTask task, 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 index f8493ed5a..28c8d5040 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs @@ -6,8 +6,6 @@ public abstract record OrchestratorChunk; public sealed record TokenChunk(string Token) : OrchestratorChunk; -public sealed record CheckpointChunk : OrchestratorChunk; - public sealed record FinalChunk( int AttemptId, AttemptStatus Status, diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs index 07739da93..ccbd520f8 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs @@ -11,7 +11,7 @@ public class ClarificationAgent : StreamingAgent, IClarificationAgent { public ClarificationAgent(IAiChatService chatService) : base(chatService) { } - public IAsyncEnumerable StreamAsync( + public IAsyncEnumerable StreamAsync( ConversationAttempt attempt, ConceptElaborationTask task, ProbeDirective? lastProbe, CancellationToken ct) { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs index 2a363d823..b0cf24f02 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs @@ -9,7 +9,7 @@ public class ClosingAgent : StreamingAgent, IClosingAgent { public ClosingAgent(IAiChatService chatService) : base(chatService) { } - public IAsyncEnumerable StreamAsync( + public IAsyncEnumerable StreamAsync( ConceptElaborationTask task, ClosingReason reason, CancellationToken ct) { var systemPrompt = ClosingPromptBuilder.BuildSystemPrompt(task, reason); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs index 672331bf1..eae16bfe0 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs @@ -10,7 +10,7 @@ public class CritiqueAgent : StreamingAgent, ICritiqueAgent { public CritiqueAgent(IAiChatService chatService) : base(chatService) { } - public IAsyncEnumerable StreamAsync( + public IAsyncEnumerable StreamAsync( TurnEvaluation evaluation, ConversationAttempt attempt, ConceptElaborationTask task, CancellationToken ct) { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs index 18fb9ef67..5909e7392 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs @@ -10,7 +10,7 @@ public class MetaHelpAgent : StreamingAgent, IMetaHelpAgent { public MetaHelpAgent(IAiChatService chatService) : base(chatService) { } - public IAsyncEnumerable StreamAsync( + public IAsyncEnumerable StreamAsync( ConceptElaborationTask task, string progressLine, ProbeDirective? nextTarget, CancellationToken ct) { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs index 85440554d..2c559941a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs @@ -11,7 +11,7 @@ public class ProbeAgent : StreamingAgent, IProbeAgent { public ProbeAgent(IAiChatService chatService) : base(chatService) { } - public IAsyncEnumerable StreamAsync( + public IAsyncEnumerable StreamAsync( ProbeDirective directive, ConversationAttempt attempt, ConceptElaborationTask task, CancellationToken ct) { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs index d6027339e..f5c9a0784 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs @@ -9,7 +9,7 @@ public class RedirectAgent : StreamingAgent, IRedirectAgent { public RedirectAgent(IAiChatService chatService) : base(chatService) { } - public IAsyncEnumerable StreamAsync(ConceptElaborationTask task, CancellationToken ct) + public IAsyncEnumerable StreamAsync(ConceptElaborationTask task, CancellationToken ct) { var systemPrompt = RedirectPromptBuilder.BuildSystemPrompt(task); var userMessage = RedirectPromptBuilder.BuildUserMessage(); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs index 4d768acc8..35d73ad27 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs @@ -11,7 +11,7 @@ public class ScaffoldingAgent : StreamingAgent, IScaffoldingAgent { public ScaffoldingAgent(IAiChatService chatService) : base(chatService) { } - public IAsyncEnumerable StreamAsync( + public IAsyncEnumerable StreamAsync( ProbeDirective target, ConversationAttempt attempt, ConceptElaborationTask task, CancellationToken ct) { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs index 42225b14d..6d2a8489d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs @@ -1,5 +1,5 @@ +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; @@ -8,18 +8,31 @@ namespace Tutor.Elaborations.Infrastructure.Agents.Summary; -public class SummaryAgent : StructuredAgent, ISummaryAgent +public class SummaryAgent : StreamingAgent, ISummaryAgent { - public SummaryAgent(IAiChatService chatService, ILogger logger) - : base(chatService, logger) { } + public SummaryAgent(IAiChatService chatService) : base(chatService) { } - public Task> SummarizeAsync( + public async Task> SummarizeAsync( ConversationAttempt attempt, ConceptElaborationTask task, CancellationToken ct) { var systemPrompt = SummaryPromptBuilder.BuildSystemPrompt(attempt, task); var transcript = SummaryPromptBuilder.BuildTranscript(attempt); - return CompleteTextAsync( - systemPrompt, transcript, maxTokens: 256, temperature: 0.5, - failureMessage: "Summary generation failed.", ct); + + var buffer = new StringBuilder(); + await foreach (var chunk in StreamAsync(systemPrompt, transcript, maxTokens: 256, temperature: 0.5, ct)) + { + switch (chunk) + { + case StreamToken token: + buffer.Append(token.Content); + break; + case StreamFailure failure: + return Result.Fail(failure.Reason); + } + } + + return buffer.Length > 0 + ? Result.Ok(buffer.ToString()) + : Result.Fail("Summary generation failed."); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs index c294ddb22..61261cbf6 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs @@ -118,13 +118,10 @@ public void SetupDialogueMock(params string[] tokens) public void SetupSummaryMock(string summary = "Test summary of the conversation.") { - MockChatService.Setup(x => x.CompleteAsync( - It.Is(r => r.MaxTokens == 256), It.IsAny())) - .ReturnsAsync(Result.Ok(new CompletionResponse - { - Content = summary, - Usage = new TokenUsage(80, 40) - })); + MockChatService.Setup(x => x.StreamAsync( + It.Is(r => r.MaxTokens == 256 && r.Temperature == 0.5), + It.IsAny())) + .Returns(MockStream([summary])); } private static async IAsyncEnumerable MockStream( diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs index 419ca4f0c..6e63f2c22 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -76,7 +76,7 @@ public async Task All_propositions_covered_completes() metadata.ShouldNotBeNull(); metadata.Status.ShouldBe("Completed"); metadata.Summary.ShouldNotBeNullOrEmpty(); - Factory.MockChatService.Verify(x => x.CompleteAsync( + Factory.MockChatService.Verify(x => x.StreamAsync( It.Is(r => r.MaxTokens == 256), It.IsAny()), Times.Once); } From a3c23c46a2398c4f082decd63640d7a2d2757133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Wed, 22 Apr 2026 15:17:20 +0300 Subject: [PATCH 22/51] feat: Adds logging of key metrics for LLM conversation observability. --- CLAUDE.md | 17 +-- .../Agents/StreamingAgent.cs | 9 +- .../Agents/StructuredAgent.cs | 25 +++-- .../AiServiceExtensions.cs | 6 +- .../LoggingAiChatServiceDecorator.cs | 100 ++++++++++++++++++ .../Orchestration/AgentOrchestratorService.cs | 12 ++- 6 files changed, 141 insertions(+), 28 deletions(-) create mode 100644 src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/LoggingAiChatServiceDecorator.cs diff --git a/CLAUDE.md b/CLAUDE.md index acac9e0d4..f111ea1cd 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) @@ -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 @@ -227,5 +216,5 @@ When creating a DTO and matching domain object in a Module.Core project, look fo # Coding Style - 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/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs index c34f7e9e3..58dabc7f8 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs @@ -18,12 +18,11 @@ protected StreamingAgent(IAiChatService chatService) ChatService = chatService; } - protected async IAsyncEnumerable StreamAsync( - string systemPrompt, string userMessage, - int maxTokens, double temperature, - [EnumeratorCancellation] CancellationToken ct) + protected async IAsyncEnumerable StreamAsync(string systemPrompt, string userMessage, + int maxTokens, double temperature, [EnumeratorCancellation] CancellationToken ct) { - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature); + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature) + with { Metadata = new Dictionary { ["AgentName"] = GetType().Name } }; var tokenCount = 0; var enumerator = ChatService.StreamAsync(request, ct).GetAsyncEnumerator(ct); diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs index ed91730a1..cd9203087 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs @@ -31,20 +31,26 @@ protected StructuredAgent(IAiChatService chatService, ILogger logger) /// Runs the request, deserializes the response as , and maps/validates it /// through . Retries on LLM failure, malformed JSON, or a failed validation Result. /// - protected async Task> CompleteJsonAsync( - string systemPrompt, string userMessage, - int maxTokens, double temperature, - Func> validateAndMap, - string failureMessage, - CancellationToken ct) where TResponse : class + protected async Task> CompleteJsonAsync(string systemPrompt, string userMessage, + int maxTokens, double temperature, Func> validateAndMap, + string failureMessage, CancellationToken ct) where TResponse : class { - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature); + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature) + with { Metadata = new Dictionary { ["AgentName"] = GetType().Name } }; for (var attempt = 0; attempt < MaxAttempts; attempt++) { var completion = await ChatService.CompleteAsync(request, ct); if (completion.IsFailed) continue; + if (ShouldSkipRetry(completion.Value.FinishReason)) + { + _logger.LogWarning( + "{Agent} skipping retry due to deterministic finish reason '{FinishReason}'.", + GetType().Name, completion.Value.FinishReason); + break; + } + var parsed = TryDeserialize(completion.Value.Content); if (parsed is null) continue; @@ -55,6 +61,11 @@ protected async Task> CompleteJsonAsync( return Result.Fail(failureMessage); } + 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) where TResponse : class { try diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/AiServiceExtensions.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/AiServiceExtensions.cs index ff1cafd2a..453c28baf 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/AiServiceExtensions.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/AiServiceExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.BuildingBlocks.AI.Core.Embeddings; @@ -30,7 +31,10 @@ public static IServiceCollection AddAIServices(this IServiceCollection services, var kernel = kernelBuilder.Build(); services.AddSingleton(kernel); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new LoggingAiChatServiceDecorator( + sp.GetRequiredService(), + sp.GetRequiredService>())); if (!string.IsNullOrWhiteSpace(configuration.EmbeddingModelId)) { diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/LoggingAiChatServiceDecorator.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/LoggingAiChatServiceDecorator.cs new file mode 100644 index 000000000..2bea5a994 --- /dev/null +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/LoggingAiChatServiceDecorator.cs @@ -0,0 +1,100 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using FluentResults; +using Microsoft.Extensions.Logging; +using Tutor.BuildingBlocks.AI.Core.Conversations; + +namespace Tutor.BuildingBlocks.AI.Infrastructure.Conversations; + +public class LoggingAiChatServiceDecorator : IAiChatService +{ + private readonly IAiChatService _inner; + private readonly ILogger _logger; + + public LoggingAiChatServiceDecorator(IAiChatService inner, ILogger logger) + { + _inner = inner; + _logger = logger; + } + + public async Task> CompleteAsync(CompletionRequest request, CancellationToken cancellationToken = default) + { + var agent = ReadAgentName(request); + var sw = Stopwatch.StartNew(); + + var result = await _inner.CompleteAsync(request, cancellationToken); + sw.Stop(); + + if (result.IsFailed) + { + _logger.LogWarning( + "LLM call failed. Agent={Agent} DurationMs={DurationMs} FailureReason={FailureReason}", + agent, sw.ElapsedMilliseconds, string.Join("; ", result.Errors.Select(e => e.Message))); + return result; + } + + var response = result.Value; + _logger.LogInformation( + "LLM call ok. Agent={Agent} DurationMs={DurationMs} PromptTokens={PromptTokens} " + + "CompletionTokens={CompletionTokens} ResponseChars={ResponseChars} FinishReason={FinishReason}", + agent, sw.ElapsedMilliseconds, response.Usage.PromptTokens, + response.Usage.CompletionTokens, response.Content.Length, response.FinishReason); + + return result; + } + + public async IAsyncEnumerable StreamAsync(CompletionRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var agent = ReadAgentName(request); + var sw = Stopwatch.StartNew(); + var charCount = 0; + + var enumerator = _inner.StreamAsync(request, cancellationToken).GetAsyncEnumerator(cancellationToken); + try + { + while (true) + { + bool moved; + try + { + moved = await enumerator.MoveNextAsync(); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + sw.Stop(); + _logger.LogWarning( + "LLM stream failed. Agent={Agent} DurationMs={DurationMs} ResponseChars={ResponseChars} " + + "ExceptionType={ExceptionType} FailureReason={FailureReason}", + agent, sw.ElapsedMilliseconds, charCount, ex.GetType().Name, ex.Message); + throw; + } + + if (!moved) break; + + var chunk = enumerator.Current; + charCount += chunk.Length; + yield return chunk; + } + } + finally + { + await enumerator.DisposeAsync(); + } + + sw.Stop(); + _logger.LogInformation( + "LLM stream ok. Agent={Agent} DurationMs={DurationMs} ResponseChars={ResponseChars}", + agent, sw.ElapsedMilliseconds, charCount); + } + + private static string ReadAgentName(CompletionRequest request) + { + if (request.Metadata != null && request.Metadata.TryGetValue("AgentName", out var name)) + return name?.ToString() ?? "unknown"; + return "unknown"; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs index faad1c65e..995ab4627 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Text; +using Microsoft.Extensions.Logging; using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; @@ -21,13 +22,15 @@ public class AgentOrchestratorService : IAgentOrchestratorService private readonly IScaffoldingAgent _scaffoldingAgent; private readonly IClosingAgent _closingAgent; private readonly ISummaryAgent _summaryAgent; + private readonly ILogger _logger; public AgentOrchestratorService( IIntentClassifier classifier, IScorer scorer, IProbeAgent probeAgent, ICritiqueAgent critiqueAgent, IClarificationAgent clarificationAgent, IRedirectAgent redirectAgent, IMetaHelpAgent metaHelpAgent, IScaffoldingAgent scaffoldingAgent, - IClosingAgent closingAgent, ISummaryAgent summaryAgent) + IClosingAgent closingAgent, ISummaryAgent summaryAgent, + ILogger logger) { _classifier = classifier; _scorer = scorer; @@ -39,12 +42,19 @@ public AgentOrchestratorService( _scaffoldingAgent = scaffoldingAgent; _closingAgent = closingAgent; _summaryAgent = summaryAgent; + _logger = logger; } public async IAsyncEnumerable ProcessTurnAsync( ConversationAttempt attempt, ConceptElaborationTask task, string learnerContent, [EnumeratorCancellation] CancellationToken ct) { + using var turnScope = _logger.BeginScope(new Dictionary + { + ["AttemptId"] = attempt.Id, + ["TurnOrd"] = attempt.Turns.Count + }); + var history = attempt.Turns.ToList(); var intentResult = await _classifier.ClassifyAsync(learnerContent, history, task, ct); From 5a23debcbe654b48babb3387767654ce7c90cf1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Wed, 22 Apr 2026 20:54:30 +0300 Subject: [PATCH 23/51] feat: Tracks actual token usage per agent during concept elaboration. --- .../Conversations/ITurnUsageTracker.cs | 12 ++++ .../AiServiceExtensions.cs | 5 +- .../SemanticKernelChatService.cs | 70 +++++++++++-------- .../Conversations/TurnUsageTracker.cs | 30 ++++++++ .../UseCases/Learning/ConversationService.cs | 7 +- .../Orchestration/AgentOrchestratorService.cs | 7 +- .../Orchestration/OrchestratorChunk.cs | 4 +- 7 files changed, 94 insertions(+), 41 deletions(-) create mode 100644 src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Conversations/ITurnUsageTracker.cs create mode 100644 src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/TurnUsageTracker.cs 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 453c28baf..b3735a678 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/AiServiceExtensions.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/AiServiceExtensions.cs @@ -31,8 +31,9 @@ public static IServiceCollection AddAIServices(this IServiceCollection services, var kernel = kernelBuilder.Build(); services.AddSingleton(kernel); - services.AddSingleton(); - services.AddSingleton(sp => new LoggingAiChatServiceDecorator( + services.AddScoped(); + services.AddScoped(); + services.AddScoped(sp => new LoggingAiChatServiceDecorator( sp.GetRequiredService(), sp.GetRequiredService>())); 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/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index 27c2e2dae..2cabf0493 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -163,14 +163,11 @@ private async IAsyncEnumerable RunTurnPipelineAsync( ConversationAttempt attempt, ConceptElaborationTask task, string content, [EnumeratorCancellation] CancellationToken ct) { - var completionLength = 0; - await foreach (var chunk in _orchestrator.ProcessTurnAsync(attempt, task, content, ct)) { switch (chunk) { case TokenChunk token: - completionLength += token.Token.Length; yield return token.Token; break; @@ -184,8 +181,8 @@ private async IAsyncEnumerable RunTurnPipelineAsync( { LearnerId = attempt.LearnerId, UnitId = task.UnitId, - PromptTokens = content.Length / 4, - CompletionTokens = completionLength / 4, + PromptTokens = final.Usage.PromptTokens, + CompletionTokens = final.Usage.CompletionTokens, FeatureType = "Elaboration", EntityId = task.Id, PromptSummary = "Concept conversation turn" diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs index 995ab4627..07797026a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs @@ -2,6 +2,7 @@ using System.Text; 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.Orchestration.Agents; @@ -22,6 +23,7 @@ public class AgentOrchestratorService : IAgentOrchestratorService private readonly IScaffoldingAgent _scaffoldingAgent; private readonly IClosingAgent _closingAgent; private readonly ISummaryAgent _summaryAgent; + private readonly ITurnUsageTracker _usageTracker; private readonly ILogger _logger; public AgentOrchestratorService( @@ -30,7 +32,7 @@ public AgentOrchestratorService( IClarificationAgent clarificationAgent, IRedirectAgent redirectAgent, IMetaHelpAgent metaHelpAgent, IScaffoldingAgent scaffoldingAgent, IClosingAgent closingAgent, ISummaryAgent summaryAgent, - ILogger logger) + ITurnUsageTracker usageTracker, ILogger logger) { _classifier = classifier; _scorer = scorer; @@ -42,6 +44,7 @@ public AgentOrchestratorService( _scaffoldingAgent = scaffoldingAgent; _closingAgent = closingAgent; _summaryAgent = summaryAgent; + _usageTracker = usageTracker; _logger = logger; } @@ -118,7 +121,7 @@ public async IAsyncEnumerable ProcessTurnAsync( } yield return new FinalChunk( - attempt.Id, attempt.Status, intent, summary, route.ProbeDirective); + attempt.Id, attempt.Status, intent, summary, route.ProbeDirective, _usageTracker.Total); } private IAsyncEnumerable Stream( 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 index 28c8d5040..d5ce76939 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs @@ -1,3 +1,4 @@ +using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; @@ -11,6 +12,7 @@ public sealed record FinalChunk( AttemptStatus Status, TurnIntent Intent, string? Summary, - ProbeDirective? ProbeDirective) : OrchestratorChunk; + ProbeDirective? ProbeDirective, + TokenUsage Usage) : OrchestratorChunk; public sealed record ErrorChunk(string Message, int Code) : OrchestratorChunk; From bda7b15c43cb1cafaf7c0eb35d0713ca94cb6483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Wed, 22 Apr 2026 21:09:17 +0300 Subject: [PATCH 24/51] refactor: Simplifies design to support logging of token usage. --- .../Agents/StreamingAgent.cs | 105 ++++++++++++------ .../Agents/StructuredAgent.cs | 78 ++++++++++--- .../Conversations/CompletionResponse.cs | 3 + .../AiServiceExtensions.cs | 6 +- .../LoggingAiChatServiceDecorator.cs | 100 ----------------- .../Clarification/ClarificationAgent.cs | 4 +- .../Agents/Closing/ClosingAgent.cs | 4 +- .../Agents/Critique/CritiqueAgent.cs | 4 +- .../Agents/MetaHelp/MetaHelpAgent.cs | 4 +- .../Agents/Probe/ProbeAgent.cs | 4 +- .../Agents/Redirect/RedirectAgent.cs | 4 +- .../Agents/Scaffolding/ScaffoldingAgent.cs | 4 +- .../Agents/Summary/SummaryAgent.cs | 4 +- 13 files changed, 157 insertions(+), 167 deletions(-) delete mode 100644 src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/LoggingAiChatServiceDecorator.cs diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs index 58dabc7f8..adb23e78e 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; using Tutor.BuildingBlocks.AI.Core.Conversations; namespace Tutor.BuildingBlocks.AI.Core.Agents; @@ -12,62 +14,93 @@ namespace Tutor.BuildingBlocks.AI.Core.Agents; public abstract class StreamingAgent { protected IAiChatService ChatService { get; } + private readonly ITurnUsageTracker _usageTracker; + private readonly ILogger _logger; - protected StreamingAgent(IAiChatService chatService) + protected StreamingAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) { ChatService = chatService; + _usageTracker = usageTracker; + _logger = logger; } protected async IAsyncEnumerable StreamAsync(string systemPrompt, string userMessage, int maxTokens, double temperature, [EnumeratorCancellation] CancellationToken ct) { - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature) - with { Metadata = new Dictionary { ["AgentName"] = GetType().Name } }; - var tokenCount = 0; + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature); + + var sw = Stopwatch.StartNew(); + var usageBefore = _usageTracker.Total; + var charCount = 0; + var status = "ok"; + string? failureCategory = null; - var enumerator = ChatService.StreamAsync(request, ct).GetAsyncEnumerator(ct); try { - while (true) + var enumerator = ChatService.StreamAsync(request, ct).GetAsyncEnumerator(ct); + try { - string? token = null; - string? failure = null; - var moved = false; - - try + while (true) { - moved = await enumerator.MoveNextAsync(); - if (moved) token = enumerator.Current; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - failure = $"Streaming call failed: {ex.Message}"; - } + string? token = null; + string? failure = null; + var moved = false; - if (failure != null) - { - yield return new StreamFailure(failure); - yield break; - } - if (!moved) break; + 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 (!string.IsNullOrEmpty(token)) - { - tokenCount++; - yield return new StreamToken(token); + 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 { - await enumerator.DisposeAsync(); + 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}", + GetType().Name, status, sw.ElapsedMilliseconds, + delta.PromptTokens, delta.CompletionTokens, charCount, 1, failureCategory); } - - if (tokenCount == 0) - yield return new StreamFailure("Empty response from LLM."); } } diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs index cd9203087..597f4769f 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.Json; using FluentResults; using Microsoft.Extensions.Logging; @@ -35,30 +36,71 @@ protected async Task> CompleteJsonAsync(stri int maxTokens, double temperature, Func> validateAndMap, string failureMessage, CancellationToken ct) where TResponse : class { - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature) - with { Metadata = new Dictionary { ["AgentName"] = GetType().Name } }; + var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature); - for (var attempt = 0; attempt < MaxAttempts; attempt++) - { - var completion = await ChatService.CompleteAsync(request, ct); - if (completion.IsFailed) continue; + var sw = Stopwatch.StartNew(); + var promptTokens = 0; + var completionTokens = 0; + var charCount = 0; + var attempts = 0; + var status = "failure"; + string? failureCategory = "transient"; - if (ShouldSkipRetry(completion.Value.FinishReason)) + try + { + for (var attempt = 0; attempt < MaxAttempts; attempt++) { - _logger.LogWarning( - "{Agent} skipping retry due to deterministic finish reason '{FinishReason}'.", - GetType().Name, completion.Value.FinishReason); - break; - } + attempts = attempt + 1; + var completion = await ChatService.CompleteAsync(request, ct); + if (completion.IsFailed) + { + failureCategory = "transient"; + continue; + } - var parsed = TryDeserialize(completion.Value.Content); - if (parsed is null) continue; + promptTokens += completion.Value.Usage.PromptTokens; + completionTokens += completion.Value.Usage.CompletionTokens; + charCount += completion.Value.Content.Length; - var mapped = validateAndMap(parsed); - if (mapped.IsSuccess) return mapped; - } + if (ShouldSkipRetry(completion.Value.FinishReason)) + { + _logger.LogWarning( + "{Agent} skipping retry due to deterministic finish reason '{FinishReason}'.", + GetType().Name, completion.Value.FinishReason); + failureCategory = "permanent"; + break; + } + + var parsed = TryDeserialize(completion.Value.Content); + if (parsed is null) + { + failureCategory = "parse"; + continue; + } - return Result.Fail(failureMessage); + var mapped = validateAndMap(parsed); + if (mapped.IsSuccess) + { + status = "ok"; + failureCategory = null; + return mapped; + } + failureCategory = "validation"; + } + + return Result.Fail(failureMessage); + } + 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}", + GetType().Name, status, sw.ElapsedMilliseconds, + promptTokens, completionTokens, charCount, attempts, failureCategory); + } } private static bool ShouldSkipRetry(string? finishReason) => 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.Infrastructure/AiServiceExtensions.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/AiServiceExtensions.cs index b3735a678..34831e344 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/AiServiceExtensions.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/AiServiceExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.BuildingBlocks.AI.Core.Embeddings; @@ -32,10 +31,7 @@ public static IServiceCollection AddAIServices(this IServiceCollection services, var kernel = kernelBuilder.Build(); services.AddSingleton(kernel); services.AddScoped(); - services.AddScoped(); - services.AddScoped(sp => new LoggingAiChatServiceDecorator( - sp.GetRequiredService(), - sp.GetRequiredService>())); + services.AddScoped(); if (!string.IsNullOrWhiteSpace(configuration.EmbeddingModelId)) { diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/LoggingAiChatServiceDecorator.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/LoggingAiChatServiceDecorator.cs deleted file mode 100644 index 2bea5a994..000000000 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/LoggingAiChatServiceDecorator.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; -using FluentResults; -using Microsoft.Extensions.Logging; -using Tutor.BuildingBlocks.AI.Core.Conversations; - -namespace Tutor.BuildingBlocks.AI.Infrastructure.Conversations; - -public class LoggingAiChatServiceDecorator : IAiChatService -{ - private readonly IAiChatService _inner; - private readonly ILogger _logger; - - public LoggingAiChatServiceDecorator(IAiChatService inner, ILogger logger) - { - _inner = inner; - _logger = logger; - } - - public async Task> CompleteAsync(CompletionRequest request, CancellationToken cancellationToken = default) - { - var agent = ReadAgentName(request); - var sw = Stopwatch.StartNew(); - - var result = await _inner.CompleteAsync(request, cancellationToken); - sw.Stop(); - - if (result.IsFailed) - { - _logger.LogWarning( - "LLM call failed. Agent={Agent} DurationMs={DurationMs} FailureReason={FailureReason}", - agent, sw.ElapsedMilliseconds, string.Join("; ", result.Errors.Select(e => e.Message))); - return result; - } - - var response = result.Value; - _logger.LogInformation( - "LLM call ok. Agent={Agent} DurationMs={DurationMs} PromptTokens={PromptTokens} " + - "CompletionTokens={CompletionTokens} ResponseChars={ResponseChars} FinishReason={FinishReason}", - agent, sw.ElapsedMilliseconds, response.Usage.PromptTokens, - response.Usage.CompletionTokens, response.Content.Length, response.FinishReason); - - return result; - } - - public async IAsyncEnumerable StreamAsync(CompletionRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var agent = ReadAgentName(request); - var sw = Stopwatch.StartNew(); - var charCount = 0; - - var enumerator = _inner.StreamAsync(request, cancellationToken).GetAsyncEnumerator(cancellationToken); - try - { - while (true) - { - bool moved; - try - { - moved = await enumerator.MoveNextAsync(); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - sw.Stop(); - _logger.LogWarning( - "LLM stream failed. Agent={Agent} DurationMs={DurationMs} ResponseChars={ResponseChars} " + - "ExceptionType={ExceptionType} FailureReason={FailureReason}", - agent, sw.ElapsedMilliseconds, charCount, ex.GetType().Name, ex.Message); - throw; - } - - if (!moved) break; - - var chunk = enumerator.Current; - charCount += chunk.Length; - yield return chunk; - } - } - finally - { - await enumerator.DisposeAsync(); - } - - sw.Stop(); - _logger.LogInformation( - "LLM stream ok. Agent={Agent} DurationMs={DurationMs} ResponseChars={ResponseChars}", - agent, sw.ElapsedMilliseconds, charCount); - } - - private static string ReadAgentName(CompletionRequest request) - { - if (request.Metadata != null && request.Metadata.TryGetValue("AgentName", out var name)) - return name?.ToString() ?? "unknown"; - return "unknown"; - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs index ccbd520f8..4bf596ac2 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; @@ -9,7 +10,8 @@ namespace Tutor.Elaborations.Infrastructure.Agents.Clarification; public class ClarificationAgent : StreamingAgent, IClarificationAgent { - public ClarificationAgent(IAiChatService chatService) : base(chatService) { } + public ClarificationAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) + : base(chatService, usageTracker, logger) { } public IAsyncEnumerable StreamAsync( ConversationAttempt attempt, ConceptElaborationTask task, ProbeDirective? lastProbe, diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs index b0cf24f02..58a1d8ed1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; @@ -7,7 +8,8 @@ namespace Tutor.Elaborations.Infrastructure.Agents.Closing; public class ClosingAgent : StreamingAgent, IClosingAgent { - public ClosingAgent(IAiChatService chatService) : base(chatService) { } + public ClosingAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) + : base(chatService, usageTracker, logger) { } public IAsyncEnumerable StreamAsync( ConceptElaborationTask task, ClosingReason reason, CancellationToken ct) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs index eae16bfe0..07882d867 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; @@ -8,7 +9,8 @@ namespace Tutor.Elaborations.Infrastructure.Agents.Critique; public class CritiqueAgent : StreamingAgent, ICritiqueAgent { - public CritiqueAgent(IAiChatService chatService) : base(chatService) { } + public CritiqueAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) + : base(chatService, usageTracker, logger) { } public IAsyncEnumerable StreamAsync( TurnEvaluation evaluation, ConversationAttempt attempt, ConceptElaborationTask task, diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs index 5909e7392..75d4e2456 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; @@ -8,7 +9,8 @@ namespace Tutor.Elaborations.Infrastructure.Agents.MetaHelp; public class MetaHelpAgent : StreamingAgent, IMetaHelpAgent { - public MetaHelpAgent(IAiChatService chatService) : base(chatService) { } + public MetaHelpAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) + : base(chatService, usageTracker, logger) { } public IAsyncEnumerable StreamAsync( ConceptElaborationTask task, string progressLine, ProbeDirective? nextTarget, diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs index 2c559941a..c93a68601 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; @@ -9,7 +10,8 @@ namespace Tutor.Elaborations.Infrastructure.Agents.Probe; public class ProbeAgent : StreamingAgent, IProbeAgent { - public ProbeAgent(IAiChatService chatService) : base(chatService) { } + public ProbeAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) + : base(chatService, usageTracker, logger) { } public IAsyncEnumerable StreamAsync( ProbeDirective directive, ConversationAttempt attempt, ConceptElaborationTask task, diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs index f5c9a0784..09029e9ca 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; @@ -7,7 +8,8 @@ namespace Tutor.Elaborations.Infrastructure.Agents.Redirect; public class RedirectAgent : StreamingAgent, IRedirectAgent { - public RedirectAgent(IAiChatService chatService) : base(chatService) { } + public RedirectAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) + : base(chatService, usageTracker, logger) { } public IAsyncEnumerable StreamAsync(ConceptElaborationTask task, CancellationToken ct) { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs index 35d73ad27..163413f88 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; @@ -9,7 +10,8 @@ namespace Tutor.Elaborations.Infrastructure.Agents.Scaffolding; public class ScaffoldingAgent : StreamingAgent, IScaffoldingAgent { - public ScaffoldingAgent(IAiChatService chatService) : base(chatService) { } + public ScaffoldingAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) + : base(chatService, usageTracker, logger) { } public IAsyncEnumerable StreamAsync( ProbeDirective target, ConversationAttempt attempt, ConceptElaborationTask task, diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs index 6d2a8489d..e2b1cd0be 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs @@ -1,5 +1,6 @@ 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; @@ -10,7 +11,8 @@ namespace Tutor.Elaborations.Infrastructure.Agents.Summary; public class SummaryAgent : StreamingAgent, ISummaryAgent { - public SummaryAgent(IAiChatService chatService) : base(chatService) { } + public SummaryAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) + : base(chatService, usageTracker, logger) { } public async Task> SummarizeAsync( ConversationAttempt attempt, ConceptElaborationTask task, CancellationToken ct) From c3a3103ec8f89d6667c3479afaf1cffe0c35f95c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Fri, 24 Apr 2026 07:56:23 +0300 Subject: [PATCH 25/51] refactor: Major. Compressess ConceptRecord into a set of entities and VOs. Simplifies Agent structure, but will need additional work. --- CLAUDE.md | 62 +++- .../Agents/StreamingAgent.cs | 16 +- .../Agents/StructuredAgent.cs | 17 +- .../BoundaryConditionDto.cs | 2 +- .../CommonMisconceptionDto.cs | 2 +- .../ConceptElaborationTaskDto.cs | 6 +- .../ConceptRecordDto.cs | 10 + .../KeyPropositionDto.cs | 2 +- .../ConceptElaborationTasks/KeyRelationDto.cs | 8 +- .../BoundaryCondition.cs | 9 - .../CommonMisconception.cs | 10 - .../ConceptElaborationTask.cs | 52 +--- .../IConceptElaborationTaskRepository.cs | 2 + .../ConceptElaborationTasks/KeyProposition.cs | 9 - .../ConceptElaborationTasks/KeyRelation.cs | 13 - .../ConceptRecords/BoundaryCondition.cs | 25 ++ .../ConceptRecords/CommonMisconception.cs | 29 ++ .../Domain/ConceptRecords/ConceptRecord.cs | 68 +++++ .../Domain/ConceptRecords/KeyProposition.cs | 25 ++ .../Domain/ConceptRecords/KeyRelation.cs | 33 +++ .../Conversations/ConversationAttempt.cs | 8 +- .../Domain/Conversations/ConversationTurn.cs | 4 +- .../Domain/Conversations/TurnEvaluation.cs | 16 +- .../Mappers/ConceptElaborationTaskProfile.cs | 31 +- .../ConceptElaborationTaskService.cs | 58 ++-- .../UseCases/Learning/ConversationService.cs | 16 +- .../Orchestration/AgentOrchestratorService.cs | 268 +++++++++++++----- .../Orchestration/Agents/IAgentJson.cs | 15 + .../Orchestration/Agents/IAgentStream.cs | 13 + .../Agents/IClarificationAgent.cs | 12 - .../Orchestration/Agents/IClosingAgent.cs | 12 - .../Orchestration/Agents/ICritiqueAgent.cs | 12 - .../Orchestration/Agents/IIntentClassifier.cs | 12 - .../Orchestration/Agents/IMetaHelpAgent.cs | 11 - .../Orchestration/Agents/IProbeAgent.cs | 12 - .../Orchestration/Agents/IRedirectAgent.cs | 9 - .../Orchestration/Agents/IScaffoldingAgent.cs | 12 - .../Learning/Orchestration/Agents/IScorer.cs | 12 - .../Orchestration/Agents/ISummaryAgent.cs | 11 - .../Learning/Orchestration/ClosingReason.cs | 3 + .../IAgentOrchestratorService.cs | 3 +- .../Learning/Orchestration/ProbeDirective.cs | 2 +- .../UseCases/Learning/Prompts/AgentConfig.cs | 9 + .../UseCases/Learning/Prompts/AgentConfigs.cs | 20 ++ .../UseCases/Learning/Prompts/AgentKind.cs | 15 + .../Learning/Prompts/AgentTurnContext.cs | 17 ++ .../Prompts/Agents/ClarificationPrompt.cs | 33 +++ .../Learning/Prompts/Agents/ClosingPrompt.cs | 34 +++ .../Learning/Prompts/Agents/CritiquePrompt.cs | 34 +++ .../Learning/Prompts/Agents/IntentPrompt.cs} | 52 ++-- .../Learning/Prompts/Agents/IntentResponse.cs | 6 + .../Learning/Prompts/Agents/MetaHelpPrompt.cs | 33 +++ .../Learning/Prompts/Agents/ProbePrompt.cs | 39 +++ .../Prompts/Agents/RedirectPrompt.cs} | 23 +- .../Prompts/Agents/ScaffoldingPrompt.cs | 37 +++ .../Learning/Prompts/Agents/ScorerPrompt.cs | 74 +++++ .../Learning/Prompts/Agents/ScorerResponse.cs | 15 + .../Learning/Prompts/Agents/SummaryPrompt.cs | 32 +++ .../Prompts/ConceptRecordRubricSection.cs | 59 ++++ .../Prompts/ConversationHistoryMapper.cs | 20 ++ .../Learning/Prompts/RuntimeContextBlock.cs | 56 ++++ .../Learning/Prompts/TargetDirective.cs | 13 + .../Agents/AgentJson.cs | 33 +++ .../Agents/AgentStream.cs | 30 ++ .../Clarification/ClarificationAgent.cs | 27 -- .../ClarificationPromptBuilder.cs | 68 ----- .../Agents/Closing/ClosingAgent.cs | 21 -- .../Agents/Closing/ClosingPromptBuilder.cs | 37 --- .../Agents/Critique/CritiqueAgent.cs | 23 -- .../Agents/Critique/CritiquePromptBuilder.cs | 111 -------- .../IntentClassifier/IntentClassifierAgent.cs | 33 --- .../Agents/MetaHelp/MetaHelpAgent.cs | 23 -- .../Agents/MetaHelp/MetaHelpPromptBuilder.cs | 56 ---- .../Agents/Probe/ProbeAgent.cs | 24 -- .../Agents/Probe/ProbePromptBuilder.cs | 83 ------ .../Agents/Redirect/RedirectAgent.cs | 20 -- .../Agents/Scaffolding/ScaffoldingAgent.cs | 24 -- .../Scaffolding/ScaffoldingPromptBuilder.cs | 66 ----- .../Agents/Scorer/ScorerAgent.cs | 71 ----- .../Agents/Scorer/ScorerPromptBuilder.cs | 128 --------- .../Agents/Summary/SummaryAgent.cs | 40 --- .../Agents/Summary/SummaryPromptBuilder.cs | 40 --- .../Database/ElaborationsContext.cs | 64 ++--- ...onceptElaborationTaskDatabaseRepository.cs | 20 +- .../ElaborationsStartup.cs | 25 +- .../ElaborationsTestFactory.cs | 24 +- .../ConceptElaborationTaskCommandTests.cs | 197 +++++++------ .../Learning/ConversationQueryTests.cs | 2 +- .../Learning/ConversationTurnTests.cs | 32 +-- .../TestData/a-delete.sql | 5 +- .../TestData/c-concept-elaboration-tasks.sql | 151 +++++----- .../TestData/e-conversation-attempts.sql | 46 +-- .../Unit/ConceptElaborationTaskTests.cs | 104 ------- .../Unit/ConceptRecordTests.cs | 84 ++++++ 94 files changed, 1599 insertions(+), 1686 deletions(-) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptRecordDto.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/BoundaryCondition.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/CommonMisconception.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyProposition.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyRelation.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentStream.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClarificationAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClosingAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ICritiqueAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IIntentClassifier.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IMetaHelpAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IProbeAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IRedirectAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScaffoldingAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScorer.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ISummaryAgent.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ClosingReason.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfig.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfigs.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentKind.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingPrompt.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs rename src/Modules/Elaborations/{Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentPromptBuilder.cs => Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs} (61%) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentResponse.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/MetaHelpPrompt.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs rename src/Modules/Elaborations/{Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectPromptBuilder.cs => Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/RedirectPrompt.cs} (57%) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerPrompt.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRecordRubricSection.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConversationHistoryMapper.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/TargetDirective.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentStream.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationPromptBuilder.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingPromptBuilder.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiquePromptBuilder.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentClassifierAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpPromptBuilder.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbePromptBuilder.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingPromptBuilder.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerPromptBuilder.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryPromptBuilder.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index f111ea1cd..8579e4bab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Projects organized as 6 domain modules, each with 4 layers, plus shared Building **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 @@ -214,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 3 parameters. - Do not write method headers and invocations where one row is one parameter. \ No newline at end of file diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs index adb23e78e..a849fedfe 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs @@ -6,10 +6,10 @@ namespace Tutor.BuildingBlocks.AI.Core.Agents; /// -/// Base class for agents that stream a single-message LLM completion token by token. -/// Derived agents build their own system and user prompts and delegate the network plumbing here. -/// Yields per content chunk and a terminal -/// on provider exception or empty response. +/// Base class for agents that stream an LLM completion token by token. +/// Derived agents build a (system prompt + native-role messages) +/// and delegate the network plumbing here. Yields per content chunk +/// and a terminal on provider exception or empty response. /// public abstract class StreamingAgent { @@ -24,11 +24,9 @@ protected StreamingAgent(IAiChatService chatService, ITurnUsageTracker usageTrac _logger = logger; } - protected async IAsyncEnumerable StreamAsync(string systemPrompt, string userMessage, - int maxTokens, double temperature, [EnumeratorCancellation] CancellationToken ct) + protected async IAsyncEnumerable StreamAsync( + CompletionRequest request, string agentLabel, [EnumeratorCancellation] CancellationToken ct) { - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature); - var sw = Stopwatch.StartNew(); var usageBefore = _usageTracker.Total; var charCount = 0; @@ -99,7 +97,7 @@ protected async IAsyncEnumerable StreamAsync(string systemPrompt, "Agent={Agent} Status={Status} DurationMs={DurationMs} PromptTokens={PromptTokens} " + "CompletionTokens={CompletionTokens} ResponseChars={ResponseChars} Attempts={Attempts} " + "FailureCategory={FailureCategory}", - GetType().Name, status, sw.ElapsedMilliseconds, + agentLabel, status, sw.ElapsedMilliseconds, delta.PromptTokens, delta.CompletionTokens, charCount, 1, failureCategory); } } diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs index 597f4769f..9b968c76c 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs @@ -32,12 +32,11 @@ protected StructuredAgent(IAiChatService chatService, ILogger logger) /// Runs the request, deserializes the response as , and maps/validates it /// through . Retries on LLM failure, malformed JSON, or a failed validation Result. /// - protected async Task> CompleteJsonAsync(string systemPrompt, string userMessage, - int maxTokens, double temperature, Func> validateAndMap, + protected async Task> CompleteJsonAsync( + CompletionRequest request, string agentLabel, + Func> validateAndMap, string failureMessage, CancellationToken ct) where TResponse : class { - var request = CompletionRequest.SingleMessage(userMessage, systemPrompt, maxTokens, temperature); - var sw = Stopwatch.StartNew(); var promptTokens = 0; var completionTokens = 0; @@ -66,12 +65,12 @@ protected async Task> CompleteJsonAsync(stri { _logger.LogWarning( "{Agent} skipping retry due to deterministic finish reason '{FinishReason}'.", - GetType().Name, completion.Value.FinishReason); + agentLabel, completion.Value.FinishReason); failureCategory = "permanent"; break; } - var parsed = TryDeserialize(completion.Value.Content); + var parsed = TryDeserialize(completion.Value.Content, agentLabel); if (parsed is null) { failureCategory = "parse"; @@ -98,7 +97,7 @@ protected async Task> CompleteJsonAsync(stri "Agent={Agent} Status={Status} DurationMs={DurationMs} PromptTokens={PromptTokens} " + "CompletionTokens={CompletionTokens} ResponseChars={ResponseChars} Attempts={Attempts} " + "FailureCategory={FailureCategory}", - GetType().Name, status, sw.ElapsedMilliseconds, + agentLabel, status, sw.ElapsedMilliseconds, promptTokens, completionTokens, charCount, attempts, failureCategory); } } @@ -108,7 +107,7 @@ private static bool ShouldSkipRetry(string? finishReason) => || string.Equals(finishReason, "max_tokens", StringComparison.OrdinalIgnoreCase) || string.Equals(finishReason, "content_filter", StringComparison.OrdinalIgnoreCase); - private TResponse? TryDeserialize(string json) where TResponse : class + private TResponse? TryDeserialize(string json, string agentLabel) where TResponse : class { try { @@ -116,7 +115,7 @@ private static bool ShouldSkipRetry(string? finishReason) => } catch (Exception ex) { - _logger.LogWarning(ex, "{Agent} failed to parse LLM response.", GetType().Name); + _logger.LogWarning(ex, "{Agent} failed to parse LLM response.", agentLabel); return null; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/BoundaryConditionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/BoundaryConditionDto.cs index 995d05260..48a42ccbe 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/BoundaryConditionDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/BoundaryConditionDto.cs @@ -2,6 +2,6 @@ namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; public class BoundaryConditionDto { - public int Id { get; set; } + 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/CommonMisconceptionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/CommonMisconceptionDto.cs index 874f4452f..e038daeba 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/CommonMisconceptionDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/CommonMisconceptionDto.cs @@ -2,7 +2,7 @@ namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; public class CommonMisconceptionDto { - public int Id { get; set; } + 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 index 3f46db65a..5d99ee7dd 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs @@ -8,10 +8,6 @@ public class ConceptElaborationTaskDto public int UnitId { get; set; } public int Order { get; set; } public string Title { get; set; } = string.Empty; - public string CanonicalDefinition { get; set; } = string.Empty; - public List KeyPropositions { get; set; } = new(); - public List BoundaryConditions { get; set; } = new(); - public List CommonMisconceptions { get; set; } = new(); - public List KeyRelations { get; set; } = new(); + public ConceptRecordDto ConceptRecord { get; set; } = new(); 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..4d3035c37 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptRecordDto.cs @@ -0,0 +1,10 @@ +namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; + +public class ConceptRecordDto +{ + public string CanonicalDefinition { get; set; } = string.Empty; + public List KeyPropositions { get; set; } = new(); + public List BoundaryConditions { 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 index 99070bb62..bb316f5e1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyPropositionDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyPropositionDto.cs @@ -2,6 +2,6 @@ namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; public class KeyPropositionDto { - public int Id { get; set; } + 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 index e6694eab9..d71e62265 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyRelationDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyRelationDto.cs @@ -2,10 +2,8 @@ namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; public class KeyRelationDto { - public int Id { get; set; } - public int SourceKeyPropositionId { get; set; } - public int TargetKeyPropositionId { get; set; } - public int? SourceKeyPropositionIndex { get; set; } - public int? TargetKeyPropositionIndex { get; set; } + 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.Core/Domain/ConceptElaborationTasks/BoundaryCondition.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/BoundaryCondition.cs deleted file mode 100644 index ee83b381a..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/BoundaryCondition.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Tutor.BuildingBlocks.Core.Domain; - -namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -public class BoundaryCondition : Entity -{ - public int ConceptElaborationTaskId { get; private set; } - public string Statement { get; private set; } = string.Empty; -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/CommonMisconception.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/CommonMisconception.cs deleted file mode 100644 index 2a49f23a4..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/CommonMisconception.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Tutor.BuildingBlocks.Core.Domain; - -namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -public class CommonMisconception : Entity -{ - public int ConceptElaborationTaskId { get; private set; } - public string Description { get; private set; } = string.Empty; - public string Correction { get; private set; } = string.Empty; -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs index 71f29dd5e..658e3fd86 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs @@ -1,5 +1,5 @@ using Tutor.BuildingBlocks.Core.Domain; -using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptRecords; namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; @@ -8,50 +8,22 @@ 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 CanonicalDefinition { get; private set; } = string.Empty; - public List KeyPropositions { get; private set; } = new(); - public List BoundaryConditions { get; private set; } = new(); - public List CommonMisconceptions { get; private set; } = new(); - public List KeyRelations { get; private set; } = new(); + public ConceptRecord ConceptRecord { get; private set; } = null!; - public void Update(ConceptElaborationTask incoming) - { - Title = incoming.Title; - CanonicalDefinition = incoming.CanonicalDefinition; - Order = incoming.Order; - KeyPropositions = incoming.KeyPropositions; - BoundaryConditions = incoming.BoundaryConditions; - CommonMisconceptions = incoming.CommonMisconceptions; - KeyRelations = incoming.KeyRelations; - } + private ConceptElaborationTask() { } - public bool AreAllPropositionsCovered(ConversationAttempt attempt) + public ConceptElaborationTask(int unitId, int order, string title, ConceptRecord conceptRecord) { - var coveredIds = attempt.GetCoveredPropositionIds(); - return KeyPropositions.All(kp => coveredIds.Contains(kp.Id)); + UnitId = unitId; + Order = order; + Title = title; + ConceptRecord = conceptRecord; } - public bool AreAllKeyRelationsArticulated(ConversationAttempt attempt) - { - if (KeyRelations.Count == 0) return true; - var articulatedIds = attempt.GetArticulatedRelationIds(); - return KeyRelations.All(kr => articulatedIds.Contains(kr.Id)); - } - - public bool IsAttemptComplete(ConversationAttempt attempt) - { - return AreAllPropositionsCovered(attempt) && AreAllKeyRelationsArticulated(attempt); - } - - public List GetUncoveredPropositionIds(ConversationAttempt attempt) - { - var coveredIds = attempt.GetCoveredPropositionIds(); - return KeyPropositions.Where(kp => !coveredIds.Contains(kp.Id)).Select(kp => kp.Id).ToList(); - } - - public List GetUnarticulatedRelationIds(ConversationAttempt attempt) + public void Update(ConceptElaborationTask incoming) { - var articulatedIds = attempt.GetArticulatedRelationIds(); - return KeyRelations.Where(kr => !articulatedIds.Contains(kr.Id)).Select(kr => kr.Id).ToList(); + Title = incoming.Title; + Order = incoming.Order; + ConceptRecord.Update(incoming.ConceptRecord); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/IConceptElaborationTaskRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/IConceptElaborationTaskRepository.cs index 32ff1ce66..468e6244b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/IConceptElaborationTaskRepository.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/IConceptElaborationTaskRepository.cs @@ -4,5 +4,7 @@ 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 deleted file mode 100644 index 4bd41b4a3..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyProposition.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Tutor.BuildingBlocks.Core.Domain; - -namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -public class KeyProposition : Entity -{ - public int ConceptElaborationTaskId { get; private set; } - public string Statement { get; private set; } = string.Empty; -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyRelation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyRelation.cs deleted file mode 100644 index 6d03b0498..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyRelation.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Tutor.BuildingBlocks.Core.Domain; - -namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -public class KeyRelation : Entity -{ - public int ConceptElaborationTaskId { get; private set; } - public int SourceKeyPropositionId { get; internal set; } - public int TargetKeyPropositionId { get; internal set; } - public KeyProposition? SourceKeyProposition { get; internal set; } - public KeyProposition? TargetKeyProposition { get; internal set; } - public string Mechanism { get; private set; } = string.Empty; -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs new file mode 100644 index 000000000..6499b90c4 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptRecords; + +public class BoundaryCondition : ValueObject +{ + [JsonPropertyName("key")] + public string Key { get; } + [JsonPropertyName("statement")] + public string Statement { get; } + + [JsonConstructor] + public BoundaryCondition(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/ConceptRecords/CommonMisconception.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs new file mode 100644 index 000000000..74a9190ea --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptRecords; + +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/ConceptRecords/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs new file mode 100644 index 000000000..48abfa5a5 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs @@ -0,0 +1,68 @@ +using Tutor.BuildingBlocks.Core.Domain; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.Domain.ConceptRecords; + +public class ConceptRecord : Entity +{ + public int ConceptElaborationTaskId { get; private set; } + public string CanonicalDefinition { get; private set; } = string.Empty; + public List KeyPropositions { get; private set; } = new(); + public List BoundaryConditions { get; private set; } = new(); + public List CommonMisconceptions { get; private set; } = new(); + public List KeyRelations { get; private set; } = new(); + + private ConceptRecord() { } + + public ConceptRecord( + int conceptElaborationTaskId, string canonicalDefinition, + List keyPropositions, List boundaryConditions, + List commonMisconceptions, List keyRelations) + { + ConceptElaborationTaskId = conceptElaborationTaskId; + CanonicalDefinition = canonicalDefinition; + KeyPropositions = keyPropositions; + BoundaryConditions = boundaryConditions; + CommonMisconceptions = commonMisconceptions; + KeyRelations = keyRelations; + } + + public void Update(ConceptRecord incoming) + { + CanonicalDefinition = incoming.CanonicalDefinition; + KeyPropositions = incoming.KeyPropositions; + BoundaryConditions = incoming.BoundaryConditions; + CommonMisconceptions = incoming.CommonMisconceptions; + KeyRelations = incoming.KeyRelations; + } + + public bool AreAllPropositionsCovered(ConversationAttempt attempt) + { + var covered = attempt.GetCoveredPropositionKeys(); + return KeyPropositions.All(kp => covered.Contains(kp.Key)); + } + + public bool AreAllKeyRelationsArticulated(ConversationAttempt attempt) + { + if (KeyRelations.Count == 0) return true; + var articulated = attempt.GetArticulatedRelationKeys(); + return KeyRelations.All(kr => articulated.Contains(kr.Key)); + } + + public bool IsAttemptComplete(ConversationAttempt attempt) + { + return AreAllPropositionsCovered(attempt) && AreAllKeyRelationsArticulated(attempt); + } + + public List GetUncoveredPropositionKeys(ConversationAttempt attempt) + { + var covered = attempt.GetCoveredPropositionKeys(); + return KeyPropositions.Where(kp => !covered.Contains(kp.Key)).Select(kp => kp.Key).ToList(); + } + + public List GetUnarticulatedRelationKeys(ConversationAttempt attempt) + { + var articulated = attempt.GetArticulatedRelationKeys(); + return KeyRelations.Where(kr => !articulated.Contains(kr.Key)).Select(kr => kr.Key).ToList(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs new file mode 100644 index 000000000..f0a7f4bf3 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptRecords; + +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/ConceptRecords/KeyRelation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs new file mode 100644 index 000000000..55cb30072 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptRecords; + +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/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index 0426bc1fc..3507e011d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -26,19 +26,19 @@ public ConversationAttempt(int conceptElaborationTaskId, int learnerId) StartedAt = DateTime.UtcNow; } - public ISet GetCoveredPropositionIds() + public ISet GetCoveredPropositionKeys() { return Turns .Where(t => t.Evaluation != null) - .SelectMany(t => t.Evaluation!.PropositionsCoveredIds) + .SelectMany(t => t.Evaluation!.PropositionsCoveredKeys) .ToHashSet(); } - public ISet GetArticulatedRelationIds() + public ISet GetArticulatedRelationKeys() { return Turns .Where(t => t.Evaluation != null) - .SelectMany(t => t.Evaluation!.RelationsArticulatedIds) + .SelectMany(t => t.Evaluation!.RelationsArticulatedKeys) .ToHashSet(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs index 47c507972..5e5986a0a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs @@ -13,7 +13,7 @@ public class ConversationTurn : Entity public TurnIntent? Intent { get; private set; } public TurnEvaluation? Evaluation { get; private set; } public ProbeTargetType? ProbeTargetType { get; private set; } - public int? ProbeTargetId { get; private set; } + public string? ProbeTargetKey { get; private set; } public int? ProbeLevel { get; private set; } private ConversationTurn() { } @@ -30,7 +30,7 @@ internal ConversationTurn( Intent = intent; Evaluation = evaluation; ProbeTargetType = probeDirective?.TargetType; - ProbeTargetId = probeDirective?.TargetId; + ProbeTargetKey = probeDirective?.TargetKey; ProbeLevel = probeDirective?.Level; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs index c4b1c671f..9d438197e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs @@ -11,9 +11,9 @@ public class TurnEvaluation : Entity public int? IntegrationScore { get; private set; } public string Justification { get; private set; } = string.Empty; public string? NovelMisconceptions { get; private set; } - public List PropositionsCoveredIds { get; private set; } = new(); - public List MisconceptionsTriggeredIds { get; private set; } = new(); - public List RelationsArticulatedIds { get; private set; } = new(); + public List PropositionsCoveredKeys { get; private set; } = new(); + public List MisconceptionsTriggeredKeys { get; private set; } = new(); + public List RelationsArticulatedKeys { get; private set; } = new(); public bool HasMultipleConcerns { get; private set; } private TurnEvaluation() { } @@ -22,8 +22,8 @@ public TurnEvaluation( int correctnessScore, int completenessScore, int? discriminationScore, int? integrationScore, string justification, string? novelMisconceptions, - List propositionsCoveredIds, List misconceptionsTriggeredIds, - List relationsArticulatedIds, bool hasMultipleConcerns) + List propositionsCoveredKeys, List misconceptionsTriggeredKeys, + List relationsArticulatedKeys, bool hasMultipleConcerns) { CorrectnessScore = correctnessScore; CompletenessScore = completenessScore; @@ -31,9 +31,9 @@ public TurnEvaluation( IntegrationScore = integrationScore; Justification = justification; NovelMisconceptions = novelMisconceptions; - PropositionsCoveredIds = propositionsCoveredIds; - MisconceptionsTriggeredIds = misconceptionsTriggeredIds; - RelationsArticulatedIds = relationsArticulatedIds; + PropositionsCoveredKeys = propositionsCoveredKeys; + MisconceptionsTriggeredKeys = misconceptionsTriggeredKeys; + RelationsArticulatedKeys = relationsArticulatedKeys; HasMultipleConcerns = hasMultipleConcerns; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs index eccd5606e..73d5746a4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs @@ -1,6 +1,7 @@ using AutoMapper; using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.ConceptRecords; namespace Tutor.Elaborations.Core.Mappers; @@ -9,32 +10,18 @@ public class ConceptElaborationTaskProfile : Profile public ConceptElaborationTaskProfile() { CreateMap() - .AfterMap((src, dest) => - { - for (var i = 0; i < src.KeyRelations.Count; i++) - { - var krDto = src.KeyRelations[i]; - if (!krDto.SourceKeyPropositionIndex.HasValue - || !krDto.TargetKeyPropositionIndex.HasValue) continue; - var kr = dest.KeyRelations[i]; - var source = dest.KeyPropositions[krDto.SourceKeyPropositionIndex.Value]; - var target = dest.KeyPropositions[krDto.TargetKeyPropositionIndex.Value]; - if (source.Id == 0) kr.SourceKeyProposition = source; - else kr.SourceKeyPropositionId = source.Id; - if (target.Id == 0) kr.TargetKeyProposition = target; - else kr.TargetKeyPropositionId = target.Id; - } - }) + .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(); - CreateMap() - .ForMember(d => d.SourceKeyProposition, opt => opt.Ignore()) - .ForMember(d => d.TargetKeyProposition, opt => opt.Ignore()) - .ReverseMap() - .ForMember(d => d.SourceKeyPropositionIndex, opt => opt.Ignore()) - .ForMember(d => d.TargetKeyPropositionIndex, opt => opt.Ignore()); + CreateMap().ReverseMap(); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs index 24a1302f1..30611325b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs @@ -8,53 +8,75 @@ namespace Tutor.Elaborations.Core.UseCases.Authoring; -public class ConceptElaborationTaskService : - CrudService, IConceptElaborationTaskService +public class ConceptElaborationTaskService : IConceptElaborationTaskService { private readonly IConceptElaborationTaskRepository _taskRepository; private readonly IAccessServices _accessServices; + private readonly IElaborationsUnitOfWork _unitOfWork; + private readonly IMapper _mapper; - public ConceptElaborationTaskService(IConceptElaborationTaskRepository repository, - IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, - IMapper mapper) : base(repository, unitOfWork, mapper) + public ConceptElaborationTaskService( + IConceptElaborationTaskRepository taskRepository, IAccessServices accessServices, + IElaborationsUnitOfWork unitOfWork, IMapper mapper) { - _taskRepository = repository; + _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.GetByUnit(unitId); - return MapToDto(tasks); + + var tasks = _taskRepository.GetByUnitWithRecords(unitId); + return Result.Ok(tasks.Select(t => _mapper.Map(t)).ToList()); } - public Result Create(ConceptElaborationTaskDto task, int instructorId) + public Result Create(ConceptElaborationTaskDto dto, int instructorId) { - if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) + if (!_accessServices.IsUnitOwner(dto.UnitId, instructorId)) return Result.Fail(FailureCode.Forbidden); - return Create(task); + + var task = _mapper.Map(dto); + task.UnitId = dto.UnitId; + var created = _taskRepository.Create(task); + + var saveResult = _unitOfWork.Save(); + if (saveResult.IsFailed) return saveResult; + + return Result.Ok(_mapper.Map(created)); } - public Result Update(ConceptElaborationTaskDto task, int instructorId) + public Result Update(ConceptElaborationTaskDto dto, int instructorId) { - if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) + if (!_accessServices.IsUnitOwner(dto.UnitId, instructorId)) return Result.Fail(FailureCode.Forbidden); - var existing = _taskRepository.Get(task.Id); - if (existing == null || existing.UnitId != task.UnitId) + + var existingTask = _taskRepository.GetWithRecord(dto.Id); + if (existingTask == null || existingTask.UnitId != dto.UnitId) return Result.Fail(FailureCode.NotFound); - existing.Update(MapToDomain(task)); - return Update(existing); + + existingTask.Update(_mapper.Map(dto)); + _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); - return Delete(id); + + _taskRepository.Delete(task); + return _unitOfWork.Save(); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index 2cabf0493..5c0ef547b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -62,7 +62,7 @@ public Result> GetTasksForUnit(int unitId, in public Result GetTaskWithAttempts(int taskId, int learnerId) { - var task = _taskRepo.Get(taskId); + var task = _taskRepo.GetWithRecord(taskId); if (task == null) return Result.Fail(FailureCode.NotFound); if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) @@ -77,7 +77,7 @@ public Result GetTaskWithAttempts(int taskId, int lea public async IAsyncEnumerable StartConversationAsync(int taskId, string content, int learnerId, [EnumeratorCancellation] CancellationToken ct) { - var task = _taskRepo.Get(taskId); + var task = _taskRepo.GetWithRecord(taskId); if (task == null) { yield return BuildErrorChunk("Task not found.", 404); yield break; } if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) @@ -109,6 +109,8 @@ public async IAsyncEnumerable StartConversationAsync(int taskId, string yield break; } + if (task.ConceptRecord == null) { yield return BuildErrorChunk("Concept record missing.", 500); yield break; } + var attempt = new ConversationAttempt(taskId, learnerId); _attemptRepo.Create(attempt); _unitOfWork.Save(); @@ -124,7 +126,7 @@ public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string cont 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 = _taskRepo.Get(attempt.ConceptElaborationTaskId); + var task = _taskRepo.GetWithRecord(attempt.ConceptElaborationTaskId); if (task == null) { yield return BuildErrorChunk("Task not found.", 404); yield break; } if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) @@ -141,6 +143,8 @@ public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string cont yield break; } + if (task.ConceptRecord == null) { yield return BuildErrorChunk("Concept record missing.", 500); yield break; } + await foreach (var token in RunTurnPipelineAsync(attempt, task, content, ct)) yield return token; } @@ -160,10 +164,10 @@ public Result AbandonAttempt(int attemptId, int learnerI } private async IAsyncEnumerable RunTurnPipelineAsync( - ConversationAttempt attempt, ConceptElaborationTask task, string content, - [EnumeratorCancellation] CancellationToken ct) + ConversationAttempt attempt, ConceptElaborationTask task, + string content, [EnumeratorCancellation] CancellationToken ct) { - await foreach (var chunk in _orchestrator.ProcessTurnAsync(attempt, task, content, ct)) + await foreach (var chunk in _orchestrator.ProcessTurnAsync(attempt, task, task.ConceptRecord, content, ct)) { switch (chunk) { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs index 07797026a..01d2b92b5 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs @@ -1,11 +1,15 @@ 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.ConceptRecords; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; +using Tutor.Elaborations.Core.UseCases.Learning.Prompts; +using Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; @@ -13,43 +17,23 @@ public class AgentOrchestratorService : IAgentOrchestratorService { private const int ScaffoldingLevel = 4; - private readonly IIntentClassifier _classifier; - private readonly IScorer _scorer; - private readonly IProbeAgent _probeAgent; - private readonly ICritiqueAgent _critiqueAgent; - private readonly IClarificationAgent _clarificationAgent; - private readonly IRedirectAgent _redirectAgent; - private readonly IMetaHelpAgent _metaHelpAgent; - private readonly IScaffoldingAgent _scaffoldingAgent; - private readonly IClosingAgent _closingAgent; - private readonly ISummaryAgent _summaryAgent; + private readonly IAgentStream _stream; + private readonly IAgentJson _json; private readonly ITurnUsageTracker _usageTracker; private readonly ILogger _logger; public AgentOrchestratorService( - IIntentClassifier classifier, IScorer scorer, - IProbeAgent probeAgent, ICritiqueAgent critiqueAgent, - IClarificationAgent clarificationAgent, IRedirectAgent redirectAgent, - IMetaHelpAgent metaHelpAgent, IScaffoldingAgent scaffoldingAgent, - IClosingAgent closingAgent, ISummaryAgent summaryAgent, + IAgentStream stream, IAgentJson json, ITurnUsageTracker usageTracker, ILogger logger) { - _classifier = classifier; - _scorer = scorer; - _probeAgent = probeAgent; - _critiqueAgent = critiqueAgent; - _clarificationAgent = clarificationAgent; - _redirectAgent = redirectAgent; - _metaHelpAgent = metaHelpAgent; - _scaffoldingAgent = scaffoldingAgent; - _closingAgent = closingAgent; - _summaryAgent = summaryAgent; + _stream = stream; + _json = json; _usageTracker = usageTracker; _logger = logger; } public async IAsyncEnumerable ProcessTurnAsync( - ConversationAttempt attempt, ConceptElaborationTask task, + ConversationAttempt attempt, ConceptElaborationTask task, ConceptRecord record, string learnerContent, [EnumeratorCancellation] CancellationToken ct) { using var turnScope = _logger.BeginScope(new Dictionary @@ -58,9 +42,9 @@ public async IAsyncEnumerable ProcessTurnAsync( ["TurnOrd"] = attempt.Turns.Count }); - var history = attempt.Turns.ToList(); + var historyBeforeTurn = (IReadOnlyList)attempt.Turns.ToList(); - var intentResult = await _classifier.ClassifyAsync(learnerContent, history, task, ct); + var intentResult = await ClassifyIntentAsync(historyBeforeTurn, record, task, learnerContent, ct); if (intentResult.IsFailed) { yield return new ErrorChunk("Intent classification failed.", 500); @@ -71,7 +55,7 @@ public async IAsyncEnumerable ProcessTurnAsync( TurnEvaluation? evaluation = null; if (intent == TurnIntent.Substantive) { - var scoreResult = await _scorer.ScoreAsync(learnerContent, history, task, ct); + var scoreResult = await ScoreTurnAsync(historyBeforeTurn, record, task, learnerContent, ct); if (scoreResult.IsFailed) { yield return new ErrorChunk("Scoring failed.", 500); @@ -82,11 +66,14 @@ public async IAsyncEnumerable ProcessTurnAsync( attempt.AddLearnerTurn(learnerContent, intent, evaluation); - var route = DecideRoute(attempt, task, intent, evaluation); + var route = DecideRoute(attempt, record, intent, evaluation); + var historyForStreaming = (IReadOnlyList)attempt.Turns.ToList(); + + var (streamKind, streamCtx) = BuildStreamContext(route, task, record, attempt.IsSoftCapReached()); var fullResponse = new StringBuilder(); StreamFailure? streamFailure = null; - await foreach (var chunk in Stream(route, attempt, task, ct)) + await foreach (var chunk in _stream.StreamAsync(streamKind, historyForStreaming, record, streamCtx, ct)) { if (chunk is StreamFailure failure) { @@ -107,45 +94,146 @@ public async IAsyncEnumerable ProcessTurnAsync( attempt.AddSystemTurn(fullResponse.ToString(), route.ProbeDirective); string? summary = null; - if (route.Closing == ClosingReason.AllCovered) - { - var summaryResult = await _summaryAgent.SummarizeAsync(attempt, task, ct); - summary = summaryResult.IsSuccess ? summaryResult.Value : null; - attempt.Complete(summary); - } - else if (route.Closing == ClosingReason.HardCapReached) + if (route.Closing is ClosingReason.AllCovered or ClosingReason.HardCapReached) { - var summaryResult = await _summaryAgent.SummarizeAsync(attempt, task, ct); - summary = summaryResult.IsSuccess ? summaryResult.Value : null; - attempt.Expire(summary); + summary = await SummarizeAsync(attempt.Turns, record, task, ct); + if (route.Closing == ClosingReason.AllCovered) attempt.Complete(summary); + else attempt.Expire(summary); } yield return new FinalChunk( attempt.Id, attempt.Status, intent, summary, route.ProbeDirective, _usageTracker.Total); } - private IAsyncEnumerable Stream( - RouteDecision route, ConversationAttempt attempt, + private Task> ClassifyIntentAsync( + IReadOnlyList history, ConceptRecord record, + ConceptElaborationTask task, string learnerContent, CancellationToken ct) + { + var ctx = new AgentTurnContext( + Instruction: "Classify the current learner message. Return JSON only.", + CurrentLearnerMessage: learnerContent, + ConceptTitle: task.Title); + + return _json.CompleteAsync( + AgentKind.IntentClassifier, history, record, ctx, + r => Enum.TryParse(r.Intent, ignoreCase: true, out var intent) + ? Result.Ok(intent) + : Result.Fail("Unrecognized intent."), + "Intent classification failed.", ct); + } + + private Task> ScoreTurnAsync( + IReadOnlyList history, ConceptRecord record, + ConceptElaborationTask task, string learnerContent, CancellationToken ct) + { + var ctx = new AgentTurnContext( + Instruction: "Score the current learner message against the rubric. Return JSON only.", + CurrentLearnerMessage: learnerContent, + ConceptTitle: task.Title); + + return _json.CompleteAsync( + AgentKind.Scorer, history, record, ctx, + r => MapToEvaluation(r, record), + "Scoring failed.", ct); + } + + private async Task SummarizeAsync( + IEnumerable turns, ConceptRecord record, ConceptElaborationTask task, CancellationToken ct) { - return route.Kind switch + var history = (IReadOnlyList)turns.ToList(); + var ctx = new AgentTurnContext( + Instruction: "Summarize what the learner demonstrated understanding of in 2-4 sentences in Serbian. Paraphrase only, no verbatim quotes of rubric items.", + ConceptTitle: task.Title); + + var buffer = new StringBuilder(); + await foreach (var chunk in _stream.StreamAsync(AgentKind.Summary, history, record, ctx, ct)) { - RouteKind.Probe => _probeAgent.StreamAsync(route.ProbeDirective!, attempt, task, ct), - RouteKind.Scaffold => _scaffoldingAgent.StreamAsync(route.ProbeDirective!, attempt, task, ct), - RouteKind.Critique => _critiqueAgent.StreamAsync(route.Evaluation!, attempt, task, ct), - RouteKind.Clarification => _clarificationAgent.StreamAsync(attempt, task, route.LastProbe, ct), - RouteKind.Redirect => _redirectAgent.StreamAsync(task, ct), - RouteKind.MetaHelp => _metaHelpAgent.StreamAsync(task, route.ProgressLine!, route.NextTarget, ct), - RouteKind.Closing => _closingAgent.StreamAsync(task, route.Closing!.Value, ct), + if (chunk is StreamFailure) return null; + buffer.Append(((StreamToken)chunk).Content); + } + + return buffer.Length > 0 ? buffer.ToString() : null; + } + + private static Result MapToEvaluation(ScorerResponse parsed, ConceptRecord record) + { + if (parsed.CorrectnessScore is < 1 or > 3) return Result.Fail("Correctness out of range."); + if (parsed.CompletenessScore is < 1 or > 3) return Result.Fail("Completeness out of range."); + if (parsed.DiscriminationScore is not null and (< 1 or > 3)) return Result.Fail("Discrimination out of range."); + if (parsed.IntegrationScore is not null and (< 1 or > 3)) return Result.Fail("Integration out of range."); + + var validKpKeys = record.KeyPropositions.Select(kp => kp.Key).ToHashSet(); + var validKrKeys = record.KeyRelations.Select(kr => kr.Key).ToHashSet(); + var validCmKeys = record.CommonMisconceptions.Select(cm => cm.Key).ToHashSet(); + + if (parsed.PropositionsCoveredKeys?.Any(k => !validKpKeys.Contains(k)) == true) + return Result.Fail("Unknown proposition key."); + if (parsed.RelationsArticulatedKeys?.Any(k => !validKrKeys.Contains(k)) == true) + return Result.Fail("Unknown relation key."); + if (parsed.MisconceptionsTriggeredKeys?.Any(k => !validCmKeys.Contains(k)) == true) + return Result.Fail("Unknown misconception key."); + + return new TurnEvaluation( + parsed.CorrectnessScore, parsed.CompletenessScore, + parsed.DiscriminationScore, parsed.IntegrationScore, + parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, + parsed.PropositionsCoveredKeys ?? new List(), + parsed.MisconceptionsTriggeredKeys ?? new List(), + parsed.RelationsArticulatedKeys ?? new List(), + parsed.HasMultipleConcerns ?? false); + } + + private static (AgentKind Kind, AgentTurnContext Ctx) BuildStreamContext( + RouteDecision route, ConceptElaborationTask task, ConceptRecord record, bool softCap) => + route.Kind switch + { + RouteKind.Probe => (AgentKind.Probe, new AgentTurnContext( + Instruction: "Produce one probe question for the target at the given level.", + Target: ResolveTarget(record, route.ProbeDirective), + SoftCapReached: softCap, + ConceptTitle: task.Title)), + + RouteKind.Scaffold => (AgentKind.Scaffolding, new AgentTurnContext( + Instruction: "Produce a scaffold (forced choice, code skeleton, or analogy) that helps the learner reach the target without revealing it.", + Target: ResolveTarget(record, route.ProbeDirective), + ConceptTitle: task.Title)), + + RouteKind.Critique => (AgentKind.Critique, new AgentTurnContext( + Instruction: "Produce a short bulleted critique of the latest learner turn based on the evaluation. Do not ask a new Socratic question.", + Evaluation: route.Evaluation, + SoftCapReached: softCap, + ConceptTitle: task.Title)), + + RouteKind.Clarification => (AgentKind.Clarification, new AgentTurnContext( + Instruction: "Rephrase the tutor's prior question in simpler terms. Do not answer it. Then invite the learner to resume.", + Target: ResolveTarget(record, route.LastProbe), + ConceptTitle: task.Title)), + + RouteKind.Redirect => (AgentKind.Redirect, new AgentTurnContext( + Instruction: "Redirect the learner back to the concept with a concrete small next step.", + ConceptTitle: task.Title)), + + RouteKind.MetaHelp => (AgentKind.MetaHelp, new AgentTurnContext( + Instruction: "Answer the learner's meta/procedural question: open with the progress line verbatim, then pivot to the remaining gap.", + ProgressLine: route.ProgressLine, + Target: ResolveTarget(record, route.NextTarget), + ConceptTitle: task.Title)), + + RouteKind.Closing => (AgentKind.Closing, new AgentTurnContext( + Instruction: route.Closing == ClosingReason.AllCovered + ? "reason=AllCovered. Acknowledge that the learner has covered the concept in 2 sentences max." + : "reason=HardCapReached. Acknowledge that the conversation is ending in 2 sentences max.", + ConceptTitle: task.Title)), + _ => throw new InvalidOperationException($"Unknown route kind: {route.Kind}") }; - } private RouteDecision DecideRoute( - ConversationAttempt attempt, ConceptElaborationTask task, + ConversationAttempt attempt, ConceptRecord record, TurnIntent intent, TurnEvaluation? evaluation) { - if (task.IsAttemptComplete(attempt)) + if (record.IsAttemptComplete(attempt)) return RouteDecision.Close(ClosingReason.AllCovered); if (attempt.IsHardCapReached()) @@ -161,17 +249,17 @@ private RouteDecision DecideRoute( case TurnIntent.MetaHelp: { - var progressLine = RenderProgressLine(attempt, task); - var next = PickNextTarget(attempt, task); + var progressLine = RenderProgressLine(attempt, record); + var next = PickNextTarget(attempt, record); return RouteDecision.Meta(progressLine, next); } case TurnIntent.Stuck: { - var next = PickNextTarget(attempt, task); + var next = PickNextTarget(attempt, record); if (next == null) return RouteDecision.Close(ClosingReason.AllCovered); var level = DeriveLevel(attempt, next); - var directive = new ProbeDirective(next.Value.Type, next.Value.Id, Math.Max(level, ScaffoldingLevel)); + var directive = new ProbeDirective(next.Value.Type, next.Value.Key, Math.Max(level, ScaffoldingLevel)); return RouteDecision.Scaffold(directive); } @@ -180,10 +268,10 @@ private RouteDecision DecideRoute( if (evaluation is { HasMultipleConcerns: true }) return RouteDecision.CritiqueFor(evaluation); - var next = PickNextTarget(attempt, task); + var next = PickNextTarget(attempt, record); if (next == null) return RouteDecision.Close(ClosingReason.AllCovered); var level = DeriveLevel(attempt, next); - var directive = new ProbeDirective(next.Value.Type, next.Value.Id, level); + var directive = new ProbeDirective(next.Value.Type, next.Value.Key, level); return level >= ScaffoldingLevel ? RouteDecision.Scaffold(directive) @@ -195,27 +283,33 @@ private RouteDecision DecideRoute( } } - private static (ProbeTargetType Type, int Id)? PickNextTarget( - ConversationAttempt attempt, ConceptElaborationTask task) + private static (ProbeTargetType Type, string Key)? PickNextTarget( + ConversationAttempt attempt, ConceptRecord record) { - var uncoveredKp = task.GetUncoveredPropositionIds(attempt); + var uncoveredKp = record.GetUncoveredPropositionKeys(attempt); if (uncoveredKp.Count > 0) - return (ProbeTargetType.KeyProposition, uncoveredKp.Min()); + return (ProbeTargetType.KeyProposition, uncoveredKp.OrderBy(KeyOrder).First()); - var unarticulatedKr = task.GetUnarticulatedRelationIds(attempt); + var unarticulatedKr = record.GetUnarticulatedRelationKeys(attempt); return unarticulatedKr.Count > 0 - ? (ProbeTargetType.KeyRelation, unarticulatedKr.Min()) + ? (ProbeTargetType.KeyRelation, unarticulatedKr.OrderBy(KeyOrder).First()) : null; } + private static int KeyOrder(string key) + { + // Natural keys are a single-letter prefix followed by digits (e.g., P1, R10, B3). + return int.TryParse(key.AsSpan(1), out var n) ? n : int.MaxValue; + } + private static int DeriveLevel( - ConversationAttempt attempt, (ProbeTargetType Type, int Id)? target) + ConversationAttempt attempt, (ProbeTargetType Type, string Key)? target) { if (target == null) return 1; var priorProbes = attempt.Turns.Count(t => t.Role == TurnRole.System && t.ProbeTargetType == target.Value.Type && - t.ProbeTargetId == target.Value.Id); + t.ProbeTargetKey == target.Value.Key); return priorProbes + 1; } @@ -226,21 +320,43 @@ private static int DeriveLevel( .OrderByDescending(t => t.Order) .FirstOrDefault(); if (last == null) return null; - return new ProbeDirective(last.ProbeTargetType!.Value, last.ProbeTargetId!.Value, last.ProbeLevel!.Value); + return new ProbeDirective(last.ProbeTargetType!.Value, last.ProbeTargetKey!, last.ProbeLevel!.Value); } - private static string RenderProgressLine(ConversationAttempt attempt, ConceptElaborationTask task) + private static string RenderProgressLine(ConversationAttempt attempt, ConceptRecord record) { - var coveredKp = attempt.GetCoveredPropositionIds().Count; - var totalKp = task.KeyPropositions.Count; - var articulatedKr = attempt.GetArticulatedRelationIds().Count; - var totalKr = task.KeyRelations.Count; + var coveredKp = attempt.GetCoveredPropositionKeys().Count; + var totalKp = record.KeyPropositions.Count; + var articulatedKr = attempt.GetArticulatedRelationKeys().Count; + var totalKr = record.KeyRelations.Count; return totalKr > 0 ? $"Dosadašnji napredak: pokriveno {coveredKp}/{totalKp} ključnih izjava i {articulatedKr}/{totalKr} ključnih veza." : $"Dosadašnji napredak: pokriveno {coveredKp}/{totalKp} ključnih izjava."; } + private static TargetDirective? ResolveTarget(ConceptRecord record, ProbeDirective? directive) + { + if (directive == null) return null; + var statement = ResolveTargetStatement(record, directive); + return new TargetDirective(directive.TargetType, directive.TargetKey, directive.Level, statement); + } + + private static string ResolveTargetStatement(ConceptRecord record, ProbeDirective directive) + { + if (directive.TargetType == ProbeTargetType.KeyProposition) + { + var kp = record.KeyPropositions.FirstOrDefault(p => p.Key == directive.TargetKey); + return kp?.Statement ?? "(unknown)"; + } + var kr = record.KeyRelations.FirstOrDefault(r => r.Key == directive.TargetKey); + if (kr == null) return "(unknown)"; + var kpByKey = record.KeyPropositions.ToDictionary(p => p.Key, p => p.Statement); + var source = kpByKey.GetValueOrDefault(kr.SourceKey, "?"); + var target = kpByKey.GetValueOrDefault(kr.TargetKey, "?"); + return $"{source} → {target}. Mechanism: {kr.Mechanism}"; + } + private enum RouteKind { Probe, Scaffold, Critique, Clarification, Redirect, MetaHelp, Closing } private sealed record RouteDecision( @@ -257,9 +373,9 @@ private sealed record RouteDecision( public static RouteDecision CritiqueFor(TurnEvaluation e) => new(RouteKind.Critique, Evaluation: e); public static RouteDecision Clarify(ProbeDirective? last) => new(RouteKind.Clarification, LastProbe: last); public static RouteDecision Redirect() => new(RouteKind.Redirect); - public static RouteDecision Meta(string progressLine, (ProbeTargetType Type, int Id)? next) + public static RouteDecision Meta(string progressLine, (ProbeTargetType Type, string Key)? next) { - var nextDirective = next == null ? null : new ProbeDirective(next.Value.Type, next.Value.Id, 1); + var nextDirective = next == null ? null : new ProbeDirective(next.Value.Type, next.Value.Key, 1); return new RouteDecision(RouteKind.MetaHelp, ProgressLine: progressLine, NextTarget: nextDirective); } public static RouteDecision Close(ClosingReason reason) => new(RouteKind.Closing, Closing: reason); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs new file mode 100644 index 000000000..13471d7ac --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs @@ -0,0 +1,15 @@ +using FluentResults; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +public interface IAgentJson +{ + Task> CompleteAsync( + AgentKind kind, IReadOnlyList history, + ConceptRecord record, AgentTurnContext ctx, + Func> validateAndMap, + string failureMessage, CancellationToken ct) where TResponse : class; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentStream.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentStream.cs new file mode 100644 index 000000000..588e713a7 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentStream.cs @@ -0,0 +1,13 @@ +using Tutor.BuildingBlocks.AI.Core.Agents; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +public interface IAgentStream +{ + IAsyncEnumerable StreamAsync( + AgentKind kind, IReadOnlyList history, + ConceptRecord record, AgentTurnContext ctx, CancellationToken ct); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClarificationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClarificationAgent.cs deleted file mode 100644 index 95f409814..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClarificationAgent.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Tutor.BuildingBlocks.AI.Core.Agents; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -public interface IClarificationAgent -{ - IAsyncEnumerable StreamAsync( - ConversationAttempt attempt, ConceptElaborationTask task, - ProbeDirective? lastProbe, CancellationToken ct); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClosingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClosingAgent.cs deleted file mode 100644 index c9858ee7c..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IClosingAgent.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Tutor.BuildingBlocks.AI.Core.Agents; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -public enum ClosingReason { AllCovered, HardCapReached } - -public interface IClosingAgent -{ - IAsyncEnumerable StreamAsync( - ConceptElaborationTask task, ClosingReason reason, CancellationToken ct); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ICritiqueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ICritiqueAgent.cs deleted file mode 100644 index bc50c49fe..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ICritiqueAgent.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Tutor.BuildingBlocks.AI.Core.Agents; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -public interface ICritiqueAgent -{ - IAsyncEnumerable StreamAsync( - TurnEvaluation evaluation, ConversationAttempt attempt, - ConceptElaborationTask task, CancellationToken ct); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IIntentClassifier.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IIntentClassifier.cs deleted file mode 100644 index 262254b33..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IIntentClassifier.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FluentResults; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -public interface IIntentClassifier -{ - Task> ClassifyAsync( - string content, List history, - ConceptElaborationTask task, CancellationToken ct); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IMetaHelpAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IMetaHelpAgent.cs deleted file mode 100644 index a57ded8ae..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IMetaHelpAgent.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Tutor.BuildingBlocks.AI.Core.Agents; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -public interface IMetaHelpAgent -{ - IAsyncEnumerable StreamAsync( - ConceptElaborationTask task, string progressLine, - ProbeDirective? nextTarget, CancellationToken ct); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IProbeAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IProbeAgent.cs deleted file mode 100644 index 04f540db8..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IProbeAgent.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Tutor.BuildingBlocks.AI.Core.Agents; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -public interface IProbeAgent -{ - IAsyncEnumerable StreamAsync( - ProbeDirective directive, ConversationAttempt attempt, - ConceptElaborationTask task, CancellationToken ct); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IRedirectAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IRedirectAgent.cs deleted file mode 100644 index be1e81c0d..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IRedirectAgent.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Tutor.BuildingBlocks.AI.Core.Agents; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -public interface IRedirectAgent -{ - IAsyncEnumerable StreamAsync(ConceptElaborationTask task, CancellationToken ct); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScaffoldingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScaffoldingAgent.cs deleted file mode 100644 index dfffa406a..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScaffoldingAgent.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Tutor.BuildingBlocks.AI.Core.Agents; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -public interface IScaffoldingAgent -{ - IAsyncEnumerable StreamAsync( - ProbeDirective target, ConversationAttempt attempt, - ConceptElaborationTask task, CancellationToken ct); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScorer.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScorer.cs deleted file mode 100644 index d8f588e33..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IScorer.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FluentResults; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -public interface IScorer -{ - Task> ScoreAsync( - string content, List history, - ConceptElaborationTask task, CancellationToken ct); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ISummaryAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ISummaryAgent.cs deleted file mode 100644 index b74357d2c..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/ISummaryAgent.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FluentResults; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -public interface ISummaryAgent -{ - Task> SummarizeAsync(ConversationAttempt attempt, - ConceptElaborationTask task, CancellationToken ct); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ClosingReason.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ClosingReason.cs new file mode 100644 index 000000000..b0ce58c6e --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ClosingReason.cs @@ -0,0 +1,3 @@ +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public enum ClosingReason { AllCovered, HardCapReached } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestratorService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestratorService.cs index c9f26f1dd..e9051455d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestratorService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestratorService.cs @@ -1,4 +1,5 @@ using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.ConceptRecords; using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; @@ -6,6 +7,6 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public interface IAgentOrchestratorService { IAsyncEnumerable ProcessTurnAsync( - ConversationAttempt attempt, ConceptElaborationTask task, + ConversationAttempt attempt, ConceptElaborationTask task, ConceptRecord record, string learnerContent, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs index 3668b066b..3614736a9 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs @@ -1,3 +1,3 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; -public record ProbeDirective(ProbeTargetType TargetType, int TargetId, int Level); +public record ProbeDirective(ProbeTargetType TargetType, string TargetKey, int Level); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfig.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfig.cs new file mode 100644 index 000000000..d8145d565 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfig.cs @@ -0,0 +1,9 @@ +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +public sealed record AgentConfig( + Func BuildSystemPrompt, + int MaxTokens, + double Temperature, + int? HistoryWindow = null); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfigs.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfigs.cs new file mode 100644 index 000000000..ca0f5ac56 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfigs.cs @@ -0,0 +1,20 @@ +using Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +public static class AgentConfigs +{ + public static readonly IReadOnlyDictionary ByKind = new Dictionary + { + [AgentKind.IntentClassifier] = new(IntentPrompt.Build, MaxTokens: 64, Temperature: 0.0, HistoryWindow: 6), + [AgentKind.Scorer] = new(ScorerPrompt.Build, MaxTokens: 1024, Temperature: 0.0), + [AgentKind.Probe] = new(ProbePrompt.Build, MaxTokens: 256, Temperature: 0.7), + [AgentKind.Scaffolding] = new(ScaffoldingPrompt.Build, MaxTokens: 512, Temperature: 0.7), + [AgentKind.Critique] = new(CritiquePrompt.Build, MaxTokens: 512, Temperature: 0.7), + [AgentKind.Clarification] = new(ClarificationPrompt.Build,MaxTokens: 256, Temperature: 0.5), + [AgentKind.Redirect] = new(RedirectPrompt.Build, MaxTokens: 128, Temperature: 0.7), + [AgentKind.MetaHelp] = new(MetaHelpPrompt.Build, MaxTokens: 256, Temperature: 0.5), + [AgentKind.Closing] = new(ClosingPrompt.Build, MaxTokens: 128, Temperature: 0.5), + [AgentKind.Summary] = new(SummaryPrompt.Build, MaxTokens: 256, Temperature: 0.5), + }; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentKind.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentKind.cs new file mode 100644 index 000000000..5d2410bed --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentKind.cs @@ -0,0 +1,15 @@ +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +public enum AgentKind +{ + IntentClassifier, + Scorer, + Probe, + Scaffolding, + Critique, + Clarification, + Redirect, + MetaHelp, + Closing, + Summary +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs new file mode 100644 index 000000000..19a7f187b --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs @@ -0,0 +1,17 @@ +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +/// +/// Per-turn volatile state handed to an agent. Lives in the trailing user message +/// (rendered by ) so none of it pollutes the cacheable system prompt. +/// Every field is optional; agents populate only what they need. +/// +public sealed record AgentTurnContext( + string Instruction, + string? ProgressLine = null, + TargetDirective? Target = null, + bool SoftCapReached = false, + TurnEvaluation? Evaluation = null, + string? CurrentLearnerMessage = null, + string? ConceptTitle = null); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs new file mode 100644 index 000000000..2d9a411ef --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs @@ -0,0 +1,33 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; + +public static class ClarificationPrompt +{ + public static string Build(ConceptRecord record) + { + var sb = new StringBuilder(); + sb.AppendLine(ConceptRecordRubricSection.Render(record)); + + sb.AppendLine("# Role"); + sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); + sb.AppendLine("The learner has asked a clarifying question. Rephrase or clarify the TUTOR's prior question in simpler language. Do NOT answer it — answering defeats the whole exercise."); + sb.AppendLine(); + + sb.AppendLine("# Rules"); + sb.AppendLine("- Max three sentences. Address only the specific thing asked."); + sb.AppendLine("- If the learner says they don't understand, rephrase the TUTOR's prior question in simpler terms. Do not answer the question under the guise of rephrasing."); + sb.AppendLine("- If the learner asks for a summary, \"the answer\", an explanation, or anything that would require producing the concept's content, REFUSE and redirect: \"Rezime i objašnjenje moraju doći od tebe — to je ono što vežbamo. Pokušaj da formulišeš svojim rečima.\""); + sb.AppendLine("- If a is given in the runtime context, it is INTERNAL — NEVER state or paraphrase that target. The whole point is that the learner articulates it."); + sb.AppendLine("- NEVER produce a list or multi-sentence breakdown."); + sb.AppendLine("- After clarifying, invite the learner to resume with one short prompt."); + sb.AppendLine(); + + sb.AppendLine("# Runtime Context Format"); + sb.AppendLine("Chat history shows the conversation so far. The last user message is the learner's clarification request."); + sb.AppendLine("The final user message contains: optional (what the TUTOR was probing — INTERNAL reference only), ."); + + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingPrompt.cs new file mode 100644 index 000000000..04b98d102 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingPrompt.cs @@ -0,0 +1,34 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; + +public static class ClosingPrompt +{ + public static string Build(ConceptRecord record) + { + var sb = new StringBuilder(); + sb.AppendLine(ConceptRecordRubricSection.Render(record)); + + sb.AppendLine("# Role"); + sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); + sb.AppendLine("Produce a closing turn that ends the conversation. The reason for closing is given in the of the runtime context."); + sb.AppendLine(); + + sb.AppendLine("# Rules"); + sb.AppendLine("- Two sentences maximum."); + sb.AppendLine("- If the instruction says the learner covered everything (reason=AllCovered), acknowledge completion in general terms (e.g. \"dobro si obuhvatio koncept\")."); + sb.AppendLine("- If the instruction says the conversation reached maximum length (reason=HardCapReached), acknowledge that the conversation is ending in general terms."); + sb.AppendLine("- DO NOT summarize the learner's explanation or the concept. The summary agent writes the summary separately."); + sb.AppendLine("- DO NOT list, restate, or paraphrase any KP/BC/CM/KR — not even ones already articulated."); + sb.AppendLine("- DO NOT use bullet points or structured lists."); + sb.AppendLine("- DO NOT ask further questions."); + sb.AppendLine(); + + sb.AppendLine("# Runtime Context Format"); + sb.AppendLine("Chat history shows the full conversation."); + sb.AppendLine("The final user message contains: (carries reason=AllCovered or reason=HardCapReached)."); + + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs new file mode 100644 index 000000000..b781d5114 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs @@ -0,0 +1,34 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; + +public static class CritiquePrompt +{ + public static string Build(ConceptRecord record) + { + var sb = new StringBuilder(); + sb.AppendLine(ConceptRecordRubricSection.Render(record)); + + sb.AppendLine("# Role"); + sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); + sb.AppendLine("The learner's latest answer has multiple concerns. Surface them as a short bulleted list so the learner can consolidate the existing answer before moving on."); + sb.AppendLine(); + + sb.AppendLine("# Rules"); + sb.AppendLine("- Respond with a short bulleted list of pushback points on concerns in the LATEST learner turn (the last user message in the chat history) ONLY — inaccuracies, triggered or novel misconceptions, vague or hand-wavy claims."); + sb.AppendLine("- NEVER raise a KP or KR that the learner has already articulated in an earlier turn. Re-raising those reads as not listening. (The scoring agent's tag in the runtime context tells you which propositions/relations this current turn covered; prior coverage is implicit from the chat history.)"); + sb.AppendLine("- NEVER provide answers, definitions, or explanations. NEVER reveal any KP/BC/CM/KR text verbatim or paraphrased."); + sb.AppendLine("- Close the bullets with a brief invitation to address them. Do not ask a new Socratic question — the learner must consolidate first."); + sb.AppendLine("- Silence on an error reads as agreement, so surface every in-turn concern."); + sb.AppendLine("- Concise language. Respect cognitive load."); + sb.AppendLine("- If is present in the runtime context, signal that you'll wrap up once these are addressed."); + sb.AppendLine(); + + sb.AppendLine("# Runtime Context Format"); + sb.AppendLine("Chat history shows the conversation so far (user=learner, assistant=tutor). The latest learner turn is the last user message."); + sb.AppendLine("The final user message may contain: (scores + triggered misconceptions for the latest turn), , ."); + + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs similarity index 61% rename from src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentPromptBuilder.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs index 874dc20b6..cfdf5d421 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentPromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs @@ -1,56 +1,44 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptRecords; -namespace Tutor.Elaborations.Infrastructure.Agents.IntentClassifier; +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; -public static class IntentPromptBuilder +public static class IntentPrompt { - public static string BuildSystemPrompt(ConceptElaborationTask task) + public static string Build(ConceptRecord record) { var sb = new StringBuilder(); + sb.AppendLine(ConceptRecordRubricSection.Render(record)); + + sb.AppendLine("# Role"); sb.AppendLine("You are an intent classifier for a Socratic tutoring conversation."); sb.AppendLine("Classify the learner's latest message into exactly one intent. Output JSON only, no other text."); sb.AppendLine(); - sb.AppendLine($"## Concept under study: {task.Title}"); - sb.AppendLine(); - sb.AppendLine("## Intent categories:"); + + sb.AppendLine("# Intent categories"); sb.AppendLine("- **Substantive**: the learner attempts to explain, define, relate, or apply the concept. Even a weak or partial attempt counts."); sb.AppendLine("- **Clarification**: the learner asks a genuine information-seeking question about the task or the tutor's last message (what / why / how / what do you mean by …?). Must be a direct question — if removing the rest and keeping just the question still makes sense."); sb.AppendLine("- **Stuck**: the learner signals confusion, inability, or not-knowing without asking a question — e.g. \"ne znam\", \"ne razumem\", \"nisam siguran\", \"teško mi je\". Not a refusal of the task, just a stall."); sb.AppendLine("- **MetaHelp**: the learner asks a procedural/meta question about the conversation itself — e.g. \"rezimiraj šta sam rekao\", \"koliko mi je ostalo\", \"objasni mi još jednom šta tražiš\"."); sb.AppendLine("- **OffTopic**: everything else — small talk, greetings, jokes, personal content, refusals (\"ne želim\", \"dosadno mi je\"), meta-comments about the conversation, deference or agreement without articulation (\"da, u pravu si\")."); sb.AppendLine(); - sb.AppendLine("## Disambiguation rules:"); - sb.AppendLine("- If the message is a verbatim or near-verbatim echo of a previous [TUTOR] line, classify as OffTopic."); + + sb.AppendLine("# Disambiguation rules"); + sb.AppendLine("- If the message is a verbatim or near-verbatim echo of a previous assistant line, classify as OffTopic."); sb.AppendLine("- Between Clarification and Stuck: if the message is a question, Clarification; if it is a statement of not-knowing, Stuck."); sb.AppendLine("- Between Clarification and MetaHelp: MetaHelp is about the conversation/progress itself; Clarification is about the concept or the tutor's last probe."); sb.AppendLine("- When in doubt between Clarification and OffTopic, choose OffTopic."); sb.AppendLine(); - sb.AppendLine("## Output format (JSON, no other text):"); - sb.AppendLine("{ \"intent\": \"Substantive\" | \"Clarification\" | \"Stuck\" | \"MetaHelp\" | \"OffTopic\" }"); - return sb.ToString(); - } - public static string BuildUserMessage(string learnerContent, List history) - { - var sb = new StringBuilder(); - sb.AppendLine("## Conversation so far (for context — do NOT classify these)"); - if (history.Count == 0) - { - sb.AppendLine("(no prior turns)"); - } - else - { - foreach (var turn in history.TakeLast(6)) - { - var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; - sb.AppendLine($"[{label}]: {turn.Content}"); - } - } + sb.AppendLine("# Runtime Context Format"); + sb.AppendLine("Chat history shows prior turns (user=learner, assistant=tutor) for context only — DO NOT classify these."); + sb.AppendLine("The final user message contains the message to classify inside , followed by ."); sb.AppendLine(); - sb.AppendLine("## Message to classify (this is the ONLY message to classify)"); - sb.AppendLine($"[LEARNER]: {learnerContent}"); + + sb.AppendLine("# Output Format"); + sb.AppendLine("JSON only, no other text:"); + sb.AppendLine("{ \"intent\": \"Substantive\" | \"Clarification\" | \"Stuck\" | \"MetaHelp\" | \"OffTopic\" }"); + return sb.ToString(); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentResponse.cs new file mode 100644 index 000000000..6bfe6183b --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentResponse.cs @@ -0,0 +1,6 @@ +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; + +public class IntentResponse +{ + public string? Intent { get; set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/MetaHelpPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/MetaHelpPrompt.cs new file mode 100644 index 000000000..816d0342d --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/MetaHelpPrompt.cs @@ -0,0 +1,33 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; + +public static class MetaHelpPrompt +{ + public static string Build(ConceptRecord record) + { + var sb = new StringBuilder(); + sb.AppendLine(ConceptRecordRubricSection.Render(record)); + + sb.AppendLine("# Role"); + sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); + sb.AppendLine("The learner asked a meta/procedural question about the conversation itself (e.g. \"rezimiraj šta sam rekao\", \"koliko mi je ostalo\")."); + sb.AppendLine(); + + sb.AppendLine("# Rules"); + sb.AppendLine("- Open with the line from the runtime context exactly as given. Do not invent numbers or substitute it."); + sb.AppendLine("- Then invite the learner to address the one remaining gap with a short, narrow question or cue."); + sb.AppendLine("- Three sentences maximum."); + sb.AppendLine("- NEVER restate the concept, enumerate covered points, or reveal any KP/KR text."); + sb.AppendLine("- Do not produce a list or a summary of what the learner said — only the progress line plus a pivot."); + sb.AppendLine("- If a is given it is the next target — INTERNAL, NEVER reveal or paraphrase its statement."); + sb.AppendLine(); + + sb.AppendLine("# Runtime Context Format"); + sb.AppendLine("Chat history shows the conversation so far. The last user message is the learner's meta/procedural question."); + sb.AppendLine("The final user message contains: (use verbatim as the opener), optional (next target — INTERNAL reference only), ."); + + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs new file mode 100644 index 000000000..87914a0f7 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs @@ -0,0 +1,39 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; + +public static class ProbePrompt +{ + public static string Build(ConceptRecord record) + { + var sb = new StringBuilder(); + sb.AppendLine(ConceptRecordRubricSection.Render(record)); + + sb.AppendLine("# Role"); + sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); + sb.AppendLine("Ask ONE question that probes the target given in the runtime context. Do not reveal the target text, do not list alternatives, do not summarize what the learner has said."); + sb.AppendLine(); + + sb.AppendLine("# Escalation levels"); + sb.AppendLine("The tag carries a level attribute (1-3) that shapes the question:"); + sb.AppendLine("- L1 — open \"why\" or \"what\" question that invites the learner to articulate the target. Broad enough to let them arrive at the idea themselves."); + sb.AppendLine("- L2 — the learner has already failed an L1 probe on this target. Ask a narrower, connective question: \"how is X tied to Y\", \"what role does X play when Y\". Still no hints to the target statement."); + sb.AppendLine("- L3 — the learner has failed twice. Offer a sentence-completion scaffold: \"Dopuni rečenicu: '…zato što…'\" or \"Dovrši misao: …\". The blank must not contain the target text."); + sb.AppendLine(); + + sb.AppendLine("# Rules"); + sb.AppendLine("- The statement is INTERNAL — NEVER reveal or paraphrase it closely enough to give away the answer."); + sb.AppendLine("- Output ONE question (or one sentence-completion prompt at L3). No preamble, no bullet list."); + sb.AppendLine("- NEVER list multiple options or enumerate gaps."); + sb.AppendLine("- Concise. Respect cognitive load."); + sb.AppendLine("- If is present, signal that you'll wrap up once this is addressed."); + sb.AppendLine(); + + sb.AppendLine("# Runtime Context Format"); + sb.AppendLine("Chat history shows the conversation so far (user=learner, assistant=tutor)."); + sb.AppendLine("The final user message contains: …statement…, optional , ."); + + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/RedirectPrompt.cs similarity index 57% rename from src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectPromptBuilder.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/RedirectPrompt.cs index 11bfd71a2..16ea9b318 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectPromptBuilder.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/RedirectPrompt.cs @@ -1,26 +1,31 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.ConceptRecords; -namespace Tutor.Elaborations.Infrastructure.Agents.Redirect; +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; -public static class RedirectPromptBuilder +public static class RedirectPrompt { - public static string BuildSystemPrompt(ConceptElaborationTask task) + public static string Build(ConceptRecord record) { var sb = new StringBuilder(); + sb.AppendLine(ConceptRecordRubricSection.Render(record)); + + sb.AppendLine("# Role"); sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); sb.AppendLine("The learner's last message is off-topic — small talk, jokes, personal content, refusals, or disengagement."); sb.AppendLine(); - sb.AppendLine("## Rules:"); + + sb.AppendLine("# Rules"); sb.AppendLine("- Acknowledge in one short clause without engaging with the off-topic content."); sb.AppendLine("- Firmly but kindly redirect to the concept. End with a concrete, small next step on it."); sb.AppendLine("- Do NOT answer off-topic questions, validate the off-topic direction, offer to change topic, suggest pausing or abandoning, or ask about the learner's mood."); sb.AppendLine("- Two sentences maximum. No bullet list."); sb.AppendLine(); - sb.AppendLine($"## Concept: {task.Title}"); + + sb.AppendLine("# Runtime Context Format"); + sb.AppendLine("Chat history shows the conversation so far. The last user message is the learner's off-topic message."); + sb.AppendLine("The final user message contains: ."); + return sb.ToString(); } - - public static string BuildUserMessage() => - "Produce the TUTOR's redirect per the system prompt."; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs new file mode 100644 index 000000000..909c6e192 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs @@ -0,0 +1,37 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; + +public static class ScaffoldingPrompt +{ + public static string Build(ConceptRecord record) + { + var sb = new StringBuilder(); + sb.AppendLine(ConceptRecordRubricSection.Render(record)); + + sb.AppendLine("# Role"); + sb.AppendLine("You are a Socratic tutoring scaffolding agent. Speak Serbian."); + sb.AppendLine("The learner has stalled after repeated probes on one target (given in the runtime context). Provide a concrete structural scaffold that gives them a foothold WITHOUT stating the target."); + sb.AppendLine(); + + sb.AppendLine("# Scaffolding options (pick ONE that fits best)"); + sb.AppendLine("1. **Forced choice** — offer two options, exactly one of which points toward the target, both phrased at the same level of abstraction. Ask the learner to choose and justify."); + sb.AppendLine("2. **Code skeleton with labeled blanks** — a minimal pseudo-code / test skeleton with `// ___` blanks labeled by role (e.g. `// pripremi očekivano`). Ask the learner to fill one blank."); + sb.AppendLine("3. **Analogy** — map the target to a simpler, non-technical domain. Present the analogy's structure and ask the learner to translate it back to the concept."); + sb.AppendLine(); + + sb.AppendLine("# Rules"); + sb.AppendLine("- The statement is INTERNAL — NEVER reveal or paraphrase it closely enough to give it away. The scaffold must make the learner do the articulation."); + sb.AppendLine("- Keep it short. A code skeleton with 3-4 labeled lines, or a two-option forced choice, or a 2-sentence analogy."); + sb.AppendLine("- End with one concrete, narrow request (\"Koja opcija i zašto?\" / \"Šta ide na mestu ___?\" / \"Prevedi ovu analogiju na naš koncept.\")."); + sb.AppendLine("- No bullet lists beyond what the scaffold structurally requires."); + sb.AppendLine(); + + sb.AppendLine("# Runtime Context Format"); + sb.AppendLine("Chat history shows the conversation so far (including the learner's prior attempts on this target)."); + sb.AppendLine("The final user message contains: …statement…, ."); + + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerPrompt.cs new file mode 100644 index 000000000..711828e00 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerPrompt.cs @@ -0,0 +1,74 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; + +public static class ScorerPrompt +{ + public static string Build(ConceptRecord record) + { + var hasBoundaryConditions = record.BoundaryConditions.Count != 0; + var hasCommonMisconceptions = record.CommonMisconceptions.Count != 0; + var hasKeyRelations = record.KeyRelations.Count != 0; + + var sb = new StringBuilder(); + sb.AppendLine(ConceptRecordRubricSection.Render(record)); + + sb.AppendLine("# Role"); + sb.AppendLine("You are a scoring agent for a Socratic tutoring system."); + sb.AppendLine("The learner's latest message is known to be Substantive (an attempt at explanation). Score it against the concept rubric and tag which propositions/relations/misconceptions it hits. Output JSON only, no other text."); + sb.AppendLine(); + + sb.AppendLine("# Scope rule"); + sb.AppendLine("Score only the text inside in the final user message. Do not credit the learner for content that appears in prior assistant turns or that the learner has only repeated from a preceding assistant turn."); + sb.AppendLine(); + + sb.AppendLine("# Rubric"); + sb.AppendLine(hasBoundaryConditions + ? "- Correctness (1-3): Are stated claims true? Check against KPs and BCs." + : "- Correctness (1-3): Are stated claims true? Check against KPs."); + sb.AppendLine("- Completeness (1-3): Are essential KPs covered in THIS message?"); + if (hasBoundaryConditions) + sb.AppendLine("- Discrimination (1-3): Does the explanation correctly exclude non-examples? Check BCs."); + if (hasKeyRelations) + sb.AppendLine("- Integration (1-3): Did the learner articulate key relations *with mechanism*? 1=no relation, 2=relation without mechanism, 3=relation with mechanism matching the authored description."); + sb.AppendLine("- Evaluate concepts, not language. Grammar and style must not reduce scores."); + sb.AppendLine("- Resist sycophancy. Evaluate strictly against rubric."); + sb.AppendLine(); + + sb.AppendLine("# Concern count (used by the orchestrator to route to critique vs probe)"); + sb.AppendLine("Count distinct concerns in the message. A concern is any of:"); + sb.AppendLine(" - a stated inaccuracy (a claim that contradicts a KP or BC);"); + sb.AppendLine(hasCommonMisconceptions + ? " - a triggered known misconception or a novel misconception;" + : " - a novel misconception (none are pre-catalogued for this concept);"); + sb.AppendLine(" - a vague or hand-wavy claim that references a KP without articulating it."); + sb.AppendLine("Set hasMultipleConcerns=true if the count is two or more; false otherwise."); + sb.AppendLine(); + + sb.AppendLine("# Runtime Context Format"); + sb.AppendLine("Chat history shows prior turns (user=learner, assistant=tutor) for context only — DO NOT score these."); + sb.AppendLine("The final user message contains the message to score inside , followed by ."); + sb.AppendLine(); + + sb.AppendLine("# Output Format (JSON only, no other text)"); + var fields = new List + { + "\"correctnessScore\": 1-3", + "\"completenessScore\": 1-3" + }; + if (hasBoundaryConditions) fields.Add("\"discriminationScore\": 1-3"); + if (hasKeyRelations) fields.Add("\"integrationScore\": 1-3"); + fields.Add("\"justification\": \"brief explanation of scores\""); + fields.Add("\"propositionsCoveredKeys\": [string list of KP keys covered in this turn, e.g. [\"P1\", \"P2\"]]"); + if (hasCommonMisconceptions) fields.Add("\"misconceptionsTriggeredKeys\": [string list of CM keys triggered, e.g. [\"M1\"]]"); + if (hasKeyRelations) fields.Add("\"relationsArticulatedKeys\": [string list of KR keys articulated with mechanism this turn, e.g. [\"R1\"]]"); + if (hasCommonMisconceptions) fields.Add("\"novelMisconceptions\": \"any misconceptions not in the list, or null\""); + fields.Add("\"hasMultipleConcerns\": true|false"); + + sb.AppendLine("{"); + sb.AppendLine(string.Join(",\n", fields.Select(f => " " + f))); + sb.AppendLine("}"); + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs new file mode 100644 index 000000000..cc77c8243 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs @@ -0,0 +1,15 @@ +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; + +public class ScorerResponse +{ + public int CorrectnessScore { get; set; } + public int CompletenessScore { get; set; } + public int? DiscriminationScore { get; set; } + public int? IntegrationScore { get; set; } + public string? Justification { get; set; } + public List? PropositionsCoveredKeys { get; set; } + public List? MisconceptionsTriggeredKeys { get; set; } + public List? RelationsArticulatedKeys { get; set; } + public string? NovelMisconceptions { get; set; } + public bool? HasMultipleConcerns { get; set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs new file mode 100644 index 000000000..2984793c2 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs @@ -0,0 +1,32 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; + +public static class SummaryPrompt +{ + public static string Build(ConceptRecord record) + { + var sb = new StringBuilder(); + sb.AppendLine(ConceptRecordRubricSection.Render(record)); + + sb.AppendLine("# Role"); + sb.AppendLine("You are a summary agent. Write a brief natural-language summary of the conversation."); + sb.AppendLine("Paraphrase what the learner demonstrated understanding of. Never quote proposition or relation statements verbatim."); + sb.AppendLine("Write in Serbian. Keep the summary to 2-4 sentences."); + sb.AppendLine(); + + sb.AppendLine("# Rules"); + sb.AppendLine("- Base the summary on the learner's actual turns in the chat history, not on the KP list."); + sb.AppendLine("- Paraphrase at the level of the learner's articulations; do not upgrade them with rubric language."); + sb.AppendLine("- NEVER quote any KP/BC/CM/KR text verbatim or near-verbatim."); + sb.AppendLine("- No bullet lists. One short paragraph, 2-4 sentences."); + sb.AppendLine(); + + sb.AppendLine("# Runtime Context Format"); + sb.AppendLine("Chat history shows the full conversation (user=learner, assistant=tutor)."); + sb.AppendLine("The final user message contains: ."); + + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRecordRubricSection.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRecordRubricSection.cs new file mode 100644 index 000000000..341c23da0 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRecordRubricSection.cs @@ -0,0 +1,59 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +/// +/// Renders the concept rubric (definition, KPs, BCs, 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 ConceptRecordRubricSection +{ + public static string Render(ConceptRecord record) + { + var sb = new StringBuilder(); + + sb.AppendLine("# Concept"); + sb.AppendLine(); + sb.AppendLine("## Canonical Definition"); + sb.AppendLine(record.CanonicalDefinition); + sb.AppendLine(); + + sb.AppendLine("## Key Propositions"); + foreach (var kp in record.KeyPropositions) + sb.AppendLine($"- [{kp.Key}] {kp.Statement}"); + sb.AppendLine(); + + if (record.BoundaryConditions.Count > 0) + { + sb.AppendLine("## Boundary Conditions"); + foreach (var bc in record.BoundaryConditions) + sb.AppendLine($"- [{bc.Key}] {bc.Statement}"); + sb.AppendLine(); + } + + 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(); + } + + 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}"); + } + } + + return sb.ToString().TrimEnd() + "\n"; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConversationHistoryMapper.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConversationHistoryMapper.cs new file mode 100644 index 000000000..0531bcfce --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConversationHistoryMapper.cs @@ -0,0 +1,20 @@ +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +/// +/// Maps domain s to native-role s +/// so the provider can cache against alternating user/assistant turns instead of a flattened transcript. +/// +public static class ConversationHistoryMapper +{ + public static List Map(IEnumerable turns, int? lastN = null) + { + var ordered = turns.OrderBy(t => t.Order); + var window = lastN is { } n ? ordered.TakeLast(n) : ordered; + return window.Select(t => t.Role == TurnRole.Learner + ? ChatMessage.FromUser(t.Content) + : ChatMessage.FromAssistant(t.Content)).ToList(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs new file mode 100644 index 000000000..7dd451de8 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs @@ -0,0 +1,56 @@ +using System.Text; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +/// +/// Renders an as a single trailing user message. +/// The XML-ish tags match the schema described in each agent's system prompt +/// so the model knows exactly how to interpret the runtime state. +/// +public static class RuntimeContextBlock +{ + public static string Render(AgentTurnContext ctx) + { + var sb = new StringBuilder(); + + if (!string.IsNullOrWhiteSpace(ctx.ProgressLine)) + sb.AppendLine($"{ctx.ProgressLine}"); + + if (ctx.Target is { } t) + sb.AppendLine($"{t.Statement}"); + + if (ctx.SoftCapReached) + sb.AppendLine(""); + + if (ctx.Evaluation is { } e) + sb.AppendLine(RenderEvaluation(e)); + + if (!string.IsNullOrWhiteSpace(ctx.CurrentLearnerMessage)) + sb.AppendLine($"{ctx.CurrentLearnerMessage}"); + + sb.Append($"{ctx.Instruction}"); + return sb.ToString(); + } + + private static string RenderEvaluation(Domain.Conversations.TurnEvaluation e) + { + var sb = new StringBuilder(); + var attrs = new List + { + $"correctness=\"{e.CorrectnessScore}\"", + $"completeness=\"{e.CompletenessScore}\"" + }; + if (e.DiscriminationScore.HasValue) attrs.Add($"discrimination=\"{e.DiscriminationScore.Value}\""); + if (e.IntegrationScore.HasValue) attrs.Add($"integration=\"{e.IntegrationScore.Value}\""); + attrs.Add($"hasMultipleConcerns=\"{e.HasMultipleConcerns.ToString().ToLowerInvariant()}\""); + + sb.Append($""); + sb.Append($"{e.Justification}"); + if (e.MisconceptionsTriggeredKeys.Count > 0) + sb.Append($"{string.Join(", ", e.MisconceptionsTriggeredKeys)}"); + if (!string.IsNullOrWhiteSpace(e.NovelMisconceptions)) + sb.Append($"{e.NovelMisconceptions}"); + sb.Append(""); + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/TargetDirective.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/TargetDirective.cs new file mode 100644 index 000000000..c053e2b2b --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/TargetDirective.cs @@ -0,0 +1,13 @@ +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +/// +/// A with the target statement pre-resolved into text. +/// The orchestrator resolves the text once per turn so prompt builders never reach into the task definition. +/// +public sealed record TargetDirective( + ProbeTargetType Type, + string Key, + int Level, + string Statement); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs new file mode 100644 index 000000000..b070605da --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs @@ -0,0 +1,33 @@ +using FluentResults; +using Microsoft.Extensions.Logging; +using Tutor.BuildingBlocks.AI.Core.Agents; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; +using Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +namespace Tutor.Elaborations.Infrastructure.Agents; + +public class AgentJson : StructuredAgent, IAgentJson +{ + public AgentJson(IAiChatService chatService, ILogger logger) + : base(chatService, logger) { } + + public Task> CompleteAsync( + AgentKind kind, IReadOnlyList history, + ConceptRecord record, AgentTurnContext ctx, + Func> validateAndMap, + string failureMessage, CancellationToken ct) where TResponse : class + { + var config = AgentConfigs.ByKind[kind]; + var messages = ConversationHistoryMapper.Map(history, config.HistoryWindow); + messages.Add(ChatMessage.FromUser(RuntimeContextBlock.Render(ctx))); + + var request = CompletionRequest.Create( + messages, config.BuildSystemPrompt(record), + maxTokens: config.MaxTokens, temperature: config.Temperature); + + return CompleteJsonAsync(request, kind.ToString(), validateAndMap, failureMessage, ct); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentStream.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentStream.cs new file mode 100644 index 000000000..9036ac34c --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentStream.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; +using Tutor.BuildingBlocks.AI.Core.Agents; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; +using Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +namespace Tutor.Elaborations.Infrastructure.Agents; + +public class AgentStream : StreamingAgent, IAgentStream +{ + public AgentStream(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) + : base(chatService, usageTracker, logger) { } + + public IAsyncEnumerable StreamAsync( + AgentKind kind, IReadOnlyList history, + ConceptRecord record, AgentTurnContext ctx, CancellationToken ct) + { + var config = AgentConfigs.ByKind[kind]; + var messages = ConversationHistoryMapper.Map(history, config.HistoryWindow); + messages.Add(ChatMessage.FromUser(RuntimeContextBlock.Render(ctx))); + + var request = CompletionRequest.Create( + messages, config.BuildSystemPrompt(record), + maxTokens: config.MaxTokens, temperature: config.Temperature); + + return StreamAsync(request, kind.ToString(), ct); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs deleted file mode 100644 index 4bf596ac2..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationAgent.cs +++ /dev/null @@ -1,27 +0,0 @@ -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.Orchestration; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -namespace Tutor.Elaborations.Infrastructure.Agents.Clarification; - -public class ClarificationAgent : StreamingAgent, IClarificationAgent -{ - public ClarificationAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) - : base(chatService, usageTracker, logger) { } - - public IAsyncEnumerable StreamAsync( - ConversationAttempt attempt, ConceptElaborationTask task, ProbeDirective? lastProbe, - CancellationToken ct) - { - var history = attempt.Turns.ToList(); - var learnerContent = history.LastOrDefault(t => t.Role == TurnRole.Learner)?.Content ?? string.Empty; - - var systemPrompt = ClarificationPromptBuilder.BuildSystemPrompt(task, lastProbe); - var userMessage = ClarificationPromptBuilder.BuildUserMessage(history, learnerContent); - return StreamAsync(systemPrompt, userMessage, maxTokens: 256, temperature: 0.5, ct); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationPromptBuilder.cs deleted file mode 100644 index 9c69c5d01..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Clarification/ClarificationPromptBuilder.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -namespace Tutor.Elaborations.Infrastructure.Agents.Clarification; - -public static class ClarificationPromptBuilder -{ - public static string BuildSystemPrompt(ConceptElaborationTask task, ProbeDirective? lastProbe) - { - var sb = new StringBuilder(); - sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); - sb.AppendLine("The learner has asked a clarifying question. Rephrase or clarify the TUTOR's prior question in simpler language. Do NOT answer it — answering defeats the whole exercise."); - sb.AppendLine(); - sb.AppendLine($"## Concept: {task.Title}"); - sb.AppendLine(); - sb.AppendLine("## Rules:"); - sb.AppendLine("- Max three sentences. Address only the specific thing asked."); - sb.AppendLine("- If the learner says they don't understand, rephrase the TUTOR's prior question in simpler terms. Do not answer the question under the guise of rephrasing."); - sb.AppendLine("- If the learner asks for a summary, \"the answer\", an explanation, or anything that would require producing the concept's content, REFUSE and redirect: \"Rezime i objašnjenje moraju doći od tebe — to je ono što vežbamo. Pokušaj da formulišeš svojim rečima.\""); - sb.AppendLine("- NEVER state or paraphrase the target below. The whole point is that the learner articulates it."); - sb.AppendLine("- NEVER produce a list or multi-sentence breakdown."); - sb.AppendLine("- After clarifying, invite the learner to resume with one short prompt."); - - if (lastProbe != null) - { - var targetText = ResolveTargetStatement(task, lastProbe); - sb.AppendLine(); - sb.AppendLine("## What the TUTOR was probing (INTERNAL — NEVER reveal this text or a paraphrase close enough to give away the answer):"); - sb.AppendLine($"[{lastProbe.TargetType}-{lastProbe.TargetId}] {targetText}"); - } - - return sb.ToString(); - } - - public static string BuildUserMessage(List history, string learnerContent) - { - var sb = new StringBuilder(); - sb.AppendLine("## Conversation so far"); - foreach (var turn in history) - { - var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; - sb.AppendLine($"[{label}]: {turn.Content}"); - } - sb.AppendLine(); - sb.AppendLine("## Latest LEARNER message (the clarification request)"); - sb.AppendLine(learnerContent); - sb.AppendLine(); - sb.AppendLine("Produce the TUTOR's clarification per the system prompt."); - return sb.ToString(); - } - - private static string ResolveTargetStatement(ConceptElaborationTask task, ProbeDirective directive) - { - if (directive.TargetType == ProbeTargetType.KeyProposition) - { - var kp = task.KeyPropositions.FirstOrDefault(p => p.Id == directive.TargetId); - return kp?.Statement ?? "(unknown)"; - } - var kr = task.KeyRelations.FirstOrDefault(r => r.Id == directive.TargetId); - if (kr == null) return "(unknown)"; - var kpById = task.KeyPropositions.ToDictionary(p => p.Id, p => p.Statement); - var source = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, "?"); - var target = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, "?"); - return $"{source} → {target}. Mechanism: {kr.Mechanism}"; - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs deleted file mode 100644 index 58a1d8ed1..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingAgent.cs +++ /dev/null @@ -1,21 +0,0 @@ -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.UseCases.Learning.Orchestration.Agents; - -namespace Tutor.Elaborations.Infrastructure.Agents.Closing; - -public class ClosingAgent : StreamingAgent, IClosingAgent -{ - public ClosingAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) - : base(chatService, usageTracker, logger) { } - - public IAsyncEnumerable StreamAsync( - ConceptElaborationTask task, ClosingReason reason, CancellationToken ct) - { - var systemPrompt = ClosingPromptBuilder.BuildSystemPrompt(task, reason); - var userMessage = ClosingPromptBuilder.BuildUserMessage(); - return StreamAsync(systemPrompt, userMessage, maxTokens: 128, temperature: 0.5, ct); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingPromptBuilder.cs deleted file mode 100644 index b970d5acb..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Closing/ClosingPromptBuilder.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -namespace Tutor.Elaborations.Infrastructure.Agents.Closing; - -public static class ClosingPromptBuilder -{ - public static string BuildSystemPrompt(ConceptElaborationTask task, ClosingReason reason) - { - var header = reason == ClosingReason.AllCovered - ? "## The learner has covered all required propositions and articulated all required relations." - : "## The conversation has reached its maximum length."; - var ack = reason == ClosingReason.AllCovered - ? "Acknowledge completion in general terms only (e.g., \"dobro si obuhvatio koncept\")." - : "Acknowledge that the conversation is ending in general terms only."; - - var sb = new StringBuilder(); - sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); - sb.AppendLine(); - sb.AppendLine(header); - sb.AppendLine("Produce a closing turn with exactly these properties:"); - sb.AppendLine("- Two sentences maximum."); - sb.AppendLine($"- {ack}"); - sb.AppendLine("- DO NOT summarize the learner's explanation or the concept."); - sb.AppendLine("- DO NOT list, restate, or paraphrase any key proposition, boundary condition, or relation — not even ones already articulated."); - sb.AppendLine("- DO NOT use bullet points or structured lists."); - sb.AppendLine("- DO NOT ask further questions."); - sb.AppendLine("The summary agent writes the summary separately; your job here is only to close the dialogue."); - sb.AppendLine(); - sb.AppendLine($"## Concept: {task.Title}"); - return sb.ToString(); - } - - public static string BuildUserMessage() => - "Produce the TUTOR's closing turn per the system prompt."; -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs deleted file mode 100644 index 07882d867..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiqueAgent.cs +++ /dev/null @@ -1,23 +0,0 @@ -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.Orchestration.Agents; - -namespace Tutor.Elaborations.Infrastructure.Agents.Critique; - -public class CritiqueAgent : StreamingAgent, ICritiqueAgent -{ - public CritiqueAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) - : base(chatService, usageTracker, logger) { } - - public IAsyncEnumerable StreamAsync( - TurnEvaluation evaluation, ConversationAttempt attempt, ConceptElaborationTask task, - CancellationToken ct) - { - var systemPrompt = CritiquePromptBuilder.BuildSystemPrompt(task, attempt, attempt.IsSoftCapReached()); - var userMessage = CritiquePromptBuilder.BuildUserMessage(evaluation, attempt.Turns.ToList()); - return StreamAsync(systemPrompt, userMessage, maxTokens: 512, temperature: 0.7, ct); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiquePromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiquePromptBuilder.cs deleted file mode 100644 index ea7809be9..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Critique/CritiquePromptBuilder.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Infrastructure.Agents.Critique; - -public static class CritiquePromptBuilder -{ - public static string BuildSystemPrompt( - ConceptElaborationTask task, ConversationAttempt attempt, bool isSoftCapReached) - { - var coveredKpIds = attempt.GetCoveredPropositionIds(); - var articulatedKrIds = attempt.GetArticulatedRelationIds(); - - var sb = new StringBuilder(); - sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); - sb.AppendLine("The learner's latest answer has multiple concerns. Your job is to surface them as a short bulleted list so the learner can consolidate their existing answer before moving on."); - sb.AppendLine(); - sb.AppendLine($"## Concept: {task.Title}"); - sb.AppendLine($"Definition: {task.CanonicalDefinition}"); - sb.AppendLine(); - - sb.AppendLine("## Key Propositions (internal reference — never quote verbatim):"); - foreach (var kp in task.KeyPropositions) - { - var marker = coveredKpIds.Contains(kp.Id) ? " [ALREADY ARTICULATED IN PRIOR TURN — DO NOT RAISE]" : ""; - sb.AppendLine($"- [KP-{kp.Id}]{marker} {kp.Statement}"); - } - sb.AppendLine(); - - if (task.BoundaryConditions.Count > 0) - { - sb.AppendLine("## Boundary Conditions (internal reference):"); - foreach (var bc in task.BoundaryConditions) - sb.AppendLine($"- [BC-{bc.Id}] {bc.Statement}"); - sb.AppendLine(); - } - - if (task.CommonMisconceptions.Count > 0) - { - sb.AppendLine("## Common Misconceptions (internal reference):"); - foreach (var cm in task.CommonMisconceptions) - sb.AppendLine($"- [CM-{cm.Id}] {cm.Description} (correction: {cm.Correction})"); - sb.AppendLine(); - } - - if (task.KeyRelations.Count > 0) - { - sb.AppendLine("## Key Relations (internal reference — never quote verbatim):"); - var kpById = task.KeyPropositions.ToDictionary(kp => kp.Id, kp => kp.Statement); - foreach (var kr in task.KeyRelations) - { - var marker = articulatedKrIds.Contains(kr.Id) ? " [ALREADY ARTICULATED IN PRIOR TURN — DO NOT RAISE]" : ""; - var source = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, "?"); - var target = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, "?"); - sb.AppendLine($"- [KR-{kr.Id}]{marker} {source} → {target}. Mechanism: {kr.Mechanism}"); - } - sb.AppendLine(); - } - - sb.AppendLine("## Rules:"); - sb.AppendLine("- Respond with a short bulleted list of pushback points on concerns in THE LATEST LEARNER TURN ONLY — inaccuracies, triggered or novel misconceptions, vague or hand-wavy claims."); - sb.AppendLine("- NEVER raise a KP or KR marked ALREADY ARTICULATED. The learner has already said those in earlier turns; re-raising them reads as not listening."); - sb.AppendLine("- NEVER provide answers, definitions, or explanations. NEVER reveal any KP/BC/CM/KR text verbatim or paraphrased."); - sb.AppendLine("- Close the bullets with a brief invitation to address them. Do not ask a new Socratic question — the learner must consolidate first."); - sb.AppendLine("- Silence on an error reads as agreement, so surface every in-turn concern."); - sb.AppendLine("- Concise language. Respect cognitive load."); - - if (isSoftCapReached) - { - sb.AppendLine(); - sb.AppendLine("## The learner is approaching the end of the conversation. Signal that you'll wrap up once these are addressed."); - } - - return sb.ToString(); - } - - public static string BuildUserMessage( - TurnEvaluation evaluation, List history) - { - var sb = new StringBuilder(); - sb.AppendLine("## Conversation so far"); - foreach (var turn in history) - { - var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; - sb.AppendLine($"[{label}]: {turn.Content}"); - } - sb.AppendLine(); - - sb.AppendLine("## Evaluation of the latest LEARNER turn (from the scoring agent — NOT from the learner)"); - var parts = new List - { - $"correctness={evaluation.CorrectnessScore}", - $"completeness={evaluation.CompletenessScore}" - }; - if (evaluation.DiscriminationScore.HasValue) - parts.Add($"discrimination={evaluation.DiscriminationScore.Value}"); - if (evaluation.IntegrationScore.HasValue) - parts.Add($"integration={evaluation.IntegrationScore.Value}"); - sb.AppendLine($"Scores: {string.Join(", ", parts)}"); - sb.AppendLine($"Justification: {evaluation.Justification}"); - if (evaluation.MisconceptionsTriggeredIds.Count != 0) - sb.AppendLine($"Triggered misconceptions: {string.Join(", ", evaluation.MisconceptionsTriggeredIds.Select(id => $"CM-{id}"))}"); - if (!string.IsNullOrWhiteSpace(evaluation.NovelMisconceptions)) - sb.AppendLine($"Novel misconceptions: {evaluation.NovelMisconceptions}"); - sb.AppendLine(); - - sb.AppendLine("Produce the TUTOR's critique of the latest LEARNER turn per the system prompt."); - return sb.ToString(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentClassifierAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentClassifierAgent.cs deleted file mode 100644 index 4617c788b..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/IntentClassifier/IntentClassifierAgent.cs +++ /dev/null @@ -1,33 +0,0 @@ -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.Orchestration.Agents; - -namespace Tutor.Elaborations.Infrastructure.Agents.IntentClassifier; - -public class IntentClassifierAgent : StructuredAgent, IIntentClassifier -{ - public IntentClassifierAgent(IAiChatService chatService, ILogger logger) - : base(chatService, logger) { } - - public Task> ClassifyAsync( - string content, List history, - ConceptElaborationTask task, CancellationToken ct) - { - var systemPrompt = IntentPromptBuilder.BuildSystemPrompt(task); - var userMessage = IntentPromptBuilder.BuildUserMessage(content, history); - - return CompleteJsonAsync( - systemPrompt, userMessage, maxTokens: 64, temperature: 0.0, - validateAndMap: r => Enum.TryParse(r.Intent, ignoreCase: true, out var intent) - ? Result.Ok(intent) - : Result.Fail("Unrecognized intent."), - failureMessage: "Intent classification failed.", - ct); - } - - private class IntentResponse { public string? Intent { get; set; } } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs deleted file mode 100644 index 75d4e2456..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpAgent.cs +++ /dev/null @@ -1,23 +0,0 @@ -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.UseCases.Learning.Orchestration; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -namespace Tutor.Elaborations.Infrastructure.Agents.MetaHelp; - -public class MetaHelpAgent : StreamingAgent, IMetaHelpAgent -{ - public MetaHelpAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) - : base(chatService, usageTracker, logger) { } - - public IAsyncEnumerable StreamAsync( - ConceptElaborationTask task, string progressLine, ProbeDirective? nextTarget, - CancellationToken ct) - { - var systemPrompt = MetaHelpPromptBuilder.BuildSystemPrompt(task, progressLine, nextTarget); - var userMessage = MetaHelpPromptBuilder.BuildUserMessage(); - return StreamAsync(systemPrompt, userMessage, maxTokens: 256, temperature: 0.5, ct); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpPromptBuilder.cs deleted file mode 100644 index 6c2e7d2ca..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/MetaHelp/MetaHelpPromptBuilder.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -namespace Tutor.Elaborations.Infrastructure.Agents.MetaHelp; - -public static class MetaHelpPromptBuilder -{ - public static string BuildSystemPrompt( - ConceptElaborationTask task, string progressLine, ProbeDirective? nextTarget) - { - var sb = new StringBuilder(); - sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); - sb.AppendLine("The learner asked a meta/procedural question about the conversation itself (e.g. \"rezimiraj šta sam rekao\", \"koliko mi je ostalo\")."); - sb.AppendLine(); - sb.AppendLine("## Rules:"); - sb.AppendLine("- Open with the pre-rendered progress line exactly as given. Do not invent numbers or substitute it."); - sb.AppendLine("- Then invite the learner to address the one remaining gap with a short, narrow question or cue."); - sb.AppendLine("- Three sentences maximum."); - sb.AppendLine("- NEVER restate the concept, enumerate covered points, or reveal any KP/KR text."); - sb.AppendLine("- Do not produce a list or a summary of what the learner said — only the progress line plus a pivot."); - sb.AppendLine(); - sb.AppendLine($"## Concept: {task.Title}"); - sb.AppendLine(); - sb.AppendLine("## Pre-rendered progress line (use verbatim as the opener):"); - sb.AppendLine(progressLine); - - if (nextTarget != null) - { - var targetText = ResolveTargetStatement(task, nextTarget); - sb.AppendLine(); - sb.AppendLine("## Next target (INTERNAL — never reveal this text or a paraphrase):"); - sb.AppendLine($"[{nextTarget.TargetType}-{nextTarget.TargetId}] {targetText}"); - } - - return sb.ToString(); - } - - public static string BuildUserMessage() => - "Produce the TUTOR's meta-help response per the system prompt."; - - private static string ResolveTargetStatement(ConceptElaborationTask task, ProbeDirective directive) - { - if (directive.TargetType == ProbeTargetType.KeyProposition) - { - var kp = task.KeyPropositions.FirstOrDefault(p => p.Id == directive.TargetId); - return kp?.Statement ?? "(unknown)"; - } - var kr = task.KeyRelations.FirstOrDefault(r => r.Id == directive.TargetId); - if (kr == null) return "(unknown)"; - var kpById = task.KeyPropositions.ToDictionary(p => p.Id, p => p.Statement); - var source = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, "?"); - var target = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, "?"); - return $"{source} → {target}. Mechanism: {kr.Mechanism}"; - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs deleted file mode 100644 index c93a68601..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbeAgent.cs +++ /dev/null @@ -1,24 +0,0 @@ -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.Orchestration; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -namespace Tutor.Elaborations.Infrastructure.Agents.Probe; - -public class ProbeAgent : StreamingAgent, IProbeAgent -{ - public ProbeAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) - : base(chatService, usageTracker, logger) { } - - public IAsyncEnumerable StreamAsync( - ProbeDirective directive, ConversationAttempt attempt, ConceptElaborationTask task, - CancellationToken ct) - { - var systemPrompt = ProbePromptBuilder.BuildSystemPrompt(task, directive, attempt.IsSoftCapReached()); - var userMessage = ProbePromptBuilder.BuildUserMessage(attempt.Turns.ToList()); - return StreamAsync(systemPrompt, userMessage, maxTokens: 256, temperature: 0.7, ct); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbePromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbePromptBuilder.cs deleted file mode 100644 index 824f22541..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Probe/ProbePromptBuilder.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -namespace Tutor.Elaborations.Infrastructure.Agents.Probe; - -public static class ProbePromptBuilder -{ - public static string BuildSystemPrompt( - ConceptElaborationTask task, ProbeDirective directive, bool isSoftCapReached) - { - var targetText = ResolveTargetStatement(task, directive); - - var sb = new StringBuilder(); - sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); - sb.AppendLine("Ask ONE question that probes the single target below. Do not reveal the target text, do not list alternatives, do not summarize what the learner has said."); - sb.AppendLine(); - sb.AppendLine($"## Concept: {task.Title}"); - sb.AppendLine($"Definition: {task.CanonicalDefinition}"); - sb.AppendLine(); - sb.AppendLine("## Target (INTERNAL — never reveal this text or a paraphrase):"); - sb.AppendLine($"[{directive.TargetType}-{directive.TargetId}] {targetText}"); - sb.AppendLine(); - sb.AppendLine($"## Escalation level: L{directive.Level}"); - sb.AppendLine(directive.Level switch - { - 1 => "L1 — open \"why\" or \"what\" question that invites the learner to articulate the target. Broad enough to let them arrive at the idea themselves.", - 2 => "L2 — the learner has already failed an L1 probe on this target. Ask a narrower, connective question: \"how is X tied to Y\", \"what role does X play when Y\". Still no hints to the target statement.", - 3 => "L3 — the learner has failed twice. Offer a sentence-completion scaffold: \"Dopuni rečenicu: '…zato što…'\" or \"Dovrši misao: …\". The blank must not contain the target text.", - _ => "Open question at the lowest level." - }); - sb.AppendLine(); - sb.AppendLine("## Rules:"); - sb.AppendLine("- Output ONE question (or one sentence-completion prompt at L3). No preamble, no bullet list."); - sb.AppendLine("- NEVER reveal the target statement or a paraphrase close enough to give away the answer."); - sb.AppendLine("- NEVER list multiple options or enumerate gaps."); - sb.AppendLine("- Concise. Respect cognitive load."); - if (isSoftCapReached) - { - sb.AppendLine(); - sb.AppendLine("## The learner is approaching the end of the conversation. Signal that you'll wrap up once this is addressed."); - } - return sb.ToString(); - } - - public static string BuildUserMessage(List history) - { - var sb = new StringBuilder(); - sb.AppendLine("## Conversation so far"); - if (history.Count == 0) - { - sb.AppendLine("(no prior turns)"); - } - else - { - foreach (var turn in history) - { - var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; - sb.AppendLine($"[{label}]: {turn.Content}"); - } - } - sb.AppendLine(); - sb.AppendLine("Produce the TUTOR's next probe question per the system prompt."); - return sb.ToString(); - } - - private static string ResolveTargetStatement(ConceptElaborationTask task, ProbeDirective directive) - { - if (directive.TargetType == ProbeTargetType.KeyProposition) - { - var kp = task.KeyPropositions.FirstOrDefault(p => p.Id == directive.TargetId); - return kp?.Statement ?? "(unknown proposition)"; - } - - var kr = task.KeyRelations.FirstOrDefault(r => r.Id == directive.TargetId); - if (kr == null) return "(unknown relation)"; - var kpById = task.KeyPropositions.ToDictionary(p => p.Id, p => p.Statement); - var source = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, "?"); - var target = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, "?"); - return $"{source} → {target}. Mechanism: {kr.Mechanism}"; - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs deleted file mode 100644 index 09029e9ca..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Redirect/RedirectAgent.cs +++ /dev/null @@ -1,20 +0,0 @@ -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.UseCases.Learning.Orchestration.Agents; - -namespace Tutor.Elaborations.Infrastructure.Agents.Redirect; - -public class RedirectAgent : StreamingAgent, IRedirectAgent -{ - public RedirectAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) - : base(chatService, usageTracker, logger) { } - - public IAsyncEnumerable StreamAsync(ConceptElaborationTask task, CancellationToken ct) - { - var systemPrompt = RedirectPromptBuilder.BuildSystemPrompt(task); - var userMessage = RedirectPromptBuilder.BuildUserMessage(); - return StreamAsync(systemPrompt, userMessage, maxTokens: 128, temperature: 0.7, ct); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs deleted file mode 100644 index 163413f88..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingAgent.cs +++ /dev/null @@ -1,24 +0,0 @@ -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.Orchestration; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -namespace Tutor.Elaborations.Infrastructure.Agents.Scaffolding; - -public class ScaffoldingAgent : StreamingAgent, IScaffoldingAgent -{ - public ScaffoldingAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) - : base(chatService, usageTracker, logger) { } - - public IAsyncEnumerable StreamAsync( - ProbeDirective target, ConversationAttempt attempt, ConceptElaborationTask task, - CancellationToken ct) - { - var systemPrompt = ScaffoldingPromptBuilder.BuildSystemPrompt(task, target); - var userMessage = ScaffoldingPromptBuilder.BuildUserMessage(attempt); - return StreamAsync(systemPrompt, userMessage, maxTokens: 512, temperature: 0.7, ct); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingPromptBuilder.cs deleted file mode 100644 index da2f523c4..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scaffolding/ScaffoldingPromptBuilder.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -namespace Tutor.Elaborations.Infrastructure.Agents.Scaffolding; - -public static class ScaffoldingPromptBuilder -{ - public static string BuildSystemPrompt( - ConceptElaborationTask task, ProbeDirective target) - { - var targetText = ResolveTargetStatement(task, target); - - var sb = new StringBuilder(); - sb.AppendLine("You are a Socratic tutoring scaffolding agent. Speak Serbian."); - sb.AppendLine("The learner has stalled after repeated probes on one target. Provide a concrete structural scaffold that gives them a foothold WITHOUT stating the target."); - sb.AppendLine(); - sb.AppendLine($"## Concept: {task.Title}"); - sb.AppendLine($"Definition: {task.CanonicalDefinition}"); - sb.AppendLine(); - sb.AppendLine("## Target (INTERNAL — NEVER reveal or paraphrase this text; it must come from the learner):"); - sb.AppendLine($"[{target.TargetType}-{target.TargetId}] {targetText}"); - sb.AppendLine(); - sb.AppendLine("## Scaffolding options (pick ONE that fits best):"); - sb.AppendLine("1. **Forced choice** — offer two options, exactly one of which points toward the target, both phrased at the same level of abstraction. Ask the learner to choose and justify."); - sb.AppendLine("2. **Code skeleton with labeled blanks** — a minimal pseudo-code / test skeleton with `// ___` blanks labeled by role (e.g. `// pripremi očekivano`). Ask the learner to fill one blank."); - sb.AppendLine("3. **Analogy** — map the target to a simpler, non-technical domain. Present the analogy's structure and ask the learner to translate it back to the concept."); - sb.AppendLine(); - sb.AppendLine("## Rules:"); - sb.AppendLine("- Keep it short. A code skeleton with 3-4 labeled lines, or a two-option forced choice, or a 2-sentence analogy."); - sb.AppendLine("- NEVER state the target text or a paraphrase close enough to give it away. The scaffold must make the learner do the articulation."); - sb.AppendLine("- End with one concrete, narrow request (\"Koja opcija i zašto?\" / \"Šta ide na mestu ___?\" / \"Prevedi ovu analogiju na naš koncept.\")."); - sb.AppendLine("- No bullet lists beyond what the scaffold structurally requires."); - return sb.ToString(); - } - - public static string BuildUserMessage(ConversationAttempt attempt) - { - var sb = new StringBuilder(); - sb.AppendLine("## Conversation so far (so you can see the learner's prior attempts on this target)"); - foreach (var turn in attempt.Turns) - { - var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; - sb.AppendLine($"[{label}]: {turn.Content}"); - } - sb.AppendLine(); - sb.AppendLine("Produce the TUTOR's scaffold per the system prompt."); - return sb.ToString(); - } - - private static string ResolveTargetStatement(ConceptElaborationTask task, ProbeDirective directive) - { - if (directive.TargetType == ProbeTargetType.KeyProposition) - { - var kp = task.KeyPropositions.FirstOrDefault(p => p.Id == directive.TargetId); - return kp?.Statement ?? "(unknown)"; - } - var kr = task.KeyRelations.FirstOrDefault(r => r.Id == directive.TargetId); - if (kr == null) return "(unknown)"; - var kpById = task.KeyPropositions.ToDictionary(p => p.Id, p => p.Statement); - var source = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, "?"); - var target = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, "?"); - return $"{source} → {target}. Mechanism: {kr.Mechanism}"; - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerAgent.cs deleted file mode 100644 index 11b56d74f..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerAgent.cs +++ /dev/null @@ -1,71 +0,0 @@ -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.Orchestration.Agents; - -namespace Tutor.Elaborations.Infrastructure.Agents.Scorer; - -public class ScorerAgent : StructuredAgent, IScorer -{ - public ScorerAgent(IAiChatService chatService, ILogger logger) - : base(chatService, logger) { } - - public Task> ScoreAsync( - string content, List history, - ConceptElaborationTask task, CancellationToken ct) - { - var systemPrompt = ScorerPromptBuilder.BuildSystemPrompt(task); - var userMessage = ScorerPromptBuilder.BuildUserMessage(content, history); - - return CompleteJsonAsync( - systemPrompt, userMessage, maxTokens: 1024, temperature: 0.0, - validateAndMap: r => MapToEvaluation(r, task), - failureMessage: "Scoring failed.", - ct); - } - - private static Result MapToEvaluation(ScorerResponse parsed, ConceptElaborationTask task) - { - if (parsed.CorrectnessScore is < 1 or > 3) return Result.Fail("Correctness out of range."); - if (parsed.CompletenessScore is < 1 or > 3) return Result.Fail("Completeness out of range."); - if (parsed.DiscriminationScore is not null and (< 1 or > 3)) return Result.Fail("Discrimination out of range."); - if (parsed.IntegrationScore is not null and (< 1 or > 3)) return Result.Fail("Integration out of range."); - - var validKpIds = task.KeyPropositions.Select(kp => kp.Id).ToHashSet(); - var validKrIds = task.KeyRelations.Select(kr => kr.Id).ToHashSet(); - var validCmIds = task.CommonMisconceptions.Select(cm => cm.Id).ToHashSet(); - - if (parsed.PropositionsCoveredIds?.Any(id => !validKpIds.Contains(id)) == true) - return Result.Fail("Unknown proposition id."); - if (parsed.RelationsArticulatedIds?.Any(id => !validKrIds.Contains(id)) == true) - return Result.Fail("Unknown relation id."); - if (parsed.MisconceptionsTriggeredIds?.Any(id => !validCmIds.Contains(id)) == true) - return Result.Fail("Unknown misconception id."); - - return new TurnEvaluation( - parsed.CorrectnessScore, parsed.CompletenessScore, - parsed.DiscriminationScore, parsed.IntegrationScore, - parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, - parsed.PropositionsCoveredIds ?? new List(), - parsed.MisconceptionsTriggeredIds ?? new List(), - parsed.RelationsArticulatedIds ?? new List(), - parsed.HasMultipleConcerns ?? false); - } - - private class ScorerResponse - { - public int CorrectnessScore { get; set; } - public int CompletenessScore { get; set; } - public int? DiscriminationScore { get; set; } - public int? IntegrationScore { get; set; } - public string? Justification { get; set; } - public List? PropositionsCoveredIds { get; set; } - public List? MisconceptionsTriggeredIds { get; set; } - public List? RelationsArticulatedIds { get; set; } - public string? NovelMisconceptions { get; set; } - public bool? HasMultipleConcerns { get; set; } - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerPromptBuilder.cs deleted file mode 100644 index 7d320ef35..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Scorer/ScorerPromptBuilder.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Infrastructure.Agents.Scorer; - -public static class ScorerPromptBuilder -{ - public static string BuildSystemPrompt(ConceptElaborationTask task) - { - var hasBoundaryConditions = task.BoundaryConditions.Count != 0; - var hasCommonMisconceptions = task.CommonMisconceptions.Count != 0; - var hasKeyRelations = task.KeyRelations.Count != 0; - - var sb = new StringBuilder(); - sb.AppendLine("You are a scoring agent for a Socratic tutoring system."); - sb.AppendLine("The learner's latest message is known to be Substantive (an attempt at explanation). Score it against the concept rubric and tag which propositions/relations/misconceptions it hits. Output JSON only, no other text."); - sb.AppendLine(); - sb.AppendLine($"## Concept: {task.Title}"); - sb.AppendLine($"Definition: {task.CanonicalDefinition}"); - sb.AppendLine(); - - sb.AppendLine("## Key Propositions:"); - foreach (var kp in task.KeyPropositions) - sb.AppendLine($"ID={kp.Id} {kp.Statement}"); - sb.AppendLine(); - - if (hasBoundaryConditions) - { - sb.AppendLine("## Boundary Conditions:"); - foreach (var bc in task.BoundaryConditions) - sb.AppendLine($"ID={bc.Id} {bc.Statement}"); - sb.AppendLine(); - } - - if (hasCommonMisconceptions) - { - sb.AppendLine("## Common Misconceptions:"); - foreach (var cm in task.CommonMisconceptions.Take(8)) - sb.AppendLine($"ID={cm.Id} {cm.Description} → Correction: {cm.Correction}"); - sb.AppendLine(); - } - - if (hasKeyRelations) - { - sb.AppendLine("## Key Relations:"); - var kpById = task.KeyPropositions.ToDictionary(kp => kp.Id, kp => kp.Statement); - foreach (var kr in task.KeyRelations) - { - var sourceText = kpById.GetValueOrDefault(kr.SourceKeyPropositionId, $"KP-{kr.SourceKeyPropositionId}"); - var targetText = kpById.GetValueOrDefault(kr.TargetKeyPropositionId, $"KP-{kr.TargetKeyPropositionId}"); - sb.AppendLine($"ID={kr.Id} {sourceText} → {targetText}. Mechanism: {kr.Mechanism}"); - } - sb.AppendLine(); - } - - sb.AppendLine("## Scope rule"); - sb.AppendLine("Score only the message demarcated as '## Message to score'. Do not credit the learner for content that appears in [TUTOR] lines or that the learner has only repeated from a preceding [TUTOR] line."); - sb.AppendLine(); - - sb.AppendLine("## Rubric:"); - sb.AppendLine(hasBoundaryConditions - ? "- Correctness (1-3): Are stated claims true? Check against KPs and BCs." - : "- Correctness (1-3): Are stated claims true? Check against KPs."); - sb.AppendLine("- Completeness (1-3): Are essential KPs covered in THIS message?"); - if (hasBoundaryConditions) - sb.AppendLine("- Discrimination (1-3): Does the explanation correctly exclude non-examples? Check BCs."); - if (hasKeyRelations) - sb.AppendLine("- Integration (1-3): Did the learner articulate key relations *with mechanism*? 1=no relation, 2=relation without mechanism, 3=relation with mechanism matching the authored description."); - sb.AppendLine("- Evaluate concepts, not language. Grammar and style must not reduce scores."); - sb.AppendLine("- Resist sycophancy. Evaluate strictly against rubric."); - sb.AppendLine(); - - sb.AppendLine("## Concern count (used by the orchestrator to route to critique vs probe):"); - sb.AppendLine("Count distinct concerns in the message. A concern is any of:"); - sb.AppendLine(" - a stated inaccuracy (a claim that contradicts a KP or BC);"); - sb.AppendLine(hasCommonMisconceptions - ? " - a triggered known misconception or a novel misconception;" - : " - a novel misconception (none are pre-catalogued for this concept);"); - sb.AppendLine(" - a vague or hand-wavy claim that references a KP without articulating it."); - sb.AppendLine("Set hasMultipleConcerns=true if the count is two or more; false otherwise."); - sb.AppendLine(); - - sb.AppendLine("## Output Format (JSON only, no other text):"); - var fields = new List - { - "\"correctnessScore\": 1-3", - "\"completenessScore\": 1-3" - }; - if (hasBoundaryConditions) fields.Add("\"discriminationScore\": 1-3"); - if (hasKeyRelations) fields.Add("\"integrationScore\": 1-3"); - fields.Add("\"justification\": \"brief explanation of scores\""); - fields.Add("\"propositionsCoveredIds\": [number list of KP IDs covered in this turn]"); - if (hasCommonMisconceptions) fields.Add("\"misconceptionsTriggeredIds\": [number list of CM IDs triggered]"); - if (hasKeyRelations) fields.Add("\"relationsArticulatedIds\": [number list of KR IDs articulated with mechanism this turn]"); - if (hasCommonMisconceptions) fields.Add("\"novelMisconceptions\": \"any misconceptions not in the list, or null\""); - fields.Add("\"hasMultipleConcerns\": true|false"); - - sb.AppendLine("{"); - sb.AppendLine(string.Join(",\n", fields.Select(f => " " + f))); - sb.AppendLine("}"); - return sb.ToString(); - } - - public static string BuildUserMessage(string learnerContent, List history) - { - var sb = new StringBuilder(); - sb.AppendLine("## Conversation so far (for context only — DO NOT score this)"); - if (history.Count == 0) - { - sb.AppendLine("(no prior turns)"); - } - else - { - foreach (var turn in history) - { - var label = turn.Role == TurnRole.Learner ? "LEARNER" : "TUTOR"; - sb.AppendLine($"[{label}]: {turn.Content}"); - } - } - sb.AppendLine(); - sb.AppendLine("## Message to score (this is the ONLY message you are scoring)"); - sb.AppendLine($"[LEARNER]: {learnerContent}"); - sb.AppendLine(); - sb.AppendLine("Score only the final [LEARNER] message under '## Message to score'."); - return sb.ToString(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs deleted file mode 100644 index e2b1cd0be..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryAgent.cs +++ /dev/null @@ -1,40 +0,0 @@ -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.Orchestration.Agents; - -namespace Tutor.Elaborations.Infrastructure.Agents.Summary; - -public class SummaryAgent : StreamingAgent, ISummaryAgent -{ - public SummaryAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) - : base(chatService, usageTracker, logger) { } - - public async Task> SummarizeAsync( - ConversationAttempt attempt, ConceptElaborationTask task, CancellationToken ct) - { - var systemPrompt = SummaryPromptBuilder.BuildSystemPrompt(attempt, task); - var transcript = SummaryPromptBuilder.BuildTranscript(attempt); - - var buffer = new StringBuilder(); - await foreach (var chunk in StreamAsync(systemPrompt, transcript, maxTokens: 256, temperature: 0.5, ct)) - { - switch (chunk) - { - case StreamToken token: - buffer.Append(token.Content); - break; - case StreamFailure failure: - return Result.Fail(failure.Reason); - } - } - - return buffer.Length > 0 - ? Result.Ok(buffer.ToString()) - : Result.Fail("Summary generation failed."); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryPromptBuilder.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryPromptBuilder.cs deleted file mode 100644 index 9e815320b..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/Summary/SummaryPromptBuilder.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Infrastructure.Agents.Summary; - -public static class SummaryPromptBuilder -{ - public static string BuildSystemPrompt(ConversationAttempt attempt, ConceptElaborationTask task) - { - var sb = new StringBuilder(); - sb.AppendLine("You are a summary agent. Write a brief natural-language summary of the conversation."); - sb.AppendLine("Paraphrase what the learner demonstrated understanding of. Never quote proposition statements verbatim."); - sb.AppendLine("Write in Serbian. Keep the summary to 2-4 sentences."); - sb.AppendLine(); - sb.AppendLine($"Concept: {task.Title}"); - - var coveredIds = attempt.GetCoveredPropositionIds(); - var covered = task.KeyPropositions.Where(kp => coveredIds.Contains(kp.Id)).ToList(); - if (covered.Count > 0) - { - sb.AppendLine("Propositions the learner covered (paraphrase, do not quote):"); - foreach (var kp in covered) - sb.AppendLine($"- {kp.Statement}"); - } - - return sb.ToString(); - } - - public static string BuildTranscript(ConversationAttempt attempt) - { - var sb = new StringBuilder(); - foreach (var turn in attempt.Turns.OrderBy(t => t.Order)) - { - var role = turn.Role == TurnRole.Learner ? "Learner" : "System"; - sb.AppendLine($"{role}: {turn.Content}"); - } - return sb.ToString(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs index 4f323f125..aa2c5faca 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.ConceptRecords; using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Infrastructure.Database; @@ -7,10 +8,7 @@ namespace Tutor.Elaborations.Infrastructure.Database; public class ElaborationsContext : DbContext { public DbSet ConceptElaborationTasks { get; set; } - public DbSet KeyPropositions { get; set; } - public DbSet BoundaryConditions { get; set; } - public DbSet CommonMisconceptions { get; set; } - public DbSet KeyRelations { get; set; } + public DbSet ConceptRecords { get; set; } public DbSet ConversationAttempts { get; set; } public DbSet ConversationTurns { get; set; } public DbSet TurnEvaluations { get; set; } @@ -22,45 +20,31 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.HasDefaultSchema("elaborations"); ConfigureConceptElaborationTasks(modelBuilder); + ConfigureConceptRecords(modelBuilder); ConfigureConversations(modelBuilder); } private static void ConfigureConceptElaborationTasks(ModelBuilder modelBuilder) { - modelBuilder.Entity() - .HasMany(cet => cet.KeyPropositions) - .WithOne() - .HasForeignKey(kp => kp.ConceptElaborationTaskId); - - modelBuilder.Entity() - .HasMany(cet => cet.BoundaryConditions) - .WithOne() - .HasForeignKey(bc => bc.ConceptElaborationTaskId); - - modelBuilder.Entity() - .HasMany(cet => cet.CommonMisconceptions) - .WithOne() - .HasForeignKey(cm => cm.ConceptElaborationTaskId); - - modelBuilder.Entity() - .HasMany(cet => cet.KeyRelations) - .WithOne() - .HasForeignKey(kr => kr.ConceptElaborationTaskId); - - modelBuilder.Entity() - .HasIndex(cet => new { cet.UnitId, cet.Order }); - - modelBuilder.Entity() - .HasOne(kr => kr.SourceKeyProposition) - .WithMany() - .HasForeignKey(kr => kr.SourceKeyPropositionId) - .OnDelete(DeleteBehavior.ClientNoAction); + modelBuilder.Entity(entity => + { + entity.HasIndex(cet => new { cet.UnitId, cet.Order }); + entity.HasOne(cet => cet.ConceptRecord) + .WithOne() + .HasForeignKey(r => r.ConceptElaborationTaskId) + .OnDelete(DeleteBehavior.Cascade); + }); + } - modelBuilder.Entity() - .HasOne(kr => kr.TargetKeyProposition) - .WithMany() - .HasForeignKey(kr => kr.TargetKeyPropositionId) - .OnDelete(DeleteBehavior.ClientNoAction); + private static void ConfigureConceptRecords(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.Property(r => r.KeyPropositions).HasColumnType("jsonb"); + entity.Property(r => r.BoundaryConditions).HasColumnType("jsonb"); + entity.Property(r => r.CommonMisconceptions).HasColumnType("jsonb"); + entity.Property(r => r.KeyRelations).HasColumnType("jsonb"); + }); } private static void ConfigureConversations(ModelBuilder modelBuilder) @@ -83,11 +67,11 @@ private static void ConfigureConversations(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { - entity.Property(te => te.PropositionsCoveredIds) + entity.Property(te => te.PropositionsCoveredKeys) .HasColumnType("jsonb"); - entity.Property(te => te.MisconceptionsTriggeredIds) + entity.Property(te => te.MisconceptionsTriggeredKeys) .HasColumnType("jsonb"); - entity.Property(te => te.RelationsArticulatedIds) + entity.Property(te => te.RelationsArticulatedKeys) .HasColumnType("jsonb"); }); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptElaborationTaskDatabaseRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptElaborationTaskDatabaseRepository.cs index 99f45f02b..b3653e759 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptElaborationTaskDatabaseRepository.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptElaborationTaskDatabaseRepository.cs @@ -9,23 +9,25 @@ public class ConceptElaborationTaskDatabaseRepository : { public ConceptElaborationTaskDatabaseRepository(ElaborationsContext dbContext) : base(dbContext) { } - public new ConceptElaborationTask? Get(int id) + public ConceptElaborationTask? GetWithRecord(int id) { return DbContext.ConceptElaborationTasks - .Include(cet => cet.KeyPropositions) - .Include(cet => cet.BoundaryConditions) - .Include(cet => cet.CommonMisconceptions) - .Include(cet => cet.KeyRelations) + .Include(cet => cet.ConceptRecord) .FirstOrDefault(cet => cet.Id == id); } public List GetByUnit(int unitId) { return DbContext.ConceptElaborationTasks - .Include(cet => cet.KeyPropositions) - .Include(cet => cet.BoundaryConditions) - .Include(cet => cet.CommonMisconceptions) - .Include(cet => cet.KeyRelations) + .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/ElaborationsStartup.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs index 7e3a2e869..63ccebf33 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs @@ -16,16 +16,7 @@ using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; using Tutor.Elaborations.Core.UseCases.Monitoring; -using Tutor.Elaborations.Infrastructure.Agents.Clarification; -using Tutor.Elaborations.Infrastructure.Agents.Closing; -using Tutor.Elaborations.Infrastructure.Agents.Critique; -using Tutor.Elaborations.Infrastructure.Agents.IntentClassifier; -using Tutor.Elaborations.Infrastructure.Agents.MetaHelp; -using Tutor.Elaborations.Infrastructure.Agents.Probe; -using Tutor.Elaborations.Infrastructure.Agents.Redirect; -using Tutor.Elaborations.Infrastructure.Agents.Scaffolding; -using Tutor.Elaborations.Infrastructure.Agents.Scorer; -using Tutor.Elaborations.Infrastructure.Agents.Summary; +using Tutor.Elaborations.Infrastructure.Agents; using Tutor.Elaborations.Infrastructure.Database; using Tutor.Elaborations.Infrastructure.Database.Repositories; @@ -60,18 +51,8 @@ private static void SetupInfrastructure(IServiceCollection services) services.AddScoped(); services.AddScoped(); - - services.AddScoped(); - services.AddScoped(); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs index 61261cbf6..0deddbe21 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs @@ -50,8 +50,8 @@ public void SetupDefaultMocks() SetupSummaryMock(); } - public void SetupEvaluationMock(List? propositionsCoveredIds = null, - List? relationsArticulatedIds = null, + public void SetupEvaluationMock(List? propositionsCoveredKeys = null, + List? relationsArticulatedKeys = null, int? discriminationScore = 2, int? integrationScore = null, string intent = "Substantive") { @@ -59,7 +59,7 @@ public void SetupEvaluationMock(List? propositionsCoveredIds = null, if (intent != "Substantive") return; - var scorerJson = BuildSubstantiveEvalJson(propositionsCoveredIds, relationsArticulatedIds, discriminationScore, integrationScore); + var scorerJson = BuildSubstantiveEvalJson(propositionsCoveredKeys, relationsArticulatedKeys, discriminationScore, integrationScore); MockChatService.Setup(x => x.CompleteAsync( It.Is(r => r.MaxTokens == 1024), It.IsAny())) .ReturnsAsync(Result.Ok(new CompletionResponse @@ -80,14 +80,14 @@ public void SetupIntentMock(string intent = "Substantive") })); } - private static string BuildSubstantiveEvalJson(List? propositionsCoveredIds, - List? relationsArticulatedIds, int? discriminationScore, int? integrationScore) + private static string BuildSubstantiveEvalJson(List? propositionsCoveredKeys, + List? relationsArticulatedKeys, int? discriminationScore, int? integrationScore) { - var coveredIds = propositionsCoveredIds != null && propositionsCoveredIds.Count > 0 - ? string.Join(",", propositionsCoveredIds) + var coveredKeys = propositionsCoveredKeys != null && propositionsCoveredKeys.Count > 0 + ? string.Join(",", propositionsCoveredKeys.Select(k => $"\"{k}\"")) : ""; - var articulatedIds = relationsArticulatedIds != null && relationsArticulatedIds.Count > 0 - ? string.Join(",", relationsArticulatedIds) + var articulatedKeys = relationsArticulatedKeys != null && relationsArticulatedKeys.Count > 0 + ? string.Join(",", relationsArticulatedKeys.Select(k => $"\"{k}\"")) : ""; var discriminationJson = discriminationScore.HasValue ? discriminationScore.Value.ToString() : "null"; var integrationJson = integrationScore.HasValue ? integrationScore.Value.ToString() : "null"; @@ -100,9 +100,9 @@ private static string BuildSubstantiveEvalJson(List? propositionsCoveredIds "discriminationScore": {{discriminationJson}}, "integrationScore": {{integrationJson}}, "justification": "Good explanation of the concept.", - "propositionsCoveredIds": [{{coveredIds}}], - "misconceptionsTriggeredIds": [], - "relationsArticulatedIds": [{{articulatedIds}}], + "propositionsCoveredKeys": [{{coveredKeys}}], + "misconceptionsTriggeredKeys": [], + "relationsArticulatedKeys": [{{articulatedKeys}}], "novelMisconceptions": null } """; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs index 9c60cca12..1bcc179a9 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs @@ -24,21 +24,24 @@ public void Creates() UnitId = -1, Order = 10, Title = "New Concept", - CanonicalDefinition = "A new concept definition.", - KeyPropositions = new List + ConceptRecord = new ConceptRecordDto { - new() { Statement = "First proposition" }, - new() { Statement = "Second proposition" } - }, - BoundaryConditions = new List - { - new() { Statement = "A boundary condition" } - }, - CommonMisconceptions = new List - { - new() { Description = "A misconception", Correction = "The correction" } - }, - KeyRelations = new List() + CanonicalDefinition = "A new concept definition.", + KeyPropositions = new List + { + new() { Key = "P1", Statement = "First proposition" }, + new() { Key = "P2", Statement = "Second proposition" } + }, + BoundaryConditions = new List + { + new() { Key = "B1", Statement = "A boundary condition" } + }, + CommonMisconceptions = new List + { + new() { Key = "M1", Description = "A misconception", Correction = "The correction" } + }, + KeyRelations = new List() + } }; dbContext.Database.BeginTransaction(); @@ -50,10 +53,10 @@ public void Creates() result.Title.ShouldBe(newEntity.Title); result.UnitId.ShouldBe(-1); result.Order.ShouldBe(10); - result.KeyPropositions.Count.ShouldBe(2); - result.BoundaryConditions.Count.ShouldBe(1); - result.CommonMisconceptions.Count.ShouldBe(1); - result.KeyRelations.Count.ShouldBe(0); + result.ConceptRecord.KeyPropositions.Count.ShouldBe(2); + result.ConceptRecord.BoundaryConditions.Count.ShouldBe(1); + result.ConceptRecord.CommonMisconceptions.Count.ShouldBe(1); + result.ConceptRecord.KeyRelations.Count.ShouldBe(0); } [Fact] @@ -67,20 +70,23 @@ public void Creates_with_relations() UnitId = -1, Order = 11, Title = "Concept With Relations", - CanonicalDefinition = "A concept created with KPs and KRs in one request.", - KeyPropositions = new List + ConceptRecord = new ConceptRecordDto { - new() { Statement = "First proposition" }, - new() { Statement = "Second proposition" } - }, - BoundaryConditions = new List(), - CommonMisconceptions = new List(), - KeyRelations = new List - { - new() + 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" } + }, + BoundaryConditions = new List(), + CommonMisconceptions = new List(), + KeyRelations = new List { - SourceKeyPropositionIndex = 0, TargetKeyPropositionIndex = 1, - Mechanism = "First enables second" + new() + { + Key = "R1", SourceKey = "P1", TargetKey = "P2", + Mechanism = "First enables second" + } } } }; @@ -91,13 +97,11 @@ public void Creates_with_relations() dbContext.ChangeTracker.Clear(); result.ShouldNotBeNull(); - result.KeyPropositions.Count.ShouldBe(2); - result.KeyRelations.Count.ShouldBe(1); - result.KeyRelations[0].Mechanism.ShouldBe("First enables second"); - result.KeyRelations[0].SourceKeyPropositionId.ShouldNotBe(0); - result.KeyRelations[0].TargetKeyPropositionId.ShouldNotBe(0); - result.KeyRelations[0].SourceKeyPropositionId.ShouldNotBe( - result.KeyRelations[0].TargetKeyPropositionId); + 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] @@ -112,14 +116,17 @@ public void Updates() UnitId = -1, Order = 1, Title = "Updated Encapsulation", - CanonicalDefinition = "Updated definition.", - KeyPropositions = new List + ConceptRecord = new ConceptRecordDto { - new() { Statement = "Updated proposition" } - }, - BoundaryConditions = new List(), - CommonMisconceptions = new List(), - KeyRelations = new List() + CanonicalDefinition = "Updated definition.", + KeyPropositions = new List + { + new() { Key = "P1", Statement = "Updated proposition" } + }, + BoundaryConditions = new List(), + CommonMisconceptions = new List(), + KeyRelations = new List() + } }; dbContext.Database.BeginTransaction(); @@ -130,43 +137,46 @@ public void Updates() result.ShouldNotBeNull(); result.Id.ShouldBe(-1); result.Title.ShouldBe("Updated Encapsulation"); - result.KeyPropositions.Count.ShouldBe(1); - result.KeyPropositions[0].Statement.ShouldBe("Updated proposition"); + result.ConceptRecord.KeyPropositions.Count.ShouldBe(1); + result.ConceptRecord.KeyPropositions[0].Statement.ShouldBe("Updated proposition"); } [Fact] - public void Updates_relations_with_indices() + 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 -70, -71 and KR -370 (source=-70, target=-71). + // 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", - CanonicalDefinition = "Polymorphism resolves method calls at runtime via dynamic dispatch.", - KeyPropositions = new List + ConceptRecord = new ConceptRecordDto { - new() { Id = -70, Statement = "A subclass can override a parent method" }, - new() { Id = -71, Statement = "The runtime selects the implementation by the actual type" }, - new() { Statement = "Dispatch table resolves virtual calls" } - }, - BoundaryConditions = new List(), - CommonMisconceptions = new List(), - KeyRelations = new List - { - new() + CanonicalDefinition = "Polymorphism resolves method calls at runtime via dynamic dispatch.", + KeyPropositions = new List { - SourceKeyPropositionIndex = 0, TargetKeyPropositionIndex = 1, - Mechanism = "Override matters because dispatch happens at runtime" + 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" } }, - new() + BoundaryConditions = new List(), + CommonMisconceptions = new List(), + KeyRelations = new List { - SourceKeyPropositionIndex = 1, TargetKeyPropositionIndex = 2, - Mechanism = "Runtime dispatch uses vtable lookup" + 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" + } } } }; @@ -177,10 +187,10 @@ public void Updates_relations_with_indices() dbContext.ChangeTracker.Clear(); result.ShouldNotBeNull(); - result.KeyPropositions.Count.ShouldBe(3); - result.KeyRelations.Count.ShouldBe(2); - result.KeyRelations.ShouldContain(kr => kr.Mechanism.Contains("dispatch happens at runtime")); - result.KeyRelations.ShouldContain(kr => kr.Mechanism.Contains("vtable lookup")); + 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] @@ -189,21 +199,24 @@ 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 -70, -71 and KR -370. Remove KR and KP -71, keeping only KP -70. + // 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", - CanonicalDefinition = "Polymorphism resolves method calls at runtime via dynamic dispatch.", - KeyPropositions = new List + ConceptRecord = new ConceptRecordDto { - new() { Id = -70, Statement = "A subclass can override a parent method" } - }, - BoundaryConditions = new List(), - CommonMisconceptions = new List(), - KeyRelations = new List() + CanonicalDefinition = "Polymorphism resolves method calls at runtime via dynamic dispatch.", + KeyPropositions = new List + { + new() { Key = "P1", Statement = "A subclass can override a parent method" } + }, + BoundaryConditions = new List(), + CommonMisconceptions = new List(), + KeyRelations = new List() + } }; dbContext.Database.BeginTransaction(); @@ -212,8 +225,8 @@ public void Removes_relation_and_referenced_kp() dbContext.ChangeTracker.Clear(); result.ShouldNotBeNull(); - result.KeyPropositions.Count.ShouldBe(1); - result.KeyRelations.Count.ShouldBe(0); + result.ConceptRecord.KeyPropositions.Count.ShouldBe(1); + result.ConceptRecord.KeyRelations.Count.ShouldBe(0); } [Fact] @@ -231,6 +244,8 @@ public void Deletes() 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] @@ -256,11 +271,14 @@ public void Non_owner_fails_to_create() UnitId = -3, Order = 99, Title = "Should Fail", - CanonicalDefinition = "Fail", - KeyPropositions = new List(), - BoundaryConditions = new List(), - CommonMisconceptions = new List(), - KeyRelations = new List() + ConceptRecord = new ConceptRecordDto + { + CanonicalDefinition = "Fail", + KeyPropositions = new List(), + BoundaryConditions = new List(), + CommonMisconceptions = new List(), + KeyRelations = new List() + } }; var actionResult = controller.Create(-3, newEntity).Result; @@ -281,11 +299,14 @@ public void Non_owner_fails_to_update() UnitId = -3, Order = 1, Title = "Should Fail", - CanonicalDefinition = "Fail", - KeyPropositions = new List(), - BoundaryConditions = new List(), - CommonMisconceptions = new List(), - KeyRelations = new List() + ConceptRecord = new ConceptRecordDto + { + CanonicalDefinition = "Fail", + KeyPropositions = new List(), + BoundaryConditions = new List(), + CommonMisconceptions = new List(), + KeyRelations = new List() + } }; var actionResult = controller.Update(-3, -4, updatedEntity).Result; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs index c63718e4e..0c48f3aa4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs @@ -52,7 +52,7 @@ public void Gets_task_detail() result.ShouldNotBeNull(); result.Id.ShouldBe(-1); result.Title.ShouldNotBeNullOrEmpty(); - result.CanonicalDefinition.ShouldNotBeNullOrEmpty(); + result.ConceptRecord.CanonicalDefinition.ShouldNotBeNullOrEmpty(); result.Attempts.ShouldNotBeNull(); result.Attempts.Count.ShouldBe(2); result.Attempts.Any(a => a.Status == "Completed").ShouldBeTrue(); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs index 6e63f2c22..bf8e6efc7 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -13,17 +13,17 @@ namespace Tutor.Elaborations.Tests.Integration.Learning; -// Test data layout: -// CET -1: Encapsulation (Basics), Unit -1 (1 KP: -10) -// CET -2: Encapsulation (Members), Unit -1 (2 KPs: -20, -21) -// CET -3: Encapsulation (Basics — Unit 2), Unit -2 (1 KP: -30) -// CET -5: Encapsulation (Members — Unit 2), Unit -2 (2 KPs: -50, -51) — isolated for StartConversation -// CET -6: Encapsulation (Invariants), Unit -2 (3 KPs: -60, -61, -62) — isolated for Start+Submit flow -// CET -7: Polymorphism Mechanics, Unit -2 (2 KPs: -70, -71 + KR -370) — isolated +// Test data layout (each task owns its own natural keys: P1, P2, R1, ...): +// CET -1: Encapsulation (Basics), Unit -1 (P1, B1, M1) +// CET -2: Encapsulation (Members), Unit -1 (P1, P2, B1, B2, M1, M2) +// CET -3: Encapsulation (Basics — Unit 2), Unit -2 (P1) +// CET -5: Encapsulation (Members — Unit 2), Unit -2 (P1, P2) — isolated for StartConversation +// CET -6: Encapsulation (Invariants), Unit -2 (P1, P2, P3) — isolated for Start+Submit flow +// CET -7: Polymorphism Mechanics, Unit -2 (P1, P2 + R1) — isolated // Learner -2: enrolled in Units -1, -2 | Learner -3: enrolled in Units -1, -2 // Learner -1: NOT enrolled | Learner -4: exhausted wallet // Attempt -3: Learner -3, CET -1, InProgress (2 turns — for conflict + eval failure tests) -// Attempt -4: Learner -3, CET -2, InProgress (KP -20 covered — completion test) +// Attempt -4: Learner -3, CET -2, InProgress (P1 covered — completion test) // Attempt -5: Learner -2, CET -2, InProgress (9 learner turns — hard cap seed) // Attempt -6: Learner -3, CET -3, InProgress (5 substantive turns — soft cap seed) // Attempt -7: Learner -3, CET -5, InProgress (isolated for abandon test) @@ -63,7 +63,7 @@ public async Task Starts_conversation_with_first_turn() public async Task All_propositions_covered_completes() { Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock([-20, -21]); + Factory.SetupEvaluationMock(["P1", "P2"]); Factory.SetupDialogueMock(); Factory.SetupSummaryMock("Completed conversation summary."); using var scope = Factory.Services.CreateScope(); @@ -84,7 +84,7 @@ public async Task All_propositions_covered_completes() public async Task Hard_cap_reached_expires() { Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock(propositionsCoveredIds: []); + Factory.SetupEvaluationMock(propositionsCoveredKeys: []); Factory.SetupDialogueMock(); Factory.SetupSummaryMock("Expired due to hard cap."); using var scope = Factory.Services.CreateScope(); @@ -103,7 +103,7 @@ public async Task Hard_cap_reached_expires() public async Task Soft_cap_reached_continues() { Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock(propositionsCoveredIds: []); + Factory.SetupEvaluationMock(propositionsCoveredKeys: []); Factory.SetupDialogueMock(); Factory.SetupSummaryMock(); using var scope = Factory.Services.CreateScope(); @@ -285,11 +285,11 @@ public async Task Submit_wrong_learner_fails() [Fact] public async Task Concept_with_relations_completes_when_relations_articulated() { - // CET -7 (KPs -70, -71 + KR -370). Strict completion: covering both KPs is not enough. + // CET -7 (KPs P1, P2 + KR R1). Strict completion: covering both KPs is not enough. Factory.MockChatService.Reset(); Factory.SetupEvaluationMock( - propositionsCoveredIds: [-70, -71], - relationsArticulatedIds: [-370], + propositionsCoveredKeys: ["P1", "P2"], + relationsArticulatedKeys: ["R1"], integrationScore: 3); Factory.SetupDialogueMock(); Factory.SetupSummaryMock("Polymorphism mechanics summary."); @@ -315,8 +315,8 @@ public async Task Concept_with_relations_does_not_complete_when_only_KPs_covered // Uses learner -2 so test doesn't collide with the "completes" test (also on CET -7). Factory.MockChatService.Reset(); Factory.SetupEvaluationMock( - propositionsCoveredIds: [-70, -71], - relationsArticulatedIds: [], + propositionsCoveredKeys: ["P1", "P2"], + relationsArticulatedKeys: [], integrationScore: 1); Factory.SetupDialogueMock(); Factory.SetupSummaryMock(); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql index 0d24590d8..b9946322d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql @@ -1,10 +1,7 @@ DELETE FROM elaborations."TurnEvaluations"; DELETE FROM elaborations."ConversationTurns"; DELETE FROM elaborations."ConversationAttempts"; -DELETE FROM elaborations."KeyRelations"; -DELETE FROM elaborations."BoundaryConditions"; -DELETE FROM elaborations."CommonMisconceptions"; -DELETE FROM elaborations."KeyPropositions"; +DELETE FROM elaborations."ConceptRecords"; DELETE FROM elaborations."ConceptElaborationTasks"; DELETE FROM courses."CourseOwnerships"; 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 index f4d5b7687..2ea948db2 100644 --- 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 @@ -1,77 +1,100 @@ --- CET -1: Encapsulation (Basics), Unit -1, Order 1 (owned by Instructor -51 via Course -1) -INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "CanonicalDefinition") -VALUES (-1, -1, 1, 'Encapsulation (Basics)', 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.'); - -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-10, -1, 'Data and methods are bundled in a class'); +-- Braces in JSON literals are doubled ({{ }}) because the test harness +-- loads this SQL via ExecuteSqlRaw, which runs it through string.Format first. -INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-110, -1, 'Does not mean hiding all data'); - -INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptElaborationTaskId", "Description", "Correction") -VALUES (-210, -1, 'Encapsulation means making everything private', 'Encapsulation is about controlled access, not total hiding'); +-- CET -1: Encapsulation (Basics), Unit -1, Order 1 (owned by Instructor -51 via Course -1) +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title") +VALUES (-1, -1, 1, 'Encapsulation (Basics)'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "BoundaryConditions", "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":"B1","statement":"Does not mean hiding all data"}}]'::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", "CanonicalDefinition") -VALUES (-2, -1, 2, 'Encapsulation (Members)', 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.'); - -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-20, -2, 'Data and methods are bundled in a class'); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-21, -2, 'Access modifiers control visibility of members'); - -INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-120, -2, 'Does not mean hiding all data'); -INSERT INTO elaborations."BoundaryConditions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-121, -2, 'Public interfaces are part of encapsulation'); - -INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptElaborationTaskId", "Description", "Correction") -VALUES (-220, -2, 'Encapsulation means making everything private', 'Encapsulation is about controlled access, not total hiding'); -INSERT INTO elaborations."CommonMisconceptions"("Id", "ConceptElaborationTaskId", "Description", "Correction") -VALUES (-221, -2, 'Getters and setters are always good encapsulation', 'Blind getters/setters can break encapsulation by exposing internals'); +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title") +VALUES (-2, -1, 2, 'Encapsulation (Members)'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "BoundaryConditions", "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":"B1","statement":"Does not mean hiding all data"}},{{"key":"B2","statement":"Public interfaces are part of encapsulation"}}]'::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", "CanonicalDefinition") -VALUES (-3, -2, 1, 'Encapsulation (Basics — Unit 2)', 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.'); - -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-30, -3, 'Data and methods are bundled in a class'); +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title") +VALUES (-3, -2, 1, 'Encapsulation (Basics — Unit 2)'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "BoundaryConditions", "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, + '[]'::jsonb); -- CET -4: Inheritance, Unit -3, Order 1 (owned ONLY by Instructor -52, NOT -51) -INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "CanonicalDefinition") -VALUES (-4, -3, 1, 'Inheritance', 'Inheritance allows a class to derive behavior from another class.'); - -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-40, -4, 'Child class inherits parent behavior'); +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title") +VALUES (-4, -3, 1, 'Inheritance'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "BoundaryConditions", "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, + '[]'::jsonb); -- CET -5: Encapsulation (Members — Unit 2), Unit -2, Order 2 (isolated for StartConversation tests) -INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "CanonicalDefinition") -VALUES (-5, -2, 2, 'Encapsulation (Members — Unit 2)', 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.'); - -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-50, -5, 'Data and methods are bundled in a class'); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-51, -5, 'Access modifiers control visibility of members'); +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title") +VALUES (-5, -2, 2, 'Encapsulation (Members — Unit 2)'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "BoundaryConditions", "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, + '[]'::jsonb); -- CET -6: Encapsulation (Invariants), Unit -2, Order 3 (isolated for Start+Submit flow test) -INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "CanonicalDefinition") -VALUES (-6, -2, 3, 'Encapsulation (Invariants)', 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.'); - -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-60, -6, 'Data and methods are bundled in a class'); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-61, -6, 'Access modifiers control visibility of members'); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-62, -6, 'Internal invariants are protected from external corruption'); +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title") +VALUES (-6, -2, 3, 'Encapsulation (Invariants)'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "BoundaryConditions", "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, + '[]'::jsonb); -- CET -7: Polymorphism Mechanics, Unit -2, Order 4 (isolated, has KeyRelation) -INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "CanonicalDefinition") -VALUES (-7, -2, 4, 'Polymorphism Mechanics', 'Polymorphism resolves method calls at runtime via dynamic dispatch.'); - -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-70, -7, 'A subclass can override a parent method'); -INSERT INTO elaborations."KeyPropositions"("Id", "ConceptElaborationTaskId", "Statement") -VALUES (-71, -7, 'The runtime selects the implementation by the actual type'); - -INSERT INTO elaborations."KeyRelations"("Id", "ConceptElaborationTaskId", "SourceKeyPropositionId", "TargetKeyPropositionId", "Mechanism") -VALUES (-370, -7, -70, -71, 'Override matters because dispatch happens at runtime, not compile time'); +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title") +VALUES (-7, -2, 4, 'Polymorphism Mechanics'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "BoundaryConditions", "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, + '[]'::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 index d50630e4a..f57bc4462 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -9,10 +9,10 @@ VALUES (-2, -1, 1, 'Good start! Can you tell me more about access modifiers?', 1 INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-3, -1, 0, 'Access modifiers like public and private control visibility.', 2, '2024-06-01 10:02:00+00', 0); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") -VALUES (-1, -1, 2, 2, 2, null, 'Accurate basic description', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") -VALUES (-3, -3, 2, 2, 2, null, 'Good description of access modifiers', null, '[-10]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-1, -1, 2, 2, 2, null, 'Accurate basic description', null, '["P1"]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-3, -3, 2, 2, 2, null, 'Good description of access modifiers', null, '["P1"]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -2: Learner -2, CET -1, Abandoned (for query tests) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -27,10 +27,10 @@ VALUES (-4, -3, 0, 'Encapsulation is about data hiding.', 0, '2024-06-03 10:01:0 INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', 1, '2024-06-03 10:01:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") -VALUES (-4, -4, 1, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '[-210]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-4, -4, 1, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '["M1"]'::jsonb, '[]'::jsonb, false); --- Attempt -4: Learner -3, CET -2, InProgress (for completion test: KP -20 already covered, submit to cover -21) +-- Attempt -4: Learner -3, CET -2, InProgress (for completion test: KP P1 already covered, submit to cover P2) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") VALUES (-4, -2, -3, 0, '2024-06-04 10:00:00+00', null, null); @@ -39,8 +39,8 @@ VALUES (-6, -4, 0, 'Encapsulation bundles data and methods together.', 0, '2024- INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-7, -4, 1, 'Good. What about access control?', 1, '2024-06-04 10:01:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") -VALUES (-6, -6, 2, 2, 2, null, 'Covers bundling proposition', null, '[-20]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-6, -6, 2, 2, 2, null, 'Covers bundling proposition', null, '["P1"]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -5: Learner -2, CET -2, InProgress with 9 learner + 9 system turns (for hard cap test) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -84,23 +84,23 @@ INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Rol VALUES (-67, -5, 1, 'Response 9', 17, '2024-06-05 10:09:05+00', null); -- Evaluations for the 9 learner turns (all with empty propositions - never completes) -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-50, -50, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-52, -52, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-54, -54, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-56, -56, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-58, -58, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-60, -60, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-62, -62, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-64, -64, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-66, -66, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -6: Learner -3, CET -3, InProgress with 5 substantive learner + 5 system turns (for soft cap test) @@ -128,15 +128,15 @@ VALUES (-78, -6, 0, 'Turn 5', 8, '2024-06-06 10:05:00+00', 0); INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-79, -6, 1, 'Response 5', 9, '2024-06-06 10:05:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-70, -70, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-72, -72, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-74, -74, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-76, -76, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredIds", "MisconceptionsTriggeredIds", "RelationsArticulatedIds", "HasMultipleConcerns") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") VALUES (-78, -78, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -7: Learner -3, CET -5, InProgress (isolated for abandon test — no other test touches this) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs deleted file mode 100644 index c7155c65e..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptElaborationTaskTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Reflection; -using Shouldly; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Tests.Unit; - -public class ConceptElaborationTaskTests -{ - [Fact] - public void IsAttemptComplete_returns_true_when_no_relations_and_all_KPs_covered() - { - var kp = MakeKp(1); - var task = MakeTask(kps: [kp], relations: []); - var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpIds: [1], articulatedRelationIds: []); - - task.IsAttemptComplete(attempt).ShouldBeTrue(); - } - - [Fact] - public void IsAttemptComplete_returns_false_when_relations_exist_but_not_articulated() - { - var kp1 = MakeKp(1); - var kp2 = MakeKp(2); - var task = MakeTask( - kps: [kp1, kp2], - relations: [MakeRelation(10, 1, 2)]); - var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpIds: [1, 2], articulatedRelationIds: []); - - task.IsAttemptComplete(attempt).ShouldBeFalse(); - } - - [Fact] - public void IsAttemptComplete_returns_true_when_all_KPs_covered_and_all_relations_articulated() - { - var kp1 = MakeKp(1); - var kp2 = MakeKp(2); - var task = MakeTask( - kps: [kp1, kp2], - relations: [MakeRelation(10, 1, 2)]); - var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpIds: [1, 2], articulatedRelationIds: [10]); - - task.IsAttemptComplete(attempt).ShouldBeTrue(); - } - - private static KeyProposition MakeKp(int id) - { - var kp = new KeyProposition(); - SetProp(kp, "Id", id); - return kp; - } - - private static KeyRelation MakeRelation(int id, int sourceKpId, int targetKpId) - { - var kr = new KeyRelation(); - SetProp(kr, "Id", id); - SetProp(kr, "SourceKeyPropositionId", sourceKpId); - SetProp(kr, "TargetKeyPropositionId", targetKpId); - return kr; - } - - private static ConceptElaborationTask MakeTask(List kps, List relations) - { - var task = new ConceptElaborationTask(); - SetProp(task, "KeyPropositions", kps); - SetProp(task, "BoundaryConditions", new List()); - SetProp(task, "CommonMisconceptions", new List()); - SetProp(task, "KeyRelations", relations); - return task; - } - - private static ConversationAttempt MakeAttemptWithCoveredAndArticulated( - List coveredKpIds, List articulatedRelationIds) - { - var ctor = typeof(ConversationAttempt) - .GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, Type.EmptyTypes)!; - var attempt = (ConversationAttempt)ctor.Invoke(null); - - var evalCtor = typeof(TurnEvaluation).GetConstructors().First(c => c.GetParameters().Length > 0); - var evaluation = (TurnEvaluation)evalCtor.Invoke([ - 2, 2, (int?)null, (int?)null, "test", null, - coveredKpIds, new List(), articulatedRelationIds, false - ]); - - var turnCtor = typeof(ConversationTurn).GetConstructors( - BindingFlags.NonPublic | BindingFlags.Instance) - .First(c => c.GetParameters().Length > 0); - var turn = (ConversationTurn)turnCtor.Invoke([ - TurnRole.Learner, "x", 0, (TurnIntent?)TurnIntent.Substantive, evaluation, null! - ]); - - SetProp(attempt, "Turns", new List { turn }); - return attempt; - } - - private static void SetProp(object instance, string propName, object? value) - { - var prop = instance.GetType().GetProperty(propName, - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - if (prop == null) - throw new InvalidOperationException($"Property {propName} not found on {instance.GetType().Name}"); - prop.SetValue(instance, value); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs new file mode 100644 index 000000000..eeef0a7c5 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs @@ -0,0 +1,84 @@ +using System.Reflection; +using Shouldly; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Tests.Unit; + +public class ConceptRecordTests +{ + [Fact] + public void IsAttemptComplete_returns_true_when_no_relations_and_all_KPs_covered() + { + var record = MakeRecord( + kps: [new KeyProposition("P1", "first")], + relations: []); + var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpKeys: ["P1"], articulatedRelationKeys: []); + + record.IsAttemptComplete(attempt).ShouldBeTrue(); + } + + [Fact] + public void IsAttemptComplete_returns_false_when_relations_exist_but_not_articulated() + { + var record = MakeRecord( + kps: [new KeyProposition("P1", "first"), new KeyProposition("P2", "second")], + relations: [new KeyRelation("R1", "P1", "P2", "m")]); + var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpKeys: ["P1", "P2"], articulatedRelationKeys: []); + + record.IsAttemptComplete(attempt).ShouldBeFalse(); + } + + [Fact] + public void IsAttemptComplete_returns_true_when_all_KPs_covered_and_all_relations_articulated() + { + var record = MakeRecord( + kps: [new KeyProposition("P1", "first"), new KeyProposition("P2", "second")], + relations: [new KeyRelation("R1", "P1", "P2", "m")]); + var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpKeys: ["P1", "P2"], articulatedRelationKeys: ["R1"]); + + record.IsAttemptComplete(attempt).ShouldBeTrue(); + } + + private static ConceptRecord MakeRecord(List kps, List relations) + { + return new ConceptRecord( + conceptElaborationTaskId: 0, + canonicalDefinition: "def", + keyPropositions: kps, + boundaryConditions: new List(), + commonMisconceptions: new List(), + keyRelations: relations); + } + + private static ConversationAttempt MakeAttemptWithCoveredAndArticulated( + List coveredKpKeys, List articulatedRelationKeys) + { + var ctor = typeof(ConversationAttempt) + .GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, Type.EmptyTypes)!; + var attempt = (ConversationAttempt)ctor.Invoke(null); + + var evaluation = new TurnEvaluation( + 2, 2, null, null, "test", null, + coveredKpKeys, new List(), articulatedRelationKeys, false); + + var turnCtor = typeof(ConversationTurn).GetConstructors( + BindingFlags.NonPublic | BindingFlags.Instance) + .First(c => c.GetParameters().Length > 0); + var turn = (ConversationTurn)turnCtor.Invoke([ + TurnRole.Learner, "x", 0, (TurnIntent?)TurnIntent.Substantive, evaluation, null! + ]); + + SetProp(attempt, "Turns", new List { turn }); + return attempt; + } + + private static void SetProp(object instance, string propName, object? value) + { + var prop = instance.GetType().GetProperty(propName, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (prop == null) + throw new InvalidOperationException($"Property {propName} not found on {instance.GetType().Name}"); + prop.SetValue(instance, value); + } +} From ea00c7c408196c2cacd280d72e1393a58fcccf27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Fri, 24 Apr 2026 12:44:58 +0300 Subject: [PATCH 26/51] refactor: Minor reductions in AgentOrchestrator complexity. --- .../Dtos/TokenWallet/TokenSpendingDtos.cs | 2 +- .../ConceptElaborationTask.cs | 4 +- .../Domain/ConceptRecords/ConceptRecord.cs | 29 ++-- .../Conversations/ConversationAttempt.cs | 22 +-- .../Domain/Conversations/ConversationTurn.cs | 6 +- .../Domain/Conversations/TurnIntent.cs | 2 +- .../UseCases/Learning/ConversationService.cs | 23 ++- ...stratorService.cs => AgentOrchestrator.cs} | 164 ++++++------------ .../Orchestration/IAgentOrchestrator.cs | 10 ++ .../IAgentOrchestratorService.cs | 12 -- .../Learning/Orchestration/ProbeDirective.cs | 2 +- .../Learning/Orchestration/ProbeTargetType.cs | 7 - .../Learning/Prompts/AgentTurnContext.cs | 5 +- .../Learning/Prompts/Agents/IntentPrompt.cs | 6 +- .../Learning/Prompts/RuntimeContextBlock.cs | 5 +- .../Learning/Prompts/TargetDirective.cs | 13 -- .../Agents/AgentJson.cs | 3 +- .../ElaborationsStartup.cs | 2 +- .../TestData/e-conversation-attempts.sql | 4 +- 19 files changed, 120 insertions(+), 201 deletions(-) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/{AgentOrchestratorService.cs => AgentOrchestrator.cs} (67%) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestrator.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestratorService.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeTargetType.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/TargetDirective.cs 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/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs index 658e3fd86..d9f8adfad 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs @@ -8,7 +8,7 @@ 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 ConceptRecord ConceptRecord { get; private set; } = null!; + public ConceptRecord? ConceptRecord { get; private set; } private ConceptElaborationTask() { } @@ -22,6 +22,8 @@ public ConceptElaborationTask(int unitId, int order, string title, ConceptRecord public void Update(ConceptElaborationTask incoming) { + if (incoming.ConceptRecord == null || ConceptRecord == null) + throw new InvalidOperationException("ConceptRecord cannot be null when updating."); Title = incoming.Title; Order = incoming.Order; ConceptRecord.Update(incoming.ConceptRecord); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs index 48abfa5a5..0a3dc3770 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs @@ -7,10 +7,10 @@ public class ConceptRecord : Entity { public int ConceptElaborationTaskId { get; private set; } public string CanonicalDefinition { get; private set; } = string.Empty; - public List KeyPropositions { get; private set; } = new(); - public List BoundaryConditions { get; private set; } = new(); - public List CommonMisconceptions { get; private set; } = new(); - public List KeyRelations { get; private set; } = new(); + public List KeyPropositions { get; private set; } = []; + public List BoundaryConditions { get; private set; } = []; + public List CommonMisconceptions { get; private set; } = []; + public List KeyRelations { get; private set; } = []; private ConceptRecord() { } @@ -38,7 +38,7 @@ public void Update(ConceptRecord incoming) public bool AreAllPropositionsCovered(ConversationAttempt attempt) { - var covered = attempt.GetCoveredPropositionKeys(); + var covered = attempt.GetArticulatedPropositionKeys(); return KeyPropositions.All(kp => covered.Contains(kp.Key)); } @@ -54,15 +54,22 @@ public bool IsAttemptComplete(ConversationAttempt attempt) return AreAllPropositionsCovered(attempt) && AreAllKeyRelationsArticulated(attempt); } - public List GetUncoveredPropositionKeys(ConversationAttempt attempt) + public string PickNextTarget(ConversationAttempt attempt) { - var covered = attempt.GetCoveredPropositionKeys(); - return KeyPropositions.Where(kp => !covered.Contains(kp.Key)).Select(kp => kp.Key).ToList(); + var articulatedKps = attempt.GetArticulatedPropositionKeys(); + var nextTarget = KeyPropositions.Where(kp => !articulatedKps.Contains(kp.Key)) + .Select(kp => kp.Statement).FirstOrDefault(); + if (nextTarget != null) return nextTarget; + + var articulatedKrs = attempt.GetArticulatedRelationKeys(); + var nextRelation = KeyRelations.First(kr => !articulatedKrs.Contains(kr.Key)); + var source = KeyPropositions.First(kp => kp.Key == nextRelation.SourceKey).Statement; + var target = KeyPropositions.First(kp => kp.Key == nextRelation.TargetKey).Statement; + return $"{source} → {target}. Mechanism: {nextRelation.Mechanism}"; } - public List GetUnarticulatedRelationKeys(ConversationAttempt attempt) + public int CountPropositionsAndRelations() { - var articulated = attempt.GetArticulatedRelationKeys(); - return KeyRelations.Where(kr => !articulated.Contains(kr.Key)).Select(kr => kr.Key).ToList(); + return KeyPropositions.Count + KeyRelations.Count; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index 3507e011d..139991d0b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -1,3 +1,4 @@ +using System.Dynamic; using Tutor.BuildingBlocks.Core.Domain; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; @@ -5,9 +6,6 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; public class ConversationAttempt : AggregateRoot { - private const int SoftCapSubstantiveTurns = 6; - private const int HardCapTotalTurns = 10; - public int ConceptElaborationTaskId { get; private set; } public int LearnerId { get; private set; } public AttemptStatus Status { get; private set; } @@ -15,18 +13,22 @@ public class ConversationAttempt : AggregateRoot public DateTime? CompletedAt { get; private set; } public string? Summary { get; private set; } public List Turns { get; private set; } = new(); + public int? SoftCapTotalTurns { get; private set; } + public int? HardCapTotalTurns { get; private set; } private ConversationAttempt() { } - public ConversationAttempt(int conceptElaborationTaskId, int learnerId) + public ConversationAttempt(int conceptElaborationTaskId, int learnerId, int totalItems) { ConceptElaborationTaskId = conceptElaborationTaskId; LearnerId = learnerId; Status = AttemptStatus.InProgress; StartedAt = DateTime.UtcNow; + HardCapTotalTurns = totalItems + 2; + SoftCapTotalTurns = Math.Max(totalItems - 2, 2); } - public ISet GetCoveredPropositionKeys() + public ISet GetArticulatedPropositionKeys() { return Turns .Where(t => t.Evaluation != null) @@ -42,17 +44,12 @@ public ISet GetArticulatedRelationKeys() .ToHashSet(); } - public int CountSubstantiveLearnerTurns() - { - return Turns.Count(t => t.Role == TurnRole.Learner && t.Intent == TurnIntent.Substantive); - } - public int CountTotalLearnerTurns() { return Turns.Count(t => t.Role == TurnRole.Learner); } - public bool IsSoftCapReached() => CountSubstantiveLearnerTurns() >= SoftCapSubstantiveTurns; + public bool IsSoftCapReached() => CountTotalLearnerTurns() >= SoftCapTotalTurns; public bool IsHardCapReached() => CountTotalLearnerTurns() >= HardCapTotalTurns; @@ -65,8 +62,7 @@ public ConversationTurn AddLearnerTurn(string content, TurnIntent intent, TurnEv public ConversationTurn AddSystemTurn(string content, ProbeDirective? probeDirective = null) { - var turn = new ConversationTurn( - TurnRole.System, content, Turns.Count, + var turn = new ConversationTurn(TurnRole.System, content, Turns.Count, intent: null, evaluation: null, probeDirective: probeDirective); Turns.Add(turn); return turn; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs index 5e5986a0a..e43416e3d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs @@ -12,8 +12,7 @@ public class ConversationTurn : Entity public DateTime Timestamp { get; private set; } public TurnIntent? Intent { get; private set; } public TurnEvaluation? Evaluation { get; private set; } - public ProbeTargetType? ProbeTargetType { get; private set; } - public string? ProbeTargetKey { get; private set; } + public string? ProbeTarget { get; private set; } public int? ProbeLevel { get; private set; } private ConversationTurn() { } @@ -29,8 +28,7 @@ internal ConversationTurn( Timestamp = DateTime.UtcNow; Intent = intent; Evaluation = evaluation; - ProbeTargetType = probeDirective?.TargetType; - ProbeTargetKey = probeDirective?.TargetKey; + ProbeTarget = probeDirective?.Target; ProbeLevel = probeDirective?.Level; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs index 09671aa32..506a5d6b3 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs @@ -6,5 +6,5 @@ public enum TurnIntent Clarification, OffTopic, Stuck, - MetaHelp + SummaryRequest } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index 5c0ef547b..b90f3ad56 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -21,7 +21,7 @@ public class ConversationService : IConversationService private readonly IConversationAttemptRepository _attemptRepo; private readonly IConceptElaborationTaskRepository _taskRepo; - private readonly IAgentOrchestratorService _orchestrator; + private readonly IAgentOrchestrator _orchestrator; private readonly ITokenSpendingService _tokenSpendingService; private readonly IAccessServices _accessServices; private readonly IElaborationsUnitOfWork _unitOfWork; @@ -29,7 +29,7 @@ public class ConversationService : IConversationService public ConversationService( IConversationAttemptRepository attemptRepo, IConceptElaborationTaskRepository taskRepo, - IAgentOrchestratorService orchestrator, ITokenSpendingService tokenSpendingService, + IAgentOrchestrator orchestrator, ITokenSpendingService tokenSpendingService, IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, IMapper mapper) { _attemptRepo = attemptRepo; @@ -109,9 +109,7 @@ public async IAsyncEnumerable StartConversationAsync(int taskId, string yield break; } - if (task.ConceptRecord == null) { yield return BuildErrorChunk("Concept record missing.", 500); yield break; } - - var attempt = new ConversationAttempt(taskId, learnerId); + var attempt = new ConversationAttempt(taskId, learnerId, task.ConceptRecord!.CountPropositionsAndRelations()); _attemptRepo.Create(attempt); _unitOfWork.Save(); @@ -143,8 +141,6 @@ public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string cont yield break; } - if (task.ConceptRecord == null) { yield return BuildErrorChunk("Concept record missing.", 500); yield break; } - await foreach (var token in RunTurnPipelineAsync(attempt, task, content, ct)) yield return token; } @@ -163,11 +159,12 @@ public Result AbandonAttempt(int attemptId, int learnerI return Result.Ok(_mapper.Map(attempt)); } - private async IAsyncEnumerable RunTurnPipelineAsync( - ConversationAttempt attempt, ConceptElaborationTask task, - string content, [EnumeratorCancellation] CancellationToken ct) + private async IAsyncEnumerable RunTurnPipelineAsync(ConversationAttempt attempt, + ConceptElaborationTask task, string content, [EnumeratorCancellation] CancellationToken ct) { - await foreach (var chunk in _orchestrator.ProcessTurnAsync(attempt, task, task.ConceptRecord, content, ct)) + if (task.ConceptRecord == null) { yield return BuildErrorChunk("Concept record missing.", 500); yield break; } + + await foreach (var chunk in _orchestrator.ProcessTurnAsync(attempt, task.ConceptRecord, content, ct)) { switch (chunk) { @@ -180,7 +177,7 @@ private async IAsyncEnumerable RunTurnPipelineAsync( yield break; case FinalChunk final: - _unitOfWork.Save(); + _unitOfWork.Save(); // Save attempt status and turn additions _tokenSpendingService.SpendTokensForUnit(new TokenSpendingRequestDto { LearnerId = attempt.LearnerId, @@ -189,7 +186,7 @@ private async IAsyncEnumerable RunTurnPipelineAsync( CompletionTokens = final.Usage.CompletionTokens, FeatureType = "Elaboration", EntityId = task.Id, - PromptSummary = "Concept conversation turn" + PromptSummary = $"Conversation turn for attempt: {final.AttemptId}" }); yield return JsonSerializer.Serialize(new SubmitTurnResponseDto { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs similarity index 67% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs index 01d2b92b5..b2f0834eb 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestratorService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -1,10 +1,9 @@ -using System.Runtime.CompilerServices; -using System.Text; using FluentResults; using Microsoft.Extensions.Logging; +using System.Runtime.CompilerServices; +using System.Text; using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.ConceptRecords; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; @@ -13,28 +12,26 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; -public class AgentOrchestratorService : IAgentOrchestratorService +public class AgentOrchestrator : IAgentOrchestrator { private const int ScaffoldingLevel = 4; - private readonly IAgentStream _stream; - private readonly IAgentJson _json; + private readonly IAgentStream _streamAgent; + private readonly IAgentJson _jsonAgent; private readonly ITurnUsageTracker _usageTracker; - private readonly ILogger _logger; + private readonly ILogger _logger; - public AgentOrchestratorService( - IAgentStream stream, IAgentJson json, - ITurnUsageTracker usageTracker, ILogger logger) + public AgentOrchestrator(IAgentStream streamAgent, IAgentJson jsonAgent, + ITurnUsageTracker usageTracker, ILogger logger) { - _stream = stream; - _json = json; + _streamAgent = streamAgent; + _jsonAgent = jsonAgent; _usageTracker = usageTracker; _logger = logger; } - public async IAsyncEnumerable ProcessTurnAsync( - ConversationAttempt attempt, ConceptElaborationTask task, ConceptRecord record, - string learnerContent, [EnumeratorCancellation] CancellationToken ct) + public async IAsyncEnumerable ProcessTurnAsync(ConversationAttempt attempt, + ConceptRecord record, string newMessage, [EnumeratorCancellation] CancellationToken ct) { using var turnScope = _logger.BeginScope(new Dictionary { @@ -44,7 +41,7 @@ public async IAsyncEnumerable ProcessTurnAsync( var historyBeforeTurn = (IReadOnlyList)attempt.Turns.ToList(); - var intentResult = await ClassifyIntentAsync(historyBeforeTurn, record, task, learnerContent, ct); + var intentResult = await ClassifyIntentAsync(historyBeforeTurn, record, newMessage, ct); if (intentResult.IsFailed) { yield return new ErrorChunk("Intent classification failed.", 500); @@ -55,7 +52,7 @@ public async IAsyncEnumerable ProcessTurnAsync( TurnEvaluation? evaluation = null; if (intent == TurnIntent.Substantive) { - var scoreResult = await ScoreTurnAsync(historyBeforeTurn, record, task, learnerContent, ct); + var scoreResult = await ScoreTurnAsync(historyBeforeTurn, record, newMessage, ct); if (scoreResult.IsFailed) { yield return new ErrorChunk("Scoring failed.", 500); @@ -64,16 +61,16 @@ public async IAsyncEnumerable ProcessTurnAsync( evaluation = scoreResult.Value; } - attempt.AddLearnerTurn(learnerContent, intent, evaluation); + attempt.AddLearnerTurn(newMessage, intent, evaluation); var route = DecideRoute(attempt, record, intent, evaluation); var historyForStreaming = (IReadOnlyList)attempt.Turns.ToList(); - var (streamKind, streamCtx) = BuildStreamContext(route, task, record, attempt.IsSoftCapReached()); + var (streamKind, streamCtx) = BuildStreamContext(route, record, attempt.IsSoftCapReached()); var fullResponse = new StringBuilder(); StreamFailure? streamFailure = null; - await foreach (var chunk in _stream.StreamAsync(streamKind, historyForStreaming, record, streamCtx, ct)) + await foreach (var chunk in _streamAgent.StreamAsync(streamKind, historyForStreaming, record, streamCtx, ct)) { if (chunk is StreamFailure failure) { @@ -96,7 +93,7 @@ public async IAsyncEnumerable ProcessTurnAsync( string? summary = null; if (route.Closing is ClosingReason.AllCovered or ClosingReason.HardCapReached) { - summary = await SummarizeAsync(attempt.Turns, record, task, ct); + summary = await SummarizeAsync(attempt.Turns, record, ct); if (route.Closing == ClosingReason.AllCovered) attempt.Complete(summary); else attempt.Expire(summary); } @@ -105,16 +102,14 @@ public async IAsyncEnumerable ProcessTurnAsync( attempt.Id, attempt.Status, intent, summary, route.ProbeDirective, _usageTracker.Total); } - private Task> ClassifyIntentAsync( - IReadOnlyList history, ConceptRecord record, - ConceptElaborationTask task, string learnerContent, CancellationToken ct) + private Task> ClassifyIntentAsync(IReadOnlyList history, + ConceptRecord record, string newMessage, CancellationToken ct) { var ctx = new AgentTurnContext( Instruction: "Classify the current learner message. Return JSON only.", - CurrentLearnerMessage: learnerContent, - ConceptTitle: task.Title); + CurrentLearnerMessage: newMessage); - return _json.CompleteAsync( + return _jsonAgent.CompleteAsync( AgentKind.IntentClassifier, history, record, ctx, r => Enum.TryParse(r.Intent, ignoreCase: true, out var intent) ? Result.Ok(intent) @@ -124,30 +119,27 @@ private Task> ClassifyIntentAsync( private Task> ScoreTurnAsync( IReadOnlyList history, ConceptRecord record, - ConceptElaborationTask task, string learnerContent, CancellationToken ct) + string learnerContent, CancellationToken ct) { var ctx = new AgentTurnContext( Instruction: "Score the current learner message against the rubric. Return JSON only.", - CurrentLearnerMessage: learnerContent, - ConceptTitle: task.Title); + CurrentLearnerMessage: learnerContent); - return _json.CompleteAsync( + return _jsonAgent.CompleteAsync( AgentKind.Scorer, history, record, ctx, r => MapToEvaluation(r, record), "Scoring failed.", ct); } private async Task SummarizeAsync( - IEnumerable turns, ConceptRecord record, - ConceptElaborationTask task, CancellationToken ct) + IEnumerable turns, ConceptRecord record, CancellationToken ct) { var history = (IReadOnlyList)turns.ToList(); var ctx = new AgentTurnContext( - Instruction: "Summarize what the learner demonstrated understanding of in 2-4 sentences in Serbian. Paraphrase only, no verbatim quotes of rubric items.", - ConceptTitle: task.Title); + Instruction: "Summarize what the learner demonstrated understanding of in 2-4 sentences in Serbian. Paraphrase only, no verbatim quotes of rubric items."); var buffer = new StringBuilder(); - await foreach (var chunk in _stream.StreamAsync(AgentKind.Summary, history, record, ctx, ct)) + await foreach (var chunk in _streamAgent.StreamAsync(AgentKind.Summary, history, record, ctx, ct)) { if (chunk is StreamFailure) return null; buffer.Append(((StreamToken)chunk).Content); @@ -185,54 +177,47 @@ private static Result MapToEvaluation(ScorerResponse parsed, Con } private static (AgentKind Kind, AgentTurnContext Ctx) BuildStreamContext( - RouteDecision route, ConceptElaborationTask task, ConceptRecord record, bool softCap) => + RouteDecision route, ConceptRecord record, bool softCap) => route.Kind switch { RouteKind.Probe => (AgentKind.Probe, new AgentTurnContext( Instruction: "Produce one probe question for the target at the given level.", - Target: ResolveTarget(record, route.ProbeDirective), - SoftCapReached: softCap, - ConceptTitle: task.Title)), + Target: route.ProbeDirective!.Target, + SoftCapReached: softCap)), RouteKind.Scaffold => (AgentKind.Scaffolding, new AgentTurnContext( Instruction: "Produce a scaffold (forced choice, code skeleton, or analogy) that helps the learner reach the target without revealing it.", - Target: ResolveTarget(record, route.ProbeDirective), - ConceptTitle: task.Title)), + Target: route.ProbeDirective!.Target)), RouteKind.Critique => (AgentKind.Critique, new AgentTurnContext( Instruction: "Produce a short bulleted critique of the latest learner turn based on the evaluation. Do not ask a new Socratic question.", Evaluation: route.Evaluation, - SoftCapReached: softCap, - ConceptTitle: task.Title)), + SoftCapReached: softCap)), RouteKind.Clarification => (AgentKind.Clarification, new AgentTurnContext( Instruction: "Rephrase the tutor's prior question in simpler terms. Do not answer it. Then invite the learner to resume.", - Target: ResolveTarget(record, route.LastProbe), - ConceptTitle: task.Title)), + Target: route.LastProbe!.Target)), RouteKind.Redirect => (AgentKind.Redirect, new AgentTurnContext( - Instruction: "Redirect the learner back to the concept with a concrete small next step.", - ConceptTitle: task.Title)), + Instruction: "Redirect the learner back to the concept with a concrete small next step.")), RouteKind.MetaHelp => (AgentKind.MetaHelp, new AgentTurnContext( Instruction: "Answer the learner's meta/procedural question: open with the progress line verbatim, then pivot to the remaining gap.", ProgressLine: route.ProgressLine, - Target: ResolveTarget(record, route.NextTarget), - ConceptTitle: task.Title)), + Target: route.NextTarget!.Target)), RouteKind.Closing => (AgentKind.Closing, new AgentTurnContext( Instruction: route.Closing == ClosingReason.AllCovered ? "reason=AllCovered. Acknowledge that the learner has covered the concept in 2 sentences max." - : "reason=HardCapReached. Acknowledge that the conversation is ending in 2 sentences max.", - ConceptTitle: task.Title)), + : "reason=HardCapReached. Acknowledge that the conversation is ending in 2 sentences max.")), _ => throw new InvalidOperationException($"Unknown route kind: {route.Kind}") }; - private RouteDecision DecideRoute( - ConversationAttempt attempt, ConceptRecord record, - TurnIntent intent, TurnEvaluation? evaluation) + private static RouteDecision DecideRoute(ConversationAttempt attempt, + ConceptRecord record, TurnIntent intent, TurnEvaluation? evaluation) { + // TODO: We should have 1-2 rounds when nearing closing to prompt for summary. if (record.IsAttemptComplete(attempt)) return RouteDecision.Close(ClosingReason.AllCovered); @@ -247,19 +232,19 @@ private RouteDecision DecideRoute( case TurnIntent.OffTopic: return RouteDecision.Redirect(); - case TurnIntent.MetaHelp: + case TurnIntent.SummaryRequest: { var progressLine = RenderProgressLine(attempt, record); - var next = PickNextTarget(attempt, record); + var next = record.PickNextTarget(attempt); return RouteDecision.Meta(progressLine, next); } case TurnIntent.Stuck: { - var next = PickNextTarget(attempt, record); + var next = record.PickNextTarget(attempt); if (next == null) return RouteDecision.Close(ClosingReason.AllCovered); var level = DeriveLevel(attempt, next); - var directive = new ProbeDirective(next.Value.Type, next.Value.Key, Math.Max(level, ScaffoldingLevel)); + var directive = new ProbeDirective(next, Math.Max(level, ScaffoldingLevel)); return RouteDecision.Scaffold(directive); } @@ -268,10 +253,10 @@ private RouteDecision DecideRoute( if (evaluation is { HasMultipleConcerns: true }) return RouteDecision.CritiqueFor(evaluation); - var next = PickNextTarget(attempt, record); + var next = record.PickNextTarget(attempt); if (next == null) return RouteDecision.Close(ClosingReason.AllCovered); var level = DeriveLevel(attempt, next); - var directive = new ProbeDirective(next.Value.Type, next.Value.Key, level); + var directive = new ProbeDirective(next, level); return level >= ScaffoldingLevel ? RouteDecision.Scaffold(directive) @@ -283,49 +268,28 @@ private RouteDecision DecideRoute( } } - private static (ProbeTargetType Type, string Key)? PickNextTarget( - ConversationAttempt attempt, ConceptRecord record) - { - var uncoveredKp = record.GetUncoveredPropositionKeys(attempt); - if (uncoveredKp.Count > 0) - return (ProbeTargetType.KeyProposition, uncoveredKp.OrderBy(KeyOrder).First()); - - var unarticulatedKr = record.GetUnarticulatedRelationKeys(attempt); - return unarticulatedKr.Count > 0 - ? (ProbeTargetType.KeyRelation, unarticulatedKr.OrderBy(KeyOrder).First()) - : null; - } - - private static int KeyOrder(string key) - { - // Natural keys are a single-letter prefix followed by digits (e.g., P1, R10, B3). - return int.TryParse(key.AsSpan(1), out var n) ? n : int.MaxValue; - } - - private static int DeriveLevel( - ConversationAttempt attempt, (ProbeTargetType Type, string Key)? target) + private static int DeriveLevel(ConversationAttempt attempt, string target) { if (target == null) return 1; var priorProbes = attempt.Turns.Count(t => t.Role == TurnRole.System && - t.ProbeTargetType == target.Value.Type && - t.ProbeTargetKey == target.Value.Key); + t.ProbeTarget == target); return priorProbes + 1; } private static ProbeDirective? FindLastProbe(ConversationAttempt attempt) { var last = attempt.Turns - .Where(t => t.Role == TurnRole.System && t.ProbeTargetType.HasValue) + .Where(t => t.Role == TurnRole.System && t.ProbeTarget != null) .OrderByDescending(t => t.Order) .FirstOrDefault(); if (last == null) return null; - return new ProbeDirective(last.ProbeTargetType!.Value, last.ProbeTargetKey!, last.ProbeLevel!.Value); + return new ProbeDirective(last.ProbeTarget!, last.ProbeLevel!.Value); } private static string RenderProgressLine(ConversationAttempt attempt, ConceptRecord record) { - var coveredKp = attempt.GetCoveredPropositionKeys().Count; + var coveredKp = attempt.GetArticulatedPropositionKeys().Count; var totalKp = record.KeyPropositions.Count; var articulatedKr = attempt.GetArticulatedRelationKeys().Count; var totalKr = record.KeyRelations.Count; @@ -335,28 +299,6 @@ private static string RenderProgressLine(ConversationAttempt attempt, ConceptRec : $"Dosadašnji napredak: pokriveno {coveredKp}/{totalKp} ključnih izjava."; } - private static TargetDirective? ResolveTarget(ConceptRecord record, ProbeDirective? directive) - { - if (directive == null) return null; - var statement = ResolveTargetStatement(record, directive); - return new TargetDirective(directive.TargetType, directive.TargetKey, directive.Level, statement); - } - - private static string ResolveTargetStatement(ConceptRecord record, ProbeDirective directive) - { - if (directive.TargetType == ProbeTargetType.KeyProposition) - { - var kp = record.KeyPropositions.FirstOrDefault(p => p.Key == directive.TargetKey); - return kp?.Statement ?? "(unknown)"; - } - var kr = record.KeyRelations.FirstOrDefault(r => r.Key == directive.TargetKey); - if (kr == null) return "(unknown)"; - var kpByKey = record.KeyPropositions.ToDictionary(p => p.Key, p => p.Statement); - var source = kpByKey.GetValueOrDefault(kr.SourceKey, "?"); - var target = kpByKey.GetValueOrDefault(kr.TargetKey, "?"); - return $"{source} → {target}. Mechanism: {kr.Mechanism}"; - } - private enum RouteKind { Probe, Scaffold, Critique, Clarification, Redirect, MetaHelp, Closing } private sealed record RouteDecision( @@ -373,9 +315,9 @@ private sealed record RouteDecision( public static RouteDecision CritiqueFor(TurnEvaluation e) => new(RouteKind.Critique, Evaluation: e); public static RouteDecision Clarify(ProbeDirective? last) => new(RouteKind.Clarification, LastProbe: last); public static RouteDecision Redirect() => new(RouteKind.Redirect); - public static RouteDecision Meta(string progressLine, (ProbeTargetType Type, string Key)? next) + public static RouteDecision Meta(string progressLine, string nextTarget) { - var nextDirective = next == null ? null : new ProbeDirective(next.Value.Type, next.Value.Key, 1); + var nextDirective = new ProbeDirective(nextTarget, 1); return new RouteDecision(RouteKind.MetaHelp, ProgressLine: progressLine, NextTarget: nextDirective); } public static RouteDecision Close(ClosingReason reason) => new(RouteKind.Closing, Closing: reason); 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..27bcf8b1c --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestrator.cs @@ -0,0 +1,10 @@ +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public interface IAgentOrchestrator +{ + IAsyncEnumerable ProcessTurnAsync(ConversationAttempt attempt, + ConceptRecord record, string newMessage, CancellationToken ct); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestratorService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestratorService.cs deleted file mode 100644 index e9051455d..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestratorService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.ConceptRecords; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -public interface IAgentOrchestratorService -{ - IAsyncEnumerable ProcessTurnAsync( - ConversationAttempt attempt, ConceptElaborationTask task, ConceptRecord record, - string learnerContent, CancellationToken ct); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs index 3614736a9..8f1cf671f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs @@ -1,3 +1,3 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; -public record ProbeDirective(ProbeTargetType TargetType, string TargetKey, int Level); +public record ProbeDirective(string Target, int Level); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeTargetType.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeTargetType.cs deleted file mode 100644 index 1b6f2f8bc..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeTargetType.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -public enum ProbeTargetType -{ - KeyProposition, - KeyRelation -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs index 19a7f187b..dcc7c896e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs @@ -10,8 +10,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public sealed record AgentTurnContext( string Instruction, string? ProgressLine = null, - TargetDirective? Target = null, + string? Target = null, bool SoftCapReached = false, TurnEvaluation? Evaluation = null, - string? CurrentLearnerMessage = null, - string? ConceptTitle = null); + string? CurrentLearnerMessage = null); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs index cfdf5d421..f838be6a3 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs @@ -19,14 +19,14 @@ public static string Build(ConceptRecord record) sb.AppendLine("- **Substantive**: the learner attempts to explain, define, relate, or apply the concept. Even a weak or partial attempt counts."); sb.AppendLine("- **Clarification**: the learner asks a genuine information-seeking question about the task or the tutor's last message (what / why / how / what do you mean by …?). Must be a direct question — if removing the rest and keeping just the question still makes sense."); sb.AppendLine("- **Stuck**: the learner signals confusion, inability, or not-knowing without asking a question — e.g. \"ne znam\", \"ne razumem\", \"nisam siguran\", \"teško mi je\". Not a refusal of the task, just a stall."); - sb.AppendLine("- **MetaHelp**: the learner asks a procedural/meta question about the conversation itself — e.g. \"rezimiraj šta sam rekao\", \"koliko mi je ostalo\", \"objasni mi još jednom šta tražiš\"."); + sb.AppendLine("- **SummaryRequest**: the learner asks a procedural/meta question about the conversation itself — e.g. \"rezimiraj šta sam rekao\", \"koliko mi je ostalo\", \"objasni mi još jednom šta tražiš\"."); sb.AppendLine("- **OffTopic**: everything else — small talk, greetings, jokes, personal content, refusals (\"ne želim\", \"dosadno mi je\"), meta-comments about the conversation, deference or agreement without articulation (\"da, u pravu si\")."); sb.AppendLine(); sb.AppendLine("# Disambiguation rules"); sb.AppendLine("- If the message is a verbatim or near-verbatim echo of a previous assistant line, classify as OffTopic."); sb.AppendLine("- Between Clarification and Stuck: if the message is a question, Clarification; if it is a statement of not-knowing, Stuck."); - sb.AppendLine("- Between Clarification and MetaHelp: MetaHelp is about the conversation/progress itself; Clarification is about the concept or the tutor's last probe."); + sb.AppendLine("- Between Clarification and SummaryRequest: SummaryRequest is about the conversation/progress itself; Clarification is about the concept or the tutor's last probe."); sb.AppendLine("- When in doubt between Clarification and OffTopic, choose OffTopic."); sb.AppendLine(); @@ -37,7 +37,7 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Output Format"); sb.AppendLine("JSON only, no other text:"); - sb.AppendLine("{ \"intent\": \"Substantive\" | \"Clarification\" | \"Stuck\" | \"MetaHelp\" | \"OffTopic\" }"); + sb.AppendLine("{ \"intent\": \"Substantive\" | \"Clarification\" | \"Stuck\" | \"SummaryRequest\" | \"OffTopic\" }"); return sb.ToString(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs index 7dd451de8..2969a9bf1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs @@ -1,4 +1,5 @@ using System.Text; +using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; @@ -17,7 +18,7 @@ public static string Render(AgentTurnContext ctx) sb.AppendLine($"{ctx.ProgressLine}"); if (ctx.Target is { } t) - sb.AppendLine($"{t.Statement}"); + sb.AppendLine($"{t}"); if (ctx.SoftCapReached) sb.AppendLine(""); @@ -32,7 +33,7 @@ public static string Render(AgentTurnContext ctx) return sb.ToString(); } - private static string RenderEvaluation(Domain.Conversations.TurnEvaluation e) + private static string RenderEvaluation(TurnEvaluation e) { var sb = new StringBuilder(); var attrs = new List diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/TargetDirective.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/TargetDirective.cs deleted file mode 100644 index c053e2b2b..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/TargetDirective.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -/// -/// A with the target statement pre-resolved into text. -/// The orchestrator resolves the text once per turn so prompt builders never reach into the task definition. -/// -public sealed record TargetDirective( - ProbeTargetType Type, - string Key, - int Level, - string Statement); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs index b070605da..126f9876b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs @@ -11,8 +11,7 @@ namespace Tutor.Elaborations.Infrastructure.Agents; public class AgentJson : StructuredAgent, IAgentJson { - public AgentJson(IAiChatService chatService, ILogger logger) - : base(chatService, logger) { } + public AgentJson(IAiChatService chatService, ILogger logger) : base(chatService, logger) { } public Task> CompleteAsync( AgentKind kind, IReadOnlyList history, diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs index 63ccebf33..eaf078be1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs @@ -50,7 +50,7 @@ private static void SetupInfrastructure(IServiceCollection services) services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); 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 index f57bc4462..0aa14d7f4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -43,8 +43,8 @@ INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Correctn VALUES (-6, -6, 2, 2, 2, null, 'Covers bundling proposition', null, '["P1"]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -5: Learner -2, CET -2, InProgress with 9 learner + 9 system turns (for hard cap test) -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") -VALUES (-5, -2, -2, 0, '2024-06-05 10:00:00+00', null, null); +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary", "HardCapTotalTurns", "SoftCapTotalTurns") +VALUES (-5, -2, -2, 0, '2024-06-05 10:00:00+00', null, null, 6, 2); INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-50, -5, 0, 'Turn 1', 0, '2024-06-05 10:01:00+00', 0); From 380e8978c9ddc4d568b2a58cb33196242b08d22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Mon, 27 Apr 2026 10:46:34 +0300 Subject: [PATCH 27/51] refactor: Removes BoundaryCondition concept and refactors AgentOrchestrator to simplify design. --- .../BoundaryConditionDto.cs | 7 - .../ConceptRecordDto.cs | 1 - .../ConceptRecords/BoundaryCondition.cs | 25 -- .../Domain/ConceptRecords/ConceptRecord.cs | 28 +- .../Domain/Conversations/AttemptStatus.cs | 1 + .../Conversations/ConversationAttempt.cs | 71 +++- .../Domain/Conversations/TurnEvaluation.cs | 20 +- .../Mappers/ConceptElaborationTaskProfile.cs | 1 - .../UseCases/Learning/ConversationService.cs | 8 +- .../Orchestration/AgentOrchestrator.cs | 399 ++++++++---------- .../Orchestration/Agents/IAgentFactory.cs | 13 + .../Orchestration/Agents/IAgentJson.cs | 4 +- .../Orchestration/Agents/IAgentStream.cs | 4 +- .../Learning/Orchestration/ClosingReason.cs | 3 - .../Orchestration/ElaborationTexts.cs | 10 + .../Orchestration/IAgentOrchestrator.cs | 4 +- .../UseCases/Learning/Prompts/AgentConfigs.cs | 18 +- .../UseCases/Learning/Prompts/AgentKind.cs | 6 +- .../Learning/Prompts/AgentTurnContext.cs | 9 +- .../Prompts/Agents/ClarificationPrompt.cs | 2 +- .../Learning/Prompts/Agents/ClosingPrompt.cs | 34 -- .../Prompts/Agents/ClosingScorerPrompt.cs | 68 +++ .../Learning/Prompts/Agents/CritiquePrompt.cs | 3 +- .../Learning/Prompts/Agents/IntentPrompt.cs | 2 +- .../Learning/Prompts/Agents/MetaHelpPrompt.cs | 33 -- .../Learning/Prompts/Agents/ProbePrompt.cs | 8 +- .../Learning/Prompts/Agents/RedirectPrompt.cs | 31 -- .../Prompts/Agents/ScaffoldingPrompt.cs | 19 +- .../Learning/Prompts/Agents/ScorerResponse.cs | 1 - .../Learning/Prompts/Agents/SummaryPrompt.cs | 11 +- .../{ScorerPrompt.cs => TurnScorerPrompt.cs} | 24 +- .../Prompts/ConceptRecordRubricSection.cs | 8 - .../Learning/Prompts/RuntimeContextBlock.cs | 22 +- .../Agents/AgentFactory.cs | 35 ++ .../Agents/AgentJson.cs | 24 +- .../Agents/AgentStream.cs | 24 +- .../Database/ElaborationsContext.cs | 5 +- .../ConversationAttemptDatabaseRepository.cs | 2 +- .../ElaborationsStartup.cs | 3 +- .../ElaborationsTestFactory.cs | 13 +- .../ConceptElaborationTaskCommandTests.cs | 17 +- .../Learning/ConversationTurnTests.cs | 34 +- .../TestData/c-concept-elaboration-tasks.sql | 21 +- .../TestData/e-conversation-attempts.sql | 76 ++-- .../TestData/f-daily-limit-attempts.sql | 6 +- .../Unit/ConceptRecordTests.cs | 6 +- 46 files changed, 575 insertions(+), 589 deletions(-) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/BoundaryConditionDto.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentFactory.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ClosingReason.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ElaborationTexts.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingPrompt.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingScorerPrompt.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/MetaHelpPrompt.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/RedirectPrompt.cs rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/{ScorerPrompt.cs => TurnScorerPrompt.cs} (76%) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentFactory.cs diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/BoundaryConditionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/BoundaryConditionDto.cs deleted file mode 100644 index 48a42ccbe..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/BoundaryConditionDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; - -public class BoundaryConditionDto -{ - 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/ConceptRecordDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptRecordDto.cs index 4d3035c37..3a02785e6 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptRecordDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptRecordDto.cs @@ -4,7 +4,6 @@ public class ConceptRecordDto { public string CanonicalDefinition { get; set; } = string.Empty; public List KeyPropositions { get; set; } = new(); - public List BoundaryConditions { get; set; } = new(); public List CommonMisconceptions { get; set; } = new(); public List KeyRelations { get; set; } = new(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs deleted file mode 100644 index 6499b90c4..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/BoundaryCondition.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Text.Json.Serialization; -using Tutor.BuildingBlocks.Core.Domain; - -namespace Tutor.Elaborations.Core.Domain.ConceptRecords; - -public class BoundaryCondition : ValueObject -{ - [JsonPropertyName("key")] - public string Key { get; } - [JsonPropertyName("statement")] - public string Statement { get; } - - [JsonConstructor] - public BoundaryCondition(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/ConceptRecords/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs index 0a3dc3770..8147c9ec3 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs @@ -8,7 +8,6 @@ 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 BoundaryConditions { get; private set; } = []; public List CommonMisconceptions { get; private set; } = []; public List KeyRelations { get; private set; } = []; @@ -16,13 +15,12 @@ private ConceptRecord() { } public ConceptRecord( int conceptElaborationTaskId, string canonicalDefinition, - List keyPropositions, List boundaryConditions, - List commonMisconceptions, List keyRelations) + List keyPropositions, List commonMisconceptions, + List keyRelations) { ConceptElaborationTaskId = conceptElaborationTaskId; CanonicalDefinition = canonicalDefinition; KeyPropositions = keyPropositions; - BoundaryConditions = boundaryConditions; CommonMisconceptions = commonMisconceptions; KeyRelations = keyRelations; } @@ -31,7 +29,6 @@ public void Update(ConceptRecord incoming) { CanonicalDefinition = incoming.CanonicalDefinition; KeyPropositions = incoming.KeyPropositions; - BoundaryConditions = incoming.BoundaryConditions; CommonMisconceptions = incoming.CommonMisconceptions; KeyRelations = incoming.KeyRelations; } @@ -54,18 +51,25 @@ public bool IsAttemptComplete(ConversationAttempt attempt) return AreAllPropositionsCovered(attempt) && AreAllKeyRelationsArticulated(attempt); } - public string PickNextTarget(ConversationAttempt attempt) + public string? PickNextTarget(ConversationAttempt attempt, IReadOnlySet? excludedTargets = null) { var articulatedKps = attempt.GetArticulatedPropositionKeys(); - var nextTarget = KeyPropositions.Where(kp => !articulatedKps.Contains(kp.Key)) - .Select(kp => kp.Statement).FirstOrDefault(); + var nextTarget = KeyPropositions + .Where(kp => !articulatedKps.Contains(kp.Key)) + .Select(kp => kp.Statement) + .FirstOrDefault(s => excludedTargets == null || !excludedTargets.Contains(s)); if (nextTarget != null) return nextTarget; var articulatedKrs = attempt.GetArticulatedRelationKeys(); - var nextRelation = KeyRelations.First(kr => !articulatedKrs.Contains(kr.Key)); - var source = KeyPropositions.First(kp => kp.Key == nextRelation.SourceKey).Statement; - var target = KeyPropositions.First(kp => kp.Key == nextRelation.TargetKey).Statement; - return $"{source} → {target}. Mechanism: {nextRelation.Mechanism}"; + foreach (var kr in KeyRelations.Where(kr => !articulatedKrs.Contains(kr.Key))) + { + var source = KeyPropositions.First(kp => kp.Key == kr.SourceKey).Statement; + var target = KeyPropositions.First(kp => kp.Key == kr.TargetKey).Statement; + var composed = $"{source} → {target}. Mechanism: {kr.Mechanism}"; + if (excludedTargets == null || !excludedTargets.Contains(composed)) return composed; + } + + return null; } public int CountPropositionsAndRelations() diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs index 46cd76d69..9d723bc40 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs @@ -3,6 +3,7 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; public enum AttemptStatus { InProgress, + InClosing, 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 index 139991d0b..8ceb5cb81 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -1,4 +1,3 @@ -using System.Dynamic; using Tutor.BuildingBlocks.Core.Domain; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; @@ -6,15 +5,21 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; public class ConversationAttempt : AggregateRoot { + private const int ProbeLadderLength = 2; + private const int ScaffoldLadderLength = 3; + private const int StalledThreshold = ProbeLadderLength + ScaffoldLadderLength; + 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 string? Summary { get; private set; } - public List Turns { get; private set; } = new(); + private List _turns = new(); + public IReadOnlyList Turns => _turns.AsReadOnly(); public int? SoftCapTotalTurns { get; private set; } public int? HardCapTotalTurns { get; private set; } + public int? ClosingTurnCount { get; private set; } private ConversationAttempt() { } @@ -53,26 +58,76 @@ public int CountTotalLearnerTurns() public bool IsHardCapReached() => CountTotalLearnerTurns() >= HardCapTotalTurns; + public int GetProbeLevelFor(string target) + { + var max = Turns + .Where(t => t.Role == TurnRole.System && t.ProbeTarget == target && t.ProbeLevel.HasValue) + .Select(t => t.ProbeLevel!.Value) + .DefaultIfEmpty(0) + .Max(); + return max + 1; + } + + public ProbeDirective? GetLastProbe() + { + var last = Turns + .Where(t => t.Role == TurnRole.System && t.ProbeTarget != null) + .OrderByDescending(t => t.Order) + .FirstOrDefault(); + if (last == null) return null; + return new ProbeDirective(last.ProbeTarget!, last.ProbeLevel!.Value); + } + + public bool IsScaffolding(int ladderLevel) => ladderLevel > ProbeLadderLength; + + public int FirstScaffoldLadderLevel => ProbeLadderLength + 1; + + public IReadOnlySet GetStalledTargets() + { + return Turns + .Where(t => t.Role == TurnRole.System && t.ProbeTarget != null && t.ProbeLevel >= StalledThreshold) + .Select(t => t.ProbeTarget!) + .ToHashSet(); + } + + public int CountNonSubstantiveClosingTurns() + { + if (ClosingTurnCount == null) return 0; + return Turns + .Skip(ClosingTurnCount.Value) + .Count(t => t.Role == TurnRole.Learner && t.Intent != TurnIntent.Substantive); + } + public ConversationTurn AddLearnerTurn(string content, TurnIntent intent, TurnEvaluation? evaluation) { - var turn = new ConversationTurn(TurnRole.Learner, content, Turns.Count, intent, evaluation); - Turns.Add(turn); + var turn = new ConversationTurn(TurnRole.Learner, content, _turns.Count, intent, evaluation); + _turns.Add(turn); return turn; } public ConversationTurn AddSystemTurn(string content, ProbeDirective? probeDirective = null) { - var turn = new ConversationTurn(TurnRole.System, content, Turns.Count, + var turn = new ConversationTurn(TurnRole.System, content, _turns.Count, intent: null, evaluation: null, probeDirective: probeDirective); - Turns.Add(turn); + _turns.Add(turn); return turn; } - public void Complete(string? summary) + public void TransitionToClosing(string closingMessage) { + AddSystemTurn(closingMessage); + Status = AttemptStatus.InClosing; + ClosingTurnCount = _turns.Count; + } + + public void Complete(string content, TurnIntent intent, TurnEvaluation evaluation) + { + AddLearnerTurn(content, intent, evaluation); + Summary = $"{evaluation.Grade()} / 10"; + AddSystemTurn(Summary); + Status = AttemptStatus.Completed; CompletedAt = DateTime.UtcNow; - Summary = summary; } public void Abandon() diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs index 9d438197e..fc79959bb 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs @@ -5,9 +5,11 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; public class TurnEvaluation : Entity { public int ConversationTurnId { get; private set; } + // 0-5: accuracy of stated claims against key propositions public int CorrectnessScore { get; private set; } + // 0-5: coverage of essential key propositions in the learner message public int CompletenessScore { get; private set; } - public int? DiscriminationScore { get; private set; } + // 0-5: whether learner articulated key relations with their causal mechanism; null when no key relations exist public int? IntegrationScore { get; private set; } public string Justification { get; private set; } = string.Empty; public string? NovelMisconceptions { get; private set; } @@ -19,15 +21,12 @@ public class TurnEvaluation : Entity private TurnEvaluation() { } public TurnEvaluation( - int correctnessScore, int completenessScore, - int? discriminationScore, int? integrationScore, - string justification, string? novelMisconceptions, - List propositionsCoveredKeys, List misconceptionsTriggeredKeys, - List relationsArticulatedKeys, bool hasMultipleConcerns) + int correctnessScore, int completenessScore, int? integrationScore, + string justification, string? novelMisconceptions, List propositionsCoveredKeys, + List misconceptionsTriggeredKeys, List relationsArticulatedKeys, bool hasMultipleConcerns) { CorrectnessScore = correctnessScore; CompletenessScore = completenessScore; - DiscriminationScore = discriminationScore; IntegrationScore = integrationScore; Justification = justification; NovelMisconceptions = novelMisconceptions; @@ -36,4 +35,11 @@ public TurnEvaluation( RelationsArticulatedKeys = relationsArticulatedKeys; HasMultipleConcerns = hasMultipleConcerns; } + + public int Grade() + { + var dims = new List { CorrectnessScore, CompletenessScore }; + if (IntegrationScore is int i) dims.Add(i); + return (int)Math.Round(dims.Average() * 2); + } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs index 73d5746a4..98480c6d0 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs @@ -20,7 +20,6 @@ public ConceptElaborationTaskProfile() .ReverseMap(); CreateMap().ReverseMap(); - CreateMap().ReverseMap(); CreateMap().ReverseMap(); CreateMap().ReverseMap(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index b90f3ad56..ef23d8759 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -122,7 +122,8 @@ public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string cont 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; } + if (attempt.Status is not (AttemptStatus.InProgress or AttemptStatus.InClosing)) + { yield return BuildErrorChunk("Conversation is no longer active.", 409); yield break; } var task = _taskRepo.GetWithRecord(attempt.ConceptElaborationTaskId); if (task == null) { yield return BuildErrorChunk("Task not found.", 404); yield break; } @@ -150,7 +151,8 @@ public Result AbandonAttempt(int attemptId, int learnerI 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); + if (attempt.Status is not (AttemptStatus.InProgress or AttemptStatus.InClosing)) + return Result.Fail(FailureCode.Conflict); attempt.Abandon(); _attemptRepo.Update(attempt); @@ -164,7 +166,7 @@ private async IAsyncEnumerable RunTurnPipelineAsync(ConversationAttempt { if (task.ConceptRecord == null) { yield return BuildErrorChunk("Concept record missing.", 500); yield break; } - await foreach (var chunk in _orchestrator.ProcessTurnAsync(attempt, task.ConceptRecord, content, ct)) + await foreach (var chunk in _orchestrator.ProcessTurnAsync(task.ConceptRecord, attempt, content, ct)) { switch (chunk) { 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 index b2f0834eb..dc990feaf 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -14,24 +14,21 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public class AgentOrchestrator : IAgentOrchestrator { - private const int ScaffoldingLevel = 4; + private const int MaxNonSubstantiveClosingTurns = 3; - private readonly IAgentStream _streamAgent; - private readonly IAgentJson _jsonAgent; + private readonly IAgentFactory _factory; private readonly ITurnUsageTracker _usageTracker; private readonly ILogger _logger; - public AgentOrchestrator(IAgentStream streamAgent, IAgentJson jsonAgent, - ITurnUsageTracker usageTracker, ILogger logger) + public AgentOrchestrator(IAgentFactory factory, ITurnUsageTracker usageTracker, ILogger logger) { - _streamAgent = streamAgent; - _jsonAgent = jsonAgent; + _factory = factory; _usageTracker = usageTracker; _logger = logger; } - public async IAsyncEnumerable ProcessTurnAsync(ConversationAttempt attempt, - ConceptRecord record, string newMessage, [EnumeratorCancellation] CancellationToken ct) + public async IAsyncEnumerable ProcessTurnAsync(ConceptRecord record, ConversationAttempt attempt, + string newMessage, [EnumeratorCancellation] CancellationToken ct) { using var turnScope = _logger.BeginScope(new Dictionary { @@ -39,20 +36,25 @@ public async IAsyncEnumerable ProcessTurnAsync(ConversationAt ["TurnOrd"] = attempt.Turns.Count }); - var historyBeforeTurn = (IReadOnlyList)attempt.Turns.ToList(); - - var intentResult = await ClassifyIntentAsync(historyBeforeTurn, record, newMessage, ct); + var intentResult = await ClassifyIntentAsync(record, attempt.Turns, newMessage, ct); if (intentResult.IsFailed) { yield return new ErrorChunk("Intent classification failed.", 500); yield break; } - var intent = intentResult.Value; + + if (attempt.Status == AttemptStatus.InClosing) + { + await foreach (var chunk in HandleClosingTurnAsync(record, attempt, newMessage, intent, ct)) + yield return chunk; + yield break; + } + TurnEvaluation? evaluation = null; if (intent == TurnIntent.Substantive) { - var scoreResult = await ScoreTurnAsync(historyBeforeTurn, record, newMessage, ct); + var scoreResult = await ScoreTurnAsync(record, attempt.Turns, newMessage, ct); if (scoreResult.IsFailed) { yield return new ErrorChunk("Scoring failed.", 500); @@ -63,263 +65,222 @@ public async IAsyncEnumerable ProcessTurnAsync(ConversationAt attempt.AddLearnerTurn(newMessage, intent, evaluation); - var route = DecideRoute(attempt, record, intent, evaluation); - var historyForStreaming = (IReadOnlyList)attempt.Turns.ToList(); + var route = Route(record, attempt, intent, evaluation); - var (streamKind, streamCtx) = BuildStreamContext(route, record, attempt.IsSoftCapReached()); + if (route is RouteResult.Transition) + { + attempt.TransitionToClosing(ElaborationTexts.InClosingTransition); + yield return new TokenChunk(ElaborationTexts.InClosingTransition); + yield return CreateFinalChunk(); + yield break; + } var fullResponse = new StringBuilder(); - StreamFailure? streamFailure = null; - await foreach (var chunk in _streamAgent.StreamAsync(streamKind, historyForStreaming, record, streamCtx, ct)) + + if (route is RouteResult.OffTopic) + { + fullResponse.Append(ElaborationTexts.OffTopic); + yield return new TokenChunk(ElaborationTexts.OffTopic); + } + else if (route is RouteResult.Stream streamRoute) { - if (chunk is StreamFailure failure) + StreamFailure? streamFailure = null; + await foreach (var chunk in streamRoute.Agent.StreamAsync(attempt.Turns, record, streamRoute.Ctx, ct)) { - streamFailure = failure; - break; + if (chunk is StreamFailure failure) { streamFailure = failure; break; } + var content = ((StreamToken)chunk).Content; + fullResponse.Append(content); + yield return new TokenChunk(content); } - var content = ((StreamToken)chunk).Content; - fullResponse.Append(content); - yield return new TokenChunk(content); - } - if (streamFailure != null) - { - yield return new ErrorChunk(streamFailure.Reason, 500); - yield break; + if (streamFailure != null) + { + yield return new ErrorChunk(streamFailure.Reason, 500); + yield break; + } } - attempt.AddSystemTurn(fullResponse.ToString(), route.ProbeDirective); - - string? summary = null; - if (route.Closing is ClosingReason.AllCovered or ClosingReason.HardCapReached) + if (attempt.IsSoftCapReached() && ShouldAppendSoftCapNudge(intent)) { - summary = await SummarizeAsync(attempt.Turns, record, ct); - if (route.Closing == ClosingReason.AllCovered) attempt.Complete(summary); - else attempt.Expire(summary); + var nudge = "\n\n" + ElaborationTexts.SoftCapNudge; + fullResponse.Append(nudge); + yield return new TokenChunk(nudge); } - yield return new FinalChunk( - attempt.Id, attempt.Status, intent, summary, route.ProbeDirective, _usageTracker.Total); - } - - private Task> ClassifyIntentAsync(IReadOnlyList history, - ConceptRecord record, string newMessage, CancellationToken ct) - { - var ctx = new AgentTurnContext( - Instruction: "Classify the current learner message. Return JSON only.", - CurrentLearnerMessage: newMessage); + var probeDirective = (route as RouteResult.Stream)?.ProbeDirective; + attempt.AddSystemTurn(fullResponse.ToString(), probeDirective); + yield return CreateFinalChunk(directive: probeDirective); + yield break; - return _jsonAgent.CompleteAsync( - AgentKind.IntentClassifier, history, record, ctx, - r => Enum.TryParse(r.Intent, ignoreCase: true, out var intent) - ? Result.Ok(intent) - : Result.Fail("Unrecognized intent."), - "Intent classification failed.", ct); - } - - private Task> ScoreTurnAsync( - IReadOnlyList history, ConceptRecord record, - string learnerContent, CancellationToken ct) - { - var ctx = new AgentTurnContext( - Instruction: "Score the current learner message against the rubric. Return JSON only.", - CurrentLearnerMessage: learnerContent); - - return _jsonAgent.CompleteAsync( - AgentKind.Scorer, history, record, ctx, - r => MapToEvaluation(r, record), - "Scoring failed.", ct); + FinalChunk CreateFinalChunk(string? summary = null, ProbeDirective? directive = null) + => new(attempt.Id, attempt.Status, intent, summary, directive, _usageTracker.Total); } - private async Task SummarizeAsync( - IEnumerable turns, ConceptRecord record, CancellationToken ct) + private async IAsyncEnumerable HandleClosingTurnAsync(ConceptRecord record, + ConversationAttempt attempt, string newMessage, TurnIntent intent, [EnumeratorCancellation] CancellationToken ct) { - var history = (IReadOnlyList)turns.ToList(); - var ctx = new AgentTurnContext( - Instruction: "Summarize what the learner demonstrated understanding of in 2-4 sentences in Serbian. Paraphrase only, no verbatim quotes of rubric items."); + FinalChunk CreateFinalChunk(string? summary = null, ProbeDirective? directive = null) + => new(attempt.Id, attempt.Status, intent, summary, directive, _usageTracker.Total); - var buffer = new StringBuilder(); - await foreach (var chunk in _streamAgent.StreamAsync(AgentKind.Summary, history, record, ctx, ct)) + if (intent == TurnIntent.Substantive) { - if (chunk is StreamFailure) return null; - buffer.Append(((StreamToken)chunk).Content); + var scoreResult = await ScoreClosingAsync(record, attempt.Turns, newMessage, ct); + if (scoreResult.IsFailed) + { + yield return new ErrorChunk("Closing scoring failed.", 500); + yield break; + } + attempt.Complete(newMessage, intent, scoreResult.Value); + yield return new TokenChunk(attempt.Summary!); + yield return CreateFinalChunk(attempt.Summary); + yield break; } - return buffer.Length > 0 ? buffer.ToString() : null; - } - - private static Result MapToEvaluation(ScorerResponse parsed, ConceptRecord record) - { - if (parsed.CorrectnessScore is < 1 or > 3) return Result.Fail("Correctness out of range."); - if (parsed.CompletenessScore is < 1 or > 3) return Result.Fail("Completeness out of range."); - if (parsed.DiscriminationScore is not null and (< 1 or > 3)) return Result.Fail("Discrimination out of range."); - if (parsed.IntegrationScore is not null and (< 1 or > 3)) return Result.Fail("Integration out of range."); - - var validKpKeys = record.KeyPropositions.Select(kp => kp.Key).ToHashSet(); - var validKrKeys = record.KeyRelations.Select(kr => kr.Key).ToHashSet(); - var validCmKeys = record.CommonMisconceptions.Select(cm => cm.Key).ToHashSet(); + attempt.AddLearnerTurn(newMessage, intent, null); - if (parsed.PropositionsCoveredKeys?.Any(k => !validKpKeys.Contains(k)) == true) - return Result.Fail("Unknown proposition key."); - if (parsed.RelationsArticulatedKeys?.Any(k => !validKrKeys.Contains(k)) == true) - return Result.Fail("Unknown relation key."); - if (parsed.MisconceptionsTriggeredKeys?.Any(k => !validCmKeys.Contains(k)) == true) - return Result.Fail("Unknown misconception key."); + if (attempt.CountNonSubstantiveClosingTurns() >= MaxNonSubstantiveClosingTurns) + { + attempt.AddSystemTurn(ElaborationTexts.ExpiredNotice); + attempt.Expire(summary: null); + yield return new TokenChunk(ElaborationTexts.ExpiredNotice); + yield return CreateFinalChunk(); + yield break; + } - return new TurnEvaluation( - parsed.CorrectnessScore, parsed.CompletenessScore, - parsed.DiscriminationScore, parsed.IntegrationScore, - parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, - parsed.PropositionsCoveredKeys ?? new List(), - parsed.MisconceptionsTriggeredKeys ?? new List(), - parsed.RelationsArticulatedKeys ?? new List(), - parsed.HasMultipleConcerns ?? false); + attempt.AddSystemTurn(ElaborationTexts.NonSubstantiveInClosingNudge); + yield return new TokenChunk(ElaborationTexts.NonSubstantiveInClosingNudge); + yield return CreateFinalChunk(); } - private static (AgentKind Kind, AgentTurnContext Ctx) BuildStreamContext( - RouteDecision route, ConceptRecord record, bool softCap) => - route.Kind switch - { - RouteKind.Probe => (AgentKind.Probe, new AgentTurnContext( - Instruction: "Produce one probe question for the target at the given level.", - Target: route.ProbeDirective!.Target, - SoftCapReached: softCap)), - - RouteKind.Scaffold => (AgentKind.Scaffolding, new AgentTurnContext( - Instruction: "Produce a scaffold (forced choice, code skeleton, or analogy) that helps the learner reach the target without revealing it.", - Target: route.ProbeDirective!.Target)), - - RouteKind.Critique => (AgentKind.Critique, new AgentTurnContext( - Instruction: "Produce a short bulleted critique of the latest learner turn based on the evaluation. Do not ask a new Socratic question.", - Evaluation: route.Evaluation, - SoftCapReached: softCap)), - - RouteKind.Clarification => (AgentKind.Clarification, new AgentTurnContext( - Instruction: "Rephrase the tutor's prior question in simpler terms. Do not answer it. Then invite the learner to resume.", - Target: route.LastProbe!.Target)), - - RouteKind.Redirect => (AgentKind.Redirect, new AgentTurnContext( - Instruction: "Redirect the learner back to the concept with a concrete small next step.")), - - RouteKind.MetaHelp => (AgentKind.MetaHelp, new AgentTurnContext( - Instruction: "Answer the learner's meta/procedural question: open with the progress line verbatim, then pivot to the remaining gap.", - ProgressLine: route.ProgressLine, - Target: route.NextTarget!.Target)), - - RouteKind.Closing => (AgentKind.Closing, new AgentTurnContext( - Instruction: route.Closing == ClosingReason.AllCovered - ? "reason=AllCovered. Acknowledge that the learner has covered the concept in 2 sentences max." - : "reason=HardCapReached. Acknowledge that the conversation is ending in 2 sentences max.")), - - _ => throw new InvalidOperationException($"Unknown route kind: {route.Kind}") - }; - - private static RouteDecision DecideRoute(ConversationAttempt attempt, - ConceptRecord record, TurnIntent intent, TurnEvaluation? evaluation) + private RouteResult Route(ConceptRecord record, ConversationAttempt attempt, + TurnIntent intent, TurnEvaluation? evaluation) { - // TODO: We should have 1-2 rounds when nearing closing to prompt for summary. - if (record.IsAttemptComplete(attempt)) - return RouteDecision.Close(ClosingReason.AllCovered); - - if (attempt.IsHardCapReached()) - return RouteDecision.Close(ClosingReason.HardCapReached); + if (record.IsAttemptComplete(attempt) || attempt.IsHardCapReached()) + return new RouteResult.Transition(); switch (intent) { - case TurnIntent.Clarification: - return RouteDecision.Clarify(FindLastProbe(attempt)); - - case TurnIntent.OffTopic: - return RouteDecision.Redirect(); - - case TurnIntent.SummaryRequest: + case TurnIntent.Substantive: { - var progressLine = RenderProgressLine(attempt, record); - var next = record.PickNextTarget(attempt); - return RouteDecision.Meta(progressLine, next); + if (evaluation is { HasMultipleConcerns: true }) + return new RouteResult.Stream(_factory.CreateCritique(), + new AgentTurnContext(Evaluation: evaluation), + ProbeDirective: null); + var next = record.PickNextTarget(attempt, attempt.GetStalledTargets()); + if (next == null) return new RouteResult.Transition(); + var ladderLevel = attempt.GetProbeLevelFor(next); + var agent = attempt.IsScaffolding(ladderLevel) ? _factory.CreateScaffolding() : _factory.CreateProbe(); + return new RouteResult.Stream(agent, new AgentTurnContext(Target: next, Level: ladderLevel), new ProbeDirective(next, ladderLevel)); } case TurnIntent.Stuck: { - var next = record.PickNextTarget(attempt); - if (next == null) return RouteDecision.Close(ClosingReason.AllCovered); - var level = DeriveLevel(attempt, next); - var directive = new ProbeDirective(next, Math.Max(level, ScaffoldingLevel)); - return RouteDecision.Scaffold(directive); + var stalledTargets = attempt.GetStalledTargets(); + var stuckTarget = attempt.GetLastProbe()?.Target; + if (stuckTarget == null || stalledTargets.Contains(stuckTarget)) + { + stuckTarget = record.PickNextTarget(attempt, stalledTargets); + if (stuckTarget == null) return new RouteResult.Transition(); + var first = attempt.FirstScaffoldLadderLevel; + return new RouteResult.Stream( + _factory.CreateScaffolding(), + new AgentTurnContext(Target: stuckTarget, Level: first), + new ProbeDirective(stuckTarget, first)); + } + var ladderLevel = attempt.GetProbeLevelFor(stuckTarget); + return new RouteResult.Stream( + _factory.CreateScaffolding(), + new AgentTurnContext(Target: stuckTarget, Level: ladderLevel), + new ProbeDirective(stuckTarget, ladderLevel)); } - case TurnIntent.Substantive: + case TurnIntent.Clarification: { - if (evaluation is { HasMultipleConcerns: true }) - return RouteDecision.CritiqueFor(evaluation); - - var next = record.PickNextTarget(attempt); - if (next == null) return RouteDecision.Close(ClosingReason.AllCovered); - var level = DeriveLevel(attempt, next); - var directive = new ProbeDirective(next, level); - - return level >= ScaffoldingLevel - ? RouteDecision.Scaffold(directive) - : RouteDecision.Probe(directive); + var last = attempt.GetLastProbe(); + return new RouteResult.Stream( + _factory.CreateClarification(), + new AgentTurnContext(Target: last?.Target), + ProbeDirective: null); } + case TurnIntent.SummaryRequest: + return new RouteResult.Stream( + _factory.CreateSummary(), + new AgentTurnContext(), + ProbeDirective: null); + + case TurnIntent.OffTopic: default: - return RouteDecision.Redirect(); + return new RouteResult.OffTopic(); } } - private static int DeriveLevel(ConversationAttempt attempt, string target) + private static bool ShouldAppendSoftCapNudge(TurnIntent intent) => + intent is TurnIntent.Substantive or TurnIntent.Stuck or TurnIntent.SummaryRequest; + + private Task> ClassifyIntentAsync(ConceptRecord record, IReadOnlyList history, + string newMessage, CancellationToken ct) { - if (target == null) return 1; - var priorProbes = attempt.Turns.Count(t => - t.Role == TurnRole.System && - t.ProbeTarget == target); - return priorProbes + 1; + var ctx = new AgentTurnContext(CurrentLearnerMessage: newMessage); + return _factory.CreateIntentClassifier().CompleteAsync( + history, record, ctx, + r => Enum.TryParse(r.Intent, ignoreCase: true, out var intent) + ? Result.Ok(intent) + : Result.Fail("Unrecognized intent."), + "Intent classification failed.", ct); } - private static ProbeDirective? FindLastProbe(ConversationAttempt attempt) + private Task> ScoreTurnAsync(ConceptRecord record, + IReadOnlyList history, + string newMessage, CancellationToken ct) { - var last = attempt.Turns - .Where(t => t.Role == TurnRole.System && t.ProbeTarget != null) - .OrderByDescending(t => t.Order) - .FirstOrDefault(); - if (last == null) return null; - return new ProbeDirective(last.ProbeTarget!, last.ProbeLevel!.Value); + var ctx = new AgentTurnContext(CurrentLearnerMessage: newMessage); + return _factory.CreateTurnScorer().CompleteAsync( + history, record, ctx, + r => MapToEvaluation(r, record), + "Scoring failed.", ct); } - private static string RenderProgressLine(ConversationAttempt attempt, ConceptRecord record) + private Task> ScoreClosingAsync(ConceptRecord record, + IReadOnlyList history, + string newMessage, CancellationToken ct) { - var coveredKp = attempt.GetArticulatedPropositionKeys().Count; - var totalKp = record.KeyPropositions.Count; - var articulatedKr = attempt.GetArticulatedRelationKeys().Count; - var totalKr = record.KeyRelations.Count; - - return totalKr > 0 - ? $"Dosadašnji napredak: pokriveno {coveredKp}/{totalKp} ključnih izjava i {articulatedKr}/{totalKr} ključnih veza." - : $"Dosadašnji napredak: pokriveno {coveredKp}/{totalKp} ključnih izjava."; + var ctx = new AgentTurnContext(CurrentLearnerMessage: newMessage); + return _factory.CreateClosingScorer().CompleteAsync( + history, record, ctx, + r => MapToEvaluation(r, record), + "Closing scoring failed.", ct); } - private enum RouteKind { Probe, Scaffold, Critique, Clarification, Redirect, MetaHelp, Closing } + private static Result MapToEvaluation(ScorerResponse parsed, ConceptRecord record) + { + if (parsed.CorrectnessScore is < 0 or > 5) return Result.Fail("Correctness out of range."); + if (parsed.CompletenessScore is < 0 or > 5) return Result.Fail("Completeness out of range."); + if (parsed.IntegrationScore is not null and (< 0 or > 5)) return Result.Fail("Integration out of range."); + + var validKpKeys = record.KeyPropositions.Select(kp => kp.Key).ToHashSet(); + var validKrKeys = record.KeyRelations.Select(kr => kr.Key).ToHashSet(); + var validCmKeys = record.CommonMisconceptions.Select(cm => cm.Key).ToHashSet(); - private sealed record RouteDecision( - RouteKind Kind, - ProbeDirective? ProbeDirective = null, - TurnEvaluation? Evaluation = null, - ProbeDirective? LastProbe = null, - string? ProgressLine = null, - ProbeDirective? NextTarget = null, - ClosingReason? Closing = null) + if (parsed.PropositionsCoveredKeys?.Any(k => !validKpKeys.Contains(k)) == true) + return Result.Fail("Unknown proposition key."); + if (parsed.RelationsArticulatedKeys?.Any(k => !validKrKeys.Contains(k)) == true) + return Result.Fail("Unknown relation key."); + if (parsed.MisconceptionsTriggeredKeys?.Any(k => !validCmKeys.Contains(k)) == true) + return Result.Fail("Unknown misconception key."); + + return new TurnEvaluation( + parsed.CorrectnessScore, parsed.CompletenessScore, parsed.IntegrationScore, + parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, parsed.PropositionsCoveredKeys ?? new List(), + parsed.MisconceptionsTriggeredKeys ?? new List(), parsed.RelationsArticulatedKeys ?? new List(), + parsed.HasMultipleConcerns ?? false); + } + + private abstract record RouteResult { - public static RouteDecision Probe(ProbeDirective d) => new(RouteKind.Probe, ProbeDirective: d); - public static RouteDecision Scaffold(ProbeDirective d) => new(RouteKind.Scaffold, ProbeDirective: d); - public static RouteDecision CritiqueFor(TurnEvaluation e) => new(RouteKind.Critique, Evaluation: e); - public static RouteDecision Clarify(ProbeDirective? last) => new(RouteKind.Clarification, LastProbe: last); - public static RouteDecision Redirect() => new(RouteKind.Redirect); - public static RouteDecision Meta(string progressLine, string nextTarget) - { - var nextDirective = new ProbeDirective(nextTarget, 1); - return new RouteDecision(RouteKind.MetaHelp, ProgressLine: progressLine, NextTarget: nextDirective); - } - public static RouteDecision Close(ClosingReason reason) => new(RouteKind.Closing, Closing: reason); + public sealed record Stream( + IAgentStream Agent, AgentTurnContext Ctx, ProbeDirective? ProbeDirective) : RouteResult; + public sealed record OffTopic : RouteResult; + public sealed record Transition : RouteResult; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentFactory.cs new file mode 100644 index 000000000..4365443d3 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentFactory.cs @@ -0,0 +1,13 @@ +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; + +public interface IAgentFactory +{ + IAgentJson CreateIntentClassifier(); + IAgentJson CreateTurnScorer(); + IAgentJson CreateClosingScorer(); + IAgentStream CreateProbe(); + IAgentStream CreateScaffolding(); + IAgentStream CreateCritique(); + IAgentStream CreateClarification(); + IAgentStream CreateSummary(); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs index 13471d7ac..bb142c13a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs @@ -8,8 +8,8 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; public interface IAgentJson { Task> CompleteAsync( - AgentKind kind, IReadOnlyList history, - ConceptRecord record, AgentTurnContext ctx, + IReadOnlyList history, ConceptRecord record, + AgentTurnContext ctx, Func> validateAndMap, string failureMessage, CancellationToken ct) where TResponse : class; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentStream.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentStream.cs index 588e713a7..91bedcf85 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentStream.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentStream.cs @@ -8,6 +8,6 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; public interface IAgentStream { IAsyncEnumerable StreamAsync( - AgentKind kind, IReadOnlyList history, - ConceptRecord record, AgentTurnContext ctx, CancellationToken ct); + IReadOnlyList history, ConceptRecord record, + AgentTurnContext ctx, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ClosingReason.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ClosingReason.cs deleted file mode 100644 index b0ce58c6e..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ClosingReason.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -public enum ClosingReason { AllCovered, HardCapReached } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ElaborationTexts.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ElaborationTexts.cs new file mode 100644 index 000000000..3762894fa --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ElaborationTexts.cs @@ -0,0 +1,10 @@ +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public static class ElaborationTexts +{ + public const string SoftCapNudge = "SOFT_CAP"; + public const string InClosingTransition = "CLOSING_TRANSITION"; + public const string NonSubstantiveInClosingNudge = "CLOSING_NUDGE"; + public const string ExpiredNotice = "EXPIRED"; + public const string OffTopic = "OFF_TOPIC"; +} 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 index 27bcf8b1c..5a5fc5bad 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestrator.cs @@ -5,6 +5,6 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public interface IAgentOrchestrator { - IAsyncEnumerable ProcessTurnAsync(ConversationAttempt attempt, - ConceptRecord record, string newMessage, CancellationToken ct); + IAsyncEnumerable ProcessTurnAsync( + ConceptRecord record, ConversationAttempt attempt, string newMessage, CancellationToken ct); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfigs.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfigs.cs index ca0f5ac56..c0b9ebf2a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfigs.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfigs.cs @@ -6,15 +6,13 @@ public static class AgentConfigs { public static readonly IReadOnlyDictionary ByKind = new Dictionary { - [AgentKind.IntentClassifier] = new(IntentPrompt.Build, MaxTokens: 64, Temperature: 0.0, HistoryWindow: 6), - [AgentKind.Scorer] = new(ScorerPrompt.Build, MaxTokens: 1024, Temperature: 0.0), - [AgentKind.Probe] = new(ProbePrompt.Build, MaxTokens: 256, Temperature: 0.7), - [AgentKind.Scaffolding] = new(ScaffoldingPrompt.Build, MaxTokens: 512, Temperature: 0.7), - [AgentKind.Critique] = new(CritiquePrompt.Build, MaxTokens: 512, Temperature: 0.7), - [AgentKind.Clarification] = new(ClarificationPrompt.Build,MaxTokens: 256, Temperature: 0.5), - [AgentKind.Redirect] = new(RedirectPrompt.Build, MaxTokens: 128, Temperature: 0.7), - [AgentKind.MetaHelp] = new(MetaHelpPrompt.Build, MaxTokens: 256, Temperature: 0.5), - [AgentKind.Closing] = new(ClosingPrompt.Build, MaxTokens: 128, Temperature: 0.5), - [AgentKind.Summary] = new(SummaryPrompt.Build, MaxTokens: 256, Temperature: 0.5), + [AgentKind.IntentClassifier] = new(IntentPrompt.Build, MaxTokens: 64, Temperature: 0.0, HistoryWindow: 6), + [AgentKind.TurnScorer] = new(TurnScorerPrompt.Build, MaxTokens: 1024, Temperature: 0.0), + [AgentKind.ClosingScorer] = new(ClosingScorerPrompt.Build, MaxTokens: 1024, Temperature: 0.0), + [AgentKind.Probe] = new(ProbePrompt.Build, MaxTokens: 256, Temperature: 0.7), + [AgentKind.Scaffolding] = new(ScaffoldingPrompt.Build, MaxTokens: 512, Temperature: 0.7), + [AgentKind.Critique] = new(CritiquePrompt.Build, MaxTokens: 512, Temperature: 0.7), + [AgentKind.Clarification] = new(ClarificationPrompt.Build, MaxTokens: 256, Temperature: 0.5), + [AgentKind.Summary] = new(SummaryPrompt.Build, MaxTokens: 256, Temperature: 0.5), }; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentKind.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentKind.cs index 5d2410bed..9ccf889ab 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentKind.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentKind.cs @@ -3,13 +3,11 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public enum AgentKind { IntentClassifier, - Scorer, + TurnScorer, + ClosingScorer, Probe, Scaffolding, Critique, Clarification, - Redirect, - MetaHelp, - Closing, Summary } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs index dcc7c896e..c13931503 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs @@ -2,15 +2,8 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; -/// -/// Per-turn volatile state handed to an agent. Lives in the trailing user message -/// (rendered by ) so none of it pollutes the cacheable system prompt. -/// Every field is optional; agents populate only what they need. -/// public sealed record AgentTurnContext( - string Instruction, - string? ProgressLine = null, string? Target = null, - bool SoftCapReached = false, + int? Level = null, TurnEvaluation? Evaluation = null, string? CurrentLearnerMessage = null); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs index 2d9a411ef..98c74ac4c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs @@ -26,7 +26,7 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Runtime Context Format"); sb.AppendLine("Chat history shows the conversation so far. The last user message is the learner's clarification request."); - sb.AppendLine("The final user message contains: optional (what the TUTOR was probing — INTERNAL reference only), ."); + sb.AppendLine("The final user message contains: optional (what the TUTOR was probing — INTERNAL reference only)."); return sb.ToString(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingPrompt.cs deleted file mode 100644 index 04b98d102..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingPrompt.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; - -public static class ClosingPrompt -{ - public static string Build(ConceptRecord record) - { - var sb = new StringBuilder(); - sb.AppendLine(ConceptRecordRubricSection.Render(record)); - - sb.AppendLine("# Role"); - sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); - sb.AppendLine("Produce a closing turn that ends the conversation. The reason for closing is given in the of the runtime context."); - sb.AppendLine(); - - sb.AppendLine("# Rules"); - sb.AppendLine("- Two sentences maximum."); - sb.AppendLine("- If the instruction says the learner covered everything (reason=AllCovered), acknowledge completion in general terms (e.g. \"dobro si obuhvatio koncept\")."); - sb.AppendLine("- If the instruction says the conversation reached maximum length (reason=HardCapReached), acknowledge that the conversation is ending in general terms."); - sb.AppendLine("- DO NOT summarize the learner's explanation or the concept. The summary agent writes the summary separately."); - sb.AppendLine("- DO NOT list, restate, or paraphrase any KP/BC/CM/KR — not even ones already articulated."); - sb.AppendLine("- DO NOT use bullet points or structured lists."); - sb.AppendLine("- DO NOT ask further questions."); - sb.AppendLine(); - - sb.AppendLine("# Runtime Context Format"); - sb.AppendLine("Chat history shows the full conversation."); - sb.AppendLine("The final user message contains: (carries reason=AllCovered or reason=HardCapReached)."); - - return sb.ToString(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingScorerPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingScorerPrompt.cs new file mode 100644 index 000000000..a86755d35 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingScorerPrompt.cs @@ -0,0 +1,68 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptRecords; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; + +public static class ClosingScorerPrompt +{ + public static string Build(ConceptRecord record) + { + var hasCommonMisconceptions = record.CommonMisconceptions.Count != 0; + var hasKeyRelations = record.KeyRelations.Count != 0; + + var sb = new StringBuilder(); + sb.AppendLine(ConceptRecordRubricSection.Render(record)); + + sb.AppendLine("# Role"); + sb.AppendLine("You are a summative scoring agent for a Socratic tutoring system."); + sb.AppendLine("The learner has just submitted their FINAL articulation of the concept — a single standalone answer to the original prompt. Grade it as a standalone deliverable against the full rubric. Output JSON only, no other text."); + sb.AppendLine(); + + sb.AppendLine("# Scope rule"); + sb.AppendLine("Score ONLY the text inside . Do NOT credit content that appears only in prior turns. The learner was asked to consolidate everything into this single message, and the grade reflects only what is present here."); + sb.AppendLine(); + + sb.AppendLine("# Rubric (applied to the whole deliverable)"); + sb.AppendLine("- Correctness (0-5): Are stated claims true? Check against KPs."); + sb.AppendLine("- Completeness (0-5): Are essential KPs covered across the whole message?"); + if (hasKeyRelations) + sb.AppendLine("- Integration (0-5): Did the learner articulate key relations *with mechanism*? 0=no relation, 1-2=relation without mechanism, 3-5=relation with mechanism matching the authored description."); + sb.AppendLine("- Evaluate concepts, not language. Grammar and style must not reduce scores."); + sb.AppendLine("- Resist sycophancy. Evaluate strictly against rubric."); + sb.AppendLine(); + + sb.AppendLine("# Concern count"); + sb.AppendLine("Count distinct concerns present in the final deliverable. A concern is any of:"); + sb.AppendLine(" - a stated inaccuracy (a claim that contradicts a KP);"); + sb.AppendLine(hasCommonMisconceptions + ? " - a triggered known misconception or a novel misconception;" + : " - a novel misconception (none are pre-catalogued for this concept);"); + sb.AppendLine(" - a vague or hand-wavy claim that references a KP without articulating it."); + sb.AppendLine("Set hasMultipleConcerns=true if the count is two or more; false otherwise."); + sb.AppendLine(); + + sb.AppendLine("# Runtime Context Format"); + sb.AppendLine("Chat history shows the full prior conversation for context only — DO NOT score these."); + sb.AppendLine("The final user message contains the deliverable inside ."); + sb.AppendLine(); + + sb.AppendLine("# Output Format (JSON only, no other text)"); + var fields = new List + { + "\"correctnessScore\": 0-5", + "\"completenessScore\": 0-5" + }; + if (hasKeyRelations) fields.Add("\"integrationScore\": 0-5"); + fields.Add("\"justification\": \"brief explanation of scores\""); + fields.Add("\"propositionsCoveredKeys\": [string list of KP keys covered, e.g. [\"P1\", \"P2\"]]"); + if (hasCommonMisconceptions) fields.Add("\"misconceptionsTriggeredKeys\": [string list of CM keys triggered, e.g. [\"M1\"]]"); + if (hasKeyRelations) fields.Add("\"relationsArticulatedKeys\": [string list of KR keys articulated with mechanism, e.g. [\"R1\"]]"); + if (hasCommonMisconceptions) fields.Add("\"novelMisconceptions\": \"any misconceptions not in the list, or null\""); + fields.Add("\"hasMultipleConcerns\": true|false"); + + sb.AppendLine("{"); + sb.AppendLine(string.Join(",\n", fields.Select(f => " " + f))); + sb.AppendLine("}"); + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs index b781d5114..c17855ca1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs @@ -22,12 +22,11 @@ public static string Build(ConceptRecord record) sb.AppendLine("- Close the bullets with a brief invitation to address them. Do not ask a new Socratic question — the learner must consolidate first."); sb.AppendLine("- Silence on an error reads as agreement, so surface every in-turn concern."); sb.AppendLine("- Concise language. Respect cognitive load."); - sb.AppendLine("- If is present in the runtime context, signal that you'll wrap up once these are addressed."); sb.AppendLine(); sb.AppendLine("# Runtime Context Format"); sb.AppendLine("Chat history shows the conversation so far (user=learner, assistant=tutor). The latest learner turn is the last user message."); - sb.AppendLine("The final user message may contain: (scores + triggered misconceptions for the latest turn), , ."); + sb.AppendLine("The final user message may contain: (scores + triggered misconceptions for the latest turn)."); return sb.ToString(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs index f838be6a3..0e29d9102 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs @@ -32,7 +32,7 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Runtime Context Format"); sb.AppendLine("Chat history shows prior turns (user=learner, assistant=tutor) for context only — DO NOT classify these."); - sb.AppendLine("The final user message contains the message to classify inside , followed by ."); + sb.AppendLine("The final user message contains the message to classify inside ."); sb.AppendLine(); sb.AppendLine("# Output Format"); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/MetaHelpPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/MetaHelpPrompt.cs deleted file mode 100644 index 816d0342d..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/MetaHelpPrompt.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; - -public static class MetaHelpPrompt -{ - public static string Build(ConceptRecord record) - { - var sb = new StringBuilder(); - sb.AppendLine(ConceptRecordRubricSection.Render(record)); - - sb.AppendLine("# Role"); - sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); - sb.AppendLine("The learner asked a meta/procedural question about the conversation itself (e.g. \"rezimiraj šta sam rekao\", \"koliko mi je ostalo\")."); - sb.AppendLine(); - - sb.AppendLine("# Rules"); - sb.AppendLine("- Open with the line from the runtime context exactly as given. Do not invent numbers or substitute it."); - sb.AppendLine("- Then invite the learner to address the one remaining gap with a short, narrow question or cue."); - sb.AppendLine("- Three sentences maximum."); - sb.AppendLine("- NEVER restate the concept, enumerate covered points, or reveal any KP/KR text."); - sb.AppendLine("- Do not produce a list or a summary of what the learner said — only the progress line plus a pivot."); - sb.AppendLine("- If a is given it is the next target — INTERNAL, NEVER reveal or paraphrase its statement."); - sb.AppendLine(); - - sb.AppendLine("# Runtime Context Format"); - sb.AppendLine("Chat history shows the conversation so far. The last user message is the learner's meta/procedural question."); - sb.AppendLine("The final user message contains: (use verbatim as the opener), optional (next target — INTERNAL reference only), ."); - - return sb.ToString(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs index 87914a0f7..51c5a4e43 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs @@ -16,23 +16,21 @@ public static string Build(ConceptRecord record) sb.AppendLine(); sb.AppendLine("# Escalation levels"); - sb.AppendLine("The tag carries a level attribute (1-3) that shapes the question:"); + sb.AppendLine("The tag carries a level attribute (1-2) that shapes the question:"); sb.AppendLine("- L1 — open \"why\" or \"what\" question that invites the learner to articulate the target. Broad enough to let them arrive at the idea themselves."); sb.AppendLine("- L2 — the learner has already failed an L1 probe on this target. Ask a narrower, connective question: \"how is X tied to Y\", \"what role does X play when Y\". Still no hints to the target statement."); - sb.AppendLine("- L3 — the learner has failed twice. Offer a sentence-completion scaffold: \"Dopuni rečenicu: '…zato što…'\" or \"Dovrši misao: …\". The blank must not contain the target text."); sb.AppendLine(); sb.AppendLine("# Rules"); sb.AppendLine("- The statement is INTERNAL — NEVER reveal or paraphrase it closely enough to give away the answer."); - sb.AppendLine("- Output ONE question (or one sentence-completion prompt at L3). No preamble, no bullet list."); + sb.AppendLine("- Output ONE question. No preamble, no bullet list."); sb.AppendLine("- NEVER list multiple options or enumerate gaps."); sb.AppendLine("- Concise. Respect cognitive load."); - sb.AppendLine("- If is present, signal that you'll wrap up once this is addressed."); sb.AppendLine(); sb.AppendLine("# Runtime Context Format"); sb.AppendLine("Chat history shows the conversation so far (user=learner, assistant=tutor)."); - sb.AppendLine("The final user message contains: …statement…, optional , ."); + sb.AppendLine("The final user message contains: …statement…."); return sb.ToString(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/RedirectPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/RedirectPrompt.cs deleted file mode 100644 index 16ea9b318..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/RedirectPrompt.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; - -public static class RedirectPrompt -{ - public static string Build(ConceptRecord record) - { - var sb = new StringBuilder(); - sb.AppendLine(ConceptRecordRubricSection.Render(record)); - - sb.AppendLine("# Role"); - sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); - sb.AppendLine("The learner's last message is off-topic — small talk, jokes, personal content, refusals, or disengagement."); - sb.AppendLine(); - - sb.AppendLine("# Rules"); - sb.AppendLine("- Acknowledge in one short clause without engaging with the off-topic content."); - sb.AppendLine("- Firmly but kindly redirect to the concept. End with a concrete, small next step on it."); - sb.AppendLine("- Do NOT answer off-topic questions, validate the off-topic direction, offer to change topic, suggest pausing or abandoning, or ask about the learner's mood."); - sb.AppendLine("- Two sentences maximum. No bullet list."); - sb.AppendLine(); - - sb.AppendLine("# Runtime Context Format"); - sb.AppendLine("Chat history shows the conversation so far. The last user message is the learner's off-topic message."); - sb.AppendLine("The final user message contains: ."); - - return sb.ToString(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs index 909c6e192..2e3b438aa 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs @@ -12,25 +12,26 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Role"); sb.AppendLine("You are a Socratic tutoring scaffolding agent. Speak Serbian."); - sb.AppendLine("The learner has stalled after repeated probes on one target (given in the runtime context). Provide a concrete structural scaffold that gives them a foothold WITHOUT stating the target."); + sb.AppendLine("The learner has stalled on the target given in the runtime context. Provide a scaffold at the specified level that helps them articulate the target WITHOUT revealing it."); sb.AppendLine(); - sb.AppendLine("# Scaffolding options (pick ONE that fits best)"); - sb.AppendLine("1. **Forced choice** — offer two options, exactly one of which points toward the target, both phrased at the same level of abstraction. Ask the learner to choose and justify."); - sb.AppendLine("2. **Code skeleton with labeled blanks** — a minimal pseudo-code / test skeleton with `// ___` blanks labeled by role (e.g. `// pripremi očekivano`). Ask the learner to fill one blank."); - sb.AppendLine("3. **Analogy** — map the target to a simpler, non-technical domain. Present the analogy's structure and ask the learner to translate it back to the concept."); + sb.AppendLine("# Escalation levels"); + sb.AppendLine("The tag carries a level attribute (3-5) that shapes the scaffold:"); + sb.AppendLine("- **L3 — Rephrase.** Restate the tutor's prior question in simpler language. Do NOT introduce new content, examples, or hints. One or two short sentences."); + sb.AppendLine("- **L4 — Worked example.** Produce ONE short concrete example (3–6 lines of code OR 2–3 sentence scenario) illustrating a CONTEXT where the target concept operates. End with one narrow question that forces the learner to name what is happening. The canonical definition and relation mechanisms are INSPIRATION for the example only — never paraphrase them."); + sb.AppendLine("- **L5 — Contrasting pair.** Produce TWO short contrasting examples — one exhibits the target correctly, one violates it in a realistic way. If a common misconception is catalogued for this concept, prefer that as the \"violates\" case. Ask which example is correct and why. The \"why\" must require articulating the target."); sb.AppendLine(); sb.AppendLine("# Rules"); sb.AppendLine("- The statement is INTERNAL — NEVER reveal or paraphrase it closely enough to give it away. The scaffold must make the learner do the articulation."); - sb.AppendLine("- Keep it short. A code skeleton with 3-4 labeled lines, or a two-option forced choice, or a 2-sentence analogy."); - sb.AppendLine("- End with one concrete, narrow request (\"Koja opcija i zašto?\" / \"Šta ide na mestu ___?\" / \"Prevedi ovu analogiju na naš koncept.\")."); - sb.AppendLine("- No bullet lists beyond what the scaffold structurally requires."); + sb.AppendLine("- Keep examples short. Code: 3–6 lines. Scenarios: 2–3 sentences."); + sb.AppendLine("- End with ONE concrete question. The two L5 options count as structural formatting, not a bullet list of hints."); + sb.AppendLine("- Do NOT use analogies, sentence-completion blanks, or forced-choice between abstract phrasings."); sb.AppendLine(); sb.AppendLine("# Runtime Context Format"); sb.AppendLine("Chat history shows the conversation so far (including the learner's prior attempts on this target)."); - sb.AppendLine("The final user message contains: …statement…, ."); + sb.AppendLine("The final user message contains: …statement…."); return sb.ToString(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs index cc77c8243..f41411142 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs @@ -4,7 +4,6 @@ public class ScorerResponse { public int CorrectnessScore { get; set; } public int CompletenessScore { get; set; } - public int? DiscriminationScore { get; set; } public int? IntegrationScore { get; set; } public string? Justification { get; set; } public List? PropositionsCoveredKeys { get; set; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs index 2984793c2..ffcb517b5 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs @@ -11,21 +11,20 @@ public static string Build(ConceptRecord record) sb.AppendLine(ConceptRecordRubricSection.Render(record)); sb.AppendLine("# Role"); - sb.AppendLine("You are a summary agent. Write a brief natural-language summary of the conversation."); - sb.AppendLine("Paraphrase what the learner demonstrated understanding of. Never quote proposition or relation statements verbatim."); - sb.AppendLine("Write in Serbian. Keep the summary to 2-4 sentences."); + sb.AppendLine("You are a progress-summary agent. Speak Serbian."); + sb.AppendLine("The learner asked a procedural question about their own progress mid-conversation (\"what have I said so far?\", \"summarize my answers\"). Paraphrase what the learner has articulated in their OWN prior turns so they can spot their own gaps."); sb.AppendLine(); sb.AppendLine("# Rules"); - sb.AppendLine("- Base the summary on the learner's actual turns in the chat history, not on the KP list."); + sb.AppendLine("- Base the summary ONLY on the learner's actual turns in the chat history. Do not list covered KPs/KRs or enumerate what is \"missing\"."); sb.AppendLine("- Paraphrase at the level of the learner's articulations; do not upgrade them with rubric language."); sb.AppendLine("- NEVER quote any KP/BC/CM/KR text verbatim or near-verbatim."); sb.AppendLine("- No bullet lists. One short paragraph, 2-4 sentences."); + sb.AppendLine("- End with a brief invitation to continue (\"nastavi odatle\" / \"šta još bi dodao?\")."); sb.AppendLine(); sb.AppendLine("# Runtime Context Format"); - sb.AppendLine("Chat history shows the full conversation (user=learner, assistant=tutor)."); - sb.AppendLine("The final user message contains: ."); + sb.AppendLine("Chat history shows the conversation so far (user=learner, assistant=tutor)."); return sb.ToString(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/TurnScorerPrompt.cs similarity index 76% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerPrompt.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/TurnScorerPrompt.cs index 711828e00..c2ed30b44 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/TurnScorerPrompt.cs @@ -3,11 +3,10 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; -public static class ScorerPrompt +public static class TurnScorerPrompt { public static string Build(ConceptRecord record) { - var hasBoundaryConditions = record.BoundaryConditions.Count != 0; var hasCommonMisconceptions = record.CommonMisconceptions.Count != 0; var hasKeyRelations = record.KeyRelations.Count != 0; @@ -24,21 +23,17 @@ public static string Build(ConceptRecord record) sb.AppendLine(); sb.AppendLine("# Rubric"); - sb.AppendLine(hasBoundaryConditions - ? "- Correctness (1-3): Are stated claims true? Check against KPs and BCs." - : "- Correctness (1-3): Are stated claims true? Check against KPs."); - sb.AppendLine("- Completeness (1-3): Are essential KPs covered in THIS message?"); - if (hasBoundaryConditions) - sb.AppendLine("- Discrimination (1-3): Does the explanation correctly exclude non-examples? Check BCs."); + sb.AppendLine("- Correctness (0-5): Are stated claims true? Check against KPs."); + sb.AppendLine("- Completeness (0-5): Are essential KPs covered in THIS message?"); if (hasKeyRelations) - sb.AppendLine("- Integration (1-3): Did the learner articulate key relations *with mechanism*? 1=no relation, 2=relation without mechanism, 3=relation with mechanism matching the authored description."); + sb.AppendLine("- Integration (0-5): Did the learner articulate key relations *with mechanism*? 0=no relation, 1-2=relation without mechanism, 3-5=relation with mechanism matching the authored description."); sb.AppendLine("- Evaluate concepts, not language. Grammar and style must not reduce scores."); sb.AppendLine("- Resist sycophancy. Evaluate strictly against rubric."); sb.AppendLine(); sb.AppendLine("# Concern count (used by the orchestrator to route to critique vs probe)"); sb.AppendLine("Count distinct concerns in the message. A concern is any of:"); - sb.AppendLine(" - a stated inaccuracy (a claim that contradicts a KP or BC);"); + sb.AppendLine(" - a stated inaccuracy (a claim that contradicts a KP);"); sb.AppendLine(hasCommonMisconceptions ? " - a triggered known misconception or a novel misconception;" : " - a novel misconception (none are pre-catalogued for this concept);"); @@ -48,17 +43,16 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Runtime Context Format"); sb.AppendLine("Chat history shows prior turns (user=learner, assistant=tutor) for context only — DO NOT score these."); - sb.AppendLine("The final user message contains the message to score inside , followed by ."); + sb.AppendLine("The final user message contains the message to score inside ."); sb.AppendLine(); sb.AppendLine("# Output Format (JSON only, no other text)"); var fields = new List { - "\"correctnessScore\": 1-3", - "\"completenessScore\": 1-3" + "\"correctnessScore\": 0-5", + "\"completenessScore\": 0-5" }; - if (hasBoundaryConditions) fields.Add("\"discriminationScore\": 1-3"); - if (hasKeyRelations) fields.Add("\"integrationScore\": 1-3"); + if (hasKeyRelations) fields.Add("\"integrationScore\": 0-5"); fields.Add("\"justification\": \"brief explanation of scores\""); fields.Add("\"propositionsCoveredKeys\": [string list of KP keys covered in this turn, e.g. [\"P1\", \"P2\"]]"); if (hasCommonMisconceptions) fields.Add("\"misconceptionsTriggeredKeys\": [string list of CM keys triggered, e.g. [\"M1\"]]"); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRecordRubricSection.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRecordRubricSection.cs index 341c23da0..4b69831ac 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRecordRubricSection.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRecordRubricSection.cs @@ -26,14 +26,6 @@ public static string Render(ConceptRecord record) sb.AppendLine($"- [{kp.Key}] {kp.Statement}"); sb.AppendLine(); - if (record.BoundaryConditions.Count > 0) - { - sb.AppendLine("## Boundary Conditions"); - foreach (var bc in record.BoundaryConditions) - sb.AppendLine($"- [{bc.Key}] {bc.Statement}"); - sb.AppendLine(); - } - if (record.CommonMisconceptions.Count > 0) { sb.AppendLine("## Common Misconceptions"); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs index 2969a9bf1..246fd1bf6 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs @@ -3,33 +3,26 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; -/// -/// Renders an as a single trailing user message. -/// The XML-ish tags match the schema described in each agent's system prompt -/// so the model knows exactly how to interpret the runtime state. -/// public static class RuntimeContextBlock { public static string Render(AgentTurnContext ctx) { var sb = new StringBuilder(); - if (!string.IsNullOrWhiteSpace(ctx.ProgressLine)) - sb.AppendLine($"{ctx.ProgressLine}"); - if (ctx.Target is { } t) - sb.AppendLine($"{t}"); - - if (ctx.SoftCapReached) - sb.AppendLine(""); + { + if (ctx.Level is { } lvl) + sb.AppendLine($"{t}"); + else + sb.AppendLine($"{t}"); + } if (ctx.Evaluation is { } e) sb.AppendLine(RenderEvaluation(e)); if (!string.IsNullOrWhiteSpace(ctx.CurrentLearnerMessage)) - sb.AppendLine($"{ctx.CurrentLearnerMessage}"); + sb.Append($"{ctx.CurrentLearnerMessage}"); - sb.Append($"{ctx.Instruction}"); return sb.ToString(); } @@ -41,7 +34,6 @@ private static string RenderEvaluation(TurnEvaluation e) $"correctness=\"{e.CorrectnessScore}\"", $"completeness=\"{e.CompletenessScore}\"" }; - if (e.DiscriminationScore.HasValue) attrs.Add($"discrimination=\"{e.DiscriminationScore.Value}\""); if (e.IntegrationScore.HasValue) attrs.Add($"integration=\"{e.IntegrationScore.Value}\""); attrs.Add($"hasMultipleConcerns=\"{e.HasMultipleConcerns.ToString().ToLowerInvariant()}\""); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentFactory.cs new file mode 100644 index 000000000..48ba27f82 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentFactory.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Logging; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; +using Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +namespace Tutor.Elaborations.Infrastructure.Agents; + +public class AgentFactory : IAgentFactory +{ + private readonly IAiChatService _chatService; + private readonly ITurnUsageTracker _usageTracker; + private readonly ILogger _jsonLogger; + private readonly ILogger _streamLogger; + + public AgentFactory(IAiChatService chatService, ITurnUsageTracker usageTracker, + ILogger jsonLogger, ILogger streamLogger) + { + _chatService = chatService; + _usageTracker = usageTracker; + _jsonLogger = jsonLogger; + _streamLogger = streamLogger; + } + + public IAgentJson CreateIntentClassifier() => Json(AgentKind.IntentClassifier); + public IAgentJson CreateTurnScorer() => Json(AgentKind.TurnScorer); + public IAgentJson CreateClosingScorer() => Json(AgentKind.ClosingScorer); + public IAgentStream CreateProbe() => Stream(AgentKind.Probe); + public IAgentStream CreateScaffolding() => Stream(AgentKind.Scaffolding); + public IAgentStream CreateCritique() => Stream(AgentKind.Critique); + public IAgentStream CreateClarification() => Stream(AgentKind.Clarification); + public IAgentStream CreateSummary() => Stream(AgentKind.Summary); + + private IAgentJson Json(AgentKind kind) => new AgentJson(kind, _chatService, _jsonLogger); + private IAgentStream Stream(AgentKind kind) => new AgentStream(kind, _chatService, _usageTracker, _streamLogger); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs index 126f9876b..de9c680b9 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs @@ -11,22 +11,28 @@ namespace Tutor.Elaborations.Infrastructure.Agents; public class AgentJson : StructuredAgent, IAgentJson { - public AgentJson(IAiChatService chatService, ILogger logger) : base(chatService, logger) { } + private readonly AgentKind _kind; + private readonly AgentConfig _config; + + public AgentJson(AgentKind kind, IAiChatService chatService, ILogger logger) + : base(chatService, logger) + { + _kind = kind; + _config = AgentConfigs.ByKind[kind]; + } public Task> CompleteAsync( - AgentKind kind, IReadOnlyList history, - ConceptRecord record, AgentTurnContext ctx, - Func> validateAndMap, + IReadOnlyList history, ConceptRecord record, + AgentTurnContext ctx, Func> validateAndMap, string failureMessage, CancellationToken ct) where TResponse : class { - var config = AgentConfigs.ByKind[kind]; - var messages = ConversationHistoryMapper.Map(history, config.HistoryWindow); + var messages = ConversationHistoryMapper.Map(history, _config.HistoryWindow); messages.Add(ChatMessage.FromUser(RuntimeContextBlock.Render(ctx))); var request = CompletionRequest.Create( - messages, config.BuildSystemPrompt(record), - maxTokens: config.MaxTokens, temperature: config.Temperature); + messages, _config.BuildSystemPrompt(record), + maxTokens: _config.MaxTokens, temperature: _config.Temperature); - return CompleteJsonAsync(request, kind.ToString(), validateAndMap, failureMessage, ct); + return CompleteJsonAsync(request, _kind.ToString(), validateAndMap, failureMessage, ct); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentStream.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentStream.cs index 9036ac34c..1b7ac2fb6 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentStream.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentStream.cs @@ -10,21 +10,27 @@ namespace Tutor.Elaborations.Infrastructure.Agents; public class AgentStream : StreamingAgent, IAgentStream { - public AgentStream(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) - : base(chatService, usageTracker, logger) { } + private readonly AgentKind _kind; + private readonly AgentConfig _config; + + public AgentStream(AgentKind kind, IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) + : base(chatService, usageTracker, logger) + { + _kind = kind; + _config = AgentConfigs.ByKind[kind]; + } public IAsyncEnumerable StreamAsync( - AgentKind kind, IReadOnlyList history, - ConceptRecord record, AgentTurnContext ctx, CancellationToken ct) + IReadOnlyList history, ConceptRecord record, + AgentTurnContext ctx, CancellationToken ct) { - var config = AgentConfigs.ByKind[kind]; - var messages = ConversationHistoryMapper.Map(history, config.HistoryWindow); + var messages = ConversationHistoryMapper.Map(history, _config.HistoryWindow); messages.Add(ChatMessage.FromUser(RuntimeContextBlock.Render(ctx))); var request = CompletionRequest.Create( - messages, config.BuildSystemPrompt(record), - maxTokens: config.MaxTokens, temperature: config.Temperature); + messages, _config.BuildSystemPrompt(record), + maxTokens: _config.MaxTokens, temperature: _config.Temperature); - return StreamAsync(request, kind.ToString(), ct); + return StreamAsync(request, _kind.ToString(), ct); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs index aa2c5faca..cf1bb320d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -41,7 +41,6 @@ private static void ConfigureConceptRecords(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.Property(r => r.KeyPropositions).HasColumnType("jsonb"); - entity.Property(r => r.BoundaryConditions).HasColumnType("jsonb"); entity.Property(r => r.CommonMisconceptions).HasColumnType("jsonb"); entity.Property(r => r.KeyRelations).HasColumnType("jsonb"); }); @@ -54,6 +53,10 @@ private static void ConfigureConversations(ModelBuilder modelBuilder) .WithOne() .HasForeignKey(ct => ct.ConversationAttemptId); + modelBuilder.Entity() + .Navigation(ca => ca.Turns) + .HasField("_turns"); + modelBuilder.Entity() .HasIndex(ca => new { ca.ConceptElaborationTaskId, ca.LearnerId }); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs index 7635cd923..961142c2f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs @@ -24,7 +24,7 @@ public ConversationAttemptDatabaseRepository(ElaborationsContext dbContext) : ba .ThenInclude(t => t.Evaluation) .FirstOrDefault(ca => ca.ConceptElaborationTaskId == conceptElaborationTaskId && ca.LearnerId == learnerId - && ca.Status == AttemptStatus.InProgress); + && (ca.Status == AttemptStatus.InProgress || ca.Status == AttemptStatus.InClosing)); } public List GetByTaskAndLearner(int conceptElaborationTaskId, int learnerId) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs index eaf078be1..3aea6dea2 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs @@ -51,8 +51,7 @@ private static void SetupInfrastructure(IServiceCollection services) services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs index 0deddbe21..3a8ed9ad2 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs @@ -51,15 +51,14 @@ public void SetupDefaultMocks() } public void SetupEvaluationMock(List? propositionsCoveredKeys = null, - List? relationsArticulatedKeys = null, - int? discriminationScore = 2, int? integrationScore = null, + List? relationsArticulatedKeys = null, int? integrationScore = null, string intent = "Substantive") { SetupIntentMock(intent); if (intent != "Substantive") return; - var scorerJson = BuildSubstantiveEvalJson(propositionsCoveredKeys, relationsArticulatedKeys, discriminationScore, integrationScore); + var scorerJson = BuildSubstantiveEvalJson(propositionsCoveredKeys, relationsArticulatedKeys, integrationScore); MockChatService.Setup(x => x.CompleteAsync( It.Is(r => r.MaxTokens == 1024), It.IsAny())) .ReturnsAsync(Result.Ok(new CompletionResponse @@ -81,7 +80,7 @@ public void SetupIntentMock(string intent = "Substantive") } private static string BuildSubstantiveEvalJson(List? propositionsCoveredKeys, - List? relationsArticulatedKeys, int? discriminationScore, int? integrationScore) + List? relationsArticulatedKeys, int? integrationScore) { var coveredKeys = propositionsCoveredKeys != null && propositionsCoveredKeys.Count > 0 ? string.Join(",", propositionsCoveredKeys.Select(k => $"\"{k}\"")) @@ -89,15 +88,13 @@ private static string BuildSubstantiveEvalJson(List? propositionsCovered var articulatedKeys = relationsArticulatedKeys != null && relationsArticulatedKeys.Count > 0 ? string.Join(",", relationsArticulatedKeys.Select(k => $"\"{k}\"")) : ""; - var discriminationJson = discriminationScore.HasValue ? discriminationScore.Value.ToString() : "null"; var integrationJson = integrationScore.HasValue ? integrationScore.Value.ToString() : "null"; return $$""" { "intent": "Substantive", - "correctnessScore": 2, - "completenessScore": 2, - "discriminationScore": {{discriminationJson}}, + "correctnessScore": 3, + "completenessScore": 3, "integrationScore": {{integrationJson}}, "justification": "Good explanation of the concept.", "propositionsCoveredKeys": [{{coveredKeys}}], diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs index 1bcc179a9..312ac7d6a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs @@ -32,10 +32,6 @@ public void Creates() new() { Key = "P1", Statement = "First proposition" }, new() { Key = "P2", Statement = "Second proposition" } }, - BoundaryConditions = new List - { - new() { Key = "B1", Statement = "A boundary condition" } - }, CommonMisconceptions = new List { new() { Key = "M1", Description = "A misconception", Correction = "The correction" } @@ -54,7 +50,6 @@ public void Creates() result.UnitId.ShouldBe(-1); result.Order.ShouldBe(10); result.ConceptRecord.KeyPropositions.Count.ShouldBe(2); - result.ConceptRecord.BoundaryConditions.Count.ShouldBe(1); result.ConceptRecord.CommonMisconceptions.Count.ShouldBe(1); result.ConceptRecord.KeyRelations.Count.ShouldBe(0); } @@ -78,7 +73,7 @@ public void Creates_with_relations() new() { Key = "P1", Statement = "First proposition" }, new() { Key = "P2", Statement = "Second proposition" } }, - BoundaryConditions = new List(), + CommonMisconceptions = new List(), KeyRelations = new List { @@ -123,7 +118,7 @@ public void Updates() { new() { Key = "P1", Statement = "Updated proposition" } }, - BoundaryConditions = new List(), + CommonMisconceptions = new List(), KeyRelations = new List() } @@ -163,7 +158,7 @@ public void Updates_relations_with_natural_keys() new() { Key = "P2", Statement = "The runtime selects the implementation by the actual type" }, new() { Key = "P3", Statement = "Dispatch table resolves virtual calls" } }, - BoundaryConditions = new List(), + CommonMisconceptions = new List(), KeyRelations = new List { @@ -213,7 +208,7 @@ public void Removes_relation_and_referenced_kp() { new() { Key = "P1", Statement = "A subclass can override a parent method" } }, - BoundaryConditions = new List(), + CommonMisconceptions = new List(), KeyRelations = new List() } @@ -275,7 +270,7 @@ public void Non_owner_fails_to_create() { CanonicalDefinition = "Fail", KeyPropositions = new List(), - BoundaryConditions = new List(), + CommonMisconceptions = new List(), KeyRelations = new List() } @@ -303,7 +298,7 @@ public void Non_owner_fails_to_update() { CanonicalDefinition = "Fail", KeyPropositions = new List(), - BoundaryConditions = new List(), + CommonMisconceptions = new List(), KeyRelations = new List() } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs index bf8e6efc7..ebc5f8a2b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -60,33 +60,40 @@ public async Task Starts_conversation_with_first_turn() } [Fact] - public async Task All_propositions_covered_completes() + public async Task Closing_turn_substantive_completes_with_grade() { + // First cover all KPs to move to InClosing, then submit the closing turn. Factory.MockChatService.Reset(); Factory.SetupEvaluationMock(["P1", "P2"]); Factory.SetupDialogueMock(); - Factory.SetupSummaryMock("Completed conversation summary."); using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-3"); - var dto = new SubmitTurnRequestDto { Content = "Access modifiers control visibility of members." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-4, dto, CancellationToken.None)); + var first = new SubmitTurnRequestDto { Content = "Covers both KPs." }; + var firstTokens = await CollectStreamAsync(controller.SubmitTurn(-4, first, CancellationToken.None)); + var firstMeta = JsonSerializer.Deserialize(firstTokens.Last()); + firstMeta.ShouldNotBeNull(); + firstMeta.Status.ShouldBe("InClosing"); + + // Now submit the final articulation — ClosingScorer grades it. + Factory.MockChatService.Reset(); + Factory.SetupEvaluationMock(propositionsCoveredKeys: ["P1", "P2"]); + var final = new SubmitTurnRequestDto { Content = "Final consolidated answer." }; + var tokens = await CollectStreamAsync(controller.SubmitTurn(-4, final, CancellationToken.None)); var metadata = JsonSerializer.Deserialize(tokens.Last()); metadata.ShouldNotBeNull(); metadata.Status.ShouldBe("Completed"); - metadata.Summary.ShouldNotBeNullOrEmpty(); - Factory.MockChatService.Verify(x => x.StreamAsync( - It.Is(r => r.MaxTokens == 256), It.IsAny()), Times.Once); + metadata.Summary.ShouldStartWith("Ocena:"); + metadata.Summary.ShouldEndWith("/10."); } [Fact] - public async Task Hard_cap_reached_expires() + public async Task Hard_cap_reached_transitions_to_closing() { Factory.MockChatService.Reset(); Factory.SetupEvaluationMock(propositionsCoveredKeys: []); Factory.SetupDialogueMock(); - Factory.SetupSummaryMock("Expired due to hard cap."); using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-2"); var dto = new SubmitTurnRequestDto { Content = "Final turn attempt." }; @@ -95,8 +102,7 @@ public async Task Hard_cap_reached_expires() var metadata = JsonSerializer.Deserialize(tokens.Last()); metadata.ShouldNotBeNull(); - metadata.Status.ShouldBe("Expired"); - metadata.Summary.ShouldNotBeNullOrEmpty(); + metadata.Status.ShouldBe("InClosing"); } [Fact] @@ -283,7 +289,7 @@ public async Task Submit_wrong_learner_fails() } [Fact] - public async Task Concept_with_relations_completes_when_relations_articulated() + public async Task Concept_with_relations_transitions_to_closing_when_relations_articulated() { // CET -7 (KPs P1, P2 + KR R1). Strict completion: covering both KPs is not enough. Factory.MockChatService.Reset(); @@ -292,7 +298,6 @@ public async Task Concept_with_relations_completes_when_relations_articulated() relationsArticulatedKeys: ["R1"], integrationScore: 3); Factory.SetupDialogueMock(); - Factory.SetupSummaryMock("Polymorphism mechanics summary."); using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-3"); var dto = new SubmitTurnRequestDto @@ -304,8 +309,7 @@ public async Task Concept_with_relations_completes_when_relations_articulated() var metadata = JsonSerializer.Deserialize(tokens.Last()); metadata.ShouldNotBeNull(); - metadata.Status.ShouldBe("Completed"); - metadata.Summary.ShouldNotBeNullOrEmpty(); + metadata.Status.ShouldBe("InClosing"); } [Fact] 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 index 2ea948db2..3551da179 100644 --- 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 @@ -7,11 +7,10 @@ VALUES (-1, -1, 1, 'Encapsulation (Basics)'); INSERT INTO elaborations."ConceptRecords"( "Id", "ConceptElaborationTaskId", "CanonicalDefinition", - "KeyPropositions", "BoundaryConditions", "CommonMisconceptions", "KeyRelations") + "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":"B1","statement":"Does not mean hiding all data"}}]'::jsonb, '[{{"key":"M1","description":"Encapsulation means making everything private","correction":"Encapsulation is about controlled access, not total hiding"}}]'::jsonb, '[]'::jsonb); @@ -21,11 +20,10 @@ VALUES (-2, -1, 2, 'Encapsulation (Members)'); INSERT INTO elaborations."ConceptRecords"( "Id", "ConceptElaborationTaskId", "CanonicalDefinition", - "KeyPropositions", "BoundaryConditions", "CommonMisconceptions", "KeyRelations") + "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":"B1","statement":"Does not mean hiding all data"}},{{"key":"B2","statement":"Public interfaces are part of encapsulation"}}]'::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); @@ -35,12 +33,11 @@ VALUES (-3, -2, 1, 'Encapsulation (Basics — Unit 2)'); INSERT INTO elaborations."ConceptRecords"( "Id", "ConceptElaborationTaskId", "CanonicalDefinition", - "KeyPropositions", "BoundaryConditions", "CommonMisconceptions", "KeyRelations") + "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, '[]'::jsonb); -- CET -4: Inheritance, Unit -3, Order 1 (owned ONLY by Instructor -52, NOT -51) @@ -49,12 +46,11 @@ VALUES (-4, -3, 1, 'Inheritance'); INSERT INTO elaborations."ConceptRecords"( "Id", "ConceptElaborationTaskId", "CanonicalDefinition", - "KeyPropositions", "BoundaryConditions", "CommonMisconceptions", "KeyRelations") + "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, '[]'::jsonb); -- CET -5: Encapsulation (Members — Unit 2), Unit -2, Order 2 (isolated for StartConversation tests) @@ -63,12 +59,11 @@ VALUES (-5, -2, 2, 'Encapsulation (Members — Unit 2)'); INSERT INTO elaborations."ConceptRecords"( "Id", "ConceptElaborationTaskId", "CanonicalDefinition", - "KeyPropositions", "BoundaryConditions", "CommonMisconceptions", "KeyRelations") + "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, '[]'::jsonb); -- CET -6: Encapsulation (Invariants), Unit -2, Order 3 (isolated for Start+Submit flow test) @@ -77,12 +72,11 @@ VALUES (-6, -2, 3, 'Encapsulation (Invariants)'); INSERT INTO elaborations."ConceptRecords"( "Id", "ConceptElaborationTaskId", "CanonicalDefinition", - "KeyPropositions", "BoundaryConditions", "CommonMisconceptions", "KeyRelations") + "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, '[]'::jsonb); -- CET -7: Polymorphism Mechanics, Unit -2, Order 4 (isolated, has KeyRelation) @@ -91,10 +85,9 @@ VALUES (-7, -2, 4, 'Polymorphism Mechanics'); INSERT INTO elaborations."ConceptRecords"( "Id", "ConceptElaborationTaskId", "CanonicalDefinition", - "KeyPropositions", "BoundaryConditions", "CommonMisconceptions", "KeyRelations") + "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, - '[]'::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 index 0aa14d7f4..9beca97e6 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -1,6 +1,6 @@ -- Attempt -1: Learner -2, CET -1, Completed with 3 turns (for query tests) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") -VALUES (-1, -1, -2, 1, '2024-06-01 10:00:00+00', '2024-06-01 10:15:00+00', 'Good understanding of encapsulation basics.'); +VALUES (-1, -1, -2, 2, '2024-06-01 10:00:00+00', '2024-06-01 10:15:00+00', 'Good understanding of encapsulation basics.'); INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-1, -1, 0, 'Encapsulation bundles data and methods in a class.', 0, '2024-06-01 10:01:00+00', 0); @@ -9,14 +9,14 @@ VALUES (-2, -1, 1, 'Good start! Can you tell me more about access modifiers?', 1 INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-3, -1, 0, 'Access modifiers like public and private control visibility.', 2, '2024-06-01 10:02:00+00', 0); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-1, -1, 2, 2, 2, null, 'Accurate basic description', null, '["P1"]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-3, -3, 2, 2, 2, null, 'Good description of access modifiers', null, '["P1"]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-1, -1, 3, 3, null, 'Accurate basic description', null, '["P1"]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-3, -3, 3, 3, null, 'Good description of access modifiers', null, '["P1"]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -2: Learner -2, CET -1, Abandoned (for query tests) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") -VALUES (-2, -1, -2, 2, '2024-06-02 10:00:00+00', '2024-06-02 10:05:00+00', null); +VALUES (-2, -1, -2, 3, '2024-06-02 10:00:00+00', '2024-06-02 10:05:00+00', null); -- Attempt -3: Learner -3, CET -1, InProgress with 2 turns (for abandon + follow-up tests) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -27,8 +27,8 @@ VALUES (-4, -3, 0, 'Encapsulation is about data hiding.', 0, '2024-06-03 10:01:0 INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', 1, '2024-06-03 10:01:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-4, -4, 1, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '["M1"]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-4, -4, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '["M1"]'::jsonb, '[]'::jsonb, false); -- Attempt -4: Learner -3, CET -2, InProgress (for completion test: KP P1 already covered, submit to cover P2) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -39,8 +39,8 @@ VALUES (-6, -4, 0, 'Encapsulation bundles data and methods together.', 0, '2024- INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-7, -4, 1, 'Good. What about access control?', 1, '2024-06-04 10:01:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-6, -6, 2, 2, 2, null, 'Covers bundling proposition', null, '["P1"]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-6, -6, 3, 3, null, 'Covers bundling proposition', null, '["P1"]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -5: Learner -2, CET -2, InProgress with 9 learner + 9 system turns (for hard cap test) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary", "HardCapTotalTurns", "SoftCapTotalTurns") @@ -84,24 +84,24 @@ INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Rol VALUES (-67, -5, 1, 'Response 9', 17, '2024-06-05 10:09:05+00', null); -- Evaluations for the 9 learner turns (all with empty propositions - never completes) -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-50, -50, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-52, -52, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-54, -54, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-56, -56, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-58, -58, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-60, -60, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-62, -62, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-64, -64, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-66, -66, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-50, -50, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-52, -52, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-54, -54, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-56, -56, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-58, -58, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-60, -60, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-62, -62, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-64, -64, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-66, -66, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -6: Learner -3, CET -3, InProgress with 5 substantive learner + 5 system turns (for soft cap test) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -128,16 +128,16 @@ VALUES (-78, -6, 0, 'Turn 5', 8, '2024-06-06 10:05:00+00', 0); INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-79, -6, 1, 'Response 5', 9, '2024-06-06 10:05:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-70, -70, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-72, -72, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-74, -74, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-76, -76, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "DiscriminationScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-78, -78, 1, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-70, -70, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-72, -72, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-74, -74, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-76, -76, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") +VALUES (-78, -78, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -7: Learner -3, CET -5, InProgress (isolated for abandon test — no other test touches this) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") 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 index a26969eca..a3ed24e0c 100644 --- 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 @@ -1,7 +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", "Summary") -VALUES (-10, -3, -2, 1, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '2 hours', 'Daily limit attempt 1'); +VALUES (-10, -3, -2, 2, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '2 hours', 'Daily limit attempt 1'); INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") -VALUES (-11, -3, -2, 1, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour', 'Daily limit attempt 2'); +VALUES (-11, -3, -2, 2, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour', 'Daily limit attempt 2'); INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") -VALUES (-12, -3, -2, 2, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '30 minutes', null); +VALUES (-12, -3, -2, 3, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '30 minutes', null); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs index eeef0a7c5..2bc8febec 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs @@ -46,7 +46,6 @@ private static ConceptRecord MakeRecord(List kps, List(), commonMisconceptions: new List(), keyRelations: relations); } @@ -59,8 +58,9 @@ private static ConversationAttempt MakeAttemptWithCoveredAndArticulated( var attempt = (ConversationAttempt)ctor.Invoke(null); var evaluation = new TurnEvaluation( - 2, 2, null, null, "test", null, - coveredKpKeys, new List(), articulatedRelationKeys, false); + 2, 2, null, + "test", null, coveredKpKeys, + new List(), articulatedRelationKeys, false); var turnCtor = typeof(ConversationTurn).GetConstructors( BindingFlags.NonPublic | BindingFlags.Instance) From 5da7ecab6ae67b489d614e167e4de7b2da8045cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Mon, 27 Apr 2026 13:54:04 +0300 Subject: [PATCH 28/51] refactor: Simplifies structured agent data flow. --- .../Agents/StructuredAgent.cs | 27 +++--------- .../Orchestration/AgentOrchestrator.cs | 43 ++++++++----------- .../Orchestration/Agents/IAgentJson.cs | 8 ++-- .../Agents/AgentJson.cs | 9 ++-- 4 files changed, 32 insertions(+), 55 deletions(-) diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs index 9b968c76c..db40b738a 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs @@ -6,10 +6,6 @@ namespace Tutor.BuildingBlocks.AI.Core.Agents; -/// -/// Base class for agents that consume a full (non-streamed) LLM completion, optionally parsing JSON into a typed result. -/// Handles the retry loop, deserialization, and logging so derived agents only define their DTO and mapping rules. -/// public abstract class StructuredAgent { private const int MaxAttempts = 2; @@ -28,14 +24,8 @@ protected StructuredAgent(IAiChatService chatService, ILogger logger) _logger = logger; } - /// - /// Runs the request, deserializes the response as , and maps/validates it - /// through . Retries on LLM failure, malformed JSON, or a failed validation Result. - /// - protected async Task> CompleteJsonAsync( - CompletionRequest request, string agentLabel, - Func> validateAndMap, - string failureMessage, CancellationToken ct) where TResponse : class + protected async Task> CompleteJsonAsync( + CompletionRequest request, string agentLabel, CancellationToken ct) where TResponse : class { var sw = Stopwatch.StartNew(); var promptTokens = 0; @@ -77,17 +67,12 @@ protected async Task> CompleteJsonAsync( continue; } - var mapped = validateAndMap(parsed); - if (mapped.IsSuccess) - { - status = "ok"; - failureCategory = null; - return mapped; - } - failureCategory = "validation"; + status = "ok"; + failureCategory = null; + return parsed; } - return Result.Fail(failureMessage); + return Result.Fail($"{agentLabel} failed."); } finally { 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 index dc990feaf..9c9b6859f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -218,38 +218,33 @@ private RouteResult Route(ConceptRecord record, ConversationAttempt attempt, private static bool ShouldAppendSoftCapNudge(TurnIntent intent) => intent is TurnIntent.Substantive or TurnIntent.Stuck or TurnIntent.SummaryRequest; - private Task> ClassifyIntentAsync(ConceptRecord record, IReadOnlyList history, - string newMessage, CancellationToken ct) + private async Task> ClassifyIntentAsync(ConceptRecord record, + IReadOnlyList history, string newMessage, CancellationToken ct) { var ctx = new AgentTurnContext(CurrentLearnerMessage: newMessage); - return _factory.CreateIntentClassifier().CompleteAsync( - history, record, ctx, - r => Enum.TryParse(r.Intent, ignoreCase: true, out var intent) - ? Result.Ok(intent) - : Result.Fail("Unrecognized intent."), - "Intent classification failed.", ct); + var result = await _factory.CreateIntentClassifier().CompleteAsync(history, record, ctx, ct); + if (result.IsFailed) return Result.Fail(result.Errors); + return Enum.TryParse(result.Value.Intent, ignoreCase: true, out var intent) + ? intent + : Result.Fail("Unrecognized intent."); } - private Task> ScoreTurnAsync(ConceptRecord record, - IReadOnlyList history, - string newMessage, CancellationToken ct) + private async Task> ScoreTurnAsync(ConceptRecord record, + IReadOnlyList history, string newMessage, CancellationToken ct) { var ctx = new AgentTurnContext(CurrentLearnerMessage: newMessage); - return _factory.CreateTurnScorer().CompleteAsync( - history, record, ctx, - r => MapToEvaluation(r, record), - "Scoring failed.", ct); + var result = await _factory.CreateTurnScorer().CompleteAsync(history, record, ctx, ct); + if (result.IsFailed) return Result.Fail(result.Errors); + return MapToEvaluation(result.Value, record); } - private Task> ScoreClosingAsync(ConceptRecord record, - IReadOnlyList history, - string newMessage, CancellationToken ct) + private async Task> ScoreClosingAsync(ConceptRecord record, + IReadOnlyList history, string newMessage, CancellationToken ct) { var ctx = new AgentTurnContext(CurrentLearnerMessage: newMessage); - return _factory.CreateClosingScorer().CompleteAsync( - history, record, ctx, - r => MapToEvaluation(r, record), - "Closing scoring failed.", ct); + var result = await _factory.CreateClosingScorer().CompleteAsync(history, record, ctx, ct); + if (result.IsFailed) return Result.Fail(result.Errors); + return MapToEvaluation(result.Value, record); } private static Result MapToEvaluation(ScorerResponse parsed, ConceptRecord record) @@ -271,8 +266,8 @@ private static Result MapToEvaluation(ScorerResponse parsed, Con return new TurnEvaluation( parsed.CorrectnessScore, parsed.CompletenessScore, parsed.IntegrationScore, - parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, parsed.PropositionsCoveredKeys ?? new List(), - parsed.MisconceptionsTriggeredKeys ?? new List(), parsed.RelationsArticulatedKeys ?? new List(), + parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, parsed.PropositionsCoveredKeys ?? [], + parsed.MisconceptionsTriggeredKeys ?? [], parsed.RelationsArticulatedKeys ?? [], parsed.HasMultipleConcerns ?? false); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs index bb142c13a..34ed13ddf 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs @@ -7,9 +7,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; public interface IAgentJson { - Task> CompleteAsync( - IReadOnlyList history, ConceptRecord record, - AgentTurnContext ctx, - Func> validateAndMap, - string failureMessage, CancellationToken ct) where TResponse : class; + Task> CompleteAsync( + IReadOnlyList history, ConceptRecord record, AgentTurnContext ctx, + CancellationToken ct) where TResponse : class; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs index de9c680b9..8caca8794 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs @@ -21,10 +21,9 @@ public AgentJson(AgentKind kind, IAiChatService chatService, ILogger _config = AgentConfigs.ByKind[kind]; } - public Task> CompleteAsync( - IReadOnlyList history, ConceptRecord record, - AgentTurnContext ctx, Func> validateAndMap, - string failureMessage, CancellationToken ct) where TResponse : class + public Task> CompleteAsync( + IReadOnlyList history, ConceptRecord record, AgentTurnContext ctx, + CancellationToken ct) where TResponse : class { var messages = ConversationHistoryMapper.Map(history, _config.HistoryWindow); messages.Add(ChatMessage.FromUser(RuntimeContextBlock.Render(ctx))); @@ -33,6 +32,6 @@ public Task> CompleteAsync( messages, _config.BuildSystemPrompt(record), maxTokens: _config.MaxTokens, temperature: _config.Temperature); - return CompleteJsonAsync(request, _kind.ToString(), validateAndMap, failureMessage, ct); + return CompleteJsonAsync(request, _kind.ToString(), ct); } } From f6869e3b7587981a654fe9b32a50453b90299901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Mon, 27 Apr 2026 15:03:38 +0300 Subject: [PATCH 29/51] refactor: Simplifies agent configuration. --- .../Agents/LlmCaller.cs | 187 ++++++++++++++++++ .../Agents/StreamingAgent.cs | 104 ---------- .../Agents/StructuredAgent.cs | 107 ---------- .../Orchestration/AgentOrchestrator.cs | 81 ++++---- .../Orchestration/Agents/IAgentFactory.cs | 13 -- .../Orchestration/Agents/IAgentJson.cs | 13 -- .../Orchestration/Agents/IAgentStream.cs | 13 -- .../Learning/Prompts/Agents/ScorerResponse.cs | 28 +++ .../Agents/AgentFactory.cs | 35 ---- .../Agents/AgentJson.cs | 37 ---- .../Agents/AgentStream.cs | 36 ---- .../ElaborationsStartup.cs | 3 - .../Learning/ConversationTurnTests.cs | 3 +- .../Unit/ConceptRecordTests.cs | 21 +- 14 files changed, 251 insertions(+), 430 deletions(-) create mode 100644 src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/LlmCaller.cs delete mode 100644 src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs delete mode 100644 src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentFactory.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentStream.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentFactory.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentStream.cs 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/StreamingAgent.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs deleted file mode 100644 index a849fedfe..000000000 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamingAgent.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; -using Microsoft.Extensions.Logging; -using Tutor.BuildingBlocks.AI.Core.Conversations; - -namespace Tutor.BuildingBlocks.AI.Core.Agents; - -/// -/// Base class for agents that stream an LLM completion token by token. -/// Derived agents build a (system prompt + native-role messages) -/// and delegate the network plumbing here. Yields per content chunk -/// and a terminal on provider exception or empty response. -/// -public abstract class StreamingAgent -{ - protected IAiChatService ChatService { get; } - private readonly ITurnUsageTracker _usageTracker; - private readonly ILogger _logger; - - protected StreamingAgent(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) - { - ChatService = chatService; - _usageTracker = usageTracker; - _logger = logger; - } - - protected async IAsyncEnumerable StreamAsync( - CompletionRequest request, string agentLabel, [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}", - agentLabel, status, sw.ElapsedMilliseconds, - delta.PromptTokens, delta.CompletionTokens, charCount, 1, failureCategory); - } - } -} diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs deleted file mode 100644 index db40b738a..000000000 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StructuredAgent.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.Diagnostics; -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 StructuredAgent -{ - private const int MaxAttempts = 2; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - protected IAiChatService ChatService { get; } - private readonly ILogger _logger; - - protected StructuredAgent(IAiChatService chatService, ILogger logger) - { - ChatService = chatService; - _logger = logger; - } - - protected async Task> CompleteJsonAsync( - CompletionRequest request, string agentLabel, 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 < MaxAttempts; 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( - "{Agent} skipping retry due to deterministic finish reason '{FinishReason}'.", - agentLabel, completion.Value.FinishReason); - failureCategory = "permanent"; - break; - } - - var parsed = TryDeserialize(completion.Value.Content, agentLabel); - if (parsed is null) - { - failureCategory = "parse"; - continue; - } - - status = "ok"; - failureCategory = null; - return parsed; - } - - return Result.Fail($"{agentLabel} 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}", - agentLabel, status, sw.ElapsedMilliseconds, - promptTokens, completionTokens, charCount, attempts, 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 agentLabel) where TResponse : class - { - try - { - return JsonSerializer.Deserialize(json, JsonOptions); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "{Agent} failed to parse LLM response.", agentLabel); - return null; - } - } -} 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 index 9c9b6859f..e804ab29a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -6,23 +6,22 @@ using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.Core.Domain.ConceptRecords; using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; using Tutor.Elaborations.Core.UseCases.Learning.Prompts; using Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; -public class AgentOrchestrator : IAgentOrchestrator +public class AgentOrchestrator : LlmCaller, IAgentOrchestrator { private const int MaxNonSubstantiveClosingTurns = 3; - private readonly IAgentFactory _factory; private readonly ITurnUsageTracker _usageTracker; private readonly ILogger _logger; - public AgentOrchestrator(IAgentFactory factory, ITurnUsageTracker usageTracker, ILogger logger) + public AgentOrchestrator(IAiChatService chatService, ITurnUsageTracker usageTracker, + ILogger logger) + : base(chatService, usageTracker, logger) { - _factory = factory; _usageTracker = usageTracker; _logger = logger; } @@ -84,8 +83,9 @@ public async IAsyncEnumerable ProcessTurnAsync(ConceptRecord } else if (route is RouteResult.Stream streamRoute) { + var request = BuildRequest(streamRoute.Kind, attempt.Turns, record, streamRoute.Ctx); StreamFailure? streamFailure = null; - await foreach (var chunk in streamRoute.Agent.StreamAsync(attempt.Turns, record, streamRoute.Ctx, ct)) + await foreach (var chunk in StreamAsync(request, streamRoute.Kind.ToString(), ct)) { if (chunk is StreamFailure failure) { streamFailure = failure; break; } var content = ((StreamToken)chunk).Content; @@ -152,7 +152,7 @@ FinalChunk CreateFinalChunk(string? summary = null, ProbeDirective? directive = yield return CreateFinalChunk(); } - private RouteResult Route(ConceptRecord record, ConversationAttempt attempt, + private static RouteResult Route(ConceptRecord record, ConversationAttempt attempt, TurnIntent intent, TurnEvaluation? evaluation) { if (record.IsAttemptComplete(attempt) || attempt.IsHardCapReached()) @@ -163,14 +163,14 @@ private RouteResult Route(ConceptRecord record, ConversationAttempt attempt, case TurnIntent.Substantive: { if (evaluation is { HasMultipleConcerns: true }) - return new RouteResult.Stream(_factory.CreateCritique(), + return new RouteResult.Stream(AgentKind.Critique, new AgentTurnContext(Evaluation: evaluation), ProbeDirective: null); var next = record.PickNextTarget(attempt, attempt.GetStalledTargets()); if (next == null) return new RouteResult.Transition(); var ladderLevel = attempt.GetProbeLevelFor(next); - var agent = attempt.IsScaffolding(ladderLevel) ? _factory.CreateScaffolding() : _factory.CreateProbe(); - return new RouteResult.Stream(agent, new AgentTurnContext(Target: next, Level: ladderLevel), new ProbeDirective(next, ladderLevel)); + var kind = attempt.IsScaffolding(ladderLevel) ? AgentKind.Scaffolding : AgentKind.Probe; + return new RouteResult.Stream(kind, new AgentTurnContext(Target: next, Level: ladderLevel), new ProbeDirective(next, ladderLevel)); } case TurnIntent.Stuck: @@ -183,13 +183,13 @@ private RouteResult Route(ConceptRecord record, ConversationAttempt attempt, if (stuckTarget == null) return new RouteResult.Transition(); var first = attempt.FirstScaffoldLadderLevel; return new RouteResult.Stream( - _factory.CreateScaffolding(), + AgentKind.Scaffolding, new AgentTurnContext(Target: stuckTarget, Level: first), new ProbeDirective(stuckTarget, first)); } var ladderLevel = attempt.GetProbeLevelFor(stuckTarget); return new RouteResult.Stream( - _factory.CreateScaffolding(), + AgentKind.Scaffolding, new AgentTurnContext(Target: stuckTarget, Level: ladderLevel), new ProbeDirective(stuckTarget, ladderLevel)); } @@ -198,16 +198,13 @@ private RouteResult Route(ConceptRecord record, ConversationAttempt attempt, { var last = attempt.GetLastProbe(); return new RouteResult.Stream( - _factory.CreateClarification(), + AgentKind.Clarification, new AgentTurnContext(Target: last?.Target), ProbeDirective: null); } case TurnIntent.SummaryRequest: - return new RouteResult.Stream( - _factory.CreateSummary(), - new AgentTurnContext(), - ProbeDirective: null); + return new RouteResult.Stream(AgentKind.Summary, new AgentTurnContext(), ProbeDirective: null); case TurnIntent.OffTopic: default: @@ -218,11 +215,22 @@ private RouteResult Route(ConceptRecord record, ConversationAttempt attempt, private static bool ShouldAppendSoftCapNudge(TurnIntent intent) => intent is TurnIntent.Substantive or TurnIntent.Stuck or TurnIntent.SummaryRequest; + private CompletionRequest BuildRequest(AgentKind kind, + IReadOnlyList history, ConceptRecord record, AgentTurnContext ctx) + { + var config = AgentConfigs.ByKind[kind]; + var messages = ConversationHistoryMapper.Map(history, config.HistoryWindow); + messages.Add(ChatMessage.FromUser(RuntimeContextBlock.Render(ctx))); + return CompletionRequest.Create(messages, config.BuildSystemPrompt(record), + maxTokens: config.MaxTokens, temperature: config.Temperature); + } + private async Task> ClassifyIntentAsync(ConceptRecord record, IReadOnlyList history, string newMessage, CancellationToken ct) { var ctx = new AgentTurnContext(CurrentLearnerMessage: newMessage); - var result = await _factory.CreateIntentClassifier().CompleteAsync(history, record, ctx, ct); + var result = await CompleteJsonAsync( + BuildRequest(AgentKind.IntentClassifier, history, record, ctx), nameof(AgentKind.IntentClassifier), ct); if (result.IsFailed) return Result.Fail(result.Errors); return Enum.TryParse(result.Value.Intent, ignoreCase: true, out var intent) ? intent @@ -233,48 +241,25 @@ private async Task> ScoreTurnAsync(ConceptRecord record, IReadOnlyList history, string newMessage, CancellationToken ct) { var ctx = new AgentTurnContext(CurrentLearnerMessage: newMessage); - var result = await _factory.CreateTurnScorer().CompleteAsync(history, record, ctx, ct); + var result = await CompleteJsonAsync( + BuildRequest(AgentKind.TurnScorer, history, record, ctx), nameof(AgentKind.TurnScorer), ct); if (result.IsFailed) return Result.Fail(result.Errors); - return MapToEvaluation(result.Value, record); + return result.Value.ToEvaluation(record); } private async Task> ScoreClosingAsync(ConceptRecord record, IReadOnlyList history, string newMessage, CancellationToken ct) { var ctx = new AgentTurnContext(CurrentLearnerMessage: newMessage); - var result = await _factory.CreateClosingScorer().CompleteAsync(history, record, ctx, ct); + var result = await CompleteJsonAsync( + BuildRequest(AgentKind.ClosingScorer, history, record, ctx), nameof(AgentKind.ClosingScorer), ct); if (result.IsFailed) return Result.Fail(result.Errors); - return MapToEvaluation(result.Value, record); - } - - private static Result MapToEvaluation(ScorerResponse parsed, ConceptRecord record) - { - if (parsed.CorrectnessScore is < 0 or > 5) return Result.Fail("Correctness out of range."); - if (parsed.CompletenessScore is < 0 or > 5) return Result.Fail("Completeness out of range."); - if (parsed.IntegrationScore is not null and (< 0 or > 5)) return Result.Fail("Integration out of range."); - - var validKpKeys = record.KeyPropositions.Select(kp => kp.Key).ToHashSet(); - var validKrKeys = record.KeyRelations.Select(kr => kr.Key).ToHashSet(); - var validCmKeys = record.CommonMisconceptions.Select(cm => cm.Key).ToHashSet(); - - if (parsed.PropositionsCoveredKeys?.Any(k => !validKpKeys.Contains(k)) == true) - return Result.Fail("Unknown proposition key."); - if (parsed.RelationsArticulatedKeys?.Any(k => !validKrKeys.Contains(k)) == true) - return Result.Fail("Unknown relation key."); - if (parsed.MisconceptionsTriggeredKeys?.Any(k => !validCmKeys.Contains(k)) == true) - return Result.Fail("Unknown misconception key."); - - return new TurnEvaluation( - parsed.CorrectnessScore, parsed.CompletenessScore, parsed.IntegrationScore, - parsed.Justification ?? string.Empty, parsed.NovelMisconceptions, parsed.PropositionsCoveredKeys ?? [], - parsed.MisconceptionsTriggeredKeys ?? [], parsed.RelationsArticulatedKeys ?? [], - parsed.HasMultipleConcerns ?? false); + return result.Value.ToEvaluation(record); } private abstract record RouteResult { - public sealed record Stream( - IAgentStream Agent, AgentTurnContext Ctx, ProbeDirective? ProbeDirective) : RouteResult; + public sealed record Stream(AgentKind Kind, AgentTurnContext Ctx, ProbeDirective? ProbeDirective) : RouteResult; public sealed record OffTopic : RouteResult; public sealed record Transition : RouteResult; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentFactory.cs deleted file mode 100644 index 4365443d3..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -public interface IAgentFactory -{ - IAgentJson CreateIntentClassifier(); - IAgentJson CreateTurnScorer(); - IAgentJson CreateClosingScorer(); - IAgentStream CreateProbe(); - IAgentStream CreateScaffolding(); - IAgentStream CreateCritique(); - IAgentStream CreateClarification(); - IAgentStream CreateSummary(); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs deleted file mode 100644 index 34ed13ddf..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentJson.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FluentResults; -using Tutor.Elaborations.Core.Domain.ConceptRecords; -using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -public interface IAgentJson -{ - Task> CompleteAsync( - IReadOnlyList history, ConceptRecord record, AgentTurnContext ctx, - CancellationToken ct) where TResponse : class; -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentStream.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentStream.cs deleted file mode 100644 index 91bedcf85..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/Agents/IAgentStream.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Tutor.BuildingBlocks.AI.Core.Agents; -using Tutor.Elaborations.Core.Domain.ConceptRecords; -using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; - -public interface IAgentStream -{ - IAsyncEnumerable StreamAsync( - IReadOnlyList history, ConceptRecord record, - AgentTurnContext ctx, CancellationToken ct); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs index f41411142..cd1aefd10 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs @@ -1,3 +1,7 @@ +using FluentResults; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; + namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; public class ScorerResponse @@ -11,4 +15,28 @@ public class ScorerResponse public List? RelationsArticulatedKeys { get; set; } public string? NovelMisconceptions { get; set; } public bool? HasMultipleConcerns { get; set; } + + public Result MapToEvaluation(ConceptRecord record) + { + if (CorrectnessScore is < 0 or > 5) return Result.Fail("Correctness out of range."); + if (CompletenessScore is < 0 or > 5) return Result.Fail("Completeness out of range."); + if (IntegrationScore is not null and (< 0 or > 5)) return Result.Fail("Integration out of range."); + + var validKpKeys = record.KeyPropositions.Select(kp => kp.Key).ToHashSet(); + var validKrKeys = record.KeyRelations.Select(kr => kr.Key).ToHashSet(); + var validCmKeys = record.CommonMisconceptions.Select(cm => cm.Key).ToHashSet(); + + if (PropositionsCoveredKeys?.Any(k => !validKpKeys.Contains(k)) == true) + return Result.Fail("Unknown proposition key."); + if (RelationsArticulatedKeys?.Any(k => !validKrKeys.Contains(k)) == true) + return Result.Fail("Unknown relation key."); + if (MisconceptionsTriggeredKeys?.Any(k => !validCmKeys.Contains(k)) == true) + return Result.Fail("Unknown misconception key."); + + return new TurnEvaluation( + CorrectnessScore, CompletenessScore, IntegrationScore, + Justification ?? string.Empty, NovelMisconceptions, PropositionsCoveredKeys ?? [], + MisconceptionsTriggeredKeys ?? [], RelationsArticulatedKeys ?? [], + HasMultipleConcerns ?? false); + } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentFactory.cs deleted file mode 100644 index 48ba27f82..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentFactory.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.Extensions.Logging; -using Tutor.BuildingBlocks.AI.Core.Conversations; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; -using Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -namespace Tutor.Elaborations.Infrastructure.Agents; - -public class AgentFactory : IAgentFactory -{ - private readonly IAiChatService _chatService; - private readonly ITurnUsageTracker _usageTracker; - private readonly ILogger _jsonLogger; - private readonly ILogger _streamLogger; - - public AgentFactory(IAiChatService chatService, ITurnUsageTracker usageTracker, - ILogger jsonLogger, ILogger streamLogger) - { - _chatService = chatService; - _usageTracker = usageTracker; - _jsonLogger = jsonLogger; - _streamLogger = streamLogger; - } - - public IAgentJson CreateIntentClassifier() => Json(AgentKind.IntentClassifier); - public IAgentJson CreateTurnScorer() => Json(AgentKind.TurnScorer); - public IAgentJson CreateClosingScorer() => Json(AgentKind.ClosingScorer); - public IAgentStream CreateProbe() => Stream(AgentKind.Probe); - public IAgentStream CreateScaffolding() => Stream(AgentKind.Scaffolding); - public IAgentStream CreateCritique() => Stream(AgentKind.Critique); - public IAgentStream CreateClarification() => Stream(AgentKind.Clarification); - public IAgentStream CreateSummary() => Stream(AgentKind.Summary); - - private IAgentJson Json(AgentKind kind) => new AgentJson(kind, _chatService, _jsonLogger); - private IAgentStream Stream(AgentKind kind) => new AgentStream(kind, _chatService, _usageTracker, _streamLogger); -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs deleted file mode 100644 index 8caca8794..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentJson.cs +++ /dev/null @@ -1,37 +0,0 @@ -using FluentResults; -using Microsoft.Extensions.Logging; -using Tutor.BuildingBlocks.AI.Core.Agents; -using Tutor.BuildingBlocks.AI.Core.Conversations; -using Tutor.Elaborations.Core.Domain.ConceptRecords; -using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; -using Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -namespace Tutor.Elaborations.Infrastructure.Agents; - -public class AgentJson : StructuredAgent, IAgentJson -{ - private readonly AgentKind _kind; - private readonly AgentConfig _config; - - public AgentJson(AgentKind kind, IAiChatService chatService, ILogger logger) - : base(chatService, logger) - { - _kind = kind; - _config = AgentConfigs.ByKind[kind]; - } - - public Task> CompleteAsync( - IReadOnlyList history, ConceptRecord record, AgentTurnContext ctx, - CancellationToken ct) where TResponse : class - { - var messages = ConversationHistoryMapper.Map(history, _config.HistoryWindow); - messages.Add(ChatMessage.FromUser(RuntimeContextBlock.Render(ctx))); - - var request = CompletionRequest.Create( - messages, _config.BuildSystemPrompt(record), - maxTokens: _config.MaxTokens, temperature: _config.Temperature); - - return CompleteJsonAsync(request, _kind.ToString(), ct); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentStream.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentStream.cs deleted file mode 100644 index 1b7ac2fb6..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Agents/AgentStream.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.Extensions.Logging; -using Tutor.BuildingBlocks.AI.Core.Agents; -using Tutor.BuildingBlocks.AI.Core.Conversations; -using Tutor.Elaborations.Core.Domain.ConceptRecords; -using Tutor.Elaborations.Core.Domain.Conversations; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; -using Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -namespace Tutor.Elaborations.Infrastructure.Agents; - -public class AgentStream : StreamingAgent, IAgentStream -{ - private readonly AgentKind _kind; - private readonly AgentConfig _config; - - public AgentStream(AgentKind kind, IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) - : base(chatService, usageTracker, logger) - { - _kind = kind; - _config = AgentConfigs.ByKind[kind]; - } - - public IAsyncEnumerable StreamAsync( - IReadOnlyList history, ConceptRecord record, - AgentTurnContext ctx, CancellationToken ct) - { - var messages = ConversationHistoryMapper.Map(history, _config.HistoryWindow); - messages.Add(ChatMessage.FromUser(RuntimeContextBlock.Render(ctx))); - - var request = CompletionRequest.Create( - messages, _config.BuildSystemPrompt(record), - maxTokens: _config.MaxTokens, temperature: _config.Temperature); - - return StreamAsync(request, _kind.ToString(), ct); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs index 3aea6dea2..3101dffb5 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs @@ -14,9 +14,7 @@ using Tutor.Elaborations.Core.UseCases.Authoring; using Tutor.Elaborations.Core.UseCases.Learning; using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration.Agents; using Tutor.Elaborations.Core.UseCases.Monitoring; -using Tutor.Elaborations.Infrastructure.Agents; using Tutor.Elaborations.Infrastructure.Database; using Tutor.Elaborations.Infrastructure.Database.Repositories; @@ -51,7 +49,6 @@ private static void SetupInfrastructure(IServiceCollection services) services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs index ebc5f8a2b..adb3d1c1b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -84,8 +84,7 @@ public async Task Closing_turn_substantive_completes_with_grade() var metadata = JsonSerializer.Deserialize(tokens.Last()); metadata.ShouldNotBeNull(); metadata.Status.ShouldBe("Completed"); - metadata.Summary.ShouldStartWith("Ocena:"); - metadata.Summary.ShouldEndWith("/10."); + metadata.Summary.ShouldBe("6 / 10"); } [Fact] diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs index 2bc8febec..03cc20554 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs @@ -61,24 +61,7 @@ private static ConversationAttempt MakeAttemptWithCoveredAndArticulated( 2, 2, null, "test", null, coveredKpKeys, new List(), articulatedRelationKeys, false); - - var turnCtor = typeof(ConversationTurn).GetConstructors( - BindingFlags.NonPublic | BindingFlags.Instance) - .First(c => c.GetParameters().Length > 0); - var turn = (ConversationTurn)turnCtor.Invoke([ - TurnRole.Learner, "x", 0, (TurnIntent?)TurnIntent.Substantive, evaluation, null! - ]); - - SetProp(attempt, "Turns", new List { turn }); + attempt.AddLearnerTurn("x", TurnIntent.Substantive, evaluation); return attempt; } - - private static void SetProp(object instance, string propName, object? value) - { - var prop = instance.GetType().GetProperty(propName, - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - if (prop == null) - throw new InvalidOperationException($"Property {propName} not found on {instance.GetType().Name}"); - prop.SetValue(instance, value); - } -} +} \ No newline at end of file From 6e18a158d4d2aa4abf78852e1bcc3b0cf7232b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Tue, 28 Apr 2026 07:40:39 +0300 Subject: [PATCH 30/51] chore: Minor cleanup performed while understanding the data flow. --- .../Domain/ConceptRecords/ConceptRecord.cs | 7 ++++--- .../Conversations/ConversationAttempt.cs | 2 +- .../Orchestration/AgentOrchestrator.cs | 21 ++++++++----------- .../Prompts/Agents/ScaffoldingPrompt.cs | 11 +++++----- .../Learning/Prompts/Agents/ScorerResponse.cs | 7 ++++++- .../Learning/Prompts/Agents/SummaryPrompt.cs | 2 +- 6 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs index 8147c9ec3..f06b2abfa 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs @@ -51,13 +51,14 @@ public bool IsAttemptComplete(ConversationAttempt attempt) return AreAllPropositionsCovered(attempt) && AreAllKeyRelationsArticulated(attempt); } - public string? PickNextTarget(ConversationAttempt attempt, IReadOnlySet? excludedTargets = null) + public string? PickNextTarget(ConversationAttempt attempt) { var articulatedKps = attempt.GetArticulatedPropositionKeys(); + var excludedTargets = attempt.GetStalledTargets(); var nextTarget = KeyPropositions .Where(kp => !articulatedKps.Contains(kp.Key)) .Select(kp => kp.Statement) - .FirstOrDefault(s => excludedTargets == null || !excludedTargets.Contains(s)); + .FirstOrDefault(s => !excludedTargets.Contains(s)); if (nextTarget != null) return nextTarget; var articulatedKrs = attempt.GetArticulatedRelationKeys(); @@ -66,7 +67,7 @@ public bool IsAttemptComplete(ConversationAttempt attempt) var source = KeyPropositions.First(kp => kp.Key == kr.SourceKey).Statement; var target = KeyPropositions.First(kp => kp.Key == kr.TargetKey).Statement; var composed = $"{source} → {target}. Mechanism: {kr.Mechanism}"; - if (excludedTargets == null || !excludedTargets.Contains(composed)) return composed; + if (!excludedTargets.Contains(composed)) return composed; } return null; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index 8ceb5cb81..e0e0f46eb 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -6,7 +6,7 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; public class ConversationAttempt : AggregateRoot { private const int ProbeLadderLength = 2; - private const int ScaffoldLadderLength = 3; + private const int ScaffoldLadderLength = 2; private const int StalledThreshold = ProbeLadderLength + ScaffoldLadderLength; public int ConceptElaborationTaskId { get; private set; } 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 index e804ab29a..f4ff30c07 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -19,8 +19,7 @@ public class AgentOrchestrator : LlmCaller, IAgentOrchestrator private readonly ILogger _logger; public AgentOrchestrator(IAiChatService chatService, ITurnUsageTracker usageTracker, - ILogger logger) - : base(chatService, usageTracker, logger) + ILogger logger) : base(chatService, usageTracker, logger) { _usageTracker = usageTracker; _logger = logger; @@ -162,11 +161,10 @@ private static RouteResult Route(ConceptRecord record, ConversationAttempt attem { case TurnIntent.Substantive: { - if (evaluation is { HasMultipleConcerns: true }) + if (evaluation != null && evaluation.HasMultipleConcerns) return new RouteResult.Stream(AgentKind.Critique, - new AgentTurnContext(Evaluation: evaluation), - ProbeDirective: null); - var next = record.PickNextTarget(attempt, attempt.GetStalledTargets()); + new AgentTurnContext(Evaluation: evaluation), null); + var next = record.PickNextTarget(attempt); if (next == null) return new RouteResult.Transition(); var ladderLevel = attempt.GetProbeLevelFor(next); var kind = attempt.IsScaffolding(ladderLevel) ? AgentKind.Scaffolding : AgentKind.Probe; @@ -175,11 +173,10 @@ private static RouteResult Route(ConceptRecord record, ConversationAttempt attem case TurnIntent.Stuck: { - var stalledTargets = attempt.GetStalledTargets(); var stuckTarget = attempt.GetLastProbe()?.Target; - if (stuckTarget == null || stalledTargets.Contains(stuckTarget)) + if (stuckTarget == null || attempt.GetStalledTargets().Contains(stuckTarget)) { - stuckTarget = record.PickNextTarget(attempt, stalledTargets); + stuckTarget = record.PickNextTarget(attempt); if (stuckTarget == null) return new RouteResult.Transition(); var first = attempt.FirstScaffoldLadderLevel; return new RouteResult.Stream( @@ -200,11 +197,11 @@ private static RouteResult Route(ConceptRecord record, ConversationAttempt attem return new RouteResult.Stream( AgentKind.Clarification, new AgentTurnContext(Target: last?.Target), - ProbeDirective: null); + null); } case TurnIntent.SummaryRequest: - return new RouteResult.Stream(AgentKind.Summary, new AgentTurnContext(), ProbeDirective: null); + return new RouteResult.Stream(AgentKind.Summary, new AgentTurnContext(), null); case TurnIntent.OffTopic: default: @@ -215,7 +212,7 @@ private static RouteResult Route(ConceptRecord record, ConversationAttempt attem private static bool ShouldAppendSoftCapNudge(TurnIntent intent) => intent is TurnIntent.Substantive or TurnIntent.Stuck or TurnIntent.SummaryRequest; - private CompletionRequest BuildRequest(AgentKind kind, + private static CompletionRequest BuildRequest(AgentKind kind, IReadOnlyList history, ConceptRecord record, AgentTurnContext ctx) { var config = AgentConfigs.ByKind[kind]; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs index 2e3b438aa..0f61e3fa5 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs @@ -16,22 +16,21 @@ public static string Build(ConceptRecord record) sb.AppendLine(); sb.AppendLine("# Escalation levels"); - sb.AppendLine("The tag carries a level attribute (3-5) that shapes the scaffold:"); - sb.AppendLine("- **L3 — Rephrase.** Restate the tutor's prior question in simpler language. Do NOT introduce new content, examples, or hints. One or two short sentences."); - sb.AppendLine("- **L4 — Worked example.** Produce ONE short concrete example (3–6 lines of code OR 2–3 sentence scenario) illustrating a CONTEXT where the target concept operates. End with one narrow question that forces the learner to name what is happening. The canonical definition and relation mechanisms are INSPIRATION for the example only — never paraphrase them."); - sb.AppendLine("- **L5 — Contrasting pair.** Produce TWO short contrasting examples — one exhibits the target correctly, one violates it in a realistic way. If a common misconception is catalogued for this concept, prefer that as the \"violates\" case. Ask which example is correct and why. The \"why\" must require articulating the target."); + sb.AppendLine("The tag carries a level attribute (3-4) that shapes the scaffold:"); + sb.AppendLine("- **L3 — Worked example.** Produce ONE short concrete example (3–6 lines of code OR 2–3 sentence scenario) illustrating a CONTEXT where the target concept operates. End with one narrow question that forces the learner to name what is happening. The canonical definition and relation mechanisms are INSPIRATION for the example only — never paraphrase them."); + sb.AppendLine("- **L4 — Contrasting pair.** Produce TWO short contrasting examples — one exhibits the target correctly, one violates it in a realistic way. If a common misconception is catalogued for this concept, prefer that as the \"violates\" case. Ask which example is correct and why. The \"why\" must require articulating the target."); sb.AppendLine(); sb.AppendLine("# Rules"); sb.AppendLine("- The statement is INTERNAL — NEVER reveal or paraphrase it closely enough to give it away. The scaffold must make the learner do the articulation."); sb.AppendLine("- Keep examples short. Code: 3–6 lines. Scenarios: 2–3 sentences."); - sb.AppendLine("- End with ONE concrete question. The two L5 options count as structural formatting, not a bullet list of hints."); + sb.AppendLine("- End with ONE concrete question. The two L4 options count as structural formatting, not a bullet list of hints."); sb.AppendLine("- Do NOT use analogies, sentence-completion blanks, or forced-choice between abstract phrasings."); sb.AppendLine(); sb.AppendLine("# Runtime Context Format"); sb.AppendLine("Chat history shows the conversation so far (including the learner's prior attempts on this target)."); - sb.AppendLine("The final user message contains: …statement…."); + sb.AppendLine("The final user message contains: …statement…."); return sb.ToString(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs index cd1aefd10..38e519a12 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs @@ -16,7 +16,7 @@ public class ScorerResponse public string? NovelMisconceptions { get; set; } public bool? HasMultipleConcerns { get; set; } - public Result MapToEvaluation(ConceptRecord record) + public Result ToEvaluation(ConceptRecord record) { if (CorrectnessScore is < 0 or > 5) return Result.Fail("Correctness out of range."); if (CompletenessScore is < 0 or > 5) return Result.Fail("Completeness out of range."); @@ -33,6 +33,11 @@ public Result MapToEvaluation(ConceptRecord record) if (MisconceptionsTriggeredKeys?.Any(k => !validCmKeys.Contains(k)) == true) return Result.Fail("Unknown misconception key."); + return CreateEvaluation(); + } + + private TurnEvaluation CreateEvaluation() + { return new TurnEvaluation( CorrectnessScore, CompletenessScore, IntegrationScore, Justification ?? string.Empty, NovelMisconceptions, PropositionsCoveredKeys ?? [], diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs index ffcb517b5..36e5f8f2c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs @@ -20,7 +20,7 @@ public static string Build(ConceptRecord record) sb.AppendLine("- Paraphrase at the level of the learner's articulations; do not upgrade them with rubric language."); sb.AppendLine("- NEVER quote any KP/BC/CM/KR text verbatim or near-verbatim."); sb.AppendLine("- No bullet lists. One short paragraph, 2-4 sentences."); - sb.AppendLine("- End with a brief invitation to continue (\"nastavi odatle\" / \"šta još bi dodao?\")."); + sb.AppendLine("- End with a brief invitation to continue (\"nastavi odatle\" / \"šta bi još dodao?\")."); sb.AppendLine(); sb.AppendLine("# Runtime Context Format"); From 3164c855b23c3cd3f8ed3794ca112d67acd68ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Tue, 28 Apr 2026 08:14:18 +0300 Subject: [PATCH 31/51] refactor: Simplifies data flow. --- .../Conversations/ConversationAttempt.cs | 4 +--- .../Orchestration/AgentOrchestrator.cs | 23 ++++++++++++------- .../Orchestration/OrchestratorChunk.cs | 1 - 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index e0e0f46eb..f6ce24ffc 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -120,12 +120,10 @@ public void TransitionToClosing(string closingMessage) ClosingTurnCount = _turns.Count; } - public void Complete(string content, TurnIntent intent, TurnEvaluation evaluation) + public void Complete(TurnEvaluation evaluation) { - AddLearnerTurn(content, intent, evaluation); Summary = $"{evaluation.Grade()} / 10"; AddSystemTurn(Summary); - Status = AttemptStatus.Completed; CompletedAt = DateTime.UtcNow; } 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 index f4ff30c07..8e7782b12 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -49,6 +49,16 @@ public async IAsyncEnumerable ProcessTurnAsync(ConceptRecord yield break; } + await foreach (var chunk in HandleProgressTurnAsync(record, attempt, newMessage, intent, ct)) + yield return chunk; + } + + private async IAsyncEnumerable HandleProgressTurnAsync(ConceptRecord record, + ConversationAttempt attempt, string newMessage, TurnIntent intent, [EnumeratorCancellation] CancellationToken ct) + { + FinalChunk CreateFinalChunk(string? summary = null) + => new(attempt.Id, attempt.Status, intent, summary, _usageTracker.Total); + TurnEvaluation? evaluation = null; if (intent == TurnIntent.Substantive) { @@ -108,18 +118,14 @@ public async IAsyncEnumerable ProcessTurnAsync(ConceptRecord var probeDirective = (route as RouteResult.Stream)?.ProbeDirective; attempt.AddSystemTurn(fullResponse.ToString(), probeDirective); - yield return CreateFinalChunk(directive: probeDirective); - yield break; - - FinalChunk CreateFinalChunk(string? summary = null, ProbeDirective? directive = null) - => new(attempt.Id, attempt.Status, intent, summary, directive, _usageTracker.Total); + yield return CreateFinalChunk(); } private async IAsyncEnumerable HandleClosingTurnAsync(ConceptRecord record, ConversationAttempt attempt, string newMessage, TurnIntent intent, [EnumeratorCancellation] CancellationToken ct) { - FinalChunk CreateFinalChunk(string? summary = null, ProbeDirective? directive = null) - => new(attempt.Id, attempt.Status, intent, summary, directive, _usageTracker.Total); + FinalChunk CreateFinalChunk(string? summary = null) + => new(attempt.Id, attempt.Status, intent, summary, _usageTracker.Total); if (intent == TurnIntent.Substantive) { @@ -129,7 +135,8 @@ FinalChunk CreateFinalChunk(string? summary = null, ProbeDirective? directive = yield return new ErrorChunk("Closing scoring failed.", 500); yield break; } - attempt.Complete(newMessage, intent, scoreResult.Value); + attempt.AddLearnerTurn(newMessage, intent, scoreResult.Value); + attempt.Complete(scoreResult.Value); yield return new TokenChunk(attempt.Summary!); yield return CreateFinalChunk(attempt.Summary); yield break; 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 index d5ce76939..acf0c13b4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs @@ -12,7 +12,6 @@ public sealed record FinalChunk( AttemptStatus Status, TurnIntent Intent, string? Summary, - ProbeDirective? ProbeDirective, TokenUsage Usage) : OrchestratorChunk; public sealed record ErrorChunk(string Message, int Code) : OrchestratorChunk; From 03507bbe590d367b4a95071139f3f5bde399f395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Wed, 29 Apr 2026 08:18:01 +0300 Subject: [PATCH 32/51] refactor: Reworks Probe to become a domain concept with cleaner design. --- .../Domain/Conversations/ActiveProbe.cs | 3 +++ .../Conversations/ConversationAttempt.cs | 23 ++++++++-------- .../Domain/Conversations/ConversationTurn.cs | 9 +++---- .../Orchestration/AgentOrchestrator.cs | 26 ++++++++++--------- .../Learning/Orchestration/ProbeDirective.cs | 3 --- .../Database/ElaborationsContext.cs | 3 +++ 6 files changed, 34 insertions(+), 33 deletions(-) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ActiveProbe.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ActiveProbe.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ActiveProbe.cs new file mode 100644 index 000000000..a6d01c748 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ActiveProbe.cs @@ -0,0 +1,3 @@ +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public record ActiveProbe(string Target, int Level); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index f6ce24ffc..19edb44a7 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -1,5 +1,4 @@ using Tutor.BuildingBlocks.Core.Domain; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; namespace Tutor.Elaborations.Core.Domain.Conversations; @@ -61,21 +60,20 @@ public int CountTotalLearnerTurns() public int GetProbeLevelFor(string target) { var max = Turns - .Where(t => t.Role == TurnRole.System && t.ProbeTarget == target && t.ProbeLevel.HasValue) - .Select(t => t.ProbeLevel!.Value) + .Where(t => t.Role == TurnRole.System && t.Probe?.Target == target) + .Select(t => t.Probe!.Level) .DefaultIfEmpty(0) .Max(); return max + 1; } - public ProbeDirective? GetLastProbe() + public ActiveProbe? GetLastProbe() { - var last = Turns - .Where(t => t.Role == TurnRole.System && t.ProbeTarget != null) + return Turns + .Where(t => t.Role == TurnRole.System && t.Probe != null) .OrderByDescending(t => t.Order) + .Select(t => t.Probe) .FirstOrDefault(); - if (last == null) return null; - return new ProbeDirective(last.ProbeTarget!, last.ProbeLevel!.Value); } public bool IsScaffolding(int ladderLevel) => ladderLevel > ProbeLadderLength; @@ -85,8 +83,8 @@ public int GetProbeLevelFor(string target) public IReadOnlySet GetStalledTargets() { return Turns - .Where(t => t.Role == TurnRole.System && t.ProbeTarget != null && t.ProbeLevel >= StalledThreshold) - .Select(t => t.ProbeTarget!) + .Where(t => t.Role == TurnRole.System && t.Probe != null && t.Probe.Level >= StalledThreshold) + .Select(t => t.Probe!.Target) .ToHashSet(); } @@ -105,10 +103,10 @@ public ConversationTurn AddLearnerTurn(string content, TurnIntent intent, TurnEv return turn; } - public ConversationTurn AddSystemTurn(string content, ProbeDirective? probeDirective = null) + public ConversationTurn AddSystemTurn(string content, ActiveProbe? probe = null) { var turn = new ConversationTurn(TurnRole.System, content, _turns.Count, - intent: null, evaluation: null, probeDirective: probeDirective); + intent: null, evaluation: null, probe: probe); _turns.Add(turn); return turn; } @@ -124,6 +122,7 @@ public void Complete(TurnEvaluation evaluation) { Summary = $"{evaluation.Grade()} / 10"; AddSystemTurn(Summary); + Status = AttemptStatus.Completed; CompletedAt = DateTime.UtcNow; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs index e43416e3d..5651feb94 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs @@ -1,5 +1,4 @@ using Tutor.BuildingBlocks.Core.Domain; -using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; namespace Tutor.Elaborations.Core.Domain.Conversations; @@ -12,15 +11,14 @@ public class ConversationTurn : Entity public DateTime Timestamp { get; private set; } public TurnIntent? Intent { get; private set; } public TurnEvaluation? Evaluation { get; private set; } - public string? ProbeTarget { get; private set; } - public int? ProbeLevel { get; private set; } + public ActiveProbe? Probe { get; private set; } private ConversationTurn() { } internal ConversationTurn( TurnRole role, string content, int order, TurnIntent? intent = null, TurnEvaluation? evaluation = null, - ProbeDirective? probeDirective = null) + ActiveProbe? probe = null) { Role = role; Content = content; @@ -28,7 +26,6 @@ internal ConversationTurn( Timestamp = DateTime.UtcNow; Intent = intent; Evaluation = evaluation; - ProbeTarget = probeDirective?.Target; - ProbeLevel = probeDirective?.Level; + Probe = probe; } } 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 index 8e7782b12..22457c916 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -56,9 +56,6 @@ public async IAsyncEnumerable ProcessTurnAsync(ConceptRecord private async IAsyncEnumerable HandleProgressTurnAsync(ConceptRecord record, ConversationAttempt attempt, string newMessage, TurnIntent intent, [EnumeratorCancellation] CancellationToken ct) { - FinalChunk CreateFinalChunk(string? summary = null) - => new(attempt.Id, attempt.Status, intent, summary, _usageTracker.Total); - TurnEvaluation? evaluation = null; if (intent == TurnIntent.Substantive) { @@ -116,17 +113,18 @@ FinalChunk CreateFinalChunk(string? summary = null) yield return new TokenChunk(nudge); } - var probeDirective = (route as RouteResult.Stream)?.ProbeDirective; - attempt.AddSystemTurn(fullResponse.ToString(), probeDirective); + var probe = (route as RouteResult.Stream)?.Probe; + attempt.AddSystemTurn(fullResponse.ToString(), probe); yield return CreateFinalChunk(); + yield break; + + FinalChunk CreateFinalChunk(string? summary = null) + => new(attempt.Id, attempt.Status, intent, summary, _usageTracker.Total); } private async IAsyncEnumerable HandleClosingTurnAsync(ConceptRecord record, ConversationAttempt attempt, string newMessage, TurnIntent intent, [EnumeratorCancellation] CancellationToken ct) { - FinalChunk CreateFinalChunk(string? summary = null) - => new(attempt.Id, attempt.Status, intent, summary, _usageTracker.Total); - if (intent == TurnIntent.Substantive) { var scoreResult = await ScoreClosingAsync(record, attempt.Turns, newMessage, ct); @@ -156,6 +154,10 @@ FinalChunk CreateFinalChunk(string? summary = null) attempt.AddSystemTurn(ElaborationTexts.NonSubstantiveInClosingNudge); yield return new TokenChunk(ElaborationTexts.NonSubstantiveInClosingNudge); yield return CreateFinalChunk(); + yield break; + + FinalChunk CreateFinalChunk(string? summary = null) + => new(attempt.Id, attempt.Status, intent, summary, _usageTracker.Total); } private static RouteResult Route(ConceptRecord record, ConversationAttempt attempt, @@ -175,7 +177,7 @@ private static RouteResult Route(ConceptRecord record, ConversationAttempt attem if (next == null) return new RouteResult.Transition(); var ladderLevel = attempt.GetProbeLevelFor(next); var kind = attempt.IsScaffolding(ladderLevel) ? AgentKind.Scaffolding : AgentKind.Probe; - return new RouteResult.Stream(kind, new AgentTurnContext(Target: next, Level: ladderLevel), new ProbeDirective(next, ladderLevel)); + return new RouteResult.Stream(kind, new AgentTurnContext(Target: next, Level: ladderLevel), new ActiveProbe(next, ladderLevel)); } case TurnIntent.Stuck: @@ -189,13 +191,13 @@ private static RouteResult Route(ConceptRecord record, ConversationAttempt attem return new RouteResult.Stream( AgentKind.Scaffolding, new AgentTurnContext(Target: stuckTarget, Level: first), - new ProbeDirective(stuckTarget, first)); + new ActiveProbe(stuckTarget, first)); } var ladderLevel = attempt.GetProbeLevelFor(stuckTarget); return new RouteResult.Stream( AgentKind.Scaffolding, new AgentTurnContext(Target: stuckTarget, Level: ladderLevel), - new ProbeDirective(stuckTarget, ladderLevel)); + new ActiveProbe(stuckTarget, ladderLevel)); } case TurnIntent.Clarification: @@ -263,7 +265,7 @@ private async Task> ScoreClosingAsync(ConceptRecord recor private abstract record RouteResult { - public sealed record Stream(AgentKind Kind, AgentTurnContext Ctx, ProbeDirective? ProbeDirective) : RouteResult; + public sealed record Stream(AgentKind Kind, AgentTurnContext Ctx, ActiveProbe? Probe) : RouteResult; public sealed record OffTopic : RouteResult; public sealed record Transition : RouteResult; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs deleted file mode 100644 index 8f1cf671f..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ProbeDirective.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; - -public record ProbeDirective(string Target, int Level); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs index cf1bb320d..855fc1595 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -65,6 +65,9 @@ private static void ConfigureConversations(ModelBuilder modelBuilder) .WithOne() .HasForeignKey(te => te.ConversationTurnId); + modelBuilder.Entity() + .OwnsOne(ct => ct.Probe, probe => probe.ToJson()); + modelBuilder.Entity() .HasIndex(ct => new { ct.ConversationAttemptId, ct.Order }); From cb32be67409a3295a99cda948183b599495df0f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Wed, 29 Apr 2026 08:44:22 +0300 Subject: [PATCH 33/51] chore: Resolves some sonar issues. --- .../Conversations/ConversationAttempt.cs | 8 ++++---- .../ConceptElaborationTaskService.cs | 20 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index 19edb44a7..b1026feba 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -14,7 +14,7 @@ public class ConversationAttempt : AggregateRoot public DateTime StartedAt { get; private set; } public DateTime? CompletedAt { get; private set; } public string? Summary { get; private set; } - private List _turns = new(); + private readonly List _turns = new(); public IReadOnlyList Turns => _turns.AsReadOnly(); public int? SoftCapTotalTurns { get; private set; } public int? HardCapTotalTurns { get; private set; } @@ -22,14 +22,14 @@ public class ConversationAttempt : AggregateRoot private ConversationAttempt() { } - public ConversationAttempt(int conceptElaborationTaskId, int learnerId, int totalItems) + public ConversationAttempt(int conceptElaborationTaskId, int learnerId, int totalTargets) { ConceptElaborationTaskId = conceptElaborationTaskId; LearnerId = learnerId; Status = AttemptStatus.InProgress; StartedAt = DateTime.UtcNow; - HardCapTotalTurns = totalItems + 2; - SoftCapTotalTurns = Math.Max(totalItems - 2, 2); + HardCapTotalTurns = totalTargets + 4; + SoftCapTotalTurns = Math.Max(totalTargets, 3); } public ISet GetArticulatedPropositionKeys() diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs index 30611325b..3863a78e2 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs @@ -34,14 +34,14 @@ public Result> GetByUnit(int unitId, int instruc return Result.Ok(tasks.Select(t => _mapper.Map(t)).ToList()); } - public Result Create(ConceptElaborationTaskDto dto, int instructorId) + public Result Create(ConceptElaborationTaskDto task, int instructorId) { - if (!_accessServices.IsUnitOwner(dto.UnitId, instructorId)) + if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) return Result.Fail(FailureCode.Forbidden); - var task = _mapper.Map(dto); - task.UnitId = dto.UnitId; - var created = _taskRepository.Create(task); + var newTask = _mapper.Map(task); + newTask.UnitId = task.UnitId; + var created = _taskRepository.Create(newTask); var saveResult = _unitOfWork.Save(); if (saveResult.IsFailed) return saveResult; @@ -49,16 +49,16 @@ public Result Create(ConceptElaborationTaskDto dto, i return Result.Ok(_mapper.Map(created)); } - public Result Update(ConceptElaborationTaskDto dto, int instructorId) + public Result Update(ConceptElaborationTaskDto task, int instructorId) { - if (!_accessServices.IsUnitOwner(dto.UnitId, instructorId)) + if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) return Result.Fail(FailureCode.Forbidden); - var existingTask = _taskRepository.GetWithRecord(dto.Id); - if (existingTask == null || existingTask.UnitId != dto.UnitId) + var existingTask = _taskRepository.GetWithRecord(task.Id); + if (existingTask == null || existingTask.UnitId != task.UnitId) return Result.Fail(FailureCode.NotFound); - existingTask.Update(_mapper.Map(dto)); + existingTask.Update(_mapper.Map(task)); _taskRepository.Update(existingTask); var saveResult = _unitOfWork.Save(); From c622ab7950b57a3c1289d385807b96ba90084364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Wed, 29 Apr 2026 09:09:58 +0300 Subject: [PATCH 34/51] refactor: Major rework of Orchestrator that compressess previous routing and decisions into one, hopefully easier-to-understand, flow. --- .../Orchestration/AgentOrchestrator.cs | 253 +++++++++++------- 1 file changed, 157 insertions(+), 96 deletions(-) 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 index 22457c916..45cb7926f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -70,56 +70,169 @@ private async IAsyncEnumerable HandleProgressTurnAsync(Concep attempt.AddLearnerTurn(newMessage, intent, evaluation); - var route = Route(record, attempt, intent, evaluation); - - if (route is RouteResult.Transition) + if (record.IsAttemptComplete(attempt) || attempt.IsHardCapReached()) { attempt.TransitionToClosing(ElaborationTexts.InClosingTransition); yield return new TokenChunk(ElaborationTexts.InClosingTransition); - yield return CreateFinalChunk(); + yield return CreateFinalChunk(attempt, intent); yield break; } - var fullResponse = new StringBuilder(); + var handler = intent switch + { + TurnIntent.Substantive => HandleSubstantiveAsync(record, attempt, evaluation!, ct), + TurnIntent.Stuck => HandleStuckAsync(record, attempt, ct), + TurnIntent.Clarification => HandleClarificationAsync(record, attempt, ct), + TurnIntent.SummaryRequest => HandleSummaryRequestAsync(record, attempt, ct), + _ => HandleOffTopicAsync(attempt, ct) + }; + await foreach (var chunk in handler.WithCancellation(ct)) + yield return chunk; + } + + private async IAsyncEnumerable HandleSubstantiveAsync(ConceptRecord record, + ConversationAttempt attempt, TurnEvaluation evaluation, [EnumeratorCancellation] CancellationToken ct) + { + AgentKind kind; + AgentTurnContext ctx; + ActiveProbe? probe; - if (route is RouteResult.OffTopic) + if (evaluation.HasMultipleConcerns) { - fullResponse.Append(ElaborationTexts.OffTopic); - yield return new TokenChunk(ElaborationTexts.OffTopic); + kind = AgentKind.Critique; + ctx = new AgentTurnContext(Evaluation: evaluation); + probe = null; } - else if (route is RouteResult.Stream streamRoute) + else { - var request = BuildRequest(streamRoute.Kind, attempt.Turns, record, streamRoute.Ctx); - StreamFailure? streamFailure = null; - await foreach (var chunk in StreamAsync(request, streamRoute.Kind.ToString(), ct)) + var next = record.PickNextTarget(attempt); + if (next == null) { - if (chunk is StreamFailure failure) { streamFailure = failure; break; } - var content = ((StreamToken)chunk).Content; - fullResponse.Append(content); - yield return new TokenChunk(content); + attempt.TransitionToClosing(ElaborationTexts.InClosingTransition); + yield return new TokenChunk(ElaborationTexts.InClosingTransition); + yield return CreateFinalChunk(attempt, TurnIntent.Substantive); + yield break; } + var level = attempt.GetProbeLevelFor(next); + kind = attempt.IsScaffolding(level) ? AgentKind.Scaffolding : AgentKind.Probe; + ctx = new AgentTurnContext(Target: next, Level: level); + probe = new ActiveProbe(next, level); + } + + var fullResponse = new StringBuilder(); + await foreach (var chunk in StreamAgentAsync(kind, ctx, attempt.Turns, record, fullResponse, ct)) + { + yield return chunk; + if (chunk is ErrorChunk) yield break; + } + + if (attempt.IsSoftCapReached()) + { + fullResponse.Append(ElaborationTexts.SoftCapNudge); + yield return new TokenChunk(ElaborationTexts.SoftCapNudge); + } + + attempt.AddSystemTurn(fullResponse.ToString(), probe); + yield return CreateFinalChunk(attempt, TurnIntent.Substantive); + } - if (streamFailure != null) + private async IAsyncEnumerable HandleStuckAsync(ConceptRecord record, + ConversationAttempt attempt, [EnumeratorCancellation] CancellationToken ct) + { + string? stuckTarget = attempt.GetLastProbe()?.Target; + int ladderLevel; + + if (stuckTarget == null || attempt.GetStalledTargets().Contains(stuckTarget)) + { + stuckTarget = record.PickNextTarget(attempt); + if (stuckTarget == null) { - yield return new ErrorChunk(streamFailure.Reason, 500); + attempt.TransitionToClosing(ElaborationTexts.InClosingTransition); + yield return new TokenChunk(ElaborationTexts.InClosingTransition); + yield return CreateFinalChunk(attempt, TurnIntent.Stuck); yield break; } + ladderLevel = attempt.FirstScaffoldLadderLevel; + } + else + { + ladderLevel = attempt.GetProbeLevelFor(stuckTarget); } - if (attempt.IsSoftCapReached() && ShouldAppendSoftCapNudge(intent)) + var probe = new ActiveProbe(stuckTarget, ladderLevel); + var ctx = new AgentTurnContext(Target: stuckTarget, Level: ladderLevel); + var fullResponse = new StringBuilder(); + await foreach (var chunk in StreamAgentAsync(AgentKind.Scaffolding, ctx, attempt.Turns, record, fullResponse, ct)) { - var nudge = "\n\n" + ElaborationTexts.SoftCapNudge; - fullResponse.Append(nudge); - yield return new TokenChunk(nudge); + yield return chunk; + if (chunk is ErrorChunk) yield break; + } + + if (attempt.IsSoftCapReached()) + { + fullResponse.Append(ElaborationTexts.SoftCapNudge); + yield return new TokenChunk(ElaborationTexts.SoftCapNudge); } - var probe = (route as RouteResult.Stream)?.Probe; attempt.AddSystemTurn(fullResponse.ToString(), probe); - yield return CreateFinalChunk(); - yield break; + yield return CreateFinalChunk(attempt, TurnIntent.Stuck); + } + + private async IAsyncEnumerable HandleClarificationAsync(ConceptRecord record, + ConversationAttempt attempt, [EnumeratorCancellation] CancellationToken ct) + { + var ctx = new AgentTurnContext(Target: attempt.GetLastProbe()?.Target); + var fullResponse = new StringBuilder(); + await foreach (var chunk in StreamAgentAsync(AgentKind.Clarification, ctx, attempt.Turns, record, fullResponse, ct)) + { + yield return chunk; + if (chunk is ErrorChunk) yield break; + } - FinalChunk CreateFinalChunk(string? summary = null) - => new(attempt.Id, attempt.Status, intent, summary, _usageTracker.Total); + if (attempt.IsSoftCapReached()) + { + fullResponse.Append(ElaborationTexts.SoftCapNudge); + yield return new TokenChunk(ElaborationTexts.SoftCapNudge); + } + + attempt.AddSystemTurn(fullResponse.ToString()); + yield return CreateFinalChunk(attempt, TurnIntent.Clarification); + } + + private async IAsyncEnumerable HandleSummaryRequestAsync(ConceptRecord record, + ConversationAttempt attempt, [EnumeratorCancellation] CancellationToken ct) + { + var fullResponse = new StringBuilder(); + await foreach (var chunk in StreamAgentAsync(AgentKind.Summary, new AgentTurnContext(), attempt.Turns, record, fullResponse, ct)) + { + yield return chunk; + if (chunk is ErrorChunk) yield break; + } + + if (attempt.IsSoftCapReached()) + { + fullResponse.Append(ElaborationTexts.SoftCapNudge); + yield return new TokenChunk(ElaborationTexts.SoftCapNudge); + } + + attempt.AddSystemTurn(fullResponse.ToString()); + yield return CreateFinalChunk(attempt, TurnIntent.SummaryRequest); + } + + private async IAsyncEnumerable HandleOffTopicAsync( + ConversationAttempt attempt, [EnumeratorCancellation] CancellationToken ct) + { + var fullResponse = new StringBuilder(ElaborationTexts.OffTopic); + yield return new TokenChunk(ElaborationTexts.OffTopic); + + if (attempt.IsSoftCapReached()) + { + fullResponse.Append(ElaborationTexts.SoftCapNudge); + yield return new TokenChunk(ElaborationTexts.SoftCapNudge); + } + + attempt.AddSystemTurn(fullResponse.ToString()); + yield return CreateFinalChunk(attempt, TurnIntent.OffTopic); } private async IAsyncEnumerable HandleClosingTurnAsync(ConceptRecord record, @@ -136,7 +249,7 @@ private async IAsyncEnumerable HandleClosingTurnAsync(Concept attempt.AddLearnerTurn(newMessage, intent, scoreResult.Value); attempt.Complete(scoreResult.Value); yield return new TokenChunk(attempt.Summary!); - yield return CreateFinalChunk(attempt.Summary); + yield return CreateFinalChunk(attempt, intent, attempt.Summary); yield break; } @@ -147,79 +260,34 @@ private async IAsyncEnumerable HandleClosingTurnAsync(Concept attempt.AddSystemTurn(ElaborationTexts.ExpiredNotice); attempt.Expire(summary: null); yield return new TokenChunk(ElaborationTexts.ExpiredNotice); - yield return CreateFinalChunk(); + yield return CreateFinalChunk(attempt, intent); yield break; } attempt.AddSystemTurn(ElaborationTexts.NonSubstantiveInClosingNudge); yield return new TokenChunk(ElaborationTexts.NonSubstantiveInClosingNudge); - yield return CreateFinalChunk(); - yield break; - - FinalChunk CreateFinalChunk(string? summary = null) - => new(attempt.Id, attempt.Status, intent, summary, _usageTracker.Total); + yield return CreateFinalChunk(attempt, intent); } - private static RouteResult Route(ConceptRecord record, ConversationAttempt attempt, - TurnIntent intent, TurnEvaluation? evaluation) + private async IAsyncEnumerable StreamAgentAsync( + AgentKind kind, AgentTurnContext ctx, IReadOnlyList turns, + ConceptRecord record, StringBuilder output, [EnumeratorCancellation] CancellationToken ct) { - if (record.IsAttemptComplete(attempt) || attempt.IsHardCapReached()) - return new RouteResult.Transition(); - - switch (intent) + var request = BuildRequest(kind, turns, record, ctx); + StreamFailure? failure = null; + await foreach (var chunk in StreamAsync(request, kind.ToString(), ct)) { - case TurnIntent.Substantive: - { - if (evaluation != null && evaluation.HasMultipleConcerns) - return new RouteResult.Stream(AgentKind.Critique, - new AgentTurnContext(Evaluation: evaluation), null); - var next = record.PickNextTarget(attempt); - if (next == null) return new RouteResult.Transition(); - var ladderLevel = attempt.GetProbeLevelFor(next); - var kind = attempt.IsScaffolding(ladderLevel) ? AgentKind.Scaffolding : AgentKind.Probe; - return new RouteResult.Stream(kind, new AgentTurnContext(Target: next, Level: ladderLevel), new ActiveProbe(next, ladderLevel)); - } - - case TurnIntent.Stuck: - { - var stuckTarget = attempt.GetLastProbe()?.Target; - if (stuckTarget == null || attempt.GetStalledTargets().Contains(stuckTarget)) - { - stuckTarget = record.PickNextTarget(attempt); - if (stuckTarget == null) return new RouteResult.Transition(); - var first = attempt.FirstScaffoldLadderLevel; - return new RouteResult.Stream( - AgentKind.Scaffolding, - new AgentTurnContext(Target: stuckTarget, Level: first), - new ActiveProbe(stuckTarget, first)); - } - var ladderLevel = attempt.GetProbeLevelFor(stuckTarget); - return new RouteResult.Stream( - AgentKind.Scaffolding, - new AgentTurnContext(Target: stuckTarget, Level: ladderLevel), - new ActiveProbe(stuckTarget, ladderLevel)); - } - - case TurnIntent.Clarification: - { - var last = attempt.GetLastProbe(); - return new RouteResult.Stream( - AgentKind.Clarification, - new AgentTurnContext(Target: last?.Target), - null); - } - - case TurnIntent.SummaryRequest: - return new RouteResult.Stream(AgentKind.Summary, new AgentTurnContext(), null); - - case TurnIntent.OffTopic: - default: - return new RouteResult.OffTopic(); + 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 static bool ShouldAppendSoftCapNudge(TurnIntent intent) => - intent is TurnIntent.Substantive or TurnIntent.Stuck or TurnIntent.SummaryRequest; + private FinalChunk CreateFinalChunk(ConversationAttempt attempt, TurnIntent intent, string? summary = null) + => new(attempt.Id, attempt.Status, intent, summary, _usageTracker.Total); private static CompletionRequest BuildRequest(AgentKind kind, IReadOnlyList history, ConceptRecord record, AgentTurnContext ctx) @@ -262,11 +330,4 @@ private async Task> ScoreClosingAsync(ConceptRecord recor if (result.IsFailed) return Result.Fail(result.Errors); return result.Value.ToEvaluation(record); } - - private abstract record RouteResult - { - public sealed record Stream(AgentKind Kind, AgentTurnContext Ctx, ActiveProbe? Probe) : RouteResult; - public sealed record OffTopic : RouteResult; - public sealed record Transition : RouteResult; - } } From 84c806638ff72efeafa677a7b91472f4350b2383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Fri, 1 May 2026 09:48:02 +0300 Subject: [PATCH 35/51] refactor: Minor cleanup while reviewing Orchestrator. --- .../Conversations/ConversationAttempt.cs | 5 +- .../Domain/Conversations/ConversationTurn.cs | 5 +- .../UseCases/Learning/ConversationService.cs | 3 +- .../Orchestration/AgentOrchestrator.cs | 63 +++++++++---------- ...ElaborationTexts.cs => SystemTurnCodes.cs} | 2 +- 5 files changed, 39 insertions(+), 39 deletions(-) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/{ElaborationTexts.cs => SystemTurnCodes.cs} (90%) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index b1026feba..987ed456c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -96,7 +96,7 @@ public int CountNonSubstantiveClosingTurns() .Count(t => t.Role == TurnRole.Learner && t.Intent != TurnIntent.Substantive); } - public ConversationTurn AddLearnerTurn(string content, TurnIntent intent, TurnEvaluation? evaluation) + public ConversationTurn AddLearnerTurn(string content, TurnIntent intent, TurnEvaluation? evaluation = null) { var turn = new ConversationTurn(TurnRole.Learner, content, _turns.Count, intent, evaluation); _turns.Add(turn); @@ -133,8 +133,9 @@ public void Abandon() CompletedAt = DateTime.UtcNow; } - public void Expire(string? summary) + public void Expire(string summary) { + AddSystemTurn(summary); Status = AttemptStatus.Expired; CompletedAt = DateTime.UtcNow; Summary = summary; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs index 5651feb94..2cfd391eb 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs @@ -15,11 +15,12 @@ public class ConversationTurn : Entity private ConversationTurn() { } - internal ConversationTurn( - TurnRole role, string content, int order, + internal ConversationTurn(TurnRole role, string content, int order, TurnIntent? intent = null, TurnEvaluation? evaluation = null, ActiveProbe? probe = null) { + if(role == TurnRole.Learner && intent == TurnIntent.Substantive && evaluation == null) + throw new ArgumentException("Substantive learner turns must have an evaluation."); Role = role; Content = content; Order = order; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index ef23d8759..680a4d611 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -122,8 +122,7 @@ public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string cont 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 is not (AttemptStatus.InProgress or AttemptStatus.InClosing)) - { yield return BuildErrorChunk("Conversation is no longer active.", 409); yield break; } + if (attempt.Status is not (AttemptStatus.InProgress or AttemptStatus.InClosing)) { yield return BuildErrorChunk("Conversation is no longer active.", 409); yield break; } var task = _taskRepo.GetWithRecord(attempt.ConceptElaborationTaskId); if (task == null) { yield return BuildErrorChunk("Task not found.", 404); yield break; } 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 index 45cb7926f..13e7faeed 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -42,15 +42,16 @@ public async IAsyncEnumerable ProcessTurnAsync(ConceptRecord } var intent = intentResult.Value; - if (attempt.Status == AttemptStatus.InClosing) + if (attempt.Status == AttemptStatus.InProgress) + { + await foreach (var chunk in HandleProgressTurnAsync(record, attempt, newMessage, intent, ct)) + yield return chunk; + } + else if(attempt.Status == AttemptStatus.InClosing) { await foreach (var chunk in HandleClosingTurnAsync(record, attempt, newMessage, intent, ct)) yield return chunk; - yield break; } - - await foreach (var chunk in HandleProgressTurnAsync(record, attempt, newMessage, intent, ct)) - yield return chunk; } private async IAsyncEnumerable HandleProgressTurnAsync(ConceptRecord record, @@ -72,8 +73,8 @@ private async IAsyncEnumerable HandleProgressTurnAsync(Concep if (record.IsAttemptComplete(attempt) || attempt.IsHardCapReached()) { - attempt.TransitionToClosing(ElaborationTexts.InClosingTransition); - yield return new TokenChunk(ElaborationTexts.InClosingTransition); + attempt.TransitionToClosing(SystemTurnCodes.InClosingTransition); + yield return new TokenChunk(SystemTurnCodes.InClosingTransition); yield return CreateFinalChunk(attempt, intent); yield break; } @@ -84,7 +85,7 @@ private async IAsyncEnumerable HandleProgressTurnAsync(Concep TurnIntent.Stuck => HandleStuckAsync(record, attempt, ct), TurnIntent.Clarification => HandleClarificationAsync(record, attempt, ct), TurnIntent.SummaryRequest => HandleSummaryRequestAsync(record, attempt, ct), - _ => HandleOffTopicAsync(attempt, ct) + _ => HandleOffTopicAsync(attempt) }; await foreach (var chunk in handler.WithCancellation(ct)) yield return chunk; @@ -108,8 +109,8 @@ private async IAsyncEnumerable HandleSubstantiveAsync(Concept var next = record.PickNextTarget(attempt); if (next == null) { - attempt.TransitionToClosing(ElaborationTexts.InClosingTransition); - yield return new TokenChunk(ElaborationTexts.InClosingTransition); + attempt.TransitionToClosing(SystemTurnCodes.InClosingTransition); + yield return new TokenChunk(SystemTurnCodes.InClosingTransition); yield return CreateFinalChunk(attempt, TurnIntent.Substantive); yield break; } @@ -128,8 +129,8 @@ private async IAsyncEnumerable HandleSubstantiveAsync(Concept if (attempt.IsSoftCapReached()) { - fullResponse.Append(ElaborationTexts.SoftCapNudge); - yield return new TokenChunk(ElaborationTexts.SoftCapNudge); + fullResponse.Append(SystemTurnCodes.SoftCapNudge); + yield return new TokenChunk(SystemTurnCodes.SoftCapNudge); } attempt.AddSystemTurn(fullResponse.ToString(), probe); @@ -147,8 +148,8 @@ private async IAsyncEnumerable HandleStuckAsync(ConceptRecord stuckTarget = record.PickNextTarget(attempt); if (stuckTarget == null) { - attempt.TransitionToClosing(ElaborationTexts.InClosingTransition); - yield return new TokenChunk(ElaborationTexts.InClosingTransition); + attempt.TransitionToClosing(SystemTurnCodes.InClosingTransition); + yield return new TokenChunk(SystemTurnCodes.InClosingTransition); yield return CreateFinalChunk(attempt, TurnIntent.Stuck); yield break; } @@ -170,8 +171,8 @@ private async IAsyncEnumerable HandleStuckAsync(ConceptRecord if (attempt.IsSoftCapReached()) { - fullResponse.Append(ElaborationTexts.SoftCapNudge); - yield return new TokenChunk(ElaborationTexts.SoftCapNudge); + fullResponse.Append(SystemTurnCodes.SoftCapNudge); + yield return new TokenChunk(SystemTurnCodes.SoftCapNudge); } attempt.AddSystemTurn(fullResponse.ToString(), probe); @@ -191,8 +192,8 @@ private async IAsyncEnumerable HandleClarificationAsync(Conce if (attempt.IsSoftCapReached()) { - fullResponse.Append(ElaborationTexts.SoftCapNudge); - yield return new TokenChunk(ElaborationTexts.SoftCapNudge); + fullResponse.Append(SystemTurnCodes.SoftCapNudge); + yield return new TokenChunk(SystemTurnCodes.SoftCapNudge); } attempt.AddSystemTurn(fullResponse.ToString()); @@ -211,24 +212,23 @@ private async IAsyncEnumerable HandleSummaryRequestAsync(Conc if (attempt.IsSoftCapReached()) { - fullResponse.Append(ElaborationTexts.SoftCapNudge); - yield return new TokenChunk(ElaborationTexts.SoftCapNudge); + fullResponse.Append(SystemTurnCodes.SoftCapNudge); + yield return new TokenChunk(SystemTurnCodes.SoftCapNudge); } attempt.AddSystemTurn(fullResponse.ToString()); yield return CreateFinalChunk(attempt, TurnIntent.SummaryRequest); } - private async IAsyncEnumerable HandleOffTopicAsync( - ConversationAttempt attempt, [EnumeratorCancellation] CancellationToken ct) + private async IAsyncEnumerable HandleOffTopicAsync(ConversationAttempt attempt) { - var fullResponse = new StringBuilder(ElaborationTexts.OffTopic); - yield return new TokenChunk(ElaborationTexts.OffTopic); + var fullResponse = new StringBuilder(SystemTurnCodes.OffTopic); + yield return new TokenChunk(SystemTurnCodes.OffTopic); if (attempt.IsSoftCapReached()) { - fullResponse.Append(ElaborationTexts.SoftCapNudge); - yield return new TokenChunk(ElaborationTexts.SoftCapNudge); + fullResponse.Append(SystemTurnCodes.SoftCapNudge); + yield return new TokenChunk(SystemTurnCodes.SoftCapNudge); } attempt.AddSystemTurn(fullResponse.ToString()); @@ -253,19 +253,18 @@ private async IAsyncEnumerable HandleClosingTurnAsync(Concept yield break; } - attempt.AddLearnerTurn(newMessage, intent, null); + attempt.AddLearnerTurn(newMessage, intent); if (attempt.CountNonSubstantiveClosingTurns() >= MaxNonSubstantiveClosingTurns) { - attempt.AddSystemTurn(ElaborationTexts.ExpiredNotice); - attempt.Expire(summary: null); - yield return new TokenChunk(ElaborationTexts.ExpiredNotice); + attempt.Expire(SystemTurnCodes.ExpiredNotice); + yield return new TokenChunk(SystemTurnCodes.ExpiredNotice); yield return CreateFinalChunk(attempt, intent); yield break; } - attempt.AddSystemTurn(ElaborationTexts.NonSubstantiveInClosingNudge); - yield return new TokenChunk(ElaborationTexts.NonSubstantiveInClosingNudge); + attempt.AddSystemTurn(SystemTurnCodes.NonSubstantiveInClosingNudge); + yield return new TokenChunk(SystemTurnCodes.NonSubstantiveInClosingNudge); yield return CreateFinalChunk(attempt, intent); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ElaborationTexts.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/SystemTurnCodes.cs similarity index 90% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ElaborationTexts.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/SystemTurnCodes.cs index 3762894fa..afccffeac 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/ElaborationTexts.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/SystemTurnCodes.cs @@ -1,6 +1,6 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; -public static class ElaborationTexts +public static class SystemTurnCodes { public const string SoftCapNudge = "SOFT_CAP"; public const string InClosingTransition = "CLOSING_TRANSITION"; From 1689acafba6740ff15ab53125d502d2286a3123d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Fri, 1 May 2026 17:37:19 +0300 Subject: [PATCH 36/51] refactor: Reduces 'agents' to 'llmRequests' and simplifies design accordingly. --- .../Orchestration/AgentOrchestrator.cs | 58 ++++------ .../UseCases/Learning/Prompts/AgentConfig.cs | 9 -- .../UseCases/Learning/Prompts/AgentConfigs.cs | 18 ---- .../UseCases/Learning/Prompts/AgentKind.cs | 13 --- .../Learning/Prompts/AgentTurnContext.cs | 9 -- .../Prompts/Agents/ClarificationPrompt.cs | 2 +- .../Learning/Prompts/Agents/CritiquePrompt.cs | 2 +- .../Learning/Prompts/Agents/IntentPrompt.cs | 2 +- .../Learning/Prompts/Agents/ProbePrompt.cs | 2 +- .../Prompts/Agents/ScaffoldingPrompt.cs | 2 +- ...gScorerPrompt.cs => ScoreClosingPrompt.cs} | 4 +- .../{ScorerResponse.cs => ScoreResponse.cs} | 2 +- ...TurnScorerPrompt.cs => ScoreTurnPrompt.cs} | 4 +- .../Learning/Prompts/Agents/SummaryPrompt.cs | 2 +- ...bricSection.cs => ConceptRubricSection.cs} | 4 +- .../Prompts/ConversationHistoryMapper.cs | 20 ---- .../Learning/Prompts/LlmRequestFactory.cs | 100 ++++++++++++++++++ .../Learning/Prompts/RuntimeContextBlock.cs | 49 --------- 18 files changed, 135 insertions(+), 167 deletions(-) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfig.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfigs.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentKind.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/{ClosingScorerPrompt.cs => ScoreClosingPrompt.cs} (97%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/{ScorerResponse.cs => ScoreResponse.cs} (98%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/{TurnScorerPrompt.cs => ScoreTurnPrompt.cs} (97%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/{ConceptRecordRubricSection.cs => ConceptRubricSection.cs} (93%) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConversationHistoryMapper.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs 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 index 13e7faeed..c077f71c9 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -94,15 +94,14 @@ private async IAsyncEnumerable HandleProgressTurnAsync(Concep private async IAsyncEnumerable HandleSubstantiveAsync(ConceptRecord record, ConversationAttempt attempt, TurnEvaluation evaluation, [EnumeratorCancellation] CancellationToken ct) { - AgentKind kind; - AgentTurnContext ctx; - ActiveProbe? probe; + CompletionRequest request; + string label; + ActiveProbe? probe = null; if (evaluation.HasMultipleConcerns) { - kind = AgentKind.Critique; - ctx = new AgentTurnContext(Evaluation: evaluation); - probe = null; + request = LlmRequestFactory.ForCritique(attempt.Turns, record, evaluation); + label = "Critique"; } else { @@ -115,13 +114,16 @@ private async IAsyncEnumerable HandleSubstantiveAsync(Concept yield break; } var level = attempt.GetProbeLevelFor(next); - kind = attempt.IsScaffolding(level) ? AgentKind.Scaffolding : AgentKind.Probe; - ctx = new AgentTurnContext(Target: next, Level: level); probe = new ActiveProbe(next, level); + var isScaffolding = attempt.IsScaffolding(level); + request = isScaffolding + ? LlmRequestFactory.ForScaffolding(attempt.Turns, record, probe) + : LlmRequestFactory.ForProbing(attempt.Turns, record, probe); + label = isScaffolding ? "Scaffolding" : "Probing"; } var fullResponse = new StringBuilder(); - await foreach (var chunk in StreamAgentAsync(kind, ctx, attempt.Turns, record, fullResponse, ct)) + await foreach (var chunk in StreamAgentAsync(request, label, fullResponse, ct)) { yield return chunk; if (chunk is ErrorChunk) yield break; @@ -161,9 +163,8 @@ private async IAsyncEnumerable HandleStuckAsync(ConceptRecord } var probe = new ActiveProbe(stuckTarget, ladderLevel); - var ctx = new AgentTurnContext(Target: stuckTarget, Level: ladderLevel); var fullResponse = new StringBuilder(); - await foreach (var chunk in StreamAgentAsync(AgentKind.Scaffolding, ctx, attempt.Turns, record, fullResponse, ct)) + await foreach (var chunk in StreamAgentAsync(LlmRequestFactory.ForScaffolding(attempt.Turns, record, probe), "Scaffolding", fullResponse, ct)) { yield return chunk; if (chunk is ErrorChunk) yield break; @@ -182,9 +183,8 @@ private async IAsyncEnumerable HandleStuckAsync(ConceptRecord private async IAsyncEnumerable HandleClarificationAsync(ConceptRecord record, ConversationAttempt attempt, [EnumeratorCancellation] CancellationToken ct) { - var ctx = new AgentTurnContext(Target: attempt.GetLastProbe()?.Target); var fullResponse = new StringBuilder(); - await foreach (var chunk in StreamAgentAsync(AgentKind.Clarification, ctx, attempt.Turns, record, fullResponse, ct)) + await foreach (var chunk in StreamAgentAsync(LlmRequestFactory.ForClarification(attempt.Turns, record, attempt.GetLastProbe()), "Clarification", fullResponse, ct)) { yield return chunk; if (chunk is ErrorChunk) yield break; @@ -204,7 +204,7 @@ private async IAsyncEnumerable HandleSummaryRequestAsync(Conc ConversationAttempt attempt, [EnumeratorCancellation] CancellationToken ct) { var fullResponse = new StringBuilder(); - await foreach (var chunk in StreamAgentAsync(AgentKind.Summary, new AgentTurnContext(), attempt.Turns, record, fullResponse, ct)) + await foreach (var chunk in StreamAgentAsync(LlmRequestFactory.ForSummary(attempt.Turns, record), "Summary", fullResponse, ct)) { yield return chunk; if (chunk is ErrorChunk) yield break; @@ -269,12 +269,11 @@ private async IAsyncEnumerable HandleClosingTurnAsync(Concept } private async IAsyncEnumerable StreamAgentAsync( - AgentKind kind, AgentTurnContext ctx, IReadOnlyList turns, - ConceptRecord record, StringBuilder output, [EnumeratorCancellation] CancellationToken ct) + CompletionRequest request, string label, + StringBuilder output, [EnumeratorCancellation] CancellationToken ct) { - var request = BuildRequest(kind, turns, record, ctx); StreamFailure? failure = null; - await foreach (var chunk in StreamAsync(request, kind.ToString(), ct)) + await foreach (var chunk in StreamAsync(request, label, ct)) { if (chunk is StreamFailure f) { failure = f; break; } var content = ((StreamToken)chunk).Content; @@ -288,22 +287,11 @@ private async IAsyncEnumerable StreamAgentAsync( private FinalChunk CreateFinalChunk(ConversationAttempt attempt, TurnIntent intent, string? summary = null) => new(attempt.Id, attempt.Status, intent, summary, _usageTracker.Total); - private static CompletionRequest BuildRequest(AgentKind kind, - IReadOnlyList history, ConceptRecord record, AgentTurnContext ctx) - { - var config = AgentConfigs.ByKind[kind]; - var messages = ConversationHistoryMapper.Map(history, config.HistoryWindow); - messages.Add(ChatMessage.FromUser(RuntimeContextBlock.Render(ctx))); - return CompletionRequest.Create(messages, config.BuildSystemPrompt(record), - maxTokens: config.MaxTokens, temperature: config.Temperature); - } - private async Task> ClassifyIntentAsync(ConceptRecord record, IReadOnlyList history, string newMessage, CancellationToken ct) { - var ctx = new AgentTurnContext(CurrentLearnerMessage: newMessage); var result = await CompleteJsonAsync( - BuildRequest(AgentKind.IntentClassifier, history, record, ctx), nameof(AgentKind.IntentClassifier), ct); + LlmRequestFactory.ForIntentClassification(history, record, newMessage), "IntentClassification", ct); if (result.IsFailed) return Result.Fail(result.Errors); return Enum.TryParse(result.Value.Intent, ignoreCase: true, out var intent) ? intent @@ -313,9 +301,8 @@ private async Task> ClassifyIntentAsync(ConceptRecord record, private async Task> ScoreTurnAsync(ConceptRecord record, IReadOnlyList history, string newMessage, CancellationToken ct) { - var ctx = new AgentTurnContext(CurrentLearnerMessage: newMessage); - var result = await CompleteJsonAsync( - BuildRequest(AgentKind.TurnScorer, history, record, ctx), nameof(AgentKind.TurnScorer), ct); + var result = await CompleteJsonAsync( + LlmRequestFactory.ForTurnScoring(history, record, newMessage), "TurnScoring", ct); if (result.IsFailed) return Result.Fail(result.Errors); return result.Value.ToEvaluation(record); } @@ -323,9 +310,8 @@ private async Task> ScoreTurnAsync(ConceptRecord record, private async Task> ScoreClosingAsync(ConceptRecord record, IReadOnlyList history, string newMessage, CancellationToken ct) { - var ctx = new AgentTurnContext(CurrentLearnerMessage: newMessage); - var result = await CompleteJsonAsync( - BuildRequest(AgentKind.ClosingScorer, history, record, ctx), nameof(AgentKind.ClosingScorer), ct); + var result = await CompleteJsonAsync( + LlmRequestFactory.ForClosingScoring(history, record, newMessage), "ClosingScoring", ct); if (result.IsFailed) return Result.Fail(result.Errors); return result.Value.ToEvaluation(record); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfig.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfig.cs deleted file mode 100644 index d8145d565..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfig.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Tutor.Elaborations.Core.Domain.ConceptRecords; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -public sealed record AgentConfig( - Func BuildSystemPrompt, - int MaxTokens, - double Temperature, - int? HistoryWindow = null); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfigs.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfigs.cs deleted file mode 100644 index c0b9ebf2a..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentConfigs.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -public static class AgentConfigs -{ - public static readonly IReadOnlyDictionary ByKind = new Dictionary - { - [AgentKind.IntentClassifier] = new(IntentPrompt.Build, MaxTokens: 64, Temperature: 0.0, HistoryWindow: 6), - [AgentKind.TurnScorer] = new(TurnScorerPrompt.Build, MaxTokens: 1024, Temperature: 0.0), - [AgentKind.ClosingScorer] = new(ClosingScorerPrompt.Build, MaxTokens: 1024, Temperature: 0.0), - [AgentKind.Probe] = new(ProbePrompt.Build, MaxTokens: 256, Temperature: 0.7), - [AgentKind.Scaffolding] = new(ScaffoldingPrompt.Build, MaxTokens: 512, Temperature: 0.7), - [AgentKind.Critique] = new(CritiquePrompt.Build, MaxTokens: 512, Temperature: 0.7), - [AgentKind.Clarification] = new(ClarificationPrompt.Build, MaxTokens: 256, Temperature: 0.5), - [AgentKind.Summary] = new(SummaryPrompt.Build, MaxTokens: 256, Temperature: 0.5), - }; -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentKind.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentKind.cs deleted file mode 100644 index 9ccf889ab..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentKind.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -public enum AgentKind -{ - IntentClassifier, - TurnScorer, - ClosingScorer, - Probe, - Scaffolding, - Critique, - Clarification, - Summary -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs deleted file mode 100644 index c13931503..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/AgentTurnContext.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -public sealed record AgentTurnContext( - string? Target = null, - int? Level = null, - TurnEvaluation? Evaluation = null, - string? CurrentLearnerMessage = null); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs index 98c74ac4c..2651eb00d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs @@ -8,7 +8,7 @@ public static class ClarificationPrompt public static string Build(ConceptRecord record) { var sb = new StringBuilder(); - sb.AppendLine(ConceptRecordRubricSection.Render(record)); + sb.AppendLine(ConceptRubricSection.Render(record)); sb.AppendLine("# Role"); sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs index c17855ca1..4edf8490f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs @@ -8,7 +8,7 @@ public static class CritiquePrompt public static string Build(ConceptRecord record) { var sb = new StringBuilder(); - sb.AppendLine(ConceptRecordRubricSection.Render(record)); + sb.AppendLine(ConceptRubricSection.Render(record)); sb.AppendLine("# Role"); sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs index 0e29d9102..3920771e1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs @@ -8,7 +8,7 @@ public static class IntentPrompt public static string Build(ConceptRecord record) { var sb = new StringBuilder(); - sb.AppendLine(ConceptRecordRubricSection.Render(record)); + sb.AppendLine(ConceptRubricSection.Render(record)); sb.AppendLine("# Role"); sb.AppendLine("You are an intent classifier for a Socratic tutoring conversation."); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs index 51c5a4e43..8f034a6d0 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs @@ -8,7 +8,7 @@ public static class ProbePrompt public static string Build(ConceptRecord record) { var sb = new StringBuilder(); - sb.AppendLine(ConceptRecordRubricSection.Render(record)); + sb.AppendLine(ConceptRubricSection.Render(record)); sb.AppendLine("# Role"); sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs index 0f61e3fa5..b7fd06dc1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs @@ -8,7 +8,7 @@ public static class ScaffoldingPrompt public static string Build(ConceptRecord record) { var sb = new StringBuilder(); - sb.AppendLine(ConceptRecordRubricSection.Render(record)); + sb.AppendLine(ConceptRubricSection.Render(record)); sb.AppendLine("# Role"); sb.AppendLine("You are a Socratic tutoring scaffolding agent. Speak Serbian."); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingScorerPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreClosingPrompt.cs similarity index 97% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingScorerPrompt.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreClosingPrompt.cs index a86755d35..4884536ec 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClosingScorerPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreClosingPrompt.cs @@ -3,7 +3,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; -public static class ClosingScorerPrompt +public static class ScoreClosingPrompt { public static string Build(ConceptRecord record) { @@ -11,7 +11,7 @@ public static string Build(ConceptRecord record) var hasKeyRelations = record.KeyRelations.Count != 0; var sb = new StringBuilder(); - sb.AppendLine(ConceptRecordRubricSection.Render(record)); + sb.AppendLine(ConceptRubricSection.Render(record)); sb.AppendLine("# Role"); sb.AppendLine("You are a summative scoring agent for a Socratic tutoring system."); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreResponse.cs similarity index 98% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreResponse.cs index 38e519a12..123b1de24 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScorerResponse.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreResponse.cs @@ -4,7 +4,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; -public class ScorerResponse +public class ScoreResponse { public int CorrectnessScore { get; set; } public int CompletenessScore { get; set; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/TurnScorerPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreTurnPrompt.cs similarity index 97% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/TurnScorerPrompt.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreTurnPrompt.cs index c2ed30b44..479d05a20 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/TurnScorerPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreTurnPrompt.cs @@ -3,7 +3,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; -public static class TurnScorerPrompt +public static class ScoreTurnPrompt { public static string Build(ConceptRecord record) { @@ -11,7 +11,7 @@ public static string Build(ConceptRecord record) var hasKeyRelations = record.KeyRelations.Count != 0; var sb = new StringBuilder(); - sb.AppendLine(ConceptRecordRubricSection.Render(record)); + sb.AppendLine(ConceptRubricSection.Render(record)); sb.AppendLine("# Role"); sb.AppendLine("You are a scoring agent for a Socratic tutoring system."); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs index 36e5f8f2c..f20565c16 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs @@ -8,7 +8,7 @@ public static class SummaryPrompt public static string Build(ConceptRecord record) { var sb = new StringBuilder(); - sb.AppendLine(ConceptRecordRubricSection.Render(record)); + sb.AppendLine(ConceptRubricSection.Render(record)); sb.AppendLine("# Role"); sb.AppendLine("You are a progress-summary agent. Speak Serbian."); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRecordRubricSection.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRubricSection.cs similarity index 93% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRecordRubricSection.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRubricSection.cs index 4b69831ac..a05eea002 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRecordRubricSection.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRubricSection.cs @@ -4,12 +4,12 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; /// -/// Renders the concept rubric (definition, KPs, BCs, CMs, KRs) as a markdown block. +/// Renders the concept rubric (definition, 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 ConceptRecordRubricSection +public static class ConceptRubricSection { public static string Render(ConceptRecord record) { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConversationHistoryMapper.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConversationHistoryMapper.cs deleted file mode 100644 index 0531bcfce..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConversationHistoryMapper.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Tutor.BuildingBlocks.AI.Core.Conversations; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -/// -/// Maps domain s to native-role s -/// so the provider can cache against alternating user/assistant turns instead of a flattened transcript. -/// -public static class ConversationHistoryMapper -{ - public static List Map(IEnumerable turns, int? lastN = null) - { - var ordered = turns.OrderBy(t => t.Order); - var window = lastN is { } n ? ordered.TakeLast(n) : ordered; - return window.Select(t => t.Role == TurnRole.Learner - ? ChatMessage.FromUser(t.Content) - : ChatMessage.FromAssistant(t.Content)).ToList(); - } -} 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..916077855 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs @@ -0,0 +1,100 @@ +using System.Text; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +public static class LlmRequestFactory +{ + public static CompletionRequest ForProbing(IReadOnlyList turns, ConceptRecord record, ActiveProbe probe) + { + var messages = ToMessages(turns); + messages.Add(ChatMessage.FromUser(RenderProbe(probe))); + return CompletionRequest.Create(messages, ProbePrompt.Build(record), maxTokens: 256, temperature: 0.7); + } + + public static CompletionRequest ForScaffolding(IReadOnlyList turns, ConceptRecord record, ActiveProbe probe) + { + var messages = ToMessages(turns); + messages.Add(ChatMessage.FromUser(RenderProbe(probe))); + return CompletionRequest.Create(messages, ScaffoldingPrompt.Build(record), maxTokens: 512, temperature: 0.7); + } + + public static CompletionRequest ForClarification(IReadOnlyList turns, ConceptRecord record, ActiveProbe? lastProbe) + { + var messages = ToMessages(turns); + if (lastProbe != null) + messages.Add(ChatMessage.FromUser(RenderProbe(lastProbe))); + return CompletionRequest.Create(messages, ClarificationPrompt.Build(record), maxTokens: 256, temperature: 0.5); + } + + public static CompletionRequest ForCritique(IReadOnlyList turns, ConceptRecord record, TurnEvaluation evaluation) + { + var messages = ToMessages(turns); + messages.Add(ChatMessage.FromUser(RenderEvaluation(evaluation))); + return CompletionRequest.Create(messages, CritiquePrompt.Build(record), maxTokens: 512, temperature: 0.7); + } + + public static CompletionRequest ForSummary(IReadOnlyList turns, ConceptRecord record) + { + return CompletionRequest.Create(ToMessages(turns), SummaryPrompt.Build(record), maxTokens: 256, temperature: 0.5); + } + + public static CompletionRequest ForIntentClassification(IReadOnlyList turns, ConceptRecord record, string message) + { + var messages = ToMessages(turns, 6); + messages.Add(ChatMessage.FromUser($"{message}")); + return CompletionRequest.Create(messages, IntentPrompt.Build(record), maxTokens: 64, temperature: 0.0); + } + + public static CompletionRequest ForTurnScoring(IReadOnlyList turns, ConceptRecord record, string message) + { + var messages = ToMessages(turns); + messages.Add(ChatMessage.FromUser($"{message}")); + return CompletionRequest.Create(messages, ScoreTurnPrompt.Build(record), maxTokens: 1024, temperature: 0.0); + } + + public static CompletionRequest ForClosingScoring(IReadOnlyList turns, ConceptRecord record, string message) + { + var messages = ToMessages(turns); + messages.Add(ChatMessage.FromUser($"{message}")); + return CompletionRequest.Create(messages, ScoreClosingPrompt.Build(record), maxTokens: 1024, temperature: 0.0); + } + + private static List ToMessages(IEnumerable turns, int? lastN = null) + { + var ordered = turns.OrderBy(t => t.Order); + var window = lastN is { } n ? ordered.TakeLast(n) : ordered; + return window.Select(t => t.Role == TurnRole.Learner + ? ChatMessage.FromUser(t.Content) + : ChatMessage.FromAssistant(t.Content)).ToList(); + } + + private static string RenderProbe(ActiveProbe probe) + { + return $"{probe.Target}"; + } + + private static string RenderEvaluation(TurnEvaluation e) + { + var sb = new StringBuilder(); + var attrs = new List + { + $"correctness=\"{e.CorrectnessScore}\"", + $"completeness=\"{e.CompletenessScore}\"" + }; + if (e.IntegrationScore.HasValue) attrs.Add($"integration=\"{e.IntegrationScore.Value}\""); + attrs.Add($"hasMultipleConcerns=\"{e.HasMultipleConcerns.ToString().ToLowerInvariant()}\""); + + sb.Append($""); + sb.Append($"{e.Justification}"); + if (e.MisconceptionsTriggeredKeys.Count > 0) + sb.Append($"{string.Join(", ", e.MisconceptionsTriggeredKeys)}"); + if (!string.IsNullOrWhiteSpace(e.NovelMisconceptions)) + sb.Append($"{e.NovelMisconceptions}"); + sb.Append(""); + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs deleted file mode 100644 index 246fd1bf6..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/RuntimeContextBlock.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -public static class RuntimeContextBlock -{ - public static string Render(AgentTurnContext ctx) - { - var sb = new StringBuilder(); - - if (ctx.Target is { } t) - { - if (ctx.Level is { } lvl) - sb.AppendLine($"{t}"); - else - sb.AppendLine($"{t}"); - } - - if (ctx.Evaluation is { } e) - sb.AppendLine(RenderEvaluation(e)); - - if (!string.IsNullOrWhiteSpace(ctx.CurrentLearnerMessage)) - sb.Append($"{ctx.CurrentLearnerMessage}"); - - return sb.ToString(); - } - - private static string RenderEvaluation(TurnEvaluation e) - { - var sb = new StringBuilder(); - var attrs = new List - { - $"correctness=\"{e.CorrectnessScore}\"", - $"completeness=\"{e.CompletenessScore}\"" - }; - if (e.IntegrationScore.HasValue) attrs.Add($"integration=\"{e.IntegrationScore.Value}\""); - attrs.Add($"hasMultipleConcerns=\"{e.HasMultipleConcerns.ToString().ToLowerInvariant()}\""); - - sb.Append($""); - sb.Append($"{e.Justification}"); - if (e.MisconceptionsTriggeredKeys.Count > 0) - sb.Append($"{string.Join(", ", e.MisconceptionsTriggeredKeys)}"); - if (!string.IsNullOrWhiteSpace(e.NovelMisconceptions)) - sb.Append($"{e.NovelMisconceptions}"); - sb.Append(""); - return sb.ToString(); - } -} From ee041f2ed1149bc5651c0685ee28a8ad3dc9737c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Fri, 1 May 2026 19:20:10 +0300 Subject: [PATCH 37/51] refactor: Improves class organization for better code discovery. --- .../ConceptElaborationTaskDto.cs | 1 + .../CommonMisconception.cs | 2 +- .../ConceptElaborationTask.cs | 6 +- .../ConceptRecord.cs | 2 +- .../KeyProposition.cs | 2 +- .../KeyRelation.cs | 2 +- .../Conversations/ConversationAttempt.cs | 15 +++- .../Mappers/ConceptElaborationTaskProfile.cs | 1 - .../UseCases/Learning/ConversationService.cs | 50 ++++-------- .../Orchestration/AgentOrchestrator.cs | 80 +++++++++---------- .../Orchestration/IAgentOrchestrator.cs | 2 +- .../Orchestration/OrchestratorChunk.cs | 1 - .../Prompts/Agents/ClarificationPrompt.cs | 2 +- .../Learning/Prompts/Agents/CritiquePrompt.cs | 2 +- .../Learning/Prompts/Agents/IntentPrompt.cs | 2 +- .../Learning/Prompts/Agents/ProbePrompt.cs | 2 +- .../Prompts/Agents/ScaffoldingPrompt.cs | 2 +- .../Prompts/Agents/ScoreClosingPrompt.cs | 2 +- .../Learning/Prompts/Agents/ScoreResponse.cs | 2 +- .../Prompts/Agents/ScoreTurnPrompt.cs | 2 +- .../Learning/Prompts/Agents/SummaryPrompt.cs | 2 +- .../Learning/Prompts/ConceptRubricSection.cs | 2 +- .../Learning/Prompts/LlmRequestFactory.cs | 25 +++--- .../Database/ElaborationsContext.cs | 1 - .../ConceptElaborationTaskCommandTests.cs | 7 ++ .../TestData/c-concept-elaboration-tasks.sql | 28 +++---- .../Unit/ConceptRecordTests.cs | 2 +- 27 files changed, 124 insertions(+), 123 deletions(-) rename src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/{ConceptRecords => ConceptElaborationTasks}/CommonMisconception.cs (91%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/{ConceptRecords => ConceptElaborationTasks}/ConceptRecord.cs (97%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/{ConceptRecords => ConceptElaborationTasks}/KeyProposition.cs (89%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/{ConceptRecords => ConceptElaborationTasks}/KeyRelation.cs (92%) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs index 5d99ee7dd..976d92fb8 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs @@ -8,6 +8,7 @@ public class ConceptElaborationTaskDto 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; } = new(); public List? Attempts { get; set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/CommonMisconception.cs similarity index 91% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/CommonMisconception.cs index 74a9190ea..442be8e77 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/CommonMisconception.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/CommonMisconception.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using Tutor.BuildingBlocks.Core.Domain; -namespace Tutor.Elaborations.Core.Domain.ConceptRecords; +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; public class CommonMisconception : ValueObject { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs index d9f8adfad..d0979344a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs @@ -1,5 +1,4 @@ using Tutor.BuildingBlocks.Core.Domain; -using Tutor.Elaborations.Core.Domain.ConceptRecords; namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; @@ -8,15 +7,17 @@ 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, ConceptRecord conceptRecord) + public ConceptElaborationTask(int unitId, int order, string title, string description, ConceptRecord conceptRecord) { UnitId = unitId; Order = order; Title = title; + Description = description; ConceptRecord = conceptRecord; } @@ -25,6 +26,7 @@ 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/ConceptRecords/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs similarity index 97% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs index f06b2abfa..06e162f97 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/ConceptRecord.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs @@ -1,7 +1,7 @@ using Tutor.BuildingBlocks.Core.Domain; using Tutor.Elaborations.Core.Domain.Conversations; -namespace Tutor.Elaborations.Core.Domain.ConceptRecords; +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; public class ConceptRecord : Entity { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyProposition.cs similarity index 89% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyProposition.cs index f0a7f4bf3..a5ef0ccb9 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyProposition.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyProposition.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using Tutor.BuildingBlocks.Core.Domain; -namespace Tutor.Elaborations.Core.Domain.ConceptRecords; +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; public class KeyProposition : ValueObject { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyRelation.cs similarity index 92% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyRelation.cs index 55cb30072..eab60616d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptRecords/KeyRelation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyRelation.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using Tutor.BuildingBlocks.Core.Domain; -namespace Tutor.Elaborations.Core.Domain.ConceptRecords; +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; public class KeyRelation : ValueObject { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index 987ed456c..135704eae 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -18,7 +18,7 @@ public class ConversationAttempt : AggregateRoot public IReadOnlyList Turns => _turns.AsReadOnly(); public int? SoftCapTotalTurns { get; private set; } public int? HardCapTotalTurns { get; private set; } - public int? ClosingTurnCount { get; private set; } + public int? TurnCountAtClosingStart { get; private set; } private ConversationAttempt() { } @@ -88,11 +88,18 @@ public IReadOnlySet GetStalledTargets() .ToHashSet(); } + public ActiveProbe? GetNextProbe() + { + var last = GetLastProbe(); + if (last == null || GetStalledTargets().Contains(last.Target)) return null; + return new ActiveProbe(last.Target, GetProbeLevelFor(last.Target)); + } + public int CountNonSubstantiveClosingTurns() { - if (ClosingTurnCount == null) return 0; + if (TurnCountAtClosingStart == null) return 0; return Turns - .Skip(ClosingTurnCount.Value) + .Skip(TurnCountAtClosingStart.Value) .Count(t => t.Role == TurnRole.Learner && t.Intent != TurnIntent.Substantive); } @@ -115,7 +122,7 @@ public void TransitionToClosing(string closingMessage) { AddSystemTurn(closingMessage); Status = AttemptStatus.InClosing; - ClosingTurnCount = _turns.Count; + TurnCountAtClosingStart = _turns.Count; } public void Complete(TurnEvaluation evaluation) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs index 98480c6d0..ae22fde73 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs @@ -1,7 +1,6 @@ using AutoMapper; using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.ConceptRecords; namespace Tutor.Elaborations.Core.Mappers; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index 680a4d611..380cddbe1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -77,22 +77,8 @@ public Result GetTaskWithAttempts(int taskId, int lea public async IAsyncEnumerable StartConversationAsync(int taskId, string content, int learnerId, [EnumeratorCancellation] CancellationToken ct) { - var task = _taskRepo.GetWithRecord(taskId); - if (task == null) { yield return BuildErrorChunk("Task not found.", 404); yield break; } - - if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) - { - yield return BuildErrorChunk("Not enrolled in unit.", 403); - yield break; - } - - var balanceCheck = _tokenSpendingService.HasSufficientBalanceForUnit( - learnerId, task.UnitId, content.Length); - if (balanceCheck.IsFailed) - { - yield return BuildErrorChunk("Insufficient token balance. Contact your administrator.", 402); - yield break; - } + var (task, taskError) = ValidateTaskAccess(taskId, learnerId, content); + if (taskError != null) { yield return taskError; yield break; } var existing = _attemptRepo.GetActiveAttempt(taskId, learnerId); if (existing != null) @@ -124,24 +110,10 @@ public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string cont if (attempt.LearnerId != learnerId) { yield return BuildErrorChunk("Access denied.", 403); yield break; } if (attempt.Status is not (AttemptStatus.InProgress or AttemptStatus.InClosing)) { yield return BuildErrorChunk("Conversation is no longer active.", 409); yield break; } - var task = _taskRepo.GetWithRecord(attempt.ConceptElaborationTaskId); - if (task == null) { yield return BuildErrorChunk("Task not found.", 404); yield break; } - - if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) - { - yield return BuildErrorChunk("Not enrolled in unit.", 403); - yield break; - } - - var balanceCheck = _tokenSpendingService.HasSufficientBalanceForUnit( - learnerId, task.UnitId, content.Length); - if (balanceCheck.IsFailed) - { - yield return BuildErrorChunk("Insufficient token balance. Contact your administrator.", 402); - yield break; - } + var (task, taskError) = ValidateTaskAccess(attempt.ConceptElaborationTaskId, learnerId, content); + if (taskError != null) { yield return taskError; yield break; } - await foreach (var token in RunTurnPipelineAsync(attempt, task, content, ct)) + await foreach (var token in RunTurnPipelineAsync(attempt, task!, content, ct)) yield return token; } @@ -200,6 +172,18 @@ private async IAsyncEnumerable RunTurnPipelineAsync(ConversationAttempt } } + private (ConceptElaborationTask? Task, string? Error) ValidateTaskAccess(int taskId, int learnerId, string content) + { + 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, content.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) 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 index c077f71c9..14e3d4381 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -4,7 +4,7 @@ using System.Text; using Tutor.BuildingBlocks.AI.Core.Agents; using Tutor.BuildingBlocks.AI.Core.Conversations; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.UseCases.Learning.Prompts; using Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; @@ -75,7 +75,7 @@ private async IAsyncEnumerable HandleProgressTurnAsync(Concep { attempt.TransitionToClosing(SystemTurnCodes.InClosingTransition); yield return new TokenChunk(SystemTurnCodes.InClosingTransition); - yield return CreateFinalChunk(attempt, intent); + yield return CreateFinalChunk(attempt); yield break; } @@ -100,7 +100,7 @@ private async IAsyncEnumerable HandleSubstantiveAsync(Concept if (evaluation.HasMultipleConcerns) { - request = LlmRequestFactory.ForCritique(attempt.Turns, record, evaluation); + request = LlmRequestFactory.ForCritique(record, attempt.Turns, evaluation); label = "Critique"; } else @@ -110,15 +110,15 @@ private async IAsyncEnumerable HandleSubstantiveAsync(Concept { attempt.TransitionToClosing(SystemTurnCodes.InClosingTransition); yield return new TokenChunk(SystemTurnCodes.InClosingTransition); - yield return CreateFinalChunk(attempt, TurnIntent.Substantive); + yield return CreateFinalChunk(attempt); yield break; } var level = attempt.GetProbeLevelFor(next); probe = new ActiveProbe(next, level); var isScaffolding = attempt.IsScaffolding(level); request = isScaffolding - ? LlmRequestFactory.ForScaffolding(attempt.Turns, record, probe) - : LlmRequestFactory.ForProbing(attempt.Turns, record, probe); + ? LlmRequestFactory.ForScaffolding(record, attempt.Turns, probe) + : LlmRequestFactory.ForProbing(record, attempt.Turns, probe); label = isScaffolding ? "Scaffolding" : "Probing"; } @@ -136,35 +136,24 @@ private async IAsyncEnumerable HandleSubstantiveAsync(Concept } attempt.AddSystemTurn(fullResponse.ToString(), probe); - yield return CreateFinalChunk(attempt, TurnIntent.Substantive); + yield return CreateFinalChunk(attempt); } private async IAsyncEnumerable HandleStuckAsync(ConceptRecord record, ConversationAttempt attempt, [EnumeratorCancellation] CancellationToken ct) { - string? stuckTarget = attempt.GetLastProbe()?.Target; - int ladderLevel; + var probe = GetNextProbe(record, attempt); - if (stuckTarget == null || attempt.GetStalledTargets().Contains(stuckTarget)) + if (probe == null) { - stuckTarget = record.PickNextTarget(attempt); - if (stuckTarget == null) - { - attempt.TransitionToClosing(SystemTurnCodes.InClosingTransition); - yield return new TokenChunk(SystemTurnCodes.InClosingTransition); - yield return CreateFinalChunk(attempt, TurnIntent.Stuck); - yield break; - } - ladderLevel = attempt.FirstScaffoldLadderLevel; - } - else - { - ladderLevel = attempt.GetProbeLevelFor(stuckTarget); + attempt.TransitionToClosing(SystemTurnCodes.InClosingTransition); + yield return new TokenChunk(SystemTurnCodes.InClosingTransition); + yield return CreateFinalChunk(attempt); + yield break; } - var probe = new ActiveProbe(stuckTarget, ladderLevel); var fullResponse = new StringBuilder(); - await foreach (var chunk in StreamAgentAsync(LlmRequestFactory.ForScaffolding(attempt.Turns, record, probe), "Scaffolding", fullResponse, ct)) + await foreach (var chunk in StreamAgentAsync(LlmRequestFactory.ForScaffolding(record, attempt.Turns, probe), "Scaffolding", fullResponse, ct)) { yield return chunk; if (chunk is ErrorChunk) yield break; @@ -177,14 +166,22 @@ private async IAsyncEnumerable HandleStuckAsync(ConceptRecord } attempt.AddSystemTurn(fullResponse.ToString(), probe); - yield return CreateFinalChunk(attempt, TurnIntent.Stuck); + yield return CreateFinalChunk(attempt); + } + + private static ActiveProbe? GetNextProbe(ConceptRecord record, ConversationAttempt attempt) + { + var nextProbe = attempt.GetNextProbe(); + if (nextProbe != null) return nextProbe; + var nextTarget = record.PickNextTarget(attempt); + return nextTarget == null ? null : new ActiveProbe(nextTarget, attempt.FirstScaffoldLadderLevel); } private async IAsyncEnumerable HandleClarificationAsync(ConceptRecord record, ConversationAttempt attempt, [EnumeratorCancellation] CancellationToken ct) { var fullResponse = new StringBuilder(); - await foreach (var chunk in StreamAgentAsync(LlmRequestFactory.ForClarification(attempt.Turns, record, attempt.GetLastProbe()), "Clarification", fullResponse, ct)) + await foreach (var chunk in StreamAgentAsync(LlmRequestFactory.ForClarification(record, attempt.Turns, attempt.GetLastProbe()), "Clarification", fullResponse, ct)) { yield return chunk; if (chunk is ErrorChunk) yield break; @@ -197,14 +194,14 @@ private async IAsyncEnumerable HandleClarificationAsync(Conce } attempt.AddSystemTurn(fullResponse.ToString()); - yield return CreateFinalChunk(attempt, TurnIntent.Clarification); + yield return CreateFinalChunk(attempt); } private async IAsyncEnumerable HandleSummaryRequestAsync(ConceptRecord record, ConversationAttempt attempt, [EnumeratorCancellation] CancellationToken ct) { var fullResponse = new StringBuilder(); - await foreach (var chunk in StreamAgentAsync(LlmRequestFactory.ForSummary(attempt.Turns, record), "Summary", fullResponse, ct)) + await foreach (var chunk in StreamAgentAsync(LlmRequestFactory.ForSummary(record, attempt.Turns), "Summary", fullResponse, ct)) { yield return chunk; if (chunk is ErrorChunk) yield break; @@ -217,7 +214,7 @@ private async IAsyncEnumerable HandleSummaryRequestAsync(Conc } attempt.AddSystemTurn(fullResponse.ToString()); - yield return CreateFinalChunk(attempt, TurnIntent.SummaryRequest); + yield return CreateFinalChunk(attempt); } private async IAsyncEnumerable HandleOffTopicAsync(ConversationAttempt attempt) @@ -232,7 +229,7 @@ private async IAsyncEnumerable HandleOffTopicAsync(Conversati } attempt.AddSystemTurn(fullResponse.ToString()); - yield return CreateFinalChunk(attempt, TurnIntent.OffTopic); + yield return CreateFinalChunk(attempt); } private async IAsyncEnumerable HandleClosingTurnAsync(ConceptRecord record, @@ -249,7 +246,7 @@ private async IAsyncEnumerable HandleClosingTurnAsync(Concept attempt.AddLearnerTurn(newMessage, intent, scoreResult.Value); attempt.Complete(scoreResult.Value); yield return new TokenChunk(attempt.Summary!); - yield return CreateFinalChunk(attempt, intent, attempt.Summary); + yield return CreateFinalChunk(attempt, attempt.Summary); yield break; } @@ -259,18 +256,17 @@ private async IAsyncEnumerable HandleClosingTurnAsync(Concept { attempt.Expire(SystemTurnCodes.ExpiredNotice); yield return new TokenChunk(SystemTurnCodes.ExpiredNotice); - yield return CreateFinalChunk(attempt, intent); + yield return CreateFinalChunk(attempt); yield break; } attempt.AddSystemTurn(SystemTurnCodes.NonSubstantiveInClosingNudge); yield return new TokenChunk(SystemTurnCodes.NonSubstantiveInClosingNudge); - yield return CreateFinalChunk(attempt, intent); + yield return CreateFinalChunk(attempt); } - private async IAsyncEnumerable StreamAgentAsync( - CompletionRequest request, string label, - StringBuilder output, [EnumeratorCancellation] CancellationToken ct) + 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)) @@ -284,14 +280,14 @@ private async IAsyncEnumerable StreamAgentAsync( yield return new ErrorChunk(failure.Reason, 500); } - private FinalChunk CreateFinalChunk(ConversationAttempt attempt, TurnIntent intent, string? summary = null) - => new(attempt.Id, attempt.Status, intent, summary, _usageTracker.Total); + private FinalChunk CreateFinalChunk(ConversationAttempt attempt, string? summary = null) + => new(attempt.Id, attempt.Status, summary, _usageTracker.Total); private async Task> ClassifyIntentAsync(ConceptRecord record, IReadOnlyList history, string newMessage, CancellationToken ct) { var result = await CompleteJsonAsync( - LlmRequestFactory.ForIntentClassification(history, record, newMessage), "IntentClassification", ct); + LlmRequestFactory.ForIntentClassification(record, history, newMessage), "IntentClassification", ct); if (result.IsFailed) return Result.Fail(result.Errors); return Enum.TryParse(result.Value.Intent, ignoreCase: true, out var intent) ? intent @@ -302,7 +298,7 @@ private async Task> ScoreTurnAsync(ConceptRecord record, IReadOnlyList history, string newMessage, CancellationToken ct) { var result = await CompleteJsonAsync( - LlmRequestFactory.ForTurnScoring(history, record, newMessage), "TurnScoring", ct); + LlmRequestFactory.ForTurnScoring(record, history, newMessage), "TurnScoring", ct); if (result.IsFailed) return Result.Fail(result.Errors); return result.Value.ToEvaluation(record); } @@ -311,7 +307,7 @@ private async Task> ScoreClosingAsync(ConceptRecord recor IReadOnlyList history, string newMessage, CancellationToken ct) { var result = await CompleteJsonAsync( - LlmRequestFactory.ForClosingScoring(history, record, newMessage), "ClosingScoring", ct); + LlmRequestFactory.ForClosingScoring(record, history, newMessage), "ClosingScoring", ct); if (result.IsFailed) return Result.Fail(result.Errors); return result.Value.ToEvaluation(record); } 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 index 5a5fc5bad..02c376639 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestrator.cs @@ -1,4 +1,4 @@ -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; 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 index acf0c13b4..c8b23b0de 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs @@ -10,7 +10,6 @@ public sealed record TokenChunk(string Token) : OrchestratorChunk; public sealed record FinalChunk( int AttemptId, AttemptStatus Status, - TurnIntent Intent, string? Summary, TokenUsage Usage) : OrchestratorChunk; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs index 2651eb00d..694c9f934 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs @@ -1,5 +1,5 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs index 4edf8490f..0217ef323 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs @@ -1,5 +1,5 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs index 3920771e1..f55c3fb77 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs @@ -1,5 +1,5 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs index 8f034a6d0..a36684fd1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs @@ -1,5 +1,5 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs index b7fd06dc1..fb0c118d1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs @@ -1,5 +1,5 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreClosingPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreClosingPrompt.cs index 4884536ec..39368f75f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreClosingPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreClosingPrompt.cs @@ -1,5 +1,5 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreResponse.cs index 123b1de24..68753ced3 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreResponse.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreResponse.cs @@ -1,5 +1,5 @@ using FluentResults; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreTurnPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreTurnPrompt.cs index 479d05a20..f7e556284 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreTurnPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreTurnPrompt.cs @@ -1,5 +1,5 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs index f20565c16..4ce2e4089 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs @@ -1,5 +1,5 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; 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 index a05eea002..c7e04c901 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRubricSection.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRubricSection.cs @@ -1,5 +1,5 @@ using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; 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 index 916077855..7b4bb4752 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs @@ -1,6 +1,6 @@ using System.Text; using Tutor.BuildingBlocks.AI.Core.Conversations; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; @@ -8,21 +8,24 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public static class LlmRequestFactory { - public static CompletionRequest ForProbing(IReadOnlyList turns, ConceptRecord record, ActiveProbe probe) + public static CompletionRequest ForProbing(ConceptRecord record, IReadOnlyList turns, + ActiveProbe probe) { var messages = ToMessages(turns); messages.Add(ChatMessage.FromUser(RenderProbe(probe))); return CompletionRequest.Create(messages, ProbePrompt.Build(record), maxTokens: 256, temperature: 0.7); } - public static CompletionRequest ForScaffolding(IReadOnlyList turns, ConceptRecord record, ActiveProbe probe) + public static CompletionRequest ForScaffolding(ConceptRecord record, IReadOnlyList turns, + ActiveProbe probe) { var messages = ToMessages(turns); messages.Add(ChatMessage.FromUser(RenderProbe(probe))); return CompletionRequest.Create(messages, ScaffoldingPrompt.Build(record), maxTokens: 512, temperature: 0.7); } - public static CompletionRequest ForClarification(IReadOnlyList turns, ConceptRecord record, ActiveProbe? lastProbe) + public static CompletionRequest ForClarification(ConceptRecord record, IReadOnlyList turns, + ActiveProbe? lastProbe) { var messages = ToMessages(turns); if (lastProbe != null) @@ -30,33 +33,37 @@ public static CompletionRequest ForClarification(IReadOnlyList return CompletionRequest.Create(messages, ClarificationPrompt.Build(record), maxTokens: 256, temperature: 0.5); } - public static CompletionRequest ForCritique(IReadOnlyList turns, ConceptRecord record, TurnEvaluation evaluation) + public static CompletionRequest ForCritique(ConceptRecord record, IReadOnlyList turns, + TurnEvaluation evaluation) { var messages = ToMessages(turns); messages.Add(ChatMessage.FromUser(RenderEvaluation(evaluation))); return CompletionRequest.Create(messages, CritiquePrompt.Build(record), maxTokens: 512, temperature: 0.7); } - public static CompletionRequest ForSummary(IReadOnlyList turns, ConceptRecord record) + public static CompletionRequest ForSummary(ConceptRecord record, IReadOnlyList turns) { return CompletionRequest.Create(ToMessages(turns), SummaryPrompt.Build(record), maxTokens: 256, temperature: 0.5); } - public static CompletionRequest ForIntentClassification(IReadOnlyList turns, ConceptRecord record, string message) + public static CompletionRequest ForIntentClassification(ConceptRecord record, IReadOnlyList turns, + string message) { var messages = ToMessages(turns, 6); messages.Add(ChatMessage.FromUser($"{message}")); return CompletionRequest.Create(messages, IntentPrompt.Build(record), maxTokens: 64, temperature: 0.0); } - public static CompletionRequest ForTurnScoring(IReadOnlyList turns, ConceptRecord record, string message) + public static CompletionRequest ForTurnScoring(ConceptRecord record, IReadOnlyList turns, + string message) { var messages = ToMessages(turns); messages.Add(ChatMessage.FromUser($"{message}")); return CompletionRequest.Create(messages, ScoreTurnPrompt.Build(record), maxTokens: 1024, temperature: 0.0); } - public static CompletionRequest ForClosingScoring(IReadOnlyList turns, ConceptRecord record, string message) + public static CompletionRequest ForClosingScoring(ConceptRecord record, IReadOnlyList turns, + string message) { var messages = ToMessages(turns); messages.Add(ChatMessage.FromUser($"{message}")); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs index 855fc1595..13324b6b4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.ConceptRecords; using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Infrastructure.Database; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs index 312ac7d6a..8516c7f1f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs @@ -24,6 +24,7 @@ public void Creates() UnitId = -1, Order = 10, Title = "New Concept", + Description = "A new concept for testing.", ConceptRecord = new ConceptRecordDto { CanonicalDefinition = "A new concept definition.", @@ -65,6 +66,7 @@ public void Creates_with_relations() 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.", @@ -111,6 +113,7 @@ public void Updates() UnitId = -1, Order = 1, Title = "Updated Encapsulation", + Description = "Updated description.", ConceptRecord = new ConceptRecordDto { CanonicalDefinition = "Updated definition.", @@ -149,6 +152,7 @@ public void Updates_relations_with_natural_keys() 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.", @@ -201,6 +205,7 @@ public void Removes_relation_and_referenced_kp() 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.", @@ -266,6 +271,7 @@ public void Non_owner_fails_to_create() UnitId = -3, Order = 99, Title = "Should Fail", + Description = "Should not be created.", ConceptRecord = new ConceptRecordDto { CanonicalDefinition = "Fail", @@ -294,6 +300,7 @@ public void Non_owner_fails_to_update() UnitId = -3, Order = 1, Title = "Should Fail", + Description = "Should not be updated.", ConceptRecord = new ConceptRecordDto { CanonicalDefinition = "Fail", 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 index 3551da179..a2d131c47 100644 --- 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 @@ -2,8 +2,8 @@ -- 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") -VALUES (-1, -1, 1, 'Encapsulation (Basics)'); +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", @@ -15,8 +15,8 @@ VALUES (-1, -1, '[]'::jsonb); -- CET -2: Encapsulation (Members), Unit -1, Order 2 -INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title") -VALUES (-2, -1, 2, 'Encapsulation (Members)'); +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", @@ -28,8 +28,8 @@ VALUES (-2, -2, '[]'::jsonb); -- CET -3: Encapsulation (Basics — Unit 2), Unit -2, Order 1 (owned by Instructor -51) -INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title") -VALUES (-3, -2, 1, 'Encapsulation (Basics — Unit 2)'); +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", @@ -41,8 +41,8 @@ VALUES (-3, -3, '[]'::jsonb); -- CET -4: Inheritance, Unit -3, Order 1 (owned ONLY by Instructor -52, NOT -51) -INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title") -VALUES (-4, -3, 1, 'Inheritance'); +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", @@ -54,8 +54,8 @@ VALUES (-4, -4, '[]'::jsonb); -- CET -5: Encapsulation (Members — Unit 2), Unit -2, Order 2 (isolated for StartConversation tests) -INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title") -VALUES (-5, -2, 2, 'Encapsulation (Members — Unit 2)'); +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", @@ -67,8 +67,8 @@ VALUES (-5, -5, '[]'::jsonb); -- CET -6: Encapsulation (Invariants), Unit -2, Order 3 (isolated for Start+Submit flow test) -INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title") -VALUES (-6, -2, 3, 'Encapsulation (Invariants)'); +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", @@ -80,8 +80,8 @@ VALUES (-6, -6, '[]'::jsonb); -- CET -7: Polymorphism Mechanics, Unit -2, Order 4 (isolated, has KeyRelation) -INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title") -VALUES (-7, -2, 4, 'Polymorphism Mechanics'); +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", diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs index 03cc20554..873409741 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs @@ -1,6 +1,6 @@ using System.Reflection; using Shouldly; -using Tutor.Elaborations.Core.Domain.ConceptRecords; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Tests.Unit; From be91ed2cbe0a01142efdfb9fd2e3f9ef1be0e852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Sat, 2 May 2026 07:23:25 +0300 Subject: [PATCH 38/51] chore: Resolves a few SCA errors. --- .../UseCases/Learning/ConversationService.cs | 2 +- .../Orchestration/AgentOrchestrator.cs | 9 +-- .../Prompts/Agents/ScoreClosingPrompt.cs | 68 ------------------- .../{Agents => }/ClarificationPrompt.cs | 2 +- .../Prompts/{Agents => }/CritiquePrompt.cs | 2 +- .../Prompts/{Agents => }/IntentPrompt.cs | 2 +- .../Prompts/{Agents => }/IntentResponse.cs | 2 +- .../Learning/Prompts/LlmRequestFactory.cs | 11 ++- .../Prompts/{Agents => }/ProbePrompt.cs | 2 +- .../Prompts/{Agents => }/ScaffoldingPrompt.cs | 2 +- .../ScoreTurnPrompt.cs => ScorePrompt.cs} | 14 ++-- .../Prompts/{Agents => }/ScoreResponse.cs | 18 +++-- .../Prompts/{Agents => }/SummaryPrompt.cs | 2 +- 13 files changed, 36 insertions(+), 100 deletions(-) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreClosingPrompt.cs rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/{Agents => }/ClarificationPrompt.cs (96%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/{Agents => }/CritiquePrompt.cs (96%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/{Agents => }/IntentPrompt.cs (97%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/{Agents => }/IntentResponse.cs (51%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/{Agents => }/ProbePrompt.cs (96%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/{Agents => }/ScaffoldingPrompt.cs (97%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/{Agents/ScoreTurnPrompt.cs => ScorePrompt.cs} (90%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/{Agents => }/ScoreResponse.cs (86%) rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/{Agents => }/SummaryPrompt.cs (95%) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index 380cddbe1..b291a6b11 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -95,7 +95,7 @@ public async IAsyncEnumerable StartConversationAsync(int taskId, string yield break; } - var attempt = new ConversationAttempt(taskId, learnerId, task.ConceptRecord!.CountPropositionsAndRelations()); + var attempt = new ConversationAttempt(taskId, learnerId, task!.ConceptRecord!.CountPropositionsAndRelations()); _attemptRepo.Create(attempt); _unitOfWork.Save(); 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 index 14e3d4381..15c85df45 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -7,7 +7,6 @@ using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Core.UseCases.Learning.Prompts; -using Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; @@ -217,7 +216,9 @@ private async IAsyncEnumerable HandleSummaryRequestAsync(Conc yield return CreateFinalChunk(attempt); } + #pragma warning disable CS1998 private async IAsyncEnumerable HandleOffTopicAsync(ConversationAttempt attempt) + #pragma warning restore CS1998 { var fullResponse = new StringBuilder(SystemTurnCodes.OffTopic); yield return new TokenChunk(SystemTurnCodes.OffTopic); @@ -237,7 +238,7 @@ private async IAsyncEnumerable HandleClosingTurnAsync(Concept { if (intent == TurnIntent.Substantive) { - var scoreResult = await ScoreClosingAsync(record, attempt.Turns, newMessage, ct); + var scoreResult = await ScoreClosingAsync(record, newMessage, ct); if (scoreResult.IsFailed) { yield return new ErrorChunk("Closing scoring failed.", 500); @@ -304,10 +305,10 @@ private async Task> ScoreTurnAsync(ConceptRecord record, } private async Task> ScoreClosingAsync(ConceptRecord record, - IReadOnlyList history, string newMessage, CancellationToken ct) + string newMessage, CancellationToken ct) { var result = await CompleteJsonAsync( - LlmRequestFactory.ForClosingScoring(record, history, newMessage), "ClosingScoring", ct); + LlmRequestFactory.ForClosingScoring(record, newMessage), "ClosingScoring", ct); if (result.IsFailed) return Result.Fail(result.Errors); return result.Value.ToEvaluation(record); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreClosingPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreClosingPrompt.cs deleted file mode 100644 index 39368f75f..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreClosingPrompt.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; - -public static class ScoreClosingPrompt -{ - public static string Build(ConceptRecord record) - { - var hasCommonMisconceptions = record.CommonMisconceptions.Count != 0; - var hasKeyRelations = record.KeyRelations.Count != 0; - - var sb = new StringBuilder(); - sb.AppendLine(ConceptRubricSection.Render(record)); - - sb.AppendLine("# Role"); - sb.AppendLine("You are a summative scoring agent for a Socratic tutoring system."); - sb.AppendLine("The learner has just submitted their FINAL articulation of the concept — a single standalone answer to the original prompt. Grade it as a standalone deliverable against the full rubric. Output JSON only, no other text."); - sb.AppendLine(); - - sb.AppendLine("# Scope rule"); - sb.AppendLine("Score ONLY the text inside . Do NOT credit content that appears only in prior turns. The learner was asked to consolidate everything into this single message, and the grade reflects only what is present here."); - sb.AppendLine(); - - sb.AppendLine("# Rubric (applied to the whole deliverable)"); - sb.AppendLine("- Correctness (0-5): Are stated claims true? Check against KPs."); - sb.AppendLine("- Completeness (0-5): Are essential KPs covered across the whole message?"); - if (hasKeyRelations) - sb.AppendLine("- Integration (0-5): Did the learner articulate key relations *with mechanism*? 0=no relation, 1-2=relation without mechanism, 3-5=relation with mechanism matching the authored description."); - sb.AppendLine("- Evaluate concepts, not language. Grammar and style must not reduce scores."); - sb.AppendLine("- Resist sycophancy. Evaluate strictly against rubric."); - sb.AppendLine(); - - sb.AppendLine("# Concern count"); - sb.AppendLine("Count distinct concerns present in the final deliverable. A concern is any of:"); - sb.AppendLine(" - a stated inaccuracy (a claim that contradicts a KP);"); - sb.AppendLine(hasCommonMisconceptions - ? " - a triggered known misconception or a novel misconception;" - : " - a novel misconception (none are pre-catalogued for this concept);"); - sb.AppendLine(" - a vague or hand-wavy claim that references a KP without articulating it."); - sb.AppendLine("Set hasMultipleConcerns=true if the count is two or more; false otherwise."); - sb.AppendLine(); - - sb.AppendLine("# Runtime Context Format"); - sb.AppendLine("Chat history shows the full prior conversation for context only — DO NOT score these."); - sb.AppendLine("The final user message contains the deliverable inside ."); - sb.AppendLine(); - - sb.AppendLine("# Output Format (JSON only, no other text)"); - var fields = new List - { - "\"correctnessScore\": 0-5", - "\"completenessScore\": 0-5" - }; - if (hasKeyRelations) fields.Add("\"integrationScore\": 0-5"); - fields.Add("\"justification\": \"brief explanation of scores\""); - fields.Add("\"propositionsCoveredKeys\": [string list of KP keys covered, e.g. [\"P1\", \"P2\"]]"); - if (hasCommonMisconceptions) fields.Add("\"misconceptionsTriggeredKeys\": [string list of CM keys triggered, e.g. [\"M1\"]]"); - if (hasKeyRelations) fields.Add("\"relationsArticulatedKeys\": [string list of KR keys articulated with mechanism, e.g. [\"R1\"]]"); - if (hasCommonMisconceptions) fields.Add("\"novelMisconceptions\": \"any misconceptions not in the list, or null\""); - fields.Add("\"hasMultipleConcerns\": true|false"); - - sb.AppendLine("{"); - sb.AppendLine(string.Join(",\n", fields.Select(f => " " + f))); - sb.AppendLine("}"); - return sb.ToString(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ClarificationPrompt.cs similarity index 96% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ClarificationPrompt.cs index 694c9f934..de386162b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ClarificationPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ClarificationPrompt.cs @@ -1,7 +1,7 @@ using System.Text; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public static class ClarificationPrompt { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs similarity index 96% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs index 0217ef323..d94352b58 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/CritiquePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs @@ -1,7 +1,7 @@ using System.Text; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public static class CritiquePrompt { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentPrompt.cs similarity index 97% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentPrompt.cs index f55c3fb77..8394367cb 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentPrompt.cs @@ -1,7 +1,7 @@ using System.Text; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public static class IntentPrompt { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentResponse.cs similarity index 51% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentResponse.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentResponse.cs index 6bfe6183b..7e90196d6 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/IntentResponse.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentResponse.cs @@ -1,4 +1,4 @@ -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public class IntentResponse { 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 index 7b4bb4752..42bfc00f4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs @@ -2,7 +2,6 @@ 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.Agents; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; @@ -59,15 +58,13 @@ public static CompletionRequest ForTurnScoring(ConceptRecord record, IReadOnlyLi { var messages = ToMessages(turns); messages.Add(ChatMessage.FromUser($"{message}")); - return CompletionRequest.Create(messages, ScoreTurnPrompt.Build(record), maxTokens: 1024, temperature: 0.0); + return CompletionRequest.Create(messages, ScorePrompt.Build(record), maxTokens: 1024, temperature: 0.0); } - public static CompletionRequest ForClosingScoring(ConceptRecord record, IReadOnlyList turns, - string message) + public static CompletionRequest ForClosingScoring(ConceptRecord record, string message) { - var messages = ToMessages(turns); - messages.Add(ChatMessage.FromUser($"{message}")); - return CompletionRequest.Create(messages, ScoreClosingPrompt.Build(record), maxTokens: 1024, temperature: 0.0); + var messages = new List { ChatMessage.FromUser($"{message}") }; + return CompletionRequest.Create(messages, ScorePrompt.Build(record), maxTokens: 1024, temperature: 0.0); } private static List ToMessages(IEnumerable turns, int? lastN = null) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ProbePrompt.cs similarity index 96% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ProbePrompt.cs index a36684fd1..eaa90a609 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ProbePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ProbePrompt.cs @@ -1,7 +1,7 @@ using System.Text; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public static class ProbePrompt { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScaffoldingPrompt.cs similarity index 97% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScaffoldingPrompt.cs index fb0c118d1..c1af19fcc 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScaffoldingPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScaffoldingPrompt.cs @@ -1,7 +1,7 @@ using System.Text; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public static class ScaffoldingPrompt { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreTurnPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs similarity index 90% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreTurnPrompt.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs index f7e556284..07732c56c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreTurnPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs @@ -1,9 +1,9 @@ using System.Text; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; -public static class ScoreTurnPrompt +public static class ScorePrompt { public static string Build(ConceptRecord record) { @@ -24,14 +24,14 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Rubric"); sb.AppendLine("- Correctness (0-5): Are stated claims true? Check against KPs."); - sb.AppendLine("- Completeness (0-5): Are essential KPs covered in THIS message?"); + sb.AppendLine("- Completeness (0-5): Are essential KPs covered in this message?"); if (hasKeyRelations) sb.AppendLine("- Integration (0-5): Did the learner articulate key relations *with mechanism*? 0=no relation, 1-2=relation without mechanism, 3-5=relation with mechanism matching the authored description."); sb.AppendLine("- Evaluate concepts, not language. Grammar and style must not reduce scores."); sb.AppendLine("- Resist sycophancy. Evaluate strictly against rubric."); sb.AppendLine(); - sb.AppendLine("# Concern count (used by the orchestrator to route to critique vs probe)"); + sb.AppendLine("# Concern count"); sb.AppendLine("Count distinct concerns in the message. A concern is any of:"); sb.AppendLine(" - a stated inaccuracy (a claim that contradicts a KP);"); sb.AppendLine(hasCommonMisconceptions @@ -46,7 +46,6 @@ public static string Build(ConceptRecord record) sb.AppendLine("The final user message contains the message to score inside ."); sb.AppendLine(); - sb.AppendLine("# Output Format (JSON only, no other text)"); var fields = new List { "\"correctnessScore\": 0-5", @@ -54,12 +53,13 @@ public static string Build(ConceptRecord record) }; if (hasKeyRelations) fields.Add("\"integrationScore\": 0-5"); fields.Add("\"justification\": \"brief explanation of scores\""); - fields.Add("\"propositionsCoveredKeys\": [string list of KP keys covered in this turn, e.g. [\"P1\", \"P2\"]]"); + fields.Add("\"propositionsCoveredKeys\": [string list of KP keys covered, e.g. [\"P1\", \"P2\"]]"); if (hasCommonMisconceptions) fields.Add("\"misconceptionsTriggeredKeys\": [string list of CM keys triggered, e.g. [\"M1\"]]"); - if (hasKeyRelations) fields.Add("\"relationsArticulatedKeys\": [string list of KR keys articulated with mechanism this turn, e.g. [\"R1\"]]"); + if (hasKeyRelations) fields.Add("\"relationsArticulatedKeys\": [string list of KR keys articulated with mechanism, e.g. [\"R1\"]]"); if (hasCommonMisconceptions) fields.Add("\"novelMisconceptions\": \"any misconceptions not in the list, or null\""); fields.Add("\"hasMultipleConcerns\": true|false"); + sb.AppendLine("# Output Format (JSON only, no other text)"); sb.AppendLine("{"); sb.AppendLine(string.Join(",\n", fields.Select(f => " " + f))); sb.AppendLine("}"); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs similarity index 86% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreResponse.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs index 68753ced3..9745a73e6 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/ScoreResponse.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs @@ -2,7 +2,7 @@ using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; using Tutor.Elaborations.Core.Domain.Conversations; -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public class ScoreResponse { @@ -22,18 +22,24 @@ public Result ToEvaluation(ConceptRecord record) if (CompletenessScore is < 0 or > 5) return Result.Fail("Completeness out of range."); if (IntegrationScore is not null and (< 0 or > 5)) return Result.Fail("Integration out of range."); + if (!KeysExist(record)) return Result.Fail("Unknown keys found."); + + return CreateEvaluation(); + } + + private bool KeysExist(ConceptRecord record) + { var validKpKeys = record.KeyPropositions.Select(kp => kp.Key).ToHashSet(); var validKrKeys = record.KeyRelations.Select(kr => kr.Key).ToHashSet(); var validCmKeys = record.CommonMisconceptions.Select(cm => cm.Key).ToHashSet(); if (PropositionsCoveredKeys?.Any(k => !validKpKeys.Contains(k)) == true) - return Result.Fail("Unknown proposition key."); + return false; if (RelationsArticulatedKeys?.Any(k => !validKrKeys.Contains(k)) == true) - return Result.Fail("Unknown relation key."); + return false; if (MisconceptionsTriggeredKeys?.Any(k => !validCmKeys.Contains(k)) == true) - return Result.Fail("Unknown misconception key."); - - return CreateEvaluation(); + return false; + return true; } private TurnEvaluation CreateEvaluation() diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/SummaryPrompt.cs similarity index 95% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/SummaryPrompt.cs index 4ce2e4089..ba712c3a4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/Agents/SummaryPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/SummaryPrompt.cs @@ -1,7 +1,7 @@ using System.Text; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts.Agents; +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public static class SummaryPrompt { From b043612e683527b5a88389c8ceb4a8ca925fd3d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Sat, 2 May 2026 07:56:00 +0300 Subject: [PATCH 39/51] fix: Removes concept record leakage to learner. --- .../Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs | 2 +- .../UseCases/Learning/ConversationService.cs | 2 +- .../Integration/Learning/ConversationQueryTests.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs index 976d92fb8..e5691bfe1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs @@ -9,6 +9,6 @@ public class ConceptElaborationTaskDto public int Order { get; set; } public string Title { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; - public ConceptRecordDto ConceptRecord { get; set; } = new(); + public ConceptRecordDto? ConceptRecord { get; set; } public List? Attempts { get; set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index b291a6b11..0eff2c112 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -62,7 +62,7 @@ public Result> GetTasksForUnit(int unitId, in public Result GetTaskWithAttempts(int taskId, int learnerId) { - var task = _taskRepo.GetWithRecord(taskId); + var task = _taskRepo.Get(taskId); if (task == null) return Result.Fail(FailureCode.NotFound); if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs index 0c48f3aa4..9e9efa4e2 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs @@ -52,7 +52,7 @@ public void Gets_task_detail() result.ShouldNotBeNull(); result.Id.ShouldBe(-1); result.Title.ShouldNotBeNullOrEmpty(); - result.ConceptRecord.CanonicalDefinition.ShouldNotBeNullOrEmpty(); + result.ConceptRecord.ShouldBeNull(); result.Attempts.ShouldNotBeNull(); result.Attempts.Count.ShouldBe(2); result.Attempts.Any(a => a.Status == "Completed").ShouldBeTrue(); From 7f0774e80d4b819c23bed3204307862b86e66885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Sat, 2 May 2026 08:17:27 +0300 Subject: [PATCH 40/51] chore: Resolves SCA issues. --- .../UseCases/Learning/Prompts/ScoreResponse.cs | 2 +- .../Authoring/ConceptElaborationTaskCommandTests.cs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs index 9745a73e6..6a935909b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs @@ -20,7 +20,7 @@ public Result ToEvaluation(ConceptRecord record) { if (CorrectnessScore is < 0 or > 5) return Result.Fail("Correctness out of range."); if (CompletenessScore is < 0 or > 5) return Result.Fail("Completeness out of range."); - if (IntegrationScore is not null and (< 0 or > 5)) return Result.Fail("Integration out of range."); + if (IntegrationScore is < 0 or > 5) return Result.Fail("Integration out of range."); if (!KeysExist(record)) return Result.Fail("Unknown keys found."); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs index 8516c7f1f..dbb01a834 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs @@ -50,6 +50,7 @@ public void Creates() 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); @@ -94,6 +95,7 @@ public void Creates_with_relations() 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"); @@ -135,6 +137,7 @@ public void Updates() 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"); } @@ -186,6 +189,7 @@ public void Updates_relations_with_natural_keys() 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")); @@ -225,6 +229,7 @@ public void Removes_relation_and_referenced_kp() dbContext.ChangeTracker.Clear(); result.ShouldNotBeNull(); + result.ConceptRecord.ShouldNotBeNull(); result.ConceptRecord.KeyPropositions.Count.ShouldBe(1); result.ConceptRecord.KeyRelations.Count.ShouldBe(0); } From 21dbc24a86d507fff35abf6f04ec8c01e68670da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Sun, 3 May 2026 13:12:20 +0300 Subject: [PATCH 41/51] fix: Improves prompts. --- .../ConceptElaborationTasks/ConceptRecord.cs | 14 +++---- .../Learning/Prompts/ClarificationPrompt.cs | 2 +- .../Learning/Prompts/ConceptRubricSection.cs | 6 +-- .../Learning/Prompts/CritiquePrompt.cs | 2 +- .../Learning/Prompts/LlmRequestFactory.cs | 42 +++++++++---------- .../UseCases/Learning/Prompts/ProbePrompt.cs | 2 +- .../Learning/Prompts/ScaffoldingPrompt.cs | 4 +- .../UseCases/Learning/Prompts/ScorePrompt.cs | 11 ++++- 8 files changed, 43 insertions(+), 40 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs index 06e162f97..cbf29dfcd 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs @@ -33,24 +33,24 @@ public void Update(ConceptRecord incoming) KeyRelations = incoming.KeyRelations; } - public bool AreAllPropositionsCovered(ConversationAttempt attempt) + public bool IsAttemptComplete(ConversationAttempt attempt) + { + return AreAllPropositionsCovered(attempt) && AreAllKeyRelationsArticulated(attempt); + } + + private bool AreAllPropositionsCovered(ConversationAttempt attempt) { var covered = attempt.GetArticulatedPropositionKeys(); return KeyPropositions.All(kp => covered.Contains(kp.Key)); } - public bool AreAllKeyRelationsArticulated(ConversationAttempt attempt) + private bool AreAllKeyRelationsArticulated(ConversationAttempt attempt) { if (KeyRelations.Count == 0) return true; var articulated = attempt.GetArticulatedRelationKeys(); return KeyRelations.All(kr => articulated.Contains(kr.Key)); } - public bool IsAttemptComplete(ConversationAttempt attempt) - { - return AreAllPropositionsCovered(attempt) && AreAllKeyRelationsArticulated(attempt); - } - public string? PickNextTarget(ConversationAttempt attempt) { var articulatedKps = attempt.GetArticulatedPropositionKeys(); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ClarificationPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ClarificationPrompt.cs index de386162b..9d7bd7f9b 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ClarificationPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ClarificationPrompt.cs @@ -26,7 +26,7 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Runtime Context Format"); sb.AppendLine("Chat history shows the conversation so far. The last user message is the learner's clarification request."); - sb.AppendLine("The final user message contains: optional (what the TUTOR was probing — INTERNAL reference only)."); + sb.AppendLine("The final user message contains the learner's latest turn, optionally followed by (the TUTOR's prior probe — INTERNAL reference only)."); return sb.ToString(); } 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 index c7e04c901..b1fa51891 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRubricSection.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRubricSection.cs @@ -4,7 +4,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; /// -/// Renders the concept rubric (definition, KPs, CMs, KRs) as a markdown block. +/// 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. @@ -17,10 +17,6 @@ public static string Render(ConceptRecord record) sb.AppendLine("# Concept"); sb.AppendLine(); - sb.AppendLine("## Canonical Definition"); - sb.AppendLine(record.CanonicalDefinition); - sb.AppendLine(); - sb.AppendLine("## Key Propositions"); foreach (var kp in record.KeyPropositions) sb.AppendLine($"- [{kp.Key}] {kp.Statement}"); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs index d94352b58..d38c45555 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs @@ -26,7 +26,7 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Runtime Context Format"); sb.AppendLine("Chat history shows the conversation so far (user=learner, assistant=tutor). The latest learner turn is the last user message."); - sb.AppendLine("The final user message may contain: (scores + triggered misconceptions for the latest turn)."); + sb.AppendLine("The final user message contains the learner's latest turn, followed by (scores + triggered misconceptions for the latest turn)."); return sb.ToString(); } 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 index 42bfc00f4..ddbf250f2 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs @@ -10,34 +10,25 @@ public static class LlmRequestFactory public static CompletionRequest ForProbing(ConceptRecord record, IReadOnlyList turns, ActiveProbe probe) { - var messages = ToMessages(turns); - messages.Add(ChatMessage.FromUser(RenderProbe(probe))); - return CompletionRequest.Create(messages, ProbePrompt.Build(record), maxTokens: 256, temperature: 0.7); + return CompletionRequest.Create(ToMessages(turns, RenderProbe(probe)), ProbePrompt.Build(record), maxTokens: 256, temperature: 0.7); } public static CompletionRequest ForScaffolding(ConceptRecord record, IReadOnlyList turns, ActiveProbe probe) { - var messages = ToMessages(turns); - messages.Add(ChatMessage.FromUser(RenderProbe(probe))); - return CompletionRequest.Create(messages, ScaffoldingPrompt.Build(record), maxTokens: 512, temperature: 0.7); + return CompletionRequest.Create(ToMessages(turns, RenderProbe(probe)), ScaffoldingPrompt.Build(record), maxTokens: 512, temperature: 0.7); } public static CompletionRequest ForClarification(ConceptRecord record, IReadOnlyList turns, ActiveProbe? lastProbe) { - var messages = ToMessages(turns); - if (lastProbe != null) - messages.Add(ChatMessage.FromUser(RenderProbe(lastProbe))); - return CompletionRequest.Create(messages, ClarificationPrompt.Build(record), maxTokens: 256, temperature: 0.5); + return CompletionRequest.Create(ToMessages(turns, lastProbe != null ? RenderProbe(lastProbe) : null), ClarificationPrompt.Build(record), maxTokens: 256, temperature: 0.5); } public static CompletionRequest ForCritique(ConceptRecord record, IReadOnlyList turns, TurnEvaluation evaluation) { - var messages = ToMessages(turns); - messages.Add(ChatMessage.FromUser(RenderEvaluation(evaluation))); - return CompletionRequest.Create(messages, CritiquePrompt.Build(record), maxTokens: 512, temperature: 0.7); + return CompletionRequest.Create(ToMessages(turns, RenderEvaluation(evaluation)), CritiquePrompt.Build(record), maxTokens: 512, temperature: 0.7); } public static CompletionRequest ForSummary(ConceptRecord record, IReadOnlyList turns) @@ -48,7 +39,7 @@ public static CompletionRequest ForSummary(ConceptRecord record, IReadOnlyList turns, string message) { - var messages = ToMessages(turns, 6); + var messages = ToMessages(turns.OrderBy(t => t.Order).TakeLast(6)); messages.Add(ChatMessage.FromUser($"{message}")); return CompletionRequest.Create(messages, IntentPrompt.Build(record), maxTokens: 64, temperature: 0.0); } @@ -64,16 +55,25 @@ public static CompletionRequest ForTurnScoring(ConceptRecord record, IReadOnlyLi public static CompletionRequest ForClosingScoring(ConceptRecord record, string message) { var messages = new List { ChatMessage.FromUser($"{message}") }; - return CompletionRequest.Create(messages, ScorePrompt.Build(record), maxTokens: 1024, temperature: 0.0); + return CompletionRequest.Create(messages, ScorePrompt.Build(record, isClosingEvaluation: true), maxTokens: 1024, temperature: 0.0); } - private static List ToMessages(IEnumerable turns, int? lastN = null) + private static List ToMessages(IEnumerable turns, string? appendToLast = null) { - var ordered = turns.OrderBy(t => t.Order); - var window = lastN is { } n ? ordered.TakeLast(n) : ordered; - return window.Select(t => t.Role == TurnRole.Learner - ? ChatMessage.FromUser(t.Content) - : ChatMessage.FromAssistant(t.Content)).ToList(); + var messages = turns.OrderBy(t => t.Order) + .Select(t => t.Role == TurnRole.Learner + ? ChatMessage.FromUser(t.Content) + : ChatMessage.FromAssistant(t.Content)) + .ToList(); + + if (appendToLast == null) return messages; + + if (messages.Count > 0 && messages[^1].Role == ChatRole.User) + messages[^1] = ChatMessage.FromUser(messages[^1].Content + "\n" + appendToLast); + else + messages.Add(ChatMessage.FromUser(appendToLast)); + + return messages; } private static string RenderProbe(ActiveProbe probe) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ProbePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ProbePrompt.cs index eaa90a609..0d1b6c0db 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ProbePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ProbePrompt.cs @@ -30,7 +30,7 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Runtime Context Format"); sb.AppendLine("Chat history shows the conversation so far (user=learner, assistant=tutor)."); - sb.AppendLine("The final user message contains: …statement…."); + sb.AppendLine("The final user message contains the learner's latest turn, followed by …statement…."); return sb.ToString(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScaffoldingPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScaffoldingPrompt.cs index c1af19fcc..083cae251 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScaffoldingPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScaffoldingPrompt.cs @@ -17,7 +17,7 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Escalation levels"); sb.AppendLine("The tag carries a level attribute (3-4) that shapes the scaffold:"); - sb.AppendLine("- **L3 — Worked example.** Produce ONE short concrete example (3–6 lines of code OR 2–3 sentence scenario) illustrating a CONTEXT where the target concept operates. End with one narrow question that forces the learner to name what is happening. The canonical definition and relation mechanisms are INSPIRATION for the example only — never paraphrase them."); + sb.AppendLine("- **L3 — Worked example.** Produce ONE short concrete example (3–6 lines of code OR 2–3 sentence scenario) illustrating a CONTEXT where the target concept operates. End with one narrow question that forces the learner to name what is happening. The KP statements are INSPIRATION for the example only — never paraphrase them."); sb.AppendLine("- **L4 — Contrasting pair.** Produce TWO short contrasting examples — one exhibits the target correctly, one violates it in a realistic way. If a common misconception is catalogued for this concept, prefer that as the \"violates\" case. Ask which example is correct and why. The \"why\" must require articulating the target."); sb.AppendLine(); @@ -30,7 +30,7 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Runtime Context Format"); sb.AppendLine("Chat history shows the conversation so far (including the learner's prior attempts on this target)."); - sb.AppendLine("The final user message contains: …statement…."); + sb.AppendLine("The final user message contains the learner's latest turn, followed by …statement…."); return sb.ToString(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs index 07732c56c..088bd988f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs @@ -5,7 +5,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public static class ScorePrompt { - public static string Build(ConceptRecord record) + public static string Build(ConceptRecord record, bool isClosingEvaluation = false) { var hasCommonMisconceptions = record.CommonMisconceptions.Count != 0; var hasKeyRelations = record.KeyRelations.Count != 0; @@ -19,7 +19,14 @@ public static string Build(ConceptRecord record) sb.AppendLine(); sb.AppendLine("# Scope rule"); - sb.AppendLine("Score only the text inside in the final user message. Do not credit the learner for content that appears in prior assistant turns or that the learner has only repeated from a preceding assistant turn."); + if (isClosingEvaluation) + { + sb.AppendLine("Score only the text inside . This is the learner's final consolidated answer submitted in isolation — no conversation history is provided. Evaluate it as a standalone response."); + } + else + { + sb.AppendLine("Score only the text inside in the final user message. Do not credit the learner for content that appears in prior assistant turns or that the learner has only repeated from a preceding assistant turn."); + } sb.AppendLine(); sb.AppendLine("# Rubric"); From a21cf427fe0f682ff6f5286af9c9217095d2dbce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Sun, 3 May 2026 19:30:36 +0300 Subject: [PATCH 42/51] refactor: Improves scoring mechanism by transitioning from holistic evaluation to per-KP/KR precision evaluation. --- .../Conversations/ConversationAttempt.cs | 4 +- .../Domain/Conversations/ScoredTarget.cs | 5 ++ .../Domain/Conversations/TurnEvaluation.cs | 39 +++------- .../Orchestration/AgentOrchestrator.cs | 7 +- .../UseCases/Learning/Prompts/IntentPrompt.cs | 8 +- .../Learning/Prompts/LlmRequestFactory.cs | 18 ++--- .../UseCases/Learning/Prompts/ScorePrompt.cs | 67 +++++++---------- .../Learning/Prompts/ScoreResponse.cs | 74 ++++++++++--------- .../Database/ElaborationsContext.cs | 8 +- .../ElaborationsTestFactory.cs | 49 ++++-------- .../Learning/ConversationTurnTests.cs | 53 +++++++------ .../TestData/e-conversation-attempts.sql | 74 +++++++++---------- .../Unit/ConceptRecordTests.cs | 8 +- 13 files changed, 184 insertions(+), 230 deletions(-) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index 135704eae..e5dad79da 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -125,9 +125,9 @@ public void TransitionToClosing(string closingMessage) TurnCountAtClosingStart = _turns.Count; } - public void Complete(TurnEvaluation evaluation) + public void Complete(int grade) { - Summary = $"{evaluation.Grade()} / 10"; + Summary = $"{grade} / 10"; AddSystemTurn(Summary); Status = AttemptStatus.Completed; 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..f75ef0f75 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs @@ -0,0 +1,5 @@ +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public record ScoredTarget(string Key, ScoredTargetType Type, int Grade); + +public enum ScoredTargetType { Proposition, Relation } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs index fc79959bb..c2ab79b55 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs @@ -5,41 +5,22 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; public class TurnEvaluation : Entity { public int ConversationTurnId { get; private set; } - // 0-5: accuracy of stated claims against key propositions - public int CorrectnessScore { get; private set; } - // 0-5: coverage of essential key propositions in the learner message - public int CompletenessScore { get; private set; } - // 0-5: whether learner articulated key relations with their causal mechanism; null when no key relations exist - public int? IntegrationScore { get; private set; } - public string Justification { get; private set; } = string.Empty; - public string? NovelMisconceptions { get; private set; } - public List PropositionsCoveredKeys { get; private set; } = new(); - public List MisconceptionsTriggeredKeys { get; private set; } = new(); - public List RelationsArticulatedKeys { get; private set; } = new(); + public List Assessments { get; private set; } = []; + public List MisconceptionsTriggeredKeys { get; private set; } = []; public bool HasMultipleConcerns { get; private set; } + public IReadOnlyList PropositionsCoveredKeys => + Assessments.Where(a => a.Type == ScoredTargetType.Proposition && a.Grade == 3).Select(a => a.Key).ToList(); + + public IReadOnlyList RelationsArticulatedKeys => + Assessments.Where(a => a.Type == ScoredTargetType.Relation && a.Grade == 3).Select(a => a.Key).ToList(); + private TurnEvaluation() { } - public TurnEvaluation( - int correctnessScore, int completenessScore, int? integrationScore, - string justification, string? novelMisconceptions, List propositionsCoveredKeys, - List misconceptionsTriggeredKeys, List relationsArticulatedKeys, bool hasMultipleConcerns) + public TurnEvaluation(List assessments, List misconceptionsTriggeredKeys, bool hasMultipleConcerns) { - CorrectnessScore = correctnessScore; - CompletenessScore = completenessScore; - IntegrationScore = integrationScore; - Justification = justification; - NovelMisconceptions = novelMisconceptions; - PropositionsCoveredKeys = propositionsCoveredKeys; + Assessments = assessments; MisconceptionsTriggeredKeys = misconceptionsTriggeredKeys; - RelationsArticulatedKeys = relationsArticulatedKeys; HasMultipleConcerns = hasMultipleConcerns; } - - public int Grade() - { - var dims = new List { CorrectnessScore, CompletenessScore }; - if (IntegrationScore is int i) dims.Add(i); - return (int)Math.Round(dims.Average() * 2); - } } 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 index 15c85df45..d47ea24ae 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -245,7 +245,8 @@ private async IAsyncEnumerable HandleClosingTurnAsync(Concept yield break; } attempt.AddLearnerTurn(newMessage, intent, scoreResult.Value); - attempt.Complete(scoreResult.Value); + var grade = ComputeGrade(scoreResult.Value, record.CountPropositionsAndRelations()); + attempt.Complete(grade); yield return new TokenChunk(attempt.Summary!); yield return CreateFinalChunk(attempt, attempt.Summary); yield break; @@ -312,4 +313,8 @@ private async Task> ScoreClosingAsync(ConceptRecord recor if (result.IsFailed) return Result.Fail(result.Errors); return result.Value.ToEvaluation(record); } + + private static int ComputeGrade(TurnEvaluation evaluation, int totalRubricItems) => + totalRubricItems == 0 ? 0 + : (int)Math.Round(evaluation.Assessments.Sum(a => a.Grade) / (3.0 * totalRubricItems) * 10); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentPrompt.cs index 8394367cb..f1c226f04 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentPrompt.cs @@ -17,10 +17,10 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Intent categories"); sb.AppendLine("- **Substantive**: the learner attempts to explain, define, relate, or apply the concept. Even a weak or partial attempt counts."); - sb.AppendLine("- **Clarification**: the learner asks a genuine information-seeking question about the task or the tutor's last message (what / why / how / what do you mean by …?). Must be a direct question — if removing the rest and keeping just the question still makes sense."); - sb.AppendLine("- **Stuck**: the learner signals confusion, inability, or not-knowing without asking a question — e.g. \"ne znam\", \"ne razumem\", \"nisam siguran\", \"teško mi je\". Not a refusal of the task, just a stall."); - sb.AppendLine("- **SummaryRequest**: the learner asks a procedural/meta question about the conversation itself — e.g. \"rezimiraj šta sam rekao\", \"koliko mi je ostalo\", \"objasni mi još jednom šta tražiš\"."); - sb.AppendLine("- **OffTopic**: everything else — small talk, greetings, jokes, personal content, refusals (\"ne želim\", \"dosadno mi je\"), meta-comments about the conversation, deference or agreement without articulation (\"da, u pravu si\")."); + sb.AppendLine("- **Clarification**: the learner asks a genuine information-seeking question about the task or the tutor's last message (e.g. what do you mean by …? / what is expected …? / what should I do …?). Must be a direct question."); + sb.AppendLine("- **Stuck**: the learner signals confusion, inability, or not-knowing without asking a question — e.g. I don't know… / I don't understand… / I am not sure… / This is hard…. Not a refusal of the task, just a stall."); + sb.AppendLine("- **SummaryRequest**: the learner asks a recap for the conversation itself — e.g. summarize what was said… / list what was correct so far…"); + sb.AppendLine("- **OffTopic**: everything else — small talk, greetings, jokes, personal content, refusals, meta-comments about the conversation, deference or agreement without articulation"); sb.AppendLine(); sb.AppendLine("# Disambiguation rules"); 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 index ddbf250f2..26db720e6 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs @@ -84,20 +84,14 @@ private static string RenderProbe(ActiveProbe probe) private static string RenderEvaluation(TurnEvaluation e) { var sb = new StringBuilder(); - var attrs = new List - { - $"correctness=\"{e.CorrectnessScore}\"", - $"completeness=\"{e.CompletenessScore}\"" - }; - if (e.IntegrationScore.HasValue) attrs.Add($"integration=\"{e.IntegrationScore.Value}\""); - attrs.Add($"hasMultipleConcerns=\"{e.HasMultipleConcerns.ToString().ToLowerInvariant()}\""); - - sb.Append($""); - sb.Append($"{e.Justification}"); + sb.Append($""); + + var vagueKeys = e.Assessments.Where(a => a.Grade == 1).Select(a => a.Key).ToList(); + if (vagueKeys.Count > 0) + sb.Append($"{string.Join(", ", vagueKeys)}"); if (e.MisconceptionsTriggeredKeys.Count > 0) sb.Append($"{string.Join(", ", e.MisconceptionsTriggeredKeys)}"); - if (!string.IsNullOrWhiteSpace(e.NovelMisconceptions)) - sb.Append($"{e.NovelMisconceptions}"); + sb.Append(""); return sb.ToString(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs index 088bd988f..69059f0a8 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs @@ -7,69 +7,56 @@ public static class ScorePrompt { public static string Build(ConceptRecord record, bool isClosingEvaluation = false) { - var hasCommonMisconceptions = record.CommonMisconceptions.Count != 0; - var hasKeyRelations = record.KeyRelations.Count != 0; - var sb = new StringBuilder(); sb.AppendLine(ConceptRubricSection.Render(record)); sb.AppendLine("# Role"); - sb.AppendLine("You are a scoring agent for a Socratic tutoring system."); - sb.AppendLine("The learner's latest message is known to be Substantive (an attempt at explanation). Score it against the concept rubric and tag which propositions/relations/misconceptions it hits. Output JSON only, no other text."); + sb.AppendLine("You are a scoring agent for a Socratic tutoring system. Output JSON only, no other text."); sb.AppendLine(); sb.AppendLine("# Scope rule"); if (isClosingEvaluation) - { - sb.AppendLine("Score only the text inside . This is the learner's final consolidated answer submitted in isolation — no conversation history is provided. Evaluate it as a standalone response."); - } + sb.AppendLine("Score only the text inside . This is the learner's final consolidated answer submitted in isolation. Evaluate it as a standalone response."); else - { sb.AppendLine("Score only the text inside in the final user message. Do not credit the learner for content that appears in prior assistant turns or that the learner has only repeated from a preceding assistant turn."); - } sb.AppendLine(); - sb.AppendLine("# Rubric"); - sb.AppendLine("- Correctness (0-5): Are stated claims true? Check against KPs."); - sb.AppendLine("- Completeness (0-5): Are essential KPs covered in this message?"); - if (hasKeyRelations) - sb.AppendLine("- Integration (0-5): Did the learner articulate key relations *with mechanism*? 0=no relation, 1-2=relation without mechanism, 3-5=relation with mechanism matching the authored description."); - sb.AppendLine("- Evaluate concepts, not language. Grammar and style must not reduce scores."); - sb.AppendLine("- Resist sycophancy. Evaluate strictly against rubric."); + sb.AppendLine("# Rating task"); + sb.AppendLine("Rate EVERY Key Proposition and Key Relation from the concept rubric above. All must appear in the output, even if not addressed."); + sb.AppendLine("Use this scale:"); + sb.AppendLine(" 0 — not addressed in this message"); + sb.AppendLine(" 1 — vague or incomplete: concept touched but not clearly articulated"); + sb.AppendLine(" 2 — partially correct: core idea present but missing detail or precision"); + sb.AppendLine(" 3 — well-articulated: accurate and sufficiently complete"); + sb.AppendLine("For each item, set type to \"proposition\" for Key Propositions and \"relation\" for Key Relations."); + sb.AppendLine("Evaluate concepts, not language. Grammar and style must not reduce scores."); + sb.AppendLine("Resist sycophancy. Evaluate strictly against the rubric."); sb.AppendLine(); - sb.AppendLine("# Concern count"); - sb.AppendLine("Count distinct concerns in the message. A concern is any of:"); - sb.AppendLine(" - a stated inaccuracy (a claim that contradicts a KP);"); - sb.AppendLine(hasCommonMisconceptions - ? " - a triggered known misconception or a novel misconception;" - : " - a novel misconception (none are pre-catalogued for this concept);"); - sb.AppendLine(" - a vague or hand-wavy claim that references a KP without articulating it."); - sb.AppendLine("Set hasMultipleConcerns=true if the count is two or more; false otherwise."); - sb.AppendLine(); + if (record.CommonMisconceptions.Count != 0) + { + sb.AppendLine("# Misconception detection"); + sb.AppendLine("List the keys of any known misconceptions triggered in this message."); + sb.AppendLine(); + } sb.AppendLine("# Runtime Context Format"); sb.AppendLine("Chat history shows prior turns (user=learner, assistant=tutor) for context only — DO NOT score these."); sb.AppendLine("The final user message contains the message to score inside ."); sb.AppendLine(); - var fields = new List - { - "\"correctnessScore\": 0-5", - "\"completenessScore\": 0-5" - }; - if (hasKeyRelations) fields.Add("\"integrationScore\": 0-5"); - fields.Add("\"justification\": \"brief explanation of scores\""); - fields.Add("\"propositionsCoveredKeys\": [string list of KP keys covered, e.g. [\"P1\", \"P2\"]]"); - if (hasCommonMisconceptions) fields.Add("\"misconceptionsTriggeredKeys\": [string list of CM keys triggered, e.g. [\"M1\"]]"); - if (hasKeyRelations) fields.Add("\"relationsArticulatedKeys\": [string list of KR keys articulated with mechanism, e.g. [\"R1\"]]"); - if (hasCommonMisconceptions) fields.Add("\"novelMisconceptions\": \"any misconceptions not in the list, or null\""); - fields.Add("\"hasMultipleConcerns\": true|false"); - + var assessmentExample = record.KeyPropositions.Count > 0 + ? $"{{ \"key\": \"{record.KeyPropositions[0].Key}\", \"type\": \"proposition\", \"grade\": 0 }}" + : "{ \"key\": \"P1\", \"type\": \"proposition\", \"grade\": 0 }"; sb.AppendLine("# Output Format (JSON only, no other text)"); sb.AppendLine("{"); - sb.AppendLine(string.Join(",\n", fields.Select(f => " " + f))); + sb.AppendLine($" \"assessments\": [ {assessmentExample}, … one entry per KP and KR ],"); + if (record.CommonMisconceptions.Count != 0) + sb.AppendLine(" \"misconceptionsTriggeredKeys\": [string list of CM keys triggered, e.g. [\"M1\"]]"); + else + sb.AppendLine(" \"misconceptionsTriggeredKeys\": []"); sb.AppendLine("}"); + return sb.ToString(); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs index 6a935909b..acf6e0c19 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs @@ -6,48 +6,52 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public class ScoreResponse { - public int CorrectnessScore { get; set; } - public int CompletenessScore { get; set; } - public int? IntegrationScore { get; set; } - public string? Justification { get; set; } - public List? PropositionsCoveredKeys { get; set; } + public List? Assessments { get; set; } public List? MisconceptionsTriggeredKeys { get; set; } - public List? RelationsArticulatedKeys { get; set; } - public string? NovelMisconceptions { get; set; } - public bool? HasMultipleConcerns { get; set; } - public Result ToEvaluation(ConceptRecord record) + public class ScoredTargetDto { - if (CorrectnessScore is < 0 or > 5) return Result.Fail("Correctness out of range."); - if (CompletenessScore is < 0 or > 5) return Result.Fail("Completeness out of range."); - if (IntegrationScore is < 0 or > 5) return Result.Fail("Integration out of range."); - - if (!KeysExist(record)) return Result.Fail("Unknown keys found."); - - return CreateEvaluation(); + public string Key { get; set; } = ""; + public string Type { get; set; } = ""; + public int Grade { get; set; } } - private bool KeysExist(ConceptRecord record) + public Result ToEvaluation(ConceptRecord record) { - var validKpKeys = record.KeyPropositions.Select(kp => kp.Key).ToHashSet(); - var validKrKeys = record.KeyRelations.Select(kr => kr.Key).ToHashSet(); + 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 < 0 or > 3)) return Result.Fail("Grade out of range."); + + var scoredTargets = new List(); + foreach (var dto in Assessments) + { + ScoredTargetType? type = dto.Type.ToLowerInvariant() switch + { + "proposition" => ScoredTargetType.Proposition, + "relation" => ScoredTargetType.Relation, + _ => null + }; + if (type == null) return Result.Fail($"Unknown type '{dto.Type}'."); + if (type == ScoredTargetType.Proposition && !kpKeys.Contains(dto.Key)) + return Result.Fail($"Key '{dto.Key}' typed as proposition but is a relation."); + if (type == ScoredTargetType.Relation && !krKeys.Contains(dto.Key)) + return Result.Fail($"Key '{dto.Key}' typed as relation but is a proposition."); + + if (dto.Grade > 0) + scoredTargets.Add(new ScoredTarget(dto.Key, type.Value, dto.Grade)); + } + + var misconceptions = MisconceptionsTriggeredKeys ?? []; var validCmKeys = record.CommonMisconceptions.Select(cm => cm.Key).ToHashSet(); + if (misconceptions.Any(k => !validCmKeys.Contains(k))) return Result.Fail("Unknown misconception key."); - if (PropositionsCoveredKeys?.Any(k => !validKpKeys.Contains(k)) == true) - return false; - if (RelationsArticulatedKeys?.Any(k => !validKrKeys.Contains(k)) == true) - return false; - if (MisconceptionsTriggeredKeys?.Any(k => !validCmKeys.Contains(k)) == true) - return false; - return true; - } - - private TurnEvaluation CreateEvaluation() - { - return new TurnEvaluation( - CorrectnessScore, CompletenessScore, IntegrationScore, - Justification ?? string.Empty, NovelMisconceptions, PropositionsCoveredKeys ?? [], - MisconceptionsTriggeredKeys ?? [], RelationsArticulatedKeys ?? [], - HasMultipleConcerns ?? false); + var concerns = scoredTargets.Count(a => a.Grade == 1) + misconceptions.Count; + return new TurnEvaluation(scoredTargets, misconceptions, hasMultipleConcerns: concerns >= 2); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs index 13324b6b4..fd358914f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -72,12 +72,8 @@ private static void ConfigureConversations(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { - entity.Property(te => te.PropositionsCoveredKeys) - .HasColumnType("jsonb"); - entity.Property(te => te.MisconceptionsTriggeredKeys) - .HasColumnType("jsonb"); - entity.Property(te => te.RelationsArticulatedKeys) - .HasColumnType("jsonb"); + entity.Property(te => te.Assessments).HasColumnType("jsonb"); + entity.Property(te => te.MisconceptionsTriggeredKeys).HasColumnType("jsonb"); }); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs index 3a8ed9ad2..7b7205b3c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Text; using FluentResults; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; @@ -43,22 +44,16 @@ protected override IServiceCollection ReplaceNeededDbContexts(IServiceCollection return services; } - public void SetupDefaultMocks() - { - SetupEvaluationMock(); - SetupDialogueMock(); - SetupSummaryMock(); - } - - public void SetupEvaluationMock(List? propositionsCoveredKeys = null, - List? relationsArticulatedKeys = null, int? integrationScore = null, + public void SetupEvaluationMock( + List<(string key, string type, int grade)> assessments, + List? misconceptionsTriggeredKeys = null, string intent = "Substantive") { SetupIntentMock(intent); if (intent != "Substantive") return; - var scorerJson = BuildSubstantiveEvalJson(propositionsCoveredKeys, relationsArticulatedKeys, integrationScore); + var scorerJson = BuildSubstantiveEvalJson(assessments, misconceptionsTriggeredKeys ?? []); MockChatService.Setup(x => x.CompleteAsync( It.Is(r => r.MaxTokens == 1024), It.IsAny())) .ReturnsAsync(Result.Ok(new CompletionResponse @@ -79,30 +74,18 @@ public void SetupIntentMock(string intent = "Substantive") })); } - private static string BuildSubstantiveEvalJson(List? propositionsCoveredKeys, - List? relationsArticulatedKeys, int? integrationScore) + private static string BuildSubstantiveEvalJson( + List<(string key, string type, int grade)> assessments, + List misconceptions) { - var coveredKeys = propositionsCoveredKeys != null && propositionsCoveredKeys.Count > 0 - ? string.Join(",", propositionsCoveredKeys.Select(k => $"\"{k}\"")) - : ""; - var articulatedKeys = relationsArticulatedKeys != null && relationsArticulatedKeys.Count > 0 - ? string.Join(",", relationsArticulatedKeys.Select(k => $"\"{k}\"")) - : ""; - var integrationJson = integrationScore.HasValue ? integrationScore.Value.ToString() : "null"; - - return $$""" - { - "intent": "Substantive", - "correctnessScore": 3, - "completenessScore": 3, - "integrationScore": {{integrationJson}}, - "justification": "Good explanation of the concept.", - "propositionsCoveredKeys": [{{coveredKeys}}], - "misconceptionsTriggeredKeys": [], - "relationsArticulatedKeys": [{{articulatedKeys}}], - "novelMisconceptions": null - } - """; + 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) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs index adb3d1c1b..f77da34bd 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -14,12 +14,12 @@ namespace Tutor.Elaborations.Tests.Integration.Learning; // Test data layout (each task owns its own natural keys: P1, P2, R1, ...): -// CET -1: Encapsulation (Basics), Unit -1 (P1, B1, M1) -// CET -2: Encapsulation (Members), Unit -1 (P1, P2, B1, B2, M1, M2) -// CET -3: Encapsulation (Basics — Unit 2), Unit -2 (P1) -// CET -5: Encapsulation (Members — Unit 2), Unit -2 (P1, P2) — isolated for StartConversation -// CET -6: Encapsulation (Invariants), Unit -2 (P1, P2, P3) — isolated for Start+Submit flow -// CET -7: Polymorphism Mechanics, Unit -2 (P1, P2 + R1) — isolated +// 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 — isolated // Learner -2: enrolled in Units -1, -2 | Learner -3: enrolled in Units -1, -2 // Learner -1: NOT enrolled | Learner -4: exhausted wallet // Attempt -3: Learner -3, CET -1, InProgress (2 turns — for conflict + eval failure tests) @@ -36,7 +36,8 @@ public ConversationTurnTests(ElaborationsTestFactory factory) : base(factory) { public async Task Starts_conversation_with_first_turn() { Factory.MockChatService.Reset(); - Factory.SetupDefaultMocks(); + Factory.SetupEvaluationMock([("P1", "proposition", 0), ("P2", "proposition", 0)]); + Factory.SetupDialogueMock(); using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-2"); var dto = new SubmitTurnRequestDto { Content = "Encapsulation bundles data and methods." }; @@ -62,9 +63,9 @@ public async Task Starts_conversation_with_first_turn() [Fact] public async Task Closing_turn_substantive_completes_with_grade() { - // First cover all KPs to move to InClosing, then submit the closing turn. + // CET -2 (P1, P2). Attempt -4 already has P1 covered. Submit P2 → InClosing, then final answer → grade. Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock(["P1", "P2"]); + Factory.SetupEvaluationMock([("P1", "proposition", 3), ("P2", "proposition", 3)]); Factory.SetupDialogueMock(); using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-3"); @@ -76,22 +77,24 @@ public async Task Closing_turn_substantive_completes_with_grade() firstMeta.Status.ShouldBe("InClosing"); // Now submit the final articulation — ClosingScorer grades it. + // CET -2 has 2 rubric items (P1, P2). Both grade 3 → grade = 6/(3×2)×10 = 10. Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock(propositionsCoveredKeys: ["P1", "P2"]); + Factory.SetupEvaluationMock([("P1", "proposition", 3), ("P2", "proposition", 3)]); var final = new SubmitTurnRequestDto { Content = "Final consolidated answer." }; var tokens = await CollectStreamAsync(controller.SubmitTurn(-4, final, CancellationToken.None)); var metadata = JsonSerializer.Deserialize(tokens.Last()); metadata.ShouldNotBeNull(); metadata.Status.ShouldBe("Completed"); - metadata.Summary.ShouldBe("6 / 10"); + metadata.Summary.ShouldBe("10 / 10"); } [Fact] public async Task Hard_cap_reached_transitions_to_closing() { + // Attempt -5: CET -2 (P1, P2), 9 learner turns already — hard cap is totalTargets+4 = 6. Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock(propositionsCoveredKeys: []); + Factory.SetupEvaluationMock([("P1", "proposition", 0), ("P2", "proposition", 0)]); Factory.SetupDialogueMock(); using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-2"); @@ -107,8 +110,9 @@ public async Task Hard_cap_reached_transitions_to_closing() [Fact] public async Task Soft_cap_reached_continues() { + // Attempt -6: CET -3 (P1 only), 5 substantive turns already. Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock(propositionsCoveredKeys: []); + Factory.SetupEvaluationMock([("P1", "proposition", 0)]); Factory.SetupDialogueMock(); Factory.SetupSummaryMock(); using var scope = Factory.Services.CreateScope(); @@ -147,7 +151,6 @@ public async Task Start_unenrolled_fails() public async Task Start_insufficient_tokens_fails() { Factory.MockChatService.Reset(); - Factory.SetupDefaultMocks(); using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-4"); var dto = new SubmitTurnRequestDto { Content = "Should fail due to exhausted wallet." }; @@ -163,7 +166,6 @@ public async Task Start_insufficient_tokens_fails() public async Task Start_max_daily_attempts_fails() { Factory.MockChatService.Reset(); - Factory.SetupDefaultMocks(); using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-2"); var dto = new SubmitTurnRequestDto { Content = "Should fail due to daily limit." }; @@ -210,8 +212,10 @@ public async Task Start_nonexistent_task_fails() [Fact] public async Task Start_then_submit_adds_turns_to_same_attempt() { + // CET -6 (P1, P2, P3). Factory.MockChatService.Reset(); - Factory.SetupDefaultMocks(); + 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(); @@ -230,7 +234,8 @@ public async Task Start_then_submit_adds_turns_to_same_attempt() // Submit second turn — should add to the same attempt Factory.MockChatService.Reset(); - Factory.SetupDefaultMocks(); + Factory.SetupEvaluationMock([("P1", "proposition", 0), ("P2", "proposition", 0), ("P3", "proposition", 0)]); + Factory.SetupDialogueMock(); var secondDto = new SubmitTurnRequestDto { Content = "Second turn for reuse test." }; var tokens = await CollectStreamAsync(controller.SubmitTurn(attemptId, secondDto, CancellationToken.None)); @@ -290,12 +295,9 @@ public async Task Submit_wrong_learner_fails() [Fact] public async Task Concept_with_relations_transitions_to_closing_when_relations_articulated() { - // CET -7 (KPs P1, P2 + KR R1). Strict completion: covering both KPs is not enough. + // CET -7 (KPs P1, P2 + KR R1). Covering both KPs and articulating R1 completes the attempt. Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock( - propositionsCoveredKeys: ["P1", "P2"], - relationsArticulatedKeys: ["R1"], - integrationScore: 3); + Factory.SetupEvaluationMock([("P1", "proposition", 3), ("P2", "proposition", 3), ("R1", "relation", 3)]); Factory.SetupDialogueMock(); using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-3"); @@ -314,13 +316,10 @@ public async Task Concept_with_relations_transitions_to_closing_when_relations_a [Fact] public async Task Concept_with_relations_does_not_complete_when_only_KPs_covered() { - // CET -7. Covering KPs but NOT articulating the relation should NOT complete. + // CET -7. Covering KPs but NOT articulating R1 should NOT complete. // Uses learner -2 so test doesn't collide with the "completes" test (also on CET -7). Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock( - propositionsCoveredKeys: ["P1", "P2"], - relationsArticulatedKeys: [], - integrationScore: 1); + Factory.SetupEvaluationMock([("P1", "proposition", 3), ("P2", "proposition", 3), ("R1", "relation", 0)]); Factory.SetupDialogueMock(); Factory.SetupSummaryMock(); using var scope = Factory.Services.CreateScope(); 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 index 9beca97e6..7eef029ac 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -9,10 +9,10 @@ VALUES (-2, -1, 1, 'Good start! Can you tell me more about access modifiers?', 1 INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-3, -1, 0, 'Access modifiers like public and private control visibility.', 2, '2024-06-01 10:02:00+00', 0); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-1, -1, 3, 3, null, 'Accurate basic description', null, '["P1"]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-3, -3, 3, 3, null, 'Good description of access modifiers', null, '["P1"]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-1, -1, '[{{"Key":"P1","Type":0,"Grade":3}}]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-3, -3, '[{{"Key":"P1","Type":0,"Grade":3}}]'::jsonb, '[]'::jsonb, false); -- Attempt -2: Learner -2, CET -1, Abandoned (for query tests) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -27,8 +27,8 @@ VALUES (-4, -3, 0, 'Encapsulation is about data hiding.', 0, '2024-06-03 10:01:0 INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', 1, '2024-06-03 10:01:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-4, -4, 1, 1, null, 'Partially correct but incomplete', null, '[]'::jsonb, '["M1"]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-4, -4, '[]'::jsonb, '["M1"]'::jsonb, false); -- Attempt -4: Learner -3, CET -2, InProgress (for completion test: KP P1 already covered, submit to cover P2) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -39,8 +39,8 @@ VALUES (-6, -4, 0, 'Encapsulation bundles data and methods together.', 0, '2024- INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-7, -4, 1, 'Good. What about access control?', 1, '2024-06-04 10:01:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-6, -6, 3, 3, null, 'Covers bundling proposition', null, '["P1"]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-6, -6, '[{{"Key":"P1","Type":0,"Grade":3}}]'::jsonb, '[]'::jsonb, false); -- Attempt -5: Learner -2, CET -2, InProgress with 9 learner + 9 system turns (for hard cap test) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary", "HardCapTotalTurns", "SoftCapTotalTurns") @@ -83,25 +83,25 @@ VALUES (-66, -5, 0, 'Turn 9', 16, '2024-06-05 10:09:00+00', 0); INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-67, -5, 1, 'Response 9', 17, '2024-06-05 10:09:05+00', null); --- Evaluations for the 9 learner turns (all with empty propositions - never completes) -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-50, -50, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-52, -52, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-54, -54, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-56, -56, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-58, -58, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-60, -60, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-62, -62, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-64, -64, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-66, -66, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +-- Evaluations for the 9 learner turns (all with empty assessments — never completes) +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-50, -50, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-52, -52, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-54, -54, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-56, -56, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-58, -58, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-60, -60, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-62, -62, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-64, -64, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-66, -66, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -6: Learner -3, CET -3, InProgress with 5 substantive learner + 5 system turns (for soft cap test) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") @@ -128,16 +128,16 @@ VALUES (-78, -6, 0, 'Turn 5', 8, '2024-06-06 10:05:00+00', 0); INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") VALUES (-79, -6, 1, 'Response 5', 9, '2024-06-06 10:05:05+00', null); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-70, -70, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-72, -72, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-74, -74, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-76, -76, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "CorrectnessScore", "CompletenessScore", "IntegrationScore", "Justification", "NovelMisconceptions", "PropositionsCoveredKeys", "MisconceptionsTriggeredKeys", "RelationsArticulatedKeys", "HasMultipleConcerns") -VALUES (-78, -78, 1, 1, null, 'Vague', null, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-70, -70, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-72, -72, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-74, -74, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-76, -76, '[]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") +VALUES (-78, -78, '[]'::jsonb, '[]'::jsonb, false); -- Attempt -7: Learner -3, CET -5, InProgress (isolated for abandon test — no other test touches this) INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs index 873409741..1fec9871f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs @@ -57,10 +57,10 @@ private static ConversationAttempt MakeAttemptWithCoveredAndArticulated( .GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, Type.EmptyTypes)!; var attempt = (ConversationAttempt)ctor.Invoke(null); - var evaluation = new TurnEvaluation( - 2, 2, null, - "test", null, coveredKpKeys, - new List(), articulatedRelationKeys, false); + var assessments = coveredKpKeys.Select(k => new ScoredTarget(k, ScoredTargetType.Proposition, 3)) + .Concat(articulatedRelationKeys.Select(k => new ScoredTarget(k, ScoredTargetType.Relation, 3))) + .ToList(); + var evaluation = new TurnEvaluation(assessments, [], hasMultipleConcerns: false); attempt.AddLearnerTurn("x", TurnIntent.Substantive, evaluation); return attempt; } From fb465571898e74eb9703c883ebe4f7117cbae442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Sun, 3 May 2026 19:53:03 +0300 Subject: [PATCH 43/51] chore: Resolves SCA issues. --- .../Conversations/ConversationAttempt.cs | 4 +- .../Domain/Conversations/TurnEvaluation.cs | 14 +++++-- .../Learning/Prompts/CritiquePrompt.cs | 4 +- .../Learning/Prompts/ScoreResponse.cs | 37 +++++++++++++------ 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index e5dad79da..2f4a84a15 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -36,7 +36,7 @@ public ISet GetArticulatedPropositionKeys() { return Turns .Where(t => t.Evaluation != null) - .SelectMany(t => t.Evaluation!.PropositionsCoveredKeys) + .SelectMany(t => t.Evaluation!.PropositionsCoveredKeys()) .ToHashSet(); } @@ -44,7 +44,7 @@ public ISet GetArticulatedRelationKeys() { return Turns .Where(t => t.Evaluation != null) - .SelectMany(t => t.Evaluation!.RelationsArticulatedKeys) + .SelectMany(t => t.Evaluation!.RelationsArticulatedKeys()) .ToHashSet(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs index c2ab79b55..54aaafe57 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs @@ -9,11 +9,17 @@ public class TurnEvaluation : Entity public List MisconceptionsTriggeredKeys { get; private set; } = []; public bool HasMultipleConcerns { get; private set; } - public IReadOnlyList PropositionsCoveredKeys => - Assessments.Where(a => a.Type == ScoredTargetType.Proposition && a.Grade == 3).Select(a => a.Key).ToList(); + public IReadOnlyList PropositionsCoveredKeys() + { + return Assessments.Where(a => a.Type == ScoredTargetType.Proposition && a.Grade == 3) + .Select(a => a.Key).ToList(); + } - public IReadOnlyList RelationsArticulatedKeys => - Assessments.Where(a => a.Type == ScoredTargetType.Relation && a.Grade == 3).Select(a => a.Key).ToList(); + public IReadOnlyList RelationsArticulatedKeys() + { + return Assessments.Where(a => a.Type == ScoredTargetType.Relation && a.Grade == 3) + .Select(a => a.Key).ToList(); + } private TurnEvaluation() { } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs index d38c45555..5530c1b33 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs @@ -17,7 +17,7 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Rules"); sb.AppendLine("- Respond with a short bulleted list of pushback points on concerns in the LATEST learner turn (the last user message in the chat history) ONLY — inaccuracies, triggered or novel misconceptions, vague or hand-wavy claims."); - sb.AppendLine("- NEVER raise a KP or KR that the learner has already articulated in an earlier turn. Re-raising those reads as not listening. (The scoring agent's tag in the runtime context tells you which propositions/relations this current turn covered; prior coverage is implicit from the chat history.)"); + sb.AppendLine("- NEVER raise a KP or KR that the learner has already articulated in an earlier turn. Re-raising those reads as not listening. (Prior well-articulated turns are implicit from the chat history; the scoring agent's tag in the runtime context lists the vague items and triggered misconceptions in THIS turn — these are your concerns.)"); sb.AppendLine("- NEVER provide answers, definitions, or explanations. NEVER reveal any KP/BC/CM/KR text verbatim or paraphrased."); sb.AppendLine("- Close the bullets with a brief invitation to address them. Do not ask a new Socratic question — the learner must consolidate first."); sb.AppendLine("- Silence on an error reads as agreement, so surface every in-turn concern."); @@ -26,7 +26,7 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Runtime Context Format"); sb.AppendLine("Chat history shows the conversation so far (user=learner, assistant=tutor). The latest learner turn is the last user message."); - sb.AppendLine("The final user message contains the learner's latest turn, followed by (scores + triggered misconceptions for the latest turn)."); + sb.AppendLine("The final user message contains the learner's latest turn, followed by (vague items and triggered misconceptions for the latest turn)."); return sb.ToString(); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs index acf6e0c19..5e9181d71 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs @@ -28,8 +28,22 @@ public Result ToEvaluation(ConceptRecord record) if (!returnedKeys.SetEquals(allKeys)) return Result.Fail("Incomplete or unknown keys in assessments."); if (Assessments.Any(a => a.Grade is < 0 or > 3)) return Result.Fail("Grade out of range."); + var creationResult = CreateScoredTargets(kpKeys, krKeys); + if(creationResult.IsFailed) return Result.Fail(creationResult.Errors); + var scoredTargets = creationResult.Value; + + var misconceptions = MisconceptionsTriggeredKeys ?? []; + var validCmKeys = record.CommonMisconceptions.Select(cm => cm.Key).ToHashSet(); + if (misconceptions.Any(k => !validCmKeys.Contains(k))) return Result.Fail("Unknown misconception key."); + + var concerns = scoredTargets.Count(a => a.Grade == 1) + misconceptions.Count; + return new TurnEvaluation(scoredTargets, misconceptions, hasMultipleConcerns: concerns >= 2); + } + + private Result> CreateScoredTargets(HashSet kpKeys, HashSet krKeys) + { var scoredTargets = new List(); - foreach (var dto in Assessments) + foreach (var dto in Assessments!) { ScoredTargetType? type = dto.Type.ToLowerInvariant() switch { @@ -37,21 +51,20 @@ public Result ToEvaluation(ConceptRecord record) "relation" => ScoredTargetType.Relation, _ => null }; - if (type == null) return Result.Fail($"Unknown type '{dto.Type}'."); - if (type == ScoredTargetType.Proposition && !kpKeys.Contains(dto.Key)) - return Result.Fail($"Key '{dto.Key}' typed as proposition but is a relation."); - if (type == ScoredTargetType.Relation && !krKeys.Contains(dto.Key)) - return Result.Fail($"Key '{dto.Key}' typed as relation but is a proposition."); + switch (type) + { + case null: + return Result.Fail($"Unknown type '{dto.Type}'."); + case ScoredTargetType.Proposition when !kpKeys.Contains(dto.Key): + return Result.Fail($"Key '{dto.Key}' typed as proposition but is a relation."); + case ScoredTargetType.Relation when !krKeys.Contains(dto.Key): + return Result.Fail($"Key '{dto.Key}' typed as relation but is a proposition."); + } if (dto.Grade > 0) scoredTargets.Add(new ScoredTarget(dto.Key, type.Value, dto.Grade)); } - var misconceptions = MisconceptionsTriggeredKeys ?? []; - var validCmKeys = record.CommonMisconceptions.Select(cm => cm.Key).ToHashSet(); - if (misconceptions.Any(k => !validCmKeys.Contains(k))) return Result.Fail("Unknown misconception key."); - - var concerns = scoredTargets.Count(a => a.Grade == 1) + misconceptions.Count; - return new TurnEvaluation(scoredTargets, misconceptions, hasMultipleConcerns: concerns >= 2); + return scoredTargets; } } From 9e689d3f18b6a9f6ca6b6cb7a28602bb22f573a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Tue, 5 May 2026 13:05:24 +0300 Subject: [PATCH 44/51] fix!: Improves CritiquePrompt behavior. --- .../ConceptElaborationTasks/ConceptRecord.cs | 2 +- .../Domain/Conversations/ConversationAttempt.cs | 4 ++-- .../Domain/Conversations/TurnEvaluation.cs | 10 ++++++++++ .../UseCases/Learning/ConversationService.cs | 4 ++-- .../Learning/Orchestration/AgentOrchestrator.cs | 8 ++------ .../Learning/Orchestration/SystemTurnCodes.cs | 10 +++++----- .../UseCases/Learning/Prompts/CritiquePrompt.cs | 14 +++++++------- .../UseCases/Learning/Prompts/LlmRequestFactory.cs | 4 ++-- 8 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs index cbf29dfcd..92eb39bfc 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs @@ -73,7 +73,7 @@ private bool AreAllKeyRelationsArticulated(ConversationAttempt attempt) return null; } - public int CountPropositionsAndRelations() + public int CountTargets() { return KeyPropositions.Count + KeyRelations.Count; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index 2f4a84a15..eaeda241c 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -53,9 +53,9 @@ public int CountTotalLearnerTurns() return Turns.Count(t => t.Role == TurnRole.Learner); } - public bool IsSoftCapReached() => CountTotalLearnerTurns() >= SoftCapTotalTurns; + public bool IsSoftCapReached() => CountTotalLearnerTurns() == SoftCapTotalTurns; - public bool IsHardCapReached() => CountTotalLearnerTurns() >= HardCapTotalTurns; + public bool IsHardCapReached() => CountTotalLearnerTurns() == HardCapTotalTurns; public int GetProbeLevelFor(string target) { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs index 54aaafe57..5175a1679 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs @@ -21,6 +21,9 @@ public IReadOnlyList RelationsArticulatedKeys() .Select(a => a.Key).ToList(); } + public bool HasBroadCoverage(int totalRubricItems) => + totalRubricItems > 0 && Assessments.Count(a => a.Grade >= 2) / (double)totalRubricItems >= 0.8; + private TurnEvaluation() { } public TurnEvaluation(List assessments, List misconceptionsTriggeredKeys, bool hasMultipleConcerns) @@ -29,4 +32,11 @@ public TurnEvaluation(List assessments, List misconception MisconceptionsTriggeredKeys = misconceptionsTriggeredKeys; HasMultipleConcerns = hasMultipleConcerns; } + + public int ComputeGrade(int totalTargets) + { + var actualScore = Assessments.Sum(a => a.Grade) - MisconceptionsTriggeredKeys.Count; + var maxScore = 3.0 * totalTargets; + return Math.Max((int)Math.Round(actualScore / maxScore * 10), 0); + } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index 0eff2c112..fd58e12a1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -17,7 +17,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning; public class ConversationService : IConversationService { - private const int MaxAttemptsPerDay = 3; + private const int MaxAttemptsPerDay = 30; private readonly IConversationAttemptRepository _attemptRepo; private readonly IConceptElaborationTaskRepository _taskRepo; @@ -95,7 +95,7 @@ public async IAsyncEnumerable StartConversationAsync(int taskId, string yield break; } - var attempt = new ConversationAttempt(taskId, learnerId, task!.ConceptRecord!.CountPropositionsAndRelations()); + var attempt = new ConversationAttempt(taskId, learnerId, task!.ConceptRecord!.CountTargets()); _attemptRepo.Create(attempt); _unitOfWork.Save(); 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 index d47ea24ae..48bacae9a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -97,7 +97,7 @@ private async IAsyncEnumerable HandleSubstantiveAsync(Concept string label; ActiveProbe? probe = null; - if (evaluation.HasMultipleConcerns) + if (evaluation.HasMultipleConcerns && !evaluation.HasBroadCoverage(record.CountTargets())) { request = LlmRequestFactory.ForCritique(record, attempt.Turns, evaluation); label = "Critique"; @@ -245,7 +245,7 @@ private async IAsyncEnumerable HandleClosingTurnAsync(Concept yield break; } attempt.AddLearnerTurn(newMessage, intent, scoreResult.Value); - var grade = ComputeGrade(scoreResult.Value, record.CountPropositionsAndRelations()); + var grade = scoreResult.Value.ComputeGrade(record.CountTargets()); attempt.Complete(grade); yield return new TokenChunk(attempt.Summary!); yield return CreateFinalChunk(attempt, attempt.Summary); @@ -313,8 +313,4 @@ private async Task> ScoreClosingAsync(ConceptRecord recor if (result.IsFailed) return Result.Fail(result.Errors); return result.Value.ToEvaluation(record); } - - private static int ComputeGrade(TurnEvaluation evaluation, int totalRubricItems) => - totalRubricItems == 0 ? 0 - : (int)Math.Round(evaluation.Assessments.Sum(a => a.Grade) / (3.0 * totalRubricItems) * 10); } 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 index afccffeac..d8f8b076e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/SystemTurnCodes.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/SystemTurnCodes.cs @@ -2,9 +2,9 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public static class SystemTurnCodes { - public const string SoftCapNudge = "SOFT_CAP"; - public const string InClosingTransition = "CLOSING_TRANSITION"; - public const string NonSubstantiveInClosingNudge = "CLOSING_NUDGE"; - public const string ExpiredNotice = "EXPIRED"; - public const string OffTopic = "OFF_TOPIC"; + public const string SoftCapNudge = "SOFT_CAP\n"; + public const string InClosingTransition = "CLOSING_TRANSITION\n"; + public const string NonSubstantiveInClosingNudge = "CLOSING_NUDGE\n"; + public const string ExpiredNotice = "EXPIRED\n"; + public const string OffTopic = "OFF_TOPIC\n"; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs index 5530c1b33..1be4c4f90 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs @@ -12,16 +12,16 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Role"); sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); - sb.AppendLine("The learner's latest answer has multiple concerns. Surface them as a short bulleted list so the learner can consolidate the existing answer before moving on."); + sb.AppendLine("The learner's latest answer has multiple concerns. Ask focused questions so the learner can identify and fix the gaps themselves."); sb.AppendLine(); sb.AppendLine("# Rules"); - sb.AppendLine("- Respond with a short bulleted list of pushback points on concerns in the LATEST learner turn (the last user message in the chat history) ONLY — inaccuracies, triggered or novel misconceptions, vague or hand-wavy claims."); - sb.AppendLine("- NEVER raise a KP or KR that the learner has already articulated in an earlier turn. Re-raising those reads as not listening. (Prior well-articulated turns are implicit from the chat history; the scoring agent's tag in the runtime context lists the vague items and triggered misconceptions in THIS turn — these are your concerns.)"); - sb.AppendLine("- NEVER provide answers, definitions, or explanations. NEVER reveal any KP/BC/CM/KR text verbatim or paraphrased."); - sb.AppendLine("- Close the bullets with a brief invitation to address them. Do not ask a new Socratic question — the learner must consolidate first."); - sb.AppendLine("- Silence on an error reads as agreement, so surface every in-turn concern."); - sb.AppendLine("- Concise language. Respect cognitive load."); + sb.AppendLine("- Surface at most 3 concerns, drawn only from and in the tag."); + sb.AppendLine("- Priority order: (1) at most one triggered misconception — name it explicitly; (2) grade-1 (vague) KP/KR items to fill remaining slots."); + sb.AppendLine("- NEVER raise a KP or KR the learner already articulated well in a prior turn."); + sb.AppendLine("- NEVER provide answers, definitions, or explanations. NEVER reveal any KP/KR/CM text verbatim or paraphrased."); + sb.AppendLine("- Frame each concern as a bullet point with a Socratic question targeting the specific gap."); + sb.AppendLine("- Close with a brief invitation to respond. No summary of the questions."); sb.AppendLine(); sb.AppendLine("# Runtime Context Format"); 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 index 26db720e6..3a8550dbe 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs @@ -16,7 +16,7 @@ public static CompletionRequest ForProbing(ConceptRecord record, IReadOnlyList turns, ActiveProbe probe) { - return CompletionRequest.Create(ToMessages(turns, RenderProbe(probe)), ScaffoldingPrompt.Build(record), maxTokens: 512, temperature: 0.7); + return CompletionRequest.Create(ToMessages(turns, RenderProbe(probe)), ScaffoldingPrompt.Build(record), maxTokens: 1024, temperature: 0.7); } public static CompletionRequest ForClarification(ConceptRecord record, IReadOnlyList turns, @@ -33,7 +33,7 @@ public static CompletionRequest ForCritique(ConceptRecord record, IReadOnlyList< public static CompletionRequest ForSummary(ConceptRecord record, IReadOnlyList turns) { - return CompletionRequest.Create(ToMessages(turns), SummaryPrompt.Build(record), maxTokens: 256, temperature: 0.5); + return CompletionRequest.Create(ToMessages(turns), SummaryPrompt.Build(record), maxTokens: 512, temperature: 0.5); } public static CompletionRequest ForIntentClassification(ConceptRecord record, IReadOnlyList turns, From fd8c957d51792e9f186585f561e5f7341a5e6e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Thu, 7 May 2026 06:50:41 +0300 Subject: [PATCH 45/51] redesign!: Reworks the complete UX of concept elaboration to focus on incremental elaboration development. --- .../Conversations/ConversationAttemptDto.cs | 2 +- .../SubmitElaborationRequestDto.cs | 6 + ...Dto.cs => SubmitElaborationResponseDto.cs} | 3 +- .../Conversations/SubmitTurnRequestDto.cs | 6 - .../Public/Learning/IConversationService.cs | 4 +- .../ConceptElaborationTasks/ConceptRecord.cs | 46 +-- .../Domain/Conversations/ActiveProbe.cs | 3 - .../Domain/Conversations/AttemptStatus.cs | 1 - .../Conversations/ConversationAttempt.cs | 167 +++++------ .../Domain/Conversations/ConversationTurn.cs | 26 +- .../Domain/Conversations/FeedbackTarget.cs | 3 + .../Domain/Conversations/ScoredTarget.cs | 4 +- .../Domain/Conversations/TargetType.cs | 3 + .../Domain/Conversations/TurnEvaluation.cs | 28 +- .../Domain/Conversations/TurnIntent.cs | 10 - .../UseCases/Learning/ConversationService.cs | 46 +-- .../Orchestration/AgentOrchestrator.cs | 270 ++---------------- .../Orchestration/IAgentOrchestrator.cs | 4 +- .../Orchestration/OrchestratorChunk.cs | 2 +- .../Learning/Orchestration/SystemTurnCodes.cs | 5 +- .../Learning/Prompts/ClarificationPrompt.cs | 33 --- .../Learning/Prompts/CritiquePrompt.cs | 33 --- .../Prompts/EvaluationFeedbackPrompt.cs | 44 +++ .../UseCases/Learning/Prompts/IntentPrompt.cs | 44 --- .../Learning/Prompts/IntentResponse.cs | 6 - .../Learning/Prompts/LlmRequestFactory.cs | 95 ++---- .../UseCases/Learning/Prompts/ProbePrompt.cs | 37 --- .../Learning/Prompts/ScaffoldingPrompt.cs | 37 --- .../UseCases/Learning/Prompts/ScorePrompt.cs | 47 +-- .../{ScoreResponse.cs => ScoreResponseDto.cs} | 41 ++- .../Learning/Prompts/SummaryPrompt.cs | 31 -- .../Database/ElaborationsContext.cs | 4 +- .../ConversationAttemptDatabaseRepository.cs | 2 +- .../ElaborationsTestFactory.cs | 32 +-- .../Learning/ConversationTurnTests.cs | 233 ++++++--------- .../TestData/e-conversation-attempts.sql | 207 +++++--------- .../TestData/f-daily-limit-attempts.sql | 12 +- .../Tutor.Elaborations.Tests.csproj | 4 + .../Unit/ConceptRecordTests.cs | 67 ----- .../Elaboration/ConversationController.cs | 14 +- 40 files changed, 451 insertions(+), 1211 deletions(-) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitElaborationRequestDto.cs rename src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/{SubmitTurnResponseDto.cs => SubmitElaborationResponseDto.cs} (66%) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnRequestDto.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ActiveProbe.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TargetType.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ClarificationPrompt.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentPrompt.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentResponse.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ProbePrompt.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScaffoldingPrompt.cs rename src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/{ScoreResponse.cs => ScoreResponseDto.cs} (61%) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/SummaryPrompt.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs index 97b4ade23..77d1cf098 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs @@ -7,6 +7,6 @@ public class ConversationAttemptDto public string Status { get; set; } = string.Empty; public DateTime StartedAt { get; set; } public DateTime? CompletedAt { get; set; } - public string? Summary { get; set; } + public double? FinalGrade { get; set; } public List Turns { get; set; } = new(); } 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/SubmitTurnResponseDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitElaborationResponseDto.cs similarity index 66% rename from src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnResponseDto.cs rename to src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitElaborationResponseDto.cs index d90262b05..762af7bd5 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnResponseDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitElaborationResponseDto.cs @@ -1,8 +1,7 @@ namespace Tutor.Elaborations.API.Dtos.Conversations; -public class SubmitTurnResponseDto +public class SubmitElaborationResponseDto { public int AttemptId { get; set; } public string Status { get; set; } = string.Empty; - public string? Summary { get; set; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnRequestDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnRequestDto.cs deleted file mode 100644 index 4e2d96fc8..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitTurnRequestDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Tutor.Elaborations.API.Dtos.Conversations; - -public class SubmitTurnRequestDto -{ - public string Content { get; set; } = string.Empty; -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs index 9af9bc921..06d6f293f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs @@ -8,7 +8,7 @@ public interface IConversationService { Result> GetTasksForUnit(int unitId, int learnerId); Result GetTaskWithAttempts(int taskId, int learnerId); - IAsyncEnumerable StartConversationAsync(int taskId, string content, int learnerId, CancellationToken ct); - IAsyncEnumerable SubmitTurnAsync(int attemptId, string content, int learnerId, CancellationToken ct); + 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.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs index 92eb39bfc..c2770e824 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs @@ -1,5 +1,4 @@ using Tutor.BuildingBlocks.Core.Domain; -using Tutor.Elaborations.Core.Domain.Conversations; namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; @@ -33,48 +32,5 @@ public void Update(ConceptRecord incoming) KeyRelations = incoming.KeyRelations; } - public bool IsAttemptComplete(ConversationAttempt attempt) - { - return AreAllPropositionsCovered(attempt) && AreAllKeyRelationsArticulated(attempt); - } - - private bool AreAllPropositionsCovered(ConversationAttempt attempt) - { - var covered = attempt.GetArticulatedPropositionKeys(); - return KeyPropositions.All(kp => covered.Contains(kp.Key)); - } - - private bool AreAllKeyRelationsArticulated(ConversationAttempt attempt) - { - if (KeyRelations.Count == 0) return true; - var articulated = attempt.GetArticulatedRelationKeys(); - return KeyRelations.All(kr => articulated.Contains(kr.Key)); - } - - public string? PickNextTarget(ConversationAttempt attempt) - { - var articulatedKps = attempt.GetArticulatedPropositionKeys(); - var excludedTargets = attempt.GetStalledTargets(); - var nextTarget = KeyPropositions - .Where(kp => !articulatedKps.Contains(kp.Key)) - .Select(kp => kp.Statement) - .FirstOrDefault(s => !excludedTargets.Contains(s)); - if (nextTarget != null) return nextTarget; - - var articulatedKrs = attempt.GetArticulatedRelationKeys(); - foreach (var kr in KeyRelations.Where(kr => !articulatedKrs.Contains(kr.Key))) - { - var source = KeyPropositions.First(kp => kp.Key == kr.SourceKey).Statement; - var target = KeyPropositions.First(kp => kp.Key == kr.TargetKey).Statement; - var composed = $"{source} → {target}. Mechanism: {kr.Mechanism}"; - if (!excludedTargets.Contains(composed)) return composed; - } - - return null; - } - - public int CountTargets() - { - return KeyPropositions.Count + KeyRelations.Count; - } + public int CountTargets() => KeyPropositions.Count + KeyRelations.Count; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ActiveProbe.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ActiveProbe.cs deleted file mode 100644 index a6d01c748..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ActiveProbe.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Tutor.Elaborations.Core.Domain.Conversations; - -public record ActiveProbe(string Target, int Level); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs index 9d723bc40..46cd76d69 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs @@ -3,7 +3,6 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; public enum AttemptStatus { InProgress, - InClosing, 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 index eaeda241c..5638214dc 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -4,21 +4,17 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; public class ConversationAttempt : AggregateRoot { - private const int ProbeLadderLength = 2; - private const int ScaffoldLadderLength = 2; - private const int StalledThreshold = ProbeLadderLength + ScaffoldLadderLength; - 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 string? Summary { get; private set; } + public double FinalGrade { get; private set; } + public int TotalTargets { get; private set; } + public int MaxRounds { get; private set; } + public int RoundCount { get; private set; } private readonly List _turns = new(); public IReadOnlyList Turns => _turns.AsReadOnly(); - public int? SoftCapTotalTurns { get; private set; } - public int? HardCapTotalTurns { get; private set; } - public int? TurnCountAtClosingStart { get; private set; } private ConversationAttempt() { } @@ -26,110 +22,96 @@ public ConversationAttempt(int conceptElaborationTaskId, int learnerId, int tota { ConceptElaborationTaskId = conceptElaborationTaskId; LearnerId = learnerId; + TotalTargets = totalTargets; Status = AttemptStatus.InProgress; StartedAt = DateTime.UtcNow; - HardCapTotalTurns = totalTargets + 4; - SoftCapTotalTurns = Math.Max(totalTargets, 3); - } - - public ISet GetArticulatedPropositionKeys() - { - return Turns - .Where(t => t.Evaluation != null) - .SelectMany(t => t.Evaluation!.PropositionsCoveredKeys()) - .ToHashSet(); + MaxRounds = Math.Max(4, (int)Math.Ceiling(totalTargets / 3.0) + 2); } - public ISet GetArticulatedRelationKeys() - { - return Turns - .Where(t => t.Evaluation != null) - .SelectMany(t => t.Evaluation!.RelationsArticulatedKeys()) - .ToHashSet(); - } + public bool IsHardCapReached() => RoundCount >= MaxRounds; - public int CountTotalLearnerTurns() + public bool IsStagnating() { - return Turns.Count(t => t.Role == TurnRole.Learner); + var scores = Turns + .Where(t => t.Role == TurnRole.Learner && t.Evaluation != null) + .OrderBy(t => t.Order) + .TakeLast(3) + .Select(t => t.Evaluation!.TotalScore()) + .ToList(); + if (scores.Count < 3) return false; + return scores[2] <= scores[1] && scores[1] <= scores[0]; } - public bool IsSoftCapReached() => CountTotalLearnerTurns() == SoftCapTotalTurns; - - public bool IsHardCapReached() => CountTotalLearnerTurns() == HardCapTotalTurns; - - public int GetProbeLevelFor(string target) + public ConversationTurn AddLearnerTurn(string content, TurnEvaluation evaluation) { - var max = Turns - .Where(t => t.Role == TurnRole.System && t.Probe?.Target == target) - .Select(t => t.Probe!.Level) - .DefaultIfEmpty(0) - .Max(); - return max + 1; - } - - public ActiveProbe? GetLastProbe() - { - return Turns - .Where(t => t.Role == TurnRole.System && t.Probe != null) - .OrderByDescending(t => t.Order) - .Select(t => t.Probe) - .FirstOrDefault(); - } - - public bool IsScaffolding(int ladderLevel) => ladderLevel > ProbeLadderLength; - - public int FirstScaffoldLadderLevel => ProbeLadderLength + 1; - - public IReadOnlySet GetStalledTargets() - { - return Turns - .Where(t => t.Role == TurnRole.System && t.Probe != null && t.Probe.Level >= StalledThreshold) - .Select(t => t.Probe!.Target) - .ToHashSet(); - } - - public ActiveProbe? GetNextProbe() - { - var last = GetLastProbe(); - if (last == null || GetStalledTargets().Contains(last.Target)) return null; - return new ActiveProbe(last.Target, GetProbeLevelFor(last.Target)); - } + var turn = new ConversationTurn(content, _turns.Count, evaluation); + _turns.Add(turn); - public int CountNonSubstantiveClosingTurns() - { - if (TurnCountAtClosingStart == null) return 0; - return Turns - .Skip(TurnCountAtClosingStart.Value) - .Count(t => t.Role == TurnRole.Learner && t.Intent != TurnIntent.Substantive); + FinalGrade = evaluation.ComputeGrade(TotalTargets); + RoundCount++; + return turn; } - public ConversationTurn AddLearnerTurn(string content, TurnIntent intent, TurnEvaluation? evaluation = null) + public ConversationTurn AddSystemTurn(string content, IReadOnlyList feedbackTargets) { - var turn = new ConversationTurn(TurnRole.Learner, content, _turns.Count, intent, evaluation); + var turn = new ConversationTurn(content, _turns.Count, feedbackTargets); _turns.Add(turn); return turn; } - public ConversationTurn AddSystemTurn(string content, ActiveProbe? probe = null) + public IReadOnlyList SelectFeedbackTargets(int maxItems = 2) { - var turn = new ConversationTurn(TurnRole.System, content, _turns.Count, - intent: null, evaluation: null, probe: probe); - _turns.Add(turn); - return turn; + var latestEvaluation = Turns[^1].Evaluation!; + + var lastSurfacedGrade = BuildLastSurfacedGradeMap(); + var targets = new List(); + + foreach (var key in latestEvaluation.MisconceptionsTriggeredKeys) + { + targets.Add(new FeedbackTarget(key, TargetType.Misconception, 0, lastSurfacedGrade.ContainsKey(key))); + if (targets.Count >= maxItems) return targets; + } + + var subpar = latestEvaluation.Assessments + .Where(a => a.Grade < 2) + .Select(a => + { + bool surfaced = lastSurfacedGrade.TryGetValue(a.Key, out var prevGrade); + bool improved = surfaced && a.Grade > prevGrade; + bool stagnant = surfaced && !improved; + return (a.Key, a.Type, a.Grade, NeedsSupport: stagnant, + GroupOrder: improved ? 0 : !surfaced ? 1 : 2); + }) + .OrderBy(x => x.GroupOrder) + .ThenBy(x => x.Grade == -1 ? 0 : x.Grade == 1 ? 1 : 2); + + foreach (var item in subpar) + { + targets.Add(new FeedbackTarget(item.Key, item.Type, item.Grade, item.NeedsSupport)); + if (targets.Count >= maxItems) return targets; + } + + return targets; } - public void TransitionToClosing(string closingMessage) + private Dictionary BuildLastSurfacedGradeMap() { - AddSystemTurn(closingMessage); - Status = AttemptStatus.InClosing; - TurnCountAtClosingStart = _turns.Count; + var result = new Dictionary(); + foreach (var systemTurn in Turns + .Where(t => t.Role == TurnRole.System && t.FeedbackTargets.Count > 0) + .OrderByDescending(t => t.Order)) + { + foreach (var target in systemTurn.FeedbackTargets) + { + if (!result.ContainsKey(target.Key)) + result[target.Key] = target.Grade; + } + } + return result; } - public void Complete(int grade) + public void Complete() { - Summary = $"{grade} / 10"; - AddSystemTurn(Summary); - Status = AttemptStatus.Completed; CompletedAt = DateTime.UtcNow; } @@ -140,11 +122,14 @@ public void Abandon() CompletedAt = DateTime.UtcNow; } - public void Expire(string summary) + public void Expire() { - AddSystemTurn(summary); Status = AttemptStatus.Expired; CompletedAt = DateTime.UtcNow; - Summary = summary; + } + + public bool IsGoodEnough() + { + return FinalGrade > 0.9; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs index 2cfd391eb..0aefc9b3f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs @@ -9,24 +9,28 @@ public class ConversationTurn : Entity public string Content { get; private set; } = string.Empty; public int Order { get; private set; } public DateTime Timestamp { get; private set; } - public TurnIntent? Intent { get; private set; } public TurnEvaluation? Evaluation { get; private set; } - public ActiveProbe? Probe { get; private set; } + + public IReadOnlyList FeedbackTargets { get; private set; } = []; private ConversationTurn() { } - internal ConversationTurn(TurnRole role, string content, int order, - TurnIntent? intent = null, TurnEvaluation? evaluation = null, - ActiveProbe? probe = null) + internal ConversationTurn(string content, int order, TurnEvaluation? evaluation) { - if(role == TurnRole.Learner && intent == TurnIntent.Substantive && evaluation == null) - throw new ArgumentException("Substantive learner turns must have an evaluation."); - Role = role; + Role = TurnRole.Learner; + Timestamp = DateTime.UtcNow; Content = content; Order = order; - Timestamp = DateTime.UtcNow; - Intent = intent; Evaluation = evaluation; - Probe = probe; + FeedbackTargets = []; + } + + internal ConversationTurn(string content, int order, IReadOnlyList feedbackTargets) + { + Role = TurnRole.System; + Timestamp = DateTime.UtcNow; + Content = content; + Order = order; + FeedbackTargets = feedbackTargets; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs new file mode 100644 index 000000000..41080c9be --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs @@ -0,0 +1,3 @@ +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public record FeedbackTarget(string Key, TargetType Type, int Grade, bool NeedsSupport); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs index f75ef0f75..97dbb1a7e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs @@ -1,5 +1,3 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; -public record ScoredTarget(string Key, ScoredTargetType Type, int Grade); - -public enum ScoredTargetType { Proposition, Relation } +public record ScoredTarget(string Key, TargetType Type, int Grade); \ 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/Domain/Conversations/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs index 5175a1679..ee85dd354 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs @@ -7,36 +7,20 @@ public class TurnEvaluation : Entity public int ConversationTurnId { get; private set; } public List Assessments { get; private set; } = []; public List MisconceptionsTriggeredKeys { get; private set; } = []; - public bool HasMultipleConcerns { get; private set; } - - public IReadOnlyList PropositionsCoveredKeys() - { - return Assessments.Where(a => a.Type == ScoredTargetType.Proposition && a.Grade == 3) - .Select(a => a.Key).ToList(); - } - - public IReadOnlyList RelationsArticulatedKeys() - { - return Assessments.Where(a => a.Type == ScoredTargetType.Relation && a.Grade == 3) - .Select(a => a.Key).ToList(); - } - - public bool HasBroadCoverage(int totalRubricItems) => - totalRubricItems > 0 && Assessments.Count(a => a.Grade >= 2) / (double)totalRubricItems >= 0.8; private TurnEvaluation() { } - public TurnEvaluation(List assessments, List misconceptionsTriggeredKeys, bool hasMultipleConcerns) + public TurnEvaluation(List assessments, List misconceptionsTriggeredKeys) { Assessments = assessments; MisconceptionsTriggeredKeys = misconceptionsTriggeredKeys; - HasMultipleConcerns = hasMultipleConcerns; } - public int ComputeGrade(int totalTargets) + public int TotalScore() => Assessments.Sum(a => a.Grade); + + public double ComputeGrade(int totalTargets) { - var actualScore = Assessments.Sum(a => a.Grade) - MisconceptionsTriggeredKeys.Count; - var maxScore = 3.0 * totalTargets; - return Math.Max((int)Math.Round(actualScore / maxScore * 10), 0); + var normalizedScore = Assessments.Sum(a => a.Grade) / (2.0 * totalTargets); + return Math.Round(Math.Max(0.0, normalizedScore - 0.2 * MisconceptionsTriggeredKeys.Count), 2); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs deleted file mode 100644 index 506a5d6b3..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnIntent.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Tutor.Elaborations.Core.Domain.Conversations; - -public enum TurnIntent -{ - Substantive, - Clarification, - OffTopic, - Stuck, - SummaryRequest -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs index fd58e12a1..be9972bfe 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -17,7 +17,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning; public class ConversationService : IConversationService { - private const int MaxAttemptsPerDay = 30; + private const int MaxAttemptsPerDay = 3; private readonly IConversationAttemptRepository _attemptRepo; private readonly IConceptElaborationTaskRepository _taskRepo; @@ -75,9 +75,10 @@ public Result GetTaskWithAttempts(int taskId, int lea return Result.Ok(dto); } - public async IAsyncEnumerable StartConversationAsync(int taskId, string content, int learnerId, [EnumeratorCancellation] CancellationToken ct) + public async IAsyncEnumerable StartConversationAsync(int taskId, string elaboration, int learnerId, + [EnumeratorCancellation] CancellationToken ct) { - var (task, taskError) = ValidateTaskAccess(taskId, learnerId, content); + var (task, taskError) = ValidateTaskAccess(taskId, learnerId, elaboration); if (taskError != null) { yield return taskError; yield break; } var existing = _attemptRepo.GetActiveAttempt(taskId, learnerId); @@ -87,8 +88,7 @@ public async IAsyncEnumerable StartConversationAsync(int taskId, string yield break; } - var recentCount = _attemptRepo.CountRecentAttempts( - taskId, learnerId, DateTime.UtcNow.AddHours(-24)); + 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); @@ -99,21 +99,26 @@ public async IAsyncEnumerable StartConversationAsync(int taskId, string _attemptRepo.Create(attempt); _unitOfWork.Save(); - await foreach (var token in RunTurnPipelineAsync(attempt, task, content, ct)) + await foreach (var token in RunSubmissionPipelineAsync(attempt, task, elaboration, ct)) yield return token; } - public async IAsyncEnumerable SubmitTurnAsync(int attemptId, string content, int learnerId, [EnumeratorCancellation] CancellationToken ct) + 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 is not (AttemptStatus.InProgress or AttemptStatus.InClosing)) { yield return BuildErrorChunk("Conversation is no longer active.", 409); 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, content); + var (task, taskError) = ValidateTaskAccess(attempt.ConceptElaborationTaskId, learnerId, elaboration); if (taskError != null) { yield return taskError; yield break; } - await foreach (var token in RunTurnPipelineAsync(attempt, task!, content, ct)) + await foreach (var token in RunSubmissionPipelineAsync(attempt, task!, elaboration, ct)) yield return token; } @@ -122,7 +127,7 @@ public Result AbandonAttempt(int attemptId, int learnerI 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 is not (AttemptStatus.InProgress or AttemptStatus.InClosing)) + if (attempt.Status != AttemptStatus.InProgress) return Result.Fail(FailureCode.Conflict); attempt.Abandon(); @@ -132,12 +137,12 @@ public Result AbandonAttempt(int attemptId, int learnerI return Result.Ok(_mapper.Map(attempt)); } - private async IAsyncEnumerable RunTurnPipelineAsync(ConversationAttempt attempt, - ConceptElaborationTask task, string content, [EnumeratorCancellation] CancellationToken ct) + 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.ProcessTurnAsync(task.ConceptRecord, attempt, content, ct)) + await foreach (var chunk in _orchestrator.ProcessSubmissionAsync(task.ConceptRecord, attempt, elaboration, ct)) { switch (chunk) { @@ -150,7 +155,7 @@ private async IAsyncEnumerable RunTurnPipelineAsync(ConversationAttempt yield break; case FinalChunk final: - _unitOfWork.Save(); // Save attempt status and turn additions + _unitOfWork.Save(); _tokenSpendingService.SpendTokensForUnit(new TokenSpendingRequestDto { LearnerId = attempt.LearnerId, @@ -159,26 +164,25 @@ private async IAsyncEnumerable RunTurnPipelineAsync(ConversationAttempt CompletionTokens = final.Usage.CompletionTokens, FeatureType = "Elaboration", EntityId = task.Id, - PromptSummary = $"Conversation turn for attempt: {final.AttemptId}" + PromptSummary = $"Elaboration submission for attempt: {final.AttemptId}" }); - yield return JsonSerializer.Serialize(new SubmitTurnResponseDto + yield return JsonSerializer.Serialize(new SubmitElaborationResponseDto { AttemptId = final.AttemptId, - Status = final.Status.ToString(), - Summary = final.Summary + Status = final.Status.ToString() }); yield break; } } } - private (ConceptElaborationTask? Task, string? Error) ValidateTaskAccess(int taskId, int learnerId, string content) + 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, content.Length); + 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); 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 index 48bacae9a..8f45d7feb 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -1,7 +1,7 @@ -using FluentResults; -using Microsoft.Extensions.Logging; 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; @@ -12,8 +12,6 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public class AgentOrchestrator : LlmCaller, IAgentOrchestrator { - private const int MaxNonSubstantiveClosingTurns = 3; - private readonly ITurnUsageTracker _usageTracker; private readonly ILogger _logger; @@ -24,246 +22,57 @@ public AgentOrchestrator(IAiChatService chatService, ITurnUsageTracker usageTrac _logger = logger; } - public async IAsyncEnumerable ProcessTurnAsync(ConceptRecord record, ConversationAttempt attempt, - string newMessage, [EnumeratorCancellation] CancellationToken ct) + public async IAsyncEnumerable ProcessSubmissionAsync( + ConceptRecord record, ConversationAttempt attempt, string elaboration, + [EnumeratorCancellation] CancellationToken ct) { - using var turnScope = _logger.BeginScope(new Dictionary + using var scope = _logger.BeginScope(new Dictionary { ["AttemptId"] = attempt.Id, - ["TurnOrd"] = attempt.Turns.Count + ["RoundCount"] = attempt.RoundCount }); - var intentResult = await ClassifyIntentAsync(record, attempt.Turns, newMessage, ct); - if (intentResult.IsFailed) + var scoreResult = await ScoreElaborationAsync(record, elaboration, ct); + if (scoreResult.IsFailed) { - yield return new ErrorChunk("Intent classification failed.", 500); + yield return new ErrorChunk("Scoring failed.", 500); yield break; } - var intent = intentResult.Value; - - if (attempt.Status == AttemptStatus.InProgress) - { - await foreach (var chunk in HandleProgressTurnAsync(record, attempt, newMessage, intent, ct)) - yield return chunk; - } - else if(attempt.Status == AttemptStatus.InClosing) - { - await foreach (var chunk in HandleClosingTurnAsync(record, attempt, newMessage, intent, ct)) - yield return chunk; - } - } - - private async IAsyncEnumerable HandleProgressTurnAsync(ConceptRecord record, - ConversationAttempt attempt, string newMessage, TurnIntent intent, [EnumeratorCancellation] CancellationToken ct) - { - TurnEvaluation? evaluation = null; - if (intent == TurnIntent.Substantive) - { - var scoreResult = await ScoreTurnAsync(record, attempt.Turns, newMessage, ct); - if (scoreResult.IsFailed) - { - yield return new ErrorChunk("Scoring failed.", 500); - yield break; - } - evaluation = scoreResult.Value; - } + var evaluation = scoreResult.Value; - attempt.AddLearnerTurn(newMessage, intent, evaluation); + attempt.AddLearnerTurn(elaboration, evaluation); - if (record.IsAttemptComplete(attempt) || attempt.IsHardCapReached()) + if (attempt.IsGoodEnough()) { - attempt.TransitionToClosing(SystemTurnCodes.InClosingTransition); - yield return new TokenChunk(SystemTurnCodes.InClosingTransition); + attempt.Complete(); yield return CreateFinalChunk(attempt); yield break; } - var handler = intent switch + if (attempt.IsHardCapReached()) { - TurnIntent.Substantive => HandleSubstantiveAsync(record, attempt, evaluation!, ct), - TurnIntent.Stuck => HandleStuckAsync(record, attempt, ct), - TurnIntent.Clarification => HandleClarificationAsync(record, attempt, ct), - TurnIntent.SummaryRequest => HandleSummaryRequestAsync(record, attempt, ct), - _ => HandleOffTopicAsync(attempt) - }; - await foreach (var chunk in handler.WithCancellation(ct)) - yield return chunk; - } - - private async IAsyncEnumerable HandleSubstantiveAsync(ConceptRecord record, - ConversationAttempt attempt, TurnEvaluation evaluation, [EnumeratorCancellation] CancellationToken ct) - { - CompletionRequest request; - string label; - ActiveProbe? probe = null; - - if (evaluation.HasMultipleConcerns && !evaluation.HasBroadCoverage(record.CountTargets())) - { - request = LlmRequestFactory.ForCritique(record, attempt.Turns, evaluation); - label = "Critique"; - } - else - { - var next = record.PickNextTarget(attempt); - if (next == null) - { - attempt.TransitionToClosing(SystemTurnCodes.InClosingTransition); - yield return new TokenChunk(SystemTurnCodes.InClosingTransition); - yield return CreateFinalChunk(attempt); - yield break; - } - var level = attempt.GetProbeLevelFor(next); - probe = new ActiveProbe(next, level); - var isScaffolding = attempt.IsScaffolding(level); - request = isScaffolding - ? LlmRequestFactory.ForScaffolding(record, attempt.Turns, probe) - : LlmRequestFactory.ForProbing(record, attempt.Turns, probe); - label = isScaffolding ? "Scaffolding" : "Probing"; - } - - var fullResponse = new StringBuilder(); - await foreach (var chunk in StreamAgentAsync(request, label, fullResponse, ct)) - { - yield return chunk; - if (chunk is ErrorChunk) yield break; - } - - if (attempt.IsSoftCapReached()) - { - fullResponse.Append(SystemTurnCodes.SoftCapNudge); - yield return new TokenChunk(SystemTurnCodes.SoftCapNudge); - } - - attempt.AddSystemTurn(fullResponse.ToString(), probe); - yield return CreateFinalChunk(attempt); - } - - private async IAsyncEnumerable HandleStuckAsync(ConceptRecord record, - ConversationAttempt attempt, [EnumeratorCancellation] CancellationToken ct) - { - var probe = GetNextProbe(record, attempt); - - if (probe == null) - { - attempt.TransitionToClosing(SystemTurnCodes.InClosingTransition); - yield return new TokenChunk(SystemTurnCodes.InClosingTransition); + attempt.Expire(); + yield return new TokenChunk(SystemTurnCodes.ExpiredNotice); yield return CreateFinalChunk(attempt); yield break; } + var targets = attempt.SelectFeedbackTargets(); var fullResponse = new StringBuilder(); - await foreach (var chunk in StreamAgentAsync(LlmRequestFactory.ForScaffolding(record, attempt.Turns, probe), "Scaffolding", fullResponse, ct)) + await foreach (var chunk in StreamAgentAsync( + LlmRequestFactory.ForEvaluationFeedback(record, elaboration, targets), "EvaluationFeedback", fullResponse, ct)) { yield return chunk; if (chunk is ErrorChunk) yield break; } - if (attempt.IsSoftCapReached()) + if (attempt.IsStagnating()) { - fullResponse.Append(SystemTurnCodes.SoftCapNudge); - yield return new TokenChunk(SystemTurnCodes.SoftCapNudge); + fullResponse.Append(SystemTurnCodes.StagnationRedirect); + yield return new TokenChunk(SystemTurnCodes.StagnationRedirect); } - attempt.AddSystemTurn(fullResponse.ToString(), probe); - yield return CreateFinalChunk(attempt); - } - - private static ActiveProbe? GetNextProbe(ConceptRecord record, ConversationAttempt attempt) - { - var nextProbe = attempt.GetNextProbe(); - if (nextProbe != null) return nextProbe; - var nextTarget = record.PickNextTarget(attempt); - return nextTarget == null ? null : new ActiveProbe(nextTarget, attempt.FirstScaffoldLadderLevel); - } - - private async IAsyncEnumerable HandleClarificationAsync(ConceptRecord record, - ConversationAttempt attempt, [EnumeratorCancellation] CancellationToken ct) - { - var fullResponse = new StringBuilder(); - await foreach (var chunk in StreamAgentAsync(LlmRequestFactory.ForClarification(record, attempt.Turns, attempt.GetLastProbe()), "Clarification", fullResponse, ct)) - { - yield return chunk; - if (chunk is ErrorChunk) yield break; - } - - if (attempt.IsSoftCapReached()) - { - fullResponse.Append(SystemTurnCodes.SoftCapNudge); - yield return new TokenChunk(SystemTurnCodes.SoftCapNudge); - } - - attempt.AddSystemTurn(fullResponse.ToString()); - yield return CreateFinalChunk(attempt); - } - - private async IAsyncEnumerable HandleSummaryRequestAsync(ConceptRecord record, - ConversationAttempt attempt, [EnumeratorCancellation] CancellationToken ct) - { - var fullResponse = new StringBuilder(); - await foreach (var chunk in StreamAgentAsync(LlmRequestFactory.ForSummary(record, attempt.Turns), "Summary", fullResponse, ct)) - { - yield return chunk; - if (chunk is ErrorChunk) yield break; - } - - if (attempt.IsSoftCapReached()) - { - fullResponse.Append(SystemTurnCodes.SoftCapNudge); - yield return new TokenChunk(SystemTurnCodes.SoftCapNudge); - } - - attempt.AddSystemTurn(fullResponse.ToString()); - yield return CreateFinalChunk(attempt); - } - - #pragma warning disable CS1998 - private async IAsyncEnumerable HandleOffTopicAsync(ConversationAttempt attempt) - #pragma warning restore CS1998 - { - var fullResponse = new StringBuilder(SystemTurnCodes.OffTopic); - yield return new TokenChunk(SystemTurnCodes.OffTopic); - - if (attempt.IsSoftCapReached()) - { - fullResponse.Append(SystemTurnCodes.SoftCapNudge); - yield return new TokenChunk(SystemTurnCodes.SoftCapNudge); - } - - attempt.AddSystemTurn(fullResponse.ToString()); - yield return CreateFinalChunk(attempt); - } - - private async IAsyncEnumerable HandleClosingTurnAsync(ConceptRecord record, - ConversationAttempt attempt, string newMessage, TurnIntent intent, [EnumeratorCancellation] CancellationToken ct) - { - if (intent == TurnIntent.Substantive) - { - var scoreResult = await ScoreClosingAsync(record, newMessage, ct); - if (scoreResult.IsFailed) - { - yield return new ErrorChunk("Closing scoring failed.", 500); - yield break; - } - attempt.AddLearnerTurn(newMessage, intent, scoreResult.Value); - var grade = scoreResult.Value.ComputeGrade(record.CountTargets()); - attempt.Complete(grade); - yield return new TokenChunk(attempt.Summary!); - yield return CreateFinalChunk(attempt, attempt.Summary); - yield break; - } - - attempt.AddLearnerTurn(newMessage, intent); - - if (attempt.CountNonSubstantiveClosingTurns() >= MaxNonSubstantiveClosingTurns) - { - attempt.Expire(SystemTurnCodes.ExpiredNotice); - yield return new TokenChunk(SystemTurnCodes.ExpiredNotice); - yield return CreateFinalChunk(attempt); - yield break; - } - - attempt.AddSystemTurn(SystemTurnCodes.NonSubstantiveInClosingNudge); - yield return new TokenChunk(SystemTurnCodes.NonSubstantiveInClosingNudge); + attempt.AddSystemTurn(fullResponse.ToString(), targets); yield return CreateFinalChunk(attempt); } @@ -282,35 +91,14 @@ private async IAsyncEnumerable StreamAgentAsync(CompletionReq yield return new ErrorChunk(failure.Reason, 500); } - private FinalChunk CreateFinalChunk(ConversationAttempt attempt, string? summary = null) - => new(attempt.Id, attempt.Status, summary, _usageTracker.Total); - - private async Task> ClassifyIntentAsync(ConceptRecord record, - IReadOnlyList history, string newMessage, CancellationToken ct) - { - var result = await CompleteJsonAsync( - LlmRequestFactory.ForIntentClassification(record, history, newMessage), "IntentClassification", ct); - if (result.IsFailed) return Result.Fail(result.Errors); - return Enum.TryParse(result.Value.Intent, ignoreCase: true, out var intent) - ? intent - : Result.Fail("Unrecognized intent."); - } - - private async Task> ScoreTurnAsync(ConceptRecord record, - IReadOnlyList history, string newMessage, CancellationToken ct) + private async Task> ScoreElaborationAsync(ConceptRecord record, string elaboration, CancellationToken ct) { - var result = await CompleteJsonAsync( - LlmRequestFactory.ForTurnScoring(record, history, newMessage), "TurnScoring", 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 async Task> ScoreClosingAsync(ConceptRecord record, - string newMessage, CancellationToken ct) - { - var result = await CompleteJsonAsync( - LlmRequestFactory.ForClosingScoring(record, newMessage), "ClosingScoring", 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 index 02c376639..03d4fa2f4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestrator.cs @@ -5,6 +5,6 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public interface IAgentOrchestrator { - IAsyncEnumerable ProcessTurnAsync( - ConceptRecord record, ConversationAttempt attempt, string newMessage, CancellationToken ct); + 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 index c8b23b0de..98f5fe624 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs @@ -10,7 +10,7 @@ public sealed record TokenChunk(string Token) : OrchestratorChunk; public sealed record FinalChunk( int AttemptId, AttemptStatus Status, - string? Summary, + 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 index d8f8b076e..f452cb195 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/SystemTurnCodes.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/SystemTurnCodes.cs @@ -2,9 +2,6 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; public static class SystemTurnCodes { - public const string SoftCapNudge = "SOFT_CAP\n"; - public const string InClosingTransition = "CLOSING_TRANSITION\n"; - public const string NonSubstantiveInClosingNudge = "CLOSING_NUDGE\n"; public const string ExpiredNotice = "EXPIRED\n"; - public const string OffTopic = "OFF_TOPIC\n"; + public const string StagnationRedirect = "STAGNATION_REDIRECT\n"; } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ClarificationPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ClarificationPrompt.cs deleted file mode 100644 index 9d7bd7f9b..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ClarificationPrompt.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -public static class ClarificationPrompt -{ - public static string Build(ConceptRecord record) - { - var sb = new StringBuilder(); - sb.AppendLine(ConceptRubricSection.Render(record)); - - sb.AppendLine("# Role"); - sb.AppendLine("You are a Socratic tutoring assistant. Speak Serbian."); - sb.AppendLine("The learner has asked a clarifying question. Rephrase or clarify the TUTOR's prior question in simpler language. Do NOT answer it — answering defeats the whole exercise."); - sb.AppendLine(); - - sb.AppendLine("# Rules"); - sb.AppendLine("- Max three sentences. Address only the specific thing asked."); - sb.AppendLine("- If the learner says they don't understand, rephrase the TUTOR's prior question in simpler terms. Do not answer the question under the guise of rephrasing."); - sb.AppendLine("- If the learner asks for a summary, \"the answer\", an explanation, or anything that would require producing the concept's content, REFUSE and redirect: \"Rezime i objašnjenje moraju doći od tebe — to je ono što vežbamo. Pokušaj da formulišeš svojim rečima.\""); - sb.AppendLine("- If a is given in the runtime context, it is INTERNAL — NEVER state or paraphrase that target. The whole point is that the learner articulates it."); - sb.AppendLine("- NEVER produce a list or multi-sentence breakdown."); - sb.AppendLine("- After clarifying, invite the learner to resume with one short prompt."); - sb.AppendLine(); - - sb.AppendLine("# Runtime Context Format"); - sb.AppendLine("Chat history shows the conversation so far. The last user message is the learner's clarification request."); - sb.AppendLine("The final user message contains the learner's latest turn, optionally followed by (the TUTOR's prior probe — INTERNAL reference only)."); - - return sb.ToString(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs deleted file mode 100644 index 1be4c4f90..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/CritiquePrompt.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -public static class CritiquePrompt -{ - public static string Build(ConceptRecord record) - { - var sb = new StringBuilder(); - sb.AppendLine(ConceptRubricSection.Render(record)); - - sb.AppendLine("# Role"); - sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); - sb.AppendLine("The learner's latest answer has multiple concerns. Ask focused questions so the learner can identify and fix the gaps themselves."); - sb.AppendLine(); - - sb.AppendLine("# Rules"); - sb.AppendLine("- Surface at most 3 concerns, drawn only from and in the tag."); - sb.AppendLine("- Priority order: (1) at most one triggered misconception — name it explicitly; (2) grade-1 (vague) KP/KR items to fill remaining slots."); - sb.AppendLine("- NEVER raise a KP or KR the learner already articulated well in a prior turn."); - sb.AppendLine("- NEVER provide answers, definitions, or explanations. NEVER reveal any KP/KR/CM text verbatim or paraphrased."); - sb.AppendLine("- Frame each concern as a bullet point with a Socratic question targeting the specific gap."); - sb.AppendLine("- Close with a brief invitation to respond. No summary of the questions."); - sb.AppendLine(); - - sb.AppendLine("# Runtime Context Format"); - sb.AppendLine("Chat history shows the conversation so far (user=learner, assistant=tutor). The latest learner turn is the last user message."); - sb.AppendLine("The final user message contains the learner's latest turn, followed by (vague items and triggered misconceptions for the latest turn)."); - - return sb.ToString(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs new file mode 100644 index 000000000..365592648 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs @@ -0,0 +1,44 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +public static class EvaluationFeedbackPrompt +{ + public static string Build(ConceptRecord record) + { + var sb = new StringBuilder(); + sb.AppendLine(ConceptRubricSection.Render(record)); + + sb.AppendLine("# Role"); + sb.AppendLine("Ti si evaluator koji daje dijagnostičku povratnu informaciju učeniku koji vežba"); + sb.AppendLine("artikulaciju koncepta za usmeni ispit. Tvoj cilj je da usmeravaš, ne da podučavaš."); + sb.AppendLine("Nikada ne davaj tačan odgovor niti citiraj tekst iz rubrike."); + sb.AppendLine(); + + sb.AppendLine("# Runtime kontekst"); + sb.AppendLine("Dobijaš:"); + sb.AppendLine(" : tekst koji je učenik napisao"); + sb.AppendLine(" : nedostaci u elaboraciji, svrstani po kategoriji i ključu iz rubrike"); + sb.AppendLine(" : pogrešno razumevanje (CM ključ)"); + sb.AppendLine(" : nedostatak KP/KR"); + sb.AppendLine(); + + sb.AppendLine("# Pravila za odabir stavki"); + sb.AppendLine("Stavke su već odabrane i prioritizovane. Daj povratnu informaciju za svaku stavku u ."); + sb.AppendLine(); + + sb.AppendLine("# Eskalacija na osnovu needsSupport"); + sb.AppendLine(" needsSupport=false: Postavi fokusirano pitanje koje sugeriše da nešto nije jasno,"); + sb.AppendLine(" bez otkrivanja odgovora."); + sb.AppendLine(" needsSupport=true: Imenuj problem direktno i kratko ispravi, ali bez navođenja"); + sb.AppendLine(" tačnog teksta ključnih proposicija ili relacija iz rubrike."); + sb.AppendLine(); + + sb.AppendLine("# Format izlaza"); + sb.AppendLine("Samo tekst povratne informacije na srpskom. Svaka stavka u novom paragrafu."); + sb.AppendLine("Bez naslova, bez numerisanja, bez dodatnog teksta."); + + return sb.ToString(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentPrompt.cs deleted file mode 100644 index f1c226f04..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentPrompt.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -public static class IntentPrompt -{ - public static string Build(ConceptRecord record) - { - var sb = new StringBuilder(); - sb.AppendLine(ConceptRubricSection.Render(record)); - - sb.AppendLine("# Role"); - sb.AppendLine("You are an intent classifier for a Socratic tutoring conversation."); - sb.AppendLine("Classify the learner's latest message into exactly one intent. Output JSON only, no other text."); - sb.AppendLine(); - - sb.AppendLine("# Intent categories"); - sb.AppendLine("- **Substantive**: the learner attempts to explain, define, relate, or apply the concept. Even a weak or partial attempt counts."); - sb.AppendLine("- **Clarification**: the learner asks a genuine information-seeking question about the task or the tutor's last message (e.g. what do you mean by …? / what is expected …? / what should I do …?). Must be a direct question."); - sb.AppendLine("- **Stuck**: the learner signals confusion, inability, or not-knowing without asking a question — e.g. I don't know… / I don't understand… / I am not sure… / This is hard…. Not a refusal of the task, just a stall."); - sb.AppendLine("- **SummaryRequest**: the learner asks a recap for the conversation itself — e.g. summarize what was said… / list what was correct so far…"); - sb.AppendLine("- **OffTopic**: everything else — small talk, greetings, jokes, personal content, refusals, meta-comments about the conversation, deference or agreement without articulation"); - sb.AppendLine(); - - sb.AppendLine("# Disambiguation rules"); - sb.AppendLine("- If the message is a verbatim or near-verbatim echo of a previous assistant line, classify as OffTopic."); - sb.AppendLine("- Between Clarification and Stuck: if the message is a question, Clarification; if it is a statement of not-knowing, Stuck."); - sb.AppendLine("- Between Clarification and SummaryRequest: SummaryRequest is about the conversation/progress itself; Clarification is about the concept or the tutor's last probe."); - sb.AppendLine("- When in doubt between Clarification and OffTopic, choose OffTopic."); - sb.AppendLine(); - - sb.AppendLine("# Runtime Context Format"); - sb.AppendLine("Chat history shows prior turns (user=learner, assistant=tutor) for context only — DO NOT classify these."); - sb.AppendLine("The final user message contains the message to classify inside ."); - sb.AppendLine(); - - sb.AppendLine("# Output Format"); - sb.AppendLine("JSON only, no other text:"); - sb.AppendLine("{ \"intent\": \"Substantive\" | \"Clarification\" | \"Stuck\" | \"SummaryRequest\" | \"OffTopic\" }"); - - return sb.ToString(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentResponse.cs deleted file mode 100644 index 7e90196d6..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/IntentResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -public class IntentResponse -{ - public string? Intent { get; set; } -} 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 index 3a8550dbe..91cf0dd5a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs @@ -7,92 +7,33 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public static class LlmRequestFactory { - public static CompletionRequest ForProbing(ConceptRecord record, IReadOnlyList turns, - ActiveProbe probe) + public static CompletionRequest ForElaborationScoring(ConceptRecord record, string elaboration) { - return CompletionRequest.Create(ToMessages(turns, RenderProbe(probe)), ProbePrompt.Build(record), maxTokens: 256, temperature: 0.7); - } - - public static CompletionRequest ForScaffolding(ConceptRecord record, IReadOnlyList turns, - ActiveProbe probe) - { - return CompletionRequest.Create(ToMessages(turns, RenderProbe(probe)), ScaffoldingPrompt.Build(record), maxTokens: 1024, temperature: 0.7); - } - - public static CompletionRequest ForClarification(ConceptRecord record, IReadOnlyList turns, - ActiveProbe? lastProbe) - { - return CompletionRequest.Create(ToMessages(turns, lastProbe != null ? RenderProbe(lastProbe) : null), ClarificationPrompt.Build(record), maxTokens: 256, temperature: 0.5); - } - - public static CompletionRequest ForCritique(ConceptRecord record, IReadOnlyList turns, - TurnEvaluation evaluation) - { - return CompletionRequest.Create(ToMessages(turns, RenderEvaluation(evaluation)), CritiquePrompt.Build(record), maxTokens: 512, temperature: 0.7); - } - - public static CompletionRequest ForSummary(ConceptRecord record, IReadOnlyList turns) - { - return CompletionRequest.Create(ToMessages(turns), SummaryPrompt.Build(record), maxTokens: 512, temperature: 0.5); - } - - public static CompletionRequest ForIntentClassification(ConceptRecord record, IReadOnlyList turns, - string message) - { - var messages = ToMessages(turns.OrderBy(t => t.Order).TakeLast(6)); - messages.Add(ChatMessage.FromUser($"{message}")); - return CompletionRequest.Create(messages, IntentPrompt.Build(record), maxTokens: 64, temperature: 0.0); - } - - public static CompletionRequest ForTurnScoring(ConceptRecord record, IReadOnlyList turns, - string message) - { - var messages = ToMessages(turns); - messages.Add(ChatMessage.FromUser($"{message}")); + var messages = new List { ChatMessage.FromUser($"{elaboration}") }; return CompletionRequest.Create(messages, ScorePrompt.Build(record), maxTokens: 1024, temperature: 0.0); } - public static CompletionRequest ForClosingScoring(ConceptRecord record, string message) - { - var messages = new List { ChatMessage.FromUser($"{message}") }; - return CompletionRequest.Create(messages, ScorePrompt.Build(record, isClosingEvaluation: true), maxTokens: 1024, temperature: 0.0); - } - - private static List ToMessages(IEnumerable turns, string? appendToLast = null) - { - var messages = turns.OrderBy(t => t.Order) - .Select(t => t.Role == TurnRole.Learner - ? ChatMessage.FromUser(t.Content) - : ChatMessage.FromAssistant(t.Content)) - .ToList(); - - if (appendToLast == null) return messages; - - if (messages.Count > 0 && messages[^1].Role == ChatRole.User) - messages[^1] = ChatMessage.FromUser(messages[^1].Content + "\n" + appendToLast); - else - messages.Add(ChatMessage.FromUser(appendToLast)); - - return messages; - } - - private static string RenderProbe(ActiveProbe probe) + public static CompletionRequest ForEvaluationFeedback(ConceptRecord record, string elaboration, + IReadOnlyList targets) { - return $"{probe.Target}"; + var messages = new List { ChatMessage.FromUser(RenderFeedbackInput(elaboration, targets)) }; + return CompletionRequest.Create(messages, EvaluationFeedbackPrompt.Build(record), maxTokens: 512, temperature: 0.7); } - private static string RenderEvaluation(TurnEvaluation e) + private static string RenderFeedbackInput(string elaboration, IReadOnlyList targets) { var sb = new StringBuilder(); - sb.Append($""); - - var vagueKeys = e.Assessments.Where(a => a.Grade == 1).Select(a => a.Key).ToList(); - if (vagueKeys.Count > 0) - sb.Append($"{string.Join(", ", vagueKeys)}"); - if (e.MisconceptionsTriggeredKeys.Count > 0) - sb.Append($"{string.Join(", ", e.MisconceptionsTriggeredKeys)}"); - - sb.Append(""); + sb.AppendLine($"{elaboration}"); + sb.Append(""); + foreach (var t in targets) + { + var support = t.NeedsSupport.ToString().ToLowerInvariant(); + if (t.Type == TargetType.Misconception) + sb.Append($""); + else + sb.Append($""); + } + sb.Append(""); return sb.ToString(); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ProbePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ProbePrompt.cs deleted file mode 100644 index 0d1b6c0db..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ProbePrompt.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -public static class ProbePrompt -{ - public static string Build(ConceptRecord record) - { - var sb = new StringBuilder(); - sb.AppendLine(ConceptRubricSection.Render(record)); - - sb.AppendLine("# Role"); - sb.AppendLine("You are a Socratic tutoring agent. Speak Serbian."); - sb.AppendLine("Ask ONE question that probes the target given in the runtime context. Do not reveal the target text, do not list alternatives, do not summarize what the learner has said."); - sb.AppendLine(); - - sb.AppendLine("# Escalation levels"); - sb.AppendLine("The tag carries a level attribute (1-2) that shapes the question:"); - sb.AppendLine("- L1 — open \"why\" or \"what\" question that invites the learner to articulate the target. Broad enough to let them arrive at the idea themselves."); - sb.AppendLine("- L2 — the learner has already failed an L1 probe on this target. Ask a narrower, connective question: \"how is X tied to Y\", \"what role does X play when Y\". Still no hints to the target statement."); - sb.AppendLine(); - - sb.AppendLine("# Rules"); - sb.AppendLine("- The statement is INTERNAL — NEVER reveal or paraphrase it closely enough to give away the answer."); - sb.AppendLine("- Output ONE question. No preamble, no bullet list."); - sb.AppendLine("- NEVER list multiple options or enumerate gaps."); - sb.AppendLine("- Concise. Respect cognitive load."); - sb.AppendLine(); - - sb.AppendLine("# Runtime Context Format"); - sb.AppendLine("Chat history shows the conversation so far (user=learner, assistant=tutor)."); - sb.AppendLine("The final user message contains the learner's latest turn, followed by …statement…."); - - return sb.ToString(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScaffoldingPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScaffoldingPrompt.cs deleted file mode 100644 index 083cae251..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScaffoldingPrompt.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -public static class ScaffoldingPrompt -{ - public static string Build(ConceptRecord record) - { - var sb = new StringBuilder(); - sb.AppendLine(ConceptRubricSection.Render(record)); - - sb.AppendLine("# Role"); - sb.AppendLine("You are a Socratic tutoring scaffolding agent. Speak Serbian."); - sb.AppendLine("The learner has stalled on the target given in the runtime context. Provide a scaffold at the specified level that helps them articulate the target WITHOUT revealing it."); - sb.AppendLine(); - - sb.AppendLine("# Escalation levels"); - sb.AppendLine("The tag carries a level attribute (3-4) that shapes the scaffold:"); - sb.AppendLine("- **L3 — Worked example.** Produce ONE short concrete example (3–6 lines of code OR 2–3 sentence scenario) illustrating a CONTEXT where the target concept operates. End with one narrow question that forces the learner to name what is happening. The KP statements are INSPIRATION for the example only — never paraphrase them."); - sb.AppendLine("- **L4 — Contrasting pair.** Produce TWO short contrasting examples — one exhibits the target correctly, one violates it in a realistic way. If a common misconception is catalogued for this concept, prefer that as the \"violates\" case. Ask which example is correct and why. The \"why\" must require articulating the target."); - sb.AppendLine(); - - sb.AppendLine("# Rules"); - sb.AppendLine("- The statement is INTERNAL — NEVER reveal or paraphrase it closely enough to give it away. The scaffold must make the learner do the articulation."); - sb.AppendLine("- Keep examples short. Code: 3–6 lines. Scenarios: 2–3 sentences."); - sb.AppendLine("- End with ONE concrete question. The two L4 options count as structural formatting, not a bullet list of hints."); - sb.AppendLine("- Do NOT use analogies, sentence-completion blanks, or forced-choice between abstract phrasings."); - sb.AppendLine(); - - sb.AppendLine("# Runtime Context Format"); - sb.AppendLine("Chat history shows the conversation so far (including the learner's prior attempts on this target)."); - sb.AppendLine("The final user message contains the learner's latest turn, followed by …statement…."); - - return sb.ToString(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs index 69059f0a8..3f967d108 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs @@ -5,30 +5,38 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public static class ScorePrompt { - public static string Build(ConceptRecord record, bool isClosingEvaluation = false) + public static string Build(ConceptRecord record) { var sb = new StringBuilder(); sb.AppendLine(ConceptRubricSection.Render(record)); sb.AppendLine("# Role"); - sb.AppendLine("You are a scoring agent for a Socratic tutoring system. Output JSON only, no other text."); + sb.AppendLine("You are a scoring agent. Output JSON only, no other text."); sb.AppendLine(); - sb.AppendLine("# Scope rule"); - if (isClosingEvaluation) - sb.AppendLine("Score only the text inside . This is the learner's final consolidated answer submitted in isolation. Evaluate it as a standalone response."); - else - sb.AppendLine("Score only the text inside in the final user message. Do not credit the learner for content that appears in prior assistant turns or that the learner has only repeated from a preceding assistant turn."); + sb.AppendLine("# Scoring task"); + sb.AppendLine("Score the learner's Elaboration inside against every Key Proposition and Key Relation in the concept rubric above."); + sb.AppendLine("All KPs and KRs must appear in the output, even if not addressed."); + sb.AppendLine(); + + sb.AppendLine("Use this scale for Key Propositions:"); + sb.AppendLine(" -1 (Incorrect): The opposite of what is true, or so unrelated it signals clear misunderstanding."); + sb.AppendLine(" 0 (Missing): Not present, or stated so vaguely it conveys nothing useful."); + sb.AppendLine(" 1 (Vague): Present but imprecise or incomplete — too broad, omits a critical qualifier,"); + sb.AppendLine(" or a reader who didn't already know the concept could not reconstruct it from this statement alone."); + sb.AppendLine(" 2 (Adequate): Clearly and correctly stated. Specific enough to distinguish it from adjacent or general concepts."); sb.AppendLine(); - sb.AppendLine("# Rating task"); - sb.AppendLine("Rate EVERY Key Proposition and Key Relation from the concept rubric above. All must appear in the output, even if not addressed."); - sb.AppendLine("Use this scale:"); - sb.AppendLine(" 0 — not addressed in this message"); - sb.AppendLine(" 1 — vague or incomplete: concept touched but not clearly articulated"); - sb.AppendLine(" 2 — partially correct: core idea present but missing detail or precision"); - sb.AppendLine(" 3 — well-articulated: accurate and sufficiently complete"); - sb.AppendLine("For each item, set type to \"proposition\" for Key Propositions and \"relation\" for Key Relations."); + sb.AppendLine("Use this scale for Key Relations:"); + sb.AppendLine(" -1 (Incorrect): The opposite of what is true, or so unrelated it signals clear misunderstanding."); + sb.AppendLine(" 0 (Missing): The causal or conditional link between the two propositions is absent."); + sb.AppendLine(" 1 (Vague): Both propositions mentioned in proximity, but the mechanism connecting them"); + sb.AppendLine(" is not expressed — the learner lists rather than relates."); + sb.AppendLine(" 2 (Adequate): The mechanism is explicitly stated: why or under what condition one proposition"); + sb.AppendLine(" determines or constrains the other."); + sb.AppendLine(); + + sb.AppendLine("Score only what is explicitly written. Do not infer or credit implied content."); sb.AppendLine("Evaluate concepts, not language. Grammar and style must not reduce scores."); sb.AppendLine("Resist sycophancy. Evaluate strictly against the rubric."); sb.AppendLine(); @@ -36,15 +44,12 @@ public static string Build(ConceptRecord record, bool isClosingEvaluation = fals if (record.CommonMisconceptions.Count != 0) { sb.AppendLine("# Misconception detection"); - sb.AppendLine("List the keys of any known misconceptions triggered in this message."); + sb.AppendLine("Flag a misconception if the learner's text contains reasoning or claims that reflect that"); + sb.AppendLine("misunderstanding, even if the learner also states something correct nearby."); + sb.AppendLine("List the keys of any known misconceptions triggered in this Elaboration."); sb.AppendLine(); } - sb.AppendLine("# Runtime Context Format"); - sb.AppendLine("Chat history shows prior turns (user=learner, assistant=tutor) for context only — DO NOT score these."); - sb.AppendLine("The final user message contains the message to score inside ."); - sb.AppendLine(); - var assessmentExample = record.KeyPropositions.Count > 0 ? $"{{ \"key\": \"{record.KeyPropositions[0].Key}\", \"type\": \"proposition\", \"grade\": 0 }}" : "{ \"key\": \"P1\", \"type\": \"proposition\", \"grade\": 0 }"; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponseDto.cs similarity index 61% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponseDto.cs index 5e9181d71..6b062d129 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponse.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponseDto.cs @@ -4,18 +4,11 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; -public class ScoreResponse +public class ScoreResponseDto { public List? Assessments { get; set; } public List? MisconceptionsTriggeredKeys { get; set; } - public class ScoredTargetDto - { - public string Key { get; set; } = ""; - public string Type { get; set; } = ""; - public int Grade { get; set; } - } - public Result ToEvaluation(ConceptRecord record) { if (Assessments == null) return Result.Fail("Assessments missing."); @@ -26,18 +19,16 @@ public Result ToEvaluation(ConceptRecord record) 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 < 0 or > 3)) return Result.Fail("Grade out of range."); + if (Assessments.Any(a => a.Grade is < -1 or > 2)) return Result.Fail("Grade out of range."); - var creationResult = CreateScoredTargets(kpKeys, krKeys); - if(creationResult.IsFailed) return Result.Fail(creationResult.Errors); - var scoredTargets = creationResult.Value; + var scoredTargets = CreateScoredTargets(kpKeys, krKeys); + if (scoredTargets.IsFailed) return Result.Fail(scoredTargets.Errors); var misconceptions = MisconceptionsTriggeredKeys ?? []; var validCmKeys = record.CommonMisconceptions.Select(cm => cm.Key).ToHashSet(); if (misconceptions.Any(k => !validCmKeys.Contains(k))) return Result.Fail("Unknown misconception key."); - var concerns = scoredTargets.Count(a => a.Grade == 1) + misconceptions.Count; - return new TurnEvaluation(scoredTargets, misconceptions, hasMultipleConcerns: concerns >= 2); + return new TurnEvaluation(scoredTargets.Value, misconceptions); } private Result> CreateScoredTargets(HashSet kpKeys, HashSet krKeys) @@ -45,26 +36,30 @@ private Result> CreateScoredTargets(HashSet kpKeys, H var scoredTargets = new List(); foreach (var dto in Assessments!) { - ScoredTargetType? type = dto.Type.ToLowerInvariant() switch + TargetType? type = dto.Type.ToLowerInvariant() switch { - "proposition" => ScoredTargetType.Proposition, - "relation" => ScoredTargetType.Relation, + "proposition" => TargetType.Proposition, + "relation" => TargetType.Relation, _ => null }; switch (type) { case null: return Result.Fail($"Unknown type '{dto.Type}'."); - case ScoredTargetType.Proposition when !kpKeys.Contains(dto.Key): + case TargetType.Proposition when !kpKeys.Contains(dto.Key): return Result.Fail($"Key '{dto.Key}' typed as proposition but is a relation."); - case ScoredTargetType.Relation when !krKeys.Contains(dto.Key): + case TargetType.Relation when !krKeys.Contains(dto.Key): return Result.Fail($"Key '{dto.Key}' typed as relation but is a proposition."); } - - if (dto.Grade > 0) - scoredTargets.Add(new ScoredTarget(dto.Key, type.Value, dto.Grade)); + scoredTargets.Add(new ScoredTarget(dto.Key, type.Value, dto.Grade)); } - return scoredTargets; } } + +public class ScoredTargetDto +{ + public string Key { get; set; } = ""; + public string Type { get; set; } = ""; + public int Grade { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/SummaryPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/SummaryPrompt.cs deleted file mode 100644 index ba712c3a4..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/SummaryPrompt.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Text; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -public static class SummaryPrompt -{ - public static string Build(ConceptRecord record) - { - var sb = new StringBuilder(); - sb.AppendLine(ConceptRubricSection.Render(record)); - - sb.AppendLine("# Role"); - sb.AppendLine("You are a progress-summary agent. Speak Serbian."); - sb.AppendLine("The learner asked a procedural question about their own progress mid-conversation (\"what have I said so far?\", \"summarize my answers\"). Paraphrase what the learner has articulated in their OWN prior turns so they can spot their own gaps."); - sb.AppendLine(); - - sb.AppendLine("# Rules"); - sb.AppendLine("- Base the summary ONLY on the learner's actual turns in the chat history. Do not list covered KPs/KRs or enumerate what is \"missing\"."); - sb.AppendLine("- Paraphrase at the level of the learner's articulations; do not upgrade them with rubric language."); - sb.AppendLine("- NEVER quote any KP/BC/CM/KR text verbatim or near-verbatim."); - sb.AppendLine("- No bullet lists. One short paragraph, 2-4 sentences."); - sb.AppendLine("- End with a brief invitation to continue (\"nastavi odatle\" / \"šta bi još dodao?\")."); - sb.AppendLine(); - - sb.AppendLine("# Runtime Context Format"); - sb.AppendLine("Chat history shows the conversation so far (user=learner, assistant=tutor)."); - - return sb.ToString(); - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs index fd358914f..0ed9610f5 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -65,10 +65,10 @@ private static void ConfigureConversations(ModelBuilder modelBuilder) .HasForeignKey(te => te.ConversationTurnId); modelBuilder.Entity() - .OwnsOne(ct => ct.Probe, probe => probe.ToJson()); + .HasIndex(ct => new { ct.ConversationAttemptId, ct.Order }); modelBuilder.Entity() - .HasIndex(ct => new { ct.ConversationAttemptId, ct.Order }); + .Property(ct => ct.FeedbackTargets).HasColumnType("jsonb"); modelBuilder.Entity(entity => { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs index 961142c2f..7635cd923 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs @@ -24,7 +24,7 @@ public ConversationAttemptDatabaseRepository(ElaborationsContext dbContext) : ba .ThenInclude(t => t.Evaluation) .FirstOrDefault(ca => ca.ConceptElaborationTaskId == conceptElaborationTaskId && ca.LearnerId == learnerId - && (ca.Status == AttemptStatus.InProgress || ca.Status == AttemptStatus.InClosing)); + && ca.Status == AttemptStatus.InProgress); } public List GetByTaskAndLearner(int conceptElaborationTaskId, int learnerId) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs index 7b7205b3c..7acae0e3a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs @@ -46,14 +46,9 @@ protected override IServiceCollection ReplaceNeededDbContexts(IServiceCollection public void SetupEvaluationMock( List<(string key, string type, int grade)> assessments, - List? misconceptionsTriggeredKeys = null, - string intent = "Substantive") + List? misconceptionsTriggeredKeys = null) { - SetupIntentMock(intent); - - if (intent != "Substantive") return; - - var scorerJson = BuildSubstantiveEvalJson(assessments, misconceptionsTriggeredKeys ?? []); + var scorerJson = BuildScorerJson(assessments, misconceptionsTriggeredKeys ?? []); MockChatService.Setup(x => x.CompleteAsync( It.Is(r => r.MaxTokens == 1024), It.IsAny())) .ReturnsAsync(Result.Ok(new CompletionResponse @@ -63,18 +58,7 @@ public void SetupEvaluationMock( })); } - public void SetupIntentMock(string intent = "Substantive") - { - MockChatService.Setup(x => x.CompleteAsync( - It.Is(r => r.MaxTokens == 64), It.IsAny())) - .ReturnsAsync(Result.Ok(new CompletionResponse - { - Content = $$"""{ "intent": "{{intent}}" }""", - Usage = new TokenUsage(30, 5) - })); - } - - private static string BuildSubstantiveEvalJson( + private static string BuildScorerJson( List<(string key, string type, int grade)> assessments, List misconceptions) { @@ -90,20 +74,12 @@ private static string BuildSubstantiveEvalJson( public void SetupDialogueMock(params string[] tokens) { - var mockTokens = tokens.Length > 0 ? tokens : ["Mock ", "response."]; + var mockTokens = tokens.Length > 0 ? tokens : ["Mock ", "feedback."]; MockChatService.Setup(x => x.StreamAsync( It.IsAny(), It.IsAny())) .Returns(MockStream(mockTokens)); } - public void SetupSummaryMock(string summary = "Test summary of the conversation.") - { - MockChatService.Setup(x => x.StreamAsync( - It.Is(r => r.MaxTokens == 256 && r.Temperature == 0.5), - It.IsAny())) - .Returns(MockStream([summary])); - } - private static async IAsyncEnumerable MockStream( string[] tokens, [EnumeratorCancellation] CancellationToken ct = default) { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs index f77da34bd..0f835611f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -8,25 +8,24 @@ using Tutor.BuildingBlocks.AI.Core.Conversations; using Tutor.Elaborations.API.Dtos.Conversations; using Tutor.Elaborations.API.Public.Learning; -using Tutor.Elaborations.Core.Domain.Conversations; using Tutor.Elaborations.Infrastructure.Database; namespace Tutor.Elaborations.Tests.Integration.Learning; -// Test data layout (each task owns its own natural keys: P1, P2, R1, ...): +// 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 — isolated +// 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 -3: Learner -3, CET -1, InProgress (2 turns — for conflict + eval failure tests) -// Attempt -4: Learner -3, CET -2, InProgress (P1 covered — completion test) -// Attempt -5: Learner -2, CET -2, InProgress (9 learner turns — hard cap seed) -// Attempt -6: Learner -3, CET -3, InProgress (5 substantive turns — soft cap seed) -// Attempt -7: Learner -3, CET -5, InProgress (isolated for abandon test) +// 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 { @@ -40,97 +39,111 @@ public async Task Starts_conversation_with_first_turn() Factory.SetupDialogueMock(); using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-2"); - var dto = new SubmitTurnRequestDto { Content = "Encapsulation bundles data and methods." }; + 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()); + 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); - - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.ChangeTracker.Clear(); - var attempt = dbContext.ConversationAttempts.Include(a => a.Turns) - .FirstOrDefault(a => a.ConceptElaborationTaskId == -5 && a.LearnerId == -2 && a.Status == 0); - attempt.ShouldNotBeNull(); - attempt.Turns.Count.ShouldBeGreaterThanOrEqualTo(2); } [Fact] - public async Task Closing_turn_substantive_completes_with_grade() + public async Task Submission_completes_when_all_targets_adequate() { - // CET -2 (P1, P2). Attempt -4 already has P1 covered. Submit P2 → InClosing, then final answer → grade. + // CET -2 has P1, P2. Attempt -4 has RoundCount=1. Submit with all grade 2 → Completed. Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock([("P1", "proposition", 3), ("P2", "proposition", 3)]); - Factory.SetupDialogueMock(); + 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 first = new SubmitTurnRequestDto { Content = "Covers both KPs." }; - var firstTokens = await CollectStreamAsync(controller.SubmitTurn(-4, first, CancellationToken.None)); - var firstMeta = JsonSerializer.Deserialize(firstTokens.Last()); - firstMeta.ShouldNotBeNull(); - firstMeta.Status.ShouldBe("InClosing"); - - // Now submit the final articulation — ClosingScorer grades it. - // CET -2 has 2 rubric items (P1, P2). Both grade 3 → grade = 6/(3×2)×10 = 10. - Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock([("P1", "proposition", 3), ("P2", "proposition", 3)]); - var final = new SubmitTurnRequestDto { Content = "Final consolidated answer." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-4, final, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.SubmitElaboration(-4, dto, CancellationToken.None)); - var metadata = JsonSerializer.Deserialize(tokens.Last()); + var metadata = JsonSerializer.Deserialize(tokens.Last()); metadata.ShouldNotBeNull(); metadata.Status.ShouldBe("Completed"); - metadata.Summary.ShouldBe("10 / 10"); } [Fact] - public async Task Hard_cap_reached_transitions_to_closing() + public async Task Hard_cap_expires_attempt() { - // Attempt -5: CET -2 (P1, P2), 9 learner turns already — hard cap is totalTargets+4 = 6. + // Attempt -5: CET -2, RoundCount=3, MaxRounds=4. Next submission hits cap → Expired. 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 SubmitTurnRequestDto { Content = "Final turn attempt." }; + var dto = new SubmitElaborationRequestDto { Elaboration = "Attempt at the hard cap boundary." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-5, dto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.SubmitElaboration(-5, dto, CancellationToken.None)); - var metadata = JsonSerializer.Deserialize(tokens.Last()); + var metadata = JsonSerializer.Deserialize(tokens.Last()); metadata.ShouldNotBeNull(); - metadata.Status.ShouldBe("InClosing"); + metadata.Status.ShouldBe("Expired"); } [Fact] - public async Task Soft_cap_reached_continues() + public async Task Submission_with_KPs_and_KR_completes_when_all_adequate() { - // Attempt -6: CET -3 (P1 only), 5 substantive turns already. + // CET -7 (KPs P1, P2 + KR R1 = 3 targets). All grade 2 → Completed immediately. Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock([("P1", "proposition", 0)]); - Factory.SetupDialogueMock(); - Factory.SetupSummaryMock(); + 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 SubmitTurnRequestDto { Content = "Sixth substantive turn." }; + 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.SubmitTurn(-6, dto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.StartConversation(-7, dto, CancellationToken.None)); - tokens.Count.ShouldBeGreaterThan(1); - var metadata = JsonSerializer.Deserialize(tokens.Last()); + var metadata = JsonSerializer.Deserialize(tokens.Last()); metadata.ShouldNotBeNull(); - metadata.Status.ShouldBe("InProgress"); + 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 turnCountAfterFirst = dbContext.ConversationAttempts + .Include(a => a.Turns).First(a => a.Id == attemptId).Turns.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 attempt = dbContext.ConversationAttempts.Include(a => a.Turns) - .ThenInclude(t => t.Evaluation).First(a => a.Id == -6); - attempt.Turns.Count(t => t.Role == TurnRole.Learner && t.Intent == TurnIntent.Substantive).ShouldBe(6); + var metadata = JsonSerializer.Deserialize(tokens.Last()); + metadata.ShouldNotBeNull(); + metadata.Status.ShouldBe("InProgress"); + metadata.AttemptId.ShouldBe(attemptId); + var reusedAttempt = dbContext.ConversationAttempts.Include(a => a.Turns).First(a => a.Id == attemptId); + reusedAttempt.Turns.Count.ShouldBe(turnCountAfterFirst + 2); } [Fact] @@ -138,7 +151,7 @@ public async Task Start_unenrolled_fails() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-1"); - var dto = new SubmitTurnRequestDto { Content = "Should fail." }; + var dto = new SubmitElaborationRequestDto { Elaboration = "Should fail." }; var tokens = await CollectStreamAsync(controller.StartConversation(-1, dto, CancellationToken.None)); @@ -153,7 +166,7 @@ public async Task Start_insufficient_tokens_fails() Factory.MockChatService.Reset(); using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-4"); - var dto = new SubmitTurnRequestDto { Content = "Should fail due to exhausted wallet." }; + var dto = new SubmitElaborationRequestDto { Elaboration = "Should fail due to exhausted wallet." }; var tokens = await CollectStreamAsync(controller.StartConversation(-1, dto, CancellationToken.None)); @@ -168,7 +181,7 @@ 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 SubmitTurnRequestDto { Content = "Should fail due to daily limit." }; + var dto = new SubmitElaborationRequestDto { Elaboration = "Should fail due to daily limit." }; var tokens = await CollectStreamAsync(controller.StartConversation(-3, dto, CancellationToken.None)); @@ -186,9 +199,9 @@ public async Task Submit_evaluation_failure_returns_error() .ReturnsAsync(Result.Fail("LLM unavailable")); using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-3"); - var dto = new SubmitTurnRequestDto { Content = "Should trigger eval failure." }; + var dto = new SubmitElaborationRequestDto { Elaboration = "Should trigger eval failure." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-3, dto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.SubmitElaboration(-3, dto, CancellationToken.None)); tokens.Count.ShouldBe(1, $"Got: [{string.Join("|", tokens)}]"); var error = JsonSerializer.Deserialize(tokens[0]); @@ -200,7 +213,7 @@ public async Task Start_nonexistent_task_fails() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-2"); - var dto = new SubmitTurnRequestDto { Content = "Task does not exist." }; + var dto = new SubmitElaborationRequestDto { Elaboration = "Task does not exist." }; var tokens = await CollectStreamAsync(controller.StartConversation(-999, dto, CancellationToken.None)); @@ -209,52 +222,12 @@ public async Task Start_nonexistent_task_fails() error.GetProperty("code").GetInt32().ShouldBe(404); } - [Fact] - public async Task Start_then_submit_adds_turns_to_same_attempt() - { - // CET -6 (P1, P2, P3). - 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 SubmitTurnRequestDto { Content = "First turn for reuse test." }; - var firstTokens = await CollectStreamAsync(controller.StartConversation(-6, firstDto, CancellationToken.None)); - - var firstMetadata = JsonSerializer.Deserialize(firstTokens.Last()); - firstMetadata.ShouldNotBeNull(); - var attemptId = firstMetadata.AttemptId; - attemptId.ShouldBeGreaterThan(0); - - dbContext.ChangeTracker.Clear(); - var createdAttempt = dbContext.ConversationAttempts.Include(a => a.Turns) - .First(a => a.Id == attemptId); - var turnCountAfterFirst = createdAttempt.Turns.Count; - - // Submit second turn — should add to the same attempt - Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock([("P1", "proposition", 0), ("P2", "proposition", 0), ("P3", "proposition", 0)]); - Factory.SetupDialogueMock(); - var secondDto = new SubmitTurnRequestDto { Content = "Second turn for reuse test." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(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.Turns) - .First(a => a.Id == attemptId); - reusedAttempt.Turns.Count.ShouldBe(turnCountAfterFirst + 2); - } - [Fact] public async Task Start_with_active_attempt_returns_conflict() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-3"); - var dto = new SubmitTurnRequestDto { Content = "Should conflict." }; + var dto = new SubmitElaborationRequestDto { Elaboration = "Should conflict." }; var tokens = await CollectStreamAsync(controller.StartConversation(-1, dto, CancellationToken.None)); @@ -269,9 +242,9 @@ public async Task Submit_nonexistent_attempt_fails() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-2"); - var dto = new SubmitTurnRequestDto { Content = "Attempt does not exist." }; + var dto = new SubmitElaborationRequestDto { Elaboration = "Attempt does not exist." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-999, dto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.SubmitElaboration(-999, dto, CancellationToken.None)); tokens.Count.ShouldBe(1); var error = JsonSerializer.Deserialize(tokens[0]); @@ -283,67 +256,23 @@ public async Task Submit_wrong_learner_fails() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-2"); - var dto = new SubmitTurnRequestDto { Content = "Not my attempt." }; + var dto = new SubmitElaborationRequestDto { Elaboration = "Not my attempt." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-4, dto, CancellationToken.None)); + 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 Concept_with_relations_transitions_to_closing_when_relations_articulated() - { - // CET -7 (KPs P1, P2 + KR R1). Covering both KPs and articulating R1 completes the attempt. - Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock([("P1", "proposition", 3), ("P2", "proposition", 3), ("R1", "relation", 3)]); - Factory.SetupDialogueMock(); - using var scope = Factory.Services.CreateScope(); - var controller = CreateController(scope, "-3"); - var dto = new SubmitTurnRequestDto - { - Content = "Override matters because the runtime picks the actual type's implementation." - }; - - var tokens = await CollectStreamAsync(controller.StartConversation(-7, dto, CancellationToken.None)); - - var metadata = JsonSerializer.Deserialize(tokens.Last()); - metadata.ShouldNotBeNull(); - metadata.Status.ShouldBe("InClosing"); - } - - [Fact] - public async Task Concept_with_relations_does_not_complete_when_only_KPs_covered() - { - // CET -7. Covering KPs but NOT articulating R1 should NOT complete. - // Uses learner -2 so test doesn't collide with the "completes" test (also on CET -7). - Factory.MockChatService.Reset(); - Factory.SetupEvaluationMock([("P1", "proposition", 3), ("P2", "proposition", 3), ("R1", "relation", 0)]); - Factory.SetupDialogueMock(); - Factory.SetupSummaryMock(); - using var scope = Factory.Services.CreateScope(); - var controller = CreateController(scope, "-2"); - var dto = new SubmitTurnRequestDto - { - Content = "Override is a thing and runtime types exist, but I won't say how they connect." - }; - - var tokens = await CollectStreamAsync(controller.StartConversation(-7, dto, CancellationToken.None)); - - var metadata = JsonSerializer.Deserialize(tokens.Last()); - metadata.ShouldNotBeNull(); - metadata.Status.ShouldBe("InProgress"); - } - [Fact] public async Task Submit_completed_attempt_fails() { using var scope = Factory.Services.CreateScope(); var controller = CreateController(scope, "-2"); - var dto = new SubmitTurnRequestDto { Content = "Attempt already done." }; + var dto = new SubmitElaborationRequestDto { Elaboration = "Attempt already done." }; - var tokens = await CollectStreamAsync(controller.SubmitTurn(-1, dto, CancellationToken.None)); + var tokens = await CollectStreamAsync(controller.SubmitElaboration(-1, dto, CancellationToken.None)); tokens.Count.ShouldBe(1); var error = JsonSerializer.Deserialize(tokens[0]); 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 index 7eef029ac..04ac1051f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -1,144 +1,71 @@ --- Attempt -1: Learner -2, CET -1, Completed with 3 turns (for query tests) -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") -VALUES (-1, -1, -2, 2, '2024-06-01 10:00:00+00', '2024-06-01 10:15:00+00', 'Good understanding of encapsulation basics.'); +-- 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", "RoundCount") +VALUES (-1, -1, -2, 1, '2024-06-01 10:00:00+00', '2024-06-01 10:15:00+00', 1.0, 1, 4, 1); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-1, -1, 0, 'Encapsulation bundles data and methods in a class.', 0, '2024-06-01 10:01:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-2, -1, 1, 'Good start! Can you tell me more about access modifiers?', 1, '2024-06-01 10:01:05+00', null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-3, -1, 0, 'Access modifiers like public and private control visibility.', 2, '2024-06-01 10:02:00+00', 0); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-1, -1, 0, 'Encapsulation bundles data and methods in a class, hiding implementation details.', 0, '2024-06-01 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-2, -1, 1, '1.00', 1, '2024-06-01 10:01:05+00'); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-1, -1, '[{{"Key":"P1","Type":0,"Grade":3}}]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-3, -3, '[{{"Key":"P1","Type":0,"Grade":3}}]'::jsonb, '[]'::jsonb, false); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys") +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", "Summary") -VALUES (-2, -1, -2, 3, '2024-06-02 10:00:00+00', '2024-06-02 10:05:00+00', null); - --- Attempt -3: Learner -3, CET -1, InProgress with 2 turns (for abandon + follow-up tests) -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") -VALUES (-3, -1, -3, 0, '2024-06-03 10:00:00+00', null, null); - -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-4, -3, 0, 'Encapsulation is about data hiding.', 0, '2024-06-03 10:01:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', 1, '2024-06-03 10:01:05+00', null); - -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-4, -4, '[]'::jsonb, '["M1"]'::jsonb, false); - --- Attempt -4: Learner -3, CET -2, InProgress (for completion test: KP P1 already covered, submit to cover P2) -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") -VALUES (-4, -2, -3, 0, '2024-06-04 10:00:00+00', null, null); - -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-6, -4, 0, 'Encapsulation bundles data and methods together.', 0, '2024-06-04 10:01:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-7, -4, 1, 'Good. What about access control?', 1, '2024-06-04 10:01:05+00', null); - -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-6, -6, '[{{"Key":"P1","Type":0,"Grade":3}}]'::jsonb, '[]'::jsonb, false); - --- Attempt -5: Learner -2, CET -2, InProgress with 9 learner + 9 system turns (for hard cap test) -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary", "HardCapTotalTurns", "SoftCapTotalTurns") -VALUES (-5, -2, -2, 0, '2024-06-05 10:00:00+00', null, null, 6, 2); - -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-50, -5, 0, 'Turn 1', 0, '2024-06-05 10:01:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-51, -5, 1, 'Response 1', 1, '2024-06-05 10:01:05+00', null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-52, -5, 0, 'Turn 2', 2, '2024-06-05 10:02:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-53, -5, 1, 'Response 2', 3, '2024-06-05 10:02:05+00', null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-54, -5, 0, 'Turn 3', 4, '2024-06-05 10:03:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-55, -5, 1, 'Response 3', 5, '2024-06-05 10:03:05+00', null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-56, -5, 0, 'Turn 4', 6, '2024-06-05 10:04:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-57, -5, 1, 'Response 4', 7, '2024-06-05 10:04:05+00', null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-58, -5, 0, 'Turn 5', 8, '2024-06-05 10:05:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-59, -5, 1, 'Response 5', 9, '2024-06-05 10:05:05+00', null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-60, -5, 0, 'Turn 6', 10, '2024-06-05 10:06:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-61, -5, 1, 'Response 6', 11, '2024-06-05 10:06:05+00', null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-62, -5, 0, 'Turn 7', 12, '2024-06-05 10:07:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-63, -5, 1, 'Response 7', 13, '2024-06-05 10:07:05+00', null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-64, -5, 0, 'Turn 8', 14, '2024-06-05 10:08:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-65, -5, 1, 'Response 8', 15, '2024-06-05 10:08:05+00', null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-66, -5, 0, 'Turn 9', 16, '2024-06-05 10:09:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-67, -5, 1, 'Response 9', 17, '2024-06-05 10:09:05+00', null); - --- Evaluations for the 9 learner turns (all with empty assessments — never completes) -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-50, -50, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-52, -52, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-54, -54, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-56, -56, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-58, -58, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-60, -60, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-62, -62, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-64, -64, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-66, -66, '[]'::jsonb, '[]'::jsonb, false); - --- Attempt -6: Learner -3, CET -3, InProgress with 5 substantive learner + 5 system turns (for soft cap test) -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") -VALUES (-6, -3, -3, 0, '2024-06-06 10:00:00+00', null, null); - -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-70, -6, 0, 'Turn 1', 0, '2024-06-06 10:01:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-71, -6, 1, 'Response 1', 1, '2024-06-06 10:01:05+00', null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-72, -6, 0, 'Turn 2', 2, '2024-06-06 10:02:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-73, -6, 1, 'Response 2', 3, '2024-06-06 10:02:05+00', null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-74, -6, 0, 'Turn 3', 4, '2024-06-06 10:03:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-75, -6, 1, 'Response 3', 5, '2024-06-06 10:03:05+00', null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-76, -6, 0, 'Turn 4', 6, '2024-06-06 10:04:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-77, -6, 1, 'Response 4', 7, '2024-06-06 10:04:05+00', null); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-78, -6, 0, 'Turn 5', 8, '2024-06-06 10:05:00+00', 0); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp", "Intent") -VALUES (-79, -6, 1, 'Response 5', 9, '2024-06-06 10:05:05+00', null); - -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-70, -70, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-72, -72, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-74, -74, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-76, -76, '[]'::jsonb, '[]'::jsonb, false); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys", "HasMultipleConcerns") -VALUES (-78, -78, '[]'::jsonb, '[]'::jsonb, false); - --- Attempt -7: Learner -3, CET -5, InProgress (isolated for abandon test — no other test touches this) -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") -VALUES (-7, -5, -3, 0, '2024-06-07 10:00:00+00', null, null); +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") +VALUES (-2, -1, -2, 2, '2024-06-02 10:00:00+00', '2024-06-02 10:05:00+00', 0.0, 1, 4, 0); + +-- Attempt -3: Learner -3, CET -1, InProgress, RoundCount=1 (conflict + eval failure tests) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") +VALUES (-3, -1, -3, 0, '2024-06-03 10:00:00+00', null, 0.0, 1, 4, 1); + +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-4, -3, 0, 'Encapsulation is about data hiding.', 0, '2024-06-03 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', 1, '2024-06-03 10:01:05+00'); + +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys") +VALUES (-4, -4, '[{{"Key":"P1","Type":0,"Grade":0}}]'::jsonb, '["M1"]'::jsonb); + +-- Attempt -4: Learner -3, CET -2, InProgress, RoundCount=1 (completion test: submit all grade 2 → Completed) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") +VALUES (-4, -2, -3, 0, '2024-06-04 10:00:00+00', null, 0.0, 2, 4, 1); + +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-6, -4, 0, 'Encapsulation bundles data and methods, but access control is unclear.', 0, '2024-06-04 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-7, -4, 1, 'Consider elaborating on how access modifiers enforce encapsulation.', 1, '2024-06-04 10:01:05+00'); + +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys") +VALUES (-6, -6, '[{{"Key":"P1","Type":0,"Grade":1}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); + +-- Attempt -5: Learner -2, CET -2, InProgress, RoundCount=3, MaxRounds=4 (hard cap test: next submission expires) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") +VALUES (-5, -2, -2, 0, '2024-06-05 10:00:00+00', null, 0.0, 2, 4, 3); + +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-50, -5, 0, 'Round 1 elaboration.', 0, '2024-06-05 10:01:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-51, -5, 1, 'Feedback 1.', 1, '2024-06-05 10:01:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-52, -5, 0, 'Round 2 elaboration.', 2, '2024-06-05 10:02:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-53, -5, 1, 'Feedback 2.', 3, '2024-06-05 10:02:05+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-54, -5, 0, 'Round 3 elaboration.', 4, '2024-06-05 10:03:00+00'); +INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") +VALUES (-55, -5, 1, 'Feedback 3.', 5, '2024-06-05 10:03:05+00'); + +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys") +VALUES (-50, -50, '[{{"Key":"P1","Type":0,"Grade":0}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys") +VALUES (-52, -52, '[{{"Key":"P1","Type":0,"Grade":0}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys") +VALUES (-54, -54, '[{{"Key":"P1","Type":0,"Grade":0}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); + +-- Attempt -6: Learner -3, CET -3, InProgress, RoundCount=0 (isolated for Start+Submit flow test) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") +VALUES (-6, -3, -3, 0, '2024-06-06 10:00:00+00', null, 0.0, 1, 4, 0); + +-- Attempt -7: Learner -3, CET -5, InProgress, RoundCount=0 (isolated for abandon test) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") +VALUES (-7, -5, -3, 0, '2024-06-07 10:00:00+00', null, 0.0, 2, 4, 0); 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 index a3ed24e0c..0ff1dd96d 100644 --- 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 @@ -1,7 +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", "Summary") -VALUES (-10, -3, -2, 2, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '2 hours', 'Daily limit attempt 1'); -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") -VALUES (-11, -3, -2, 2, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour', 'Daily limit attempt 2'); -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "Summary") -VALUES (-12, -3, -2, 3, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '30 minutes', null); +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") +VALUES (-10, -3, -2, 1, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '2 hours', 1.0, 1, 4, 1); +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") +VALUES (-11, -3, -2, 1, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour', 1.0, 1, 4, 1); +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") +VALUES (-12, -3, -2, 2, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '30 minutes', 0.0, 1, 4, 0); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj index c50607ccd..20ed7a9d4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj @@ -27,4 +27,8 @@ + + + + diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs deleted file mode 100644 index 1fec9871f..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Unit/ConceptRecordTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Reflection; -using Shouldly; -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; -using Tutor.Elaborations.Core.Domain.Conversations; - -namespace Tutor.Elaborations.Tests.Unit; - -public class ConceptRecordTests -{ - [Fact] - public void IsAttemptComplete_returns_true_when_no_relations_and_all_KPs_covered() - { - var record = MakeRecord( - kps: [new KeyProposition("P1", "first")], - relations: []); - var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpKeys: ["P1"], articulatedRelationKeys: []); - - record.IsAttemptComplete(attempt).ShouldBeTrue(); - } - - [Fact] - public void IsAttemptComplete_returns_false_when_relations_exist_but_not_articulated() - { - var record = MakeRecord( - kps: [new KeyProposition("P1", "first"), new KeyProposition("P2", "second")], - relations: [new KeyRelation("R1", "P1", "P2", "m")]); - var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpKeys: ["P1", "P2"], articulatedRelationKeys: []); - - record.IsAttemptComplete(attempt).ShouldBeFalse(); - } - - [Fact] - public void IsAttemptComplete_returns_true_when_all_KPs_covered_and_all_relations_articulated() - { - var record = MakeRecord( - kps: [new KeyProposition("P1", "first"), new KeyProposition("P2", "second")], - relations: [new KeyRelation("R1", "P1", "P2", "m")]); - var attempt = MakeAttemptWithCoveredAndArticulated(coveredKpKeys: ["P1", "P2"], articulatedRelationKeys: ["R1"]); - - record.IsAttemptComplete(attempt).ShouldBeTrue(); - } - - private static ConceptRecord MakeRecord(List kps, List relations) - { - return new ConceptRecord( - conceptElaborationTaskId: 0, - canonicalDefinition: "def", - keyPropositions: kps, - commonMisconceptions: new List(), - keyRelations: relations); - } - - private static ConversationAttempt MakeAttemptWithCoveredAndArticulated( - List coveredKpKeys, List articulatedRelationKeys) - { - var ctor = typeof(ConversationAttempt) - .GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, Type.EmptyTypes)!; - var attempt = (ConversationAttempt)ctor.Invoke(null); - - var assessments = coveredKpKeys.Select(k => new ScoredTarget(k, ScoredTargetType.Proposition, 3)) - .Concat(articulatedRelationKeys.Select(k => new ScoredTarget(k, ScoredTargetType.Relation, 3))) - .ToList(); - var evaluation = new TurnEvaluation(assessments, [], hasMultipleConcerns: false); - attempt.AddLearnerTurn("x", TurnIntent.Substantive, evaluation); - return attempt; - } -} \ No newline at end of file diff --git a/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs b/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs index 01941102f..1583b542a 100644 --- a/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs +++ b/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs @@ -35,23 +35,23 @@ public ActionResult GetTaskWithAttempts(int taskId) [HttpPost("concept-elaborations/{taskId:int}/conversations")] public async IAsyncEnumerable StartConversation(int taskId, - [FromBody] SubmitTurnRequestDto dto, + [FromBody] SubmitElaborationRequestDto dto, [EnumeratorCancellation] CancellationToken ct) { await foreach (var token in _conversationService.StartConversationAsync( - taskId, dto.Content, User.LearnerId(), ct)) + taskId, dto.Elaboration, User.LearnerId(), ct)) { yield return token; } } - [HttpPost("concept-elaborations/attempts/{attemptId:int}/turns")] - public async IAsyncEnumerable SubmitTurn(int attemptId, - [FromBody] SubmitTurnRequestDto dto, + [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.SubmitTurnAsync( - attemptId, dto.Content, User.LearnerId(), ct)) + await foreach (var token in _conversationService.SubmitElaborationAsync( + attemptId, dto.Elaboration, User.LearnerId(), ct)) { yield return token; } From fd73204ebfb358d5ae95c5728fff347fea74b55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Thu, 7 May 2026 12:43:53 +0300 Subject: [PATCH 46/51] refactor: Compresses learner turn, evaluation, system turn, and feedback probes into a single Round class. --- .../Conversations/ConversationAttemptDto.cs | 2 +- .../Conversations/ConversationRoundDto.cs | 9 ++ .../Dtos/Conversations/ConversationTurnDto.cs | 10 -- .../Conversations/ConversationAttempt.cs | 137 +++++++++++------- .../Domain/Conversations/ConversationRound.cs | 30 ++++ .../Domain/Conversations/ConversationTurn.cs | 36 ----- .../Domain/Conversations/FeedbackTarget.cs | 5 +- .../Domain/Conversations/ScoredTarget.cs | 6 +- .../Domain/Conversations/TurnEvaluation.cs | 13 +- .../Domain/Conversations/TurnRole.cs | 7 - .../Mappers/ConversationProfile.cs | 3 +- .../Orchestration/AgentOrchestrator.cs | 6 +- .../Database/ElaborationsContext.cs | 24 +-- .../ConversationAttemptDatabaseRepository.cs | 10 +- .../Learning/ConversationTurnTests.cs | 8 +- .../TestData/e-conversation-attempts.sql | 90 +++++------- .../TestData/f-daily-limit-attempts.sql | 12 +- 17 files changed, 215 insertions(+), 193 deletions(-) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationRoundDto.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationTurnDto.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationRound.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnRole.cs diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs index 77d1cf098..8b85706ca 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs @@ -8,5 +8,5 @@ public class ConversationAttemptDto public DateTime StartedAt { get; set; } public DateTime? CompletedAt { get; set; } public double? FinalGrade { get; set; } - public List Turns { get; set; } = new(); + 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/ConversationTurnDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationTurnDto.cs deleted file mode 100644 index 182cab8c1..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationTurnDto.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Tutor.Elaborations.API.Dtos.Conversations; - -public class ConversationTurnDto -{ - public int Id { get; set; } - public string Role { get; set; } = string.Empty; - public string Content { get; set; } = string.Empty; - public int Order { get; set; } - public DateTime Timestamp { get; set; } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index 5638214dc..e8a8c5b7d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -12,9 +12,8 @@ public class ConversationAttempt : AggregateRoot public double FinalGrade { get; private set; } public int TotalTargets { get; private set; } public int MaxRounds { get; private set; } - public int RoundCount { get; private set; } - private readonly List _turns = new(); - public IReadOnlyList Turns => _turns.AsReadOnly(); + private readonly List _rounds = new(); + public IReadOnlyList Rounds => _rounds.AsReadOnly(); private ConversationAttempt() { } @@ -28,86 +27,121 @@ public ConversationAttempt(int conceptElaborationTaskId, int learnerId, int tota MaxRounds = Math.Max(4, (int)Math.Ceiling(totalTargets / 3.0) + 2); } - public bool IsHardCapReached() => RoundCount >= MaxRounds; + public bool IsHardCapReached() => _rounds.Count >= MaxRounds; public bool IsStagnating() { - var scores = Turns - .Where(t => t.Role == TurnRole.Learner && t.Evaluation != null) - .OrderBy(t => t.Order) + var scores = _rounds .TakeLast(3) - .Select(t => t.Evaluation!.TotalScore()) + .Select(r => r.Evaluation.TotalScore()) .ToList(); if (scores.Count < 3) return false; return scores[2] <= scores[1] && scores[1] <= scores[0]; } - public ConversationTurn AddLearnerTurn(string content, TurnEvaluation evaluation) + public void BeginRound(string elaboration, TurnEvaluation evaluation) { - var turn = new ConversationTurn(content, _turns.Count, evaluation); - _turns.Add(turn); - + _rounds.Add(new ConversationRound(_rounds.Count, elaboration, evaluation)); FinalGrade = evaluation.ComputeGrade(TotalTargets); - RoundCount++; - return turn; } - public ConversationTurn AddSystemTurn(string content, IReadOnlyList feedbackTargets) + public void CompleteCurrentRound(string feedbackContent, IReadOnlyList feedbackTargets) { - var turn = new ConversationTurn(content, _turns.Count, feedbackTargets); - _turns.Add(turn); - return turn; + _rounds[^1].Complete(feedbackContent, feedbackTargets); } public IReadOnlyList SelectFeedbackTargets(int maxItems = 2) { - var latestEvaluation = Turns[^1].Evaluation!; - - var lastSurfacedGrade = BuildLastSurfacedGradeMap(); + var excludedProbes = GetExcludedProbes(); + var activeProbes = GetRecentActiveProbes(2); + var deficientTargets = _rounds[^1].Evaluation.GetDeficientTargets(excludedProbes); var targets = new List(); - foreach (var key in latestEvaluation.MisconceptionsTriggeredKeys) + targets.AddRange(CreateMomentumProbes(deficientTargets, activeProbes)); // Active probes where grade improved + if (targets.Count >= maxItems) return targets.Take(maxItems).ToList(); + + targets.AddRange(CreateStagnantProbes(deficientTargets, activeProbes)); // Active probes where grade did not improve + if (targets.Count >= maxItems) return targets.Take(maxItems).ToList(); + + targets.AddRange(CreateNewProbes(deficientTargets, activeProbes)); // No active probes + + return targets.Take(maxItems).ToList(); + } + + private static List CreateMomentumProbes(List deficientTargets, List activeProbes) + { + var momentumProbes = new List(); + foreach (var target in deficientTargets) { - targets.Add(new FeedbackTarget(key, TargetType.Misconception, 0, lastSurfacedGrade.ContainsKey(key))); - if (targets.Count >= maxItems) return targets; + var relatedProbe = activeProbes.Find(probe => probe.ScoredTarget.SameTarget(target)); + if (relatedProbe?.ScoredTarget.Grade < target.Grade) + { + momentumProbes.Add(new FeedbackTarget(target, 0)); + } } - var subpar = latestEvaluation.Assessments - .Where(a => a.Grade < 2) - .Select(a => + return momentumProbes; + } + + private static List CreateStagnantProbes(List deficientTargets, List activeProbes) + { + var stagnantProbes = new List(); + foreach (var target in deficientTargets) + { + var relatedProbe = activeProbes.Find(probe => probe.ScoredTarget.SameTarget(target)); + if (relatedProbe == null || relatedProbe.ScoredTarget.Grade < target.Grade) continue; + if (relatedProbe.ScoredTarget.Grade == target.Grade) { - bool surfaced = lastSurfacedGrade.TryGetValue(a.Key, out var prevGrade); - bool improved = surfaced && a.Grade > prevGrade; - bool stagnant = surfaced && !improved; - return (a.Key, a.Type, a.Grade, NeedsSupport: stagnant, - GroupOrder: improved ? 0 : !surfaced ? 1 : 2); - }) - .OrderBy(x => x.GroupOrder) - .ThenBy(x => x.Grade == -1 ? 0 : x.Grade == 1 ? 1 : 2); - - foreach (var item in subpar) + stagnantProbes.Add(new FeedbackTarget(target, relatedProbe.ProbesWithoutGradeChangeCount + 1)); + continue; + } + stagnantProbes.Add(new FeedbackTarget(target, 0)); + } + return stagnantProbes; + } + + private static List CreateNewProbes(List deficientTargets, List activeProbes) + { + var newProbes = new List(); + foreach (var target in deficientTargets) { - targets.Add(new FeedbackTarget(item.Key, item.Type, item.Grade, item.NeedsSupport)); - if (targets.Count >= maxItems) return targets; + var relatedProbe = activeProbes.Find(probe => probe.ScoredTarget.SameTarget(target)); + if (relatedProbe == null) + { + newProbes.Add(new FeedbackTarget(target, 0)); + } } - return targets; + return newProbes; } - private Dictionary BuildLastSurfacedGradeMap() + private List GetRecentActiveProbes(int lookBack) { - var result = new Dictionary(); - foreach (var systemTurn in Turns - .Where(t => t.Role == TurnRole.System && t.FeedbackTargets.Count > 0) - .OrderByDescending(t => t.Order)) + var activeProbes = new List(); + foreach (var round in _rounds.SkipLast(1).Reverse().Take(lookBack)) { - foreach (var target in systemTurn.FeedbackTargets) + foreach (var target in round.FeedbackTargets) { - if (!result.ContainsKey(target.Key)) - result[target.Key] = target.Grade; + if (target.IsStalled()) continue; + if (activeProbes.Any(p => p.ScoredTarget.SameTarget(target.ScoredTarget))) continue; + activeProbes.Add(target); } } - return result; + return activeProbes; + } + + private List GetExcludedProbes() + { + var excludedProbes = new List(); + foreach (var round in _rounds.SkipLast(1).Reverse()) + { + foreach (var target in round.FeedbackTargets.Where(t => t.IsStalled())) + { + if (!excludedProbes.Any(p => p.ScoredTarget.SameTarget(target.ScoredTarget))) + excludedProbes.Add(target); + } + } + return excludedProbes; } public void Complete() @@ -128,8 +162,5 @@ public void Expire() CompletedAt = DateTime.UtcNow; } - public bool IsGoodEnough() - { - return FinalGrade > 0.9; - } + 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..3da6f9143 --- /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 TurnEvaluation Evaluation { get; private set; } = null!; + public string? FeedbackContent { get; private set; } + public IReadOnlyList FeedbackTargets { get; private set; } = []; + + private ConversationRound() { } + + internal ConversationRound(int order, string elaborationContent, TurnEvaluation evaluation) + { + Order = order; + ElaborationContent = elaborationContent; + SubmittedAt = DateTime.UtcNow; + Evaluation = evaluation; + } + + internal void Complete(string feedbackContent, IReadOnlyList feedbackTargets) + { + FeedbackContent = feedbackContent; + FeedbackTargets = feedbackTargets; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs deleted file mode 100644 index 0aefc9b3f..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationTurn.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Tutor.BuildingBlocks.Core.Domain; - -namespace Tutor.Elaborations.Core.Domain.Conversations; - -public class ConversationTurn : Entity -{ - public int ConversationAttemptId { get; private set; } - public TurnRole Role { get; private set; } - public string Content { get; private set; } = string.Empty; - public int Order { get; private set; } - public DateTime Timestamp { get; private set; } - public TurnEvaluation? Evaluation { get; private set; } - - public IReadOnlyList FeedbackTargets { get; private set; } = []; - - private ConversationTurn() { } - - internal ConversationTurn(string content, int order, TurnEvaluation? evaluation) - { - Role = TurnRole.Learner; - Timestamp = DateTime.UtcNow; - Content = content; - Order = order; - Evaluation = evaluation; - FeedbackTargets = []; - } - - internal ConversationTurn(string content, int order, IReadOnlyList feedbackTargets) - { - Role = TurnRole.System; - Timestamp = DateTime.UtcNow; - Content = content; - Order = order; - FeedbackTargets = feedbackTargets; - } -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs index 41080c9be..c8d2c1fb1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs @@ -1,3 +1,6 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; -public record FeedbackTarget(string Key, TargetType Type, int Grade, bool NeedsSupport); +public record FeedbackTarget(ScoredTarget ScoredTarget, int ProbesWithoutGradeChangeCount) +{ + public bool IsStalled() => ProbesWithoutGradeChangeCount >= 2; +}; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs index 97dbb1a7e..cf2c61fe0 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs @@ -1,3 +1,7 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; -public record ScoredTarget(string Key, TargetType Type, int Grade); \ No newline at end of file +public record ScoredTarget(string Key, TargetType Type, int Grade) +{ + public bool SameTarget(ScoredTarget other) => Key == other.Key && Type == other.Type; + public int SeverityRank() => this.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/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs index ee85dd354..173dcf384 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs @@ -4,7 +4,7 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; public class TurnEvaluation : Entity { - public int ConversationTurnId { get; private set; } + public int ConversationRoundId { get; private set; } public List Assessments { get; private set; } = []; public List MisconceptionsTriggeredKeys { get; private set; } = []; @@ -23,4 +23,15 @@ 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 * MisconceptionsTriggeredKeys.Count), 2); } + + public List GetDeficientTargets(List excludedProbes) + { + var misconceptionTargets = MisconceptionsTriggeredKeys.Select(key => new ScoredTarget(key, TargetType.Misconception, -2)); + var unfinishedTargets = Assessments.Where(a => a.Grade < 2); + + return unfinishedTargets.Concat(misconceptionTargets) + .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/TurnRole.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnRole.cs deleted file mode 100644 index 19c08ff18..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnRole.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Tutor.Elaborations.Core.Domain.Conversations; - -public enum TurnRole -{ - Learner, - System -} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConversationProfile.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConversationProfile.cs index 28620e781..d50f63457 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConversationProfile.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConversationProfile.cs @@ -10,7 +10,6 @@ public ConversationProfile() { CreateMap().ReverseMap() .ForMember(d => d.Status, opt => opt.MapFrom(s => s.Status.ToString())); - CreateMap().ReverseMap() - .ForMember(d => d.Role, opt => opt.MapFrom(s => s.Role.ToString())); + CreateMap().ReverseMap(); } } 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 index 8f45d7feb..fa8f8f34e 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -29,7 +29,7 @@ public async IAsyncEnumerable ProcessSubmissionAsync( using var scope = _logger.BeginScope(new Dictionary { ["AttemptId"] = attempt.Id, - ["RoundCount"] = attempt.RoundCount + ["RoundCount"] = attempt.Rounds.Count }); var scoreResult = await ScoreElaborationAsync(record, elaboration, ct); @@ -40,7 +40,7 @@ public async IAsyncEnumerable ProcessSubmissionAsync( } var evaluation = scoreResult.Value; - attempt.AddLearnerTurn(elaboration, evaluation); + attempt.BeginRound(elaboration, evaluation); if (attempt.IsGoodEnough()) { @@ -72,7 +72,7 @@ public async IAsyncEnumerable ProcessSubmissionAsync( yield return new TokenChunk(SystemTurnCodes.StagnationRedirect); } - attempt.AddSystemTurn(fullResponse.ToString(), targets); + attempt.CompleteCurrentRound(fullResponse.ToString(), targets); yield return CreateFinalChunk(attempt); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs index 0ed9610f5..0f0c53b79 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -9,7 +9,7 @@ public class ElaborationsContext : DbContext public DbSet ConceptElaborationTasks { get; set; } public DbSet ConceptRecords { get; set; } public DbSet ConversationAttempts { get; set; } - public DbSet ConversationTurns { get; set; } + public DbSet ConversationRounds { get; set; } public DbSet TurnEvaluations { get; set; } public ElaborationsContext(DbContextOptions options) : base(options) { } @@ -48,27 +48,27 @@ private static void ConfigureConceptRecords(ModelBuilder modelBuilder) private static void ConfigureConversations(ModelBuilder modelBuilder) { modelBuilder.Entity() - .HasMany(ca => ca.Turns) + .HasMany(ca => ca.Rounds) .WithOne() - .HasForeignKey(ct => ct.ConversationAttemptId); + .HasForeignKey(r => r.ConversationAttemptId); modelBuilder.Entity() - .Navigation(ca => ca.Turns) - .HasField("_turns"); + .Navigation(ca => ca.Rounds) + .HasField("_rounds"); modelBuilder.Entity() .HasIndex(ca => new { ca.ConceptElaborationTaskId, ca.LearnerId }); - modelBuilder.Entity() - .HasOne(ct => ct.Evaluation) + modelBuilder.Entity() + .HasOne(r => r.Evaluation) .WithOne() - .HasForeignKey(te => te.ConversationTurnId); + .HasForeignKey(te => te.ConversationRoundId); - modelBuilder.Entity() - .HasIndex(ct => new { ct.ConversationAttemptId, ct.Order }); + modelBuilder.Entity() + .HasIndex(r => new { r.ConversationAttemptId, r.Order }); - modelBuilder.Entity() - .Property(ct => ct.FeedbackTargets).HasColumnType("jsonb"); + modelBuilder.Entity() + .Property(r => r.FeedbackTargets).HasColumnType("jsonb"); modelBuilder.Entity(entity => { diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs index 7635cd923..21e985e21 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs @@ -12,16 +12,16 @@ public ConversationAttemptDatabaseRepository(ElaborationsContext dbContext) : ba public new ConversationAttempt? Get(int id) { return DbContext.ConversationAttempts - .Include(ca => ca.Turns.OrderBy(t => t.Order)) - .ThenInclude(t => t.Evaluation) + .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.Turns.OrderBy(t => t.Order)) - .ThenInclude(t => t.Evaluation) + .Include(ca => ca.Rounds.OrderBy(r => r.Order)) + .ThenInclude(r => r.Evaluation) .FirstOrDefault(ca => ca.ConceptElaborationTaskId == conceptElaborationTaskId && ca.LearnerId == learnerId && ca.Status == AttemptStatus.InProgress); @@ -30,7 +30,7 @@ public ConversationAttemptDatabaseRepository(ElaborationsContext dbContext) : ba public List GetByTaskAndLearner(int conceptElaborationTaskId, int learnerId) { return DbContext.ConversationAttempts - .Include(ca => ca.Turns.OrderBy(t => t.Order)) + .Include(ca => ca.Rounds.OrderBy(r => r.Order)) .Where(ca => ca.ConceptElaborationTaskId == conceptElaborationTaskId && ca.LearnerId == learnerId) .OrderByDescending(ca => ca.StartedAt) .ToList(); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs index 0f835611f..dafcc862d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -126,8 +126,8 @@ public async Task Start_then_submit_adds_turns_to_same_attempt() var attemptId = firstMetadata.AttemptId; dbContext.ChangeTracker.Clear(); - var turnCountAfterFirst = dbContext.ConversationAttempts - .Include(a => a.Turns).First(a => a.Id == attemptId).Turns.Count; + var roundCountAfterFirst = dbContext.ConversationAttempts + .Include(a => a.Rounds).First(a => a.Id == attemptId).Rounds.Count; Factory.MockChatService.Reset(); Factory.SetupEvaluationMock( @@ -142,8 +142,8 @@ public async Task Start_then_submit_adds_turns_to_same_attempt() metadata.ShouldNotBeNull(); metadata.Status.ShouldBe("InProgress"); metadata.AttemptId.ShouldBe(attemptId); - var reusedAttempt = dbContext.ConversationAttempts.Include(a => a.Turns).First(a => a.Id == attemptId); - reusedAttempt.Turns.Count.ShouldBe(turnCountAfterFirst + 2); + var reusedAttempt = dbContext.ConversationAttempts.Include(a => a.Rounds).First(a => a.Id == attemptId); + reusedAttempt.Rounds.Count.ShouldBe(roundCountAfterFirst + 1); } [Fact] 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 index 04ac1051f..ad4247e05 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -1,71 +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", "RoundCount") -VALUES (-1, -1, -2, 1, '2024-06-01 10:00:00+00', '2024-06-01 10:15:00+00', 1.0, 1, 4, 1); +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."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-1, -1, 0, 'Encapsulation bundles data and methods in a class, hiding implementation details.', 0, '2024-06-01 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-2, -1, 1, '1.00', 1, '2024-06-01 10:01:05+00'); +INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "FeedbackTargets") +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."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") 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", "RoundCount") -VALUES (-2, -1, -2, 2, '2024-06-02 10:00:00+00', '2024-06-02 10:05:00+00', 0.0, 1, 4, 0); +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, RoundCount=1 (conflict + eval failure tests) -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") -VALUES (-3, -1, -3, 0, '2024-06-03 10:00:00+00', null, 0.0, 1, 4, 1); +-- 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."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-4, -3, 0, 'Encapsulation is about data hiding.', 0, '2024-06-03 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-5, -3, 1, 'What else can you tell me about encapsulation?', 1, '2024-06-03 10:01:05+00'); +INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "FeedbackTargets") +VALUES (-3, -3, 0, 'Encapsulation is about data hiding.', '2024-06-03 10:01:00+00', 'What else can you tell me about encapsulation?', '[{{"Key":"P1","Type":0,"Grade":0,"NeedsSupport":false}}]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys") -VALUES (-4, -4, '[{{"Key":"P1","Type":0,"Grade":0}}]'::jsonb, '["M1"]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") +VALUES (-3, -3, '[{{"Key":"P1","Type":0,"Grade":0}}]'::jsonb, '["M1"]'::jsonb); --- Attempt -4: Learner -3, CET -2, InProgress, RoundCount=1 (completion test: submit all grade 2 → Completed) -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") -VALUES (-4, -2, -3, 0, '2024-06-04 10:00:00+00', null, 0.0, 2, 4, 1); +-- 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."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-6, -4, 0, 'Encapsulation bundles data and methods, but access control is unclear.', 0, '2024-06-04 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-7, -4, 1, 'Consider elaborating on how access modifiers enforce encapsulation.', 1, '2024-06-04 10:01:05+00'); +INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "FeedbackTargets") +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.', '[{{"Key":"P2","Type":0,"Grade":0,"NeedsSupport":false}}]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys") -VALUES (-6, -6, '[{{"Key":"P1","Type":0,"Grade":1}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") +VALUES (-4, -4, '[{{"Key":"P1","Type":0,"Grade":1}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); --- Attempt -5: Learner -2, CET -2, InProgress, RoundCount=3, MaxRounds=4 (hard cap test: next submission expires) -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") -VALUES (-5, -2, -2, 0, '2024-06-05 10:00:00+00', null, 0.0, 2, 4, 3); +-- 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."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-50, -5, 0, 'Round 1 elaboration.', 0, '2024-06-05 10:01:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-51, -5, 1, 'Feedback 1.', 1, '2024-06-05 10:01:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-52, -5, 0, 'Round 2 elaboration.', 2, '2024-06-05 10:02:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-53, -5, 1, 'Feedback 2.', 3, '2024-06-05 10:02:05+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-54, -5, 0, 'Round 3 elaboration.', 4, '2024-06-05 10:03:00+00'); -INSERT INTO elaborations."ConversationTurns"("Id", "ConversationAttemptId", "Role", "Content", "Order", "Timestamp") -VALUES (-55, -5, 1, 'Feedback 3.', 5, '2024-06-05 10:03:05+00'); +INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "FeedbackTargets") +VALUES (-50, -5, 0, 'Round 1 elaboration.', '2024-06-05 10:01:00+00', 'Feedback 1.', '[{{"Key":"P1","Type":0,"Grade":0,"NeedsSupport":false}},{{"Key":"P2","Type":0,"Grade":0,"NeedsSupport":false}}]'::jsonb); +INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "FeedbackTargets") +VALUES (-52, -5, 1, 'Round 2 elaboration.', '2024-06-05 10:02:00+00', 'Feedback 2.', '[{{"Key":"P1","Type":0,"Grade":0,"NeedsSupport":true}},{{"Key":"P2","Type":0,"Grade":0,"NeedsSupport":true}}]'::jsonb); +INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "FeedbackTargets") +VALUES (-54, -5, 2, 'Round 3 elaboration.', '2024-06-05 10:03:00+00', 'Feedback 3.', '[{{"Key":"P1","Type":0,"Grade":0,"NeedsSupport":true}},{{"Key":"P2","Type":0,"Grade":0,"NeedsSupport":true}}]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") VALUES (-50, -50, '[{{"Key":"P1","Type":0,"Grade":0}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") VALUES (-52, -52, '[{{"Key":"P1","Type":0,"Grade":0}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationTurnId", "Assessments", "MisconceptionsTriggeredKeys") +INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") VALUES (-54, -54, '[{{"Key":"P1","Type":0,"Grade":0}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); --- Attempt -6: Learner -3, CET -3, InProgress, RoundCount=0 (isolated for Start+Submit flow test) -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") -VALUES (-6, -3, -3, 0, '2024-06-06 10:00:00+00', null, 0.0, 1, 4, 0); +-- 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, RoundCount=0 (isolated for abandon test) -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") -VALUES (-7, -5, -3, 0, '2024-06-07 10:00:00+00', null, 0.0, 2, 4, 0); +-- 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 index 0ff1dd96d..705d0dfd2 100644 --- 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 @@ -1,7 +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", "RoundCount") -VALUES (-10, -3, -2, 1, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '2 hours', 1.0, 1, 4, 1); -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") -VALUES (-11, -3, -2, 1, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour', 1.0, 1, 4, 1); -INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds", "RoundCount") -VALUES (-12, -3, -2, 2, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '30 minutes', 0.0, 1, 4, 0); +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); From 243a4871faef6f15288a33907a505fba9487b867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Thu, 7 May 2026 12:57:54 +0300 Subject: [PATCH 47/51] fix: Resolves build and test issue. --- .../Domain/Conversations/FeedbackTarget.cs | 2 +- .../Domain/Conversations/TurnEvaluation.cs | 2 +- .../Prompts/EvaluationFeedbackPrompt.cs | 32 +++++++++++++------ .../Learning/Prompts/LlmRequestFactory.cs | 26 ++++++++++----- .../TestData/a-delete.sql | 2 +- .../TestData/e-conversation-attempts.sql | 10 +++--- 6 files changed, 48 insertions(+), 26 deletions(-) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs index c8d2c1fb1..79dc0ed3d 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs @@ -3,4 +3,4 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; public record FeedbackTarget(ScoredTarget ScoredTarget, int ProbesWithoutGradeChangeCount) { public bool IsStalled() => ProbesWithoutGradeChangeCount >= 2; -}; +} \ No newline at end of file diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs index 173dcf384..5b38cd97a 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs @@ -21,7 +21,7 @@ public TurnEvaluation(List assessments, List misconception 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 * MisconceptionsTriggeredKeys.Count), 2); + return Math.Round(Math.Max(0.0, normalizedScore - (0.2 * MisconceptionsTriggeredKeys.Count)), 2); } public List GetDeficientTargets(List excludedProbes) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs index 365592648..cd4d03003 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs @@ -19,20 +19,32 @@ public static string Build(ConceptRecord record) sb.AppendLine("# Runtime kontekst"); sb.AppendLine("Dobijaš:"); sb.AppendLine(" : tekst koji je učenik napisao"); - sb.AppendLine(" : nedostaci u elaboraciji, svrstani po kategoriji i ključu iz rubrike"); - sb.AppendLine(" : pogrešno razumevanje (CM ključ)"); - sb.AppendLine(" : nedostatak KP/KR"); + sb.AppendLine(" : CM ključevi koje je učenik pogrešno primenio"); + sb.AppendLine(" : probeCount = broj puta probeovano bez napretka"); + sb.AppendLine(" : nedostaci u KP/KR elaboraciji"); + sb.AppendLine(" "); + sb.AppendLine(" grade: -1 = netačna tvrdnja | 0 = izostavljena oblast | 1 = nejasna/parcijalna tvrdnja"); + sb.AppendLine(" probeCount: broj puta probeovano bez napretka"); sb.AppendLine(); - sb.AppendLine("# Pravila za odabir stavki"); - sb.AppendLine("Stavke su već odabrane i prioritizovane. Daj povratnu informaciju za svaku stavku u ."); + sb.AppendLine("Stavke su već odabrane i prioritizovane. Daj povratnu informaciju za svaku stavku."); sb.AppendLine(); - sb.AppendLine("# Eskalacija na osnovu needsSupport"); - sb.AppendLine(" needsSupport=false: Postavi fokusirano pitanje koje sugeriše da nešto nije jasno,"); - sb.AppendLine(" bez otkrivanja odgovora."); - sb.AppendLine(" needsSupport=true: Imenuj problem direktno i kratko ispravi, ali bez navođenja"); - sb.AppendLine(" tačnog teksta ključnih proposicija ili relacija iz rubrike."); + sb.AppendLine("# Smernice po tipu i broju proba"); + sb.AppendLine(); + sb.AppendLine("Misconceptions:"); + sb.AppendLine(" probeCount=0: Imenuj zabludu i objasni zašto je pogrešna; pozovi učenika da je ispravi."); + sb.AppendLine(" probeCount=1: Pojačaj ispravku konkretnim kontrastom ili primerom; budi direktniji."); + sb.AppendLine(); + sb.AppendLine("Gaps — grade=\"-1\" (netačno):"); + sb.AppendLine(" probeCount=0: Postavi pitanje koje dovodi u pitanje netačnu tvrdnju, bez otkrivanja odgovora."); + sb.AppendLine(" probeCount=1: Imenuj grešku direktno i kratko objasni zašto je netačna; pozovi na ispravku."); + sb.AppendLine("Gaps — grade=\"0\" (izostavlja oblast):"); + sb.AppendLine(" probeCount=0: Postavi otvoreno pitanje koje poziva učenika da pokrije tu oblast."); + sb.AppendLine(" probeCount=1: Naznači direktno da je ta oblast izostavljena; daj usmerenje bez otkrivanja odgovora."); + sb.AppendLine("Gaps — grade=\"1\" (nejasno/parcijalno):"); + sb.AppendLine(" probeCount=0: Postavi pitanje koje traži veću preciznost ili dubinu."); + sb.AppendLine(" probeCount=1: Imenuj šta nedostaje u preciznosti; ukaži šta bi potpuniji odgovor sadržao."); sb.AppendLine(); sb.AppendLine("# Format izlaza"); 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 index 91cf0dd5a..dfc0b0a61 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs @@ -24,16 +24,26 @@ private static string RenderFeedbackInput(string elaboration, IReadOnlyList{elaboration}"); - sb.Append(""); - foreach (var t in targets) + + var misconceptions = targets.Where(t => t.ScoredTarget.Type == TargetType.Misconception).ToList(); + var gaps = targets.Where(t => t.ScoredTarget.Type != TargetType.Misconception).ToList(); + + if (misconceptions.Count > 0) + { + sb.Append(""); + foreach (var t in misconceptions) + sb.Append($""); + sb.Append(""); + } + + if (gaps.Count > 0) { - var support = t.NeedsSupport.ToString().ToLowerInvariant(); - if (t.Type == TargetType.Misconception) - sb.Append($""); - else - sb.Append($""); + sb.Append(""); + foreach (var t in gaps) + sb.Append($""); + sb.Append(""); } - sb.Append(""); + return sb.ToString(); } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql index b9946322d..2dd54fb10 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql @@ -1,5 +1,5 @@ DELETE FROM elaborations."TurnEvaluations"; -DELETE FROM elaborations."ConversationTurns"; +DELETE FROM elaborations."ConversationRounds"; DELETE FROM elaborations."ConversationAttempts"; DELETE FROM elaborations."ConceptRecords"; DELETE FROM elaborations."ConceptElaborationTasks"; 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 index ad4247e05..ae48e2c62 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -17,7 +17,7 @@ INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId" 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", "FeedbackTargets") -VALUES (-3, -3, 0, 'Encapsulation is about data hiding.', '2024-06-03 10:01:00+00', 'What else can you tell me about encapsulation?', '[{{"Key":"P1","Type":0,"Grade":0,"NeedsSupport":false}}]'::jsonb); +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}},"ProbesWithoutGradeChangeCount":0}}]'::jsonb); INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") VALUES (-3, -3, '[{{"Key":"P1","Type":0,"Grade":0}}]'::jsonb, '["M1"]'::jsonb); @@ -27,7 +27,7 @@ INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId" 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", "FeedbackTargets") -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.', '[{{"Key":"P2","Type":0,"Grade":0,"NeedsSupport":false}}]'::jsonb); +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}},"ProbesWithoutGradeChangeCount":0}}]'::jsonb); INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") VALUES (-4, -4, '[{{"Key":"P1","Type":0,"Grade":1}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); @@ -37,11 +37,11 @@ INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId" 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", "FeedbackTargets") -VALUES (-50, -5, 0, 'Round 1 elaboration.', '2024-06-05 10:01:00+00', 'Feedback 1.', '[{{"Key":"P1","Type":0,"Grade":0,"NeedsSupport":false}},{{"Key":"P2","Type":0,"Grade":0,"NeedsSupport":false}}]'::jsonb); +VALUES (-50, -5, 0, 'Round 1 elaboration.', '2024-06-05 10:01:00+00', 'Feedback 1.', '[{{"ScoredTarget":{{"Key":"P1","Type":0,"Grade":0}},"ProbesWithoutGradeChangeCount":0}},{{"ScoredTarget":{{"Key":"P2","Type":0,"Grade":0}},"ProbesWithoutGradeChangeCount":0}}]'::jsonb); INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "FeedbackTargets") -VALUES (-52, -5, 1, 'Round 2 elaboration.', '2024-06-05 10:02:00+00', 'Feedback 2.', '[{{"Key":"P1","Type":0,"Grade":0,"NeedsSupport":true}},{{"Key":"P2","Type":0,"Grade":0,"NeedsSupport":true}}]'::jsonb); +VALUES (-52, -5, 1, 'Round 2 elaboration.', '2024-06-05 10:02:00+00', 'Feedback 2.', '[{{"ScoredTarget":{{"Key":"P1","Type":0,"Grade":0}},"ProbesWithoutGradeChangeCount":1}},{{"ScoredTarget":{{"Key":"P2","Type":0,"Grade":0}},"ProbesWithoutGradeChangeCount":1}}]'::jsonb); INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "FeedbackTargets") -VALUES (-54, -5, 2, 'Round 3 elaboration.', '2024-06-05 10:03:00+00', 'Feedback 3.', '[{{"Key":"P1","Type":0,"Grade":0,"NeedsSupport":true}},{{"Key":"P2","Type":0,"Grade":0,"NeedsSupport":true}}]'::jsonb); +VALUES (-54, -5, 2, 'Round 3 elaboration.', '2024-06-05 10:03:00+00', 'Feedback 3.', '[{{"ScoredTarget":{{"Key":"P1","Type":0,"Grade":0}},"ProbesWithoutGradeChangeCount":2}},{{"ScoredTarget":{{"Key":"P2","Type":0,"Grade":0}},"ProbesWithoutGradeChangeCount":2}}]'::jsonb); INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") VALUES (-50, -50, '[{{"Key":"P1","Type":0,"Grade":0}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); From 8e424c5281b7a2ad1a9aa950a118176c039459c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Thu, 7 May 2026 13:11:38 +0300 Subject: [PATCH 48/51] refactor: Renames FeedbackTarget > Probe; TurnEvaluation > RoundEvaluation --- .../Conversations/ConversationAttempt.cs | 98 +++++++++---------- .../Domain/Conversations/ConversationRound.cs | 10 +- .../Domain/Conversations/FeedbackTarget.cs | 6 -- .../Domain/Conversations/Probe.cs | 6 ++ .../{TurnEvaluation.cs => RoundEvaluation.cs} | 10 +- .../Orchestration/AgentOrchestrator.cs | 8 +- .../Learning/Prompts/LlmRequestFactory.cs | 18 ++-- .../Learning/Prompts/ScoreResponseDto.cs | 4 +- .../Database/ElaborationsContext.cs | 8 +- .../TestData/a-delete.sql | 2 +- .../TestData/e-conversation-attempts.sql | 34 +++---- 11 files changed, 97 insertions(+), 107 deletions(-) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/Probe.cs rename src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/{TurnEvaluation.cs => RoundEvaluation.cs} (77%) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs index e8a8c5b7d..3a3fb69cf 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -33,115 +33,105 @@ public bool IsStagnating() { var scores = _rounds .TakeLast(3) - .Select(r => r.Evaluation.TotalScore()) + .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, TurnEvaluation evaluation) + public void BeginRound(string elaboration, RoundEvaluation evaluation) { _rounds.Add(new ConversationRound(_rounds.Count, elaboration, evaluation)); FinalGrade = evaluation.ComputeGrade(TotalTargets); } - public void CompleteCurrentRound(string feedbackContent, IReadOnlyList feedbackTargets) + public void CompleteRound(string feedbackContent, IReadOnlyList probes) { - _rounds[^1].Complete(feedbackContent, feedbackTargets); + _rounds[^1].Complete(feedbackContent, probes); } - public IReadOnlyList SelectFeedbackTargets(int maxItems = 2) + public IReadOnlyList SelectProbes(int maxItems = 2) { var excludedProbes = GetExcludedProbes(); var activeProbes = GetRecentActiveProbes(2); var deficientTargets = _rounds[^1].Evaluation.GetDeficientTargets(excludedProbes); - var targets = new List(); + var probes = new List(); - targets.AddRange(CreateMomentumProbes(deficientTargets, activeProbes)); // Active probes where grade improved - if (targets.Count >= maxItems) return targets.Take(maxItems).ToList(); + probes.AddRange(CreateMomentumProbes(deficientTargets, activeProbes)); + if (probes.Count >= maxItems) return probes.Take(maxItems).ToList(); - targets.AddRange(CreateStagnantProbes(deficientTargets, activeProbes)); // Active probes where grade did not improve - if (targets.Count >= maxItems) return targets.Take(maxItems).ToList(); + probes.AddRange(CreateStagnantProbes(deficientTargets, activeProbes)); + if (probes.Count >= maxItems) return probes.Take(maxItems).ToList(); - targets.AddRange(CreateNewProbes(deficientTargets, activeProbes)); // No active probes + probes.AddRange(CreateNewProbes(deficientTargets, activeProbes)); - return targets.Take(maxItems).ToList(); + return probes.Take(maxItems).ToList(); } - private static List CreateMomentumProbes(List deficientTargets, List activeProbes) + private static List CreateMomentumProbes(List deficientTargets, List activeProbes) { - var momentumProbes = new List(); + var result = new List(); foreach (var target in deficientTargets) { - var relatedProbe = activeProbes.Find(probe => probe.ScoredTarget.SameTarget(target)); - if (relatedProbe?.ScoredTarget.Grade < target.Grade) - { - momentumProbes.Add(new FeedbackTarget(target, 0)); - } + var related = activeProbes.Find(p => p.ScoredTarget.SameTarget(target)); + if (related?.ScoredTarget.Grade < target.Grade) + result.Add(new Probe(target, 0)); } - - return momentumProbes; + return result; } - private static List CreateStagnantProbes(List deficientTargets, List activeProbes) + private static List CreateStagnantProbes(List deficientTargets, List activeProbes) { - var stagnantProbes = new List(); + var result = new List(); foreach (var target in deficientTargets) { - var relatedProbe = activeProbes.Find(probe => probe.ScoredTarget.SameTarget(target)); - if (relatedProbe == null || relatedProbe.ScoredTarget.Grade < target.Grade) continue; - if (relatedProbe.ScoredTarget.Grade == target.Grade) - { - stagnantProbes.Add(new FeedbackTarget(target, relatedProbe.ProbesWithoutGradeChangeCount + 1)); - continue; - } - stagnantProbes.Add(new FeedbackTarget(target, 0)); + 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 stagnantProbes; + return result; } - private static List CreateNewProbes(List deficientTargets, List activeProbes) + private static List CreateNewProbes(List deficientTargets, List activeProbes) { - var newProbes = new List(); + var result = new List(); foreach (var target in deficientTargets) { - var relatedProbe = activeProbes.Find(probe => probe.ScoredTarget.SameTarget(target)); - if (relatedProbe == null) - { - newProbes.Add(new FeedbackTarget(target, 0)); - } + if (activeProbes.Find(p => p.ScoredTarget.SameTarget(target)) == null) + result.Add(new Probe(target, 0)); } - - return newProbes; + return result; } - private List GetRecentActiveProbes(int lookBack) + private List GetRecentActiveProbes(int lookBack) { - var activeProbes = new List(); + var result = new List(); foreach (var round in _rounds.SkipLast(1).Reverse().Take(lookBack)) { - foreach (var target in round.FeedbackTargets) + foreach (var probe in round.Probes) { - if (target.IsStalled()) continue; - if (activeProbes.Any(p => p.ScoredTarget.SameTarget(target.ScoredTarget))) continue; - activeProbes.Add(target); + if (probe.IsStalled()) continue; + if (result.Any(p => p.ScoredTarget.SameTarget(probe.ScoredTarget))) continue; + result.Add(probe); } } - return activeProbes; + return result; } - private List GetExcludedProbes() + private List GetExcludedProbes() { - var excludedProbes = new List(); + var result = new List(); foreach (var round in _rounds.SkipLast(1).Reverse()) { - foreach (var target in round.FeedbackTargets.Where(t => t.IsStalled())) + foreach (var probe in round.Probes.Where(p => p.IsStalled())) { - if (!excludedProbes.Any(p => p.ScoredTarget.SameTarget(target.ScoredTarget))) - excludedProbes.Add(target); + if (!result.Any(p => p.ScoredTarget.SameTarget(probe.ScoredTarget))) + result.Add(probe); } } - return excludedProbes; + return result; } public void Complete() diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationRound.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationRound.cs index 3da6f9143..d94143ff9 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationRound.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationRound.cs @@ -8,13 +8,13 @@ public class ConversationRound : Entity public int Order { get; private set; } public string ElaborationContent { get; private set; } = string.Empty; public DateTime SubmittedAt { get; private set; } - public TurnEvaluation Evaluation { get; private set; } = null!; + public RoundEvaluation Evaluation { get; private set; } = null!; public string? FeedbackContent { get; private set; } - public IReadOnlyList FeedbackTargets { get; private set; } = []; + public IReadOnlyList Probes { get; private set; } = []; private ConversationRound() { } - internal ConversationRound(int order, string elaborationContent, TurnEvaluation evaluation) + internal ConversationRound(int order, string elaborationContent, RoundEvaluation evaluation) { Order = order; ElaborationContent = elaborationContent; @@ -22,9 +22,9 @@ internal ConversationRound(int order, string elaborationContent, TurnEvaluation Evaluation = evaluation; } - internal void Complete(string feedbackContent, IReadOnlyList feedbackTargets) + internal void Complete(string feedbackContent, IReadOnlyList probes) { FeedbackContent = feedbackContent; - FeedbackTargets = feedbackTargets; + Probes = probes; } } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs deleted file mode 100644 index 79dc0ed3d..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/FeedbackTarget.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Tutor.Elaborations.Core.Domain.Conversations; - -public record FeedbackTarget(ScoredTarget ScoredTarget, int ProbesWithoutGradeChangeCount) -{ - public bool IsStalled() => ProbesWithoutGradeChangeCount >= 2; -} \ No newline at end of file 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/TurnEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/RoundEvaluation.cs similarity index 77% rename from src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs rename to src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/RoundEvaluation.cs index 5b38cd97a..613ae63c1 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TurnEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/RoundEvaluation.cs @@ -2,21 +2,21 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; -public class TurnEvaluation : Entity +public class RoundEvaluation : Entity { public int ConversationRoundId { get; private set; } public List Assessments { get; private set; } = []; public List MisconceptionsTriggeredKeys { get; private set; } = []; - private TurnEvaluation() { } + private RoundEvaluation() { } - public TurnEvaluation(List assessments, List misconceptionsTriggeredKeys) + public RoundEvaluation(List assessments, List misconceptionsTriggeredKeys) { Assessments = assessments; MisconceptionsTriggeredKeys = misconceptionsTriggeredKeys; } - public int TotalScore() => Assessments.Sum(a => a.Grade); + public int ComputeTotalScore() => Assessments.Sum(a => a.Grade); public double ComputeGrade(int totalTargets) { @@ -24,7 +24,7 @@ public double ComputeGrade(int totalTargets) return Math.Round(Math.Max(0.0, normalizedScore - (0.2 * MisconceptionsTriggeredKeys.Count)), 2); } - public List GetDeficientTargets(List excludedProbes) + public List GetDeficientTargets(List excludedProbes) { var misconceptionTargets = MisconceptionsTriggeredKeys.Select(key => new ScoredTarget(key, TargetType.Misconception, -2)); var unfinishedTargets = Assessments.Where(a => a.Grade < 2); 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 index fa8f8f34e..d8e0e8378 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -57,10 +57,10 @@ public async IAsyncEnumerable ProcessSubmissionAsync( yield break; } - var targets = attempt.SelectFeedbackTargets(); + var probes = attempt.SelectProbes(); var fullResponse = new StringBuilder(); await foreach (var chunk in StreamAgentAsync( - LlmRequestFactory.ForEvaluationFeedback(record, elaboration, targets), "EvaluationFeedback", fullResponse, ct)) + LlmRequestFactory.ForEvaluationFeedback(record, elaboration, probes), "EvaluationFeedback", fullResponse, ct)) { yield return chunk; if (chunk is ErrorChunk) yield break; @@ -72,7 +72,7 @@ public async IAsyncEnumerable ProcessSubmissionAsync( yield return new TokenChunk(SystemTurnCodes.StagnationRedirect); } - attempt.CompleteCurrentRound(fullResponse.ToString(), targets); + attempt.CompleteRound(fullResponse.ToString(), probes); yield return CreateFinalChunk(attempt); } @@ -91,7 +91,7 @@ private async IAsyncEnumerable StreamAgentAsync(CompletionReq yield return new ErrorChunk(failure.Reason, 500); } - private async Task> ScoreElaborationAsync(ConceptRecord record, string elaboration, CancellationToken ct) + private async Task> ScoreElaborationAsync(ConceptRecord record, string elaboration, CancellationToken ct) { var result = await CompleteJsonAsync( LlmRequestFactory.ForElaborationScoring(record, elaboration), "ElaborationScoring", ct); 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 index dfc0b0a61..fbe48f663 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs @@ -14,33 +14,33 @@ public static CompletionRequest ForElaborationScoring(ConceptRecord record, stri } public static CompletionRequest ForEvaluationFeedback(ConceptRecord record, string elaboration, - IReadOnlyList targets) + IReadOnlyList probes) { - var messages = new List { ChatMessage.FromUser(RenderFeedbackInput(elaboration, targets)) }; + var messages = new List { ChatMessage.FromUser(RenderFeedbackInput(elaboration, probes)) }; return CompletionRequest.Create(messages, EvaluationFeedbackPrompt.Build(record), maxTokens: 512, temperature: 0.7); } - private static string RenderFeedbackInput(string elaboration, IReadOnlyList targets) + private static string RenderFeedbackInput(string elaboration, IReadOnlyList probes) { var sb = new StringBuilder(); sb.AppendLine($"{elaboration}"); - var misconceptions = targets.Where(t => t.ScoredTarget.Type == TargetType.Misconception).ToList(); - var gaps = targets.Where(t => t.ScoredTarget.Type != TargetType.Misconception).ToList(); + 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 t in misconceptions) - sb.Append($""); + foreach (var p in misconceptions) + sb.Append($""); sb.Append(""); } if (gaps.Count > 0) { sb.Append(""); - foreach (var t in gaps) - sb.Append($""); + foreach (var p in gaps) + sb.Append($""); sb.Append(""); } 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 index 6b062d129..ad6db6934 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponseDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponseDto.cs @@ -9,7 +9,7 @@ public class ScoreResponseDto public List? Assessments { get; set; } public List? MisconceptionsTriggeredKeys { get; set; } - public Result ToEvaluation(ConceptRecord record) + public Result ToEvaluation(ConceptRecord record) { if (Assessments == null) return Result.Fail("Assessments missing."); @@ -28,7 +28,7 @@ public Result ToEvaluation(ConceptRecord record) var validCmKeys = record.CommonMisconceptions.Select(cm => cm.Key).ToHashSet(); if (misconceptions.Any(k => !validCmKeys.Contains(k))) return Result.Fail("Unknown misconception key."); - return new TurnEvaluation(scoredTargets.Value, misconceptions); + return new RoundEvaluation(scoredTargets.Value, misconceptions); } private Result> CreateScoredTargets(HashSet kpKeys, HashSet krKeys) diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs index 0f0c53b79..b28ad8b24 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -10,7 +10,7 @@ public class ElaborationsContext : DbContext public DbSet ConceptRecords { get; set; } public DbSet ConversationAttempts { get; set; } public DbSet ConversationRounds { get; set; } - public DbSet TurnEvaluations { get; set; } + public DbSet RoundEvaluations { get; set; } public ElaborationsContext(DbContextOptions options) : base(options) { } @@ -62,15 +62,15 @@ private static void ConfigureConversations(ModelBuilder modelBuilder) modelBuilder.Entity() .HasOne(r => r.Evaluation) .WithOne() - .HasForeignKey(te => te.ConversationRoundId); + .HasForeignKey(te => te.ConversationRoundId); modelBuilder.Entity() .HasIndex(r => new { r.ConversationAttemptId, r.Order }); modelBuilder.Entity() - .Property(r => r.FeedbackTargets).HasColumnType("jsonb"); + .Property(r => r.Probes).HasColumnType("jsonb"); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.Property(te => te.Assessments).HasColumnType("jsonb"); entity.Property(te => te.MisconceptionsTriggeredKeys).HasColumnType("jsonb"); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql index 2dd54fb10..deeec1aaf 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql @@ -1,4 +1,4 @@ -DELETE FROM elaborations."TurnEvaluations"; +DELETE FROM elaborations."RoundEvaluations"; DELETE FROM elaborations."ConversationRounds"; DELETE FROM elaborations."ConversationAttempts"; DELETE FROM elaborations."ConceptRecords"; 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 index ae48e2c62..b5cf7b2d9 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -2,10 +2,10 @@ 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", "FeedbackTargets") +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."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") +INSERT INTO elaborations."RoundEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") VALUES (-1, -1, '[{{"Key":"P1","Type":0,"Grade":2}}]'::jsonb, '[]'::jsonb); -- Attempt -2: Learner -2, CET -1, Abandoned (for query tests) @@ -16,38 +16,38 @@ VALUES (-2, -1, -2, 2, '2024-06-02 10:00:00+00', '2024-06-02 10:05:00+00', 0.0, 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", "FeedbackTargets") -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}},"ProbesWithoutGradeChangeCount":0}}]'::jsonb); +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."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") +INSERT INTO elaborations."RoundEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") VALUES (-3, -3, '[{{"Key":"P1","Type":0,"Grade":0}}]'::jsonb, '["M1"]'::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", "FeedbackTargets") -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}},"ProbesWithoutGradeChangeCount":0}}]'::jsonb); +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."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") +INSERT INTO elaborations."RoundEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") 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", "FeedbackTargets") -VALUES (-50, -5, 0, 'Round 1 elaboration.', '2024-06-05 10:01:00+00', 'Feedback 1.', '[{{"ScoredTarget":{{"Key":"P1","Type":0,"Grade":0}},"ProbesWithoutGradeChangeCount":0}},{{"ScoredTarget":{{"Key":"P2","Type":0,"Grade":0}},"ProbesWithoutGradeChangeCount":0}}]'::jsonb); -INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "FeedbackTargets") -VALUES (-52, -5, 1, 'Round 2 elaboration.', '2024-06-05 10:02:00+00', 'Feedback 2.', '[{{"ScoredTarget":{{"Key":"P1","Type":0,"Grade":0}},"ProbesWithoutGradeChangeCount":1}},{{"ScoredTarget":{{"Key":"P2","Type":0,"Grade":0}},"ProbesWithoutGradeChangeCount":1}}]'::jsonb); -INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "FeedbackTargets") -VALUES (-54, -5, 2, 'Round 3 elaboration.', '2024-06-05 10:03:00+00', 'Feedback 3.', '[{{"ScoredTarget":{{"Key":"P1","Type":0,"Grade":0}},"ProbesWithoutGradeChangeCount":2}},{{"ScoredTarget":{{"Key":"P2","Type":0,"Grade":0}},"ProbesWithoutGradeChangeCount":2}}]'::jsonb); +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."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") +INSERT INTO elaborations."RoundEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") VALUES (-50, -50, '[{{"Key":"P1","Type":0,"Grade":0}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") +INSERT INTO elaborations."RoundEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") VALUES (-52, -52, '[{{"Key":"P1","Type":0,"Grade":0}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); -INSERT INTO elaborations."TurnEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") +INSERT INTO elaborations."RoundEvaluations"("Id", "ConversationRoundId", "Assessments", "MisconceptionsTriggeredKeys") 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 From 4a11bfe86ec24f306114cb0413c06f2ed564357d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Fri, 8 May 2026 11:06:28 +0300 Subject: [PATCH 49/51] feat: Expands ScoreTarget to include evidence and aligns prompts to utilize this. --- .../Domain/Conversations/ScoredTarget.cs | 2 +- .../Tutor.Elaborations.Core.csproj | 5 ++ .../Learning/Prompts/ConceptRubricSection.cs | 16 ++--- .../Prompts/EvaluationFeedbackPrompt.cs | 56 +++------------- .../Prompts/EvaluationFeedbackPrompt.md | 0 .../Learning/Prompts/LlmRequestFactory.cs | 4 +- .../UseCases/Learning/Prompts/ScorePrompt.cs | 67 +++---------------- .../UseCases/Learning/Prompts/ScorePrompt.md | 40 +++++++++++ .../Learning/Prompts/ScoreResponseDto.cs | 3 +- 9 files changed, 78 insertions(+), 115 deletions(-) create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.md create mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.md diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs index cf2c61fe0..511e2aca9 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs @@ -1,6 +1,6 @@ namespace Tutor.Elaborations.Core.Domain.Conversations; -public record ScoredTarget(string Key, TargetType Type, int Grade) +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() => this.Grade switch { -2 => 0, -1 => 1, 1 => 2, _ => 3 }; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Tutor.Elaborations.Core.csproj b/src/Modules/Elaborations/Tutor.Elaborations.Core/Tutor.Elaborations.Core.csproj index 7662f8fa1..67eca5bcd 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Tutor.Elaborations.Core.csproj +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Tutor.Elaborations.Core.csproj @@ -6,6 +6,11 @@ enable + + + + + 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 index b1fa51891..5c19b77cb 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRubricSection.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRubricSection.cs @@ -22,14 +22,6 @@ public static string Render(ConceptRecord record) sb.AppendLine($"- [{kp.Key}] {kp.Statement}"); sb.AppendLine(); - 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(); - } - if (record.KeyRelations.Count > 0) { sb.AppendLine("## Key Relations"); @@ -42,6 +34,14 @@ public static string Render(ConceptRecord record) } } + 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.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs index cd4d03003..2711d3545 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs @@ -1,56 +1,20 @@ -using System.Text; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public static class EvaluationFeedbackPrompt { - public static string Build(ConceptRecord record) - { - var sb = new StringBuilder(); - sb.AppendLine(ConceptRubricSection.Render(record)); - - sb.AppendLine("# Role"); - sb.AppendLine("Ti si evaluator koji daje dijagnostičku povratnu informaciju učeniku koji vežba"); - sb.AppendLine("artikulaciju koncepta za usmeni ispit. Tvoj cilj je da usmeravaš, ne da podučavaš."); - sb.AppendLine("Nikada ne davaj tačan odgovor niti citiraj tekst iz rubrike."); - sb.AppendLine(); - - sb.AppendLine("# Runtime kontekst"); - sb.AppendLine("Dobijaš:"); - sb.AppendLine(" : tekst koji je učenik napisao"); - sb.AppendLine(" : CM ključevi koje je učenik pogrešno primenio"); - sb.AppendLine(" : probeCount = broj puta probeovano bez napretka"); - sb.AppendLine(" : nedostaci u KP/KR elaboraciji"); - sb.AppendLine(" "); - sb.AppendLine(" grade: -1 = netačna tvrdnja | 0 = izostavljena oblast | 1 = nejasna/parcijalna tvrdnja"); - sb.AppendLine(" probeCount: broj puta probeovano bez napretka"); - sb.AppendLine(); + private static readonly string Template = LoadTemplate(); - sb.AppendLine("Stavke su već odabrane i prioritizovane. Daj povratnu informaciju za svaku stavku."); - sb.AppendLine(); + public static string Build(ConceptRecord record) => + ConceptRubricSection.Render(record) + "\n" + Template; - sb.AppendLine("# Smernice po tipu i broju proba"); - sb.AppendLine(); - sb.AppendLine("Misconceptions:"); - sb.AppendLine(" probeCount=0: Imenuj zabludu i objasni zašto je pogrešna; pozovi učenika da je ispravi."); - sb.AppendLine(" probeCount=1: Pojačaj ispravku konkretnim kontrastom ili primerom; budi direktniji."); - sb.AppendLine(); - sb.AppendLine("Gaps — grade=\"-1\" (netačno):"); - sb.AppendLine(" probeCount=0: Postavi pitanje koje dovodi u pitanje netačnu tvrdnju, bez otkrivanja odgovora."); - sb.AppendLine(" probeCount=1: Imenuj grešku direktno i kratko objasni zašto je netačna; pozovi na ispravku."); - sb.AppendLine("Gaps — grade=\"0\" (izostavlja oblast):"); - sb.AppendLine(" probeCount=0: Postavi otvoreno pitanje koje poziva učenika da pokrije tu oblast."); - sb.AppendLine(" probeCount=1: Naznači direktno da je ta oblast izostavljena; daj usmerenje bez otkrivanja odgovora."); - sb.AppendLine("Gaps — grade=\"1\" (nejasno/parcijalno):"); - sb.AppendLine(" probeCount=0: Postavi pitanje koje traži veću preciznost ili dubinu."); - sb.AppendLine(" probeCount=1: Imenuj šta nedostaje u preciznosti; ukaži šta bi potpuniji odgovor sadržao."); - sb.AppendLine(); - - sb.AppendLine("# Format izlaza"); - sb.AppendLine("Samo tekst povratne informacije na srpskom. Svaka stavka u novom paragrafu."); - sb.AppendLine("Bez naslova, bez numerisanja, bez dodatnog teksta."); - - return sb.ToString(); + private static string LoadTemplate() + { + var assembly = typeof(EvaluationFeedbackPrompt).Assembly; + using var stream = assembly.GetManifestResourceStream( + "Tutor.Elaborations.Core.UseCases.Learning.Prompts.EvaluationFeedbackPrompt.md")!; + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); } } 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 index fbe48f663..4a3b5fbc2 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs @@ -32,7 +32,7 @@ private static string RenderFeedbackInput(string elaboration, IReadOnlyList"); foreach (var p in misconceptions) - sb.Append($""); + sb.Append($""); sb.Append(""); } @@ -40,7 +40,7 @@ private static string RenderFeedbackInput(string elaboration, IReadOnlyList"); foreach (var p in gaps) - sb.Append($""); + sb.Append($""); sb.Append(""); } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs index 3f967d108..cf30116ef 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs @@ -1,67 +1,20 @@ -using System.Text; using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public static class ScorePrompt { - public static string Build(ConceptRecord record) - { - var sb = new StringBuilder(); - sb.AppendLine(ConceptRubricSection.Render(record)); - - sb.AppendLine("# Role"); - sb.AppendLine("You are a scoring agent. Output JSON only, no other text."); - sb.AppendLine(); - - sb.AppendLine("# Scoring task"); - sb.AppendLine("Score the learner's Elaboration inside against every Key Proposition and Key Relation in the concept rubric above."); - sb.AppendLine("All KPs and KRs must appear in the output, even if not addressed."); - sb.AppendLine(); - - sb.AppendLine("Use this scale for Key Propositions:"); - sb.AppendLine(" -1 (Incorrect): The opposite of what is true, or so unrelated it signals clear misunderstanding."); - sb.AppendLine(" 0 (Missing): Not present, or stated so vaguely it conveys nothing useful."); - sb.AppendLine(" 1 (Vague): Present but imprecise or incomplete — too broad, omits a critical qualifier,"); - sb.AppendLine(" or a reader who didn't already know the concept could not reconstruct it from this statement alone."); - sb.AppendLine(" 2 (Adequate): Clearly and correctly stated. Specific enough to distinguish it from adjacent or general concepts."); - sb.AppendLine(); + private static readonly string Template = LoadTemplate(); - sb.AppendLine("Use this scale for Key Relations:"); - sb.AppendLine(" -1 (Incorrect): The opposite of what is true, or so unrelated it signals clear misunderstanding."); - sb.AppendLine(" 0 (Missing): The causal or conditional link between the two propositions is absent."); - sb.AppendLine(" 1 (Vague): Both propositions mentioned in proximity, but the mechanism connecting them"); - sb.AppendLine(" is not expressed — the learner lists rather than relates."); - sb.AppendLine(" 2 (Adequate): The mechanism is explicitly stated: why or under what condition one proposition"); - sb.AppendLine(" determines or constrains the other."); - sb.AppendLine(); + public static string Build(ConceptRecord record) => + ConceptRubricSection.Render(record) + "\n" + Template; - sb.AppendLine("Score only what is explicitly written. Do not infer or credit implied content."); - sb.AppendLine("Evaluate concepts, not language. Grammar and style must not reduce scores."); - sb.AppendLine("Resist sycophancy. Evaluate strictly against the rubric."); - sb.AppendLine(); - - if (record.CommonMisconceptions.Count != 0) - { - sb.AppendLine("# Misconception detection"); - sb.AppendLine("Flag a misconception if the learner's text contains reasoning or claims that reflect that"); - sb.AppendLine("misunderstanding, even if the learner also states something correct nearby."); - sb.AppendLine("List the keys of any known misconceptions triggered in this Elaboration."); - sb.AppendLine(); - } - - var assessmentExample = record.KeyPropositions.Count > 0 - ? $"{{ \"key\": \"{record.KeyPropositions[0].Key}\", \"type\": \"proposition\", \"grade\": 0 }}" - : "{ \"key\": \"P1\", \"type\": \"proposition\", \"grade\": 0 }"; - sb.AppendLine("# Output Format (JSON only, no other text)"); - sb.AppendLine("{"); - sb.AppendLine($" \"assessments\": [ {assessmentExample}, … one entry per KP and KR ],"); - if (record.CommonMisconceptions.Count != 0) - sb.AppendLine(" \"misconceptionsTriggeredKeys\": [string list of CM keys triggered, e.g. [\"M1\"]]"); - else - sb.AppendLine(" \"misconceptionsTriggeredKeys\": []"); - sb.AppendLine("}"); - - return sb.ToString(); + private static string LoadTemplate() + { + var assembly = typeof(ScorePrompt).Assembly; + using var stream = assembly.GetManifestResourceStream( + "Tutor.Elaborations.Core.UseCases.Learning.Prompts.ScorePrompt.md")!; + 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..2c9ae5c41 --- /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 if the learner's text contains reasoning or claims that reflect that misunderstanding, even if the learner also states something correct nearby. +- List the keys of any known misconceptions triggered in this Elaboration. + +# Output Format (JSON only, no other text) +{ + "assessments": [ { "key": "P1", "type": "proposition", "evidence": "exact quotes", "grade": 0 }, … one entry per KP and KR ], + "misconceptionsTriggeredKeys": [ "M1", … string list of CM keys triggered (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 index ad6db6934..1f2368cdf 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponseDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponseDto.cs @@ -51,7 +51,7 @@ private Result> CreateScoredTargets(HashSet kpKeys, H 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)); + scoredTargets.Add(new ScoredTarget(dto.Key, type.Value, dto.Grade, dto.Evidence ?? "")); } return scoredTargets; } @@ -61,5 +61,6 @@ 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 From a89af5f6a7d32496de4389cafc999bffa33b7c6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Fri, 8 May 2026 13:20:16 +0300 Subject: [PATCH 50/51] refactor: Simplifies prompt infrastructure and improves misconception tracking. --- .../Domain/Conversations/RoundEvaluation.cs | 11 +++++----- .../Domain/Conversations/ScoredTarget.cs | 2 +- .../Prompts/EvaluationFeedbackPrompt.cs | 20 ------------------- .../Learning/Prompts/LlmRequestFactory.cs | 16 +++++++++++++-- .../UseCases/Learning/Prompts/ScorePrompt.cs | 20 ------------------- .../UseCases/Learning/Prompts/ScorePrompt.md | 6 +++--- .../Learning/Prompts/ScoreResponseDto.cs | 13 ++++++++---- .../Database/ElaborationsContext.cs | 2 +- .../TestData/e-conversation-attempts.sql | 14 ++++++------- 9 files changed, 40 insertions(+), 64 deletions(-) delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs delete mode 100644 src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.cs diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/RoundEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/RoundEvaluation.cs index 613ae63c1..4635c1f23 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/RoundEvaluation.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/RoundEvaluation.cs @@ -6,14 +6,14 @@ public class RoundEvaluation : Entity { public int ConversationRoundId { get; private set; } public List Assessments { get; private set; } = []; - public List MisconceptionsTriggeredKeys { get; private set; } = []; + public List TriggeredMisconceptions { get; private set; } = []; private RoundEvaluation() { } - public RoundEvaluation(List assessments, List misconceptionsTriggeredKeys) + public RoundEvaluation(List assessments, List triggeredMisconceptions) { Assessments = assessments; - MisconceptionsTriggeredKeys = misconceptionsTriggeredKeys; + TriggeredMisconceptions = triggeredMisconceptions; } public int ComputeTotalScore() => Assessments.Sum(a => a.Grade); @@ -21,15 +21,14 @@ public RoundEvaluation(List assessments, List misconceptio 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 * MisconceptionsTriggeredKeys.Count)), 2); + return Math.Round(Math.Max(0.0, normalizedScore - (0.2 * TriggeredMisconceptions.Count)), 2); } public List GetDeficientTargets(List excludedProbes) { - var misconceptionTargets = MisconceptionsTriggeredKeys.Select(key => new ScoredTarget(key, TargetType.Misconception, -2)); var unfinishedTargets = Assessments.Where(a => a.Grade < 2); - return unfinishedTargets.Concat(misconceptionTargets) + 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 index 511e2aca9..3e8fb381f 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs @@ -3,5 +3,5 @@ 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() => this.Grade switch { -2 => 0, -1 => 1, 1 => 2, _ => 3 }; + 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/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs deleted file mode 100644 index 2711d3545..000000000 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; - -namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; - -public static class EvaluationFeedbackPrompt -{ - private static readonly string Template = LoadTemplate(); - - public static string Build(ConceptRecord record) => - ConceptRubricSection.Render(record) + "\n" + Template; - - private static string LoadTemplate() - { - var assembly = typeof(EvaluationFeedbackPrompt).Assembly; - using var stream = assembly.GetManifestResourceStream( - "Tutor.Elaborations.Core.UseCases.Learning.Prompts.EvaluationFeedbackPrompt.md")!; - using var reader = new StreamReader(stream); - return reader.ReadToEnd(); - } -} 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 index 4a3b5fbc2..ba62e4050 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs @@ -7,17 +7,20 @@ 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, ScorePrompt.Build(record), maxTokens: 1024, temperature: 0.0); + 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, EvaluationFeedbackPrompt.Build(record), maxTokens: 512, temperature: 0.7); + return CompletionRequest.Create(messages, EvaluationFeedbackTemplate + "\n" + ConceptRubricSection.Render(record), maxTokens: 512, temperature: 0.7); } private static string RenderFeedbackInput(string elaboration, IReadOnlyList probes) @@ -46,4 +49,13 @@ private static string RenderFeedbackInput(string elaboration, IReadOnlyList - ConceptRubricSection.Render(record) + "\n" + Template; - - private static string LoadTemplate() - { - var assembly = typeof(ScorePrompt).Assembly; - using var stream = assembly.GetManifestResourceStream( - "Tutor.Elaborations.Core.UseCases.Learning.Prompts.ScorePrompt.md")!; - 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 index 2c9ae5c41..6a24a09a4 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.md +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.md @@ -27,13 +27,13 @@ For grades -1, 1, and 2, evidence MUST contain at least one verbatim quote. For - 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 if the learner's text contains reasoning or claims that reflect that misunderstanding, even if the learner also states something correct nearby. -- List the keys of any known misconceptions triggered in this Elaboration. +- 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 ], - "misconceptionsTriggeredKeys": [ "M1", … string list of CM keys triggered (empty array if none)] + "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 index 1f2368cdf..7b5e91492 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponseDto.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponseDto.cs @@ -7,7 +7,7 @@ namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; public class ScoreResponseDto { public List? Assessments { get; set; } - public List? MisconceptionsTriggeredKeys { get; set; } + public List? Misconceptions { get; set; } public Result ToEvaluation(ConceptRecord record) { @@ -24,11 +24,14 @@ public Result ToEvaluation(ConceptRecord record) var scoredTargets = CreateScoredTargets(kpKeys, krKeys); if (scoredTargets.IsFailed) return Result.Fail(scoredTargets.Errors); - var misconceptions = MisconceptionsTriggeredKeys ?? []; + var misconceptions = Misconceptions ?? []; var validCmKeys = record.CommonMisconceptions.Select(cm => cm.Key).ToHashSet(); - if (misconceptions.Any(k => !validCmKeys.Contains(k))) return Result.Fail("Unknown misconception key."); + if (misconceptions.Any(m => !validCmKeys.Contains(m.Key))) return Result.Fail("Unknown misconception key."); - return new RoundEvaluation(scoredTargets.Value, misconceptions); + 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) @@ -57,6 +60,8 @@ private Result> CreateScoredTargets(HashSet kpKeys, H } } +public record MisconceptionDto(string Key, string Evidence); + public class ScoredTargetDto { public string Key { get; set; } = ""; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs index b28ad8b24..9f473e979 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -73,7 +73,7 @@ private static void ConfigureConversations(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.Property(te => te.Assessments).HasColumnType("jsonb"); - entity.Property(te => te.MisconceptionsTriggeredKeys).HasColumnType("jsonb"); + entity.Property(te => te.TriggeredMisconceptions).HasColumnType("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 index b5cf7b2d9..9901acde8 100644 --- a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -5,7 +5,7 @@ VALUES (-1, -1, -2, 1, '2024-06-01 10:00:00+00', '2024-06-01 10:15:00+00', 1.0, 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", "MisconceptionsTriggeredKeys") +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) @@ -19,8 +19,8 @@ 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", "MisconceptionsTriggeredKeys") -VALUES (-3, -3, '[{{"Key":"P1","Type":0,"Grade":0}}]'::jsonb, '["M1"]'::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") @@ -29,7 +29,7 @@ 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", "MisconceptionsTriggeredKeys") +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) @@ -43,11 +43,11 @@ VALUES (-52, -5, 1, 'Round 2 elaboration.', '2024-06-05 10:02:00+00', 'Feedback 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", "MisconceptionsTriggeredKeys") +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", "MisconceptionsTriggeredKeys") +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", "MisconceptionsTriggeredKeys") +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 From 16cd6a9bb55bd323ed545d6b933a274208dcc619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Luburi=C4=87?= Date: Mon, 11 May 2026 08:38:57 +0300 Subject: [PATCH 51/51] feat: Utility for extracting elaboration conversations from DB for critical analysis. --- utils/.gitignore | 1 + utils/conversation-rounds.sql | 18 +++++++++ utils/fetch_elaborations.py | 69 +++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 utils/.gitignore create mode 100644 utils/conversation-rounds.sql create mode 100644 utils/fetch_elaborations.py 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()