AI Extraction Review

Check extracted fields against the document they came from, and decide each one.

Invoice 4471 from Northwind Traders

0 of 9 reviewed · 2 without evidence

NORTHWIND TRADERS
14 Harbour Street, Portsmouth PO1 3AX

INVOICE 4471
Issued 1 March 2026
Due 31 March 2026

Bill to: Acme Ltd, 8 Lower Ground, Leeds LS1 4AP

Description                          Qty      Amount
Structural dowels, oak, 8 mm       1,200     £840.00
Delivery                                1     £400.00

Subtotal                                    £1,240.00
VAT (20%)                                     £248.00
Total due                                   £1,488.00

Pay within 30 days by bank transfer.
Sort code 40-12-76, account 91827364.
  1. Not reviewed

    In the source: “NORTHWIND TRADERS”

  2. Not reviewed

    In the source: “4471”

  3. Not reviewed

    In the source: “1 March 2026”

  4. Not reviewed

    In the source: “31 March 2026”

  5. Not reviewed

    In the source: “Acme Ltd, 8 Lower Ground, Leeds LS1 4AP”

  6. Not reviewed

    In the source: “£1,240.00”

    97% confidence

  7. Not reviewed

    In the source: “£1,488.00”

    99% confidence

  8. Not reviewed

    Not in the source — the model supplied this without evidence.

  9. Not reviewed

    Not in the source — the model supplied this without evidence.

    Low confidence, 31% — worth checking

Installation

Terminal
pnpm dlx @dowel-ui/cli add ai-extraction-review

No npm packages are needed beyond what Dowel already requires.

Accessibility

Every field quotes its evidence in text under the value, so a reviewer who cannot see the highlight in the source still has what the model read. A value with no evidence is said in words — never left to look like a good one. The source is a focusable named region because it scrolls, and its highlights are mark elements with nothing spliced into the text, since a document read aloud with field names inserted is no longer the document. Focus anywhere in a field brings its evidence into view without moving focus, so a keyboard user is shown the source rather than sent into it. Each control's name carries the field it belongs to, so ten Accept buttons are ten different buttons. Status is a word, outside the label so editing never renames the control, and the running count is a polite live region present from the start. Enter accepts, except while an IME is composing.

Props

ExtractionReview

PropTypeDefault
fields (required)ExtractionField[]
heading (required)

What is being reviewed, as the heading.

string
source (required)

What the model read. Every span indexes into it.

string
decisions

Per-field decisions, keyed by field name. Controlled.

Record<string, FieldDecision>{}
lowConfidenceBelow

Confidence below this is called low, in words.

number0.7
onDecision

Omit to render the review as a record rather than something to operate.

(name: string, decision: FieldDecision) => void
sourceLabel

Names the source — "Invoice 4471.pdf, page 1".

string"Source"
streaming

True while fields are still arriving. Missing values wait instead of reading as not found.

booleanfalse

Plus every attribute of <section> except children.

Quality

10/10 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Source

This is exactly what dowel add ai-extraction-review writes into your project, with imports rewritten to your own path alias.

ui/extraction-model.ts
/**
 * The model behind a review of extracted fields: where each value was read
 * from, what the reviewer decided about it, and how far through the review
 * they are.
 *
 * Pure, so overlap handling and the completion rule are tested without
 * rendering anything — and so an application can answer "is this review
 * finished" on the server from the same decisions the component reports,
 * rather than trusting a flag the client sent.
 */

export interface SourceSpan {
  /** Offset into the source text in UTF-16 code units. Inclusive. */
  start: number;
  /** Exclusive. */
  end: number;
}

export interface ExtractionField {
  /** Key in the extracted object. */
  name: string;
  label: string;
  /**
   * What the model extracted. Absent while streaming means it has not arrived;
   * absent once streaming has ended means the model found nothing.
   */
  value?: string;
  /**
   * Where in the source the value was read from. Absent means the model
   * supplied the value without evidence, which the review says outright.
   */
  span?: SourceSpan;
  /** 0 to 1. Shown as a number, never only as a colour. */
  confidence?: number;
  /** A textarea instead of a single line. For addresses and descriptions. */
  multiline?: boolean;
}

export type FieldDecision =
  /** The model's value, taken as it was. */
  | { kind: "accepted"; value: string }
  /** The reviewer's value, with the model's kept so a record can tell them apart. */
  | { kind: "corrected"; value: string; proposed: string }
  /** Wrong and not corrected — the field does not apply, or cannot be known. */
  | { kind: "rejected" };

