mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 11:07:41 +00:00
LLM reccomendation service
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user