add architecture skeleton
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
using GymLogAI.Application.Interfaces;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GymLogAI.AI;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddAiServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services
|
||||
.AddOptions<OpenRouterOptions>()
|
||||
.Bind(configuration.GetSection(OpenRouterOptions.SectionName));
|
||||
|
||||
services.AddHttpClient<OpenRouterChatClient>((serviceProvider, client) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<OpenRouterOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
});
|
||||
|
||||
services.AddHttpClient<OpenRouterEmbeddingClient>((serviceProvider, client) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<OpenRouterOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
});
|
||||
|
||||
services.AddScoped<IWorkoutParser, WorkoutParserAIService>();
|
||||
services.AddScoped<IWorkoutRecommendationGenerator, WorkoutRecommendationAIService>();
|
||||
services.AddScoped<IEmbeddingService, StubEmbeddingService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GymLogAI.Application\GymLogAI.Application.csproj" />
|
||||
<ProjectReference Include="..\GymLogAI.Core\GymLogAI.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GymLogAI.AI;
|
||||
|
||||
public sealed class OpenRouterChatClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<OpenRouterOptions> options,
|
||||
ILogger<OpenRouterChatClient> logger)
|
||||
{
|
||||
private readonly OpenRouterOptions _options = options.Value;
|
||||
|
||||
public async Task<string?> CompleteAsync(string prompt, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ApiKey))
|
||||
{
|
||||
logger.LogInformation("OpenRouter API key is not configured. Returning stubbed chat completion.");
|
||||
return null;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
model = _options.ChatModel,
|
||||
messages = new[] { new { role = "user", content = prompt } }
|
||||
})
|
||||
};
|
||||
|
||||
request.Headers.Authorization = new("Bearer", _options.ApiKey);
|
||||
var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GymLogAI.AI;
|
||||
|
||||
public sealed class OpenRouterEmbeddingClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<OpenRouterOptions> options,
|
||||
ILogger<OpenRouterEmbeddingClient> logger)
|
||||
{
|
||||
private readonly OpenRouterOptions _options = options.Value;
|
||||
|
||||
public async Task<float[]?> GenerateAsync(string input, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ApiKey))
|
||||
{
|
||||
logger.LogInformation("OpenRouter API key is not configured. Returning stubbed embedding.");
|
||||
return null;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "embeddings")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
model = _options.EmbeddingModel,
|
||||
input
|
||||
})
|
||||
};
|
||||
|
||||
request.Headers.Authorization = new("Bearer", _options.ApiKey);
|
||||
var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace GymLogAI.AI;
|
||||
|
||||
public sealed class OpenRouterOptions
|
||||
{
|
||||
public const string SectionName = "OpenRouter";
|
||||
|
||||
public string BaseUrl { get; init; } = "https://openrouter.ai/api/v1/";
|
||||
public string? ApiKey { get; init; }
|
||||
public string ChatModel { get; init; } = "openai/gpt-4.1-mini";
|
||||
public string EmbeddingModel { get; init; } = "text-embedding-3-small";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using GymLogAI.Application.Interfaces;
|
||||
|
||||
namespace GymLogAI.AI;
|
||||
|
||||
public sealed class StubEmbeddingService(OpenRouterEmbeddingClient embeddingClient) : IEmbeddingService
|
||||
{
|
||||
public async Task<float[]> GenerateEmbeddingAsync(string input, CancellationToken cancellationToken)
|
||||
{
|
||||
var remoteEmbedding = await embeddingClient.GenerateAsync(input, cancellationToken);
|
||||
if (remoteEmbedding is not null)
|
||||
{
|
||||
return remoteEmbedding;
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
input.Length,
|
||||
input.Count(char.IsLetter),
|
||||
input.Count(char.IsDigit),
|
||||
input.Count(char.IsWhiteSpace)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using GymLogAI.Application.DTOs;
|
||||
using GymLogAI.Application.Interfaces;
|
||||
|
||||
namespace GymLogAI.AI;
|
||||
|
||||
public sealed class WorkoutParserAIService(OpenRouterChatClient chatClient) : IWorkoutParser
|
||||
{
|
||||
public async Task<ParsedWorkoutDto?> ParseAsync(string text, CancellationToken cancellationToken)
|
||||
{
|
||||
await chatClient.CompleteAsync($"Parse workout:\n{text}", cancellationToken);
|
||||
|
||||
var lines = text
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.ToArray();
|
||||
|
||||
if (lines.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var exercises = lines
|
||||
.Select((line, index) => new ParsedExerciseDto(
|
||||
line,
|
||||
index + 1,
|
||||
null,
|
||||
[new ParsedExerciseSetDto(1, 10, null, null, null, "Stub set")]))
|
||||
.ToArray();
|
||||
|
||||
return new ParsedWorkoutDto(
|
||||
DateTime.UtcNow,
|
||||
"Imported workout",
|
||||
"Stubbed parser result",
|
||||
exercises);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using GymLogAI.Application.DTOs;
|
||||
using GymLogAI.Application.Interfaces;
|
||||
using GymLogAI.Core.Entities;
|
||||
|
||||
namespace GymLogAI.AI;
|
||||
|
||||
public sealed class WorkoutRecommendationAIService(OpenRouterChatClient chatClient) : IWorkoutRecommendationGenerator
|
||||
{
|
||||
public async Task<WorkoutRecommendationDto> GenerateAsync(
|
||||
WorkoutRecommendationContext context,
|
||||
IReadOnlyCollection<Workout> workoutHistory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await chatClient.CompleteAsync(
|
||||
$"Recommend workout for goal {context.GoalType} with {workoutHistory.Count} historical workouts.",
|
||||
cancellationToken);
|
||||
|
||||
var lastWorkout = workoutHistory.FirstOrDefault();
|
||||
var fallbackExercises = lastWorkout?.WorkoutExercises
|
||||
.OrderBy(x => x.Order)
|
||||
.Select(x => x.Exercise?.Name ?? "Bodyweight movement")
|
||||
.Distinct()
|
||||
.Take(4)
|
||||
.ToArray();
|
||||
|
||||
return new WorkoutRecommendationDto(
|
||||
"Today's training recommendation",
|
||||
$"Goal: {context.GoalType}. History count: {workoutHistory.Count}.",
|
||||
fallbackExercises is { Length: > 0 }
|
||||
? fallbackExercises
|
||||
: ["Squat", "Bench Press", "Row", "Plank"],
|
||||
"Keep the session moderate and leave 1-2 reps in reserve.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user