wired up microservices

This commit is contained in:
2026-02-20 21:40:34 -08:00
parent da4320f26e
commit 27056e54d2
8 changed files with 235 additions and 4 deletions
+12
View File
@@ -35,6 +35,18 @@ builder.Services.AddAuthorization();
builder.Services.AddScoped<TokenService>();
builder.Services.AddHttpClient<TranscriptionService>(client =>
{
client.BaseAddress = new Uri("https://stt.opelly.me");
client.Timeout = TimeSpan.FromMinutes(5);
});
builder.Services.AddHttpClient<OllamaService>(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();
+4
View File
@@ -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",
+4 -3
View File
@@ -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)
@@ -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; }
}
@@ -0,0 +1,7 @@
namespace WinStudentGoalTracker.Models.ResponseTypes;
public class GoalBreakdownResponse
{
public string Goal { get; set; } = string.Empty;
public List<string> Subgoals { get; set; } = [];
}
@@ -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; }
}
+163
View File
@@ -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<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
}
+42
View File
@@ -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<string> 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<TranscriptionResult>(cancellationToken)
?? throw new InvalidOperationException("Transcription service returned an empty response.");
return result.Text;
}
private record TranscriptionResult(string Text);
}