Compare commits

..

No commits in common. "fc5eace79175da83159089686dad33e2f7dadcda" and "07d46088889b926b95ef4fbc8b38bd5cb316f2c4" have entirely different histories.

5 changed files with 30 additions and 137 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: 'force' },
{ uid: 'zk-act-retry-call', label: 'Retry Call', by: 'Scheduled', role: 'again' },
],
'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: 'force' },
{ uid: 'zk-act-doc-reminder', label: 'Send a reminder', by: 'Scheduled', role: 'again' },
{ 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, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useZino } from '../api/provider.jsx'
import { baseFieldId, fieldApplies } from '../api/config.js'
import { fieldApplies } from '../api/config.js'
import { describeError, describeValidation } from '../api/errors.js'
import FileField from './FileField.jsx'
import './ActivityForm.css'
@ -41,10 +41,6 @@ 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(() => {
@ -72,55 +68,21 @@ 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 || {}
const seed = {}
if (pre && typeof pre === 'object') {
const seed = {}
for (const f of s.fields) {
const base = baseFieldId(f.id ?? '')
const base = String(f.id ?? '').replace(/_\d+$/, '')
const v = pre[f.id] ?? pre[f.uid] ?? (base ? pre[base] : undefined)
if (v !== undefined && v !== null && v !== '') seed[f.id] = v
}
}
// 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.
@ -144,57 +106,6 @@ 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)
}
@ -272,7 +183,19 @@ 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) => applyExtraction(f, fields)}
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
})
}}
/>
) : f.data_type === 'id_gen' ? (
<div className="af__auto">

View File

@ -48,12 +48,6 @@ 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,16 +348,8 @@ 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. */}
{(() => {
// 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 step = actions.filter((a) => a.role === 'do')
const again = actions.filter((a) => a.role === 'again')
const force = actions.filter((a) => a.role === 'force')
const exit = actions.filter((a) => a.role === 'exit')
const extras = [...force, ...exit]
@ -391,8 +383,6 @@ 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,12 +43,7 @@ import './screens.css'
export default function Pipeline() {
const { stageUid } = useParams()
const { client } = useZino()
// "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 stage = STAGES.find((s) => s.uid === stageUid)
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
@ -62,22 +57,15 @@ export default function Pipeline() {
if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
return client
.recordView(RV_LEADS, {
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 ?? '' }] }),
limit: 100,
filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }],
})
.then((res) => {
if (cancelled) return
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))
}
const rows = res?.data ?? res?.rows ?? res?.records ?? []
// 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 = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length)
const total = res?.pagination?.total_count ?? rows.length
setState({ status: 'ready', rows, total, error: null })
})
.catch((err) => {
@ -94,7 +82,7 @@ export default function Pipeline() {
}, 30000)
return () => { cancelled = true; clearInterval(id) }
}, [client, stageUid, stage?.name, isAll])
}, [client, stageUid, stage?.name])
if (!stage) return <p className="empty">Unknown stage.</p>
@ -104,9 +92,7 @@ export default function Pipeline() {
<div>
<h1 className="page__title">{stage.need ?? stage.name}</h1>
<p className="page__sub">
{isAll
? 'Every lead not yet closed, across all stages.'
: stage.kind === 'auto'
{stage.kind === 'auto'
? `Automated · ${stage.doing.toLowerCase()}`
: stage.kind === 'end'
? 'Closed — retained for reporting.'