diff --git a/api/Program.cs b/api/Program.cs index ebd05eb..548945c 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -35,6 +35,18 @@ builder.Services.AddAuthorization(); builder.Services.AddScoped(); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://stt.opelly.me"); + client.Timeout = TimeSpan.FromMinutes(5); +}); + +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri(builder.Configuration["Ollama:BaseUrl"] ?? "https://llm.opelly.me"); + client.Timeout = TimeSpan.FromMinutes(3); +}); + builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); diff --git a/api/appsettings.json b/api/appsettings.json index 1e6f72a..88d864b 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -6,6 +6,10 @@ "Key": "super_secret_key_change_me_in_production_123!", "Issuer": "WinStudentGoalTrackerAPI" }, + "Ollama": { + "BaseUrl": "https://llm.opelly.me", + "Model": "gpt-oss:20b" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/api/src/Controllers/AuthController.cs b/api/src/Controllers/AuthController.cs index e3980e1..66b2a08 100644 --- a/api/src/Controllers/AuthController.cs +++ b/api/src/Controllers/AuthController.cs @@ -75,7 +75,7 @@ public class AuthController : BaseController } // Generate JWT access token - var accessToken = _tokenService.GenerateToken(user.IdUser, user.Email!, user.RoleName); + var accessToken = _tokenService.GenerateToken(user.IdUser, user.Email!, user.RoleInternalName); // Generate refresh token secret var secretToken = Guid.NewGuid().ToString(); @@ -118,7 +118,8 @@ public class AuthController : BaseController Email = user.Email!, Jwt = accessToken, RefreshToken = fullRefreshToken, - Role = user.RoleName + Role = user.RoleInternalName, + RoleDisplayName = user.RoleDisplayName } }); } @@ -209,7 +210,7 @@ public class AuthController : BaseController } // Generate new JWT - var newJwtToken = _tokenService.GenerateToken(tokenUser.IdUser, tokenUser.Email!, tokenUser.RoleName); + var newJwtToken = _tokenService.GenerateToken(tokenUser.IdUser, tokenUser.Email!, tokenUser.RoleInternalName); var jwtExpiresIn = _tokenService.GetTokenExpiryInSeconds(newJwtToken); // Generate new refresh token (rotation) diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs b/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs index c37abb4..aae070c 100644 --- a/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs +++ b/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs @@ -11,5 +11,6 @@ public class dbUser public int FailedLoginAttempts { get; set; } public DateTime? LockedUntil { get; set; } public DateTime? CreatedAt { get; set; } - public string? RoleName { get; set; } + public string? RoleInternalName { get; set; } + public string? RoleDisplayName { get; set; } } diff --git a/api/src/Models/ResponseTypes/GoalBreakdownResponse.cs b/api/src/Models/ResponseTypes/GoalBreakdownResponse.cs new file mode 100644 index 0000000..cdf62ba --- /dev/null +++ b/api/src/Models/ResponseTypes/GoalBreakdownResponse.cs @@ -0,0 +1,7 @@ +namespace WinStudentGoalTracker.Models.ResponseTypes; + +public class GoalBreakdownResponse +{ + public string Goal { get; set; } = string.Empty; + public List Subgoals { get; set; } = []; +} diff --git a/api/src/Models/ResponseTypes/LoginResponse.cs b/api/src/Models/ResponseTypes/LoginResponse.cs index da90350..7480e25 100644 --- a/api/src/Models/ResponseTypes/LoginResponse.cs +++ b/api/src/Models/ResponseTypes/LoginResponse.cs @@ -7,4 +7,5 @@ public class LoginResponse public required string Jwt { get; set; } public required string RefreshToken { get; set; } public string? Role { get; set; } + public string? RoleDisplayName { get; set; } } diff --git a/api/src/Services/OllamaService.cs b/api/src/Services/OllamaService.cs new file mode 100644 index 0000000..078feca --- /dev/null +++ b/api/src/Services/OllamaService.cs @@ -0,0 +1,163 @@ +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/TranscriptionService.cs b/api/src/Services/TranscriptionService.cs new file mode 100644 index 0000000..26b49e2 --- /dev/null +++ b/api/src/Services/TranscriptionService.cs @@ -0,0 +1,42 @@ +using System.Net.Http.Headers; + +namespace WinStudentGoalTracker.Services; + +public class TranscriptionService +{ + private readonly HttpClient _httpClient; + + public TranscriptionService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task TranscribeAsync(Stream audioStream, string fileName, CancellationToken cancellationToken = default) + { + using var content = new MultipartFormDataContent(); + + var fileContent = new StreamContent(audioStream); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + content.Add(fileContent, "file", fileName); + content.Add(new StringContent("whisper-1"), "model"); + content.Add(new StringContent("json"), "response_format"); + + var response = await _httpClient.PostAsync("/v1/audio/transcriptions", content, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException( + $"Transcription service returned {(int)response.StatusCode}: {errorBody}", + null, + response.StatusCode); + } + + var result = await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new InvalidOperationException("Transcription service returned an empty response."); + + return result.Text; + } + + private record TranscriptionResult(string Text); +}