Blog

Notes on the .Net ecosystem, Angular, Software Architecture, Machine Learning and CyberSecurity

Articles

All posts
RockRecommender: reading real feedback straight from Mongo
  • Machine Learning
  • MLOps
  • .NET

RockRecommender: reading real feedback straight from Mongo

The previous article laid out the whole design. Now comes the first real piece: RockRecommender.Training needs to learn to read the feedback collection the Api has been writing to since the RockRecommender API article.

One type for two sources

Up to this point, the pipeline only knew SyntheticInteraction, the shape the synthetic generator produced. Since there are now two possible sources for the same training data, that type became Interaction, dropping "synthetic" from the name, because it now represents both equally:

public sealed record Interaction(Guid UserId, Guid SongId, bool Liked);

The synthetic generator stays exactly the same, just returning this type instead of the old one. And the real feedback, which already exists in Mongo as the Feedback entity, gets a path to become the same thing. This matters because the rest of the pipeline (training, evaluation, comparison) now deals with a single format, without needing to know where it came from.

Fetching everything, through the service, not the repository

The repository only knows how to fetch by user, which makes sense for the Api (which always asks "what's this user's history"). Training needs a different question, "how much feedback exists in total", and that question follows the same rule as everywhere else in the project: Controller → Service → Repository. Whoever sits at the entry point, a controller in the Api, the training pipeline here, only ever talks to the Application layer, never straight to a repository. That's why this method lives on FeedbackService, returning its own DTO instead of the Domain entity:

public sealed class FeedbackService(IFeedbackRepository feedbackRepository)
{
    public async Task SubmitFeedbackAsync(Guid userId, FeedbackRequest request)
    {
        var feedback = new Feedback(userId, request.SongId, request.Liked, DateTime.UtcNow);

        await feedbackRepository.AddAsync(feedback);
    }

    public async Task<List<FeedbackResponse>> GetAllAsync()
    {
        var feedback = await feedbackRepository.GetAllAsync();

        return [.. feedback.Select(ToResponse)];
    }

    private static FeedbackResponse ToResponse(Feedback feedback) =>
        new(feedback.UserId, feedback.SongId, feedback.Liked);
}

FeedbackResponse is a small record with just UserId, SongId and Liked. It exists so Training never needs a reference to the Domain entity, the same reason the Api's own controllers never see one either.

InteractionSourceSelector: comparing volumes

This is the central piece of the article. It generates the synthetic interactions (like it always has), asks the service for the real feedback, and decides which of the two the pipeline will use, by comparing the size of the two lists:

public sealed class InteractionSourceSelector(FeedbackService feedbackService, SyntheticInteractionGenerator syntheticGenerator)
{
    public async Task<TrainingInteractions> SelectAsync(List<Song> songs, int syntheticUserCount)
    {
        var syntheticInteractions = syntheticGenerator.Generate(songs, syntheticUserCount);
        var realInteractions = await LoadRealInteractionsAsync();

        return realInteractions.Count > syntheticInteractions.Count
            ? new TrainingInteractions(realInteractions, IsReal: true)
            : new TrainingInteractions(syntheticInteractions, IsReal: false);
    }

    private async Task<List<Interaction>> LoadRealInteractionsAsync()
    {
        var feedback = await feedbackService.GetAllAsync();

        return RealInteractionMapper.ToInteractions(feedback);
    }
}

Notice it never imports anything from the Domain or from the repository namespace, only the service. That is on purpose: RockRecommender.Training follows the exact same rule the Api's controllers do, whoever calls in from the outside only ever sees the Application layer.

Notice it also always generates the synthetic side, even when it ends up not using it. That's not wasted work: that exact number is the ruler that decides whether the real data is already big enough. TrainingInteractions is just a simple pair, the chosen list and a flag for which source won, used further down to decide what shows up in the console report.

This rule is deliberately simple: compare counts, without weighing quality or distribution. It solves the real problem (not training on half a dozen interactions from two people), without trying to guess a fancier criterion nobody has asked for yet.

The next article covers the second piece: after picking the source, how the pipeline decides whether the model trained with it is good enough to replace what's already live.

Sources