add architecture skeleton

This commit is contained in:
2026-04-19 13:12:04 +03:00
parent 312708531f
commit 2738f31f26
84 changed files with 2710 additions and 70 deletions
+33
View File
@@ -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;
}
}
+19
View File
@@ -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>
+36
View File
@@ -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);
}
}
+37
View File
@@ -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;
}
}
+11
View File
@@ -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";
}
+23
View File
@@ -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)
];
}
}
+36
View File
@@ -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.");
}
}