Reading an invoice
One scanned invoice, one rule set, one result. This page runs the whole block end to end: convert the
OCR output, inspect it, extract the header fields and the line-item table with rules, validate what
was extracted, and then run the checks again over HTTP against
rules.svc. Every command, rule and output below was
executed against the shipped CLI; the console blocks are what it printed, with paths shortened.
What you need
Section titled “What you need”| You need | Note |
|---|---|
The kis CLI | The entry point for every local command here. |
| The rules plugin | kis rules, kis ocr, kis bbox and kis hocr2bbox are proxy commands — see below. |
| OCR output for the invoice | An hOCR file from your recogniser. The block ingests OCR; it does not run it. |
kis rules, kis ocr, kis bbox and kis hocr2bbox are dispatched to a separately installed plugin
binary. Without it every command on this page prints Error: 'ocr' is not installed. and exits
non-zero — the command name you typed is interpolated. Search order and flags:
the CLI.
The project:
invoice/ rules/ extract.grl # header fields, totals, line items validate.grl # the three checks, against the page pages/ invoice.hocr # OCR output for the scan invoice.bbox # written by kis hocr2bbox invoice.json # the facts for this page invoice-b.hocr # a second scan, used in step 8 invoice-b.json checks.grl # the same three checks, against facts (step 9) policy.json # the currency allow list (step 9)checks.grl sits outside rules/ on purpose: -r rules/ loads every .grl in the directory into
one rule set, and the two versions of the checks would both fire. extracted/ appears in step 9,
written by the CLI.
The invoice
Section titled “The invoice”The scan carries a header block, a line-item table and a totals block:
NORTHWIND SUPPLY CO12 Harbour Road, Bristol BS1 4RN
Invoice Number: INV-2024-0042Invoice Date: 2024-03-14Currency: EURBill To: Acme Robotics GmbH
Line ItemsDescription Qty Unit Price AmountWidget A 10 45.00 450.00Widget B 5 60.00 300.00Cable Set 20 12.50 250.00
Subtotal: 1,000.00 Tax 20%: 200.00 Total Due: 1,200.00
Payment terms: 30 daysIts hOCR is ordinary recogniser output — a page box, then lines, then words with a per-word box and confidence. The first line of the fifteen:
<div class='ocr_page' id='page_1' title='image "invoice.png"; bbox 0 0 1275 1650; ppageno 0'> <div class='ocr_carea' id='block_1_1' title='bbox 100 110 720 145'> <p class='ocr_par' id='par_1_1' lang='eng' title='bbox 100 110 720 145'> <span class='ocr_line' id='line_1_1' title='bbox 100 110 720 145; baseline 0 -6'> <span class='ocrx_word' id='word_1_1' title='bbox 100 110 420 145; x_wconf 96'>NORTHWIND</span> <span class='ocrx_word' id='word_1_2' title='bbox 440 110 620 145; x_wconf 95'>SUPPLY</span> <span class='ocrx_word' id='word_1_3' title='bbox 640 110 720 145; x_wconf 96'>CO</span> </span> </p> </div> <!-- fourteen more ocr_carea blocks, one per printed line --></div>The remaining fourteen lines follow the same shape. Every word carries its own bbox and x_wconf;
the words are laid out in three x-bands — labels near x 100, the line-item columns ending at x 470,
790 and 1010, and the totals labels near x 700 with their amounts right-aligned at x 1010.
Step 1 — convert the OCR output
Section titled “Step 1 — convert the OCR output”kis hocr2bbox -i pages/invoice.hocr -l info2026-07-27T17:46:41+05:30 INF successfully converted hocr to bbox app=kis.ai input=pages/invoice.hocr output=pages/invoice.bbox words=56Success is logged at info, and the default log level is error, so without -l info the command
prints nothing at all. It writes pages/invoice.bbox beside the input — one page, 56 words, each
with its box and confidence:
{ "pages": [ { "number": 1, "width": 1275, "height": 1650, "words": [ { "text": "NORTHWIND", "box": { "x0": 100, "y0": 110, "x1": 420, "y1": 145 }, "confidence": 0.96, "page": 1, "line_id": 1 } ] } ]}The other 55 words are elided, and so are the page’s lines and paragraphs arrays, which the
converter writes from the ocr_line and ocr_par nesting. That file is the document input for
everything that follows. Format details and the cleanup flags are on
OCR ingestion; the field-by-field reference is
the OCR API.
Step 2 — look at the page before writing a rule
Section titled “Step 2 — look at the page before writing a rule”kis bbox reads a .bbox file and prints what the detectors found. No rules are loaded, nothing is
written back. Start with the tables:
kis bbox tables -i pages/invoice.bbox=== Table 1 (Page 1) ===Dimensions: 4 rows x 5 columns
Invoice | Number: | INV-2024-0042 | |Invoice | Date: | 2024-03-14 | | | Currency: | EUR | |Bill | To: | Acme | Robotics | GmbH
=== Table 2 (Page 1) ===Dimensions: 4 rows x 4 columnsHeaders: Description | Qty | Unit Price | Amount
Description | Qty | Unit Price | Amount------------+-----+------------+-------Widget A | 10 | 45.00 | 450.00Widget B | 5 | 60.00 | 300.00Cable Set | 20 | 12.50 | 250.00
=== Table 3 (Page 1) ===Dimensions: 3 rows x 3 columns
| Subtotal: | 1,000.00Tax | 20%: | 200.00Total | Due: | 1,200.00Three “tables”, because detection is text-alignment clustering over blocks and nothing else — the label/value header and the totals block cluster just as readily as the real table, and the label column splits wherever the word gaps happen to fall. Only Table 2 has a detected header row, and only Table 2 is a table you would have called one. Table indexes are an artefact of that clustering: select by anchor instead, and confirm the anchor and the headers up front:
kis bbox table -i pages/invoice.bbox -a "Line Items" -H Description -H Qty -H Amount=== Table (Page 1) ===Dimensions: 4 rows x 4 columnsHeaders: Description | Qty | Unit Price | Amount
Description | Qty | Unit Price | Amount------------+-----+------------+-------Widget A | 10 | 45.00 | 450.00Widget B | 5 | 60.00 | 300.00Cable Set | 20 | 12.50 | 250.00Every -H value must be present or the command fails with
Header validation failed: missing headers: … and exits non-zero, which makes this a usable
regression check over a fixture set before any rule exists.
Check the anchors the rules will use the same way:
kis bbox phrase -i pages/invoice.bbox -p "Total Due"Found 1 match(es) for phrase "Total Due"
=== Match 1 (Page 1) ===Location: (700.0, 980.0) - (846.0, 1015.0)Line: 14
Cable Set 20 12.50 250.00 Subtotal: 1,000.00 Tax 20%: 200.00 >> Total Due: 1,200.00 Payment terms: 30 daysOne match, at line 14, and the reported box covers the matched words only — Total through Due:,
not the amount. So Due: is unambiguous on this page and a rule can anchor on it. Detection rules,
the column-spec language and the rest of the table surface are on
tables; anchors and label matching are on
extraction and
the extraction API.
Step 3 — the facts
Section titled “Step 3 — the facts”kis ocr page runs one execution per <prefix>.json + <prefix>.bbox pair, so the page needs a
facts file even when the document carries most of the input. Everything the rules need that is not
on the page goes here:
{ "source": "invoice-scan-001.png", "allowed_currencies": "EUR,GBP,USD"}Two facts: source and allowed_currencies. Both are flat scalars, deliberately. A rule reads a
fact by its bare name — allowed_currencies, not in.GetStr("allowed_currencies"), which returns the
empty string for a fact that is present. Nesting costs you: a nested fact needs bracket access
(invoice["currency"]), dot access on it aborts the run, and the value that comes back out cannot be
passed to a typed parameter. Those costs are the rule language’s, not the wire’s — a nested fact
behaves the same way under kis rules and on
rules.svc. Flat scalars have none of them.
Step 4 — extract
Section titled “Step 4 — extract”rules/extract.grl. Three rules, distinct salience, each retracting itself:
rule ExtractHeader "invoice number, date and currency" salience 100 { when bbox.Has("page") then doc = bbox.Get("page"); out.Set("invoice_number", doc.FieldValue("Number")); out.Set("invoice_date", doc.FieldValue("Date:")); out.Set("currency", doc.FieldValue("Currency:")); audit.Log("ExtractHeader", "header fields read"); Retract("ExtractHeader");}
rule ExtractTotals "the three printed money amounts" salience 90 { when bbox.Has("page") then doc = bbox.Get("page"); out.Set("subtotal", doc.CurrencyRightOf("Subtotal:")); out.Set("tax", doc.CurrencyRightOf("20%:")); out.Set("total", doc.CurrencyRightOf("Due:")); audit.Log("ExtractTotals", "totals read"); Retract("ExtractTotals");}
rule ExtractLineItems "the table under the Line Items heading" salience 80 { when bbox.Has("page") then out.Set("line_items", bbox.Get("page").SafeTableBelow("Line Items").RowsAsMaps()); out.Set("line_item_count", bbox.Get("page").SafeTableBelow("Line Items").DataRowCount()); audit.Log("ExtractLineItems", "line-item table read"); Retract("ExtractLineItems");}| Call | Returns | Why this one |
|---|---|---|
FieldValue(label) | string | Label lookup — resolves Number to the value beside it |
CurrencyRightOf(anchor) | float64 | Parses 1,000.00 to a number, so the check below is arithmetic |
SafeTableBelow(anchor) | nil-safe table | Anchor selection; an unmatched anchor yields an empty table, not a panic |
RowsAsMaps() | []map[string]string | One map per data row, keyed by the detected column header |
Three details in that file decide whether it works at all:
bbox.Hasguards every rule.bbox.Geton a name that was never loaded returns nothing, and the next call in the chain dereferences it — that panic aborts the execution and discards all output.docholds a document reference, which survives assignment. Maps and slices do not: assignRowsAsMaps()to a variable and passing it onward fails withreflect: Call using zero Value argument. Keep those inline, asExtractLineItemsdoes.- Salience is distinct on every rule. Equal salience gives a different firing order run to run.
Step 5 — validate
Section titled “Step 5 — validate”rules/validate.grl. Each check is a pass rule and a fail rule with mutually exclusive conditions and
adjacent, distinct salience:
rule TotalsReconcile "subtotal + tax equals the printed total" salience 70 { when bbox.Has("page") && math.Abs(bbox.Get("page").CurrencyRightOf("Subtotal:") + bbox.Get("page").CurrencyRightOf("20%:") - bbox.Get("page").CurrencyRightOf("Due:")) <= 0.01 then out.Set("check_totals", "pass"); audit.Log("TotalsReconcile", "subtotal + tax = total"); Retract("TotalsReconcile");}
rule TotalsMismatch "the printed total does not reconcile" salience 69 { when bbox.Has("page") && math.Abs(bbox.Get("page").CurrencyRightOf("Subtotal:") + bbox.Get("page").CurrencyRightOf("20%:") - bbox.Get("page").CurrencyRightOf("Due:")) > 0.01 then out.Set("check_totals", "fail"); audit.Log("TotalsMismatch", "subtotal + tax does not equal the printed total"); Retract("TotalsMismatch");}
rule DatePresent "the invoice carries a date" salience 60 { when bbox.Has("page") && bbox.Get("page").FieldValue("Date:") != "" then out.Set("check_date", "pass"); audit.Log("DatePresent", "invoice date present"); Retract("DatePresent");}
rule DateMissing "no invoice date was read" salience 59 { when bbox.Has("page") && bbox.Get("page").FieldValue("Date:") == "" then out.Set("check_date", "fail"); audit.Log("DateMissing", "no invoice date on the page"); Retract("DateMissing");}
rule CurrencyAllowed "the currency is on the allow list" salience 50 { when bbox.Has("page") && array.ContainsStr(strings.Split(allowed_currencies, ","), bbox.Get("page").FieldValue("Currency:")) then out.Set("check_currency", "pass"); audit.Log("CurrencyAllowed", "currency accepted"); Retract("CurrencyAllowed");}
rule CurrencyRejected "the currency is not on the allow list" salience 49 { when bbox.Has("page") && array.ContainsStr(strings.Split(allowed_currencies, ","), bbox.Get("page").FieldValue("Currency:")) == false then out.Set("check_currency", "fail"); audit.Log("CurrencyRejected", "currency not on the allow list"); Retract("CurrencyRejected");}The checks re-query the document instead of reading back what the extraction rules wrote. That is not
a stylistic choice: out.Get in a when is memoised and never sees a later write, so a validation
rule cannot read the extracted result out of the output collector. Document queries are pure, so
asking the page again is the shape that works, and it costs nothing here — the second query hits the
same analysed document. See rule patterns for the ways a
rule set can chain.
Two things make the guards safe:
&&short-circuits.bbox.Has("nope") && bbox.Get("nope").LineCount() > 0evaluates to false and runs cleanly; theGetis never reached. That is what lets one expression both guard and query.math.Abs(...)gets afloat64.CurrencyRightOfreturnsfloat64and the arithmetic staysfloat64, so the argument type matches. Function arguments do not coerce: an integer literal where afloat64is declared fails withreflect: Call using int64 as type float64.
Step 6 — run it
Section titled “Step 6 — run it”kis ocr page -r rules/ -i pages/ invoiceResult for /work/invoice/pages/invoice.json:{ "check_currency": "pass", "check_date": "pass", "check_totals": "pass", "currency": "EUR", "invoice_date": "2024-03-14", "invoice_number": "INV-2024-0042", "line_item_count": 3, "line_items": [ { "Amount": "450.00", "Description": "Widget A", "Qty": "10", "Unit Price": "45.00" }, { "Amount": "300.00", "Description": "Widget B", "Qty": "5", "Unit Price": "60.00" }, { "Amount": "250.00", "Description": "Cable Set", "Qty": "20", "Unit Price": "12.50" } ], "subtotal": 1000, "tax": 200, "total": 1200}Processed 1/1 prefix(es) successfully-r rules/ compiles both .grl files into one rule set. The positional invoice is a prefix:
pages/invoice.json and pages/invoice.bbox must both exist, and kis ocr page binds the document
under the name page — the name every rule passes to bbox.Has and bbox.Get. Change it with
--bbox-name, and change the rules to match; a name mismatch is a nil dereference, not a
“no such document” message.
kis rules reaches the same result with the document named explicitly:
kis rules -r rules/ -i pages/ -p invoice.json -b page=pages/invoice.bboxThat form loads the document plugin only because -b is present. Without -b or --with-bbox,
bbox is not bound at all and every rule here aborts with got non existent key bbox. kis ocr page
always binds it.
Step 7 — see what fired
Section titled “Step 7 — see what fired”kis ocr page -r rules/ -i pages/ --audit invoice--- Audit for /work/invoice/pages/invoice.json --- 1. [ExtractHeader] executed 2. [ExtractHeader] header fields read 3. [ExtractTotals] executed 4. [ExtractTotals] totals read 5. [ExtractLineItems] executed 6. [ExtractLineItems] line-item table read 7. [TotalsReconcile] executed 8. [TotalsReconcile] subtotal + tax = total 9. [DatePresent] executed 10. [DatePresent] invoice date present 11. [CurrencyAllowed] executed 12. [CurrencyAllowed] currency acceptedSix rules fired, in salience order, and the three fail rules never matched. The executed lines are
the engine’s, one per firing; the others are the audit.Log calls in the rule bodies. --audit adds
this block, it does not replace anything — the Result for … block from step 6 still follows it, and
is elided here. --history gives the same run as the list of out.Set writes instead, each
attributed to the rule that made it:
--- History for /work/invoice/pages/invoice.json --- 1. [ExtractHeader] invoice_number = "INV-2024-0042" 2. [ExtractHeader] invoice_date = "2024-03-14" 3. [ExtractHeader] currency = "EUR" 4. [ExtractTotals] subtotal = 1000 5. [ExtractTotals] tax = 200 6. [ExtractTotals] total = 1200 7. [ExtractLineItems] line_items = [{"Amount":"450.00","Description":"Widget A","Qty":"10","Unit Price":"45.00"},{"Amount":"300.00","De... 8. [ExtractLineItems] line_item_count = 3 9. [TotalsReconcile] check_totals = "pass" 10. [DatePresent] check_date = "pass" 11. [CurrencyAllowed] check_currency = "pass"Long values are truncated in that view, as line_items is above.
Step 8 — an invoice that fails
Section titled “Step 8 — an invoice that fails”The same rule set over a second scan whose printed total reads 1,300.00, converted and paired the
same way (pages/invoice-b.json beside pages/invoice-b.bbox):
kis ocr page -r rules/ -i pages/ --audit invoice-b--- Audit for /work/invoice/pages/invoice-b.json --- 1. [ExtractHeader] executed 2. [ExtractHeader] header fields read 3. [ExtractTotals] executed 4. [ExtractTotals] totals read 5. [ExtractLineItems] executed 6. [ExtractLineItems] line-item table read 7. [TotalsMismatch] executed 8. [TotalsMismatch] subtotal + tax does not equal the printed total 9. [DatePresent] executed 10. [DatePresent] invoice date present 11. [CurrencyAllowed] executed 12. [CurrencyAllowed] currency accepted{ "check_currency": "pass", "check_date": "pass", "check_totals": "fail", "currency": "EUR", "invoice_date": "2024-03-14", "invoice_number": "INV-2024-0043", "line_item_count": 3, "subtotal": 1000, "tax": 200, "total": 1300}TotalsMismatch fired where TotalsReconcile did on the first invoice, and the extraction still
completed — a failed check is a value in the result, not an error. The line_items array is identical
to the first run and is elided above.
Recording a failed check
Section titled “Recording a failed check”The engine carries a severity-graded findings collector beside out and audit, and a rule can read
it — findings.Count() and findings.Passed() return 0 and true. No rule can write to it.
The shape the Go model implies, once a rule-facing entry point exists, is four plain strings — a rule id, a severity name, a category and a message — because a rule body can only produce strings, numbers and booleans:
rule TotalsMismatch "the printed total does not reconcile" salience 69 { when bbox.Has("page") && math.Abs(bbox.Get("page").CurrencyRightOf("Subtotal:") + bbox.Get("page").CurrencyRightOf("20%:") - bbox.Get("page").CurrencyRightOf("Due:")) > 0.01 then findings.Add("TotalsMismatch", "ERROR", "totals", "subtotal + tax does not equal the printed total"); Retract("TotalsMismatch");}What works is the pair this page already uses, and it covers the same ground:
| Need | Working call | Where it lands |
|---|---|---|
| A machine-readable verdict the caller must see | out.Set("check_totals", "fail") | The result — CLI output and HTTP response body |
| A human-readable trail of what each rule decided | audit.Log("TotalsMismatch", "…") | --audit output and the -hist-audit.json sidecar |
Keep the verdict in out. The audit trail is the human-readable record of what each rule decided;
the response body carries what the rules wrote to out, so anything a caller must act on has to be
an out.Set.
Step 9 — the same rules over HTTP
Section titled “Step 9 — the same rules over HTTP”rules.svc runs the same rule sets on the wire: facts on the request body, documents under the
reserved bbox object. Two shapes work, and this step covers both.
The whole rule set, document included
Section titled “The whole rule set, document included”Send the page and the rule set from steps 4 and 5 runs unchanged. bbox on the POST /rule body is
keyed by document name, and the name is the one every rule already passes to bbox.Has and
bbox.Get — page here. The value is the contents of pages/invoice.bbox from step 1, inline:
{ "name": "invoice-extract-and-check", "allowed_currencies": "EUR,GBP,USD", "bbox": { "page": {"pages": [{"number": 1, "width": 1275, "height": 1650, "words": []}]} }}The service parses and analyses each document before any rule runs, so bbox.Has("page"),
FieldValue, CurrencyRightOf and SafeTableBelow behave exactly as they did under kis ocr page,
and the 200 body carries the whole step 6 result — the extracted header, totals and line items
alongside the three checks, because both files write to out. The key under bbox is the same knob
as --bbox-name: a name mismatch is a nil dereference, not a “no such document” message. The
document-input contract is in full on
running rules.svc.
invoice-extract-and-check is a rules entry holding extract.grl and validate.grl verbatim; the
metadata shape is the same one below.
Or split at the seam
Section titled “Or split at the seam”The rest of this step takes the other shape: split the rule set where it already divides.
extract.grl reads the page, validate.grl decides. Run extraction where the scan is, send the
service the extracted facts, and the request body stays small — a page’s word boxes are far larger
than the handful of values they produce. That split is why the six checking rules below are rewritten
to read facts instead of the page.
First run extraction alone and keep the result. -o writes the rule output to a directory, mirroring
the input’s relative path, instead of printing it:
kis ocr page -r rules/extract.grl -i pages/ -o extracted/ invoiceProcessed 1/1 prefix(es) successfullyextracted/invoice.json is the extraction, and nothing else — the source facts stay behind:
{ "currency": "EUR", "invoice_date": "2024-03-14", "invoice_number": "INV-2024-0042", "line_item_count": 3, "line_items": [ { "Amount": "450.00", "Description": "Widget A", "Qty": "10", "Unit Price": "45.00" }, { "Amount": "300.00", "Description": "Widget B", "Qty": "5", "Unit Price": "60.00" }, { "Amount": "250.00", "Description": "Cable Set", "Qty": "20", "Unit Price": "12.50" } ], "subtotal": 1000, "tax": 200, "total": 1200}The checks as a facts-only rule set
Section titled “The checks as a facts-only rule set”checks.grl. Same six rules, same salience ladder, same verdicts — every bbox query replaced
by the fact the extraction produced, and every bbox.Has guard by in.Has:
rule TotalsReconcile "subtotal + tax equals the printed total" salience 70 { when in.Has("subtotal") && in.Has("tax") && in.Has("total") && math.Abs(subtotal + tax - total) <= 0.01 then out.Set("check_totals", "pass"); audit.Log("TotalsReconcile", "subtotal + tax = total"); Retract("TotalsReconcile");}
rule TotalsMismatch "the printed total does not reconcile" salience 69 { when in.Has("subtotal") && in.Has("tax") && in.Has("total") && math.Abs(subtotal + tax - total) > 0.01 then out.Set("check_totals", "fail"); audit.Log("TotalsMismatch", "subtotal + tax does not equal the printed total"); Retract("TotalsMismatch");}
rule DatePresent "the invoice carries a date" salience 60 { when in.Has("invoice_date") && invoice_date != "" then out.Set("check_date", "pass"); audit.Log("DatePresent", "invoice date present"); Retract("DatePresent");}
rule DateMissing "no invoice date was read" salience 59 { when in.Has("invoice_date") == false || invoice_date == "" then out.Set("check_date", "fail"); audit.Log("DateMissing", "no invoice date on the page"); Retract("DateMissing");}
rule CurrencyAllowed "the currency is on the allow list" salience 50 { when in.Has("currency") && in.Has("allowed_currencies") && array.ContainsStr(strings.Split(allowed_currencies, ","), currency) then out.Set("check_currency", "pass"); audit.Log("CurrencyAllowed", "currency accepted"); Retract("CurrencyAllowed");}
rule CurrencyRejected "the currency is not on the allow list" salience 49 { when in.Has("currency") && in.Has("allowed_currencies") && array.ContainsStr(strings.Split(allowed_currencies, ","), currency) == false then out.Set("check_currency", "fail"); audit.Log("CurrencyRejected", "currency not on the allow list"); Retract("CurrencyRejected");}in.Has is the guard that works: in.GetStr, in.GetInt, in.GetFloat and in.GetBool return the
zero value even for a fact that is present, so a condition built on them never matches. ||
short-circuits like &&, which is what makes DateMissing safe when the fact is absent entirely.
Run it locally first — kis rules takes plain facts with no document at all, so a request body is a
valid input file. -f merges a second facts file into the same execution, which is where the allow
list comes from (policy.json holds {"allowed_currencies": "EUR,GBP,USD"}):
kis rules -r checks.grl -i extracted/ -p invoice.json -f policy.json --audit--- Audit for /work/invoice/extracted/invoice.json --- 1. [TotalsReconcile] executed 2. [TotalsReconcile] subtotal + tax = total 3. [DatePresent] executed 4. [DatePresent] invoice date present 5. [CurrencyAllowed] executed 6. [CurrencyAllowed] currency accepted
Result for /work/invoice/extracted/invoice.json:{ "check_currency": "pass", "check_date": "pass", "check_totals": "pass"}Processed 1/1 file(s) successfullyDo not reach for -v for that allow list: --vars splits its own value on commas, so
-v allowed_currencies=EUR,GBP,USD fails with
parsing vars: invalid var format "GBP": expected key=value. A facts file has no such limit.
Deliver the rule set
Section titled “Deliver the rule set”Rules reach rules.svc as product metadata under a rules list. name is what a caller sends as
name, and one entry’s rule string holds every rule in the set:
rules: - name: invoice-checks rule: | rule TotalsReconcile "subtotal + tax equals the printed total" salience 70 { when in.Has("subtotal") && in.Has("tax") && in.Has("total") && math.Abs(subtotal + tax - total) <= 0.01 then out.Set("check_totals", "pass"); Retract("TotalsReconcile"); }
rule TotalsMismatch "the printed total does not reconcile" salience 69 { when in.Has("subtotal") && in.Has("tax") && in.Has("total") && math.Abs(subtotal + tax - total) > 0.01 then out.Set("check_totals", "fail"); Retract("TotalsMismatch"); }
// the remaining four rules from checks.grl, verbatim// is the comment marker inside a rule file. # is not — it fails the whole file at load.
Call it
Section titled “Call it”name selects the rule set; every other top-level key of the body becomes a fact. Post the extraction
with the allow list added:
{ "name": "invoice-checks", "allowed_currencies": "EUR,GBP,USD", "currency": "EUR", "invoice_date": "2024-03-14", "invoice_number": "INV-2024-0042", "line_item_count": 3, "line_items": [ { "Amount": "450.00", "Description": "Widget A", "Qty": "10", "Unit Price": "45.00" }, { "Amount": "300.00", "Description": "Widget B", "Qty": "5", "Unit Price": "60.00" }, { "Amount": "250.00", "Description": "Cable Set", "Qty": "20", "Unit Price": "12.50" } ], "subtotal": 1000, "tax": 200, "total": 1200}curl -X POST https://rules.example.com/rule -H 'Content-Type: application/json' -H 'Authorization: Bearer <tenant-jwt>' -H 'X-Customer: acme' -H 'X-Product: erp' -H 'X-Env: prod' -H 'X-Tenant: eu1' -d @invoice-request.json200, carrying exactly what the rules wrote to out and nothing else:
{ "check_currency": "pass", "check_date": "pass", "check_totals": "pass"}invoice_number, line_items and the rest ride along as facts no rule reads; they are not echoed
back. If the caller needs them in the response, a rule has to out.Set them.
What changed between the two runs
Section titled “What changed between the two runs”kis ocr page / kis rules | POST /rule | |
|---|---|---|
| Rule source | -r rules/, compiled at start-up | Product metadata, polled every 15s |
| Rule set selected by | fixed name rules on kis rules; --ruleset on kis ocr | the body’s name, required |
| Facts | top-level keys of the input .json, plus -f and -v | every top-level key of the body |
| Document input | -b name=file.bbox, --with-bbox, or the .bbox beside the prefix | the reserved bbox object on the body, keyed by document name |
| Result | printed, or written with -o / --update | the 200 body |
| Audit trail | --audit and the -hist-audit.json sidecar | returned under audit when the body sets "audit": true |
| Failure | ERROR: <path>: … on stderr, non-zero exit | 400 and the error envelope |
| Rule text | — | identical, documents included |
Headers, tenancy, status codes and the error envelope are on running rules.svc; how definitions reach the service is there too.
What this page relied on
Section titled “What this page relied on”| Behaviour | Consequence for your rule set |
|---|---|
| One rule fires per cycle, highest salience first | Give every rule a distinct salience; equal salience is non-deterministic |
| A retracted rule leaves the pool; every other rule is re-evaluated | Make competing rules mutually exclusive, and retract each one in its own then |
| Evaluation failure aborts the whole execution | One bad rule discards every extracted value; guard with bbox.Has and in.Has |
&& short-circuits | A guard and a query can share one expression |
| Function arguments do not coerce | Match every literal to the parameter type; 1 is int64, 1.0 is float64 |
| Map and slice values die on assignment | Keep RowsAsMaps(), Split() and grid rows inline |
Table cell accessors take Go int | Cell(0, 1) aborts with reflect: Call using int64 as type int; read whole rows |
out.Get in a when is memoised | Validation rules re-query the document rather than reading back out |
in.GetStr and friends return the zero value | Read facts by their bare name; guard with in.Has |
bbox is bound from the host’s document data | The CLI reads .bbox files; rules.svc reads the bbox object on the body |
| A label binds by score, not by printed text | FieldValue("Total") returns the subtotal here; probe every label |
Continue with
Section titled “Continue with”- The document model — what
Analyze()builds from those 56 words - Extraction — labels, anchors, scored fields, schemas
- Tables — detection settings and the full table surface
- Documents in rules — every
bboxmethod and factory - Rule patterns — guard, extract, publish, chain
- Built-in functions —
math,strings,array,in,out - The CLI —
kis ocr,kis bbox,kis hocr2bboxin full - Running rules.svc — the execute-rule route end to end
- Document model API — the exact signature of every call above
- Rules engine API — the collectors, the plugin contract, the limits
- Findings API — the Go findings model behind that caution
- Errors — the code table behind every message above