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
@@ -0,0 +1,7 @@
namespace GymLogAI.Application.DTOs;
public sealed record ParsedExerciseDto(
string Name,
int Order,
string? Notes,
IReadOnlyCollection<ParsedExerciseSetDto> Sets);
@@ -0,0 +1,9 @@
namespace GymLogAI.Application.DTOs;
public sealed record ParsedExerciseSetDto(
int Order,
int? Repetitions,
decimal? WeightKg,
int? DurationSeconds,
decimal? DistanceMeters,
string? Notes);
@@ -0,0 +1,7 @@
namespace GymLogAI.Application.DTOs;
public sealed record ParsedWorkoutDto(
DateTime PerformedAtUtc,
string? Title,
string? Notes,
IReadOnlyCollection<ParsedExerciseDto> Exercises);
@@ -0,0 +1,8 @@
using GymLogAI.Core.Enums;
namespace GymLogAI.Application.DTOs;
public sealed record WorkoutRecommendationContext(
Guid UserId,
GoalType GoalType,
string? AdditionalContext);
@@ -0,0 +1,7 @@
namespace GymLogAI.Application.DTOs;
public sealed record WorkoutRecommendationDto(
string Title,
string Summary,
IReadOnlyCollection<string> Exercises,
string RecoveryAdvice);
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GymLogAI.Core\GymLogAI.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,66 @@
using GymLogAI.Application.Interfaces;
using GymLogAI.Core.Abstractions;
using GymLogAI.Core.Entities;
using GymLogAI.Core.Enums;
namespace GymLogAI.Application.Handlers;
public sealed class ImportTelegramMessageHandler(
IUserRepository userRepository,
ITelegramMessageRepository telegramMessageRepository,
IClock clock,
IIdGenerator idGenerator)
{
public async Task<Guid> HandleAsync(ImportTelegramMessageCommand command, CancellationToken cancellationToken)
{
var user = await userRepository.GetByIdAsync(command.UserId, cancellationToken);
if (user is null)
{
user = new User
{
Id = command.UserId,
TelegramUserId = command.TelegramUserId,
Username = command.Username,
FirstName = command.FirstName,
LastName = command.LastName,
CreatedAtUtc = clock.UtcNow
};
await userRepository.AddAsync(user, cancellationToken);
}
else
{
user.TelegramUserId = command.TelegramUserId;
user.Username = command.Username ?? user.Username;
user.FirstName = command.FirstName ?? user.FirstName;
user.LastName = command.LastName ?? user.LastName;
await userRepository.UpdateAsync(user, cancellationToken);
}
var message = new TelegramMessage
{
Id = idGenerator.New(),
UserId = user.Id,
TelegramChatId = command.TelegramChatId,
TelegramMessageId = command.TelegramMessageId,
Text = command.Text,
SentAtUtc = command.SentAtUtc,
ImportedAtUtc = clock.UtcNow,
ParsingStatus = ParsingStatus.Pending
};
await telegramMessageRepository.AddAsync(message, cancellationToken);
return message.Id;
}
}
public sealed record ImportTelegramMessageCommand(
Guid UserId,
long TelegramUserId,
long TelegramChatId,
int TelegramMessageId,
string Text,
DateTime SentAtUtc,
string? Username,
string? FirstName,
string? LastName);
@@ -0,0 +1,108 @@
using GymLogAI.Application.Interfaces;
using GymLogAI.Core.Abstractions;
using GymLogAI.Core.Entities;
using GymLogAI.Core.Enums;
namespace GymLogAI.Application.Handlers;
public sealed class ParseTelegramMessageHandler(
ITelegramMessageRepository telegramMessageRepository,
IWorkoutRepository workoutRepository,
IExerciseRepository exerciseRepository,
IWorkoutParser workoutParser,
IEmbeddingService embeddingService,
IClock clock,
IIdGenerator idGenerator)
{
public async Task<bool> HandleAsync(Guid telegramMessageId, CancellationToken cancellationToken)
{
var message = await telegramMessageRepository.GetByIdAsync(telegramMessageId, cancellationToken);
if (message is null)
{
return false;
}
message.ParsingStatus = ParsingStatus.Processing;
message.ParsingError = null;
await telegramMessageRepository.UpdateAsync(message, cancellationToken);
try
{
var parsedWorkout = await workoutParser.ParseAsync(message.Text, cancellationToken);
if (parsedWorkout is null || parsedWorkout.Exercises.Count == 0)
{
message.ParsingStatus = ParsingStatus.Failed;
message.ParsingError = "Unable to parse workout from message.";
message.ParsedAtUtc = clock.UtcNow;
await telegramMessageRepository.UpdateAsync(message, cancellationToken);
return false;
}
var workout = new Workout
{
Id = idGenerator.New(),
UserId = message.UserId,
SourceTelegramMessageId = message.Id,
PerformedAtUtc = parsedWorkout.PerformedAtUtc,
Title = parsedWorkout.Title,
Notes = parsedWorkout.Notes,
SourceType = WorkoutSourceType.Telegram,
CreatedAtUtc = clock.UtcNow
};
foreach (var parsedExercise in parsedWorkout.Exercises.OrderBy(x => x.Order))
{
var exercise = await exerciseRepository.GetByNameAsync(parsedExercise.Name, cancellationToken)
?? await exerciseRepository.AddAsync(new Exercise
{
Id = idGenerator.New(),
Name = parsedExercise.Name.Trim(),
CreatedAtUtc = clock.UtcNow,
Embedding = await embeddingService.GenerateEmbeddingAsync(parsedExercise.Name, cancellationToken)
}, cancellationToken);
var workoutExercise = new WorkoutExercise
{
Id = idGenerator.New(),
WorkoutId = workout.Id,
ExerciseId = exercise.Id,
Order = parsedExercise.Order,
Notes = parsedExercise.Notes
};
foreach (var parsedSet in parsedExercise.Sets.OrderBy(x => x.Order))
{
workoutExercise.Sets.Add(new ExerciseSet
{
Id = idGenerator.New(),
WorkoutExerciseId = workoutExercise.Id,
Order = parsedSet.Order,
Repetitions = parsedSet.Repetitions,
WeightKg = parsedSet.WeightKg,
DurationSeconds = parsedSet.DurationSeconds,
DistanceMeters = parsedSet.DistanceMeters,
Notes = parsedSet.Notes
});
}
workout.WorkoutExercises.Add(workoutExercise);
}
await workoutRepository.AddAsync(workout, cancellationToken);
message.WorkoutId = workout.Id;
message.ParsingStatus = ParsingStatus.Parsed;
message.ParsedAtUtc = clock.UtcNow;
await telegramMessageRepository.UpdateAsync(message, cancellationToken);
return true;
}
catch (Exception ex)
{
message.ParsingStatus = ParsingStatus.Failed;
message.ParsingError = ex.Message;
message.ParsedAtUtc = clock.UtcNow;
await telegramMessageRepository.UpdateAsync(message, cancellationToken);
return false;
}
}
}
@@ -0,0 +1,26 @@
using GymLogAI.Application.DTOs;
using GymLogAI.Application.Interfaces;
using GymLogAI.Core.Enums;
namespace GymLogAI.Application.Handlers;
public sealed class RecommendWorkoutHandler(
IWorkoutRepository workoutRepository,
IWorkoutRecommendationGenerator workoutRecommendationGenerator)
{
public async Task<WorkoutRecommendationDto> HandleAsync(
RecommendWorkoutCommand command,
CancellationToken cancellationToken)
{
var history = await workoutRepository.GetHistoryAsync(command.UserId, cancellationToken);
var context = new WorkoutRecommendationContext(
command.UserId,
command.GoalType,
command.AdditionalContext);
return await workoutRecommendationGenerator.GenerateAsync(context, history, cancellationToken);
}
}
public sealed record RecommendWorkoutCommand(Guid UserId, GoalType GoalType, string? AdditionalContext);
@@ -0,0 +1,6 @@
namespace GymLogAI.Application.Interfaces;
public interface IEmbeddingService
{
Task<float[]> GenerateEmbeddingAsync(string input, CancellationToken cancellationToken);
}
@@ -0,0 +1,10 @@
using GymLogAI.Core.Entities;
namespace GymLogAI.Application.Interfaces;
public interface IExerciseRepository
{
Task<Exercise?> GetByNameAsync(string name, CancellationToken cancellationToken);
Task<Exercise> AddAsync(Exercise exercise, CancellationToken cancellationToken);
Task<Exercise> UpdateAsync(Exercise exercise, CancellationToken cancellationToken);
}
@@ -0,0 +1,11 @@
using GymLogAI.Core.Entities;
namespace GymLogAI.Application.Interfaces;
public interface ITelegramMessageRepository
{
Task<TelegramMessage> AddAsync(TelegramMessage message, CancellationToken cancellationToken);
Task<TelegramMessage?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<IReadOnlyCollection<TelegramMessage>> GetPendingAsync(int take, CancellationToken cancellationToken);
Task<TelegramMessage> UpdateAsync(TelegramMessage message, CancellationToken cancellationToken);
}
@@ -0,0 +1,11 @@
using GymLogAI.Core.Entities;
namespace GymLogAI.Application.Interfaces;
public interface IUserRepository
{
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<User?> GetByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken);
Task<User> AddAsync(User user, CancellationToken cancellationToken);
Task<User> UpdateAsync(User user, CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
using GymLogAI.Application.DTOs;
namespace GymLogAI.Application.Interfaces;
public interface IWorkoutParser
{
Task<ParsedWorkoutDto?> ParseAsync(string text, CancellationToken cancellationToken);
}
@@ -0,0 +1,12 @@
using GymLogAI.Application.DTOs;
using GymLogAI.Core.Entities;
namespace GymLogAI.Application.Interfaces;
public interface IWorkoutRecommendationGenerator
{
Task<WorkoutRecommendationDto> GenerateAsync(
WorkoutRecommendationContext context,
IReadOnlyCollection<Workout> workoutHistory,
CancellationToken cancellationToken);
}
@@ -0,0 +1,9 @@
using GymLogAI.Core.Entities;
namespace GymLogAI.Application.Interfaces;
public interface IWorkoutRepository
{
Task<Workout> AddAsync(Workout workout, CancellationToken cancellationToken);
Task<IReadOnlyCollection<Workout>> GetHistoryAsync(Guid userId, CancellationToken cancellationToken);
}