From fc5eace79175da83159089686dad33e2f7dadcda Mon Sep 17 00:00:00 2001 From: Yashas Date: Tue, 8 Sep 2026 11:06:41 +0530 Subject: [PATCH] form: make OCR autofill actually reach the fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploading an RC read the document correctly, said so, and filled nothing. The endpoint answers keyed by the ocr_config's extraction_fields[].key — `reg_no`, `make_model`, `mfg_year`, `engine_cc`, `fuel` — because that is what the vision prompt was asked to produce. Its own package doc says so. The form's fields are `motor_reg_no_6`, `motor_cc_3`, `motor_fuel_3`. The console wrote the response straight into values, so every extracted value landed on a key no field renders: the read succeeded, the "we read it for you" panel appeared, and the boxes stayed empty. This is the third place the same mismatch has bitten. Prefill hit it (the pipeline names globals, the form uses suffixed activity keys) and the validation errors hit it (the workflow reports the machine key). Different surfaces, one cause: nothing in the stack translates between a global field name and a form's per-activity id, so every consumer has to do it and each one forgot. The bridge was already on the field. ocr_config.field_mappings maps extraction_key -> target_field (the global), and the form field is that global plus a suffix. Verified against the live Collect Documents schema: all five RC keys now resolve to real fields. Also: which value wins. Seeding the form from the lead record (6a6801d) made every mirrored field non-empty, so the old "only fill what is blank" rule would have blocked OCR from writing anything at all. Values copied off the lead are now tracked, and a document may overwrite them — it is the better source for what it states. Anything typed by a person is never overwritten, and stops being overwritable the moment it is typed. --- src/components/ActivityForm.jsx | 86 +++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/src/components/ActivityForm.jsx b/src/components/ActivityForm.jsx index e980b1e..77e497e 100644 --- a/src/components/ActivityForm.jsx +++ b/src/components/ActivityForm.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useZino } from '../api/provider.jsx' import { baseFieldId, fieldApplies } from '../api/config.js' import { describeError, describeValidation } from '../api/errors.js' @@ -41,6 +41,10 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on // `error`, because this is the form talking about itself rather than the // server refusing something. const [missing, setMissing] = useState([]) + // Field ids filled from the lead record rather than typed. OCR may overwrite + // these — the document is more authoritative than a copy of the record — but + // must never overwrite something a person typed. + const seededRef = useRef(new Set()) const [busy, setBusy] = useState(false) useEffect(() => { @@ -88,22 +92,35 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on // ids are never seeded — a file reference is not a value to copy, and an // id_gen is the platform's to issue. Only on an existing lead: an INIT // form has no record behind it. + const seededFromLead = new Set() if (lead && instanceId) { for (const f of s.fields) { if (seed[f.id] !== undefined) continue if (['file', 'ocr', 'id_gen'].includes(f.data_type)) continue const v = lead[baseFieldId(f.id ?? '')] - if (v !== undefined && v !== null && v !== '' && typeof v !== 'object') seed[f.id] = v + if (v !== undefined && v !== null && v !== '' && typeof v !== 'object') { + seed[f.id] = v + seededFromLead.add(f.id) + } } } + seededRef.current = seededFromLead if (Object.keys(seed).length) setValues(seed) }) .catch((e) => { if (!dead) setError(e) }) return () => { dead = true } + // `lead` is READ here but deliberately not a dependency. It is the parent's + // polled record object, so its identity changes on every refresh; listing it + // would re-run this effect, re-fetch the schema and call setValues(seed) — + // discarding whatever the operator had typed, every few seconds, mid-form. + // The seed is a one-time starting point, not a subscription. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [client, activityUid, instanceId]) function set(id, v) { + // Typed by hand: from here on it outranks any document. + seededRef.current.delete(id) setValues((p) => ({ ...p, [id]: v })) // Clear the complaint as soon as the field is filled. Leaving a red field // marked after it has been corrected teaches people to ignore the marking. @@ -127,6 +144,57 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on const hidesMandatory = schema ? schema.fields.some((f) => f.mandatory && !fieldApplies(f.id, lead)) : false const fields = applicable.length && !hidesMandatory ? applicable : (schema?.fields ?? []) + /** + * Turn an /ocr-extract response into form values. + * + * THE ENDPOINT DOES NOT SPEAK THE FORM'S LANGUAGE. It answers keyed by the + * ocr_config's `extraction_fields[].key` — `reg_no`, `engine_cc`, `fuel` — + * because that is what the vision prompt was asked to produce. The form's + * fields are `motor_reg_no_6`, `motor_cc_3`, `motor_fuel_3`. Written straight + * in, as they were, every extracted value landed on a key no field renders: + * the read succeeded, the panel said so, and nothing filled. + * + * The bridge is already on the field. `ocr_config.field_mappings` maps + * extraction_key -> target_field (the GLOBAL name), and the form field is that + * global plus a per-form suffix. So: key -> target -> the field whose base id + * matches. Same suffix rule as prefill and the same trap, in a third place. + */ + function applyExtraction(ocrField, extracted) { + const maps = ocrField?.properties?.ocr_config?.field_mappings + ?? ocrField?.ocr_config?.field_mappings ?? [] + const all = schema?.fields ?? [] + + // extraction_key -> form field id + const target = {} + for (const m of maps) { + if (!m?.extraction_key || !m?.target_field) continue + const hit = all.find((f) => f.id === m.target_field) + ?? all.find((f) => baseFieldId(f.id) === m.target_field) + if (hit) target[m.extraction_key] = hit.id + } + + setValues((prev) => { + const next = { ...prev } + for (const [key, val] of Object.entries(extracted)) { + if (val === null || val === undefined || String(val) === '') continue + // An unmapped key may still name a field directly on forms whose + // extraction keys ARE the field names. + const id = target[key] + ?? all.find((f) => f.id === key)?.id + ?? all.find((f) => baseFieldId(f.id) === key)?.id + if (!id) continue + // Never overwrite something a person typed. A value we copied off the + // lead is fair game — the document is the better source for it. + const isBlank = next[id] === undefined || next[id] === '' + if (isBlank || seededRef.current.has(id)) { + next[id] = val + seededRef.current.delete(id) + } + } + return next + }) + } + function isEmpty(v) { return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0) } @@ -204,19 +272,7 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on set(f.id, refs)} - onExtract={(fields) => { - // The document answered questions the form was about to ask. - // Only fill what is still blank — never overwrite something a - // person typed with something a model read. - setValues((prev) => { - const next = { ...prev } - for (const [k, val] of Object.entries(fields)) { - if (val === null || val === undefined || String(val) === '') continue - if (next[k] === undefined || next[k] === '') next[k] = val - } - return next - }) - }} + onExtract={(fields) => applyExtraction(f, fields)} /> ) : f.data_type === 'id_gen' ? (