143 lines
5.7 KiB
JavaScript
143 lines
5.7 KiB
JavaScript
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;
|
||
|
||
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) }));
|