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': [ 'zk-state-qualified': [
{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI', role: 'force' }, { 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': [ 'zk-state-contacted': [
{ uid: 'zk-act-request-docs', label: 'Request Documents', by: 'Engage AI', role: 'force' }, { 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. // be found and uploaded. Everything else here is the AI's own chain.
'zk-state-docs': [ 'zk-state-docs': [
{ uid: 'zk-act-collect-docs', label: 'Upload documents', by: 'you', role: 'do' }, { 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-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-capture-sme', label: 'Capture SME Risk', by: 'Engage AI', role: 'force' },
{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor 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 { 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 { describeError, describeValidation } from '../api/errors.js'
import FileField from './FileField.jsx' import FileField from './FileField.jsx'
import './ActivityForm.css' 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 // `error`, because this is the form talking about itself rather than the
// server refusing something. // server refusing something.
const [missing, setMissing] = useState([]) 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) const [busy, setBusy] = useState(false)
useEffect(() => { useEffect(() => {
@ -72,55 +68,21 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
// whichever convention a pipeline is authored in, rather than breaking // whichever convention a pipeline is authored in, rather than breaking
// again the next time one is written the other way. // again the next time one is written the other way.
const pre = s.prefill_data || s.prefillData || s.field_defaults || {} const pre = s.prefill_data || s.prefillData || s.field_defaults || {}
const seed = {}
if (pre && typeof pre === 'object') { if (pre && typeof pre === 'object') {
const seed = {}
for (const f of s.fields) { 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) const v = pre[f.id] ?? pre[f.uid] ?? (base ? pre[base] : undefined)
if (v !== undefined && v !== null && v !== '') seed[f.id] = v 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) }) .catch((e) => { if (!dead) setError(e) })
return () => { dead = true } 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]) }, [client, activityUid, instanceId])
function set(id, v) { function set(id, v) {
// Typed by hand: from here on it outranks any document.
seededRef.current.delete(id)
setValues((p) => ({ ...p, [id]: v })) setValues((p) => ({ ...p, [id]: v }))
// Clear the complaint as soon as the field is filled. Leaving a red field // 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. // 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 hidesMandatory = schema ? schema.fields.some((f) => f.mandatory && !fieldApplies(f.id, lead)) : false
const fields = applicable.length && !hidesMandatory ? applicable : (schema?.fields ?? []) 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) { function isEmpty(v) {
return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0) 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 <FileField
field={f} value={v} instanceId={instanceId} activityUid={activityUid} field={f} value={v} instanceId={instanceId} activityUid={activityUid}
onChange={(refs) => set(f.id, refs)} 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' ? ( ) : f.data_type === 'id_gen' ? (
<div className="af__auto"> <div className="af__auto">

View File

@ -48,12 +48,6 @@ export default function Shell() {
> >
Overview Overview
</NavLink> </NavLink>
<NavLink
to="/stage/all"
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}
>
All leads
</NavLink>
{canFile ? ( {canFile ? (
<NavLink to="/add" className="shell__add"> <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 Mark Lost. Rendering them as one flat row of equals is what made
six buttons appear where one was the answer. */} six buttons appear where one was the answer. */}
{(() => { {(() => {
// Once the three documents are in, the upload is no longer the step: const step = actions.filter((a) => a.role === 'do')
// the lead stays in Document Pending while Engage captures the risk const again = actions.filter((a) => a.role === 'again')
// 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 force = actions.filter((a) => a.role === 'force')
const exit = actions.filter((a) => a.role === 'exit') const exit = actions.filter((a) => a.role === 'exit')
const extras = [...force, ...exit] const extras = [...force, ...exit]
@ -391,9 +383,7 @@ export default function Lead() {
? 'This is what this lead is waiting on.' ? 'This is what this lead is waiting on.'
: isClosed : isClosed
? 'This lead is closed.' ? 'This lead is closed.'
: docsDone : `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`}
? '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> </p>
</div> </div>
</div> </div>

View File

@ -43,12 +43,7 @@ import './screens.css'
export default function Pipeline() { export default function Pipeline() {
const { stageUid } = useParams() const { stageUid } = useParams()
const { client } = useZino() const { client } = useZino()
// "all" is not a stage it is every open lead in one list. An operator const stage = STAGES.find((s) => s.uid === stageUid)
// 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 }) 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 }) if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
return client return client
.recordView(RV_LEADS, { .recordView(RV_LEADS, {
limit: isAll ? 200 : 100, limit: 100,
// No stage filter on the all view. Closed leads are dropped below filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }],
// 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) => { .then((res) => {
if (cancelled) return if (cancelled) return
let rows = res?.data ?? res?.rows ?? res?.records ?? [] const 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 // 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. // 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 }) setState({ status: 'ready', rows, total, error: null })
}) })
.catch((err) => { .catch((err) => {
@ -94,7 +82,7 @@ export default function Pipeline() {
}, 30000) }, 30000)
return () => { cancelled = true; clearInterval(id) } return () => { cancelled = true; clearInterval(id) }
}, [client, stageUid, stage?.name, isAll]) }, [client, stageUid, stage?.name])
if (!stage) return <p className="empty">Unknown stage.</p> if (!stage) return <p className="empty">Unknown stage.</p>
@ -104,9 +92,7 @@ export default function Pipeline() {
<div> <div>
<h1 className="page__title">{stage.need ?? stage.name}</h1> <h1 className="page__title">{stage.need ?? stage.name}</h1>
<p className="page__sub"> <p className="page__sub">
{isAll {stage.kind === 'auto'
? 'Every lead not yet closed, across all stages.'
: stage.kind === 'auto'
? `Automated · ${stage.doing.toLowerCase()}` ? `Automated · ${stage.doing.toLowerCase()}`
: stage.kind === 'end' : stage.kind === 'end'
? 'Closed — retained for reporting.' ? 'Closed — retained for reporting.'