Sesjon 3 av 3 — leverer Fase 7-9 av v1.11.0-planen. Fase 7 (Acme-rename på demo-state): - Rename "Acme AS" → "Acme Kommune" og "Demosystem" → "Acme Kunde-chatbot" konsistent på tvers av alle 17 fixtures. - build-demo-state.mjs: organization.name → "Acme Kommune", projects[0] → id "acme-kunde-chatbot" / name "Acme: Kunde-chatbot". - Re-bygd demo-state-v1-blokk i playground HTML. Fase 8 (Screenshots-regenerering): - 24 nye PNG-er under playground/screenshots/v1.11.0/ (12 surfaces × 2 tema, retina, fullPage). v1.10.0-mappen beholdt som historisk referanse. - tests/screenshot/run.mjs: OUT_DIR + kommentarer bumpet til v1.11.0. Fase 9 (Release: docs + versjonsbump): - plugin.json 1.10.1 → 1.11.0. - README.md (plugin): version-badge + Version History + screenshot-gallery refs + demo-data refs oppdatert. - CLAUDE.md (plugin): Playground-overskrift v3/v1.10.0 → v3/v1.11.0, Demo system-seksjon v1.10.1 → v1.11.0, screenshot-refs v1.10.0 → v1.11.0, "Inline CSS-kandidater" konvertert til "Design-system 100%-adoption" status. - Root README.md: ms-ai-architect-versjon 1.10.1 → 1.11.0, demo-tekst og Playground-tekst regenerert for v1.11.0, "271 PASS combined" → "278 PASS". Verifisering: - bash tests/run-e2e.sh --playground → 271/271 PASS (static + parsers). - bash tests/test-playground-migrations.sh → 7/7 PASS. - Total: 278/278 PASS, 0 FAIL. Refs: NEXT-SESSION-PROMPT.local.md (Sesjon 3 av 3, plan .claude/plans/jeg-skal-pr-ve-effervescent-token.md). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
5547 lines
301 KiB
HTML
5547 lines
301 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="nb" data-theme="dark">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>ms-ai-architect — Playground v3</title>
|
||
|
||
<!-- Theme bootstrap. Må kjøre før stylesheets parses for å unngå
|
||
flash-of-wrong-theme (FOUC). Prioritet:
|
||
1) lagret valg (localStorage 'ms-ai-architect-theme')
|
||
2) OS-preferanse via matchMedia('(prefers-color-scheme: dark)')
|
||
3) HTML-attributtets default ('dark')
|
||
Setter både data-theme + colorScheme for native form-controls/scrollbars.
|
||
Wrappes i try/catch — file:// + privatmodus kan blokkere localStorage. -->
|
||
<script>
|
||
(function () {
|
||
var theme = null;
|
||
try {
|
||
var saved = localStorage.getItem('ms-ai-architect-theme');
|
||
if (saved === 'light' || saved === 'dark') theme = saved;
|
||
} catch (e) { /* localStorage utilgjengelig */ }
|
||
if (!theme && window.matchMedia) {
|
||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||
}
|
||
if (!theme) theme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
document.documentElement.style.colorScheme = theme;
|
||
})();
|
||
</script>
|
||
|
||
<!-- Vendored design-system. Kilden er shared/playground-design-system/ — synces via
|
||
scripts/sync-design-system.mjs ved marketplace-rot. Aldri rediger filer under
|
||
playground/vendor/ direkte; endringer går i shared/ + re-sync. -->
|
||
<link rel="stylesheet" href="vendor/playground-design-system/fonts.css">
|
||
<link rel="stylesheet" href="vendor/playground-design-system/tokens.css">
|
||
<link rel="stylesheet" href="vendor/playground-design-system/base.css">
|
||
<link rel="stylesheet" href="vendor/playground-design-system/components.css">
|
||
<link rel="stylesheet" href="vendor/playground-design-system/components-tier2.css">
|
||
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3.css">
|
||
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3-supplement.css">
|
||
|
||
<!-- App-shell layout. Vendored design-system levner komponent-CSS;
|
||
her bor kun side-spesifikk layout-grid (sidebar+main, modals, sub-cards).
|
||
Kompakt med vilje — ingen komponent-CSS skal duplikeres her. -->
|
||
<style>
|
||
main#app { min-height: 100vh; padding: 0; }
|
||
|
||
/* Hidden-attribute respekt. Vendored .error-summary, .modal-backdrop osv.
|
||
setter eksplisitt display, som overstyrer HTMLs default [hidden] {display:none}.
|
||
Globalt override slik at hidden-attributt faktisk skjuler elementet. */
|
||
[hidden] { display: none !important; }
|
||
|
||
/* .app-shell + .app-shell--wide hentet fra vendored DS v0.3 (tier3-supplement section 25) */
|
||
|
||
/* App-header (.app-header*) hentet fra vendored DS (components.css). */
|
||
|
||
/* Onboarding-layout: sidebar + main */
|
||
.onboarding-layout { display: grid; grid-template-columns: 280px 1fr; gap: var(--space-6); align-items: start; }
|
||
@media (max-width: 880px) { .onboarding-layout { grid-template-columns: 1fr; } .form-progress { position: static; width: auto; } }
|
||
.onboarding-header { margin-bottom: var(--space-5); }
|
||
.onboarding-header h1 { font-size: var(--font-size-2xl); margin: 0 0 var(--space-2); }
|
||
.onboarding-header p { color: var(--color-text-secondary); margin: 0; max-width: 60ch; }
|
||
.onboarding-groups { display: flex; flex-direction: column; gap: var(--space-3); margin-bottom: var(--space-6); }
|
||
.onboarding-fields { display: flex; flex-direction: column; gap: var(--space-4); padding: var(--space-2) 0; }
|
||
|
||
/* Form-patterns (.field-row, .field-label, .field-help, .multi-select,
|
||
.checkbox-row, .required-mark) hentet fra vendored DS v0.3 (tier3-supplement section 21) */
|
||
|
||
.onboarding-actions { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) 0; flex-wrap: wrap; }
|
||
.onboarding-help { font-size: var(--font-size-sm); color: var(--color-text-tertiary); }
|
||
|
||
/* Home + project list */
|
||
.home-hero { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-5); }
|
||
.home-hero h1 { font-size: var(--font-size-3xl); }
|
||
.home-hero p { color: var(--color-text-secondary); }
|
||
.home-section-head { display: flex; align-items: baseline; justify-content: space-between; margin: var(--space-6) 0 var(--space-3); }
|
||
.home-section-head h2 { font-size: var(--font-size-xl); }
|
||
.home-section-head .home-section-meta { color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
|
||
|
||
/* Project surface */
|
||
.project-header { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-5) 0 var(--space-4); border-bottom: 1px solid var(--color-border-subtle); margin-bottom: var(--space-5); }
|
||
.project-header__top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
|
||
.project-header__title { font-size: var(--font-size-2xl); margin: 0; }
|
||
.project-header__meta { display: flex; flex-wrap: wrap; gap: var(--space-3); font-size: var(--font-size-sm); color: var(--color-text-secondary); }
|
||
.project-header__chip { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: var(--radius-sm); background: var(--color-bg-soft); color: var(--color-text-secondary); font-size: var(--font-size-xs); font-family: var(--font-family-mono); }
|
||
.project-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--color-border-subtle); margin-bottom: var(--space-5); flex-wrap: wrap; }
|
||
.project-tab { background: transparent; border: 0; padding: 10px 16px; cursor: pointer; font-family: inherit; font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); color: var(--color-text-secondary); border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||
.project-tab:hover { color: var(--color-text-primary); }
|
||
.project-tab[aria-current="true"] { color: var(--color-text-primary); border-bottom-color: var(--color-primary-500); }
|
||
.project-tab__count { display: inline-block; margin-left: 6px; padding: 1px 6px; background: var(--color-bg-soft); border-radius: 10px; font-size: 11px; color: var(--color-text-tertiary); }
|
||
.command-cards { display: flex; flex-direction: column; gap: var(--space-4); }
|
||
/* .card + .card__* hentet fra vendored DS (base.css + tier3-supplement). */
|
||
.sub-zone { border-top: 1px solid var(--color-border-subtle); padding-top: var(--space-3); }
|
||
.sub-zone__heading { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-tertiary); margin: 0 0 var(--space-2); }
|
||
.paste-import-row { display: flex; flex-direction: column; gap: var(--space-2); }
|
||
.paste-import-row__actions { display: flex; gap: var(--space-2); align-items: center; }
|
||
.form-zone-placeholder { padding: var(--space-3); background: var(--color-bg-soft); border-radius: var(--radius-sm); font-size: var(--font-size-sm); color: var(--color-text-tertiary); font-style: italic; }
|
||
.report-slot { min-height: 24px; }
|
||
.report-slot:empty::before { content: "Ingen importert rapport ennå."; font-size: var(--font-size-sm); color: var(--color-text-tertiary); font-style: italic; }
|
||
|
||
/* Modal */
|
||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; padding: var(--space-4); }
|
||
.modal { background: var(--color-surface); border-radius: var(--radius-lg); padding: var(--space-5); max-width: 560px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: var(--shadow-lg); display: flex; flex-direction: column; gap: var(--space-4); }
|
||
.modal--wide { max-width: 760px; }
|
||
.modal__title { margin: 0; font-size: var(--font-size-xl); }
|
||
.modal__actions { display: flex; gap: var(--space-2); justify-content: flex-end; padding-top: var(--space-3); border-top: 1px solid var(--color-border-subtle); }
|
||
[data-theme="dark"] .modal-backdrop { background: rgba(0,0,0,0.7); }
|
||
|
||
/* Command form (Step 8) */
|
||
.command-form { display: flex; flex-direction: column; gap: var(--space-3); }
|
||
.command-form__fields { display: flex; flex-direction: column; gap: var(--space-3); }
|
||
.command-form__actions { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; padding-top: var(--space-2); border-top: 1px dashed var(--color-border-subtle); }
|
||
.command-form__hint { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||
.command-form__copy-confirm { font-size: var(--font-size-xs); color: var(--color-text-secondary); }
|
||
.field-from-tag { display: inline-block; padding: 1px 6px; background: var(--color-bg-soft); color: var(--color-text-tertiary); border-radius: var(--radius-sm); font-size: 10px; font-weight: var(--font-weight-medium); margin-left: 6px; letter-spacing: 0.04em; text-transform: uppercase; }
|
||
.form-preview { padding: var(--space-3); background: var(--color-bg-soft); border-radius: var(--radius-sm); margin-top: var(--space-2); overflow-x: auto; }
|
||
.form-preview__heading { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-tertiary); margin: 0 0 var(--space-2); }
|
||
.code-block { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-text-primary); white-space: pre-wrap; word-break: break-all; margin: 0; }
|
||
|
||
/* Catalog (Step 9) */
|
||
.catalog-header { display: flex; flex-direction: column; gap: var(--space-2); margin: var(--space-3) 0 var(--space-4); }
|
||
.catalog-header h1 { font-size: var(--font-size-2xl); margin: 0; }
|
||
.catalog-header p { color: var(--color-text-secondary); margin: 0; max-width: 70ch; }
|
||
.catalog-toolbar { display: flex; gap: var(--space-3); align-items: center; margin-bottom: var(--space-4); flex-wrap: wrap; }
|
||
.catalog-toolbar .input { max-width: 480px; flex: 1 1 280px; }
|
||
.catalog-toolbar__count { font-size: var(--font-size-sm); color: var(--color-text-tertiary); }
|
||
.catalog-groups { display: flex; flex-direction: column; gap: var(--space-3); }
|
||
.catalog-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--space-3); padding: var(--space-2) 0; }
|
||
/* .card + .card__* hentet fra vendored DS (base.css + tier3-supplement). */
|
||
.catalog-tool-notice { padding: var(--space-2) var(--space-3); background: var(--color-bg-soft); border-left: 3px solid var(--color-primary-500); border-radius: var(--radius-sm); font-size: var(--font-size-xs); color: var(--color-text-secondary); }
|
||
|
||
/* Foundation .page__*, .key-stats, .key-stat--{level} hentet fra vendored DS (tier3-supplement section 14-15).
|
||
.top-risks*, .top-risk*, .recommendation-card* hentet fra DS section 18-19.
|
||
.pair-before-after*, .pyramide-tier-detail*, .tab-list/.tab/.tab-panel også fra DS. */
|
||
|
||
/* .verdict-pill: plugin-domain semantikk (go/block/approved/allow/warning/n-a) — distinkt fra
|
||
DS .verdict-pill-lg (severity-band: critical/high/medium/low/positive). Domain-pillen brukes
|
||
av architect-rapporter for GO/BLOCK-beslutninger; severity-pillen brukes for risiko-band. */
|
||
.verdict-pill { display: inline-flex; align-items: center; padding: var(--space-2) var(--space-4); border-radius: var(--radius-pill); font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.06em; white-space: nowrap; flex-shrink: 0; }
|
||
.verdict-pill[data-verdict="go"],
|
||
.verdict-pill[data-verdict="approved"],
|
||
.verdict-pill[data-verdict="allow"] { background: var(--color-state-success); color: #fff; }
|
||
.verdict-pill[data-verdict="go-with-conditions"],
|
||
.verdict-pill[data-verdict="warning"] { background: var(--color-severity-medium); color: var(--color-severity-medium-on); }
|
||
.verdict-pill[data-verdict="block"],
|
||
.verdict-pill[data-verdict="failed"] { background: var(--color-severity-critical); color: var(--color-severity-critical-on); }
|
||
.verdict-pill[data-verdict="n-a"] { background: var(--color-bg-soft); color: var(--color-text-secondary); border: 1px solid var(--color-border-subtle); }
|
||
|
||
/* .scenario-card[data-status="met/partial/missing"]: plugin-spesifikke 3-stadie-status —
|
||
DS har kun "winner". Beholdt for AI Act / cost-distribution / capability-matrix-renderers. */
|
||
.scenario-card[data-status="met"] { border-left: 4px solid var(--color-state-success); }
|
||
.scenario-card[data-status="partial"] { border-left: 4px solid var(--color-severity-medium); }
|
||
.scenario-card[data-status="missing"] { border-left: 4px solid var(--color-severity-critical); }
|
||
|
||
/* AI Act-pyramide-overrides: bumpe label-font så tier-tekst ikke klippes,
|
||
sikre tilstrekkelig parent-bredde (DS-default har ingen min-width). */
|
||
.pyramide { min-width: 480px; max-width: 100%; }
|
||
.pyramide__tier { font-size: var(--font-size-md); padding: var(--space-3) var(--space-4); }
|
||
@media (max-width: 560px) { .pyramide { min-width: 0; } .pyramide__tier { font-size: var(--font-size-sm); padding: 8px 12px; } }
|
||
|
||
/* .read-more-block + .suppressed-panel: native <details>-baserte mønstre — distinkte fra
|
||
DS .read-more og .suppressed (som bruker JS-toggled aria-expanded). */
|
||
.read-more-block { margin: var(--space-2) 0; }
|
||
.read-more-block summary { cursor: pointer; color: var(--color-text-link); font-weight: var(--font-weight-medium); }
|
||
.suppressed-panel { margin: var(--space-4) 0 0 0; padding: var(--space-3) var(--space-4); background: var(--color-bg-soft); border: 1px dashed var(--color-border-subtle); border-radius: var(--radius-md); opacity: 0.85; }
|
||
.suppressed-panel summary { cursor: pointer; color: var(--color-text-secondary); font-weight: var(--font-weight-medium); font-size: var(--font-size-sm); }
|
||
.suppressed-panel[open] summary { margin-bottom: var(--space-2); }
|
||
.suppressed-panel__list { display: flex; flex-direction: column; gap: var(--space-2); margin: var(--space-2) 0 0 0; }
|
||
.suppressed-panel__item { padding: var(--space-2) var(--space-3); background: var(--color-surface); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm); font-size: var(--font-size-sm); color: var(--color-text-secondary); display: flex; gap: var(--space-3); align-items: baseline; }
|
||
.suppressed-panel__id { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- Walking-skeleton: 4 placeholder-overflater. Step 5-7 fyller dem ut.
|
||
Bare én av disse er aktiv om gangen via state.activeSurface. -->
|
||
<main id="app">
|
||
<section id="surface-onboarding" data-surface="onboarding" hidden></section>
|
||
<section id="surface-home" data-surface="home" hidden></section>
|
||
<section id="surface-catalog" data-surface="catalog" hidden></section>
|
||
<section id="surface-project" data-surface="project" hidden></section>
|
||
</main>
|
||
|
||
<!-- Inlined demo-state for "Last inn demo-data"-knapp. Bygges av
|
||
scripts/build-demo-state.mjs fra playground/test-fixtures/*.md.
|
||
IKKE rediger manuelt — kjør skriptet på nytt. -->
|
||
<script type="application/json" id="demo-state-v1">
|
||
{
|
||
"schemaVersion": 1,
|
||
"dataVersion": 2,
|
||
"shared": {
|
||
"organization": {
|
||
"name": "Acme Kommune",
|
||
"description": "Mellomstor norsk kommune med ~8 000 ansatte. Ansvar for skole, helse, byggesak og digitalisering. Bruker pluginen for å vurdere AI-tjenester før innføring.",
|
||
"sector": "Kommunal",
|
||
"size": "8 000"
|
||
},
|
||
"regulatory": {
|
||
"regulatory_requirements": "GDPR/Personopplysningsloven, Sikkerhetsloven, Forvaltningsloven, Arkivloven, Helseregisterloven (for helsetjenestene)",
|
||
"ai_act_role": "deployer",
|
||
"risk_level": "high"
|
||
},
|
||
"technology": {
|
||
"cloud_platform": "Azure (Norge Øst), M365 E5, on-prem datasenter for kommunale fagsystem",
|
||
"license_type": "M365 E5 (alle ansatte) + Azure Enterprise Agreement + Power Platform per app",
|
||
"ai_services_in_use": "Azure OpenAI (GPT-4o), Azure AI Search, Copilot for M365 (pilot 50 brukere), Power Automate AI Builder"
|
||
},
|
||
"security": {
|
||
"data_classification": [
|
||
"Åpen",
|
||
"Intern",
|
||
"Fortrolig"
|
||
],
|
||
"data_residency": "EU/EØS — fortrinnsvis Norge",
|
||
"dpia_practice": "Sentralt personvernombud + kommune-DPO. Mal etter Datatilsynet. DPIA er obligatorisk for alle nye AI-tjenester som behandler personopplysninger.",
|
||
"certifications": "ISO 27001, NSM grunnprinsipper for IKT-sikkerhet, Digdir Trygg-pilot"
|
||
},
|
||
"architecture": {
|
||
"preferred_platform": "Azure AI Foundry (for nye løsninger), Copilot Studio (for low-code agenter)",
|
||
"integration_needs": "M365, Public 360 (sak/arkiv), KOMTEK (byggesak), Visma Enterprise HRM, REST API mot folkeregister og matrikkel",
|
||
"annual_ai_budget": "3 MNOK (2026), forventet 5 MNOK (2027)"
|
||
},
|
||
"business": {
|
||
"governance_model": "Sentralt AI-råd ledes av digitaliseringsdirektør. Beslutninger over 500 kNOK eskalerer til CIO. Tillitsvalgt og personvernombud inkluderes i alle høyrisiko-vurderinger.",
|
||
"doc_format_preferences": "Markdown for tekniske dokumenter, PDF for styringsdokumenter, Confluence for arbeidsdokumenter",
|
||
"reference_architecture": "TOGAF-tilpasset, Digdir arkitekturprinsipper, intern Confluence /arkitektur"
|
||
}
|
||
},
|
||
"projects": [
|
||
{
|
||
"id": "acme-kunde-chatbot",
|
||
"name": "Acme: Kunde-chatbot",
|
||
"description": "AI-chatbot som hjelper innbyggere med byggesak-spørsmål. Trenger DPIA, ROS, EU AI Act-klassifisering og kostnadsestimat før beslutning. Alle 17 rapport-typer er pre-importert med eksempel-data.",
|
||
"scenarios": [
|
||
"Chatbot/agent",
|
||
"Beslutningsstøtte"
|
||
],
|
||
"createdAt": "2026-05-04T08:00:00.000Z",
|
||
"reports": {
|
||
"classify": {
|
||
"input": {},
|
||
"raw_markdown": "# EU AI Act — Klassifisering: Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nBeskrivelse: AI-system som identifiserer objekter som krever oppfølging via sensordata + objektregister\n\n## Risikonivå\n\nRisk-level: høy\n\n## Rolle\n\nRolle: Provider og Deployer (utvikler internt + drifter selv)\n\n## Begrunnelse\n\nReasoning: Systemet brukes av offentlig myndighet for håndheving av lov, og påvirker individers rettigheter direkte gjennom automatisert beslutningsstøtte for håndtering. Dette plasserer systemet under Annex III, punkt 6 (rettshåndhevelse) og krever full høyrisiko-compliance per Art. 6(2).\n\n## Forpliktelser\n\n- Risk management system per Art. 9\n- Data governance og -kvalitet per Art. 10\n- Teknisk dokumentasjon per Art. 11\n- Logging og sporbarhet per Art. 12\n- Transparens overfor deployer per Art. 13\n- Menneskelig oversikt per Art. 14\n- Robusthet, sikkerhet og nøyaktighet per Art. 15\n- FRIA (Fundamental Rights Impact Assessment) per Art. 27 — obligatorisk for offentlig sektor\n- Registrering i EU-database per Art. 49\n- Conformity assessment per Art. 43\n\n## Frist\n\nFull compliance innen 2027-08-02 (Annex III høyrisiko full compliance).\n"
|
||
},
|
||
"requirements": {
|
||
"input": {},
|
||
"raw_markdown": "# EU AI Act — Krav for høyrisiko provider+deployer\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nKlassifisering: høy risiko, rolle Provider+Deployer\n\n## Krav\n\n| Krav | Status | Kilde |\n|------|--------|-------|\n| Risk Management System etablert og dokumentert | partial | Art. 9 |\n| Treningsdata-governance med kvalitetssjekker | met | Art. 10 |\n| Teknisk dokumentasjon (Annex IV) komplett | partial | Art. 11 |\n| Automatisk logging av hendelser implementert | met | Art. 12 |\n| Transparens-instruksjoner for deployer skrevet | missing | Art. 13 |\n| Human-in-the-loop på alle sanksjonsavgjørelser | met | Art. 14 |\n| Nøyaktighetsmål med stratifisert testing | partial | Art. 15 |\n| Cybersikkerhetstiltak verifisert (NSM Grunnprinsipper) | met | Art. 15 |\n| FRIA gjennomført før idriftsettelse | missing | Art. 27 |\n| Registrering i EU-database planlagt | missing | Art. 49 |\n| Conformity assessment per Annex VI gjennomført | missing | Art. 43 |\n| CE-merking utført før markedsføring | missing | Art. 48 |\n| Post-market monitoring system etablert | partial | Art. 72 |\n| Avviksrapportering til myndigheter rutinert | partial | Art. 73 |\n\n## Sammendrag\n\n- 4 krav er møtt (met)\n- 4 krav er delvis møtt (partial)\n- 6 krav mangler implementering (missing)\n\nPrioritering: FRIA og transparens-instruksjoner må adresseres før idriftsettelse 2027-08-02.\n"
|
||
},
|
||
"transparency": {
|
||
"input": {},
|
||
"raw_markdown": "# Transparensnotis — Acme Kunde-chatbot\n\nTittel: Informasjon om automatisert operasjonell analyse (Art. 13 og Art. 50)\n\n## Hva systemet gjør\n\nAcme Kommune bruker et AI-system som leser av objekt-ID (Acme Kunde-chatbot — automatisert klassifisering) fra sensordata langs produksjonsmiljøet. Systemet identifiserer objekter som har overtrådt terskelverdi gjennom å beregne gjennomsnittlig respons mellom to datapunkt.\n\n## Hvilke data som behandles\n\nBehandlede data inkluderer objekt-ID, tidsstempel, datapunkt, objektklasse og oppslag i Acme Kommune objektregister. Personlig identifiserbar informasjon kobles ikke til oppføring uten saksbehandler eksplisitte godkjenning.\n\n## Hvordan beslutninger tas\n\nSystemet er beslutningsstøtte, ikke -taker. Hver flagged hendelse går til menneskelig saksbehandler som tar endelig avgjørelse om gebyr eller anmeldelse. AI-output inkluderer konfidensgrad og forklaring av hvorfor saken ble flagget.\n\n## Dine rettigheter\n\nSom registrert har du rett til innsyn (GDPR Art. 15), retting (Art. 16), sletting (Art. 17 — med begrensninger ved lovhjemmel), og å klage til Datatilsynet. Du kan også be om manuell vurdering uten AI-bistand per GDPR Art. 22.\n\n## Kontakt\n\nPersonvernombud: pvo@Acme.no\nTilsyn: Datatilsynet — postkasse@datatilsynet.no\nEU AI Act-tilsyn: under etablering (Digitaliseringsdirektoratet er forventet)\n"
|
||
},
|
||
"frimpact": {
|
||
"input": {},
|
||
"raw_markdown": "# FRIA (Fundamental Rights Impact Assessment) — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nHjemmel: EU AI Act Art. 27 (obligatorisk for offentlig sektor)\n\n## Vurderte rettigheter\n\n| Rettighet | Impact | Tiltak |\n|-----------|--------|--------|\n| Menneskeverd | 1 | Ingen reduksjon — saksbehandler tar endelig avgjørelse, ikke AI |\n| Rett til frihet og sikkerhet | 1 | Ingen frihetsberøvelse direkte fra AI; politi/domstol er reell beslutter |\n| Respekt for privatliv | 4 | Massiv overvåking via veikameraer — kompenseres med strenge oppbevaringsregler (90 dager), formålsbegrensning, og minimering av kobling til objektregister |\n| Personvern | 4 | DPIA gjennomført; Datatilsynet konsultert; rettslig grunnlag i interne retningslinjer §13 — likevel høy impact pga skala |\n| Ikke-diskriminering | 3 | Algoritmisk bias-testing på objekt-ID fra utenlandske registre (lavere Acme Kunde-chatbot-nøyaktighet) — kvartalsvis review |\n| Ytringsfrihet og informasjonsfrihet | 0 | Ikke berørt |\n| Forsamlingsfrihet | 0 | Ikke berørt |\n| Religionsfrihet | 0 | Ikke berørt |\n| Eiendomsrett | 2 | Gebyr/sanksjoner berører eiendomsrett — kompenseres med klagemulighet og rettslig prøving |\n| Rett til effektivt rettsmiddel | 2 | Klageadgang sikret; menneskelig review garantert; AI-forklaring tilgjengelig for klager |\n| Barns rettigheter | 1 | Lav direkte påvirkning; barn er sjelden registrerte førere |\n| Eldres rettigheter | 2 | Eldre kan ha vanskeligere for å klage digitalt — papir-klage må fortsatt være tilgjengelig |\n\n## Konklusjon\n\nTre rettigheter har høy impact (3-4): privatliv, personvern og ikke-diskriminering. Tiltakene reduserer reell risiko, men FRIA må re-evalueres årlig per Art. 27(2).\n"
|
||
},
|
||
"conformity": {
|
||
"input": {},
|
||
"raw_markdown": "# Samsvarsvurdering (Art. 43) — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nVurderingsprosedyre: Annex VI (intern kontroll)\n\n## Sjekkliste\n\n| Krav | Status | Bevis |\n|------|--------|-------|\n| Risk Management System dokumentert | bestått | RMS-rapport v2.1 (2026-04-15) |\n| Treningsdata-governance med kvalitetskriterier | bestått | Data-governance handbook §4.2 |\n| Teknisk dokumentasjon Annex IV komplett | betinget | Mangler ytelsesmål per stratum |\n| Logging av hendelser implementert | bestått | OpenTelemetry-spans i Azure Monitor |\n| Transparens-instruksjoner skrevet | avvist | Skal leveres innen 2026-09-01 |\n| Menneskelig oversikt på saksbehandler | bestått | Workflow-design godkjent av juridisk |\n| Nøyaktighetsmål dokumentert | betinget | 96.3% overall, men ikke per objekt-ID-region |\n| Robusthet under adversarielle forhold | betinget | Test-suite mangler skitne plater og natt-scenarier |\n| Cybersikkerhetstiltak per Art. 15 | bestått | NSM Grunnprinsipper-vurdering bestått |\n| Conformity assessment underskrevet | avvist | Avhengig av FRIA-resultat |\n| EU declaration of conformity utstedt | avvist | Avhenger av Art. 47 |\n| CE-merking påført | avvist | Markedsplassering ikke aktuell (intern bruk) — vurder om Art. 48 gjelder |\n\n## Frister\n\n| Dato | Milepæl | Status |\n|------|---------|--------|\n| 2026-08-02 | GPAI-krav + Annex III høyrisiko | upcoming |\n| 2026-09-01 | Transparens-instruksjoner ferdigstilt | upcoming |\n| 2027-02-01 | FRIA og DPIA-revisjon | upcoming |\n| 2027-08-02 | Full Annex III høyrisiko-compliance | upcoming |\n\n## Konklusjon\n\n5 av 12 krav er fullt møtt; 4 er delvis møtt; 3 mangler implementering. Critical path: transparens-instruksjoner (Art. 13) blokkerer conformity declaration.\n"
|
||
},
|
||
"dpia": {
|
||
"input": {},
|
||
"raw_markdown": "# DPIA / PVK — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nMetodikk: Datatilsynets veileder + ISO/IEC 29134\n\n## Risikomatrise (5×5)\n\n| Trussel | Sannsynlighet | Konsekvens | Score | Nivå |\n|---------|---------------|------------|-------|------|\n| Feilaktig objekt-ID-tolkning fører til urettmessig sanksjon | 3 | 4 | 12 | medium |\n| Massiv lokasjonsdata-lekkasje fra objektregister | 2 | 5 | 10 | medium |\n| AI-forklaring viser sensitiv kontekst om eier | 3 | 3 | 9 | medium |\n| Stratifisert bias mot utenlandske objekt-ID | 4 | 3 | 12 | medium |\n| Fysisk angrep på sensordata skaper deteksjonshull | 2 | 2 | 4 | low |\n| Insider-misbruk for sporing av enkeltpersoner | 2 | 5 | 10 | medium |\n| Auto-flagging utløser kjedereaksjon ved system-feil | 1 | 5 | 5 | low |\n| Subject Access Request (GDPR Art. 15) ignoreres | 3 | 3 | 9 | medium |\n\n## Trusler\n\n| ID | Beskrivelse | Severity | Tiltak |\n|----|-------------|----------|--------|\n| T-001 | Feilaktig OCR av objekt-ID | high | Konfidensgrad-cutoff på 0.95; saksbehandler-review under cutoff |\n| T-002 | Lokasjonsdata-lekkasje | critical | Pseudonymisering ved lagring; HSM-backed nøkler i Azure Key Vault |\n| T-003 | Kontekst-eksponering i AI-forklaring | high | Filter på sensitive felt; kontekst kun til autorisert saksbehandler |\n| T-004 | Bias mot utenlandske registre | high | Kvartalsvis stratifisert testing; juster modell ved >5% avvik |\n| T-005 | Insider-misbruk | critical | Audit-logging på alle oppslag; SIEM-deteksjon av unormale mønstre |\n\n## Tiltak\n\n| ID | Tiltak | Status | Eier |\n|----|--------|--------|------|\n| M-001 | Cutoff-konfidensgrad implementert | done | Tech Lead |\n| M-002 | Pseudonymisering pilotert | in-progress | Sikkerhetsarkitekt |\n| M-003 | Bias-test-pipeline etablert | planned | Data Scientist |\n| M-004 | Audit-logging utrullet | done | Drift |\n| M-005 | SIEM-regler kalibrert | in-progress | SOC |\n\n## Konklusjon\n\nRestrisiko: 4×3 → 2×2\n\nRestrisiko etter tiltak: medium-lav. DPIA godkjent av Datatilsynet 2026-04-22.\n"
|
||
},
|
||
"security": {
|
||
"input": {},
|
||
"raw_markdown": "# Sikkerhetsvurdering 6×5 — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nRammeverk: NSM Grunnprinsipper + Microsoft Cloud Security + EU AI Act Art. 15\n\n## Score per dimensjon\n\n| Dimensjon | Score | Vurdering |\n|-----------|-------|-----------|\n| Identitet og tilgang | 4 | Entra ID med MFA, conditional access; mangler PIM på enkelte serviceprinciper |\n| Datasikkerhet og personvern | 3 | Customer-managed keys, pseudonymisering pilotert; full Customer Lockbox ikke aktivert |\n| Modell- og prompt-sikkerhet | 3 | Content filters aktivert; jailbreak-deteksjon via Azure AI Content Safety; ingen red-team-runde gjort |\n| Nettverk og perimeter | 5 | Private Endpoint mot alle Azure AI-tjenester; ingen offentlig eksponering |\n| Logging og hendelseshåndtering | 4 | OpenTelemetry → Sentinel; SOC integrert; mangler automatisk avviksdeteksjon for AI-output |\n| Operasjonell og leverandørsikkerhet | 3 | Hovedleverandører verifisert; mangler third-party penetrasjons-test siste 12 mnd |\n\n## Risikomatrise (6×5)\n\n| Risiko | Sannsynlighet | Konsekvens | Score |\n|--------|---------------|------------|-------|\n| Lekkasje av treningsdata | 2 | 5 | 10 |\n| Prompt injection i forklaringsmodell | 3 | 3 | 9 |\n| Modell-tyveri (model extraction) | 2 | 3 | 6 |\n| Adversarielt eksempel forgifter output | 2 | 4 | 8 |\n| Cloud-leverandør-utilgjengelighet | 2 | 4 | 8 |\n| Insider-trussel (unauthorized inference) | 2 | 5 | 10 |\n\n## Funn\n\n| ID | Severity | Lokasjon | Anbefaling |\n|----|----------|----------|------------|\n| S-01 | high | Identity | Aktivér PIM på alle serviceprinciper innen 2026-06-01 |\n| S-02 | medium | Data | Aktivér Customer Lockbox for operasjonelle data |\n| S-03 | high | Model | Gjennomfør formell red-team-runde med Azure AI Red Team-veiledning |\n| S-04 | low | Network | Periodisk verifikasjon av Private Endpoint-konfigurasjon |\n| S-05 | medium | Logging | Implementer ML-basert avviksdeteksjon på AI-output-rate |\n| S-06 | medium | Vendor | Bestilt third-party penetrasjons-test for Q3 2026 |\n\n## Top-risikoer\n\n| ID | Risiko | Score | Severity |\n|----|--------|-------|----------|\n| R-01 | Lekkasje av treningsdata | 10 | high |\n| R-02 | Insider-trussel (unauthorized inference) | 10 | high |\n| R-03 | Prompt injection i forklaringsmodell | 9 | high |\n| R-04 | Adversarielt eksempel forgifter output | 8 | medium |\n| R-05 | Cloud-leverandør-utilgjengelighet | 8 | medium |\n\n## Kategori-snitt\n\n| Kategori | Snitt |\n|----------|-------|\n| Identitet og tilgang | 4 |\n| Datasikkerhet og personvern | 3 |\n| Modell- og prompt-sikkerhet | 3 |\n| Nettverk og perimeter | 5 |\n| Logging og hendelseshåndtering | 4 |\n| Operasjonell og leverandørsikkerhet | 3 |\n\nRestrisiko: 5×4 → 2×3\n\n## Aggregat\n\nTotalscore: 22/30 (73%) — modent men ikke best-i-klassen. Modell- og prompt-sikkerhet er svakeste dimensjon.\n"
|
||
},
|
||
"ros": {
|
||
"input": {},
|
||
"raw_markdown": "# ROS-analyse — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nMetodikk: NS 5814 / ISO 31000 + AI-trusselbibliotek\n\n## Risikomatrise (5×5)\n\n| Trussel | Sannsynlighet | Konsekvens | Score | Nivå |\n|---------|---------------|------------|-------|------|\n| Modell-drift som degraderer nøyaktighet | 4 | 3 | 12 | medium |\n| Treningsdata-bias mot småbiler eller MC | 3 | 3 | 9 | medium |\n| Adversarielle plate-design unngår OCR | 2 | 4 | 8 | medium |\n| API-utilgjengelighet i kritisk periode | 2 | 4 | 8 | medium |\n| Klage-saksbehandling overbelastet ved skalering | 4 | 3 | 12 | medium |\n| Datatap pga manglende georedundans | 1 | 5 | 5 | low |\n| Misbruk av AI-forklaring som bevis | 3 | 4 | 12 | medium |\n| Kjedevirkning ved feil i objektregister | 2 | 5 | 10 | medium |\n\n## Radar-akser (7 dimensjoner)\n\n| Akse | Score (1-5) |\n|------|-------------|\n| Tilgjengelighet | 4 |\n| Konfidensialitet | 4 |\n| Integritet | 4 |\n| Sporbarhet | 5 |\n| Pålitelighet | 3 |\n| Robusthet | 3 |\n| Etterlevelse | 4 |\n\n## Trusler\n\n| ID | Beskrivelse | Severity | Tiltak |\n|----|-------------|----------|--------|\n| T-101 | Modell-drift over tid | high | Månedlig retraining-pipeline; alarm ved >2% nøyaktighetsfall |\n| T-102 | Bias mot småbiler/MC | high | Stratifisert evaluering ved hver release |\n| T-103 | Adversarielle plate-design | medium | Robusthetstest mot kjente angreps-mønstre |\n| T-104 | API-utilgjengelighet | medium | Multi-region failover med RTO 1t |\n| T-105 | Saksbehandlings-overbelastning | high | Automatisk batching + prioriteringsregler |\n\n## Tiltak\n\n| ID | Tiltak | Status | Eier |\n|----|--------|--------|------|\n| M-101 | Retraining-pipeline etablert | done | MLOps |\n| M-102 | Stratifisert evalueringssett bygget | in-progress | Data Scientist |\n| M-103 | Robusthetstest planlagt | planned | Sikkerhetsarkitekt |\n| M-104 | Multi-region failover testet | done | Drift |\n| M-105 | Batching-logikk implementert | in-progress | Tech Lead |\n\n## Top-risikoer\n\n| ID | Trussel | Score | Severity |\n|----|---------|-------|----------|\n| T-101 | Modell-drift over tid | 12 | high |\n| T-105 | Saksbehandlings-overbelastning | 12 | high |\n| T-107 | Misbruk av AI-forklaring som bevis | 12 | high |\n| T-108 | Kjedevirkning ved feil i objektregister | 10 | high |\n| T-103 | Bias mot småbiler/MC | 9 | medium |\n\nRestrisiko: 4×3 → 2×2\n\n## Anbefaling\n\nROS godkjent av seksjonsleder 2026-04-25 forutsatt at M-103 (robusthetstest) ferdigstilles innen 2026-06-15. Re-evaluering ved hver modell-release eller ved endring i sak-volum > 20%.\n\n## Konklusjon\n\nRestrisiko etter tiltak: medium. ROS godkjent av seksjonsleder 2026-04-25.\n"
|
||
},
|
||
"review": {
|
||
"input": {},
|
||
"raw_markdown": "# Arkitekturgjennomgang — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nVurderingsdato: 2026-04-30\nReviewers: AI-arkitekt, sikkerhetsarkitekt, Datatilsynet\n\n## Funn\n\n| ID | Severity | Status | Lokasjon | Anbefaling |\n|----|----------|--------|----------|------------|\n| F-01 | critical | remove | Authentication layer | Tilgang til AI-forklaringer mangler attribute-based access control — alle saksbehandler ser alle saker. Implementer ABAC basert på sak-tildeling. |\n| F-02 | high | review | Data pipeline | Treningsdata oppdateres månedlig, men ingen formell drift-deteksjon. Etabler statistisk drift-monitoring i Azure Monitor. |\n| F-03 | high | review | Model serving | Modellen serves fra en enkelt regional endpoint uten failover. Replikér til en sekundær region for RTO < 1t. |\n| F-04 | high | review | Logging | Audit-logg lagres 30 dager — under arkivlovens krav for sak-relevant info. Endre retensjon til 7 år for sak-knyttede oppslag. |\n| F-05 | medium | keep | Cost management | Ingen budsjettalarmer på Azure AI Services — prediction-kostnaden kan øke med 4× ved belastnings-topper uten varsel. |\n| F-06 | medium | review | Compliance | FRIA-rapport ikke vedlikeholdt etter modell-endring 2026-03-12. Re-evaluering trengs. |\n| F-07 | medium | keep | UX | saksbehandler-grensesnitt viser ikke konfidensgrad tydelig nok — risiko for over-trust på AI-output. |\n| F-08 | low | suppressed | Documentation | README mangler oppdatert arkitekturdiagram (siste fra 2025-11). |\n| F-09 | low | suppressed | Testing | Manglende E2E-test for utenlandske objekt-ID. |\n\n## Sammendrag\n\nCritical (1): ABAC mangler — må fikses før idriftsettelse.\nHigh (3): Drift-deteksjon, failover, logg-retensjon — må fikses innen 6 mnd.\nMedium (3): Budsjett, FRIA-revisjon, UX-konfidens — bør fikses innen 12 mnd.\nLow (2): Dokumentasjon, testing — opportunity-quality.\n\n## Anbefaling\n\nIdriftsettelse anbefales IKKE før F-01 er løst. F-02 til F-04 må adresseres innen 2026-09-01 for å holde 2027-08-02-fristen.\n"
|
||
},
|
||
"cost": {
|
||
"input": {},
|
||
"raw_markdown": "# Kostnadsestimat — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nPeriode: 12 måneder fra produksjonssetting\nValuta: NOK\n\n## Distribusjon (P10/P50/P90)\n\n| Persentil | Månedlig (NOK) | Årlig (NOK) |\n|-----------|----------------|-------------|\n| P10 | 78 000 | 936 000 |\n| P50 | 142 000 | 1 704 000 |\n| P90 | 285 000 | 3 420 000 |\n\n## Månedlig fordeling (P50)\n\n| Komponent | Kostnad (NOK/mnd) |\n|-----------|-------------------|\n| Azure AI Services (OCR + classification) | 64 000 |\n| Azure OpenAI (forklaringsmodell) | 28 000 |\n| Azure AI Search (indeks for objektregister) | 12 000 |\n| Storage (blob + cosmos for audit) | 8 500 |\n| Compute (Container Apps for orchestration) | 11 000 |\n| Networking (Private Endpoints + egress) | 5 200 |\n| Monitoring (Sentinel + Log Analytics) | 9 800 |\n| Backup og DR | 3 500 |\n\n## TCO-tabell (3 år)\n\n| År | Capex | Opex | Total | Akkumulert |\n|----|-------|------|-------|------------|\n| År 1 | 850 000 | 1 704 000 | 2 554 000 | 2 554 000 |\n| År 2 | 120 000 | 1 875 000 | 1 995 000 | 4 549 000 |\n| År 3 | 80 000 | 2 060 000 | 2 140 000 | 6 689 000 |\n\n## Kostnadsdrivere\n\n- Datavolum: ~12 millioner Acme Kunde-chatbot-deteksjoner/mnd\n- Forklaring-prompt-tokens: ~250 tokens per flagged hendelse\n- Reservert kapasitet for 99.9% SLA\n\n## Konfidensgradering\n\nP50 er beregnet med 95% konfidens basert på 6 måneder pilot-data. P90 inkluderer 2× volum-skalering ved fullnasjonal utrulling. P10 forutsetter optimaliserte prompt-cache (>40% hit-rate).\n\n## Anbefaling\n\nBruk P50 som budsjettlinje. Sett alarm på 1.4× P50 (≈ 200 000/mnd) for tidlig varsling.\n"
|
||
},
|
||
"license": {
|
||
"input": {},
|
||
"raw_markdown": "# Lisens-kapabilitetsmatrise — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nVurderingsdato: 2026-04-30\n\n## Matrise\n\n| Kapabilitet | M365 E3 | M365 E5 | Copilot for M365 | Copilot Studio | Azure AI Foundry |\n|-------------|---------|---------|------------------|----------------|------------------|\n| OCR av objekt-ID | missing | missing | missing | conditional | available |\n| Custom modell-trening | missing | missing | missing | missing | available |\n| Audit-logging på AI-input | missing | available | available | available | available |\n| Customer-managed keys | missing | available | conditional | conditional | available |\n| Private Endpoints | missing | available | missing | conditional | available |\n| saksbehandler-co-pilot UI | missing | missing | available | available | conditional |\n| Norsk språkstøtte i prompts | available | available | available | available | available |\n| Compliance-pakke for leverandøren | missing | available | conditional | conditional | available |\n| Real-time inference (<100ms) | missing | missing | missing | missing | available |\n| Batch-inference for nattlige jobber | missing | missing | missing | missing | available |\n\n## Status-betydning\n\n- available: Inkludert i lisensen, klar til bruk\n- cost: Tilgjengelig som tillegg, krever separat fakturering\n- conditional: Kan brukes med begrensninger eller add-on\n- missing: Ikke tilgjengelig på dette lisensnivået\n\n## Sammendrag\n\nAzure AI Foundry er eneste lisens som dekker alle kjernekapabiliteter. Copilot Studio passer for saksbehandler-UI men kan ikke håndtere OCR/custom modeller alene. Hybrid: Foundry (kjerne) + Copilot Studio (UI) gir best dekning.\n\n## Anbefaling\n\nBruk Azure AI Foundry for AI-tjenester (OCR, klassifisering, forklaring). Hold M365 E5 på saksbehandler-arbeidsstasjoner for audit-logging og compliance-pakke. Vurder Copilot Studio i fase 2 for saksbehandler-co-pilot.\n"
|
||
},
|
||
"migrate": {
|
||
"input": {},
|
||
"raw_markdown": "# Migrasjonsplan — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nFra: On-prem OCR + manuell klassifisering\nTil: Azure AI Foundry + saksbehandler-co-pilot\n\n## Faser\n\n### Fase 1 — Foundry-fundament (uker 1-6)\n\nVarighet: 6 uker\nStatus: done\n\nMilepæler:\n- Hub + projects opprettet i West Europe\n- Network isolation: Private Endpoints + Vnet integration\n- Identity: Entra ID-integrasjon med PIM\n- Logging: OpenTelemetry → Sentinel pipeline\n\nSuksesskriterier:\n- Pilot OCR-modell deployert med <100ms latency P95\n- Audit-logg fanger 100% av inferences\n- Sikkerhetsarkitekt godkjenner foundation-design\n\n### Fase 2 — Modell-trening og baseline (uker 7-14)\n\nVarighet: 8 uker\nStatus: done\n\nMilepæler:\n- Treningsdata kuratert (200k norske objekt-ID, stratifisert)\n- Custom modell trent på Azure ML\n- Baseline-nøyaktighet etablert (mål: ≥96% F1)\n- Bias-evaluering på utenlandske registre fullført\n\nSuksesskriterier:\n- F1 ≥ 96% overall, ≥ 92% per objekter-segment\n- Drift-deteksjon kalibrert med terskel\n- ROS-revisjon godkjent\n\n### Fase 3 — saksbehandler-co-pilot (uker 15-22)\n\nVarighet: 8 uker\nStatus: active\n\nMilepæler:\n- Forklaringsmodell (GPT-4 Turbo) integrert via Foundry\n- saksbehandler-UI bygget (Copilot Studio + Power Platform)\n- Workflow: AI flagger → saksbehandler reviewer → klar for sanksjon\n- Brukertest med 12 saksbehandler fra ulike regioner\n\nSuksesskriterier:\n- Saksbehandlingstid -40% vs baseline\n- saksbehandler-tillit >7/10 i post-pilot survey\n- Ingen kritiske UX-feil\n\n### Fase 4 — Compliance og produksjonssetting (uker 23-28)\n\nVarighet: 6 uker\nStatus: planned\n\nMilepæler:\n- FRIA gjennomført og godkjent\n- Conformity assessment ferdigstilt per Annex VI\n- DPIA oppdatert med nye operasjonelle data\n- Produksjonssetting til 3 piloter (Oslo, Bergen, Trondheim)\n\nSuksesskriterier:\n- Personvernombud signerer DPIA\n- Ingen open critical-funn fra arkitekturgjennomgang\n- Stabil 99.9% uptime i 30 dager pilot\n\n## Risiko\n\n| Risiko | Sannsynlighet | Konsekvens | Tiltak |\n|--------|---------------|------------|--------|\n| Custom modell underyter mot 96% mål | medium | high | Backup-strategi: bruk Azure AI Vision OCR som fallback |\n| saksbehandler-motstand mot AI | medium | medium | Tidlig involvering; transparent forklaring; opt-out på enkelt-saker |\n| FRIA blokkerer fase 4 | low | high | Pre-FRIA-kjøring i fase 2 for tidlig varsling |\n| Cost-overrun ved skalering | medium | medium | Reserved capacity-binding etter fase 3 |\n\n## Total varighet\n\n28 uker (~7 måneder). Avhengighet: Foundry-fundament må være ferdig før modell-trening starter.\n"
|
||
},
|
||
"adr": {
|
||
"input": {},
|
||
"raw_markdown": "# ADR-001 — Velg Azure AI Foundry som primær AI-plattform for Acme Kunde-chatbot\n\nStatus: accepted\nDate: 2026-04-30\nDeciders: AI-arkitekt, sikkerhetsarkitekt, seksjonsleder\nConsulted: Datatilsynet, juridisk rådgiver, Drift\nInformed: prosjekteierskap, AI-teamet\n\n## Context and Problem Statement\n\nAcme Kommune skal modernisere Acme Kunde-chatbot fra on-prem OCR-løsning til skybasert AI-plattform. Plattformen må støtte custom modell-trening, audit-logging på inferens-nivå, real-time inferens (<100ms P95), og full compliance med EU AI Act + GDPR + sikkerhetsloven.\n\n## Decision Drivers\n\n- Compliance med EU AI Act høyrisiko-krav (Art. 9-15)\n- Norsk dataresidens-krav\n- Customer-managed keys og Private Endpoints\n- Custom modell-trening kapabilitet\n- Total cost of ownership over 3 år\n- Driftbarhet for AI-teamet\n\n## Considered Options\n\n1. **Azure AI Foundry** — Enterprise AI-plattform med full compliance-pakke\n2. **Azure ML + AKS** — Mer kontroll, men høyere driftskost\n3. **AWS SageMaker** — Konkurransedyktig, men mangler norske compliance-sertifiseringer\n4. **On-prem GPU-cluster** — Maks kontroll, men krever betydelig CapEx og driftskompetanse\n\n## Decision Outcome\n\nChosen option: **Azure AI Foundry**, fordi det balanserer compliance, driftbarhet, og fleksibilitet best for vår bemanning og tidsramme.\n\n### Consequences\n\n- Good: full compliance-pakke for leverandøren, raskere time-to-prod, integrert med eksisterende Entra ID\n- Good: customer-managed keys og Customer Lockbox tilgjengelig\n- Bad: lock-in til Azure, men mitigert via standardiserte modell-formater (ONNX) og data-portabilitet\n- Bad: høyere månedlig kostnad enn ren Azure ML — kompenseres ved redusert egen-drift\n\n## Validation\n\nBeslutning evalueres etter 12 måneder mot KPI-er:\n- Saksbehandlingstid (mål: -40%)\n- Modell-nøyaktighet (mål: ≥96% F1)\n- Total cost (mål: ≤ NOK 1.7M/år)\n- Compliance-status (mål: 100% av krav dekket innen 2027-08-02)\n\n## More Information\n\n- Compare-rapport: see `compare-foundry-vs-aml.md`\n- Cost-analyse: see `cost-tco-3year.md`\n- Security-vurdering: see `security-foundry-baseline.md`\n"
|
||
},
|
||
"summary": {
|
||
"input": {},
|
||
"raw_markdown": "# Beslutningsnotat — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nDato: 2026-04-30\nTil: Direktør for Digital og IT\nFra: AI-teamet\n\n## Verdict\n\nVerdict: warning\nSub: Pilot anbefalt med betingelser\n\n## Rationale\n\nArkitekturen er teknisk solid og økonomisk forsvarlig (P50 NOK 1.7M/år), men compliance-arbeidet ligger 6 måneder bak ideell tidslinje. Pilot kan starte etter at FRIA og transparens-instruksjoner er ferdigstilt; full produksjonssetting krever lukking av alle critical funn fra arkitekturgjennomgang.\n\n## Key Metrics\n\n| Metric | Verdi | Mål |\n|--------|-------|-----|\n| Compliance-dekning | 33% (4/12 fullt møtt) | 100% innen 2027-08-02 |\n| Sikkerhetsscore | 22/30 (73%) | ≥27/30 (90%) |\n| TCO 3 år | NOK 6.7M | ≤ NOK 7M |\n| Saksbehandlingstid (pilot) | -32% (estimert) | -40% |\n| ROS-restrisiko | medium | low-medium |\n\n## Next Steps\n\n- Lukk F-01 (ABAC) innen 2026-06-15\n- Gjennomfør FRIA innen 2026-07-15 (Art. 27-frist)\n- Produksjonsdokumentere transparens-instruksjoner innen 2026-09-01\n- Pilot 3 regioner (Oslo, Bergen, Trondheim) Q4 2026\n- Full utrulling Q2 2027\n\n## Restrisiko\n\nEtter foreslåtte tiltak: medium. Hovedeksponering: bias mot utenlandske objekt-ID krever løpende monitoring.\n\n## Anbefaling\n\nGodkjenn pilot-fase med tydelig stage-gate til full produksjonssetting. Avstem med Datatilsynet før fase 4.\n"
|
||
},
|
||
"poc": {
|
||
"input": {},
|
||
"raw_markdown": "# POC-plan — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nPOC-mål: Validere at Azure AI Foundry kan dekke OCR + forklaring + audit innen tids- og kostbudsjett\n\n## Faser\n\n### Fase 1 — Foundation (uker 1-2)\n\nVarighet: 2 uker\nStatus: done\n\nMilepæler:\n- Foundry hub + project i West Europe\n- Identity og networking konfigurert\n- Sample-data uploadet (10k anonymiserte objekt-ID)\n\nSuksesskriterier:\n- Inferens-endpoint nåbart fra dev-Vnet via Private Endpoint\n- Audit-logg fanger første test-inferens\n- Cost-monitor viser daglig forbruk i Azure portal\n\n### Fase 2 — OCR-modell (uker 3-5)\n\nVarighet: 3 uker\nStatus: active\n\nMilepæler:\n- Pre-trent Azure AI Vision OCR pilotert\n- Custom fine-tune på 10k objekt-ID\n- Sammenligning av accuracy/latency mellom de to\n\nSuksesskriterier:\n- F1 ≥ 92% på pilot-sett (lavere mål enn produksjon, akseptabelt for POC)\n- Latency P95 < 200ms\n- Inference-cost ≤ NOK 0.04 per kall\n\n### Fase 3 — Forklarings-loop (uker 6-7)\n\nVarighet: 2 uker\nStatus: planned\n\nMilepæler:\n- GPT-4 Turbo via Foundry integrert\n- Prompt-template for forklaring av flagged sak\n- saksbehandler-mock UI (en enkel webside) prøvd ut med 3 brukere\n\nSuksesskriterier:\n- Forklaring referer til konfidens og kontekst korrekt i 95% av tilfellene\n- saksbehandler-feedback kvalitativt positiv (\"forståelig, men trenger justering\")\n- Prompt-tokens under 250 i snitt per sak\n\n### Fase 4 — Compliance-pre-check (uke 8)\n\nVarighet: 1 uke\nStatus: planned\n\nMilepæler:\n- Audit-logg mot EU AI Act Art. 12-krav\n- Customer-managed keys verifisert\n- Pre-DPIA-sjekk gjort med Datatilsynet\n\nSuksesskriterier:\n- Audit-logg dekker 100% av inferences med tidsstempel + bruker\n- Personvernombud signer pre-DPIA-utkast\n- Ingen åpenbare GDPR-blokkere\n\n## Risiko\n\n| Risiko | Sannsynlighet | Konsekvens | Tiltak |\n|--------|---------------|------------|--------|\n| Custom OCR-modell underyter pre-trent | medium | medium | Aksepter pre-trent for POC; planlegg custom for full prod |\n| Foundry-quota i West Europe utilstrekkelig | low | medium | Reserver kapasitet før POC starter |\n| saksbehandler-recruitment forsinker fase 3 | medium | low | Bruk interne ressurser i AI-teamet som mock |\n| Audit-logg-format ikke kompatibelt med Sentinel | low | medium | Test integrasjon i fase 1 |\n\n## POC-Verdict: BETINGET\n\nPilot-fase 1 fullført med F1=0.94 og inference-cost 0.038 NOK/kall (under budsjett). Fase 2 pågår — sammenligning av custom fine-tune mot pre-trent OCR i progress. Forklarings-loop og compliance-pre-check planlagt for siste halvdel.\n\n## Total varighet\n\n8 uker. Beslutningskriterium for full prosjektgodkjenning: alle 4 fasers suksesskriterier møtt.\n"
|
||
},
|
||
"utredning": {
|
||
"input": {},
|
||
"raw_markdown": "# AI-arkitekturutredning — Acme Kunde-chatbot for Acme Kommune\n\n## 1. Bakgrunn og formål\n\nAcme Kommune har siden 2018 driftet en on-prem Acme Kunde-chatbot-løsning for operasjonell analyse på tvers av leverandørens tjenesteportefølje. Løsningen er basert på et OCR-bibliotek fra 2017 og leveres som et lukket system uten mulighet for retrening eller forbedring av modell. Saksbehandlingen er manuell og tar i snitt 14 minutter per sak. Et internt AI-team utreder modernisering til en skybasert AI-plattform som støtter custom modell-trening, audit-logging på inferens-nivå, og saksbehandler-co-pilot.\n\n## 2. Mandat\n\nUtredningen skal:\n- Anbefale teknologivalg blant Azure AI Foundry, Azure ML+AKS, AWS SageMaker og on-prem GPU-cluster\n- Vurdere compliance-status mot EU AI Act, GDPR, sikkerhetsloven og arkivloven\n- Estimere TCO over 3 år\n- Identifisere risiko og foreslå mitigerende tiltak\n- Definere KPI-er for produksjonssetting\n\n## 3. Metode\n\nUtredningen kombinerer:\n- Kvalitativ analyse av compliance-krav per relevante lover og forskrifter\n- Kvantitativ TCO-analyse basert på 12 millioner Acme Kunde-chatbot-deteksjoner/mnd\n- Risikoanalyse per NS 5814 og DPIA per Datatilsynets veileder\n- Markedsundersøkelse av tilgjengelige plattformer fra Azure, AWS og GCP\n\n## 4. Funn\n\n### 4.1 Compliance\n\nEU AI Act klassifiserer systemet som høyrisiko (Annex III, punkt 6 — rettshåndhevelse). Acme Kommune er Provider og Deployer, hvilket trigger alle krav i Art. 9-15 + Art. 27 (FRIA) + Art. 49 (registrering).\n\n### 4.2 Teknologivalg\n\nAzure AI Foundry er anbefalt primær plattform fordi:\n- Full compliance-pakke for leverandøren\n- Customer-managed keys og Customer Lockbox tilgjengelig\n- Custom modell-trening via integrert Azure ML\n- Norsk dataresidens (West Europe + EU Data Boundary)\n\n### 4.3 TCO\n\n3-års TCO estimert til NOK 6.7M (P50). Hovedkostnad: Azure AI Services (38%) + Azure OpenAI (16%).\n\n### 4.4 Risiko\n\nHovedrisiko: bias mot utenlandske objekt-ID, modell-drift over tid, og manglende ABAC-implementering på saksbehandler-tilgang. Alle har konkrete tiltak.\n\n## 5. Konklusjon\n\nAnbefalt: gjennomfør 8-ukers POC før formell prosjektoppstart. Ved vellykket POC, full implementering over 28 uker mot produksjonssetting Q2 2027.\n\n## 6. Anbefaling\n\nGodkjenn POC-budsjett på NOK 1.2M og forenkle prosjekt-mandat for fase 1-4 ved positiv POC-evaluering.\n\n## 7. Referanser\n\n- EU AI Act 2024/1689\n- GDPR 2016/679\n- Sikkerhetsloven (LOV-2018-06-01-24)\n- Arkivloven (LOV-1992-12-04-126)\n- NS 5814:2008 — Krav til risikovurderinger\n- Datatilsynets veileder for AI og personvern (2024)\n"
|
||
},
|
||
"compare": {
|
||
"input": {},
|
||
"raw_markdown": "# Sammenligning — Azure AI Foundry vs Azure ML + AKS\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nSammenligningsdato: 2026-04-30\n\n## Subjects\n\nSubject 1: Azure AI Foundry\nSubject 2: Azure ML + AKS\n\n## Sammenligning\n\n| Aspekt | Azure AI Foundry | Azure ML + AKS | Vinner |\n|--------|------------------|----------------|--------|\n| Time-to-prod | 6-8 uker for fundament | 12-16 uker | Foundry |\n| Custom modell-trening | Integrert via Azure ML under panseret | Direkte Azure ML | Lik |\n| Compliance-pakke for leverandøren | Inkludert | Må bygges selv | Foundry |\n| Driftbarhet for AI-teamet | Lav driftbyrde, mest klikk-ops | Høy driftbyrde, full DevOps | Foundry |\n| Fleksibilitet for custom infrastruktur | Begrenset til Foundry-mønstre | Full kontroll over AKS-cluster | Azure ML + AKS |\n| Audit-logging på inferens | Innebygd | Må konfigureres manuelt | Foundry |\n| Customer-managed keys | Tilgjengelig | Tilgjengelig | Lik |\n| Customer Lockbox | Tilgjengelig | Tilgjengelig | Lik |\n| Private Endpoints | Tilgjengelig | Tilgjengelig | Lik |\n| Real-time inferens (<100ms) | Tilgjengelig via Foundry endpoints | Tilgjengelig via AKS | Lik |\n| Total cost (3 år) | NOK 6.7M | NOK 5.9M | Azure ML + AKS |\n| Lock-in til Azure | Høy | Medium (mer portabilitet i AKS) | Azure ML + AKS |\n| Forklaringsmodell-integrasjon | Native Foundry-integrasjon | Krever egen wrapper | Foundry |\n| Multi-region failover | Innebygd | Må implementeres manuelt | Foundry |\n\n## Sammendrag\n\nAzure AI Foundry vinner på time-to-prod, compliance-pakke, og driftbarhet. Azure ML + AKS vinner på pris (-12%) og fleksibilitet. Differansen i pris (~NOK 800k over 3 år) er liten sammenlignet med besparelsen i drift-tid for AI-teamet.\n\n## Vinner: Azure AI Foundry\n\n## Anbefaling\n\nFor Acme Kommune med begrenset KI-driftkapasitet anbefales Azure AI Foundry. For organisasjoner med dedikert MLOps-team kan Azure ML + AKS gi marginalt bedre kost-nytte.\n\n## Kontekst\n\nBeslutningen er sterkere drevet av compliance og driftbarhet enn ren kostnad. Foundry's leverandøren-pakke sparer 8-12 uker arbeid med å sertifisere baseline-konfigurasjonen.\n"
|
||
}
|
||
}
|
||
}
|
||
],
|
||
"activeProjectId": "acme-kunde-chatbot",
|
||
"activeSurface": "project",
|
||
"preferences": {
|
||
"theme": "dark"
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<!--
|
||
Klassisk script (ikke type="module") av to grunner:
|
||
1. External <script type="module" src="..."> feiler på file:// i Chrome+Firefox
|
||
(ref WHATWG html#8121, Chromium 41378227).
|
||
2. Single-file deployment per brief Constraints — ingen build-step.
|
||
Kommende steps utvider IIFE-en under: Step 2 (state-modul), Step 3 (eksport/import),
|
||
Step 4 (CATALOG), osv.
|
||
-->
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
|
||
// localStorage-nøkkel og schema-versjon. Endring av STATE_KEY krever migrasjons-steg
|
||
// (se Step 3 — MIGRATIONS-pipeline). SCHEMA_VERSION bumpes ved breaking endringer
|
||
// i state-form og driver eager migrations ved import.
|
||
const STATE_KEY = 'ms-ai-architect-state-v1';
|
||
const SCHEMA_VERSION = 1;
|
||
|
||
// Eksponer som globals for Verify-asserts og DevTools-debugging. Senere steps
|
||
// utvider window.__-namespace med __store, __CATALOG, __PARSERS, __RENDERERS,
|
||
// __buildCommand, __buildEnvelope, __handlePasteImport.
|
||
window.__STATE_KEY = STATE_KEY;
|
||
window.__SCHEMA_VERSION = SCHEMA_VERSION;
|
||
|
||
// ============================================================
|
||
// STATE MODULE (Step 2)
|
||
// ============================================================
|
||
//
|
||
// Reactivity-skjelett: Proxy + EventTarget. set-trap batcher dispatchEvent
|
||
// via queueMicrotask, så N synkrone mutasjoner gir bare én 'change'-event
|
||
// per mikrotask-tick. Bruker dyp wrap (Proxy rekursivt på objekt-properties)
|
||
// så nestede oppdateringer (state.shared.organization.name = ...) fanges.
|
||
//
|
||
// Persistens: IDB primær (~ubegrenset for 1-5 MB), localStorage fallback
|
||
// (5 MiB cap). Open-DB pattern bruker Promise-wrapper og synkrone
|
||
// migrasjoner i onupgradeneeded — async cursor-iterasjon er forbudt
|
||
// (ref w3c/IndexedDB#282: korrupsjons-risiko ved async i upgrade-tx).
|
||
//
|
||
// Multi-tab: db.onversionchange = () => db.close() defensivt på alle
|
||
// koblinger så en versjon-bump i en annen tab ikke stuck-blokkerer denne.
|
||
|
||
class StateBus extends EventTarget {}
|
||
|
||
const sharedBus = new StateBus();
|
||
const projectBus = new StateBus();
|
||
|
||
// Initial state-form. Step 5+ utvider shared.* etter onboarding-skjema;
|
||
// Step 7 utvider projects[]. preferences.theme settes i Step 13.
|
||
const INITIAL_STATE = {
|
||
schemaVersion: SCHEMA_VERSION,
|
||
shared: {
|
||
organization: {},
|
||
technology: {},
|
||
security: {},
|
||
architecture: {},
|
||
business: {}
|
||
},
|
||
projects: [],
|
||
activeProjectId: null,
|
||
activeSurface: 'home',
|
||
preferences: { theme: 'dark' }
|
||
};
|
||
|
||
// Microtask-batched event dispatcher. Mange synkrone set-traps i samme
|
||
// tick → én 'change'-event neste mikrotask. Forhindrer N renders ved
|
||
// batch-mutasjoner (f.eks. import-flow).
|
||
function makeBatchedDispatcher(bus) {
|
||
let pending = false;
|
||
const changedPaths = new Set();
|
||
return function dispatch(path) {
|
||
changedPaths.add(path);
|
||
if (pending) return;
|
||
pending = true;
|
||
queueMicrotask(function () {
|
||
pending = false;
|
||
const paths = Array.from(changedPaths);
|
||
changedPaths.clear();
|
||
bus.dispatchEvent(new CustomEvent('change', { detail: { paths: paths } }));
|
||
});
|
||
};
|
||
}
|
||
|
||
// Dyp Proxy-wrap. Lazy: wrapper child-objekter ved første read, så cost
|
||
// er bare betalt for grener brukeren faktisk berører. set-trap returnerer
|
||
// boolean (Proxy spec-invariant) og dispatcher batched 'change'-event.
|
||
// Path tracking gjør at subscribers kan filtrere på relevante grener.
|
||
function deepProxy(target, dispatch, path) {
|
||
path = path || '';
|
||
const cache = new WeakMap();
|
||
const handler = {
|
||
get: function (obj, key) {
|
||
const value = obj[key];
|
||
if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
||
if (cache.has(value)) return cache.get(value);
|
||
const childPath = path ? path + '.' + String(key) : String(key);
|
||
const wrapped = new Proxy(value, makeHandler(childPath));
|
||
cache.set(value, wrapped);
|
||
return wrapped;
|
||
}
|
||
if (Array.isArray(value)) {
|
||
// Array-mutasjoner via push/splice trigger set på indekser; wrap likt.
|
||
if (cache.has(value)) return cache.get(value);
|
||
const childPath = path ? path + '.' + String(key) : String(key);
|
||
const wrapped = new Proxy(value, makeHandler(childPath));
|
||
cache.set(value, wrapped);
|
||
return wrapped;
|
||
}
|
||
return value;
|
||
},
|
||
set: function (obj, key, value) {
|
||
obj[key] = value;
|
||
dispatch(path ? path + '.' + String(key) : String(key));
|
||
return true;
|
||
},
|
||
deleteProperty: function (obj, key) {
|
||
delete obj[key];
|
||
dispatch(path ? path + '.' + String(key) : String(key));
|
||
return true;
|
||
}
|
||
};
|
||
function makeHandler(p) {
|
||
return {
|
||
get: function (o, k) { return new Proxy(target, handler).constructor === Proxy ? handler.get(o, k) : o[k]; },
|
||
set: function (o, k, v) { o[k] = v; dispatch(p ? p + '.' + String(k) : String(k)); return true; },
|
||
deleteProperty: function (o, k) { delete o[k]; dispatch(p ? p + '.' + String(k) : String(k)); return true; }
|
||
};
|
||
}
|
||
return new Proxy(target, handler);
|
||
}
|
||
|
||
function createStore(initial, bus) {
|
||
const dispatch = makeBatchedDispatcher(bus);
|
||
const proxied = deepProxy(initial, dispatch, '');
|
||
return {
|
||
state: proxied,
|
||
raw: initial, // referanse til underliggende objekt — for serialisering
|
||
subscribe: function (handler) { bus.addEventListener('change', handler); },
|
||
unsubscribe: function (handler) { bus.removeEventListener('change', handler); }
|
||
};
|
||
}
|
||
|
||
// Throttled persistens: debounce 300 ms etter siste mutasjon, så bursts
|
||
// (import-flow, batch-form-submit) committer bare én gang.
|
||
function makeThrottledWriter(persist) {
|
||
let timer = null;
|
||
return function schedule() {
|
||
if (timer) clearTimeout(timer);
|
||
timer = setTimeout(function () {
|
||
timer = null;
|
||
persist().catch(function (err) {
|
||
console.error('[playground v3] persist failed:', err);
|
||
});
|
||
}, 300);
|
||
};
|
||
}
|
||
|
||
// ============================================================
|
||
// PERSISTENCE LAYER
|
||
// ============================================================
|
||
//
|
||
// IDB primær. Én DB ('ms-ai-architect-playground-v1') med to object-stores:
|
||
// - 'shared': nøkkel 'shared' → { organization, technology, ... }
|
||
// - 'projects': nøkkel 'projectId' → project-objekt
|
||
//
|
||
// Migrasjoner i onupgradeneeded er SYNKRONE per spec — ingen await på
|
||
// cursor.continue(); bruk callback-stil. async cursor-iterasjon i en
|
||
// upgrade-tx kan korruptere DB (w3c/IndexedDB#282).
|
||
|
||
function openDB(name, version) {
|
||
return new Promise(function (resolve, reject) {
|
||
if (typeof indexedDB === 'undefined') {
|
||
reject(new Error('IndexedDB ikke tilgjengelig'));
|
||
return;
|
||
}
|
||
const req = indexedDB.open(name, version);
|
||
req.onupgradeneeded = function (ev) {
|
||
const db = req.result;
|
||
const oldVersion = ev.oldVersion;
|
||
// Synkrone migrasjoner — opprette stores per oldVersion-guard.
|
||
if (oldVersion < 1) {
|
||
if (!db.objectStoreNames.contains('shared')) {
|
||
db.createObjectStore('shared');
|
||
}
|
||
if (!db.objectStoreNames.contains('projects')) {
|
||
db.createObjectStore('projects', { keyPath: 'id' });
|
||
}
|
||
if (!db.objectStoreNames.contains('meta')) {
|
||
db.createObjectStore('meta');
|
||
}
|
||
}
|
||
// Senere bump-er legges til som "if (oldVersion < N)"-blokker.
|
||
};
|
||
req.onsuccess = function () {
|
||
const db = req.result;
|
||
// Defensiv multi-tab: hvis annen tab åpner med høyere versjon,
|
||
// lukk denne så de ikke blokkerer hverandre.
|
||
db.onversionchange = function () {
|
||
db.close();
|
||
console.warn('[playground v3] IDB versionchange — closed for upgrade');
|
||
};
|
||
resolve(db);
|
||
};
|
||
req.onerror = function () { reject(req.error); };
|
||
req.onblocked = function () {
|
||
// En annen tab holder en eldre versjon åpen; usannsynlig i v3
|
||
// (én DB-versjon per release), men logg likevel.
|
||
console.warn('[playground v3] IDB open blocked — another tab holds older version');
|
||
};
|
||
});
|
||
}
|
||
|
||
// Primær persistens: IDB. Ved feil (Safari private mode, kvote-overflow)
|
||
// fallback til localStorage. Returnerer adapter med lik API — kallere
|
||
// trenger ikke vite hvilken backend som er i bruk.
|
||
async function makePersistence() {
|
||
const DB_NAME = 'ms-ai-architect-playground-v1';
|
||
const DB_VERSION = 1;
|
||
try {
|
||
const db = await openDB(DB_NAME, DB_VERSION);
|
||
return {
|
||
backend: 'idb',
|
||
load: function () {
|
||
return new Promise(function (resolve, reject) {
|
||
const tx = db.transaction(['shared', 'projects', 'meta'], 'readonly');
|
||
const sharedReq = tx.objectStore('shared').get('shared');
|
||
const projectsReq = tx.objectStore('projects').getAll();
|
||
const metaReq = tx.objectStore('meta').get('meta');
|
||
tx.oncomplete = function () {
|
||
resolve({
|
||
schemaVersion: (metaReq.result && metaReq.result.schemaVersion) || SCHEMA_VERSION,
|
||
shared: sharedReq.result || INITIAL_STATE.shared,
|
||
projects: projectsReq.result || [],
|
||
activeProjectId: (metaReq.result && metaReq.result.activeProjectId) || null,
|
||
activeSurface: (metaReq.result && metaReq.result.activeSurface) || 'home',
|
||
preferences: (metaReq.result && metaReq.result.preferences) || INITIAL_STATE.preferences
|
||
});
|
||
};
|
||
tx.onerror = function () { reject(tx.error); };
|
||
});
|
||
},
|
||
save: function (state) {
|
||
return new Promise(function (resolve, reject) {
|
||
const tx = db.transaction(['shared', 'projects', 'meta'], 'readwrite');
|
||
tx.objectStore('shared').put(state.shared, 'shared');
|
||
const projectStore = tx.objectStore('projects');
|
||
// Clear-and-rewrite er enkelt og atomær for moderate volum.
|
||
// Ved >100 prosjekter bør dette switch-es til diff-write.
|
||
projectStore.clear();
|
||
for (let i = 0; i < state.projects.length; i++) {
|
||
projectStore.put(state.projects[i]);
|
||
}
|
||
tx.objectStore('meta').put({
|
||
schemaVersion: state.schemaVersion,
|
||
activeProjectId: state.activeProjectId,
|
||
activeSurface: state.activeSurface,
|
||
preferences: state.preferences
|
||
}, 'meta');
|
||
tx.oncomplete = function () { resolve(); };
|
||
tx.onerror = function () { reject(tx.error); };
|
||
});
|
||
}
|
||
};
|
||
} catch (err) {
|
||
console.warn('[playground v3] IDB ikke tilgjengelig, faller tilbake til localStorage:', err && err.message);
|
||
return makeLocalStorageFallback();
|
||
}
|
||
}
|
||
|
||
function makeLocalStorageFallback() {
|
||
return {
|
||
backend: 'localStorage',
|
||
load: function () {
|
||
try {
|
||
const raw = localStorage.getItem(STATE_KEY);
|
||
if (!raw) return Promise.resolve(JSON.parse(JSON.stringify(INITIAL_STATE)));
|
||
return Promise.resolve(JSON.parse(raw));
|
||
} catch (err) {
|
||
console.error('[playground v3] localStorage parse-feil, returnerer initial state:', err);
|
||
return Promise.resolve(JSON.parse(JSON.stringify(INITIAL_STATE)));
|
||
}
|
||
},
|
||
save: function (state) {
|
||
try {
|
||
const payload = JSON.stringify(state);
|
||
// Cap-advarsel: localStorage 5 MiB cap. Ved ~4.5 MB warn brukeren.
|
||
if (payload.length > 4.5 * 1024 * 1024) {
|
||
console.warn('[playground v3] State nærmer seg localStorage 5 MiB cap. Bruk en moderne nettleser med IDB-støtte.');
|
||
}
|
||
localStorage.setItem(STATE_KEY, payload);
|
||
return Promise.resolve();
|
||
} catch (err) {
|
||
return Promise.reject(err);
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
// ============================================================
|
||
// BOOTSTRAP
|
||
// ============================================================
|
||
//
|
||
// Initialiser persistens, last lagret state, opprett store, hook opp
|
||
// throttled writer. Eksponer __store på window for Verify-asserts og
|
||
// DevTools — Step 3 utvider med __buildEnvelope, Step 4 med __CATALOG.
|
||
|
||
let store = null;
|
||
let persistence = null;
|
||
let scheduleWrite = null;
|
||
|
||
async function bootstrap() {
|
||
persistence = await makePersistence();
|
||
const loaded = await persistence.load();
|
||
// Sørg for at schemaVersion finnes (cold start kan returnere uten).
|
||
if (!loaded.schemaVersion) loaded.schemaVersion = SCHEMA_VERSION;
|
||
// Data-version migrasjon (v1.9.0 -> v1.10.0): utled verdict/keyStats
|
||
// for eksisterende parser-output. Idempotent via state.dataVersion-guard.
|
||
try { migrateDataVersion(loaded, defaultArchetypeFor); }
|
||
catch (e) { console.warn('[playground v3] migrateDataVersion failed:', e); }
|
||
store = createStore(loaded, sharedBus);
|
||
scheduleWrite = makeThrottledWriter(function () {
|
||
return persistence.save(store.raw);
|
||
});
|
||
store.subscribe(function () { scheduleWrite(); });
|
||
window.__store = store;
|
||
window.__persistence = persistence;
|
||
// Initial-surface heuristikk: hvis onboarding aldri er gjort (ingen
|
||
// organisasjons-navn) og state ikke har eksplisitt valg fra forrige
|
||
// sesjon, gå til onboarding. Ellers bruk lagret activeSurface.
|
||
const orgName = store.state.shared && store.state.shared.organization && store.state.shared.organization.name;
|
||
if (!orgName) store.state.activeSurface = 'onboarding';
|
||
else if (!store.state.activeSurface) store.state.activeSurface = 'home';
|
||
scheduleRender();
|
||
}
|
||
|
||
// ============================================================
|
||
// EXPORT / IMPORT (Step 3)
|
||
// ============================================================
|
||
//
|
||
// Brukeren kan eksportere hele state som JSON-fil og re-importere på en
|
||
// annen enhet (eller etter localStorage.clear()). Format er en envelope
|
||
// med schemaVersion + appId — så fremtidige versjoner kan lese gamle
|
||
// eksporter via MIGRATIONS-pipeline.
|
||
//
|
||
// File System Access API krever HTTPS (secure context) og er ikke
|
||
// tilgjengelig på file:// — vi bruker Blob + URL.createObjectURL +
|
||
// <a download> for eksport, og <input type="file"> + File.text() for
|
||
// import. Begge fungerer på file:// i alle target-browsers.
|
||
//
|
||
// MIGRATIONS er en eager pipeline: ved import (eller cold-load fra
|
||
// gammel state) kjøres alle migrasjoner sekvensielt fra fil-versjon til
|
||
// gjeldende SCHEMA_VERSION. Aldri hopp over et steg — selv tomme
|
||
// migrasjoner skal være registrert (no-op) for å bevise at hoppet er
|
||
// håndtert.
|
||
|
||
const APP_ID = 'ms-ai-architect-playground';
|
||
|
||
function buildEnvelope() {
|
||
// Snapshot av rå state. JSON.stringify(JSON.parse(...)) sørger for
|
||
// at Proxy-er er stripped; vi vil ikke at envelopet skal beholde
|
||
// wrapper-referanser.
|
||
const snapshot = store ? JSON.parse(JSON.stringify(store.raw)) : JSON.parse(JSON.stringify(INITIAL_STATE));
|
||
return {
|
||
appId: APP_ID,
|
||
schemaVersion: snapshot.schemaVersion || SCHEMA_VERSION,
|
||
exportedAt: new Date().toISOString(),
|
||
shared: snapshot.shared,
|
||
projects: snapshot.projects,
|
||
activeProjectId: snapshot.activeProjectId,
|
||
activeSurface: snapshot.activeSurface,
|
||
preferences: snapshot.preferences
|
||
};
|
||
}
|
||
|
||
function exportState() {
|
||
const envelope = buildEnvelope();
|
||
const json = JSON.stringify(envelope, null, 2);
|
||
const blob = new Blob([json], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
const stamp = envelope.exportedAt.replace(/[:.]/g, '-');
|
||
a.href = url;
|
||
a.download = APP_ID + '-' + stamp + '.json';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
// revokeObjectURL etter at klikket har skjedd; setTimeout 0 er nok i
|
||
// alle target-browsers (Blob URL holdes så lenge nedlastningen står
|
||
// for å initieres).
|
||
setTimeout(function () { URL.revokeObjectURL(url); }, 0);
|
||
return envelope;
|
||
}
|
||
|
||
// MIGRATIONS-pipeline. Nøkkel-format: 'N->M' der N og M er fortløpende
|
||
// SCHEMA_VERSION-tall. Funksjon tar et state-objekt og returnerer et
|
||
// nytt state-objekt på neste versjon. Aldri muter input.
|
||
//
|
||
// Når SCHEMA_VERSION bumpes til 2: legg til '1->2'-funksjon som
|
||
// transformerer v1-state til v2-form. importState plukker opp
|
||
// migrasjonen automatisk.
|
||
const MIGRATIONS = {
|
||
// Eksempel for fremtid (no-op stub):
|
||
// '1->2': function (state) { return Object.assign({}, state, { schemaVersion: 2 }); }
|
||
};
|
||
|
||
function migrateState(state) {
|
||
let current = state;
|
||
let from = current.schemaVersion || 1;
|
||
while (from < SCHEMA_VERSION) {
|
||
const key = from + '->' + (from + 1);
|
||
const fn = MIGRATIONS[key];
|
||
if (!fn) {
|
||
throw new Error('[playground v3] mangler migrasjon ' + key + ' — kan ikke trygt oppgradere import-fil');
|
||
}
|
||
current = fn(current);
|
||
if (current.schemaVersion !== from + 1) {
|
||
throw new Error('[playground v3] migrasjon ' + key + ' satte ikke schemaVersion til ' + (from + 1));
|
||
}
|
||
from = current.schemaVersion;
|
||
}
|
||
return current;
|
||
}
|
||
|
||
async function importState(file) {
|
||
// file er File-objekt fra <input type="file"> change-event.
|
||
// file.text() er Promise<string> — fungerer på file:// uten secure context.
|
||
const text = await file.text();
|
||
let envelope;
|
||
try {
|
||
envelope = JSON.parse(text);
|
||
} catch (err) {
|
||
throw new Error('Ugyldig JSON: ' + err.message);
|
||
}
|
||
if (envelope.appId !== APP_ID) {
|
||
throw new Error('Fil-en er ikke en ' + APP_ID + '-eksport (appId=' + envelope.appId + ')');
|
||
}
|
||
if (typeof envelope.schemaVersion !== 'number') {
|
||
throw new Error('Mangler schemaVersion i envelope');
|
||
}
|
||
// Migrer envelope opp til gjeldende SCHEMA_VERSION før vi commit-er.
|
||
const migrated = migrateState({
|
||
schemaVersion: envelope.schemaVersion,
|
||
dataVersion: envelope.dataVersion,
|
||
shared: envelope.shared || INITIAL_STATE.shared,
|
||
projects: envelope.projects || [],
|
||
activeProjectId: envelope.activeProjectId || null,
|
||
activeSurface: envelope.activeSurface || 'home',
|
||
preferences: envelope.preferences || INITIAL_STATE.preferences
|
||
});
|
||
// Data-version migrasjon (additive — verdict/keyStats utledes for
|
||
// pre-v1.10.0 envelope-er). Idempotent via dataVersion=2 guard.
|
||
try { migrateDataVersion(migrated, defaultArchetypeFor); }
|
||
catch (e) { console.warn('[playground v3] migrateDataVersion (import) failed:', e); }
|
||
// Skriv direkte til persistens for å unngå at debounce-vinduet
|
||
// svelger import-en ved en samtidig page-unload.
|
||
if (persistence) {
|
||
await persistence.save(migrated);
|
||
}
|
||
// Erstatt store-state in-place. Vi kan ikke bytte ut store.raw
|
||
// sin referanse fordi Proxy-en er bundet til den; muter feltvis.
|
||
const target = store.raw;
|
||
target.schemaVersion = migrated.schemaVersion;
|
||
if (migrated.dataVersion != null) target.dataVersion = migrated.dataVersion;
|
||
target.shared = migrated.shared;
|
||
target.projects = migrated.projects;
|
||
target.activeProjectId = migrated.activeProjectId;
|
||
target.activeSurface = migrated.activeSurface;
|
||
target.preferences = migrated.preferences;
|
||
// Trigger en change-event manuelt så subscribers re-rendrer.
|
||
sharedBus.dispatchEvent(new CustomEvent('change', { detail: { paths: ['*'] } }));
|
||
return migrated;
|
||
}
|
||
|
||
// Eksponer for UI-handlere (Step 5+) og DevTools-debugging.
|
||
window.__buildEnvelope = buildEnvelope;
|
||
window.__exportState = exportState;
|
||
window.__importState = importState;
|
||
window.__MIGRATIONS = MIGRATIONS;
|
||
|
||
// ============================================================
|
||
// COMMAND CATALOG (Step 4)
|
||
// ============================================================
|
||
//
|
||
// Kanonisk single-source-of-truth for alle 24 commands. Driver:
|
||
// - Step 5/8: skjema-render via input_fields[]
|
||
// - Step 9: katalog-UI gruppert på category
|
||
// - Step 11: parser-routing via report_archetype
|
||
// - Step 12: renderer-routing via renderer-feltet
|
||
// - __buildCommand: pipeline-string-bygging per command
|
||
//
|
||
// Felles-state-felter har from='shared' + shared_path='group.field'
|
||
// (oppslag mot state.shared.<group>.<field>). Lokale felter har
|
||
// from='local' og lagres i project.reports[id].input.
|
||
//
|
||
// Verktøy-commands (architect, help, research, diagram, onboard,
|
||
// generate-skills, export) har produces_report=false og null for
|
||
// archetype/root/renderer — Step 11/12 hopper over dem.
|
||
|
||
const FIELD_TYPES = {
|
||
TEXT: 'text',
|
||
TEXTAREA: 'textarea',
|
||
SELECT: 'select',
|
||
MULTI_SELECT: 'multiSelect',
|
||
BOOLEAN: 'boolean',
|
||
NUMBER: 'number'
|
||
};
|
||
|
||
// Felles felt-shorthands. Holder CATALOG kompakt og sikrer at samme
|
||
// felles-felt har eksakt samme label/type på tvers av alle commands
|
||
// som bruker det.
|
||
const SHARED = {
|
||
organisation_name: { id: 'organisation_name', label: 'Virksomhet', type: 'text', from: 'shared', shared_path: 'organization.name' },
|
||
sector: { id: 'sector', label: 'Sektor', type: 'select', from: 'shared', shared_path: 'organization.sector', options: ['Statlig', 'Kommunal', 'Fylkeskommune', 'Helseforetak', 'Undervisning', 'Annet'] },
|
||
regulatory_requirements: { id: 'regulatory_requirements', label: 'Regulatoriske krav', type: 'multiSelect', from: 'shared', shared_path: 'organization.regulatory_requirements', options: ['Personopplysningsloven/GDPR', 'Sikkerhetsloven', 'Arkivloven', 'Forvaltningsloven', 'Offentleglova', 'Helseregisterloven', 'Annet'] },
|
||
cloud_platform: { id: 'cloud_platform', label: 'Skyplattform', type: 'multiSelect', from: 'shared', shared_path: 'technology.cloud_platform', options: ['Azure', 'M365', 'Power Platform', 'On-prem', 'Hybrid', 'Annet'] },
|
||
license_type: { id: 'license_type', label: 'Lisenstype', type: 'select', from: 'shared', shared_path: 'technology.license_type', options: ['E3', 'E5', 'F1/F3', 'A3/A5', 'G3/G5', 'Annet'] },
|
||
ai_services_in_use: { id: 'ai_services_in_use', label: 'AI-tjenester i bruk', type: 'multiSelect', from: 'shared', shared_path: 'technology.ai_services_in_use', options: ['Azure OpenAI', 'Copilot for M365', 'Copilot Studio', 'AI Builder', 'Azure AI Search', 'Azure AI Services', 'Ingen', 'Annet'] },
|
||
data_classification: { id: 'data_classification', label: 'Dataklassifisering', type: 'multiSelect', from: 'shared', shared_path: 'security.data_classification', options: ['Åpen', 'Intern', 'Fortrolig', 'Strengt fortrolig', 'Hemmelig'] },
|
||
dpia_practice: { id: 'dpia_practice', label: 'DPIA-praksis i organisasjonen', type: 'select', from: 'shared', shared_path: 'security.dpia_practice', options: ['Systematisk', 'Ad hoc', 'Ikke etablert', 'Usikker'] },
|
||
annual_ai_budget: { id: 'annual_ai_budget', label: 'Årlig AI-budsjett', type: 'select', from: 'shared', shared_path: 'architecture.annual_ai_budget', options: ['<500k', '500k-2M', '2M-10M', '>10M', 'Ikke definert'] }
|
||
};
|
||
|
||
const PLATFORMS = ['Azure AI Foundry', 'Copilot Studio', 'M365 Copilot', 'Power Automate', 'AI Builder', 'Azure OpenAI', 'Azure AI Search', 'Annet'];
|
||
const RISK_LEVELS = ['minimal', 'limited', 'high', 'forbidden', 'ukjent'];
|
||
const ORG_ROLES = ['provider', 'deployer', 'distributor', 'importer', 'usikker'];
|
||
|
||
const CATALOG = {
|
||
version: '1.0',
|
||
generated_for_schema: SCHEMA_VERSION,
|
||
categories: [
|
||
{ id: 'regulatory', label: 'Regulatorisk', count: 6 },
|
||
{ id: 'security', label: 'Sikkerhet', count: 3 },
|
||
{ id: 'economy', label: 'Økonomi', count: 2 },
|
||
{ id: 'documentation', label: 'Dokumentasjon', count: 6 },
|
||
{ id: 'tool', label: 'Verktøy', count: 7 }
|
||
],
|
||
commands: [
|
||
// ===== REGULATORY (6) =====
|
||
{
|
||
id: 'classify',
|
||
category: 'regulatory',
|
||
label: 'EU AI Act — Klassifisering',
|
||
description: 'Klassifiser AI-system etter EU AI Act-risikonivå (forbidden/high/limited/minimal) og bestem rolle.',
|
||
argument_hint: '[system-beskrivelse]',
|
||
calls_agent: 'ai-act-assessor',
|
||
kb_files: ['ai-act-classification-methodology.md', 'ai-act-annex-iii-checklist.md', 'ai-act-compliance-guide.md'],
|
||
produces_report: true,
|
||
report_archetype: 'aiact',
|
||
report_root_class: 'pyramide',
|
||
renderer: 'renderAiActPyramid',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.sector,
|
||
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
|
||
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
|
||
{ id: 'users', label: 'Brukere', type: 'text', from: 'local' },
|
||
{ id: 'interaction_type', label: 'Interaksjonstype', type: 'select', from: 'local', options: ['chatbot', 'beslutningsstøtte', 'automatisering', 'anbefaling', 'annet'] },
|
||
{ id: 'data_sources', label: 'Datakilder', type: 'textarea', from: 'local' },
|
||
{ id: 'risk_level_assumption', label: 'Risk-level (forhåndsvurdering)', type: 'select', from: 'local', options: RISK_LEVELS }
|
||
]
|
||
},
|
||
{
|
||
id: 'requirements',
|
||
category: 'regulatory',
|
||
label: 'EU AI Act — Krav per risiko + rolle',
|
||
description: 'Konkrete AI Act-forpliktelser basert på klassifisering og rolle (provider/deployer).',
|
||
argument_hint: '[system-beskrivelse el. klassifisering]',
|
||
calls_agent: 'ai-act-assessor',
|
||
kb_files: ['ai-act-provider-obligations.md', 'ai-act-deployer-obligations.md', 'ai-act-microsoft-tools-mapping.md'],
|
||
produces_report: true,
|
||
report_archetype: 'requirements-list',
|
||
report_root_class: 'findings',
|
||
renderer: 'renderRequirements',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.sector,
|
||
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
|
||
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
|
||
{ id: 'risk_classification', label: 'Risikoklassifisering', type: 'select', from: 'local', options: RISK_LEVELS },
|
||
{ id: 'org_role', label: 'Rolle', type: 'select', from: 'local', options: ORG_ROLES }
|
||
]
|
||
},
|
||
{
|
||
id: 'transparency',
|
||
category: 'regulatory',
|
||
label: 'Transparensnotis (Art. 13/50)',
|
||
description: 'Generer Art. 13/50-transparensnotis på norsk for AI-system.',
|
||
argument_hint: '[system-beskrivelse]',
|
||
calls_agent: 'ai-act-assessor',
|
||
kb_files: ['ai-act-transparency-notices.md'],
|
||
produces_report: true,
|
||
report_archetype: 'text-document',
|
||
report_root_class: 'markdown-fallback',
|
||
renderer: 'renderTransparency',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.sector,
|
||
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
|
||
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
|
||
{ id: 'interaction_type', label: 'Interaksjonstype', type: 'select', from: 'local', options: ['chatbot', 'beslutningsstøtte', 'automatisering', 'anbefaling', 'annet'] },
|
||
{ id: 'target_audience', label: 'Målgruppe', type: 'text', from: 'local' },
|
||
{ id: 'risk_classification', label: 'Risikoklassifisering', type: 'select', from: 'local', options: RISK_LEVELS }
|
||
]
|
||
},
|
||
{
|
||
id: 'frimpact',
|
||
category: 'regulatory',
|
||
label: 'FRIA (Art. 27)',
|
||
description: 'Fundamental Rights Impact Assessment — obligatorisk for offentlig sektor som deployer.',
|
||
argument_hint: '[system-beskrivelse]',
|
||
calls_agent: 'ai-act-assessor',
|
||
kb_files: ['ai-act-fria-template.md', 'ai-act-deployer-obligations.md'],
|
||
produces_report: true,
|
||
report_archetype: 'fria',
|
||
report_root_class: 'rights-matrix',
|
||
renderer: 'renderFria',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.sector,
|
||
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
|
||
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
|
||
{ id: 'affected_groups', label: 'Berørte grupper', type: 'textarea', from: 'local' },
|
||
{ id: 'decisions_affected', label: 'Beslutninger som påvirkes', type: 'textarea', from: 'local' },
|
||
{ id: 'risk_classification', label: 'Risikoklassifisering', type: 'select', from: 'local', options: RISK_LEVELS }
|
||
]
|
||
},
|
||
{
|
||
id: 'conformity',
|
||
category: 'regulatory',
|
||
label: 'Samsvarsvurdering (Art. 43)',
|
||
description: 'Annex IV-sjekkliste og EU-erklæring for høyrisiko AI-systemer.',
|
||
argument_hint: '[system-beskrivelse]',
|
||
calls_agent: 'ai-act-assessor',
|
||
kb_files: ['ai-act-conformity-assessment.md', 'ai-act-provider-obligations.md'],
|
||
produces_report: true,
|
||
report_archetype: 'conformity-checklist',
|
||
report_root_class: 'findings',
|
||
renderer: 'renderConformity',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.sector,
|
||
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
|
||
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
|
||
{ id: 'risk_classification', label: 'Risikoklassifisering', type: 'select', from: 'local', options: RISK_LEVELS },
|
||
{ id: 'org_role', label: 'Rolle', type: 'select', from: 'local', options: ORG_ROLES },
|
||
{ id: 'existing_documentation', label: 'Eksisterende dokumentasjon', type: 'textarea', from: 'local' }
|
||
]
|
||
},
|
||
{
|
||
id: 'dpia',
|
||
category: 'regulatory',
|
||
label: 'DPIA / PVK',
|
||
description: 'Personvernkonsekvensvurdering for AI-system med risikomatrise og tiltakstabell.',
|
||
argument_hint: '[system-beskrivelse]',
|
||
calls_agent: 'dpia-agent',
|
||
kb_files: ['dpia-norwegian-methodology-ai.md', 'gdpr-compliance-ai-systems.md', 'ai-impact-assessment-framework.md'],
|
||
produces_report: true,
|
||
report_archetype: 'matrix-risk',
|
||
report_root_class: 'matrix',
|
||
renderer: 'renderDpia',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.sector,
|
||
SHARED.dpia_practice,
|
||
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
|
||
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
|
||
{ id: 'personal_data_types', label: 'Typer personopplysninger', type: 'textarea', from: 'local' },
|
||
{ id: 'data_subjects', label: 'Registrerte (data subjects)', type: 'text', from: 'local' },
|
||
{ id: 'legal_basis', label: 'Behandlingsgrunnlag (GDPR Art. 6)', type: 'select', from: 'local', options: ['Samtykke', 'Avtale', 'Rettslig forpliktelse', 'Vitale interesser', 'Allmenn interesse', 'Berettiget interesse'] },
|
||
{ id: 'data_sources', label: 'Datakilder', type: 'textarea', from: 'local' }
|
||
]
|
||
},
|
||
|
||
// ===== SECURITY (3) =====
|
||
{
|
||
id: 'security',
|
||
category: 'security',
|
||
label: 'Sikkerhetsvurdering (6×5)',
|
||
description: 'Sikkerhetsvurdering på 6 dimensjoner med 1-5 score, OWASP LLM Top 10.',
|
||
argument_hint: '[plattform] for [bruksscenario]',
|
||
calls_agent: 'security-assessment-agent',
|
||
kb_files: ['security-scoring-rubrics-6x5.md', 'ai-security-scoring-framework.md', 'ai-threat-modeling-stride.md'],
|
||
produces_report: true,
|
||
report_archetype: 'matrix-risk-6x5',
|
||
report_root_class: 'matrix',
|
||
renderer: 'renderSecurity',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.sector,
|
||
SHARED.cloud_platform,
|
||
SHARED.data_classification,
|
||
{ id: 'platform', label: 'Plattform', type: 'select', from: 'local', options: PLATFORMS },
|
||
{ id: 'use_case', label: 'Bruksscenario', type: 'textarea', from: 'local' },
|
||
{ id: 'citizen_facing', label: 'Eksponert for innbyggere?', type: 'boolean', from: 'local' },
|
||
{ id: 'data_sources', label: 'Datakilder', type: 'textarea', from: 'local' }
|
||
]
|
||
},
|
||
{
|
||
id: 'ros',
|
||
category: 'security',
|
||
label: 'ROS-analyse (NS 5814 / ISO 31000)',
|
||
description: 'Risiko- og sårbarhetsanalyse med 7 dimensjoner og 49-trussel-bibliotek.',
|
||
argument_hint: '[system-beskrivelse] [--quick]',
|
||
calls_agent: 'ros-analysis-agent',
|
||
kb_files: ['ros-ai-threat-library.md', 'ros-scoring-rubrics-7x5.md', 'ros-methodology-ns5814-iso31000.md'],
|
||
produces_report: true,
|
||
report_archetype: 'matrix-risk',
|
||
report_root_class: 'matrix',
|
||
renderer: 'renderRos',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.sector,
|
||
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
|
||
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
|
||
{ id: 'complexity', label: 'Kompleksitet', type: 'select', from: 'local', options: ['ENKEL', 'MIDDELS', 'KOMPLEKS'] },
|
||
{ id: 'quick_mode', label: 'Hurtig-modus (mal A)', type: 'boolean', from: 'local' }
|
||
]
|
||
},
|
||
{
|
||
id: 'review',
|
||
category: 'security',
|
||
label: 'Arkitekturgjennomgang',
|
||
description: 'Gjennomgang mot Digdir, AI Act, NSM, Schrems II og norsk offentlig sektor-krav.',
|
||
argument_hint: '[arkitektur el. kontekst]',
|
||
calls_agent: 'architecture-review-agent',
|
||
kb_files: ['decision-trees.md', 'security.md', 'public-sector-checklist.md'],
|
||
produces_report: true,
|
||
report_archetype: 'findings',
|
||
report_root_class: 'findings',
|
||
renderer: 'renderReview',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.sector,
|
||
{ id: 'architecture_description', label: 'Arkitekturbeskrivelse', type: 'textarea', from: 'local' },
|
||
{ id: 'review_stage', label: 'Stadium', type: 'select', from: 'local', options: ['Pre-implementering', 'POC', 'Produksjon'] }
|
||
]
|
||
},
|
||
|
||
// ===== ECONOMY (2) =====
|
||
{
|
||
id: 'cost',
|
||
category: 'economy',
|
||
label: 'Kostnadsestimat (P10/P50/P90 NOK)',
|
||
description: 'Kostnadsestimering med konfidensgradering og TCO-sammenligning.',
|
||
argument_hint: '[plattform] med [antall brukere], [volum/dag]',
|
||
calls_agent: 'cost-estimation-agent',
|
||
kb_files: ['deterministic-cost-calculation-model.md', 'azure-ai-foundry-cost-governance.md', 'cost-models.md'],
|
||
produces_report: true,
|
||
report_archetype: 'cost-distribution',
|
||
report_root_class: 'distribution',
|
||
renderer: 'renderCost',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.license_type,
|
||
SHARED.cloud_platform,
|
||
{ id: 'platform', label: 'Plattform', type: 'select', from: 'local', options: PLATFORMS },
|
||
{ id: 'users', label: 'Antall brukere', type: 'number', from: 'local' },
|
||
{ id: 'volume_per_day', label: 'Volum per dag (transaksjoner/forespørsler)', type: 'text', from: 'local' },
|
||
{ id: 'region', label: 'Region', type: 'select', from: 'local', options: ['Norge (Norway East/West)', 'EU/EØS', 'Globalt'] }
|
||
]
|
||
},
|
||
{
|
||
id: 'license',
|
||
category: 'economy',
|
||
label: 'Lisens → AI-kapabiliteter',
|
||
description: 'Map lisenstype mot inkluderte AI-kapabiliteter og identifiser gap.',
|
||
argument_hint: '[lisenstype]',
|
||
calls_agent: 'license-mapper-agent',
|
||
kb_files: ['licensing-matrix.md'],
|
||
produces_report: true,
|
||
report_archetype: 'capability',
|
||
report_root_class: 'capability-matrix',
|
||
renderer: 'renderLicense',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.license_type,
|
||
SHARED.ai_services_in_use,
|
||
{ id: 'license_types', label: 'Lisenser å vurdere', type: 'multiSelect', from: 'local', options: ['E3', 'E5', 'F1/F3', 'A3/A5', 'G3/G5', 'Copilot for M365', 'Power Platform Premium'] }
|
||
]
|
||
},
|
||
|
||
// ===== DOCUMENTATION (6) =====
|
||
{
|
||
id: 'migrate',
|
||
category: 'documentation',
|
||
label: 'Migreringsplan',
|
||
description: 'Plan for migrasjon mellom Microsoft AI-plattformer.',
|
||
argument_hint: 'fra [kilde] til [mål]',
|
||
calls_agent: null,
|
||
kb_files: ['migration-patterns.md'],
|
||
produces_report: true,
|
||
report_archetype: 'phased-plan',
|
||
report_root_class: 'aiact-timeline',
|
||
renderer: 'renderMigrate',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
{ id: 'source_platform', label: 'Fra (kildeplattform)', type: 'select', from: 'local', options: PLATFORMS },
|
||
{ id: 'target_platform', label: 'Til (målplattform)', type: 'select', from: 'local', options: PLATFORMS },
|
||
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
|
||
{ id: 'timeline_weeks', label: 'Tidslinje (uker)', type: 'number', from: 'local' }
|
||
]
|
||
},
|
||
{
|
||
id: 'adr',
|
||
category: 'documentation',
|
||
label: 'ADR (MADR v3.0)',
|
||
description: 'Architecture Decision Record i MADR v3.0-format.',
|
||
argument_hint: '[valgfritt: tittel]',
|
||
calls_agent: 'adr-writer-agent',
|
||
kb_files: ['adr-template.md'],
|
||
produces_report: true,
|
||
report_archetype: 'markdown',
|
||
report_root_class: 'markdown-fallback',
|
||
renderer: 'renderAdr',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
{ id: 'decision_title', label: 'Beslutningstittel', type: 'text', from: 'local' },
|
||
{ id: 'decision_context', label: 'Kontekst', type: 'textarea', from: 'local' },
|
||
{ id: 'alternatives', label: 'Alternativer vurdert', type: 'textarea', from: 'local' },
|
||
{ id: 'decision', label: 'Valgt løsning', type: 'textarea', from: 'local' }
|
||
]
|
||
},
|
||
{
|
||
id: 'summary',
|
||
category: 'documentation',
|
||
label: 'Teknisk sammendrag + beslutningsnotat',
|
||
description: 'Aggregerer .work/-rapporter til teknisk sammendrag og beslutningsnotat.',
|
||
argument_hint: '[løsningsnavn]',
|
||
calls_agent: 'summary-agent',
|
||
kb_files: [],
|
||
produces_report: true,
|
||
report_archetype: 'verdict',
|
||
report_root_class: 'verdict-block',
|
||
renderer: 'renderSummary',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
{ id: 'solution_name', label: 'Løsningsnavn', type: 'text', from: 'local' }
|
||
]
|
||
},
|
||
{
|
||
id: 'poc',
|
||
category: 'documentation',
|
||
label: 'POC-plan',
|
||
description: 'POC-plan med suksesskriterier, tidslinje, risiko og Go/No-Go.',
|
||
argument_hint: '[plattform] for [use case]',
|
||
calls_agent: null,
|
||
kb_files: ['poc-template.md'],
|
||
produces_report: true,
|
||
report_archetype: 'phased-plan',
|
||
report_root_class: 'pipeline-cockpit',
|
||
renderer: 'renderPoc',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.sector,
|
||
SHARED.annual_ai_budget,
|
||
{ id: 'platform', label: 'Plattform', type: 'select', from: 'local', options: PLATFORMS },
|
||
{ id: 'use_case', label: 'Use case', type: 'textarea', from: 'local' },
|
||
{ id: 'team_size', label: 'Team-størrelse', type: 'number', from: 'local' },
|
||
{ id: 'team_level', label: 'Team-nivå', type: 'select', from: 'local', options: ['Junior', 'Mid', 'Senior', 'Mixed'] },
|
||
{ id: 'timeline_weeks', label: 'Tidslinje (uker)', type: 'number', from: 'local' },
|
||
{ id: 'stakeholders', label: 'Interessenter', type: 'textarea', from: 'local' }
|
||
]
|
||
},
|
||
{
|
||
id: 'utredning',
|
||
category: 'documentation',
|
||
label: 'AI-arkitekturutredning (off. sektor)',
|
||
description: 'Full S0–S9 arkitekturutredning for norsk offentlig sektor.',
|
||
argument_hint: '[scenario]',
|
||
calls_agent: null,
|
||
kb_files: ['ai-utredning-template.md'],
|
||
produces_report: true,
|
||
report_archetype: 'markdown',
|
||
report_root_class: 'markdown-fallback',
|
||
renderer: 'renderUtredning',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.sector,
|
||
SHARED.regulatory_requirements,
|
||
{ id: 'scenario_name', label: 'Scenario-navn', type: 'text', from: 'local' },
|
||
{ id: 'scenario_description', label: 'Scenario-beskrivelse', type: 'textarea', from: 'local' },
|
||
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' }
|
||
]
|
||
},
|
||
{
|
||
id: 'compare',
|
||
category: 'documentation',
|
||
label: 'Sammenlign plattformer',
|
||
description: 'Side-by-side sammenligning av Microsoft AI-plattformer for et use case.',
|
||
argument_hint: '[plattform A] vs [plattform B] for [use case]',
|
||
calls_agent: 'research-agent',
|
||
kb_files: ['decision-trees.md'],
|
||
produces_report: true,
|
||
report_archetype: 'comparison',
|
||
report_root_class: 'diff',
|
||
renderer: 'renderCompare',
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
{ id: 'platform_a', label: 'Plattform A', type: 'select', from: 'local', options: PLATFORMS },
|
||
{ id: 'platform_b', label: 'Plattform B', type: 'select', from: 'local', options: PLATFORMS },
|
||
{ id: 'use_case', label: 'Use case', type: 'textarea', from: 'local' }
|
||
]
|
||
},
|
||
|
||
// ===== TOOL (7) — ingen rapport, kun skjema + output-kopiering =====
|
||
{
|
||
id: 'architect',
|
||
category: 'tool',
|
||
label: 'Start Cosmo-rådgivning',
|
||
description: 'Start strukturert AI-arkitekturrådgivning med Cosmo Skyberg-persona.',
|
||
argument_hint: '[beskriv ditt forretningsproblem]',
|
||
calls_agent: null,
|
||
kb_files: [],
|
||
produces_report: false,
|
||
report_archetype: null,
|
||
report_root_class: null,
|
||
renderer: null,
|
||
input_fields: [
|
||
SHARED.organisation_name,
|
||
SHARED.sector,
|
||
{ id: 'business_problem', label: 'Forretningsproblem', type: 'textarea', from: 'local' }
|
||
]
|
||
},
|
||
{
|
||
id: 'help',
|
||
category: 'tool',
|
||
label: 'Hjelp',
|
||
description: 'Vis kommando-/agent-/KB-oversikt eller detaljer for et emne.',
|
||
argument_hint: '[emne for detaljer]',
|
||
calls_agent: null,
|
||
kb_files: [],
|
||
produces_report: false,
|
||
report_archetype: null,
|
||
report_root_class: null,
|
||
renderer: null,
|
||
input_fields: [
|
||
{ id: 'topic', label: 'Emne (valgfritt)', type: 'text', from: 'local' }
|
||
]
|
||
},
|
||
{
|
||
id: 'research',
|
||
category: 'tool',
|
||
label: 'Plattform-research',
|
||
description: 'Siste-nytt-research for en Microsoft AI-plattform.',
|
||
argument_hint: '[plattformnavn] [tidsperiode]',
|
||
calls_agent: 'research-agent',
|
||
kb_files: [],
|
||
produces_report: false,
|
||
report_archetype: null,
|
||
report_root_class: null,
|
||
renderer: null,
|
||
input_fields: [
|
||
{ id: 'platform', label: 'Plattform', type: 'select', from: 'local', options: PLATFORMS },
|
||
{ id: 'time_period', label: 'Tidsperiode', type: 'select', from: 'local', options: ['siste uke', 'siste måned', 'siste kvartal', 'siste år'] }
|
||
]
|
||
},
|
||
{
|
||
id: 'diagram',
|
||
category: 'tool',
|
||
label: 'Generer arkitekturdiagram',
|
||
description: 'Generer arkitekturdiagram med Imagen 3 (mcp-image).',
|
||
argument_hint: '[type] for [scenario]',
|
||
calls_agent: 'diagram-generation-agent',
|
||
kb_files: ['diagram-prompt-templates.md'],
|
||
produces_report: false,
|
||
report_archetype: null,
|
||
report_root_class: null,
|
||
renderer: null,
|
||
input_fields: [
|
||
{ id: 'diagram_type', label: 'Diagramtype', type: 'select', from: 'local', options: ['arkitektur', 'sikkerhet', 'dataflyt', 'problem', 'roadmap'] },
|
||
{ id: 'scenario', label: 'Scenario', type: 'text', from: 'local' },
|
||
{ id: 'component_list', label: 'Komponenter (valgfritt)', type: 'textarea', from: 'local' }
|
||
]
|
||
},
|
||
{
|
||
id: 'onboard',
|
||
category: 'tool',
|
||
label: 'Onboard plugin',
|
||
description: 'Onboard pluginen med virksomhetsspesifikk kontekst (5-fase intervju).',
|
||
argument_hint: '[--status]',
|
||
calls_agent: 'onboarding-agent',
|
||
kb_files: [],
|
||
produces_report: false,
|
||
report_archetype: null,
|
||
report_root_class: null,
|
||
renderer: null,
|
||
input_fields: [
|
||
{ id: 'status_only', label: 'Bare vis status', type: 'boolean', from: 'local' }
|
||
]
|
||
},
|
||
{
|
||
id: 'generate-skills',
|
||
category: 'tool',
|
||
label: 'Generer KB-filer (batch)',
|
||
description: 'Generer kunnskapsfiler med MCP-research i batch.',
|
||
argument_hint: '[antall]',
|
||
calls_agent: null,
|
||
kb_files: [],
|
||
produces_report: false,
|
||
report_archetype: null,
|
||
report_root_class: null,
|
||
renderer: null,
|
||
input_fields: [
|
||
{ id: 'count', label: 'Antall filer å generere', type: 'number', from: 'local' }
|
||
]
|
||
},
|
||
{
|
||
id: 'export',
|
||
category: 'tool',
|
||
label: 'Eksporter til PDF',
|
||
description: 'Eksporter et arkitekturdokument til PDF.',
|
||
argument_hint: '[filsti til markdown]',
|
||
calls_agent: null,
|
||
kb_files: [],
|
||
produces_report: false,
|
||
report_archetype: null,
|
||
report_root_class: null,
|
||
renderer: null,
|
||
input_fields: [
|
||
{ id: 'file_path', label: 'Filsti til markdown', type: 'text', from: 'local' }
|
||
]
|
||
}
|
||
]
|
||
};
|
||
|
||
// Eksponer for Step 5/8/9/11/12 og DevTools.
|
||
window.__CATALOG = CATALOG;
|
||
window.__SHARED_FIELDS = SHARED;
|
||
window.__FIELD_TYPES = FIELD_TYPES;
|
||
|
||
// ============================================================
|
||
// DOM HELPERS
|
||
// ============================================================
|
||
|
||
function escapeHtml(str) {
|
||
return String(str == null ? '' : str)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
function escapeAttr(str) { return escapeHtml(str); }
|
||
|
||
// ============================================================
|
||
// COMMAND FORM RENDERER + __buildCommand (Step 8)
|
||
// ============================================================
|
||
//
|
||
// renderCommandForm(commandId, opts) genererer HTML for ett command-skjema
|
||
// basert på CATALOG[id].input_fields. Brukes både i prosjekt-detalj
|
||
// (Step 7 form-zone) og i katalog-modal (Step 9). Felter med from='shared'
|
||
// pre-fylles fra state.shared via field.shared_path; lokale felter
|
||
// pre-fylles fra project.reports[id].input når opts.projectId er gitt.
|
||
//
|
||
// window.__buildCommand(commandId, formData) bygger '/architect:<id>
|
||
// key="value" ...'-streng. Shared-felter merges inn først, formData
|
||
// overstyrer hvis samme nøkkel. Tomme/null-verdier hoppes over. formData
|
||
// kan inneholde nøkler som ikke finnes i CATALOG (passthrough).
|
||
|
||
function resolveSharedPath(path) {
|
||
if (!path || !store || !store.state || !store.state.shared) return undefined;
|
||
const parts = String(path).split('.');
|
||
let cur = store.state.shared;
|
||
for (let i = 0; i < parts.length; i++) {
|
||
if (cur == null || typeof cur !== 'object') return undefined;
|
||
cur = cur[parts[i]];
|
||
}
|
||
return cur;
|
||
}
|
||
|
||
function isFilledArg(v, type) {
|
||
if (v == null) return false;
|
||
if (type === 'multiSelect' || Array.isArray(v)) return Array.isArray(v) && v.length > 0;
|
||
if (type === 'boolean' || typeof v === 'boolean') return v === true;
|
||
if (type === 'number' || typeof v === 'number') return !isNaN(v);
|
||
return String(v).trim() !== '';
|
||
}
|
||
|
||
function serializeArgValue(v) {
|
||
if (Array.isArray(v)) {
|
||
return '"' + v.map(function (x) { return String(x).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }).join(',') + '"';
|
||
}
|
||
if (typeof v === 'boolean') return String(v);
|
||
if (typeof v === 'number') return String(v);
|
||
const s = String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||
return '"' + s + '"';
|
||
}
|
||
|
||
function buildCommand(commandId, formData) {
|
||
formData = formData || {};
|
||
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
|
||
const args = {};
|
||
// 1. Pre-fyll fra shared (CATALOG-definerte felles felter).
|
||
if (cmd && cmd.input_fields) {
|
||
cmd.input_fields.forEach(function (f) {
|
||
if (f.from === 'shared' && f.shared_path) {
|
||
const v = resolveSharedPath(f.shared_path);
|
||
if (isFilledArg(v, f.type)) args[f.id] = v;
|
||
}
|
||
});
|
||
}
|
||
// 2. formData overstyrer / utvider. Tillater nøkler som ikke er i CATALOG.
|
||
Object.keys(formData).forEach(function (k) {
|
||
const v = formData[k];
|
||
if (isFilledArg(v)) args[k] = v;
|
||
else delete args[k];
|
||
});
|
||
// 3. Bygg streng. Stable order: shared-felter først (i CATALOG-rekkefølge),
|
||
// så resten i insertion-order.
|
||
const orderedKeys = [];
|
||
const seen = {};
|
||
if (cmd && cmd.input_fields) {
|
||
cmd.input_fields.forEach(function (f) {
|
||
if (Object.prototype.hasOwnProperty.call(args, f.id) && !seen[f.id]) {
|
||
orderedKeys.push(f.id);
|
||
seen[f.id] = true;
|
||
}
|
||
});
|
||
}
|
||
Object.keys(args).forEach(function (k) {
|
||
if (!seen[k]) {
|
||
orderedKeys.push(k);
|
||
seen[k] = true;
|
||
}
|
||
});
|
||
const parts = ['/architect:' + commandId];
|
||
orderedKeys.forEach(function (k) {
|
||
parts.push(k + '=' + serializeArgValue(args[k]));
|
||
});
|
||
return parts.join(' ');
|
||
}
|
||
|
||
function renderCommandFormField(field, domId, value) {
|
||
const fromAttr = field.from === 'shared' ? 'shared' : 'local';
|
||
const dataAttrs = 'data-cf-field="' + escapeAttr(field.id) + '" data-cf-from="' + fromAttr + '" data-cf-type="' + escapeAttr(field.type) + '"';
|
||
const fromTag = field.from === 'shared'
|
||
? '<span class="field-from-tag" title="Forhåndsutfylt fra onboarding (state.shared.' + escapeAttr(field.shared_path || '') + ')">felles</span>'
|
||
: '';
|
||
const labelHtml = '<label for="' + domId + '" class="field-label">' + escapeHtml(field.label) + fromTag + '</label>';
|
||
let inputHtml = '';
|
||
if (field.type === 'text') {
|
||
inputHtml = '<input type="text" id="' + domId + '" ' + dataAttrs + ' value="' + escapeAttr(value == null ? '' : String(value)) + '" class="input">';
|
||
} else if (field.type === 'textarea') {
|
||
inputHtml = '<textarea id="' + domId + '" ' + dataAttrs + ' class="textarea" rows="3">' + escapeHtml(value == null ? '' : String(value)) + '</textarea>';
|
||
} else if (field.type === 'number') {
|
||
inputHtml = '<input type="number" id="' + domId + '" ' + dataAttrs + ' value="' + escapeAttr(value == null || value === '' ? '' : String(value)) + '" class="input">';
|
||
} else if (field.type === 'select') {
|
||
const opts = ['<option value="">(velg)</option>'].concat((field.options || []).map(function (o) {
|
||
const sel = (o === value) ? ' selected' : '';
|
||
return '<option value="' + escapeAttr(o) + '"' + sel + '>' + escapeHtml(o) + '</option>';
|
||
})).join('');
|
||
inputHtml = '<select id="' + domId + '" ' + dataAttrs + ' class="select">' + opts + '</select>';
|
||
} else if (field.type === 'multiSelect') {
|
||
const arr = Array.isArray(value) ? value : [];
|
||
const opts = (field.options || []).map(function (o, i) {
|
||
const checked = arr.indexOf(o) >= 0 ? ' checked' : '';
|
||
const cbId = domId + '-' + i;
|
||
return (
|
||
'<label class="checkbox-row" for="' + cbId + '">' +
|
||
'<input type="checkbox" id="' + cbId + '" ' + dataAttrs + ' data-cf-multi="' + escapeAttr(o) + '"' + checked + '>' +
|
||
'<span>' + escapeHtml(o) + '</span>' +
|
||
'</label>'
|
||
);
|
||
}).join('');
|
||
inputHtml = (
|
||
'<fieldset class="multi-select" aria-labelledby="' + domId + '-legend">' +
|
||
'<legend id="' + domId + '-legend" class="visually-hidden">' + escapeHtml(field.label) + '</legend>' +
|
||
opts +
|
||
'</fieldset>'
|
||
);
|
||
} else if (field.type === 'boolean') {
|
||
const checked = value === true ? ' checked' : '';
|
||
inputHtml = (
|
||
'<label class="checkbox-row" for="' + domId + '">' +
|
||
'<input type="checkbox" id="' + domId + '" ' + dataAttrs + checked + '>' +
|
||
'<span>Ja</span>' +
|
||
'</label>'
|
||
);
|
||
} else {
|
||
// Ukjent type — fall tilbake til text.
|
||
inputHtml = '<input type="text" id="' + domId + '" ' + dataAttrs + ' value="' + escapeAttr(value == null ? '' : String(value)) + '" class="input">';
|
||
}
|
||
return '<div class="field-row" data-cf-field-row="' + escapeAttr(field.id) + '">' + labelHtml + inputHtml + '</div>';
|
||
}
|
||
|
||
function renderCommandForm(commandId, opts) {
|
||
opts = opts || {};
|
||
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
|
||
if (!cmd) {
|
||
return '<div class="guide-panel guide-panel--warn"><div class="guide-panel__icon" aria-hidden="true">!</div><div class="guide-panel__body"><p class="guide-panel__text">Ukjent command: ' + escapeHtml(commandId) + '</p></div></div>';
|
||
}
|
||
const project = opts.projectId ? findProject(opts.projectId) : null;
|
||
const savedInput = (project && project.reports && project.reports[commandId] && project.reports[commandId].input) || {};
|
||
const scope = opts.scope || 'p';
|
||
|
||
const fieldRows = (cmd.input_fields || []).map(function (f) {
|
||
const domId = 'cf-' + scope + '-' + cmd.id + '-' + f.id;
|
||
let value;
|
||
if (f.from === 'shared' && f.shared_path) {
|
||
value = resolveSharedPath(f.shared_path);
|
||
}
|
||
if (value === undefined || value === null || value === '') {
|
||
if (Object.prototype.hasOwnProperty.call(savedInput, f.id)) value = savedInput[f.id];
|
||
}
|
||
return renderCommandFormField(f, domId, value);
|
||
}).join('');
|
||
|
||
const sharedCount = (cmd.input_fields || []).filter(function (f) { return f.from === 'shared'; }).length;
|
||
const fieldCount = (cmd.input_fields || []).length;
|
||
|
||
return (
|
||
'<form class="command-form" data-command-form="' + escapeAttr(cmd.id) + '" data-command-form-scope="' + escapeAttr(scope) + '" autocomplete="off" onsubmit="return false;">' +
|
||
'<div class="command-form__fields">' + fieldRows + '</div>' +
|
||
'<div class="command-form__actions">' +
|
||
'<button type="button" class="btn btn--primary btn--sm" data-action="copy-command" data-command="' + escapeAttr(cmd.id) + '">Kopier kommando</button>' +
|
||
'<button type="button" class="btn btn--secondary btn--sm" data-action="preview-command" data-command="' + escapeAttr(cmd.id) + '">Forhåndsvis</button>' +
|
||
'<span class="command-form__hint">' + fieldCount + ' felter (' + sharedCount + ' fra shared).</span>' +
|
||
'<span class="command-form__copy-confirm" data-copy-confirm hidden></span>' +
|
||
'</div>' +
|
||
'<div class="form-preview" data-form-preview hidden>' +
|
||
'<h5 class="form-preview__heading">Pipeline-streng</h5>' +
|
||
'<pre class="code-block" data-form-preview-text></pre>' +
|
||
'</div>' +
|
||
'</form>'
|
||
);
|
||
}
|
||
|
||
function readCommandFormValues(formEl) {
|
||
const data = {};
|
||
if (!formEl) return data;
|
||
const cmdId = formEl.dataset.commandForm;
|
||
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === cmdId; });
|
||
// Initialiser multiSelect til [] så uavkryssede ender opp tomme.
|
||
if (cmd && cmd.input_fields) {
|
||
cmd.input_fields.forEach(function (f) {
|
||
if (f.type === 'multiSelect') data[f.id] = [];
|
||
});
|
||
}
|
||
const inputs = formEl.querySelectorAll('[data-cf-field]');
|
||
for (let i = 0; i < inputs.length; i++) {
|
||
const el = inputs[i];
|
||
const id = el.dataset.cfField;
|
||
if (el.matches('input[type="checkbox"][data-cf-multi]')) {
|
||
if (el.checked) {
|
||
if (!Array.isArray(data[id])) data[id] = [];
|
||
data[id].push(el.dataset.cfMulti);
|
||
}
|
||
} else if (el.matches('input[type="checkbox"]')) {
|
||
data[id] = el.checked;
|
||
} else if (el.matches('input[type="number"]')) {
|
||
if (el.value === '' || el.value == null) {
|
||
data[id] = null;
|
||
} else {
|
||
const n = Number(el.value);
|
||
data[id] = isNaN(n) ? null : n;
|
||
}
|
||
} else {
|
||
data[id] = el.value;
|
||
}
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function showCommandPreview(formEl, str) {
|
||
if (!formEl) return;
|
||
const box = formEl.querySelector('[data-form-preview]');
|
||
const text = formEl.querySelector('[data-form-preview-text]');
|
||
if (!box || !text) return;
|
||
text.textContent = str;
|
||
box.hidden = false;
|
||
}
|
||
|
||
function flashCopyConfirm(formEl, message) {
|
||
if (!formEl) return;
|
||
const tag = formEl.querySelector('[data-copy-confirm]');
|
||
if (!tag) return;
|
||
tag.textContent = message || 'Kopiert til utklippstavle.';
|
||
tag.hidden = false;
|
||
clearTimeout(tag.__hideTimer);
|
||
tag.__hideTimer = setTimeout(function () { tag.hidden = true; }, 2400);
|
||
}
|
||
|
||
// ============================================================
|
||
// SURFACE ROUTING (Step 5)
|
||
// ============================================================
|
||
//
|
||
// Én [data-surface] er synlig om gangen, drevet av state.activeSurface.
|
||
// navigate(name) muterer state og scheduler render. scheduleRender batcher
|
||
// via queueMicrotask så flere mutasjoner i samme tick gir én render.
|
||
//
|
||
// Vi subscriber IKKE alle state-endringer til render — det ville
|
||
// re-rendret skjemaer mens brukeren skriver. Render trigges eksplisitt
|
||
// fra action-handlers og navigate().
|
||
|
||
function getSurfaceEl(name) {
|
||
return document.querySelector('[data-surface="' + name + '"]');
|
||
}
|
||
|
||
function showSurface(name) {
|
||
const surfaces = document.querySelectorAll('main#app > [data-surface]');
|
||
for (let i = 0; i < surfaces.length; i++) {
|
||
surfaces[i].hidden = (surfaces[i].dataset.surface !== name);
|
||
}
|
||
}
|
||
|
||
let renderQueued = false;
|
||
function scheduleRender() {
|
||
if (renderQueued) return;
|
||
renderQueued = true;
|
||
queueMicrotask(function () {
|
||
renderQueued = false;
|
||
renderActive();
|
||
});
|
||
}
|
||
|
||
function renderActive() {
|
||
if (!store) return;
|
||
const active = store.state.activeSurface || 'home';
|
||
showSurface(active);
|
||
if (active === 'onboarding') renderOnboardingSurface();
|
||
else if (active === 'home') renderHomeSurface();
|
||
else if (active === 'project') renderProjectSurface();
|
||
else if (active === 'catalog') renderCatalogSurface();
|
||
}
|
||
|
||
function navigate(surface) {
|
||
store.state.activeSurface = surface;
|
||
scheduleRender();
|
||
}
|
||
|
||
// App-header — gjenbrukes på home, catalog, project. Onboarding viser ingen header
|
||
// (full-fokus førstegangs-flyt). Eksport/import-knapper wires opp til
|
||
// __exportState/__importState fra Step 3.
|
||
function renderTopbar(crumb) {
|
||
const orgName = (store.state.shared.organization && store.state.shared.organization.name) || '';
|
||
const breadcrumbInner = (orgName ? escapeHtml(orgName) : '') + (orgName && crumb ? ' · ' : '') + (crumb || '');
|
||
const breadcrumbHtml = breadcrumbInner
|
||
? '<nav class="app-header__breadcrumb" aria-label="Brødsmuler">' + breadcrumbInner + '</nav>'
|
||
: '';
|
||
const currentTheme = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||
const themeLabel = currentTheme === 'light' ? 'Lys' : 'Mørk';
|
||
const themeNext = currentTheme === 'light' ? 'mørk' : 'lys';
|
||
return (
|
||
'<header class="app-header">' +
|
||
'<div class="app-header__brand">' +
|
||
'<span class="app-header__brand-mark" aria-hidden="true">M</span>' +
|
||
'<span>ms-ai-architect</span>' +
|
||
'</div>' +
|
||
breadcrumbHtml +
|
||
'<div class="app-header__spacer"></div>' +
|
||
'<div class="app-header__actions" role="group" aria-label="Hovednavigasjon">' +
|
||
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-home">Hjem</button>' +
|
||
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-catalog">Katalog</button>' +
|
||
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-onboarding">Re-onboard</button>' +
|
||
'<button type="button" class="btn btn--secondary btn--sm" data-action="export-state" aria-label="Eksporter state til JSON">Eksporter</button>' +
|
||
'<button type="button" class="btn btn--secondary btn--sm" data-action="import-state" aria-label="Importer state fra JSON">Importer</button>' +
|
||
'<input type="file" accept="application/json,.json" data-import-input hidden>' +
|
||
'<button type="button" class="theme-toggle" data-action="toggle-theme" aria-label="Bytt til ' + themeNext + ' modus">' +
|
||
'<span data-theme-label>' + themeLabel + '</span>' +
|
||
'</button>' +
|
||
'</div>' +
|
||
'</header>'
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// HOME SURFACE (Step 6)
|
||
// ============================================================
|
||
//
|
||
// 3 entry-tracks (.tracks med .tracks__card--guided/explore/expert) som
|
||
// første-valg på home. Under: prosjekt-liste i .fleet-grid med .fleet-tile
|
||
// per prosjekt. Tom-state: .guide-panel--info. "Nytt prosjekt"-knapp
|
||
// åpner modal (modal-handler i Step 7 — Step 6 har stub).
|
||
|
||
function projectReportCount(p) {
|
||
if (!p || !p.reports) return 0;
|
||
let count = 0;
|
||
for (const k in p.reports) {
|
||
if (p.reports[k] && p.reports[k].parsed) count++;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
// Aggregert verdict for project-surface page-shell. Henter parsed.verdict
|
||
// fra alle reports og kollapser til én pille: block > go-with-conditions
|
||
// > approved > n-a. Tom reports{} -> 'n-a' per Sesjon 2-risk-flagg.
|
||
function inferProjectVerdict(project) {
|
||
const reports = (project && project.reports) || {};
|
||
const verdicts = [];
|
||
for (const k in reports) {
|
||
const v = reports[k] && reports[k].parsed && reports[k].parsed.verdict;
|
||
if (v) verdicts.push(String(v).toLowerCase());
|
||
}
|
||
if (verdicts.length === 0) return 'n-a';
|
||
for (let i = 0; i < verdicts.length; i++) {
|
||
if (verdicts[i] === 'block' || verdicts[i] === 'failed') return 'block';
|
||
}
|
||
for (let i = 0; i < verdicts.length; i++) {
|
||
const v = verdicts[i];
|
||
if (v === 'go-with-conditions' || v === 'warning') return 'go-with-conditions';
|
||
}
|
||
let allGo = true;
|
||
for (let i = 0; i < verdicts.length; i++) {
|
||
const v = verdicts[i];
|
||
if (v !== 'go' && v !== 'approved' && v !== 'allow') { allGo = false; break; }
|
||
}
|
||
return allGo ? 'approved' : 'n-a';
|
||
}
|
||
|
||
function inferProjectLastUpdated(project) {
|
||
const reports = (project && project.reports) || {};
|
||
let latest = null;
|
||
for (const k in reports) {
|
||
const r = reports[k];
|
||
if (r && r.updatedAt) {
|
||
if (!latest || r.updatedAt > latest) latest = r.updatedAt;
|
||
}
|
||
}
|
||
const ts = latest || (project && project.createdAt) || '';
|
||
return ts ? String(ts).slice(0, 10) : '–';
|
||
}
|
||
|
||
function projectMeterBand(filled, total) {
|
||
if (total === 0) return '4'; // tom = "krever oppmerksomhet"
|
||
const pct = filled / total;
|
||
if (pct >= 0.8) return '1';
|
||
if (pct >= 0.5) return '2';
|
||
if (pct >= 0.2) return '3';
|
||
return '4';
|
||
}
|
||
|
||
function renderHomeSurface() {
|
||
const root = getSurfaceEl('home');
|
||
if (!root) return;
|
||
|
||
const projects = store.state.projects || [];
|
||
const reportTotal = CATALOG.commands.filter(function (c) { return c.produces_report; }).length;
|
||
|
||
const tracksHtml = (
|
||
'<div class="tracks">' +
|
||
'<button type="button" class="tracks__card tracks__card--guided" data-action="goto-onboarding">' +
|
||
'<span class="tracks__card-icon" aria-hidden="true">⚙︎</span>' +
|
||
'<h3 class="tracks__card-title">Onboard / Re-onboard</h3>' +
|
||
'<p class="tracks__card-desc">Oppdater de 20 felles feltene som forhåndsutfyller alle command-skjemaer.</p>' +
|
||
'<span class="tracks__card-meta"><span>Felles state</span><span class="tracks__card-cta">Åpne →</span></span>' +
|
||
'</button>' +
|
||
'<button type="button" class="tracks__card tracks__card--explore" data-action="new-project">' +
|
||
'<span class="tracks__card-icon" aria-hidden="true">+</span>' +
|
||
'<h3 class="tracks__card-title">Nytt prosjekt</h3>' +
|
||
'<p class="tracks__card-desc">Start et nytt arkitektur-prosjekt. Hvert prosjekt holder sine egne ROS, DPIA, AI Act-klassifisering osv.</p>' +
|
||
'<span class="tracks__card-meta"><span>Per-prosjekt state</span><span class="tracks__card-cta">Opprett →</span></span>' +
|
||
'</button>' +
|
||
'<button type="button" class="tracks__card tracks__card--expert" data-action="goto-catalog">' +
|
||
'<span class="tracks__card-icon" aria-hidden="true">◇</span>' +
|
||
'<h3 class="tracks__card-title">Command-katalog</h3>' +
|
||
'<p class="tracks__card-desc">Bla i alle 24 commands gruppert på kategori. Generer pipeline-strenger uten et prosjekt.</p>' +
|
||
'<span class="tracks__card-meta"><span>' + CATALOG.commands.length + ' commands</span><span class="tracks__card-cta">Bla →</span></span>' +
|
||
'</button>' +
|
||
'</div>'
|
||
);
|
||
|
||
const projectListHtml = (function () {
|
||
if (projects.length === 0) {
|
||
return (
|
||
'<div class="guide-panel guide-panel--info">' +
|
||
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
|
||
'<div class="guide-panel__body">' +
|
||
'<h3 class="guide-panel__title">Du har ingen prosjekter ennå</h3>' +
|
||
'<p class="guide-panel__text">Opprett ditt første for å starte ROS-, DPIA- og AI Act-arbeid. Felles felter du fylte ut i onboarding gjenbrukes automatisk.</p>' +
|
||
'<div class="guide-panel__action">' +
|
||
'<button type="button" class="btn btn--primary" data-action="new-project">Opprett første prosjekt</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
const tiles = projects.map(function (p) {
|
||
const filled = projectReportCount(p);
|
||
const band = projectMeterBand(filled, reportTotal);
|
||
const pct = reportTotal ? Math.round(100 * filled / reportTotal) : 0;
|
||
const scenarios = Array.isArray(p.scenarios) ? p.scenarios : [];
|
||
const chip = scenarios.length > 0
|
||
? '<span class="fleet-tile__chip">' + escapeHtml(scenarios[0]) + (scenarios.length > 1 ? ' +' + (scenarios.length - 1) : '') + '</span>'
|
||
: '<span class="fleet-tile__chip">Uten scenario</span>';
|
||
return (
|
||
'<button type="button" class="fleet-tile" data-action="open-project" data-project-id="' + escapeAttr(p.id) + '">' +
|
||
'<div class="fleet-tile__row">' +
|
||
'<span class="fleet-tile__name" title="' + escapeAttr(p.name) + '">' + escapeHtml(p.name) + '</span>' +
|
||
chip +
|
||
'</div>' +
|
||
'<div class="fleet-tile__meter" aria-label="Rapport-fremdrift">' +
|
||
'<span class="fleet-tile__meter-fill" data-band="' + band + '" style="width:' + Math.max(pct, 4) + '%"></span>' +
|
||
'</div>' +
|
||
'<div class="fleet-tile__meta">' +
|
||
'<span>' + filled + '/' + reportTotal + ' rapporter</span>' +
|
||
'<span class="fleet-tile__trend--stable">' + pct + '%</span>' +
|
||
'</div>' +
|
||
'</button>'
|
||
);
|
||
}).join('');
|
||
return '<div class="fleet-grid">' + tiles + '</div>';
|
||
})();
|
||
|
||
const orgName = (store.state.shared.organization && store.state.shared.organization.name) || '';
|
||
const activeReportCount = projects.reduce(function (a, p) { return a + projectReportCount(p); }, 0);
|
||
const homeShell = renderPageShell({
|
||
eyebrow: 'HJEM',
|
||
title: 'Hei, ' + (orgName || 'venn'),
|
||
lede: orgName
|
||
? 'Velg arbeidsspor eller utforsk eksisterende prosjekter. Felles state er aktiv og forhåndsutfyller skjemaer.'
|
||
: 'Single-file arkitektur-rådgivning for Microsoft AI-stakken. Start med onboarding for å aktivere felles state.',
|
||
verdict: 'n-a',
|
||
keyStats: [
|
||
{ label: 'PROSJEKTER', value: projects.length },
|
||
{ label: 'AKTIVE RAPPORTER', value: activeReportCount }
|
||
]
|
||
},
|
||
'<div class="stack-lg">' +
|
||
tracksHtml +
|
||
'<section class="home-projects">' +
|
||
'<span class="eyebrow">PROSJEKTER · ' + projects.length + ' av ' + projects.length + '</span>' +
|
||
'<div class="home-section-head">' +
|
||
'<h2>Mine prosjekter</h2>' +
|
||
'<span class="home-section-meta">' + projects.length + ' prosjekt' + (projects.length === 1 ? '' : 'er') + ' · maks ' + reportTotal + ' rapporter per prosjekt</span>' +
|
||
'</div>' +
|
||
projectListHtml +
|
||
(projects.length > 0 ? '<div class="onboarding-actions" style="margin-top: var(--space-4);"><button type="button" class="btn btn--primary" data-action="new-project">Nytt prosjekt</button></div>' : '') +
|
||
'</section>' +
|
||
'</div>'
|
||
);
|
||
|
||
root.innerHTML = (
|
||
renderTopbar('Hjem') +
|
||
'<div class="app-shell">' +
|
||
homeShell +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// PROJECT SURFACE (Step 7)
|
||
// ============================================================
|
||
//
|
||
// Per-prosjekt detalj: header med navn + scenario-chips, 5 kategori-tabs
|
||
// (én per CATALOG-kategori), command-kort i hver tab. Sub-zones per kort:
|
||
// 1. Skjema-zone — placeholder (Step 8 fyller med renderCommandForm)
|
||
// 2. Paste-import — KUN for produces_report=true (textarea + parse-knapp)
|
||
// 3. Rapport-slot — KUN for produces_report=true (data-report-slot)
|
||
// Verktøy-commands får skjema-zone + .guide-panel--info 'Verktøy'-notis.
|
||
//
|
||
// Prosjekt-opprettelse via modal (createProjectFromModal). projectId =
|
||
// crypto.randomUUID. Sletting via .error-summary-modal med eksplisitt
|
||
// bekreftelse.
|
||
//
|
||
// Active-tab er transient (modul-lokal currentProjectTab) så export-state
|
||
// ikke forurenses av UI-state. Default 'regulatory' ved hver project-enter.
|
||
|
||
// 8 scenarioer fra v2 — gjenbrukes som scenario-tags på prosjekter.
|
||
const SCENARIOS = [
|
||
{ id: 'rag-chatbot', name: 'RAG-chatbot for interne dokumenter' },
|
||
{ id: 'autonomous-agent', name: 'Autonom agent for saksbehandling' },
|
||
{ id: 'document-classification', name: 'Dokumentklassifisering og -prosessering' },
|
||
{ id: 'multi-agent', name: 'Multi-agent workflow' },
|
||
{ id: 'copilot-extension', name: 'Copilot-utvidelse for M365' },
|
||
{ id: 'customer-service', name: 'Kundeservice-chatbot' },
|
||
{ id: 'intelligent-search', name: 'Intelligent søk på tvers av fagsystemer' },
|
||
{ id: 'reporting', name: 'AI-assistert rapportering' }
|
||
];
|
||
|
||
let currentProjectTab = 'regulatory';
|
||
// Screen-tabs på project-surface (A4 Tier 3): Oversikt / Rapporter /
|
||
// Kontekst / Eksport. Default 'rapporter' = primær arbeidsflate (eksisterende
|
||
// category-tabs + panels). Andre skjermer er stub i Sesjon 2 og fylles ut
|
||
// i senere sesjoner.
|
||
let currentProjectScreen = 'rapporter';
|
||
|
||
function findProject(id) {
|
||
const list = store.state.projects || [];
|
||
for (let i = 0; i < list.length; i++) {
|
||
if (list[i].id === id) return list[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function createProject(data) {
|
||
const id = (typeof crypto !== 'undefined' && crypto.randomUUID)
|
||
? crypto.randomUUID()
|
||
: 'p-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10);
|
||
const project = {
|
||
id: id,
|
||
name: data.name || 'Uten navn',
|
||
description: data.description || '',
|
||
scenarios: Array.isArray(data.scenarios) ? data.scenarios.slice() : [],
|
||
createdAt: new Date().toISOString(),
|
||
reports: {} // commandId → { input: {...}, raw_markdown: '', parsed: {...} }
|
||
};
|
||
// Push via Proxy så change-event fyres og persistens skedules.
|
||
store.state.projects.push(project);
|
||
store.state.activeProjectId = id;
|
||
currentProjectTab = 'regulatory';
|
||
currentProjectScreen = 'rapporter';
|
||
return project;
|
||
}
|
||
|
||
function deleteProject(id) {
|
||
const list = store.state.projects;
|
||
for (let i = 0; i < list.length; i++) {
|
||
if (list[i].id === id) {
|
||
list.splice(i, 1);
|
||
break;
|
||
}
|
||
}
|
||
if (store.state.activeProjectId === id) store.state.activeProjectId = null;
|
||
}
|
||
|
||
// ---- Modal infrastructure ----
|
||
|
||
function mountModal(html) {
|
||
unmountModal();
|
||
const wrapper = document.createElement('div');
|
||
wrapper.innerHTML = html;
|
||
const node = wrapper.firstElementChild;
|
||
if (!node) return;
|
||
node.setAttribute('data-modal-root', 'true');
|
||
document.body.appendChild(node);
|
||
// Klikk på backdrop (selve roten) lukker; klikk inni .modal bobler ikke til root.
|
||
node.addEventListener('click', function (ev) {
|
||
if (ev.target === node) unmountModal();
|
||
});
|
||
// Esc lukker
|
||
function escHandler(ev) {
|
||
if (ev.key === 'Escape') {
|
||
unmountModal();
|
||
document.removeEventListener('keydown', escHandler);
|
||
}
|
||
}
|
||
document.addEventListener('keydown', escHandler);
|
||
// Fokuser første input
|
||
setTimeout(function () {
|
||
const first = node.querySelector('input, select, textarea, button');
|
||
if (first && first.focus) first.focus();
|
||
}, 0);
|
||
}
|
||
|
||
function unmountModal() {
|
||
const existing = document.querySelector('[data-modal-root]');
|
||
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
||
}
|
||
|
||
function renderNewProjectModalHtml() {
|
||
const scenarioOptions = SCENARIOS.map(function (s, i) {
|
||
return (
|
||
'<label class="checkbox-row" for="np-scen-' + i + '">' +
|
||
'<input type="checkbox" id="np-scen-' + i + '" data-new-project-scenario value="' + escapeAttr(s.id) + '">' +
|
||
'<span>' + escapeHtml(s.name) + '</span>' +
|
||
'</label>'
|
||
);
|
||
}).join('');
|
||
return (
|
||
'<div class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="np-title">' +
|
||
'<div class="modal">' +
|
||
'<h2 class="modal__title" id="np-title">Nytt prosjekt</h2>' +
|
||
'<div class="field-row">' +
|
||
'<label for="np-name" class="field-label">Prosjektnavn<span class="required-mark" aria-hidden="true">*</span></label>' +
|
||
'<input type="text" id="np-name" class="input" data-new-project-field="name" required>' +
|
||
'</div>' +
|
||
'<div class="field-row">' +
|
||
'<label for="np-desc" class="field-label">System-beskrivelse</label>' +
|
||
'<textarea id="np-desc" class="textarea" data-new-project-field="description" rows="3" placeholder="Hva skal AI-systemet gjøre? Hvilke brukere?"></textarea>' +
|
||
'</div>' +
|
||
'<div class="field-row">' +
|
||
'<span class="field-label">Scenario-tagging</span>' +
|
||
'<fieldset class="multi-select" aria-label="Scenarioer">' + scenarioOptions + '</fieldset>' +
|
||
'<span class="field-help">Brukes for sammenligning og pipeline-anbefalinger.</span>' +
|
||
'</div>' +
|
||
'<div class="error-summary" data-new-project-errors hidden role="alert">' +
|
||
'<h3 class="error-summary__heading">Mangler input</h3>' +
|
||
'<div class="error-summary__body"><p data-new-project-error-text></p></div>' +
|
||
'</div>' +
|
||
'<div class="modal__actions">' +
|
||
'<button type="button" class="btn btn--ghost" data-action="modal-cancel">Avbryt</button>' +
|
||
'<button type="button" class="btn btn--primary" data-action="create-project">Opprett</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
function renderDeleteProjectModalHtml(project) {
|
||
const reportCount = projectReportCount(project);
|
||
return (
|
||
'<div class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="dp-title">' +
|
||
'<div class="modal">' +
|
||
'<h2 class="modal__title" id="dp-title">Slett prosjekt?</h2>' +
|
||
'<div class="error-summary">' +
|
||
'<h3 class="error-summary__heading">Bekreft sletting</h3>' +
|
||
'<div class="error-summary__body">' +
|
||
'<p>Dette fjerner prosjektet <strong>' + escapeHtml(project.name) + '</strong> og ' + reportCount + ' importert' + (reportCount === 1 ? '' : 'e') + ' rapport' + (reportCount === 1 ? '' : 'er') + '. Handlingen kan ikke angres.</p>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="modal__actions">' +
|
||
'<button type="button" class="btn btn--ghost" data-action="modal-cancel">Avbryt</button>' +
|
||
'<button type="button" class="btn btn--destructive" data-action="confirm-delete-project" data-project-id="' + escapeAttr(project.id) + '">Slett prosjekt</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
// ---- Sub-card rendering ----
|
||
|
||
function renderCommandSubCard(cmd, projectId) {
|
||
// Sev-modifier: hvis rapporten er parsed, mappe verdict til DS card--severity-{level}.
|
||
// Plugin-domain-verdicts (go/block/approved/...) → severity-band (positive/critical/medium).
|
||
const project = findProject(projectId);
|
||
const report = project && project.reports && project.reports[cmd.id];
|
||
const verdict = report && report.parsed && report.parsed.verdict
|
||
? String(report.parsed.verdict).toLowerCase() : '';
|
||
const sevMap = {
|
||
'go': 'positive', 'approved': 'positive', 'allow': 'positive',
|
||
'go-with-conditions': 'medium', 'warning': 'medium',
|
||
'block': 'critical', 'failed': 'critical'
|
||
};
|
||
const sevModifier = sevMap[verdict] || '';
|
||
const sevClass = sevModifier ? ' card--severity-' + sevModifier : '';
|
||
|
||
const titleHtml = (
|
||
'<div class="card__head">' +
|
||
'<div>' +
|
||
'<h3 class="card__title">' + escapeHtml(cmd.label) + '</h3>' +
|
||
'<p class="card__desc">' + escapeHtml(cmd.description) + '</p>' +
|
||
'</div>' +
|
||
'<span class="card__id">/architect:' + escapeHtml(cmd.id) + '</span>' +
|
||
'</div>'
|
||
);
|
||
|
||
const formZone = (
|
||
'<div class="sub-zone">' +
|
||
'<h4 class="sub-zone__heading">Skjema</h4>' +
|
||
'<div data-form-zone="' + escapeAttr(cmd.id) + '">' +
|
||
renderCommandForm(cmd.id, { context: 'project', projectId: projectId, scope: 'p' }) +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
|
||
if (!cmd.produces_report) {
|
||
// Verktøy: skjema-zone + .guide-panel--info notis
|
||
const toolNotice = (
|
||
'<div class="sub-zone">' +
|
||
'<div class="guide-panel guide-panel--info">' +
|
||
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
|
||
'<div class="guide-panel__body">' +
|
||
'<h3 class="guide-panel__title">Verktøy</h3>' +
|
||
'<p class="guide-panel__text">Dette er et verktøy. Ingen rapport-import — bruk skjemaet til å bygge en pipeline-streng som kjøres i terminalen.</p>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
return (
|
||
'<article class="card' + sevClass + '" data-command-card data-command-id="' + escapeAttr(cmd.id) + '">' +
|
||
titleHtml +
|
||
formZone +
|
||
toolNotice +
|
||
'</article>'
|
||
);
|
||
}
|
||
|
||
// Rapport-produserende: skjema-zone + paste-import-zone + report-zone
|
||
const pasteZone = (
|
||
'<div class="sub-zone">' +
|
||
'<h4 class="sub-zone__heading">Lim inn rapport-output</h4>' +
|
||
'<div class="paste-import-row">' +
|
||
'<textarea class="textarea" data-paste-import="' + escapeAttr(cmd.id) + '" rows="4" placeholder="Lim inn markdown-output fra terminalen her"></textarea>' +
|
||
'<div class="paste-import-row__actions">' +
|
||
'<button type="button" class="btn btn--secondary btn--sm" data-action="parse" data-command="' + escapeAttr(cmd.id) + '">Analyser rapport</button>' +
|
||
'<span class="field-help">Routes via PARSERS[' + escapeHtml(cmd.report_archetype || '?') + '] → ' + escapeHtml(cmd.renderer || '?') + ' (Step 11/12).</span>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
|
||
const reportZone = (
|
||
'<div class="sub-zone">' +
|
||
'<h4 class="sub-zone__heading">Visualisering</h4>' +
|
||
'<div class="report-slot ' + escapeAttr(cmd.report_root_class || '') + '" data-report-slot="' + escapeAttr(cmd.id) + '"></div>' +
|
||
'</div>'
|
||
);
|
||
|
||
return (
|
||
'<article class="card' + sevClass + '" data-command-card data-command-id="' + escapeAttr(cmd.id) + '">' +
|
||
titleHtml +
|
||
formZone +
|
||
pasteZone +
|
||
reportZone +
|
||
'</article>'
|
||
);
|
||
}
|
||
|
||
function renderProjectSurface() {
|
||
const root = getSurfaceEl('project');
|
||
if (!root) return;
|
||
|
||
const project = findProject(store.state.activeProjectId);
|
||
if (!project) {
|
||
// Mistet aktivt prosjekt — fall tilbake til hjem.
|
||
navigate('home');
|
||
return;
|
||
}
|
||
|
||
const reportTotal = CATALOG.commands.filter(function (c) { return c.produces_report; }).length;
|
||
const reportFilled = projectReportCount(project);
|
||
|
||
// Action-bar (Tilbake / Slett) flyttet under page-shell-headeren —
|
||
// page__header har dedikert verdict-slot som ikke tar arbitrary HTML.
|
||
const actionBar = (
|
||
'<div class="onboarding-actions" style="justify-content: flex-end; margin-bottom: var(--space-4);">' +
|
||
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-home">← Tilbake</button>' +
|
||
'<button type="button" class="btn btn--secondary btn--sm" data-action="delete-project" data-project-id="' + escapeAttr(project.id) + '">Slett</button>' +
|
||
'</div>'
|
||
);
|
||
|
||
// Tab-list (DS): Oversikt / Rapporter / Kontekst / Eksport.
|
||
// Sesjon 2: Rapporter er primær; andre er stub-skjermer som fylles ut
|
||
// i Sesjon 3-6.
|
||
const SCREENS = [
|
||
{ id: 'oversikt', label: 'Oversikt' },
|
||
{ id: 'rapporter', label: 'Rapporter' },
|
||
{ id: 'kontekst', label: 'Kontekst' },
|
||
{ id: 'eksport', label: 'Eksport' }
|
||
];
|
||
const screenTabsHtml = '<nav class="tab-list" role="tablist" aria-label="Prosjekt-skjermer">' + SCREENS.map(function (s) {
|
||
const isActive = currentProjectScreen === s.id;
|
||
return (
|
||
'<button type="button" class="tab" role="tab"' +
|
||
' aria-current="' + (isActive ? 'true' : 'false') + '"' +
|
||
' data-action="project-screen" data-screen="' + escapeAttr(s.id) + '">' +
|
||
escapeHtml(s.label) +
|
||
'</button>'
|
||
);
|
||
}).join('') + '</nav>';
|
||
|
||
// Tabs per CATALOG.categories — kun synlig under "Rapporter"-skjermen.
|
||
const tabsHtml = '<div class="project-tabs" role="tablist">' + CATALOG.categories.map(function (cat) {
|
||
const isActive = currentProjectTab === cat.id;
|
||
return (
|
||
'<button type="button" class="project-tab" role="tab"' +
|
||
(isActive ? ' aria-current="true"' : '') +
|
||
' data-action="project-tab" data-tab="' + escapeAttr(cat.id) + '">' +
|
||
escapeHtml(cat.label) +
|
||
'<span class="project-tab__count">' + cat.count + '</span>' +
|
||
'</button>'
|
||
);
|
||
}).join('') + '</div>';
|
||
|
||
// Render ALLE kategori-paneler i DOM (med [hidden] på inaktive). Dette
|
||
// sikrer at querySelectorAll('[data-paste-import]') matcher alle 17
|
||
// rapport-produserende commands uavhengig av aktiv tab.
|
||
const panelsHtml = CATALOG.categories.map(function (cat) {
|
||
const isActive = currentProjectTab === cat.id;
|
||
const cards = CATALOG.commands
|
||
.filter(function (c) { return c.category === cat.id; })
|
||
.map(function (c) { return renderCommandSubCard(c, project.id); }).join('');
|
||
return (
|
||
'<div class="command-cards" role="tabpanel" data-tab-panel="' + escapeAttr(cat.id) + '"' + (isActive ? '' : ' hidden') + '>' +
|
||
cards +
|
||
'</div>'
|
||
);
|
||
}).join('');
|
||
|
||
const scenarioChipsList = (project.scenarios || []).map(function (sid) {
|
||
const s = SCENARIOS.find(function (x) { return x.id === sid; });
|
||
return '<li>' + escapeHtml(s ? s.name : sid) + '</li>';
|
||
}).join('');
|
||
|
||
const oversiktHtml = (
|
||
'<div class="tab-panel" data-screen-id="oversikt"' + (currentProjectScreen === 'oversikt' ? '' : ' hidden') + '>' +
|
||
'<div class="guide-panel guide-panel--info">' +
|
||
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
|
||
'<div class="guide-panel__body">' +
|
||
'<h3 class="guide-panel__title">Oversikt</h3>' +
|
||
'<p class="guide-panel__text">Opprettet ' + escapeHtml((project.createdAt || '').slice(0, 10)) + '. ' + reportFilled + ' av ' + reportTotal + ' rapporter generert.</p>' +
|
||
(scenarioChipsList ? '<p class="guide-panel__text" style="margin-top: var(--space-2);"><strong>Scenarioer:</strong></p><ul style="margin: 0; padding-left: var(--space-4); color: var(--color-text-secondary);">' + scenarioChipsList + '</ul>' : '') +
|
||
'<p class="guide-panel__text" style="margin-top: var(--space-3);"><em>Sesjon 3+: aggregerte verdict-pillen, recommended-next-actions og top-risks vises her.</em></p>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
|
||
const rapporterHtml = (
|
||
'<div class="tab-panel" data-screen-id="rapporter"' + (currentProjectScreen === 'rapporter' ? '' : ' hidden') + '>' +
|
||
tabsHtml +
|
||
panelsHtml +
|
||
'</div>'
|
||
);
|
||
|
||
const kontekstHtml = (
|
||
'<div class="tab-panel" data-screen-id="kontekst"' + (currentProjectScreen === 'kontekst' ? '' : ' hidden') + '>' +
|
||
'<div class="guide-panel guide-panel--info">' +
|
||
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
|
||
'<div class="guide-panel__body">' +
|
||
'<h3 class="guide-panel__title">Kontekst</h3>' +
|
||
'<p class="guide-panel__text">Fellesfeltene fra onboarding gjenbrukes automatisk i alle command-skjemaer. Bruk <button type="button" class="btn btn--ghost btn--sm" data-action="goto-onboarding" style="display:inline;">Re-onboard</button> for å oppdatere.</p>' +
|
||
'<p class="guide-panel__text" style="margin-top: var(--space-2);"><em>Sesjon 3+: snapshot av de 20 fellesfeltene og hva som er prefilled per command vises her.</em></p>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
|
||
const eksportHtml = (
|
||
'<div class="tab-panel" data-screen-id="eksport"' + (currentProjectScreen === 'eksport' ? '' : ' hidden') + '>' +
|
||
'<div class="guide-panel guide-panel--info">' +
|
||
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
|
||
'<div class="guide-panel__body">' +
|
||
'<h3 class="guide-panel__title">Eksport</h3>' +
|
||
'<p class="guide-panel__text">Bruk <strong>Eksporter</strong> i toppmenyen for hele state. Per-prosjekt eksport (PDF/Markdown) kommer i Sesjon 6.</p>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
|
||
const projectShell = renderPageShell({
|
||
eyebrow: 'PROSJEKT',
|
||
title: project.name,
|
||
lede: project.description || '',
|
||
verdict: inferProjectVerdict(project),
|
||
keyStats: [
|
||
{ label: 'RAPPORTER', value: reportFilled + '/' + reportTotal },
|
||
{ label: 'SIST OPPDATERT', value: inferProjectLastUpdated(project) }
|
||
]
|
||
},
|
||
'<div class="stack-lg">' +
|
||
actionBar +
|
||
screenTabsHtml +
|
||
oversiktHtml +
|
||
rapporterHtml +
|
||
kontekstHtml +
|
||
eksportHtml +
|
||
'</div>'
|
||
);
|
||
|
||
root.innerHTML = (
|
||
renderTopbar('Prosjekt: ' + escapeHtml(project.name)) +
|
||
'<div class="app-shell app-shell--wide">' +
|
||
projectShell +
|
||
'</div>'
|
||
);
|
||
|
||
// v1.10.0+: rehydrer paste-imports etter at DOM er bygget. queueMicrotask
|
||
// sikrer at innerHTML har commit-et før vi spørr etter [data-paste-import].
|
||
queueMicrotask(rehydratePasteImports);
|
||
}
|
||
|
||
// ============================================================
|
||
// CATALOG SURFACE (Step 9)
|
||
// ============================================================
|
||
//
|
||
// 24 commands gruppert i 5 .expansion-grupper (CATALOG.categories) med
|
||
// søke-input som filtrerer på id+label+description+argument_hint.
|
||
// Hver kategori-expansion rendrer en .catalog-cards-grid med kort.
|
||
// "Åpne skjema" på et kort åpner renderCommandForm() i modal.
|
||
//
|
||
// Søk: input-event oppdaterer modul-lokal catalogSearchQuery og
|
||
// re-rendrer kun groups-containeren (bevarer fokus/cursor i søkefeltet).
|
||
// Når query er ikke-tom forces alle expansions åpne. I tom-state er
|
||
// 'regulatory' åpen som standard (mest brukt entry-point).
|
||
//
|
||
// Verktøy-commands får .catalog-tool-notice "Verktøy"-pill + samme
|
||
// skjema-modal — ingen rapport-import (parser/renderer hopper dem over).
|
||
|
||
let catalogSearchQuery = '';
|
||
|
||
function catalogMatches(cmd, q) {
|
||
if (!q) return true;
|
||
const hay = (
|
||
(cmd.id || '') + ' ' +
|
||
(cmd.label || '') + ' ' +
|
||
(cmd.description || '') + ' ' +
|
||
(cmd.argument_hint || '')
|
||
).toLowerCase();
|
||
return hay.indexOf(q) >= 0;
|
||
}
|
||
|
||
function renderCatalogCardHtml(cmd) {
|
||
const isVerktoy = !cmd.produces_report;
|
||
const pill = isVerktoy
|
||
? '<span class="card__pill">Verktøy</span>'
|
||
: '<span class="card__pill">Rapport</span>';
|
||
const hintHtml = cmd.argument_hint
|
||
? '<span class="card__hint">' + escapeHtml(cmd.argument_hint) + '</span>'
|
||
: '';
|
||
const verktoyNotice = isVerktoy
|
||
? '<div class="catalog-tool-notice">Verktøy — ingen rapport-import. Skjema bygger pipeline-streng som kjøres i terminalen.</div>'
|
||
: '';
|
||
return (
|
||
'<article class="card" data-command-card data-command-id="' + escapeAttr(cmd.id) + '">' +
|
||
'<div class="card__head">' +
|
||
'<div>' +
|
||
'<h3 class="card__title">' + escapeHtml(cmd.label) + '</h3>' +
|
||
'<p class="card__desc">' + escapeHtml(cmd.description) + '</p>' +
|
||
'</div>' +
|
||
pill +
|
||
'</div>' +
|
||
'<div class="card__meta">' +
|
||
'<span class="card__id">/architect:' + escapeHtml(cmd.id) + '</span>' +
|
||
hintHtml +
|
||
'</div>' +
|
||
verktoyNotice +
|
||
'<div class="card__actions">' +
|
||
'<button type="button" class="btn btn--secondary btn--sm" data-action="open-catalog-form" data-command="' + escapeAttr(cmd.id) + '">Åpne skjema</button>' +
|
||
'</div>' +
|
||
'</article>'
|
||
);
|
||
}
|
||
|
||
function renderCatalogGroupsHtml() {
|
||
const q = (catalogSearchQuery || '').trim().toLowerCase();
|
||
return CATALOG.categories.map(function (cat) {
|
||
const cmds = CATALOG.commands.filter(function (c) {
|
||
return c.category === cat.id && catalogMatches(c, q);
|
||
});
|
||
const cardsHtml = cmds.map(renderCatalogCardHtml).join('');
|
||
// Force-open når aktiv søk-query har treff. Ellers: 'regulatory' åpen som default.
|
||
const expanded = q ? (cmds.length > 0 ? 'true' : 'false') : (cat.id === 'regulatory' ? 'true' : 'false');
|
||
const subLabel = cmds.length === cat.count
|
||
? cat.count + ' commands'
|
||
: cmds.length + ' / ' + cat.count + ' commands';
|
||
const body = cmds.length > 0
|
||
? '<div class="catalog-cards">' + cardsHtml + '</div>'
|
||
: '<p class="command-form__hint" style="padding: var(--space-2) 0;">Ingen treff i denne kategorien.</p>';
|
||
return (
|
||
'<section class="expansion" aria-expanded="' + expanded + '" data-catalog-group="' + escapeAttr(cat.id) + '">' +
|
||
'<button type="button" class="expansion__head" data-action="catalog-toggle-group">' +
|
||
'<span class="expansion__title">' +
|
||
'<span class="expansion__title-main">' + escapeHtml(cat.label) + '</span>' +
|
||
'<span class="expansion__title-sub">' + subLabel + '</span>' +
|
||
'</span>' +
|
||
'<span class="expansion__chev" aria-hidden="true">▾</span>' +
|
||
'</button>' +
|
||
'<div class="expansion__body">' +
|
||
'<div class="expansion__body-inner">' + body + '</div>' +
|
||
'</div>' +
|
||
'</section>'
|
||
);
|
||
}).join('');
|
||
}
|
||
|
||
function renderCatalogSurface() {
|
||
const root = getSurfaceEl('catalog');
|
||
if (!root) return;
|
||
const q = (catalogSearchQuery || '').trim().toLowerCase();
|
||
const totalMatches = CATALOG.commands.filter(function (c) { return catalogMatches(c, q); }).length;
|
||
const countText = totalMatches + ' av ' + CATALOG.commands.length + ' treff' + (q ? ' for «' + escapeHtml(catalogSearchQuery) + '»' : '');
|
||
const catalogShell = renderPageShell({
|
||
eyebrow: 'KATALOG',
|
||
title: 'Command-katalog',
|
||
lede: '24 kommandoer i 5 fagområder. Filtrer for å finne det du trenger, åpne skjema for å bygge en pipeline-streng.',
|
||
verdict: 'n-a',
|
||
keyStats: [
|
||
{ label: 'KOMMANDOER', value: 24 },
|
||
{ label: 'AGENTER', value: 12 },
|
||
{ label: 'SKILLS', value: 5 }
|
||
]
|
||
},
|
||
'<div class="stack-lg">' +
|
||
'<div class="catalog-toolbar">' +
|
||
'<input type="search" class="input" placeholder="Søk på navn, beskrivelse eller argument-hint…" value="' + escapeAttr(catalogSearchQuery) + '" data-catalog-search aria-label="Søk i katalog">' +
|
||
'<span class="catalog-toolbar__count" data-catalog-count>' + countText + '</span>' +
|
||
'</div>' +
|
||
'<div class="catalog-groups" data-catalog-groups>' + renderCatalogGroupsHtml() + '</div>' +
|
||
'</div>'
|
||
);
|
||
root.innerHTML = (
|
||
renderTopbar('Katalog') +
|
||
'<div class="app-shell app-shell--wide">' +
|
||
catalogShell +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
function refreshCatalogResults() {
|
||
const root = getSurfaceEl('catalog');
|
||
if (!root) return;
|
||
const groupsEl = root.querySelector('[data-catalog-groups]');
|
||
if (groupsEl) groupsEl.innerHTML = renderCatalogGroupsHtml();
|
||
const countEl = root.querySelector('[data-catalog-count]');
|
||
if (countEl) {
|
||
const q = (catalogSearchQuery || '').trim().toLowerCase();
|
||
const totalMatches = CATALOG.commands.filter(function (c) { return catalogMatches(c, q); }).length;
|
||
countEl.textContent = totalMatches + ' av ' + CATALOG.commands.length + ' treff' + (q ? ' for «' + catalogSearchQuery + '»' : '');
|
||
}
|
||
}
|
||
|
||
function renderCatalogFormModalHtml(cmd) {
|
||
const formHtml = renderCommandForm(cmd.id, { context: 'modal', scope: 'm' });
|
||
const verktoyBanner = !cmd.produces_report
|
||
? (
|
||
'<div class="guide-panel guide-panel--info">' +
|
||
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
|
||
'<div class="guide-panel__body">' +
|
||
'<h3 class="guide-panel__title">Verktøy</h3>' +
|
||
'<p class="guide-panel__text">Dette er et verktøy. Skjemaet bygger en pipeline-streng — ingen rapport-import.</p>' +
|
||
'</div>' +
|
||
'</div>'
|
||
)
|
||
: '';
|
||
return (
|
||
'<div class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="cf-modal-title">' +
|
||
'<div class="modal modal--wide">' +
|
||
'<div>' +
|
||
'<h2 class="modal__title" id="cf-modal-title">' + escapeHtml(cmd.label) + '</h2>' +
|
||
'<p class="card__desc" style="margin-top: var(--space-2);">' + escapeHtml(cmd.description) + '</p>' +
|
||
'<span class="card__id">/architect:' + escapeHtml(cmd.id) + '</span>' +
|
||
'</div>' +
|
||
verktoyBanner +
|
||
'<div>' + formHtml + '</div>' +
|
||
'<div class="modal__actions">' +
|
||
'<button type="button" class="btn btn--ghost" data-action="modal-cancel">Lukk</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// MARKDOWN PARSERS (Step 11)
|
||
// ============================================================
|
||
//
|
||
// 14 parser-arketyper per kanonisk routing-tabell. Hver parser tar
|
||
// markdown-streng og returnerer { ok: true, data: {...} } eller
|
||
// { ok: false, errors: [{section, reason}] }. Parsers er tolerante
|
||
// (kaster aldri unntak) — tom/uventet input gir strukturert feil.
|
||
//
|
||
// Routing: PARSERS[archetype] for oppslag i handlePasteImport.
|
||
|
||
// ---- Felles helpers ----
|
||
|
||
function parseTableRow(line) {
|
||
const inner = line.replace(/^\|/, '').replace(/\|$/, '');
|
||
return inner.split('|').map(function (c) { return c.trim(); });
|
||
}
|
||
|
||
function parseTable(md, anchorRegex) {
|
||
if (typeof md !== 'string') return null;
|
||
let body = md;
|
||
if (anchorRegex) {
|
||
const m = anchorRegex.exec(md);
|
||
if (!m) return null;
|
||
body = md.slice(m.index + m[0].length);
|
||
}
|
||
const lines = body.split(/\r?\n/);
|
||
for (let i = 0; i < lines.length - 1; i++) {
|
||
const line = lines[i].trim();
|
||
const next = (lines[i + 1] || '').trim();
|
||
if (line.indexOf('|') === 0 && /^\|[\s\-:|]+\|$/.test(next)) {
|
||
const headers = parseTableRow(line);
|
||
const rows = [];
|
||
for (let j = i + 2; j < lines.length; j++) {
|
||
const rowLine = lines[j].trim();
|
||
if (rowLine.indexOf('|') !== 0) break;
|
||
const cells = parseTableRow(rowLine);
|
||
if (cells.length === 0) break;
|
||
const row = {};
|
||
for (let k = 0; k < headers.length; k++) {
|
||
row[headers[k]] = (cells[k] || '').trim();
|
||
}
|
||
rows.push(row);
|
||
}
|
||
return { headers: headers, rows: rows };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function parseSections(md) {
|
||
if (typeof md !== 'string') return [];
|
||
const sections = [];
|
||
const lines = md.split(/\r?\n/);
|
||
let current = null;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
const m = /^##\s+(.+)$/.exec(line);
|
||
if (m && line.charAt(2) === ' ') { // exactly two #
|
||
if (current) sections.push(current);
|
||
current = { heading: m[1].trim(), body: '' };
|
||
} else if (current) {
|
||
current.body += (current.body ? '\n' : '') + line;
|
||
}
|
||
}
|
||
if (current) sections.push(current);
|
||
return sections.map(function (s) {
|
||
return { heading: s.heading, body: s.body.trim() };
|
||
});
|
||
}
|
||
|
||
function extractField(md, label) {
|
||
if (typeof md !== 'string') return null;
|
||
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const re = new RegExp('^\\s*' + escaped + '\\s*:\\s*(.+)$', 'mi');
|
||
const m = re.exec(md);
|
||
return m ? m[1].trim() : null;
|
||
}
|
||
|
||
function intOrZero(s) {
|
||
if (typeof s !== 'string') return 0;
|
||
const v = parseInt(s.replace(/[^\d-]/g, ''), 10);
|
||
return isNaN(v) ? 0 : v;
|
||
}
|
||
|
||
function emptyInput(md) {
|
||
return !md || typeof md !== 'string' || !md.trim();
|
||
}
|
||
|
||
// ---- 14 archetype parsers ----
|
||
|
||
function parseAiAct(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const errors = [];
|
||
const sections = parseSections(md);
|
||
|
||
let risk_level = extractField(md, 'Risk-level') || extractField(md, 'Risikonivå');
|
||
if (!risk_level) {
|
||
const sec = sections.find(function (s) { return /risikoniv|risk.level/i.test(s.heading); });
|
||
if (sec) {
|
||
const firstLine = sec.body.split(/\r?\n/)[0] || '';
|
||
risk_level = firstLine.replace(/^Risk-level:\s*/i, '').replace(/^Risikonivå:\s*/i, '').trim();
|
||
}
|
||
}
|
||
if (!risk_level) errors.push({ section: 'risk_level', reason: 'Fant ikke risikonivå' });
|
||
|
||
const role = extractField(md, 'Rolle') || extractField(md, 'Role') || '';
|
||
if (!role) errors.push({ section: 'role', reason: 'Fant ikke rolle' });
|
||
|
||
let reasoning = extractField(md, 'Reasoning') || extractField(md, 'Begrunnelse') || '';
|
||
if (!reasoning) {
|
||
const sec = sections.find(function (s) { return /begrunnelse|reasoning/i.test(s.heading); });
|
||
if (sec) reasoning = sec.body;
|
||
}
|
||
|
||
const obligations = [];
|
||
const oblSec = sections.find(function (s) { return /forpliktelser|obligations/i.test(s.heading); });
|
||
if (oblSec) {
|
||
oblSec.body.split(/\r?\n/).forEach(function (line) {
|
||
const m = /^[-*]\s+(.+)$/.exec(line.trim());
|
||
if (m) obligations.push(m[1].trim());
|
||
});
|
||
}
|
||
|
||
if (errors.length > 0) return { ok: false, errors: errors };
|
||
return {
|
||
ok: true,
|
||
data: {
|
||
risk_level: (risk_level || '').toLowerCase(),
|
||
role: role,
|
||
reasoning: reasoning,
|
||
obligations: obligations
|
||
}
|
||
};
|
||
}
|
||
|
||
function parseRequirements(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const tbl = parseTable(md);
|
||
if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen krav-tabell funnet' }] };
|
||
const reqKey = tbl.headers.find(function (h) { return /krav|requirement/i.test(h); }) || tbl.headers[0];
|
||
const statusKey = tbl.headers.find(function (h) { return /status/i.test(h); }) || tbl.headers[1];
|
||
const sourceKey = tbl.headers.find(function (h) { return /kilde|source|art/i.test(h); }) || tbl.headers[2];
|
||
const items = tbl.rows.map(function (row) {
|
||
return {
|
||
requirement: row[reqKey] || '',
|
||
status: (row[statusKey] || '').toLowerCase().trim(),
|
||
source_article: row[sourceKey] || ''
|
||
};
|
||
});
|
||
return { ok: true, data: { items: items } };
|
||
}
|
||
|
||
function parseTextDocument(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const sections = parseSections(md);
|
||
if (!sections.length) {
|
||
return { ok: true, data: { sections: [{ heading: 'Innhold', body: md.trim() }] } };
|
||
}
|
||
return { ok: true, data: { sections: sections } };
|
||
}
|
||
|
||
function parseFria(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const tbl = parseTable(md);
|
||
if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen rettighet-tabell funnet' }] };
|
||
const nameKey = tbl.headers.find(function (h) { return /rettighet|right/i.test(h); }) || tbl.headers[0];
|
||
const impactKey = tbl.headers.find(function (h) { return /impact|påvirkning/i.test(h); }) || tbl.headers[1];
|
||
const mitigKey = tbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); }) || tbl.headers[2];
|
||
const rights = tbl.rows.map(function (row) {
|
||
return {
|
||
name: row[nameKey] || '',
|
||
impact: intOrZero(row[impactKey] || '0'),
|
||
mitigation: row[mitigKey] || ''
|
||
};
|
||
});
|
||
return { ok: true, data: { rights: rights } };
|
||
}
|
||
|
||
function parseConformityChecklist(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const checklistTbl = parseTable(md, /##\s*Sjekkliste/i) || parseTable(md);
|
||
if (!checklistTbl) return { ok: false, errors: [{ section: 'checklist', reason: 'Ingen sjekkliste-tabell funnet' }] };
|
||
const reqKey = checklistTbl.headers.find(function (h) { return /krav|requirement/i.test(h); }) || checklistTbl.headers[0];
|
||
const statusKey = checklistTbl.headers.find(function (h) { return /status/i.test(h); }) || checklistTbl.headers[1];
|
||
const evidKey = checklistTbl.headers.find(function (h) { return /bevis|evidence/i.test(h); }) || checklistTbl.headers[2];
|
||
// Bucket-klassifisering — støtter bade engelske og norske status-markører.
|
||
const bucketOf = function (status) {
|
||
const s = (status || '').toLowerCase().trim();
|
||
if (/^(pass|met|ok|bestått|bestatt|godkjent|approved|done)$/.test(s)) return 'passed';
|
||
if (/^(partial|conditional|betinget|delvis|in-progress|active)$/.test(s)) return 'conditional';
|
||
if (/^(missing|failed|avvist|underkjent|fail|rejected|blocked)$/.test(s)) return 'failed';
|
||
return 'conditional';
|
||
};
|
||
const checklist = checklistTbl.rows.map(function (row) {
|
||
const status = (row[statusKey] || '').toLowerCase().trim();
|
||
return {
|
||
requirement: row[reqKey] || '',
|
||
status: status,
|
||
bucket: bucketOf(status),
|
||
evidence: row[evidKey] || ''
|
||
};
|
||
});
|
||
const buckets = { passed: [], conditional: [], failed: [] };
|
||
checklist.forEach(function (it) { buckets[it.bucket].push(it); });
|
||
const deadlinesTbl = parseTable(md, /##\s*Frister/i);
|
||
const deadlines = deadlinesTbl ? deadlinesTbl.rows.map(function (row) {
|
||
const dateKey = deadlinesTbl.headers.find(function (h) { return /dato|date/i.test(h); }) || deadlinesTbl.headers[0];
|
||
const mileKey = deadlinesTbl.headers.find(function (h) { return /milepæl|milestone/i.test(h); }) || deadlinesTbl.headers[1];
|
||
const stKey = deadlinesTbl.headers.find(function (h) { return /status/i.test(h); }) || deadlinesTbl.headers[2];
|
||
return {
|
||
date: row[dateKey] || '',
|
||
milestone: row[mileKey] || '',
|
||
status: (row[stKey] || '').toLowerCase().trim()
|
||
};
|
||
}) : [];
|
||
return { ok: true, data: { checklist: checklist, buckets: buckets, deadlines: deadlines } };
|
||
}
|
||
|
||
function parseMatrixRisk(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const matrixTbl = parseTable(md, /Risikomatrise.*5/i) || parseTable(md);
|
||
if (!matrixTbl) return { ok: false, errors: [{ section: 'matrix', reason: 'Ingen risikomatrise funnet' }] };
|
||
const labelKey = matrixTbl.headers[0];
|
||
const sannKey = matrixTbl.headers.find(function (h) { return /sannsynlig/i.test(h); });
|
||
const konsKey = matrixTbl.headers.find(function (h) { return /konsekvens/i.test(h); });
|
||
const scoreKey = matrixTbl.headers.find(function (h) { return /score/i.test(h); });
|
||
const matrix_cells = matrixTbl.rows.map(function (row) {
|
||
return {
|
||
label: row[labelKey] || '',
|
||
prob: intOrZero(row[sannKey] || '0'),
|
||
cons: intOrZero(row[konsKey] || '0'),
|
||
score: intOrZero(row[scoreKey] || '0')
|
||
};
|
||
});
|
||
const threatsTbl = parseTable(md, /##\s*Trusler/i);
|
||
const threats = threatsTbl ? threatsTbl.rows.map(function (row) {
|
||
const idKey = threatsTbl.headers[0];
|
||
const descKey = threatsTbl.headers.find(function (h) { return /beskrivelse|description/i.test(h); }) || threatsTbl.headers[1];
|
||
const sevKey = threatsTbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
|
||
const mitKey = threatsTbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); });
|
||
return {
|
||
id: row[idKey] || '',
|
||
description: row[descKey] || '',
|
||
severity: (row[sevKey] || '').toLowerCase().trim(),
|
||
mitigation: row[mitKey] || ''
|
||
};
|
||
}) : [];
|
||
const radarTbl = parseTable(md, /Radar.akser/i);
|
||
const radar_axes = radarTbl ? radarTbl.rows.map(function (row) {
|
||
const akseKey = radarTbl.headers.find(function (h) { return /akse|axis/i.test(h); }) || radarTbl.headers[0];
|
||
const scKey = radarTbl.headers.find(function (h) { return /score/i.test(h); }) || radarTbl.headers[1];
|
||
return {
|
||
name: row[akseKey] || '',
|
||
score: intOrZero(row[scKey] || '0')
|
||
};
|
||
}) : null;
|
||
// Restrisiko / residual-pair (Sesjon 3 — Dpia, men felt er optional og
|
||
// gjelder også fremtidig Ros-bruk per R15). Markdown-syntaks:
|
||
// Restrisiko: 4×3 → 2×2 (numerisk before/after med score)
|
||
// Restrisiko: medium → lav (label-only fallback)
|
||
let residualPair = null;
|
||
const rrMatch = md.match(/^Restrisiko\s*:\s*([^\n]+)$/im);
|
||
if (rrMatch) {
|
||
const txt = rrMatch[1];
|
||
const num = /(\d+)\s*[×x*]\s*(\d+)\s*(?:[-=]?[>→]|->)\s*(\d+)\s*[×x*]\s*(\d+)/.exec(txt);
|
||
if (num) {
|
||
const b1 = +num[1], b2 = +num[2], a1 = +num[3], a2 = +num[4];
|
||
residualPair = {
|
||
before: { prob: b1, cons: b2, score: b1 * b2 },
|
||
after: { prob: a1, cons: a2, score: a1 * a2 }
|
||
};
|
||
} else {
|
||
const parts = txt.split(/(?:[-=]?[>→]|->)/);
|
||
if (parts.length === 2) {
|
||
residualPair = {
|
||
before: { label: parts[0].trim() },
|
||
after: { label: parts[1].trim() }
|
||
};
|
||
}
|
||
}
|
||
}
|
||
// _consumer-diskriminator (R15): Settes til 'ros' når Ros-spesifikk
|
||
// markdown oppdages (## Top-risikoer eller ## Anbefaling). Dpia-fixturer
|
||
// har ingen av disse seksjonene → forblir null.
|
||
const hasTopRisks = /^##\s*Top.?risikoer\b/im.test(md);
|
||
const hasAnbefal = /^##\s*Anbefaling\b/im.test(md);
|
||
const consumer = (hasTopRisks || hasAnbefal) ? 'ros' : null;
|
||
// topRisks (R15, Ros-only): parse explicit ## Top-risikoer table, eller
|
||
// fallback til threats sortert på severity-rank (kritisk>høy>medium>lav).
|
||
// Felt er optional og brukes ikke av renderDpia. Tie-breaker: alfabetisk
|
||
// på description.
|
||
const sevRank = function (s) {
|
||
const v = String(s || '').toLowerCase();
|
||
if (/crit|kritisk/.test(v)) return 4;
|
||
if (/høy|high/.test(v)) return 3;
|
||
if (/medium|moderat/.test(v)) return 2;
|
||
if (/lav|low/.test(v)) return 1;
|
||
return 0;
|
||
};
|
||
let topRisks = [];
|
||
if (consumer === 'ros') {
|
||
const trTbl = parseTable(md, /##\s*Top.?risikoer/i);
|
||
if (trTbl && trTbl.rows.length) {
|
||
const idKey = trTbl.headers[0];
|
||
const descKey = trTbl.headers.find(function (h) { return /trussel|risiko|description|beskrivelse/i.test(h); }) || trTbl.headers[1];
|
||
const scKey = trTbl.headers.find(function (h) { return /score/i.test(h); });
|
||
const sevKey = trTbl.headers.find(function (h) { return /severity|alvorlighet|nivå/i.test(h); });
|
||
topRisks = trTbl.rows.map(function (row) {
|
||
return {
|
||
id: row[idKey] || '',
|
||
description: row[descKey] || row[idKey] || '',
|
||
score: scKey ? intOrZero(row[scKey] || '0') : 0,
|
||
severity: (sevKey && (row[sevKey] || '').toLowerCase().trim()) || ''
|
||
};
|
||
}).slice(0, 5);
|
||
} else if (threats.length) {
|
||
topRisks = threats.slice().sort(function (a, b) {
|
||
const r = sevRank(b.severity) - sevRank(a.severity);
|
||
if (r !== 0) return r;
|
||
return String(a.description || '').localeCompare(String(b.description || ''));
|
||
}).slice(0, 5).map(function (t) {
|
||
return { id: t.id, description: t.description, score: 0, severity: t.severity };
|
||
});
|
||
}
|
||
}
|
||
// recommendation (Ros-only): første avsnitt under ## Anbefaling.
|
||
let recommendation = '';
|
||
if (consumer === 'ros' && hasAnbefal) {
|
||
const m = md.match(/^##\s*Anbefaling\s*\n+([\s\S]*?)(?=\n##\s|\n$|$)/im);
|
||
if (m) recommendation = m[1].replace(/\n+$/, '').trim();
|
||
}
|
||
return { ok: true, data: {
|
||
matrix_cells: matrix_cells,
|
||
threats: threats,
|
||
radar_axes: radar_axes,
|
||
residualPair: residualPair,
|
||
topRisks: topRisks,
|
||
recommendation: recommendation,
|
||
_consumer: consumer
|
||
} };
|
||
}
|
||
|
||
function parseMatrixRisk6x5(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const dimsTbl = parseTable(md, /Score per dimensjon/i);
|
||
if (!dimsTbl) return { ok: false, errors: [{ section: 'dimensions', reason: 'Ingen dimensjon-tabell funnet' }] };
|
||
const dimNameKey = dimsTbl.headers.find(function (h) { return /dimensjon/i.test(h); }) || dimsTbl.headers[0];
|
||
const dimScoreKey = dimsTbl.headers.find(function (h) { return /score/i.test(h); }) || dimsTbl.headers[1];
|
||
const dimVurdKey = dimsTbl.headers.find(function (h) { return /vurdering/i.test(h); });
|
||
const dimensions = dimsTbl.rows.map(function (row) {
|
||
return {
|
||
name: row[dimNameKey] || '',
|
||
score: intOrZero(row[dimScoreKey] || '0'),
|
||
assessment: row[dimVurdKey] || ''
|
||
};
|
||
});
|
||
const matrixTbl = parseTable(md, /Risikomatrise.*6/i);
|
||
const matrix_cells = matrixTbl ? matrixTbl.rows.map(function (row) {
|
||
const labelKey = matrixTbl.headers[0];
|
||
const sannKey = matrixTbl.headers.find(function (h) { return /sannsynlig/i.test(h); });
|
||
const konsKey = matrixTbl.headers.find(function (h) { return /konsekvens/i.test(h); });
|
||
const scoreKey = matrixTbl.headers.find(function (h) { return /score/i.test(h); });
|
||
return {
|
||
label: row[labelKey] || '',
|
||
prob: intOrZero(row[sannKey] || '0'),
|
||
cons: intOrZero(row[konsKey] || '0'),
|
||
score: intOrZero(row[scoreKey] || '0')
|
||
};
|
||
}) : [];
|
||
const findingsTbl = parseTable(md, /##\s*Funn/i);
|
||
const findings = findingsTbl ? findingsTbl.rows.map(function (row) {
|
||
const idKey = findingsTbl.headers[0];
|
||
const sevKey = findingsTbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
|
||
const locKey = findingsTbl.headers.find(function (h) { return /lokasjon|location/i.test(h); });
|
||
const recKey = findingsTbl.headers.find(function (h) { return /anbefaling|recommendation/i.test(h); });
|
||
return {
|
||
id: row[idKey] || '',
|
||
severity: (row[sevKey] || '').toLowerCase().trim(),
|
||
location: row[locKey] || '',
|
||
recommendation: row[recKey] || ''
|
||
};
|
||
}) : [];
|
||
// topRisks: prøv ## Top-risikoer-tabell først, ellers fall tilbake til
|
||
// matrix_cells sortert desc på score.
|
||
const topRisksTbl = parseTable(md, /##\s*Top.?risikoer/i);
|
||
let topRisks = [];
|
||
if (topRisksTbl && topRisksTbl.rows.length) {
|
||
const idKey = topRisksTbl.headers[0];
|
||
const descKey = topRisksTbl.headers.find(function (h) { return /risiko|trussel|description|beskrivelse/i.test(h); }) || topRisksTbl.headers[1];
|
||
const scKey = topRisksTbl.headers.find(function (h) { return /score/i.test(h); });
|
||
const sevKey = topRisksTbl.headers.find(function (h) { return /severity|alvorlighet|nivå/i.test(h); });
|
||
topRisks = topRisksTbl.rows.map(function (row) {
|
||
return {
|
||
id: row[idKey] || '',
|
||
description: row[descKey] || row[idKey] || '',
|
||
score: scKey ? intOrZero(row[scKey] || '0') : 0,
|
||
severity: (sevKey && (row[sevKey] || '').toLowerCase().trim()) || ''
|
||
};
|
||
}).slice(0, 5);
|
||
} else if (matrix_cells.length) {
|
||
topRisks = matrix_cells.slice().sort(function (a, b) {
|
||
if (b.score !== a.score) return b.score - a.score;
|
||
return String(a.label || '').localeCompare(String(b.label || ''));
|
||
}).slice(0, 5).map(function (c) {
|
||
return {
|
||
id: '',
|
||
description: c.label || '',
|
||
score: c.score,
|
||
severity: ''
|
||
};
|
||
});
|
||
}
|
||
// categoryGrades: prøv ## Kategori-snitt-tabell først, ellers utled
|
||
// fra dimensions[]. Score → letter-grade A-F (5→A, 4→B, 3→C, 2→D, ≤1→F).
|
||
const gradeFor = function (sc) {
|
||
const n = Number(sc) || 0;
|
||
if (n >= 5) return 'A';
|
||
if (n >= 4) return 'B';
|
||
if (n >= 3) return 'C';
|
||
if (n >= 2) return 'D';
|
||
return 'F';
|
||
};
|
||
const catTbl = parseTable(md, /##\s*Kategori.snitt/i);
|
||
let categoryGrades = [];
|
||
if (catTbl && catTbl.rows.length) {
|
||
const nameKey = catTbl.headers[0];
|
||
const scKey = catTbl.headers.find(function (h) { return /score|snitt/i.test(h); }) || catTbl.headers[1];
|
||
categoryGrades = catTbl.rows.map(function (row) {
|
||
const sc = intOrZero(row[scKey] || '0');
|
||
return { name: row[nameKey] || '', score: sc, grade: gradeFor(sc) };
|
||
});
|
||
} else {
|
||
categoryGrades = dimensions.map(function (d) {
|
||
return { name: d.name, score: d.score, grade: gradeFor(d.score) };
|
||
});
|
||
}
|
||
// residualPair: same syntax som parseMatrixRisk.
|
||
let residualPair = null;
|
||
const rrMatch = md.match(/^Restrisiko\s*:\s*([^\n]+)$/im);
|
||
if (rrMatch) {
|
||
const txt = rrMatch[1];
|
||
const num = /(\d+)\s*[×x*]\s*(\d+)\s*(?:[-=]?[>→]|->)\s*(\d+)\s*[×x*]\s*(\d+)/.exec(txt);
|
||
if (num) {
|
||
const b1 = +num[1], b2 = +num[2], a1 = +num[3], a2 = +num[4];
|
||
residualPair = {
|
||
before: { prob: b1, cons: b2, score: b1 * b2 },
|
||
after: { prob: a1, cons: a2, score: a1 * a2 }
|
||
};
|
||
} else {
|
||
const parts = txt.split(/(?:[-=]?[>→]|->)/);
|
||
if (parts.length === 2) {
|
||
residualPair = {
|
||
before: { label: parts[0].trim() },
|
||
after: { label: parts[1].trim() }
|
||
};
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
ok: true,
|
||
data: {
|
||
dimensions: dimensions,
|
||
matrix_cells: matrix_cells,
|
||
findings: findings,
|
||
scores: dimensions.map(function (d) { return d.score; }),
|
||
topRisks: topRisks,
|
||
categoryGrades: categoryGrades,
|
||
residualPair: residualPair
|
||
}
|
||
};
|
||
}
|
||
|
||
function parseFindings(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const tbl = parseTable(md, /##\s*Funn/i) || parseTable(md);
|
||
if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen funn-tabell funnet' }] };
|
||
const idKey = tbl.headers[0];
|
||
const sevKey = tbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
|
||
const locKey = tbl.headers.find(function (h) { return /lokasjon|location/i.test(h); });
|
||
const recKey = tbl.headers.find(function (h) { return /anbefaling|recommendation/i.test(h); });
|
||
const stKey = tbl.headers.find(function (h) { return /^status$/i.test(h); });
|
||
const findings = tbl.rows.map(function (row) {
|
||
return {
|
||
id: row[idKey] || '',
|
||
severity: (row[sevKey] || '').toLowerCase().trim(),
|
||
location: row[locKey] || '',
|
||
recommendation: row[recKey] || '',
|
||
status: stKey ? String(row[stKey] || '').toLowerCase().trim() : ''
|
||
};
|
||
});
|
||
// Bucket-mapping (E1 kanban + E6 suppressed-panel).
|
||
// Eksplisitt status-felt vinner. Fallback: severity-basert.
|
||
// suppressed/waived/ignored/akseptert → suppressed
|
||
// keep/behold/accepted → keep
|
||
// review/tilsyn/escalate/eskaler → review
|
||
// remove/fjern/reject/avvis/blokker → remove
|
||
// severity critical/kritisk/high/høy → review
|
||
// severity medium/moderat/low/lav → keep
|
||
const bucketOf = function (f) {
|
||
const s = f.status || '';
|
||
if (/suppress|waive|ignore|akseptert/.test(s)) return 'suppressed';
|
||
if (/^keep$|behold|accepted/.test(s)) return 'keep';
|
||
if (/^review$|tilsyn|escalat|eskaler/.test(s)) return 'review';
|
||
if (/^remove$|fjern|reject|avvis|blokk/.test(s)) return 'remove';
|
||
const sev = f.severity || '';
|
||
if (/crit|kritisk/.test(sev)) return 'review';
|
||
if (/høy|high/.test(sev)) return 'review';
|
||
if (/medium|moderat/.test(sev)) return 'keep';
|
||
if (/lav|low/.test(sev)) return 'keep';
|
||
return 'review';
|
||
};
|
||
const buckets = { keep: [], review: [], remove: [], suppressed: [] };
|
||
findings.forEach(function (f) { buckets[bucketOf(f)].push(f); });
|
||
return { ok: true, data: { findings: findings, buckets: buckets } };
|
||
}
|
||
|
||
function parseCostDistribution(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const distTbl = parseTable(md, /Distribusjon/i);
|
||
if (!distTbl) return { ok: false, errors: [{ section: 'distribution', reason: 'Ingen distribusjons-tabell funnet' }] };
|
||
const persKey = distTbl.headers.find(function (h) { return /persentil|percentile/i.test(h); }) || distTbl.headers[0];
|
||
const monthlyKey = distTbl.headers.find(function (h) { return /månedlig|monthly/i.test(h); }) || distTbl.headers[1];
|
||
const yearlyKey = distTbl.headers.find(function (h) { return /årlig|yearly/i.test(h); });
|
||
let p10 = null, p50 = null, p90 = null;
|
||
distTbl.rows.forEach(function (row) {
|
||
const monthly = intOrZero(row[monthlyKey] || '0');
|
||
const yearly = yearlyKey ? intOrZero(row[yearlyKey] || '0') : null;
|
||
const entry = { monthly: monthly, yearly: yearly };
|
||
const tag = (row[persKey] || '').toUpperCase();
|
||
if (/P10|P\.10|P 10/.test(tag)) p10 = entry;
|
||
else if (/P50|P\.50|P 50/.test(tag)) p50 = entry;
|
||
else if (/P90|P\.90|P 90/.test(tag)) p90 = entry;
|
||
});
|
||
const monthlyTbl = parseTable(md, /Månedlig fordeling/i);
|
||
const monthly_breakdown = monthlyTbl ? monthlyTbl.rows.map(function (row) {
|
||
const compKey = monthlyTbl.headers[0];
|
||
const costKey = monthlyTbl.headers[1];
|
||
return {
|
||
component: row[compKey] || '',
|
||
cost: intOrZero(row[costKey] || '0')
|
||
};
|
||
}) : [];
|
||
const tcoTbl = parseTable(md, /TCO/i);
|
||
const tco_table = tcoTbl ? tcoTbl.rows : [];
|
||
return {
|
||
ok: true,
|
||
data: {
|
||
p10: p10, p50: p50, p90: p90,
|
||
monthly_breakdown: monthly_breakdown,
|
||
tco_table: tco_table,
|
||
tco_headers: tcoTbl ? tcoTbl.headers : []
|
||
}
|
||
};
|
||
}
|
||
|
||
function parseCapabilityMatrix(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const tbl = parseTable(md, /##\s*Matrise/i) || parseTable(md);
|
||
if (!tbl) return { ok: false, errors: [{ section: 'matrix', reason: 'Ingen matrise funnet' }] };
|
||
const capKey = tbl.headers[0];
|
||
const licenseNames = tbl.headers.slice(1);
|
||
const licenses = licenseNames.map(function (name) {
|
||
return { name: name, capabilities: [] };
|
||
});
|
||
tbl.rows.forEach(function (row) {
|
||
const capName = row[capKey];
|
||
licenseNames.forEach(function (licName, i) {
|
||
licenses[i].capabilities.push({
|
||
name: capName,
|
||
status: (row[licName] || '').toLowerCase().trim()
|
||
});
|
||
});
|
||
});
|
||
return { ok: true, data: { licenses: licenses } };
|
||
}
|
||
|
||
function parsePhasedPlan(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const phases = [];
|
||
const lines = md.split(/\r?\n/);
|
||
let current = null;
|
||
let bucket = null;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
const phaseMatch = /^###\s+(?:Fase\s+\d+\s*[—-]\s*)?(.+?)\s*(?:\(.*\))?\s*$/i.exec(line.trim());
|
||
const isH3 = /^###\s+/.test(line);
|
||
const isH2 = /^##\s+/.test(line) && !isH3;
|
||
if (isH3 && phaseMatch) {
|
||
if (current) phases.push(current);
|
||
current = {
|
||
name: phaseMatch[1].trim(),
|
||
milestones: [],
|
||
success_criteria: [],
|
||
duration_weeks: null,
|
||
status: null
|
||
};
|
||
bucket = null;
|
||
continue;
|
||
}
|
||
if (isH2) {
|
||
if (current) { phases.push(current); current = null; }
|
||
bucket = null;
|
||
continue;
|
||
}
|
||
if (!current) continue;
|
||
const trimmed = line.trim();
|
||
const durMatch = /^Varighet:\s*(\d+)\s*uke/i.exec(trimmed);
|
||
if (durMatch) {
|
||
current.duration_weeks = parseInt(durMatch[1], 10);
|
||
continue;
|
||
}
|
||
const statusMatch = /^Status\s*:\s*([\wæøåA-Za-z-]+)/i.exec(trimmed);
|
||
if (statusMatch) {
|
||
// Normaliser til en av: planned | active | done.
|
||
const raw = statusMatch[1].toLowerCase();
|
||
let s = null;
|
||
if (/^(done|ferdig|fullf[øo]rt|complete[d]?)$/.test(raw)) s = 'done';
|
||
else if (/^(active|aktiv|p[åa]g[åa]ende|igang|in[-_]?progress|current|n[åa])$/.test(raw)) s = 'active';
|
||
else if (/^(planned|planlagt|kommende|future|fremtid)$/.test(raw)) s = 'planned';
|
||
current.status = s || raw;
|
||
continue;
|
||
}
|
||
if (/^Milep[æa]ler\s*:?\s*$/i.test(trimmed)) { bucket = 'milestones'; continue; }
|
||
if (/^Suksesskriterier\s*:?\s*$/i.test(trimmed)) { bucket = 'success_criteria'; continue; }
|
||
const bulletMatch = /^[-*]\s+(.+)$/.exec(trimmed);
|
||
if (bulletMatch && bucket && current[bucket]) {
|
||
current[bucket].push(bulletMatch[1].trim());
|
||
}
|
||
}
|
||
if (current) phases.push(current);
|
||
|
||
// Utled currentPhaseIndex: første 'active' ELLER første ikke-'done'.
|
||
// R15: -1 hvis ingen faser har status (forward-compat — eksisterende fixtures uberørt).
|
||
let currentPhaseIndex = -1;
|
||
const anyStatus = phases.some(function (p) { return p.status; });
|
||
if (anyStatus) {
|
||
for (let i = 0; i < phases.length; i++) {
|
||
if (phases[i].status === 'active') { currentPhaseIndex = i; break; }
|
||
}
|
||
if (currentPhaseIndex < 0) {
|
||
for (let i = 0; i < phases.length; i++) {
|
||
if (phases[i].status !== 'done') { currentPhaseIndex = i; break; }
|
||
}
|
||
}
|
||
}
|
||
|
||
// POC-verdict (kun for poc-consumer): "## POC-Verdict: GO|BETINGET|BLOKK"
|
||
// R15: undefined for migrate-consumer (uberørt felt).
|
||
let pocVerdict;
|
||
const pvMatch = /^##\s*POC[- ]?Verdict\s*:\s*([A-Za-zØøÆæÅå -]+)$/im.exec(md);
|
||
if (pvMatch) {
|
||
const tag = pvMatch[1].toLowerCase().trim();
|
||
if (/^(go-?with-?conditions|betinget|conditions?|conditional)$/.test(tag)) pocVerdict = 'go-with-conditions';
|
||
else if (/^(block|blokk|blokkert|stop)$/.test(tag)) pocVerdict = 'block';
|
||
else if (/^(go|godkjent|ok|pass)$/.test(tag)) pocVerdict = 'go';
|
||
}
|
||
|
||
const risksTbl = parseTable(md, /##\s*Risiko/i);
|
||
const risks = risksTbl ? risksTbl.rows.map(function (row) {
|
||
const risikoKey = risksTbl.headers[0];
|
||
const sannKey = risksTbl.headers.find(function (h) { return /sannsynlig/i.test(h); });
|
||
const konsKey = risksTbl.headers.find(function (h) { return /konsekvens/i.test(h); });
|
||
const tiltakKey = risksTbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); });
|
||
return {
|
||
risk: row[risikoKey] || '',
|
||
probability: row[sannKey] || '',
|
||
consequence: row[konsKey] || '',
|
||
mitigation: row[tiltakKey] || ''
|
||
};
|
||
}) : [];
|
||
|
||
if (!phases.length) return { ok: false, errors: [{ section: 'phases', reason: 'Ingen faser funnet (### Fase N)' }] };
|
||
const out = { phases: phases, risks: risks, currentPhaseIndex: currentPhaseIndex };
|
||
if (pocVerdict) out.pocVerdict = pocVerdict;
|
||
return { ok: true, data: out };
|
||
}
|
||
|
||
function parseMarkdown(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const titleMatch = /^#\s+(.+)$/m.exec(md);
|
||
const title = titleMatch ? titleMatch[1].trim() : '';
|
||
const sections = parseSections(md);
|
||
// Frontmatter-style fields (Status, Date, Deciders) — typisk i ADR
|
||
const status = extractField(md, 'Status') || '';
|
||
const date = extractField(md, 'Date') || extractField(md, 'Dato') || '';
|
||
const deciders = extractField(md, 'Deciders') || extractField(md, 'Beslutningstakere') || '';
|
||
return { ok: true, data: { title: title, sections: sections, raw: md, status: status, date: date, deciders: deciders } };
|
||
}
|
||
|
||
function parseVerdict(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const verdictRaw = extractField(md, 'Verdict') || '';
|
||
const verdict = verdictRaw.toLowerCase().trim();
|
||
const sub = extractField(md, 'Sub') || '';
|
||
const sections = parseSections(md);
|
||
const ratSec = sections.find(function (s) { return /rationale|begrunnelse/i.test(s.heading); });
|
||
const rationale = ratSec ? ratSec.body : '';
|
||
const metricsTbl = parseTable(md, /Key Metrics|Nøkkelmetrikker/i);
|
||
const key_metrics = metricsTbl ? metricsTbl.rows : [];
|
||
const metrics_headers = metricsTbl ? metricsTbl.headers : [];
|
||
const nextSec = sections.find(function (s) { return /next steps|neste steg/i.test(s.heading); });
|
||
const next_steps = [];
|
||
if (nextSec) {
|
||
nextSec.body.split(/\r?\n/).forEach(function (line) {
|
||
const m = /^[-*]\s+(.+)$/.exec(line.trim());
|
||
if (m) next_steps.push(m[1].trim());
|
||
});
|
||
}
|
||
if (!verdict) return { ok: false, errors: [{ section: 'verdict', reason: 'Fant ikke "Verdict:"-linje' }] };
|
||
return {
|
||
ok: true,
|
||
data: {
|
||
verdict: verdict,
|
||
sub: sub,
|
||
rationale: rationale,
|
||
key_metrics: key_metrics,
|
||
metrics_headers: metrics_headers,
|
||
next_steps: next_steps
|
||
}
|
||
};
|
||
}
|
||
|
||
function parseComparison(md) {
|
||
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
|
||
const subject1 = extractField(md, 'Subject 1') || '';
|
||
const subject2 = extractField(md, 'Subject 2') || '';
|
||
const tbl = parseTable(md, /##\s*Sammenligning|##\s*Comparison/i) || parseTable(md);
|
||
if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen sammenligningstabell funnet' }] };
|
||
const aspectKey = tbl.headers[0];
|
||
const v1Key = tbl.headers[1];
|
||
const v2Key = tbl.headers[2];
|
||
const winnerKey = tbl.headers[3];
|
||
const subjects = [subject1 || v1Key || '', subject2 || v2Key || ''];
|
||
const rows = tbl.rows.map(function (row) {
|
||
return {
|
||
aspect: row[aspectKey] || '',
|
||
value1: row[v1Key] || '',
|
||
value2: row[v2Key] || '',
|
||
winner: winnerKey ? (row[winnerKey] || '') : ''
|
||
};
|
||
});
|
||
// R15: optional winner-felt fra "## Vinner: <id>"-linje. Brukes av
|
||
// renderCompare for verdict-pill og scenario-card highlight.
|
||
const out = { subjects: subjects, rows: rows };
|
||
const winMatch = /^##\s*Vinner\s*:\s*(.+?)\s*$/im.exec(md) || /^Winner\s*:\s*(.+?)\s*$/im.exec(md);
|
||
if (winMatch) out.winner = winMatch[1].trim();
|
||
return { ok: true, data: out };
|
||
}
|
||
|
||
// ---- PARSERS routing-objekt ----
|
||
|
||
const PARSERS = {
|
||
'aiact': parseAiAct,
|
||
'requirements-list': parseRequirements,
|
||
'text-document': parseTextDocument,
|
||
'fria': parseFria,
|
||
'conformity-checklist': parseConformityChecklist,
|
||
'matrix-risk': parseMatrixRisk,
|
||
'matrix-risk-6x5': parseMatrixRisk6x5,
|
||
'findings': parseFindings,
|
||
'cost-distribution': parseCostDistribution,
|
||
'capability': parseCapabilityMatrix,
|
||
'phased-plan': parsePhasedPlan,
|
||
'markdown': parseMarkdown,
|
||
'verdict': parseVerdict,
|
||
'comparison': parseComparison
|
||
};
|
||
|
||
// Eksponer for Verify-asserts og Step 12.
|
||
window.__PARSERS = PARSERS;
|
||
window.__parseTable = parseTable;
|
||
window.__parseSections = parseSections;
|
||
window.__extractField = extractField;
|
||
|
||
// ============================================================
|
||
// REPORT RENDERERS (Step 12)
|
||
// ============================================================
|
||
//
|
||
// 17 renderers per kanonisk archetype-routing-tabell. Hver renderer
|
||
// tar parsed data + slot DOM-element, og fyller slot.innerHTML med
|
||
// markup som matcher design-system BEM-klasser (.pyramide, .matrix,
|
||
// .findings, .rights-matrix, .capability-matrix, .distribution,
|
||
// .verdict-block, .pipeline-cockpit, .diff, .aiact-timeline).
|
||
//
|
||
// Routing: RENDERERS[command.renderer] for oppslag i handlePasteImport
|
||
// (under). Verktøy-commands (produces_report=false) får ingen renderer.
|
||
|
||
// ---- Felles helpers ----
|
||
|
||
function renderEmptyState() {
|
||
return '<div class="guide-panel guide-panel--info">' +
|
||
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
|
||
'<div class="guide-panel__body"><p class="guide-panel__text">Ingen data å vise — tom eller ufullstendig parsing.</p></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function renderError(errors, slot) {
|
||
const items = (errors || []).map(function (e) {
|
||
return '<li><strong>' + escapeHtml(e.section || 'feil') + ':</strong> ' + escapeHtml(e.reason || 'Ukjent') + '</li>';
|
||
}).join('');
|
||
slot.innerHTML =
|
||
'<div class="error-summary" role="alert">' +
|
||
'<h3 class="error-summary__heading">Kunne ikke parse rapporten</h3>' +
|
||
'<div class="error-summary__body"><p>Justér markdown-format og lim inn på nytt.</p>' +
|
||
(items ? '<ul>' + items + '</ul>' : '') +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function renderThreatsTable(threats) {
|
||
if (!threats || !threats.length) return '';
|
||
const rows = threats.map(function (t) {
|
||
return '<tr><td>' + escapeHtml(t.id || '') + '</td><td>' + escapeHtml(t.description || '') + '</td><td>' + escapeHtml(t.severity || '') + '</td><td>' + escapeHtml(t.mitigation || '') + '</td></tr>';
|
||
}).join('');
|
||
return '<table class="report-table"><thead><tr><th>ID</th><th>Beskrivelse</th><th>Severity</th><th>Tiltak</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||
}
|
||
|
||
function renderFindingsBlock(findings, label) {
|
||
const items = findings.map(function (f) {
|
||
return '<li class="findings__item">' +
|
||
'<span class="findings__item-severity-dot" data-severity="' + escapeAttr(f.severity || 'info') + '"></span>' +
|
||
'<span class="findings__item-id">' + escapeHtml(f.id || '') + '</span>' +
|
||
'<span class="findings__item-title">' + escapeHtml(f.recommendation || '') + '</span>' +
|
||
'<span class="findings__item-meta">Lokasjon: ' + escapeHtml(f.location || '—') + ' · Severity: ' + escapeHtml(f.severity || '—') + '</span>' +
|
||
'</li>';
|
||
}).join('');
|
||
return '<div class="findings">' +
|
||
'<div class="findings__list">' +
|
||
'<div class="findings__group">' +
|
||
'<div class="findings__group-header"><span>' + escapeHtml(label) + '</span><span>' + findings.length + '</span></div>' +
|
||
'<ul class="findings__items">' + items + '</ul>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function renderMatrixHtml(data, cons_max) {
|
||
cons_max = cons_max || 5;
|
||
const cells = data.matrix_cells || [];
|
||
const byPC = {};
|
||
cells.forEach(function (c) {
|
||
const k = c.prob + '_' + c.cons;
|
||
if (!byPC[k]) byPC[k] = [];
|
||
byPC[k].push(c);
|
||
});
|
||
const probSize = 5;
|
||
let html = '<div class="matrix"><div class="matrix__y-label">Konsekvens</div><div class="matrix__main">';
|
||
html += '<div class="matrix__grid" style="grid-template-rows: repeat(' + cons_max + ', 1fr) 32px;">';
|
||
for (let cons = cons_max; cons >= 1; cons--) {
|
||
html += '<div class="matrix__y-tick">' + cons + '</div>';
|
||
for (let prob = 1; prob <= probSize; prob++) {
|
||
const score = prob * cons;
|
||
const items = byPC[prob + '_' + cons] || [];
|
||
const bubblesHtml = items.length
|
||
? '<div class="matrix__cell-bubbles">' +
|
||
items.slice(0, 3).map(function (it, i) {
|
||
return '<span class="matrix__bubble" title="' + escapeAttr(it.label || '') + '">' + (i + 1) + '</span>';
|
||
}).join('') +
|
||
(items.length > 3 ? '<span class="matrix__bubble matrix__bubble--count">+' + (items.length - 3) + '</span>' : '') +
|
||
'</div>'
|
||
: '';
|
||
html += '<div class="matrix__cell" data-score="' + score + '">' +
|
||
'<span class="matrix__cell-score">' + score + '</span>' + bubblesHtml +
|
||
'</div>';
|
||
}
|
||
}
|
||
html += '<div class="matrix__corner"></div>';
|
||
for (let prob = 1; prob <= probSize; prob++) {
|
||
html += '<div class="matrix__x-tick">' + prob + '</div>';
|
||
}
|
||
html += '</div>';
|
||
html += '<div class="matrix__x-label">Sannsynlighet</div>';
|
||
html += '</div></div>';
|
||
return html;
|
||
}
|
||
|
||
function renderRadarSvg(axes) {
|
||
if (!axes || !axes.length) return '';
|
||
const N = axes.length;
|
||
const cx = 150, cy = 150, R = 100;
|
||
const points = axes.map(function (a, i) {
|
||
const angle = (i / N) * 2 * Math.PI - Math.PI / 2;
|
||
const r = R * (Math.max(0, Math.min(5, a.score)) / 5);
|
||
return (cx + r * Math.cos(angle)).toFixed(1) + ',' + (cy + r * Math.sin(angle)).toFixed(1);
|
||
}).join(' ');
|
||
const labels = axes.map(function (a, i) {
|
||
const angle = (i / N) * 2 * Math.PI - Math.PI / 2;
|
||
const x = cx + (R + 25) * Math.cos(angle);
|
||
const y = cy + (R + 25) * Math.sin(angle);
|
||
return '<text class="radar__label" x="' + x.toFixed(1) + '" y="' + y.toFixed(1) + '" text-anchor="middle" dominant-baseline="middle">' + escapeHtml(a.name) + '</text>';
|
||
}).join('');
|
||
const spokes = axes.map(function (a, i) {
|
||
const angle = (i / N) * 2 * Math.PI - Math.PI / 2;
|
||
const x = cx + R * Math.cos(angle);
|
||
const y = cy + R * Math.sin(angle);
|
||
return '<line class="radar__axis" x1="' + cx + '" y1="' + cy + '" x2="' + x.toFixed(1) + '" y2="' + y.toFixed(1) + '"/>';
|
||
}).join('');
|
||
return '<div class="radar"><div class="radar__chart">' +
|
||
'<svg class="radar__svg" viewBox="0 0 300 300">' +
|
||
'<circle class="radar__grid-line" cx="' + cx + '" cy="' + cy + '" r="' + R + '" fill="none"/>' +
|
||
'<circle class="radar__grid-line" cx="' + cx + '" cy="' + cy + '" r="' + (R * 0.6) + '" fill="none"/>' +
|
||
spokes + labels +
|
||
'<polygon class="radar__series" points="' + points + '" fill="rgba(99,102,241,0.25)" stroke="currentColor" stroke-width="2"/>' +
|
||
'</svg>' +
|
||
'</div></div>';
|
||
}
|
||
|
||
// ---- Sub-batch A: Regulatory (6) ----
|
||
|
||
function renderAiActPyramid(data, slot) {
|
||
const norm = (data.risk_level || '').toLowerCase();
|
||
let activeTier = 'minimal';
|
||
if (/forbidden|uakseptabel|prohibited|unacceptable/.test(norm)) activeTier = 'forbidden';
|
||
else if (/høy|high|hoy/.test(norm)) activeTier = 'high';
|
||
else if (/begrenset|limited/.test(norm)) activeTier = 'limited';
|
||
else if (/minimal|low/.test(norm)) activeTier = 'minimal';
|
||
|
||
const tiers = [
|
||
{ id: 'forbidden', label: 'Uakseptabel risiko (Art. 5)', desc: 'Forbudte AI-praksiser: subliminal manipulasjon, sosial scoring, manipulering av sårbare grupper, biometrisk fjernidentifisering i sanntid (med unntak).' },
|
||
{ id: 'high', label: 'Høyrisiko (Art. 6 + Annex III)', desc: 'Krever full compliance: risikostyringssystem, datakvalitet, teknisk dokumentasjon, transparens, menneskelig oversikt, robusthet og — for offentlig sektor — FRIA før idriftsettelse.' },
|
||
{ id: 'limited', label: 'Begrenset risiko (Art. 50)', desc: 'Transparenskrav: brukere skal informeres om at de samhandler med AI. Gjelder bl.a. chatboter, deepfakes og emosjonell gjenkjenning.' },
|
||
{ id: 'minimal', label: 'Minimal risiko', desc: 'Ingen særskilte krav under AI Act. Frivillige Codes of Conduct anbefales og dokumenteres som god praksis.' }
|
||
];
|
||
const tiersHtml = tiers.map(function (t) {
|
||
const active = (t.id === activeTier);
|
||
const ariaCurrent = active ? ' aria-current="true"' : '';
|
||
const marker = active ? ' <span class="pyramide__tier-share">← klassifisert</span>' : '';
|
||
return '<div class="pyramide__tier pyramide__tier--' + t.id + '"' + ariaCurrent + '>' +
|
||
'<div class="pyramide__tier-label">' + escapeHtml(t.label) + '</div>' +
|
||
marker +
|
||
'</div>';
|
||
}).join('');
|
||
|
||
const tierDescsHtml = '<div class="stack-sm" style="margin-top: var(--space-4);">' + tiers.map(function (t) {
|
||
const open = (t.id === activeTier) ? ' open' : '';
|
||
return '<details class="pyramide-tier-detail" data-tier="' + escapeAttr(t.id) + '"' + open + '>' +
|
||
'<summary>' + escapeHtml(t.label) + '</summary>' +
|
||
'<div class="pyramide-tier-detail__body">' + escapeHtml(t.desc) + '</div>' +
|
||
'</details>';
|
||
}).join('') + '</div>';
|
||
|
||
const obligationsHtml = (data.obligations || []).length
|
||
? '<section class="report-meta"><h4>Forpliktelser</h4><ul>' +
|
||
data.obligations.map(function (o) { return '<li>' + escapeHtml(o) + '</li>'; }).join('') +
|
||
'</ul></section>'
|
||
: '';
|
||
const meta = '<section class="report-meta"><dl>' +
|
||
'<dt>Rolle</dt><dd>' + escapeHtml(data.role || '—') + '</dd>' +
|
||
(data.reasoning ? '<dt>Begrunnelse</dt><dd>' + escapeHtml(data.reasoning).slice(0, 800) + '</dd>' : '') +
|
||
'</dl></section>';
|
||
const body = '<div class="pyramide">' + tiersHtml + '</div>' + tierDescsHtml + meta + obligationsHtml;
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'KLASSIFISERING',
|
||
title: data.title || 'EU AI Act-klassifisering',
|
||
lede: data.lede || 'Risikonivå, rolle og forpliktelser etter AI Act.',
|
||
verdict: data.verdict || inferVerdict(data, 'aiact'),
|
||
keyStats: data.keyStats || inferKeyStats(data, 'aiact')
|
||
}, body);
|
||
}
|
||
|
||
function renderRequirements(data, slot) {
|
||
const items = data.items || [];
|
||
const sevForStatus = function (status) {
|
||
const s = (status || '').toLowerCase();
|
||
if (s === 'met') return 'low';
|
||
if (s === 'partial') return 'medium';
|
||
if (s === 'missing') return 'critical';
|
||
return 'info';
|
||
};
|
||
const dominantStatus = function (group) {
|
||
if (group.some(function (it) { return /missing/i.test(it.status); })) return 'missing';
|
||
if (group.some(function (it) { return /partial/i.test(it.status); })) return 'partial';
|
||
return 'met';
|
||
};
|
||
|
||
// Group by source_article (Art. X) for scenario-card-grid.
|
||
const groups = {};
|
||
items.forEach(function (it) {
|
||
const key = it.source_article || 'Andre';
|
||
if (!groups[key]) groups[key] = [];
|
||
groups[key].push(it);
|
||
});
|
||
const groupKeys = Object.keys(groups).sort();
|
||
const cardsHtml = groupKeys.length ? '<div class="scenario-card-grid">' + groupKeys.map(function (k) {
|
||
const group = groups[k];
|
||
const status = dominantStatus(group);
|
||
return '<div class="scenario-card" data-status="' + escapeAttr(status) + '">' +
|
||
'<div class="scenario-card__head">' +
|
||
'<span class="scenario-card__source">' + escapeHtml(k) + '</span>' +
|
||
'<span class="scenario-card__count">' + group.length + ' krav</span>' +
|
||
'</div>' +
|
||
'<p class="scenario-card__title">' + escapeHtml(group[0].requirement) + (group.length > 1 ? ' (+' + (group.length - 1) + ')' : '') + '</p>' +
|
||
'</div>';
|
||
}).join('') + '</div>' : '';
|
||
|
||
const expansionsHtml = items.length ? items.map(function (it, idx) {
|
||
const sev = sevForStatus(it.status);
|
||
return '<div class="expansion" aria-expanded="false">' +
|
||
'<button type="button" class="expansion__head" data-action="requirement-expand" data-idx="' + idx + '">' +
|
||
'<span class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></span>' +
|
||
'<span class="expansion__title">' +
|
||
'<span class="expansion__title-main">R-' + String(idx + 1).padStart(2, '0') + ' — ' + escapeHtml(it.requirement) + '</span>' +
|
||
'<span class="expansion__title-sub">Kilde: ' + escapeHtml(it.source_article || '—') + ' · Status: ' + escapeHtml(it.status || '—') + '</span>' +
|
||
'</span>' +
|
||
'<span class="expansion__chev" aria-hidden="true">▾</span>' +
|
||
'</button>' +
|
||
'<div class="expansion__body"><div class="expansion__body-inner"><div>' +
|
||
'<dl><dt>Kilde</dt><dd>' + escapeHtml(it.source_article || '—') + '</dd>' +
|
||
'<dt>Status</dt><dd>' + escapeHtml(it.status || '—') + '</dd></dl>' +
|
||
'</div></div></div>' +
|
||
'</div>';
|
||
}).join('') : '';
|
||
|
||
const body = cardsHtml + (expansionsHtml ? '<div class="findings">' + expansionsHtml + '</div>' : '');
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'KRAV',
|
||
title: data.title || 'AI Act-krav per risiko og rolle',
|
||
lede: data.lede || 'Konkrete forpliktelser gruppert etter Art-paragraf med detaljer per krav.',
|
||
verdict: data.verdict || inferVerdict(data, 'requirements-list'),
|
||
keyStats: data.keyStats || inferKeyStats(data, 'requirements-list')
|
||
}, body);
|
||
}
|
||
|
||
function renderTransparency(data, slot) {
|
||
const READ_MORE_THRESHOLD = 240;
|
||
const sectionsHtml = (data.sections || []).map(function (s) {
|
||
const body = (s.body || '').trim();
|
||
if (body.length > READ_MORE_THRESHOLD) {
|
||
const head = body.slice(0, READ_MORE_THRESHOLD);
|
||
const rest = body.slice(READ_MORE_THRESHOLD);
|
||
return '<section><h2>' + escapeHtml(s.heading) + '</h2>' +
|
||
'<p>' + escapeHtml(head).replace(/\n/g, '<br>') + '…</p>' +
|
||
'<details class="read-more-block">' +
|
||
'<summary>Les hele klausulen</summary>' +
|
||
'<p>' + escapeHtml(rest).replace(/\n/g, '<br>') + '</p>' +
|
||
'</details>' +
|
||
'</section>';
|
||
}
|
||
return '<section><h2>' + escapeHtml(s.heading) + '</h2><p>' + escapeHtml(body).replace(/\n/g, '<br>') + '</p></section>';
|
||
}).join('');
|
||
const body = '<article class="report-doc">' + sectionsHtml + '</article>';
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'ÅPENHET',
|
||
title: data.title || 'Transparensnotis',
|
||
lede: data.lede || 'Generert basert på EU AI Act Art. 13/50 og GDPR Art. 13/14.',
|
||
verdict: data.verdict || inferVerdict(data, 'text-document'),
|
||
keyStats: data.keyStats || inferKeyStats(data, 'text-document')
|
||
}, body);
|
||
}
|
||
|
||
function renderFria(data, slot) {
|
||
const sevForImpact = function (n) {
|
||
const v = Number(n) || 0;
|
||
if (v >= 4) return 'critical';
|
||
if (v >= 3) return 'high';
|
||
if (v >= 2) return 'medium';
|
||
if (v >= 1) return 'low';
|
||
return 'info';
|
||
};
|
||
const cardsHtml = (data.rights || []).map(function (r, idx) {
|
||
const sev = sevForImpact(r.impact);
|
||
return '<div class="critique-card" data-severity="' + escapeAttr(sev) + '">' +
|
||
'<div class="critique-card__header">' +
|
||
'<div class="critique-card__title">' + escapeHtml(r.name) + '</div>' +
|
||
'<div class="critique-card__meta">' +
|
||
'<span class="critique-card__id">FRIA-' + String(idx + 1).padStart(2, '0') + '</span>' +
|
||
'<span class="critique-card__id" style="background: var(--color-bg-soft);">Impact ' + escapeHtml(String(r.impact || 0)) + '/5</span>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
(r.mitigation ? '<div class="critique-card__recommendation">' + escapeHtml(r.mitigation) + '</div>' : '') +
|
||
'</div>';
|
||
}).join('');
|
||
const body = '<div class="critique-cards">' + (cardsHtml || '<p class="muted">Ingen rettigheter registrert.</p>') + '</div>';
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'FRIA',
|
||
title: data.title || 'Fundamental Rights Impact Assessment',
|
||
lede: data.lede || 'EU AI Act Art. 27 — obligatorisk for offentlig sektor som deployer.',
|
||
verdict: data.verdict || inferVerdict(data, 'fria'),
|
||
keyStats: data.keyStats || inferKeyStats(data, 'fria')
|
||
}, body);
|
||
}
|
||
|
||
function renderConformity(data, slot) {
|
||
const buckets = data.buckets || { passed: [], conditional: [], failed: [] };
|
||
const cardFor = function (bucket, label) {
|
||
const items = buckets[bucket] || [];
|
||
const cards = items.length ? items.map(function (it, idx) {
|
||
return '<div class="kanban-card">' +
|
||
'<div class="kanban-card__name">C-' + String(idx + 1).padStart(2, '0') + ' — ' + escapeHtml(it.requirement) + '</div>' +
|
||
'<div class="kanban-card__meta">Bevis: ' + escapeHtml(it.evidence || '—') + '</div>' +
|
||
'</div>';
|
||
}).join('') : '<div class="kanban-col__empty">Ingen krav</div>';
|
||
return '<div class="kanban-col" data-bucket="' + escapeAttr(bucket) + '">' +
|
||
'<div class="kanban-col__head">' +
|
||
'<span class="kanban-col__title">' + escapeHtml(label) + '</span>' +
|
||
'<span class="kanban-col__count">' + items.length + '</span>' +
|
||
'</div>' +
|
||
cards +
|
||
'</div>';
|
||
};
|
||
const kanbanHtml = '<div class="kanban-board">' +
|
||
cardFor('passed', 'Bestått') +
|
||
cardFor('conditional', 'Med betingelser') +
|
||
cardFor('failed', 'Ikke bestått') +
|
||
'</div>';
|
||
|
||
const stateOf = function (status) {
|
||
const s = (status || '').toLowerCase();
|
||
if (s === 'passed' || s === 'met' || s === 'done') return 'passed';
|
||
if (s === 'active' || s === 'partial' || s === 'in-progress') return 'active';
|
||
return 'upcoming';
|
||
};
|
||
const dlList = data.deadlines || [];
|
||
let timelineHtml = '';
|
||
if (dlList.length) {
|
||
const milestones = dlList.map(function (d, i) {
|
||
const left = ((i + 1) / (dlList.length + 1)) * 100;
|
||
return '<div class="aiact-timeline__milestone" data-state="' + escapeAttr(stateOf(d.status)) + '" style="left: ' + left.toFixed(1) + '%">' +
|
||
'<div class="aiact-timeline__dot"></div>' +
|
||
'<div class="aiact-timeline__label">' +
|
||
'<span class="aiact-timeline__label-date">' + escapeHtml(d.date) + '</span>' +
|
||
'<span class="aiact-timeline__label-name">' + escapeHtml(d.milestone) + '</span>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
timelineHtml =
|
||
'<section class="report-meta"><h4>Frister</h4>' +
|
||
'<div class="aiact-timeline">' +
|
||
'<div class="aiact-timeline__track">' +
|
||
'<div class="aiact-timeline__progress" style="width: 0%"></div>' +
|
||
milestones +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</section>';
|
||
}
|
||
|
||
const body = kanbanHtml + timelineHtml;
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'SAMSVAR',
|
||
title: data.title || 'Samsvarsvurdering (Art. 43)',
|
||
lede: data.lede || 'Annex IV-sjekkliste fordelt på Bestått / Med betingelser / Ikke bestått.',
|
||
verdict: data.verdict || inferVerdict(data, 'conformity-checklist'),
|
||
keyStats: data.keyStats || inferKeyStats(data, 'conformity-checklist')
|
||
}, body);
|
||
}
|
||
|
||
function renderDpia(data, slot) {
|
||
const matrixHtml = renderMatrixHtml(data, 5);
|
||
const threatsHtml = renderThreatsTable(data.threats);
|
||
const rp = data.residualPair;
|
||
let residualHtml = '';
|
||
if (rp && rp.before && rp.after) {
|
||
const sevFor = function (s) {
|
||
if (s == null) return '';
|
||
if (s >= 16) return 'critical';
|
||
if (s >= 9) return 'high';
|
||
if (s >= 4) return 'medium';
|
||
return 'low';
|
||
};
|
||
const labelOf = function (cell) {
|
||
if (cell.score != null) return cell.prob + '×' + cell.cons + ' = ' + cell.score;
|
||
return cell.label || '—';
|
||
};
|
||
const sevBefore = rp.before.score != null ? sevFor(rp.before.score) : '';
|
||
const sevAfter = rp.after.score != null ? sevFor(rp.after.score) : '';
|
||
const cellBefore = 'pair-before-after__cell' + (sevBefore ? ' pair-before-after__cell--severity-' + sevBefore : '');
|
||
const cellAfter = 'pair-before-after__cell' + (sevAfter ? ' pair-before-after__cell--severity-' + sevAfter : '');
|
||
residualHtml = '<div class="pair-before-after">' +
|
||
'<div class="' + cellBefore + '">' +
|
||
'<span class="pair-before-after__cell-label">FØR TILTAK</span>' +
|
||
'<span class="pair-before-after__cell-value">' + escapeHtml(labelOf(rp.before)) + '</span>' +
|
||
'<span class="pair-before-after__cell-meta">Sannsynlighet × konsekvens</span>' +
|
||
'</div>' +
|
||
'<div class="pair-before-after__arrow" aria-hidden="true"></div>' +
|
||
'<div class="' + cellAfter + '">' +
|
||
'<span class="pair-before-after__cell-label">ETTER TILTAK</span>' +
|
||
'<span class="pair-before-after__cell-value">' + escapeHtml(labelOf(rp.after)) + '</span>' +
|
||
'<span class="pair-before-after__cell-meta">Restrisiko</span>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
const body = matrixHtml + residualHtml + threatsHtml;
|
||
// Utvid matrix-risk-keyStats med RESTRISIKO når residualPair finnes.
|
||
const baseStats = inferKeyStats(data, 'matrix-risk');
|
||
const stats = (data.keyStats || (rp && rp.after
|
||
? baseStats.concat([{
|
||
label: 'RESTRISIKO',
|
||
value: rp.after.score != null ? String(rp.after.score) : (rp.after.label || '—'),
|
||
modifier: rp.after.score != null && rp.after.score >= 9 ? 'high' : 'low',
|
||
hint: 'etter tiltak'
|
||
}])
|
||
: baseStats));
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'DPIA',
|
||
title: data.title || 'DPIA / Personvernkonsekvensvurdering',
|
||
lede: data.lede || 'Risikomatrise og tiltak iht. Datatilsynets metodikk og GDPR Art. 35.',
|
||
verdict: data.verdict || inferVerdict(data, 'matrix-risk'),
|
||
keyStats: stats
|
||
}, body);
|
||
}
|
||
|
||
// ---- Sub-batch B: Security (3) ----
|
||
|
||
function renderSecurity(data, slot) {
|
||
const sevForScore = function (s) {
|
||
const n = Number(s) || 0;
|
||
if (n >= 16) return 'critical';
|
||
if (n >= 9) return 'high';
|
||
if (n >= 4) return 'medium';
|
||
return 'low';
|
||
};
|
||
const matrixHtml = renderMatrixHtml(data, 6);
|
||
const radarHtml = renderRadarSvg(data.dimensions || []);
|
||
// C7 small-multiples per OWASP-kategori (driver: categoryGrades).
|
||
const cats = data.categoryGrades || [];
|
||
const smallMultiplesHtml = cats.length ? '<div class="small-multiples">' + cats.map(function (c) {
|
||
const grade = c.grade || '';
|
||
const fillPct = Math.max(0, Math.min(100, ((Number(c.score) || 0) / 5) * 100));
|
||
return '<div class="sm-card">' +
|
||
'<div class="sm-card__header">' +
|
||
'<span class="sm-card__name">' + escapeHtml(c.name || '') + '</span>' +
|
||
'<span class="sm-card__grade" data-grade="' + escapeAttr(grade) + '">' + escapeHtml(grade) + '</span>' +
|
||
'</div>' +
|
||
'<div class="sm-card__bar"><div class="sm-card__bar-fill" style="width: ' + fillPct.toFixed(0) + '%"></div></div>' +
|
||
'<span class="sm-card__status">Score ' + escapeHtml(String(c.score || 0)) + ' / 5</span>' +
|
||
'</div>';
|
||
}).join('') + '</div>' : '';
|
||
// C4 top-risks-list (max 5).
|
||
const topRisks = (data.topRisks || []).slice(0, 5);
|
||
const topRisksHtml = topRisks.length ? '<section class="top-risks">' +
|
||
'<h4 class="top-risks__heading">Top-risikoer</h4>' +
|
||
topRisks.map(function (r, i) {
|
||
const sev = r.severity || sevForScore(r.score);
|
||
return '<div class="top-risk" data-severity="' + escapeAttr(sev) + '">' +
|
||
'<span class="top-risk__rank">' + (i + 1) + '</span>' +
|
||
'<span class="top-risk__desc">' + escapeHtml(r.description || '') + '</span>' +
|
||
'<span class="top-risk__score">' + escapeHtml(String(r.score || 0)) + '</span>' +
|
||
'</div>';
|
||
}).join('') + '</section>' : '';
|
||
// B6 residual-pair (når data.residualPair finnes).
|
||
const rp = data.residualPair;
|
||
let residualHtml = '';
|
||
if (rp && rp.before && rp.after) {
|
||
const labelOf = function (cell) {
|
||
if (cell.score != null) return cell.prob + '×' + cell.cons + ' = ' + cell.score;
|
||
return cell.label || '—';
|
||
};
|
||
const sevBefore = rp.before.score != null ? sevForScore(rp.before.score) : '';
|
||
const sevAfter = rp.after.score != null ? sevForScore(rp.after.score) : '';
|
||
const cellBefore = 'pair-before-after__cell' + (sevBefore ? ' pair-before-after__cell--severity-' + sevBefore : '');
|
||
const cellAfter = 'pair-before-after__cell' + (sevAfter ? ' pair-before-after__cell--severity-' + sevAfter : '');
|
||
residualHtml = '<div class="pair-before-after">' +
|
||
'<div class="' + cellBefore + '">' +
|
||
'<span class="pair-before-after__cell-label">FØR TILTAK</span>' +
|
||
'<span class="pair-before-after__cell-value">' + escapeHtml(labelOf(rp.before)) + '</span>' +
|
||
'<span class="pair-before-after__cell-meta">Sannsynlighet × konsekvens</span>' +
|
||
'</div>' +
|
||
'<div class="pair-before-after__arrow" aria-hidden="true"></div>' +
|
||
'<div class="' + cellAfter + '">' +
|
||
'<span class="pair-before-after__cell-label">ETTER TILTAK</span>' +
|
||
'<span class="pair-before-after__cell-value">' + escapeHtml(labelOf(rp.after)) + '</span>' +
|
||
'<span class="pair-before-after__cell-meta">Restrisiko</span>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
const findingsHtml = renderFindingsBlock(data.findings || [], 'Sikkerhetsfunn');
|
||
const body = matrixHtml + radarHtml + smallMultiplesHtml + topRisksHtml + residualHtml + findingsHtml;
|
||
// Utvid matrix-risk-6x5-keyStats med RESTRISIKO når residualPair finnes.
|
||
const baseStats = inferKeyStats(data, 'matrix-risk-6x5');
|
||
const stats = (data.keyStats || (rp && rp.after
|
||
? baseStats.concat([{
|
||
label: 'RESTRISIKO',
|
||
value: rp.after.score != null ? String(rp.after.score) : (rp.after.label || '—'),
|
||
modifier: rp.after.score != null && rp.after.score >= 9 ? 'high' : 'low',
|
||
hint: 'etter tiltak'
|
||
}])
|
||
: baseStats));
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'SIKKERHET',
|
||
title: data.title || 'Sikkerhetsvurdering (6×5)',
|
||
lede: data.lede || 'Score per dimensjon, risikomatrise og topp-risikoer mot NSM, Microsoft Cloud Security og AI Act Art. 15.',
|
||
verdict: data.verdict || inferVerdict(data, 'matrix-risk-6x5'),
|
||
keyStats: stats
|
||
}, body);
|
||
}
|
||
|
||
function renderRos(data, slot) {
|
||
const sevForScore = function (s) {
|
||
const n = Number(s) || 0;
|
||
if (n >= 16) return 'critical';
|
||
if (n >= 9) return 'high';
|
||
if (n >= 4) return 'medium';
|
||
return 'low';
|
||
};
|
||
const matrixHtml = renderMatrixHtml(data, 5);
|
||
const radarHtml = renderRadarSvg(data.radar_axes || []);
|
||
// C4 top-risks-list (max 5).
|
||
const topRisks = (data.topRisks || []).slice(0, 5);
|
||
const topRisksHtml = topRisks.length ? '<section class="top-risks">' +
|
||
'<h4 class="top-risks__heading">Top-risikoer</h4>' +
|
||
topRisks.map(function (r, i) {
|
||
const sev = r.severity || sevForScore(r.score);
|
||
const scoreLabel = r.score ? String(r.score) : (r.severity || '—').toUpperCase();
|
||
return '<div class="top-risk" data-severity="' + escapeAttr(sev) + '">' +
|
||
'<span class="top-risk__rank">' + (i + 1) + '</span>' +
|
||
'<span class="top-risk__desc">' + escapeHtml(r.description || '') + '</span>' +
|
||
'<span class="top-risk__score">' + escapeHtml(scoreLabel) + '</span>' +
|
||
'</div>';
|
||
}).join('') + '</section>' : '';
|
||
// B6 residual-pair (gjenbruker mønster fra Dpia / Security).
|
||
const rp = data.residualPair;
|
||
let residualHtml = '';
|
||
if (rp && rp.before && rp.after) {
|
||
const labelOf = function (cell) {
|
||
if (cell.score != null) return cell.prob + '×' + cell.cons + ' = ' + cell.score;
|
||
return cell.label || '—';
|
||
};
|
||
const sevBefore = rp.before.score != null ? sevForScore(rp.before.score) : '';
|
||
const sevAfter = rp.after.score != null ? sevForScore(rp.after.score) : '';
|
||
const cellBefore = 'pair-before-after__cell' + (sevBefore ? ' pair-before-after__cell--severity-' + sevBefore : '');
|
||
const cellAfter = 'pair-before-after__cell' + (sevAfter ? ' pair-before-after__cell--severity-' + sevAfter : '');
|
||
residualHtml = '<div class="pair-before-after">' +
|
||
'<div class="' + cellBefore + '">' +
|
||
'<span class="pair-before-after__cell-label">FØR TILTAK</span>' +
|
||
'<span class="pair-before-after__cell-value">' + escapeHtml(labelOf(rp.before)) + '</span>' +
|
||
'<span class="pair-before-after__cell-meta">Sannsynlighet × konsekvens</span>' +
|
||
'</div>' +
|
||
'<div class="pair-before-after__arrow" aria-hidden="true"></div>' +
|
||
'<div class="' + cellAfter + '">' +
|
||
'<span class="pair-before-after__cell-label">ETTER TILTAK</span>' +
|
||
'<span class="pair-before-after__cell-value">' + escapeHtml(labelOf(rp.after)) + '</span>' +
|
||
'<span class="pair-before-after__cell-meta">Restrisiko</span>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
// D5 recommendation-card.
|
||
const rec = data.recommendation || '';
|
||
const recommendationHtml = rec ? '<aside class="recommendation-card">' +
|
||
'<span class="recommendation-card__label">Anbefaling</span>' +
|
||
'<p class="recommendation-card__body">' + escapeHtml(rec).replace(/\n/g, '<br>') + '</p>' +
|
||
'</aside>' : '';
|
||
const threatsHtml = renderThreatsTable(data.threats);
|
||
const body = matrixHtml + radarHtml + topRisksHtml + residualHtml + threatsHtml + recommendationHtml;
|
||
// Utvid matrix-risk-keyStats med RESTRISIKO når residualPair finnes
|
||
// (samme mønster som renderDpia).
|
||
const baseStats = inferKeyStats(data, 'matrix-risk');
|
||
const stats = (data.keyStats || (rp && rp.after
|
||
? baseStats.concat([{
|
||
label: 'RESTRISIKO',
|
||
value: rp.after.score != null ? String(rp.after.score) : (rp.after.label || '—'),
|
||
modifier: rp.after.score != null && rp.after.score >= 9 ? 'high' : 'low',
|
||
hint: 'etter tiltak'
|
||
}])
|
||
: baseStats));
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'ROS',
|
||
title: data.title || 'ROS-analyse (5×5)',
|
||
lede: data.lede || 'Risiko- og sårbarhetsanalyse iht. NS 5814 / ISO 31000 med AI-trusselbibliotek.',
|
||
verdict: data.verdict || inferVerdict(data, 'matrix-risk'),
|
||
keyStats: stats
|
||
}, body);
|
||
}
|
||
|
||
function renderReview(data, slot) {
|
||
const buckets = data.buckets || { keep: [], review: [], remove: [], suppressed: [] };
|
||
const cardFor = function (bucket, label) {
|
||
const items = buckets[bucket] || [];
|
||
const cards = items.length ? items.map(function (it) {
|
||
const sev = (it.severity || '').toUpperCase();
|
||
const head = it.id ? (it.id + ' — ' + (it.location || '')) : (it.location || '');
|
||
const recommendation = it.recommendation ? '<div class="kanban-card__meta">' + escapeHtml(it.recommendation) + '</div>' : '';
|
||
const sevTag = sev ? '<div class="kanban-card__meta">Severity: ' + escapeHtml(sev) + '</div>' : '';
|
||
return '<div class="kanban-card" data-severity="' + escapeAttr(it.severity || '') + '">' +
|
||
'<div class="kanban-card__name">' + escapeHtml(head) + '</div>' +
|
||
sevTag +
|
||
recommendation +
|
||
'</div>';
|
||
}).join('') : '<div class="kanban-col__empty">Ingen funn</div>';
|
||
return '<div class="kanban-col" data-bucket="' + escapeAttr(bucket) + '">' +
|
||
'<div class="kanban-col__head">' +
|
||
'<span class="kanban-col__title">' + escapeHtml(label) + '</span>' +
|
||
'<span class="kanban-col__count">' + items.length + '</span>' +
|
||
'</div>' +
|
||
cards +
|
||
'</div>';
|
||
};
|
||
const kanbanHtml = '<div class="kanban-board">' +
|
||
cardFor('keep', 'Keep') +
|
||
cardFor('review', 'Review') +
|
||
cardFor('remove', 'Remove') +
|
||
'</div>';
|
||
// E6 suppressed-panel for waived/akseptert items (collapsed by default).
|
||
const suppressed = buckets.suppressed || [];
|
||
const suppressedHtml = suppressed.length ? '<details class="suppressed-panel">' +
|
||
'<summary>Undertrykt (' + suppressed.length + ') — godtatt eller waiver registrert</summary>' +
|
||
'<div class="suppressed-panel__list">' + suppressed.map(function (it) {
|
||
return '<div class="suppressed-panel__item">' +
|
||
'<span class="suppressed-panel__id">' + escapeHtml(it.id || '—') + '</span>' +
|
||
'<span>' + escapeHtml(it.location || it.recommendation || '') + '</span>' +
|
||
'</div>';
|
||
}).join('') + '</div>' +
|
||
'</details>' : '';
|
||
const body = kanbanHtml + suppressedHtml;
|
||
// KeyStats: utvid 'findings'-archetype med BUCKET-stats (KEEP/REVIEW/REMOVE).
|
||
const baseStats = inferKeyStats(data, 'findings');
|
||
const stats = data.keyStats || baseStats.concat([
|
||
{ label: 'KEEP', value: (buckets.keep || []).length, modifier: 'low' },
|
||
{ label: 'REVIEW', value: (buckets.review || []).length, modifier: (buckets.review || []).length ? 'high' : 'low' },
|
||
{ label: 'REMOVE', value: (buckets.remove || []).length, modifier: (buckets.remove || []).length ? 'critical' : 'low' }
|
||
]);
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'REVIEW',
|
||
title: data.title || 'Arkitekturgjennomgang',
|
||
lede: data.lede || 'Funn fordelt på Keep / Review / Remove med suppressed-panel for waived items.',
|
||
verdict: data.verdict || inferVerdict(data, 'findings'),
|
||
keyStats: stats
|
||
}, body);
|
||
}
|
||
|
||
// ---- Sub-batch C: Economy (2) ----
|
||
|
||
function renderCost(data, slot) {
|
||
const p10 = data.p10 ? data.p10.monthly : 0;
|
||
const p50 = data.p50 ? data.p50.monthly : 0;
|
||
const p90 = data.p90 ? data.p90.monthly : 0;
|
||
const max = Math.max(p10, p50, p90, 1);
|
||
const distRows = [
|
||
{ label: 'P10 (lavt)', value: p10 },
|
||
{ label: 'P50 (median)', value: p50 },
|
||
{ label: 'P90 (høyt)', value: p90 }
|
||
].map(function (r) {
|
||
const w = (r.value / max) * 100;
|
||
return '<div class="distribution__row">' +
|
||
'<div class="distribution__label">' + escapeHtml(r.label) + '</div>' +
|
||
'<div class="distribution__track">' +
|
||
'<div class="distribution__band" style="left: 0%; width: ' + w.toFixed(1) + '%"></div>' +
|
||
'<div class="distribution__median" style="left: ' + w.toFixed(1) + '%">' +
|
||
'<span class="distribution__median-label">' + r.value.toLocaleString('nb-NO') + ' NOK</span>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
const distHtml =
|
||
'<div class="distribution">' + distRows +
|
||
'<div class="distribution__axis"><div class="distribution__axis-ticks">' +
|
||
'<span>0</span><span>' + Math.floor(max / 2).toLocaleString('nb-NO') + '</span><span>' + max.toLocaleString('nb-NO') + ' NOK/mnd</span>' +
|
||
'</div></div>' +
|
||
'</div>';
|
||
const breakdownRows = (data.monthly_breakdown || []).map(function (m) {
|
||
return '<tr><td>' + escapeHtml(m.component) + '</td><td>' + m.cost.toLocaleString('nb-NO') + ' NOK</td></tr>';
|
||
}).join('');
|
||
const breakdownHtml = breakdownRows
|
||
? '<table class="report-table"><thead><tr><th>Komponent</th><th>NOK/mnd</th></tr></thead><tbody>' + breakdownRows + '</tbody></table>'
|
||
: '';
|
||
const tcoHeaders = data.tco_headers || [];
|
||
const tcoHeader = tcoHeaders.map(function (h) { return '<th>' + escapeHtml(h) + '</th>'; }).join('');
|
||
const tcoRows = (data.tco_table || []).map(function (r) {
|
||
const cells = tcoHeaders.map(function (h) { return '<td>' + escapeHtml(r[h] || '') + '</td>'; }).join('');
|
||
return '<tr>' + cells + '</tr>';
|
||
}).join('');
|
||
const tcoHtml = tcoRows
|
||
? '<table class="report-table"><thead><tr>' + tcoHeader + '</tr></thead><tbody>' + tcoRows + '</tbody></table>'
|
||
: '';
|
||
const body = distHtml + breakdownHtml + tcoHtml;
|
||
// Utvid cost-distribution-keyStats med DOMINERENDE (top-komponent i breakdown).
|
||
const breakdown = data.monthly_breakdown || [];
|
||
const dominant = breakdown.reduce(function (acc, m) {
|
||
return (m && Number(m.cost) > Number(acc && acc.cost || 0)) ? m : acc;
|
||
}, null);
|
||
const baseStats = inferKeyStats(data, 'cost-distribution');
|
||
const stats = data.keyStats || (dominant
|
||
? baseStats.concat([{
|
||
label: 'DOMINERENDE',
|
||
value: String(dominant.component || '').slice(0, 28),
|
||
hint: formatNok(dominant.cost) + '/mnd'
|
||
}])
|
||
: baseStats);
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'KOSTNAD',
|
||
title: data.title || 'Kostnadsestimat',
|
||
lede: data.lede || 'Distribusjon P10/P50/P90 i NOK med månedlig fordeling og TCO over 3 år.',
|
||
verdict: data.verdict || inferVerdict(data, 'cost-distribution'),
|
||
keyStats: stats
|
||
}, body);
|
||
}
|
||
|
||
function renderLicense(data, slot) {
|
||
const licenses = data.licenses || [];
|
||
if (!licenses.length) { slot.innerHTML = renderEmptyState(); return; }
|
||
const headHtml =
|
||
'<div class="capability-matrix__head">' +
|
||
'<div class="capability-matrix__head-cell capability-matrix__head-cell--name">Kapabilitet</div>' +
|
||
licenses.map(function (l) {
|
||
return '<div class="capability-matrix__head-cell">' + escapeHtml(l.name) + '</div>';
|
||
}).join('') +
|
||
'</div>';
|
||
const capabilityNames = (licenses[0].capabilities || []).map(function (c) { return c.name; });
|
||
const rowsHtml = capabilityNames.map(function (capName, capIdx) {
|
||
const cells = licenses.map(function (l) {
|
||
const cap = l.capabilities[capIdx];
|
||
const status = (cap && cap.status) || 'missing';
|
||
return '<div class="capability-matrix__cell" data-status="' + escapeAttr(status) + '">' +
|
||
'<div class="capability-matrix__cell-icon"></div>' +
|
||
'</div>';
|
||
}).join('');
|
||
return '<div class="capability-matrix__row">' +
|
||
'<div class="capability-matrix__name">' + escapeHtml(capName) + '</div>' +
|
||
cells +
|
||
'</div>';
|
||
}).join('');
|
||
const matrixHtml = '<div class="capability-matrix" style="grid-template-columns: 220px repeat(' + licenses.length + ', 1fr);">' +
|
||
headHtml + rowsHtml + '</div>';
|
||
// D1 scenario-card-grid per lisens: hver lisens som card med dekning-stat.
|
||
const isAvail = function (cap) { return /^avail|tilgjengelig/i.test((cap && cap.status) || ''); };
|
||
const isMiss = function (cap) { return /^miss/i.test((cap && cap.status) || ''); };
|
||
const totalCaps = capabilityNames.length;
|
||
const licScores = licenses.map(function (l) {
|
||
const caps = l.capabilities || [];
|
||
const avail = caps.filter(isAvail).length;
|
||
const miss = caps.filter(isMiss).length;
|
||
const ratio = totalCaps ? (avail / totalCaps) : 0;
|
||
const status = ratio >= 0.8 ? 'met' : ratio >= 0.4 ? 'partial' : 'missing';
|
||
return { name: l.name, avail: avail, miss: miss, total: totalCaps, ratio: ratio, status: status };
|
||
});
|
||
const scenarioGridHtml = '<div class="scenario-card-grid">' + licScores.map(function (s) {
|
||
const pct = (s.ratio * 100).toFixed(0);
|
||
return '<div class="scenario-card" data-status="' + escapeAttr(s.status) + '">' +
|
||
'<div class="scenario-card__head">' +
|
||
'<span class="scenario-card__source">LISENS</span>' +
|
||
'<span class="scenario-card__count">' + s.avail + '/' + s.total + '</span>' +
|
||
'</div>' +
|
||
'<h4 class="scenario-card__title">' + escapeHtml(s.name) + '</h4>' +
|
||
'<div class="scenario-card__source">' + pct + '% dekket · ' + s.miss + ' mangler</div>' +
|
||
'</div>';
|
||
}).join('') + '</div>';
|
||
const body = scenarioGridHtml + matrixHtml;
|
||
// Utvid capability-keyStats med BESTE LISENS (høyest avail-ratio).
|
||
const baseStats = inferKeyStats(data, 'capability');
|
||
const top = licScores.reduce(function (a, b) { return b.ratio > (a ? a.ratio : -1) ? b : a; }, null);
|
||
const stats = data.keyStats || (top
|
||
? baseStats.concat([{
|
||
label: 'TOPP-LISENS',
|
||
value: String(top.name || '').slice(0, 24),
|
||
hint: top.avail + '/' + top.total + ' kapabiliteter',
|
||
modifier: top.status === 'met' ? 'low' : top.status === 'partial' ? 'medium' : 'high'
|
||
}])
|
||
: baseStats);
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'LISENS',
|
||
title: data.title || 'Lisens-kapabilitetsmatrise',
|
||
lede: data.lede || 'Kapabilitetsdekning per lisensnivå med scenario-cards og full matrise.',
|
||
verdict: data.verdict || inferVerdict(data, 'capability'),
|
||
keyStats: stats
|
||
}, body);
|
||
}
|
||
|
||
// ---- Sub-batch D: Documentation (6) ----
|
||
|
||
function renderMigrate(data, slot) {
|
||
const phases = data.phases || [];
|
||
if (!phases.length) { slot.innerHTML = renderEmptyState(); return; }
|
||
// Map fase-status til mat-step data-state. R15: hvis ingen faser har
|
||
// status, fall tilbake til "alle future" — eksisterende fixtures uberørt.
|
||
const cpi = (typeof data.currentPhaseIndex === 'number') ? data.currentPhaseIndex : -1;
|
||
const stepStateFor = function (p, i) {
|
||
if (p.status === 'done') return 'completed';
|
||
if (p.status === 'active') return 'current';
|
||
if (p.status === 'planned' || p.status) return 'future';
|
||
// Fallback uten status: bruk currentPhaseIndex hvis satt.
|
||
if (cpi < 0) return 'future';
|
||
if (i < cpi) return 'completed';
|
||
if (i === cpi) return 'current';
|
||
return 'future';
|
||
};
|
||
const stepsHtml = phases.map(function (p, i) {
|
||
const state = stepStateFor(p, i);
|
||
const num = String(i + 1).padStart(2, '0');
|
||
const pill = state === 'current'
|
||
? '<span class="mat-step__pill mat-step__pill--current">PÅGÅR</span>'
|
||
: state === 'completed'
|
||
? '<span class="mat-step__pill mat-step__pill--complete">FERDIG</span>'
|
||
: '';
|
||
const dur = p.duration_weeks ? '<div class="mat-step__progress"><span>' + p.duration_weeks + ' uker</span></div>' : '';
|
||
const desc = (p.milestones && p.milestones.length)
|
||
? '<div class="mat-step__desc">' + escapeHtml(p.milestones[0]) + '</div>'
|
||
: '';
|
||
return '<div class="mat-step" data-state="' + escapeAttr(state) + '">' +
|
||
'<div class="mat-step__icon">' + num + '</div>' +
|
||
'<div>' +
|
||
'<div class="mat-step__name">' + escapeHtml(p.name) + ' ' + pill + '</div>' +
|
||
desc +
|
||
dur +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
const ladderHtml = '<div class="mat-ladder">' + stepsHtml + '</div>';
|
||
// E4 cycle-ribbon: bare når en fase er aktiv. data-phase=execution som
|
||
// standard for migrasjonens "kjøre"-fase.
|
||
let ribbonHtml = '';
|
||
if (cpi >= 0 && phases[cpi]) {
|
||
const cur = phases[cpi];
|
||
const cumWeeks = phases.slice(0, cpi).reduce(function (a, p) { return a + (Number(p.duration_weeks) || 0); }, 0);
|
||
const weekStart = cumWeeks + 1;
|
||
const weekEnd = cumWeeks + (Number(cur.duration_weeks) || 0);
|
||
const weekRange = cur.duration_weeks ? ('Uke ' + weekStart + '-' + weekEnd) : '';
|
||
ribbonHtml = '<div class="cycle-ribbon" data-phase="execution">' +
|
||
'<span class="cycle-ribbon__id">M-' + (cpi + 1) + '</span>' +
|
||
(weekRange ? '<span class="cycle-ribbon__week">' + escapeHtml(weekRange) + '</span>' : '') +
|
||
'<span class="cycle-ribbon__phase">PÅGÅR</span>' +
|
||
'<span class="cycle-ribbon__msg">' + escapeHtml(cur.name) + '</span>' +
|
||
'</div>';
|
||
}
|
||
const detailsHtml = phases.map(function (p) {
|
||
const ms = (p.milestones || []).map(function (m) { return '<li>' + escapeHtml(m) + '</li>'; }).join('');
|
||
const sc = (p.success_criteria || []).map(function (s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('');
|
||
return '<section class="phase-detail">' +
|
||
'<h3>' + escapeHtml(p.name) + ' <small>(' + (p.duration_weeks || '?') + ' uker)</small></h3>' +
|
||
(ms ? '<h4>Milepæler</h4><ul>' + ms + '</ul>' : '') +
|
||
(sc ? '<h4>Suksesskriterier</h4><ul>' + sc + '</ul>' : '') +
|
||
'</section>';
|
||
}).join('');
|
||
const risksRows = (data.risks || []).map(function (r) {
|
||
return '<tr><td>' + escapeHtml(r.risk || '') + '</td><td>' + escapeHtml(r.probability || '') + '</td><td>' + escapeHtml(r.consequence || '') + '</td><td>' + escapeHtml(r.mitigation || '') + '</td></tr>';
|
||
}).join('');
|
||
const risksHtml = risksRows
|
||
? '<table class="report-table"><thead><tr><th>Risiko</th><th>Sannsynlighet</th><th>Konsekvens</th><th>Tiltak</th></tr></thead><tbody>' + risksRows + '</tbody></table>'
|
||
: '';
|
||
const body = ribbonHtml + ladderHtml + detailsHtml + risksHtml;
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'MIGRASJON',
|
||
title: data.title || 'Migrasjonsplan',
|
||
lede: data.lede || 'Faseinndelt migrasjon med mat-ladder, cycle-ribbon for aktiv fase og risikomatrise.',
|
||
verdict: data.verdict || inferVerdict(data, 'phased-plan'),
|
||
keyStats: data.keyStats || inferKeyStats(data, 'phased-plan')
|
||
}, body);
|
||
}
|
||
|
||
function renderAdr(data, slot) {
|
||
const meta =
|
||
'<dl class="adr-meta">' +
|
||
(data.status ? '<dt>Status</dt><dd>' + escapeHtml(data.status) + '</dd>' : '') +
|
||
(data.date ? '<dt>Date</dt><dd>' + escapeHtml(data.date) + '</dd>' : '') +
|
||
(data.deciders ? '<dt>Deciders</dt><dd>' + escapeHtml(data.deciders) + '</dd>' : '') +
|
||
'</dl>';
|
||
// D4 critique-card per beslutningsseksjon. Ingen severity (ADR-seksjoner
|
||
// er ikke risikorangert), bruker rekkefølge-id ADR-01..n.
|
||
const sections = data.sections || [];
|
||
const cardsHtml = sections.length ? '<div class="critique-cards">' + sections.map(function (s, i) {
|
||
const id = 'ADR-' + String(i + 1).padStart(2, '0');
|
||
const body = escapeHtml(s.body || '').replace(/\n/g, '<br>');
|
||
return '<div class="critique-card">' +
|
||
'<div class="critique-card__header">' +
|
||
'<div class="critique-card__title">' + escapeHtml(s.heading) + '</div>' +
|
||
'<div class="critique-card__meta">' +
|
||
'<span class="critique-card__id">' + id + '</span>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="critique-card__recommendation">' + body + '</div>' +
|
||
'</div>';
|
||
}).join('') + '</div>' : '';
|
||
const body = meta + cardsHtml;
|
||
// ADR-status til verdict: accepted/godkjent → approved, proposed → go-with-conditions,
|
||
// rejected → failed, deprecated/superseded → warning.
|
||
const statusMap = {
|
||
accepted: 'approved', godkjent: 'approved', approved: 'approved',
|
||
proposed: 'go-with-conditions', foreslått: 'go-with-conditions', 'foreslatt': 'go-with-conditions',
|
||
rejected: 'failed', avvist: 'failed',
|
||
deprecated: 'warning', foreldet: 'warning', superseded: 'warning'
|
||
};
|
||
const verdict = data.verdict || statusMap[String(data.status || '').toLowerCase().trim()] || 'n-a';
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'ADR',
|
||
title: data.title || 'Architecture Decision Record',
|
||
lede: data.lede || (data.status ? 'Status: ' + data.status : 'MADR v3.0 — beslutningsdokument med kontekst, alternativer og konsekvenser.'),
|
||
verdict: verdict,
|
||
keyStats: data.keyStats || [
|
||
{ label: 'STATUS', value: String(data.status || '—').toUpperCase() },
|
||
{ label: 'SEKSJONER', value: sections.length },
|
||
{ label: 'BESLUTTERE', value: data.deciders ? String(data.deciders).split(/[,;]/).length : 0, hint: 'antall' }
|
||
]
|
||
}, body);
|
||
}
|
||
|
||
function renderSummary(data, slot) {
|
||
const verdictMap = {
|
||
block: { variant: 'block', label: 'BLOCK' },
|
||
warning: { variant: 'warning', label: 'WARNING' },
|
||
allow: { variant: 'allow', label: 'ALLOW' }
|
||
};
|
||
const v = verdictMap[(data.verdict || '').toLowerCase()] || { variant: 'warning', label: (data.verdict || '?').toUpperCase() };
|
||
const score = v.variant === 'block' ? 92 : v.variant === 'warning' ? 55 : 22;
|
||
const verdictHtml =
|
||
'<div class="verdict-block">' +
|
||
'<div class="verdict-pill-lg" data-verdict="' + escapeAttr(v.variant) + '">' +
|
||
'<div class="verdict-pill-lg__verdict">' + escapeHtml(v.label) + '</div>' +
|
||
'<div class="verdict-pill-lg__sub">' + escapeHtml(data.sub || 'AI-vurdering') + '</div>' +
|
||
'</div>' +
|
||
'<div class="risk-meter">' +
|
||
'<div class="risk-meter__readout">' +
|
||
'<span class="risk-meter__score">' + score + '</span>' +
|
||
'<span class="risk-meter__band-label">heuristisk score (0-100)</span>' +
|
||
'</div>' +
|
||
'<div class="risk-meter__track">' +
|
||
'<div class="risk-meter__pointer" style="left: ' + score + '%"></div>' +
|
||
'</div>' +
|
||
'<div class="risk-meter__bands">' +
|
||
'<span>Allow</span><span>Notice</span><span>Warning</span><span>Block</span><span>Critical</span>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
// E8 read-more: lange rationale (>300 tegn) skjuler hale i <details>.
|
||
let rationaleHtml = '';
|
||
if (data.rationale) {
|
||
const raw = String(data.rationale);
|
||
if (raw.length > 300) {
|
||
const head = raw.slice(0, 220);
|
||
const tail = raw.slice(220);
|
||
rationaleHtml = '<section><h3>Rationale</h3>' +
|
||
'<p>' + escapeHtml(head).replace(/\n/g, '<br>') + '…</p>' +
|
||
'<details class="read-more-block"><summary>Vis hele rationale</summary>' +
|
||
'<p>' + escapeHtml(tail).replace(/\n/g, '<br>') + '</p>' +
|
||
'</details>' +
|
||
'</section>';
|
||
} else {
|
||
rationaleHtml = '<section><h3>Rationale</h3><p>' + escapeHtml(raw).replace(/\n/g, '<br>') + '</p></section>';
|
||
}
|
||
}
|
||
let metricsHtml = '';
|
||
if ((data.key_metrics || []).length) {
|
||
const headers = data.metrics_headers || Object.keys(data.key_metrics[0] || {});
|
||
const headerRow = headers.map(function (h) { return '<th>' + escapeHtml(h) + '</th>'; }).join('');
|
||
const rows = data.key_metrics.map(function (m) {
|
||
const cells = headers.map(function (h) { return '<td>' + escapeHtml(m[h] || '') + '</td>'; }).join('');
|
||
return '<tr>' + cells + '</tr>';
|
||
}).join('');
|
||
metricsHtml = '<section><h3>Key Metrics</h3><table class="report-table"><thead><tr>' + headerRow + '</tr></thead><tbody>' + rows + '</tbody></table></section>';
|
||
}
|
||
const nextHtml = (data.next_steps || []).length
|
||
? '<section><h3>Next Steps</h3><ul>' + data.next_steps.map(function (s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('') + '</ul></section>'
|
||
: '';
|
||
const body = verdictHtml + rationaleHtml + metricsHtml + nextHtml;
|
||
// Map summary-verdict (allow/warning/block) til canonical verdict for header-pill.
|
||
const headerVerdictMap = { allow: 'allow', warning: 'warning', block: 'block' };
|
||
const headerVerdict = headerVerdictMap[v.variant] || 'warning';
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'SAMMENDRAG',
|
||
title: data.title || 'Beslutningsnotat',
|
||
lede: data.lede || 'Teknisk sammendrag med verdict, key metrics og neste steg.',
|
||
verdict: headerVerdict,
|
||
keyStats: data.keyStats || inferKeyStats(data, 'verdict')
|
||
}, body);
|
||
}
|
||
|
||
function renderPoc(data, slot) {
|
||
const phases = data.phases || [];
|
||
if (!phases.length) { slot.innerHTML = renderEmptyState(); return; }
|
||
// E2 mat-ladder (samme mønster som migrate). POC uses currentPhaseIndex/status.
|
||
const cpi = (typeof data.currentPhaseIndex === 'number') ? data.currentPhaseIndex : -1;
|
||
const stepStateFor = function (p, i) {
|
||
if (p.status === 'done') return 'completed';
|
||
if (p.status === 'active') return 'current';
|
||
if (p.status === 'planned' || p.status) return 'future';
|
||
if (cpi < 0) return 'future';
|
||
if (i < cpi) return 'completed';
|
||
if (i === cpi) return 'current';
|
||
return 'future';
|
||
};
|
||
const stepsHtml = phases.map(function (p, i) {
|
||
const state = stepStateFor(p, i);
|
||
const num = String(i + 1).padStart(2, '0');
|
||
const pill = state === 'current'
|
||
? '<span class="mat-step__pill mat-step__pill--current">PÅGÅR</span>'
|
||
: state === 'completed'
|
||
? '<span class="mat-step__pill mat-step__pill--complete">FERDIG</span>'
|
||
: '';
|
||
const dur = p.duration_weeks ? '<div class="mat-step__progress"><span>' + p.duration_weeks + ' uker</span></div>' : '';
|
||
const desc = (p.milestones && p.milestones.length)
|
||
? '<div class="mat-step__desc">' + escapeHtml(p.milestones[0]) + '</div>'
|
||
: '';
|
||
return '<div class="mat-step" data-state="' + escapeAttr(state) + '">' +
|
||
'<div class="mat-step__icon">' + num + '</div>' +
|
||
'<div>' +
|
||
'<div class="mat-step__name">' + escapeHtml(p.name) + ' ' + pill + '</div>' +
|
||
desc +
|
||
dur +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
const ladderHtml = '<div class="mat-ladder">' + stepsHtml + '</div>';
|
||
// B5 traffic-light per success-kriterie. R15: uten eksplisitt status,
|
||
// bruk fasens state — done=go, active=warning, future=neutral.
|
||
const detailsHtml = phases.map(function (p, i) {
|
||
const state = stepStateFor(p, i);
|
||
const tlStatus = state === 'completed' ? 'green' : state === 'current' ? 'yellow' : 'gray';
|
||
const ms = (p.milestones || []).map(function (m) { return '<li>' + escapeHtml(m) + '</li>'; }).join('');
|
||
const sc = (p.success_criteria || []).map(function (s) {
|
||
return '<li class="traffic-row">' +
|
||
'<span class="traffic-light" data-status="' + escapeAttr(tlStatus) + '" aria-label="' + escapeAttr(tlStatus) + '">' +
|
||
'<span class="traffic-light__dot"></span>' +
|
||
'<span class="traffic-light__label">' + escapeHtml(s) + '</span>' +
|
||
'</span>' +
|
||
'</li>';
|
||
}).join('');
|
||
return '<section class="phase-detail">' +
|
||
'<h3>' + escapeHtml(p.name) + ' <small>(' + (p.duration_weeks || '?') + ' uker)</small></h3>' +
|
||
(ms ? '<h4>Milepæler</h4><ul>' + ms + '</ul>' : '') +
|
||
(sc ? '<h4>Suksesskriterier</h4><ul class="traffic-list">' + sc + '</ul>' : '') +
|
||
'</section>';
|
||
}).join('');
|
||
const risksRows = (data.risks || []).map(function (r) {
|
||
return '<tr><td>' + escapeHtml(r.risk || '') + '</td><td>' + escapeHtml(r.probability || '') + '</td><td>' + escapeHtml(r.consequence || '') + '</td><td>' + escapeHtml(r.mitigation || '') + '</td></tr>';
|
||
}).join('');
|
||
const risksHtml = risksRows
|
||
? '<table class="report-table"><thead><tr><th>Risiko</th><th>Sannsynlighet</th><th>Konsekvens</th><th>Tiltak</th></tr></thead><tbody>' + risksRows + '</tbody></table>'
|
||
: '';
|
||
const body = ladderHtml + detailsHtml + risksHtml;
|
||
// B1 verdict-pille: data.pocVerdict styrer (go/go-with-conditions/block).
|
||
// R15: hvis ikke satt, fall tilbake til risk-baserte heuristikk.
|
||
let verdict = data.verdict || data.pocVerdict;
|
||
if (!verdict) {
|
||
const risks = data.risks || [];
|
||
const critical = risks.some(function (r) { return /high|h[øo]y/i.test(r.consequence || '') && /high|h[øo]y/i.test(r.probability || ''); });
|
||
verdict = critical ? 'go-with-conditions' : (risks.length ? 'go-with-conditions' : 'go');
|
||
}
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'POC',
|
||
title: data.title || 'POC-plan',
|
||
lede: data.lede || 'Faseinndelt POC med mat-ladder, suksesskriterier og go/no-go-vurdering.',
|
||
verdict: verdict,
|
||
keyStats: data.keyStats || inferKeyStats(data, 'phased-plan')
|
||
}, body);
|
||
}
|
||
|
||
function renderUtredning(data, slot) {
|
||
const sections = data.sections || [];
|
||
// A4 SCREEN-TABS: kuratert sett av 4 strukturerte tabs.
|
||
// R15: Hvis utredningen mangler en av seksjonene, hopp over den taben.
|
||
const tabSpec = [
|
||
{ id: 'bakgrunn', label: 'Bakgrunn', match: /\bbakgrunn\b/i },
|
||
{ id: 'funn', label: 'Funn', match: /\bfunn\b/i },
|
||
{ id: 'konklusjon', label: 'Konklusjon', match: /\bkonklusjon\b/i },
|
||
{ id: 'anbefaling', label: 'Anbefaling', match: /\banbefaling\b/i }
|
||
];
|
||
// Heading-normaliser: fjern "1. ", "1.2 " prefiks.
|
||
const normalize = function (h) { return String(h || '').replace(/^\s*\d+(\.\d+)*\s*\.?\s*/, '').trim(); };
|
||
const findSec = function (m) {
|
||
return sections.find(function (s) { return m.test(normalize(s.heading)); });
|
||
};
|
||
const usedIdx = new Set();
|
||
const tabs = tabSpec.map(function (t) {
|
||
const sec = findSec(t.match);
|
||
if (sec) usedIdx.add(sections.indexOf(sec));
|
||
return { id: t.id, label: t.label, sec: sec };
|
||
}).filter(function (t) { return t.sec; });
|
||
// E8 read-more body: lange seksjoner (>500 tegn) skjuler hale i <details>.
|
||
const renderBody = function (raw) {
|
||
const txt = String(raw || '');
|
||
if (txt.length > 500) {
|
||
const head = txt.slice(0, 380);
|
||
const tail = txt.slice(380);
|
||
return '<p>' + escapeHtml(head).replace(/\n/g, '<br>') + '…</p>' +
|
||
'<details class="read-more-block"><summary>Vis hele seksjonen</summary>' +
|
||
'<p>' + escapeHtml(tail).replace(/\n/g, '<br>') + '</p>' +
|
||
'</details>';
|
||
}
|
||
return '<div>' + escapeHtml(txt).replace(/\n/g, '<br>') + '</div>';
|
||
};
|
||
const tabsNavHtml = tabs.length ? '<nav class="tab-list" role="tablist" aria-label="Utredning">' + tabs.map(function (t, i) {
|
||
return '<a class="tab" role="tab" aria-current="' + (i === 0 ? 'true' : 'false') + '" href="#utr-' + escapeAttr(t.id) + '">' + escapeHtml(t.label) + '</a>';
|
||
}).join('') + '</nav>' : '';
|
||
const tabsBodyHtml = tabs.map(function (t) {
|
||
return '<section id="utr-' + escapeAttr(t.id) + '" class="utr-panel">' +
|
||
'<h2>' + escapeHtml(normalize(t.sec.heading)) + '</h2>' +
|
||
renderBody(t.sec.body) +
|
||
'</section>';
|
||
}).join('');
|
||
// Resterende seksjoner (mandat, metode, referanser m.fl.) under en samlende read-more.
|
||
const otherSecs = sections.filter(function (s, i) { return !usedIdx.has(i); });
|
||
const otherHtml = otherSecs.length ? '<details class="read-more-block utr-other"><summary>Vis øvrige seksjoner (' + otherSecs.length + ')</summary>' +
|
||
otherSecs.map(function (s) {
|
||
return '<section><h3>' + escapeHtml(normalize(s.heading)) + '</h3>' + renderBody(s.body) + '</section>';
|
||
}).join('') +
|
||
'</details>' : '';
|
||
const body = tabsNavHtml + tabsBodyHtml + otherHtml;
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'UTREDNING',
|
||
title: data.title || 'AI-arkitekturutredning',
|
||
lede: data.lede || 'Strukturert utredning med kuraterte seksjoner: bakgrunn, funn, konklusjon og anbefaling.',
|
||
verdict: data.verdict || 'n-a',
|
||
keyStats: data.keyStats || [
|
||
{ label: 'TABS', value: tabs.length, hint: 'av 4' },
|
||
{ label: 'SEKSJONER', value: sections.length },
|
||
{ label: 'ØVRIGE', value: otherSecs.length, hint: 'andre seksjoner' }
|
||
]
|
||
}, body);
|
||
}
|
||
|
||
function renderCompare(data, slot) {
|
||
const subjects = (data.subjects && data.subjects.length === 2) ? data.subjects : ['Subjekt 1', 'Subjekt 2'];
|
||
const firstWord = function (s) { return (s || '').toLowerCase().split(/\s+/)[0] || ''; };
|
||
const fw1 = firstWord(subjects[0]);
|
||
const fw2 = firstWord(subjects[1]);
|
||
let count1 = 0, count2 = 0, lik = 0;
|
||
(data.rows || []).forEach(function (r) {
|
||
const w = (r.winner || '').toLowerCase();
|
||
if (!w || /lik|begge|—|-/.test(w)) lik++;
|
||
else if (fw1 && w.indexOf(fw1) >= 0) count1++;
|
||
else if (fw2 && w.indexOf(fw2) >= 0) count2++;
|
||
else lik++;
|
||
});
|
||
// Vinner: eksplisitt parseComparison.winner ELLER auto fra row-counts.
|
||
const explicitWin = String(data.winner || '').toLowerCase();
|
||
let winnerIdx = -1;
|
||
if (explicitWin) {
|
||
if (fw1 && explicitWin.indexOf(fw1) >= 0) winnerIdx = 0;
|
||
else if (fw2 && explicitWin.indexOf(fw2) >= 0) winnerIdx = 1;
|
||
}
|
||
if (winnerIdx < 0 && (count1 || count2)) {
|
||
winnerIdx = count1 > count2 ? 0 : count2 > count1 ? 1 : -1;
|
||
}
|
||
// D1 scenario-cards-grid per kandidat. Vinner får data-status="met",
|
||
// taper "partial", tied/no-winner forblir "partial".
|
||
const cardSubjects = subjects.map(function (s, i) {
|
||
const wins = i === 0 ? count1 : count2;
|
||
const status = i === winnerIdx ? 'met' : 'partial';
|
||
const total = (data.rows || []).length;
|
||
return { name: s, wins: wins, total: total, status: status, isWinner: i === winnerIdx };
|
||
});
|
||
const cardsHtml = '<div class="scenario-card-grid">' + cardSubjects.map(function (c) {
|
||
const winnerBadge = c.isWinner ? '<span class="scenario-card__count" style="background: var(--color-state-success); color: #fff;">VINNER</span>' : '<span class="scenario-card__count">' + c.wins + '/' + c.total + '</span>';
|
||
return '<div class="scenario-card" data-status="' + escapeAttr(c.status) + '">' +
|
||
'<div class="scenario-card__head">' +
|
||
'<span class="scenario-card__source">KANDIDAT</span>' +
|
||
winnerBadge +
|
||
'</div>' +
|
||
'<h4 class="scenario-card__title">' + escapeHtml(c.name) + '</h4>' +
|
||
'<div class="scenario-card__source">' + c.wins + ' vinn · ' + (c.total - c.wins) + ' lik/tap</div>' +
|
||
'</div>';
|
||
}).join('') + '</div>';
|
||
const summaryHtml =
|
||
'<div class="diff__summary">' +
|
||
'<div class="diff__summary-item"><span class="diff__summary-count">' + count1 + '</span> ' + escapeHtml(subjects[0]) + '</div>' +
|
||
'<div class="diff__summary-item"><span class="diff__summary-count">' + count2 + '</span> ' + escapeHtml(subjects[1]) + '</div>' +
|
||
'<div class="diff__summary-item"><span class="diff__summary-count">' + lik + '</span> Lik</div>' +
|
||
'</div>';
|
||
const headerHtml =
|
||
'<div class="diff__row">' +
|
||
'<div class="diff__cell diff__cell--unchanged"><strong>' + escapeHtml(subjects[0]) + '</strong></div>' +
|
||
'<div class="diff__cell diff__cell--unchanged"><strong>' + escapeHtml(subjects[1]) + '</strong></div>' +
|
||
'</div>';
|
||
const rowsHtml = (data.rows || []).map(function (r) {
|
||
const w = (r.winner || '').toLowerCase();
|
||
let cls1 = 'diff__cell--unchanged', cls2 = 'diff__cell--unchanged';
|
||
if (fw1 && w.indexOf(fw1) >= 0) cls1 = 'diff__cell--added';
|
||
if (fw2 && w.indexOf(fw2) >= 0) cls2 = 'diff__cell--added';
|
||
return '<div class="diff__row">' +
|
||
'<div class="diff__cell ' + cls1 + '"><strong>' + escapeHtml(r.aspect) + ':</strong> ' + escapeHtml(r.value1) + '</div>' +
|
||
'<div class="diff__cell ' + cls2 + '"><strong>' + escapeHtml(r.aspect) + ':</strong> ' + escapeHtml(r.value2) + '</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
const diffHtml = '<div class="diff">' + summaryHtml + headerHtml + rowsHtml + '</div>';
|
||
const body = cardsHtml + diffHtml;
|
||
// Verdict-pille: vinner satt → 'go' (klar anbefaling). Tied/uavklart → 'go-with-conditions'.
|
||
const verdict = data.verdict || (winnerIdx >= 0 ? 'go' : 'go-with-conditions');
|
||
// Utvid comparison-keyStats med VINNER-felt.
|
||
const baseStats = inferKeyStats(data, 'comparison');
|
||
const stats = data.keyStats || (winnerIdx >= 0
|
||
? baseStats.concat([{
|
||
label: 'VINNER',
|
||
value: String(subjects[winnerIdx] || '').slice(0, 24),
|
||
hint: (winnerIdx === 0 ? count1 : count2) + ' vinn',
|
||
modifier: 'low'
|
||
}])
|
||
: baseStats.concat([{ label: 'VINNER', value: 'UAVKLART', modifier: 'medium' }]));
|
||
slot.innerHTML = renderPageShell({
|
||
eyebrow: 'SAMMENLIGN',
|
||
title: data.title || 'Sammenligning',
|
||
lede: data.lede || 'Aspekt-for-aspekt-sammenligning av to kandidater med vinner-pille og diff-tabell.',
|
||
verdict: verdict,
|
||
keyStats: stats
|
||
}, body);
|
||
}
|
||
|
||
// === V2_FOUNDATION_BEGIN ===
|
||
// ============================================================
|
||
// FOUNDATION HELPERS (v1.10.0 Sesjon 1)
|
||
// ============================================================
|
||
//
|
||
// Felles grunnskjelett for alle 17 renderers. Sesjon 3-5 wrapper hver
|
||
// renderer med renderPageShell({...}, bodyHtml) — body forblir mest
|
||
// uendret, header/verdict/keyStats kommer fra denne foundation-laget.
|
||
//
|
||
// V2-data-shape (parser-output utvides — beholder v1-felter):
|
||
// data.verdict?: 'go'|'go-with-conditions'|'block'|'approved'|'failed'|
|
||
// 'allow'|'warning'|'n-a'
|
||
// data.keyStats?: Array<{label, value, hint?, modifier?}>
|
||
//
|
||
// MIGRATIONS v1->v2 i bootstrap (se migrateDataVersion under) utleder
|
||
// verdict + keyStats fra v1-felter idempotent for eksisterende state.
|
||
|
||
const VERDICT_NORMAL = {
|
||
'go': 'go', 'godkjent': 'approved', 'approved': 'approved',
|
||
'go-with-conditions': 'go-with-conditions', 'conditions': 'go-with-conditions', 'betinget': 'go-with-conditions',
|
||
'block': 'block', 'blokkert': 'block', 'forbudt': 'block', 'forbidden': 'block',
|
||
'failed': 'failed', 'feilet': 'failed', 'underkjent': 'failed',
|
||
'allow': 'allow', 'tillatt': 'allow',
|
||
'warning': 'warning', 'advarsel': 'warning',
|
||
'n-a': 'n-a', 'na': 'n-a'
|
||
};
|
||
|
||
function normalizeVerdict(raw) {
|
||
if (raw == null) return 'n-a';
|
||
const k = String(raw).toLowerCase().trim();
|
||
return VERDICT_NORMAL[k] || 'n-a';
|
||
}
|
||
|
||
function riskLevelModifier(level) {
|
||
const k = String(level || '').toLowerCase();
|
||
if (k === 'forbudt' || k === 'forbidden') return 'critical';
|
||
if (k === 'høy' || k === 'high') return 'high';
|
||
if (k === 'begrenset' || k === 'limited') return 'medium';
|
||
if (k === 'minimal' || k === 'low') return 'low';
|
||
return undefined;
|
||
}
|
||
|
||
function formatNok(n) {
|
||
if (n == null) return '—';
|
||
const num = Number(n);
|
||
if (!isFinite(num)) return String(n);
|
||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||
if (num >= 1000) return Math.round(num / 1000) + 'k';
|
||
return String(num);
|
||
}
|
||
|
||
// Per-archetype default keyStats utledere. Hver tar v2-data, returnerer
|
||
// Array<{label, value, hint?, modifier?}>. Tom array hvis archetype ikke
|
||
// har et naturlig keyStats-aggregat (transparency, plain markdown).
|
||
const KEY_STATS_CONFIG = {
|
||
'aiact': function (d) {
|
||
return [
|
||
{ label: 'RISIKONIVÅ', value: d.risk_level || '—', modifier: riskLevelModifier(d.risk_level) },
|
||
{ label: 'ROLLE', value: d.role || '—' },
|
||
{ label: 'FORPLIKTELSER', value: (d.obligations || []).length, hint: 'antall' }
|
||
];
|
||
},
|
||
'requirements-list': function (d) {
|
||
const items = d.items || [];
|
||
const required = items.filter(function (i) { return /påkrev|required/i.test(i.status || ''); }).length;
|
||
return [
|
||
{ label: 'KRAV', value: items.length },
|
||
{ label: 'PÅKREVD', value: required, modifier: required ? 'high' : 'low' }
|
||
];
|
||
},
|
||
'text-document': function () { return []; },
|
||
'fria': function (d) {
|
||
const rights = d.rights || [];
|
||
return [
|
||
{ label: 'BERØRTE GRUPPER', value: rights.length },
|
||
{ label: 'MITIGERENDE', value: rights.filter(function (r) { return r.mitigation; }).length, hint: 'tiltak' }
|
||
];
|
||
},
|
||
'conformity-checklist': function (d) {
|
||
const cl = d.checklist || [];
|
||
const passed = cl.filter(function (c) { return /pass|bestått|ok/i.test(c.status || ''); }).length;
|
||
return [
|
||
{ label: 'KRITERIER', value: cl.length },
|
||
{ label: 'BESTÅTT', value: passed, modifier: passed === cl.length ? 'low' : 'medium' },
|
||
{ label: 'FRISTER', value: (d.deadlines || []).length, hint: 'kommende' }
|
||
];
|
||
},
|
||
'matrix-risk': function (d) {
|
||
const threats = d.threats || [];
|
||
const high = threats.filter(function (t) {
|
||
const s = String(t.severity || '').toLowerCase();
|
||
return s === 'høy' || s === 'high' || s === 'kritisk' || s === 'critical';
|
||
}).length;
|
||
return [
|
||
{ label: 'TRUSLER', value: threats.length },
|
||
{ label: 'HØY/KRITISK', value: high, modifier: high ? 'high' : 'low' },
|
||
{ label: 'CELLER', value: (d.matrix_cells || []).length, hint: 'i matrise' }
|
||
];
|
||
},
|
||
'matrix-risk-6x5': function (d) {
|
||
const findings = d.findings || [];
|
||
const dims = d.dimensions || [];
|
||
const sum = dims.reduce(function (a, b) { return a + (Number(b.score) || 0); }, 0);
|
||
const avg = dims.length ? (sum / dims.length).toFixed(1) : '—';
|
||
return [
|
||
{ label: 'DIMENSJONER', value: dims.length },
|
||
{ label: 'SNITT', value: avg, hint: 'av 5' },
|
||
{ label: 'FUNN', value: findings.length, modifier: findings.length > 5 ? 'high' : 'medium' }
|
||
];
|
||
},
|
||
'findings': function (d) {
|
||
const f = d.findings || [];
|
||
const crit = f.filter(function (x) { return /crit|kritisk/i.test(x.severity || ''); }).length;
|
||
return [
|
||
{ label: 'FUNN', value: f.length },
|
||
{ label: 'KRITISKE', value: crit, modifier: crit ? 'critical' : 'low' }
|
||
];
|
||
},
|
||
'cost-distribution': function (d) {
|
||
return [
|
||
{ label: 'P50', value: formatNok(d.p50), hint: 'median' },
|
||
{ label: 'P90', value: formatNok(d.p90), hint: 'pessimistisk', modifier: 'high' },
|
||
{ label: 'KOMPONENTER', value: (d.monthly_breakdown || []).length }
|
||
];
|
||
},
|
||
'capability': function (d) {
|
||
const lic = d.licenses || [];
|
||
const totalCaps = lic.reduce(function (a, l) { return a + ((l.capabilities || []).length); }, 0);
|
||
return [
|
||
{ label: 'LISENSER', value: lic.length },
|
||
{ label: 'KAPABILITETER', value: totalCaps }
|
||
];
|
||
},
|
||
'phased-plan': function (d) {
|
||
const phases = d.phases || [];
|
||
const totalWeeks = phases.reduce(function (a, p) { return a + (Number(p.duration_weeks) || 0); }, 0);
|
||
const risks = d.risks || [];
|
||
return [
|
||
{ label: 'FASER', value: phases.length },
|
||
{ label: 'VARIGHET', value: totalWeeks || '—', hint: 'uker totalt' },
|
||
{ label: 'RISIKOER', value: risks.length, modifier: risks.length > 3 ? 'high' : 'medium' }
|
||
];
|
||
},
|
||
'markdown': function (d) {
|
||
const sec = d.sections || [];
|
||
return sec.length ? [{ label: 'SEKSJONER', value: sec.length }] : [];
|
||
},
|
||
'verdict': function (d) {
|
||
const km = d.key_metrics || [];
|
||
return km.slice(0, 4).map(function (m) {
|
||
return {
|
||
label: String(m.label || m.name || '').toUpperCase(),
|
||
value: m.value != null ? m.value : '—',
|
||
hint: m.unit || undefined
|
||
};
|
||
});
|
||
},
|
||
'comparison': function (d) {
|
||
return [
|
||
{ label: 'KANDIDATER', value: (d.subjects || []).length },
|
||
{ label: 'DIMENSJONER', value: (d.rows || []).length }
|
||
];
|
||
}
|
||
};
|
||
|
||
function inferVerdict(data, archetype) {
|
||
if (!data) return 'n-a';
|
||
// Eksplisitt verdict tar prioritet uansett kilde.
|
||
if (data.verdict) return normalizeVerdict(data.verdict);
|
||
switch (archetype) {
|
||
case 'aiact': {
|
||
const lvl = String(data.risk_level || '').toLowerCase();
|
||
if (lvl === 'forbudt' || lvl === 'forbidden') return 'block';
|
||
if (lvl === 'høy' || lvl === 'high') return 'warning';
|
||
if (lvl === 'begrenset' || lvl === 'limited') return 'go-with-conditions';
|
||
if (lvl === 'minimal' || lvl === 'low') return 'go';
|
||
return 'n-a';
|
||
}
|
||
case 'matrix-risk':
|
||
case 'matrix-risk-6x5': {
|
||
const threats = data.threats || data.findings || [];
|
||
const hasCritical = threats.some(function (t) { return /crit|kritisk/i.test(t.severity || ''); });
|
||
if (hasCritical) return 'block';
|
||
if (threats.length) return 'warning';
|
||
return 'n-a';
|
||
}
|
||
case 'fria': {
|
||
const rights = data.rights || [];
|
||
if (!rights.length) return 'n-a';
|
||
const max = rights.reduce(function (a, r) { const v = Number(r.impact) || 0; return v > a ? v : a; }, 0);
|
||
if (max >= 4) return 'block';
|
||
if (max >= 3) return 'warning';
|
||
if (max >= 1) return 'go-with-conditions';
|
||
return 'go';
|
||
}
|
||
case 'conformity-checklist': {
|
||
const cl = data.checklist || [];
|
||
if (!cl.length) return 'n-a';
|
||
const anyFailed = cl.some(function (c) { return /fail|underkjent/i.test(c.status || ''); });
|
||
if (anyFailed) return 'failed';
|
||
const allPassed = cl.every(function (c) { return /pass|bestått|ok/i.test(c.status || ''); });
|
||
if (allPassed) return 'approved';
|
||
return 'go-with-conditions';
|
||
}
|
||
case 'findings': {
|
||
const fs = data.findings || [];
|
||
if (!fs.length) return 'allow';
|
||
const crit = fs.some(function (f) { return /crit|kritisk/i.test(f.severity || ''); });
|
||
return crit ? 'block' : 'warning';
|
||
}
|
||
case 'cost-distribution': {
|
||
if (data.p90 != null && data.p50 != null) {
|
||
const ratio = Number(data.p90) / Math.max(Number(data.p50), 1);
|
||
return ratio > 2 ? 'warning' : 'go';
|
||
}
|
||
return 'n-a';
|
||
}
|
||
default:
|
||
return 'n-a';
|
||
}
|
||
}
|
||
|
||
function inferKeyStats(data, archetype) {
|
||
if (!data) return [];
|
||
// Eksplisitt keyStats tar prioritet
|
||
if (Array.isArray(data.keyStats)) return data.keyStats;
|
||
const fn = KEY_STATS_CONFIG[archetype];
|
||
if (typeof fn !== 'function') return [];
|
||
try {
|
||
const out = fn(data);
|
||
return Array.isArray(out) ? out : [];
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function renderVerdictPill(verdict) {
|
||
const v = String(verdict || 'n-a').toLowerCase();
|
||
const labels = {
|
||
'go': 'GO',
|
||
'go-with-conditions': 'BETINGET',
|
||
'block': 'BLOKKERT',
|
||
'approved': 'GODKJENT',
|
||
'failed': 'UNDERKJENT',
|
||
'allow': 'TILLATT',
|
||
'warning': 'ADVARSEL',
|
||
'n-a': 'IKKE VURDERT'
|
||
};
|
||
return '<span class="verdict-pill" data-verdict="' + escapeAttr(v) + '">' +
|
||
escapeHtml(labels[v] || v.toUpperCase()) +
|
||
'</span>';
|
||
}
|
||
|
||
function renderKeyStatsGrid(stats) {
|
||
if (!stats || !stats.length) return '';
|
||
const items = stats.map(function (s) {
|
||
const cls = 'key-stat' + (s.modifier ? ' key-stat--' + escapeAttr(s.modifier) : '');
|
||
const hint = s.hint ? '<span class="key-stat__hint">' + escapeHtml(s.hint) + '</span>' : '';
|
||
return '<div class="' + cls + '">' +
|
||
'<span class="key-stat__label">' + escapeHtml(s.label || '') + '</span>' +
|
||
'<span class="key-stat__value">' + escapeHtml(String(s.value)) + '</span>' +
|
||
hint +
|
||
'</div>';
|
||
}).join('');
|
||
return '<div class="key-stats">' + items + '</div>';
|
||
}
|
||
|
||
function renderPageShell(opts, bodyHtml) {
|
||
opts = opts || {};
|
||
const eyebrow = opts.eyebrow ? '<span class="page__eyebrow">' + escapeHtml(opts.eyebrow) + '</span>' : '';
|
||
const title = '<h1 class="page__title">' + escapeHtml(opts.title || '') + '</h1>';
|
||
const lede = opts.lede ? '<p class="page__lede">' + escapeHtml(opts.lede) + '</p>' : '';
|
||
const verdict = (opts.verdict && opts.verdict !== 'n-a') ? renderVerdictPill(opts.verdict) : '';
|
||
const aside = verdict ? '<div class="page__header-aside">' + verdict + '</div>' : '';
|
||
const stats = renderKeyStatsGrid(opts.keyStats);
|
||
return (
|
||
'<header class="page__header">' +
|
||
'<div class="page__header-main">' + eyebrow + title + lede + '</div>' +
|
||
aside +
|
||
'</header>' +
|
||
stats +
|
||
(bodyHtml || '')
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// DATA-VERSION MIGRATION (v1->v2)
|
||
// ============================================================
|
||
//
|
||
// State.dataVersion sporer parser-output-format separat fra
|
||
// state.schemaVersion (som sporer state-shape). v1.9.0 produserte
|
||
// parser-output uten verdict/keyStats; v1.10.0 utvider med felles
|
||
// grunnskjelett-data. Migrasjonen er additive — eksisterende felter
|
||
// forblir uendret.
|
||
//
|
||
// v1_to_v2-handler: itererer projects[].reports[cmdId].parsed; hvis
|
||
// verdict eller keyStats mangler, utled fra eksisterende felter via
|
||
// inferVerdict + inferKeyStats. Setter state.dataVersion = 2 så
|
||
// migrasjonen er idempotent (re-kjøring er no-op).
|
||
|
||
function migrateDataVersion(state, archetypeFor) {
|
||
if (!state) return state;
|
||
if (state.dataVersion === 2) return state;
|
||
const projects = state.projects || [];
|
||
for (let i = 0; i < projects.length; i++) {
|
||
const reports = (projects[i] && projects[i].reports) || {};
|
||
const ids = Object.keys(reports);
|
||
for (let j = 0; j < ids.length; j++) {
|
||
const cmdId = ids[j];
|
||
const r = reports[cmdId];
|
||
if (!r || !r.parsed) continue;
|
||
const arche = typeof archetypeFor === 'function' ? archetypeFor(cmdId) : null;
|
||
if (!arche) continue;
|
||
if (r.parsed.verdict == null) r.parsed.verdict = inferVerdict(r.parsed, arche);
|
||
if (!Array.isArray(r.parsed.keyStats)) r.parsed.keyStats = inferKeyStats(r.parsed, arche);
|
||
}
|
||
}
|
||
state.dataVersion = 2;
|
||
return state;
|
||
}
|
||
|
||
function defaultArchetypeFor(commandId) {
|
||
const cmds = (CATALOG && CATALOG.commands) || [];
|
||
for (let i = 0; i < cmds.length; i++) {
|
||
if (cmds[i].id === commandId) return cmds[i].report_archetype || null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Eksponer for tester og fremtidig renderer-iterasjon (Sesjon 3-5)
|
||
window.__renderPageShell = renderPageShell;
|
||
window.__renderVerdictPill = renderVerdictPill;
|
||
window.__renderKeyStatsGrid = renderKeyStatsGrid;
|
||
window.__inferVerdict = inferVerdict;
|
||
window.__inferKeyStats = inferKeyStats;
|
||
window.__KEY_STATS_CONFIG = KEY_STATS_CONFIG;
|
||
window.__migrateDataVersion = migrateDataVersion;
|
||
window.__defaultArchetypeFor = defaultArchetypeFor;
|
||
// === V2_FOUNDATION_END ===
|
||
|
||
// ---- RENDERERS routing-objekt (17 commands) ----
|
||
|
||
const RENDERERS = {
|
||
renderAiActPyramid: renderAiActPyramid,
|
||
renderRequirements: renderRequirements,
|
||
renderTransparency: renderTransparency,
|
||
renderFria: renderFria,
|
||
renderConformity: renderConformity,
|
||
renderDpia: renderDpia,
|
||
renderSecurity: renderSecurity,
|
||
renderRos: renderRos,
|
||
renderReview: renderReview,
|
||
renderCost: renderCost,
|
||
renderLicense: renderLicense,
|
||
renderMigrate: renderMigrate,
|
||
renderAdr: renderAdr,
|
||
renderSummary: renderSummary,
|
||
renderPoc: renderPoc,
|
||
renderUtredning: renderUtredning,
|
||
renderCompare: renderCompare
|
||
};
|
||
window.__RENDERERS = RENDERERS;
|
||
|
||
// ---- Paste-import: parser + renderer routing (replaces stub) ----
|
||
|
||
function handlePasteImport(commandId, markdown) {
|
||
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
|
||
const slot = document.querySelector('[data-report-slot="' + commandId + '"]');
|
||
if (!cmd || !cmd.produces_report) {
|
||
if (slot) slot.innerHTML = renderEmptyState();
|
||
return;
|
||
}
|
||
if (!slot) return;
|
||
const parser = PARSERS[cmd.report_archetype];
|
||
const renderer = RENDERERS[cmd.renderer];
|
||
if (!parser || !renderer) {
|
||
slot.innerHTML = '<div class="error-summary"><h3 class="error-summary__heading">Routing-feil</h3><div class="error-summary__body"><p>Mangler parser eller renderer for ' + escapeHtml(cmd.id) + '.</p></div></div>';
|
||
return;
|
||
}
|
||
const result = parser(markdown);
|
||
// Topic 2 strategi A: sentralisert _consumer-tildeling i import-flow.
|
||
// Respekterer parser-spesifikk verdi (f.eks. parseMatrixRisk → 'ros').
|
||
// Renderere kan bruke _consumer for å velge variant-spesifikk markup
|
||
// der parser-arketypen er delt mellom flere commands.
|
||
if (result && result.ok && result.data && result.data._consumer == null) {
|
||
result.data._consumer = cmd.id;
|
||
}
|
||
slot.innerHTML = '';
|
||
if (result.ok) renderer(result.data, slot);
|
||
else renderError(result.errors, slot);
|
||
|
||
// v1.10.0+: persister raw_markdown på aktivt prosjekt så paste-imports
|
||
// overlever reload + rehydreres når brukeren navigerer tilbake.
|
||
// Skip equal-value writes — set-trap dispatcher uavhengig av verdi-likhet,
|
||
// og rehydrate kaller handlePasteImport med eksisterende markdown.
|
||
// Uten guarden ville det blitt render-loop.
|
||
const project = findProject(store.state.activeProjectId);
|
||
if (project && markdown && typeof markdown === 'string' && markdown.trim()) {
|
||
if (!project.reports) project.reports = {};
|
||
if (!project.reports[commandId]) project.reports[commandId] = { input: {} };
|
||
if (project.reports[commandId].raw_markdown !== markdown) {
|
||
project.reports[commandId].raw_markdown = markdown;
|
||
}
|
||
}
|
||
}
|
||
window.__handlePasteImport = handlePasteImport;
|
||
|
||
// v1.10.0+: Rehydrer paste-imports fra raw_markdown på aktivt prosjekt.
|
||
// Kalles av project surface render etter at tabs/panels er i DOM.
|
||
// Filler textareas og kjører handlePasteImport for hver lagret rapport.
|
||
function rehydratePasteImports() {
|
||
const project = findProject(store.state.activeProjectId);
|
||
if (!project || !project.reports) return;
|
||
const root = getSurfaceEl('project');
|
||
if (!root) return;
|
||
Object.keys(project.reports).forEach(function (cmdId) {
|
||
const rec = project.reports[cmdId];
|
||
if (!rec || !rec.raw_markdown) return;
|
||
const ta = root.querySelector('[data-paste-import="' + cmdId + '"]');
|
||
if (ta) ta.value = rec.raw_markdown;
|
||
// Render visualiseringen — handlePasteImport finner slot via querySelector.
|
||
handlePasteImport(cmdId, rec.raw_markdown);
|
||
});
|
||
}
|
||
window.__rehydratePasteImports = rehydratePasteImports;
|
||
|
||
// ============================================================
|
||
// ONBOARDING SURFACE (Step 5)
|
||
// ============================================================
|
||
//
|
||
// 18 felles felter strukturert i 5 grupper per agents/onboarding-agent.md
|
||
// Phase 1-5. Sidebar = .form-progress med count utfylte felter per gruppe.
|
||
// Hver gruppe = .expansion (Tier 3 supplement). Validering bruker
|
||
// .error-summary (Tier 3) med klikkbare links som fokuserer feil-felt.
|
||
//
|
||
// Lagring: commitOnboarding() muterer state.shared.<group>.<field>;
|
||
// Proxy-set-trap dispatcher 'change' → throttled writer persisterer
|
||
// til IDB. Re-onboard er bare navigate('onboarding') igjen — skjemaet
|
||
// pre-fylles automatisk fra eksisterende state.
|
||
|
||
// v1.10.0: 4 strukturerte (sector, ai_act_role, risk_level, data_classification)
|
||
// + 16 fritekst (text/textarea med placeholder). Per R4 i plan-Revisions:
|
||
// free-text gir presis virksomhetskontekst som kan settes inn direkte i
|
||
// command-prompts uten å tvinge sjabloner som ikke matcher domenet.
|
||
const ONBOARDING_SCHEMA = [
|
||
{
|
||
id: 'organization',
|
||
title: 'Virksomhetsprofil',
|
||
sub: 'Hvem er dere?',
|
||
fields: [
|
||
{ id: 'name', label: 'Virksomhetsnavn', type: 'text', required: true,
|
||
placeholder: 'f.eks. Bærum kommune, Statens vegvesen, Helse Sør-Øst RHF' },
|
||
{ id: 'description', label: 'Kort beskrivelse', type: 'textarea',
|
||
placeholder: 'Hva gjør virksomheten? F.eks. "Kommune med 8 000 ansatte, ansvar for skole, helse og byggesak."' },
|
||
{ id: 'sector', label: 'Sektor', type: 'select', required: true,
|
||
options: ['Statlig', 'Kommunal', 'Fylkeskommune', 'Helseforetak', 'Undervisning', 'Annet'] },
|
||
{ id: 'size', label: 'Antall ansatte', type: 'text',
|
||
placeholder: 'f.eks. "1 500", "ca. 8 000", "<100"' }
|
||
]
|
||
},
|
||
{
|
||
id: 'regulatory',
|
||
title: 'Regulatorisk grunnlag',
|
||
sub: 'Hvilke krav styrer dere etter, og hvilken AI Act-rolle har dere?',
|
||
fields: [
|
||
{ id: 'regulatory_requirements', label: 'Regulatoriske krav', type: 'textarea',
|
||
placeholder: 'f.eks. "GDPR/Personopplysningsloven, Sikkerhetsloven, Forvaltningsloven, Helseregisterloven, Arkivloven"' },
|
||
{ id: 'ai_act_role', label: 'EU AI Act-rolle', type: 'select',
|
||
options: ['provider', 'deployer', 'distributor', 'importer'] },
|
||
{ id: 'risk_level', label: 'EU AI Act risikonivå', type: 'select',
|
||
options: ['forbidden', 'high', 'limited', 'minimal'] }
|
||
]
|
||
},
|
||
{
|
||
id: 'technology',
|
||
title: 'Teknologistack',
|
||
sub: 'Hva har dere allerede?',
|
||
fields: [
|
||
{ id: 'cloud_platform', label: 'Skyplattform', type: 'textarea',
|
||
placeholder: 'f.eks. "Azure (Norge Øst), AWS (Stockholm), on-prem datasenter Drammen"' },
|
||
{ id: 'license_type', label: 'Lisenstype', type: 'text',
|
||
placeholder: 'f.eks. "M365 E5", "Azure Enterprise Agreement", "Power Platform per app"' },
|
||
{ id: 'ai_services_in_use', label: 'AI-tjenester i bruk', type: 'textarea',
|
||
placeholder: 'f.eks. "Azure OpenAI (GPT-4o, embedding), Copilot for M365, AI Builder, Azure AI Search"' }
|
||
]
|
||
},
|
||
{
|
||
id: 'security',
|
||
title: 'Sikkerhet og compliance',
|
||
sub: 'Hvilke data og praksiser styrer dere etter?',
|
||
fields: [
|
||
{ id: 'data_classification', label: 'Dataklassifisering', type: 'multiSelect',
|
||
options: ['Åpen', 'Intern', 'Fortrolig', 'Strengt fortrolig', 'Hemmelig'] },
|
||
{ id: 'data_residency', label: 'Dataresidens-krav', type: 'text',
|
||
placeholder: 'f.eks. "Kun Norge", "EU/EØS", "Norden", "Ingen spesifikke krav"' },
|
||
{ id: 'dpia_practice', label: 'DPIA-praksis', type: 'textarea',
|
||
placeholder: 'Hvordan utløses og gjennomføres DPIA? F.eks. "Sentralt personvernombud, mal etter Datatilsynet, halvårlig revisjon."' },
|
||
{ id: 'certifications', label: 'Sertifiseringer / rammeverk', type: 'textarea',
|
||
placeholder: 'f.eks. "ISO 27001, Digdir Trygg-pilot, NSM grunnprinsipper for IKT-sikkerhet"' }
|
||
]
|
||
},
|
||
{
|
||
id: 'architecture',
|
||
title: 'Arkitekturbeslutninger',
|
||
sub: 'Hvor vil dere?',
|
||
fields: [
|
||
{ id: 'preferred_platform', label: 'Foretrukket AI-plattform', type: 'text',
|
||
placeholder: 'f.eks. "Azure AI Foundry", "Copilot Studio", "Power Platform/AI Builder", "Ikke bestemt"' },
|
||
{ id: 'integration_needs', label: 'Integrasjonsbehov', type: 'textarea',
|
||
placeholder: 'Eksisterende systemer som trenger AI-integrasjon. F.eks. "M365, SAP S/4, fagsystem KOMTEK, REST API mot folkeregister."' },
|
||
{ id: 'annual_ai_budget', label: 'Årlig AI-budsjett', type: 'text',
|
||
placeholder: 'f.eks. "2 MNOK", "500k-2M", "Ikke definert"' }
|
||
]
|
||
},
|
||
{
|
||
id: 'business',
|
||
title: 'Forretningsreferanser',
|
||
sub: 'Hvordan styrer dere?',
|
||
fields: [
|
||
{ id: 'governance_model', label: 'Styringsmodell for AI', type: 'textarea',
|
||
placeholder: 'Hvem eier AI-beslutninger? F.eks. "Sentralt AI-råd ledes av digitaliseringsdirektør, beslutninger eskalerer til CIO."' },
|
||
{ id: 'doc_format_preferences', label: 'Dokumentformat', type: 'text',
|
||
placeholder: 'f.eks. "Markdown + PDF", "Confluence", "SharePoint Wiki", "Word"' },
|
||
{ id: 'reference_architecture', label: 'Referansearkitektur', type: 'textarea',
|
||
placeholder: 'Eksisterende prinsipper, lenker til wiki/docs. F.eks. "TOGAF-tilpasset, ref Confluence /arch."' }
|
||
]
|
||
}
|
||
];
|
||
|
||
function fieldFilled(value, type) {
|
||
if (value == null) return false;
|
||
if (type === 'multiSelect') return Array.isArray(value) && value.length > 0;
|
||
if (type === 'boolean') return value === true;
|
||
return String(value).trim() !== '';
|
||
}
|
||
|
||
function getOnboardingValue(groupId, fieldId) {
|
||
const grp = store.state.shared && store.state.shared[groupId];
|
||
if (!grp) return undefined;
|
||
return grp[fieldId];
|
||
}
|
||
|
||
function groupProgress(group) {
|
||
let filled = 0;
|
||
for (let i = 0; i < group.fields.length; i++) {
|
||
const f = group.fields[i];
|
||
if (fieldFilled(getOnboardingValue(group.id, f.id), f.type)) filled++;
|
||
}
|
||
return { filled: filled, total: group.fields.length };
|
||
}
|
||
|
||
function renderOnboardingField(field, fieldId, groupId, value) {
|
||
const path = groupId + '.' + field.id;
|
||
const dataAttrs = 'data-onboarding-field="' + escapeAttr(path) + '"';
|
||
const requiredMark = field.required ? '<span class="required-mark" aria-hidden="true">*</span>' : '';
|
||
const labelHtml = '<label for="' + fieldId + '" class="field-label">' + escapeHtml(field.label) + requiredMark + '</label>';
|
||
const placeholderAttr = field.placeholder ? ' placeholder="' + escapeAttr(field.placeholder) + '"' : '';
|
||
let inputHtml = '';
|
||
if (field.type === 'text') {
|
||
inputHtml = '<input type="text" id="' + fieldId + '" ' + dataAttrs + placeholderAttr + ' value="' + escapeAttr(value || '') + '" class="input">';
|
||
} else if (field.type === 'textarea') {
|
||
inputHtml = '<textarea id="' + fieldId + '" ' + dataAttrs + placeholderAttr + ' class="textarea" rows="3">' + escapeHtml(value || '') + '</textarea>';
|
||
} else if (field.type === 'select') {
|
||
const opts = ['<option value="">(velg)</option>'].concat(field.options.map(function (o) {
|
||
const sel = (o === value) ? ' selected' : '';
|
||
return '<option value="' + escapeAttr(o) + '"' + sel + '>' + escapeHtml(o) + '</option>';
|
||
})).join('');
|
||
inputHtml = '<select id="' + fieldId + '" ' + dataAttrs + ' class="select">' + opts + '</select>';
|
||
} else if (field.type === 'multiSelect') {
|
||
const arr = Array.isArray(value) ? value : [];
|
||
const opts = field.options.map(function (o, i) {
|
||
const checked = arr.indexOf(o) >= 0 ? ' checked' : '';
|
||
const cbId = fieldId + '-' + i;
|
||
return '<label class="checkbox-row" for="' + cbId + '"><input type="checkbox" id="' + cbId + '" ' + dataAttrs + ' data-multi-option="' + escapeAttr(o) + '"' + checked + '><span>' + escapeHtml(o) + '</span></label>';
|
||
}).join('');
|
||
inputHtml = '<fieldset class="multi-select" aria-labelledby="' + fieldId + '-legend"><legend id="' + fieldId + '-legend" class="visually-hidden">' + escapeHtml(field.label) + '</legend>' + opts + '</fieldset>';
|
||
} else if (field.type === 'boolean') {
|
||
const checked = value === true ? ' checked' : '';
|
||
inputHtml = '<label class="checkbox-row" for="' + fieldId + '"><input type="checkbox" id="' + fieldId + '" ' + dataAttrs + checked + '><span>' + escapeHtml(field.label) + '</span></label>';
|
||
}
|
||
return '<div class="field-row" data-field-row="' + escapeAttr(path) + '">' + labelHtml + inputHtml + '</div>';
|
||
}
|
||
|
||
function renderOnboardingSurface() {
|
||
const root = getSurfaceEl('onboarding');
|
||
if (!root) return;
|
||
|
||
const progress = ONBOARDING_SCHEMA.map(function (g) {
|
||
const p = groupProgress(g);
|
||
return { id: g.id, title: g.title, filled: p.filled, total: p.total };
|
||
});
|
||
const totalFilled = progress.reduce(function (a, p) { return a + p.filled; }, 0);
|
||
const totalAll = ONBOARDING_SCHEMA.reduce(function (a, g) { return a + g.fields.length; }, 0);
|
||
|
||
const sidebarSteps = progress.map(function (p, idx) {
|
||
let state = 'pending';
|
||
if (p.filled === p.total) state = 'done';
|
||
else if (p.filled > 0) state = 'in-progress';
|
||
const pct = p.total ? Math.round(100 * p.filled / p.total) : 0;
|
||
const numHtml = (state === 'done' ? '✓' : String(idx + 1));
|
||
return (
|
||
'<button type="button" class="fp-step" data-state="' + state + '" data-action="onboarding-goto-group" data-group="' + escapeAttr(p.id) + '">' +
|
||
'<span class="fp-step__num" aria-hidden="true">' + numHtml + '</span>' +
|
||
'<span>' +
|
||
'<span class="fp-step__name">' + escapeHtml(p.title) + '</span>' +
|
||
'<span class="fp-step__progress">' +
|
||
'<span class="fp-step__bar"><span class="fp-step__bar-fill" style="width:' + pct + '%"></span></span>' +
|
||
'<span>' + p.filled + '/' + p.total + '</span>' +
|
||
'</span>' +
|
||
'</span>' +
|
||
'</button>'
|
||
);
|
||
}).join('');
|
||
|
||
const sidebar = (
|
||
'<aside class="form-progress" aria-label="Onboarding-fremdrift">' +
|
||
'<div class="form-progress__autosave">' +
|
||
'<span class="form-progress__autosave-dot"></span>' +
|
||
'<span>Lagres automatisk</span>' +
|
||
'</div>' +
|
||
'<div class="form-progress__steps">' + sidebarSteps + '</div>' +
|
||
'<div class="form-progress__remaining">' +
|
||
'<span>Utfylt</span>' +
|
||
'<span>' + totalFilled + '/' + totalAll + '</span>' +
|
||
'</div>' +
|
||
'</aside>'
|
||
);
|
||
|
||
const groupsHtml = ONBOARDING_SCHEMA.map(function (g) {
|
||
const p = groupProgress(g);
|
||
const expandedAttr = (p.filled < p.total ? 'true' : 'false');
|
||
const fieldsHtml = g.fields.map(function (f) {
|
||
const fieldId = 'ob-' + g.id + '-' + f.id;
|
||
const value = getOnboardingValue(g.id, f.id);
|
||
return renderOnboardingField(f, fieldId, g.id, value);
|
||
}).join('');
|
||
return (
|
||
'<section class="expansion" aria-expanded="' + expandedAttr + '" data-onboarding-group="' + escapeAttr(g.id) + '">' +
|
||
'<button type="button" class="expansion__head" data-action="onboarding-toggle-group">' +
|
||
'<span class="expansion__title">' +
|
||
'<span class="expansion__title-main">' + escapeHtml(g.title) + '</span>' +
|
||
'<span class="expansion__title-sub">' + escapeHtml(g.sub) + ' — ' + p.filled + '/' + p.total + '</span>' +
|
||
'</span>' +
|
||
'<span class="expansion__chev" aria-hidden="true">▾</span>' +
|
||
'</button>' +
|
||
'<div class="expansion__body">' +
|
||
'<div class="expansion__body-inner">' +
|
||
'<div class="onboarding-fields">' + fieldsHtml + '</div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</section>'
|
||
);
|
||
}).join('');
|
||
|
||
const errorSummary = (
|
||
'<div class="error-summary" data-onboarding-errors hidden role="alert" aria-live="polite">' +
|
||
'<h2 class="error-summary__heading">Noen felter må fylles ut</h2>' +
|
||
'<div class="error-summary__body">' +
|
||
'<ul class="error-summary__list" data-onboarding-error-list></ul>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
|
||
const orgName = store.state.shared.organization && store.state.shared.organization.name;
|
||
const skipBackBtn = orgName
|
||
? '<button type="button" class="btn btn--ghost" data-action="onboarding-cancel">Tilbake til hjem</button>'
|
||
: '';
|
||
const hasDemoBlock = !!document.getElementById('demo-state-v1');
|
||
const demoBtn = hasDemoBlock
|
||
? '<button type="button" class="btn btn--secondary" data-action="load-demo" title="Hopper over onboarding og laster ett ferdig demo-prosjekt med alle 17 rapport-typer pre-importert">Last inn demo-data</button>'
|
||
: '';
|
||
|
||
const actionBar = (
|
||
'<div class="onboarding-actions">' +
|
||
'<button type="button" class="btn btn--primary" data-action="onboarding-save">Lagre og fortsett</button>' +
|
||
skipBackBtn +
|
||
demoBtn +
|
||
'<span class="onboarding-help">Du kan endre alt senere via Re-onboard. Demo-data overskriver eksisterende state.</span>' +
|
||
'</div>'
|
||
);
|
||
|
||
const onboardingShell = renderPageShell({
|
||
eyebrow: 'ONBOARDING',
|
||
title: 'Bli kjent med oss',
|
||
lede: 'Oppgi virksomhetskontekst slik at vi kan tilpasse arkitekturråd til din situasjon. 20 felles felter gjenbrukes på tvers av alle commands.',
|
||
verdict: 'n-a',
|
||
keyStats: []
|
||
},
|
||
'<div class="onboarding-layout">' +
|
||
sidebar +
|
||
'<div class="onboarding-main">' +
|
||
errorSummary +
|
||
'<div class="onboarding-groups">' + groupsHtml + '</div>' +
|
||
actionBar +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
|
||
root.innerHTML = (
|
||
'<div class="app-shell">' +
|
||
onboardingShell +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
function readOnboardingValues() {
|
||
const values = {};
|
||
ONBOARDING_SCHEMA.forEach(function (g) { values[g.id] = {}; });
|
||
const root = getSurfaceEl('onboarding');
|
||
if (!root) return values;
|
||
const fields = root.querySelectorAll('[data-onboarding-field]');
|
||
// Initialiser alle multiSelect-felter til [] så uavkryssede arrays
|
||
// blir tomme arrays (ikke undefined).
|
||
ONBOARDING_SCHEMA.forEach(function (g) {
|
||
g.fields.forEach(function (f) {
|
||
if (f.type === 'multiSelect') values[g.id][f.id] = [];
|
||
});
|
||
});
|
||
for (let i = 0; i < fields.length; i++) {
|
||
const el = fields[i];
|
||
const path = el.dataset.onboardingField;
|
||
const dot = path.indexOf('.');
|
||
const groupId = path.slice(0, dot);
|
||
const fieldId = path.slice(dot + 1);
|
||
if (el.matches('input[type="checkbox"][data-multi-option]')) {
|
||
if (el.checked) values[groupId][fieldId].push(el.dataset.multiOption);
|
||
} else if (el.matches('input[type="checkbox"]')) {
|
||
values[groupId][fieldId] = el.checked;
|
||
} else {
|
||
values[groupId][fieldId] = el.value;
|
||
}
|
||
}
|
||
return values;
|
||
}
|
||
|
||
function validateOnboarding(values) {
|
||
const errors = [];
|
||
ONBOARDING_SCHEMA.forEach(function (g) {
|
||
g.fields.forEach(function (f) {
|
||
if (!f.required) return;
|
||
const v = values[g.id][f.id];
|
||
if (!fieldFilled(v, f.type)) {
|
||
errors.push({
|
||
path: g.id + '.' + f.id,
|
||
label: g.title + ' → ' + f.label,
|
||
message: 'Påkrevd felt mangler verdi'
|
||
});
|
||
}
|
||
});
|
||
});
|
||
return errors;
|
||
}
|
||
|
||
function showOnboardingErrors(errors) {
|
||
const root = getSurfaceEl('onboarding');
|
||
if (!root) return;
|
||
const summary = root.querySelector('[data-onboarding-errors]');
|
||
const list = root.querySelector('[data-onboarding-error-list]');
|
||
if (!summary || !list) return;
|
||
if (errors.length === 0) {
|
||
summary.hidden = true;
|
||
list.innerHTML = '';
|
||
return;
|
||
}
|
||
summary.hidden = false;
|
||
list.innerHTML = errors.map(function (e) {
|
||
return '<li class="error-summary__item"><a href="#" class="error-summary__link" data-action="onboarding-focus-error" data-error-target="' + escapeAttr(e.path) + '">' + escapeHtml(e.label) + ' — ' + escapeHtml(e.message) + '</a></li>';
|
||
}).join('');
|
||
summary.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
summary.focus && summary.focus();
|
||
}
|
||
|
||
function commitOnboarding(values) {
|
||
// Muter via Proxy så change-events fyres og throttled writer persisterer.
|
||
ONBOARDING_SCHEMA.forEach(function (g) {
|
||
if (!store.state.shared[g.id]) store.state.shared[g.id] = {};
|
||
g.fields.forEach(function (f) {
|
||
const v = values[g.id][f.id];
|
||
if (f.type === 'multiSelect') {
|
||
store.state.shared[g.id][f.id] = Array.isArray(v) ? v.slice() : [];
|
||
} else {
|
||
store.state.shared[g.id][f.id] = v;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// ACTION ROUTER
|
||
// ============================================================
|
||
//
|
||
// Én delegert click-handler på document. Mapper data-action til
|
||
// registrerte handlers. Surfaces og modaler kan registrere actions ved
|
||
// å sette window.__ACTIONS[name] = function(ev, el) { ... }.
|
||
|
||
const ACTIONS = {};
|
||
window.__ACTIONS = ACTIONS;
|
||
|
||
document.addEventListener('click', function (ev) {
|
||
const actionEl = ev.target.closest('[data-action]');
|
||
if (!actionEl) return;
|
||
const action = actionEl.dataset.action;
|
||
const handler = ACTIONS[action];
|
||
if (handler) handler(ev, actionEl);
|
||
});
|
||
|
||
ACTIONS['onboarding-toggle-group'] = function (ev, el) {
|
||
const exp = el.closest('.expansion');
|
||
if (!exp) return;
|
||
const open = exp.getAttribute('aria-expanded') === 'true';
|
||
exp.setAttribute('aria-expanded', open ? 'false' : 'true');
|
||
};
|
||
|
||
ACTIONS['onboarding-goto-group'] = function (ev, el) {
|
||
const groupId = el.dataset.group;
|
||
const root = getSurfaceEl('onboarding');
|
||
if (!root) return;
|
||
const exp = root.querySelector('[data-onboarding-group="' + groupId + '"]');
|
||
if (exp) {
|
||
exp.setAttribute('aria-expanded', 'true');
|
||
exp.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
};
|
||
|
||
ACTIONS['onboarding-save'] = function (ev) {
|
||
const values = readOnboardingValues();
|
||
const errors = validateOnboarding(values);
|
||
if (errors.length > 0) {
|
||
showOnboardingErrors(errors);
|
||
return;
|
||
}
|
||
commitOnboarding(values);
|
||
navigate('home');
|
||
};
|
||
|
||
ACTIONS['onboarding-cancel'] = function () {
|
||
navigate('home');
|
||
};
|
||
|
||
// v1.10.0+: Last inn demo-state fra inline JSON-blokken.
|
||
// Bygges av scripts/build-demo-state.mjs ved hver release. Erstatter all
|
||
// eksisterende state med ferdig demo-prosjekt + 17 pre-importerte rapporter.
|
||
ACTIONS['load-demo'] = function () {
|
||
const node = document.getElementById('demo-state-v1');
|
||
if (!node) {
|
||
console.warn('[playground v3] demo-state-v1 inline JSON ikke funnet — kjør node scripts/build-demo-state.mjs');
|
||
return;
|
||
}
|
||
let demo;
|
||
try {
|
||
demo = JSON.parse(node.textContent || '{}');
|
||
} catch (e) {
|
||
console.warn('[playground v3] demo-state-v1 JSON parse feilet:', e);
|
||
return;
|
||
}
|
||
// Erstatt top-level state-grener via Proxy-mutasjon for reactivity.
|
||
// schemaVersion + dataVersion bevares fra demo-state for migrasjons-konsistens.
|
||
['schemaVersion', 'dataVersion', 'shared', 'projects', 'activeProjectId',
|
||
'activeSurface', 'preferences'].forEach(function (k) {
|
||
if (demo[k] !== undefined) store.state[k] = demo[k];
|
||
});
|
||
// Reset interne UI-state-variabler så project-render starter i 'rapporter'-tab.
|
||
currentProjectTab = 'regulatory';
|
||
currentProjectScreen = 'rapporter';
|
||
navigate(demo.activeSurface || 'project');
|
||
};
|
||
|
||
ACTIONS['onboarding-focus-error'] = function (ev, el) {
|
||
ev.preventDefault();
|
||
const path = el.dataset.errorTarget;
|
||
const root = getSurfaceEl('onboarding');
|
||
if (!root || !path) return;
|
||
const fieldRow = root.querySelector('[data-field-row="' + path + '"]');
|
||
if (!fieldRow) return;
|
||
const exp = fieldRow.closest('.expansion');
|
||
if (exp) exp.setAttribute('aria-expanded', 'true');
|
||
fieldRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
const input = fieldRow.querySelector('input, select, textarea');
|
||
if (input) input.focus();
|
||
};
|
||
|
||
// ============================================================
|
||
// NAV + EXPORT/IMPORT ACTIONS (Step 6)
|
||
// ============================================================
|
||
|
||
ACTIONS['goto-home'] = function () { navigate('home'); };
|
||
ACTIONS['goto-catalog'] = function () { navigate('catalog'); };
|
||
ACTIONS['goto-onboarding'] = function () { navigate('onboarding'); };
|
||
|
||
// Theme toggle (Step 13). Veksler data-theme på <html>, persisterer i
|
||
// localStorage('ms-ai-architect-theme'). Tar høyde for begrensning fra
|
||
// file:// + privatmodus. Re-renderer ikke surfaces — endrer kun attributt
|
||
// og synkroniserer alle [data-theme-label]-elementer in-place.
|
||
ACTIONS['toggle-theme'] = function () {
|
||
const current = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||
const next = current === 'dark' ? 'light' : 'dark';
|
||
document.documentElement.setAttribute('data-theme', next);
|
||
try { localStorage.setItem('ms-ai-architect-theme', next); } catch (e) { /* ignore */ }
|
||
const labels = document.querySelectorAll('[data-theme-label]');
|
||
for (let i = 0; i < labels.length; i++) {
|
||
labels[i].textContent = next === 'dark' ? 'Mørk' : 'Lys';
|
||
}
|
||
const buttons = document.querySelectorAll('[data-action="toggle-theme"]');
|
||
for (let j = 0; j < buttons.length; j++) {
|
||
buttons[j].setAttribute('aria-label', 'Bytt til ' + (next === 'dark' ? 'lys' : 'mørk') + ' modus');
|
||
}
|
||
};
|
||
|
||
ACTIONS['open-project'] = function (ev, el) {
|
||
const id = el.dataset.projectId;
|
||
if (!id) return;
|
||
store.state.activeProjectId = id;
|
||
navigate('project');
|
||
};
|
||
|
||
ACTIONS['new-project'] = function () {
|
||
mountModal(renderNewProjectModalHtml());
|
||
};
|
||
|
||
ACTIONS['modal-cancel'] = function () { unmountModal(); };
|
||
|
||
ACTIONS['create-project'] = function () {
|
||
const modal = document.querySelector('[data-modal-root]');
|
||
if (!modal) return;
|
||
const nameEl = modal.querySelector('[data-new-project-field="name"]');
|
||
const descEl = modal.querySelector('[data-new-project-field="description"]');
|
||
const errBox = modal.querySelector('[data-new-project-errors]');
|
||
const errText = modal.querySelector('[data-new-project-error-text]');
|
||
const name = nameEl ? String(nameEl.value || '').trim() : '';
|
||
const description = descEl ? String(descEl.value || '').trim() : '';
|
||
if (!name) {
|
||
if (errBox && errText) {
|
||
errBox.hidden = false;
|
||
errText.textContent = 'Prosjektnavn er påkrevd.';
|
||
}
|
||
if (nameEl) nameEl.focus();
|
||
return;
|
||
}
|
||
const scenarios = Array.from(modal.querySelectorAll('[data-new-project-scenario]'))
|
||
.filter(function (cb) { return cb.checked; })
|
||
.map(function (cb) { return cb.value; });
|
||
createProject({ name: name, description: description, scenarios: scenarios });
|
||
unmountModal();
|
||
navigate('project');
|
||
};
|
||
|
||
ACTIONS['delete-project'] = function (ev, el) {
|
||
const id = el.dataset.projectId;
|
||
const project = findProject(id);
|
||
if (!project) return;
|
||
mountModal(renderDeleteProjectModalHtml(project));
|
||
};
|
||
|
||
ACTIONS['confirm-delete-project'] = function (ev, el) {
|
||
const id = el.dataset.projectId;
|
||
if (!id) return;
|
||
deleteProject(id);
|
||
unmountModal();
|
||
navigate('home');
|
||
};
|
||
|
||
ACTIONS['project-tab'] = function (ev, el) {
|
||
const tab = el.dataset.tab;
|
||
if (!tab) return;
|
||
currentProjectTab = tab;
|
||
// Toggle visning uten full re-render (bevarer textarea-input).
|
||
const root = getSurfaceEl('project');
|
||
if (!root) return;
|
||
const tabs = root.querySelectorAll('.project-tab');
|
||
tabs.forEach(function (t) {
|
||
if (t.dataset.tab === tab) t.setAttribute('aria-current', 'true');
|
||
else t.removeAttribute('aria-current');
|
||
});
|
||
const panels = root.querySelectorAll('[data-tab-panel]');
|
||
panels.forEach(function (p) {
|
||
p.hidden = (p.dataset.tabPanel !== tab);
|
||
});
|
||
};
|
||
|
||
ACTIONS['project-screen'] = function (ev, el) {
|
||
const screen = el.dataset.screen;
|
||
if (!screen) return;
|
||
currentProjectScreen = screen;
|
||
// Toggle aria-current på .tab-list-knappene + [hidden] på .tab-panel-paneler
|
||
// uten full re-render (bevarer evt textarea-input i panels).
|
||
const root = getSurfaceEl('project');
|
||
if (!root) return;
|
||
const tabs = root.querySelectorAll('.tab-list .tab');
|
||
tabs.forEach(function (t) {
|
||
t.setAttribute('aria-current', t.dataset.screen === screen ? 'true' : 'false');
|
||
});
|
||
const screens = root.querySelectorAll('.tab-panel[data-screen-id]');
|
||
screens.forEach(function (s) {
|
||
if (s.dataset.screenId === screen) s.removeAttribute('hidden');
|
||
else s.setAttribute('hidden', '');
|
||
});
|
||
};
|
||
|
||
ACTIONS['parse'] = function (ev, el) {
|
||
const commandId = el.dataset.command;
|
||
if (!commandId) return;
|
||
// Finn nærmeste paste-import textarea (project-overflate eller modal — Step 9
|
||
// bruker ikke parse-knapp, men vi holder oss generisk via closest()).
|
||
const scope = el.closest('[data-modal-root], [data-surface]') || document;
|
||
const textarea = scope.querySelector('[data-paste-import="' + commandId + '"]');
|
||
if (!textarea) return;
|
||
const markdown = textarea.value || '';
|
||
handlePasteImport(commandId, markdown);
|
||
};
|
||
|
||
// ---- Step 8: copy-command + preview-command ----
|
||
|
||
ACTIONS['copy-command'] = function (ev, el) {
|
||
const commandId = el.dataset.command;
|
||
const formEl = el.closest('[data-command-form]');
|
||
if (!commandId || !formEl) return;
|
||
const data = readCommandFormValues(formEl);
|
||
const cmdString = buildCommand(commandId, data);
|
||
// Vis preview alltid — clipboard kan feile på file://-protokoll i noen browsers.
|
||
showCommandPreview(formEl, cmdString);
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(cmdString).then(function () {
|
||
flashCopyConfirm(formEl, 'Kopiert til utklippstavle.');
|
||
}).catch(function (err) {
|
||
console.warn('[playground v3] clipboard write feilet:', err);
|
||
flashCopyConfirm(formEl, 'Kunne ikke kopiere — bruk forhåndsvisningen under.');
|
||
});
|
||
} else {
|
||
flashCopyConfirm(formEl, 'Clipboard utilgjengelig — bruk forhåndsvisningen under.');
|
||
}
|
||
};
|
||
|
||
ACTIONS['preview-command'] = function (ev, el) {
|
||
const commandId = el.dataset.command;
|
||
const formEl = el.closest('[data-command-form]');
|
||
if (!commandId || !formEl) return;
|
||
const data = readCommandFormValues(formEl);
|
||
showCommandPreview(formEl, buildCommand(commandId, data));
|
||
};
|
||
|
||
// ---- Step 9: catalog actions ----
|
||
|
||
ACTIONS['open-catalog-form'] = function (ev, el) {
|
||
const commandId = el.dataset.command;
|
||
if (!commandId) return;
|
||
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
|
||
if (!cmd) return;
|
||
mountModal(renderCatalogFormModalHtml(cmd));
|
||
};
|
||
|
||
ACTIONS['catalog-toggle-group'] = function (ev, el) {
|
||
const exp = el.closest('.expansion');
|
||
if (!exp) return;
|
||
const open = exp.getAttribute('aria-expanded') === 'true';
|
||
exp.setAttribute('aria-expanded', open ? 'false' : 'true');
|
||
};
|
||
|
||
// Søk-input: input-event oppdaterer query og re-rendrer kun groups-containeren
|
||
// (bevarer fokus/cursor i selve søke-feltet — full re-render ville flyttet caret).
|
||
document.addEventListener('input', function (ev) {
|
||
if (!ev.target.matches || !ev.target.matches('[data-catalog-search]')) return;
|
||
catalogSearchQuery = ev.target.value || '';
|
||
refreshCatalogResults();
|
||
});
|
||
|
||
// Eksponer for Verify-asserts og Step 8/9/12.
|
||
window.__SCENARIOS = SCENARIOS;
|
||
window.__createProject = createProject;
|
||
window.__deleteProject = deleteProject;
|
||
window.__findProject = findProject;
|
||
window.__mountModal = mountModal;
|
||
window.__unmountModal = unmountModal;
|
||
window.__buildCommand = buildCommand;
|
||
window.__renderCommandForm = renderCommandForm;
|
||
window.__readCommandFormValues = readCommandFormValues;
|
||
window.__resolveSharedPath = resolveSharedPath;
|
||
window.__renderCatalogSurface = renderCatalogSurface;
|
||
window.__refreshCatalogResults = refreshCatalogResults;
|
||
|
||
ACTIONS['export-state'] = function () {
|
||
try { exportState(); }
|
||
catch (err) { console.error('[playground v3] export feilet:', err); alert('Eksport feilet: ' + err.message); }
|
||
};
|
||
|
||
ACTIONS['import-state'] = function (ev, el) {
|
||
const header = el.closest('.app-header');
|
||
if (!header) return;
|
||
const input = header.querySelector('[data-import-input]');
|
||
if (!input) return;
|
||
input.value = ''; // tillat samme fil to ganger
|
||
input.click();
|
||
};
|
||
|
||
// File-input change handler (én gang for hele dokumentet — input genereres
|
||
// fortløpende via renderTopbar, men endringen bobler).
|
||
document.addEventListener('change', function (ev) {
|
||
if (!ev.target.matches || !ev.target.matches('[data-import-input]')) return;
|
||
const file = ev.target.files && ev.target.files[0];
|
||
if (!file) return;
|
||
importState(file)
|
||
.then(function () {
|
||
scheduleRender();
|
||
alert('Import fullført. Nåværende state er erstattet av filens innhold.');
|
||
})
|
||
.catch(function (err) {
|
||
console.error('[playground v3] import feilet:', err);
|
||
alert('Import feilet: ' + err.message);
|
||
});
|
||
});
|
||
|
||
// Eksponer for Verify-asserts og Steps 6-9.
|
||
window.__navigate = navigate;
|
||
window.__scheduleRender = scheduleRender;
|
||
window.__ONBOARDING_SCHEMA = ONBOARDING_SCHEMA;
|
||
window.__readOnboardingValues = readOnboardingValues;
|
||
window.__validateOnboarding = validateOnboarding;
|
||
window.__commitOnboarding = commitOnboarding;
|
||
|
||
// Auto-bootstrap. Kjør så snart DOM er parsed; vi er på slutten av <body>
|
||
// så DOM er allerede klar.
|
||
bootstrap().catch(function (err) {
|
||
console.error('[playground v3] bootstrap failed:', err);
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|