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' ? (