BLACKBOOK

Jeg gjorde min portfolio til en samtale-AI. Sådan er arkitekturen.

14. juni 2026

Forsiden på blackbook.dk — 'Making AI work'-sektionen med ekspertiseområder

Alle taler om AI. Jeg byggede et produkt. blackbook.dk er en designportfolio, hvor den primære grænseflade er en samtale — en AI-agent, der kender hvert projekt, hver kompetence og hver kunde, og finder dem ud fra betydning, ikke søgeord. Under motorhjelmen er det ét indholdslag, der fodrer tre output: det visuelle site, en schema.org-vidensgraf og AI-agenten. Ingen duplikering, ingen manuel synkronisering.

Sådan fungerer det.

Kerneideen: indhold som strukturerede data

Hvert projekt, hver kompetence, kunde og rolle findes én gang — som struktureret indhold i Sanity CMS. Et projekt er ikke en side; det er et dokument med typede relationer til kunder, kategorier (kompetencer), ekspertiseområder og erfaringsposter.

// Sanity project schema — simplified
defineField({
  name: 'client',
  type: 'reference',
  to: [{ type: 'clientLogo' }],
}),
defineField({
  name: 'areas',
  type: 'array',
  of: [{ type: 'reference', to: [{ type: 'portfolioArea' }] }],
}),
defineField({
  name: 'categories',
  type: 'array',
  of: [{
    type: 'object',
    fields: [
      { name: 'category', type: 'reference', to: [{ type: 'category' }] },
      { name: 'isPrimary', type: 'boolean' },
    ],
  }],
}),

Én indholdsmodel. Tre forbrugere: den visuelle grænseflade renderer det som sider, vidensgrafen serialiserer det som JSON-LD, og AI-agenten forespørger det, mens samtalen foregår. Ret et projekt ét sted — det opdateres alle steder.

Projektside for Valiyou.io renderet fra struktureret Sanity-indhold

Søgning på betydning, ikke søgeord

Det her er den del, der overraskede mig mest. Når nogen beder agenten "vis mig pharma-arbejde", leder den ikke efter ordet "pharma" i titlerne. Den bruger Sanitys indbyggede semantiske similarity — vektor-embeddings beregnet server-side, forespurgt med ét enkelt GROQ-udtryk:

*[_type == "project" && hidden != true]
  | score(text::semanticSimilarity($q))
  | order(_score desc)[0...12] {
    _score,
    "titleEn": title.en,
    "slug": slug.current,
    "clientName": client->name,
    featured,
    "categories": categories[0..5]{
      "en": category->title.en
    }
  }

Brugerens fulde spørgsmål sendes med som $q. Sanity returnerer de 12 bedste projekter rangeret efter semantisk lighed, hver med en _score. Resultater under en tærskel på 0.05 filtreres fra.

Ingen Pinecone. Ingen Weaviate. Ingen pgvector. Sanitys eget embedding-indeks håndterer vektorsøgningen. Hele opslaget er én GROQ-query med en scoringsfunktion.

Når semantisk søgning intet returnerer (sjældent, men muligt), falder systemet tilbage på keyword-matching — en simpel scoringsfunktion, der tæller hvor mange udtrukne nøgleord der optræder i hvert projekts titel, tagline, kategorier og SEO-felter:

function scoreProject(p: Project, keywords: string[]): number {
  const hay = [
    p.titleEn, p.titleDa, p.taglineEn, p.taglineDa,
    p.seoEn, p.seoDa, p.clientName, cats,
  ].join(' ').toLowerCase()
  return keywords.filter(k => hay.includes(k)).length
}

Semantik først, keywords som fallback. Agenten logger, hvilken metode der blev brugt ved hvert svar.

Kompetencesiden med AI-agenten, der svarer på 'Who is Jeppe Sloth Carlsen?'

Én model, tre output

Det samme Sanity-indhold, som AI'en forespørger, genererer også en schema.org-vidensgraf — 32 entitetstyper fordelt over sitet. Hver kompetence linker til Wikidata, og hvert projekt er et CreativeWork med typede relationer:

{
  "@type": "CreativeWork",
  "@id": "https://www.blackbook.dk/en/portfolio/danske-bank/#work",
  "name": "White-Label Banking Solution — Danske Bank",
  "creator": { "@id": "https://www.blackbook.dk/#person-jeppe" },
  "sourceOrganization": {
    "@type": "Organization",
    "name": "Danske Bank",
    "sameAs": ["https://en.wikipedia.org/wiki/Danske_Bank",
               "https://www.wikidata.org/wiki/Q1636974"]
  },
  "about": [
    { "@type": "Thing", "name": "UX & Product Design",
      "sameAs": ["https://www.wikidata.org/wiki/Q11248500"] },
    { "@type": "Thing", "name": "FinTech",
      "sameAs": ["https://www.wikidata.org/wiki/Q16319025"] }
  ]
}

