Compare commits
2 Commits
f4c67310cb
...
93baba3fa1
| Author | SHA1 | Date | |
|---|---|---|---|
| 93baba3fa1 | |||
| 5d146e9ffb |
@ -420,3 +420,71 @@ export function blockedOn(lead) {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* WHEN AN AGENT STOPS, WHAT DOES A PERSON PRESS?
|
||||
*
|
||||
* Agents stall. Not often, but they do, and for reasons nothing in this app
|
||||
* controls: the model provider slows to ninety seconds a call or returns a 502,
|
||||
* a thinking budget runs out mid-sentence, a late webhook wakes the wrong
|
||||
* employee. Watched from the console every one of those looks identical —
|
||||
* a lead that simply stops — and the screen kept promising it would "complete
|
||||
* within two minutes" indefinitely.
|
||||
*
|
||||
* There is no retry button in the platform, and none of these agents can be
|
||||
* woken directly. But every one of them is woken BY AN ACTIVITY, so performing
|
||||
* that activity again wakes it again. That is what this table holds: for a
|
||||
* lead sitting in an automated step, the activity a person can perform to make
|
||||
* the stalled agent run once more.
|
||||
*
|
||||
* `by` is who to expect to move afterwards, so the button says what will
|
||||
* happen rather than just what it does.
|
||||
*
|
||||
* The choice within Document Pending depends on HOW FAR the chain got, because
|
||||
* three agents work that state in sequence and the one to restart is the one
|
||||
* that did not finish.
|
||||
*/
|
||||
export function nudgeFor(stage, lead) {
|
||||
if (!stage || !lead) return null
|
||||
|
||||
switch (stage.uid) {
|
||||
case 'zk-state-new':
|
||||
return { uid: 'zk-act-qualify', label: 'Run the check again', by: 'Intake' }
|
||||
|
||||
case 'zk-state-qualified':
|
||||
return { uid: 'zk-act-contact', label: 'Record the call outcome', by: 'Engage' }
|
||||
|
||||
case 'zk-state-contacted':
|
||||
return { uid: 'zk-act-request-docs', label: 'Ask for the documents again', by: 'Engage' }
|
||||
|
||||
case 'zk-state-docs': {
|
||||
// Engage captures, the Advisor recommends, the rating engine prices —
|
||||
// in that order, each woken by the one before.
|
||||
const line = lead.product_line === 'sme_package' ? 'zk-act-capture-sme' : 'zk-act-capture-motor'
|
||||
if (!lead.motor_idv && !lead.sme_value_at_risk) {
|
||||
return { uid: line, label: 'Capture the risk again', by: 'Engage' }
|
||||
}
|
||||
if (!lead.ai_recommended_cover) {
|
||||
// The Advisor is the ONE step nobody may perform by hand — it is
|
||||
// permitted to ai_advisor alone. Re-performing the activity that wakes
|
||||
// it is the only way back, and it is the reason this table exists.
|
||||
return { uid: line, label: 'Wake the Advisor', by: 'the Advisor' }
|
||||
}
|
||||
if (!lead.quoted_premium) {
|
||||
return { uid: 'zk-act-quote', label: 'Build the quote again', by: 'the rating engine' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case 'zk-state-issued':
|
||||
return { uid: 'zk-act-onboard', label: 'Close the file', by: 'Engage' }
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** How long an automated step may sit before the console stops reassuring and
|
||||
* starts offering the way out. Generous: an employee wake plus a slow model
|
||||
* can legitimately take three or four minutes. */
|
||||
export const STALL_AFTER_MS = 5 * 60 * 1000
|
||||
|
||||
@ -11,18 +11,31 @@ import './Conversation.css'
|
||||
* right.
|
||||
*
|
||||
* WHAT IT CAN AND CANNOT SHOW, said plainly at the foot of the panel rather
|
||||
* than left for someone to discover. Every message the workflow RECORDS is
|
||||
* here: the customer's replies (customer_reply) and the replies written back
|
||||
* (customer_answer). The three automatic sends are not. The quote goes out as
|
||||
* a registered WhatsApp template, and the acknowledgement and the acceptance
|
||||
* confirmation are composed inside trigger nodes and never written to a field.
|
||||
* A thread that quietly omitted them would be worse than one that says which
|
||||
* parts it holds.
|
||||
* than left for someone to discover. The console can only show what the
|
||||
* workflow RECORDS, so anything sent from inside a trigger node and never
|
||||
* written to a field is invisible here however certainly the customer received
|
||||
* it. The acceptance confirmation used to be exactly that — the panel showed
|
||||
* nothing after "I confirm", which read as though we had ignored somebody
|
||||
* agreeing to buy — so 90 writes it into customer_answer and it appears.
|
||||
*
|
||||
* Two sends remain absent by choice: the quote, which is a registered WhatsApp
|
||||
* template rather than text we compose, and the "I have your message" receipt,
|
||||
* which would put a line between every question and its answer.
|
||||
*
|
||||
* A thread that quietly omitted any of this would be worse than one that says
|
||||
* which parts it holds.
|
||||
*/
|
||||
function turnsFrom(rows) {
|
||||
if (!Array.isArray(rows)) return []
|
||||
const out = []
|
||||
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
|
||||
// Sorted on (timestamp, id). One submission writes three rows in the same
|
||||
// second — the trigger commit, its repeat, and the settle — so a timestamp
|
||||
// alone leaves their order to the sort's stability and puts a reply before
|
||||
// the message it answers whenever the two land in the same second.
|
||||
const ordered = [...rows].sort((a, b) => {
|
||||
const t = String(a.created_at).localeCompare(String(b.created_at))
|
||||
return t !== 0 ? t : (Number(a.id) || 0) - (Number(b.id) || 0)
|
||||
})
|
||||
for (const r of ordered) {
|
||||
const fields = Array.isArray(r.fields) && r.fields.length
|
||||
? r.fields
|
||||
@ -35,15 +48,17 @@ function turnsFrom(rows) {
|
||||
if (base === 'customer_answer') out.push({ side: 'us', text: v, at: r.created_at, key: r.id + '-out' })
|
||||
}
|
||||
}
|
||||
// One submission can be recorded more than once (the trigger commit and the
|
||||
// settle both carry the payload), so the same sentence would print twice.
|
||||
// De-duped on side + text rather than on the row id for that reason.
|
||||
const seen = new Set()
|
||||
return out.filter((t) => {
|
||||
const k = t.side + ' ' + t.text
|
||||
if (seen.has(k)) return false
|
||||
seen.add(k)
|
||||
return true
|
||||
// One submission is recorded three times — the trigger commit, its repeat,
|
||||
// and the settle — so the same sentence would print three times.
|
||||
//
|
||||
// De-duped against the PREVIOUS turn only, not against the whole thread. A
|
||||
// customer who asks the same thing twice because the first went unanswered
|
||||
// has said it twice, and a thread that silently showed it once would hide
|
||||
// exactly the impatience an operator needs to see. Only an immediate repeat
|
||||
// of the same side and the same words is the platform talking to itself.
|
||||
return out.filter((t, i) => {
|
||||
const prev = out[i - 1]
|
||||
return !(prev && prev.side === t.side && prev.text === t.text)
|
||||
})
|
||||
}
|
||||
|
||||
@ -105,9 +120,11 @@ export default function Conversation({ rows, name, onClose }) {
|
||||
</div>
|
||||
|
||||
<p className="conv__note">
|
||||
Shows the customer's messages and the replies written back. The quote
|
||||
itself, the read receipt and the acceptance confirmation are sent
|
||||
automatically and are not recorded as text, so they do not appear here.
|
||||
Shows the customer's messages and everything written back to them,
|
||||
including the acceptance confirmation. Two automatic sends are not here:
|
||||
the quote, which goes out as a registered WhatsApp template rather than
|
||||
text we compose, and the “I have your message” receipt sent
|
||||
before anything has read it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,7 @@ import AgentChip from '../components/AgentChip.jsx'
|
||||
import ClampText from '../components/ClampText.jsx'
|
||||
import Conversation from '../components/Conversation.jsx'
|
||||
import Timeline from '../components/Timeline.jsx'
|
||||
import { APP_ID, DV_LEAD, PRODUCTS, STAGES, blockedOn, phaseOf } from '../api/config.js'
|
||||
import { APP_ID, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
|
||||
import { AGENTS } from '../api/agents.js'
|
||||
import { actionsFor, rolesOf } from '../api/permissions.js'
|
||||
import { describeError } from '../api/errors.js'
|
||||
@ -152,6 +152,11 @@ export default function Lead() {
|
||||
const [audit, setAudit] = useState(null)
|
||||
const [showChat, setShowChat] = useState(false)
|
||||
const [openAgent, setOpenAgent] = useState(null)
|
||||
// A clock in state rather than Date.now() in the render body. Reading the
|
||||
// wall clock while rendering is impure — React may render twice and get two
|
||||
// answers — and it also means the "nothing for 6 minutes" counter would only
|
||||
// move when something else happened to re-render the page. This ticks it.
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
const load = useCallback((quiet = false) => {
|
||||
if (!quiet) setErr(null)
|
||||
@ -206,6 +211,11 @@ export default function Lead() {
|
||||
* moves while it is on screen. Poll quietly: no spinner, no skeleton, and not
|
||||
* at all while the tab is in the background.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 20000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Not while a form is open. A successful poll rewrites `actions`, and if the
|
||||
// lead has moved on the open form unmounts — taking whatever was typed into
|
||||
@ -268,6 +278,17 @@ export default function Lead() {
|
||||
// the "in progress" strip below, which would otherwise keep promising that
|
||||
// something is happening for as long as the lead is left alone.
|
||||
const blocked = stage?.kind === 'auto' ? blockedOn(row) : null
|
||||
// An automated step that has sat too long. Distinct from `blocked`, which is
|
||||
// the workflow refusing for a stated reason — this is the agent not having
|
||||
// come back, and the cause is usually outside this app entirely: a slow or
|
||||
// failing model provider, a thinking budget that ran out mid-sentence, a
|
||||
// wake that never arrived. The console cannot see any of that, so it reports
|
||||
// the only thing it can know — nothing has happened for a while — and offers
|
||||
// the way out rather than going on promising two minutes.
|
||||
const stillFor = row.updated_at ? now - Date.parse(row.updated_at) : 0
|
||||
const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS
|
||||
? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) }
|
||||
: null
|
||||
|
||||
|
||||
return (
|
||||
@ -398,6 +419,29 @@ export default function Lead() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : stalled ? (
|
||||
<div className="doing doing--stalled">
|
||||
<span className="doing__stop" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{stage.doing} — nothing for {stalled.mins} minutes</strong>
|
||||
<span>
|
||||
{stage.by} has not come back. That is usually the model provider being
|
||||
slow or dropping a request, not a problem with this lead — the work so far
|
||||
is safe and nothing has been lost.
|
||||
{stalled.nudge
|
||||
? ` Running ${stalled.nudge.label.toLowerCase()} wakes ${stalled.nudge.by} to try again.`
|
||||
: ' There is no step to re-run from here; the actions below are the way on.'}
|
||||
</span>
|
||||
{stalled.nudge ? (
|
||||
<button
|
||||
type="button" className="doing__fix"
|
||||
onClick={() => setOpen(stalled.nudge.uid)}
|
||||
>
|
||||
{stalled.nudge.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : stage?.kind === 'customer' && stage.doing ? (
|
||||
/* Not an agent working, and not a task of yours either — the lead
|
||||
is with someone outside the business. Without this the screen
|
||||
|
||||
@ -1358,3 +1358,14 @@
|
||||
color: var(--zk-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- an agent that has not come back ----
|
||||
Amber like `blocked`, because both need a person — but worded and coloured
|
||||
apart from it: blocked means the workflow refused for a stated reason and
|
||||
the fix is known; stalled means nobody knows, and the honest offer is to try
|
||||
again. No pulse either way. */
|
||||
.doing--stalled {
|
||||
border-color: var(--zk-amber-line);
|
||||
background: var(--zk-amber-tint);
|
||||
}
|
||||
.doing--stalled strong { color: var(--zk-amber-ink); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user