feat: OCR-driven capture and document upload

This commit is contained in:
Yashas 2026-08-17 17:27:54 +05:30
parent 602b570064
commit 0b4c5576e4
4 changed files with 302 additions and 42 deletions

View File

@ -6,12 +6,13 @@ import type {
DetailViewResponse, DetailViewResponse,
FormScreenResponse, FormScreenResponse,
LoginResponse, LoginResponse,
OcrResult,
RecordViewParams, RecordViewParams,
RecordViewResponse, RecordViewResponse,
Row, Row,
User, User,
} from './types'; } from './types';
import { APP_ID } from './config'; import { APP_ID, WORKFLOW } from './config';
const TOKEN_KEY = 'hdfc_loan_desk_token'; 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<OcrResult> {
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<string, string> = {};
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 ------------------------------------------------- // --- Workflow execution -------------------------------------------------
/** Start a new instance (an INIT activity such as Register Flight). */ /** Start a new instance (an INIT activity such as Register Flight). */

View File

@ -115,6 +115,23 @@ export interface FormScreenResponse {
field_defaults?: Record<string, unknown>; field_defaults?: Record<string, unknown>;
} }
/**
* 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<string, unknown>;
raw?: string;
parse_error?: string;
}
export interface ActivityResult { export interface ActivityResult {
success?: boolean; success?: boolean;
message?: string; message?: string;

View File

@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { FileText, Loader2, Upload } from 'lucide-react';
import { useQuery, useZino } from '../api/provider'; import { useQuery, useZino } from '../api/provider';
import { Button, ErrorNote, Input, Label, Modal, Select, Spinner, Textarea } from './core'; import { Button, ErrorNote, Input, Label, Modal, Select, Spinner, Textarea } from './core';
import type { FormField } from '../api/types'; import type { FormField } from '../api/types';
@ -56,14 +57,7 @@ export function ActivityForm({
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const all = schema.data?.fields ?? []; const fields = 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 initial = useMemo( const initial = useMemo(
() => ({ ...(schema.data?.field_defaults ?? {}), ...(seed ?? {}) }), () => ({ ...(schema.data?.field_defaults ?? {}), ...(seed ?? {}) }),
@ -87,6 +81,44 @@ export function ActivityForm({
setErr(null); 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, unknown>): string[] {
const byId = new Map(fields.map((f) => [f.id, f]));
const filled: string[] = [];
const next: Record<string, unknown> = {};
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() { async function submit() {
setTouched(true); setTouched(true);
if (missing.length) return; if (missing.length) return;
@ -121,7 +153,7 @@ export function ActivityForm({
<div className="space-y-4"> <div className="space-y-4">
{err && <ErrorNote>{err}</ErrorNote>} {err && <ErrorNote>{err}</ErrorNote>}
{fields.length === 0 && pendingDocs.length === 0 && ( {fields.length === 0 && (
<p className="text-sm text-muted"> <p className="text-sm text-muted">
This activity takes no input submitting records it as performed. This activity takes no input submitting records it as performed.
</p> </p>
@ -136,24 +168,13 @@ export function ActivityForm({
readOnly={seed ? Object.prototype.hasOwnProperty.call(seed, f.id) : false} readOnly={seed ? Object.prototype.hasOwnProperty.call(seed, f.id) : false}
error={touched && missing.includes(f.id) ? 'Required' : null} error={touched && missing.includes(f.id) ? 'Required' : null}
onChange={(v) => setValues((s) => ({ ...s, [f.id]: v }))} onChange={(v) => setValues((s) => ({ ...s, [f.id]: v }))}
activityUid={activityUid}
instanceId={instanceId}
onExtract={mergeExtracted}
/> />
))} ))}
</div> </div>
{pendingDocs.length > 0 && (
<div className="rounded-md border border-dashed border-line bg-panel2 px-3 py-2.5">
<p className="text-xs font-semibold tracking-wide text-faint uppercase">
Document uploads
</p>
<p className="mt-1 text-xs text-muted">
{pendingDocs.map((f) => f.name).join(' · ')}
</p>
<p className="mt-1.5 text-xs text-faint">
Extraction is not wired in this build enter the extracted figures above.
</p>
</div>
)}
<div className="flex items-center justify-end gap-2 border-t border-line pt-4"> <div className="flex items-center justify-end gap-2 border-t border-line pt-4">
<Button kind="ghost" onClick={onClose} disabled={busy}> <Button kind="ghost" onClick={onClose} disabled={busy}>
Cancel Cancel
@ -174,15 +195,35 @@ function FieldControl({
onChange, onChange,
readOnly, readOnly,
error, error,
activityUid,
instanceId,
onExtract,
}: { }: {
field: FormField; field: FormField;
value: unknown; value: unknown;
onChange: (v: unknown) => void; onChange: (v: unknown) => void;
readOnly?: boolean; readOnly?: boolean;
error?: string | null; error?: string | null;
activityUid: string;
instanceId?: number | string;
onExtract: (extracted: Record<string, unknown>) => string[];
}) { }) {
const opts = field.properties?.options ?? []; const opts = field.properties?.options ?? [];
// A document, not a value the operator types. It fills the other fields.
if (field.data_type === 'ocr') {
return (
<OcrUpload
field={field}
activityUid={activityUid}
instanceId={instanceId}
onExtract={onExtract}
onFilename={onChange}
filename={String(value ?? '')}
/>
);
}
// Long prose gets a textarea whatever the schema says, because a // Long prose gets a textarea whatever the schema says, because a
// single-line input for a credit narrative is unusable — and the narrative // single-line input for a credit narrative is unusable — and the narrative
// is the field a credit manager actually reads. // is the field a credit manager actually reads.
@ -253,3 +294,121 @@ function FieldControl({
</div> </div>
); );
} }
/**
* 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, unknown>) => string[];
onFilename: (v: unknown) => void;
filename: string;
}) {
const { client } = useZino();
const [busy, setBusy] = useState(false);
const [filled, setFilled] = useState<string[] | null>(null);
const [warn, setWarn] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(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 (
<div className="sm:col-span-full">
<Label>{field.name}</Label>
<div className="rounded-md border border-dashed border-line bg-panel2 px-3 py-3">
<div className="flex flex-wrap items-center gap-3">
<label
className={`inline-flex cursor-pointer items-center gap-2 rounded-md bg-panel px-3 py-1.5 text-sm font-semibold text-brand ring-1 ring-inset ring-line transition-colors hover:bg-panel3 ${
busy ? 'pointer-events-none opacity-60' : ''
}`}
>
<Upload className="h-4 w-4" />
{filename ? 'Replace document' : 'Choose document'}
<input
type="file"
className="hidden"
accept="application/pdf,image/png,image/jpeg"
disabled={busy}
onChange={(e) => {
const f = e.target.files?.[0];
// Reset the input so choosing the SAME file again re-runs.
e.target.value = '';
if (f) void run(f);
}}
/>
</label>
{busy && (
<span className="inline-flex items-center gap-2 text-sm text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
Reading the document
</span>
)}
{!busy && filename && (
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
<FileText className="h-4 w-4 text-faint" />
{filename}
</span>
)}
</div>
{err && <p className="mt-2 text-sm text-bad">{err}</p>}
{warn && <p className="mt-2 text-sm text-warn">{warn}</p>}
{filled && filled.length > 0 && (
<div className="mt-2.5 rounded-md bg-good-soft px-3 py-2 ring-1 ring-inset ring-good-edge">
<p className="text-sm font-semibold text-good">
Filled {filled.length} {filled.length === 1 ? 'field' : 'fields'} from this document
</p>
<p className="mt-1 text-xs text-good">{filled.join(' · ')}</p>
</div>
)}
</div>
</div>
);
}

View File

@ -117,7 +117,9 @@ export function EvidencePanel({ row }: { row: Row }) {
const variance = num(row.income_variance_pct); const variance = num(row.income_variance_pct);
const varianceBreach = variance !== null && variance > POLICY.varianceTolerancePct; const varianceBreach = variance !== null && variance > POLICY.varianceTolerancePct;
const isPersonal = str(row.loan_product_family) === 'personal';
const dscr = num(row.dscr); const dscr = num(row.dscr);
const foir = num(row.foir);
const maxElig = num(row.max_eligible_amount); const maxElig = num(row.max_eligible_amount);
const requested = num(row.requested_amount); const requested = num(row.requested_amount);
const bureau = num(row.bureau_score); const bureau = num(row.bureau_score);
@ -197,6 +199,20 @@ export function EvidencePanel({ row }: { row: Row }) {
subtitle={`Server-side, reproducible from stored evidence · policy ${POLICY.version}`} subtitle={`Server-side, reproducible from stored evidence · policy ${POLICY.version}`}
> >
<div className="px-4 py-1"> <div className="px-4 py-1">
{/* 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 ? (
<Test
label="FOIR — obligations against income"
value={ratioPct(row.foir)}
threshold={`ceiling ${(POLICY.foirCeiling * 100).toFixed(0)}%`}
pass={foir === null ? null : foir <= POLICY.foirCeiling}
/>
) : (
<>
<Test <Test
label="DSCR — debt service coverage" label="DSCR — debt service coverage"
value={ratio(dscr)} value={ratio(dscr)}
@ -207,8 +223,11 @@ export function EvidencePanel({ row }: { row: Row }) {
label="Debt service ratio" label="Debt service ratio"
value={ratioPct(row.foir)} value={ratioPct(row.foir)}
threshold="share of monthly cash flow committed to debt" threshold="share of monthly cash flow committed to debt"
note="Shown for context — DSCR is the test that gates an MSME file."
pass={null} pass={null}
/> />
</>
)}
<Test <Test
label="Maximum eligible amount" label="Maximum eligible amount"
value={moneyShort(maxElig)} value={moneyShort(maxElig)}
@ -221,12 +240,16 @@ export function EvidencePanel({ row }: { row: Row }) {
threshold={`floor ${POLICY.bureauFloor}`} threshold={`floor ${POLICY.bureauFloor}`}
pass={bureau === null ? null : bureau >= POLICY.bureauFloor} pass={bureau === null ? null : bureau >= 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 && (
<Test <Test
label="CIBIL MSME Rank" label="CIBIL MSME Rank"
value={cmr === null ? '—' : `CMR-${cmr}`} value={cmr === null || cmr === 0 ? '—' : `CMR-${cmr}`}
threshold={`ceiling CMR-${POLICY.cmrCeiling}`} threshold={`ceiling CMR-${POLICY.cmrCeiling}`}
pass={cmr === null ? null : cmr <= POLICY.cmrCeiling} pass={cmr === null || cmr === 0 ? null : cmr <= POLICY.cmrCeiling}
/> />
)}
<Test <Test
label="Live DPD" label="Live DPD"
value={dpd === null ? '—' : `${dpd} d`} value={dpd === null ? '—' : `${dpd} d`}