Skip to content
Talk to our solutions team

Running rules over documents

A rule never touches OCR words. The surface you run rules on loads OCR JSON, analyses it, and binds the analysed document into the rule data context under an identifier a rule can name. That identifier is bbox, and bbox is what a rule author writes.

Everything on this page describes the bridge — the contract, the payload shape each surface supplies, and every method the document plugin exposes to rule text. What you can ask a document once you hold one is covered in the document model, extraction, tables and matching.

Surfacebbox boundHow
kis CLIyeskis ocr page, kis ocr doc, kis rules --bbox / --with-bbox
rules.svc over HTTPyesPOST /rule carries a reserved bbox object alongside the facts

Both surfaces reach the same document model, the same detectors and the same bbox methods. A rule set that extracts under kis ocr page runs unchanged on the service; the only difference is where the OCR JSON comes from — a file on disk for the CLI, the request body for the service. See the service page for the rest of the HTTP surface.

A plugin is three methods. Nothing else.

MethodWhen it runsWhat it produces
NameThe registry key and the per-run payload key
InitOnce, when the engine is builtFacts bound into every execution
PrepareOnce per executionFacts bound into that execution only

Each key of a returned map becomes a top-level identifier in rule text. The split is what makes the document bridge safe under concurrency: stateless helper namespaces come from Init and are shared, per-document state comes from Prepare and is private to one run.

The document plugin returns nothing from Init. It publishes bbox only from Prepare, and Prepare constructs a brand-new plugin instance for that execution rather than mutating the registered one — so parallel workers never share the document map, and bbox.Remove or bbox.Clear inside a rule can never affect another in-flight execution.

The document plugin is enabled per run, not by static configuration.

SurfaceEnabled when
kis ocr page, kis ocr docAlways
kis rulesOnly with --bbox or --with-bbox
rules.svcThe request body carries a bbox object

The utility namespaces (util, num, strings, time, math, map, array) are always registered, on both surfaces, whether or not a document is present.

The per-run payload is keyed by plugin name. The document plugin accepts two shapes under bbox.

Plain shape — a map of document name to content. Each value is either parsed OCR JSON or, where the surface reads files, a path to a .bbox file.

Schema shape — two reserved keys. __docs holds the same map as above; __schemas holds parsed JSON Schemas by name. When __schemas is present the plugin calls SetSchemas on every loaded document, which is what makes doc.ExtractForm("name") resolve. __schemas is read only when __docs is present, so a payload carrying __schemas alone is treated as a document named __schemas and fails to load.

The CLI assembles that payload from your flags. Over HTTP it is the value of the reserved bbox key on the request body, so you write one shape or the other yourself: the plain shape when no schema is in play, the schema shape when one is.

The inner keys are the names a rule passes to bbox.Has and bbox.Get. The CLI picks them for you:

CommandDocument nameFlag
kis ocr pagepage--bbox-name
kis ocr docdoc--bbox-name
kis rules --with-bboxbbox--with-bbox-name
kis rules -b key=pathkey— (defaults to bbox)

POST /rule carries the document alongside the facts. bbox is a reserved top-level key: it maps a document name to that document’s OCR JSON, and those names are exactly what a rule passes to bbox.Has and bbox.Get.

{
"name": "invoice-checks",
"bbox": {
"page": {
"pages": [
{
"number": 1,
"width": 612,
"height": 792,
"words": [
{
"text": "Name",
"box": {"x0": 72, "y0": 100, "x1": 120, "y1": 115},
"confidence": 0.98,
"page": 1
}
]
}
]
}
},
"currency": "EUR"
}

When the rule set calls ExtractForm or SafeExtractForm, send the schema shape instead: bbox holds __docs — the same document map as above — and __schemas, mapping schema name to a JSON Schema document. The service loads and analyses every entry before the first rule is evaluated, so a document that will not parse or analyse fails the request with 400 and the same plugin bbox prepare: detail the CLI prints.

{
"name": "closing-disclosure",
"bbox": {
"__docs": {"page": {"pages": []}},
"__schemas": {"closing_disclosure": {"type": "object"}}
}
}

The response is unchanged from any other execution: 200 with exactly the key/value pairs the rules wrote to out.

For each entry in the payload the plugin parses the OCR JSON, wraps it, and runs Analyze(). Analyze groups words into blocks and lines per page, aggregates document statistics, builds a spatial index, then runs the detectors — tables, fields, checkboxes and checkbox groups, page regions, font sizes, and special regions such as signatures, handwriting and barcodes. Every query a rule makes is a query over that analysed model. Failure to read, parse or analyse aborts the execution before any rule runs, with plugin bbox prepare: <detail>.

The input format is described in OCR ingestion.

Twenty methods, in three groups.

