„Przepiszcie tematy maili — otwieralność jest zerowa”. Rada brzmiała sensownie, a liczba pod nią była prawdziwa: otwarć 0. Model nie pomylił się w arytmetyce ani nie potknął na parsowaniu — dostał liczbę, która znaczy coś innego. Zero nie znaczyło „nikt nie otwiera maili”, tylko „nikt nie mierzy otwarć”: pole zapisuje się raz przy wysyłce i nigdy nie jest aktualizowane, bo produkt w ogóle nie ma śledzenia otwarć. Rada wyszła spójna, pewna siebie i zmyślona — i po tekście tego nie widać.

Po co ten artykuł

Moglibyśmy napisać „podpięliśmy AI do analityki”. Takich tekstów są setki i nie różnią się od siebie. Warto było napisać o czym innym: kiedy podłączaliśmy metryki produktowe do modelu językowego, prawie cały ból okazał się nie w modelu, nie w prompcie i nie w wyborze dostawcy, tylko w liczbach technicznie nienagannych, które znaczyły co innego, niż zakładał czytający je kod. Takiego defektu nie złapie ani awaria, ani walidacja schematu: model nie protestuje, tylko wydaje spójną, pewną siebie i błędną radę, w tekście nie do odróżnienia od trafnej.

„Podłączyć analitykę produktową do LLM” jest dziś zadaniem masowym, a rozbioru akurat tych grabi prawie nigdzie nie ma. Dlatego ten artykuł nie jest o architekturze ani o wyborze modelu, tylko o sześciu konkretnych przypadkach, z których każdy nam się przydarzył, i o tym, jak każdy zamknęliśmy. Plus część niewygodna: dwa defekty złapały testy, jeden dożył produkcji, i dlaczego tak wyszło.

Co to za system. Wielokanałowe rekomendacje AI w platformie marketingowej: raz na dobę serwis zbiera liczby z pięciu źródeł w jeden JSON, wkłada go do promptu i dostaje z powrotem karty z poradami. Wiedza o ML niepotrzebna — nie ma jej w tym artykule; znajomość API i SQL zakładana.

Zbieranie, normalizacja, analiza

Trzy kroki. Zbieranie — pięć źródeł zapisuje do bazy datowane wiersze. Normalizacja — zamiana tych wierszy na liczby o jednym znaczeniu. Analiza — jeden JSON idzie do promptu, odpowiedź jest parsowana na karty. Zbieranie i analizę pisze się raz i potem prawie się ich nie zmienia; wszystkie sześć defektów siedzi w środku.

Formy wierszy różnią się między źródłami i nie jest to detal integracji — to z tego wyrastają błędy.

TikTok przez Display API zwraca wyłącznie liczniki dożywotnie: wiersz znaczy „łącznie od początku świata na dany dzień”, a nie wynik tego dnia.

Instagram — szereg dzienny ma tylko zasięg, reszta metryk przychodzi jedną wartością za cały okres.

Google Play — eksport CSV z magazynu chmurowego, gdzie dni bez danych wypełnione są zerami-wypełniaczami.

Search Console — sześć zapytań do Google w momencie wywołania naszego endpointu, z cache na godzinę; jego okno kończy się dwa dni przed dzisiaj.

Własne tabele — odwiedzający, lejek, tagi UTM, pozycje słów kluczowych, maile.

Na to nakłada się throttling zależny od planu i osobna częstotliwość crona dla każdego kanału, więc nawet „dzisiejsze” wiersze w bazie mają różny wiek.

Droga liczby: zbieranie z pięciu źródeł, normalizacja według rodzaju metryki, analiza jednym digestem

Trzy rodzaje liczb: strumień, poziom, wskaźnik

Każda metryka należy do jednego z trzech rodzajów, a rodzaj odpowiada od razu na dwa pytania: jak liczyć i które wiersze wolno przy tym czytać. To drugie łatwo stracić z oczu — i właśnie na nim się wyłożyliśmy.

Strumień — instalacje w miesiącu, wyświetlenia postów, kliknięcia. Liczony sumą, a czytać wolno tylko wiersze wewnątrz okna raportowania: poza nim to już inny okres.

Poziom — obserwujący, aktywna baza urządzeń, średnia ocena. Liczony jako ostatnia znana wartość, a czytać trzeba całą historię: poziom nie przestaje istnieć dlatego, że okno się przesunęło.

