feat: OCR-driven capture and document upload
This commit is contained in:
parent
602b570064
commit
0b4c5576e4
@ -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<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 -------------------------------------------------
|
||||
|
||||
/** Start a new instance (an INIT activity such as Register Flight). */
|
||||
|
||||
@ -115,6 +115,23 @@ export interface FormScreenResponse {
|
||||
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 {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
|
||||
@ -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<string | null>(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, 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() {
|
||||
setTouched(true);
|
||||
if (missing.length) return;
|
||||
@ -121,7 +153,7 @@ export function ActivityForm({
|
||||
<div className="space-y-4">
|
||||
{err && <ErrorNote>{err}</ErrorNote>}
|
||||
|
||||
{fields.length === 0 && pendingDocs.length === 0 && (
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
This activity takes no input — submitting records it as performed.
|
||||
</p>
|
||||
@ -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}
|
||||
/>
|
||||
))}
|
||||
</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">
|
||||
<Button kind="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
@ -174,15 +195,35 @@ function FieldControl({
|
||||
onChange,
|
||||
readOnly,
|
||||
error,
|
||||
activityUid,
|
||||
instanceId,
|
||||
onExtract,
|
||||
}: {
|
||||
field: FormField;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
readOnly?: boolean;
|
||||
error?: string | null;
|
||||
activityUid: string;
|
||||
instanceId?: number | string;
|
||||
onExtract: (extracted: Record<string, unknown>) => string[];
|
||||
}) {
|
||||
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
|
||||
// single-line input for a credit narrative is unusable — and the narrative
|
||||
// is the field a credit manager actually reads.
|
||||
@ -253,3 +294,121 @@ function FieldControl({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,6 +199,20 @@ export function EvidencePanel({ row }: { row: Row }) {
|
||||
subtitle={`Server-side, reproducible from stored evidence · policy ${POLICY.version}`}
|
||||
>
|
||||
<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
|
||||
label="DSCR — debt service coverage"
|
||||
value={ratio(dscr)}
|
||||
@ -207,8 +223,11 @@ export function EvidencePanel({ row }: { row: Row }) {
|
||||
label="Debt service ratio"
|
||||
value={ratioPct(row.foir)}
|
||||
threshold="share of monthly cash flow committed to debt"
|
||||
note="Shown for context — DSCR is the test that gates an MSME file."
|
||||
pass={null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Test
|
||||
label="Maximum eligible amount"
|
||||
value={moneyShort(maxElig)}
|
||||
@ -221,12 +240,16 @@ export function EvidencePanel({ row }: { row: Row }) {
|
||||
threshold={`floor ${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
|
||||
label="CIBIL MSME Rank"
|
||||
value={cmr === null ? '—' : `CMR-${cmr}`}
|
||||
value={cmr === null || cmr === 0 ? '—' : `CMR-${cmr}`}
|
||||
threshold={`ceiling CMR-${POLICY.cmrCeiling}`}
|
||||
pass={cmr === null ? null : cmr <= POLICY.cmrCeiling}
|
||||
pass={cmr === null || cmr === 0 ? null : cmr <= POLICY.cmrCeiling}
|
||||
/>
|
||||
)}
|
||||
<Test
|
||||
label="Live DPD"
|
||||
value={dpd === null ? '—' : `${dpd} d`}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user