DEV Community

KenjiTanaka6849
KenjiTanaka6849

Posted on

Human-in-the-Loop Metadata Inspection — Linking Extracted Text to Source Images

A receipt review console is only trustworthy when every extracted value can be traced to the exact pixels a reviewer is judging. Short answer: store immutable source-image identity, page dimensions, and normalized bounding geometry beside each text field; render that evidence on demand; and treat every human correction as a versioned, idempotent decision rather than an edit to OCR output. Moderation coverage should be explicit: fields outside the review policy remain unapproved, even if extraction produced plausible text.

I've been paged by missed jobs and duplicate deliveries in cron and queue infrastructure. The lesson transfers cleanly here — a review queue is still a queue. If a worker can overwrite evidence, or a duplicate correction can advance state twice, the console may look healthy while its audit trail has already split. I first tend to ask whether the bounding box draws correctly. The more important question is whether the box, source object, extraction revision, and decision still describe the same event.

This is the invariant: a reviewer decision must point backward to fixed evidence and forward to a new revision. Never mutate the evidence underneath it.

How should a receipt review console link extracted text back to the source image?

Use three references, each with one job. A content identity selects the source image. An extraction revision selects the machine-produced metadata. A stable field ID selects one candidate and its geometry. The browser may scale or rotate the displayed image, but those presentation choices don't change the stored evidence.

For each extracted field, store the page number, the original pixel width and height, and a polygon or rectangle in source-image coordinates. Normalized coordinates in the closed range from 0 to 1 make display scaling straightforward, while the original dimensions let an operator reconstruct and validate the mapping. Keep the raw extracted text as evidence; put corrected text in the review decision. It costs a little more storage, but it prevents a correction from erasing what the extractor actually returned.

A useful review payload has enough context to answer four questions without another guess: which object was inspected, which extraction produced this field, where was it found, and what moderation policy applies? For a receipt, that usually means presenting the candidate value, its label, its location, and nearby visual context. It does not mean sending an entire mutable document through every queue hop.

The format of the source still matters. A review UI must choose an image representation its target browsers can decode; the MDN media formats guide is a practical compatibility reference for that decision. Preserve the canonical source separately from any review rendition so a codec or resizing change cannot silently redefine old evidence.

Small detail, big consequence.

Do not store only CSS pixels. A rectangle measured after responsive layout is tied to one viewport, zoom level, and orientation. The same receipt reopened on a narrower screen will highlight the wrong line. Instead, transform stored source coordinates into display coordinates at render time, then apply the same rotation and crop transform used by the image element.

The evidence contract survives retries and reprocessing

Retries happen.

The contract should distinguish identity from location. Object keys can be reassigned by an application; a content digest is a stronger check that the fetched bytes match the reviewed bytes. A field ID should remain stable inside one extraction revision, while a new extraction creates a new revision rather than recycling IDs. This makes stale decisions detectable instead of ambiguous.

Concern Persisted value Failure it prevents
Source identity Object reference plus content digest Reviewing replaced image bytes
Extraction identity Revision ID and extractor configuration revision Mixing fields from separate runs
Visual location Page, source dimensions, normalized polygon Viewport-dependent highlights
Review identity Decision ID, reviewer actor, policy revision Duplicate or unattributed moderation
Concurrency Expected review revision Silent last-write-wins corrections

Keep queue messages narrow: carry IDs and expected revisions, then load the authoritative record. Delivery can repeat. Processing should therefore claim a deterministic idempotency key, such as the tuple of review task ID and submitted decision ID, before changing state. If the key already committed, return the recorded outcome. Don't create a second decision.

Out-of-order delivery needs a separate guard. A decision created against review revision 7 must not overwrite revision 8 just because its message arrived later. Reject that state transition as a conflict and reload the current task. A conflict is not an extraction failure; it is proof that optimistic concurrency stopped stale work from becoming accepted truth.

Moderation coverage belongs in this contract too. Define which field classes require review, which confidence bands may be sampled, and which are blocked from downstream use until accepted. I'm not sure a single confidence threshold can serve every receipt program; the right boundary depends on the harm caused by a wrong total, merchant, date, or line item. Resolve that uncertainty with labeled review outcomes and a documented policy revision, not an undocumented UI default.

Review state is a state machine, not a checkbox

A practical state model can stay small: pending, claimed, accepted, corrected, rejected, and superseded. The allowed transitions matter more than the labels. Claiming should have a lease so abandoned tasks return to the queue. Accepting or correcting should require the extraction revision and current review revision the operator saw. Reprocessing should supersede outstanding tasks tied to older extraction evidence.

Now the trap.

Now consider a bounded failure scenario. A receipt is extracted, a review task is delivered twice, and two browser tabs open the same candidate total. The first tab corrects the value and commits revision 12. The delayed second tab tries to accept the original value against revision 11. If the service merely updates a row by task ID, the delayed click erases the correction. With an expected-revision check, it receives a conflict and must refresh. With a decision idempotency key, retrying the first submission returns its original result. Those are two controls for two different failure modes; one cannot substitute for the other.