Wskaźnik — odsetek awarii, CTR, zaangażowanie. Też ostatnia znana wartość; dodawanie procentów nie ma sensu.

Dalej sześć przypadków, w których rozróżnienie albo nie zostało przeprowadzone, albo zostało przeprowadzone poprawnie i zastosowane do złego pytania. Najpierw krótko, co wychodziło na wyjściu:

Co trafiło do digestuCo odpowiada model
Skumulowane migawki zsumowane jak wartości dzienneWzrost kilkukrotnie większy niż rzeczywisty
Pozycja w wynikach przekazana bez zmianZ 20. na 4. czyta się jako pogorszenie
opens: 0 tam, gdzie nie ma śledzenia otwarć„Przepiszcie tematy maili” pod nieistniejący problem
Poziom szukany wyłącznie wewnątrz okna„Aplikacja nie ma użytkowników” — o żywej aplikacji
Kliknięcia z wyszukiwarki wobec wizyt za te same daty„Załamanie ruchu z wyszukiwarki” zamiast opóźnienia raportowania
0 zamiast nullWnioski z danych, których nikt nie zbierał

Pułapka 1. Liczniki dożywotnie

Co jest w danych. TikTok zwraca sumę narastającą. Jeśli konto ma 1000 wyświetleń, jutro w wierszu będzie 1200, pojutrze 1250 — i każdy wiersz zawiera całą historię, a nie przyrost dnia.

Co z tego wychodziło. Suma takich wierszy dodaje nie przyrosty, tylko trzydzieści kopii całej historii konta. Miesiąc z realnym przyrostem kilkuset zamieniał się w dziesiątki tysięcy, a model pisał o eksplozji wzrostu i radził podwoić stawkę na ten kanał.

Jak to naprawiliśmy. Wynik za okres to ostatnia migawka minus pierwsza, a nie suma wierszy. Osobne zastrzeżenie co do historii: migawki zaczynają się w dniu podłączenia konta, wcześniej nie ma nic. Jeśli więc konto podłączono w środku okna, uczciwa odpowiedź to przyrost od dnia podłączenia, a nie od początku okna; nie ma z czego uzupełnić przeszłości, bo TikTok minionych dni nie oddaje.

Instagram ma tę samą pułapkę od drugiej strony: skoro szereg dzienny ma tylko zasięg, sumowanie pozostałych wierszy dodaje tę samą wartość okresową kilka razy. Dlatego zaangażowanie liczymy z wierszy postów, a nie z migawek konta: post ma datę publikacji i własne reakcje — to uczciwy strumień wewnątrz okna.

Pułapka 2. Pozycja w wynikach

Co jest w danych. Pozycja to rzadka metryka, w której mniej znaczy lepiej: pierwsze miejsce to 1, dwudzieste to 20.

Co z tego wychodziło. Para „było 20, jest 4” bez wyjaśnienia czyta się jako spadek: liczba zmalała, czyli jest gorzej. Model współczująco proponował ratować działania, które właśnie zadziałały.

Jak to naprawiliśmy. Do digestu jedzie nie pozycja, tylko zdobyte pozycje: jedna liczba +16 zamiast pary. Kierunek jest wbudowany w samą wielkość, więc model nie ma czego zgadywać. Do tego osobne zdanie w prompcie, że mniejsza liczba pozycji jest lepsza.

Tam samo naprawiamy skalę CTR. Search Console zwraca ułamek, 0.032, a w sąsiednim bloku digestu, zbudowanym z własnych tabel, leżą procenty, 3.2. To samo pole w dwóch blokach w dwóch skalach to gotowy — i arytmetycznie nienaganny — wniosek „CTR spadł stukrotnie”. Rozwiązanie banalne: sprowadzamy wszystko do procentów na wejściu, zanim obie liczby spotkają się w jednym JSON-ie.

Pułapka 3. Metryka, która zawsze jest zerem

Co jest w danych. Pole ze statystyką maila zapisuje się raz przy wysyłce i nigdy więcej nie jest ruszane. Otwarcia nie są w produkcie śledzone w ogóle.

Co z tego wychodziło. Przypadek z początku artykułu: pewna siebie rada, żeby przepisać tematy maili dla otwieralności, której nikt nie mierzy.

Jak to naprawiliśmy. Nie podstawiać zera, nie podstawiać średniej rynkowej i w ogóle nie przekazywać liczby. Blok maili niesie flagę openTracking: false, a prompt wyjaśnia, co ta flaga znaczy. Model daje wtedy jedyną sensowną radę — uruchomić śledzenie otwarć — zamiast leczyć problem, którego nikt nie zaobserwował.

