Compare commits
No commits in common. "93baba3fa19ab2a35df9231c77cbf3887de66f6e" and "f4c67310cbbfff276f6ca47df38d3d7ff8038e85" have entirely different histories.
93baba3fa1
...
f4c67310cb
@ -420,71 +420,3 @@ export function blockedOn(lead) {
|
|||||||
}
|
}
|
||||||
return null
|
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,31 +11,18 @@ import './Conversation.css'
|
|||||||
* right.
|
* right.
|
||||||
*
|
*
|
||||||
* WHAT IT CAN AND CANNOT SHOW, said plainly at the foot of the panel rather
|
* WHAT IT CAN AND CANNOT SHOW, said plainly at the foot of the panel rather
|
||||||
* than left for someone to discover. The console can only show what the
|
* than left for someone to discover. Every message the workflow RECORDS is
|
||||||
* workflow RECORDS, so anything sent from inside a trigger node and never
|
* here: the customer's replies (customer_reply) and the replies written back
|
||||||
* written to a field is invisible here however certainly the customer received
|
* (customer_answer). The three automatic sends are not. The quote goes out as
|
||||||
* it. The acceptance confirmation used to be exactly that — the panel showed
|
* a registered WhatsApp template, and the acknowledgement and the acceptance
|
||||||
* nothing after "I confirm", which read as though we had ignored somebody
|
* confirmation are composed inside trigger nodes and never written to a field.
|
||||||
* agreeing to buy — so 90 writes it into customer_answer and it appears.
|
* A thread that quietly omitted them would be worse than one that says which
|
||||||
*
|
* parts it holds.
|
||||||
* 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) {
|
function turnsFrom(rows) {
|
||||||
if (!Array.isArray(rows)) return []
|
if (!Array.isArray(rows)) return []
|
||||||
const out = []
|
const out = []
|
||||||
// Sorted on (timestamp, id). One submission writes three rows in the same
|
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
|
||||||
// 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) {
|
for (const r of ordered) {
|
||||||
const fields = Array.isArray(r.fields) && r.fields.length
|
const fields = Array.isArray(r.fields) && r.fields.length
|
||||||
? r.fields
|
? r.fields
|
||||||
@ -48,17 +35,15 @@ function turnsFrom(rows) {
|
|||||||
if (base === 'customer_answer') out.push({ side: 'us', text: v, at: r.created_at, key: r.id + '-out' })
|
if (base === 'customer_answer') out.push({ side: 'us', text: v, at: r.created_at, key: r.id + '-out' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// One submission is recorded three times — the trigger commit, its repeat,
|
// One submission can be recorded more than once (the trigger commit and the
|
||||||
// and the settle — so the same sentence would print three times.
|
// 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.
|
||||||
// De-duped against the PREVIOUS turn only, not against the whole thread. A
|
const seen = new Set()
|
||||||
// customer who asks the same thing twice because the first went unanswered
|
return out.filter((t) => {
|
||||||
// has said it twice, and a thread that silently showed it once would hide
|
const k = t.side + ' ' + t.text
|
||||||
// exactly the impatience an operator needs to see. Only an immediate repeat
|
if (seen.has(k)) return false
|
||||||
// of the same side and the same words is the platform talking to itself.
|
seen.add(k)
|
||||||
return out.filter((t, i) => {
|
return true
|
||||||
const prev = out[i - 1]
|
|
||||||
return !(prev && prev.side === t.side && prev.text === t.text)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,11 +105,9 @@ export default function Conversation({ rows, name, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="conv__note">
|
<p className="conv__note">
|
||||||
Shows the customer's messages and everything written back to them,
|
Shows the customer's messages and the replies written back. The quote
|
||||||
including the acceptance confirmation. Two automatic sends are not here:
|
itself, the read receipt and the acceptance confirmation are sent
|
||||||
the quote, which goes out as a registered WhatsApp template rather than
|
automatically and are not recorded as text, so they do not appear here.
|
||||||
text we compose, and the “I have your message” receipt sent
|
|
||||||
before anything has read it.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import AgentChip from '../components/AgentChip.jsx'
|
|||||||
import ClampText from '../components/ClampText.jsx'
|
import ClampText from '../components/ClampText.jsx'
|
||||||
import Conversation from '../components/Conversation.jsx'
|
import Conversation from '../components/Conversation.jsx'
|
||||||
import Timeline from '../components/Timeline.jsx'
|
import Timeline from '../components/Timeline.jsx'
|
||||||
import { APP_ID, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
|
import { APP_ID, DV_LEAD, PRODUCTS, STAGES, blockedOn, phaseOf } from '../api/config.js'
|
||||||
import { AGENTS } from '../api/agents.js'
|
import { AGENTS } from '../api/agents.js'
|
||||||
import { actionsFor, rolesOf } from '../api/permissions.js'
|
import { actionsFor, rolesOf } from '../api/permissions.js'
|
||||||
import { describeError } from '../api/errors.js'
|
import { describeError } from '../api/errors.js'
|
||||||
@ -152,11 +152,6 @@ export default function Lead() {
|
|||||||
const [audit, setAudit] = useState(null)
|
const [audit, setAudit] = useState(null)
|
||||||
const [showChat, setShowChat] = useState(false)
|
const [showChat, setShowChat] = useState(false)
|
||||||
const [openAgent, setOpenAgent] = useState(null)
|
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) => {
|
const load = useCallback((quiet = false) => {
|
||||||
if (!quiet) setErr(null)
|
if (!quiet) setErr(null)
|
||||||
@ -211,11 +206,6 @@ export default function Lead() {
|
|||||||
* moves while it is on screen. Poll quietly: no spinner, no skeleton, and not
|
* moves while it is on screen. Poll quietly: no spinner, no skeleton, and not
|
||||||
* at all while the tab is in the background.
|
* at all while the tab is in the background.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
|
||||||
const id = setInterval(() => setNow(Date.now()), 20000)
|
|
||||||
return () => clearInterval(id)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Not while a form is open. A successful poll rewrites `actions`, and if the
|
// 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
|
// lead has moved on the open form unmounts — taking whatever was typed into
|
||||||
@ -278,17 +268,6 @@ export default function Lead() {
|
|||||||
// the "in progress" strip below, which would otherwise keep promising that
|
// the "in progress" strip below, which would otherwise keep promising that
|
||||||
// something is happening for as long as the lead is left alone.
|
// something is happening for as long as the lead is left alone.
|
||||||
const blocked = stage?.kind === 'auto' ? blockedOn(row) : null
|
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 (
|
return (
|
||||||
@ -419,29 +398,6 @@ export default function Lead() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
) : stage?.kind === 'customer' && stage.doing ? (
|
||||||
/* Not an agent working, and not a task of yours either — the lead
|
/* Not an agent working, and not a task of yours either — the lead
|
||||||
is with someone outside the business. Without this the screen
|
is with someone outside the business. Without this the screen
|
||||||
|
|||||||
@ -1358,14 +1358,3 @@
|
|||||||
color: var(--zk-muted);
|
color: var(--zk-muted);
|
||||||
white-space: nowrap;
|
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