Compare commits

...

2 Commits

Author SHA1 Message Date
fc5eace791 form: make OCR autofill actually reach the fields
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.
2026-09-08 11:06:41 +05:30
6a6801d2bd console: all-leads view, document form prefill, and two things that should not be offered
Four operator-reported issues, one commit because two of them are about
the same form.

ALL LEADS WAS GONE. The team's rewrite of the queue screen dropped the
/stage/all view, so finding a lead meant guessing which of nine queues
it sat in. Restored: no stage filter, closed leads dropped client-side,
one request for the whole open book. Linked under Overview in the
sidebar so it is one click from anywhere.

THE DOCUMENT FORM ASKED FOR WHAT THE LEAD ALREADY KNEW. Collect
Documents mirrors fourteen record fields — registration, make and model,
previous insurer, expiry, policy number, PAN — so the OCR has somewhere
to write. Rendered blank, they read as fourteen more things to type, on
a form whose whole point is uploading three files. They are now seeded
from the lead by base key: an agent uploading for KA01MF6618 sees
KA01MF6618 already there. Server prefill wins, then anything typed;
files and generated ids are never seeded, and an INIT form has no
record to seed from. SME-only fields were already hidden on a motor
lead by fieldLine — that half was working.

"SEND A REMINDER" IS THE SCHEDULER'S JOB, NOT A BUTTON. It fires at
24h, 72h and 120h on its own. Offering it as a loop action invited a
person to send a reminder the platform was already about to send. It
and Retry Call move to `force` — reachable behind "Lead not moving?"
for the day the schedule itself fails, and otherwise out of sight.

THE UPLOAD STAYED THE "NEXT STEP" AFTER THE UPLOAD. Once
documents_status is complete the lead remains in Document Pending while
Engage captures the risk and the quote is built — and the panel kept
saying "Upload documents", which reads as though nothing was received.
Now, when complete, the upload becomes a loop (a fourth document, a
correction) and the panel says what is actually happening: documents
received, Engage AI is capturing the risk and preparing the quote.

Permissions unchanged; ACTIONS still matches
tbl_wf_state_allowed_activities exactly.
2026-09-07 19:26:13 +05:30
5 changed files with 137 additions and 30 deletions

View File

