Files
doormilxpress_astryx/src/lib/skills/positionFlow.js
2026-08-14 17:33:16 +05:30

503 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}?` },
]);