From 0b4c5576e458a508a5723207fa09d6f6e9fb6203 Mon Sep 17 00:00:00 2001 From: Yashas Date: Mon, 17 Aug 2026 17:27:54 +0530 Subject: [PATCH] feat: OCR-driven capture and document upload --- src/api/client.ts | 63 +++++++++- src/api/types.ts | 17 +++ src/components/ActivityForm.tsx | 205 +++++++++++++++++++++++++++---- src/components/EvidencePanel.tsx | 59 ++++++--- 4 files changed, 302 insertions(+), 42 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 4ed6c66..14206c6 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -6,12 +6,13 @@ import type { DetailViewResponse, FormScreenResponse, LoginResponse, + OcrResult, RecordViewParams, RecordViewResponse, Row, User, } from './types'; -import { APP_ID } from './config'; +import { APP_ID, WORKFLOW } from './config'; const TOKEN_KEY = 'hdfc_loan_desk_token'; @@ -187,6 +188,66 @@ export class ZinoClient { }); } + // --- Document intelligence ---------------------------------------------- + + /** + * Run OCR on an uploaded document for an `ocr` field. + * + * The extraction contract is NOT sent from here. The endpoint resolves the + * field's `ocr_config` server-side from the deployed workflow config and + * ignores anything a client supplies — so which values come back is decided + * by the field's configuration, and the response keys are exactly that + * config's `extraction_fields[].key`. + * + * `instanceId` is omitted on an INIT activity, where no instance exists yet. + * + * This deliberately bypasses `request()`: it must NOT set Content-Type. The + * browser has to write `multipart/form-data; boundary=…` itself, and setting + * that header by hand omits the boundary, after which the server rejects the + * body as malformed multipart. + */ + async ocrExtract( + activityUid: string, + fieldId: string, + file: File, + instanceId?: number | string, + ): Promise { + const fd = new FormData(); + fd.append('file', file); + fd.append('workflow_uuid', WORKFLOW); + fd.append('activity_id', activityUid); + fd.append('field_id', fieldId); + if (instanceId != null && instanceId !== '') { + fd.append('instance_id', String(numericId(instanceId))); + } + + const headers: Record = {}; + if (this.token) headers['Authorization'] = `Bearer ${this.token}`; + + const res = await fetch(`${this.baseUrl}/app/${APP_ID}/ocr-extract`, { + method: 'POST', + headers, + body: fd, + }); + + if (res.status === 401) { + this.setToken(null); + this.onAuthError?.(); + throw { status: 401, message: 'Session expired — sign in again' } as ApiError; + } + if (!res.ok) { + let message = res.statusText; + try { + const j = (await res.json()) as { error?: string; message?: string }; + message = j.error || j.message || message; + } catch { + /* non-JSON body */ + } + throw { status: res.status, message } as ApiError; + } + return (await res.json()) as OcrResult; + } + // --- Workflow execution ------------------------------------------------- /** Start a new instance (an INIT activity such as Register Flight). */ diff --git a/src/api/types.ts b/src/api/types.ts index 02a2948..a9c0c86 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -115,6 +115,23 @@ export interface FormScreenResponse { field_defaults?: Record; } +/** + * What `/ocr-extract` answers. + * + * `extracted` is keyed by the field's configured `extraction_fields[].key`, and + * those keys are deliberately named after the form fields they populate — so + * merging is a key-for-key copy with no translation table to drift. + * + * `parse_error` arrives WITH a 200: the model answered but its output was not + * parseable as the requested JSON. `raw` still carries the text, so the operator + * sees what came back instead of losing it. + */ +export interface OcrResult { + extracted?: Record; + raw?: string; + parse_error?: string; +} + export interface ActivityResult { success?: boolean; message?: string; diff --git a/src/components/ActivityForm.tsx b/src/components/ActivityForm.tsx index 7e30c9e..a3b838e 100644 --- a/src/components/ActivityForm.tsx +++ b/src/components/ActivityForm.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react'; +import { FileText, Loader2, Upload } from 'lucide-react'; import { useQuery, useZino } from '../api/provider'; import { Button, ErrorNote, Input, Label, Modal, Select, Spinner, Textarea } from './core'; import type { FormField } from '../api/types'; @@ -56,14 +57,7 @@ export function ActivityForm({ const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); - const all = schema.data?.fields ?? []; - - // OCR fields carry a document upload plus an extracted payload. The - // extraction pipeline is not wired in this build, so rather than render a - // control that pretends to work — or silently drop the field, which is how - // submissions quietly lose data — they render as a visible pending row. - const fields = all.filter((f) => f.data_type !== 'ocr'); - const pendingDocs = all.filter((f) => f.data_type === 'ocr'); + const fields = schema.data?.fields ?? []; const initial = useMemo( () => ({ ...(schema.data?.field_defaults ?? {}), ...(seed ?? {}) }), @@ -87,6 +81,44 @@ export function ActivityForm({ setErr(null); } + /** + * Merge an OCR result into the form. + * + * The response keys are the field ids they belong to — that is arranged in + * the field's ocr_config, precisely so this needs no mapping table. What it + * DOES need is coercion: the extractor returns JSON, and a number field + * handed the string "1500000" would submit a string into an integer column. + * So each value is cast to the target field's own data_type, and anything + * with no matching field on this form is ignored rather than smuggled into + * the payload. + */ + function mergeExtracted(extracted: Record): string[] { + const byId = new Map(fields.map((f) => [f.id, f])); + const filled: string[] = []; + const next: Record = {}; + + for (const [key, raw] of Object.entries(extracted)) { + const target = byId.get(key); + if (!target) continue; + if (raw === null || raw === undefined || raw === '') continue; + + if (target.data_type === 'number') { + // Strip anything a model might helpfully add — "Rs.", commas, spaces. + const n = Number(String(raw).replace(/[^0-9.-]/g, '')); + if (!Number.isFinite(n)) continue; + next[key] = n; + } else if (target.data_type === 'boolean') { + next[key] = raw === true || String(raw).toLowerCase() === 'true'; + } else { + next[key] = String(raw); + } + filled.push(target.name); + } + + if (Object.keys(next).length) setValues((s) => ({ ...s, ...next })); + return filled; + } + async function submit() { setTouched(true); if (missing.length) return; @@ -121,7 +153,7 @@ export function ActivityForm({
{err && {err}} - {fields.length === 0 && pendingDocs.length === 0 && ( + {fields.length === 0 && (

This activity takes no input — submitting records it as performed.

@@ -136,24 +168,13 @@ export function ActivityForm({ readOnly={seed ? Object.prototype.hasOwnProperty.call(seed, f.id) : false} error={touched && missing.includes(f.id) ? 'Required' : null} onChange={(v) => setValues((s) => ({ ...s, [f.id]: v }))} + activityUid={activityUid} + instanceId={instanceId} + onExtract={mergeExtracted} /> ))}
- {pendingDocs.length > 0 && ( -
-

- Document uploads -

-

- {pendingDocs.map((f) => f.name).join(' · ')} -

-

- Extraction is not wired in this build — enter the extracted figures above. -

-
- )} -
); } + +/** + * The document-intelligence control. + * + * Pick a file, and the values it contains land in the other fields on this + * form. What it extracts is not this component's decision — the field's + * `ocr_config` on the server decides that, which is why there is no field list + * anywhere in here. + * + * Three states worth designing for, not two: + * * read the call takes 5-20 seconds against a vision model, so the + * control says what it is doing rather than looking hung + * * filled it names EVERY field it populated. On the application form + * that is fifteen at once, and the operator has to be able to + * check the machine rather than trust it + * * partial `parse_error` arrives WITH a 200 — the model answered but not + * in the requested shape. That is a different failure from a + * network error and it says so, because the fix is different + */ +function OcrUpload({ + field, + activityUid, + instanceId, + onExtract, + onFilename, + filename, +}: { + field: FormField; + activityUid: string; + instanceId?: number | string; + onExtract: (extracted: Record) => string[]; + onFilename: (v: unknown) => void; + filename: string; +}) { + const { client } = useZino(); + const [busy, setBusy] = useState(false); + const [filled, setFilled] = useState(null); + const [warn, setWarn] = useState(null); + const [err, setErr] = useState(null); + + async function run(file: File) { + setBusy(true); + setErr(null); + setWarn(null); + setFilled(null); + try { + const res = await client.ocrExtract(activityUid, field.id, file, instanceId); + onFilename(file.name); + const names = onExtract(res.extracted ?? {}); + setFilled(names); + if (res.parse_error) { + setWarn(`The document was read but the response could not be parsed fully: ${res.parse_error}`); + } else if (names.length === 0) { + setWarn('Nothing was extracted. Check this is the right document for this field.'); + } + } catch (e) { + setErr((e as { message?: string })?.message ?? 'Could not read the document'); + } finally { + setBusy(false); + } + } + + return ( +
+ +
+
+ + + {busy && ( + + + Reading the document… + + )} + + {!busy && filename && ( + + + {filename} + + )} +
+ + {err &&

{err}

} + {warn &&

{warn}

} + + {filled && filled.length > 0 && ( +
+

+ Filled {filled.length} {filled.length === 1 ? 'field' : 'fields'} from this document +

+

{filled.join(' · ')}

+
+ )} +
+
+ ); +} diff --git a/src/components/EvidencePanel.tsx b/src/components/EvidencePanel.tsx index 4c9955e..05542cd 100644 --- a/src/components/EvidencePanel.tsx +++ b/src/components/EvidencePanel.tsx @@ -117,7 +117,9 @@ export function EvidencePanel({ row }: { row: Row }) { const variance = num(row.income_variance_pct); const varianceBreach = variance !== null && variance > POLICY.varianceTolerancePct; + const isPersonal = str(row.loan_product_family) === 'personal'; const dscr = num(row.dscr); + const foir = num(row.foir); const maxElig = num(row.max_eligible_amount); const requested = num(row.requested_amount); const bureau = num(row.bureau_score); @@ -197,18 +199,35 @@ export function EvidencePanel({ row }: { row: Row }) { subtitle={`Server-side, reproducible from stored evidence · policy ${POLICY.version}`} >
- = POLICY.dscrFloor} - /> - + {/* Which capacity test GATES the file depends on the product, and only + the gating one is shown as a pass/fail. Both figures are computed + for every file, but a DSCR on a salaried personal loan is not a + test anybody applies — showing it with a cross beside it would + have a reader querying a number that decided nothing. */} + {isPersonal ? ( + + ) : ( + <> + = POLICY.dscrFloor} + /> + + + )} = POLICY.bureauFloor} /> - + {/* Commercial bureau only. A personal file has no MSME rank, and a + row reading "CMR-0 —" is noise on a screen built to be scanned. */} + {!isPersonal && ( + + )}