3 ud af 4 mails kunne kategoriseres automatisk med 90 % præcision.
Hvor mange af jeres kundemails bliver sendt videre mindst én gang, før de lander hos den rigtige?
Hvis en medarbejder bruger 30 sekunder på at læse og sortere hver mail, og I får 500 om dagen, er det over 4 timer dagligt. Hver dag. Og det er tid, der går fra det, kundeservice egentlig er til for: at hjælpe kunderne.
Vi er på en mission: at fjerne de ting, der gør kundeservice tungt. Mails, der ligger i den forkerte kø. Kunder, der venter, fordi ingen har set, at det haster. Medarbejdere, der bruger deres dag på at sortere i stedet for at løse problemer.
Samtidig ligger en stor del af løsningen ofte gemt i jeres egne systemer. Hver gang en medarbejder har flyttet en mail til den rigtige kø, sat en prioritet eller markeret en sag som en fejl, har de i virkeligheden lavet et træningseksempel. De fleste kundeserviceafdelinger sidder på tusindvis af dem uden at bruge dem.
Vi ville finde ud af, hvor langt man kan komme med det.
Hvorfor ikke bare bruge en stor sprogmodel?
Det er det første spørgsmål, de fleste stiller, og det er et godt spørgsmål. Store sprogmodeller som dem bag ChatGPT er imponerende til at skrive, opsummere og ræsonnere. Men at sortere tusindvis af mails om dagen er en anden slags opgave.
Den ligner mere det, en erfaren medarbejder gør på to sekunder: kigger på mailen og ved med det samme, at den skal til økonomi, og at det haster. Det kræver ikke en lang tekst. Det kræver en hurtig, pålidelig beslutning, og en klar fornemmelse af, hvornår man er i tvivl.
En sprogmodel er bygget til at skrive, ikke til at beslutte. Den skal omsætte sit svar til tekst, som så skal fortolkes. Den er langsommere og dyrere pr. mail. Den kan formulere et forkert svar lige så overbevisende som et rigtigt. Og dens egen vurdering af, hvor sikker den er, passer ikke nødvendigvis med, hvor ofte den faktisk har ret.
Derfor tror vi ikke, at den store sprogmodel er det endelige svar på kundeservice. Den er et af svarene. Den er god til at skrive udkast til svar og hjælpe med de komplicerede sager. Men til de mange hurtige, gentagne beslutninger, der i dag tager tid fra medarbejderne, er der brug for et andet værktøj.

Hurtige beslutninger og langsom tænkning er to forskellige opgaver. Det giver mening at bruge forskellige værktøjer til dem.
Hvad vi gjorde
Vi har trænet vores egen AI-model til formålet, en såkaldt System One-model. Navnet kommer fra psykologien, hvor "system 1" er den hurtige, intuitive del af vores tænkning. Modellen skriver ikke tekst, men træffer hurtige beslutninger. Den læser en mail og svarer på tre spørgsmål på én gang:
- Hvilken afdeling skal have mailen?
- Hvor meget haster den?
- Hvad slags henvendelse er det: en fejl, en forespørgsel, et problem eller en ændring?

Modellen skriver ikke et svar. Den træffer tre beslutninger om mailen og fortæller, hvor sikker den er på hver af dem.
Modellen lærte af 26.000 supportmails på tysk og engelsk, og vi testede den på 1.225 tyske mails, den aldrig havde set. For at vide, om resultaterne faktisk var gode, har vi undervejs målt den mod de alternativer, en kundeserviceafdeling realistisk ville overveje: at gætte på det mest almindelige svar, en klassisk statistisk metode, en hostet kommerciel beslutningsmodel og en åben beslutningsmodel bygget på en stor sprogmodel. Alle blev testet på præcis de samme mails, så sammenligningen er fair.
Resultaterne
De tre spørgsmål viste sig at være meget forskellige i sværhedsgrad:
| Spørgsmål | Valgmuligheder | Rigtigt svar | Håndteres automatisk ved 90 % præcision |
|---|---|---|---|
| Hvad slags henvendelse | 4 | 83 % | 77 % |
| Hvor meget haster den | 3 | 56 % | 3 % |
| Hvilken afdeling | 10 | 46 % | 10 % |
Den sidste kolonne er den vigtigste. Den viser, hvor stor en del af mailene modellen var sikker nok på til at håndtere selv, og blandt dem havde den ret 9 ud af 10 gange.
Til spørgsmålet om, hvad slags henvendelse det er, kunne modellen håndtere 77,2 % af mailene automatisk med 90 % præcision. Resten sendte modellen videre til et menneske.
Det er en vigtig forskel. Vi prøver ikke at bygge en model, der absolut skal svare på alt. Vi vil hellere have en model, der kan genkende de mails, den er sikker nok på, og lade et menneske tage resten.
Afdelingsvalget og hastegraden var langt sværere, og her er det vigtigt at sige det ligeud: ingen af de metoder, vi testede, klarede dem godt. Ikke den klassiske metode, ikke de store kommercielle modeller og heller ikke vores egen. Når meget forskellige metoder rammer det samme loft, ligger problemet i data og kategorier, ikke i metoderne. Vi vender tilbage til hvorfor.
På afdelingsvalget kunne vores model alligevel håndtere 10 % af mailene automatisk ved samme præcisionskrav. Det er ikke meget, men det er mere end dobbelt så mange som den klassiske metode.
Et af de mere overraskende resultater var, at de store, generelle beslutningsmodeller uden træning faktisk klarede sig dårligere på afdelingsvalget end bare at vælge den mest almindelige afdeling. Størrelse gør det ikke alene. Når opgaven handler om virksomhedens egne kategorier og beslutninger, er det svært at slå træning på de rigtige data.

Andel af mails, der kan håndteres automatisk med 90 % præcision. Den store, generelle model uden træning kunne ikke automatisere et eneste afdelingsvalg sikkert.
Tre ting, vi ikke havde forventet
1. Over halvdelen af data måtte smides ud
Datasættet så rent ud ved første øjekast. Men da vi gravede i det, viste det sig, at over halvdelen af de tyske mails stammede fra en anden version med andre kategorier, en anden skala for hastegrad og manglende oplysninger.
Havde vi trænet på det hele, havde modellen lært at blande to forskellige sorteringssystemer sammen.