Log transitions, not screenshots. An operational event should identify the review task, source digest, extraction revision, prior and next state, policy revision, decision ID, and outcome. Avoid placing full receipt text in routine logs. Metrics should expose queue age, claim expiration, conflict rate, duplicate-decision rate, and the share of policy-required fields that reached a terminal reviewed state. That final measure is moderation coverage; raw task throughput isn't.

The runbook should begin with invariants. Can every accepted field still load its source bytes? Does its geometry remain inside the source dimensions? Are required fields stuck outside a terminal state? Is one extraction revision feeding more than one active task for the same field? Dashboards are useful, but a periodic integrity scan catches relational drift that request counters cannot see.

A preventative Go path for geometry and decisions

The core path doesn't need a client SDK. It needs strict validation around boring types. The example below keeps coordinates normalized, checks source identity and revision at the boundary, and makes the decision ID part of the command. Persistence still needs a unique constraint on DecisionID and an atomic compare-and-swap on ExpectedReviewRevision; application checks alone are not enough under concurrency.

package review

import (
    "errors"
    "fmt"
)

type Point struct {
    X float64 `json:"x"`
    Y float64 `json:"y"`
}

type Evidence struct {
    SourceRef          string  `json:"source_ref"`
    SourceDigest       string  `json:"source_digest"`
    ExtractionRevision string `json:"extraction_revision"`
    FieldID            string  `json:"field_id"`
    Page               int     `json:"page"`
    SourceWidth        int     `json:"source_width"`
    SourceHeight       int     `json:"source_height"`
    Polygon            []Point `json:"polygon"`
    ExtractedText      string  `json:"extracted_text"`
}

type DecisionCommand struct {
    TaskID                 string `json:"task_id"`
    DecisionID             string `json:"decision_id"`
    ExpectedReviewRevision int64  `json:"expected_review_revision"`
    ExtractionRevision     string `json:"extraction_revision"`
    Action                 string `json:"action"`
    CorrectedText          string `json:"corrected_text,omitempty"`
    PolicyRevision         string `json:"policy_revision"`
}

func (e Evidence) Validate() error {
    if e.SourceRef == "" || e.SourceDigest == "" || e.ExtractionRevision == "" {
        return errors.New("source identity and extraction revision are required")
    }
    if e.Page < 1 || e.SourceWidth < 1 || e.SourceHeight < 1 {
        return errors.New("invalid source geometry")
    }
    if len(e.Polygon) < 3 {
        return errors.New("a field polygon needs at least three points")
    }
    for i, p := range e.Polygon {
        if p.X < 0 || p.X > 1 || p.Y < 0 || p.Y > 1 {
            return fmt.Errorf("polygon point %d is outside the source image", i)
        }
    }
    return nil
}

func DisplayPolygon(points []Point, width, height float64) ([]Point, error) {
    if width <= 0 || height <= 0 {
        return nil, errors.New("display dimensions must be positive")
    }
    out := make([]Point, len(points))
    for i, p := range points {
        out[i] = Point{X: p.X * width, Y: p.Y * height}
    }
    return out, nil
}
Enter fullscreen mode Exit fullscreen mode

Test this path with property checks: generated normalized points must remain within the rendered bounds at many display sizes. Add fixtures for rotation and cropping because the UI transform must match the overlay transform. Then test the service with repeated decision IDs, stale expected revisions, reprocessed extraction revisions, expired claims, and two concurrent decisions. The desired result isn't that every request succeeds. It is that exactly one valid transition commits and every retry observes a deterministic result.

No guessing.

Deployment deserves the same caution. Add the evidence columns first, write both old and new representations during a bounded migration, backfill with integrity checks, and switch reads only after coverage is measured. Do not manufacture geometry for historical fields when the original location was never retained; mark that evidence unavailable. An honest gap is safer than a precise-looking rectangle with no provenance.

When should this design give way to a simpler review flow?

The catch is the evidence model adds storage, migration work, concurrency handling, and UI transform tests. It is not suitable when reviewers only classify an entire image and never need field-level provenance; an immutable image reference plus one image-level decision is then enough. A polygon model is also the wrong fit for text whose meaningful source spans video time or audio intervals. Use time ranges, track identity, and frame references for those media types instead.

Stick with a document-native annotation model when the authoritative source already has stable page coordinates and text objects that must survive zoom and selection. Choose a richer region representation when curved text or non-rectangular areas affect the judgment. Your mileage may vary on polygons versus rectangles, but the decision rule is stable: store the least complex geometry that can reproduce what the reviewer saw without ambiguity.

For a receipt console, field-level source linkage earns its cost when corrected values drive refunds, accounting, fraud checks, or customer-support decisions. The operational test is blunt: if an on-call engineer cannot reconstruct why a value was accepted after retries, reprocessing, and a UI resize, the metadata inspection path is incomplete.

References

Top comments (0)