@ -119,7 +119,7 @@ const STATE_ACTIVITIES = {
],
'zk-state-qualified': [
{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI', role: 'force' },
{ uid: 'zk-act-retry-call', label: 'Retry Call', by: 'Scheduled', role: 'again' },
{ uid: 'zk-act-retry-call', label: 'Retry Call', by: 'Scheduled', role: 'force' },
],
'zk-state-contacted': [
{ uid: 'zk-act-request-docs', label: 'Request Documents', by: 'Engage AI', role: 'force' },
@ -128,7 +128,7 @@ const STATE_ACTIVITIES = {
// be found and uploaded. Everything else here is the AI's own chain.
'zk-state-docs': [
{ uid: 'zk-act-collect-docs', label: 'Upload documents', by: 'you', role: 'do' },
{ uid: 'zk-act-doc-reminder', label: 'Send a reminder', by: 'Scheduled', role: 'again' },
{ uid: 'zk-act-doc-reminder', label: 'Send a reminder', by: 'Scheduled', role: 'force' },
{ uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI', role: 'force' },
{ uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI', role: 'force' },
{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI', role: 'force' },

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useZino } from '../api/provider.jsx'
import { fieldApplies } from '../api/config.js'
import { baseFieldId, fieldApplies } from '../api/config.js'
import { describeError, describeValidation } from '../api/errors.js'
import FileField from './FileField.jsx'
import './ActivityForm.css'
@ -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(() => {
@ -68,21 +72,55 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
// whichever convention a pipeline is authored in, rather than breaking
// again the next time one is written the other way.
const pre = s.prefill_data || s.prefillData || s.field_defaults || {}
if (pre && typeof pre === 'object') {
const seed = {}
if (pre && typeof pre === 'object') {
for (const f of s.fields) {
const base = String(f.id ?? '').replace(/_\d+$/, '')
const base = baseFieldId(f.id ?? '')
const v = pre[f.id] ?? pre[f.uid] ?? (base ? pre[base] : undefined)
if (v !== undefined && v !== null && v !== '') seed[f.id] = v
}
if (Object.keys(seed).length) setValues(seed)
}
// THE LEAD ALREADY KNOWS MOST OF THIS. The document form mirrors
// fourteen fields the record carries registration, make and model,
// previous insurer, expiry, policy number, PAN so the OCR can write
// into them. Rendered blank, they read as fourteen more things to type.
// Seed each from the lead by its base key, so an agent uploading for
// KA01MF6618 sees KA01MF6618 already there.
//
// Server prefill wins, then anything already typed. Files and generated
// 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
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.
@ -106,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)
}
@ -183,19 +272,7 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
<FileField
field={f} value={v} instanceId={instanceId} activityUid={activityUid}
onChange={(refs) => 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' ? (
<div className="af__auto">

View File

@ -48,6 +48,12 @@ export default function Shell() {
>
Overview
</NavLink>
<NavLink
to="/stage/all"
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}
>
All leads
</NavLink>
{canFile ? (
<NavLink to="/add" className="shell__add">

View File

@ -348,8 +348,16 @@ export default function Lead() {
Mark Lost. Rendering them as one flat row of equals is what made
six buttons appear where one was the answer. */}
{(() => {
const step = actions.filter((a) => a.role === 'do')
const again = actions.filter((a) => a.role === 'again')
// Once the three documents are in, the upload is no longer the step:
// the lead stays in Document Pending while Engage captures the risk
// and the quote is built, and offering "Upload documents" through
// that reads as though nothing was received. A further upload stays
// possible as a loop a fourth document, a corrected one it is
// just not what the lead is waiting on.
const docsDone = stage?.uid === 'zk-state-docs' && row.documents_status === 'complete'
const roleOf = (a) => (docsDone && a.uid === 'zk-act-collect-docs' ? 'again' : a.role)
const step = actions.filter((a) => roleOf(a) === 'do')
const again = actions.filter((a) => roleOf(a) === 'again')
const force = actions.filter((a) => a.role === 'force')
const exit = actions.filter((a) => a.role === 'exit')
const extras = [...force, ...exit]
@ -383,6 +391,8 @@ export default function Lead() {
? 'This is what this lead is waiting on.'
: isClosed
? 'This lead is closed.'
: docsDone
? 'Documents received. Engage AI is capturing the risk and preparing the quote.'
: `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`}
</p>
</div>

View File

@ -43,7 +43,12 @@ import './screens.css'
export default function Pipeline() {
const { stageUid } = useParams()
const { client } = useZino()
const stage = STAGES.find((s) => s.uid === stageUid)
// "all" is not a stage it is every open lead in one list. An operator
// wanting to find a lead should not have to guess which queue it is in.
const isAll = stageUid === 'all'
const stage = isAll
? { uid: 'all', name: 'All open leads', kind: 'all', by: 'everyone' }
: STAGES.find((s) => s.uid === stageUid)
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
@ -57,15 +62,22 @@ export default function Pipeline() {
if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
return client
.recordView(RV_LEADS, {
limit: 100,
filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }],
limit: isAll ? 200 : 100,
// No stage filter on the all view. Closed leads are dropped below
// rather than in the query: one request beats three negations, and
// 200 covers a demo book comfortably.
...(isAll ? {} : { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }] }),
})
.then((res) => {
if (cancelled) return
const rows = res?.data ?? res?.rows ?? res?.records ?? []
let rows = res?.data ?? res?.rows ?? res?.records ?? []
if (isAll) {
const closed = new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name))
rows = rows.filter((r) => !closed.has(r.current_state_name))
}
// total_count is the size of the QUEUE; rows is one page of at most
// 100 of it. Counting the page would quietly under-report a busy stage.
const total = res?.pagination?.total_count ?? rows.length
const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length)
setState({ status: 'ready', rows, total, error: null })
})
.catch((err) => {
@ -82,7 +94,7 @@ export default function Pipeline() {
}, 30000)
return () => { cancelled = true; clearInterval(id) }
}, [client, stageUid, stage?.name])
}, [client, stageUid, stage?.name, isAll])
if (!stage) return <p className="empty">Unknown stage.</p>
@ -92,7 +104,9 @@ export default function Pipeline() {
<div>
<h1 className="page__title">{stage.need ?? stage.name}</h1>
<p className="page__sub">
{stage.kind === 'auto'
{isAll
? 'Every lead not yet closed, across all stages.'
: stage.kind === 'auto'
? `Automated · ${stage.doing.toLowerCase()}`
: stage.kind === 'end'
? 'Closed — retained for reporting.'