Pułapka 4. Poziom odczytany wewnątrz okna

Najdroższa z sześciu i jedyna, której testy nie złapały.

Co jest w danych. Eksport Google Play dla jednego projektu urwał się miesiąc przed oknem raportowania. Wewnątrz trzydziestodniowego okna zostały same wiersze-wypełniacze:

DatainstallsactiveDeviceInstallsaverageRating
2026-07-29000
2026-07-28005

Co z tego wychodziło. Średnia ocena rozwiązała się poprawnie, na 5: logika „cofnij się przez zera i weź ostatnią niezerową” zadziałała zgodnie z projektem. Aktywna baza urządzeń rozwiązała się na 0 — jej niezerowe odczyty, dochodzące do 15, leżały tuż przed granicą okna, a wewnątrz okna nie było ich wcale. Model dostał żywą aplikację z zerem użytkowników i odpowiedział dokładnie tak, jak na to należy odpowiedzieć: radą, żeby uruchomić coś, co jest już opublikowane.

Na czym polegał błąd. Rozróżnienie „strumień kontra poziom” było tu przeprowadzone, i to poprawnie. Decydowało o metodzie: strumieniowi suma, poziomowi ostatnia wartość. Ale wybór wierszy pozostał wspólny dla obu przypadków — z okna. Kod pytał więc „ostatnia wartość spośród wierszy okna”, podczas gdy właściwe pytanie brzmi „ostatnia wartość w ogóle”. Reguła była słuszna, jej zakres nie. Trudno to zauważyć właśnie dlatego, że reguła jest już napisana i wygląda poprawnie.

Jak to naprawiliśmy. Szukanie ostatniej wartości poziomu nie jest już ograniczone oknem: idzie po całej dostępnej historii metryki i zwraca ostatni niezerowy pomiar, choćby sprzed wielu dni. Okno zostało tam, gdzie jego miejsce — przy strumieniach.

Pułapka 5. Opóźnienie raportowania

Co jest w danych. Search Console nie oddaje dwóch ostatnich dni: po stronie Google nie są jeszcze doliczone.

Co z tego wychodziło. Jeśli położyć obok siebie kliknięcia z wyszukiwarki i odwiedzających stronę za te same daty, na końcu okresu zieje dziura. W liczbach to załamanie ruchu z wyszukiwarki, i model uczciwie o nim pisał — choć ruch nigdzie się nie podział.

Jak to naprawiliśmy. Opóźnienia nie da się ukryć, ale można je przekazać jako dane. Blok wyszukiwarki wiezie pole lagDays — o ile dni okno nie dochodzi do dzisiaj — a prompt wprost zabrania porównywać te dwa szeregi po datach i czytać lukę jako załamanie.

Pułapka 6. null i zero

Co jest w danych. Każde miejsce, w którym podstawialiśmy 0, żeby nie psuć schematu JSON.

Co z tego wychodziło. Zero jest wynikiem pomiaru, null jego brakiem. Po zwinięciu do jednego pola nie da się ich już odróżnić, a różnica między „zmierzyliśmy i wyszło zero” a „tego nie mierzyliśmy” odwraca wniosek.

Jak to naprawiliśmy. Reguła bez wyjątków: niezmierzone jedzie jako null, a prompt mówi, że null znaczy „nie wiemy”, a nie „zero”. Pułapka 3 to szczególny przypadek tej reguły doprowadzony do osobnej flagi; degradacja na końcu artykułu to ten sam przypadek dla awarii sieci.

Sześć rozwiązań w jednym miejscu

PułapkaJak ją zamknęliśmy
Liczniki dożywotnie TikTokaOstatnia migawka minus pierwsza, a nie suma wierszy
Pozycja w wynikachDo digestu jadą zdobyte pozycje: +16 zamiast pary liczb
Otwarcia maili, których nikt nie mierzyZamiast liczby flaga openTracking: false
Poziom odczytany wewnątrz oknaSzukanie ostatniej wartości zdjęte z okna, idzie po całej historii
Opóźnienie raportowania Search ConsolePole lagDays plus zakaz w prompcie porównywania szeregów po datach
0 zamiast nullNiezmierzone jedzie jako null, a prompt mówi, że null to nie zero

