update ui design for all pages
This commit is contained in:
@@ -5,6 +5,55 @@ import { recalcProfilePatch } from './krowScore';
|
||||
import { logActivity } from './userTracking';
|
||||
import { evaluateChallenge } from './provingGround';
|
||||
|
||||
/**
|
||||
* The signed-in user, on the `['user']` key the assistant and the 404 page
|
||||
* already read. One cache entry means a profile edit reaches the navbar, the
|
||||
* Profile page and Owliver's greeting without any of them knowing about each
|
||||
* other.
|
||||
*/
|
||||
export function useCurrentUser() {
|
||||
return useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: () => base44.auth.me().catch(() => null),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/** Saves profile fields and refreshes every consumer of `['user']`. */
|
||||
export function useUpdateProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (patch) => base44.auth.updateMe(patch),
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(['user'], user);
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Product preferences, stored on the user record.
|
||||
*
|
||||
* Reads fall back to the synchronous accessor so the first render already has
|
||||
* the real values rather than a default that corrects itself a tick later.
|
||||
*/
|
||||
export function usePreferences() {
|
||||
const { data: user } = useCurrentUser();
|
||||
return { ...base44.auth.preferences(), ...(user?.preferences || {}) };
|
||||
}
|
||||
|
||||
/** Writes one or more preferences and refreshes every consumer of `['user']`. */
|
||||
export function useUpdatePreferences() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (patch) => base44.auth.updatePreferences(patch),
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(['user'], user);
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useJobPostings() {
|
||||
return useQuery({
|
||||
queryKey: ['jobPostings'],
|
||||
@@ -278,6 +327,38 @@ export function useCourse(id) {
|
||||
return useQuery({ queryKey: ['course', id], queryFn: () => base44.entities.Course.get(id), enabled: !!id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Author a skill in KROW Forge.
|
||||
*
|
||||
* A skill and the challenge that verifies it are one Course record — the entity
|
||||
* already carries the skill (title, category, difficulty, proof_skill), the
|
||||
* training (quiz, estimated_minutes), the proof (challenge.type) and the
|
||||
* evaluation Owliver runs (challenge.rubric). So Forge authoring is a create on
|
||||
* the entity the whole product already reads, not a parallel store.
|
||||
*/
|
||||
export function useCreateCourse() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data) => base44.entities.Course.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['courses'] });
|
||||
logActivity('create_skill_training');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Status changes and edits to an existing skill. */
|
||||
export function useUpdateCourse() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Course.update(id, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['courses'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['course', variables.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLearningPaths() {
|
||||
return useQuery({ queryKey: ['learningPaths'], queryFn: () => base44.entities.LearningPath.list('-created_date', 100) });
|
||||
}
|
||||
|
||||
90
src/lib/positionModel.js
Normal file
90
src/lib/positionModel.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The position record, as one definition.
|
||||
*
|
||||
* Create Position (the form) and Owliver (the conversation) are two ways into
|
||||
* the same record, and the field names, option lists and defaults have to be the
|
||||
* same in both — a category the form offers and the conversation does not is a
|
||||
* position that can only be created one way. So the shape lives here and both
|
||||
* read it, rather than each carrying its own copy.
|
||||
*
|
||||
* Nothing in this file talks to the store. Creating a position is still
|
||||
* `useCreateJobPosting`, in both paths.
|
||||
*/
|
||||
|
||||
/** Certifications a position can require, in the order they are offered. */
|
||||
export const CERT_OPTIONS = [
|
||||
'Food Handler Card',
|
||||
'ServSafe',
|
||||
'ABC License',
|
||||
'TIPS Certified',
|
||||
'CPR/First Aid',
|
||||
'Background Check Cleared',
|
||||
];
|
||||
|
||||
/** English levels, stored as the value and shown as the label. */
|
||||
export const ENGLISH_LEVELS = [
|
||||
{ value: 'basic', label: 'Basic' },
|
||||
{ value: 'conversational', label: 'Conversational' },
|
||||
{ value: 'fluent', label: 'Fluent' },
|
||||
{ value: 'native', label: 'Native' },
|
||||
];
|
||||
|
||||
/** How each vetting weight reads when it is labelled. */
|
||||
export const CRITERIA_LABELS = {
|
||||
experience: 'Experience',
|
||||
english: 'English',
|
||||
reliability: 'Reliability',
|
||||
certifications: 'Certifications',
|
||||
availability: 'Availability',
|
||||
};
|
||||
|
||||
/**
|
||||
* A blank position.
|
||||
*
|
||||
* A function rather than a constant, so two callers cannot end up sharing — and
|
||||
* mutating — the same `vetting_criteria` or certifications array.
|
||||
*/
|
||||
export function defaultPosition() {
|
||||
return {
|
||||
title: '',
|
||||
role_category: 'Server',
|
||||
custom_requirements: '',
|
||||
physical_requirements: '',
|
||||
leadership_expectations: '',
|
||||
attendance_expectations: '',
|
||||
min_experience_years: 0,
|
||||
english_required: 'basic',
|
||||
location: '',
|
||||
pay_range_min: '',
|
||||
pay_range_max: '',
|
||||
certifications_required: [],
|
||||
vetting_criteria: { experience: 25, english: 20, reliability: 20, certifications: 20, availability: 15 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A draft — from either path — as the record the store is given.
|
||||
*
|
||||
* The two numeric fields are text inputs in the form, so they are coerced here
|
||||
* rather than at each call site. Everything absent falls back to the same
|
||||
* defaults the form starts from.
|
||||
*/
|
||||
export function toPositionPayload(draft = {}, { status = 'active' } = {}) {
|
||||
const record = { ...defaultPosition(), ...draft };
|
||||
return {
|
||||
...record,
|
||||
status,
|
||||
pay_range_min: Number(record.pay_range_min) || 0,
|
||||
pay_range_max: Number(record.pay_range_max) || 0,
|
||||
min_experience_years: Number(record.min_experience_years) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** `$30–$40/hr`, or a single rate, or nothing when no pay is set. */
|
||||
export function payLabel({ pay_range_min: min, pay_range_max: max } = {}) {
|
||||
const low = Number(min) || 0;
|
||||
const high = Number(max) || 0;
|
||||
if (!low && !high) return null;
|
||||
if (!high || high === low) return `$${low}/hr`;
|
||||
return `$${low}–$${high}/hr`;
|
||||
}
|
||||
9
src/lib/roleCategories.js
Normal file
9
src/lib/roleCategories.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* The role categories a position can be filed under.
|
||||
*
|
||||
* Shared because two things read them: the Create Position form renders them as
|
||||
* options, and Owliver matches a spoken role against them. Custom categories
|
||||
* added through the form come from the RoleCategory entity and are merged on
|
||||
* top of these at both call sites.
|
||||
*/
|
||||
export const ROLE_CATEGORIES = ['Server', 'Bartender', 'Chef', 'Security', 'Picker', 'Event Staff', 'Other'];
|
||||
490
src/lib/skills/actions.js
Normal file
490
src/lib/skills/actions.js
Normal file
@@ -0,0 +1,490 @@
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS, toPositionPayload } from '@/lib/positionModel';
|
||||
import { routeForPageKey } from './registry';
|
||||
|
||||
/**
|
||||
* Skill actions.
|
||||
*
|
||||
* A skill file names the actions it may perform; this module is the only place
|
||||
* that decides what those names do. Markdown is never executed — an unknown
|
||||
* action name resolves to nothing rather than to arbitrary behaviour.
|
||||
*
|
||||
* Every action is a description of intent, returned to the panel to carry out
|
||||
* with the app's own router. Nothing here writes data: the most an action does
|
||||
* is open a form with fields filled in, which the user then reviews and submits
|
||||
* through the existing flow.
|
||||
*/
|
||||
|
||||
/* ── Natural-language extraction ────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The role being asked for.
|
||||
*
|
||||
* Matched against the categories the app already has rather than a list written
|
||||
* here, so "create a chef position" resolves to whatever the deployment calls
|
||||
* that category — and a category added in Create Position is understood without
|
||||
* a change to this file.
|
||||
*/
|
||||
export function extractRole(question, categories = []) {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
/* Longest match wins: "executive chef" should not resolve to "chef". */
|
||||
let best = null;
|
||||
for (const category of categories) {
|
||||
const name = String(category).toLowerCase();
|
||||
if (name && q.includes(name) && (!best || name.length > best.length)) best = category;
|
||||
}
|
||||
if (best) return best;
|
||||
|
||||
/* "…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);
|
||||
if (!phrase) return null;
|
||||
|
||||
const cleaned = phrase[1].trim().replace(/\s+/g, ' ');
|
||||
return cleaned ? cleaned.replace(/\b\w/g, (c) => c.toUpperCase()) : null;
|
||||
}
|
||||
|
||||
/** A location, when the sentence names one with "in" or "at". */
|
||||
export function extractLocation(question) {
|
||||
const match = /\b(?:in|at|based in|located in)\s+([A-Z][A-Za-z.'-]*(?:\s+[A-Z][A-Za-z.'-]*){0,3})/.exec(question);
|
||||
if (!match) return null;
|
||||
const value = match[1].trim();
|
||||
/* "in Chennai" is a location; "in the morning" is not. */
|
||||
return /^(The|A|An|This|That|Order|Total|Fact)$/i.test(value) ? null : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pay range. Accepts "$30-$40/hr", "30 to 40 an hour", "$25/hr".
|
||||
* Returns whole dollars, because the form's fields are numeric.
|
||||
*/
|
||||
export function extractPay(question) {
|
||||
const range = /\$?\s*(\d{1,3})(?:\.\d+)?\s*(?:-|–|—|to)\s*\$?\s*(\d{1,3})(?:\.\d+)?/.exec(question);
|
||||
if (range) {
|
||||
const min = Number(range[1]);
|
||||
const max = Number(range[2]);
|
||||
if (min > 0 && max >= min) return { min, max };
|
||||
}
|
||||
const single = /\$\s*(\d{1,3})(?:\.\d+)?/.exec(question);
|
||||
if (single) {
|
||||
const value = Number(single[1]);
|
||||
if (value > 0) return { min: value, max: value };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimum experience in years, when the sentence states one.
|
||||
*
|
||||
* "no experience needed" is an answer rather than a silence, so it returns 0 —
|
||||
* which is what stops the conversation asking a question the request already
|
||||
* settled.
|
||||
*/
|
||||
export function extractExperience(question) {
|
||||
const q = String(question).toLowerCase();
|
||||
if (/\b(no|zero)\s+(minimum|min|experience|exp)\b/.test(q) || /\bentry[- ]level\b/.test(q)) return 0;
|
||||
|
||||
const match = /(\d{1,2})\s*\+?\s*(?:years?|yrs?)(?:\s+(?:of\s+)?(?:experience|exp))?/.exec(q);
|
||||
if (!match) return null;
|
||||
const years = Number(match[1]);
|
||||
return years >= 0 && years <= 40 ? years : null;
|
||||
}
|
||||
|
||||
/** An English level, matched against the levels the record actually stores. */
|
||||
export function extractEnglish(question) {
|
||||
const q = String(question).toLowerCase();
|
||||
const level = ENGLISH_LEVELS.find(({ value }) => new RegExp(`\\b${value}\\b`).test(q));
|
||||
/* "english" alone names the field, not a level — leave it to be asked. */
|
||||
return level ? level.value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certifications the sentence names, matched against the list the form offers.
|
||||
*
|
||||
* An explicit "no certifications" is an answer — an empty list — and returns
|
||||
* `[]`, which is different from returning nothing.
|
||||
*/
|
||||
export function extractCertifications(question) {
|
||||
const q = String(question).toLowerCase();
|
||||
if (/\bno (?:certification|certificate|cert)s?\b|\bnone required\b/.test(q)) return [];
|
||||
|
||||
const found = CERT_OPTIONS.filter((cert) => q.includes(cert.toLowerCase()));
|
||||
return found.length ? found : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a position can be given from one sentence.
|
||||
*
|
||||
* Only fields the record actually has. Nothing is invented, and anything absent
|
||||
* stays absent — for the conversation that is the difference between a question
|
||||
* worth asking and one that repeats what the user already said.
|
||||
*/
|
||||
export function buildPositionPrefill(question, categories = []) {
|
||||
const role = extractRole(question, categories);
|
||||
const location = extractLocation(question);
|
||||
const pay = extractPay(question);
|
||||
const experience = extractExperience(question);
|
||||
const english = extractEnglish(question);
|
||||
const certifications = extractCertifications(question);
|
||||
|
||||
const prefill = {};
|
||||
if (role) {
|
||||
/* A known category fills the category field; anything else is a title, so a
|
||||
one-off role is not silently filed under the wrong category. Either way
|
||||
the title is set, because a position without one cannot be created. */
|
||||
const known = categories.find((c) => String(c).toLowerCase() === String(role).toLowerCase());
|
||||
if (known) prefill.role_category = known;
|
||||
prefill.title = known || role;
|
||||
}
|
||||
if (location) prefill.location = location;
|
||||
if (pay) {
|
||||
prefill.pay_range_min = String(pay.min);
|
||||
prefill.pay_range_max = String(pay.max);
|
||||
}
|
||||
if (experience !== null) prefill.min_experience_years = experience;
|
||||
if (english) prefill.english_required = english;
|
||||
if (certifications) prefill.certifications_required = certifications;
|
||||
|
||||
return { prefill, role, location, pay };
|
||||
}
|
||||
|
||||
/**
|
||||
* Words that are the sentence's own scaffolding rather than a skill.
|
||||
*
|
||||
* "Create a skill training" names nothing — matching it would put the word
|
||||
* "Skill" in the name field, which the author then has to notice and delete.
|
||||
* An empty field is the honest answer to a request that named no subject.
|
||||
*/
|
||||
const GENERIC_SKILL_WORDS = new Set([
|
||||
'skill', 'skills', 'training', 'trainings', 'course', 'courses', 'challenge',
|
||||
'challenges', 'new', 'another', 'one', 'it', 'this', 'that', 'them',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The skill a Forge request is about.
|
||||
*
|
||||
* "Create a training for knife skills" → "Knife Skills". The sentence marks the
|
||||
* subject with "for" or "about", or names it between the verb and the word
|
||||
* training; anything else is left for the author to type, because a wrong guess
|
||||
* in a form field is worse than an empty one.
|
||||
*/
|
||||
export function extractSkillName(question) {
|
||||
const patterns = [
|
||||
/\b(?:training|skill|challenge|course)s?\s+(?:for|on|about|covering)\s+(?:a|an|the)?\s*([a-z][a-z0-9\s&/'-]{2,50})/i,
|
||||
/\b(?:create|add|build|make|new)\s+(?:a|an)?\s*([a-z][a-z0-9\s&/'-]{2,50}?)\s+(?:training|skill|challenge|course)\b/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = pattern.exec(question);
|
||||
if (!match) continue;
|
||||
const cleaned = match[1]
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
/* "for kitchen staff" names an audience, not a skill — drop the trailing
|
||||
audience clause so the field holds the subject on its own. */
|
||||
.replace(/\s+(?:for|to)\s+(?:my|our|the)?\s*\w+(?:\s+\w+)?$/i, '')
|
||||
.trim();
|
||||
|
||||
if (cleaned.length < 3) continue;
|
||||
|
||||
/* A phrase made only of scaffolding named no subject. */
|
||||
const words = cleaned.toLowerCase().split(/\s+/);
|
||||
if (words.every((w) => GENERIC_SKILL_WORDS.has(w))) continue;
|
||||
|
||||
return cleaned.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The skill a "review" follow-up refers to.
|
||||
*
|
||||
* Owliver's draft is not stored anywhere — the follow-up chip carries the same
|
||||
* subject the request did, and the draft is derived again from it. That is what
|
||||
* lets a chip and a typed sentence go through one resolution path and arrive at
|
||||
* the same draft, rather than a chip replaying a remembered answer.
|
||||
*/
|
||||
export function extractReviewSubject(question) {
|
||||
const match = /\breview\s+(?:the\s+)?([a-z][a-z0-9\s&/'-]{2,60}?)\s+skill\b/i.exec(question);
|
||||
if (!match) return null;
|
||||
const cleaned = match[1].trim().replace(/\s+/g, ' ');
|
||||
return cleaned.length >= 3 ? cleaned.replace(/\b\w/g, (c) => c.toUpperCase()) : null;
|
||||
}
|
||||
|
||||
/* ── Draft suggestions ──────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Starting points for a skill draft, by subject.
|
||||
*
|
||||
* These are suggestions an author reviews and edits, not knowledge the platform
|
||||
* holds — which is why a subject matching nothing here yields no outline at all
|
||||
* rather than three generic lines. An empty step the author fills in is honest;
|
||||
* an invented curriculum is not.
|
||||
*
|
||||
* `proof` is a challenge type the challenge runner can actually run.
|
||||
*/
|
||||
const SKILL_TEMPLATES = [
|
||||
{
|
||||
terms: ['allerg', 'intoleran', 'cross-contact', 'cross contact'],
|
||||
category: 'Safety & Hygiene',
|
||||
difficulty: 'intermediate',
|
||||
outline: ['Recognizing allergens', 'Preventing cross-contact', 'Handling allergy requests'],
|
||||
proof: 'roleplay',
|
||||
},
|
||||
{
|
||||
terms: ['food safety', 'hygiene', 'sanitation', 'sanitisation', 'haccp', 'temperature'],
|
||||
category: 'Safety & Hygiene',
|
||||
difficulty: 'intermediate',
|
||||
outline: ['Safe holding temperatures', 'Storage and labelling', 'Cleaning and sanitising'],
|
||||
proof: 'photo',
|
||||
},
|
||||
{
|
||||
terms: ['hazard', 'ppe', 'manual handling', 'lifting', 'fire', 'first aid', 'accident'],
|
||||
category: 'Safety & Hygiene',
|
||||
difficulty: 'intermediate',
|
||||
outline: ['Spotting the hazard', 'The safe method', 'What to do when it goes wrong'],
|
||||
proof: 'photo_identify',
|
||||
},
|
||||
{
|
||||
terms: ['knife', 'cut', 'chop', 'prep', 'plating'],
|
||||
category: 'Kitchen',
|
||||
difficulty: 'beginner',
|
||||
outline: ['Grip and posture', 'The basic cuts', 'Working safely at speed'],
|
||||
proof: 'video',
|
||||
},
|
||||
{
|
||||
terms: ['guest', 'customer', 'service', 'complaint', 'front of house', 'hospitality'],
|
||||
category: 'Guest Service',
|
||||
difficulty: 'intermediate',
|
||||
outline: ['Reading the guest', 'Handling a complaint', 'Recovering the experience'],
|
||||
proof: 'roleplay',
|
||||
},
|
||||
{
|
||||
terms: ['bar', 'cocktail', 'alcohol', 'licensing', 'pour'],
|
||||
category: 'Bar',
|
||||
difficulty: 'intermediate',
|
||||
outline: ['Responsible service', 'Standard pours and specs', 'Refusing service'],
|
||||
proof: 'roleplay',
|
||||
},
|
||||
{
|
||||
terms: ['clean', 'housekeep', 'laundry', 'linen'],
|
||||
category: 'Cleaning',
|
||||
difficulty: 'beginner',
|
||||
outline: ['The cleaning sequence', 'Chemicals and dilution', 'Checking your own work'],
|
||||
proof: 'photo',
|
||||
},
|
||||
{
|
||||
terms: ['till', 'cash', 'payment', 'pos', 'checkout'],
|
||||
category: 'Operations',
|
||||
difficulty: 'beginner',
|
||||
outline: ['Opening and closing the till', 'Taking payment accurately', 'Handling discrepancies'],
|
||||
proof: 'roleplay',
|
||||
},
|
||||
];
|
||||
|
||||
/** How each proof method reads when Owliver describes it in a sentence. */
|
||||
export const VERIFICATION_LABEL = {
|
||||
roleplay: 'Scenario-based assessment',
|
||||
video: 'Recorded demonstration',
|
||||
photo: 'Photo evidence',
|
||||
photo_identify: 'Hazard identification',
|
||||
};
|
||||
|
||||
const DIFFICULTIES = ['beginner', 'intermediate', 'advanced'];
|
||||
|
||||
/** The template whose terms the text matches, or null. Longest term wins. */
|
||||
function matchTemplate(text) {
|
||||
const q = String(text).toLowerCase();
|
||||
let best = null;
|
||||
for (const template of SKILL_TEMPLATES) {
|
||||
for (const term of template.terms) {
|
||||
if (q.includes(term) && (!best || term.length > best.term.length)) {
|
||||
best = { ...template, term };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the Add Skill Training flow can be given from one sentence.
|
||||
*
|
||||
* The name and any category the library already uses come from the request
|
||||
* itself. A level, a starting outline and a proof method come from the template
|
||||
* the subject matches, and are absent when it matches none — the flow exists to
|
||||
* walk the author through those decisions, so a blank field is a prompt rather
|
||||
* than a gap.
|
||||
*/
|
||||
export function buildSkillPrefill(question, categories = []) {
|
||||
const title = extractSkillName(question) || extractReviewSubject(question);
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
/* Longest match wins, so "front of house" beats "house". */
|
||||
let category = null;
|
||||
for (const candidate of categories) {
|
||||
const name = String(candidate).toLowerCase();
|
||||
if (name && q.includes(name) && (!category || name.length > category.length)) {
|
||||
category = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const template = matchTemplate(`${title || ''} ${question}`);
|
||||
|
||||
/* A level the author names outranks the template's. */
|
||||
const stated = DIFFICULTIES.find((d) => q.includes(d));
|
||||
const difficulty = stated || template?.difficulty || null;
|
||||
|
||||
const outline = template?.outline || [];
|
||||
const challengeType = template?.proof || null;
|
||||
|
||||
const prefill = {};
|
||||
if (title) prefill.title = title;
|
||||
if (category || template?.category) prefill.category = category || template.category;
|
||||
if (difficulty) prefill.difficulty = difficulty;
|
||||
if (outline.length) prefill.outline = [...outline];
|
||||
if (challengeType) prefill.challengeType = challengeType;
|
||||
|
||||
return {
|
||||
prefill,
|
||||
title,
|
||||
category: prefill.category || null,
|
||||
difficulty,
|
||||
outline,
|
||||
challengeType,
|
||||
verification: challengeType ? VERIFICATION_LABEL[challengeType] : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An existing skill, as the authoring flow's own fields.
|
||||
*
|
||||
* Adding training to a published skill edits that record rather than creating a
|
||||
* second one, so the flow has to open holding everything the record already
|
||||
* says. Suggested steps are added only when the skill carries no outline of its
|
||||
* own — Owliver does not overwrite an author's work with a template.
|
||||
*/
|
||||
export function buildTrainingPrefill(course) {
|
||||
if (!course) return { prefill: {}, suggested: [] };
|
||||
|
||||
const existing = (Array.isArray(course.training_outline) ? course.training_outline : []).filter(Boolean);
|
||||
const template = existing.length
|
||||
? null
|
||||
: matchTemplate(`${course.title || ''} ${course.category || ''} ${course.description || ''}`);
|
||||
const suggested = template?.outline || [];
|
||||
|
||||
const rubric = (course.challenge?.rubric || []).map((r) => r.criterion).filter(Boolean);
|
||||
|
||||
return {
|
||||
prefill: {
|
||||
courseId: course.id,
|
||||
title: course.title || '',
|
||||
category: course.category || '',
|
||||
difficulty: course.difficulty || 'beginner',
|
||||
description: course.description || '',
|
||||
proof_skill: course.proof_skill || '',
|
||||
outline: existing.length ? existing : suggested.length ? [...suggested] : [''],
|
||||
estimated_minutes: String(course.estimated_minutes || 15),
|
||||
challengeType: course.challenge?.type || 'roleplay',
|
||||
prompt: course.challenge?.prompt || '',
|
||||
criteria: rubric.length ? rubric : ['', '', ''],
|
||||
pass_score: String(course.pass_score || 70),
|
||||
badge_reward: course.badge_reward || '',
|
||||
},
|
||||
suggested,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The skill in the library a request names, or null.
|
||||
*
|
||||
* Matched against the records that exist rather than a list written here, so a
|
||||
* skill added this morning is addressable this afternoon. Longest title wins,
|
||||
* so "Knife Skills — Advanced" beats "Knife Skills".
|
||||
*/
|
||||
export function findCourseByName(question, courses = []) {
|
||||
const q = String(question).toLowerCase();
|
||||
let best = null;
|
||||
for (const course of courses) {
|
||||
const title = String(course.title || '').toLowerCase();
|
||||
if (title.length >= 3 && q.includes(title) && (!best || title.length > best.title.length)) {
|
||||
best = { course, title };
|
||||
}
|
||||
}
|
||||
return best?.course || null;
|
||||
}
|
||||
|
||||
/* ── Action implementations ─────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The handlers a skill may name. Each returns a plain description of what
|
||||
* should happen; the panel performs it with the router it already has.
|
||||
*/
|
||||
const HANDLERS = {
|
||||
/**
|
||||
* Write the position the conversation collected.
|
||||
*
|
||||
* The one action in this module that results in a record, and it exists
|
||||
* because the alternative — opening the manual form with fields filled in —
|
||||
* made the user finish by hand a job they had already described in a sentence.
|
||||
* The panel performs it with `useCreateJobPosting`, the same mutation the form
|
||||
* calls, so there is one create path and the list refreshes the same way.
|
||||
*
|
||||
* Reached only from the confirmation step, after the position has been read
|
||||
* back and the user has chosen to create it.
|
||||
*/
|
||||
create_position: ({ draft }) => ({
|
||||
type: 'create_position',
|
||||
data: toPositionPayload(draft || {}),
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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
|
||||
* state — the panel does not reach into the page, and the page decides what to
|
||||
* do with it.
|
||||
*/
|
||||
open_create_skill_training: ({ prefill }) => ({
|
||||
type: 'navigate',
|
||||
route: routeForPageKey('university'),
|
||||
state: {
|
||||
openSkillTraining: true,
|
||||
...(prefill && Object.keys(prefill).length ? { prefill } : {}),
|
||||
},
|
||||
}),
|
||||
|
||||
/**
|
||||
* The same flow, opened on its training step for a skill that already exists.
|
||||
* `courseId` is what makes this an edit of that record rather than a second
|
||||
* copy of it — the page reads it and saves back to the skill it came from.
|
||||
*/
|
||||
open_create_training: ({ prefill, courseId }) => ({
|
||||
type: 'navigate',
|
||||
route: routeForPageKey('university'),
|
||||
state: {
|
||||
openSkillTraining: true,
|
||||
startStep: 'training',
|
||||
...(courseId ? { courseId } : {}),
|
||||
...(prefill && Object.keys(prefill).length ? { prefill } : {}),
|
||||
},
|
||||
}),
|
||||
|
||||
navigate_to_positions: () => ({ type: 'navigate', route: routeForPageKey('positions') }),
|
||||
navigate_to_candidates: () => ({ type: 'navigate', route: routeForPageKey('candidates') }),
|
||||
navigate_to_forge: () => ({ type: 'navigate', route: routeForPageKey('university') }),
|
||||
navigate_to_analytics: () => ({ type: 'navigate', route: routeForPageKey('analytics') }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves an action name to something to do.
|
||||
*
|
||||
* `skill.actions` gates it: a skill can only run what its own file declares,
|
||||
* so a handler existing in code is not the same as a skill being allowed to use
|
||||
* it. Unknown or undeclared names return null and nothing happens.
|
||||
*/
|
||||
export function runAction(name, { skill, ...payload } = {}) {
|
||||
if (skill && !skill.actions.includes(name)) return null;
|
||||
const handler = HANDLERS[name];
|
||||
return handler ? handler(payload) : null;
|
||||
}
|
||||
|
||||
export const ACTION_NAMES = Object.keys(HANDLERS);
|
||||
455
src/lib/skills/positionFlow.js
Normal file
455
src/lib/skills/positionFlow.js
Normal file
@@ -0,0 +1,455 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
|
||||
import {
|
||||
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
|
||||
extractLocation, extractPay, extractRole,
|
||||
} from './actions';
|
||||
|
||||
/**
|
||||
* Creating a position, as a conversation.
|
||||
*
|
||||
* Owliver used to answer "create a bartender position" by opening the Create
|
||||
* Position form with three fields filled in — which left the person who had just
|
||||
* described the job in one sentence to type the rest of it into a drawer. The
|
||||
* form was doing the asking, and the assistant was doing the paperwork.
|
||||
*
|
||||
* This module turns that round. The skill file lists the questions; this decides
|
||||
* what each field name means — how an answer is read, whether it is already
|
||||
* settled, and how it reads back in the summary. Same split as `actions.js`:
|
||||
* Markdown declares, code interprets, and a field the code does not know is
|
||||
* simply not asked about rather than being handled arbitrarily.
|
||||
*
|
||||
* The flow is a plain serializable object. It survives being written to
|
||||
* sessionStorage between turns, and it holds no component state, so the
|
||||
* conversation can be resumed exactly where it stopped.
|
||||
*
|
||||
* Nothing here writes. The last step returns a draft, and the panel creates the
|
||||
* position with the same mutation the form uses.
|
||||
*/
|
||||
|
||||
/* ── Reading answers ────────────────────────────────────────────────────── */
|
||||
|
||||
/** Title Case, for the free-text answers that name a proper noun. */
|
||||
const titleCase = (value) => value.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
/** A short free-text answer, stripped of the words around it. */
|
||||
function cleanPhrase(answer, max = 60) {
|
||||
const value = String(answer)
|
||||
.trim()
|
||||
.replace(/^(?:in|at|around|near|it is|its|it's)\s+/i, '')
|
||||
.replace(/[.!?,;]+$/, '')
|
||||
.replace(/\s+/g, ' ');
|
||||
return value && value.length <= max ? value : null;
|
||||
}
|
||||
|
||||
/** "Skip" is an answer to an optional question — it settles it, unanswered. */
|
||||
const SKIP = /^(?:skip|skip this|skip it|no preference|not sure|does ?n(?:'|o)t matter|any|none of these)$/i;
|
||||
|
||||
/** A chip that means "let me type it" rather than an answer in itself. */
|
||||
const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere else)$/i;
|
||||
|
||||
/**
|
||||
* What each field means: when it is already settled, how an answer is read, and
|
||||
* how it reads back.
|
||||
*
|
||||
* `parse` returns a patch for the draft, or `null` when the answer was not
|
||||
* understood — which re-asks with `retry` rather than storing a guess.
|
||||
*/
|
||||
const FIELDS = {
|
||||
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 Create position.')
|
||||
),
|
||||
followUp: [
|
||||
{ label: 'Create position', prompt: 'Create position' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Which detail to change — the answers already given, as chips. */
|
||||
function changeMenu(flow, steps) {
|
||||
const settled = steps.filter((s) => isSettled(flow, s.field));
|
||||
return {
|
||||
flow: { ...flow, stage: 'change', step: null },
|
||||
doc: doc(text('What should I change?')),
|
||||
followUp: settled.map((s) => ({ label: FIELDS[s.field].label, prompt: FIELDS[s.field].label })),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Entry points ───────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Start collecting, from whatever the request already said.
|
||||
*
|
||||
* "Create a bartender position in Chennai paying $30–$40/hr" answers three
|
||||
* questions before the first one is asked, and those are not asked again.
|
||||
*/
|
||||
export function beginPositionFlow({ question, skill, roles = [] }) {
|
||||
const { prefill } = buildPositionPrefill(question, roles);
|
||||
const steps = stepsOf(skill);
|
||||
|
||||
const flow = {
|
||||
skillId: skill.id,
|
||||
draft: prefill,
|
||||
skipped: [],
|
||||
editing: null,
|
||||
stage: 'collect',
|
||||
step: null,
|
||||
};
|
||||
|
||||
const known = summaryLines(flow, steps);
|
||||
return ask(flow, steps, {
|
||||
roles,
|
||||
preamble: known.length ? `Got it — here is what I have so far:` : null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One answer, and whatever it makes the next thing to say.
|
||||
*
|
||||
* Returns `{ flow, doc, followUp }`, and on the confirmation step additionally
|
||||
* `create: { draft }` — the panel writes it, this module does not.
|
||||
*/
|
||||
export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
|
||||
const steps = stepsOf(skill);
|
||||
const said = String(answer).trim();
|
||||
|
||||
/* A way out that does not require finishing. `flow: null` ends it, and the
|
||||
next question is answered by the page as usual. */
|
||||
if (/^(?:cancel|stop|never ?mind|nevermind|forget it|quit|exit)$/i.test(said)) {
|
||||
return {
|
||||
flow: null,
|
||||
doc: doc(
|
||||
text('Stopped — nothing was created.'),
|
||||
note('Ask me to create a position whenever you are ready.')
|
||||
),
|
||||
followUp: [],
|
||||
};
|
||||
}
|
||||
|
||||
/* The confirmation step. "Create position" is the only path to a record. */
|
||||
if (flow.stage === 'review') {
|
||||
if (/^(?:create position|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 } };
|
||||
}
|
||||
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 Create position, 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) {
|
||||
return doc(
|
||||
text('Position created successfully.'),
|
||||
list([
|
||||
position.title,
|
||||
position.location,
|
||||
payLabel(position),
|
||||
].filter(Boolean)),
|
||||
note('It is on the Positions list now — applications will start appearing against it.')
|
||||
);
|
||||
}
|
||||
|
||||
/** The write failed. The draft is kept, so the answers are not lost. */
|
||||
export function positionFailedReply() {
|
||||
return doc(
|
||||
text('I could not create that position.'),
|
||||
note('Nothing was saved. Choose Create position to try again.')
|
||||
);
|
||||
}
|
||||
|
||||
/** What the panel offers after a position is created. */
|
||||
export const createdFollowUp = (position) => [
|
||||
{ label: 'View position', route: `/admin/positions/${position.id}` },
|
||||
];
|
||||
254
src/lib/skills/registry.js
Normal file
254
src/lib/skills/registry.js
Normal file
@@ -0,0 +1,254 @@
|
||||
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
|
||||
/**
|
||||
* Owliver skill registry.
|
||||
*
|
||||
* A skill is a Markdown file: frontmatter declares what it is and where it
|
||||
* applies, the body documents what it can do. Nothing here executes Markdown —
|
||||
* a skill names actions, and `actions.js` decides what those names mean. The
|
||||
* split is the point: adding a capability is a file, not a change to Owliver.
|
||||
*
|
||||
* Registration is the filesystem. `import.meta.glob` picks up every file under
|
||||
* `src/skills/`, so a new page's skill is registered by existing, and no
|
||||
* component contains a route check to go with it.
|
||||
*/
|
||||
|
||||
const FILES = import.meta.glob('/src/skills/**/*.md', { query: '?raw', import: 'default', eager: true });
|
||||
|
||||
/**
|
||||
* Frontmatter, parsed to the subset the format actually uses: `key: value` and
|
||||
* `key:` followed by an indented `- item` list.
|
||||
*
|
||||
* Deliberately not a YAML library — the app has none, this needs no dependency,
|
||||
* and a skill file that reaches for anchors or nested maps has outgrown being a
|
||||
* declaration anyway.
|
||||
*/
|
||||
function parseFrontmatter(raw) {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
|
||||
if (!match) return { data: {}, body: raw };
|
||||
|
||||
const data = {};
|
||||
let listKey = null;
|
||||
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const item = /^\s*-\s+(.*)$/.exec(line);
|
||||
if (item && listKey) {
|
||||
data[listKey].push(item[1].trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
||||
if (!pair) continue;
|
||||
|
||||
const [, key, value] = pair;
|
||||
if (value === '') {
|
||||
listKey = key;
|
||||
data[key] = [];
|
||||
} else {
|
||||
listKey = null;
|
||||
data[key] = value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return { data, body: raw.slice(match[0].length).trim() };
|
||||
}
|
||||
|
||||
/** Bullets under a `## Heading`, for the capability list shown in Settings. */
|
||||
function sectionBullets(body, heading) {
|
||||
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
|
||||
if (!section) return [];
|
||||
return section[1]
|
||||
.split(/\r?\n/)
|
||||
.map((l) => /^\s*-\s+(.*)$/.exec(l)?.[1]?.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* The questions a skill asks, from its `## Conversation` section.
|
||||
*
|
||||
* One bullet per question: `field | question | suggestions | required?`, where
|
||||
* suggestions are separated by `;`. A skill that declares no conversation gets
|
||||
* an empty list and behaves as it always did — this is additive.
|
||||
*
|
||||
* Nothing is executed and no suggestion is interpreted here. What a field name
|
||||
* means, and what a suggestion resolves to, is `positionFlow.js`'s decision, the
|
||||
* same way `actions.js` is the only place an action name means anything.
|
||||
*/
|
||||
function sectionSteps(body, heading) {
|
||||
return sectionBullets(body, heading)
|
||||
.map((line) => {
|
||||
const [field, question, options = '', flag = ''] = line.split('|').map((p) => p.trim());
|
||||
if (!field || !question) return null;
|
||||
return {
|
||||
field,
|
||||
question,
|
||||
options: options.split(';').map((o) => o.trim()).filter(Boolean),
|
||||
required: !/^optional$/i.test(flag),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* The page key a route belongs to — `/admin/positions` → `positions`.
|
||||
*
|
||||
* Derived from the placement table rather than written down again, so a route
|
||||
* added there is addressable by a skill without touching this file.
|
||||
*/
|
||||
export function pageKeyForRoute(route) {
|
||||
const tail = route.replace(/^\/admin\/?/, '');
|
||||
return tail === '' ? 'control-center' : tail;
|
||||
}
|
||||
|
||||
/** contextId → page key, for resolving skills from the assistant's context. */
|
||||
const PAGE_KEY_BY_CONTEXT = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route, contextId]) => {
|
||||
acc[contextId] = pageKeyForRoute(route);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
/** page key → route, so an action can navigate without hard-coding a path. */
|
||||
const ROUTE_BY_PAGE_KEY = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route, contextId]) => {
|
||||
acc[pageKeyForRoute(route)] = { route, contextId };
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export const routeForPageKey = (key) => ROUTE_BY_PAGE_KEY[key]?.route ?? null;
|
||||
export const pageKeyForContext = (contextId) => PAGE_KEY_BY_CONTEXT[contextId] ?? null;
|
||||
|
||||
/**
|
||||
* One Markdown definition → one skill.
|
||||
*
|
||||
* Shared by the files on disk and by anything added at runtime, so a skill
|
||||
* written in the Add Skill dialog is parsed by exactly the same code as
|
||||
* `create-position.md` and cannot drift into a second format.
|
||||
*/
|
||||
export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
{
|
||||
const { data, body } = parseFrontmatter(raw);
|
||||
const pages = Array.isArray(data.pages) ? data.pages : [];
|
||||
return {
|
||||
id: data.id || path.split('/').pop().replace(/\.md$/, ''),
|
||||
name: data.name || 'Untitled skill',
|
||||
description: data.description || '',
|
||||
status: data.status === 'inactive' ? 'inactive' : 'active',
|
||||
pages,
|
||||
/* The page label Settings shows, taken from the context the page carries
|
||||
so the two never disagree. */
|
||||
actions: Array.isArray(data.actions) ? data.actions : [],
|
||||
/* A skill with no declared triggers answers to its own name, so a
|
||||
definition that omits the field is still reachable by asking for it.
|
||||
Explicit triggers replace the fallback rather than adding to it. */
|
||||
triggers: (Array.isArray(data.triggers) && data.triggers.length
|
||||
? data.triggers
|
||||
: [data.name].filter(Boolean)
|
||||
).map((t) => String(t).toLowerCase()),
|
||||
prompt: data.prompt || null,
|
||||
capabilities: sectionBullets(body, 'Capabilities'),
|
||||
purpose: sectionBullets(body, 'Purpose'),
|
||||
/* The questions this skill asks, when it collects its input in the chat
|
||||
rather than by opening something. */
|
||||
conversation: sectionSteps(body, 'Conversation'),
|
||||
path,
|
||||
body,
|
||||
custom,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Every skill on disk, parsed once at module load. */
|
||||
export const SKILLS = Object.entries(FILES)
|
||||
.map(([path, raw]) => parseSkill(raw, { path }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
/**
|
||||
* The built-in skills plus any the account has added.
|
||||
*
|
||||
* Custom definitions are stored as their Markdown source, so what is persisted
|
||||
* is the same artefact a file on disk would be — nothing is half-parsed into a
|
||||
* bespoke record. A custom skill sharing an id with a built-in replaces it,
|
||||
* which is how one would be overridden without editing the repository.
|
||||
*/
|
||||
export function allSkills(customSources = []) {
|
||||
const custom = customSources
|
||||
.map((entry, i) => {
|
||||
try {
|
||||
return parseSkill(entry.raw ?? entry, { path: entry.path || `custom/${i}.md`, custom: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((s) => s && s.id);
|
||||
|
||||
const byId = new Map(SKILLS.map((s) => [s.id, s]));
|
||||
custom.forEach((s) => byId.set(s.id, s));
|
||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Validates a definition before it is stored. Returns an error string or null. */
|
||||
export function validateSkillSource(raw) {
|
||||
if (!String(raw).trim()) return 'Paste or upload a Markdown definition.';
|
||||
let skill;
|
||||
try {
|
||||
skill = parseSkill(raw, { custom: true });
|
||||
} catch {
|
||||
return 'That definition could not be parsed.';
|
||||
}
|
||||
if (!skill.id) return 'The frontmatter needs an `id`.';
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(skill.id)) return '`id` must be lower-case letters, numbers and dashes.';
|
||||
if (!skill.name) return 'The frontmatter needs a `name`.';
|
||||
if (!skill.pages.length) return 'The frontmatter needs at least one `pages` entry.';
|
||||
const unknown = skill.pages.filter((p) => !ROUTE_BY_PAGE_KEY[p]);
|
||||
if (unknown.length) {
|
||||
return `Unknown page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Known pages: ${Object.keys(ROUTE_BY_PAGE_KEY).join(', ')}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Page keys a skill may attach to, for the dialog's own guidance. */
|
||||
export const PAGE_KEYS = Object.keys(ROUTE_BY_PAGE_KEY).sort();
|
||||
|
||||
/** Context ids a skill applies to, resolved through the placement table. */
|
||||
export function contextIdsForSkill(skill) {
|
||||
return skill.pages.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills available on a page.
|
||||
*
|
||||
* `disabled` is the account's list of switched-off skill ids, so a skill can be
|
||||
* turned off from Settings without being deleted from disk.
|
||||
*/
|
||||
export function skillsForContext(contextId, disabled = [], customSources = []) {
|
||||
const pageKey = PAGE_KEY_BY_CONTEXT[contextId];
|
||||
if (!pageKey) return [];
|
||||
return allSkills(customSources).filter(
|
||||
(s) => s.status === 'active' && !disabled.includes(s.id) && s.pages.includes(pageKey)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does one trigger match?
|
||||
*
|
||||
* A trigger is a phrase. `*` stands for "anything in between", which is what
|
||||
* lets one line cover "create a position", "create a chef position" and
|
||||
* "create a bartender position in Chennai" without listing every role — the
|
||||
* roles come from data, so they must not be written into triggers.
|
||||
*/
|
||||
function triggerMatches(trigger, question) {
|
||||
if (!trigger.includes('*')) return question.includes(trigger);
|
||||
const pattern = trigger
|
||||
.split('*')
|
||||
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('[\\s\\S]{0,40}?');
|
||||
return new RegExp(pattern).test(question);
|
||||
}
|
||||
|
||||
/** The first skill on this page whose triggers match the question. */
|
||||
export function matchSkill(question, contextId, disabled = [], customSources = []) {
|
||||
const q = String(question).toLowerCase();
|
||||
return skillsForContext(contextId, disabled, customSources).find(
|
||||
(s) => s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
|
||||
) ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user