Przejdź do treści

Jak zbudowałem system rekomendacji posiłków z algorytmem scoringowym

Wizualizacja algorytmu scoringowego z talerz posiłku i panelem rekomendacji

PlateMate to aplikacja do planowania posiłków, która działa jako PWA (Progressive Web App) - instalujesz ją na telefonie prosto z przeglądarki, bez App Store. Jedną z jej funkcji jest system rekomendacji - użytkownik klika "zaproponuj posiłek" i dostaje sugestię dopasowaną do swoich preferencji, historii i diety. W tym artykule opisuję jak ten system działa od środka.

Stack i architektura

Aplikacja stoi na Laravel 12 z Livewire 3. Wybrałem Livewire zamiast SPA (React/Vue) z jednego powodu: cała logika w PHP, zero konieczności utrzymywania osobnego API. Komponenty Livewire dają interaktywność (drag & drop w planerze, live search, real-time aktualizacje) bez pisania JavaScript.

Logika biznesowa jest wydzielona do serwisów:

app/Services/
  MealRecommendationService.php   # algorytm rekomendacji
  MealStatisticsService.php       # analityka
  ShoppingListService.php         # agregacja listy zakupów
  GusApiService.php               # integracja z API GUS

Service Layer zamiast tłustych kontrolerów. Każdy serwis robi jedną rzecz, jest testowalny osobno i reużywalny między komponentami Livewire.

Algorytm rekomendacji

Nie ma tu żadnej sieci neuronowej ani fine-tunowanego modelu. System rekomendacji to algorytm scoringowy - każdy posiłek dostaje punkty na podstawie kilku czynników, a wynik sortujemy od najwyższego.

Czynniki scoringu

Posiłek oznaczony jako ulubiony: +10 punktów. To najsilniejszy sygnał - użytkownik jawnie powiedział "lubię to".

Przepis od obserwowanego kucharza: +5 punktów. Jeśli obserwujesz kogoś, to znaczy że jego przepisy Ci pasują.

Twoja ocena tego posiłku: pomnożona razy 2. Ocena 5 gwiazdek = +10 punktów, 3 gwiazdki = +6. Historyczne oceny mówią wprost co użytkownikowi smakowało.

Średnia ocena społeczności: +1 do +5 punktów. Słabszy sygnał niż osobista ocena, ale pomaga przy nowych posiłkach, których użytkownik jeszcze nie próbował.

Lubiane składniki: +1 punkt za każdy składnik, który użytkownik lubi. Jeśli ktoś lubi bazylię i mozzarellę, caprese dostanie +2.

Na końcu element losowy (RAND()) zapobiega temu, żeby te same posiłki wracały w identycznej kolejności.

Filtry wykluczające

Zanim algorytm policzy punkty, wycina z puli posiłki które nie powinny się pojawić:

  • Posiłki gotowane w ostatnich 7 dniach (żeby nie jeść tego samego ciągle)
  • Posiłki już zaplanowane na dany dzień (nie chcesz dwóch obiadów)
  • Posiłki z nielubianymi składnikami (deklarowane w preferencjach)
  • Posiłki niezgodne z dietą (wegetariańska, wegańska, bezglutenowa)

Implementacja w SQL

Cały scoring działa w jednym zapytaniu SQL z CASE WHEN i podzapytaniami. Nie ciągnę wszystkich posiłków do PHP żeby je posortować - baza danych robi to szybciej.

SELECT meals.*,
  (CASE WHEN meals.id IN (
    SELECT meal_id FROM user_favorites WHERE user_id = ?
  ) THEN 10 ELSE 0 END) +
  (CASE WHEN meals.user_id IN (
    SELECT followed_id FROM user_follows WHERE follower_id = ?
  ) THEN 5 ELSE 0 END) +
  COALESCE((
    SELECT rating FROM meal_ratings
    WHERE meal_id = meals.id AND user_id = ?
  ), 0) * 2 +
  COALESCE((
    SELECT AVG(rating) FROM meal_ratings
    WHERE meal_id = meals.id
  ), 0) as score
FROM meals
ORDER BY score DESC, RAND()

Można by to zrobić w Elasticsearch albo z cache'owanymi wektorami, ale przy 180+ przepisach to przerost formy nad treścią. SQL wystarczy.

Integracja z API GUS

Jedną z funkcji PlateMate jest szacowanie kosztów przepisów. Żeby to robić z realnymi cenami (a nie hardkodowanymi wartościami), zintegrowałem aplikację z API Banku Danych Lokalnych GUS.

Skąd biorę ceny

GUS udostępnia publiczne API z cenami produktów spożywczych. Dwa źródła:

Źródło Produkty Aktualizacja
Ceny detaliczne (P1466) ok. 60 produktów Miesięczna
Ceny targowiskowe (P3156) ok. 20 produktów Półroczna

Razem 80 produktów: nabiał, mięso, wędliny, ryby, warzywa, owoce, pieczywo, mąka, cukier, olej.

Mapowanie składników

Problem: składnik w przepisie to "masło", a w GUS to "Masło 82.5% tłuszczu" z kodem 4981. Trzeba zmapować jedno do drugiego.

