Regelkjøring — deterministisk, hybrid og KI-vurdering⚓︎
Content Understanding svarer på hva står det i dokumentet. Regelmotoren svarer på
hva betyr det for saken. Denne siden er brua mellom de to: hvordan fakta fra CU blir
til et Regelresultat, og hva som faktisk skjer i de tre regeltypene.
Interaktivt
Diagrammet har fire guidede views (fra CU-fakta, deterministisk, hybrid, KI-vurdering), søk, fokus på enkeltnoder og eksport til PNG/SVG/WebM.
Diagraminnholdet er norsk, men viewer-menyene (Light/Dark, Present, Export) eies
av rendereren og er på engelsk.
Hvor kalles LLM-en?
Bare ett sted: JudgmentEvaluator, nederste bane i diagrammet.
Structuredkommer aldri dit — banen er ren C# og RulesEngine.Judgmentgår rett dit fra dispatch. Det er den «rene» KI-vurderingen: 35 av 89 regler.Hybridstarter alltid deterministisk, og havner der bare når det deterministiske svaret bleNotMetogfallbackWhensier at grunnlaget var tvetydig — den stiplede kanten i diagrammet.
Kommer du fra Content Understanding?⚓︎
Da kjenner du venstre halvdel allerede. Det som er nytt starter ved FactsExtractor:
| Du kjenner | Det regelmotoren gjør med det |
|---|---|
analyzers/<navn>.json definerer felter |
Feltene havner i en typet C#-record pr. virkemiddel (UtslippsfrieAnleggsmaskinerFacts, TungeNullutslippFacts, …), ikke i en felles faktakatalog |
Fakta-blober under …/facts/{analyzerId}/ |
BlobFactsRepository leser dem og bygger FaktaSegment-er som ekstraktoren setter sammen |
| CU-konfidens pr. felt | Brukes av ReasonDetailTolker.UtledConfidence, og kan trigge LLM-bekreftelse i hybridregler |
Facts-objektet er altså ikke rå CU-output — det er et typet snapshot som regelkjøringen evalueres mot, og CU er bare den ene av to kilder som skriver inn i det.
CU leverer to ting, ikke bare felt⚓︎
Dette er lett å overse: fakta-bloben er ikke bare uttrekte feltverdier. Classifier-svaret
gir to slags innhold, og CuFactsSelector beholder begge i samme blob:
| CU-respons | Hva | Hvem bruker det |
|---|---|---|
contents[0] |
Hele dokumentets markdown fra base-analyzeren — alle sider, ingen kategori | KI-vurderingsregler (og hybrid sin LLM-fallback) |
contents[1..n] |
Kategorisegmentene classifieren skar ut, med category + fields |
Deterministiske regler |
Begge lander i FaktaSegment (Markdown + Fields) og videre på Facts-recorden: feltene som
typede properties, markdownen som Documents[].Text. Ekstraktoren velger markdownen fra det
segmentet som spenner flest sider — altså det kategoriløse contents[0] — og kjører den gjennom
DokumenttekstRens før den legges på recorden.
Ikke drop contents[0]
Den ble en gang fjernet i den tro at den var classifier-metadata. For flersides tilbud
forsvant da hele spesifikasjonen, og bare siden classifieren rakk å kategorisere ble igjen
(AB#20785). Kommentaren i CuFactsSelector sier det rett ut: markdown-baserte regler
trenger hele dokumentet; felt-baserte regler bruker kategorisegmentenes fields.
Hvem leser hva, konkret:
- Deterministiske regeluttrykk leser aldri dokumentteksten. Ingen av de 60 seedede
RulesEngine-uttrykkene refererer
Documentseller.Text— de leser typede felt (facts.Tilbud,facts.Soknad,facts.Registerfakta). Teknisk kunne de: helefacts-objektet sendes inn somRuleParameter. Ingen gjør det i dag. - KI-vurdering får hele markdownen.
JudgmentEvaluatorserialiserer hele Facts-objektet — inkludertDocuments[].Text— som brukermelding. Det er derfor promptene sier «Inputen er et JSON-objekt med saksfakta, inkludert «Documents» … «Text» (markdown)».
Ingen trunkering
Det finnes ingen lengdebegrensning i denne stien. Hele den rensede dokument-markdownen går
til modellen med mindre regelen selv snevrer inn med krever_fakta eller
krever_dokumenttyper — begge er opt-in, og default sender alt. Det er den viktigste
token-kostnadsdriveren i regelmotoren.
Unntaket som gjør skillet uskarpt: MaskinspesifikasjonParser
Én deterministisk sti leser likevel markdown — men ved ekstraksjon, ikke ved
regelevaluering. UtslippsfrieAnleggsmaskinerFactsExtractor kjører
MaskinspesifikasjonParser.EgenvektKg() / .NominellEffektKw() over DocumentRef.Text
som et regex-sikkerhetsnett, men bare når CUs egne MachineWeightKg /
NominalMotorPowerKw er null eller usannsynlige (AB#21706: CU klassifiserte
spesifikasjonssiden som other, så ingen leaf-analyzer kjørte og feltet kom tomt tilbake
selv om verdien stod i teksten).
Verdien pakkes i et NumberField med bevisst lavere konfidens (0.5) og blir et helt
vanlig typet felt. Den deterministiske regelen som senere leser det, vet ikke at det kom
fra markdown. Deterministiske regler er altså transitivt avhengige av dokumentteksten —
de leser den bare aldri selv.
Fakta kommer fra to kilder⚓︎
CU er ikke eneste faktakilde
Regelmotoren evaluerer mot dokumentfakta og registerfakta på samme Facts-record. Glemmer du den andre, ser en regel ut til å mangle data den faktisk har.
| Kilde | Hva | Vei inn |
|---|---|---|
| Dokumentfakta | Det CU leste ut av saksdokumentene | BlobFactsRepository → fakta-blober → virkemiddel-ekstraktoren |
| Registerfakta | Autoritative felt fra ERS-registeret — Soknadsdato, SoknadTotalKostnadGodkjent, SoknadReferanseKostnadGodkjent |
Registerfakta.FraSak(sak, kostnad) projisert fra Sak-replikaen i Postgres |
Registerfaktaene kommer opprinnelig fra Mimir (production.gold.*, bygget fra ERS), men
regelkjøringen leser dem ikke fra Mimir direkte — den leser Sak-replikaen i Postgres, som
ReplikaSync fyller én gang i døgnet. Gold er immutable etter morgenens ETL, så hyppigere
synk finner ingenting nytt. Se Kildesystemer og
ADR-016.
De slås aldri sammen
Registerfakta overskriver aldri dokumentfakta, og omvendt — de lever side om side
(ADR-017). Avvik mellom dem er ikke
støy som skal ryddes bort i faktalaget; det er noe en regel skal fange.
R-UAM-SOKNADSDATO-SAMSVAR finnes nettopp for å sammenligne søknadsdatoen CU leste i
dokumentet mot Registerfakta.Soknadsdato fra ERS.
Navnene på registerfeltene er bevisst ASCII (Soknadsdato, ikke Søknadsdato) fordi de
refereres direkte i RulesEngine-lambdauttrykk.
Én bryter i YAML avgjør regeltypen⚓︎
En regel er én YAML-fil under src/Enova.Kai.Application/Rules/<Virkemiddel>/regel-*.yaml.
Feltet evaluering er hele valget:
evaluering: |
RegelType (Regel.Kind) |
Evaluator | Antall i dag |
|---|---|---|---|
deterministisk (default) |
Structured |
RulesEngineEvaluator |
49 |
hybrid |
Hybrid |
HybridEvaluator |
5 |
llm |
Judgment |
JudgmentEvaluator |
35 |
RuleConfigurationSeeder mapper evaluering → RegelType ved seeding, og
KindDispatchingRuleEvaluator er den eneste dispatch-en i runtime — en switch på
regel.Kind. Alle tre grener returnerer samme RegelEvalueringsResultat.
Fordelingen pr. virkemiddel (89 regler totalt)
| Virkemiddel | Deterministisk | Hybrid | KI-vurdering |
|---|---|---|---|
| Bedriftslading | 12 | 0 | 3 |
| Energibygg attest | 7 | 0 | 3 |
| Energibygg kostnad | 12 | 0 | 3 |
| Kartlegging borettslag | 1 | 0 | 8 |
| Kartlegging yrkesbygg | 1 | 0 | 11 |
| Tunge nullutslipp | 9 | 1 | 4 |
| Utslippsfrie anleggsmaskiner | 7 | 4 | 3 |
Kartleggingsvirkemidlene er nesten rent KI-vurdering fordi kravene gjelder innholdet i en rapport, ikke tall som kan sammenlignes. Anleggsmaskiner har flest hybridregler fordi terskelverdiene finnes som tall — de er bare ikke alltid lesbare.
Deterministisk — RegelType.Structured⚓︎
DefinitionJson er en Microsoft RulesEngine-Workflow (pakken RulesEngine, se
Directory.Packages.props). Uttrykket er en System.Linq.Dynamic.Core-lambda som
evalueres mot én RuleParameter med navnet facts:
Aggregering i RulesEngineEvaluator: kastet exception under evaluering → Verdict.Error;
minst én feilet regel → Verdict.NotMet med sammenslåtte ErrorMessage-er; alle passerer →
Verdict.Met.
To feller som ikke gir kompileringsfeil
.All(…)er vakuøst sant på tom liste. En regel som itererer overfacts.Tilbudpasserer når saken ikke har noen tilbud. Derfor ligger det en «detektert»-gate foran (R-UAM-TILBUD-DETEKTERT), og de itererende reglene hardepends_ontil den.- Uttrykket er ikke typesjekket. Et feltnavn som ikke finnes på Facts-typen oppdages
først ved kjøring, som
Verdict.Error.ActivateRuleValidatorsjekker det ikke (ADR-013, kjent begrensning 1).
Et deterministisk Met kan i tillegg nedgraderes til NotAssessed av ProvenanceGate når
regelens Inputkontrakt krever felt som CU-provenansen aldri fylte — bedre «ikke vurdert»
enn et «oppfylt» bygget på tomme felt.
Hybrid — RegelType.Hybrid⚓︎
Hybrid er ikke «litt LLM». Det er deterministisk først, LLM bare når det deterministiske
svaret er tvetydig. DefinitionJson har tre deler:
| src/Enova.Kai.Application/Rules/Hybrid/HybridDefinition.cs | |
|---|---|
HybridEvaluator kjører i denne rekkefølgen:
- Deterministisk tier kjøres alltid.
- Ble det
Met? Da er det svaret — med ett unntak: er konfidensenLow(svakt lest CU-felt), gjøres et uavhengig LLM-kall for å bekrefte. Enig ⇒ konfidens heves tilHighogavgjortAv: "deterministisk+llm-bekreftet". Uenig eller LLM-feil ⇒ det deterministiskeMetstår urørt. - Ble det ikke
Met? Da evalueresFallbackWhensom en egen ett-regels gate. Er gaten ikkeMet, er svaret et ekte deterministiskNotMet— og LLM-en kalles aldri. - Er gaten
Met? Først da kjøresJudgment-delen, og resultatet merkesavgjortAv: "llm-fallback".
Invarianten som gjør hybrid trygg
Et deterministisk Met blir aldri nedgradert av LLM-en. Den kan bare bekrefte et
svakt lest Met, eller steppe inn der det deterministiske svaret var NotMet og
gaten sier at grunnlaget var tvetydig.
Hybridregelen skriver alltid sin egen rad
Alle fire utganger over ender i Restamp(regel, …), som setter
RuleId/RuleVersion/Kind tilbake til hybridregelens egne. Raden som havner i
Regelresultat har derfor alltid Kind = Hybrid — aldri Structured eller Judgment,
uansett hvilken tier som faktisk avgjorde.
De to indre evaluatorene kalles på syntetiske Regel-objekter som aldri persisteres, og
HybridEvaluator går utenom KindDispatchingRuleEvaluator — den injiserer
RulesEngineEvaluator og JudgmentEvaluator direkte. Vil du vite hvilken vei som avgjorde,
er det avgjortAv i ReasonDetail som svarer, ikke Kind.
Gaten er poenget — den skiller «leste en verdi, den var for lav» fra «klarte ikke lese verdien»:
| VektMinimumFallbackWhen — kun manglende verdi trigger LLM | |
|---|---|
Leste vi 1800 kg, er NotMet et ekte utfall og LLM-en er unødvendig. Er MachineWeightKg
null, kan vekten stå i dokumentteksten i et format CU ikke traff — da er det verdt et
LLM-kall. ReasonDetailTolker.LøsHybrid leser avgjortAv tilbake ut av ReasonDetail når
flaten skal vise hvilken vei som faktisk avgjorde saken.
KI-vurdering — RegelType.Judgment⚓︎
| Feltene som gjelder i regel-*.yaml | |
|---|---|
Prompten ligger inline i regelen, ikke i et eget promptregister — én kilde, og den
versjoneres sammen med regelen via Regel.Version (ADR-013).
JudgmentEvaluator bygger kallet slik:
| Del | Hva |
|---|---|
| Systemmelding | Regelens prompt, ordrett |
| Brukermelding | Hele Facts-objektet serialisert til JSON — inkludert Documents[].Text, altså CU-markdownen — eventuelt snevret av RequiredFacts / RequiredDocumentKinds |
ResponseFormat |
ChatResponseFormat.ForJsonSchema(outputType) — modellen er skjema-tvunget, ikke bare bedt pent om JSON |
Temperature |
0 |
| Klient | IChatClient mot Azure AI Foundry, deployment gpt-4.1 (Providers:AzureAiFoundry:CompletionDeployment) |
Hvorfor fakta er en egen melding
Dokumenttekst er søker-kontrollert input. Den flettes derfor aldri inn i instruksjonene — prompt og data er separate meldinger, og promptene sier i tillegg eksplisitt at dokumentinnhold skal behandles som data, ikke instruksjoner.
utdata slår opp en type i JudgmentOutputRegistry. Typen implementerer IJudgmentOutput
(IsMet, Reason), og kan i tillegg implementere:
IJudgmentFaktakontroll— modellens egen dekningssjekk. Har den ikke vurdert nok fakta, degraderes svaret tilVerdict.Error, ikkeNotMet.IJudgmentUtfall— utdatatypen regner ut sitt egetVerdict, slik at den kan svare et ærligNotAssessed.
Et gjennomgående mønster er LLM ekstraherer, C# bestemmer: TilbudGyldighet lar modellen
hente ut gyldighetsperioder og sitat pr. tilbud, mens IsMet regnes ut deterministisk i C#
fra det den fant. Samme deling brukes i DrivlinjeKlassifisering.
Degraderer, kaster aldri
Tomt svar, uparsebar JSON eller for tynn faktadekning gir Verdict.Error — aldri en
exception. Evalueringen looper over alle regler i saken, og en kastet exception ville
stoppet persisteringen av hele Regelresultat-raden.
Felles resultatform⚓︎
Uansett regeltype:
| src/Enova.Kai.Domain/Rules/RegelEvalueringsResultat.cs | |
|---|---|
Verdict har fem verdier, og de tre siste er ikke synonymer:
| Verdi | Betyr |
|---|---|
Met / NotMet |
Regelen ble evaluert og ga et utfall |
NotApplicable |
Regelen gjelder ikke denne saken |
NotAssessed |
Regelen gjelder, men en depends_on-forutsetning var ikke oppfylt — eller provenansen manglet |
Error |
Evalueringen feilet (uttrykksfeil, ubrukelig LLM-svar) |
AiCallAuditRef (respons-id) og Modell fylles kun på LLM-veiene. Per kjøring lagrer
Regelresultat i tillegg FactsSchemaVersion, FactsSnapshotHash (SHA-256 av de
serialiserte faktaene) og EngineVersion — det er de tre feltene som gjør en kjøring
etterprøvbar i ettertid.
Rekkefølgen reglene kjører i⚓︎
workflow.yaml pr. virkemiddel er en graf av regelnoder som WorkflowGraphRunner går
gjennom. Kanttyper: direct, conditional, fan_out, fan_in. Sentinel-nodene heter
__start__ og __end__.
| Utdrag fra UtslippsfrieAnleggsmaskiner/workflow.yaml | |
|---|---|
To ting er verdt å vite:
fan_out-grener kjører parallelt, hver i sitt eget DI-scope —DbContexter ikke trådsikker. Den sekvensielle ryggraden deler ett scope.depends_onsparer penger. Blokkeres en node av en uoppfylt forutsetning, kortsluttes den tilNotAssessedog evalueres aldri. Derfor hengerR-UAM-MVA-SAMSVARpå valutaregelen: finner valutaregelen utenlandsk valuta, kjøres den dyre hybrid-/LLM- evalueringen aldri.
I tillegg gater ERS-status pr. node (ers_statuser i regelen) hvilke regler som er aktuelle
i stadiet saken står i.
Den valgfrie nøkkelen dokumentpakke: søknad | sluttrapport | begge overstyrer hvilken
dokumentpakke regelen får dokumenter fra — uavhengig av ERS-status. Uten nøkkelen følger
pakkevalget stadiet (ErsStatuspolicy.DokumentpakkeFor). Brukes av de sju
sluttrapporteringsreglene (ers_statuser: [5]) som likevel leser søknadsdokumentet, se
ADR-022.
Hva som trigger en kjøring⚓︎
Regelkjøring er siste steg i worker-pipelinen, som lenkede TickerQ-jobber:
| Text Only | |
|---|---|
RunCaseWorkflowJob er den durable inngangen — også for manuell re-trigger og
BulkReevaluationJob. Se Bakgrunnsjobber og
ADR-012.
Det finnes en andre inngang, EvaluateCaseCommand, som kjører alle anvendelige regler som
flat liste uten graf. Den skiller seg på EngineVersion (RulesEngine/6.0.0 mot
WorkflowGraphRunner/1.0.0) og brukes programmatisk, ikke fra pipelinen.
Drift av LLM-kallene⚓︎
Dekoratørstakken rundt IChatClient, innerst til ytterst:
AzureOpenAIClient → OpenTelemetryChatClient (token-telemetri) → ThrottlingChatClient
(MaxConcurrentCompletions) → TokenBudgetChatClient (TPM-budsjett, kun når
TokensPerMinuteBudget > 0) → RetryingChatClient.
RetryingChatClient retryer 429/503 inntil 6 forsøk, med eksponentiell backoff eller
serverens retry-after-header — den lengste av de to.
Ingen cache
Det finnes ingen respons-cache for LLM-kall. En re-kjøring av en Judgment-node er et
nytt kall med ny kostnad. Det er også grunnen til at en HITL-pause er dyr i dag: en
gjenopptakelse starter på __start__ og re-eksekverer hele grafen
(ADR-013, kjente begrensninger 2–3).
Hvilken type skal jeg velge?⚓︎
| Text Only | |
|---|---|
Hybrid er riktig valg når kriteriet er skarpt men inputen er upålitelig — «minst 2000 kg»
er ikke en skjønnsvurdering, men vekten står ikke alltid i et felt CU treffer. Er selve
kriteriet skjønn («er dette i realiteten en signert kjøpekontrakt?»), er det llm.
Praktisk oppskrift for å legge til eller endre en regel ligger i regelmotor-regel-skillet
i repoet.
Slik oppdaterer du diagrammet⚓︎
Diagrammet genereres fra en typet spec i repoet — ikke rediger HTML-fila direkte (den er ~700 kB generert output).
- Rediger
docs/sourcefiles/arkitektur/kai-regelkjoring.workflow.json. -
Regenerer artefakten med
archify-skillet:Bash -
Commit både spec-en og den regenererte HTML-fila.
Videre lesning⚓︎
- ADR-013 — hybrid regelmotor og workflow-orkestrering
- CU-analyzere — fra portal via git til worker
- Runtime-arkitektur — hvor regelmotoren sitter i systemet
- Bakgrunnsjobber — pipelinen som trigger kjøringen