To, co łączy wszystkie sześć, warto powiedzieć osobno: żadne z rozwiązań nie dotyczy modelu. Ani lepszy prompt, ani mądrzejszy model nie naprawiają żadnego z tych przypadków. W każdym zmienia się dokładnie jedno — która liczba trafia do JSON-a.

Gdzie to umieścić w kodzie

Po pierwsze: cała arytmetyka metryk wyjechała do czystych modułów bez dostępu do bazy. Na wejściu wiersze, na wyjściu gotowy blok digestu. Nie chodzi o urodę architektury, tylko o to, że taką funkcję da się testować pod kątem semantyki, a nie rachunku. Użyteczny test brzmi tu nie „suma trzech liczb równa się ich sumie”, tylko „dwa identyczne pomiary to zmierzony zerowy wzrost, a jeden pomiar to brak pomiaru, i są to różne odpowiedzi”.

Po drugie: mniej więcej połowa promptu systemowego to nie zadanie, tylko zasady czytania liczb. Nie „daj rekomendacje marketingowe”, tylko wyjaśnienie, co znaczą pola w JSON-ie. Trzy z tych zasad dosłownie:

Żadna z nich nie powstała z przezorności. Każda pojawiła się po tym, jak przeczytaliśmy odpowiadającą jej pewną siebie błędną odpowiedź.

Po trzecie, drobniej: listy w digeście są przycięte do pięciu pozycji, żeby prompt nie puchł; konta jednego kanału są agregowane; odpowiedź modelu parsujemy ściśle — z tolerancją na opakowania z potrójnych apostrofów, normalizacją wartości enum i regułą, że pusta odpowiedź nigdy nie nadpisuje już dobrych kart.

Czego testy nie złapały

Dwa defekty z sześciu złapały testy jednostkowe czystych modułów — dokładnie te sprawdzenia semantyki, o których wyżej. Pułapka 4, z bazą urządzeń, wyszła dopiero na żywych danych produkcyjnych.

Powód nie leży w lenistwie. Moki pisała ta sama osoba i z tym samym rozumieniem dziedziny, co kod. Nikt nie wymyślił moka, w którym historia metryki zaczyna się przed oknem: żeby go wymyślić, trzeba było najpierw wiedzieć, że granica okna ma znaczenie. Mok nie sprawdza założenia, które sam podziela.

Osobno pouczający jest jeden szczegół. Test tego bloku istniał i przechodził, ale był ślepy: mok zwracał tę samą tablicę wierszy na dwa różne zapytania — na czytanie okna rosnąco po dacie i na szukanie ostatniej wartości malejąco. Nie potrafił ich rozróżnić, więc sprawdzenie przechodziło przy dowolnej implementacji, w tym przy zepsutej. Słabym ogniwem był mok, a nie asercja. To przypadek, w którym zielony test jest gorszy od brakującego: zajmuje miejsce, na którym mogło stać prawdziwe sprawdzenie.

Praktyczny wniosek jest prosty: jeśli mok odpowiada na kilka różnych zapytań, musi odpowiadać na nie różnie. Inaczej sprawdza sam siebie, a nie kod.

Degradacja: dlaczego przy awarii uczciwiej zwrócić null

Search Console to jedyne źródło, które w trakcie żądania wychodzi do sieci, i robi to od razu sześcioma wywołaniami. Stoi za ośmiosekundowym timeoutem, a awaria ma trzy różne wyniki zamiast jednego: nie ma integracji — nie dzwonimy w ogóle; skonfigurowana połowicznie — blok jest oznaczony jako niepodłączony; awaria lub timeout — blok zostaje podłączony, ale jego liczby to null. Każdy blok digestu składa się niezależnie, więc padające źródło zabiera ze sobą tylko własną sekcję, a nie całe rekomendacje.

Ten trzeci wynik to pułapka 6 doprowadzona do awarii. Zera przy niedostępności Google wyglądają jak załamanie ruchu, a model zaczyna ratować ruch, z którym wszystko jest w porządku. Null znaczy „nie wiemy”, i to jedyna prawdziwa rzecz, jaką można powiedzieć o źródle, które nie odpowiedziało.

Co się zmieniło w odpowiedziach

Znamienne jest nie to, że rekomendacje zrobiły się dłuższe, tylko że przed i po to teksty o różnych rzeczach. Przed: „brak słów kluczowych i konkurentów wskazuje na słabą strategię SEO” — zdanie pasujące do dowolnego projektu, bo nie opiera się na żadnej liczbie.

