{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"extraction-review","type":"registry:block","title":"Structured Extraction & Review","description":"Upload a document, watch fields fill in, then review the flagged ones — per-field confidence, corrections that keep the original, export earned.","author":"Scrim UI (https://scrimui.dev)","categories":["pattern"],"docs":"https://scrimui.dev/patterns/extraction-review","dependencies":[],"registryDependencies":["https://scrimui.dev/r/file-upload.json","https://scrimui.dev/r/agent-status.json","https://scrimui.dev/r/inline-correction.json"],"files":[{"path":"src/showcase/patterns/extraction-review/extraction-review.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { FileUpload } from \"@/components/ui/file-upload\";\nimport { AgentStatus, type AgentState } from \"@/components/ui/agent-status\";\nimport { InlineCorrection } from \"@/components/ui/inline-correction\";\n\n/**\n * Structured extraction with a human review pass.\n *\n * The workflow this pattern exists to show:\n *\n * 1. **Fields land progressively.** The table renders the moment extraction\n *    starts; pending rows are honest placeholders, not a spinner over a\n *    blank page.\n * 2. **Confidence is per field, and it badges the risk — not the solid\n *    values.** High-confidence fields stay quiet; medium and low get the\n *    amber/red treatment borrowed from Confidence Answer, with the specific\n *    reason (\"smudged in scan\", \"ambiguous date format\").\n * 3. **A correction never destroys the extracted value.** The original\n *    stays visible under the human value — the audit trail is the point of\n *    the review pass.\n * 4. **Validation happens at the field, on the human's edit.** A bad value\n *    blocks that row, not the whole document.\n * 5. **Export is earned.** The button stays disabled until every flagged\n *    field is confirmed or corrected — \"ready to export\" is a state the\n *    interface computes, not a hope.\n *\n * Boundary with the Pro structured-extraction template: no schema-driven\n * generation, no partial-object streaming — mock state, scripted fill.\n */\n\n/* ------------------------------------------------------------------ */\n/* Field model                                                         */\n/* ------------------------------------------------------------------ */\n\ntype Confidence = \"high\" | \"medium\" | \"low\";\n\ntype Field = {\n  id: string;\n  label: string;\n  /** Extracted value. Undefined while extraction hasn't reached this row. */\n  value?: string;\n  confidence?: Confidence;\n  /** The specific thing to check — shown for medium/low, like a hedge. */\n  note?: string;\n  /** The human's correction. The extracted value is never mutated. */\n  corrected?: string;\n  error?: string;\n  validate?: (v: string) => string | undefined;\n};\n\nconst INITIAL_FIELDS: Field[] = [\n  { id: \"number\", label: \"Invoice number\" },\n  { id: \"vendor\", label: \"Vendor\" },\n  { id: \"issued\", label: \"Issue date\" },\n  { id: \"due\", label: \"Due date\" },\n  { id: \"taxid\", label: \"Tax ID\" },\n  {\n    id: \"total\",\n    label: \"Total due\",\n    validate: (v) => {\n      const n = Number(v.replace(/[,$\\s]/g, \"\"));\n      if (Number.isNaN(n) || v.trim() === \"\") return \"Enter a numeric amount, e.g. 1475.60\";\n      if (n <= 0) return \"Amount must be greater than zero\";\n      return undefined;\n    },\n  },\n];\n\nconst EXTRACTED: Record<string, { value: string; confidence: Confidence; note?: string }> = {\n  number: { value: \"INV-1042\", confidence: \"high\" },\n  vendor: { value: \"Acme Office Supplies\", confidence: \"high\" },\n  issued: { value: \"2026-08-14\", confidence: \"high\" },\n  due: { value: \"2026-09-13\", confidence: \"medium\", note: \"Date was printed as 08/09 — day/month order is ambiguous.\" },\n  taxid: { value: \"DE 3141 5926\", confidence: \"low\", note: \"Smudged in the scan — two digits uncertain.\" },\n  total: { value: \"1,475.60\", confidence: \"high\" },\n};\n\nconst TERMS_TEXT = \"Net 30. Late payments accrue 1.5% monthly interest.\";\n\nconst CONFIDENCE_STYLES: Record<\"medium\" | \"low\", { label: string; dot: string; text: string }> = {\n  medium: { label: \"Double-check\", dot: \"bg-amber-500\", text: \"text-amber-700 dark:text-amber-400\" },\n  low: { label: \"Treat as a guess\", dot: \"bg-red-500\", text: \"text-red-700 dark:text-red-400\" },\n};\n\n/* ------------------------------------------------------------------ */\n/* Pattern                                                             */\n/* ------------------------------------------------------------------ */\n\nexport function ExtractionReviewPattern() {\n  const [fields, setFields] = React.useState<Field[]>(INITIAL_FIELDS);\n  const [runState, setRunState] = React.useState<AgentState | \"idle\">(\"idle\");\n  const [editingId, setEditingId] = React.useState<string | null>(null);\n  const [draft, setDraft] = React.useState(\"\");\n  const [confirmed, setConfirmed] = React.useState<string[]>([]);\n  const [termsCorrection, setTermsCorrection] = React.useState<string | undefined>();\n  const [exported, setExported] = React.useState(false);\n  const timerRef = React.useRef<number | null>(null);\n\n  const filledCount = fields.filter((f) => f.value !== undefined).length;\n  const flagged = fields.filter(\n    (f) => f.value !== undefined && f.confidence !== \"high\" && !f.corrected && !confirmed.includes(f.id),\n  );\n  const errors = fields.filter((f) => f.error);\n  const readyToExport = runState === \"completed\" && flagged.length === 0 && errors.length === 0;\n  const correctedCount = fields.filter((f) => f.corrected).length + (termsCorrection ? 1 : 0);\n\n  function runExtraction() {\n    setRunState(\"running\");\n    setFields(INITIAL_FIELDS);\n    setConfirmed([]);\n    setExported(false);\n    let i = 0;\n    const ids = INITIAL_FIELDS.map((f) => f.id);\n    timerRef.current = window.setInterval(() => {\n      const id = ids[i];\n      const e = EXTRACTED[id];\n      setFields((fs) => fs.map((f) => (f.id === id ? { ...f, value: e.value, confidence: e.confidence, note: e.note } : f)));\n      i += 1;\n      if (i >= ids.length) {\n        if (timerRef.current) window.clearInterval(timerRef.current);\n        setRunState(\"completed\");\n      }\n    }, 550);\n  }\n\n  function startEdit(field: Field) {\n    setEditingId(field.id);\n    setDraft(field.corrected ?? field.value ?? \"\");\n  }\n\n  function commitEdit(field: Field) {\n    const v = draft.trim();\n    if (!v) {\n      setEditingId(null);\n      return;\n    }\n    const error = field.validate?.(v);\n    if (error) {\n      setFields((fs) => fs.map((f) => (f.id === field.id ? { ...f, error } : f)));\n      return;\n    }\n    setFields((fs) => fs.map((f) => (f.id === field.id ? { ...f, corrected: v, error: undefined } : f)));\n    setEditingId(null);\n  }\n\n  return (\n    <div className=\"flex h-[640px] overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900\">\n      {/* Documents rail */}\n      <aside className=\"hidden w-56 shrink-0 flex-col gap-3 overflow-y-auto border-r border-zinc-200 p-3 dark:border-zinc-800 md:flex\">\n        <p className=\"text-[13px] font-semibold text-zinc-900 dark:text-zinc-100\">Documents</p>\n        <FileUpload status=\"idle\" accept=\".pdf,.png,.jpg\" onSelect={() => {}} />\n        <ul className=\"space-y-1\">\n          <li className=\"flex items-center gap-2 rounded-lg bg-zinc-100 px-2.5 py-2 dark:bg-zinc-800\">\n            <span className=\"min-w-0 flex-1 truncate text-[13px] font-medium text-zinc-800 dark:text-zinc-100\">\n              invoice-1042.pdf\n            </span>\n            <span className=\"shrink-0 text-[11px] text-zinc-400\">1 page</span>\n          </li>\n        </ul>\n      </aside>\n\n      {/* Review table */}\n      <div className=\"flex min-w-0 flex-1 flex-col\">\n        <div className=\"border-b border-zinc-200 px-4 py-3 dark:border-zinc-800\">\n          <p className=\"text-sm font-semibold text-zinc-900 dark:text-zinc-100\">Extraction Review</p>\n          <p className=\"truncate text-xs text-zinc-500 dark:text-zinc-400\">\n            Fields land as they&apos;re read — confirm or correct the flagged ones\n          </p>\n        </div>\n\n        <div className=\"border-b border-zinc-100 px-4 py-3 dark:border-zinc-800/60\">\n          <AgentStatus\n            name=\"Extractor\"\n            status={runState === \"idle\" ? \"waiting\" : runState}\n            action={\n              runState === \"idle\"\n                ? \"Ready — run extraction on invoice-1042.pdf\"\n                : runState === \"running\"\n                  ? `Reading invoice-1042.pdf… ${filledCount} of ${fields.length} fields`\n                  : runState === \"completed\"\n                    ? `Extracted ${fields.length} fields — ${flagged.length} need review`\n                    : \"Extraction failed\"\n            }\n          />\n          {runState === \"idle\" && (\n            <button\n              type=\"button\"\n              onClick={runExtraction}\n              className=\"mt-2 rounded-lg bg-zinc-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300\"\n            >\n              Run extraction\n            </button>\n          )}\n        </div>\n\n        <div className=\"flex-1 overflow-y-auto px-4 py-3\">\n          <ul className=\"divide-y divide-zinc-100 dark:divide-zinc-800/60\">\n            {fields.map((f) => {\n              const displayValue = f.corrected ?? f.value;\n              const needsReview = f.value !== undefined && f.confidence !== \"high\" && !f.corrected && !confirmed.includes(f.id);\n              const style = f.confidence && f.confidence !== \"high\" ? CONFIDENCE_STYLES[f.confidence] : null;\n              return (\n                <li key={f.id} className=\"flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5\">\n                  <span className=\"w-28 shrink-0 text-xs font-medium text-zinc-500 dark:text-zinc-400\">{f.label}</span>\n\n                  {f.value === undefined ? (\n                    <span className=\"h-4 w-24 animate-pulse rounded bg-zinc-100 dark:bg-zinc-800\" aria-label=\"Extracting…\" />\n                  ) : editingId === f.id ? (\n                    <span className=\"flex min-w-0 flex-1 flex-wrap items-center gap-2\">\n                      <input\n                        autoFocus\n                        value={draft}\n                        onChange={(e) => setDraft(e.target.value)}\n                        onKeyDown={(e) => {\n                          if (e.key === \"Enter\") commitEdit(f);\n                          if (e.key === \"Escape\") setEditingId(null);\n                        }}\n                        aria-label={`Correct ${f.label}`}\n                        className=\"w-44 rounded-md border border-zinc-300 bg-white px-2 py-1 text-[13px] text-zinc-900 outline-none focus:border-zinc-500 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100\"\n                      />\n                      <button\n                        type=\"button\"\n                        onClick={() => commitEdit(f)}\n                        className=\"rounded-md bg-zinc-900 px-2 py-1 text-[11px] font-medium text-white dark:bg-zinc-100 dark:text-zinc-900\"\n                      >\n                        Save\n                      </button>\n                      <button\n                        type=\"button\"\n                        onClick={() => setEditingId(null)}\n                        className=\"text-[11px] text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300\"\n                      >\n                        Cancel\n                      </button>\n                    </span>\n                  ) : (\n                    <span className=\"min-w-0 flex-1\">\n                      <span className=\"text-[13px] font-medium text-zinc-900 dark:text-zinc-100\">{displayValue}</span>\n                      {f.corrected && (\n                        <span className=\"ml-2 text-[11px] text-zinc-400 dark:text-zinc-500\">\n                          was: <s>{f.value}</s>\n                        </span>\n                      )}\n                    </span>\n                  )}\n\n                  {f.value !== undefined && editingId !== f.id && (\n                    <span className=\"flex shrink-0 items-center gap-2\">\n                      {needsReview && style && (\n                        <>\n                          <span className={`inline-flex items-center gap-1 text-[11px] font-medium ${style.text}`}>\n                            <span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} />\n                            {style.label}\n                          </span>\n                          <button\n                            type=\"button\"\n                            onClick={() => setConfirmed((c) => [...c, f.id])}\n                            className=\"rounded-md border border-zinc-200 px-2 py-0.5 text-[11px] font-medium text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n                          >\n                            Confirm\n                          </button>\n                        </>\n                      )}\n                      {(f.corrected || confirmed.includes(f.id)) && (\n                        <span className=\"text-[11px] font-medium text-teal-600 dark:text-teal-400\">\n                          {f.corrected ? \"Corrected\" : \"Confirmed\"}\n                        </span>\n                      )}\n                      <button\n                        type=\"button\"\n                        onClick={() => startEdit(f)}\n                        className=\"rounded-md border border-zinc-200 px-2 py-0.5 text-[11px] font-medium text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n                      >\n                        Edit\n                      </button>\n                    </span>\n                  )}\n\n                  {(f.error || (needsReview && f.note)) && (\n                    <span className=\"w-full pl-28\">\n                      {f.error ? (\n                        <span role=\"alert\" className=\"text-[11px] text-red-600 dark:text-red-400\">{f.error}</span>\n                      ) : (\n                        <span className=\"text-[11px] text-zinc-400 dark:text-zinc-500\">{f.note}</span>\n                      )}\n                    </span>\n                  )}\n                </li>\n              );\n            })}\n          </ul>\n\n          {runState === \"completed\" && (\n            <div className=\"mt-3\">\n              <p className=\"mb-1 text-xs font-medium text-zinc-500 dark:text-zinc-400\">Payment terms (free text)</p>\n              <InlineCorrection\n                text={TERMS_TEXT}\n                correction={termsCorrection}\n                correctedBy=\"you\"\n                onSubmit={(v) => setTermsCorrection(v)}\n                onRevert={() => setTermsCorrection(undefined)}\n              />\n            </div>\n          )}\n        </div>\n\n        {/* Export footer — earned, not assumed */}\n        <div className=\"flex flex-wrap items-center justify-between gap-2 border-t border-zinc-200 px-4 py-3 dark:border-zinc-800\">\n          <p className=\"text-xs text-zinc-500 dark:text-zinc-400\">\n            {runState === \"completed\"\n              ? readyToExport\n                ? `${fields.length} fields · ${correctedCount} corrected · ready to export`\n                : `${flagged.length} flagged · ${errors.length} invalid — resolve before export`\n              : \"Run extraction to begin review\"}\n          </p>\n          <button\n            type=\"button\"\n            disabled={!readyToExport || exported}\n            onClick={() => setExported(true)}\n            className=\"rounded-lg bg-zinc-900 px-3.5 py-1.5 text-xs font-medium text-white transition-colors enabled:hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-zinc-100 dark:text-zinc-900 dark:enabled:hover:bg-zinc-300\"\n          >\n            {exported ? \"Exported ✓\" : \"Export JSON\"}\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n","type":"registry:block","target":"components/blocks/extraction-review.tsx"}]}