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:
@@ -159,6 +159,58 @@ record(
|
||||
`${cp.conversation.length} steps`
|
||||
);
|
||||
|
||||
/* ── Every conversation step resolves in the registry it is bound to ─────── */
|
||||
|
||||
/*
|
||||
* The check that catches the failure nobody else would.
|
||||
*
|
||||
* `stepsOf` filters a skill's steps down to the fields its registry defines.
|
||||
* A typo in a field name — or a step added to the Markdown before the field
|
||||
* exists in code — is therefore DROPPED SILENTLY: the flow asks fewer questions
|
||||
* than the file lists, and a skill whose steps are all unknown asks none at all,
|
||||
* jumps straight to the summary, and offers to write an empty record. Nothing
|
||||
* errors, nothing logs, and the Markdown still reads correctly.
|
||||
*
|
||||
* It is also the failure a second conversation makes easy: `flow: employee-role`
|
||||
* against position field names parses, validates, and produces a flow that asks
|
||||
* nothing.
|
||||
*/
|
||||
const flowsModule = await server.ssrLoadModule('/src/lib/skills/flows/index.js');
|
||||
|
||||
const conversational = reg.SKILLS.filter((s) => (s.conversation || []).length);
|
||||
record(
|
||||
'every conversational skill declares a flow that exists',
|
||||
conversational.every((s) => flowsModule.flowFor(s)),
|
||||
conversational.filter((s) => !flowsModule.flowFor(s))
|
||||
.map((s) => `${s.id} → ${s.flow ?? 'none'}`).join(', ')
|
||||
|| `${conversational.length} skill(s): ${conversational.map((s) => `${s.id}→${s.flow}`).join(', ')}`
|
||||
);
|
||||
|
||||
const unresolvedSteps = conversational.flatMap((skill) => {
|
||||
const registry = flowsModule.flowFor(skill);
|
||||
if (!registry) return [];
|
||||
return (skill.conversation || [])
|
||||
.filter((step) => !registry.fields[step.field])
|
||||
.map((step) => `${skill.id}:${step.field}`);
|
||||
});
|
||||
record(
|
||||
'...and every one of its steps resolves to a field that registry defines',
|
||||
unresolvedSteps.length === 0,
|
||||
unresolvedSteps.join(', ')
|
||||
|| `${conversational.reduce((n, s) => n + s.conversation.length, 0)} steps all resolve`
|
||||
);
|
||||
|
||||
/* A registry's commit vocabulary has to accept the chip it renders, or the
|
||||
summary offers a button that falls through to "I did not catch that". */
|
||||
const unspokenVerbs = Object.values(flowsModule.FLOWS).flatMap((registry) => registry.verbs
|
||||
.filter((verb) => !verb.says.has(verb.chip.toLowerCase()))
|
||||
.map((verb) => `${registry.id}:${verb.chip}`));
|
||||
record(
|
||||
'every commit chip is a phrase its own flow accepts',
|
||||
unspokenVerbs.length === 0,
|
||||
unspokenVerbs.join(', ') || 'all verbs answer their own chips'
|
||||
);
|
||||
|
||||
const numbered = reg.parseSkill(
|
||||
`---\nid: numbered\nname: Numbered\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n`
|
||||
+ `## Capabilities\n\n1. First instruction.\n2. Second instruction.\n3. Third instruction.\n`,
|
||||
@@ -2940,6 +2992,221 @@ record('open_related_page resolves only addresses the product has',
|
||||
=== '/admin/positions'
|
||||
&& actions.runAction('open_related_page', { skill: { actions: ['open_related_page'] }, page: 'the-moon' }) === null);
|
||||
|
||||
/* ── Both conversations, end to end ─────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* A whole conversation, driven the way the panel drives it.
|
||||
*
|
||||
* The flow engine is the one place a wrong answer produces no error at all: an
|
||||
* unrecognised reply re-renders the summary, which is exactly what a CORRECT
|
||||
* reply does on the turn before. So the only way to know the confirmation step
|
||||
* works is to walk one to the end and check that a record came out.
|
||||
*/
|
||||
const flowEngine = await server.ssrLoadModule('/src/lib/skills/conversationFlow.js');
|
||||
|
||||
const ROLES = ['Bartender', 'Server', 'Line Cook'];
|
||||
const WORKERS = [
|
||||
{ id: 'wp-1', name: 'Asha Menon', email: 'asha@example.test' },
|
||||
{ id: 'wp-2', name: 'Ravi Kumar', email: 'ravi@example.test' },
|
||||
];
|
||||
const COMPANIES = ['Fairmont San Jose', 'Taj Coromandel'];
|
||||
|
||||
/** Answer a flow's questions in order, and return the last turn. */
|
||||
function walk(registry, skill, opening, answers, ctx) {
|
||||
let turn = flowEngine.beginFlow({ registry, question: opening, skill, ctx });
|
||||
for (const answer of answers) {
|
||||
if (!turn.flow) break;
|
||||
turn = flowEngine.advanceFlow({ registry, flow: turn.flow, answer, skill, ctx });
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
const positionSkill = reg.SKILLS.find((s) => s.id === 'create-position');
|
||||
const positionFlow = flowsModule.flowFor(positionSkill);
|
||||
const positionCtx = { roles: ROLES, companies: COMPANIES };
|
||||
|
||||
const posted = walk(positionFlow, positionSkill,
|
||||
'Create a bartender position in Chennai paying $30-$40/hr',
|
||||
['Fairmont San Jose', 'No minimum', 'Skip', 'None', 'Publish Job Posting'],
|
||||
positionCtx);
|
||||
|
||||
record('the position conversation reaches a record',
|
||||
Boolean(posted.create),
|
||||
posted.create ? `status=${posted.create.status}` : `stage=${posted.flow?.stage}, step=${posted.flow?.step}`);
|
||||
|
||||
record('...carrying every answer the conversation collected',
|
||||
posted.create?.draft.company === 'Fairmont San Jose'
|
||||
&& posted.create?.draft.title === 'Bartender'
|
||||
&& posted.create?.draft.location === 'Chennai'
|
||||
&& posted.create?.draft.pay_range_min === '30',
|
||||
JSON.stringify(posted.create?.draft ?? {}));
|
||||
|
||||
record('...and the payload the API receives is unchanged by the split',
|
||||
JSON.stringify(actions.runAction('create_position',
|
||||
{ skill: positionSkill, draft: posted.create.draft, status: posted.create.status })?.data.status) === '"active"'
|
||||
&& actions.runAction('create_position',
|
||||
{ skill: positionSkill, draft: posted.create.draft, status: posted.create.status })?.data.pay_range_min === 30);
|
||||
|
||||
/*
|
||||
* The confirmation bug, pinned.
|
||||
*
|
||||
* "Create position" committed and "create positions" — the plural the Positions
|
||||
* page itself uses — silently did not: the anchored regex missed it, and the
|
||||
* reader got the summary back with no clue what was wrong. All four phrasings
|
||||
* are now the same answer.
|
||||
*/
|
||||
const atReview = walk(positionFlow, positionSkill,
|
||||
'Create a bartender position in Chennai paying $30-$40/hr',
|
||||
['Fairmont San Jose', 'No minimum', 'Skip', 'None'],
|
||||
positionCtx);
|
||||
|
||||
for (const [phrase, shouldCommit] of [
|
||||
['Create position', true], ['create positions', true], ['Create a position', true],
|
||||
['create it', true], ['Publish', true], ['Save as draft', true],
|
||||
['banana', false], ['no', false],
|
||||
]) {
|
||||
const turn = flowEngine.advanceFlow({
|
||||
registry: positionFlow, flow: atReview.flow, answer: phrase, skill: positionSkill, ctx: positionCtx,
|
||||
});
|
||||
record(`confirmation: "${phrase}" ${shouldCommit ? 'commits' : 'does not commit'}`,
|
||||
Boolean(turn.create) === shouldCommit,
|
||||
`create=${Boolean(turn.create)}`);
|
||||
}
|
||||
|
||||
record('...and "no" opens the change menu rather than refusing',
|
||||
flowEngine.advanceFlow({
|
||||
registry: positionFlow, flow: atReview.flow, answer: 'no', skill: positionSkill, ctx: positionCtx,
|
||||
}).flow?.stage === 'change');
|
||||
|
||||
record('...while "Save as draft" commits the draft status, not the published one',
|
||||
flowEngine.advanceFlow({
|
||||
registry: positionFlow, flow: atReview.flow, answer: 'Save as draft', skill: positionSkill, ctx: positionCtx,
|
||||
}).create?.status === 'draft');
|
||||
|
||||
/*
|
||||
* A request that names no role must settle no title.
|
||||
*
|
||||
* "Create new position" used to arrive with the title already set to "New" — a
|
||||
* value nobody typed, on the one field a position cannot be created without.
|
||||
* The scaffolding is a PHRASE at least as often as it is a single word, and the
|
||||
* first fix for this only handled the single word: "create another new position"
|
||||
* still produced "Nother New", the regex having eaten the "a" of "another".
|
||||
*
|
||||
* Both halves are pinned below, because both failed silently — a fabricated
|
||||
* title reads like an answer, so nothing about it looks wrong until somebody
|
||||
* notices the position is called "One More".
|
||||
*/
|
||||
for (const question of [
|
||||
'Create position', 'Create a position', 'create positions',
|
||||
'Create new position', 'create a new position', 'create another new position',
|
||||
'create a brand new position', 'create one more position', 'create the position',
|
||||
'open a new role', 'add a new position', 'post a new job',
|
||||
]) {
|
||||
const draft = flowEngine.beginFlow({
|
||||
registry: positionFlow, question, skill: positionSkill, ctx: positionCtx,
|
||||
}).flow.draft;
|
||||
record(`"${question}" settles no title`, !draft.title, JSON.stringify(draft));
|
||||
}
|
||||
|
||||
/*
|
||||
* ...and the fix must not rename a role that legitimately uses one of those
|
||||
* words. The scaffolding list is stripped to DECIDE whether a phrase named
|
||||
* anything, never to rewrite what it named — otherwise "second chef" becomes
|
||||
* "Chef" and the cure is worse than the bug.
|
||||
*/
|
||||
for (const [question, want] of [
|
||||
['create a bartender position', 'Bartender'],
|
||||
['create a sous chef position', 'Sous Chef'],
|
||||
['create a second chef position', 'Second Chef'],
|
||||
['create an open kitchen lead position', 'Open Kitchen Lead'],
|
||||
['create a fresh produce buyer position', 'Fresh Produce Buyer'],
|
||||
['create an extra hands supervisor position', 'Extra Hands Supervisor'],
|
||||
['position for a third cook', 'Third Cook'],
|
||||
]) {
|
||||
const draft = flowEngine.beginFlow({
|
||||
registry: positionFlow, question, skill: positionSkill, ctx: positionCtx,
|
||||
}).flow.draft;
|
||||
record(`"${question}" keeps its own words`, draft.title === want, draft.title ?? 'null');
|
||||
}
|
||||
|
||||
/* The employee role: the other conversation, over the same engine. */
|
||||
const roleSkill = reg.SKILLS.find((s) => s.id === 'create-employee-role');
|
||||
const roleFlow = flowsModule.flowFor(roleSkill);
|
||||
const roleCtx = { roles: ROLES, workers: WORKERS };
|
||||
|
||||
const recorded = walk(roleFlow, roleSkill, 'Create an employee role',
|
||||
['Asha Menon', 'Bartender', '3 years', 'Skip', 'None', '$25-$35/hr', 'Weekends', 'Skip',
|
||||
'Create employee role'],
|
||||
roleCtx);
|
||||
|
||||
record('the employee-role conversation reaches a record',
|
||||
Boolean(recorded.create),
|
||||
recorded.create ? `status=${recorded.create.status}` : `stage=${recorded.flow?.stage}, step=${recorded.flow?.step}`);
|
||||
|
||||
record('...against the worker that was chosen, resolved to a real profile',
|
||||
recorded.create?.draft.worker_email === 'asha@example.test'
|
||||
&& recorded.create?.draft.worker_profile_id === 'wp-1'
|
||||
&& recorded.create?.draft.role_category === 'Bartender',
|
||||
JSON.stringify(recorded.create?.draft ?? {}));
|
||||
|
||||
/* The subject is never assumed. An operator records this on somebody's behalf,
|
||||
so a conversation that names nobody must not invent one. */
|
||||
record('...and a worker it cannot resolve is re-asked, never guessed',
|
||||
flowEngine.advanceFlow({
|
||||
registry: roleFlow,
|
||||
flow: flowEngine.beginFlow({ registry: roleFlow, question: 'Create an employee role', skill: roleSkill, ctx: roleCtx }).flow,
|
||||
answer: 'somebody', skill: roleSkill, ctx: roleCtx,
|
||||
}).flow.step === 'worker');
|
||||
|
||||
/* An email with no profile behind it is still an answer: a role can be declared
|
||||
before the profile exists. */
|
||||
record('...while a bare email address is accepted without one',
|
||||
flowEngine.advanceFlow({
|
||||
registry: roleFlow,
|
||||
flow: flowEngine.beginFlow({ registry: roleFlow, question: 'Create an employee role', skill: roleSkill, ctx: roleCtx }).flow,
|
||||
answer: 'newcomer@example.test', skill: roleSkill, ctx: roleCtx,
|
||||
}).flow.draft.worker_email === 'newcomer@example.test');
|
||||
|
||||
/* The `@` tokens resolve from data the caller already holds. */
|
||||
const firstChips = (registry, skill, ctx) => flowEngine.beginFlow({
|
||||
registry, question: registry.id === 'position' ? 'Create a position' : 'Create an employee role', skill, ctx,
|
||||
}).followUp.map((c) => c.label);
|
||||
|
||||
record('@companies offers the clients this organization already staffs for',
|
||||
COMPANIES.every((c) => firstChips(positionFlow, positionSkill, positionCtx).includes(c)),
|
||||
firstChips(positionFlow, positionSkill, positionCtx).join(', '));
|
||||
|
||||
record('@workers offers the profiles the panel already holds',
|
||||
WORKERS.every((w) => firstChips(roleFlow, roleSkill, roleCtx).includes(w.name)),
|
||||
firstChips(roleFlow, roleSkill, roleCtx).join(', '));
|
||||
|
||||
/*
|
||||
* The chip the server suggests has to be a phrase a skill answers to.
|
||||
*
|
||||
* No page context declares `capabilities`, so every server suggestion is
|
||||
* dispatched as its own TEXT — which means the catalogue's wording and the
|
||||
* skills' triggers are one coupling with nothing else holding it together. A
|
||||
* renamed chip would produce a suggestion that opens nothing, silently.
|
||||
*/
|
||||
for (const [chip, wantSkill] of [
|
||||
['Create a company position', 'create-position'],
|
||||
['Create an employee role', 'create-employee-role'],
|
||||
]) {
|
||||
const matched = reg.matchSkill(chip, 'admin.positions', [], []);
|
||||
record(`the "${chip}" chip is answered by ${wantSkill}`,
|
||||
matched?.id === wantSkill,
|
||||
matched?.id ?? `no skill on Positions answers "${chip}"`);
|
||||
}
|
||||
|
||||
/* And the wording actually distinguishes them: the two chips must not both
|
||||
route to the same conversation, which is the whole reason "company" and
|
||||
"employee" are in the text the reader clicks. */
|
||||
record('...and the two create chips route to different conversations',
|
||||
reg.matchSkill('Create a company position', 'admin.positions', [], [])?.flow
|
||||
!== reg.matchSkill('Create an employee role', 'admin.positions', [], [])?.flow,
|
||||
`${reg.matchSkill('Create a company position', 'admin.positions', [], [])?.flow} vs `
|
||||
+ `${reg.matchSkill('Create an employee role', 'admin.positions', [], [])?.flow}`);
|
||||
|
||||
/* ── Knowledge ──────────────────────────────────────────────────────────── */
|
||||
|
||||
record('an agent with no knowledge says so rather than returning nothing',
|
||||
|
||||
@@ -4,7 +4,7 @@ name: Positions Agent
|
||||
description: Open roles — what they need, who has applied, and which are at risk of going unfilled.
|
||||
icon: briefcase
|
||||
status: published
|
||||
version: 1
|
||||
version: 2
|
||||
reasoning: balanced
|
||||
trigger: Use on Positions, for open roles, applicant flow, and specifying a new role.
|
||||
pages:
|
||||
@@ -12,6 +12,7 @@ pages:
|
||||
- create-position
|
||||
skills:
|
||||
- create-position
|
||||
- create-employee-role
|
||||
- hiring-activity-assistant
|
||||
- staffing-risk
|
||||
starters:
|
||||
|
||||
@@ -4,13 +4,14 @@ name: Talent Pool Agent
|
||||
description: Available talent — who is in the pool, who is verified, and who is ready to place.
|
||||
icon: layers
|
||||
status: published
|
||||
version: 1
|
||||
version: 2
|
||||
reasoning: balanced
|
||||
trigger: Use on Talent Pool, for supply, availability and readiness of known workers.
|
||||
pages:
|
||||
- talent-pool
|
||||
skills:
|
||||
- talent-pool-analysis
|
||||
- create-employee-role
|
||||
starters:
|
||||
- label: Who is available?
|
||||
prompt: Who is available in the talent pool?
|
||||
|
||||
@@ -29,6 +29,11 @@ const ENTITY_NAMES = [
|
||||
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
||||
'Course', 'Badge', 'LearningPath', 'Certification', 'RoleCategory',
|
||||
'UserActivity', 'Evidence', 'User',
|
||||
/* What a worker declares they DO — role, experience, desired pay,
|
||||
availability. The supply side of JobPosting, which is what the organization
|
||||
needs filled. The two meet through JobApplication, not through a reference
|
||||
between them. */
|
||||
'EmployeeRole',
|
||||
/* Who is on which position, and for how long. The record that turns "hired"
|
||||
into workforce allocation: without it a position knows its demand and its
|
||||
applicants but not who is actually covering it. */
|
||||
|
||||
@@ -99,6 +99,7 @@ const RESOURCE_PATHS = {
|
||||
AIInterview: 'ai-interviews',
|
||||
Staff: 'staff',
|
||||
WorkerProfile: 'worker-profiles',
|
||||
EmployeeRole: 'employee-roles',
|
||||
Course: 'courses',
|
||||
Badge: 'badges',
|
||||
LearningPath: 'learning-paths',
|
||||
|
||||
@@ -9,7 +9,8 @@ import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import {
|
||||
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
|
||||
useAssignments, useAssignWorkers, useCreateEmployeeRole, useCreateJobPosting,
|
||||
useGenerateJobDescription,
|
||||
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
|
||||
useUpdateJobPosting,
|
||||
usePreferences, useRoleCategories,
|
||||
@@ -441,6 +442,61 @@ export default function KrowAssistant({
|
||||
return createJob.mutateAsync(result.data);
|
||||
}, [createJob]);
|
||||
|
||||
/**
|
||||
* Every conversation's write, keyed by the flow's id.
|
||||
*
|
||||
* `useAssistant` looks the writer up by the flow the answering skill declares,
|
||||
* so adding a conversation is adding an entry here rather than another prop
|
||||
* threaded through the panel.
|
||||
*/
|
||||
const createRole = useCreateEmployeeRole();
|
||||
const createEmployeeRole = React.useCallback(async (draft, skill, status) => {
|
||||
const result = runAction('create_employee_role', { draft, skill, status });
|
||||
if (result?.type !== 'create_employee_role') return null;
|
||||
return createRole.mutateAsync(result.data);
|
||||
}, [createRole]);
|
||||
|
||||
const flowWriters = React.useMemo(() => ({
|
||||
position: createPosition,
|
||||
'employee-role': createEmployeeRole,
|
||||
}), [createPosition, createEmployeeRole]);
|
||||
|
||||
/**
|
||||
* The clients this organization already staffs for.
|
||||
*
|
||||
* Distinct company names off the postings the panel has already loaded for
|
||||
* this caller — org-scoped by the API, and nothing here widens that. They are
|
||||
* offered as chips on the conversation's company question so an existing
|
||||
* client is a tap, while typing a name that is not on the list is still how a
|
||||
* new one is named. There is no company record to create: see the `@companies`
|
||||
* note in `lib/skills/flows/position.js`.
|
||||
*
|
||||
* Deliberately NOT sorted here. The order is the one the postings arrived in
|
||||
* — the API's `-created_date` — so the clients staffed for most recently are
|
||||
* the ones offered first, and the panel does no ranking of its own. That last
|
||||
* part is a rule `npm test` enforces structurally, and it is the right rule:
|
||||
* a second opinion formed in the panel outranking the server's is exactly the
|
||||
* failure that decays quietly.
|
||||
*/
|
||||
/**
|
||||
* The workers a role can be recorded against.
|
||||
*
|
||||
* The profiles the panel already holds for this caller — org-scoped by the
|
||||
* API. The conversation offers the names as chips and resolves a pick back to
|
||||
* the profile id and email, so the row names a real person rather than
|
||||
* whatever was typed. The operator is never the subject: the question is
|
||||
* required and there is no fallback to the session.
|
||||
*/
|
||||
const workers = React.useMemo(() => (facts.profiles || []).map((w) => ({
|
||||
id: w.id,
|
||||
name: w.full_name || w.name || '',
|
||||
email: w.email || '',
|
||||
})).filter((w) => w.email), [facts.profiles]);
|
||||
|
||||
const companies = React.useMemo(() => [...new Set(
|
||||
(facts.postings || []).map((p) => String(p.company || '').trim()).filter(Boolean)
|
||||
)], [facts.postings]);
|
||||
|
||||
/**
|
||||
* What to ask next, from the server, after something has been written.
|
||||
*
|
||||
@@ -562,7 +618,9 @@ export default function KrowAssistant({
|
||||
pageLabel: context.page,
|
||||
onNavigate: goToPage,
|
||||
onAction: performAction,
|
||||
onCreatePosition: createPosition,
|
||||
flowWriters,
|
||||
companies,
|
||||
workers,
|
||||
onRefreshSuggestions: refreshSuggestions,
|
||||
onUpdatePosition,
|
||||
onGenerateDescription,
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
|
||||
extractSkillName, findCourseByName,
|
||||
} from '@/lib/skills/actions';
|
||||
import { beginPositionFlow } from '@/lib/skills/positionFlow';
|
||||
import { beginFlow } from '@/lib/skills/conversationFlow';
|
||||
import { flowFor } from '@/lib/skills/flows';
|
||||
|
||||
/**
|
||||
* Intent routing — deciding whether a question belongs to the page you are on.
|
||||
@@ -633,7 +634,7 @@ function declaredAnswer({ skill, capability, question, skillContext }) {
|
||||
*/
|
||||
function resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
skillContext = null,
|
||||
companies = [], skillContext = null,
|
||||
}) {
|
||||
/**
|
||||
* One question, one skill, then one way of answering it.
|
||||
@@ -728,15 +729,21 @@ function resolveSkill({
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Position is collected in the conversation, not in a form.
|
||||
* A conversational skill is collected in the chat, not in a form.
|
||||
*
|
||||
* Nothing opens and nothing is navigated to: the skill's questions come back
|
||||
* as a reply and its answers as chips, and the position is written at the end
|
||||
* as a reply and its answers as chips, and the record is written at the end
|
||||
* from what the conversation gathered. `flow` is the state that turn carries
|
||||
* forward — the panel keeps it and feeds the next answer back in.
|
||||
*
|
||||
* Which conversation is the SKILL'S OWN `flow:` declaration, resolved through
|
||||
* `FLOWS`. This used to be `if (skill.id === 'create-position')`, which made a
|
||||
* second conversational skill a change to the router rather than a file on
|
||||
* disk — precisely the `if agent_key == ...` shape §2's I6 rules out.
|
||||
*/
|
||||
if (skill.id === 'create-position') {
|
||||
return { kind: 'skill', skill, ...beginPositionFlow({ question, skill, roles }) };
|
||||
const registry = flowFor(skill);
|
||||
if (registry) {
|
||||
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies } }) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -855,6 +862,10 @@ function resolveDraftAction(question, workforce, positionId = null) {
|
||||
export function resolveIntent({
|
||||
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
||||
courses = [], workforce = null, skillContext = null,
|
||||
/* The clients this organization already staffs for, offered as chips on the
|
||||
company question. Read off the postings the caller can already see, so it
|
||||
expands nobody's view — see the `@companies` note in flows/position.js. */
|
||||
companies = [],
|
||||
/**
|
||||
* The active agent, and where the reader is.
|
||||
*
|
||||
@@ -902,7 +913,7 @@ export function resolveIntent({
|
||||
|
||||
/* 2. Current page skills — specific triggers, ahead of the general reader. */
|
||||
const skill = resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies,
|
||||
/* The envelope travels beside the collections rather than replacing them:
|
||||
a resolver reads records, and the envelope says where the reader is. A
|
||||
source that needs a position still finds it exactly where it always was. */
|
||||
|
||||
@@ -6,9 +6,8 @@ import {
|
||||
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { skillsForContext } from '@/lib/skills/registry';
|
||||
import {
|
||||
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
||||
} from '@/lib/skills/positionFlow';
|
||||
import { advanceFlow } from '@/lib/skills/conversationFlow';
|
||||
import { flowFor } from '@/lib/skills/flows';
|
||||
import {
|
||||
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
|
||||
publishedReply, weightsSetReply, weightsUnchangedReply,
|
||||
@@ -237,7 +236,16 @@ function withoutAuthoringActions(messages = []) {
|
||||
* routing applies to both without either knowing it exists.
|
||||
*/
|
||||
export function useConversation({
|
||||
contextId, facts, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
|
||||
contextId, facts, onNavigate, onAction, onRefreshSuggestions,
|
||||
/**
|
||||
* How each conversation's record gets written, keyed by the flow's id.
|
||||
*
|
||||
* A single `onCreatePosition` prop was the last place the panel named one
|
||||
* kind of record. A second conversation needed a second prop, a second branch
|
||||
* at the write, and a second set of outcome renderers — three edits to answer
|
||||
* "and now employee roles too". This is one entry in a map.
|
||||
*/
|
||||
flowWriters = {},
|
||||
onAssignWorkers, onScheduleInterview,
|
||||
/* Finishing a draft: the same two mutations the Create Position form calls.
|
||||
Passed in rather than reached for, so this layer still writes nothing
|
||||
@@ -245,6 +253,12 @@ export function useConversation({
|
||||
onUpdatePosition, onGenerateDescription,
|
||||
workforce = null, disabledSkills = [], customSkills = [],
|
||||
roles = [], skillCategories = [], courses = [], skillContext = null,
|
||||
/* The clients this organization already staffs for, offered as chips on the
|
||||
company question. Derived from postings the caller can already read. */
|
||||
companies = [],
|
||||
/* The worker profiles a declared role can be recorded against, as
|
||||
`{ id, name, email }`. Same rule: already-loaded, already-permitted rows. */
|
||||
workers = [],
|
||||
/**
|
||||
* The active agent and where the reader is.
|
||||
*
|
||||
@@ -475,7 +489,10 @@ export function useConversation({
|
||||
intent = {
|
||||
kind: 'flow',
|
||||
skill,
|
||||
...advancePositionFlow({ flow: flowRef.current, answer: text, skill, roles }),
|
||||
...advanceFlow({
|
||||
registry: flowFor(skill), flow: flowRef.current, answer: text, skill,
|
||||
ctx: { roles, companies, workers },
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -490,7 +507,7 @@ export function useConversation({
|
||||
question: text,
|
||||
contextId: turnContext,
|
||||
disabledSkills: turnDisabled,
|
||||
customSkills, roles, skillCategories,
|
||||
customSkills, roles, skillCategories, companies,
|
||||
courses, workforce, skillContext, positionId,
|
||||
agent: turnAgent,
|
||||
agentCoversPage: turnCovers,
|
||||
@@ -509,13 +526,19 @@ export function useConversation({
|
||||
* be true when it is said.
|
||||
*/
|
||||
if (intent.kind === 'flow' && intent.create) {
|
||||
/* The skill says which conversation this is, so the write and the wording
|
||||
of its outcome both come from that registry rather than from a name
|
||||
hardcoded here. */
|
||||
const registry = flowFor(intent.skill);
|
||||
let created = null;
|
||||
/* Kept, not swallowed. The reply states the outcome, and "it did not
|
||||
work" is a worse outcome to state than the reason it did not: a
|
||||
required field, a refused role, or an API that is not running. */
|
||||
let failure = null;
|
||||
try {
|
||||
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
|
||||
created = await flowWriters[registry.id]?.(
|
||||
intent.create.draft, intent.skill, intent.create.status
|
||||
);
|
||||
} catch (error) {
|
||||
created = null;
|
||||
failure = error;
|
||||
@@ -551,19 +574,16 @@ export function useConversation({
|
||||
intent = {
|
||||
...intent,
|
||||
flow: null,
|
||||
doc: positionCreatedReply(created),
|
||||
followUp: [...createdFollowUp(created), ...refreshed],
|
||||
doc: registry.outcome.created(created),
|
||||
followUp: [...registry.outcome.followUp(created), ...refreshed],
|
||||
};
|
||||
} else {
|
||||
/* Keep the answers: the summary is still there to try again from. */
|
||||
intent = {
|
||||
...intent,
|
||||
flow: { ...intent.flow, stage: 'review' },
|
||||
doc: positionFailedReply(failure?.message),
|
||||
followUp: [
|
||||
{ label: 'Create position', prompt: 'Create position' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
doc: registry.outcome.failed(failure?.message),
|
||||
followUp: registry.outcome.retryChips,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -785,12 +805,12 @@ export function useConversation({
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
|
||||
}, [contextId, facts, persist, onNavigate, onAction, flowWriters, onRefreshSuggestions,
|
||||
onUpdatePosition,
|
||||
onGenerateDescription, onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses, skillContext,
|
||||
customSkills, roles, skillCategories, courses, skillContext, companies, workers,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
69
src/lib/employeeRoleModel.js
Normal file
69
src/lib/employeeRoleModel.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* A worker's declared professional role.
|
||||
*
|
||||
* The supply side of `positionModel.js`: that one describes what an
|
||||
* organization needs filled, this one describes what a person says they do.
|
||||
* They share a vocabulary — a role category, an English level, certifications —
|
||||
* and almost nothing else, which is why the pay fields are named for what the
|
||||
* worker WANTS rather than what a posting OFFERS.
|
||||
*/
|
||||
|
||||
/** English levels, in the order the schema declares them. */
|
||||
export const EMPLOYEE_ROLE_STATUSES = ['seeking', 'placed', 'inactive'];
|
||||
|
||||
/** When somebody can work. Free text in the column; these are the common ones. */
|
||||
export const AVAILABILITY_OPTIONS = ['Weekdays', 'Weekends', 'Evenings', 'Full time'];
|
||||
|
||||
/** Everything the record needs, before the conversation has said anything. */
|
||||
export const defaultEmployeeRole = () => ({
|
||||
worker_profile_id: null,
|
||||
worker_email: '',
|
||||
worker_name: '',
|
||||
role_category: '',
|
||||
experience_years: 0,
|
||||
english_level: 'basic',
|
||||
certifications: [],
|
||||
desired_pay_min: 0,
|
||||
desired_pay_max: 0,
|
||||
availability: [],
|
||||
notes: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* "$25–$35/hr", or "from $25/hr" when only a floor was given.
|
||||
*
|
||||
* A maximum of zero means "no ceiling stated" rather than "free", which is why
|
||||
* it is not rendered as a range ending at nothing.
|
||||
*/
|
||||
export function desiredPayLabel(record = {}) {
|
||||
const min = Number(record.desired_pay_min) || 0;
|
||||
const max = Number(record.desired_pay_max) || 0;
|
||||
if (!min && !max) return null;
|
||||
if (min && max) return `$${min}–$${max}/hr`;
|
||||
return min ? `From $${min}/hr` : `Up to $${max}/hr`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The record the API is asked to create.
|
||||
*
|
||||
* Numbers are coerced here rather than at the field, because the conversation
|
||||
* collects strings and the column is an int — and a string in an int column is
|
||||
* a 400 the reader cannot act on. `org_id` and `created_by` are absent
|
||||
* deliberately: both are the server's, derived from the session, and a value
|
||||
* sent for either is dropped before the insert.
|
||||
*/
|
||||
export function toEmployeeRolePayload(draft = {}) {
|
||||
const record = { ...defaultEmployeeRole(), ...draft };
|
||||
return {
|
||||
...record,
|
||||
worker_email: String(record.worker_email || '').trim(),
|
||||
worker_name: String(record.worker_name || '').trim(),
|
||||
role_category: String(record.role_category || '').trim(),
|
||||
experience_years: Number(record.experience_years) || 0,
|
||||
desired_pay_min: Number(record.desired_pay_min) || 0,
|
||||
desired_pay_max: Number(record.desired_pay_max) || 0,
|
||||
certifications: Array.isArray(record.certifications) ? record.certifications : [],
|
||||
availability: Array.isArray(record.availability) ? record.availability : [],
|
||||
notes: String(record.notes || '').trim(),
|
||||
};
|
||||
}
|
||||
@@ -317,6 +317,28 @@ export function useCreateJobPosting() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what a worker declares they do.
|
||||
*
|
||||
* The supply-side twin of `useCreateJobPosting`. It invalidates the worker
|
||||
* queries as well as its own, because a declared role changes what the Talent
|
||||
* Pool shows about that person — and the Owliver context, because the
|
||||
* organization now has one more worker offering that role and what is worth
|
||||
* asking has changed with it.
|
||||
*/
|
||||
export function useCreateEmployeeRole() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: /** @param {any} data */ (data) => base44.entities.EmployeeRole.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['employeeRoles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
|
||||
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||
logActivity('create_employee_role');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateJobPosting() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { toEmployeeRolePayload } from '@/lib/employeeRoleModel';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS, toPositionPayload } from '@/lib/positionModel';
|
||||
import { routeForPageKey } from './registry';
|
||||
|
||||
@@ -24,6 +25,34 @@ import { routeForPageKey } from './registry';
|
||||
* that category — and a category added in Create Position is understood without
|
||||
* a change to this file.
|
||||
*/
|
||||
/**
|
||||
* Words that introduce a role rather than name one.
|
||||
*
|
||||
* "Create new position" marks "new" as the role by the same grammar that marks
|
||||
* "sous chef" in "create sous chef position", and the phrase pattern below
|
||||
* cannot tell them apart. Without this, that request opened the conversation
|
||||
* with the title already set to "New" — a value nobody typed, on the one field
|
||||
* a position cannot be created without, which the reader then had to notice and
|
||||
* correct. An empty title is the honest answer to a request that named no role.
|
||||
*
|
||||
* These are stripped to decide whether a phrase named anything, and the ORIGINAL
|
||||
* phrase is what is returned when it did. That distinction is the whole design:
|
||||
* a rule that returned the stripped words instead would turn "second chef" into
|
||||
* "Chef" and "open kitchen lead" into "Kitchen Lead", quietly renaming real
|
||||
* roles to fix a problem those roles do not have. Strip to TEST, never to
|
||||
* rewrite.
|
||||
*
|
||||
* Only determiners, quantifiers and intensifiers belong here. Nothing that
|
||||
* could be part of a job title, which is what makes the strip-and-test safe:
|
||||
* a phrase is rejected only when EVERY word in it is one of these.
|
||||
*/
|
||||
const GENERIC_ROLE_WORDS = new Set([
|
||||
'a', 'an', 'the', 'this', 'that', 'these', 'those', 'it',
|
||||
'new', 'brand', 'fresh', 'another', 'other', 'more', 'extra', 'additional',
|
||||
'further', 'second', 'third', 'next', 'one', 'couple', 'few', 'several',
|
||||
'some', 'any', 'open', 'spare', 'whole', 'just', 'quick',
|
||||
]);
|
||||
|
||||
export function extractRole(question, categories = []) {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
@@ -37,12 +66,24 @@ export function extractRole(question, categories = []) {
|
||||
|
||||
/* "…for a Sous Chef", "…a Line Cook position" — take the phrase the sentence
|
||||
itself marks as the role when it matches no known category. */
|
||||
const phrase = /(?:create|open|post|add|new|hiring|hire)\s+(?:a|an)?\s*([a-z][a-z\s/-]{2,40}?)\s*(?:position|role|job|opening)\b/i.exec(question)
|
||||
|| /\b(?:position|role|job)\s+for\s+(?:a|an)?\s*([a-z][a-z\s/-]{2,40})/i.exec(question);
|
||||
/* The article is optional but must be a WHOLE word when present. Written as
|
||||
`(?:a|an)?\s*` it matched the "a" of "another", so "create another new
|
||||
position" captured "nother new" and offered the position a title that is
|
||||
not even a word. */
|
||||
const phrase = /(?:create|open|post|add|new|hiring|hire)\s+(?:(?:an?)\s+)?([a-z][a-z\s/-]{2,40}?)\s*(?:position|role|job|opening)\b/i.exec(question)
|
||||
|| /\b(?:position|role|job)\s+for\s+(?:(?:an?)\s+)?([a-z][a-z\s/-]{2,40})/i.exec(question);
|
||||
if (!phrase) return null;
|
||||
|
||||
const cleaned = phrase[1].trim().replace(/\s+/g, ' ');
|
||||
return cleaned ? cleaned.replace(/\b\w/g, (c) => c.toUpperCase()) : null;
|
||||
if (!cleaned) return null;
|
||||
|
||||
/* Named nothing if every word was scaffolding — "a brand new", "one more".
|
||||
A single-word check missed all of those, because the scaffolding is a
|
||||
PHRASE at least as often as it is one word. */
|
||||
const named = cleaned.split(' ').some((word) => !GENERIC_ROLE_WORDS.has(word.toLowerCase()));
|
||||
if (!named) return null;
|
||||
|
||||
return cleaned.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** A location, when the sentence names one with "in" or "at". */
|
||||
@@ -440,6 +481,18 @@ const HANDLERS = {
|
||||
data: toPositionPayload(draft || {}, status ? { status } : undefined),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Write the employee role the conversation collected.
|
||||
*
|
||||
* The supply-side twin of `create_position`. `status` is the WORKER's
|
||||
* situation rather than how finished the record is, so the conversation's one
|
||||
* verb commits it as `seeking` — there is no draft of a person's own role.
|
||||
*/
|
||||
create_employee_role: ({ draft, status }) => ({
|
||||
type: 'create_employee_role',
|
||||
data: { ...toEmployeeRolePayload(draft || {}), status: status || 'seeking' },
|
||||
}),
|
||||
|
||||
/**
|
||||
* Open the Add Skill Training flow, on the Forge page, with what the request
|
||||
* already answered. Routing to the page carries the intent in navigation
|
||||
|
||||
354
src/lib/skills/conversationFlow.js
Normal file
354
src/lib/skills/conversationFlow.js
Normal file
@@ -0,0 +1,354 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
|
||||
/**
|
||||
* A skill's questions, asked one at a time, as a conversation.
|
||||
*
|
||||
* This is the engine `positionFlow.js` used to be. It was five things 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.
|
||||
*
|
||||
* So the control flow lives here and the five domain concerns live in a
|
||||
* REGISTRY, one per kind of record. Adding a second conversation is writing a
|
||||
* second registry; it is not editing this file. That is the same rule §3 states
|
||||
* for agents, applied one level down: the skill file declares the questions, the
|
||||
* registry says what each field means, and this decides what to say next.
|
||||
*
|
||||
* WHAT A REGISTRY PROVIDES
|
||||
*
|
||||
* fields { [field]: { label, settled, retry, parse, summary } }
|
||||
* resolve an `@token` from the skill file → the chips it stands for
|
||||
* prefill a first draft read out of the opening request
|
||||
* extract one sentence read against every field at once (see `restate`)
|
||||
* verbs what the reader can say at the summary, and what each commits
|
||||
* copy the wording of the summary, the cancel and the failure
|
||||
*
|
||||
* A flow is a plain serializable object: it survives sessionStorage between
|
||||
* turns and holds no component state, so a conversation resumes where it
|
||||
* stopped. Nothing here writes. The last step returns a draft and the panel
|
||||
* performs the mutation.
|
||||
*/
|
||||
|
||||
/* ── Reading answers ────────────────────────────────────────────────────── */
|
||||
|
||||
/** "Skip" is an answer to an optional question — it settles it, unanswered. */
|
||||
export 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. */
|
||||
export const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere else)$/i;
|
||||
|
||||
/** Title Case, for the free-text answers that name a proper noun. */
|
||||
export const titleCase = (value) => value.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
/** A short free-text answer, stripped of the words around it. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* One spoken reply, reduced to the form the vocabularies below are matched on.
|
||||
*
|
||||
* This exists because the confirmation step used to test the raw answer against
|
||||
* an ANCHORED regex, which meant "create position" committed and "create
|
||||
* positions" — the plural, and the wording the Positions page itself uses —
|
||||
* fell through to the not-understood branch. The reader saw the summary again
|
||||
* with no indication of what was wrong with what they said.
|
||||
*
|
||||
* Lower case, single-spaced, trailing punctuation removed. Matching is then
|
||||
* exact set membership rather than a pattern, so a vocabulary is a list of
|
||||
* phrases somebody can read rather than an expression somebody has to parse.
|
||||
*/
|
||||
export const normalizeReply = (said) => String(said)
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[.!?,;]+$/, '')
|
||||
.trim();
|
||||
|
||||
/** Ending the conversation without finishing it. */
|
||||
const CANCEL = new Set([
|
||||
'cancel', 'stop', 'never mind', 'nevermind', 'forget it', 'quit', 'exit',
|
||||
]);
|
||||
|
||||
/** Going back to a detail already given. */
|
||||
const CHANGE = new Set([
|
||||
'change', 'change details', 'edit', 'change something', 'change a detail', 'no',
|
||||
]);
|
||||
|
||||
/* ── Flow state ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The steps a skill declares, keeping only the fields this registry understands.
|
||||
*
|
||||
* A field the registry does not know is dropped, which is deliberate — but it
|
||||
* used to be dropped SILENTLY, and a skill whose steps were all unknown asked
|
||||
* nothing and went straight to a summary of an empty record. `npm test` now
|
||||
* refuses a conversation step whose field no registry defines, so the drop here
|
||||
* only ever removes a field a shipped skill does not have.
|
||||
*/
|
||||
export const stepsOf = (registry, skill) => (skill?.conversation || [])
|
||||
.filter((s) => registry.fields[s.field]);
|
||||
|
||||
/** Is this field answered, or deliberately passed over? */
|
||||
export const isSettled = (registry, flow, field) => registry.fields[field].settled(flow.draft)
|
||||
|| flow.skipped.includes(field);
|
||||
|
||||
/** The next question, or `null` when there is nothing left to ask. */
|
||||
export function nextStep(registry, flow, steps) {
|
||||
if (flow.editing) return steps.find((s) => s.field === flow.editing) || null;
|
||||
return steps.find((s) => !isSettled(registry, flow, s.field)) || null;
|
||||
}
|
||||
|
||||
/** Everything decided so far, one line each, in the order it is asked. */
|
||||
export function summaryLines(registry, flow, steps) {
|
||||
return steps
|
||||
.filter((s) => isSettled(registry, flow, s.field))
|
||||
.map((s) => registry.fields[s.field].summary(flow.draft))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** The required fields the record cannot be created without. */
|
||||
export const missingRequired = (registry, flow, steps) => steps
|
||||
.filter((s) => s.required && !registry.fields[s.field].settled(flow.draft));
|
||||
|
||||
/* ── Suggestions ────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A step's chips.
|
||||
*
|
||||
* `@`-prefixed entries in the skill file resolve through the registry 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.
|
||||
*
|
||||
* An `@token` the registry cannot resolve contributes nothing rather than
|
||||
* appearing as the literal "@workers", which is what it did before registries
|
||||
* existed and each resolver knew every token.
|
||||
*/
|
||||
function suggestionsFor(registry, step, ctx) {
|
||||
const resolved = step.options.flatMap((option) => {
|
||||
if (!option.startsWith('@')) return [option];
|
||||
return registry.resolve?.(option, ctx) || [];
|
||||
});
|
||||
|
||||
const capped = [...new Set(resolved.filter(Boolean))].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 }));
|
||||
}
|
||||
|
||||
/* ── Replies ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Ask the next question, or read the record back when there is none left. */
|
||||
function ask(registry, flow, steps, ctx, { preamble = null, retry = null } = {}) {
|
||||
const step = nextStep(registry, flow, steps);
|
||||
|
||||
if (!step) return review(registry, flow, steps);
|
||||
|
||||
const known = summaryLines(registry, flow, steps);
|
||||
return {
|
||||
flow: { ...flow, stage: 'collect', step: step.field },
|
||||
doc: doc(
|
||||
preamble ? text(preamble) : null,
|
||||
preamble && known.length ? list(known) : null,
|
||||
text(step.question),
|
||||
retry ? note(retry) : null
|
||||
),
|
||||
followUp: suggestionsFor(registry, step, ctx),
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole record, before anything is written. */
|
||||
function review(registry, flow, steps) {
|
||||
const missing = missingRequired(registry, 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(registry.copy.reviewQuestion),
|
||||
list(summaryLines(registry, flow, steps)),
|
||||
note(registry.copy.reviewNote)
|
||||
),
|
||||
followUp: registry.verbs
|
||||
.filter((v) => v.chip)
|
||||
.map((v) => ({ label: v.chip, prompt: v.chip }))
|
||||
.concat([{ label: 'Change details', prompt: 'Change details' }]),
|
||||
};
|
||||
}
|
||||
|
||||
/** Which detail to change — the answers already given, as chips. */
|
||||
function changeMenu(registry, flow, steps) {
|
||||
const settled = steps.filter((s) => isSettled(registry, flow, s.field));
|
||||
return {
|
||||
flow: { ...flow, stage: 'change', step: null },
|
||||
doc: doc(text('What should I change?')),
|
||||
followUp: settled.map((s) => ({
|
||||
label: registry.fields[s.field].label,
|
||||
prompt: registry.fields[s.field].label,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The reading itself is the registry's — a sentence about a job posting and a
|
||||
* sentence about a worker's role name different things — and only the bookkeeping
|
||||
* around it is general.
|
||||
*/
|
||||
function restate(registry, flow, steps, said, ctx) {
|
||||
const fields = new Set(steps.map((s) => s.field));
|
||||
const result = registry.extract?.(said, ctx, fields);
|
||||
if (!result || !result.touched?.size) return null;
|
||||
|
||||
return {
|
||||
...flow,
|
||||
draft: { ...flow.draft, ...result.patch },
|
||||
/* Step names, not record fields — a step the sentence settled must come off
|
||||
the skipped list so the summary shows it. */
|
||||
skipped: flow.skipped.filter((f) => !result.touched.has(f)),
|
||||
editing: null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 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 beginFlow({ registry, question, skill, ctx = {} }) {
|
||||
const steps = stepsOf(registry, skill);
|
||||
|
||||
const flow = {
|
||||
flowId: registry.id,
|
||||
skillId: skill.id,
|
||||
draft: registry.prefill(question, ctx),
|
||||
skipped: [],
|
||||
editing: null,
|
||||
stage: 'collect',
|
||||
step: null,
|
||||
};
|
||||
|
||||
const known = summaryLines(registry, flow, steps);
|
||||
return ask(registry, flow, steps, ctx, {
|
||||
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, status }` — the panel writes it, this module does not.
|
||||
*/
|
||||
export function advanceFlow({ registry, flow, answer, skill, ctx = {} }) {
|
||||
const steps = stepsOf(registry, skill);
|
||||
const said = String(answer).trim();
|
||||
const reply = normalizeReply(said);
|
||||
|
||||
/* 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.has(reply)) {
|
||||
return {
|
||||
flow: null,
|
||||
doc: doc(text(registry.copy.cancelled), note(registry.copy.cancelledNote)),
|
||||
followUp: [],
|
||||
};
|
||||
}
|
||||
|
||||
/* The confirmation step. A verb is the only path to a record.
|
||||
Change is tested before the verbs so that a bare "no" reaches the change
|
||||
menu rather than being read as a refusal to commit. */
|
||||
if (flow.stage === 'review') {
|
||||
if (CHANGE.has(reply)) return changeMenu(registry, flow, steps);
|
||||
|
||||
const verb = registry.verbs.find((v) => v.says.has(reply));
|
||||
if (verb) {
|
||||
if (missingRequired(registry, flow, steps).length) return review(registry, flow, steps);
|
||||
return {
|
||||
flow: { ...flow, stage: 'creating' },
|
||||
create: { draft: flow.draft, status: verb.status },
|
||||
};
|
||||
}
|
||||
|
||||
/* 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 = restate(registry, flow, steps, said, ctx);
|
||||
if (revised) return review(registry, revised, steps);
|
||||
|
||||
return {
|
||||
...review(registry, flow, steps),
|
||||
doc: doc(
|
||||
text(`I did not catch that. ${registry.copy.reviewQuestion}`),
|
||||
list(summaryLines(registry, flow, steps)),
|
||||
note(registry.copy.reviewRetryNote)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/* Choosing which detail to revisit. */
|
||||
if (flow.stage === 'change') {
|
||||
const target = steps.find((s) => registry.fields[s.field].label.toLowerCase() === reply);
|
||||
if (!target) {
|
||||
const revised = restate(registry, flow, steps, said, ctx);
|
||||
if (revised) return review(registry, revised, steps);
|
||||
return changeMenu(registry, flow, steps);
|
||||
}
|
||||
return ask(registry, { ...flow, editing: target.field }, steps, ctx);
|
||||
}
|
||||
|
||||
/* Answering the question that was asked. */
|
||||
const step = steps.find((s) => s.field === flow.step) || nextStep(registry, flow, steps);
|
||||
if (!step) return review(registry, flow, steps);
|
||||
|
||||
const field = registry.fields[step.field];
|
||||
|
||||
if (SKIP.test(said) && !step.required) {
|
||||
const next = { ...flow, skipped: [...flow.skipped, step.field], editing: null };
|
||||
return ask(registry, next, steps, ctx);
|
||||
}
|
||||
|
||||
const patch = field.parse(said, ctx);
|
||||
if (!patch) {
|
||||
/* Not an answer to this question — but it may still be a fact about the
|
||||
record ("in Chennai" while being asked for pay). Take it if so. */
|
||||
const revised = restate(registry, flow, steps, said, ctx);
|
||||
if (revised) return ask(registry, revised, steps, ctx);
|
||||
return ask(registry, flow, steps, ctx, { 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(registry, next, steps);
|
||||
|
||||
return ask(registry, next, steps, ctx);
|
||||
}
|
||||
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.',
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,5 @@
|
||||
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 { advanceFlow, beginFlow } from './conversationFlow';
|
||||
import { positionRegistry } from './flows/position';
|
||||
|
||||
/**
|
||||
* Creating a position, as a conversation.
|
||||
@@ -13,515 +9,44 @@ import {
|
||||
* 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.
|
||||
* This module turns that round. The skill file lists the questions,
|
||||
* `flows/position.js` says what each field name means, and `conversationFlow.js`
|
||||
* decides what to say next. Same split as `actions.js`: Markdown declares, code
|
||||
* interprets, and a field the code does not know is simply not asked about.
|
||||
*
|
||||
* 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.
|
||||
* WHAT IS LEFT HERE. The two entry points the panel calls, bound to the posting
|
||||
* registry. The outcome renderers moved into that registry with everything else
|
||||
* that knows what a job posting is; they are re-exported below because the
|
||||
* panel and its tests have always imported them from here.
|
||||
*
|
||||
* 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,
|
||||
/** Start collecting, from whatever the request already said. */
|
||||
export const beginPositionFlow = ({ question, skill, roles = [], companies = [] }) => beginFlow({
|
||||
registry: positionRegistry, question, skill, ctx: { roles, companies },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
/** One answer, and whatever it makes the next thing to say. */
|
||||
export const advancePositionFlow = ({ flow, answer, skill, roles = [], companies = [] }) => advanceFlow({
|
||||
registry: positionRegistry, flow, answer, skill, ctx: { roles, companies },
|
||||
});
|
||||
|
||||
/* 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(
|
||||
/* 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.
|
||||
* Re-exported from the registry, where they now live beside the field table.
|
||||
*
|
||||
* 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.
|
||||
* The panel reads these off `registry.outcome` and no longer names a position
|
||||
* to render one. These names stay so that nothing importing them has to change
|
||||
* in the same commit as the split.
|
||||
*/
|
||||
export function positionFailedReply(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.
|
||||
*/
|
||||
export 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}?` },
|
||||
]);
|
||||
export const {
|
||||
created: positionCreatedReply,
|
||||
failed: positionFailedReply,
|
||||
followUp: createdFollowUp,
|
||||
} = positionRegistry.outcome;
|
||||
|
||||
@@ -427,6 +427,16 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
capabilities: sectionBullets(body, 'Capabilities'),
|
||||
purpose: sectionBullets(body, 'Purpose'),
|
||||
conversation,
|
||||
/**
|
||||
* Which conversation registry reads this skill's questions.
|
||||
*
|
||||
* Data, not a branch. `routing.js` used to name `create-position` in an
|
||||
* `if`, which meant a second conversational skill was a code change in
|
||||
* the router rather than a file on disk — the same shape §3 rules out for
|
||||
* agents, one level down. A skill with a `## Conversation` block and no
|
||||
* `flow:` defaults to `position`, so nothing that shipped changes.
|
||||
*/
|
||||
flow: data.flow ? String(data.flow) : (conversation.length ? 'position' : null),
|
||||
path,
|
||||
body,
|
||||
custom,
|
||||
|
||||
@@ -42,6 +42,18 @@ export const TOOLS = [
|
||||
/* Writes a record other people will act on. Always confirmed. */
|
||||
requiresApproval: true,
|
||||
},
|
||||
{
|
||||
name: 'create_employee_role',
|
||||
label: 'Create employee role',
|
||||
summary: "Records what a worker declares they do — role, experience, desired pay and availability — from a conversation.",
|
||||
params: ['draft', 'status'],
|
||||
readOnly: false,
|
||||
mutates: 'EmployeeRole',
|
||||
/* Writes a record ABOUT SOMEBODY ELSE, which is the stronger case for a
|
||||
confirmation rather than the weaker one: the person it names is not the
|
||||
person approving it. */
|
||||
requiresApproval: true,
|
||||
},
|
||||
{
|
||||
name: 'open_create_skill_training',
|
||||
label: 'Open Add Skill Training',
|
||||
|
||||
77
src/skills/owliver/create-employee-role.md
Normal file
77
src/skills/owliver/create-employee-role.md
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
id: create-employee-role
|
||||
name: Create Employee Role
|
||||
description: Record what a worker does — their role, experience, pay and availability — by answering a few questions in the chat.
|
||||
pages:
|
||||
- talent-pool
|
||||
- positions
|
||||
status: active
|
||||
version: 1
|
||||
prompt: Create an employee role
|
||||
flow: employee-role
|
||||
triggers:
|
||||
- create an employee role
|
||||
- create employee role
|
||||
- create employee roles
|
||||
- add an employee role
|
||||
- add employee role
|
||||
- new employee role
|
||||
- create a worker role
|
||||
- create worker role
|
||||
- record a role for
|
||||
- add a worker role
|
||||
actions:
|
||||
- create_employee_role
|
||||
---
|
||||
|
||||
# Create Employee Role
|
||||
|
||||
## Purpose
|
||||
|
||||
Record a worker's declared professional role without leaving the page. Owliver
|
||||
asks one question at a time, offers the answers as chips, and reads the whole
|
||||
thing back before anything is written.
|
||||
|
||||
**This is not Create Position, and the difference is the point.** A position is
|
||||
what the ORGANIZATION needs filled — a company, a title, a pay range it will
|
||||
pay. An employee role is what a WORKER says they do — the role they present
|
||||
themselves as, the experience they have, and the pay they are looking for. The
|
||||
two share a vocabulary and nothing else: "3 years" on a position is a minimum an
|
||||
applicant must clear, and the same words here are what this person has.
|
||||
|
||||
They are never joined by a column. Supply and demand meet through applications,
|
||||
which already carry the funnel, the interview and the outcome.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Understand requests to record what a worker does.
|
||||
- Ask who the role is for, and resolve the answer to a real worker profile.
|
||||
- Read the role, experience, English level, certifications, desired pay and
|
||||
availability out of a single sentence.
|
||||
- Ask only for what the request did not already answer.
|
||||
- Offer each answer as a suggestion, so the whole flow can be clicked.
|
||||
- Read the role back for confirmation before recording it.
|
||||
|
||||
## Conversation
|
||||
|
||||
Each line is `field | question | suggestions | required?`. Suggestions beginning
|
||||
with `@` come from the application's own data.
|
||||
|
||||
`@workers` is the worker profiles already on screen for this organization.
|
||||
Picking one records the role against that person's profile and email; typing an
|
||||
email address that has no profile yet also works, because a role can be declared
|
||||
before a profile exists. The worker is always asked for and is never assumed to
|
||||
be whoever is typing — an operator records this on somebody's behalf.
|
||||
|
||||
- worker | Which worker is this role for? Type their name or email. | @workers | required
|
||||
- role_category | What role do they work as? | @roles | required
|
||||
- experience_years | How much experience do they have? | No experience; 1 year; 2 years; 3+ years | optional
|
||||
- english_level | What is their English level? | @english | optional
|
||||
- certifications | Any certifications they hold? | @certifications; None | optional
|
||||
- desired_pay | What pay are they looking for? | $18–$28/hr; $25–$35/hr; $30–$40/hr; Custom | optional
|
||||
- availability | When are they available? | @availability | optional
|
||||
- notes | Anything else worth recording? | | optional
|
||||
|
||||
## Actions
|
||||
|
||||
- create_employee_role
|
||||
@@ -56,7 +56,12 @@ Each line is `field | question | suggestions | required?`. Suggestions beginning
|
||||
with `@` come from the application's own data, so a role category added in the
|
||||
form is offered here without this file changing.
|
||||
|
||||
- company | Which client is this role for? Type the company name. | | required
|
||||
`@companies` is the clients this organization already staffs for, read off the
|
||||
postings already on screen. Picking one is a tap; typing a name that is not on
|
||||
the list is how a new client is named, which is all "create a client" has ever
|
||||
meant here — the company is a field on the position, not a record of its own.
|
||||
|
||||
- company | Which client is this role for? | @companies | required
|
||||
- role_category | What role are you hiring for? | @roles | required
|
||||
- location | Where will this role be based? | Chennai; Bengaluru; Coimbatore; Bay Area; Other | required
|
||||
- pay | What is the pay range? | $18–$28/hr; $25–$35/hr; $30–$40/hr; Custom | required
|
||||
|
||||
Reference in New Issue
Block a user