mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 02:57:36 +00:00
LLM reccomendation service
This commit is contained in:
+2
-1
@@ -61,11 +61,12 @@ builder.Services.AddHttpClient<TranscriptionService>(client =>
|
|||||||
client.Timeout = TimeSpan.FromMinutes(5);
|
client.Timeout = TimeSpan.FromMinutes(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddHttpClient<OllamaService>(client =>
|
builder.Services.AddHttpClient<OllamaClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri(builder.Configuration["Ollama:BaseUrl"] ?? "https://llm.opelly.me");
|
client.BaseAddress = new Uri(builder.Configuration["Ollama:BaseUrl"] ?? "https://llm.opelly.me");
|
||||||
client.Timeout = TimeSpan.FromMinutes(3);
|
client.Timeout = TimeSpan.FromMinutes(3);
|
||||||
});
|
});
|
||||||
|
builder.Services.AddScoped<RecommendationService>();
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using WinStudentGoalTracker.Models;
|
using WinStudentGoalTracker.Models;
|
||||||
|
using WinStudentGoalTracker.Models.ResponseTypes;
|
||||||
using WinStudentGoalTracker.BaseClasses;
|
using WinStudentGoalTracker.BaseClasses;
|
||||||
using WinStudentGoalTracker.DataAccess;
|
using WinStudentGoalTracker.DataAccess;
|
||||||
using WinStudentGoalTracker.Services;
|
using WinStudentGoalTracker.Services;
|
||||||
@@ -11,7 +12,14 @@ namespace WinStudentGoalTracker.Controllers;
|
|||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class StudentController : BaseController
|
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")]
|
[HttpGet("my")]
|
||||||
@@ -711,4 +719,74 @@ public class StudentController : BaseController
|
|||||||
Data = markdown
|
Data = markdown
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{idStudent:guid}/goals/{idGoal:guid}/benchmark-recommendation")]
|
||||||
|
[Authorize(Roles = $"{UserRoles.Teacher},{UserRoles.Paraeducator},{UserRoles.ProgramAdmin}")]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<BenchmarkRecommendationResponse>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ResponseResult<BenchmarkRecommendationResponse>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ResponseResult<BenchmarkRecommendationResponse>>> 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<BenchmarkRecommendationResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Student not found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile = await _studentRepository.GetFullProfileAsync(idStudent);
|
||||||
|
if (profile is null)
|
||||||
|
{
|
||||||
|
return NotFound(new ResponseResult<BenchmarkRecommendationResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Student not found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.Goals.All(g => g.GoalId != idGoal))
|
||||||
|
{
|
||||||
|
return NotFound(new ResponseResult<BenchmarkRecommendationResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Goal not found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var recommendation = await _recommendationService.RecommendBenchmarkAsync(profile, idGoal, cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new ResponseResult<BenchmarkRecommendationResponse>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "Benchmark recommendation generated successfully.",
|
||||||
|
Data = recommendation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (OllamaClient.OllamaUnavailableException ex)
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status503ServiceUnavailable, new ResponseResult<BenchmarkRecommendationResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = ex.Message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status503ServiceUnavailable, new ResponseResult<BenchmarkRecommendationResponse>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = ex.Message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string> 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<OllamaChatResponse>(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<OllamaChatMessage> 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
|
||||||
|
}
|
||||||
@@ -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<GoalBreakdownResponse> 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<OllamaChatResponse>(cancellationToken)
|
|
||||||
?? throw new JsonException("Ollama returned an empty response.");
|
|
||||||
|
|
||||||
var parsed = JsonSerializer.Deserialize<OllamaSubgoalsResult>(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<string> NormalizeSubgoals(List<JsonElement> rawSubgoals)
|
|
||||||
{
|
|
||||||
var normalized = new List<string>();
|
|
||||||
|
|
||||||
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<OllamaChatMessage> 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<JsonElement> Subgoals { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
@@ -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<GoalBreakdownResponse> 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<OllamaSubgoalsResult>(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<BenchmarkRecommendationResponse> 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<StudentBenchmarkItem> existingBenchmarks,
|
||||||
|
List<ProgressEventWithGoalResponse> 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\": \"<full benchmark text>\", \"short_name\": \"<brief label, 2-5 words>\"}");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<BenchmarkRecommendationResponse> FetchBenchmarkRecommendationAsync(
|
||||||
|
string prompt,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; attempt < _maxRetries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var content = await _ollamaClient.ChatAsync(prompt, cancellationToken);
|
||||||
|
|
||||||
|
var parsed = JsonSerializer.Deserialize<OllamaBenchmarkResult>(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<string> NormalizeSubgoals(List<JsonElement> rawSubgoals)
|
||||||
|
{
|
||||||
|
var normalized = new List<string>();
|
||||||
|
|
||||||
|
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<JsonElement> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -1,4 +1,15 @@
|
|||||||
<app-modal-shell [title]="modalTitle" (closed)="closed.emit()">
|
<app-modal-shell [title]="modalTitle" (closed)="closed.emit()">
|
||||||
|
@if (!isEditMode) {
|
||||||
|
<div class="ai-suggest-row">
|
||||||
|
<button class="btn-ai" (click)="onGetRecommendation()" [disabled]="recommending() || saving()">
|
||||||
|
{{ recommending() ? 'Generating...' : '✦ Suggest with AI' }}
|
||||||
|
</button>
|
||||||
|
@if (recommendError()) {
|
||||||
|
<p class="recommend-error">{{ recommendError() }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label">Benchmark</label>
|
<label class="field-label">Benchmark</label>
|
||||||
<textarea class="field-input field-textarea" [(ngModel)]="benchmarkText"
|
<textarea class="field-input field-textarea" [(ngModel)]="benchmarkText"
|
||||||
@@ -16,7 +27,7 @@
|
|||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn-secondary" (click)="closed.emit()">Cancel</button>
|
<button class="btn-secondary" (click)="closed.emit()">Cancel</button>
|
||||||
<button class="btn-primary" (click)="onSave()" [disabled]="saving() || !benchmarkText.trim()">
|
<button class="btn-primary" (click)="onSave()" [disabled]="saving() || recommending() || !benchmarkText.trim()">
|
||||||
{{ saving() ? 'Saving...' : submitLabel }}
|
{{ saving() ? 'Saving...' : submitLabel }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+32
-1
@@ -1 +1,32 @@
|
|||||||
/* Inherits all styles from modal-shell via ::ng-deep */
|
:host ::ng-deep {
|
||||||
|
.ai-suggest-row {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ai {
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid #c4b5fd;
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #6d28d9;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #ede9fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommend-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #dc2626;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+18
@@ -22,7 +22,9 @@ export class EditBenchmarkModal {
|
|||||||
readonly closed = output<void>();
|
readonly closed = output<void>();
|
||||||
|
|
||||||
protected readonly saving = signal(false);
|
protected readonly saving = signal(false);
|
||||||
|
protected readonly recommending = signal(false);
|
||||||
protected readonly errorMessage = signal<string | null>(null);
|
protected readonly errorMessage = signal<string | null>(null);
|
||||||
|
protected readonly recommendError = signal<string | null>(null);
|
||||||
|
|
||||||
protected shortName = '';
|
protected shortName = '';
|
||||||
protected benchmarkText = '';
|
protected benchmarkText = '';
|
||||||
@@ -47,6 +49,22 @@ export class EditBenchmarkModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onGetRecommendation() {
|
||||||
|
this.recommending.set(true);
|
||||||
|
this.recommendError.set(null);
|
||||||
|
|
||||||
|
const result = await this.studentService.getBenchmarkRecommendation(this.studentId(), this.goalId());
|
||||||
|
|
||||||
|
this.recommending.set(false);
|
||||||
|
|
||||||
|
if (result.success && result.payload) {
|
||||||
|
this.benchmarkText = result.payload.benchmark;
|
||||||
|
this.shortName = result.payload.shortName;
|
||||||
|
} else {
|
||||||
|
this.recommendError.set(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onSave() {
|
async onSave() {
|
||||||
if (!this.benchmarkText.trim()) return;
|
if (!this.benchmarkText.trim()) return;
|
||||||
this.saving.set(true);
|
this.saving.set(true);
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
export interface BenchmarkRecommendationDto {
|
||||||
|
benchmark: string;
|
||||||
|
shortName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StudentBenchmarkSummary {
|
export interface StudentBenchmarkSummary {
|
||||||
studentIdentifier: string;
|
studentIdentifier: string;
|
||||||
benchmarks: BenchmarkDto[];
|
benchmarks: BenchmarkDto[];
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function describeHttpError(error: HttpErrorResponse): string {
|
|||||||
case 500:
|
case 500:
|
||||||
return 'Server error (500). The API encountered an internal failure (possibly a database issue).';
|
return 'Server error (500). The API encountered an internal failure (possibly a database issue).';
|
||||||
case 503:
|
case 503:
|
||||||
return 'Server unavailable (503). The API may be starting up or overwhelmed.';
|
return serverMessage ?? 'Service unavailable (503). The API or a required provider may be starting up or unreachable.';
|
||||||
default:
|
default:
|
||||||
return `Unexpected error (${error.status}).`;
|
return `Unexpected error (${error.status}).`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { CreateStudentDto } from '../classes/create-student.dto';
|
|||||||
import { CreateGoalDto } from '../classes/create-goal.dto';
|
import { CreateGoalDto } from '../classes/create-goal.dto';
|
||||||
import { StudentCardDto } from '../classes/student-card.dto';
|
import { StudentCardDto } from '../classes/student-card.dto';
|
||||||
import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal';
|
import { StudentGoalSummary, StudentGoalItem } from '../classes/student-goal';
|
||||||
import { StudentBenchmarkSummary } from '../classes/benchmark.dto';
|
import { BenchmarkRecommendationDto, StudentBenchmarkSummary } from '../classes/benchmark.dto';
|
||||||
import { StudentProgressReportDto } from '../classes/student-progress-report.dto';
|
import { StudentProgressReportDto } from '../classes/student-progress-report.dto';
|
||||||
import { StudentFullProfileDto } from '../classes/student-full-profile.dto';
|
import { StudentFullProfileDto } from '../classes/student-full-profile.dto';
|
||||||
|
|
||||||
@@ -269,6 +269,24 @@ export class StudentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// *****************************************************************
|
||||||
|
// Requests an AI-generated benchmark recommendation for a goal.
|
||||||
|
// *****************************************************************
|
||||||
|
async getBenchmarkRecommendation(studentId: string, goalId: string): Promise<ApiResult<BenchmarkRecommendationDto>> {
|
||||||
|
try {
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
this.http.get<ResponseResult<BenchmarkRecommendationDto>>(
|
||||||
|
`${this.base}/api/Student/${studentId}/goals/${goalId}/benchmark-recommendation`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return result.success && result.data
|
||||||
|
? ApiResult.ok(result.data)
|
||||||
|
: ApiResult.fail(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
return ApiResult.fail(describeHttpError(error as HttpErrorResponse));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
// Updates a goal's description, category, and baseline.
|
// Updates a goal's description, category, and baseline.
|
||||||
// *****************************************************************
|
// *****************************************************************
|
||||||
|
|||||||
Reference in New Issue
Block a user