Over halvdelen af de tyske mails måtte fravælges, fordi de fulgte et andet sorteringssystem.
Det samme sker i virkelige ticket-systemer. Kategorier bliver omdøbt, afdelinger slået sammen, og prioriteringsskalaer ændret gennem årene. Kvaliteten og konsistensen af jeres historiske sortering sætter derfor en grænse for, hvad en model kan lære af den.
Det er måske ikke så sexet som at vælge en større model, men det er sandsynligvis noget af det vigtigste at få styr på først.
2. En klassisk metode fra 1970'erne var sværere at slå, end vi troede
Vi sammenlignede med en simpel statistisk metode, der sorterer efter, hvilke ord der optræder i mailen. Den trænes på få sekunder, kræver meget lidt computerkraft, og på den samlede præcision var den næsten lige så god som vores model.
Forskellen lå et andet sted.
Vores model var markant bedre til de sjældne afdelinger, dem der kun får få mails, og som den simple metode typisk overser. Den samlede præcision var 44,2 % for den klassiske metode mod 45,5 % for vores model, altså næsten ingen forskel.
Men når vi måler med macro-F1, som giver de sjældne afdelinger samme vægt som de store, får vi 0,26 mod 0,37.

Næsten samme samlede præcision, men vores model rammer markant bedre på de sjældne afdelinger.
Lærdommen er, at man skal måle mod det simple alternativ. Hvis en avanceret model ikke kan slå en metode, der trænes på få sekunder, er der ikke meget grund til at bruge den. Men man skal også vælge et mål, der afspejler det problem, man faktisk forsøger at løse.
3. Det vigtigste er ikke, hvor ofte modellen har ret, men om den ved, hvornår den har ret
Da den samlede præcision var næsten uafgjort, hvorfor kunne vores model så automatisere mere end dobbelt så mange afdelingsvalg sikkert?
Fordi dens selvtillid kan bruges til at styre, hvornår den får lov til at handle.
Når modellen er meget sikker på et svar, kan vi lade den håndtere mailen automatisk. Når den er usikker, sender vi den videre til et menneske. På den måde behøver modellen ikke være perfekt for at være nyttig.
Det er også her, 90 % kommer ind i billedet. I bestemmer selv, hvor sikker modellen skal være, før den må handle på egen hånd. Sætter I grænsen højt, automatiserer I mindre, men med højere sikkerhed. Sætter I den lavere, kan flere mails håndteres automatisk.

I bestemmer grænsen. Alt, hvor modellen er sikker nok, håndteres automatisk; resten går til en medarbejder.
Det betyder, at automatisering ikke behøver være et spørgsmål om at erstatte medarbejdere. Det kan lige så godt handle om at fjerne den del af arbejdet, hvor en medarbejder bruger tid på at læse en mail, finde den rigtige kø og klikke på den rigtige kategori.
Hvorfor afdelingsvalget er svært
Når selv den bedste metode rammer under halvdelen på afdelingsvalget, skyldes det ikke kun modellen.
Mange af kategorierne overlapper: Teknisk support, Produktsupport, IT-support og Kundeservice beskriver ofte de samme mails. En mail om en printer, der ikke virker, kan med god ret høre til tre af dem.