Po, na tym samym projekcie: 89 odwiedzających przy zerowych konwersjach; Instagram ma i wyższe zaangażowanie, i wyższy przyrost obserwujących niż pozostałe kanały; TikTok ma dużo wyświetleń przy niskim zaangażowaniu; średnia pozycja 25.1, część zapytań na granicy top 10.

Różnica nie leży w elokwencji modelu ani w rozmiarze promptu. Po prostu dostał liczby o jednym znaczeniu.

Podsumowanie

Jeśli wynieść stąd jedną myśl: model językowy nie sprawdza znaczenia liczb, które dostał. Sprawdza spójność własnego tekstu — i robi to dobrze. Dlatego błąd semantyczny w danych wychodzi na wierzch nie jako awaria, tylko jako pewna siebie rada, a złapać go można wyłącznie tam, gdzie liczba powstaje, a nie tam, gdzie jest czytana.

Ustal rodzaj metryki, zanim ją policzysz. Strumień, poziom czy wskaźnik decyduje i o wzorze, i o tym, które wiersze wolno czytać. O tym drugim zapomina się częściej i kosztuje więcej.

Nigdy nie podstawiaj zera zamiast „nie wiemy”. Zero jest wynikiem pomiaru. Brak pomiaru to null albo osobna flaga — a prompt musi mówić, co ta flaga znaczy.

Wkładaj do promptu zasady czytania, nie tylko zadanie. Połowa naszego promptu systemowego wyjaśnia, co znaczą pola JSON, i każda linia powstała po konkretnej błędnej odpowiedzi, a nie z przezorności.

Testuj semantykę w czystych modułach — i nie ufaj mokom. Mok napisany przez tę samą osobę, co kod, podziela jej założenia. Właśnie dlatego jeden z sześciu defektów dożył produkcji.

Uczciwie o słabym punkcie całego wywodu: to nie jest temat o uczeniu maszynowym, tylko o higienie danych. Ale to właśnie ona decyduje, czy odpowiedź modelu będzie użyteczna, czy tylko gładka — i dzieje się, zanim model w ogóle wejdzie do gry.

Najczęstsze pytania

Na czym polega błąd semantyczny metryki, skoro liczba jest poprawna?
Liczba jest wyciągnięta z właściwych wierszy i poprawnie policzona, ale znaczy co innego, niż zakłada model. Zero otwarć maili nie znaczyło „nikt nie otwiera”, tylko „nikt nie mierzy”. Model nie zgłasza wtedy błędu — wydaje spójną i pewną siebie rekomendację pod problem, którego nie ma.
Czym różni się strumień od poziomu i wskaźnika?
Strumień (instalacje, wyświetlenia) sumuje się ściśle wewnątrz okna raportowania. Poziom (obserwujący, aktywna baza urządzeń, ocena) to ostatnia znana wartość i okno go nie ogranicza. Wskaźnik (CTR, zaangażowanie, odsetek awarii) to też ostatnia wartość, a sumowanie procentów nie ma sensu.
Dlaczego poziom odczytany wewnątrz okna dał zero przy żywej aplikacji?
Eksport Google Play urwał się miesiąc wcześniej, więc wewnątrz trzydziestodniowego okna zostały tylko wiersze z zerami wypełniacza. Jedyne niezerowe odczyty aktywnej bazy urządzeń, dochodzące do 15, leżały tuż przed granicą okna. Rozróżnienie strumień–poziom decydowało, jak liczyć, ale nie decydowało, które wiersze wolno czytać.
Dlaczego przy awarii źródła lepiej zwrócić null niż zero?
Zero jest wynikiem pomiaru, null jego brakiem. Zera zwrócone przy niedostępnym Search Console wyglądają jak załamanie ruchu i model zacznie ratować ruch, z którym wszystko jest w porządku. Dlatego awaria ma trzy różne wyniki: brak integracji, konfiguracja połowiczna oraz podłączone z liczbami null.
Dlaczego testy jednostkowe nie wystarczyły?
Dwa defekty z sześciu złapały testy czystych modułów, trzeci wyszedł dopiero na danych produkcyjnych. Moki pisała ta sama osoba i z tym samym rozumieniem dziedziny, co kod, więc nikt nie wymyślił moka z historią sprzed granicy okna. Dodatkowo istniejący test był ślepy: mok zwracał tę samą tablicę na dwa zapytania o różnym sortowaniu.