[ case study ]
Smart Radiowęzeł - System dla szkoły
Oddaliśmy szkolny radiowęzeł w ręce uczniów. .NET orkiestruje dzwonki (Hangfire), Redis i SignalR dźwigają głosowania na żywo w sieci LAN, a Guardrail AI w Pythonie blokuje wulgaryzmy. Czyste wdrożenie on-premise spięte z fizycznym sprzętem audio.
Full-Stack & DevOps Lead
Zespół 3 osoby · 400+ aktywnych użytkowników
Stack
Goal
Społecznościowy wybór muzyki na przerwy z gwarancją bezpieczeństwa. AI chroni głośniki przed trollami, a infrastruktura on-premise bezbłędnie synchronizuje się z fizycznym czasem dzwonków szkolnych.
System wdrożony - działa w moim byłym technikum: ZSZ Gostyń
01 - Z chmury do serwerowni
Z chmury do szkolnej serwerowni
Większość case studies juniorów kończy się na deployu w chmurze i linku do Vercela. Mój poprzedni projekt - GroupNote - był architektonicznym monstrum, które padło ofiarą over-engineeringu i nigdy nie zderzyło się z rynkiem. Wyciągnąłem z tego prostą lekcję: prawdziwa inżynieria to nie budowanie idealnych systemów w próżni, tylko rozwiązywanie brudnych, fizycznych problemów.
Smart Radiowęzeł to odwrócenie startupowej mrzonki: system zrodzony i wdrożony w bojowych warunkach - w zamkniętej sieci ZSZ im. Powstańców Wielkopolskich w Gostyniu.
Proof of work: to nie działało „na moim komputerze”. Działało na szkolnym serwerze bare-metal, obsługując codziennie - ułamek sekundy po dzwonku - setki uczniów próbujących przeforsować swoje ulubione (i często zakazane) utwory.
Problem: jak oddać głośniki uczniom, nie ryzykując katastrofy?
Szkolny radiowęzeł to infrastruktura podwyższonego ryzyka. Dźwięk idzie na korytarze, do sal i poza budynek. Stary układ to PC i statyczna playlista. Chcieliśmy dać uczniom kontrolę z telefonów - ale z pełną świadomością ryzyka:
Trolling i wulgaryzmy: pierwsza myśl uczniów to niecenzuralny rap albo „żartobliwy” hymn. Potrzebna była twarda polityka zero trust wobec treści.
Synchronizacja z fizycznym czasem: głosowania i odtwarzanie musiały trzymać się dzwonków - bez losowych opóźnień „z chmury”.
Thundering herd: o 8:35 dzwoni dzwonek i w tej samej sekundzie setki telefonów uderza w API - baza i realtime musiały to znosić bez zadyszki.
Zespół i półtora roku iteracji
Przez około półtora roku, w trzyosobowym zespole (ja + dwóch kolegów z klasy) pod okiem nauczyciela IT, zbudowaliśmy architekturę, która spięła nowoczesny web z fizycznym sprzętem.
Ja odpowiadałem za Lead Backend + DevOps + architekturę: baza danych, .NET, SignalR, Redis, Docker, aplikację kliencką w React oraz aplikację administracyjną we Flutterze. Koledzy dostarczyli kluczowy mikroserwis w Pythonie - AI Guardrail scrapujący teksty z sieci i oceniający treść modelami LLM pod kątem przydatności do szkoły.
To case study o zderzeniu kodu z setkami użytkowników naraz i o podłączeniu nowoczesnego softu do miedzianego kabla od szkolnego wzmacniacza.
02 - Architektura
Architektura - modularny monolit na bare-metalu
[ design principle ]
Pragmatyzm ponad hype. Rozbicie systemu na mikroserwisy dla ~400 użytkowników w szkolnym LAN-ie to książkowy over-engineering, który zabiłby nas operacyjnie. Z drugiej strony klasyczne warstwowe spaghetti uniemożliwiłoby bezpieczne aktualizacje na produkcji między przerwami. Wybrałem Modular Monolith.
System musiał działać stabilnie w jednym kontenerze Dockera na lokalnym serwerze w sieci ODN_Uczniowie. Zamiast rozpraszać architekturę po sieci, zbudowałem jeden proces orkiestrujący całość - logicznie podzielony, operacyjnie spójny.
Vertical slices i komunikacja in-process
Backend ASP.NET Core podzieliłem na pionowe moduły: Modules.Users, Modules.Votings, Modules.Admin, Modules.Feedback.
Zamiast REST „wewnątrz monolitu”, użyłem jawnej komunikacji in-process przez MediatR: każda operacja to Command + Handler. Kontrakty między modułami leżą w Shared/Events.
Efekt: jeden proces na serwerze, ale czytelne granice domeny (bounded contexts) w kodzie.
Baza danych: jeden serwer, cztery konteksty
Jedna instancja PostgreSQL, ale w kodzie absolutna separacja: każdy moduł miał własny DbContext i własne migracje - najczęstszy błąd monolitów (wspólne tabele bez granic) był z góry blokowany.
public class VotingDbContext : DbContext
{
public DbSet<Song> Songs { get; set; }
public DbSet<VotingSession> Votings { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("votings"); // Izolacja na poziomie schematu DB
// Konfiguracja specyficzna dla modułu głosowań
}
}Dzięki temu moduł Admin nie mógł „na skróty” czytać tabel Votings - tylko przez jawny kontrakt (np. query przez MediatR).
Docker Compose jako operacyjny backbone
Na bare-metal nie ma managed DB ani auto-scalingu - jedynym kontraktem z serwerem był docker-compose.yml. Musiał wstać po każdym resecie: API .NET, PostgreSQL, Redis (sesje + SignalR), telemetria (Aspire Dashboard).
# Fragment infrastruktury - jedna sieć LAN
services:
radiowezelapi:
build:
context: .
dockerfile: RadiowezelAPI/Dockerfile
environment:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://radiowezel.dashboard:18889
- PYTHON_URL=${PYTHON_URL}
- TZ=Europe/Warsaw # Krytyczne: synchronizacja z czasem szkolnym
ports:
- "8080:8080"
depends_on:
- radiowezel.cache
- radiowezel.postgresKluczowy detal: TZ=Europe/Warsaw. Gdy większość stacków cloudowych żyje w UTC, my sterowaliśmy fizycznymi dzwonkami w polskim technikum - Hangfire musiał rozumieć lokalną strefę, letni/zimowy i moment, kdy wybija 8:35.
Pipeline produktowy w skrócie: link YT → walidacja → kolejka → głosowanie → odtwarzanie na głośnikach.
03 - Most sprzętowy i czas
Most sprzętowy i czas fizyczny (Hangfire)
Większość aplikacji webowych żyje w świecie, gdzie czas to po prostu DateTime.UtcNow. U nas musieliśmy trzymać się fizycznych dzwonków na korytarzach. Spóźnienie o kilka sekund oznaczało muzykę w trakcie lekcji - czyli natychmiastowe wyłączenie systemu przez dyrekcję.
Hangfire i pułapka stref czasowych
Harmonogram otwierania i zamykania głosowań na przerwach oparliśmy o Hangfire z magazynem w PostgreSQL. W kontenerach szybko wyszło, że Docker domyślnie żyje w UTC, a dzwonek w polskiej szkole - w Europe/Warsaw z letnim/zimowym.
[ time zone ]
Ustawiliśmy w kontenerze jawnie TZ=Europe/Warsaw, a harmonogram w .NET wymuszał czas lokalny serwera. Bez tego cały rozkład przerw „jeździłby” po każdej zmianie czasu.
services.AddHangfire(cfg => cfg
.SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UsePostgreSqlStorage(connectionString));
// Krytyczny detal: harmonogram musi rozumieć fizyczny czas lokalny
var jobsOptions = new RecurringJobOptions { TimeZone = TimeZoneInfo.Local };
// Rejestracja jobów startujących głosowanie (Cron)
RecurringJob.AddOrUpdate<VotingJobHandler>(
"StartVoting_Przerwa1",
x => x.StartVotingAsync(CancellationToken.None),
"35 8 * * 1-5", // Pon-Pt, 8:35
jobsOptions);Hardware bridge: od .NET do miedzianego kabla
Samo zamknięcie głosowania w bazie nie generuje dźwięku - trzeba było wypchnąć zwycięskie utwory na fizyczne głośniki. Sprzęt to stary komputer podłączony do wzmacniacza i AIMP. Bez budżetu na dedykowane API audio poszedł pragmatic wrapper wokół CLI.
Koledzy znaleźli narzędzie CLI do AIMP-a, a na komputerze w „kanciapie” radiowęzła postawiliśmy cienki serwis FastAPI, który cyklicznie wołał backend:
GET /voting/songs-to-play - zwycięska playlista (URL + czas trwania), potem mapowanie na polecenia AIMP (play, pause, głośność).
Pętla zwrotna (Now Playing)
System był dwukierunkowy: gdy AIMP zaczynał grać, controller w Pythonie wołał POST /voting/playing-song. Backend zapisywał krótki stan w Redis i od razu wypychał zmianę przez SignalR do telefonów - w praktyce status „Teraz gra” pojawiał się na ekranach w tym samym momencie, kiedy bas docierał do korytarza.
POST /voting/playing-song
{
"songId": "guid-utworu",
"duration": 215
}04 - Ruch na przerwie
Ruch na przerwie (Redis & SignalR)
W typowej aplikacji ruch rozłoży się w miarę równo w ciągu dnia. U nas przez 45 minut lekcji było prawie zero - a o 8:35, w ułamku sekundy po dzwonku, setki uczniów włączało aplikację, żeby zagłosować. To książkowy thundering herd.
Gdyby przy każdym odświeżeniu pytać PostgreSQL, czy głosowanie jest aktywne i przeliczać lajki w locie, w kilka sekund dobilibyśmy connection pool.
Redis jako tarcza dla bazy
Redis trzymał gorący stan na czas przerwy - krótki, ulotny, w RAM:
stan głosowania (aktywne / nieaktywne),
bieżący utwór (Now Playing),
sesje (ograniczenie multikont),
szybki cache oddanych głosów.
API odpowiadało z pamięci zamiast uderzać w relacyjną bazę przy każdym ruchu na liście.
// Modules.Users/Auth - szybki check sesji w Redis (antyspam)
var sessionExists = await cache.GetStringAsync($"session:{userId}");
if (sessionExists is null)
{
await cache.SetStringAsync($"session:{userId}", "true",
options: new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15)
});
return false;
}
return true; // Blokada: uczeń już działa w tej sesjiPragmatyczny event bus (SignalR)
Polling zabiłby Wi‑Fi. Frontend w React musiał żyć na żywo - weszliśmy w SignalR, ale bez armii dedykowanych metod w hubie na każdą akcję.
Jeden kanał ReceiveMessage: backend wysyłał krótkie stringi, a klient decydował w switch/if, co z nimi zrobić.
// React - jeden kanał ReceiveMessage zamiast armii metod w Hubie
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${API_BASE_URL}/voting-hub`)
.withAutomaticReconnect()
.build();
connection.on("ReceiveMessage", (message: string) => {
if (isPlayingSongDto(message)) {
onPlayingSongUpdate?.(message);
} else if (message === "Like added to song.") {
onLikeUpdate?.();
} else if (message === "Voting started.") {
onVotingStarted?.();
} else if (message === "Voting ended.") {
onVotingEnded?.();
}
});Trade-off inżynieryjny: poświęciliśmy silne typowanie każdego eventu po stronie huba, żeby zyskać prostą integrację i jeden strumień wiadomości. Nowy sygnał z backendu (np. komunikat awaryjny) nie wymagał zmiany sygnatury Huba - wystarczyła kolejna gałąź po stronie frontu.
05 - AI Guardrail
AI Guardrail - tarcza przed trollami
Gdy oddajesz setkom uczniów możliwość puszczania muzyki, pierwszy ruch to często wulgarny rap albo „żart” w stylu hymnu ZSRR. Ręczna moderacja każdego utworu przez nauczyciela zabiłaby projekt pierwszego dnia - potrzebowaliśmy automatycznej tarczy.
Wszedł moduł AI Guardrail: analiza treści zanim piosenka trafi do puli głosów.
Zespół i granice API: modułu AI w Pythonie (FastAPI + LLM) nie pisałem sam - dostarczyli go dwaj koledzy z zespołu (scraping, modele, Gemini). Moja rola po stronie .NET to orkiestracja: zaprojektowanie twardych kontraktów HTTP, decyzje domenowe po odpowiedzi AI oraz mapowanie wyników na realne tabele - RejectedSongs, SongsToCheck - tak, żeby ich worker pozostał czarną skrzynką, a granice systemu były jasne.
Podział: orchestrator vs worker
Nie mieszaliśmy scrapingu tekstów i zapytań do Gemini w głównym kodzie C# - podział był jawny:
Python (worker): link YouTube, metadane, tekst z zewnętrznych źródeł, wywołanie LLM, zwrot ustandaryzowanego wyniku (Positive / Negative / Neutral).
.NET (orchestrator): przepływ domeny, baza, ostateczna decyzja co zrobić z wynikiem - i persystencja, której Python nie musiał znać.
Z perspektywy backendu moduł kolegów był czarną skrzynką z prostym kontraktem:
var result = await $"{PythonApiUrl}/sentiment"
.PostJsonAsync(new { URL = request.Url }) // Kontrakt uzgodniony z zespołem Pythona
.ReceiveJson<ValidateSongResponse>();Logika domenowa po stronie .NET
Samo „odpytanie AI” to za mało. Zbudowałem w C# trójdzielny flow na podstawie odpowiedzi z Pythona:
Positive - automatyczna akceptacja, utwór idzie do głosowania.
Negative - twardy blok: zapis do RejectedSongs (banlista), błąd na froncie.
Neutral - bezpieczny bufor: wpis do SongsToCheck na ręczną decyzję w aplikacji admina (Flutter). LLM bywa niepewny albo nie łapie ironii - lepiej jeden klik nauczyciela niż przepuszczenie ryzykownej treści.
// Fragment logiki weryfikacji w handlerze .NET
if (isInRejectedSongs)
{
return Result.Failure<AddSongResponse>(
Error.Conflict("Utwór zablokowany przez Guardrail AI.")
);
}
if (aiResult.Sentiment == Sentiment.Neutral)
{
await context.SongsToCheck.AddAsync(new SongToCheck { Url = request.Url });
await context.SaveChangesAsync();
return Result.Success(new AddSongResponse("Piosenka oczekuje na moderację."));
}Stan: oczekiwanie
Stan: sukces
06 - Auth i pragmatyzm
Bezpieczeństwo i pragmatyzm auth
W systemach enterprise standardem jest pełne IAM - OAuth2, OpenID Connect, Azure AD. Szkoła miała konta Microsoft 365 dla każdego ucznia, więc technicznie „Zaloguj się kontem szkolnym” było najbardziej „poprawną” ścieżką.
Dobry inżynier produktu wie jednak, kiedy „najlepsze praktyki” zabiją projekt.
[ UX vs security ]
Wyobraź sobie ~400 uczniów z ~10 minutami przerwy. Zmuszenie ich do wpisywania na telefonach długich maili (imie.nazwisko@domena.pl) i haseł zabiłoby adopcję pierwszego dnia. Zrezygnowałem z Azure AD na rzecz maksymalnego obniżenia tarcia.
Logowanie bez tarcia: 4 znaki
Zamiast korporacyjnego SSO każdy uczeń dostał wygenerowany, unikalny 4-znakowy kod alfanumeryczny. Wpisanie go na klawiaturze telefonu zajmowało sekundę.
Twój kod
Logowanie
Proste kody brzmią jak koszmar bezpieczeństwa, ale system nie chronił danych bankowych - tylko dostępu do głośników. Żeby ograniczyć nadużycia (jeden uczeń, wiele kart, spam głosów), dodałem szybką blokadę sesji w Redis przypisaną do użytkownika na czas przerwy:
// Modules.Users/Auth - blokada sesji w Redis
var sessionExists = await cache.GetStringAsync($"session:{userId}");
if (sessionExists is not null)
{
return Result.Failure(Error.Conflict("Sesja jest już aktywna."));
}
await cache.SetStringAsync(
$"session:{userId}",
"true",
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15)
});Dzięki temu prosta autoryzacja była odporna na próby rozciągnięcia jednego konta na wiele urządzeń naraz i na manipulację wynikami głosowania w szczycie ruchu.
Admin: pragmatyczne OTP
Odrzucenie ciężkiego identity dotyczyło też panelu administratora (moderacja bufora Neutral). Nie stawiałem IdentityServera - zrobiłem prosty OTP wysyłany przez SMTP na predefiniowane adresy. Kod był ważny ~25 minut i lądował w Redisie; chronił go dedykowany middleware na wybranych ścieżkach:
// Fragment middleware chroniącego endpointy administracyjne
if (context.Request.Path.StartsWithSegments("/admin/songs"))
{
if (!context.Request.Headers.TryGetValue("X-Admin-OTP", out var providedOtp))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
var cachedOtp = await cache.GetStringAsync("OTP");
if (string.IsNullOrEmpty(cachedOtp) || cachedOtp != providedOtp)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
}Żadnych ról, żadnych rozbudowanych polityk claimsów - surowe sprawdzenie nagłówka HTTP i pamięci podręcznej, dopasowane do skali projektu w LAN.
07 - Obserwowalność
Obserwowalność w szafie - OpenTelemetry
Na Vercelu błędy oglądasz w wygodnym dashboardzie. Przy deployu on-prem na szkolnym serwerze domyślnie zostają płaskie logi z Dockera i czarny terminal.
Gdy w trakcie przerwy milknie muzyka, nie ma czasu na żmudne grep po SSH - trzeba od razu wiedzieć: czy padła baza, czy Python zwrócił timeout, czy przyszedł zepsuty link.
[ minimum viable ops ]
Nie budowałem centrum dowodzenia z Grafaną, Prometheusem i ELK - to zabiłoby mały serwer (kolejny argument za unikaniem over-engineeringu). Zamiast tego: czyste OpenTelemetry w .NET i lekki kontener Aspire Dashboard.
Telemetria strukturalna (.NET + OTLP)
W głównym API skonfigurowałem instrumentację zbierającą metryki i ślady z krytycznych ścieżek:
// RadiowezelAPI/Program.cs - rejestracja OpenTelemetry
services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService("radiowezel"))
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation(); // Korelacja wywołań do Pythona
metrics.AddOtlpExporter();
})
.WithTracing(tracing =>
{
tracing.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation(); // Zapytania SQL
tracing.AddOtlpExporter();
});AddEntityFrameworkCoreInstrumentation pokazywał czas zapytań do Postgresa - przy „lagi” na przerwie od razu było widać, czy winna jest baza, czy np. blokada w Redis.
AddHttpClientInstrumentation dawał pełny obraz czasu po stronie mikroserwisu Python (AI Guardrail) przy analizie treści utworu.
Aspire Dashboard w Docker Compose
Do docker-compose.yml dorzuciłem oficjalny obraz Aspire Dashboard - w tej samej sieci LAN zbierał dane po OTLP:
# Fragment docker-compose.yml
services:
radiowezel.dashboard:
image: mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest
ports:
- "15677:18888" # Dostęp lokalnie dla deweloperów
radiowezelapi:
environment:
# API wysyła telemetrię OpenTelemetry do Dashboardu (OTLP)
- OTEL_EXPORTER_OTLP_ENDPOINT=http://radiowezel.dashboard:18889Efekt: pod jednym adresem na serwerze szkolnym - dashboard z korelacją między requestem od ucznia, czasem w bazie i zapytaniem do AI. Koszt operacyjny bliski zeru względem pełnego stacku observability.
08 - Zderzenie z użytkownikami
Zderzenie z użytkownikami - dzień pierwszy
Testy na localhost z kilkoma rekordami to jedno. Wpuszczenie do systemu ~400 uczniów, których głównym celem jest znalezienie luki i zrobienie „dymu” na korytarzu, to inny sport. Dzień pierwszy był brutalną lekcją utrzymania na żywo (live ops).
Chińskie znaki i brak twardego MaxLength
W pierwszych godzinach baza „spuchła”. Jeden z uczniów odkrył, że formularz dodawania piosenki przyjmuje dowolną długość tekstu - skryptem lub ręcznie zasypał endpoint tysiącami znaków w polu tytułu.
[ live hotfix ]
Klasyczny brak walidacji domenowej po stronie API. Fix: reguły w FluentValidation (limit długości), sprzątanie śmieci w Postgresie z CLI i restart kontenera na serwerze szkolnym - zanim zadzwonił dzwonek na kolejną przerwę.
Stress-test AI Guardraila
Gdy uczniowie zorientowali się, że utwory nie wchodzą od razu, zaczęli testować granice modelu w Pythonie: hymn ZSRR, wulgaryzmy „zakamuflowane” w tekście, treści skrajne.
Tu uratowała nas decyzja o buforze Neutral (SongsToCheck): LLM odrzucał oczywisty spam (Negative), a przy sprytniejszych próbach - gdy w sieci nie było oficjalnego tekstu - bezpiecznie klasyfikował jako niepewne. Z poziomu aplikacji Flutter czyściliśmy bufor jednym kliknięciem.
Produkt: komunikacja dev → user
Uczniowie irytują się, gdy „ich piosenka nie leci”, a nie wiedzą dlaczego. Zamiast zostawiać ich w ciemności, dokładaliśmy lekkie moduły: Modules.Feedback oraz ogłoszenia.
Dev announcements - baner w Reactcie dla wszystkich: gdy Hangfire zsunął się z dzwonkiem albo serwer stał na hotfixie, komunikat w stylu: system wstrzymany, wracamy na następnej przerwie.
POST /feedback - prosty kanał zgłoszeń z poziomu apki, żeby czuli się częścią projektu.
Zbudowaliśmy system, ale to zderzenie z użytkownikami ukształtowało z niego prawdziwy produkt.
09 - Wnioski i sukcesja
Wnioski, sukces i ostatnie słowo
Większość szkolnych projektów umiera w dniu zakończenia roku. Smart Radiowęzeł działa do dziś. Przy końcu technikum zrobiliśmy pełny handover dla młodszych roczników profili informatycznych.
Przekazaliśmy dokumentację, repozytoria na GitHubie oraz dostęp do infrastruktury (m.in. Supabase z produkcją). Że inni uczniowie podnieśli dev environment i dalej utrzymują system - to dla mnie największy sukces inżynieryjny: budowa na tyle dojrzała, że przeżyła swoich autorów.
Czego nauczyły okopy - trzy twarde wnioski
01 - UX wygrywa z checklistą „enterprise security”. Autoryzacja musi pasować do kontekstu: odrzucenie Azure AD na rzecz 4-znakowych kodów uratowało adopcję. Najbezpieczniejszy system nie jest tym, którego „nie da się zhakować”, lecz tym, którego ludzie faktycznie używają.
02 - Pragmatyczna architektura. Modularny monolit przy ~400 użytkownikach na LAN: kilka DbContextów na jednej bazie dało czytelne granice bez narzutu armii mikroserwisów. Przy bare-metalu i jednym docker-compose.yml prostota wdrożenia jest krytyczna.
03 - AI jako Guardrail, nie zabawka. Zamiast robić z LLM centralnego bohatera UI (jak w moim GroupNote), tutaj model w Pythonie jest tarczą (sentyment, SongsToCheck) - oszczędność godzin moderacji. To jest sensowne „utility AI”.
Refleksja: GroupNote nauczyło mnie zaawansowanego kodu i chmury. Radiowęzeł nauczył dostarczania produktu: kod jest narzędziem do brudnego, fizycznego problemu - dzwonek, korytarz, głośniki.
W GroupNote ten sam modularny monolit sprzyjał scope creep - kolejne moduły dosypywały się prawie bez tarcia, a zamiast MVP powstała lista ficzerów przed pierwszym prawdziwym użytkownikiem (klasyczna pułapka „jeszcze jednego ficzera” i złudzenie progresu z samego kodowania). Tutaj ta sama idea jasnych granic w kodzie posłużyła odwrotnie: trzymaliśmy zakres, który dało się utrzymać operacyjnie w jednym stacku na szkolnym LAN.
Mały zestaw przed dzwonkiem
Zamiast armii ficzerów - to, co musiało przeżyć dzwonek: głosowanie, kolejka, odtwarzanie; AI Guardrail z buforem Neutral, a nie „idealny model od dnia zero”; auth bez Azure AD; OpenTelemetry + Aspire zamiast farmy Grafany na start.
„Jeszcze jedna rzecz” - tym razem z kontekstem
I tak były hotfixe: limity pól, ogłoszenia po migracji, feedback. Różnica: kolejna iteracja wynikała z realnego zachowania uczniów, nie z listy „fajnie byłoby”; priorytetem było „działa na następnej przerwie”, nie „kompletne na slajdzie”.
Utrzymanie na żywo kosztuje - ale kupuje coś, czego nie da się zasymulować w IDE: setki telefonów w tej samej sekundzie co dzwonek. I co najważniejsze i zamyka całą tą historię. To produkt który został wdrożony i realnie zyskał użytkowników, nawet więcej niż się spodziewaliśny. Ogromna frajda i doświadczenie.
Zamykam ten case study tam, gdzie GroupNote kończyła się na archiwum: przy wdrożeniu, które przetrwało po nas.