En lärarassistent för JavaScript som förklarar, inte löser. Välj nivå (nybörjare / student / utvecklare), ställ en fråga och få ett streamat svar grundat i MDN:s dokumentation — med källhänvisningar.
Chas u15 — Gruppuppgift: AI-baserad webbapplikation. Team: Henrik (orchestrator/infra) · Yasmin (data/RAG) · Fausto (AI/backend) · Ernest (frontend/UX)
- Next.js (App Router) + TypeScript + Tailwind, deploy på Vercel
- Vercel AI SDK — chat, streaming
- OpenRouter —
openai/gpt-4o-mini(chat) +openai/text-embedding-3-small(embeddings) - Supabase + pgvector — vektorlagring och semantisk sökning (RAG)
- Data: MDN Web Docs (CC-BY-SA 2.5) — kurerat urval av JavaScript-dokumentationen
Live på Vercel: https://chas-u15.vercel.app — auto-deploy från main.
pnpm install
cp .env.example .env.local # fyll i nycklar (delas i gruppens privata kanal)
pnpm devPlanen, arkitekturen och alla beslut finns i docs/PLAN.md. Läget just nu indexeras i STATE.md.
Vi jobbade i en orchestrator/session-modell med tre AI-verktyg parallellt (Claude Code, Codex och Cursor — Fausto bytte från Codex till Claude Code mellan wave 0 och 1). Henrik som main orchestrator skickade startprompts i början av varje wave; var och en startade en personlig orchestrator i sitt eget verktyg, som i sin tur gav prompts för en session per issue. En avslutad session lämnade en session-handoff till den personliga orchestratorn, och en avslutad wave en wave-handoff till Henrik (docs/handoffs/) — Henrik reconcilade, mergade och öppnade nästa wave.
Arbetet gick i 2-dagars waves med issues per person, styrda av sveprincipen (docs/PLAN.md §4): allt en person behöver för sina issues — delade nycklar, mergade kontrakt/stubbar, installerade paket — ska finnas innan waven öppnas, beroenden mellan personer läggs alltid på wave-gränsen (aldrig inuti en wave), och en öppnad PR blockerar aldrig arbetet — man pingar en namngiven reviewer och fortsätter direkt på nästa issue. Regeln kom av en lärdom från wave 0, där beroendekedjor mitt i waven serialiserade arbetet (docs/state-archive/wave-0.md).
Version 3 (wave 3, pågående). Besluten i docs/PLAN.md §3, lärdomarna i docs/state-archive/wave-0.md och API-kontraktet i docs/api-contract.md. Wave 2:s resultat är invävda nedan: retrievalfixen 6/10 → 10/10, prompt-designens alla fem iterationer (inklusive v4:s misslyckande med förbudslistor), modell-A/B-slutsatsen och integrationsbuggen med stackade PR:er. De fyra personliga AI-reflektionerna från wave 0–1 står kvar under "Vad var svårt?" — wave 2:s reflektioner (Yasmin, Ernest, Fausto) finns i docs/handoffs/ och vävs in i slutversionen.
RAG (retrieval-augmented generation) är kärntekniken. Kedjan ser ut så här:
- Ett kurerat urval av MDN:s dokumentation — 528 sidor JavaScript-guide,
referens och utvalda
web/api-sidor (docs/mdn-selection.md) — chunkas och embeddas medopenai/text-embedding-3-small(1536 dimensioner). - Vektorerna lagras i Supabase med pgvector, tabellen
documentsplus SQL-funktionenmatch_documentsför cosine-likhet (docs/db-schema.md). - När en fråga kommer in embeddas den med samma modell, de närmaste
chunkarna hämtas och skickas som kontext till
openai/gpt-4o-mini. - Svaret streamas tillbaka tillsammans med de MDN-sidor kontexten kom ifrån, som klickbara källchips.
Vercel AI SDK (v7) är limmet: streamText på servern, useChat på
klienten, och SDK:ns UI Message Stream v1 över Server-Sent Events som
wire-format. Källorna skickas som SDK:ns inbyggda source-url-händelser i
stället för ett eget dataformat — då dyker de upp som source-url-parts i
klienten utan att vi behöver komma överens om något extra, och de existerar
inte förrän texten är färdigstreamad, vilket är precis vad UI-skissen ville ha
(källor under ett färdigt svar, inte medan det skrivs).
Nivåanpassningen görs med en egen system-prompt per nivå (nybörjare /
student / utvecklare). level följer med varje anrop i request-bodyn och
valideras hårt — svenskt ord i UI:t, engelskt värde över nätet.
Hela kontraktet mellan frontend och backend skrevs som ett dokument innan någon av delarna byggdes (docs/api-contract.md), och en mock-route implementerade kontraktet i wave 0 så att UI:t kunde byggas mot ett riktigt streamande API innan RAG-routen fanns. Poängen: frontenden ska inte behöva ändras när mocken byts mot riktig RAG.
Vi använde också AI som utvecklingsverktyg genom hela projektet — tre verktyg parallellt (Claude Code, Codex och Cursor) i en orchestrator/session- modell där varje avslutad session lämnar en skriftlig handoff med konkret bevis (docs/handoffs/).
Den första ingestion-körningen (wave 1, fast chunkstorlek) skrev 1 738 chunks ur alla 528 sidor till
documents (radantalet verifierat oberoende via en egen REST-HEAD-fråga,
inte bara scriptets egen logg). Efter wave 2:s hybrid-chunkning är siffran 3 547 chunks. Retrieval-baseline låg på 6/10 rätt sida
i topp-3 över tio testfrågor (docs/retrieval-sanity.md)
— de fyra missarna är namngivna och verifierat att vara renodlade
retrieval-gap, inte täckningsluckor, och är wave 2:s startlista (#37).
Retrieval: 6/10 → 10/10 (wave 2). Två separata fixar, båda mätta mot exakt samma tio frågor för att vara jämförbara:
- Hybrid rubrik-chunkning (#37,
scripts/ingest.ts) —##-rubriker som primärregel i stället för fast chunkstorlek, med sidtiteln prependad i embeddingtexten. Löste ett av de fyra gapen (hoisting) rakt av, men de tre andra (let/const, map(), prototype) förbättrades bara till "jämnt lopp" — målsidan hamnade på rank 4, under 0,01 similarity bakom rank 3. En chunkningsstrategi kan inte ensam vinna marginaler så små. - Query-normalisering (#52,
lib/ai/retrieval.ts) tog resten: frågan expanderas till en hel mening före embedding (6/10 → 9/10 ensamt — en kort fråga som "hur fungerar map()?" ligger näraMap-objektet, en hel mening drar mot guide-/referenstext), och en jämförelsefråga som "let vs const" söks som hela frasen plus en sökning per begrepp, med träffarna varvade i stället för sorterade på similarity (9/10 → 10/10). Poängen med varvningen: sorterar man unionen tar det starkare begreppet (const) alla topp-3-platserna — exakt felet som gjorde attletaldrig syntes. Live-verifierat i produktion: closures, let+const ochArray.prototype.map()kommer nu överst.
Två strategier som såg lovande ut föll på mätningen i stället för på
magkänslan: engelska nyckelord istf svenska gav bara 6/10, och
sönderdelning av frågan utan varvning gav 5/10 och bröt dessutom == vs
===.
Besluten är dokumenterade som mini-ADR i docs/PLAN.md §3 och ändras bara med gruppbeslut:
| # | Beslut | Varför |
|---|---|---|
| 1 | MDN i stället för att skrapa W3Schools | Öppen licens (CC-BY-SA), ren Markdown i ett publikt repo, bättre innehåll. Skrapning hade varit både juridiskt tveksamt och tekniskt sämre. |
| 2 | OpenRouter för både chat och embeddings | En nyckel, en klient, en spending-limit att bevaka i ett publikt repo. |
| 3 | text-embedding-3-small, 1536 dim |
Billig och standard. Låst beslut: byter vi modell måste allt indexeras om, eftersom vektorerna inte är jämförbara mellan modeller. |
| 4 | gpt-4o-mini som default-chatmodell |
Billig, stabil, bra på svenska. Ligger som env-variabel CHAT_MODEL så vi kan A/B-testa modeller utan kodändring. |
| 5 | Supabase + pgvector | Gratis nivå som räcker, ger riktig SQL-erfarenhet, och likhetssökningen blir en vanlig SQL-funktion i stället för ännu en tjänst. |
| 6 | Assistenten förklarar, löser inte | Det är produktidén. En modell som skriver färdig kod finns redan överallt; en som vägrar och i stället lär ut är både mer användbar för en kursdeltagare och mer intressant att redovisa. |
| 7 | Text-chat är MVP, röst är stretch | 8 dagars tidslinje. Röst planeras tidigast wave 3, och bara om kärnresan är klar. |
Vercel AI SDK valdes för att streaming, källor och klientstate annars blir tre
egna problem: SDK:n har ett färdigt wire-format som useChat förstår utan
konfiguration, och source-url är en inbyggd del-typ. Next.js API-routes är
backenden — ingen separat server att drifta för en app som ska leva i åtta dagar.
Alternativet utan AI vore en söksida: nyckelordssökning mot MDN plus en FAQ. Det hade fungerat för "vad heter metoden som…", men inte för det appen faktiskt gör:
- Nivåanpassning. Samma fråga ska ge tre olika svar. En sökmotor har bara ett dokument att visa; MDN är dessutom skrivet på en enda nivå, ungefär vår "utvecklare"-nivå. Att skriva om innehållet för en nybörjare är en språkuppgift, och det är där en LLM faktiskt tillför något.
- Syntes över flera sidor. "Skillnaden mellan let och const" bor i två referenssidor plus ett guideavsnitt. Ett svar kräver att de vägs samman.
- Naturligt språk, och svenska. Frågan ställs som en fråga, inte som nyckelord, och besvaras på svenska trots att källorna är engelska.
- Semantisk träff i stället för ordträff. Embeddings hittar rätt sida även när användaren inte kan ordet — "funktion som minns variabler" leder till closures. Nyckelordssökning kräver att man redan kan svaret.
Var AI inte var rätt verktyg — lika viktigt för bedömningen:
- Urvalet av de 528 MDN-sidorna görs av ett regelbaserat skript, inte av en modell. Det ska vara reproducerbart och granskningsbart: ändras kriterierna kör man om skriptet, i stället för att lita på ett val ingen kan upprepa.
- Källvisningen är ren datavisning. Länkarna kommer från de chunkar retrievalen faktiskt hämtade, inte från modellens text — annars kan modellen hitta på en URL som ser rimlig ut.
- Retrievalen är vektormatematik i SQL, inte ett modellanrop.
- Validering, felkoder och streamformat är vanlig kod, hårt specificerad i kontraktet.
Just hallucinationsrisken är hela motivet till RAG: en naken LLM svarar självsäkert fel om JavaScript-detaljer, och en kursdeltagare kan inte se skillnaden. Med MDN-kontext plus synliga källor kan användaren kontrollera svaret på en klickning. Att modellen ändå kan ha fel är anledningen till att källorna aldrig är valfria.
AI-verktygens kunskap om AI-biblioteket var det största problemet. Vercel
AI SDK är på v7, men i stort sett all dokumentation, alla blogginlägg och alla
AI-genererade exempel man hittar beskriver v4 eller v5 — där meddelandetexten
ligger i message.content och streamformatet är ett annat. I v7 heter det
message.parts. Varje AI-verktyg vi använde föreslog v4/v5-kod med full
självsäkerhet. Motmedlet blev att skriva API-kontraktet ur avlästa typer i
ai@7.0.88 och verifiera stream-sekvensen genom SDK:ns egen
readUIMessageStream innan någon kod byggdes på den, samt att låsa exakta
versioner i STATE.md med en varning i kontraktets §0. Samma mönster
dök upp i Next.js 16, som numera själv skriver en AGENTS.md som pekar
AI-verktyg mot node_modules/next/dist/docs/ i stället för mot deras minne.
Lärdomen: när ramverket är nyare än modellens träningsdata är AI-verktyget
snabbast på fel svar, och tiden ska läggas på att göra rätt källa läsbar för
verktyget.
Att arbeta parallellt var svårare än att koda. Wave 0 gav tre konkreta lärdomar (docs/state-archive/wave-0.md): beroendekedjor mellan personer inuti en wave serialiserade arbetet (kontrakt → mock → UI), en Supabase-nyckel som inte delades vid wave-öppning stoppade en issue i två dagar, och reviews blev en flaskhals — noll reviews på ett dygn. Fixen blev "sveprincipen" i docs/PLAN.md §4: allt som behövs ska finnas innan waven öppnas, beroenden mellan personer läggs på wave-gränsen i stället för inuti waven, och en öppnad PR blockerar aldrig — man pingar en namngiven reviewer och fortsätter direkt.
Plattformsdetaljer kostade också tid: ett fullständigt klon av
mdn/content föll på Filename too long på Windows och fick göras om med
sparse checkout, och main är skyddad med PR-krav som bara syns som en varning
för den som har admin-bypass.
Fyra reflektioner ur docs/handoffs/, en per wave-handoff:
Yasmin (wave 0 + wave 1): Det mest lärorika mönstret var att sessionerna
byggde en vana av att verifiera faktiskt tillstånd i stället för att lita
på ett kommandos egen "success"-rapport — i wave 0 upptäcktes en
CREATE TABLE som Supabase SQL Editor tyst blockerat bakom en modal bara för
att sessionen körde select count(*) efteråt, och samma mönster upprepades
i wave 1 när #17:s radantal verifierades med en oberoende REST-HEAD-fråga i
stället för att lita på scriptets egen logg. Störst friktion gav
miljöbegränsningar utanför själva kodningen: gh CLI saknades i varje
session, så PR:er fick öppnas manuellt via länk i wave 0 — tills en wave
1-session löste det själv genom att återanvända git-autentiseringen
(git credential fill) mot GitHub:s REST API för att öppna PR:er, sätta
reviewers och posta ping-kommentarer, helt utan att exponera token i
loggar. Windows-specifika problem (path-längd vid full klon av
mdn/content) hittades och löstes av AI:n på egen hand.
Ernest: Cursor ritade snygga PNG-skisser i wave 0 men förstörde svensk
text, så SVG + kanonisk copy blev den riktiga sanningen i stället — en
påminnelse om att inte lita på genererad UI-copy. I wave 1 var
API-kontraktets §8 (useChat + DefaultChatTransport + parts) mer värt
än googlad AI SDK-dokumentation, som fortfarande beskriver v4/v5; när UI:t
byggdes strikt mot kontraktet passade mocken direkt, och den enda fällan var
att råka skicka "nybörjare" i stället för "beginner".
Fausto: Bytte verktyg från Codex till Claude Code mellan wave 0 och 1,
och lärdomen höll över båda: AI:n är svag när den svarar ur minnet — den
ville skriva API-kontraktet mot AI SDK v5, som var aktuell i dess
träningsdata, vilket hade spräckt Ernests useChat vid första
hopkopplingen — och stark när den läser det faktiska artefakten, som när
kontraktet i stället verifierades genom att installera SDK:n, läsa typerna
och köra streamformatet genom bibliotekets egen readUIMessageStream-parser.
Samma mönster upprepades i wave 1: instinkten sa att similarity-tröskeln
borde ligga runt 0,5, men Yasmins uppmätta data visade 0,22–0,58 — hade
instinkten fått styra hade appen visat noll källor på de flesta frågor.
Wave 1:s enda produktionsbugg fångades av smoke-testets pre-flight, inte av koden: alla sex env-variabler fanns till namnet i Vercels dashboard men hade tomma värden, vilket aldrig upptäcktes tidigare eftersom RAG-routen (#19) var den första deployade koden som faktiskt läste dem. Dokumenterat i docs/smoke-runs/wave-1.md: att en variabel finns i Production säger inget om att den har ett värde — checklistan kollar nu båda.
En andra produktionsbugg kom inte från ren kod utan från git-processen: när
två stackade PR:er (den ena byggd ovanpå den andra, båda mot main) mergades
med squash försvann COPY.inputPlaceholder — en konstant den andra PR:en
hade lagt till men den första inte kände till — och typecheck var trasig i
cirka tio minuter innan det upptäcktes och rättades
(7f8bb4a).
Orsaken: en stackad PR byggdes och
testades mot sin egen bas, inte mot main efter att den underliggande PR:en
redan mergats in. Lärdom, tillagd i
docs/PLAN.md §4s git-regler: stackade PR:er ska byggas om
mot main efter varje merge i kedjan, inte bara verifieras mot sin egen
utgångspunkt.
Nivåprompterna gick genom fem versioner innan de höll
(docs/prompt-design.md). v1 — sex rader inbakade
i routen — gav fyra synliga problem: nivåskillnaden var bara ordval,
developer förklarade grunderna ändå, vägran läckte lösningen som ett
numrerat recept, och irrelevant MDN-kontext citerades ändå. v2 — egen
modul, längdtak per nivå, hårdare vägransregel — fixade tre av fyra, men
developer öppnade fortfarande med en definition; en negativ regel om
innehåll ("förklara inte grunderna") räckte inte. v3:s enda ändring
var att i stället styra formen på första meningen: förbjudna
inledningsfraser som "X är en kombination av", och första meningen ska
handla om beteende eller fallgrop i stället för definition.
v4 — den misslyckade iterationen (wave 2). Hypotesen var att göra regeln
mekanisk: räkna upp exakt vilka formuleringar som är förbjudna ("är en",
"innebär att", "kombination av"…) och "nämn ALDRIG alla metoder i ordning"
för vägran. Resultatet blev sämre på alla mått — developer-definitioner
gick från 1/10 till 3/10, beginner över ordtaket från 0/10 till
5/10. Två lärdomar: att räkna upp förbjudna formuleringar ordagrant gör
dem mer närvarande i modellens svar, inte mindre, och regelmassan i sig
tränger undan andra regler den delar block med (längdtaket, som var orört,
slutade hållas i hälften av körningarna). v5 vände på greppet helt —
inga förbud, bara positiva mönster att följa (svara som en lärare vid en
whiteboard, fyra öppningsmallar för developer, konkretiserad längdregel
sist i blocket) — och tog developer-definitioner och beginner-ordtaket
till 0/20 vardera över tjugo varv.
Lärdomen som håller genom alla fem: när modellen har en stark stilmässig
vana slår en regel om form en regel om innehåll, och ett förbud
biter sämre än ett positivt mönster att följa. Den andra lärdomen var
metodologisk: v1–v3 mättes med en körning per ändring, vilket dolde variansen
helt — main-orchestratorns live-regression hittade v3-stilen tillbaka i
ungefär en av flera körningar. Därför finns nu
lib/ai/prompt-regression.mjs, som kör tio/tjugo varv per ändring i stället
för ett.
Tre modeller jämfördes med identiska v5-prompts, identisk retrieval och samma
fem frågor (docs/model-ab.md): openai/gpt-4o-mini,
anthropic/claude-haiku-4.5 och openai/gpt-5-mini. Beslutet blev att
behålla gpt-4o-mini: gpt-5-mini diskvalificerades på latens (17 s median
till första token — ohållbart i en streamande chatt), och Haiku, som var
pedagogiskt starkast, sprängde ordtaken och återinförde
definitionsinledningar — exakt de fel promptarbetet byggt bort. Jämförelsen
lärde oss också att inte mäta vägran enbart med regex (falsklarm rättades
efter manuell kontroll) och att en promptsvit tunad mot en modell gynnar just
den modellen i en jämförelse. Bytet är fortsatt en env-variabel —
Haiku tas upp igen om markdown-renderingen förändrar kalkylen.
Kunskapsinnehållet kommer från MDN Web Docs av Mozilla Contributors, licensierat under CC-BY-SA 2.5.