Rozwiązanie: mapa słów kluczowych. "mleko 3" mapuje na ID 4975, "jajko" na ID 4993, "masło" na ID 4981. 130+ mapowań.

protected function findBestGusMatch(string $ingredientName): ?int
{
    $keywordMap = [
        'mleko 3' => 4975,
        'mleko 2' => 4976,
        'jajko'   => 4993,
        'masło'   => 4981,
        // ... 130+ mapowań
    ];

    foreach ($keywordMap as $keyword => $variableId) {
        if (str_contains($normalized, $keyword)) {
            return $variableId;
        }
    }
    return null;
}

Synchronizacja uruchamiana komendą artisan: php artisan gus:sync-prices --auto-map --create-missing.

Przeliczanie jednostek

Kolejny problem: GUS podaje cenę masła za 200g, a przepis wymaga 50g. Przelicznik:

public const KNOWN_PRODUCTS = [
    4981 => [
        'name' => 'Masło 82.5%',
        'unit' => 'kg',
        'gus_unit' => '200g',
        'multiplier' => 5  // 200g x 5 = 1kg
    ],
];

Model Ingredient ma metodę calculateCost() która bierze ilość z przepisu, przelicza jednostki i mnoży przez cenę z GUS. Efekt: użytkownik widzi "Naleśniki: ok. 5.75 zł za porcję" z realnymi cenami.

Livewire: interaktywność bez JS

Planer tygodniowy to najbardziej interaktywny komponent w aplikacji. Drag & drop posiłków między dniami, podgląd przepisu w modalu, losowanie nowego posiłku jednym kliknięciem.

Wszystko to działa w Livewire 3 z kilkoma rozwiązaniami wartymi uwagi:

URL binding - filtry w bibliotece posiłków są bindowane do URL przez atrybut #[Url]. Użytkownik może zapisać link z aktualnymi filtrami.

#[Url]
public string $search = '';

#[Url]
public string $category = '';

Computed properties z cache - lista kategorii ładuje się raz i jest cache'owana na godzinę.

#[Computed]
public function categories(): array
{
    return Cache::remember('meal_categories', 3600, fn() =>
        Category::orderBy('order')->pluck('name', 'slug')->toArray()
    );
}

Toast notifications - ujednolicony system powiadomień przez dispatch:

$this->dispatch('toast', message: 'Posiłek dodany do planera', type: 'success');

AI w procesie developmentu

Cały projekt zbudowałem przy wsparciu Claude Code. Nie w sensie "AI wygenerował mi boilerplate" - w sensie partnerstwa programistycznego na każdym etapie.

Architektura bazy danych, relacje między tabelami, wybór wzorców (Service Layer) - konsultowane z AI. Implementacja algorytmu rekomendacji - AI zaproponowało podejście scoringowe zamiast collaborative filtering, bo przy małej bazie danych scoring działa lepiej. Integracja z GUS - AI przeprowadził research dostępnych API, znalazł GUS BDL i zaimplementował klienta HTTP.

Tradycyjna implementacja czegoś takiego to 2-3 miesiące solo. Z Claude Code zajęło 2 tygodnie. Różnica nie bierze się z "szybszego pisania kodu" - bierze się z tego, że AI pokrywa edge case'y, pisze dokumentację na bieżąco i robi code review przy każdym commicie.

Aspekt Bez AI Z AI
Czas implementacji jednej funkcji 2-3 dni 2-4 godziny
Pokrycie edge case'ów Podstawowe, dopracowywane w kolejnych iteracjach Znacznie szersze od pierwszego commita
Dokumentacja "zrobię później" Na bieżąco
Refaktoryzacja Osobny sprint W trakcie implementacji

Czego się nauczyłem

Algorytm scoringowy w SQL wystarczy na małą bazę danych. Nie trzeba Elasticsearch, Redis ani ML pipeline'u żeby dawać trafne rekomendacje. Ważniejsze jest dobre zdefiniowanie czynników i ich wag.

Integracja z GUS to darmowe źródło cen, ale dane targowiskowe aktualizują się co pół roku. Ceny detaliczne co miesiąc. Trzeba to komunikować użytkownikowi jako "szacunek", nie "dokładną cenę".

Livewire 3 nadaje się do aplikacji z interaktywnością na poziomie planera tygodniowego. Drag & drop, live search, real-time kalkulacje - wszystko działa. Gdzie bym nie użył Livewire: gry, edytory graficzne, cokolwiek z animacjami powyżej 60fps.

PlateMate działa jako PWA - użytkownik instaluje aplikację na telefonie prosto z przeglądarki, ma pełny ekran bez paska, działa offline (przepis sprawdzisz w sklepie bez internetu). Jedna baza kodu, trzy platformy. Efekt natywnej apki bez kosztu publikacji w App Store.

Sprawdź sam: plate-mate.pl

Buduję aplikacje webowe w Laravel i integruję je z AI - więcej na stronie backend development.


KC
Kamil Czurak

Pomagam firmom wdrażać AI, które działa - od chatbotów po automatyzacje i agentów. 7 lat jako programista, z czego ostatnie 2 w AI.

Więcej o mnie →

Chcesz podobne rozwiązanie?

Wybierz termin w kalendarzu - 30 minut, zero zobowiązań.

Umów konsultację