# Logboek Bijgehouden tijdens de bouw. Per stap: wat ik deed, waarom, wat ik heb laten vallen. ## Dag 1 - korte sessie in de avond ### Opzet Wat ik deed: - Afspraken vastgelegd in AGENTS.md (architectuur, naamgeving, wanneer commentaar, wat de linters afdwingen). - Backend-skelet: Python 3.13 + FastAPI via uv, mappenstructuur volgens ports-and-adapters (domain / ports / adapters / pipeline / api), config met alleen de app-mode (demo of live), health-endpoint met een smoke test. - Frontend-skelet: Vue 3 + TypeScript via Vite, met eslint, prettier en vue-tsc. - docker-compose die 1 image bouwt: frontend-build en API samen op dezelfde poort. De poort ligt vast omdat de geregistreerde Spotify redirect-URI moet kloppen. - CI op elke push: ruff, mypy, pytest voor de backend; typecheck, lint en build voor de frontend. Groen vanaf de eerste commit. - Repo op eigen Forgejo met een mirror naar GitHub. Waarom: - Structuur eerst, dan blijft elke volgende stap klein en controleerbaar. - `docker compose up` is het opleverbare, dus dat werkt vanaf het begin van development. - Alles Engels behalve dit logboek; ik volg de taal van de opdracht. Wat ik heb laten vallen of uitgesteld: - Domainmodellen, protocols, prompts, pipeline-instellingen en het frontend-typecontract bewust nog niet neergezet. Die ontstaan in de stap waar ze horen. Ik probeer op die manier bewust vroeg drift en dode code te voorkomen. - Geen apart beslisdocument. De motivering staat in de README en hier. ## Dag 2 ### Spotify-koppeling Wat ik deed: - Login via Spotify met PKCE en cookie-sessies: tokens blijven server-side, de browser krijgt alleen een opaque HttpOnly cookie. - Dunne async client op de Spotify Web API met per-endpoint retry policy: leesacties herhalen maximaal 1 keer en alleen binnen een grens (Retry-After), schrijfacties op playlists nooit. - Token-refresh is single-flight: parallelle requests delen 1 refresh in plaats van er allemaal zelf een te starten. - Mapping van Spotify-JSON naar eigen modellen op 1 plek; kapotte items vallen weg in plaats van dat ze de app breken. - Getypeerde errors en 12 transport- en routetests op een mock transport. - Na een eerste review de login-flow uit de routes getrokken naar een eigen module (routes zijn nu dunne passthroughs) en de retry policy herschreven naar een lineaire keten van losse regels in plaats van een loop met flags; foutdetails uit de Spotify-body worden meegenomen in de getypeerde errors en quota exhaustion (QUOTA_EXCEEDED) wordt apart herkend en nooit opnieuw geprobeerd. - Daarna het API-contract vastgelegd: request-schema en de gestreamde events (metadata / track / warning / error / done), gespiegeld in TypeScript. Waarom: - Schrijfacties blind herhalen kan dubbele playlist-items opleveren; dat risico sluit ik structureel uit in de retry policy. - Het contract eerst bevriezen maakt parallel werken aan frontend en pipeline mogelijk zonder elkaar te breken. Wat ik heb laten vallen of uitgesteld: - De naam-naar-ID cache en de port-interfaces doorgeschoven naar de pipeline-stap; daar bestaat het ontwerp pas echt. - OpenAPI-codegen voor het contract overwogen en afgewezen: de kern van dit contract is de event-stream en die modelleert OpenAPI niet. ### Pipeline Wat ik deed: - Domeinbasis: titel/artiest-matching (normalisatie, met tolerantie voor Spotify's versie-suffixen zoals "- Remaster 2023"), compressie van het Spotify taste profile naar prompttekst plus een set bekende track-ids, prompts als data in een eigen module, en alle instelbare waarden gesectioneerd in de config met per waarde het waarom. - Twee LLM-calls achter een eigen interface: call 1 interpreteert de vraag (mood, activity, language, familiarity) en stelt 30-40 echte nummers voor als gestructureerde output; call 2 herordent uitsluitend geverifieerde nummers en streamt per nummer een eerlijke justification. Elke output wordt gevalideerd, met hooguit 1 correctie-retry. - Grounding: begrensde parallelle search fan-out met early stop, een deadline, een naam-naar-id cache en twee aparte metrieken: niet gevonden versus wel gevonden maar afgekeurd door de match-check. Een track-id dat niet in de geverifieerde pool zit kan nooit bij de gebruiker terechtkomen. - Na review de orkestratie herschreven: de stream-functie leest nu als de pipeline-stappen zelf, en de selectie-logica (alleen pool-ids, geen duplicaten, begrensd aantal, ranking) zit in 1 kleine klasse die zowel het normale pad als de fallback bedient. Waarom: - De LLM is hier de recommender, maar mag alleen creatief zijn tussen twee deterministische muren: alles wat hij ziet is echte data, alles wat de gebruiker ziet is geverifieerd op Spotify. Een verzonnen nummer valt stilletjes af en verschijnt nooit. - Zoeken geeft maximaal 10 resultaten per call, dus resolutie is per definitie een fan-out; liever een kandidaat laten vallen dan het verkeerde nummer aanbevelen. Wat ik heb laten vallen of uitgesteld: - Refinements doen geen nieuwe search-ronde: turn 2 herordent de bestaande geverifieerde pool. Sneller en consistent, maar een refinement haalt geen nieuwe nummers op. Dit is een bewuste afweging, mocht er tijd over zijn is dit 1 van de uitbreidingen die ik op zou kunnen pakken. ### Streaming API Wat ik deed: - De pipeline als streamend endpoint: POST /api/recommendations levert NDJSON events (metadata / track / warning / error / done); een client-disconnect stopt de stream en de grounding. - Playlist-endpoint dat schrijft met een vaste naam-prefix, zodat aangemaakte playlists later in bulk op te ruimen zijn (ik gebruik mijn persoonlijke spotify account/abonnement voor de demo). - Request-timing middleware met per-request counters (Spotify calls, cache hits, LLM tokens) in gestructureerde logs. - Seed session voor gehost draaien: in live mode installeert een refresh token bij startup een sessie, zodat een publieke instantie werkt zonder interactieve login. Waarom: - Streamen maakt de wachttijd eerlijk: de eerste kaart telt. Een afgebroken request mag geen werk laten doorlopen. ### Review-fixes en de account-quota Wat ik deed: - Twee fixes uit de live-test: gebruikers-id key op het stabiele account_id veld (met id als fallback), en de intent-call accepteert nu een afwijkend kandidaten-aantal in plaats van hard te falen als het model er 34 in plaats van 35 teruggeeft. - Tijdens het opnemen van demo-fixtures de dagelijkse development-quota van de Spotify-app geraakt: honderden searches in enkele minuten, daarna QUOTA_EXCEEDED met een Retry-After van bijna 7 uur. Het systeem degradeerde zoals ontworpen: een eerlijke foutmelding, geen stille fallback naar verzonnen resultaten. Waarom: - De quota is per developer-account en per dag; bulk-werk zoals fixtures opnemen moet dus gebudgetteerd, en het cache-ontwerp (naam-naar-id, smaakprofiel) is noodzakelijk om binnen de quota te blijven. ### Meer live-test fixes Wat ik deed: - Matching accepteert nu ook nummers die alleen als release met een versie-suffix bestaan ("Immunity - Remaster 2023"), met een lichte voorkeur voor de originele release bij gelijke score; de live-test liet zien dat echte nummers hierdoor onterecht afvielen. - Mislukte rerank-pogingen worden met reden gelogd, en de token-ceilings van beide LLM-calls zijn verhoogd: adaptive thinking telt mee in max_tokens en kon de gestructureerde output afkappen. - Per unresolved kandidaat wordt titel, artiest en status gelogd, zodat zichtbaar is WAT het model verzon in plaats van alleen hoeveel. ### Eerlijke partial results Wat ik deed: - Onder de grounding-floor geeft de pipeline nu een warning plus de nummers die wel geverifieerd zijn, in plaats van een lege foutmelding; alleen een volledig lege pool is nog een terminal error. Waarom: - Vijf goede, geverifieerde nummers zijn een bruikbaar antwoord; een foutmelding die echte resultaten verbergt is dat niet. Bij "verras me met iets nieuws" vragen valt een groot deel van de kandidaten af bij de verificatie, dus juist daar telt dit. Een weg om dit potentieel te voorkomen/verbeteren in de toekomst is het verbeteren van de prompt, of de LLM met behulp van een derde partij API die zonder de Spotify API te overbelasten gebruikt kan worden om echte nummers te vinden. ### Frontend: fundament Voor de frontend heb ik gedurende de eerste paar uur op de achtergrond Open Design (een design-tool) laten lopen. Ik ben geen visual designer, maar op deze manier lukt het mij om met minimale effort een geschikt UI bouwpakket te ontwikkelen. De oplevering heeft zo'n vorm dat ik het gemakkelijk door een cli agent kan laten uitbouwen in Vue. Wat ik deed: - Design tokens uit het ontwerppakket (donker thema, typografie, spacing, motion) als basis voor alle componenten. - Typed stream client: fetch met een ReadableStream, regelgebaseerde NDJSON-parsing naar de contract-events, en AbortController-cancellation zodra een nieuwe vraag start. - API client voor sessie-status, logout en playlist-save. Waarom: - De frontend is de tweede consument van het bevroren contract: elke event wordt tegen de TypeScript-union gevalideerd in plaats van los geparst; wat niet valideert is een transport failure, geen gok. ### Frontend: componenten en app shell Dit deel is grotendeels geautomatiseerde omzetting: het UI bouwpakket uit Open Design heb ik door een cli agent laten converteren naar Vue single file components tegen het bevroren contract. De transportlaag eronder (stream client, validatie) komt uit de vorige stap. Review- en hardeningpassen op dit resultaat volgen hierna als eigen stappen; wat hier staat is de ruwe conversie. Wat ik deed: - Alle componenten uit het bouwpakket laten omzetten: chat view, composer met suggestion chips, streamende track cards met album covers en een justification per nummer, warning banners, error states, login-status, mode banner en een dev panel. - App shell met keyboard-navigatie, focus management en reduced-motion support. Waarom: - Het bouwpakket was hierop ontworpen; de componenten volgen de tokens en states daaruit, en elke regel gaat alsnog door review voordat die op main landt. - Styling is scoped CSS per component bovenop globale design tokens (custom properties). Scoped omdat machine-geconverteerde componenten om style leakage te voorkomen, en omdat het bouwpakket al gewone CSS per component leverde: een utility framework had een rewrite plus een extra dependency gekost. De tokens houden het thema op 1 plek, dus het bekende scoped-CSS risico (waarden die per component uiteenlopen) is afgedekt. Wat ik heb laten vallen of uitgesteld: - Een lijst met eerdere chats. Gesprekken leven bewust alleen client-side en de server bewaart niets; een history-lijst kan later contract-schoon via localStorage, zonder server-state. Potentiele extra als er tijd over is, geen onderdeel van de kern. ### Frontend: review-pass op de conversie De findings hieronder komen uit twee richtingen: een adversarial review die ik op de conversie heb laten draaien, en mijn eigen review van de code. Beide sets zijn in dezelfde pass verwerkt. Wat ik deed: - Uit de adversarial review. De grootste finding: API-strings gingen ongefilterd een href in (URL-injectie mogelijk!); links worden nu alleen gezet voor https-URLs op open.spotify.com. - Verder: een expliciete phase machine op de event-stream (metadata, dan tracks/warnings, dan precies 1 keer done of error; al het andere is een transport failure), een race gefixt waarbij een snelle nieuwe vraag een afgeronde beurt als cancelled kon markeren, response-body cancellation op parse-fouten, query-invoer begrensd op de request-limiet, warnings in een live region voor screenreaders, focus-herstel van het dev panel, en contrast op WCAG AA gebracht. - Een vitest-suite toegevoegd (26 tests) die de NDJSON-parser op gefragmenteerde chunks, error-terminaliteit, de request-caps en abort-races vastlegt; draait mee in CI. - Alle user-facing tekst uit de componenten geextraheerd naar 1 getypeerde messages-module (i18n-klaar zonder er nu een framework voor mee te nemen), alle magic numbers vervangen door benoemde constants met per waarde een reden (de request-caps verwijzen expliciet naar de backend schema-bounds), en dichtgeschreven TypeScript herschreven naar leesbare code zonder gedragsverandering. Waarom: - Machine-geconverteerde UI-code krijgt dezelfde behandeling als de backend: een review-pass met concrete findings en een test-suite die de contractkritische randen vastzet, voordat het richting main gaat. ## Dag 3 - avond ### Live eval run Wat ik deed: - Alle 8 scenario's in 1 run live gedraaid, plus de baseline arm (bare search) voor de comparison table. - Het Spotify-dagquotum was eerder al een keer vol; daarom vooraf maatregelen genomen: scenario's sequentieel, geen retries, de server-boot zelf als quota-check, en live meegekeken op 429's (niet gehit). - Uitkomst: 4 scenario's pass, 3 vallen alleen op een te strenge synonym-check in de eval zelf (gefixt), en discover-new faalt echt: call 1 verzint bij familiarity=new titels die niet bestaan, dus er overleeft niets de grounding. Fix volgt in de intent prompt. - De refinement turn deed live 0 Spotify calls: pool reuse werkt. Waarom: - De comparison table in de README wil ik op echte metingen baseren, en het quotum maakt herhalen duur. Wat ik heb laten vallen of uitgesteld: - discover-new opnieuw opnemen wacht op de prompt fix. ### Frontend: presentatie Wat ik deed: - Cards komen rustig gestaggerd binnen (met reduced-motion pad), results en chips scrollen in eigen panelen zodat de summary zichtbaar blijft, klik op het logo start een nieuwe chat, fonts self-hosted. - Een error die voor de metadata binnenkomt is nu een echte foutmelding in de chat, geen transport failure. Playlist-namen hadden een dubbele prefix; de frontend stuurt nu de kale query. 10 nieuwe tests. Waarom: - Dit is wat de reviewer als eerste ziet; rustige presentatie en eerlijke foutmeldingen gaan voor extra features. Wat ik heb laten vallen of uitgesteld: - Verdere styling; de tijd gaat naar eval, demo mode en de README. ### Backend hardening Wat ik deed: - Een hardening pass over de backend randgevallen, deels gevonden via een adversarial review: fouten per candidate ingedamd zodat 1 kapotte candidate nooit de hele request breekt, quota exhaustion apart herkend van gewone 429's, token refresh single-flight per sessie-generatie, typed errors voor de intent stap, en de seed-sessie kan de boot niet meer laten crashen. - De request deadline start nu bij binnenkomst van de request en dekt alles tot en met grounding. De gestreamde rerank heeft bewust een eigen timeout: een totaalbudget zou een gezonde stream halverwege afkappen. - Concurrency op de grounding fan-out is nu process-wide begrensd in plaats van per request. Waarom: - Dit zijn precies de randgevallen die je in een demo niet wilt zien; ze nu dichtzetten is goedkoper dan er straks 1 in een review tegenkomen. Wat ik heb laten vallen of uitgesteld: - Een totaalbudget over de hele request heen; de afweging staat hierboven en komt ook in de README. ### Discover-new gefixt in de intent prompt Wat ik deed: - Het discover-new scenario faalde live steeds op de grounding: call 1 verzon voor echte, goed gekozen artiesten generieke titels die niet bestaan ("Vibes", "Drift", "Shine"), en de matcher liet terecht niets door. Niet de matcher aangepast maar de prompt: bij familiarity=new alleen signature tracks en scene-anthems noemen die het model met zekerheid kan spellen; nieuw-voor-de-luisteraar wordt toch al afgedwongen door de exact-ID exclusion na grounding. - Live gevalideerd: het scenario levert nu 15 tracks van 16 artiesten. Van de 32 kandidaten vielen er 12 als verzinsel af en resolvede de rest; drop-over-substitute deed precies wat het moet doen. Waarom: - Titel-recall van een LLM stort in zodra je bewust om onbekend werk vraagt; om obscuriteit vragen is dan het verkeerde gereedschap. Nieuw en obscuur zijn geen synoniemen. Wat ik heb laten vallen of uitgesteld: - Een repair loop die gefaalde kandidaten opnieuw aan het model voorlegt; blijft staan als potentiele vervolgstap. ### Evaluatie en fixtures Wat ik deed: - Een eval-script dat de app de 8 standaardvragen stelt en de antwoorden controleert: komen de events in de juiste volgorde, zijn alle tracks uniek, heeft elke track een reden, kwam de eerste kaart binnen het tijdsbudget, en gaat het antwoord echt over wat er gevraagd werd. - Dezelfde 8 vragen ook aan de kale Spotify search gesteld; de vergelijking tussen die twee wordt de tabel in de README. - Elk scenario 1 keer opgenomen tegen de echte API's en opgeslagen als cassettes; demo mode speelt die af, dus de app werkt straks ook zonder keys. Persoonlijke data gaat er bij het opnemen uit (nepprofiel, geen tokens); de ruwe opnames blijven buiten git. - Dit draait ook in CI, zonder keys. Waarom: - De claim dat de LLM-laag iets toevoegt moet meetbaar zijn, niet beweerd. En de reviewer moet de app kunnen starten zonder eigen keys. Wat ik heb laten vallen of uitgesteld: - Byte-snapshots per pipeline-stap; de checks hierboven en de cassettes zijn nu het bewijs. ## Dag 3 - afronding ### Demo mode Wat ik deed: - Demo mode af: zonder keys speelt de app de opgenomen cassettes af door dezelfde pipeline en hetzelfde streamingpad als live. Onbekende vragen krijgen het dichtstbijzijnde scenario, met een banner die dat eerlijk benoemt; playlist-acties zijn gesimuleerd en zo gelabeld. - Een eerste versie deelde replay-state tussen gelijktijdige requests; dat kon elkaars refinement verstoren. Nu krijgt elke request zijn eigen replay-pool, met een test die de botsing naspeelt. - Het docker image bestaat nu in twee smaken: live zonder fixtures, demo met. docker compose bouwt standaard de demo-smaak, dus een verse clone zonder keys werkt direct. Een .dockerignore bracht de build context van ~350 MB terug naar ~7 MB. Waarom: - De reviewer moet de app kunnen starten met alleen docker compose, zonder accounts of keys; demo mode is ook het bewijs dat de ports-and-adapters opzet echt is (dezelfde poorten, andere adapter). Wat ik heb laten vallen of uitgesteld: - Meer demo-scenario's; de 8 opgenomen zijn de suggestion chips en dat is genoeg voor het verhaal. ### Opruimen Wat ik deed: - De suggestion chips terug naar de wrapped look; de scrollbare box uit de vorige batch stond lelijker dan wat er eerst was. - Ongebruikte OAuth scopes weggehaald (player en recently-played; nergens in de code gebruikt), .env.example compleet gemaakt en AGENTS.md gelijkgetrokken met hoe de repo er echt uitziet. Waarom: - Minder scopes vragen dan je gebruikt is netter richting de reviewer en richting Spotify. ### Documentatie Wat ik deed: - De README geschreven: wat het is, drie manieren om het te draaien, hoe de pipeline werkt, de keuzes, eerlijke performance-cijfers (eerste kaart 20 tot 30 s, gemeten via de UI en de eval), een stukje van de vergelijking met kale search, en een "where to look" tabel per beoordelingscriterium. - docs/workflow.md toegevoegd: hoe ik werk. De server, Forgejo met een GitHub-mirror, Komodo en Caddy voor de gehoste instantie, en hoe ik agents inzet: implementatie parallel en goedkoop, ontwerp, review en verificatie serieel en bij mij. - De gehoste preview bouwt nu bij elke merge opnieuw vanaf main. Waarom: - De reviewer leest de README het eerst; die moet kort zijn en naar de rest wijzen in plaats van alles zelf te vertellen. En de werkwijze uitleggen is eerlijker dan hem laten raden waarom commits in bursts binnenkomen. Wat ik heb laten vallen of uitgesteld: - Een sneller model op de intent call (zou de wachttijd flink verlagen; de fabrication rate is de afweging om dan te meten) staat als vervolg in de README, niet gebouwd. ### Afronding en de laatste live-test Wat ik deed: - Laatste ronde live testen op de gehoste instantie voor het insturen. Twee dingen gevonden: een vraag waarbij call 1 zo lang nadacht dat de request deadline al om was voordat grounding begon (0 resultaten, 0 Spotify calls), en opnieuw QUOTA_EXCEEDED 429's omdat het dagquotum van gisteren nog meetelde in het huidige quota-venster. - De deadline via env verhoogd naar 60 s en de cache-TTL's op de gehoste instantie opgerekt (resolutie 24 h, taste profile 1 h) zodat het quotum langer meegaat; een structurele fix (een gegarandeerd minimum tijdsbudget voor grounding, los van wat call 1 opmaakt) staat in de README als vervolgstap. - De card-animatie versneld: elke kaart wachtte nog index maal 80 ms na binnenkomst voordat hij verscheen, bovenop het tempo van de stream zelf. De vertraging is weg; de stream is nu zelf de cadans. De resultatenlijst toont nu ongeveer 5,5 kaarten zodat zichtbaar is dat er meer te scrollen valt. - Insturen gepland na de quota-reset vanavond. Waarom: - Een reviewer die de live instantie opent moet niet als eerste een uitgeput quotum zien; de reset bepaalt dus het moment van insturen. ### Demo escape hatch Wat ik deed: - De gehoste live instantie heeft er een demo-tweeling naast gekregen (zelfde app, fixture replay, zelfde login). De UI toont een demo-link in de header en in elke foutmelding, aangestuurd door 1 optionele env var die het health endpoint doorgeeft; zonder die var is er niets te zien. Waarom: - Als het Spotify-dagquotum op is moet een reviewer altijd nog een werkende versie 1 klik verderop hebben. Een runtime mode-switch in de app zelf zou een derde codepad zijn; twee instanties met elk 1 mode houden het ontwerp schoon. ### Final polishing round Wat ik deed: - De laatste live-test gaf op de Spotify Search endpoint opnieuw `QUOTA_EXCEEDED`, terwijl auth en de taste-profile calls wel werkten. De pipeline toont dit nu als een `quota_exhausted` error in plaats van als nul resultaten; de retry button verdwijnt en de demo-link blijft staan. - De eerdere aanname van een dagelijks resetmoment gecorrigeerd. Spotify deelt Development Mode quota per developer-account en endpoint bucket, maar publiceert de limieten en het reset window niet. - De gecontroleerde live-test hield door een slechte candidate call maar 1 van 35 tracks over. Voor `mix` beginnen nu 15 candidates als exact gekopieerde taste-profile anchors; dezelfde test gaf daarna 17 grounded tracks, 15 cards en een werkende playlist write. - `.env.example` copy-safe gemaakt voor local live mode: een geldige demo default, de exacte loopback redirect URI en secure cookies uit op HTTP. - De gehoste seed session toont geen logout meer: die gedeelde identity wordt bij startup gezet en heeft geen interactieve login flow. De demo-link in de header gebruikt nu dezelfde button style als Dev. Waarom: - Een quota error is geen geldige zoekopdracht met nul resultaten. Door deze rate-limit issues lever ik op 12 augustus in: eerst één gecontroleerde live-test, daarna direct de hand-in. - Grounding moet hallucinations tegenhouden, maar met genoeg echte tracks in het taste profile mag één slechte LLM call niet eindigen in één card. Wat ik heb laten vallen of uitgesteld: - De structurele oplossing voor quota is een Spotify key met hogere quota. Dat is voor deze PoC-assessment niet proportioneel; live mode valt niet stil terug op fixtures. - Geen bounded retry na een dunne pool: die kon de 75 Spotify calls verdubbelen en voegde een nieuwe control flow toe. De prompt gebruikt eerst de data die al in het taste profile staat.