add architecture skeleton
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user