Make a conversation a registry, and add the second one
Some checks failed
CI / check (push) Failing after 4m57s
Some checks failed
CI / check (push) Failing after 4m57s
`positionFlow.js` was five per-domain concerns in one file — a field table, an
`@`-token resolver, a sentence extractor, a commit vocabulary and a set of
outcome renderers — and only the control flow between them was general.
Everything else knew it was creating a job posting. Adding a second
conversation meant a second copy of all of it.
So the control flow is now `conversationFlow.js` and each kind of record is a
REGISTRY. Which conversation a skill runs is the skill's own `flow:` line,
resolved through a map: `routing.js` used to say `if (skill.id ===
'create-position')`, which made a second conversational skill a change to the
router rather than a file on disk — the `if agent_key == ...` shape the
platform rules out one level up. The panel's write callback is likewise a map
keyed by flow id instead of an `onCreatePosition` prop, and the outcome wording
comes off the registry, so nothing in the panel names a kind of record any
more.
`create-employee-role` is the second registry. The worker is asked for and
never assumed: a conversation that names nobody re-asks rather than falling
back to the session, because an operator records this on somebody's behalf. Its
`extract` is deliberately narrower than the posting's — "bartender, weekends,
$30/hr" settles three fields and leaves the subject alone, since guessing WHO a
record is about from a fragment is how a role gets filed against the wrong
person.
THE CONFIRMATION STEP ACCEPTED "create position" AND SILENTLY REJECTED "create
positions" — the plural the Positions page itself uses. An anchored regex missed
it, and the reader got the summary back with no indication of what was wrong
with what they said, which is indistinguishable from the screen not having
updated. Matching is now exact membership against a normalized reply, so a
vocabulary is a list of phrases somebody can read rather than an expression
somebody has to parse.
Two bugs in `extractRole`, both of which fabricated a value nobody typed on the
one field a position cannot be created without:
- The phrase pattern marks "new" as the role by the same grammar that marks
"sous chef", so "create new position" opened the conversation titled "New".
The scaffolding is a PHRASE at least as often as a single word, so a
per-word test still produced "Brand New" and "One More". Scaffolding words
are now stripped to DECIDE whether the phrase named anything, and the
ORIGINAL phrase is returned when it did — strip to test, never to rewrite,
or "second chef" becomes "Chef" and the cure is worse than the bug.
- `(?:a|an)?\s*` has no word boundary, so it matched the leading "a" of
"another" and the capture began mid-word. That mangled scaffolding into
"Nother New" and, worse, corrupted every role introduced with "an":
"create an open kitchen lead position" titled the position "N Open Kitchen
Lead". A real role, typed correctly, silently wrong. Found by mutation
testing the first fix.
`@companies` and `@workers` resolve from data the panel already holds — postings
and profiles the API has already scoped to the caller — so neither widens
anybody's view and neither costs a request. The company list is deliberately
unsorted: `useJobPostings` asks for `-created_date`, so the clients staffed for
most recently come first, and the panel does no ranking of its own. That last
part is a rule the suite enforces structurally, and it is the right rule — a
second opinion formed in the panel outranking the server's is exactly the kind
of thing that decays quietly.
`npm test` now refuses a conversation step whose field its registry does not
define. `stepsOf` drops unknown fields, so a typo means the flow asks fewer
questions than the file lists — and a skill whose steps are ALL unknown asks
none, jumps to the summary, and offers to write an empty record. Nothing errors
and the Markdown still reads correctly. 970 checks, up from 924; the new ones
walk both conversations end to end, because a wrong answer at the confirmation
step re-renders the same summary a right answer does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
344
src/lib/skills/flows/employeeRole.js
Normal file
344
src/lib/skills/flows/employeeRole.js
Normal file
@@ -0,0 +1,344 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS } from '@/lib/positionModel';
|
||||
import {
|
||||
AVAILABILITY_OPTIONS, defaultEmployeeRole, desiredPayLabel,
|
||||
} from '@/lib/employeeRoleModel';
|
||||
import {
|
||||
extractCertifications, extractEnglish, extractExperience, extractPay, extractRole,
|
||||
} from '../actions';
|
||||
import { cleanPhrase, titleCase } from '../conversationFlow';
|
||||
|
||||
/**
|
||||
* What an employee role's questions mean.
|
||||
*
|
||||
* The supply side. Its sibling registry, `position.js`, is the demand side —
|
||||
* what the organization needs filled. The distinction matters more than the
|
||||
* shared vocabulary suggests: "3 years" on a posting is a MINIMUM the applicant
|
||||
* must clear, and the same words here are what the person HAS. Collapsing them
|
||||
* into one registry with a mode flag would put that difference in a branch
|
||||
* rather than in a field.
|
||||
*
|
||||
* The worker is never derived from the session. An operator records a role on
|
||||
* somebody's behalf, so the subject is answered explicitly — which is also why
|
||||
* `employee-roles` grants Create to operators only. See the policy note in
|
||||
* `internal/domain/policy.go`.
|
||||
*/
|
||||
const fields = {
|
||||
/**
|
||||
* Whose role this is.
|
||||
*
|
||||
* Settled by the email, not the name: the email is what the row is scoped on
|
||||
* and what survives a worker profile being removed. A name with no email
|
||||
* behind it is not an answer, so `parse` refuses one it cannot resolve.
|
||||
*/
|
||||
worker: {
|
||||
label: 'Worker',
|
||||
settled: (draft) => Boolean(String(draft.worker_email || '').trim()),
|
||||
retry: 'Name the worker, or type their email address.',
|
||||
parse: (answer, { workers = [] }) => {
|
||||
const said = String(answer).trim();
|
||||
if (!said) return null;
|
||||
|
||||
/* An email typed outright is the worker, whether or not a profile
|
||||
exists — a role can be recorded before the profile is created. */
|
||||
const email = /\b[^\s@]+@[^\s@]+\.[^\s@]+\b/.exec(said)?.[0];
|
||||
const lower = said.toLowerCase();
|
||||
|
||||
const match = workers.find((w) => (email
|
||||
? String(w.email || '').toLowerCase() === email.toLowerCase()
|
||||
: String(w.name || '').toLowerCase() === lower))
|
||||
/* A chip carries the full name; a typed answer may be part of one. */
|
||||
|| (!email && workers.find((w) => String(w.name || '').toLowerCase().includes(lower)));
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
worker_profile_id: match.id || null,
|
||||
worker_email: match.email,
|
||||
worker_name: match.name || '',
|
||||
};
|
||||
}
|
||||
return email ? { worker_profile_id: null, worker_email: email, worker_name: '' } : null;
|
||||
},
|
||||
summary: (draft) => (draft.worker_name
|
||||
? `Worker: ${draft.worker_name} (${draft.worker_email})`
|
||||
: `Worker: ${draft.worker_email}`),
|
||||
},
|
||||
|
||||
role_category: {
|
||||
label: 'Role',
|
||||
settled: (draft) => Boolean(String(draft.role_category || '').trim()),
|
||||
retry: 'Name the role they work as — "bartender", "server", "line cook".',
|
||||
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());
|
||||
return { role_category: known || titleCase(role) };
|
||||
},
|
||||
summary: (draft) => `Role: ${draft.role_category}`,
|
||||
},
|
||||
|
||||
experience_years: {
|
||||
label: 'Experience',
|
||||
settled: (draft) => draft.experience_years !== undefined && draft.experience_years !== null,
|
||||
retry: 'How many years — or "no experience".',
|
||||
parse: (answer) => {
|
||||
const years = extractExperience(answer);
|
||||
if (years !== null) return { experience_years: years };
|
||||
const bare = /^(\d{1,2})\s*\+?$/.exec(String(answer).trim());
|
||||
if (bare) return { experience_years: Number(bare[1]) };
|
||||
return /^(?:none|no experience|new|fresher)$/i.test(String(answer).trim())
|
||||
? { experience_years: 0 }
|
||||
: null;
|
||||
},
|
||||
summary: (draft) => (draft.experience_years
|
||||
? `${draft.experience_years} years experience`
|
||||
: 'No experience yet'),
|
||||
},
|
||||
|
||||
english_level: {
|
||||
label: 'English',
|
||||
settled: (draft) => Boolean(draft.english_level),
|
||||
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_level: level } : null;
|
||||
},
|
||||
summary: (draft) => {
|
||||
const level = ENGLISH_LEVELS.find((l) => l.value === draft.english_level);
|
||||
return level ? `English: ${level.label}` : null;
|
||||
},
|
||||
},
|
||||
|
||||
certifications: {
|
||||
label: 'Certifications',
|
||||
settled: (draft) => Array.isArray(draft.certifications),
|
||||
retry: 'Name a certification they hold, or "none".',
|
||||
parse: (answer) => {
|
||||
const found = extractCertifications(answer);
|
||||
if (found) return { certifications: found };
|
||||
return /^(?:none|no|no certifications?)$/i.test(String(answer).trim())
|
||||
? { certifications: [] }
|
||||
: null;
|
||||
},
|
||||
summary: (draft) => (draft.certifications?.length
|
||||
? `Certifications: ${draft.certifications.join(', ')}`
|
||||
: null),
|
||||
},
|
||||
|
||||
desired_pay: {
|
||||
label: 'Desired pay',
|
||||
settled: (draft) => Number(draft.desired_pay_min) > 0 || Number(draft.desired_pay_max) > 0,
|
||||
retry: 'Give a range like "$25–$35/hr", or a single rate.',
|
||||
parse: (answer) => {
|
||||
const pay = extractPay(answer);
|
||||
return pay ? { desired_pay_min: pay.min, desired_pay_max: pay.max } : null;
|
||||
},
|
||||
summary: (draft) => {
|
||||
const label = desiredPayLabel(draft);
|
||||
return label ? `Looking for ${label}` : null;
|
||||
},
|
||||
},
|
||||
|
||||
availability: {
|
||||
label: 'Availability',
|
||||
settled: (draft) => Array.isArray(draft.availability),
|
||||
retry: 'When can they work — weekdays, weekends, evenings, full time?',
|
||||
parse: (answer) => {
|
||||
const said = String(answer).toLowerCase();
|
||||
const found = AVAILABILITY_OPTIONS.filter((o) => said.includes(o.toLowerCase()));
|
||||
if (found.length) return { availability: found };
|
||||
const value = cleanPhrase(answer, 40);
|
||||
return value ? { availability: [titleCase(value)] } : null;
|
||||
},
|
||||
summary: (draft) => (draft.availability?.length
|
||||
? `Available: ${draft.availability.join(', ')}`
|
||||
: null),
|
||||
},
|
||||
|
||||
notes: {
|
||||
label: 'Notes',
|
||||
settled: (draft) => typeof draft.notes === 'string' && draft.notes !== '',
|
||||
retry: 'Anything worth recording, or "skip".',
|
||||
parse: (answer) => {
|
||||
const value = cleanPhrase(answer, 280);
|
||||
return value ? { notes: value } : null;
|
||||
},
|
||||
summary: (draft) => (draft.notes ? `Notes: ${draft.notes}` : null),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The `@` tokens this conversation's skill file may use.
|
||||
*
|
||||
* `@workers` is the worker profiles the panel has already loaded for this
|
||||
* caller — org-scoped by the API, and nothing here widens that view.
|
||||
*/
|
||||
const resolve = (token, { roles = [], workers = [] }) => {
|
||||
switch (token) {
|
||||
case '@workers': return workers.map((w) => w.name).filter(Boolean);
|
||||
case '@roles': return roles;
|
||||
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
|
||||
case '@certifications': return CERT_OPTIONS;
|
||||
case '@availability': return AVAILABILITY_OPTIONS;
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* One sentence read against every field at once.
|
||||
*
|
||||
* Deliberately narrower than the posting's. A worker is never inferred from a
|
||||
* loose sentence: "bartender, weekends, $30/hr" settles three fields and leaves
|
||||
* the subject alone, because guessing WHO a record is about from a fragment is
|
||||
* how a role gets filed against the wrong person.
|
||||
*/
|
||||
function extract(said, ctx, fieldNames) {
|
||||
const patch = {};
|
||||
const touched = new Set();
|
||||
|
||||
if (fieldNames.has('role_category')) {
|
||||
const role = extractRole(said, ctx.roles || []);
|
||||
if (role) {
|
||||
patch.role_category = role;
|
||||
touched.add('role_category');
|
||||
}
|
||||
}
|
||||
if (fieldNames.has('desired_pay')) {
|
||||
const pay = extractPay(said);
|
||||
if (pay) {
|
||||
patch.desired_pay_min = pay.min;
|
||||
patch.desired_pay_max = pay.max;
|
||||
touched.add('desired_pay');
|
||||
}
|
||||
}
|
||||
if (fieldNames.has('experience_years')) {
|
||||
const years = extractExperience(said);
|
||||
if (years !== null) {
|
||||
patch.experience_years = years;
|
||||
touched.add('experience_years');
|
||||
}
|
||||
}
|
||||
if (fieldNames.has('english_level')) {
|
||||
const level = extractEnglish(said);
|
||||
if (level) {
|
||||
patch.english_level = level;
|
||||
touched.add('english_level');
|
||||
}
|
||||
}
|
||||
if (fieldNames.has('certifications')) {
|
||||
const certs = extractCertifications(said);
|
||||
if (certs) {
|
||||
patch.certifications = certs;
|
||||
touched.add('certifications');
|
||||
}
|
||||
}
|
||||
if (fieldNames.has('availability')) {
|
||||
const lower = String(said).toLowerCase();
|
||||
const found = AVAILABILITY_OPTIONS.filter((o) => lower.includes(o.toLowerCase()));
|
||||
if (found.length) {
|
||||
patch.availability = found;
|
||||
touched.add('availability');
|
||||
}
|
||||
}
|
||||
|
||||
return { patch, touched };
|
||||
}
|
||||
|
||||
/* ── Outcomes ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The role exists. Said plainly, with what was recorded. */
|
||||
const createdReply = (record) => doc(
|
||||
text('Employee role recorded.'),
|
||||
list([
|
||||
record.worker_name || record.worker_email,
|
||||
record.role_category,
|
||||
record.experience_years ? `${record.experience_years} years experience` : null,
|
||||
desiredPayLabel(record),
|
||||
].filter(Boolean)),
|
||||
note('It is on the Talent Pool now — this worker can be matched against open positions by this role.')
|
||||
);
|
||||
|
||||
/**
|
||||
* The write failed. The answers are kept, so nothing has to be retyped.
|
||||
*
|
||||
* The server's own wording is said out loud when there is one, for the same
|
||||
* reason it is on the posting side: a validation error naming a field, a
|
||||
* refused role and an API that is not running are three different problems and
|
||||
* one sentence cannot tell them apart.
|
||||
*/
|
||||
const failedReply = (reason = null) => {
|
||||
const said = String(reason || '').trim();
|
||||
return doc(
|
||||
text('I could not record that employee role.'),
|
||||
said ? note(said) : null,
|
||||
note('Nothing was saved. Choose Create employee role to try again.')
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* What is worth asking once the role exists.
|
||||
*
|
||||
* The obvious next question is which open positions this person now matches,
|
||||
* and that is an ordinary question the workforce engine already answers — so it
|
||||
* is offered as words rather than as a handler of its own.
|
||||
*/
|
||||
const createdFollowUp = (record) => [
|
||||
{ label: 'Match positions', prompt: `Which positions suit ${record.worker_name || record.worker_email}?` },
|
||||
];
|
||||
|
||||
/* ── The registry ───────────────────────────────────────────────────────── */
|
||||
|
||||
export const employeeRoleRegistry = {
|
||||
id: 'employee-role',
|
||||
fields,
|
||||
resolve,
|
||||
extract,
|
||||
|
||||
/**
|
||||
* No prefill from the opening request.
|
||||
*
|
||||
* "Create an employee role" names nobody, and the posting flow's habit of
|
||||
* reading a role out of the request would settle `role_category` from the
|
||||
* word "role" in the phrase that started the conversation. The first question
|
||||
* is who this is about, and it is asked.
|
||||
*/
|
||||
prefill: () => defaultEmployeeRole(),
|
||||
|
||||
/**
|
||||
* One verb, because there is one outcome. A declared role has no draft state:
|
||||
* `status` is about the WORKER's situation — seeking, placed, inactive — not
|
||||
* about how finished the record is, which is why 'draft' has no meaning here
|
||||
* and is not offered.
|
||||
*/
|
||||
verbs: [
|
||||
{
|
||||
chip: 'Create employee role',
|
||||
status: 'seeking',
|
||||
says: new Set([
|
||||
'create employee role', 'create an employee role', 'create the employee role',
|
||||
'create employee roles', 'add employee role', 'add an employee role',
|
||||
'create role', 'create it', 'create', 'save', 'save it',
|
||||
'yes', 'confirm', 'looks good', 'go ahead',
|
||||
]),
|
||||
},
|
||||
],
|
||||
|
||||
outcome: {
|
||||
created: createdReply,
|
||||
failed: failedReply,
|
||||
followUp: createdFollowUp,
|
||||
retryChips: [
|
||||
{ label: 'Create employee role', prompt: 'Create employee role' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
},
|
||||
|
||||
copy: {
|
||||
reviewQuestion: 'Ready to record this employee role?',
|
||||
reviewNote: 'Nothing is saved until you choose. The worker can hold more than one role — recording this does not replace an existing one.',
|
||||
reviewRetryNote: 'Choose Create employee role, or tell me what to change.',
|
||||
cancelled: 'Stopped — nothing was recorded.',
|
||||
cancelledNote: 'Ask me to create an employee role whenever you are ready.',
|
||||
},
|
||||
};
|
||||
18
src/lib/skills/flows/index.js
Normal file
18
src/lib/skills/flows/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { employeeRoleRegistry } from './employeeRole';
|
||||
import { positionRegistry } from './position';
|
||||
|
||||
/**
|
||||
* Every conversation a skill can be bound to, by the id its `flow:` names.
|
||||
*
|
||||
* A skill declares `flow: position`; this is what that word resolves to. Adding
|
||||
* a conversation is adding a registry here and a `flow:` line in the skill file
|
||||
* — there is no branch in the router, and nothing in the engine learns a new
|
||||
* name. That is §3's "specs are data" applied to conversations.
|
||||
*/
|
||||
export const FLOWS = {
|
||||
[positionRegistry.id]: positionRegistry,
|
||||
[employeeRoleRegistry.id]: employeeRoleRegistry,
|
||||
};
|
||||
|
||||
/** The registry a parsed skill is bound to, or `null` if it declares none. */
|
||||
export const flowFor = (skill) => (skill?.flow ? FLOWS[skill.flow] || null : null);
|
||||
322
src/lib/skills/flows/position.js
Normal file
322
src/lib/skills/flows/position.js
Normal file
@@ -0,0 +1,322 @@
|
||||
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';
|
||||
import { FREE_TEXT, cleanPhrase, titleCase } from '../conversationFlow';
|
||||
|
||||
/**
|
||||
* What a job posting's questions mean.
|
||||
*
|
||||
* The demand side: what the organization needs filled. Its sibling registry,
|
||||
* `employeeRole.js`, is the supply side — what a worker says they do. They share
|
||||
* a vocabulary and almost nothing else, which is why they are two registries
|
||||
* over one engine rather than one registry with a mode flag.
|
||||
*
|
||||
* `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.
|
||||
*
|
||||
* Blueprint decision D2 (a `clients` table) is still open and this does not
|
||||
* pre-empt it: promoting company to a record later adds a nullable reference
|
||||
* beside this column and changes no endpoint.
|
||||
*/
|
||||
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),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The `@` tokens this conversation's skill file may use.
|
||||
*
|
||||
* `@companies` is the clients this organization already staffs for, read off the
|
||||
* postings the caller can already see — so it offers an existing client without
|
||||
* a new endpoint, and typing a name that is not on the list is still how a new
|
||||
* client is named. Which is the whole of "create a company": there is no
|
||||
* company record to create, and creating an `organizations` row instead would
|
||||
* provision a TENANT the operator cannot then see, because every read is
|
||||
* predicated on the session's own org_id.
|
||||
*/
|
||||
const resolve = (token, { roles = [], companies = [] }) => {
|
||||
switch (token) {
|
||||
case '@roles': return roles;
|
||||
case '@companies': return companies;
|
||||
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
|
||||
case '@certifications': return CERT_OPTIONS;
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** One sentence read against every field of a posting at once. */
|
||||
function extract(said, { roles = [] }, fieldNames) {
|
||||
const { prefill } = buildPositionPrefill(said, roles);
|
||||
const patch = {};
|
||||
const touched = new Set();
|
||||
|
||||
if (fieldNames.has('role_category') && prefill.title) {
|
||||
if (prefill.role_category) patch.role_category = prefill.role_category;
|
||||
patch.title = prefill.title;
|
||||
touched.add('role_category');
|
||||
}
|
||||
if (fieldNames.has('location') && prefill.location) {
|
||||
patch.location = prefill.location;
|
||||
touched.add('location');
|
||||
}
|
||||
if (fieldNames.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 (fieldNames.has('min_experience_years') && prefill.min_experience_years !== undefined) {
|
||||
patch.min_experience_years = prefill.min_experience_years;
|
||||
touched.add('min_experience_years');
|
||||
}
|
||||
if (fieldNames.has('english_required') && prefill.english_required) {
|
||||
patch.english_required = prefill.english_required;
|
||||
touched.add('english_required');
|
||||
}
|
||||
if (fieldNames.has('certifications_required') && prefill.certifications_required) {
|
||||
patch.certifications_required = prefill.certifications_required;
|
||||
touched.add('certifications_required');
|
||||
}
|
||||
|
||||
return { patch, touched };
|
||||
}
|
||||
|
||||
/* ── Outcomes ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The position exists. Said plainly, with what was created. */
|
||||
function createdReply(position) {
|
||||
const isDraft = position.status === 'draft';
|
||||
|
||||
return doc(
|
||||
/* Draft and published are different outcomes, so they are named
|
||||
differently: one was saved, the other went live. */
|
||||
text(isDraft ? 'Saved as a draft.' : 'Position published 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.
|
||||
*
|
||||
* The server's own message is said out loud when there is one. This used to be
|
||||
* a fixed sentence, and a fixed sentence is the wrong answer to three different
|
||||
* failures: a validation error naming a field, a permission refusal, and an API
|
||||
* that is not running all read as "I could not create that position", leaving
|
||||
* the reader to guess which of the three they are looking at and what to change.
|
||||
*
|
||||
* The message comes from `KrowApiError.message`, which `httpClient` sets to the
|
||||
* server's wording verbatim — so the reason is the API's, not one invented here
|
||||
* from a status code.
|
||||
*/
|
||||
function failedReply(reason = null) {
|
||||
const said = String(reason || '').trim();
|
||||
|
||||
return doc(
|
||||
text('I could not create that position.'),
|
||||
said ? note(said) : null,
|
||||
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.
|
||||
*/
|
||||
const createdFollowUp = (position) => (position.status === 'draft'
|
||||
/**
|
||||
* A saved draft offers no action, and that is the fix.
|
||||
*
|
||||
* This used to offer "Continue to save", routing back to the Create Position
|
||||
* form. It made the completed state look unfinished: the reader had just been
|
||||
* told the position was saved, and was immediately asked to save it again —
|
||||
* by a button that put them back in the form they had just left. The write
|
||||
* has happened, the record exists with `status: draft`, and the way to finish
|
||||
* a draft later is its own card on the Positions list.
|
||||
*/
|
||||
? []
|
||||
: [
|
||||
{ label: 'View position', route: `/admin/positions/${position.id}` },
|
||||
{ label: 'Match candidates', prompt: `Who matches ${position.title}?` },
|
||||
]);
|
||||
|
||||
/* ── The registry ───────────────────────────────────────────────────────── */
|
||||
|
||||
export const positionRegistry = {
|
||||
id: 'position',
|
||||
fields,
|
||||
resolve,
|
||||
extract,
|
||||
prefill: (question, { roles = [] }) => buildPositionPrefill(question, roles).prefill,
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* `says` is exact membership against a normalized reply, not a pattern. The
|
||||
* anchored regex this replaced accepted "create position" and rejected
|
||||
* "create positions" — the plural the Positions page itself uses — with no
|
||||
* indication of what was wrong.
|
||||
*/
|
||||
verbs: [
|
||||
{
|
||||
chip: 'Save as Draft',
|
||||
status: 'draft',
|
||||
says: new Set(['save as draft', 'save draft', 'draft', 'save it as a draft']),
|
||||
},
|
||||
{
|
||||
chip: 'Publish Job Posting',
|
||||
status: 'active',
|
||||
says: new Set([
|
||||
'publish job posting', 'publish', 'publish it',
|
||||
'create position', 'create positions', 'create a position',
|
||||
'create the position', 'create this position', 'create it', 'create',
|
||||
'yes', 'confirm', 'looks good', 'go ahead',
|
||||
]),
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* How the outcome is said. The panel renders these without knowing what kind
|
||||
* of record it just wrote — which is what lets a second conversation report
|
||||
* its own result instead of borrowing a job posting's wording.
|
||||
*/
|
||||
outcome: {
|
||||
created: createdReply,
|
||||
failed: failedReply,
|
||||
followUp: createdFollowUp,
|
||||
/* Offered beside the failure, so a retry does not need retyping. */
|
||||
retryChips: [
|
||||
{ label: 'Create position', prompt: 'Create position' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
},
|
||||
|
||||
copy: {
|
||||
reviewQuestion: 'Ready to create this position?',
|
||||
reviewNote: 'Nothing is saved until you choose one. Save as Draft keeps it unpublished — the same as the button on the form.',
|
||||
reviewRetryNote: 'Choose Save as Draft or Publish Job Posting, or tell me what to change.',
|
||||
cancelled: 'Stopped — nothing was created.',
|
||||
cancelledNote: 'Ask me to create a position whenever you are ready.',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user