/** A run of source text and the fields whose evidence covers it. */
export interface SourceRun {
  text: string;
  /** Field names, in declaration order. Empty for text nobody cites. */
  fields: string[];
}

/**
 * A span brought within the source, or null if nothing of it is.
 *
 * Offsets come from a model or an OCR layer and are wrong often enough that
 * refusing to render on a bad one would leave the whole review blank. A span
 * past the end is clamped; an inverted or empty one is treated as no evidence,
 * which is what it is.
 */
export function clampSpan(span: SourceSpan, length: number): SourceSpan | null {
  const start = Math.max(0, Math.min(span.start, length));
  const end = Math.max(0, Math.min(span.end, length));
  return end > start ? { start, end } : null;
}

/** The text a span points at, or an empty string if it points at nothing. */
export function evidenceOf(source: string, span: SourceSpan | undefined): string {
  if (!span) return "";
  const clamped = clampSpan(span, source.length);
  return clamped ? source.slice(clamped.start, clamped.end) : "";
}

/** Whether a field's value can be checked against the source at all. */
export function isSourced(source: string, field: ExtractionField): boolean {
  return field.span !== undefined && clampSpan(field.span, source.length) !== null;
}

/**
 * Cuts the source into runs at every span boundary, so overlapping evidence —
 * a total that sits inside the line that contains it — renders as nested
 * coverage rather than as two marks fighting over the same characters.
 */
export function segmentSource(source: string, fields: ExtractionField[]): SourceRun[] {
  const spans = fields.flatMap((field) => {
    const clamped = field.span ? clampSpan(field.span, source.length) : null;
    return clamped ? [{ name: field.name, ...clamped }] : [];
  });

  if (source.length === 0) return [];
  if (spans.length === 0) return [{ text: source, fields: [] }];

  const cuts = new Set<number>([0, source.length]);
  for (const span of spans) {
    cuts.add(span.start);
    cuts.add(span.end);
  }
  const points = [...cuts].sort((a, b) => a - b);

  const runs: SourceRun[] = [];
  for (let index = 0; index < points.length - 1; index += 1) {
    const start = points[index] ?? 0;
    const end = points[index + 1] ?? start;
    if (end <= start) continue;
    runs.push({
      text: source.slice(start, end),
      fields: spans.filter((span) => span.start <= start && span.end >= end).map((s) => s.name),
    });
  }
  return runs;
}

export interface ReviewSummary {
  total: number;
  reviewed: number;
  accepted: number;
  corrected: number;
  rejected: number;
  /** Fields with a value the model could not point to in the source. */
  unsourced: string[];
  /** Fields still awaiting a decision. */
  remaining: string[];
  /** Every field has a decision. The only completion signal worth trusting. */
  complete: boolean;
}

export function summarizeReview(
  source: string,
  fields: ExtractionField[],
  decisions: Record<string, FieldDecision> = {},
): ReviewSummary {
  const summary: ReviewSummary = {
    total: fields.length,
    reviewed: 0,
    accepted: 0,
    corrected: 0,
    rejected: 0,
    unsourced: [],
    remaining: [],
    complete: false,
  };

  for (const field of fields) {
    if (field.value !== undefined && !isSourced(source, field)) {
      summary.unsourced.push(field.name);
    }
    const decision = decisions[field.name];
    if (!decision) {
      summary.remaining.push(field.name);
      continue;
    }
    summary.reviewed += 1;
    summary[decision.kind] += 1;
  }

  summary.complete = fields.length > 0 && summary.remaining.length === 0;
  return summary;
}
ui/ai-extraction-review.tsx
"use client";

import {
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type ComponentPropsWithRef,
  type KeyboardEvent,
  type ReactNode,
} from "react";

