„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.
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 digestu | Co odpowiada model |
|---|---|
| Skumulowane migawki zsumowane jak wartości dzienne | Wzrost kilkukrotnie większy niż rzeczywisty |
| Pozycja w wynikach przekazana bez zmian | Z 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 null | Wnioski 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:
| Data | installs | activeDeviceInstalls | averageRating |
|---|---|---|---|
| 2026-07-29 | 0 | 0 | 0 |
| 2026-07-28 | 0 | 0 | 5 |
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łapka | Jak ją zamknęliśmy |
|---|---|
| Liczniki dożywotnie TikToka | Ostatnia migawka minus pierwsza, a nie suma wierszy |
| Pozycja w wynikach | Do digestu jadą zdobyte pozycje: +16 zamiast pary liczb |
| Otwarcia maili, których nikt nie mierzy | Zamiast liczby flaga openTracking: false |
| Poziom odczytany wewnątrz okna | Szukanie ostatniej wartości zdjęte z okna, idzie po całej historii |
| Opóźnienie raportowania Search Console | Pole lagDays plus zakaz w prompcie porównywania szeregów po datach |
| 0 zamiast null | Niezmierzone 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.