De fleste fejl sker mellem fire afdelinger, der beskriver de samme slags mails. Problemet ligger i kategorierne, ikke kun i modellen.
Det er en indsigt, der er værd at tage med hjem: før man automatiserer routing, skal kategorierne være skarpe nok til, at to medarbejdere nogenlunde konsekvent ville sortere den samme mail ens.
Hvis mennesker selv er uenige om, hvilken afdeling en sag hører til, er det svært at forvente, at en model kan løse problemet alene.
Dertil kommer, at facit ikke altid er rigtigt. Datasættet, vi testede på, er genereret og ikke sorteret af rigtige medarbejdere, og vi fandt eksempler, hvor afdelingen i data selv var forkert. En mail om Office-programmer, der ikke ville starte, lå for eksempel under HR. Ingen model kan score højt mod et facit, der selv er forkert.
Det er derfor, vi altid starter med at se på kategorierne, før vi træner noget. Ofte er det dér, den største gevinst ligger.
Et spørgsmål, I bør stille enhver AI-leverandør
Undervejs opdagede vi også noget, der ikke har meget med selve modellen at gøre: træningsdata.
Mange af de populære offentlige datasæt, som AI-modeller trænes på, har licenser eller brugsbetingelser, der begrænser kommerciel anvendelse. Det kan være fint til forskning og eksperimenter, men det bliver pludselig relevant, når modellen skal bruges i et kommercielt produkt.
Vi har derfor dokumenteret kilden og licensen for hvert eneste datasæt, som de modeller, vi bygger til kunder, trænes på, og sorteret data fra, hvor vilkårene var uklare.
Så når I kigger på AI-løsninger til jeres kundeservice, så spørg:
Hvad er modellen trænet på, og må det bruges kommercielt?
Et godt svar er konkret. Et vagt svar er værd at undersøge nærmere.
Hvad det kræver
Metoden er den samme på jeres egne data. I skal bruge historiske sager med kø, kategori eller prioritet, og helst nok eksempler til, at de forskellige typer henvendelser faktisk er repræsenteret.
Kategorierne behøver ikke være perfekte. Men hvis de har ændret betydning flere gange gennem årene, skal det håndteres, før man begynder at træne.
Og så skal der være en beslutning om, hvor jeres data skal behandles. Modellen kan køre på jeres egne servere eller i vores cloud i Danmark. Uanset hvad I vælger, forlader jeres data aldrig landet, og I sender dem ikke til en udenlandsk AI-tjeneste.
Resultaterne her er fra et offentligt datasæt. På jeres egne mails vil de være anderledes, og det er netop derfor, det er værd at måle.
Hvad kan jeres egne data?
Vores mission er ikke at sætte en chatbot foran jeres kunder. Det er at fjerne friktionen bag kulisserne, så jeres medarbejdere kan bruge tiden på de samtaler, der faktisk kræver et menneske.
Vi leder efter 2-3 kundeserviceafdelinger, der vil være med til at finde ud af, hvor langt man kan komme.
Vi tager jeres historiske sager, træner og tester en model på dem og viser jer, hvor stor en del af jeres mails der kan håndteres sikkert automatisk. Vi viser også, hvilke kategorier der fungerer godt, hvor modellen bliver usikker, og hvor jeres eksisterende sortering måske er for uklar til at automatisere.
I behøver altså ikke tage vores ord for, at AI kan gøre en forskel.
Vi kan måle det på jeres egne data.
Skriv til mig, hvis det er jer.
Prøv modellen selv
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { font-family: 'IBM Plex Sans', system-ui, sans-serif; background: transparent; }
textarea::placeholder { color: #64748B; }
.lm-page { padding: 24px; }
.lm-card { max-width: 880px; margin: 0 auto; border-radius: 20px; border: 1px solid rgba(59,130,246,0.28); background: linear-gradient(135deg, #0F172A 0%, #12275A 60%, #0B3E6B 100%); padding: 32px clamp(18px, 4vw, 36px); display: flex; flex-direction: column; gap: 24px; color: #F8FAFC; }
.lm-kicker { color: #7DD3FC; font-size: 13px; font-weight: 500; letter-spacing: 0.16em; }
.lm-title { font-size: 28px; font-weight: 600; letter-spacing: -0.01em; line-height: 1.2; }
.lm-lead { color: #B9C7DC; font-size: 15px; line-height: 1.55; max-width: 620px; }
.lm-label { color: #94A3B8; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; }
.lm-chips { display: flex; flex-wrap: nowrap; gap: 6px; align-items: center; overflow-x: auto; scrollbar-width: none; }
.lm-chips::-webkit-scrollbar { display: none; }
.lm-chip { font-family: inherit; font-size: 13px; white-space: nowrap; flex-shrink: 0; padding: 6px 10px; border-radius: 8px; cursor: pointer; background: transparent; border: 1px solid #334155; color: #CBD5E1; transition: all 150ms; display: flex; align-items: center; gap: 6px; }
.lm-chip:hover { border-color: #22D3EE; }
.lm-chip.on { background: rgba(34,211,238,0.14); border-color: #22D3EE; color: #A5F3FC; }
.lm-chip svg { stroke: #7DD3FC; flex-shrink: 0; display: block; }
.lm-chip.on svg { stroke: #22D3EE; }
.lm-inputbox { border-radius: 14px; background: rgba(11,21,38,0.7); border: 1px solid rgba(59,130,246,0.34); display: flex; flex-direction: column; overflow: hidden; }
.lm-inputbox:focus-within { border-color: #22D3EE; }
.lm-textarea { font-family: inherit; font-size: 16px; line-height: 1.55; color: #F8FAFC; background: transparent; border: none; outline: none; resize: vertical; padding: 16px 18px 8px; min-height: 96px; }
.lm-inputfooter { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 12px 12px 18px; flex-wrap: wrap; }
.lm-charcount { color: #64748B; font-size: 12px; font-family: 'IBM Plex Mono', monospace; }
.lm-runbtn { font-family: inherit; font-size: 15px; font-weight: 600; white-space: nowrap; flex-shrink: 0; padding: 10px 18px; border-radius: 10px; border: none; background: #22D3EE; color: #0F172A; cursor: pointer; display: flex; align-items: center; gap: 8px; }
.lm-runbtn:hover:not(:disabled) { background: #67E8F9; }
.lm-runbtn:disabled { cursor: default; }
.lm-deptrow { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.lm-thresholdlabel { display: flex; align-items: center; gap: 10px; color: #CBD5E1; font-size: 13px; }
.lm-thresholdlabel input[type=range] { accent-color: #22D3EE; width: 140px; }
.lm-thresholdval { font-family: 'IBM Plex Mono', monospace; color: #F8FAFC; min-width: 44px; }
.lm-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
.lm-tile { border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; justify-content: space-between; gap: 4px; min-height: 58px; background: rgba(11,21,38,0.55); border: 1.5px solid rgba(59,130,246,0.22); transition: background 120ms, border-color 120ms, transform 200ms; transform: scale(1); }
.lm-tile-name { font-size: 13px; font-weight: 500; line-height: 1.25; color: #CBD5E1; }
.lm-tile-pct { font-size: 12px; font-family: 'IBM Plex Mono', monospace; color: #64748B; }
.lm-verdict { border-radius: 12px; padding: 14px 18px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; background: rgba(148,163,184,0.08); border: 1px solid rgba(148,163,184,0.35); opacity: 0.35; transform: translateY(4px); transition: opacity 350ms, transform 350ms, background 350ms, border-color 350ms; }
.lm-verdict-dot { width: 10px; height: 10px; border-radius: 50%; background: #CBD5E1; flex-shrink: 0; }
.lm-verdict-lead { flex: 1 1 240px; min-width: 0; color: #CBD5E1; font-size: 15px; line-height: 1.4; }
.lm-verdict-target { color: #F8FAFC; font-size: 17px; font-weight: 600; white-space: nowrap; }
.lm-verdict-pct { margin-left: auto; font-family: 'IBM Plex Mono', monospace; font-size: 15px; white-space: nowrap; color: #CBD5E1; }
.lm-timing { display: flex; align-items: center; gap: 10px 18px; flex-wrap: wrap; font-size: 13px; color: #94A3B8; opacity: 0.35; transition: opacity 350ms; }
.lm-timing-item { display: flex; align-items: center; gap: 7px; }
.lm-timing-val { font-family: 'IBM Plex Mono', monospace; font-size: 14px; white-space: nowrap; color: #F8FAFC; }
.lm-timing-factor { white-space: nowrap; color: #67E8F9; font-family: 'IBM Plex Mono', monospace; }
.lm-details { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px; }
.lm-card2 { border-radius: 14px; background: rgba(11,21,38,0.66); border: 1px solid rgba(59,130,246,0.28); padding: 18px; display: flex; flex-direction: column; gap: 14px; }
.lm-card2-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.lm-card2-title { font-size: 15px; font-weight: 600; }
.lm-badge { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; padding: 3px 8px; border-radius: 5px; background: rgba(148,163,184,0.1); color: #64748B; }
.lm-barrow { display: flex; flex-direction: column; gap: 5px; }
.lm-barrow-head { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; min-width: 0; }
.lm-barrow-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #94A3B8; }
.lm-barrow-pct { flex-shrink: 0; white-space: nowrap; font-family: 'IBM Plex Mono', monospace; color: #94A3B8; }
.lm-bartrack { height: 6px; border-radius: 3px; background: rgba(148,163,184,0.18); overflow: hidden; }
.lm-barfill { height: 6px; border-radius: 3px; width: 0%; background: #475569; transition: width 700ms cubic-bezier(.2,.8,.2,1); }
.lm-urgplot { display: flex; align-items: flex-end; gap: 12px; height: 120px; padding-top: 6px; }
.lm-urgcol { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; justify-content: flex-end; }
.lm-urgpct { font-size: 13px; white-space: nowrap; font-family: 'IBM Plex Mono', monospace; color: #94A3B8; }
.lm-urgbar { width: 100%; max-width: 56px; border-radius: 6px 6px 0 0; height: 3px; background: #475569; transition: height 700ms cubic-bezier(.2,.8,.2,1), background 200ms; }
.lm-urglabels { display: flex; gap: 12px; border-top: 1px solid #334155; padding-top: 8px; }
.lm-urglabel { flex: 1; text-align: center; font-size: 13px; color: #94A3B8; }
.lm-donutwrap { display: flex; align-items: center; gap: 12px; }
.lm-donut { position: relative; width: 104px; height: 104px; flex-shrink: 0; }
.lm-donut svg { display: block; transform: rotate(-90deg); }
.lm-donut-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; }
.lm-donut-pct { font-size: 18px; font-weight: 600; white-space: nowrap; font-family: 'IBM Plex Mono', monospace; }
.lm-donut-name { font-size: 11px; color: #93A7C4; }
.lm-legend { flex: 1; display: flex; flex-direction: column; gap: 7px; min-width: 0; }
.lm-legend-row { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.lm-legend-swatch { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
.lm-legend-name { color: #CBD5E1; white-space: nowrap; }
.lm-legend-pct { margin-left: auto; white-space: nowrap; font-family: 'IBM Plex Mono', monospace; color: #94A3B8; }
.lm-footer { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; border-top: 1px solid rgba(59,130,246,0.2); padding-top: 16px; }
.lm-footnote { color: #7C8DA8; font-size: 12px; line-height: 1.5; max-width: 520px; }
.lm-brand { display: flex; align-items: center; gap: 10px; }
.lm-brand img { height: 24px; width: 24px; display: block; }
.lm-brand span { color: #F8FAFC; font-size: 12px; letter-spacing: 0.1em; }
</style>
</head>
<body>
<div class="lm-page"><div class="lm-card" id="card">
<div style="display:flex;flex-direction:column;gap:8px;">
<div class="lm-kicker" id="kicker"></div>
<div class="lm-title" id="title"></div>
<div class="lm-lead" id="lead"></div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;">
<div class="lm-label" id="samplesLabel"></div>
<div class="lm-chips" id="chips"></div>
<div class="lm-inputbox">
<textarea class="lm-textarea" id="textarea" rows="4"></textarea>
<div class="lm-inputfooter">
<div class="lm-charcount" id="charCount"></div>
<button class="lm-runbtn" id="runBtn" type="button"><span id="runLabel"></span> <span>→</span></button>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:14px;">
<div class="lm-deptrow">
<div class="lm-label" id="deptLabel"></div>
<label class="lm-thresholdlabel">
<span id="thresholdLabel"></span>
<input type="range" min="50" max="99" value="80" id="thresholdSlider">
<span class="lm-thresholdval" id="thresholdVal"></span>
</label>
</div>
<div class="lm-tiles" id="tiles"></div>
<div class="lm-verdict" id="verdict">
<div class="lm-verdict-dot" id="verdictDot"></div>
<div class="lm-verdict-lead" id="verdictLead">— <span class="lm-verdict-target" id="verdictTarget"></span></div>
<div class="lm-verdict-pct" id="verdictPct"></div>
</div>
<div class="lm-timing" id="timing">
<div class="lm-timing-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#67E8F9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v5l3 2M9 2h6M12 2v3M12 22a8.5 8.5 0 1 0 0-17 8.5 8.5 0 0 0 0 17z"></path></svg>
<span id="decTimeLabel"></span><span class="lm-timing-val" id="msVal"></span>
</div>
<div class="lm-timing-item"><span id="manualLabel"></span><span class="lm-timing-val" id="manualVal" style="color:#CBD5E1;"></span></div>
<div class="lm-timing-factor" id="factorVal"></div>
</div>
</div>
<div class="lm-details">
<div class="lm-card2">
<div class="lm-card2-head"><div class="lm-card2-title" id="deptCardTitle"></div><div class="lm-badge" id="deptBadge"></div></div>
<div id="deptBars"></div>
</div>
<div class="lm-card2">
<div class="lm-card2-head"><div class="lm-card2-title" id="urgCardTitle"></div><div class="lm-badge" id="urgBadge"></div></div>
<div class="lm-urgplot" id="urgPlot"></div>
<div class="lm-urglabels" id="urgLabels"></div>
</div>
<div class="lm-card2">
<div class="lm-card2-head"><div class="lm-card2-title" id="typeCardTitle"></div><div class="lm-badge" id="typeBadge"></div></div>
<div class="lm-donutwrap">
<div class="lm-donut">
<svg width="104" height="104" viewBox="0 0 112 112">
<circle cx="56" cy="56" r="42" fill="none" stroke="rgba(148,163,184,0.15)" stroke-width="14"></circle>
<g id="donutSegs"></g>
</svg>
<div class="lm-donut-center">
<div class="lm-donut-pct" id="donutPct"></div>
<div class="lm-donut-name" id="donutName"></div>
</div>
</div>
<div class="lm-legend" id="legend"></div>
</div>
</div>
</div>
<div class="lm-footer">
<div class="lm-footnote" id="footnote"></div>
<div class="lm-brand"><img src="https://liviate.com/liviate-mark.svg" alt=""><span>infrastructure, simplified.</span></div>
</div>
</div></div>
<script>
const LANG = "da";
const DEPTS = {"da":["Teknisk support","Produktsupport","IT-support","Kundeservice","Fakturering og betaling","Returnering og ombytning","Driftsforstyrrelser","Salg og førsalg","HR","Generelle henvendelser"],"de":["Technischer Support","Produktsupport","IT-Support","Kundenservice","Abrechnung und Zahlung","Rückgabe und Umtausch","Störungen und Wartung","Vertrieb und Vorverkauf","Personalwesen","Allgemeine Anfragen"],"en":["Technical Support","Product Support","IT Support","Customer Service","Billing and Payments","Returns and Exchanges","Outages and Maintenance","Sales and Pre-Sales","Human Resources","General Inquiry"]};
const T = {"da":{"kicker":"PRØV MODELLEN","decTime":"Beslutningstid","manual":"Manuel sortering","title":"Hvor skal mailen hen?","lead":"Vælg et eksempel eller skriv din egen kundemail. Modellen træffer tre beslutninger og viser, hvor sikker den er på hver af dem.","samplesLabel":"Eksempler","placeholder":"Skriv en kundemail …","run":"Sortér mail","running":"Sorterer …","threshold":"Sikkerhedsgrænse","dept":"Afdeling","urg":"Hastegrad","type":"Type","auto":"Automatisk","human":"Medarbejder","verdictAuto":"Sendes automatisk til","verdictHuman":"For usikker. Sendes til en medarbejder. Bedste gæt:","chars":"tegn","urgL":["Lav","Mellem","Høj"],"typeL":["Fejl","Forespørgsel","Problem","Ændring"],"footnote":"Demo: svarene er illustrative og beregnes i browseren, ikke af den trænede model.","samples":[["Dobbelt betaling","Hej. Jeg kan se, at mit abonnement for marts er trukket to gange på mit kort. Kan I tilbagebetale det ene beløb hurtigst muligt? Mvh. Maria"],["Tilbud på licenser","Hej, vi er 40 medarbejdere og overvejer at købe licenser til hele teamet. Kan I sende et tilbud og evt. booke en demo i næste uge?"],["Platformen er nede","Hele platformen er nede for os siden kl. 8 i morges. Ingen af vores medarbejdere kan logge ind. Det haster!"],["Printer efter opdatering","Printeren på 2. sal virker ikke længere efter gårsdagens opdatering. Den står bare og blinker."],["Bytte størrelse","Hej, jeg vil gerne bytte de sko, jeg modtog i går, til en størrelse 42. Hvordan gør jeg det?"]]},"de":{"kicker":"MODELL TESTEN","decTime":"Entscheidungszeit","manual":"Manuelle Sortierung","title":"Wohin gehört diese Mail?","lead":"Wählen Sie ein Beispiel oder schreiben Sie Ihre eigene Kundenmail. Das Modell trifft drei Entscheidungen und zeigt, wie sicher es sich jeweils ist.","samplesLabel":"Beispiele","placeholder":"Kundenmail eingeben …","run":"Mail sortieren","running":"Sortiert …","threshold":"Sicherheitsgrenze","dept":"Abteilung","urg":"Dringlichkeit","type":"Typ","auto":"Automatisch","human":"Mitarbeitende","verdictAuto":"Wird automatisch weitergeleitet an","verdictHuman":"Zu unsicher. Geht an Mitarbeitende. Bester Tipp:","chars":"Zeichen","urgL":["Niedrig","Mittel","Hoch"],"typeL":["Störung","Anfrage","Problem","Änderung"],"footnote":"Demo: Die Antworten sind illustrativ und werden im Browser berechnet, nicht vom trainierten Modell.","samples":[["Doppelte Abbuchung","Hallo, ich sehe, dass mein Abo für März zweimal von meiner Karte abgebucht wurde. Können Sie einen der Beträge so schnell wie möglich erstatten? Viele Grüße, Maria"],["Angebot für Lizenzen","Hallo, wir sind 40 Mitarbeitende und überlegen, Lizenzen für das ganze Team zu kaufen. Können Sie uns ein Angebot schicken und nächste Woche eine Demo einplanen?"],["Plattform ausgefallen","Die gesamte Plattform ist bei uns seit 8 Uhr ausgefallen. Niemand kann sich einloggen. Es ist dringend!"],["Drucker nach Update","Der Drucker im 2. Stock funktioniert seit dem gestrigen Update nicht mehr. Er blinkt nur noch."],["Größe umtauschen","Hallo, ich möchte die Schuhe, die ich gestern bekommen habe, gegen Größe 42 umtauschen. Wie mache ich das?"]]},"en":{"kicker":"TRY THE MODEL","decTime":"Decision time","manual":"Manual sorting","title":"Where should this email go?","lead":"Pick an example or write your own customer email. The model makes three decisions and shows how confident it is in each.","samplesLabel":"Examples","placeholder":"Type a customer email …","run":"Route email","running":"Routing …","threshold":"Confidence threshold","dept":"Department","urg":"Urgency","type":"Type","auto":"Automatic","human":"Agent","verdictAuto":"Routed automatically to","verdictHuman":"Not confident enough. Sent to an agent. Best guess:","chars":"characters","urgL":["Low","Medium","High"],"typeL":["Incident","Request","Problem","Change"],"footnote":"Demo: results are illustrative and computed in the browser, not by the trained model.","samples":[["Charged twice","Hi, I can see that my subscription for March has been charged to my card twice. Could you refund one of the payments as soon as possible? Best, Maria"],["Licence quote","Hi, we are a team of 40 and are considering buying licences for everyone. Could you send us a quote and maybe book a demo next week?"],["Platform down","The whole platform has been down for us since 8 this morning. None of our staff can log in. This is urgent!"],["Printer after update","The printer on the 2nd floor stopped working after yesterday’s update. It just keeps blinking."],["Exchange a size","Hi, I’d like to exchange the shoes I received yesterday for a size 42. How do I do that?"]]}};
const FIXED = [{"dept":[0.01,0.01,0.01,0.03,0.93,0.005,0.005,0.005,0,0.005],"urg":[0.04,0.08,0.88],"type":[0.05,0.03,0.91,0.01]},{"dept":[0.01,0.02,0,0.02,0.03,0,0,0.91,0,0.01],"urg":[0.72,0.24,0.04],"type":[0.02,0.94,0.02,0.02]},{"dept":[0.02,0,0.01,0.01,0,0,0.95,0,0,0.01],"urg":[0.01,0.03,0.96],"type":[0.93,0.01,0.05,0.01]},{"dept":[0.41,0.14,0.33,0.07,0,0,0.03,0,0,0.02],"urg":[0.18,0.61,0.21],"type":[0.78,0.06,0.12,0.04]},{"dept":[0,0.02,0,0.06,0.02,0.89,0,0,0,0.01],"urg":[0.66,0.3,0.04],"type":[0.03,0.92,0.01,0.04]}];
const KW = [["fejl","error","bug","crash","login","log ind","password","adgangskode","app","software","fehler","absturz","passwort","einloggen"],["produkt","product","funktion","feature","hvordan","how do i","wie kann","indstilling","setting","einstellung"],["printer","drucker","netværk","network","netzwerk","vpn","office","outlook","laptop","pc","wifi","wlan","computer"],["kundeservice","customer service","kundenservice","klage","complaint","beschwerde","levering","delivery","lieferung","ordre","order","bestellung","pakke","package","paket"],["faktura","invoice","rechnung","betaling","payment","zahlung","trukket","charged","abgebucht","refund","tilbagebetal","erstatt","beløb","betrag","amount","kort","card","karte"],["retur","return","bytte","exchange","umtausch","rückgabe","umtauschen","størrelse","size","größe","fortryd"],["nede","down","ausfall","ausgefallen","nedbrud","outage","vedligehold","maintenance","wartung","utilgængelig","unavailable","nicht erreichbar","störung"],["tilbud","quote","angebot","licens","licence","license","lizenz","køb","buy","purchase","kaufen","demo","pris","pricing","preis"],["ferie","vacation","urlaub","løn","salary","gehalt","ansættelse","job","kontrakt","contract","vertrag","sygemeld","sick","krank"],["spørgsmål","question","frage","information","åbningstid","opening hours","öffnungszeiten","adresse","address"]];
const URG_KW = [["når i får tid","no rush","keine eile","tilbud","quote","angebot","information","spørgsmål","question"],[],["haster","akut","hurtigst","straks","asap","urgent","immediately","sofort","dringend","nede","down","ausgefallen","kritisk","critical","kritisch","!"]];
const TYPE_KW = [["virker ikke","not working","funktioniert nicht","fejl","error","fehler","nede","down","crash","ausgefallen","blinker"],["kan i","could you","können sie","ønsker","would like","möchte","tilbud","quote","angebot","bitte","gerne","please","?"],["igen","again","wieder","gentagne","repeatedly","hver gang","every time","immer noch","still"],["ændre","change","ändern","opdater","update","flytte","move","opsige","cancel","kündigen"]];
const ICONS = ["M3 7h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H3zM3 11h16M7 5h14v10M6 15h3","M6 3h9l4 4v14H6zM15 3v4h4M9 12h7M9 16h5","M13 2L4 14h7l-1 8 9-12h-7z","M7 9V3h10v6M6 18H4v-7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v7h-2M7 14h10v7H7z","M4 8h13l-3-3M20 16H7l3 3"];
const TYPE_COLORS = ["#22D3EE","#3B82F6","#A5F3FC","#64748B"];
(function () {
const t = T[LANG];
const depts = DEPTS[LANG];
const en = LANG === 'en';
const pct = v => Math.round(v * 100) + (en ? '%' : ' %');
const fmtInt = n => n.toLocaleString(en ? 'en-US' : (LANG === 'de' ? 'de-DE' : 'da-DK'));
const hits = (s, list) => list.reduce((n, k) => n + (s.includes(k) ? 1 : 0), 0);
const softmax = (xs, k) => { const e = xs.map(x => Math.exp(x * k)); const z = e.reduce((a, b) => a + b, 0); return e.map(v => v / z); };
const norm = xs => { const z = xs.reduce((a, b) => a + b, 0) || 1; return xs.map(v => v / z); };
function classify(text) {
const s = ' ' + text.toLowerCase() + ' ';
return {
dept: softmax(KW.map((l, i) => (i === 9 ? 0.6 : 0.2) + 1.6 * hits(s, l)), 1.25),
urg: softmax([0.6 + 1.4 * hits(s, URG_KW[0]), 1.0, 0.4 + 1.3 * hits(s, URG_KW[2])], 1.3),
type: softmax([0.3 + 1.5 * hits(s, TYPE_KW[0]), 0.7 + 1.1 * hits(s, TYPE_KW[1]), 0.2 + 1.4 * hits(s, TYPE_KW[2]), 0.2 + 1.6 * hits(s, TYPE_KW[3])], 1.3),
};
}
// ---- one-time DOM construction (kept stable across renders so CSS transitions animate) ----
const $ = id => document.getElementById(id);
const card = $('card');
$('kicker').textContent = t.kicker;
$('title').textContent = t.title;
$('lead').textContent = t.lead;
$('samplesLabel').textContent = t.samplesLabel;
$('deptLabel').textContent = t.dept;
$('thresholdLabel').textContent = t.threshold;
$('decTimeLabel').textContent = t.decTime + ' ';
$('manualLabel').textContent = t.manual + ' ';
$('deptCardTitle').textContent = t.dept;
$('urgCardTitle').textContent = t.urg;
$('typeCardTitle').textContent = t.type;
$('footnote').textContent = t.footnote;
const textarea = $('textarea');
textarea.placeholder = t.placeholder;
const runBtn = $('runBtn');
const runLabel = $('runLabel');
const thresholdSlider = $('thresholdSlider');
// Sample chips (rebuilt each render -- plain buttons, nothing to animate/preserve focus for).
const chipsEl = $('chips');
// 10 department tiles, created once.
const tileEls = depts.map((name) => {
const div = document.createElement('div');
div.className = 'lm-tile';
const nameEl = document.createElement('div');
nameEl.className = 'lm-tile-name';
nameEl.textContent = name;
const pctEl = document.createElement('div');
pctEl.className = 'lm-tile-pct';
pctEl.textContent = '·';
div.appendChild(nameEl);
div.appendChild(pctEl);
$('tiles').appendChild(div);
return { root: div, nameEl, pctEl };
});
// 4 department detail bars, created once (top-4 by probability; which dept occupies which row
// changes between runs, so the row's NAME text updates, but the row/fill DOM nodes persist so
// the fill's width transition animates).
const deptBarEls = Array.from({ length: 4 }, () => {
const row = document.createElement('div');
row.className = 'lm-barrow';
const head = document.createElement('div');
head.className = 'lm-barrow-head';
const nameEl = document.createElement('div');
nameEl.className = 'lm-barrow-name';
const pctEl = document.createElement('div');
pctEl.className = 'lm-barrow-pct';
head.appendChild(nameEl);
head.appendChild(pctEl);
const track = document.createElement('div');
track.className = 'lm-bartrack';
const fill = document.createElement('div');
fill.className = 'lm-barfill';
track.appendChild(fill);
row.appendChild(head);
row.appendChild(track);
$('deptBars').appendChild(row);
return { nameEl, pctEl, fill };
});
// 3 urgency bars, created once. Names (Low/Medium/High) are fixed per language.
const urgBarEls = t.urgL.map((name) => {
const col = document.createElement('div');
col.className = 'lm-urgcol';
const pctEl = document.createElement('div');
pctEl.className = 'lm-urgpct';
const bar = document.createElement('div');
bar.className = 'lm-urgbar';
col.appendChild(pctEl);
col.appendChild(bar);
$('urgPlot').appendChild(col);
const label = document.createElement('div');
label.className = 'lm-urglabel';
label.textContent = name;
$('urgLabels').appendChild(label);
return { pctEl, bar };
});
// 4 donut segments + legend rows, created once.
const C = 2 * Math.PI * 42;
const svgNS = 'http://www.w3.org/2000/svg';
const donutSegs = t.typeL.map((_, i) => {
const circle = document.createElementNS(svgNS, 'circle');
circle.setAttribute('cx', '56'); circle.setAttribute('cy', '56'); circle.setAttribute('r', '42');
circle.setAttribute('fill', 'none'); circle.setAttribute('stroke-width', '14');
circle.setAttribute('stroke', TYPE_COLORS[i]);
circle.setAttribute('stroke-dasharray', '0 ' + C.toFixed(2));
circle.setAttribute('stroke-dashoffset', '0');
circle.style.transition = 'stroke-dasharray 700ms cubic-bezier(.2,.8,.2,1), stroke-dashoffset 700ms cubic-bezier(.2,.8,.2,1)';
$('donutSegs').appendChild(circle);
return circle;
});
const legendEls = t.typeL.map((name, i) => {
const row = document.createElement('div');
row.className = 'lm-legend-row';
const sw = document.createElement('div');
sw.className = 'lm-legend-swatch';
sw.style.background = TYPE_COLORS[i];
const nameEl = document.createElement('div');
nameEl.className = 'lm-legend-name';
nameEl.textContent = name;
const pctEl = document.createElement('div');
pctEl.className = 'lm-legend-pct';
row.appendChild(sw); row.appendChild(nameEl); row.appendChild(pctEl);
$('legend').appendChild(row);
return { pctEl };
});
// ---- state ----
let state = { sample: 0, text: null, phase: 'idle', scanIdx: -1, result: null, threshold: 80, ms: 0 };
let timers = [];
function currentText() {
return state.text ?? (state.sample != null ? t.samples[state.sample][1] : '');
}
// No resize reporting needed here: the blog's own embed harness auto-injects a
// ResizeObserver-based resize script into every embed fence's srcdoc (see BlogPost.tsx's
// EMBED_RESIZE_SCRIPT), generically, for any embed.
function render() {
const text = currentText();
const phase = state.phase;
const result = state.result;
const done = phase === 'done' && result;
const r = result || { dept: Array(10).fill(0), urg: [0, 0, 0], type: [0, 0, 0, 0] };
const win = r.dept.indexOf(Math.max(...r.dept));
const conf = v => v * 100 >= state.threshold;
const autoDept = conf(r.dept[win]);
// Textarea (never recreated -- only .value is ever set programmatically).
if (textarea.value !== text) textarea.value = text;
// Sample chips.
chipsEl.innerHTML = '';
t.samples.forEach(([title], i) => {
const on = state.sample === i && state.text == null;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'lm-chip' + (on ? ' on' : '');
btn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="' + ICONS[i] + '"></path></svg>' + title;
btn.addEventListener('click', () => { state = { ...state, sample: i, text: null }; run(); });
chipsEl.appendChild(btn);
});
$('charCount').textContent = text.length + ' ' + t.chars;
runLabel.textContent = phase === 'scan' ? t.running : t.run;
runBtn.disabled = !text.trim() || phase === 'scan';
runBtn.style.opacity = !text.trim() ? '0.4' : '1';
$('thresholdVal').textContent = state.threshold + (en ? '%' : ' %');
// Department tiles.
tileEls.forEach((el, i) => {
const scanning = phase === 'scan' && i === state.scanIdx;
const isWin = done && i === win;
let bg = 'rgba(11,21,38,0.55)', border = 'rgba(59,130,246,0.22)', color = done ? '#94A3B8' : '#CBD5E1', pctColor = '#64748B', borderStyle = 'solid', scale = 'scale(1)';
if (scanning) { bg = 'rgba(34,211,238,0.22)'; border = '#22D3EE'; color = '#F8FAFC'; }
if (isWin && autoDept) { bg = '#22D3EE'; border = '#22D3EE'; color = '#0F172A'; pctColor = '#0F172A'; scale = 'scale(1.03)'; }
if (isWin && !autoDept) { bg = 'rgba(148,163,184,0.16)'; border = '#CBD5E1'; color = '#F8FAFC'; pctColor = '#CBD5E1'; borderStyle = 'dashed'; scale = 'scale(1.03)'; }
el.root.style.background = bg;
el.root.style.border = '1.5px ' + borderStyle + ' ' + border;
el.root.style.transform = scale;
el.nameEl.style.color = color;
el.pctEl.style.color = pctColor;
el.pctEl.textContent = done ? pct(r.dept[i]) : '·';
});
// Verdict + timing.
const verdictEl = $('verdict');
verdictEl.style.opacity = done ? '1' : '0.35';
verdictEl.style.transform = done ? 'translateY(0)' : 'translateY(4px)';
verdictEl.style.background = done && autoDept ? 'rgba(34,211,238,0.10)' : 'rgba(148,163,184,0.08)';
verdictEl.style.borderColor = done && autoDept ? 'rgba(34,211,238,0.6)' : 'rgba(148,163,184,0.35)';
const verdictColor = done && autoDept ? '#22D3EE' : '#CBD5E1';
$('verdictDot').style.background = verdictColor;
$('verdictPct').style.color = verdictColor;
$('verdictLead').firstChild.textContent = (done ? (autoDept ? t.verdictAuto : t.verdictHuman) : t.running) + ' ';
$('verdictTarget').textContent = done ? depts[win] : '';
$('verdictPct').textContent = done ? pct(r.dept[win]) : '';
$('timing').style.opacity = done ? '1' : '0.35';
$('msVal').textContent = done ? state.ms + ' ms' : '…';
$('manualVal').textContent = en ? '~30 s' : '~30 sek.';
$('factorVal').textContent = done ? '≈ ' + fmtInt(Math.round(30000 / state.ms)) + '×' : '';
// Detail cards.
const badge = v => done ? (conf(v) ? { label: t.auto, bg: 'rgba(34,211,238,0.16)', color: '#67E8F9' } : { label: t.human, bg: 'rgba(148,163,184,0.18)', color: '#CBD5E1' }) : { label: '…', bg: 'rgba(148,163,184,0.1)', color: '#64748B' };
const applyBadge = (id, b) => { const el = $(id); el.textContent = b.label; el.style.background = b.bg; el.style.color = b.color; };
applyBadge('deptBadge', badge(r.dept[win]));
const order = r.dept.map((v, i) => [v, i]).sort((a, b) => b[0] - a[0]).slice(0, 4);
deptBarEls.forEach((el, k) => {
const [v, i] = order[k];
const isTop = k === 0;
el.nameEl.textContent = depts[i];
el.pctEl.textContent = done ? pct(v) : '';
el.nameEl.style.color = isTop ? '#F8FAFC' : '#94A3B8';
el.pctEl.style.color = isTop ? '#F8FAFC' : '#94A3B8';
el.fill.style.width = done ? (v * 100).toFixed(1) + '%' : '0%';
el.fill.style.background = isTop ? '#22D3EE' : '#475569';
});
const urgTop = r.urg.indexOf(Math.max(...r.urg));
applyBadge('urgBadge', badge(r.urg[urgTop]));
urgBarEls.forEach((el, i) => {
const isTop = i === urgTop;
el.pctEl.textContent = done ? pct(r.urg[i]) : '';
el.pctEl.style.color = isTop ? '#F8FAFC' : '#94A3B8';
el.bar.style.height = done ? Math.max(r.urg[i] * 88, 3).toFixed(1) + 'px' : '3px';
el.bar.style.background = isTop ? '#22D3EE' : '#475569';
});
const typeTopI = r.type.indexOf(Math.max(...r.type));
applyBadge('typeBadge', badge(r.type[typeTopI]));
let acc = 0;
donutSegs.forEach((circle, i) => {
const v = r.type[i];
const len = done ? v * C : 0;
circle.setAttribute('stroke-dasharray', Math.max(len - 1.5, 0).toFixed(2) + ' ' + C.toFixed(2));
circle.setAttribute('stroke-dashoffset', (-acc).toFixed(2));
acc += len;
});
$('donutPct').textContent = done ? pct(r.type[typeTopI]) : '';
$('donutName').textContent = done ? t.typeL[typeTopI] : '';
legendEls.forEach((el, i) => { el.pctEl.textContent = done ? pct(r.type[i]) : ''; });
}
function run() {
const text = currentText().trim();
if (!text) { render(); return; }
timers.forEach(clearTimeout); timers = [];
const raw = (state.sample != null && state.text == null) ? FIXED[state.sample] : classify(text);
const result = { dept: norm(raw.dept), urg: norm(raw.urg), type: norm(raw.type) };
const win = result.dept.indexOf(Math.max(...result.dept));
const steps = 13, path = [];
let prev = -1;
for (let k = 0; k < steps - 1; k++) {
let n;
do { n = Math.floor(Math.random() * 10); } while (n === prev || n === win);
path.push(n); prev = n;
}
path.push(win);
const ms = Math.round(7 + text.length / 45 + Math.random() * 6);
state = { ...state, phase: 'scan', scanIdx: path[0], result, ms };
render();
let tAcc = 0;
path.forEach((idx, k) => {
tAcc += 40 + k * k * 2.2;
timers.push(setTimeout(() => { state = { ...state, scanIdx: idx }; render(); }, tAcc));
});
timers.push(setTimeout(() => { state = { ...state, phase: 'done' }; render(); }, tAcc + 260));
}
textarea.addEventListener('input', (e) => {
state = { ...state, text: e.target.value, sample: null };
render();
});
runBtn.addEventListener('click', () => run());
thresholdSlider.value = String(state.threshold);
thresholdSlider.addEventListener('input', (e) => {
state = { ...state, threshold: +e.target.value };
render();
});
render();
run();
})();
</script>
</body>
</html>