MethodSignatureCallable from a rule
GetGet(name string) *Documentyes
HasHas(name string) boolyes
NamesNames() []stringyes
RemoveRemove(name string)yes
ClearClear()yes
MustLoadFromFileMustLoadFromFile(name, path string) *Documentyes — its panic aborts the run
LoadFromFileLoadFromFile(name, path string) (*Document, error)no
LoadFromReaderLoadFromReader(name string, r io.Reader) (*Document, error)no
LoadFromBytesLoadFromBytes(name string, b []byte) (*Document, error)no
LoadFromMapLoadFromMap(name string, m map[string]any) (*Document, error)no
LoadFromEnvLoadFromEnv(name, envVar string) (*Document, error)no
GetOrLoadGetOrLoad(name, path string) (*Document, error)no

Get returns nil for an unknown name; it never loads and never errors. Chaining a method onto that nil is fatal:

rule engine execute panic on rule X ! recovered : runtime error: invalid memory address or nil pointer dereference

Assigning it to a variable first only changes the wording — reflect: call of reflect.Value.Type on zero Value — not the outcome. The panic is recovered but the execution still aborts and every value already collected is discarded. Always guard with bbox.Has in the when.

Rule text can only produce string, int64, float64 and bool. Any document method whose parameter is a typed Go value is unreachable unless a factory manufactures it. These six factories are that bridge.

FactorySignatureFeeds
BoxBox(x0, y0, x1, y1 float64) BoundingBoxBlocksInBox, BlocksIntersectingBox, NearestBlockIn
PtPt(x, y float64) PointNearestBlocks, BlocksNearPoint
DirDir(name string) DirectionFuzzyTextNear*, FieldNear*, CheckedOptionNear*, NearestBlockIn
RelDirRelDir(name string) RelativeDirExtractAfter
GeoBoxGeoBox(x0, y0, x1, y1 float64) geometry.BoxBlocksInRegion, BlocksIntersecting, HasPhraseInBox, FindNextFuzzy, FindNextFuzzyWithNgram
GeoDirGeoDir(name string) geometry.DirectionNearestBlockInDirection, FindNextFuzzy, FindNextFuzzyWithNgram

Every direction factory accepts left, right, above / up, below / down.

These two are the only document-level operations the plugin re-exposes on itself, so a rule does not have to fetch the document first.

MethodSignatureBehaviour
ExtractColumnAlignedSectionExtractColumnAlignedSection(docName, sectionStart, sectionEnd, columnHeaders string) []map[string]anyLooks the document up by name; returns nil for an unknown name
PostprocessRowsPostprocessRows(rows []map[string]any, key, fn string) []map[string]anyApplies fn to the string at key in every row; chainable

fn accepts trim, upper, lower, normalize_spaces, trim_currency, and the parameterised form ExtractRegex("pattern", "default"). An unknown name returns the rows unchanged, silently — a typo in trim_currency produces untrimmed values, not an error.

The column-spec mini-language is documented in tables.

What the rule author writes. Guard on Has, bind the document to a variable, query it, publish with out.Set, Retract so the rule does not re-fire.

rule ExtractInvoiceVendor "Vendor name and total from the invoice" salience 100 {
when
bbox.Has("page")
then
doc = bbox.Get("page");
out.Set("vendor", doc.TextRightOf("Name"));
out.Set("total", doc.FuzzyTextNear("Total", bbox.Dir("right"), 1, 2, 0.3));
out.Set("lines", doc.LineCount());
Retract("ExtractInvoiceVendor");
}

What the CLI does.

Terminal window
kis ocr page -r rules/ invoice

The positional argument is a prefix: both invoice.json (the facts) and invoice.bbox (the OCR JSON) must exist, and either file’s path may be given instead — the extension is stripped. kis ocr page builds the engine with the document plugin enabled, reads both files, and passes {"bbox": {"page": <content>}} as the per-run payload.

What the service does. The same rule set, no edits, with the OCR JSON in the request instead of on disk.

Terminal window
curl -X POST https://rules.example.com/rule -H 'Authorization: Bearer <tenant-jwt>' -H 'X-Customer: acme' -H 'X-Product: erp' -H 'X-Env: prod' -H 'X-Tenant: eu1' -d @invoice-request.json

invoice-request.json holds name, the facts, and bbox keyed by the same document name the rule uses — {"name":"invoice-checks","bbox":{"page":{"pages":[…]}},"currency":"EUR"}. The service passes that object through as the per-run payload unchanged.

What the plugin resolves. Prepare sees a non-nil entry under bbox, constructs a fresh instance, loads page from the supplied content — parsing the OCR JSON and running Analyze() — and returns {"bbox": instance}. The engine binds that under the identifier bbox for this execution only.