import { disabledStyles, focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

import {
  evidenceOf,
  isSourced,
  segmentSource,
  summarizeReview,
  type ExtractionField,
  type FieldDecision,
  type SourceRun,
} from "./extraction-model";

/**
 * The check after extraction: the document on one side, what the model read
 * out of it on the other, and a decision about every field.
 *
 * Every extraction demo shows the filled object and stops. The products that
 * run extraction for a living — invoice capture, KYC onboarding, claims intake —
 * all have this screen, each built from scratch, because a value that cannot
 * be checked can only be trusted, and nobody who is paying an invoice trusts a
 * model that far. No component library ships it.
 *
 * The link is the component. Each field carries where in the source it was
 * read from, quoted in text under the value as well as highlighted in the
 * document, so a reviewer who cannot see the highlight still has the evidence.
 * And a value with no evidence is said outright — "the model supplied this
 * without evidence" — because that is the case a review exists to catch, and
 * the one every filled-object view renders identically to a good value.
 *
 * Evidence is a text offset, not a bounding box. A language model reads text,
 * and a text layer with offsets is what every OCR pipeline already yields.
 * Boxes over a rendered page need page rendering, zoom and geometry, and are a
 * different component; this one does not pretend at them.
 *
 * Decisions are controlled and the component writes nothing. It reports that
 * a field was accepted as proposed, corrected from what was proposed, or
 * rejected — a richer record than a form's submitted values, and the one an
 * audit later needs — and leaves what to do with it to the application.
 */

export interface ExtractionReviewProps extends Omit<
  ComponentPropsWithRef<"section">,
  "children"
> {
  /** What the model read. Every span indexes into it. */
  source: string;
  /** Names the source — "Invoice 4471.pdf, page 1". */
  sourceLabel?: string;
  /** What is being reviewed, as the heading. */
  heading: string;
  fields: ExtractionField[];
  /** Per-field decisions, keyed by field name. Controlled. */
  decisions?: Record<string, FieldDecision>;
  /** Omit to render the review as a record rather than something to operate. */
  onDecision?: (name: string, decision: FieldDecision) => void;
  /** True while fields are still arriving. Missing values wait instead of reading as not found. */
  streaming?: boolean;
  /** Confidence below this is called low, in words. */
  lowConfidenceBelow?: number;
  children?: ReactNode;
}

export function ExtractionReview({
  className,
  source,
  sourceLabel = "Source",
  heading,
  fields,
  decisions = {},
  onDecision,
  streaming = false,
  lowConfidenceBelow = 0.7,
  children,
  ...props
}: ExtractionReviewProps) {
  const headingId = useId();
  const [activeField, setActiveField] = useState<string | null>(null);

  const runs = useMemo(() => segmentSource(source, fields), [source, fields]);
  const summary = useMemo(
    () => summarizeReview(source, fields, decisions),
    [source, fields, decisions],
  );

  return (
    <section
      data-slot="extraction-review"
      data-complete={summary.complete || undefined}
      aria-labelledby={headingId}
      aria-busy={streaming || undefined}
      className={cn("flex flex-col gap-3", className)}
      {...props}
    >
      <div className="flex flex-wrap items-baseline justify-between gap-2">
        <h3 id={headingId} className="text-sm font-medium">
          {heading}
        </h3>
        {/* Live from the start and short, so each decision is followed by
            where the review stands rather than by silence. Evidence gaps are
            counted here too: they are the reason to read carefully, and worth
            knowing before starting rather than discovering on field six. */}
        <p
          data-slot="extraction-review-summary"
          aria-live="polite"
          className="text-xs text-muted-foreground"
        >
          {streaming
            ? "Fields are still arriving"
            : `${String(summary.reviewed)} of ${String(summary.total)} reviewed`}
          {summary.unsourced.length > 0
            ? ` · ${String(summary.unsourced.length)} without evidence`
            : null}
        </p>
      </div>

      {children}

      <div className="grid gap-4 md:grid-cols-2">
        <SourcePanel
          label={sourceLabel}
          runs={runs}
          active={activeField}
          decisions={decisions}
        />

        <ol data-slot="extraction-fields" className="m-0 flex list-none flex-col gap-3 p-0">
          {fields.map((field) => (
            <ReviewField
              key={field.name}
              field={field}
              source={source}
              decision={decisions[field.name]}
              streaming={streaming}
              lowConfidenceBelow={lowConfidenceBelow}
              active={activeField === field.name}
              onActivate={() => {
                setActiveField(field.name);
              }}
              onDecision={onDecision}
            />
          ))}
        </ol>
      </div>
    </section>
  );
}

function SourcePanel({
  label,
  runs,
  active,
  decisions,
}: {
  label: string;
  runs: SourceRun[];
  active: string | null;
  decisions: Record<string, FieldDecision>;
}) {
  const ref = useRef<HTMLDivElement>(null);

  // Bring the active field's evidence into view. Focus stays where it is —
  // in the field — so a keyboard user is shown the source without being
  // moved into it.
  useEffect(() => {
    if (!active || !ref.current) return;
    const mark = ref.current.querySelector(`mark[data-active]`);
    mark?.scrollIntoView({ block: "nearest" });
  }, [active]);

  return (
    <div
      ref={ref}
      data-slot="extraction-source"
      // A scrolling box has to be focusable to be scrolled by keyboard, and a
      // focusable box needs a name.
      role="region"
      aria-label={label}
      tabIndex={0}
      // Sticks beside the fields on a wide screen, so the evidence for field
      // nine is still in view when the reviewer reaches it.
      className={cn(
        "max-h-96 overflow-auto rounded-lg border border-border bg-muted/30 p-3 md:sticky md:top-0 md:self-start",
        focusRing,
      )}
    >
      {runs.length === 0 ? (
        <p className="text-xs text-muted-foreground">No source text.</p>
      ) : (
        <pre className="m-0 font-sans text-sm leading-relaxed break-words whitespace-pre-wrap">
          {runs.map((run, index) =>
            run.fields.length === 0 ? (
              <span key={index}>{run.text}</span>
            ) : (
              // A mark element, so the evidence survives as structure rather
              // than only as a tint. No label is inserted into the text: a
              // document read aloud with field names spliced into it is not
              // the document, and the field already quotes its evidence.
              <mark
                key={index}
                data-fields={run.fields.join(" ")}
                data-active={active !== null && run.fields.includes(active) ? "" : undefined}
                className={cn(
                  "rounded-[2px] text-inherit",
                  run.fields.every((name) => decisions[name]?.kind === "rejected")
                    ? "bg-muted line-through decoration-muted-foreground"
                    : "bg-primary/15",
                  active !== null &&
                    run.fields.includes(active) &&
                    "bg-primary/30 ring-1 ring-primary ring-inset",
                )}
              >
                {run.text}
              </mark>
            ),
          )}
        </pre>
      )}
    </div>
  );
}

/** Whitespace collapsed and cut short, for quoting evidence under a field. */
function quote(text: string, limit = 80): string {
  const collapsed = text.replace(/\s+/g, " ").trim();
  return collapsed.length > limit ? `${collapsed.slice(0, limit - 1)}…` : collapsed;
}

function ReviewField({
  field,
  source,
  decision,
  streaming,
  lowConfidenceBelow,
  active,
  onActivate,
  onDecision,
}: {
  field: ExtractionField;
  source: string;
  decision?: FieldDecision;
  streaming: boolean;
  lowConfidenceBelow: number;
  active: boolean;
  onActivate: () => void;
  onDecision?: (name: string, decision: FieldDecision) => void;
}) {
  const inputId = useId();
  const evidenceId = useId();
  const statusId = useId();
  const confidenceId = useId();

  // The reviewer's typing, kept apart from the model's value so the decision
  // can say which is which.
  const [draft, setDraft] = useState<string | null>(null);

  const proposed = field.value ?? "";
  const decided = decision && decision.kind !== "rejected" ? decision.value : undefined;
  const current = draft ?? decided ?? proposed;
  const edited = current !== proposed;
  const changedSince = decided !== undefined && current !== decided;

  const arriving = streaming && field.value === undefined;
  const sourced = isSourced(source, field);
  const evidence = sourced ? evidenceOf(source, field.span) : "";
  const unsourced = !arriving && field.value !== undefined && !sourced;

  const accept = () => {
    onDecision?.(
      field.name,
      edited
        ? { kind: "corrected", value: current, proposed }
        : { kind: "accepted", value: current },
    );
  };

  const onKeyDown = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    if (event.key !== "Enter") return;
    // Enter confirms an IME candidate before it means anything else. Without
    // this, a reviewer typing a Japanese correction accepts a fragment of it.
    if (event.nativeEvent.isComposing) return;
    if (field.multiline && !(event.ctrlKey || event.metaKey)) return;
    event.preventDefault();
    accept();
  };

  const status = arriving
    ? "Arriving"
    : !decision
      ? "Not reviewed"
      : decision.kind === "rejected"
        ? "Rejected"
        : changedSince
          ? `Changed since it was ${decision.kind}`
          : decision.kind === "accepted"
            ? "Accepted"
            : decision.proposed
              ? `Corrected from “${quote(decision.proposed, 40)}”`
              : "Supplied by the reviewer";

  const low = field.confidence !== undefined && field.confidence < lowConfidenceBelow;
  const percent =
    field.confidence === undefined
      ? null
      : new Intl.NumberFormat(undefined, { style: "percent", maximumFractionDigits: 0 }).format(
          field.confidence,
        );

  const describedBy = [evidenceId, statusId, percent !== null ? confidenceId : null]
    .filter(Boolean)
    .join(" ");

  const controlClass = cn(
    "w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm",
    edited && "border-warning",
    focusRing,
  );

  return (
    <li
      data-slot="extraction-field"
      data-field={field.name}
      data-state={arriving ? "arriving" : (decision?.kind ?? "undecided")}
      data-active={active || undefined}
      data-unsourced={unsourced || undefined}
      // Focus anywhere in the field — value, either button — is the reviewer
      // looking at it, so the source follows without a separate step.
      onFocus={onActivate}
      className={cn(
        "flex flex-col gap-1 rounded-lg border p-3",
        active ? "border-primary" : "border-border",
        decision?.kind === "rejected" && "bg-muted/40",
      )}
    >
      <div className="flex flex-wrap items-baseline justify-between gap-2">
        {onDecision && !arriving ? (
          <label htmlFor={inputId} className="text-xs font-medium text-muted-foreground">
            {field.label}
          </label>
        ) : (
          <span className="text-xs font-medium text-muted-foreground">{field.label}</span>
        )}
        {/* Status as a word, outside the label so editing never renames the
            control under a screen reader user. */}
        <span
          id={statusId}
          data-slot="extraction-field-status"
          className={cn(
            "text-xs",
            decision?.kind === "rejected" ? "text-destructive" : "text-muted-foreground",
            changedSince && "text-warning",
          )}
        >
          {status}
        </span>
      </div>

      {arriving ? (
        <span
          aria-hidden="true"
          className="block h-8 w-full animate-pulse-soft rounded-md bg-muted"
        />
      ) : !onDecision ? (
        <p className="rounded-md bg-muted px-2 py-1.5 text-sm break-words">
          {current || <span className="text-muted-foreground">Empty</span>}
        </p>
      ) : field.multiline ? (
        <textarea
          id={inputId}
          rows={3}
          value={current}
          aria-describedby={describedBy}
          onChange={(event) => {
            setDraft(event.target.value);
          }}
          onKeyDown={onKeyDown}
          className={cn(controlClass, "resize-none")}
        />
      ) : (
        <input
          id={inputId}
          type="text"
          value={current}
          aria-describedby={describedBy}
          onChange={(event) => {
            setDraft(event.target.value);
          }}
          onKeyDown={onKeyDown}
          className={controlClass}
        />
      )}

      {/* The evidence in text. This is the accessible path to the source, and
          for a sighted reviewer it is the comparison itself — "1 March 2026"
          beside "2026-03-01" is a normalisation, not an error, and only reads
          that way with both in view. */}
      <p
        id={evidenceId}
        data-slot="extraction-field-evidence"
        className={cn("text-xs", unsourced ? "text-warning" : "text-muted-foreground")}
      >
        {arriving
          ? "Waiting for the model"
          : sourced
            ? `In the source: “${quote(evidence)}”`
            : field.value === undefined
              ? "The model found nothing for this field."
              : "Not in the source — the model supplied this without evidence."}
      </p>

      {percent !== null ? (
        <p
          id={confidenceId}
          data-slot="extraction-field-confidence"
          data-low={low || undefined}
          className={cn("text-xs", low ? "text-warning" : "text-muted-foreground")}
        >
          {low ? `Low confidence, ${percent} — worth checking` : `${percent} confidence`}
        </p>
      ) : null}

      {onDecision && !arriving ? (
        <div className="mt-1 flex flex-wrap items-center gap-2">
          <FieldButton
            variant="primary"
            aria-label={`${edited ? "Accept correction" : "Accept"} — ${field.label}`}
            aria-pressed={
              decision !== undefined && decision.kind !== "rejected" && !changedSince
            }
            onClick={accept}
          >
            {edited ? "Accept correction" : "Accept"}
          </FieldButton>
          <FieldButton
            aria-label={`Reject — ${field.label}`}
            aria-pressed={decision?.kind === "rejected"}
            onClick={() => {
              onDecision(field.name, { kind: "rejected" });
            }}
          >
            Reject
          </FieldButton>
          {sourced ? (
            <FieldButton
              aria-label={`Show in source — ${field.label}`}
              className="ms-auto"
              onClick={onActivate}
            >
              Show in source
            </FieldButton>
          ) : null}
        </div>
      ) : null}
    </li>
  );
}

function FieldButton({
  className,
  variant = "default",
  ...props
}: ComponentPropsWithRef<"button"> & { variant?: "default" | "primary" }) {
  return (
    <button
      type="button"
      className={cn(
        "rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
        variant === "primary" &&
          "border-primary bg-primary text-primary-foreground hover:bg-primary-hover aria-pressed:bg-primary-active",
        variant === "default" &&
          "border-input bg-background hover:bg-accent hover:text-accent-foreground aria-pressed:bg-secondary aria-pressed:text-secondary-foreground",
        focusRing,
        disabledStyles,
        className,
      )}
      {...props}
    />
  );
}