Organization-entiteten bærer et kurateret knowsAbout — 12 emner, der definerer hvad studiet laver, hver især linket til Wikidata. Person-entiteten bærer 94 kompetencer (biografiske). To adskilte sæt: kommerciel positionering vs. individuel ekspertise, forbundet via founder / worksFor.

Google, ChatGPT og Perplexity kan alle læse grafen. Når nogen spørger en AI-assistent "hvem laver fintech-UX i København", er det de strukturerede data, der gør svaret muligt — ikke sideteksten.

Portfoliosiden med Talk to My Agent-panelet og foreslåede spørgsmål

Den svære del: at gøre site og samtale til ét system

Den ærlige udfordring var ikke at bygge en AI-chat. Det var at gøre sitet og samtalen til ét system.

AI-agenten kører på Claude Haiku. Dens kontekst samles ved hver request fra tre kilder — alle fra det samme Sanity-datasæt, som sitet renderer fra:

// Same Sanity content, three context blocks
const portfolioContext = await fetchPortfolioContext(messages)
const servicesContext  = await getServicesContext(lang)

const dynamicBlock = langInstruction
  + (servicesContext ? '\n\n' + servicesContext : '')
  + (portfolioContext ? '\n\n' + portfolioContext : '')

fetchPortfolioContext forespørger de samme project-dokumenter, som portfolio-siderne renderer. getServicesContext læser de samme category-beskrivelser, som vises på service-siderne. Der findes ikke noget separat "AI-indhold" — agenten læser sitet.

Det betyder, at når jeg opdaterer en projektbeskrivelse, tilføjer en ny kompetenceside eller ændrer et kundenavn, samler AI-agenten det op i den næste samtale. Ingen eksport, ingen embedding-pipeline, ingen manuel synkronisering. Én kilde til sandhed.

Prisen: agentens viden er afgrænset af indholdsmodellen. Den kan ikke vide ting, der ikke er struktureret i Sanity. Det er en feature, ikke en bug — det holder agenten ærlig og indholdet autoritativt.

En note om motion

Sitet har et håndbygget motion-lag — ingen template, intet plugin, intet animationsbibliotek.

Sideovergange bruger et cirkelgrid — et 15×17 CSS-grid af cirkulære <div>-elementer, der skalerer fra centrum og udad, drevet af scroll-progress eller klik. Hver celle har en tærskel baseret på dens afstand til centrum:

// Each cell scales based on how far past its threshold the progress is
const cp = Math.min(1, Math.max(0,
  (progress * (1 + SPREAD) - cell.threshold) / SPREAD
))
cell.el.style.transform = cp <= 0
  ? 'scale(0)'
  : `scale(${cp * GRID_SCALE})`

På projektsider er overgangen scroll-drevet — brugeren styrer afsløringen ved at scrolle forbi bunden. Samme komponent håndterer klik-baserede sideovergange, billedafsløringer (iris-mønster, cirkler der skrumper og afslører billedet) og den scroll-drevne VennDiagram-animation på about-siden. Én genbrugelig komponent, fire anvendelser.

Projekt-tiles bruger en fysikbaseret evade-effekt — tiles deformeres og fjedrer væk fra cursoren via mesh-segmentering og spring damping. WebGL bruges kun til hero-billedernes displacement-overgange på forsiden. Alt andet er CSS-transforms ved 60 fps.

Hvad jeg ville gøre anderledes

Semantisk søgning skulle have været udgangspunktet, ikke en tilføjelse. Jeg byggede keyword-scoring først og tilføjede semantik bagefter. Set i bakspejlet er Sanitys text::semanticSimilarity() så enkel at implementere (én GROQ-funktion), at den skulle have været den eneste retrieval-metode fra dag ét, med keywords som nød-fallback.

Schema.org-grafen tog længere tid end forventet. 32 entitetstyper, tosproget, med Wikidata-krydsreferencer — det virker, men validerings- og debug-cyklussen er langsom. Skulle jeg gøre det igen, ville jeg bygge en testsuite, der validerer hver sides JSON-LD mod schema.org ved hvert deploy.

Motion-laget er det værd, men dyrt. Håndbyggede overgange betyder håndvedligeholdte overgange. Hver ny sidetype kræver scroll-håndtering, touch-håndtering og iOS-edge-cases. Et framework som Framer Motion ville have sparet tid — men ville ikke have givet mig scroll-drevne cirkelgrids.

Interaktiv vidensgraf på blackbook.dk bygget på det samme Sanity-datasæt

Prøv den selv

Åbn blackbook.dk, klik på orb'en nederst til højre, og spørg den om hvad som helst. Spørg om fintech, spørg om en bestemt kunde, eller bed den overraske dig.

Eller udforsk vidensgrafen direkte: blackbook.dk/cv — det er de samme data, visualiseret som et force-directed netværk.

Kilden til sandhed er ét Sanity-datasæt. AI'en, sitet og vidensgrafen er tre visninger af det samme indhold. Det er arkitekturen.

Bygget med Next.js 16, Sanity, Claude Haiku og alt for mange requestAnimationFrame-callbacks.