From 41d0dc8d4067c9f391bc9af71a5d1baf375e4718 Mon Sep 17 00:00:00 2001 From: Oliver Pelly Date: Wed, 8 Apr 2026 19:07:09 -0700 Subject: [PATCH] LLM reccomendation service --- api/Program.cs | 3 +- api/src/Controllers/StudentController.cs | 80 +++++- .../BenchmarkRecommendationResponse.cs | 7 + api/src/Services/OllamaClient.cs | 92 +++++++ api/src/Services/OllamaService.cs | 163 ----------- api/src/Services/RecommendationService.cs | 257 ++++++++++++++++++ .../edit-benchmark-modal.html | 13 +- .../edit-benchmark-modal.scss | 33 ++- .../edit-benchmark-modal.ts | 18 ++ .../src/app/shared/classes/benchmark.dto.ts | 5 + .../src/app/shared/classes/http-errors.ts | 2 +- .../app/shared/services/student.service.ts | 20 +- 12 files changed, 524 insertions(+), 169 deletions(-) create mode 100644 api/src/Models/ResponseTypes/BenchmarkRecommendationResponse.cs create mode 100644 api/src/Services/OllamaClient.cs delete mode 100644 api/src/Services/OllamaService.cs create mode 100644 api/src/Services/RecommendationService.cs diff --git a/api/Program.cs b/api/Program.cs index d45abbf..1534881 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -61,11 +61,12 @@ builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromMinutes(5); }); -builder.Services.AddHttpClient(client => +builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(builder.Configuration["Ollama:BaseUrl"] ?? "https://llm.opelly.me"); client.Timeout = TimeSpan.FromMinutes(3); }); +builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); diff --git a/api/src/Controllers/StudentController.cs b/api/src/Controllers/StudentController.cs index 99dc6e7..56fe0a0 100644 --- a/api/src/Controllers/StudentController.cs +++ b/api/src/Controllers/StudentController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using WinStudentGoalTracker.Models; +using WinStudentGoalTracker.Models.ResponseTypes; using WinStudentGoalTracker.BaseClasses; using WinStudentGoalTracker.DataAccess; using WinStudentGoalTracker.Services; @@ -11,7 +12,14 @@ namespace WinStudentGoalTracker.Controllers; [Route("api/[controller]")] public class StudentController : BaseController { - private readonly StudentRepository _studentRepository = new(); + private readonly StudentRepository _studentRepository; + private readonly RecommendationService _recommendationService; + + public StudentController(RecommendationService recommendationService) + { + _studentRepository = new(); + _recommendationService = recommendationService; + } [HttpGet("my")] @@ -711,4 +719,74 @@ public class StudentController : BaseController Data = markdown }); } + + [HttpGet("{idStudent:guid}/goals/{idGoal:guid}/benchmark-recommendation")] + [Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status404NotFound)] + public async Task>> GetBenchmarkRecommendation( + Guid idStudent, Guid idGoal, CancellationToken cancellationToken) + { + var (userId, email, programId, role, error) = GetProgramUserFromClaims(); + if (error is not null) + return error; + + var students = await _studentRepository.GetMyStudentsAsync(userId, programId, role, "all"); + + if (!students.Select(s => s.StudentId).Contains(idStudent)) + { + return NotFound(new ResponseResult + { + Success = false, + Message = "Student not found." + }); + } + + var profile = await _studentRepository.GetFullProfileAsync(idStudent); + if (profile is null) + { + return NotFound(new ResponseResult + { + Success = false, + Message = "Student not found." + }); + } + + if (profile.Goals.All(g => g.GoalId != idGoal)) + { + return NotFound(new ResponseResult + { + Success = false, + Message = "Goal not found." + }); + } + + try + { + var recommendation = await _recommendationService.RecommendBenchmarkAsync(profile, idGoal, cancellationToken); + + return Ok(new ResponseResult + { + Success = true, + Message = "Benchmark recommendation generated successfully.", + Data = recommendation + }); + } + catch (OllamaClient.OllamaUnavailableException ex) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new ResponseResult + { + Success = false, + Message = ex.Message + }); + } + catch (InvalidOperationException ex) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new ResponseResult + { + Success = false, + Message = ex.Message + }); + } + } } diff --git a/api/src/Models/ResponseTypes/BenchmarkRecommendationResponse.cs b/api/src/Models/ResponseTypes/BenchmarkRecommendationResponse.cs new file mode 100644 index 0000000..79bce30 --- /dev/null +++ b/api/src/Models/ResponseTypes/BenchmarkRecommendationResponse.cs @@ -0,0 +1,7 @@ +namespace WinStudentGoalTracker.Models.ResponseTypes; + +public class BenchmarkRecommendationResponse +{ + public string Benchmark { get; set; } = string.Empty; + public string ShortName { get; set; } = string.Empty; +} diff --git a/api/src/Services/OllamaClient.cs b/api/src/Services/OllamaClient.cs new file mode 100644 index 0000000..a964fc2 --- /dev/null +++ b/api/src/Services/OllamaClient.cs @@ -0,0 +1,92 @@ +using System.Text.Json.Serialization; + +namespace WinStudentGoalTracker.Services; + +public class OllamaClient +{ + private readonly HttpClient _httpClient; + public readonly string Model; + + public OllamaClient(HttpClient httpClient, IConfiguration config) + { + _httpClient = httpClient; + Model = config["Ollama:Model"] ?? "gpt-oss:20b"; + } + + public async Task ChatAsync(string prompt, CancellationToken cancellationToken = default) + { + var requestBody = new OllamaChatRequest + { + Model = Model, + Messages = [new OllamaChatMessage { Role = "user", Content = prompt }], + Format = "json", + Stream = false + }; + + HttpResponseMessage response; + try + { + response = await _httpClient.PostAsJsonAsync("/api/chat", requestBody, cancellationToken); + } + catch (HttpRequestException ex) when (ex.StatusCode is null) + { + throw new OllamaUnavailableException( + $"The Ollama provider could not be reached at {_httpClient.BaseAddress}. Ensure the service is running.", ex); + } + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException( + $"Ollama service returned {(int)response.StatusCode}: {errorBody}", + null, + response.StatusCode); + } + + var chatResponse = await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new System.Text.Json.JsonException("Ollama returned an empty response."); + + return chatResponse.Message.Content; + } + + #region Exceptions + + public class OllamaUnavailableException(string message, Exception? inner = null) + : Exception(message, inner); + + #endregion + + #region Ollama API DTOs + + private class OllamaChatRequest + { + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("messages")] + public List Messages { get; set; } = []; + + [JsonPropertyName("format")] + public string Format { get; set; } = string.Empty; + + [JsonPropertyName("stream")] + public bool Stream { get; set; } + } + + private class OllamaChatMessage + { + [JsonPropertyName("role")] + public string Role { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; + } + + private class OllamaChatResponse + { + [JsonPropertyName("message")] + public OllamaChatMessage Message { get; set; } = new(); + } + + #endregion +} diff --git a/api/src/Services/OllamaService.cs b/api/src/Services/OllamaService.cs deleted file mode 100644 index 078feca..0000000 --- a/api/src/Services/OllamaService.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using WinStudentGoalTracker.Models.ResponseTypes; - -namespace WinStudentGoalTracker.Services; - -public class OllamaService -{ - private readonly HttpClient _httpClient; - private readonly string _model; - private readonly int _maxRetries = 3; - - public OllamaService(HttpClient httpClient, IConfiguration config) - { - _httpClient = httpClient; - _model = config["Ollama:Model"] ?? "gpt-oss:20b"; - } - - public async Task BreakdownGoalAsync(string goal, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(goal)) - throw new ArgumentException("Goal cannot be empty.", nameof(goal)); - - var prompt = "You are a task planning assistant. Given a goal, break it down into smaller, actionable subgoals.\n\n" + - $"Goal: {goal}\n\n" + - "Respond with ONLY a JSON object in this exact format, no other text:\n" + - "{\"subgoals\": [\"subgoal 1\", \"subgoal 2\", \"subgoal 3\"]}\n\n" + - "Be specific and practical. Generate 3-7 subgoals depending on complexity."; - - var requestBody = new OllamaChatRequest - { - Model = _model, - Messages = [new OllamaChatMessage { Role = "user", Content = prompt }], - Format = "json", - Stream = false - }; - - for (var attempt = 0; attempt < _maxRetries; attempt++) - { - var response = await _httpClient.PostAsJsonAsync("/api/chat", requestBody, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); - throw new HttpRequestException( - $"Ollama service returned {(int)response.StatusCode}: {errorBody}", - null, - response.StatusCode); - } - - try - { - var chatResponse = await response.Content.ReadFromJsonAsync(cancellationToken) - ?? throw new JsonException("Ollama returned an empty response."); - - var parsed = JsonSerializer.Deserialize(chatResponse.Message.Content) - ?? throw new JsonException("LLM response deserialized to null."); - - var subgoals = NormalizeSubgoals(parsed.Subgoals); - - if (subgoals.Count == 0) - throw new JsonException("LLM response contained no valid subgoals."); - - return new GoalBreakdownResponse - { - Goal = goal, - Subgoals = subgoals - }; - } - catch (JsonException) when (attempt < _maxRetries - 1) - { - // Malformed response from the model — retry - } - } - - throw new InvalidOperationException( - $"Failed to get a valid response from the LLM after {_maxRetries} attempts."); - } - - private static List NormalizeSubgoals(List rawSubgoals) - { - var normalized = new List(); - - foreach (var item in rawSubgoals) - { - switch (item.ValueKind) - { - case JsonValueKind.String: - normalized.Add(item.GetString()!); - break; - - case JsonValueKind.Object: - string[] preferredKeys = ["description", "task", "subtask", "subgoal", "name", "action"]; - var added = false; - - foreach (var key in preferredKeys) - { - if (item.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) - { - normalized.Add(value.GetString()!); - added = true; - break; - } - } - - if (!added) - { - foreach (var prop in item.EnumerateObject()) - { - if (prop.Value.ValueKind == JsonValueKind.String) - { - normalized.Add(prop.Value.GetString()!); - break; - } - } - } - break; - } - } - - return normalized; - } - - #region Ollama API DTOs - - private class OllamaChatRequest - { - [JsonPropertyName("model")] - public string Model { get; set; } = string.Empty; - - [JsonPropertyName("messages")] - public List Messages { get; set; } = []; - - [JsonPropertyName("format")] - public string Format { get; set; } = string.Empty; - - [JsonPropertyName("stream")] - public bool Stream { get; set; } - } - - private class OllamaChatMessage - { - [JsonPropertyName("role")] - public string Role { get; set; } = string.Empty; - - [JsonPropertyName("content")] - public string Content { get; set; } = string.Empty; - } - - private class OllamaChatResponse - { - [JsonPropertyName("message")] - public OllamaChatMessage Message { get; set; } = new(); - } - - private class OllamaSubgoalsResult - { - [JsonPropertyName("subgoals")] - public List Subgoals { get; set; } = []; - } - - #endregion -} diff --git a/api/src/Services/RecommendationService.cs b/api/src/Services/RecommendationService.cs new file mode 100644 index 0000000..9b4ba4d --- /dev/null +++ b/api/src/Services/RecommendationService.cs @@ -0,0 +1,257 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using WinStudentGoalTracker.Models; +using WinStudentGoalTracker.Models.ResponseTypes; + +namespace WinStudentGoalTracker.Services; + +public class RecommendationService +{ + private readonly OllamaClient _ollamaClient; + private readonly int _maxRetries = 3; + + public RecommendationService(OllamaClient ollamaClient) + { + _ollamaClient = ollamaClient; + } + + // ------------------------------------------------------------------------- + // Goal breakdown + // ------------------------------------------------------------------------- + + public async Task BreakdownGoalAsync(string goal, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(goal)) + throw new ArgumentException("Goal cannot be empty.", nameof(goal)); + + var prompt = "You are a task planning assistant. Given a goal, break it down into smaller, actionable subgoals.\n\n" + + $"Goal: {goal}\n\n" + + "Respond with ONLY a JSON object in this exact format, no other text:\n" + + "{\"subgoals\": [\"subgoal 1\", \"subgoal 2\", \"subgoal 3\"]}\n\n" + + "Be specific and practical. Generate 3-7 subgoals depending on complexity."; + + for (var attempt = 0; attempt < _maxRetries; attempt++) + { + try + { + var content = await _ollamaClient.ChatAsync(prompt, cancellationToken); + + var parsed = JsonSerializer.Deserialize(content) + ?? throw new JsonException("LLM response deserialized to null."); + + var subgoals = NormalizeSubgoals(parsed.Subgoals); + + if (subgoals.Count == 0) + throw new JsonException("LLM response contained no valid subgoals."); + + return new GoalBreakdownResponse + { + Goal = goal, + Subgoals = subgoals + }; + } + catch (JsonException) when (attempt < _maxRetries - 1) + { + // Malformed response from the model — retry + } + } + + throw new InvalidOperationException( + $"Failed to get a valid response from the LLM after {_maxRetries} attempts."); + } + + // ------------------------------------------------------------------------- + // Benchmark recommendation + // ------------------------------------------------------------------------- + + public async Task RecommendBenchmarkAsync( + StudentFullProfileResponse profile, + Guid goalId, + CancellationToken cancellationToken = default) + { + var goal = profile.Goals.FirstOrDefault(g => g.GoalId == goalId) + ?? throw new ArgumentException($"Goal {goalId} not found in student profile.", nameof(goalId)); + + var existingBenchmarks = profile.Benchmarks + .Where(b => b.GoalId == goalId) + .ToList(); + + var progressEvents = profile.ProgressEvents + .Where(e => e.GoalId == goalId) + .OrderByDescending(e => e.CreatedAt) + .ToList(); + + var prompt = BuildBenchmarkPrompt(goal, existingBenchmarks, progressEvents); + return await FetchBenchmarkRecommendationAsync(prompt, cancellationToken); + } + + private static string BuildBenchmarkPrompt( + StudentGoalItem goal, + List existingBenchmarks, + List progressEvents) + { + var sb = new StringBuilder(); + + sb.AppendLine("You are an educational specialist assisting with student IEP (Individualized Education Program) goal planning."); + sb.AppendLine(); + sb.AppendLine("A benchmark is a measurable subgoal or step that takes a student closer to achieving their overall goal."); + sb.AppendLine(); + + sb.AppendLine("## Goal Details"); + sb.AppendLine($"Category: {goal.Category ?? "Not specified"}"); + sb.AppendLine($"Description: {goal.Description ?? "Not specified"}"); + sb.AppendLine($"Baseline: {goal.Baseline ?? "Not specified"}"); + + if (existingBenchmarks.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("## Existing Benchmarks"); + sb.AppendLine("The following benchmarks have already been set for this goal. Your recommendation must be distinct from all of these, and should match their style and level of specificity:"); + for (var i = 0; i < existingBenchmarks.Count; i++) + { + var bm = existingBenchmarks[i]; + var label = string.IsNullOrWhiteSpace(bm.ShortName) ? $"Benchmark {i + 1}" : bm.ShortName; + sb.AppendLine($"{i + 1}. [{label}] {bm.Benchmark}"); + } + } + + if (progressEvents.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("## Recent Progress Notes"); + sb.AppendLine("These notes describe the student's recent progress toward this goal (most recent first):"); + foreach (var evt in progressEvents.Take(5)) + { + var date = evt.CreatedAt?.ToString("MMM d, yyyy") ?? "Unknown date"; + sb.AppendLine($"- [{date}] {evt.Content}"); + } + } + + sb.AppendLine(); + + if (existingBenchmarks.Count > 0) + { + sb.AppendLine("Generate a new benchmark that:"); + sb.AppendLine("- Is measurable and specific"); + sb.AppendLine("- Is clearly distinct from the existing benchmarks listed above"); + sb.AppendLine("- Matches the style and format of the existing benchmarks"); + sb.AppendLine("- Represents a meaningful next step toward the overall goal"); + } + else + { + sb.AppendLine("Generate a first benchmark for this goal that:"); + sb.AppendLine("- Is measurable and specific"); + sb.AppendLine("- Represents an achievable initial step toward the overall goal"); + sb.AppendLine("- Is grounded in the student's baseline performance described above"); + } + + sb.AppendLine(); + sb.AppendLine("Respond with ONLY a JSON object in this exact format, no other text:"); + sb.AppendLine("{\"benchmark\": \"\", \"short_name\": \"\"}"); + + return sb.ToString(); + } + + private async Task FetchBenchmarkRecommendationAsync( + string prompt, + CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < _maxRetries; attempt++) + { + try + { + var content = await _ollamaClient.ChatAsync(prompt, cancellationToken); + + var parsed = JsonSerializer.Deserialize(content) + ?? throw new JsonException("LLM response deserialized to null."); + + if (string.IsNullOrWhiteSpace(parsed.Benchmark)) + throw new JsonException("LLM response contained no valid benchmark text."); + + if (string.IsNullOrWhiteSpace(parsed.ShortName)) + throw new JsonException("LLM response contained no valid short name."); + + return new BenchmarkRecommendationResponse + { + Benchmark = parsed.Benchmark.Trim(), + ShortName = parsed.ShortName.Trim() + }; + } + catch (JsonException) when (attempt < _maxRetries - 1) + { + // Malformed response from the model — retry + } + } + + throw new InvalidOperationException( + $"Failed to get a valid benchmark recommendation from the LLM after {_maxRetries} attempts."); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static List NormalizeSubgoals(List rawSubgoals) + { + var normalized = new List(); + + foreach (var item in rawSubgoals) + { + switch (item.ValueKind) + { + case JsonValueKind.String: + normalized.Add(item.GetString()!); + break; + + case JsonValueKind.Object: + string[] preferredKeys = ["description", "task", "subtask", "subgoal", "name", "action"]; + var added = false; + + foreach (var key in preferredKeys) + { + if (item.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) + { + normalized.Add(value.GetString()!); + added = true; + break; + } + } + + if (!added) + { + foreach (var prop in item.EnumerateObject()) + { + if (prop.Value.ValueKind == JsonValueKind.String) + { + normalized.Add(prop.Value.GetString()!); + break; + } + } + } + break; + } + } + + return normalized; + } + + // ------------------------------------------------------------------------- + // Private DTOs + // ------------------------------------------------------------------------- + + private class OllamaSubgoalsResult + { + [JsonPropertyName("subgoals")] + public List Subgoals { get; set; } = []; + } + + private class OllamaBenchmarkResult + { + [JsonPropertyName("benchmark")] + public string Benchmark { get; set; } = string.Empty; + + [JsonPropertyName("short_name")] + public string ShortName { get; set; } = string.Empty; + } +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/edit-benchmark-modal/edit-benchmark-modal.html b/ui/winstudentgoaltracker/src/app/desktop/components/edit-benchmark-modal/edit-benchmark-modal.html index 8838b30..d171d7f 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/edit-benchmark-modal/edit-benchmark-modal.html +++ b/ui/winstudentgoaltracker/src/app/desktop/components/edit-benchmark-modal/edit-benchmark-modal.html @@ -1,4 +1,15 @@ + @if (!isEditMode) { +
+ + @if (recommendError()) { +

{{ recommendError() }}

+ } +
+ } +