diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index 7c0d77c..3e7fc01 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -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', diff --git a/src/agents/positions-agent.md b/src/agents/positions-agent.md index da55eeb..fc52b49 100644 --- a/src/agents/positions-agent.md +++ b/src/agents/positions-agent.md @@ -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: diff --git a/src/agents/talent-pool-agent.md b/src/agents/talent-pool-agent.md index 31e320a..e67a96e 100644 --- a/src/agents/talent-pool-agent.md +++ b/src/agents/talent-pool-agent.md @@ -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? diff --git a/src/api/base44Client.js b/src/api/base44Client.js index cc619ac..05f1992 100644 --- a/src/api/base44Client.js +++ b/src/api/base44Client.js @@ -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. */ diff --git a/src/api/httpClient.js b/src/api/httpClient.js index a9b1f8e..05d4d6b 100644 --- a/src/api/httpClient.js +++ b/src/api/httpClient.js @@ -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', diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx index 0a46248..7c369f1 100644 --- a/src/components/ai-assistant/KrowAssistant.jsx +++ b/src/components/ai-assistant/KrowAssistant.jsx @@ -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, diff --git a/src/components/ai-assistant/routing.js b/src/components/ai-assistant/routing.js index a82fa00..1d03d6b 100644 --- a/src/components/ai-assistant/routing.js +++ b/src/components/ai-assistant/routing.js @@ -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. */ diff --git a/src/components/ai-assistant/useAssistant.js b/src/components/ai-assistant/useAssistant.js index a2c1df9..ad075d6 100644 --- a/src/components/ai-assistant/useAssistant.js +++ b/src/components/ai-assistant/useAssistant.js @@ -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(), []); diff --git a/src/lib/employeeRoleModel.js b/src/lib/employeeRoleModel.js new file mode 100644 index 0000000..ddbd53c --- /dev/null +++ b/src/lib/employeeRoleModel.js @@ -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(), + }; +} diff --git a/src/lib/krowHooks.js b/src/lib/krowHooks.js index 7df7638..52fba06 100644 --- a/src/lib/krowHooks.js +++ b/src/lib/krowHooks.js @@ -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({ diff --git a/src/lib/skills/actions.js b/src/lib/skills/actions.js index ae28b00..f4b7f9d 100644 --- a/src/lib/skills/actions.js +++ b/src/lib/skills/actions.js @@ -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 diff --git a/src/lib/skills/conversationFlow.js b/src/lib/skills/conversationFlow.js new file mode 100644 index 0000000..b48416e --- /dev/null +++ b/src/lib/skills/conversationFlow.js @@ -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); +} diff --git a/src/lib/skills/flows/employeeRole.js b/src/lib/skills/flows/employeeRole.js new file mode 100644 index 0000000..bc154a1 --- /dev/null +++ b/src/lib/skills/flows/employeeRole.js @@ -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.', + }, +}; diff --git a/src/lib/skills/flows/index.js b/src/lib/skills/flows/index.js new file mode 100644 index 0000000..b9e3f86 --- /dev/null +++ b/src/lib/skills/flows/index.js @@ -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); diff --git a/src/lib/skills/flows/position.js b/src/lib/skills/flows/position.js new file mode 100644 index 0000000..168df88 --- /dev/null +++ b/src/lib/skills/flows/position.js @@ -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.', + }, +}; diff --git a/src/lib/skills/positionFlow.js b/src/lib/skills/positionFlow.js index d65007e..9d70626 100644 --- a/src/lib/skills/positionFlow.js +++ b/src/lib/skills/positionFlow.js @@ -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); +/** Start collecting, from whatever the request already said. */ +export const beginPositionFlow = ({ question, skill, roles = [], companies = [] }) => beginFlow({ + registry: positionRegistry, question, skill, ctx: { roles, companies }, +}); - const flow = { - skillId: skill.id, - draft: prefill, - skipped: [], - editing: null, - stage: 'collect', - step: null, - }; +/** 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 }, +}); - const known = summaryLines(flow, steps); - return ask(flow, steps, { - roles, - preamble: known.length ? `Got it — here is what I have so far:` : null, - }); -} - -/** - * One answer, and whatever it makes the next thing to say. - * - * Returns `{ flow, doc, followUp }`, and on the confirmation step additionally - * `create: { draft }` — the panel writes it, this module does not. - */ -export function advancePositionFlow({ flow, answer, skill, roles = [] }) { - const steps = stepsOf(skill); - const said = String(answer).trim(); - - /* A way out that does not require finishing. `flow: null` ends it, and the - next question is answered by the page as usual. */ - if (/^(?:cancel|stop|never ?mind|nevermind|forget it|quit|exit)$/i.test(said)) { - return { - flow: null, - doc: doc( - text('Stopped — nothing was created.'), - note('Ask me to create a position whenever you are ready.') - ), - followUp: [], - }; - } - - /* The confirmation step. "Create position" is the only path to a record. */ - if (flow.stage === 'review') { - /* Saving unpublished. The same write, with the status the form's own - "Save as Draft" button sets — one create path, two statuses. */ - if (/^(?:save as draft|save draft|draft|save it as a draft)$/i.test(said)) { - if (missingRequired(flow, steps).length) return review(flow, steps); - return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'draft' } }; - } - if (/^(?:create position|publish job posting|publish|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) { - if (missingRequired(flow, steps).length) return review(flow, steps); - return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'active' } }; - } - if (/^(?:change|change details|edit|change something|no)$/i.test(said)) return changeMenu(flow, steps); - - /* Anything else at the confirmation step is a correction stated outright — - "make it Bengaluru", "$32–$40". Read it against every field and apply - what it settles, rather than making the user find the menu. */ - const revised = applyStatement(flow, steps, said, roles); - if (revised) return review(revised, steps); - - return { - ...review(flow, steps), - doc: doc( - text('I did not catch that. Ready to create this position?'), - list(summaryLines(flow, steps)), - note('Choose Save as Draft or Publish Job Posting, or tell me what to change.') - ), - }; - } - - /* Choosing which detail to revisit. */ - if (flow.stage === 'change') { - const target = steps.find((s) => FIELDS[s.field].label.toLowerCase() === said.toLowerCase()); - if (!target) { - const revised = applyStatement(flow, steps, said, roles); - if (revised) return review(revised, steps); - return changeMenu(flow, steps); - } - return ask({ ...flow, editing: target.field }, steps, { roles }); - } - - /* Answering the question that was asked. */ - const step = steps.find((s) => s.field === flow.step) || nextStep(flow, steps); - if (!step) return review(flow, steps); - - const field = FIELDS[step.field]; - - if (SKIP.test(said) && !step.required) { - const next = { ...flow, skipped: [...flow.skipped, step.field], editing: null }; - return ask(next, steps, { roles }); - } - - const patch = field.parse(said, { roles }); - if (!patch) { - /* Not an answer to this question — but it may still be a fact about the - position ("in Chennai" while being asked for pay). Take it if so. */ - const revised = applyStatement(flow, steps, said, roles); - if (revised) return ask(revised, steps, { roles }); - return ask(flow, steps, { roles, retry: field.retry }); - } - - const next = { - ...flow, - draft: { ...flow.draft, ...patch }, - skipped: flow.skipped.filter((f) => f !== step.field), - editing: null, - }; - - /* A detail revisited from the change menu goes straight back to the summary - rather than walking the rest of the questions again. */ - if (flow.editing) return review(next, steps); - - return ask(next, steps, { roles }); -} - -/** - * A sentence read against every field at once. - * - * This is what makes the conversation forgiving: an answer that arrives out of - * order, or a correction stated rather than chosen from a menu, lands in the - * right field instead of being rejected for not answering the question asked. - * Returns an updated flow, or `null` when the sentence settles nothing. - */ -function applyStatement(flow, steps, said, roles) { - const { prefill } = buildPositionPrefill(said, roles); - const fields = new Set(steps.map((s) => s.field)); - - const patch = {}; - /* Step names, not record fields — a step the sentence settled must come off - the skipped list so the summary shows it. */ - const touched = new Set(); - - if (fields.has('role_category') && prefill.title) { - if (prefill.role_category) patch.role_category = prefill.role_category; - patch.title = prefill.title; - touched.add('role_category'); - } - if (fields.has('location') && prefill.location) { - patch.location = prefill.location; - touched.add('location'); - } - if (fields.has('pay') && prefill.pay_range_min) { - patch.pay_range_min = prefill.pay_range_min; - patch.pay_range_max = prefill.pay_range_max; - touched.add('pay'); - } - if (fields.has('min_experience_years') && prefill.min_experience_years !== undefined) { - patch.min_experience_years = prefill.min_experience_years; - touched.add('min_experience_years'); - } - if (fields.has('english_required') && prefill.english_required) { - patch.english_required = prefill.english_required; - touched.add('english_required'); - } - if (fields.has('certifications_required') && prefill.certifications_required) { - patch.certifications_required = prefill.certifications_required; - touched.add('certifications_required'); - } - - if (!touched.size) return null; - - return { - ...flow, - draft: { ...flow.draft, ...patch }, - skipped: flow.skipped.filter((f) => !touched.has(f)), - editing: null, - }; -} /* ── Outcomes ───────────────────────────────────────────────────────────── */ -/** The position exists. Said plainly, with what was created. */ -export function positionCreatedReply(position) { - const isDraft = position.status === 'draft'; - - return doc( - /* 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; diff --git a/src/lib/skills/registry.js b/src/lib/skills/registry.js index ca70acb..188a956 100644 --- a/src/lib/skills/registry.js +++ b/src/lib/skills/registry.js @@ -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, diff --git a/src/lib/skills/tools.js b/src/lib/skills/tools.js index c74e6f0..c01b111 100644 --- a/src/lib/skills/tools.js +++ b/src/lib/skills/tools.js @@ -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', diff --git a/src/skills/owliver/create-employee-role.md b/src/skills/owliver/create-employee-role.md new file mode 100644 index 0000000..65a89ca --- /dev/null +++ b/src/skills/owliver/create-employee-role.md @@ -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 diff --git a/src/skills/owliver/create-position.md b/src/skills/owliver/create-position.md index 5bb74a4..ae3d5a8 100644 --- a/src/skills/owliver/create-position.md +++ b/src/skills/owliver/create-position.md @@ -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