Gå til innhold

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.

Åpne i fullskjerm

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.

  • Structured kommer aldri dit — banen er ren C# og RulesEngine.
  • Judgment går rett dit fra dispatch. Det er den «rene» KI-vurderingen: 35 av 89 regler.
  • Hybrid starter alltid deterministisk, og havner der bare når det deterministiske svaret ble NotMet og fallbackWhen sier 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 Documents eller .Text — de leser typede felt (facts.Tilbud, facts.Soknad, facts.Registerfakta). Teknisk kunne de: hele facts-objektet sendes inn som RuleParameter. Ingen gjør det i dag.
  • KI-vurdering får hele markdownen. JudgmentEvaluator serialiserer hele Facts-objektet — inkludert Documents[].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:

R-UAM-VALUTA-NOK (forkortet)
{
  "WorkflowName": "UtslippsfrieAnleggsmaskinerR32",
  "Rules": [
    {
      "RuleName": "AllOffersInNok",
      "SuccessEvent": "Met",
      "ErrorMessage": "Ett eller flere tilbud er prissatt i annen valuta enn NOK.",
      "RuleExpressionType": "LambdaExpression",
      "Expression": "facts.Tilbud.All(t => t.TotalSalesCurrency.Trim().ToUpper() == \"NOK\" || …)"
    }
  ]
}

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 over facts.Tilbud passerer når saken ikke har noen tilbud. Derfor ligger det en «detektert»-gate foran (R-UAM-TILBUD-DETEKTERT), og de itererende reglene har depends_on til den.
  • Uttrykket er ikke typesjekket. Et feltnavn som ikke finnes på Facts-typen oppdages først ved kjøring, som Verdict.Error. ActivateRuleValidator sjekker 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
1
2
3
4
public sealed record HybridDefinition(
    string Deterministic,   // RulesEngine-Workflow-JSON — kjøres først
    string FallbackWhen,    // lambda over facts — gater LLM-kallet
    JudgmentDefinition Judgment);

HybridEvaluator kjører i denne rekkefølgen:

  1. Deterministisk tier kjøres alltid.
  2. Ble det Met? Da er det svaret — med ett unntak: er konfidensen Low (svakt lest CU-felt), gjøres et uavhengig LLM-kall for å bekrefte. Enig ⇒ konfidens heves til High og avgjortAv: "deterministisk+llm-bekreftet". Uenig eller LLM-feil ⇒ det deterministiske Met står urørt.
  3. Ble det ikke Met? Da evalueres FallbackWhen som en egen ett-regels gate. Er gaten ikke Met, er svaret et ekte deterministisk NotMet — og LLM-en kalles aldri.
  4. Er gaten Met? Først da kjøres Judgment-delen, og resultatet merkes avgjortAv: "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
1
2
3
facts.Tilbud.Any(t =>
    (t.VehicleType == "elektrisk" || t.VehicleType == "hydrogen" || t.VehicleType == null)
    && t.MachineWeightKg == null)

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
1
2
3
4
evaluering: llm
prompt: |
  Du vurderer om … Svar KUN med ett JSON-objekt på denne formen: {…}
utdata: MaskinTerskelVurdering

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 til Verdict.Error, ikke NotMet.
  • IJudgmentUtfall — utdatatypen regner ut sitt eget Verdict, slik at den kan svare et ærlig NotAssessed.

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
1
2
3
4
record RegelEvalueringsResultat(
    RegelId RuleId, int RuleVersion, RegelType Kind, Verdict Verdict,
    string Reason, JsonElement? ReasonDetail, string? AiCallAuditRef,
    string? Modell = null, Overstyringsspor? Overstyring = null);

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
1
2
3
- from: [R-UAM-VALUTA-NOK]
  to: [R-UAM-MVA-SAMSVAR]
  type: direct

To ting er verdt å vite:

  • fan_out-grener kjører parallelt, hver i sitt eget DI-scope — DbContext er ikke trådsikker. Den sekvensielle ryggraden deler ett scope.
  • depends_on sparer penger. Blokkeres en node av en uoppfylt forutsetning, kortsluttes den til NotAssessed og evalueres aldri. Derfor henger R-UAM-MVA-SAMSVAR på 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
1
2
3
4
5
6
7
SakSeleksjon → FetchDocuments → ExtractDocument → Evaluation
                                                      │
                                          RunCaseWorkflowJob
                                                      │
                                    RunCaseWorkflowCommand (Mediator)
                                                      │
                                            WorkflowGraphRunner

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
1
2
3
4
Kan spørsmålet besvares ved å sammenligne verdier som allerede finnes i Facts?
├─ Ja, alltid            → deterministisk
├─ Ja, men verdien lar seg ikke alltid lese ut av dokumentet → hybrid
└─ Nei, det krever at noen leser og tolker dokumentteksten   → llm

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).

  1. Rediger docs/sourcefiles/arkitektur/kai-regelkjoring.workflow.json.
  2. Regenerer artefakten med archify-skillet:

    Bash
    node .claude/skills/archify/bin/archify.mjs deliver workflow docs/sourcefiles/arkitektur/kai-regelkjoring.workflow.json docs/publisert/arkitektur/diagrammer/kai-regelkjoring.html --quality showcase
    
  3. Commit både spec-en og den regenererte HTML-fila.

Videre lesning⚓︎