diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..49782fb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + paths: ['src/**', '.github/workflows/ci.yml'] + push: + branches: [main] + paths: ['src/**', '.github/workflows/ci.yml'] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + working-directory: src + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: src/package-lock.json + - run: npm ci + - run: npm run format:check + - run: npm run typecheck + - run: npm test + - run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b45d75 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.env +.env.* +!.env.example +*.local +.DS_Store +*.log +mise.local.toml diff --git a/README.md b/README.md index 9c84e8b..7955bc9 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ | Mappe | Innhold | | --- | --- | +| [`src/`](src/) | Applikasjonen: frontend, server og kontrakt, bygd på `digdir-headless-rag`. Se [`src/README.md`](src/README.md). | | [`docs/`](docs/) | Dokumentasjon av designarbeidet. | | [`evals/`](evals/) | Evalueringer og golden questions. | diff --git a/src/.dockerignore b/src/.dockerignore new file mode 100644 index 0000000..f8c89fd --- /dev/null +++ b/src/.dockerignore @@ -0,0 +1,12 @@ +node_modules +**/node_modules +**/dist +.git +.github +docs +decisions +**/*.test.ts +**/*.test.tsx +**/.env +**/.env.* +!**/.env.example diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..f15441a --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/src/.npmrc b/src/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/src/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/src/.prettierignore b/src/.prettierignore new file mode 100644 index 0000000..787d9f4 --- /dev/null +++ b/src/.prettierignore @@ -0,0 +1,4 @@ +dist/ +package-lock.json +docs/fixtures/*.sse +docs/backend-patches/*.patch diff --git a/src/.prettierrc.json b/src/.prettierrc.json new file mode 100644 index 0000000..0fe63e2 --- /dev/null +++ b/src/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "printWidth": 96, + "trailingComma": "all" +} diff --git a/src/Dockerfile b/src/Dockerfile new file mode 100644 index 0000000..f9902b9 --- /dev/null +++ b/src/Dockerfile @@ -0,0 +1,35 @@ +# Node 22.18+ runs the server's TypeScript directly, so only the SPA is built. +FROM node:22.23.2-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +COPY packages/contract/package.json packages/contract/ +COPY apps/server/package.json apps/server/ +COPY apps/web/package.json apps/web/ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22.23.2-alpine AS runtime +ENV NODE_ENV=production +WORKDIR /app +COPY package.json package-lock.json ./ +COPY packages/contract/package.json packages/contract/ +COPY apps/server/package.json apps/server/ +COPY apps/web/package.json apps/web/ +RUN npm ci --omit=dev --workspace apps/server --include-workspace-root \ + && npm cache clean --force +COPY packages/contract/src packages/contract/src +COPY apps/server/src apps/server/src +COPY --from=build /app/apps/web/dist apps/web/dist + +ENV WEB_ROOT=./apps/web/dist +ENV PORT=8787 +EXPOSE 8787 + +RUN addgroup -S ka && adduser -S ka -G ka && chown -R ka:ka /app +USER ka + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ + CMD node -e "fetch('http://localhost:'+(process.env.PORT||8787)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "apps/server/src/server.ts"] diff --git a/src/LICENSE b/src/LICENSE new file mode 100644 index 0000000..51b303a --- /dev/null +++ b/src/LICENSE @@ -0,0 +1,7 @@ +Copyright 2026 Digitaliseringsdirektoratet (Digdir) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..bb29e7a --- /dev/null +++ b/src/README.md @@ -0,0 +1,177 @@ +# Kunnskapsassistenten, applikasjonen + +**Frontenden til Kunnskapsassistenten, bygd på `digdir-headless-rag`.** + +Samme produkt, samme brukeropplevelse, ny teknologi og en annen backend. Den +skal kunne kjøres ved siden av den "gamle" +[Kunnskapsassistenten](https://test.kunnskap.digdir.cloud) og sammenlignes med +den. Selve poenget er byttet: hold frontenden konstant, bytt motoren under, og +finn ut hva den nye backenden faktisk klarer. + +**Porten er gjennomført.** Klienten er skrevet om fra bunnen i Preact, snakker +med `digdir-headless-rag` over det offentlige API-et, og gir svar med kilder +mot Kudos-korpuset: tråder, strømmede svar med statusetiketter, kildepanel med +utdrag, filtre og innlogging. Underveis ga det elleve dokumenterte hull i +backendens offentlige kontrakt, og noe for [`../evals`](../evals) å kjøre mot. + +## Utrullet + + + +Logg inn med @digdir.no-adressen din. Du får en engangskode på e-post, ingen +passord å opprette. Andre domener slipper ikke inn. + +To ting er ikke på plass i det utrullede miljøet, og begge ligger i backenden, +ikke her: + +- **Svar.** `test.rag.digdir.cloud` tilbyr bare `fact-checker` og + `retrieve-only`, og avviser de agentiske RAG-modusene med `mode_not_allowed`. + Appen er koblet opp og klar; den dagen agenten er slått på der, svarer den. +- **Filtre.** Den utrullede backenden tar ikke imot filteret fra klienten, så + chipsene vises deaktivert med en forklaring. De slår seg på av seg selv når + backenden begynner å ta imot dem, uten ny utrulling. + +Mot en lokal backend med de foreslåtte endringene virker begge deler, og det +er der sammenligningen mot dagens Kunnskapsassistent gjøres. + +``` +apps/web Vite + Preact + Designsystemet (-css / -web). Ingen hemmeligheter. +apps/server Hono. Holder API-nøkkelen. Det eneste som snakker med backenden. +packages/contract Typene som går mellom de to. +``` + +``` +Preact SPA ──► apps/server ──► digdir-headless-rag + (BFF-en) /api/mcp samtale + strømming + /v1/models oppslag + ──► Typesense fasetter, utdrag fra kilder + (ingen av delene finnes i API-et) +``` + +Nettleseren får aldri API-nøkkelen. Det er et krav fra backenden, ikke en +preferanse: den autentiserer applikasjoner og ikke personer, og `X-User-Id` +godtas uten verifisering. Derfor må noe på serversiden _være_ identiteten. Se +[`decisions/0002`](decisions/0002-backend-for-frontend.md). + +## Komme i gang + +```sh +mise install && mise trust # node 22.18+, kjører TypeScript direkte +npm install +cp apps/server/.env.example apps/server/.env # sett DIGDIR_API_BASE og DIGDIR_API_KEY +npm run doctor # sjekker backenden før du starter +npm run dev # server :8787, SPA :5173 +``` + +`npm run doctor` er den raske måten å finne ut hvorfor ingenting virker. +Den sjekker at nøkkelen autentiserer, at den gir tilgang til agenten som er +satt opp, at samtale-API-et svarer, og at Typesense er tilgjengelig. Feiler +noe, sier den hva du skal endre. + +To backender, styrt av `DIGDIR_API_BASE`: + +| | | +| ------------------------------- | ------------------------------------------------------------------------------------- | +| `https://test.rag.digdir.cloud` | utrullet testmiljø. Ingenting å kjøre selv, men du trenger en nøkkel for det miljøet. | +| `http://localhost:8099` | lokalt. Oppsett ligger i `digdir-headless-rag`. | + +Nøkler gjelder per miljø: en som er laget lokalt autentiserer ikke mot den +utrullede backenden. Typesense er valgfritt. Uten det kjører appen fint, men +filterraden skjules og kildekortene viser ingen utdrag. + +## Kommandoer + +| | | +| ------------------- | ---------------------------------- | +| `npm run dev` | begge appene, med watch | +| `npm run doctor` | sjekk forbindelsen til backenden | +| `npm run build` | produksjonsbygg av SPA-en | +| `npm test` | server (node:test) og web (vitest) | +| `npm run typecheck` | alle tre prosjektene | +| `npm run format` | prettier | + +## Status + +Den portede brukeropplevelsen virker ende til ende mot Kudos-korpuset: tråder, +strømmede svar med statusetiketter, og kilder med utdrag. + +Den samme builden kjører mot begge versjoner av backenden. Se [Hva backenden +støtter](#hva-backenden-støtter). + +| | | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Samtale over `/api/mcp`, SSE-strømming, statusetiketter | ferdig | +| Trådliste: søk, endre navn, slett | ferdig | +| Flere turer i samme tråd, startskjerm | ferdig | +| Kildepanel: snarveier, kort, utdrag, husket per tråd | ferdig | +| Kopier svar og lenke, tilbakemeldingslenke, nøkkelord | ferdig | +| Innlogging | ferdig. Engangskode i drift, Entra ID venter på samtykke. Av lokalt. Se [Innlogging](#innlogging) | +| Utrulling til Azure Container Apps | utrullet og i bruk. Se [`deploy/`](deploy/README.md) | +| Filterpanel: fasetter, chips med antall, låst per tråd | vises alltid. Deaktivert med forklaring når backenden ikke tar imot filtre, og slår seg på av seg selv når den gjør det | +| Genererte trådtitler | faller tilbake på spørsmålet til backenden lager en | +| `Vis andres tråder` | deaktivert, API-et kan ikke liste tråder du ikke eier | +| Mapper | droppet. Backenden har tagger, ikke mapper | + +## Hva backenden støtter + +Serveren måler det ved oppstart i stedet for å anta det, og eksponerer +resultatet på `/api/capabilities`. Da virker den samme builden mot begge +versjoner av backenden, og filtrene slår seg på uten ny utrulling den dagen +endringene er inne. + +Filtermålingen er en faktisk test, ikke en erklæring: den sender et filter som +ikke kan treffe noe, og sammenligner med samme søk uten filter. Kommer det +treff uten filter og null med, kom filteret fram. Uten den kontrollen ville en +backend som er nede sett ut som en som støtter filtre. + +Tvinges med `KA_CAPABILITIES="filters"` eller `"no-filters"`. + +## Innlogging + +`AUTH_MODE` velger mekanisme. Alle tre ender i den samme signerte +øktinformasjonskapselen, så å bytte er ren konfigurasjon. + +| | | +| ---------- | ----------------------------------------------------------------- | +| `entra` | Entra ID. Krever at en administrator har gitt samtykke for appen. | +| `supabase` | Engangskode på e-post. Mellomløsning mens man venter på samtykke. | +| `off` | Kun lokalt. Usignert id, hvem som helst kan bli hvem som helst. | + +Utelater du `AUTH_MODE` utledes den: `entra` hvis Azure-variablene er satt, +`supabase` hvis Supabase-variablene er satt, ellers `off`. Serveren skriver hvilken +modus den kjører i ved hver oppstart. + +Uansett modus når ingen token nettleseren, bare en signert +informasjonskapsel. `/api/*` svarer 401 uten innlogging, `/api/health` er +åpen, og `ALLOWED_EMAIL_DOMAINS` gjelder i begge innloggingsmodusene. + +`supabase` har ingen brukerliste og ingen passord: alle med en adresse på et +tillatt domene får en engangskode på e-post, og det å motta den er det som +beviser at de eier adressen. Koden løses inn på serveren, så ingen token +havner i nettleseren. `ALLOWED_EMAIL_DOMAINS` er påkrevd i den +modusen, ellers nekter serveren å starte. Oppsett og bytte til Entra ID i +[`deploy/README.md`](deploy/README.md). + +## Dokumentasjon + +[`decisions/`](decisions/) er på engelsk, som resten av koden og som +`digdir-headless-rag`: hvorfor Preact, hvorfor en BFF, hvorfor `/api/mcp` +framfor `/v1`. + +[`deploy/README.md`](deploy/README.md) dekker utrulling til Azure og +innlogging. + +## Hvor ting ligger + +| Hvor | Hva | +| ---------------------------- | ---------------------------------------------------------------------------------------------- | +| `src/` | **Dette.** Applikasjonen: SPA, server og kontrakt. | +| [`../docs/`](../docs) | Designarbeid, akseptansekriterier, innsikt. | +| [`../evals/`](../evals) | Evalueringene, som denne gir noe å kjøre mot. | +| `digdir/digdir-headless-rag` | **Backenden.** Brukes kun over HTTP. | +| `digdir/digdir-rag` | **Arkivert.** Den opprinnelige Clojure-monolitten. Kilde til norsk tekst, ikke en avhengighet. | +| `digdir/digdir-rag-frontend` | **Egen alfa-demo.** Uten sammenheng med dette, tross navnet. | + +Arbeidsmateriale som hører til prosjektet men ikke til repoet, som den +opprinnelige planen, presentasjonene og det fangede stilarket, ligger utenfor i +`_notes/` ved siden av utsjekkene. diff --git a/src/apps/server/.env.example b/src/apps/server/.env.example new file mode 100644 index 0000000..5d0def4 --- /dev/null +++ b/src/apps/server/.env.example @@ -0,0 +1,60 @@ +# ── Backend ─────────────────────────────────────────────────────────────── +# Pick one. Keys are per environment: a key minted against a local backend +# will not authenticate against a deployed one. +# +# https://test.rag.digdir.cloud deployed test (no setup, needs a key) +# http://localhost:8099 local (see docs/local-backend.md) +DIGDIR_API_BASE=https://test.rag.digdir.cloud + +# Server-side only. It must never reach the browser. +DIGDIR_API_KEY=rag_... + +# ── Pinned server-side on purpose; the browser cannot override these ────── +DIGDIR_TOOL=builtin.agent-rag-agent__agent-rag-graph-bundled +DIGDIR_TENANT=public-sector-knowledge +DIGDIR_DATASET_CONFIG_KEY=default +DIGDIR_AGENT_ID=builtin/agent-rag-agent + +# ── Typesense: optional, read-only ──────────────────────────────────────── +# Fills the filter panel and the source excerpts, both of which the backend +# does not expose over HTTP (docs/contract-gaps.md, gaps 1 and 5). Leave these +# unset and the app still runs; the filter row hides and cards show no excerpt. +TYPESENSE_API_HOST= +TYPESENSE_API_KEY_ADMIN= +KUDOS_DOCS_COLLECTION= + +# Where "Åpne" on a source points. +KUDOS_BASE=https://kudos.dfo.no + +# ── Innlogging (Entra ID) ───────────────────────────────────────────────── +# Leave AZURE_CLIENT_ID empty and the server runs with an unsigned development +# identity where anyone can be anyone. It logs which mode it is in on boot. +# Set all three and /api/* requires a signed-in user. See deploy/README.md. +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +AZURE_REDIRECT_URI=http://localhost:8787/auth/callback + +# Required when the three above are set. 32+ chars: openssl rand -hex 32 +SESSION_SECRET= + +# Optional. Empty means anyone the tenant already admits. +# ALLOWED_EMAIL_DOMAINS=digdir.no + +# ── Hvilken innlogging ──────────────────────────────────────────────────── +# entra | supabase | off. Utelatt: utledes av hva som er satt opp. +# `supabase` sender en engangskode på e-post, som mellomløsning mens Entra ID +# venter på samtykke. Krever ALLOWED_EMAIL_DOMAINS og egen SMTP. +# AUTH_MODE=supabase +# +# Kun for AUTH_MODE=supabase. publishable-nøkkelen er ikke hemmelig, men holdes +# serverside her uansett. Se deploy/README.md for e-postmalen som må endres. +# SUPABASE_URL=https://.supabase.co +# SUPABASE_PUBLISHABLE_KEY= + +# ── Overrides ───────────────────────────────────────────────────────────── +# The server measures what the backend supports at boot. Force it instead +# with a space-separated list, e.g. "filters" or "no-filters". +# KA_CAPABILITIES= + +PORT=8787 diff --git a/src/apps/server/fixtures/mcp-stream-raw.sse b/src/apps/server/fixtures/mcp-stream-raw.sse new file mode 100644 index 0000000..460e151 --- /dev/null +++ b/src/apps/server/fixtures/mcp-stream-raw.sse @@ -0,0 +1,32 @@ +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":1,"total":10,"message":"Iteration 0 of 10","_meta":{"event":"agent/iteration-started","iteration":0,"max-iterations":10}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":2,"message":"Turn 0 complete","_meta":{"event":"agent/turn-completed","iteration":0,"tool-calls":[{"args":{"queries":["Digdir samfunnsoppdrag","Digitaliseringsdirektoratet oppdrag","Hva er Digdir sitt formål?"]},"ok?":true,"stage":"search","tool":"search","duration-ms":335,"result-summary":"Search pass 1: found 0 chunks (0 new). 0 total seen. Ranking: ColBERT semantic rerank (0ms). Higher rerank= means more semantically relevant to the query.\n","sub-skill":"builtin/retrieval","effective-parameters":{"sub-skill":"builtin/retrieval","primary":{"retrieve-top-k":100,"max-per-document":10,"query-aware-boost":true,"filter-by":null,"metadata-only":true}},"args-summary":"{:queries [\"Digdir samfunnsoppdrag\" \"Digitaliseringsdirektoratet oppdrag\" \"Hva er Digdir sitt formål?\"]}"}]}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":3,"total":10,"message":"Iteration 1 of 10","_meta":{"event":"agent/iteration-started","iteration":1,"max-iterations":10}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":4,"message":"Turn 1 complete","_meta":{"event":"agent/turn-completed","iteration":1,"tool-calls":[{"args":{"query":"Hva er Digdir sitt samfunnsoppdrag?"},"ok?":true,"stage":"plan_queries","tool":"plan_queries","duration-ms":2538,"result-summary":"Generated 6 queries: \"hva er digdir sitt samfunnsoppdrag\", \"digdir sitt samfunnsoppdrag\", \"samfunnsoppdraget til digitaliseringsdirektoratet\", \"digitaliseringsdirektoratets rolle i samfunnet\", \"oppgav","sub-skill":"builtin/query-planner","effective-parameters":{"sub-skill":"builtin/query-planner","primary":{"expansion-mode":"corpus-aware-2hop"}},"args-summary":"{:query \"Hva er Digdir sitt samfunnsoppdrag?\"}"}]}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":5,"total":10,"message":"Iteration 2 of 10","_meta":{"event":"agent/iteration-started","iteration":2,"max-iterations":10}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":6,"message":"Turn 2 complete","_meta":{"event":"agent/turn-completed","iteration":2,"tool-calls":[{"args":{"queries":["hva er digdir sitt samfunnsoppdrag","digdir sitt samfunnsoppdrag","samfunnsoppdraget til digitaliseringsdirektoratet","digitaliseringsdirektoratets rolle i samfunnet","oppgaver og ansvar for digdir","formålet med digdir sitt arbeid"]},"ok?":true,"stage":"search","tool":"search","duration-ms":160,"result-summary":"Search pass 2: found 0 chunks (0 new). 0 total seen. Ranking: ColBERT semantic rerank (0ms). Higher rerank= means more semantically relevant to the query.\n","sub-skill":"builtin/retrieval","effective-parameters":{"sub-skill":"builtin/retrieval","primary":{"retrieve-top-k":100,"max-per-document":10,"query-aware-boost":true,"filter-by":null,"metadata-only":true}},"args-summary":"{:queries [\"hva er digdir sitt samfunnsoppdrag\" \"digdir sitt samfunnsoppdrag\" \"samfunnsoppdraget til digitaliseringsdirektoratet\" \"digitaliseringsdirektoratets rolle i samfunnet\" \"oppgaver og ansvar for digdir\" \"formålet med digdir sitt arbeid\"]}"}]}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":7,"total":10,"message":"Iteration 3 of 10","_meta":{"event":"agent/iteration-started","iteration":3,"max-iterations":10}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":8,"message":"Turn 3 complete","_meta":{"event":"agent/turn-completed","iteration":3,"tool-calls":[{"args":{"fields":["title","orgs_short","owner_short"],"max_options":10},"ok?":true,"stage":"inspect_filters","tool":"inspect_filters","duration-ms":48,"result-summary":"Error executing inspect_filters: Collection not found","sub-skill":"builtin/inspect-filters","effective-parameters":{"sub-skill":"builtin/inspect-filters","dataset-ref":null,"primary":{"fields":["title","orgs_short","owner_short"],"max-options":10}},"args-summary":"{}"}]}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":9,"total":10,"message":"Iteration 4 of 10","_meta":{"event":"agent/iteration-started","iteration":4,"max-iterations":10}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":10,"message":"Turn 4 complete","_meta":{"event":"agent/turn-completed","iteration":4,"tool-calls":[{"args":{"sample_size":10},"ok?":true,"stage":"unknown","tool":"analyze_corpus","duration-ms":64,"result-summary":"{\"tool\":\"analyze_corpus\",\"error\":\"Collection not found\",\"error-type\":\"class clojure.lang.ExceptionInfo\"}","sub-skill":"unknown","effective-parameters":{"sub-skill":"unknown","primary":null},"args-summary":"{}"}]}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":11,"total":10,"message":"Iteration 5 of 10","_meta":{"event":"agent/iteration-started","iteration":5,"max-iterations":10}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":12,"message":"Jeg kunne ikke finne spesifikke dokumenter eller informasjon om Digdirs samfunnsoppdrag i den tilgjengelige kunnskapsbasen. Hvis du har mer kontekst eller spes","_meta":{"event":"response/chunk","delta":"Jeg kunne ikke finne spesifikke dokumenter eller informasjon om Digdirs samfunnsoppdrag i den tilgjengelige kunnskapsbasen. Hvis du har mer kontekst eller spes"}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":13,"message":"ifikke detaljer du ønsker å utforske, kan du dele dem, så prøver jeg igjen! Alternativt kan du sjekke Digdirs offisielle nettsider for presis informasjon.","_meta":{"event":"response/chunk","delta":"ifikke detaljer du ønsker å utforske, kan du dele dem, så prøver jeg igjen! Alternativt kan du sjekke Digdirs offisielle nettsider for presis informasjon."}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":14,"message":"Reasoning…","_meta":{"event":"agent/thinking","iteration":5,"reasoning":"Jeg kunne ikke finne spesifikke dokumenter eller informasjon om Digdirs samfunnsoppdrag i den tilgjengelige kunnskapsbasen. Hvis du har mer kontekst eller spesifikke detaljer du ønsker å utforske, kan du dele dem, så prøver jeg igjen! Alternativt kan du sjekke Digdirs offisielle nettsider for presis informasjon."}}} + +data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ka-dev-1","progress":15,"message":"Response finalized","_meta":{"event":"agent/finalized","iteration":5,"response-length":313}}} + +data: {"jsonrpc":"2.0","id":5,"result":{"resultType":"complete","content":[{"type":"text","text":"Jeg kunne ikke finne spesifikke dokumenter eller informasjon om Digdirs samfunnsoppdrag i den tilgjengelige kunnskapsbasen. Hvis du har mer kontekst eller spesifikke detaljer du ønsker å utforske, kan du dele dem, så prøver jeg igjen! Alternativt kan du sjekke Digdirs offisielle nettsider for presis informasjon."}],"structuredContent":{"conversation_id":"FQFaNPYMCBvVeTFO3E6ot"},"isError":false,"_meta":{"conversation_id":"FQFaNPYMCBvVeTFO3E6ot","mode":"agent-rag-graph-bundled","agent_id":"builtin/agent-rag-agent","status":"complete","insufficient":false,"execution_metadata":{"total-duration-ms":14645,"steps-executed":3,"step-timings":{"setup":{"step-id":"setup","skill-id":"builtin/agent-setup","stage":"agent-setup","duration-ms":36,"status":"ok"},"react":{"step-id":"react","skill-id":"builtin/agent-iteration-bundled","stage":"agent-iteration-bundled","duration-ms":14581,"status":"ok","loop?":true},"finalize":{"step-id":"finalize","skill-id":"builtin/agent-finalize","stage":"agent-finalize","duration-ms":27,"status":"ok"}},"stage-timings":[{"step-id":"setup","skill-id":"builtin/agent-setup","stage":"agent-setup","duration-ms":36,"status":"ok"},{"step-id":"react","skill-id":"builtin/agent-iteration-bundled","stage":"agent-iteration-bundled","duration-ms":14581,"status":"ok","loop?":true},{"step-id":"finalize","skill-id":"builtin/agent-finalize","stage":"agent-finalize","duration-ms":27,"status":"ok"}]}}}} + diff --git a/src/apps/server/package.json b/src/apps/server/package.json new file mode 100644 index 0000000..6e42d31 --- /dev/null +++ b/src/apps/server/package.json @@ -0,0 +1,21 @@ +{ + "name": "@ka/server", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node --env-file-if-exists=.env --watch src/server.ts", + "start": "node --env-file-if-exists=.env src/server.ts", + "doctor": "node --env-file-if-exists=.env src/doctor.ts", + "test": "node --test src/*.test.ts" + }, + "dependencies": { + "@azure/msal-node": "^6.0.1", + "@hono/node-server": "^1.13.8", + "@ka/contract": "*", + "hono": "^4.13.7" + }, + "devDependencies": { + "@types/node": "^22.20.2" + } +} diff --git a/src/apps/server/src/auth.test.ts b/src/apps/server/src/auth.test.ts new file mode 100644 index 0000000..08d4d9e --- /dev/null +++ b/src/apps/server/src/auth.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { before, describe, test } from 'node:test'; + +process.env.DIGDIR_API_KEY ??= 'test-key'; + +let domainsOf: typeof import('./auth.ts').domainsOf; +let safeReturnTo: typeof import('./auth.ts').safeReturnTo; + +before(async () => { + ({ domainsOf, safeReturnTo } = await import('./auth.ts')); +}); + +describe('domainsOf', () => { + test('a member account yields its own domain', () => { + assert.deepEqual(domainsOf({ email: 'ansatt@ai-dev.no' }), ['ai-dev.no']); + }); + + test('a guest UPN yields the domain the person actually belongs to', () => { + assert.deepEqual( + domainsOf({ upn: 'fornavn.etternavn_digdir.no#EXT#@aidev.onmicrosoft.com' }), + ['digdir.no'], + ); + }); + + test('the guest mail and the mangled UPN agree on one domain', () => { + assert.deepEqual( + domainsOf({ + email: 'fornavn.etternavn@digdir.no', + upn: 'fornavn.etternavn_digdir.no#EXT#@aidev.onmicrosoft.com', + }), + ['digdir.no'], + ); + }); + + test('the tenant domain is not mistaken for the guest domain', () => { + const d = domainsOf({ upn: 'x_digdir.no#EXT#@aidev.onmicrosoft.com' }); + assert.ok(!d.includes('aidev.onmicrosoft.com')); + }); + + test('nothing in, nothing out', () => { + assert.deepEqual(domainsOf({}), []); + }); +}); + +describe('safeReturnTo', () => { + test('an ordinary path is kept', () => { + assert.equal(safeReturnTo('/tråd/42'), '/tråd/42'); + }); + + test('an auth route is refused, since that is how the redirect loop starts', () => { + assert.equal(safeReturnTo('/auth/login'), '/'); + assert.equal(safeReturnTo('/auth/login?next=%2Fauth%2Flogin'), '/'); + }); + + test('another origin is refused', () => { + assert.equal(safeReturnTo('//evil.example.com'), '/'); + assert.equal(safeReturnTo('https://evil.example.com'), '/'); + }); + + test('nothing becomes the root', () => { + assert.equal(safeReturnTo(undefined), '/'); + assert.equal(safeReturnTo(''), '/'); + }); +}); + +describe('safeReturnTo hardening', () => { + test('a script-breaking path cannot reach a page', () => { + assert.equal(safeReturnTo('/'), '/'); + }); + + test('a backslash cannot be used to leave the origin', () => { + assert.equal(safeReturnTo('/\\evil.com'), '/'); + assert.equal(safeReturnTo('/\\/evil.com'), '/'); + }); + + test('a newline cannot be smuggled into a header', () => { + assert.equal(safeReturnTo('/\r\nX-Evil: 1'), '/'); + }); +}); diff --git a/src/apps/server/src/auth.ts b/src/apps/server/src/auth.ts new file mode 100644 index 0000000..b652c74 --- /dev/null +++ b/src/apps/server/src/auth.ts @@ -0,0 +1,183 @@ +import { randomUUID } from 'node:crypto'; +import { ConfidentialClientApplication } from '@azure/msal-node'; +import type { Context, MiddlewareHandler } from 'hono'; +import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie'; +import { config } from './config.ts'; +import { mountSupabaseAuth } from './supabaseAuth.ts'; + +const SESSION = 'ka_session'; +const RETURN_TO = 'ka_return_to'; +const MAX_AGE = 60 * 60 * 8; +const SCOPES = ['openid', 'profile', 'email']; + +export interface User { + id: string; + name: string; + email: string; +} + +/** A guest's UPN is `navn_digdir.no#EXT#@tenant`. */ +export function domainsOf(claims: { email?: string; upn?: string }): string[] { + const out = new Set(); + const add = (d?: string) => { + if (d) out.add(d.toLowerCase()); + }; + add(claims.email?.split('@').pop()); + const upn = claims.upn ?? ''; + const ext = upn.indexOf('#EXT#'); + if (ext === -1) add(upn.split('@').pop()); + else add(upn.slice(0, ext).split('_').pop()); + return [...out].filter(Boolean); +} + +export function domainAllowed(claims: { email?: string; upn?: string }): boolean { + const allowed = config.auth.allowedDomains; + if (allowed.length === 0) return true; + return domainsOf(claims).some((d) => allowed.includes(d)); +} + +const msal = + config.auth.mode === 'entra' + ? new ConfidentialClientApplication({ + auth: { + clientId: config.auth.clientId, + authority: `https://login.microsoftonline.com/${config.auth.tenantId}`, + clientSecret: config.auth.clientSecret, + }, + }) + : null; + +const secure = () => !config.auth.origin.startsWith('http://localhost'); + +function cookieOptions() { + return { + path: '/', + httpOnly: true, + secure: secure(), + sameSite: 'Lax' as const, + maxAge: MAX_AGE, + }; +} + +export async function readUser(c: Context): Promise { + const raw = await getSignedCookie(c, config.auth.sessionSecret, SESSION); + if (!raw) return null; + try { + const u = JSON.parse(raw) as User & { exp?: number }; + if (!u.id) return null; + if (u.exp && Date.now() > u.exp) return null; + return { id: u.id, name: u.name, email: u.email }; + } catch { + return null; + } +} + +export function clearUser(c: Context): void { + deleteCookie(c, SESSION, { path: '/' }); +} + +export async function writeUser(c: Context, user: User): Promise { + await setSignedCookie( + c, + SESSION, + JSON.stringify({ ...user, exp: Date.now() + MAX_AGE * 1000 }), + config.auth.sessionSecret, + cookieOptions(), + ); +} + +const UNSAFE_IN_PATH = /[\u0000-\u001f\u007f<>"'`\\]/; + +export function safeReturnTo(value: string | undefined): string { + if (!value || !value.startsWith('/')) return '/'; + if (UNSAFE_IN_PATH.test(value)) return '/'; + if (value.startsWith('//')) return '/'; + if (value.startsWith('/auth/')) return '/'; + return value; +} + +export function mountAuth(app: { + get: (path: string, handler: (c: Context) => Promise | Response) => unknown; + post: (path: string, handler: (c: Context) => Promise | Response) => unknown; +}): void { + if (config.auth.mode === 'supabase') { + mountSupabaseAuth(app, { writeUser, clearUser, safeReturnTo }); + return; + } + if (!msal) return; + + app.get('/auth/login', async (c) => { + const next = safeReturnTo(c.req.query('next')); + const state = randomUUID(); + const pending = JSON.stringify({ state, next }); + await setSignedCookie(c, RETURN_TO, pending, config.auth.sessionSecret, { + ...cookieOptions(), + maxAge: 600, + }); + const url = await msal.getAuthCodeUrl({ + scopes: SCOPES, + redirectUri: config.auth.redirectUri, + state, + prompt: 'select_account', + }); + return c.redirect(url); + }); + + app.get('/auth/callback', async (c) => { + const code = c.req.query('code'); + if (!code) return c.text('Mangler autorisasjonskode.', 400); + + const pendingRaw = await getSignedCookie(c, config.auth.sessionSecret, RETURN_TO); + deleteCookie(c, RETURN_TO, { path: '/' }); + let pending: { state?: string; next?: string } = {}; + try { + pending = typeof pendingRaw === 'string' ? JSON.parse(pendingRaw) : {}; + } catch {} + if (!pending.state || pending.state !== c.req.query('state')) { + return c.text('Ugyldig state. Prøv innloggingen på nytt.', 400); + } + + try { + const result = await msal.acquireTokenByCode({ + code, + scopes: SCOPES, + redirectUri: config.auth.redirectUri, + }); + const claims = (result.idTokenClaims ?? {}) as Record; + const id = result.account?.homeAccountId ?? claims.oid ?? claims.sub; + if (!id) return c.text('Fant ingen bruker i svaret fra Entra ID.', 502); + const email = claims.preferred_username ?? claims.email ?? ''; + if (!domainAllowed({ email, upn: claims.upn ?? result.account?.username })) { + return c.text( + `Kontoen ${email} har ikke tilgang. Tillatte domener: ` + + config.auth.allowedDomains.join(', '), + 403, + ); + } + await writeUser(c, { + id, + name: result.account?.name ?? claims.name ?? '', + email, + }); + return c.redirect(safeReturnTo(pending.next)); + } catch (err) { + console.error('auth callback', err); + return c.text('Innlogging feilet.', 502); + } + }); + + app.get('/auth/logout', (c) => { + deleteCookie(c, SESSION, { path: '/' }); + const post = encodeURIComponent(new URL('/', config.auth.redirectUri).toString()); + return c.redirect( + `https://login.microsoftonline.com/${config.auth.tenantId}/oauth2/v2.0/logout` + + `?post_logout_redirect_uri=${post}`, + ); + }); +} + +export const requireAuth: MiddlewareHandler = async (c, next) => { + if (!config.auth.enabled) return next(); + if (await readUser(c)) return next(); + return c.json({ error: 'Ikke innlogget.', login: '/auth/login' }, 401); +}; diff --git a/src/apps/server/src/capabilities.test.ts b/src/apps/server/src/capabilities.test.ts new file mode 100644 index 0000000..098a042 --- /dev/null +++ b/src/apps/server/src/capabilities.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { before, describe, test } from 'node:test'; + +// config.ts exits on a missing key at import time, hence the dynamic import. +process.env.DIGDIR_API_KEY ??= 'test-key'; + +let noteTitleFromBackend: typeof import('./capabilities.ts').noteTitleFromBackend; +let capabilities: typeof import('./capabilities.ts').capabilities; + +before(async () => { + ({ noteTitleFromBackend, capabilities } = await import('./capabilities.ts')); +}); + +describe('noteTitleFromBackend', () => { + test('a title echoed back unchanged is not a generated one', () => { + noteTitleFromBackend('Hva gjorde Digdir i 2024?', 'Hva gjorde Digdir i 2024?'); + assert.equal(capabilities().threadTitles, false); + }); + + test('a title the backend changed means it names threads itself', () => { + noteTitleFromBackend('Hva gjorde Digdir i 2024?', 'Digdirs aktiviteter i 2024'); + assert.equal(capabilities().threadTitles, true); + }); + + test('an empty title never flips the capability', () => { + noteTitleFromBackend('Noe', ''); + assert.equal(capabilities().threadTitles, true); + }); + + test('capabilities() hands out a copy, so a caller cannot mutate the state', () => { + const snapshot = capabilities(); + snapshot.filters = true; + assert.equal(capabilities().filters, false); + }); +}); diff --git a/src/apps/server/src/capabilities.ts b/src/apps/server/src/capabilities.ts new file mode 100644 index 0000000..31a7c8e --- /dev/null +++ b/src/apps/server/src/capabilities.ts @@ -0,0 +1,103 @@ +import { config } from './config.ts'; + +export interface Capabilities { + filters: boolean; + othersThreads: boolean; + threadTitles: boolean; +} + +const PROBE_TOOL = 'builtin.retrieve-only-agent__retrieve-only'; +const PROBE_QUERY = 'tiltak og resultater'; +const IMPOSSIBLE = { + fields: [{ field: 'type', 'selected-options': ['ZZZ_ingen_slik_type'] }], +}; + +let current: Capabilities = { filters: false, othersThreads: false, threadTitles: false }; +let probed = false; + +export const capabilities = (): Capabilities => ({ ...current }); +export const probeComplete = (): boolean => probed; + +export function noteTitleFromBackend(sent: string, received: string): void { + if (received && received !== sent) current = { ...current, threadTitles: true }; +} + +function countChunks(text: string): number { + let most = 0; + for (const m of text.matchAll(/"chunks":\s*(\[[\s\S]*?\])\s*(?:,\s*"queries"|\})/g)) { + try { + const n = (JSON.parse(m[1] ?? '[]') as unknown[]).length; + if (n > most) most = n; + } catch {} + } + return most; +} + +async function retrieveOnly(filterBy: unknown, signal: AbortSignal): Promise { + const res = await fetch(`${config.apiBase}/api/mcp`, { + method: 'POST', + signal, + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + 'X-API-Key': config.apiKey, + 'MCP-Protocol-Version': '2026-07-28', + 'Mcp-Method': 'tools/call', + 'Mcp-Name': PROBE_TOOL, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: PROBE_TOOL, + arguments: { + query: PROBE_QUERY, + tenant: config.tenant, + dataset_config_key: config.datasetConfigKey, + ...(filterBy ? { overrides: { 'retrieve-filter-by': filterBy } } : {}), + }, + }, + }), + }); + if (!res.ok) return null; + const text = await res.text(); + return text.includes('"isError":true') ? null : countChunks(text); +} + +async function probeFilters(signal: AbortSignal): Promise { + const baseline = await retrieveOnly(undefined, signal); + if (baseline === null || baseline === 0) return false; + const filtered = await retrieveOnly(IMPOSSIBLE, signal); + return filtered === 0; +} + +function fromEnv(): Partial { + const raw = process.env.KA_CAPABILITIES?.trim(); + if (!raw) return {}; + const on = new Set(raw.split(/[,\s]+/).filter(Boolean)); + const pick = (k: keyof Capabilities) => + on.has(k) ? { [k]: true } : on.has(`no-${k}`) ? { [k]: false } : {}; + return { ...pick('filters'), ...pick('othersThreads'), ...pick('threadTitles') }; +} + +export async function probe(timeoutMs = 60_000): Promise { + const override = fromEnv(); + if ('filters' in override) { + current = { ...current, ...override }; + probed = true; + return capabilities(); + } + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + current = { ...current, filters: await probeFilters(ac.signal) }; + } catch { + current = { ...current, filters: false }; + } finally { + clearTimeout(timer); + } + current = { ...current, ...override }; + probed = true; + return capabilities(); +} diff --git a/src/apps/server/src/config.ts b/src/apps/server/src/config.ts new file mode 100644 index 0000000..81aa2cb --- /dev/null +++ b/src/apps/server/src/config.ts @@ -0,0 +1,110 @@ +function required(name: string): string { + const v = process.env[name]; + if (!v) { + console.error( + [ + `Mangler ${name}.`, + '', + ' cp apps/server/.env.example apps/server/.env', + ' # fyll inn DIGDIR_API_KEY og DIGDIR_API_BASE', + ' npm run doctor', + ].join('\n'), + ); + process.exit(1); + } + return v; +} + +function allowedDomainsOf(): string[] { + return (process.env.ALLOWED_EMAIL_DOMAINS ?? '') + .split(/[,\s]+/) + .map((d) => d.trim().toLowerCase().replace(/^@/, '')) + .filter(Boolean); +} + +function authMode(entraReady: boolean, supabaseReady: boolean): 'entra' | 'supabase' | 'off' { + const asked = (process.env.AUTH_MODE ?? '').trim().toLowerCase(); + if (asked === 'entra' || asked === 'supabase' || asked === 'off') return asked; + if (asked) { + console.error(`AUTH_MODE må være entra, supabase eller off. Fikk "${asked}".`); + process.exit(1); + } + return entraReady ? 'entra' : supabaseReady ? 'supabase' : 'off'; +} + +function authConfig() { + const tenantId = process.env.AZURE_TENANT_ID ?? ''; + const clientId = process.env.AZURE_CLIENT_ID ?? ''; + const clientSecret = process.env.AZURE_CLIENT_SECRET ?? ''; + const redirectUri = + process.env.AZURE_REDIRECT_URI ?? + `http://localhost:${process.env.PORT ?? 8787}/auth/callback`; + const origin = process.env.APP_ORIGIN?.replace(/\/$/, '') ?? new URL(redirectUri).origin; + const sessionSecret = process.env.SESSION_SECRET ?? ''; + const supabaseUrl = process.env.SUPABASE_URL ?? ''; + const supabasePublishableKey = process.env.SUPABASE_PUBLISHABLE_KEY ?? ''; + const entraReady = Boolean(tenantId && clientId && clientSecret); + const supabaseReady = Boolean(supabaseUrl && supabasePublishableKey); + const mode = authMode(entraReady, supabaseReady); + const enabled = mode !== 'off'; + if (mode === 'entra' && !entraReady) { + console.error( + 'AUTH_MODE=entra krever AZURE_TENANT_ID, AZURE_CLIENT_ID og AZURE_CLIENT_SECRET.', + ); + process.exit(1); + } + if (mode === 'supabase' && !supabaseReady) { + console.error('AUTH_MODE=supabase krever SUPABASE_URL og SUPABASE_PUBLISHABLE_KEY.'); + process.exit(1); + } + if (mode === 'supabase' && allowedDomainsOf().length === 0) { + console.error('AUTH_MODE=supabase krever ALLOWED_EMAIL_DOMAINS.'); + process.exit(1); + } + if (enabled && sessionSecret.length < 32) { + console.error('SESSION_SECRET må være minst 32 tegn når innlogging er slått på.'); + process.exit(1); + } + const allowedDomains = allowedDomainsOf(); + return { + enabled, + mode, + supabaseUrl, + supabasePublishableKey, + tenantId, + clientId, + clientSecret, + redirectUri, + origin, + sessionSecret, + allowedDomains, + }; +} + +export const config = { + port: Number(process.env.PORT ?? 8787), + apiBase: (process.env.DIGDIR_API_BASE ?? 'http://localhost:8099').replace(/\/$/, ''), + apiKey: required('DIGDIR_API_KEY'), + + /** Default only: the browser may name another that the key already grants. */ + tool: process.env.DIGDIR_TOOL ?? 'builtin.agent-rag-agent__agent-rag-graph-bundled', + tenant: process.env.DIGDIR_TENANT ?? 'public-sector-knowledge', + agentId: process.env.DIGDIR_AGENT_ID ?? 'builtin/agent-rag-agent', + datasetConfigKey: process.env.DIGDIR_DATASET_CONFIG_KEY ?? 'default', + + kudosBase: (process.env.KUDOS_BASE ?? 'https://kudos.dfo.no').replace(/\/$/, ''), + + typesenseHost: process.env.TYPESENSE_API_HOST ?? '', + typesenseKey: process.env.TYPESENSE_API_KEY_ADMIN ?? '', + docsCollection: process.env.KUDOS_DOCS_COLLECTION ?? '', + + maxQueryLength: 4000, + + webRoot: process.env.WEB_ROOT ?? '', + + auth: authConfig(), +} as const; + +export const typesenseConfigured = Boolean( + config.typesenseHost && config.typesenseKey && config.docsCollection, +); diff --git a/src/apps/server/src/conversations.ts b/src/apps/server/src/conversations.ts new file mode 100644 index 0000000..7264b3f --- /dev/null +++ b/src/apps/server/src/conversations.ts @@ -0,0 +1,86 @@ +/** `X-User-Id` always comes from the session, never from a request. */ +import type { ConversationDetail, ConversationSummary, Message } from '@ka/contract'; +import { config } from './config.ts'; +import { noteTitleFromBackend } from './capabilities.ts'; + +const headers = (userId: string): Record => ({ + 'Content-Type': 'application/json', + 'X-API-Key': config.apiKey, + 'X-User-Id': userId, +}); + +interface BackendConversation { + id: string; + topic?: string; + created?: number; +} + +const toSummary = (c: BackendConversation): ConversationSummary => ({ + id: c.id, + topic: c.topic?.trim() || 'Uten tittel', + created: c.created ?? 0, +}); + +export function topicFrom(query: string): string { + const one = query.replace(/\s+/g, ' ').trim(); + return one.length <= 60 ? one : `${one.slice(0, 57)}…`; +} + +export async function list(userId: string): Promise { + const res = await fetch(`${config.apiBase}/api/conversations?page_size=100`, { + headers: headers(userId), + }); + if (!res.ok) throw new Error(`list ${res.status}`); + const body = (await res.json()) as { conversations?: BackendConversation[] }; + return (body.conversations ?? []).map(toSummary).sort((a, b) => b.created - a.created); +} + +export async function create(userId: string, topic: string): Promise { + const res = await fetch(`${config.apiBase}/api/conversations`, { + method: 'POST', + headers: headers(userId), + body: JSON.stringify({ agentId: config.agentId, title: topic }), + }); + if (!res.ok) throw new Error(`create ${res.status}`); + const body = (await res.json()) as { conversation: BackendConversation }; + const summary = toSummary(body.conversation); + noteTitleFromBackend(topic, summary.topic); + return summary; +} + +export async function detail(userId: string, id: string): Promise { + const res = await fetch(`${config.apiBase}/api/conversations/${encodeURIComponent(id)}`, { + headers: headers(userId), + }); + if (!res.ok) throw new Error(`detail ${res.status}`); + const body = (await res.json()) as { + conversation: BackendConversation; + messages?: Array<{ id: string; role: string; text: string; created: number }>; + }; + const messages: Message[] = (body.messages ?? []) + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => ({ + id: m.id, + role: m.role as 'user' | 'assistant', + text: m.text ?? '', + created: m.created ?? 0, + })); + return { conversation: toSummary(body.conversation), messages }; +} + +export async function rename(userId: string, id: string, title: string): Promise { + const res = await fetch(`${config.apiBase}/api/conversations/${encodeURIComponent(id)}`, { + method: 'PUT', + headers: headers(userId), + body: JSON.stringify({ title }), + }); + if (!res.ok) throw new Error(`rename ${res.status}`); +} + +export async function remove(userId: string, id: string): Promise { + const res = await fetch(`${config.apiBase}/api/conversations/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: headers(userId), + }); + if (!res.ok) throw new Error(`delete ${res.status}`); +} diff --git a/src/apps/server/src/doctor.ts b/src/apps/server/src/doctor.ts new file mode 100644 index 0000000..28eed73 --- /dev/null +++ b/src/apps/server/src/doctor.ts @@ -0,0 +1,122 @@ +import { config } from './config.ts'; + +type Result = { ok: boolean; detail: string; fix?: string }; + +const results: Array<{ name: string; result: Result }> = []; + +async function check(name: string, fn: () => Promise): Promise { + let result: Result; + try { + result = await fn(); + } catch (err) { + result = { + ok: false, + detail: err instanceof Error ? err.message : String(err), + fix: 'Is DIGDIR_API_BASE right, and is the host reachable?', + }; + } + results.push({ name, result }); + const mark = result.ok ? '✓' : '✗'; + console.log(`${mark} ${name.padEnd(26)} ${result.detail}`); + if (!result.ok && result.fix) console.log(` ${' '.repeat(26)} → ${result.fix}`); + return result; +} + +const headers = () => ({ 'X-API-Key': config.apiKey, 'Content-Type': 'application/json' }); + +const withTimeout = (ms: number) => AbortSignal.timeout(ms); + +async function models(): Promise { + const res = await fetch(`${config.apiBase}/v1/models`, { + headers: headers(), + signal: withTimeout(15_000), + }); + if (res.status === 401 || res.status === 403) { + return { + ok: false, + detail: `${res.status} — the key was rejected`, + fix: `DIGDIR_API_KEY is not valid for ${config.apiBase}. Keys are per environment: a key minted against a local backend will not work against a deployed one.`, + }; + } + if (!res.ok) return { ok: false, detail: `HTTP ${res.status}` }; + + const body = (await res.json()) as { data?: Array<{ id: string }> }; + const ids = (body.data ?? []).map((m) => m.id); + if (!ids.length) return { ok: false, detail: 'authenticated, but the key grants no agents' }; + + if (!ids.includes(config.tool)) { + return { + ok: false, + detail: `${ids.length} agents, but not the pinned one`, + fix: `DIGDIR_TOOL is "${config.tool}". Available: ${ids.join(', ')}`, + }; + } + return { ok: true, detail: `${ids.length} agents, including the pinned one` }; +} + +async function conversations(): Promise { + const res = await fetch(`${config.apiBase}/api/conversations?page_size=1`, { + headers: { ...headers(), 'X-User-Id': 'doctor-probe' }, + signal: withTimeout(15_000), + }); + if (!res.ok) { + return { + ok: false, + detail: `HTTP ${res.status}`, + fix: 'Threads will not list or persist. The chat itself may still work.', + }; + } + return { ok: true, detail: 'readable and scoped by X-User-Id' }; +} + +async function typesense(): Promise { + if (!config.typesenseHost || !config.typesenseKey || !config.docsCollection) { + return { + ok: false, + detail: 'not configured', + fix: 'Optional. Without it the filter panel is empty and sources show no excerpt. Set TYPESENSE_API_HOST, TYPESENSE_API_KEY_ADMIN, KUDOS_DOCS_COLLECTION.', + }; + } + const res = await fetch( + `https://${config.typesenseHost}/collections/${config.docsCollection}`, + { headers: { 'X-TYPESENSE-API-KEY': config.typesenseKey }, signal: withTimeout(15_000) }, + ); + if (!res.ok) { + return { + ok: false, + detail: `HTTP ${res.status} on ${config.docsCollection}`, + fix: 'Check TYPESENSE_API_KEY_ADMIN and that KUDOS_DOCS_COLLECTION names an existing collection.', + }; + } + const body = (await res.json()) as { num_documents?: number }; + const chunks = config.docsCollection.replace('_documents_', '_chunks_'); + const chunksRes = await fetch(`https://${config.typesenseHost}/collections/${chunks}`, { + headers: { 'X-TYPESENSE-API-KEY': config.typesenseKey }, + signal: withTimeout(15_000), + }); + if (!chunksRes.ok) { + return { + ok: false, + detail: `${body.num_documents ?? '?'} documents, but no chunks collection`, + fix: `Expected "${chunks}" alongside the documents collection. Source excerpts need it.`, + }; + } + return { ok: true, detail: `${body.num_documents ?? '?'} documents, chunks present` }; +} + +console.log(`\nBackend ${config.apiBase}`); +console.log(`Agent ${config.tool}`); +console.log(`Dataset ${config.tenant}/${config.datasetConfigKey}\n`); + +await check('agents (/v1/models)', models); +await check('conversations API', conversations); +await check('typesense (optional)', typesense); + +const required = results.filter((r) => r.name !== 'typesense (optional)'); +const failed = required.filter((r) => !r.result.ok); +console.log( + failed.length + ? `\n${failed.length} required check(s) failed. The app will not work yet.\n` + : '\nReady. `npm run dev` and open http://localhost:5173\n', +); +process.exit(failed.length ? 1 : 0); diff --git a/src/apps/server/src/excerpts.ts b/src/apps/server/src/excerpts.ts new file mode 100644 index 0000000..aea9dc4 --- /dev/null +++ b/src/apps/server/src/excerpts.ts @@ -0,0 +1,31 @@ +import { config } from './config.ts'; + +const chunksCollection = () => config.docsCollection.replace('_documents_', '_chunks_'); + +export async function excerpts(chunkIds: string[]): Promise> { + const out = new Map(); + const ids = [...new Set(chunkIds.filter((id) => /^[A-Za-z0-9._:-]+$/.test(id)))]; + if (!ids.length || !config.typesenseHost || !config.typesenseKey || !config.docsCollection) { + return out; + } + + const url = new URL( + `https://${config.typesenseHost}/collections/${chunksCollection()}/documents/search`, + ); + url.searchParams.set('q', '*'); + url.searchParams.set('filter_by', `chunk_id:[${ids.join(',')}]`); + url.searchParams.set('include_fields', 'chunk_id,content_markdown'); + url.searchParams.set('per_page', String(Math.min(ids.length, 250))); + + const res = await fetch(url, { headers: { 'X-TYPESENSE-API-KEY': config.typesenseKey } }); + if (!res.ok) return out; + + const body = (await res.json()) as { + hits?: Array<{ document?: { chunk_id?: string; content_markdown?: string } }>; + }; + for (const hit of body.hits ?? []) { + const d = hit.document; + if (d?.chunk_id && d.content_markdown) out.set(d.chunk_id, d.content_markdown.trim()); + } + return out; +} diff --git a/src/apps/server/src/facets.test.ts b/src/apps/server/src/facets.test.ts new file mode 100644 index 0000000..1c492e2 --- /dev/null +++ b/src/apps/server/src/facets.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { before, describe, test } from 'node:test'; + +process.env.DIGDIR_API_KEY ??= 'test-key'; + +let facets: typeof import('./facets.ts'); +before(async () => { + facets = await import('./facets.ts'); +}); + +const counts = (...pairs: Array<[string, number]>) => + pairs.map(([value, count]) => ({ value, count })); + +describe('shapeFacet', () => { + test('orders ordinary fields by count, descending', () => { + const f = facets.shapeFacet('type', 'dokumenttyper', counts(['B', 2], ['A', 9], ['C', 5])); + assert.deepEqual( + f.options.map((o) => o.value), + ['A', 'C', 'B'], + ); + }); + + test('orders years chronologically, newest first, not by count', () => { + const f = facets.shapeFacet( + 'concerned_years', + 'år', + counts(['2020', 900], ['2024', 3], ['2022', 50]), + ); + assert.deepEqual( + f.options.map((o) => o.value), + ['2024', '2022', '2020'], + ); + }); + + test('drops year values that are page numbers or parse noise', () => { + const f = facets.shapeFacet( + 'concerned_years', + 'år', + counts(['2436', 40], ['1989', 1], ['2036', 1], ['2024', 1], ['1990', 1], ['2035', 1]), + ); + assert.deepEqual( + f.options.map((o) => o.value), + ['2035', '2024', '1990'], + ); + }); + + test('keeps a non-year field intact even when its values look numeric', () => { + const f = facets.shapeFacet('type', 'dokumenttyper', counts(['2436', 1])); + assert.deepEqual( + f.options.map((o) => o.value), + ['2436'], + ); + }); + + test('drops the empty value Typesense reports for unset fields', () => { + const f = facets.shapeFacet('type', 'dokumenttyper', counts(['', 400], ['A', 1])); + assert.deepEqual( + f.options.map((o) => o.value), + ['A'], + ); + }); + + test('caps organisations higher than other fields', () => { + const many = (n: number) => + Array.from({ length: n }, (_, i) => ({ value: `v${i}`, count: n - i })); + assert.equal(facets.shapeFacet('orgs_long', 'x', many(400)).options.length, 300); + assert.equal(facets.shapeFacet('type', 'x', many(400)).options.length, 60); + }); + + test('a field Typesense returned nothing for yields no options', () => { + assert.deepEqual(facets.shapeFacet('type', 'x', undefined).options, []); + }); +}); + +describe('toFilterBy', () => { + test('shapes the selection the way the retrieval skill wants it', () => { + assert.deepEqual(facets.toFilterBy({ type: ['Evaluering'] }), { + fields: [{ field: 'type', 'selected-options': ['Evaluering'] }], + }); + }); + + test('drops fields with nothing selected', () => { + assert.deepEqual(facets.toFilterBy({ type: ['A'], orgs_long: [] }), { + fields: [{ field: 'type', 'selected-options': ['A'] }], + }); + }); + + test('ignores field names the backend does not facet on', () => { + assert.equal(facets.toFilterBy({ not_a_field: ['x'] }), undefined); + }); + + test('an empty or absent selection sends no filter at all', () => { + assert.equal(facets.toFilterBy({}), undefined); + assert.equal(facets.toFilterBy(undefined), undefined); + }); +}); diff --git a/src/apps/server/src/facets.ts b/src/apps/server/src/facets.ts new file mode 100644 index 0000000..499c838 --- /dev/null +++ b/src/apps/server/src/facets.ts @@ -0,0 +1,83 @@ +import { config } from './config.ts'; + +export interface FacetOption { + value: string; + count: number; +} + +export interface FacetField { + field: string; + /** Norwegian noun; the client prefixes it with Velg or Valgt. */ + label: string; + options: FacetOption[]; +} + +/** `orgs_short` is left out: it is empty in this corpus. */ +const FIELDS: Array<{ field: string; label: string }> = [ + { field: 'type', label: 'dokumenttyper' }, + { field: 'orgs_long', label: 'virksomheter' }, + { field: 'concerned_years', label: 'år' }, +]; + +let cache: { at: number; data: FacetField[] } | null = null; +const TTL_MS = 10 * 60 * 1000; + +export async function facets(): Promise { + if (!config.typesenseHost || !config.typesenseKey || !config.docsCollection) return []; + if (cache && Date.now() - cache.at < TTL_MS) return cache.data; + + const url = new URL( + `https://${config.typesenseHost}/collections/${config.docsCollection}/documents/search`, + ); + url.searchParams.set('q', '*'); + url.searchParams.set('query_by', 'title'); + url.searchParams.set('per_page', '0'); + url.searchParams.set('facet_by', FIELDS.map((f) => f.field).join(',')); + url.searchParams.set('max_facet_values', '200'); + + const res = await fetch(url, { headers: { 'X-TYPESENSE-API-KEY': config.typesenseKey } }); + if (!res.ok) throw new Error(`facets ${res.status}`); + + const body = (await res.json()) as { + facet_counts?: Array<{ field_name: string; counts: FacetOption[] }>; + }; + const byField = new Map((body.facet_counts ?? []).map((f) => [f.field_name, f.counts])); + + const data = FIELDS.map(({ field, label }) => + shapeFacet(field, label, byField.get(field)), + ).filter((f) => f.options.length > 0); + + cache = { at: Date.now(), data }; + return data; +} + +const CAP: Record = { orgs_long: 300 }; + +/** `concerned_years` holds parse noise like 2436. */ +export function shapeFacet( + field: string, + label: string, + counts: FacetOption[] | undefined, +): FacetField { + const years = field === 'concerned_years'; + const options = (counts ?? []) + .filter((o) => { + if (o.value === '') return false; + if (!years) return true; + const y = Number(o.value); + return Number.isInteger(y) && y >= 1990 && y <= 2035; + }) + .sort((a, b) => (years ? Number(b.value) - Number(a.value) : b.count - a.count)) + .slice(0, CAP[field] ?? 60); + return { field, label, options }; +} + +export function toFilterBy( + selected: Record | undefined, +): { fields: Array<{ field: string; 'selected-options': string[] }> } | undefined { + if (!selected) return undefined; + const fields = Object.entries(selected) + .filter(([field, values]) => FIELDS.some((f) => f.field === field) && values?.length) + .map(([field, values]) => ({ field, 'selected-options': values })); + return fields.length ? { fields } : undefined; +} diff --git a/src/apps/server/src/mcp.test.ts b/src/apps/server/src/mcp.test.ts new file mode 100644 index 0000000..9a1e969 --- /dev/null +++ b/src/apps/server/src/mcp.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { before, describe, test } from 'node:test'; + +// config.ts exits on a missing key at import time, hence the dynamic import. +process.env.DIGDIR_API_KEY ??= 'test-key'; +process.env.KUDOS_BASE = 'https://kudos.example'; + +let mcp: typeof import('./mcp.ts'); +before(async () => { + mcp = await import('./mcp.ts'); +}); + +const noExcerpts = async () => new Map(); + +describe('takeFrames', () => { + test('splits on the blank line and drops the separator', () => { + const { frames, rest } = mcp.takeFrames('data: a\n\ndata: b\n\n'); + assert.deepEqual(frames, ['data: a', 'data: b']); + assert.equal(rest, ''); + }); + + test('holds back a frame that has not finished arriving', () => { + const { frames, rest } = mcp.takeFrames('data: a\n\ndata: par'); + assert.deepEqual(frames, ['data: a']); + assert.equal(rest, 'data: par'); + }); + + test('keeps a multi-line frame whole — the split is a blank line, not any newline', () => { + const { frames } = mcp.takeFrames('event: message\ndata: {"a":1}\n\n'); + assert.deepEqual(frames, ['event: message\ndata: {"a":1}']); + }); + + test('parses the captured backend stream into its 16 frames', () => { + const raw = readFileSync( + new URL('../fixtures/mcp-stream-raw.sse', import.meta.url), + 'utf8', + ); + const { frames } = mcp.takeFrames(raw); + const messages = frames.map(mcp.frameData).filter(Boolean); + assert.equal(messages.length, 16); + assert.equal(messages.filter((m) => m!.result).length, 1, 'exactly one final result frame'); + }); + + test('a frame split across two reads parses once, whole', () => { + const first = mcp.takeFrames('data: {"id":'); + assert.deepEqual(first.frames, []); + const second = mcp.takeFrames(first.rest + '1}\n\n'); + assert.deepEqual(second.frames, ['data: {"id":1}']); + assert.equal(mcp.frameData(second.frames[0]!)?.id, 1); + }); +}); + +describe('frameData', () => { + test('reads the data line past any event line', () => { + assert.deepEqual(mcp.frameData('event: message\ndata: {"a":1}'), { a: 1 }); + }); + + test('returns null rather than throwing on a comment or malformed JSON', () => { + assert.equal(mcp.frameData(': keep-alive'), null); + assert.equal(mcp.frameData('data: {oops'), null); + }); +}); + +describe('toSources', () => { + test('collapses many chunks of one document into a single source', async () => { + const sources = await mcp.toSources( + [ + { chunk_id: 'a', doc_num: '1', title: 'Rapport' }, + { chunk_id: 'b', doc_num: '1', title: 'Rapport' }, + { chunk_id: 'c', doc_num: '2', title: 'Annen' }, + ], + noExcerpts, + ); + assert.equal(sources.length, 2); + assert.deepEqual( + sources.map((s) => s.marker), + [1, 2], + ); + }); + + test('markers are 1-based and follow retrieval order', async () => { + const sources = await mcp.toSources( + [ + { doc_num: '9', title: 'Ni' }, + { doc_num: '4', title: 'Fire' }, + ], + noExcerpts, + ); + assert.deepEqual( + sources.map((s) => [s.docNum, s.marker]), + [ + ['9', 1], + ['4', 2], + ], + ); + }); + + test('builds a Kudos url when the backend leaves url null', async () => { + const [source] = await mcp.toSources([{ doc_num: '413482' }], noExcerpts); + assert.equal(source?.url, 'https://kudos.example/documents/413482'); + }); + + test('keeps a url the backend did supply', async () => { + const [source] = await mcp.toSources( + [{ doc_num: '1', url: 'https://elsewhere.example/doc' }], + noExcerpts, + ); + assert.equal(source?.url, 'https://elsewhere.example/doc'); + }); + + test('falls back to a title when the chunk has none', async () => { + const [source] = await mcp.toSources([{ doc_num: '77' }], noExcerpts); + assert.equal(source?.title, 'Dokument 77'); + }); + + test('joins every passage of a document, in retrieval order', async () => { + const [source] = await mcp.toSources( + [ + { chunk_id: 'a', doc_num: '1' }, + { chunk_id: 'b', doc_num: '1' }, + ], + async () => + new Map([ + ['a', 'first'], + ['b', 'second'], + ]), + ); + assert.equal(source?.excerpt, 'first\n\nsecond'); + }); + + test('omits excerpt entirely when no passage was found', async () => { + const [source] = await mcp.toSources([{ chunk_id: 'a', doc_num: '1' }], noExcerpts); + assert.equal('excerpt' in source!, false); + }); + + test('a failed excerpt lookup costs the passage, not the turn', async () => { + const sources = await mcp.toSources( + [{ chunk_id: 'a', doc_num: '1', title: 'T' }], + async () => { + throw new Error('typesense down'); + }, + ); + assert.equal(sources.length, 1); + assert.equal(sources[0]?.excerpt, undefined); + }); + + test('skips chunks with neither doc_num nor url', async () => { + const sources = await mcp.toSources([{ chunk_id: 'orphan' }], noExcerpts); + assert.deepEqual(sources, []); + }); + + test('no chunks means no sources', async () => { + assert.deepEqual(await mcp.toSources(undefined, noExcerpts), []); + assert.deepEqual(await mcp.toSources([], noExcerpts), []); + }); +}); + +describe('documentUrl safety', () => { + test('a javascript: url from an indexed document never becomes a link', async () => { + const sources = await mcp.toSources( + [{ chunk_id: 'c1', doc_num: '42', url: 'javascript:alert(document.cookie)' }], + async () => new Map(), + ); + assert.ok(!sources[0]?.url.startsWith('javascript:')); + }); + + test('a data: url is refused too', async () => { + const sources = await mcp.toSources( + [{ chunk_id: 'c1', doc_num: '42', url: 'data:text/html,' }], + async () => new Map(), + ); + assert.ok(!sources[0]?.url.startsWith('data:')); + }); + + test('an ordinary https url is kept', async () => { + const sources = await mcp.toSources( + [{ chunk_id: 'c1', doc_num: '42', url: 'https://kudos.dfo.no/documents/42' }], + async () => new Map(), + ); + assert.equal(sources[0]?.url, 'https://kudos.dfo.no/documents/42'); + }); +}); diff --git a/src/apps/server/src/mcp.ts b/src/apps/server/src/mcp.ts new file mode 100644 index 0000000..9464b8b --- /dev/null +++ b/src/apps/server/src/mcp.ts @@ -0,0 +1,277 @@ +import type { Source, Stage, TurnEvent } from '@ka/contract'; +import { config } from './config.ts'; +import { excerpts } from './excerpts.ts'; + +const PROTOCOL_VERSION = '2026-07-28'; + +const allowedTools = new Set(); + +export function setAllowedTools(ids: string[]): void { + allowedTools.clear(); + for (const id of ids) allowedTools.add(id); +} + +/** Derived from the body: a disagreeing header is rejected with -32020. */ +function headers(method: string, userId: string, toolName?: string): Record { + const h: Record = { + 'Content-Type': 'application/json', + 'X-API-Key': config.apiKey, + 'X-User-Id': userId, + 'MCP-Protocol-Version': PROTOCOL_VERSION, + 'Mcp-Method': method, + }; + if (toolName) h['Mcp-Name'] = toolName; + return h; +} + +interface ProgressMeta { + event?: string; + delta?: string; + iteration?: number | string; + 'max-iterations'?: number | string; + 'tool-calls'?: unknown; + reasoning?: string; +} + +const num = (v: unknown, fallback: number): number => { + const n = typeof v === 'string' ? Number.parseInt(v, 10) : v; + return typeof n === 'number' && Number.isFinite(n) ? n : fallback; +}; + +function toStage(event: string, meta: ProgressMeta): Stage | null { + switch (event) { + case 'agent/iteration-started': + return 'starting'; + case 'agent/turn-completed': { + const calls = JSON.stringify(meta['tool-calls'] ?? ''); + if (calls.includes('read_chunks')) return 'reading'; + if (calls.includes('search') || calls.includes('plan_queries')) return 'searching'; + return 'starting'; + } + case 'agent/thinking': + return 'writing'; + case 'agent/finalized': + return 'done'; + default: + return null; + } +} + +function queriesFrom(toolCalls: unknown): string[] | undefined { + const found: string[] = []; + const walk = (v: unknown): void => { + if (Array.isArray(v)) return v.forEach(walk); + if (v && typeof v === 'object') { + for (const [k, val] of Object.entries(v)) { + if (k === 'queries' && Array.isArray(val)) { + found.push(...val.filter((q): q is string => typeof q === 'string')); + } else walk(val); + } + } + }; + walk(toolCalls); + return found.length ? found : undefined; +} + +export function takeFrames(buffer: string): { frames: string[]; rest: string } { + const frames: string[] = []; + let rest = buffer; + let sep: number; + while ((sep = rest.indexOf('\n\n')) !== -1) { + frames.push(rest.slice(0, sep)); + rest = rest.slice(sep + 2); + } + return { frames, rest }; +} + +export function frameData(frame: string): Record | null { + const line = frame.split('\n').find((l) => l.startsWith('data: ')); + if (!line) return null; + try { + return JSON.parse(line.slice(6)) as Record; + } catch { + return null; + } +} + +export interface ResultChunk { + chunk_id?: string; + doc_num?: string; + chunk_index?: number; + title?: string; + url?: string; +} + +function safeHttpUrl(value: string): string { + try { + const u = new URL(value); + return u.protocol === 'https:' || u.protocol === 'http:' ? value : ''; + } catch { + return ''; + } +} + +/** `/documents/`, not `/files/` — the latter 404s. */ +function documentUrl(chunk: ResultChunk): string { + if (chunk.url) { + const safe = safeHttpUrl(chunk.url); + if (safe) return safe; + } + if (chunk.doc_num) return `${config.kudosBase}/documents/${chunk.doc_num}`; + return ''; +} + +export async function toSources( + chunks: ResultChunk[] | undefined, + lookup: (ids: string[]) => Promise> = excerpts, +): Promise { + if (!chunks?.length) return []; + const byDoc = new Map(); + for (const c of chunks) { + const key = c.doc_num || c.url; + if (!key) continue; + const entry = byDoc.get(key) ?? { chunk: c, chunkIds: [] }; + if (c.chunk_id) entry.chunkIds.push(c.chunk_id); + byDoc.set(key, entry); + } + + let text = new Map(); + try { + text = await lookup([...byDoc.values()].flatMap((e) => e.chunkIds)); + } catch {} + + return [...byDoc.values()].map(({ chunk, chunkIds }, i) => { + const passages = chunkIds.map((id) => text.get(id)).filter(Boolean) as string[]; + return { + docNum: chunk.doc_num ?? '', + title: chunk.title || `Dokument ${chunk.doc_num ?? ''}`.trim(), + url: documentUrl(chunk), + marker: i + 1, + ...(passages.length ? { excerpt: passages.join('\n\n') } : {}), + }; + }); +} + +/** Never accumulates the upstream body: that would stall the stream. */ +export async function* ask( + query: string, + userId: string, + conversationId: string | undefined, + signal: AbortSignal, + requestedTool?: string, + filterBy?: { fields: Array<{ field: string; 'selected-options': string[] }> }, +): AsyncGenerator { + const tool = requestedTool && allowedTools.has(requestedTool) ? requestedTool : config.tool; + const body = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: tool, + _meta: { progressToken: `ka-${Date.now()}` }, + arguments: { + query, + // Silently dropped unless the backend advertises filter support. + ...(filterBy ? { overrides: { 'retrieve-filter-by': filterBy } } : {}), + tenant: config.tenant, + dataset_config_key: config.datasetConfigKey, + ...(conversationId ? { conversation_id: conversationId } : {}), + }, + }, + }; + + const upstream = await fetch(`${config.apiBase}/api/mcp`, { + method: 'POST', + headers: { ...headers('tools/call', userId, tool), Accept: 'text/event-stream' }, + body: JSON.stringify(body), + signal, + }); + + if (!upstream.ok || !upstream.body) { + yield { type: 'error', message: `Backend svarte ${upstream.status}.` }; + return; + } + + const reader = upstream.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let lastStage: Stage | null = null; + let streamedAnyText = false; + let iteration = 0; + let maxIterations = 10; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const { frames, rest } = takeFrames(buffer); + buffer = rest; + + for (const frame of frames) { + const msg = frameData(frame); + if (!msg) continue; + + if (msg.result) { + const r = msg.result; + const sc = r.structuredContent ?? {}; + const meta = r._meta ?? {}; + const convo: string | undefined = sc.conversation_id ?? meta.conversation_id; + + if (r.isError) { + const text = r.content?.[0]?.text ?? 'Ukjent feil fra backend.'; + yield { type: 'error', message: text, conversationId: convo }; + return; + } + + if (!streamedAnyText) { + const full = r.content?.find((b: { type?: string }) => b.type === 'text')?.text; + if (full) yield { type: 'delta', text: full }; + } + + const sources = await toSources(sc.chunks); + if (sources.length) yield { type: 'sources', sources }; + yield { + type: 'done', + conversationId: convo ?? '', + insufficient: Boolean(meta.insufficient), + }; + return; + } + + const meta: ProgressMeta = msg.params?._meta ?? {}; + const event = meta.event; + if (!event) continue; + + iteration = num(meta.iteration, iteration); + maxIterations = num(meta['max-iterations'], maxIterations); + + if (event === 'response/chunk') { + if (lastStage !== 'writing') { + lastStage = 'writing'; + yield { type: 'stage', stage: 'writing', iteration, maxIterations }; + } + if (meta.delta) { + streamedAnyText = true; + yield { type: 'delta', text: meta.delta }; + } + continue; + } + + const stage = toStage(event, meta); + if (!stage || stage === lastStage) continue; + lastStage = stage; + yield { + type: 'stage', + stage, + iteration, + maxIterations, + ...(event === 'agent/turn-completed' + ? { queries: queriesFrom(meta['tool-calls']) } + : {}), + }; + } + } + + yield { type: 'error', message: 'Forbindelsen til backend ble brutt.' }; +} diff --git a/src/apps/server/src/server.ts b/src/apps/server/src/server.ts new file mode 100644 index 0000000..9c6feb0 --- /dev/null +++ b/src/apps/server/src/server.ts @@ -0,0 +1,279 @@ +import { serve } from '@hono/node-server'; +import { serveStatic } from '@hono/node-server/serve-static'; +import { Hono } from 'hono'; +import { csrf } from 'hono/csrf'; +import { secureHeaders } from 'hono/secure-headers'; +import type { AskRequest } from '@ka/contract'; +import { mountAuth, readUser, requireAuth } from './auth.ts'; +import { capabilities, probe, probeComplete } from './capabilities.ts'; +import { config, typesenseConfigured } from './config.ts'; +import { ask, setAllowedTools } from './mcp.ts'; +import * as convos from './conversations.ts'; +import { facets, toFilterBy } from './facets.ts'; +import { sessionMiddleware, type SessionVars } from './session.ts'; +import * as sourceStore from './sourceStore.ts'; + +const app = new Hono<{ Variables: SessionVars }>(); + +app.use('*', sessionMiddleware); + +const CDN = 'https://altinncdn.no'; + +app.use( + '*', + secureHeaders({ + contentSecurityPolicy: { + defaultSrc: ["'self'"], + baseUri: ["'none'"], + objectSrc: ["'none'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'", CDN], + fontSrc: ["'self'", 'data:', CDN], + imgSrc: ["'self'", 'data:'], + connectSrc: ["'self'"], + formAction: ["'self'"], + frameAncestors: ["'none'"], + }, + strictTransportSecurity: config.auth.origin.startsWith('https://') + ? 'max-age=31536000; includeSubDomains' + : false, + xFrameOptions: 'DENY', + }), +); + +// Only form-encoded posts are checked, so the JSON API keeps working. +app.use('*', csrf({ origin: config.auth.origin })); + +mountAuth(app); +app.use('/api/*', async (c, next) => + c.req.path === '/api/health' ? next() : requireAuth(c, next), +); + +app.get('/api/health', (c) => c.json({ ok: true })); + +app.get('/api/me', (c) => + c.json({ + userId: c.get('userId'), + user: c.get('user'), + authenticated: !config.auth.enabled || Boolean(c.get('user')), + authEnabled: config.auth.enabled, + backend: config.apiBase, + tool: config.tool, + }), +); + +app.get('/api/capabilities', (c) => + c.json({ capabilities: capabilities(), settled: probeComplete() }), +); + +app.get('/api/models', async (c) => { + try { + const res = await fetch(`${config.apiBase}/v1/models`, { + headers: { 'X-API-Key': config.apiKey }, + }); + if (!res.ok) return c.json({ models: [] }); + const body = (await res.json()) as { + data?: Array<{ + id: string; + description?: string; + _default?: boolean; + _mode?: string; + _agent_id?: string; + }>; + }; + setAllowedTools((body.data ?? []).map((m) => m.id)); + return c.json({ + models: (body.data ?? []).map((m) => { + const agent = ( + (m._agent_id ?? m.id.split('__')[0] ?? '').split(/[/.]/).pop() ?? m.id + ).replace(/-agent$/, ''); + const mode = (m._mode ?? m.id.split('__')[1] ?? '').replace(/^agent-rag-graph-/, ''); + // `description` is " — : ". + const description = (m.description ?? '').split(/\s+—\s+/)[0]?.trim(); + return { + id: m.id, + label: mode && mode !== agent ? `${agent} (${mode})` : agent, + isDefault: Boolean(m._default), + ...(description ? { description } : {}), + }; + }), + }); + } catch { + return c.json({ models: [] }); + } +}); + +app.get('/api/facets', async (c) => { + try { + return c.json({ facets: await facets() }); + } catch { + return c.json({ facets: [] }); + } +}); + +app.get('/api/conversations', async (c) => { + try { + return c.json({ conversations: await convos.list(c.get('userId')) }); + } catch { + return c.json({ error: 'Kunne ikke hente samtaler.' }, 502); + } +}); + +app.get('/api/conversations/:id', async (c) => { + try { + const detail = await convos.detail(c.get('userId'), c.req.param('id')); + return c.json({ ...detail, sources: sourceStore.recall(c.req.param('id')) }); + } catch { + return c.json({ error: 'Fant ikke samtalen.' }, 404); + } +}); + +app.put('/api/conversations/:id', async (c) => { + const { title } = (await c.req.json().catch(() => ({}))) as { title?: string }; + if (!title?.trim()) return c.json({ error: 'Tittel kan ikke være tom.' }, 400); + try { + await convos.rename(c.get('userId'), c.req.param('id'), title.trim()); + return c.json({ ok: true }); + } catch { + return c.json({ error: 'Kunne ikke endre navn.' }, 502); + } +}); + +app.delete('/api/conversations/:id', async (c) => { + try { + await convos.remove(c.get('userId'), c.req.param('id')); + sourceStore.forget(c.req.param('id')); + return c.json({ ok: true }); + } catch { + return c.json({ error: 'Kunne ikke slette samtalen.' }, 502); + } +}); + +app.post('/api/ask', async (c) => { + let body: AskRequest; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Ugyldig JSON.' }, 400); + } + + const query = (body.query ?? '').trim(); + if (!query) return c.json({ error: 'Spørsmålet kan ikke være tomt.' }, 400); + if (query.length > config.maxQueryLength) { + return c.json( + { error: `Spørsmålet er for langt (maks ${config.maxQueryLength} tegn).` }, + 400, + ); + } + + const filterBy = toFilterBy((body as { filter?: Record }).filter); + const model = + typeof (body as { model?: unknown }).model === 'string' + ? (body as { model: string }).model + : undefined; + const userId = c.get('userId'); + let conversationId = + typeof body.conversationId === 'string' ? body.conversationId : undefined; + + // Discarded on purpose: detail() is the ownership check. + if (conversationId) { + try { + await convos.detail(userId, conversationId); + } catch { + return c.json({ error: 'Fant ikke samtalen.' }, 404); + } + } + + let created: { id: string; topic: string } | null = null; + if (!conversationId) { + try { + const conv = await convos.create(userId, convos.topicFrom(query)); + conversationId = conv.id; + created = { id: conv.id, topic: conv.topic }; + } catch { + return c.json({ error: 'Kunne ikke opprette samtale.' }, 502); + } + } + + // Accumulating, compressing or dropping X-Accel-Buffering re-buffers the stream. + const encoder = new TextEncoder(); + const upstreamAbort = new AbortController(); + + const stream = new ReadableStream({ + async start(controller) { + const send = (obj: unknown) => + controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`)); + try { + if (created) send({ type: 'conversation', ...created }); + for await (const event of ask( + query, + userId, + conversationId, + upstreamAbort.signal, + model, + filterBy, + )) { + if (event.type === 'sources' && conversationId) { + sourceStore.remember(conversationId, event.sources); + } + send(event); + } + } catch (err) { + const message = + err instanceof Error && err.name === 'AbortError' + ? 'Avbrutt.' + : 'Uventet feil mot backend.'; + if (!(err instanceof Error && err.name === 'AbortError')) console.error(err); + send({ type: 'error', message }); + } finally { + controller.close(); + } + }, + cancel() { + upstreamAbort.abort(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); +}); + +if (config.webRoot) { + app.use('/assets/*', serveStatic({ root: config.webRoot })); + app.get('/favicon.ico', serveStatic({ root: config.webRoot, path: 'favicon.ico' })); + app.get('*', async (c, next) => { + if (config.auth.enabled && !(await readUser(c))) { + return c.redirect(`/auth/login?next=${encodeURIComponent(c.req.path)}`); + } + return next(); + }); + app.get('*', serveStatic({ root: config.webRoot, path: 'index.html' })); +} + +serve({ fetch: app.fetch, port: config.port }, (info) => { + console.log(`BFF på http://localhost:${info.port} → ${config.apiBase}`); + console.log(`verktøy: ${config.tool}`); + console.log(`datasett: ${config.tenant}/${config.datasetConfigKey}`); + console.log( + config.auth.mode === 'entra' + ? `innlogging: Entra ID, tenant ${config.auth.tenantId}` + : config.auth.mode === 'supabase' + ? `innlogging: engangslenke på e-post, midlertidig. Domener: ${config.auth.allowedDomains.join(', ')}` + : 'innlogging: AV. Usignert utviklings-id, ikke bruk dette utenfor egen maskin.', + ); + if (!typesenseConfigured) { + console.log('Typesense er ikke satt opp: ingen filtre og ingen utdrag i kildepanelet.'); + } + void probe().then((caps) => { + const on = Object.entries(caps) + .map(([k, v]) => `${v ? '+' : '-'}${k}`) + .join(' '); + console.log(`backend kan: ${on}`); + }); +}); diff --git a/src/apps/server/src/session.ts b/src/apps/server/src/session.ts new file mode 100644 index 0000000..a104692 --- /dev/null +++ b/src/apps/server/src/session.ts @@ -0,0 +1,37 @@ +/** The only place `X-User-Id` may be derived. */ +import { randomUUID } from 'node:crypto'; +import { getCookie } from 'hono/cookie'; +import { createMiddleware } from 'hono/factory'; +import { readUser, type User } from './auth.ts'; +import { config } from './config.ts'; + +export type SessionVars = { userId: string; user: User | null }; + +const COOKIE = 'ka_sid'; +const MAX_AGE = 60 * 60 * 24 * 30; + +export const sessionMiddleware = createMiddleware<{ Variables: SessionVars }>( + async (c, next) => { + if (config.auth.enabled) { + const user = await readUser(c); + c.set('user', user); + c.set('userId', user?.id ?? ''); + return next(); + } + + const existing = getCookie(c, COOKIE); + const sid = existing ?? randomUUID(); + c.set('userId', sid); + c.set('user', null); + + await next(); + + // setCookie's header is lost when a handler returns a raw Response. + if (!existing) { + c.res.headers.append( + 'Set-Cookie', + `${COOKIE}=${sid}; Max-Age=${MAX_AGE}; Path=/; HttpOnly; SameSite=Lax`, + ); + } + }, +); diff --git a/src/apps/server/src/sourceStore.ts b/src/apps/server/src/sourceStore.ts new file mode 100644 index 0000000..b46f04e --- /dev/null +++ b/src/apps/server/src/sourceStore.ts @@ -0,0 +1,17 @@ +import type { Source } from '@ka/contract'; + +const MAX_CONVERSATIONS = 500; +const store = new Map(); + +export function remember(conversationId: string, sources: Source[]): void { + if (!conversationId || !sources.length) return; + store.delete(conversationId); + store.set(conversationId, sources); + while (store.size > MAX_CONVERSATIONS) { + store.delete(store.keys().next().value as string); + } +} + +export const recall = (conversationId: string): Source[] => store.get(conversationId) ?? []; + +export const forget = (conversationId: string): void => void store.delete(conversationId); diff --git a/src/apps/server/src/supabaseAuth.test.ts b/src/apps/server/src/supabaseAuth.test.ts new file mode 100644 index 0000000..3250d86 --- /dev/null +++ b/src/apps/server/src/supabaseAuth.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { before, describe, test } from 'node:test'; + +process.env.DIGDIR_API_KEY ??= 'test-key'; +process.env.ALLOWED_EMAIL_DOMAINS ??= 'digdir.no'; + +let mod: typeof import('./supabaseAuth.ts'); + +before(async () => { + mod = await import('./supabaseAuth.ts'); +}); + +describe('throttled', () => { + test('an address that has not asked is not throttled', () => { + assert.equal(mod.throttled('fersk@digdir.no'), false); + }); +}); + +describe('verifyBlocked', () => { + test('an address that has not guessed is not blocked', () => { + assert.equal(mod.verifyBlocked('rolig@digdir.no'), false); + }); + + test('five wrong codes block the sixth attempt', () => { + const now = Date.now(); + for (let i = 0; i < 4; i++) mod.recordFailure('gjetter@digdir.no', now); + assert.equal(mod.verifyBlocked('gjetter@digdir.no', now), false); + mod.recordFailure('gjetter@digdir.no', now); + assert.equal(mod.verifyBlocked('gjetter@digdir.no', now), true); + }); + + test('the block lifts once the window has passed', () => { + const now = Date.now(); + for (let i = 0; i < 5; i++) mod.recordFailure('venter@digdir.no', now); + assert.equal(mod.verifyBlocked('venter@digdir.no', now + 10 * 60_000), false); + }); + + test('a correct code clears the count', () => { + const now = Date.now(); + for (let i = 0; i < 5; i++) mod.recordFailure('klarert@digdir.no', now); + mod.clearFailures('klarert@digdir.no'); + assert.equal(mod.verifyBlocked('klarert@digdir.no', now), false); + }); +}); + +describe('inline scripts', () => { + test('the pages carry no inline script for a CSP to allow', () => { + for (const html of [mod.loginPage('/'), mod.codePage('a@digdir.no', '/')]) { + assert.ok(!/]*\bsrc=)/.test(html)); + } + }); +}); + +describe('loginPage', () => { + test('the return path is escaped rather than interpolated raw', () => { + const html = mod.loginPage('/">'); + assert.ok(!html.includes('`); +} + +export async function userFromAccessToken(token: string): Promise { + let res: Response; + try { + res = await fetch(api('/user'), { + headers: { apikey: config.auth.supabasePublishableKey, Authorization: `Bearer ${token}` }, + }); + } catch { + return null; + } + if (!res.ok) return null; + const u = (await res.json().catch(() => ({}))) as { id?: string; email?: string }; + const email = u.email?.toLowerCase(); + if (!u.id || !email) return null; + if (!domainAllowed({ email })) return null; + return { id: `sb:${u.id}`, name: email.split('@')[0] ?? '', email }; +} + +export function loginPage(next: string, error?: string): string { + return page(`

Kunnskapsassistenten

+ ${error ? `

${esc(error)}

` : ''} +
+ + + + +
+

Du får en engangskode på e-post.

`); +} + +export function codePage(email: string, next: string, error?: string): string { + return page(`

Skriv inn koden

+ ${error ? `

${esc(error)}

` : ''} +

Sendt til ${esc(email)}.

+
+ + + + + +
+

Send på nytt

`); +} + +export function mountSupabaseAuth( + app: { + get: (p: string, h: (c: Context) => Promise | Response) => unknown; + post: (p: string, h: (c: Context) => Promise | Response) => unknown; + }, + deps: { + writeUser: (c: Context, u: User) => Promise; + clearUser: (c: Context) => void; + safeReturnTo: (v: string | undefined) => string; + }, +): void { + const origin = config.auth.origin; + const domains = config.auth.allowedDomains.join(', '); + + app.get('/auth/login', (c) => c.html(loginPage(deps.safeReturnTo(c.req.query('next'))))); + + app.post('/auth/login', async (c) => { + const f = await c.req.parseBody(); + const email = String(f.email ?? '') + .trim() + .toLowerCase(); + const next = deps.safeReturnTo(String(f.next ?? '/')); + + if (!domainAllowed({ email })) { + return c.html(loginPage(next, `Bare adresser på ${domains} har tilgang.`), 403); + } + if (throttled(email)) { + return c.html(loginPage(next, 'Lenke er allerede sendt. Vent ett minutt.'), 429); + } + const redirectTo = `${origin}/auth/callback?next=${encodeURIComponent(next)}`; + if (!(await sendLink(email, redirectTo))) { + return c.html(loginPage(next, 'Klarte ikke sende lenken. Prøv igjen.'), 502); + } + return c.html(codePage(email, next)); + }); + + app.post('/auth/code', async (c) => { + const f = await c.req.parseBody(); + const email = String(f.email ?? '') + .trim() + .toLowerCase(); + const code = String(f.code ?? '').trim(); + const next = deps.safeReturnTo(String(f.next ?? '/')); + + if (!domainAllowed({ email })) { + return c.html(loginPage(next, `Bare adresser på ${domains} har tilgang.`), 403); + } + if (verifyBlocked(email)) { + return c.html(codePage(email, next, 'For mange forsøk. Vent ti minutter.'), 429); + } + const user = await verifyCode(email, code); + if (!user) { + recordFailure(email); + return c.html(codePage(email, next, 'Feil eller utløpt kode.'), 401); + } + clearFailures(email); + await deps.writeUser(c, user); + return c.redirect(next); + }); + + app.get('/auth/handoff.js', (c) => + c.body(HANDOFF_SCRIPT, 200, { 'Content-Type': 'text/javascript; charset=utf-8' }), + ); + + app.get('/auth/callback', async (c) => { + const tokenHash = c.req.query('token_hash'); + const next = deps.safeReturnTo(c.req.query('next')); + if (!tokenHash) return c.html(fragmentHandoff(next)); + const user = await verifyLink(tokenHash, verifyType(c.req.query('type'))); + if (!user) return c.html(loginPage(next, 'Lenken er brukt opp eller utløpt.'), 401); + await deps.writeUser(c, user); + return c.redirect(next); + }); + + app.post('/auth/session', async (c) => { + const body = (await c.req.json().catch(() => ({}))) as { access_token?: string }; + if (!body.access_token) return c.json({ error: 'mangler token' }, 400); + const user = await userFromAccessToken(body.access_token); + if (!user) return c.json({ error: 'ugyldig token' }, 401); + await deps.writeUser(c, user); + return c.json({ ok: true }); + }); + + app.get('/auth/logout', (c) => { + deps.clearUser(c); + return c.redirect('/'); + }); +} diff --git a/src/apps/server/tsconfig.json b/src/apps/server/tsconfig.json new file mode 100644 index 0000000..2322097 --- /dev/null +++ b/src/apps/server/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "types": ["node"] }, + "include": ["src"] +} diff --git a/src/apps/web/index.html b/src/apps/web/index.html new file mode 100644 index 0000000..08cf794 --- /dev/null +++ b/src/apps/web/index.html @@ -0,0 +1,18 @@ + + + + + + Kunnskapsassistenten + + + +
+ + + diff --git a/src/apps/web/package.json b/src/apps/web/package.json new file mode 100644 index 0000000..92de51d --- /dev/null +++ b/src/apps/web/package.json @@ -0,0 +1,28 @@ +{ + "name": "@ka/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@digdir/designsystemet-css": "^1.21.1", + "@digdir/designsystemet-web": "^1.21.1", + "@ka/contract": "*", + "dompurify": "^3.2.7", + "marked": "^16.4.1", + "preact": "^10.27.2" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.2", + "@testing-library/preact": "^3.2.4", + "jsdom": "^30.0.1", + "typescript": "^5.9.3", + "vite": "^7.1.9", + "vitest": "^5.0.1" + } +} diff --git a/src/apps/web/src/App.tsx b/src/apps/web/src/App.tsx new file mode 100644 index 0000000..ef17d81 --- /dev/null +++ b/src/apps/web/src/App.tsx @@ -0,0 +1,368 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; +import type { ModelOption, Stage } from '@ka/contract'; +import { Filters, type Selection } from './Filters.tsx'; +import { CopyIcon } from './icons.tsx'; +import { Sidebar } from './Sidebar.tsx'; +import { SourcesPanel } from './SourcesPanel.tsx'; +import { useConversations } from './useConversations.ts'; +import { useTurn } from './useTurn.ts'; +import { renderMarkdown } from './markdown.ts'; + +const STAGE_LABEL: Record = { + starting: 'Tenker', + searching: 'Ser etter dokumenter', + reading: 'Leser kilder', + writing: 'Skriver svar', + done: 'Ferdig', +}; + +function Answer({ text }: { text: string }) { + return
; +} + +function AnswerBlock({ + text, + queries, + threadUrl, +}: { + text: string; + queries?: string[]; + threadUrl: string; +}) { + return ( +
+
+ + {queries?.length ? ( +

+ Nøkkelord: {queries.join(', ')} +

+ ) : null} +
+ + +
+
+ +
+ ); +} + +function NewThread() { + return ( +
+

+ Ny tråd +

+

+ Velg eventuelle filtre over, og still spørsmålet ditt under. Filtrene låses til tråden + når du har stilt det første spørsmålet. +

+
+ ); +} + +function Home() { + return ( +
+

+ Presis og pålitelig innsikt, skreddersydd for deg. +

+

Utforsk Kudos-dokumenter fra 2020–2025

+
    +
  • tildelingsbrev, årsrapporter
  • +
  • evalueringer, statusrapporter
  • +
  • proposisjoner til Stortinget
  • +
  • strategier og planer
  • +
+

Kjapt og enkelt.

+
+ ); +} + +function LoadingState({ + stage, + iteration, + maxIterations, +}: { + stage: Stage; + iteration: number; + maxIterations: number; +}) { + return ( +
+
+
+