What the document model computes. bbox.Has("page") is a map lookup. bbox.Get("page") hands the rule the analysed document, on which the whole query surface is callable. TextRightOf runs an anchor lookup against the spatial index; FuzzyTextNear fuzzy-matches the label, expands a search region to the right, and returns the text found there; LineCount reads the line model built by Analyze.

What comes back. Each out.Set writes into the Output collector, stamped with the firing rule’s name. On a two-row document holding Name / John and Total / $1,250.00 the result is {vendor: "John", total: "$1,250.00", lines: 2} — printed by the CLI, and returned as the 200 JSON body by the service.

When the run supplies schemas, extraction can live in a schema instead of rule code and the rule just names it. From the CLI the schema name is the filename without its extension; register schemas with kis ocr page --schema closing_disclosure.schema or --schemas schemas/ — both are repeatable, and neither flag exists on kis rules. Over HTTP the name is the key you use under __schemas in the request body’s bbox object.

rule ExtractClosingForm salience 90 {
when
bbox.Has("page")
then
out.Set("borrower", map.GetStr(bbox.Get("page").SafeExtractForm("closing_disclosure"), "borrower_name", ""));
Retract("ExtractClosingForm");
}

Use SafeExtractForm, not ExtractForm, and keep the whole chain in one expression. See the two sections below for why.

rule OriginationCharges salience 80 {
when
bbox.Has("page")
then
out.Set("homeowner_fee", map.GetStr(array.FindRowBy(bbox.PostprocessRows(bbox.ExtractColumnAlignedSection("page", "Origination Charges", "Services Borrower Did Not Shop For", "At Closing,Before Closing,Paid By Others"), "At Closing", "trim_currency"), "description", "homeowner"), "At Closing", ""));
Retract("OriginationCharges");
}

array.FindRowBy and map.GetStr come from the always-on utility namespaces (functions). If the section is absent the extraction returns nil and the next call in the chain aborts the run.

Assign a map or a slice to a rule variable and you can never pass that variable to another function: the argument arrives as a zero reflect.Value and the run aborts.

rows = bbox.ExtractColumnAlignedSection("page", "Origination Charges", "", "At Closing");
row = array.FindRowBy(rows, "description", "homeowner");

That second line always fails with reflect: Call using zero Value argument, on a full section as readily as on a missing one, and it fails identically for strings.Split, bbox.Names and every Safe* method that returns a map or a slice. Nest the calls instead — one expression, no intermediate variable. Document references (doc = bbox.Get("page")) and primitives are unaffected; only values you pass onward are.

Nil results are fatal, so use the Safe* family

Section titled “Nil results are fatal, so use the Safe* family”

When a document method returns a nil slice or map, the rule variable holds an untyped nil.

What you writeWhat happens
x.Len() on a nil resultis not referencing an object thus function Len call is not supported. Kind invalid
passing a nil result straight into another callreflect: Call using zero Value argument
a method call chained onto bbox.Get("unknown")runtime error: invalid memory address or nil pointer dereference

All three abort the entire execution and discard every value already collected — one unguarded call destroys the output of every rule that already fired. The Safe* family exists for exactly this: SafeTable, SafeTableBelow, SafeTableAbove, SafeTableBetween, SafeTableBelowFuzzy, SafeTableAboveFuzzy, SafeTableBetweenFuzzy, SafeExtractForm, SafeExtractFormOnPage, SafeExtractTable and SafeExtractTableOnPage all return non-nil empties where their unsafe counterparts return nil. Prefer them wherever the section may be absent.

MessageWhereCause
got non existent key bboxrule evaluationThe run carried no document — no --bbox flag, or no bbox key
bbox plugin expects map[string]any, got %Tinputbbox was not an object
__docs: expected map[string]any, got %TinputSchema-shaped payload malformed
__schemas: expected map[string]*jsonschema.Schema, got %TinputA schema entry was not a JSON Schema
document %s: expected map[string]any or string, got %TinputA document value was neither OCR JSON nor a path
plugin bbox prepare: loading document %s: ...before any rule runsBad path, or OCR JSON that will not parse or analyse
calling function <Name> whichmultiple valuerule evaluationA two-value loader method was called from rule text
reflect: Call using zero Value argumentrule evaluationA map- or slice-valued variable, or a nil result, was passed as an argument
reflect: Call using int64 as type float64rule evaluationWhole-number literal where a float64 is wanted
reflect: Call using float64 as type int64rule evaluationDecimal literal where an int64 is wanted
reflect: Call using string as type document.Directionrule evaluationBare string where bbox.Dir(...) is required
reflect: Call using int as type document.Directionrule evaluationA bbox.Dir(...) result was assigned to a variable before being passed

Over HTTP every one of these comes back as 400 with the detail in the error envelope. Every rule-evaluation failure above is fatal to the whole run, not to the one rule — abort-on-failed-evaluation is unconditional and every non-retracted rule is evaluated each cycle. Troubleshooting walks the diagnosis.