update ui design for all pages
This commit is contained in:
455
src/lib/skills/positionFlow.js
Normal file
455
src/lib/skills/positionFlow.js
Normal file
@@ -0,0 +1,455 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
|
||||
import {
|
||||
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
|
||||
extractLocation, extractPay, extractRole,
|
||||
} from './actions';
|
||||
|
||||
/**
|
||||
* Creating a position, as a conversation.
|
||||
*
|
||||
* Owliver used to answer "create a bartender position" by opening the Create
|
||||
* Position form with three fields filled in — which left the person who had just
|
||||
* described the job in one sentence to type the rest of it into a drawer. The
|
||||
* form was doing the asking, and the assistant was doing the paperwork.
|
||||
*
|
||||
* This module turns that round. The skill file lists the questions; this decides
|
||||
* what each field name means — how an answer is read, whether it is already
|
||||
* settled, and how it reads back in the summary. Same split as `actions.js`:
|
||||
* Markdown declares, code interprets, and a field the code does not know is
|
||||
* simply not asked about rather than being handled arbitrarily.
|
||||
*
|
||||
* The flow is a plain serializable object. It survives being written to
|
||||
* sessionStorage between turns, and it holds no component state, so the
|
||||
* conversation can be resumed exactly where it stopped.
|
||||
*
|
||||
* Nothing here writes. The last step returns a draft, and the panel creates the
|
||||
* position with the same mutation the form uses.
|
||||
*/
|
||||
|
||||
/* ── Reading answers ────────────────────────────────────────────────────── */
|
||||
|
||||
/** Title Case, for the free-text answers that name a proper noun. */
|
||||
const titleCase = (value) => value.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
/** A short free-text answer, stripped of the words around it. */
|
||||
function cleanPhrase(answer, max = 60) {
|
||||
const value = String(answer)
|
||||
.trim()
|
||||
.replace(/^(?:in|at|around|near|it is|its|it's)\s+/i, '')
|
||||
.replace(/[.!?,;]+$/, '')
|
||||
.replace(/\s+/g, ' ');
|
||||
return value && value.length <= max ? value : null;
|
||||
}
|
||||
|
||||
/** "Skip" is an answer to an optional question — it settles it, unanswered. */
|
||||
const SKIP = /^(?:skip|skip this|skip it|no preference|not sure|does ?n(?:'|o)t matter|any|none of these)$/i;
|
||||
|
||||
/** A chip that means "let me type it" rather than an answer in itself. */
|
||||
const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere else)$/i;
|
||||
|
||||
/**
|
||||
* What each field means: when it is already settled, how an answer is read, and
|
||||
* how it reads back.
|
||||
*
|
||||
* `parse` returns a patch for the draft, or `null` when the answer was not
|
||||
* understood — which re-asks with `retry` rather than storing a guess.
|
||||
*/
|
||||
const FIELDS = {
|
||||
role_category: {
|
||||
label: 'Role',
|
||||
settled: (draft) => Boolean(draft.title),
|
||||
retry: 'Name the role — "bartender", "line cook", "event staff".',
|
||||
parse: (answer, { roles }) => {
|
||||
const role = extractRole(answer, roles) || cleanPhrase(answer, 40);
|
||||
if (!role || role.length < 2) return null;
|
||||
const known = roles.find((r) => String(r).toLowerCase() === role.toLowerCase());
|
||||
/* A known category files the position; anything else becomes the title and
|
||||
leaves the category at its default, exactly as the form behaves. */
|
||||
return known
|
||||
? { role_category: known, title: known }
|
||||
: { title: titleCase(role) };
|
||||
},
|
||||
summary: (draft) => (draft.role_category && draft.role_category !== draft.title
|
||||
? `${draft.title} · ${draft.role_category}`
|
||||
: draft.title),
|
||||
},
|
||||
|
||||
location: {
|
||||
label: 'Location',
|
||||
settled: (draft) => Boolean(draft.location),
|
||||
retry: 'Type the city or area this role is based in.',
|
||||
parse: (answer) => {
|
||||
if (FREE_TEXT.test(String(answer).trim())) return null;
|
||||
const value = extractLocation(answer) || cleanPhrase(answer);
|
||||
return value ? { location: titleCase(value) } : null;
|
||||
},
|
||||
summary: (draft) => draft.location,
|
||||
},
|
||||
|
||||
pay: {
|
||||
label: 'Pay',
|
||||
settled: (draft) => Number(draft.pay_range_min) > 0 || Number(draft.pay_range_max) > 0,
|
||||
retry: 'Give a range like "$28–$36/hr", or a single rate.',
|
||||
parse: (answer) => {
|
||||
const pay = extractPay(answer);
|
||||
return pay ? { pay_range_min: String(pay.min), pay_range_max: String(pay.max) } : null;
|
||||
},
|
||||
summary: (draft) => payLabel(draft),
|
||||
},
|
||||
|
||||
min_experience_years: {
|
||||
label: 'Experience',
|
||||
settled: (draft) => draft.min_experience_years !== undefined && draft.min_experience_years !== null,
|
||||
retry: 'How many years — or "no minimum".',
|
||||
parse: (answer) => {
|
||||
const years = extractExperience(answer);
|
||||
if (years !== null) return { min_experience_years: years };
|
||||
const bare = /^(\d{1,2})\s*\+?$/.exec(String(answer).trim());
|
||||
return bare ? { min_experience_years: Number(bare[1]) } : null;
|
||||
},
|
||||
summary: (draft) => (draft.min_experience_years
|
||||
? `${draft.min_experience_years}+ years experience`
|
||||
: 'No minimum experience'),
|
||||
},
|
||||
|
||||
english_required: {
|
||||
label: 'English',
|
||||
settled: (draft) => Boolean(draft.english_required),
|
||||
retry: `One of ${ENGLISH_LEVELS.map((l) => l.label).join(', ')}.`,
|
||||
parse: (answer) => {
|
||||
const level = extractEnglish(answer)
|
||||
|| ENGLISH_LEVELS.find((l) => l.label.toLowerCase() === String(answer).trim().toLowerCase())?.value;
|
||||
return level ? { english_required: level } : null;
|
||||
},
|
||||
summary: (draft) => {
|
||||
const level = ENGLISH_LEVELS.find((l) => l.value === draft.english_required);
|
||||
return level ? `English: ${level.label}` : null;
|
||||
},
|
||||
},
|
||||
|
||||
certifications_required: {
|
||||
label: 'Certifications',
|
||||
settled: (draft) => Array.isArray(draft.certifications_required),
|
||||
retry: 'Name a certification, or "none".',
|
||||
parse: (answer) => {
|
||||
const found = extractCertifications(answer);
|
||||
if (found) return { certifications_required: found };
|
||||
if (/^(?:none|no|no certifications?)$/i.test(String(answer).trim())) {
|
||||
return { certifications_required: [] };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
summary: (draft) => (draft.certifications_required?.length
|
||||
? `Certifications: ${draft.certifications_required.join(', ')}`
|
||||
: null),
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Suggestions ────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A step's chips.
|
||||
*
|
||||
* `@`-prefixed entries in the skill file resolve to the lists the application
|
||||
* already owns, so the roles offered here are the roles the form offers and a
|
||||
* certification added to the record's options appears in the conversation
|
||||
* without this file or the skill file changing.
|
||||
*/
|
||||
function suggestionsFor(step, { roles }) {
|
||||
const resolved = step.options.flatMap((option) => {
|
||||
if (option === '@roles') return roles;
|
||||
if (option === '@english') return ENGLISH_LEVELS.map((l) => l.label);
|
||||
if (option === '@certifications') return CERT_OPTIONS;
|
||||
return [option];
|
||||
});
|
||||
|
||||
const capped = [...new Set(resolved)].slice(0, 6);
|
||||
/* An optional question needs a way past it that is not a typed sentence. */
|
||||
if (!step.required) capped.push('Skip');
|
||||
return capped.map((label) => ({ label, prompt: label }));
|
||||
}
|
||||
|
||||
/* ── Flow state ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The steps a skill declares, keeping only the fields this module understands. */
|
||||
const stepsOf = (skill) => (skill?.conversation || []).filter((s) => FIELDS[s.field]);
|
||||
|
||||
/** Is this field answered, or deliberately passed over? */
|
||||
const isSettled = (flow, field) => FIELDS[field].settled(flow.draft) || flow.skipped.includes(field);
|
||||
|
||||
/** The next question, or `null` when there is nothing left to ask. */
|
||||
function nextStep(flow, steps) {
|
||||
if (flow.editing) return steps.find((s) => s.field === flow.editing) || null;
|
||||
return steps.find((s) => !isSettled(flow, s.field)) || null;
|
||||
}
|
||||
|
||||
/** Everything decided so far, one line each, in the order it is asked. */
|
||||
function summaryLines(flow, steps) {
|
||||
return steps
|
||||
.filter((s) => isSettled(flow, s.field))
|
||||
.map((s) => FIELDS[s.field].summary(flow.draft))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** The required fields a position cannot be created without. */
|
||||
const missingRequired = (flow, steps) => steps.filter((s) => s.required && !FIELDS[s.field].settled(flow.draft));
|
||||
|
||||
/* ── Replies ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Ask the next question, or read the position back when there is none left. */
|
||||
function ask(flow, steps, { roles, preamble = null, retry = null }) {
|
||||
const step = nextStep(flow, steps);
|
||||
|
||||
if (!step) return review(flow, steps);
|
||||
|
||||
return {
|
||||
flow: { ...flow, stage: 'collect', step: step.field },
|
||||
doc: doc(
|
||||
preamble ? text(preamble) : null,
|
||||
preamble && summaryLines(flow, steps).length ? list(summaryLines(flow, steps)) : null,
|
||||
text(step.question),
|
||||
retry ? note(retry) : null
|
||||
),
|
||||
followUp: suggestionsFor(step, { roles }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole position, before anything is written. */
|
||||
function review(flow, steps) {
|
||||
const missing = missingRequired(flow, steps);
|
||||
/* Only reachable if a required answer was cleared — ask for it rather than
|
||||
offering to create something incomplete. */
|
||||
if (missing.length) {
|
||||
return {
|
||||
flow: { ...flow, stage: 'collect', step: missing[0].field, editing: null },
|
||||
doc: doc(text(missing[0].question)),
|
||||
followUp: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
flow: { ...flow, stage: 'review', step: null, editing: null },
|
||||
doc: doc(
|
||||
text('Ready to create this position?'),
|
||||
list(summaryLines(flow, steps)),
|
||||
note('Nothing is saved until you choose Create position.')
|
||||
),
|
||||
followUp: [
|
||||
{ label: 'Create position', prompt: 'Create position' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Which detail to change — the answers already given, as chips. */
|
||||
function changeMenu(flow, steps) {
|
||||
const settled = steps.filter((s) => isSettled(flow, s.field));
|
||||
return {
|
||||
flow: { ...flow, stage: 'change', step: null },
|
||||
doc: doc(text('What should I change?')),
|
||||
followUp: settled.map((s) => ({ label: FIELDS[s.field].label, prompt: FIELDS[s.field].label })),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Entry points ───────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Start collecting, from whatever the request already said.
|
||||
*
|
||||
* "Create a bartender position in Chennai paying $30–$40/hr" answers three
|
||||
* questions before the first one is asked, and those are not asked again.
|
||||
*/
|
||||
export function beginPositionFlow({ question, skill, roles = [] }) {
|
||||
const { prefill } = buildPositionPrefill(question, roles);
|
||||
const steps = stepsOf(skill);
|
||||
|
||||
const flow = {
|
||||
skillId: skill.id,
|
||||
draft: prefill,
|
||||
skipped: [],
|
||||
editing: null,
|
||||
stage: 'collect',
|
||||
step: null,
|
||||
};
|
||||
|
||||
const known = summaryLines(flow, steps);
|
||||
return ask(flow, steps, {
|
||||
roles,
|
||||
preamble: known.length ? `Got it — here is what I have so far:` : null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One answer, and whatever it makes the next thing to say.
|
||||
*
|
||||
* Returns `{ flow, doc, followUp }`, and on the confirmation step additionally
|
||||
* `create: { draft }` — the panel writes it, this module does not.
|
||||
*/
|
||||
export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
|
||||
const steps = stepsOf(skill);
|
||||
const said = String(answer).trim();
|
||||
|
||||
/* A way out that does not require finishing. `flow: null` ends it, and the
|
||||
next question is answered by the page as usual. */
|
||||
if (/^(?:cancel|stop|never ?mind|nevermind|forget it|quit|exit)$/i.test(said)) {
|
||||
return {
|
||||
flow: null,
|
||||
doc: doc(
|
||||
text('Stopped — nothing was created.'),
|
||||
note('Ask me to create a position whenever you are ready.')
|
||||
),
|
||||
followUp: [],
|
||||
};
|
||||
}
|
||||
|
||||
/* The confirmation step. "Create position" is the only path to a record. */
|
||||
if (flow.stage === 'review') {
|
||||
if (/^(?:create position|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) {
|
||||
if (missingRequired(flow, steps).length) return review(flow, steps);
|
||||
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft } };
|
||||
}
|
||||
if (/^(?:change|change details|edit|change something|no)$/i.test(said)) return changeMenu(flow, steps);
|
||||
|
||||
/* Anything else at the confirmation step is a correction stated outright —
|
||||
"make it Bengaluru", "$32–$40". Read it against every field and apply
|
||||
what it settles, rather than making the user find the menu. */
|
||||
const revised = applyStatement(flow, steps, said, roles);
|
||||
if (revised) return review(revised, steps);
|
||||
|
||||
return {
|
||||
...review(flow, steps),
|
||||
doc: doc(
|
||||
text('I did not catch that. Ready to create this position?'),
|
||||
list(summaryLines(flow, steps)),
|
||||
note('Choose Create position, or tell me what to change.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/* Choosing which detail to revisit. */
|
||||
if (flow.stage === 'change') {
|
||||
const target = steps.find((s) => FIELDS[s.field].label.toLowerCase() === said.toLowerCase());
|
||||
if (!target) {
|
||||
const revised = applyStatement(flow, steps, said, roles);
|
||||
if (revised) return review(revised, steps);
|
||||
return changeMenu(flow, steps);
|
||||
}
|
||||
return ask({ ...flow, editing: target.field }, steps, { roles });
|
||||
}
|
||||
|
||||
/* Answering the question that was asked. */
|
||||
const step = steps.find((s) => s.field === flow.step) || nextStep(flow, steps);
|
||||
if (!step) return review(flow, steps);
|
||||
|
||||
const field = FIELDS[step.field];
|
||||
|
||||
if (SKIP.test(said) && !step.required) {
|
||||
const next = { ...flow, skipped: [...flow.skipped, step.field], editing: null };
|
||||
return ask(next, steps, { roles });
|
||||
}
|
||||
|
||||
const patch = field.parse(said, { roles });
|
||||
if (!patch) {
|
||||
/* Not an answer to this question — but it may still be a fact about the
|
||||
position ("in Chennai" while being asked for pay). Take it if so. */
|
||||
const revised = applyStatement(flow, steps, said, roles);
|
||||
if (revised) return ask(revised, steps, { roles });
|
||||
return ask(flow, steps, { roles, retry: field.retry });
|
||||
}
|
||||
|
||||
const next = {
|
||||
...flow,
|
||||
draft: { ...flow.draft, ...patch },
|
||||
skipped: flow.skipped.filter((f) => f !== step.field),
|
||||
editing: null,
|
||||
};
|
||||
|
||||
/* A detail revisited from the change menu goes straight back to the summary
|
||||
rather than walking the rest of the questions again. */
|
||||
if (flow.editing) return review(next, steps);
|
||||
|
||||
return ask(next, steps, { roles });
|
||||
}
|
||||
|
||||
/**
|
||||
* A sentence read against every field at once.
|
||||
*
|
||||
* This is what makes the conversation forgiving: an answer that arrives out of
|
||||
* order, or a correction stated rather than chosen from a menu, lands in the
|
||||
* right field instead of being rejected for not answering the question asked.
|
||||
* Returns an updated flow, or `null` when the sentence settles nothing.
|
||||
*/
|
||||
function applyStatement(flow, steps, said, roles) {
|
||||
const { prefill } = buildPositionPrefill(said, roles);
|
||||
const fields = new Set(steps.map((s) => s.field));
|
||||
|
||||
const patch = {};
|
||||
/* Step names, not record fields — a step the sentence settled must come off
|
||||
the skipped list so the summary shows it. */
|
||||
const touched = new Set();
|
||||
|
||||
if (fields.has('role_category') && prefill.title) {
|
||||
if (prefill.role_category) patch.role_category = prefill.role_category;
|
||||
patch.title = prefill.title;
|
||||
touched.add('role_category');
|
||||
}
|
||||
if (fields.has('location') && prefill.location) {
|
||||
patch.location = prefill.location;
|
||||
touched.add('location');
|
||||
}
|
||||
if (fields.has('pay') && prefill.pay_range_min) {
|
||||
patch.pay_range_min = prefill.pay_range_min;
|
||||
patch.pay_range_max = prefill.pay_range_max;
|
||||
touched.add('pay');
|
||||
}
|
||||
if (fields.has('min_experience_years') && prefill.min_experience_years !== undefined) {
|
||||
patch.min_experience_years = prefill.min_experience_years;
|
||||
touched.add('min_experience_years');
|
||||
}
|
||||
if (fields.has('english_required') && prefill.english_required) {
|
||||
patch.english_required = prefill.english_required;
|
||||
touched.add('english_required');
|
||||
}
|
||||
if (fields.has('certifications_required') && prefill.certifications_required) {
|
||||
patch.certifications_required = prefill.certifications_required;
|
||||
touched.add('certifications_required');
|
||||
}
|
||||
|
||||
if (!touched.size) return null;
|
||||
|
||||
return {
|
||||
...flow,
|
||||
draft: { ...flow.draft, ...patch },
|
||||
skipped: flow.skipped.filter((f) => !touched.has(f)),
|
||||
editing: null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Outcomes ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The position exists. Said plainly, with what was created. */
|
||||
export function positionCreatedReply(position) {
|
||||
return doc(
|
||||
text('Position created successfully.'),
|
||||
list([
|
||||
position.title,
|
||||
position.location,
|
||||
payLabel(position),
|
||||
].filter(Boolean)),
|
||||
note('It is on the Positions list now — applications will start appearing against it.')
|
||||
);
|
||||
}
|
||||
|
||||
/** The write failed. The draft is kept, so the answers are not lost. */
|
||||
export function positionFailedReply() {
|
||||
return doc(
|
||||
text('I could not create that position.'),
|
||||
note('Nothing was saved. Choose Create position to try again.')
|
||||
);
|
||||
}
|
||||
|
||||
/** What the panel offers after a position is created. */
|
||||
export const createdFollowUp = (position) => [
|
||||
{ label: 'View position', route: `/admin/positions/${position.id}` },
|
||||
];
|
||||
Reference in New Issue
Block a user