503 lines
20 KiB
JavaScript
503 lines
20 KiB
JavaScript
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 = {
|
||
/**
|
||
* The client this role is being staffed for.
|
||
*
|
||
* A "client" is not a record of its own in this product — it is the `company`
|
||
* on the position, which is the field the Create Position form writes, the
|
||
* Positions card leads with, and Hired History reports against. So the
|
||
* conversation collects it into the same field rather than into a store of
|
||
* its own, and asking Owliver to create a client starts here.
|
||
*/
|
||
company: {
|
||
label: 'Company',
|
||
settled: (draft) => Boolean(String(draft.company || '').trim()),
|
||
retry: 'Type the client or company name — "Fairmont San Jose".',
|
||
parse: (answer) => {
|
||
const value = cleanPhrase(answer, 60);
|
||
return value && value.length > 1 ? { company: titleCase(value) } : null;
|
||
},
|
||
summary: (draft) => (draft.company ? `Company: ${draft.company}` : null),
|
||
},
|
||
|
||
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 one. Save as Draft keeps it unpublished — the same as the button on the form.')
|
||
),
|
||
/* The two the form offers, in the same words and the same order, so the
|
||
conversation and the page commit a position the same two ways. */
|
||
followUp: [
|
||
{ label: 'Save as Draft', prompt: 'Save as draft' },
|
||
{ label: 'Publish Job Posting', prompt: 'Publish job posting' },
|
||
{ 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') {
|
||
/* Saving unpublished. The same write, with the status the form's own
|
||
"Save as Draft" button sets — one create path, two statuses. */
|
||
if (/^(?:save as draft|save draft|draft|save it as a draft)$/i.test(said)) {
|
||
if (missingRequired(flow, steps).length) return review(flow, steps);
|
||
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'draft' } };
|
||
}
|
||
if (/^(?:create position|publish job posting|publish|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, status: 'active' } };
|
||
}
|
||
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 Save as Draft or Publish Job Posting, 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) {
|
||
const isDraft = position.status === 'draft';
|
||
|
||
return doc(
|
||
text(isDraft ? 'Saved as a draft.' : 'Position created successfully.'),
|
||
list([
|
||
position.company,
|
||
position.title,
|
||
position.location,
|
||
payLabel(position),
|
||
].filter(Boolean)),
|
||
note(isDraft
|
||
? 'It is on the Positions list as a draft — nobody can apply until it is published, and it stays a draft until you publish it.'
|
||
: '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.
|
||
*
|
||
* A published role has an obvious next question — who can fill it — so it is
|
||
* offered here rather than left to be typed. The chip carries the position's
|
||
* own title and the wording the workforce engine already answers, so it is an
|
||
* ordinary question resolved by the path that was already there: no handler,
|
||
* no navigation, and the same answer as asking it by hand.
|
||
*/
|
||
export const createdFollowUp = (position) => (position.status === 'draft'
|
||
/* A draft is unfinished, so the way on is the form that finishes it — the
|
||
same route the Positions card's Continue uses. */
|
||
? [{ label: 'Continue to save', route: `/admin/positions/new?draft=${encodeURIComponent(position.id)}` }]
|
||
: [
|
||
{ label: 'View position', route: `/admin/positions/${position.id}` },
|
||
{ label: 'Match candidates', prompt: `Who matches ${position.title}?` },
|
||
]);
|