initial commit
This commit is contained in:
142
src/components/assistant/customerFlow.js
Normal file
142
src/components/assistant/customerFlow.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { parseCustomerDraft, validateCustomerDraft, buildCustomerPayload } from './actions';
|
||||
|
||||
// ==============================|| Doormile AI — conversational create-customer ||============================== //
|
||||
//
|
||||
// Asks for one field at a time, then shows what it will send and waits for
|
||||
// Submit.
|
||||
//
|
||||
// ---- Why this lives here and not in the router ------------------------------
|
||||
//
|
||||
// A first attempt at this shipped and broke immediately: the operator typed
|
||||
// "create customer", was asked for a phone number, replied "8494948494", and
|
||||
// got "I can't answer that one yet".
|
||||
//
|
||||
// The cause was architectural, not a typo. `answerQuestion` picks an intent by
|
||||
// MATCHING THE TEXT — and a bare phone number matches nothing, so the reply was
|
||||
// routed to the fallback and discarded. Threading a partial draft through the
|
||||
// router's `context` didn't help, because the router had already failed to
|
||||
// choose an intent before the draft was ever consulted.
|
||||
//
|
||||
// So the conversation is owned by the PANEL, which checks for an active flow
|
||||
// BEFORE calling the router at all. A reply mid-flow is never routed. That is
|
||||
// the only arrangement where "8494948494" can't be misread as a question.
|
||||
//
|
||||
// Field set and validation mirror pages/nearle/clients/createCustomer.js:
|
||||
// name and a 10-digit phone are required, everything else is optional and
|
||||
// skippable.
|
||||
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
const EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.]{2,}$/;
|
||||
|
||||
// "skip", "none", "no", "-" all mean "leave it blank". Without this the
|
||||
// operator has no way past an optional field except inventing a value.
|
||||
const SKIP_RE = /^(?:skip|none|no|n\/a|na|-|nil)$/i;
|
||||
|
||||
export const isSkip = (text) => SKIP_RE.test(String(text || '').trim());
|
||||
|
||||
// Ordered. `ask` is the question; `apply` folds the answer into the draft;
|
||||
// `validate` returns an error string to re-ask with, or null to accept.
|
||||
export const CUSTOMER_STEPS = [
|
||||
{
|
||||
field: 'name',
|
||||
ask: 'What’s the customer’s name?',
|
||||
required: true,
|
||||
apply: (draft, text) => {
|
||||
const [firstname, ...rest] = String(text).trim().split(/\s+/);
|
||||
return { ...draft, firstname, lastname: rest.join(' ') || undefined };
|
||||
},
|
||||
validate: (text) => (String(text).trim().length >= 2 ? null : 'I need a name — at least two characters.')
|
||||
},
|
||||
{
|
||||
field: 'phone',
|
||||
ask: 'And their 10-digit mobile number?',
|
||||
required: true,
|
||||
apply: (draft, text) => ({ ...draft, phone: String(text).replace(/\D/g, '') }),
|
||||
// Validated against the digits only, so "98765 43210" and "+91 9876543210"
|
||||
// are both accepted rather than rejected on formatting.
|
||||
validate: (text) => {
|
||||
const digits = String(text)
|
||||
.replace(/\D/g, '')
|
||||
.replace(/^91(?=\d{10}$)/, '');
|
||||
return PHONE_RE.test(digits) ? null : 'That doesn’t look like 10 digits — try again.';
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
ask: 'Email address? (say “skip” if there isn’t one)',
|
||||
apply: (draft, text) => ({ ...draft, email: String(text).trim() }),
|
||||
validate: (text) => (EMAIL_RE.test(String(text).trim()) ? null : 'That doesn’t look like an email — or say “skip”.')
|
||||
},
|
||||
{
|
||||
field: 'address',
|
||||
ask: 'Address? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, address: String(text).trim() })
|
||||
},
|
||||
{
|
||||
field: 'city',
|
||||
ask: 'City? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, city: String(text).trim() })
|
||||
},
|
||||
{
|
||||
field: 'postcode',
|
||||
ask: 'Postcode? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, postcode: String(text).replace(/\D/g, '') })
|
||||
}
|
||||
];
|
||||
|
||||
// Starts the flow, pre-filling anything already said in the opening message —
|
||||
// "create a customer Ramesh 9876543210" should not then ask for the name and
|
||||
// the phone it was just given.
|
||||
export const startCustomerFlow = (text) => {
|
||||
const draft = parseCustomerDraft(text);
|
||||
return advance({ kind: 'createCustomer', step: 0, draft });
|
||||
};
|
||||
|
||||
// Moves to the next step that still needs an answer. Returns either a question
|
||||
// to ask, or the finished proposal.
|
||||
export const advance = (flow) => {
|
||||
let { step } = flow;
|
||||
const { draft } = flow;
|
||||
|
||||
while (step < CUSTOMER_STEPS.length) {
|
||||
const s = CUSTOMER_STEPS[step];
|
||||
const already = s.field === 'name' ? draft.firstname : draft[s.field];
|
||||
if (already) {
|
||||
step += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
return { flow: { ...flow, step }, ask: s.ask, done: false };
|
||||
}
|
||||
|
||||
// Every step visited. The required check is repeated here rather than
|
||||
// trusted from the walk above, so a skipped-but-required field can never
|
||||
// reach a proposal.
|
||||
const { ok, missing } = validateCustomerDraft(draft);
|
||||
if (!ok) {
|
||||
return { flow: { ...flow, step: 0 }, ask: `I still need ${missing.join(' and ')}. What’s the name?`, done: false };
|
||||
}
|
||||
return { flow: { ...flow, step, complete: true }, done: true, payload: buildCustomerPayload(draft) };
|
||||
};
|
||||
|
||||
// Applies one answer. Returns the next question, or the completed proposal, or
|
||||
// a re-ask when the answer didn't validate.
|
||||
export const answerStep = (flow, text) => {
|
||||
const s = CUSTOMER_STEPS[flow.step];
|
||||
if (!s) return advance(flow);
|
||||
|
||||
if (isSkip(text)) {
|
||||
if (s.required) return { flow, ask: `Sorry — ${s.field === 'name' ? 'a name' : 'this'} is required. ${s.ask}`, done: false };
|
||||
// Skipped optional field: step past it without writing anything, so the
|
||||
// payload builder drops it rather than sending an empty string.
|
||||
return advance({ ...flow, step: flow.step + 1 });
|
||||
}
|
||||
|
||||
const error = s.validate?.(text);
|
||||
if (error) return { flow, ask: error, done: false, retry: true };
|
||||
|
||||
return advance({ ...flow, step: flow.step + 1, draft: s.apply(flow.draft, text) });
|
||||
};
|
||||
|
||||
// Human-readable summary of what will be sent, for the confirm step.
|
||||
export const describePayload = (payload) => Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) }));
|
||||
Reference in New Issue
Block a user