# 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.

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.

Wat ik heb laten vallen of uitgesteld:

- De structurele oplossing is een Spotify key met hogere quota. Dat is voor
  deze PoC-assessment niet proportioneel; ik bouw geen slimme workaround en
  live mode valt niet stil terug op fixtures.
