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}?` }, ]);