update owliver skill
This commit is contained in:
315
src/lib/hiringRecords.js
Normal file
315
src/lib/hiringRecords.js
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* The hiring record, derived once.
|
||||
*
|
||||
* Analytics and Hired History ask different questions of the same facts — "how
|
||||
* is our hiring performing" and "who did we hire, and what happened" — and they
|
||||
* answer them with different pages. What they must never do is *count*
|
||||
* differently: a total on one page and the same total on the other have to be
|
||||
* the same number, or the two pages stop being two views and become two claims.
|
||||
*
|
||||
* So the counting lives here, in plain functions over the collections the app
|
||||
* already holds, and each page renders what it needs from the result. Nothing in
|
||||
* this module knows what either page looks like.
|
||||
*/
|
||||
|
||||
/** The mean of a numeric list, rounded. Zero for an empty list. */
|
||||
export const avg = (xs) => {
|
||||
const values = xs.filter((n) => Number.isFinite(n));
|
||||
return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Everyone hired, as one list.
|
||||
*
|
||||
* A Staff record is the hire; the application it came from carries how long it
|
||||
* took and what it scored, and the posting carries the department. Joined here
|
||||
* so neither page repeats the join — and so "department" means the same thing
|
||||
* on both.
|
||||
*/
|
||||
export function buildHires({ staff = [], applications = [], postings = [] }) {
|
||||
return staff.map((s) => {
|
||||
const app = applications.find((a) => a.id === s.application_id);
|
||||
const posting = postings.find((p) => p.id === s.job_posting_id);
|
||||
const days = app
|
||||
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
|
||||
: null;
|
||||
|
||||
return {
|
||||
...s,
|
||||
company: s.company || posting?.company || '—',
|
||||
department: posting?.role_category || s.department || '—',
|
||||
role: s.role || posting?.title || '—',
|
||||
timeToHire: days || s.timeToHire || null,
|
||||
score: s.ai_score || app?.ai_score || s.score || null,
|
||||
profile_tier: s.profile_tier || 'skilled',
|
||||
hire_date: s.hire_date || s.created_date || null,
|
||||
applicationId: app?.id || s.application_id || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo fill, carried over from the page this module was extracted from.
|
||||
*
|
||||
* The store seeds three Staff records; Hired History has always padded that to
|
||||
* eight so the page reads as a hiring history rather than as three rows. That
|
||||
* padding is pre-existing product behaviour, not something derived — it is kept
|
||||
* here, named for what it is, so both pages show what the page has always shown
|
||||
* and there is one list to delete when the deployment has real volume.
|
||||
*
|
||||
* It only ever *adds* people the store does not already have, matched on email,
|
||||
* so a real hire is never shadowed by a demo one.
|
||||
*/
|
||||
const DEMO_FILL = [
|
||||
{ id: 's4', name: 'Sophia Chen', email: 'sophia.chen@email.com', role: 'Guest Relations Lead', department: 'Front Desk', profile_tier: 'expert', score: 90, timeToHire: 2, hire_date: '2026-07-22', status: 'hired' },
|
||||
{ id: 's5', name: 'Oliver Bennett', email: 'oliver.b@email.com', role: 'Event Coordinator', department: 'Event Manager', profile_tier: 'skilled', score: 91, timeToHire: 3, hire_date: '2026-07-20', status: 'hired' },
|
||||
{ id: 's6', name: 'Aaliyah Patel', email: 'aaliyah.p@email.com', role: 'Operations Supervisor', department: 'Housekeeping', profile_tier: 'solid', score: 87, timeToHire: 2, hire_date: '2026-07-18', status: 'hired' },
|
||||
{ id: 's7', name: 'Lucas Wright', email: 'lucas.w@email.com', role: 'Concierge Lead', department: 'Front Desk', profile_tier: 'solid', score: 88, timeToHire: 2, hire_date: '2026-07-15', status: 'hired' },
|
||||
{ id: 's8', name: 'Elena Rostova', email: 'elena.r@email.com', role: 'Lead Security Officer', department: 'Security', profile_tier: 'expert', score: 95, timeToHire: 1, hire_date: '2026-07-12', status: 'hired' },
|
||||
];
|
||||
|
||||
/** The joined hires, with the demo fill applied for anyone not already on file. */
|
||||
export function hiresWithFill(sources) {
|
||||
const live = buildHires(sources);
|
||||
const seen = new Set(live.map((h) => String(h.email || h.name).toLowerCase()));
|
||||
|
||||
const filled = [...live];
|
||||
for (const person of DEMO_FILL) {
|
||||
const key = String(person.email || person.name).toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
filled.push({ ...person, company: person.company || '—' });
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
/** Headline figures: volume, speed, quality, and how many are still on. */
|
||||
export function summarise(hires) {
|
||||
return {
|
||||
total: hires.length,
|
||||
speed: avg(hires.map((h) => h.timeToHire)),
|
||||
quality: avg(hires.map((h) => h.score)),
|
||||
active: hires.filter((h) => h.status !== 'inactive').length,
|
||||
onboarding: hires.filter((h) => h.status === 'onboarding').length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The application stages this product counts, in the order they happen. */
|
||||
export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
|
||||
|
||||
/**
|
||||
* Applied → screened → shortlisted → interview → hired, with the pass-through
|
||||
* rate and the loss at each step.
|
||||
*
|
||||
* Counted at-or-beyond, so a candidate who reached interview is counted as
|
||||
* having been screened — a funnel that counts only the current status shows
|
||||
* later stages as larger than earlier ones, which is not a funnel.
|
||||
*/
|
||||
export function buildFunnel(applications) {
|
||||
const atOrBeyond = (stage) => {
|
||||
const from = STAGE_ORDER.indexOf(stage);
|
||||
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from).length;
|
||||
};
|
||||
|
||||
const stages = [
|
||||
{ key: 'applied', label: 'Applied', count: applications.length },
|
||||
{ key: 'ai_screened', label: 'Screened', count: atOrBeyond('ai_screened') },
|
||||
{ key: 'shortlisted', label: 'Shortlisted', count: atOrBeyond('shortlisted') },
|
||||
{ key: 'interview', label: 'Interview', count: atOrBeyond('interview') },
|
||||
{ key: 'hired', label: 'Hired', count: applications.filter((a) => a.status === 'hired').length },
|
||||
];
|
||||
|
||||
const transitions = stages.slice(1).map((stage, i) => {
|
||||
const previous = stages[i];
|
||||
return {
|
||||
from: previous.key,
|
||||
to: stage.key,
|
||||
rate: previous.count ? Math.round((stage.count / previous.count) * 100) : 0,
|
||||
lost: Math.max(0, previous.count - stage.count),
|
||||
};
|
||||
});
|
||||
|
||||
/* The step losing the most people — the one worth acting on. */
|
||||
const weakest = transitions.reduce(
|
||||
(worst, t) => (!worst || t.rate < worst.rate ? t : worst),
|
||||
null
|
||||
);
|
||||
|
||||
return {
|
||||
stages,
|
||||
transitions,
|
||||
weakestKey: weakest?.to || null,
|
||||
conversion: applications.length
|
||||
? Math.round((stages[4].count / applications.length) * 100)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Cumulative hires by month — a trend needs a baseline, not a single bar. */
|
||||
export function buildTrend(hires) {
|
||||
const byMonth = new Map();
|
||||
hires.filter((h) => h.hire_date).forEach((h) => {
|
||||
const d = new Date(h.hire_date);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
byMonth.set(key, (byMonth.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
let running = 0;
|
||||
return [...byMonth.entries()].sort().map(([key, count]) => {
|
||||
running += count;
|
||||
const [y, m] = key.split('-');
|
||||
return {
|
||||
label: new Date(Number(y), Number(m) - 1).toLocaleDateString(undefined, { month: 'short' }),
|
||||
hires: count,
|
||||
cumulative: running,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Hires grouped by department, best-performing first. */
|
||||
export function byDepartment(hires) {
|
||||
const map = new Map();
|
||||
|
||||
hires.forEach((h) => {
|
||||
const name = h.department && h.department !== '—' ? h.department : 'General';
|
||||
const entry = map.get(name) || {
|
||||
name, department: name, count: 0, scores: [], times: [], rolesSet: new Set(), hiresList: [],
|
||||
};
|
||||
entry.count += 1;
|
||||
if (h.score) entry.scores.push(h.score);
|
||||
if (h.timeToHire) entry.times.push(h.timeToHire);
|
||||
if (h.role && h.role !== '—') entry.rolesSet.add(h.role);
|
||||
entry.hiresList.push(h);
|
||||
map.set(name, entry);
|
||||
});
|
||||
|
||||
return [...map.values()]
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
department: e.department,
|
||||
count: e.count,
|
||||
scores: e.scores,
|
||||
avgScore: avg(e.scores),
|
||||
avgTimeToHire: avg(e.times),
|
||||
roles: [...e.rolesSet],
|
||||
hiresList: e.hiresList,
|
||||
}))
|
||||
.sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0) || b.count - a.count);
|
||||
}
|
||||
|
||||
/** Hires grouped by the role they were hired into, most-filled first. */
|
||||
export function byPosition(hires) {
|
||||
const map = new Map();
|
||||
|
||||
hires.forEach((h) => {
|
||||
const key = h.role && h.role !== '—' ? h.role : 'Unspecified';
|
||||
const entry = map.get(key) || { role: key, count: 0, scores: [], days: [], rated: 0, ratings: [] };
|
||||
entry.count += 1;
|
||||
if (h.score) entry.scores.push(h.score);
|
||||
if (h.timeToHire) entry.days.push(h.timeToHire);
|
||||
if (h.client_rating) { entry.rated += 1; entry.ratings.push(h.client_rating); }
|
||||
map.set(key, entry);
|
||||
});
|
||||
|
||||
return [...map.values()]
|
||||
.map((e) => ({
|
||||
role: e.role,
|
||||
count: e.count,
|
||||
avgScore: avg(e.scores),
|
||||
avgDays: avg(e.days),
|
||||
rated: e.rated,
|
||||
avgRating: e.ratings.length
|
||||
? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1))
|
||||
: null,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where hiring is slow, and where it is fast.
|
||||
*
|
||||
* Velocity is only meaningful against something, so each role is measured
|
||||
* against the workspace's own median rather than an industry figure nobody
|
||||
* here can check.
|
||||
*/
|
||||
export function buildEfficiency(hires) {
|
||||
const timed = hires.filter((h) => h.timeToHire);
|
||||
if (!timed.length) return { median: 0, fastest: [], slowest: [], within48h: 0 };
|
||||
|
||||
const sorted = [...timed].sort((a, b) => a.timeToHire - b.timeToHire);
|
||||
const median = sorted[Math.floor(sorted.length / 2)].timeToHire;
|
||||
|
||||
const roles = byPosition(timed).filter((r) => r.avgDays);
|
||||
|
||||
return {
|
||||
median,
|
||||
fastest: [...roles].sort((a, b) => a.avgDays - b.avgDays).slice(0, 4),
|
||||
slowest: [...roles].sort((a, b) => b.avgDays - a.avgDays).slice(0, 4),
|
||||
within48h: Math.round((timed.filter((h) => h.timeToHire <= 2).length / timed.length) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the numbers say, as findings rather than figures.
|
||||
*
|
||||
* Every one is conditional on the data supporting it: a claim about the fastest
|
||||
* department is only made when there is more than one department to compare, and
|
||||
* a risk is only raised when something is actually at risk. A page that always
|
||||
* shows three insights is showing decoration.
|
||||
*/
|
||||
export function buildInsights({ hires, departments, positions, funnel, efficiency }) {
|
||||
const out = [];
|
||||
const summary = summarise(hires);
|
||||
|
||||
if (departments.length > 1) {
|
||||
const best = departments[0];
|
||||
out.push({
|
||||
tone: 'success',
|
||||
title: `${best.department} is hiring the strongest candidates`,
|
||||
body: `${best.count} hire${best.count === 1 ? '' : 's'} at an average score of ${best.avgScore}, against ${summary.quality} across the workspace.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (funnel.weakestKey) {
|
||||
const weak = funnel.transitions.find((t) => t.to === funnel.weakestKey);
|
||||
const label = funnel.stages.find((s) => s.key === funnel.weakestKey)?.label;
|
||||
if (weak && weak.lost > 0) {
|
||||
out.push({
|
||||
tone: 'warning',
|
||||
title: `The largest drop-off is into ${label}`,
|
||||
body: `${weak.rate}% pass through and ${weak.lost} candidate${weak.lost === 1 ? '' : 's'} stop there. It is the step with the most to recover.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (efficiency.slowest.length && efficiency.median) {
|
||||
const slow = efficiency.slowest[0];
|
||||
if (slow.avgDays > efficiency.median) {
|
||||
out.push({
|
||||
tone: 'risk',
|
||||
title: `${slow.role} takes longest to fill`,
|
||||
body: `${slow.avgDays} days on average against a median of ${efficiency.median}. ${slow.count} hire${slow.count === 1 ? '' : 's'} on that record.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const unrated = positions.reduce((n, p) => n + (p.count - p.rated), 0);
|
||||
if (unrated > 0) {
|
||||
out.push({
|
||||
tone: 'info',
|
||||
title: `${unrated} hire${unrated === 1 ? '' : 's'} ${unrated === 1 ? 'has' : 'have'} no client review`,
|
||||
body: 'Quality of hire is measured on the AI score alone until a review lands. Chasing these closes the loop on outcomes.',
|
||||
});
|
||||
}
|
||||
|
||||
if (funnel.conversion) {
|
||||
out.push({
|
||||
tone: 'info',
|
||||
title: `${funnel.conversion}% of applicants are hired`,
|
||||
body: `${funnel.stages[4].count} of ${funnel.stages[0].count} applications reached a hire.`,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -432,9 +432,12 @@ const HANDLERS = {
|
||||
* Reached only from the confirmation step, after the position has been read
|
||||
* back and the user has chosen to create it.
|
||||
*/
|
||||
create_position: ({ draft }) => ({
|
||||
/* `status` is the one the conversation's confirmation step chose — draft or
|
||||
active — and is passed to the payload builder the form already uses, so
|
||||
both routes write the same record with the same defaults. */
|
||||
create_position: ({ draft, status }) => ({
|
||||
type: 'create_position',
|
||||
data: toPositionPayload(draft || {}),
|
||||
data: toPositionPayload(draft || {}, status ? { status } : undefined),
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,30 +9,132 @@ import { parseSkill } from './registry';
|
||||
* writers with two shapes would be a second skill system by accident.
|
||||
*/
|
||||
|
||||
/** The starting definition offered to an author, in the existing format. */
|
||||
export const skillTemplate = ({ id = '', name = '', description = '', pages = [] } = {}) => `---
|
||||
id: ${id || 'my-skill'}
|
||||
name: ${name || 'My Skill'}
|
||||
description: ${description || 'What this skill helps Owliver do.'}
|
||||
pages:
|
||||
${(pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n')}
|
||||
status: active
|
||||
triggers:
|
||||
- ${(name || 'my skill').toLowerCase()}
|
||||
---
|
||||
/**
|
||||
* The starting definitions offered to an author.
|
||||
*
|
||||
* Two templates, because there are two jobs and one of them was being learned
|
||||
* from the other's example. A UI skill's first draft declares a section; an
|
||||
* Owliver skill's declares triggers and the shapes of an answer. Both are the
|
||||
* same format, read by the same parser — what differs is which half of it the
|
||||
* author is being handed.
|
||||
*/
|
||||
|
||||
# ${name || 'My Skill'}
|
||||
const frontMatter = ({ id, name, description, pages, fallback }) => [
|
||||
`id: ${id || fallback.id}`,
|
||||
`name: ${name || fallback.name}`,
|
||||
`description: ${description || fallback.description}`,
|
||||
'pages:',
|
||||
(pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n'),
|
||||
'status: active',
|
||||
].join('\n');
|
||||
|
||||
/** A definition that draws a section on the pages it names. */
|
||||
export const uiSkillTemplate = ({
|
||||
id = '', name = '', description = '', pages = [],
|
||||
type = 'flow', placement = '', source = 'position.activity', periods = [],
|
||||
} = {}) => `---
|
||||
${frontMatter({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
pages,
|
||||
fallback: {
|
||||
id: 'my-ui-skill',
|
||||
name: 'My UI Skill',
|
||||
description: 'What this skill adds to the page.',
|
||||
},
|
||||
})}
|
||||
ui:
|
||||
type: ${type}
|
||||
${placement ? ` placement: ${placement}\n` : ''} title: ${name || 'My UI Skill'}
|
||||
source: ${source}
|
||||
${periods.length ? ` periods:\n${periods.map((p) => ` - ${p}`).join('\n')}\n` : ''}---
|
||||
|
||||
# ${name || 'My UI Skill'}
|
||||
|
||||
## Purpose
|
||||
|
||||
Describe what Owliver should help with on these pages.
|
||||
Describe what this section shows, and why it belongs on these pages.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Describe one thing the skill can do.
|
||||
- Describe one thing the section reports.
|
||||
- Add more as needed.
|
||||
`;
|
||||
|
||||
/** A definition that teaches Owliver what it can be asked for. */
|
||||
export const owliverSkillTemplate = ({
|
||||
id = '', name = '', description = '', pages = [],
|
||||
triggers = [], suggestions = [], capabilities = [], source = '', periods = [],
|
||||
} = {}) => {
|
||||
const label = name || 'My Owliver Skill';
|
||||
const lines = [`---
|
||||
${frontMatter({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
pages,
|
||||
fallback: {
|
||||
id: 'my-owliver-skill',
|
||||
name: label,
|
||||
description: 'What this skill helps Owliver answer.',
|
||||
},
|
||||
})}`];
|
||||
|
||||
lines.push('triggers:');
|
||||
lines.push((triggers.length ? triggers : [label.toLowerCase()]).map((t) => ` - ${t}`).join('\n'));
|
||||
|
||||
lines.push('owliver:');
|
||||
lines.push(' enabled: true');
|
||||
|
||||
if (suggestions.length) {
|
||||
lines.push(' suggestions:');
|
||||
lines.push(suggestions.map((s) => ` - ${s}`).join('\n'));
|
||||
}
|
||||
|
||||
if (capabilities.length) {
|
||||
lines.push(' capabilities:');
|
||||
lines.push(capabilities.map((c) => ` - ${c}`).join('\n'));
|
||||
|
||||
if (source) {
|
||||
lines.push(' responses:');
|
||||
for (const capability of capabilities) {
|
||||
lines.push(` ${capability}:`);
|
||||
lines.push(` source: ${source}`);
|
||||
if (periods.length) {
|
||||
lines.push(' periods:');
|
||||
lines.push(periods.map((p) => ` - ${p}`).join('\n'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`---
|
||||
|
||||
# ${label}
|
||||
|
||||
## Purpose
|
||||
|
||||
Describe what Owliver should be able to answer on these pages.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Describe one thing Owliver can be asked for.
|
||||
- Add more as needed.
|
||||
`);
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* The template the Add Skill dialog offers.
|
||||
*
|
||||
* That dialog opens from Owliver's own header, mid-conversation, so what it
|
||||
* hands the author is an Owliver skill. Kept under its original name because
|
||||
* it is what the dialog already imports.
|
||||
*/
|
||||
export const skillTemplate = owliverSkillTemplate;
|
||||
|
||||
/** Parses a stored entry, tolerating the bare-string form. */
|
||||
const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry?.raw ?? '');
|
||||
|
||||
|
||||
516
src/lib/skills/dataResolver.js
Normal file
516
src/lib/skills/dataResolver.js
Normal file
@@ -0,0 +1,516 @@
|
||||
import { SUPPORTED_PERIODS, periodLabel } from './surfaces';
|
||||
import { CRITERIA_LABELS } from '@/lib/positionModel';
|
||||
import { poolFor } from '@/lib/workforce';
|
||||
import { candidateRoute } from './workforceFlow';
|
||||
|
||||
/**
|
||||
* The one place a skill's declared data source becomes real data.
|
||||
*
|
||||
* A section says `data.source: position.activity`. It does not say where that
|
||||
* comes from, cannot reach a store, and cannot name a field. This module owns
|
||||
* the mapping — source id in, normalized reading out — which is what keeps a
|
||||
* declarative file from turning into a query language.
|
||||
*
|
||||
* Two rules hold throughout:
|
||||
*
|
||||
* - **Real records only.** Every figure is counted from the collections the
|
||||
* application already holds. Nothing is generated to make a section look
|
||||
* populated; a source with nothing to report returns `empty: true` and the
|
||||
* renderer says so.
|
||||
* - **Time is computed, never stored.** "Today" is a window over the record
|
||||
* timestamps, evaluated against the current date at read time. No date is
|
||||
* written into a definition and none is hard-coded here.
|
||||
*/
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Midnight at the start of the given day, in local time. */
|
||||
const startOfDay = (date) => {
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
/**
|
||||
* The window a period covers, as `[from, to)`.
|
||||
*
|
||||
* Weeks run Monday to Monday and months from the 1st, which is how the rest of
|
||||
* the product reports them.
|
||||
*/
|
||||
export function periodRange(period, now = new Date()) {
|
||||
const today = startOfDay(now);
|
||||
|
||||
switch (period) {
|
||||
case 'today':
|
||||
return { from: today, to: new Date(today.getTime() + DAY) };
|
||||
case 'yesterday':
|
||||
return { from: new Date(today.getTime() - DAY), to: today };
|
||||
case 'last-7-days':
|
||||
return { from: new Date(today.getTime() - 7 * DAY), to: new Date(today.getTime() + DAY) };
|
||||
case 'last-week': {
|
||||
/* The calendar week before the one we are in. */
|
||||
const weekday = (today.getDay() + 6) % 7;
|
||||
const thisMonday = new Date(today.getTime() - weekday * DAY);
|
||||
return { from: new Date(thisMonday.getTime() - 7 * DAY), to: thisMonday };
|
||||
}
|
||||
case 'this-month': {
|
||||
const from = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return { from, to: new Date(today.getFullYear(), today.getMonth() + 1, 1) };
|
||||
}
|
||||
case 'previous-month': {
|
||||
const from = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
return { from, to: new Date(today.getFullYear(), today.getMonth(), 1) };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Records whose `created_date` falls inside the window. */
|
||||
const inPeriod = (records, period, now) => {
|
||||
const range = periodRange(period, now);
|
||||
if (!range) return [];
|
||||
return records.filter((record) => {
|
||||
const at = new Date(record.created_date || record.updated_date || 0).getTime();
|
||||
return at >= range.from.getTime() && at < range.to.getTime();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a matched candidate fits, in one line.
|
||||
*
|
||||
* Met requirements first, then the gaps, then what is known about availability —
|
||||
* the same order and the same words the panel's match cards use, because they
|
||||
* are read from the same row. A gap is stated as a gap: a line that only listed
|
||||
* strengths would make every candidate look like a strong match.
|
||||
*/
|
||||
function matchDetail(row) {
|
||||
if (!row.match) return 'Nothing on this position to score this candidate against';
|
||||
|
||||
const parts = [
|
||||
...row.match.met.map((line) => `✓ ${line.name} — ${line.heldLabel}`),
|
||||
...row.match.gaps.map((line) => `⚠ ${line.name} — ${line.heldLabel}, needs ${line.requiredLabel}`),
|
||||
];
|
||||
|
||||
if (row.availability?.known === false) parts.push('Availability not on file');
|
||||
else if (row.availability) {
|
||||
parts.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** The application stages this product counts, in the order they happen. */
|
||||
const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
|
||||
|
||||
const atOrBeyond = (applications, stage) => {
|
||||
const from = STAGE_ORDER.indexOf(stage);
|
||||
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
|
||||
};
|
||||
|
||||
/** Applications counted by period — the reading behind an activity section. */
|
||||
function activityOverTime(applications, periods, now) {
|
||||
const wanted = periods.length ? periods : ['today', 'yesterday', 'last-week'];
|
||||
|
||||
const steps = wanted
|
||||
.filter((period) => SUPPORTED_PERIODS.includes(period))
|
||||
.map((period) => {
|
||||
const records = inPeriod(applications, period, now);
|
||||
return {
|
||||
id: period,
|
||||
label: periodLabel(period),
|
||||
value: records.length,
|
||||
detail: records.length
|
||||
? `${records.length} application${records.length === 1 ? '' : 's'}`
|
||||
: 'No applications',
|
||||
records,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
steps,
|
||||
total: applications.length,
|
||||
empty: steps.every((s) => s.value === 0),
|
||||
emptyNote: applications.length
|
||||
? 'No applications in these periods.'
|
||||
: 'No applications on this position yet.',
|
||||
};
|
||||
}
|
||||
|
||||
/** The hiring funnel for a set of applications. */
|
||||
function pipelineOf(applications) {
|
||||
const steps = [
|
||||
{ id: 'applied', label: 'Applied', value: applications.length },
|
||||
{ id: 'screened', label: 'Screened', value: atOrBeyond(applications, 'ai_screened').length },
|
||||
{ id: 'shortlisted', label: 'Shortlisted', value: atOrBeyond(applications, 'shortlisted').length },
|
||||
{ id: 'interview', label: 'Interview', value: atOrBeyond(applications, 'interview').length },
|
||||
{ id: 'hired', label: 'Hired', value: applications.filter((a) => a.status === 'hired').length },
|
||||
];
|
||||
|
||||
return {
|
||||
steps,
|
||||
total: applications.length,
|
||||
empty: applications.length === 0,
|
||||
emptyNote: 'No applications on this position yet.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every source the vocabulary offers, and how each is read.
|
||||
*
|
||||
* Keyed by the same ids `surfaces.js` publishes, so the list an author can
|
||||
* choose from and the list that can actually be resolved are the same list.
|
||||
*/
|
||||
const RESOLVERS = {
|
||||
'position.activity': ({ position, applications }, section, now) => {
|
||||
const mine = applications.filter((a) => a.job_posting_id === position?.id);
|
||||
return activityOverTime(mine, section.periods, now);
|
||||
},
|
||||
|
||||
'position.pipeline': ({ position, applications }) =>
|
||||
pipelineOf(applications.filter((a) => a.job_posting_id === position?.id)),
|
||||
|
||||
'position.candidates': ({ position, applications }, section) => {
|
||||
const mine = applications
|
||||
.filter((a) => a.job_posting_id === position?.id)
|
||||
.sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0))
|
||||
.slice(0, section.limit || 5);
|
||||
|
||||
return {
|
||||
items: mine.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.applicant_name,
|
||||
detail: [
|
||||
a.years_experience != null ? `${a.years_experience} yrs experience` : null,
|
||||
String(a.status || '').replace(/_/g, ' '),
|
||||
].filter(Boolean).join(' · '),
|
||||
value: a.ai_score > 0 ? a.ai_score : null,
|
||||
to: `/admin/candidates/${a.id}`,
|
||||
})),
|
||||
columns: [
|
||||
{ key: 'title', label: 'Candidate' },
|
||||
{ key: 'detail', label: 'Status' },
|
||||
{ key: 'value', label: 'Score', align: 'right' },
|
||||
],
|
||||
empty: mine.length === 0,
|
||||
emptyNote: 'No candidates have applied to this position yet.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The candidate pool, scored against this position.
|
||||
*
|
||||
* Every figure here comes from `poolFor` — the same engine behind the
|
||||
* workforce conversation and the position page's own recommendations. This
|
||||
* resolver ranks nothing and scores nothing; it reads the rows the engine
|
||||
* returned and states them, including *why* each one fits, in the engine's own
|
||||
* terms. A candidate the position gives nothing to measure against is reported
|
||||
* as unscored rather than given a number.
|
||||
*
|
||||
* `to` is the candidate's real record, carrying the position they were being
|
||||
* considered for — the same route the panel's match cards open.
|
||||
*/
|
||||
'position.matches': ({ position, ...context }, section) => {
|
||||
if (!position) {
|
||||
return { items: [], empty: true, emptyNote: 'No position to match candidates against.' };
|
||||
}
|
||||
|
||||
const rows = poolFor(position, {
|
||||
profiles: context.profiles || context.workerProfiles || [],
|
||||
applications: context.applications || [],
|
||||
assignments: context.assignments || [],
|
||||
courses: context.courses || [],
|
||||
staff: context.staff || [],
|
||||
}).slice(0, section.limit || 5);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
id: row.candidateId,
|
||||
title: row.name,
|
||||
/* The met requirements and the gaps, as the engine stated them. */
|
||||
detail: matchDetail(row),
|
||||
value: row.scored ? row.score : null,
|
||||
to: candidateRoute(row, context.applications || [], position),
|
||||
})),
|
||||
columns: [
|
||||
{ key: 'title', label: 'Candidate' },
|
||||
{ key: 'detail', label: 'Why' },
|
||||
{ key: 'value', label: 'Match', align: 'right' },
|
||||
],
|
||||
empty: rows.length === 0,
|
||||
emptyNote: 'No candidates on file to score against this position yet.',
|
||||
};
|
||||
},
|
||||
|
||||
'position.requirements': ({ position }) => {
|
||||
const items = [
|
||||
position?.min_experience_years
|
||||
? { id: 'experience', title: 'Minimum experience', detail: `${position.min_experience_years} years` }
|
||||
: null,
|
||||
position?.english_required
|
||||
? { id: 'english', title: 'English level', detail: String(position.english_required) }
|
||||
: null,
|
||||
...(position?.certifications_required || []).map((c) => ({
|
||||
id: `cert-${c}`, title: 'Certification', detail: c,
|
||||
})),
|
||||
...(position?.skill_requirements || []).map((r) => ({
|
||||
id: `skill-${r.skill_id}`, title: r.skill_id, detail: `${r.level} · weight ${r.weight}`,
|
||||
})),
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
items,
|
||||
columns: [{ key: 'title', label: 'Requirement' }, { key: 'detail', label: 'Needs' }],
|
||||
empty: items.length === 0,
|
||||
emptyNote: 'This position states no requirements.',
|
||||
};
|
||||
},
|
||||
|
||||
'candidate.readiness': ({ candidate }) => {
|
||||
const breakdown = candidate?.score_breakdown || {};
|
||||
const items = Object.entries(breakdown)
|
||||
.filter(([, value]) => Number(value) > 0)
|
||||
.map(([key, value]) => ({
|
||||
id: key,
|
||||
title: key.replace(/_/g, ' '),
|
||||
value: Number(value),
|
||||
max: 100,
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
columns: [{ key: 'title', label: 'Dimension' }, { key: 'value', label: 'Score', align: 'right' }],
|
||||
empty: items.length === 0,
|
||||
emptyNote: 'This candidate has not been screened, so there are no dimensions to show.',
|
||||
};
|
||||
},
|
||||
|
||||
'candidate.activity': ({ candidate, interviews = [] }) => {
|
||||
const events = [
|
||||
candidate?.created_date && {
|
||||
id: 'applied', title: 'Applied', detail: candidate.job_title, at: candidate.created_date,
|
||||
},
|
||||
candidate?.ai_score > 0 && {
|
||||
id: 'screened', title: 'AI screened', detail: `Scored ${candidate.ai_score}`, at: candidate.updated_date,
|
||||
},
|
||||
...interviews
|
||||
.filter((i) => i.application_id === candidate?.id)
|
||||
.map((i) => ({ id: i.id, title: 'Interview', detail: i.status, at: i.created_date })),
|
||||
candidate?.status === 'hired' && {
|
||||
id: 'hired', title: 'Hired', detail: candidate.job_title, at: candidate.updated_date,
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
items: events,
|
||||
empty: events.length === 0,
|
||||
emptyNote: 'Nothing has happened on this record yet.',
|
||||
};
|
||||
},
|
||||
|
||||
'candidates.pipeline': ({ applications }) => pipelineOf(applications),
|
||||
|
||||
'candidates.activity': ({ applications }, section, now) =>
|
||||
activityOverTime(applications, section.periods, now),
|
||||
|
||||
'positions.demand': ({ positions = [], applications }, section) => {
|
||||
const items = positions
|
||||
.filter((p) => p.status === 'active')
|
||||
.slice(0, section.limit || 5)
|
||||
.map((p) => {
|
||||
const mine = applications.filter((a) => a.job_posting_id === p.id);
|
||||
return {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
detail: [p.company, p.location].filter(Boolean).join(' · '),
|
||||
value: mine.length,
|
||||
to: `/admin/positions/${p.id}`,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Position' },
|
||||
{ key: 'detail', label: 'Client' },
|
||||
{ key: 'value', label: 'Applicants', align: 'right' },
|
||||
],
|
||||
empty: items.length === 0,
|
||||
emptyNote: 'No open positions.',
|
||||
};
|
||||
},
|
||||
|
||||
'workforce.training': ({ trainingPaths = [] }, section) => {
|
||||
const items = trainingPaths.slice(0, section.limit || 10).map(({ definition, state }) => ({
|
||||
id: definition.id,
|
||||
title: state.name,
|
||||
detail: state.verifiedLabel,
|
||||
value: state.totalModules ? Math.round((state.totalCompleted / state.totalModules) * 100) : 0,
|
||||
max: 100,
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Path' },
|
||||
{ key: 'detail', label: 'Level' },
|
||||
{ key: 'value', label: 'Complete', align: 'right' },
|
||||
],
|
||||
empty: items.length === 0,
|
||||
emptyNote: 'No training paths are registered.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The vetting weights on the position in context.
|
||||
*
|
||||
* The same reading whether that position is a saved record or the draft being
|
||||
* typed into Create Position — both carry `vetting_criteria`, so a section
|
||||
* declared once reports the specification as it stands on either surface.
|
||||
*/
|
||||
'position.vetting': ({ position }) => {
|
||||
const criteria = position?.vetting_criteria || {};
|
||||
const steps = Object.entries(criteria).map(([key, value]) => ({
|
||||
id: key,
|
||||
label: CRITERIA_LABELS[key] || key.replace(/_/g, ' '),
|
||||
title: CRITERIA_LABELS[key] || key.replace(/_/g, ' '),
|
||||
value: Number(value) || 0,
|
||||
max: 100,
|
||||
detail: `${Number(value) || 0}% of the screening score`,
|
||||
}));
|
||||
|
||||
const total = steps.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
return {
|
||||
steps,
|
||||
items: steps,
|
||||
total,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Criterion' },
|
||||
{ key: 'value', label: 'Weight', align: 'right' },
|
||||
],
|
||||
empty: steps.length === 0,
|
||||
emptyNote: 'This position states no vetting weights.',
|
||||
};
|
||||
},
|
||||
|
||||
/** Everyone hired, most recent first — the record rather than the analysis. */
|
||||
'hires.recent': ({ staff = [], applications = [], positions = [] }, section) => {
|
||||
const items = [...staff]
|
||||
.sort((a, b) => new Date(b.hire_date || b.created_date || 0) - new Date(a.hire_date || a.created_date || 0))
|
||||
.slice(0, section.limit || 10)
|
||||
.map((s) => {
|
||||
const app = applications.find((a) => a.id === s.application_id);
|
||||
const posting = positions.find((p) => p.id === s.job_posting_id);
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.name,
|
||||
detail: [s.role || posting?.title, posting?.role_category || s.department]
|
||||
.filter(Boolean).join(' · '),
|
||||
value: s.ai_score || app?.ai_score || s.score || null,
|
||||
at: s.hire_date || s.created_date || null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Hire' },
|
||||
{ key: 'detail', label: 'Role' },
|
||||
{ key: 'value', label: 'Score', align: 'right' },
|
||||
],
|
||||
empty: items.length === 0,
|
||||
emptyNote: 'Nobody has been hired yet.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The four figures that answer "how is our hiring performing" — counted from
|
||||
* applications and the staff records they became, never stored.
|
||||
*/
|
||||
'hires.performance': ({ applications = [], staff = [] }) => {
|
||||
const hired = applications.filter((a) => a.status === 'hired');
|
||||
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
|
||||
const days = hired
|
||||
.map((a) => Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 86400000))
|
||||
.filter((n) => Number.isFinite(n) && n >= 0);
|
||||
|
||||
const mean = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
|
||||
const total = staff.length || hired.length;
|
||||
|
||||
const steps = [
|
||||
{ id: 'hires', label: 'Total hires', title: 'Total hires', value: total },
|
||||
{ id: 'speed', label: 'Avg days to hire', title: 'Avg days to hire', value: mean(days) },
|
||||
{ id: 'quality', label: 'Quality of hire', title: 'Quality of hire', value: mean(scores) },
|
||||
{
|
||||
id: 'conversion',
|
||||
label: 'Conversion rate',
|
||||
title: 'Conversion rate',
|
||||
value: applications.length ? Math.round((hired.length / applications.length) * 100) : 0,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
steps,
|
||||
items: steps,
|
||||
total,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Measure' },
|
||||
{ key: 'value', label: 'Value', align: 'right' },
|
||||
],
|
||||
empty: applications.length === 0 && total === 0,
|
||||
emptyNote: 'No hiring activity has been recorded yet.',
|
||||
};
|
||||
},
|
||||
|
||||
/** The workspace audit trail, most recent first. */
|
||||
'activity.events': ({ activity = [] }, section) => {
|
||||
const items = [...activity]
|
||||
.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0))
|
||||
.slice(0, section.limit || 10)
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
title: String(event.event_type || 'event').replace(/_/g, ' '),
|
||||
detail: [event.user_name, event.details].filter(Boolean).join(' — '),
|
||||
at: event.created_date,
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Event' },
|
||||
{ key: 'detail', label: 'Who' },
|
||||
],
|
||||
empty: items.length === 0,
|
||||
emptyNote: 'No activity has been recorded yet.',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* One section's data, read from the application's own records.
|
||||
*
|
||||
* `context` is what the page supplies — the position or candidate being looked
|
||||
* at, plus the collections it already loaded. A source whose required context
|
||||
* is missing returns `unavailable`, which the renderer states rather than
|
||||
* filling in.
|
||||
*/
|
||||
export function resolveSkillData(section, context = {}, now = new Date()) {
|
||||
const resolve = RESOLVERS[section?.source];
|
||||
if (!resolve) return { unavailable: true, emptyNote: `No resolver for ${section?.source}.` };
|
||||
|
||||
if (section.context === 'positionId' && !context.position) {
|
||||
return { unavailable: true, emptyNote: 'This section needs a position to read.' };
|
||||
}
|
||||
if (section.context === 'candidateId' && !context.candidate) {
|
||||
return { unavailable: true, emptyNote: 'This section needs a candidate to read.' };
|
||||
}
|
||||
|
||||
try {
|
||||
return resolve(context, section, now);
|
||||
} catch {
|
||||
/* A resolver that throws is a bug in this file, not in the definition —
|
||||
the section reports it has nothing rather than taking the page down. */
|
||||
return { unavailable: true, emptyNote: 'This section could not be read.' };
|
||||
}
|
||||
}
|
||||
247
src/lib/skills/owliverConfig.js
Normal file
247
src/lib/skills/owliverConfig.js
Normal file
@@ -0,0 +1,247 @@
|
||||
import { normalizeSection } from './uiConfig';
|
||||
import {
|
||||
SUPPORTED_OWLIVER_CAPABILITIES, SUPPORTED_SECTION_TYPES, owliverCapabilityFor,
|
||||
} from './surfaces';
|
||||
|
||||
/**
|
||||
* The `owliver:` block of a skill definition, checked and normalized.
|
||||
*
|
||||
* The same file that extends a page can extend the panel beside it. `ui:` says
|
||||
* what the page renders; `owliver:` says what can be *asked for*, and both name
|
||||
* the same data source — so the card and the answer are two readings of one
|
||||
* declaration rather than two definitions that have to be kept in step.
|
||||
*
|
||||
* owliver:
|
||||
* enabled: true
|
||||
* suggestions:
|
||||
* - Show hiring activity
|
||||
* - Summarize hiring activity
|
||||
* capabilities:
|
||||
* - summary
|
||||
* - flow
|
||||
* responses:
|
||||
* flow:
|
||||
* title: Hiring Activity Flow
|
||||
* source: position.activity
|
||||
* steps: [today, yesterday, last-week]
|
||||
*
|
||||
* Three properties hold, and each one is a rule the rest of the system relies
|
||||
* on:
|
||||
*
|
||||
* - **A response is a section.** It is normalized by the same function the
|
||||
* page's `ui:` sections go through, so a capability resolves to the same
|
||||
* record, the same data source and the same renderer. There is no second
|
||||
* shape for "the chat version".
|
||||
* - **The block is optional.** A definition with no `owliver:` normalizes to
|
||||
* a disabled record and behaves exactly as it did before this existed.
|
||||
* - **Nothing unknown survives.** Capabilities, sources, periods and shapes
|
||||
* are checked against the closed vocabulary in `surfaces.js`; an
|
||||
* unrecognised value is a named error rather than a dropped key.
|
||||
*/
|
||||
|
||||
/** What a definition with no `owliver:` block gets. */
|
||||
export const NO_OWLIVER = Object.freeze({
|
||||
enabled: false,
|
||||
suggestions: [],
|
||||
capabilities: [],
|
||||
responses: {},
|
||||
});
|
||||
|
||||
/** The section type a capability is drawn with — `summary` draws nothing. */
|
||||
const shapeOf = (capability) => owliverCapabilityFor(capability)?.shape || null;
|
||||
|
||||
/**
|
||||
* The reading a response inherits when it does not name one.
|
||||
*
|
||||
* A skill that already declares a `ui:` section has stated its source once;
|
||||
* making it state it again for the panel would be the format asking the author
|
||||
* to repeat themselves, and would let the two drift apart. The first declared
|
||||
* section wins, in declaration order.
|
||||
*/
|
||||
function inheritedSection(ui) {
|
||||
for (const page of Object.values(ui || {})) {
|
||||
const section = (page.sections || [])[0];
|
||||
if (section) return section;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One suggestion, in either the plain-string or the mapping form. */
|
||||
function normalizeSuggestion(raw, { errors, capabilities, index }) {
|
||||
const where = `owliver.suggestions[${index}]`;
|
||||
|
||||
if (typeof raw === 'string' || typeof raw === 'number') {
|
||||
const label = String(raw).trim();
|
||||
if (!label) {
|
||||
errors.push(`${where}: a suggestion needs text.`);
|
||||
return null;
|
||||
}
|
||||
return { label, prompt: label, capability: null };
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
errors.push(`${where}: a suggestion must be a line of text, or a mapping of options.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = String(raw.label ?? raw.prompt ?? '').trim();
|
||||
if (!label) {
|
||||
errors.push(`${where}: a suggestion needs a \`label\`.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* A suggestion may say which capability it asks for. That is what makes a
|
||||
chip exact — the words are the author's, and the answer is not left to be
|
||||
re-derived from them. */
|
||||
const capability = raw.capability == null ? null : String(raw.capability).trim();
|
||||
if (capability && !SUPPORTED_OWLIVER_CAPABILITIES.includes(capability)) {
|
||||
errors.push(
|
||||
`Unsupported Owliver capability: ${capability}. Supported capabilities: ${SUPPORTED_OWLIVER_CAPABILITIES.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (capability && capabilities.length && !capabilities.includes(capability)) {
|
||||
errors.push(`${where}: \`${capability}\` is not listed under \`owliver.capabilities\`.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
prompt: String(raw.prompt ?? raw.label).trim(),
|
||||
capability: capability || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole `owliver:` block, normalized.
|
||||
*
|
||||
* Returns `{ owliver, errors }`. As with `ui:`, what validates is kept and what
|
||||
* does not is reported: a definition with one bad response still registers its
|
||||
* good ones, and the author is told why the other was refused.
|
||||
*/
|
||||
export function normalizeSkillOwliver(raw, { ui = {}, skillId = '', skillName = '' } = {}) {
|
||||
const errors = [];
|
||||
|
||||
if (raw == null) return { owliver: NO_OWLIVER, errors };
|
||||
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return { owliver: NO_OWLIVER, errors: ['`owliver` must be a mapping of options.'] };
|
||||
}
|
||||
|
||||
/* Declaring the block is the opt-in; `enabled: false` is how it is switched
|
||||
off without deleting what was written. */
|
||||
const enabled = raw.enabled !== false;
|
||||
|
||||
/* Capabilities may be listed, or left to be read off the responses — which is
|
||||
what a definition that writes one response and nothing else means. */
|
||||
const declared = Array.isArray(raw.capabilities)
|
||||
? raw.capabilities.map((c) => String(c).trim()).filter(Boolean)
|
||||
: [];
|
||||
const responsesRaw = raw.responses && typeof raw.responses === 'object' && !Array.isArray(raw.responses)
|
||||
? raw.responses
|
||||
: {};
|
||||
if (raw.responses != null && !Object.keys(responsesRaw).length) {
|
||||
errors.push('`owliver.responses` must be a mapping of capability names to responses.');
|
||||
}
|
||||
|
||||
const capabilities = [];
|
||||
for (const capability of [...declared, ...Object.keys(responsesRaw)]) {
|
||||
if (!SUPPORTED_OWLIVER_CAPABILITIES.includes(capability)) {
|
||||
errors.push(
|
||||
`Unsupported Owliver capability: ${capability}. Supported capabilities: ${SUPPORTED_OWLIVER_CAPABILITIES.join(', ')}.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!capabilities.includes(capability)) capabilities.push(capability);
|
||||
}
|
||||
|
||||
/* Suggestions are read after capabilities, so one naming a capability can be
|
||||
checked against what the skill actually offers. */
|
||||
const suggestionsRaw = raw.suggestions == null ? [] : raw.suggestions;
|
||||
let suggestions = [];
|
||||
if (!Array.isArray(suggestionsRaw)) {
|
||||
errors.push('`owliver.suggestions` must be a list.');
|
||||
} else {
|
||||
suggestions = suggestionsRaw
|
||||
.map((entry, index) => normalizeSuggestion(entry, { errors, capabilities, index }))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/* Every capability resolves to a section — declared, or inherited from the
|
||||
page UI this skill already configures. A capability that can name no
|
||||
reading is refused: it would otherwise register as something Owliver
|
||||
offers and then have nothing to answer with. */
|
||||
const inherited = inheritedSection(ui);
|
||||
const responses = {};
|
||||
const seen = new Set();
|
||||
|
||||
for (const capability of capabilities) {
|
||||
const declaredResponse = responsesRaw[capability];
|
||||
if (declaredResponse != null
|
||||
&& (typeof declaredResponse !== 'object' || Array.isArray(declaredResponse))) {
|
||||
errors.push(`owliver.responses.${capability}: expected a mapping of options.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = declaredResponse || {};
|
||||
/* `steps:` is what a flow reads like in a definition; `periods:` is what
|
||||
the rest of the format calls the same list. */
|
||||
const periods = response.steps ?? response.periods ?? (declaredResponse ? null : inherited?.periods);
|
||||
const source = response.data?.source ?? response.source ?? inherited?.source;
|
||||
|
||||
if (!source) {
|
||||
errors.push(
|
||||
`owliver.responses.${capability}: a response needs a \`source\`, or a \`ui:\` section to read from.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const section = normalizeSection(
|
||||
{
|
||||
id: `${skillId || 'skill'}-${capability}`,
|
||||
type: capability,
|
||||
title: response.title ?? null,
|
||||
description: response.description ?? null,
|
||||
source,
|
||||
periods: periods ?? [],
|
||||
limit: response.limit ?? inherited?.limit ?? null,
|
||||
/* Editing is a property of the capability, and is inherited from the
|
||||
page section the same way the source is: a definition that made its
|
||||
card adjustable meant the answer to be adjustable too, unless it
|
||||
says otherwise. */
|
||||
editable: response.editable ?? (declaredResponse ? false : inherited?.editable) ?? false,
|
||||
},
|
||||
{
|
||||
errors,
|
||||
seen,
|
||||
where: `owliver.responses.${capability}`,
|
||||
fallbackId: skillId,
|
||||
placement: false,
|
||||
types: [...SUPPORTED_SECTION_TYPES, 'summary'],
|
||||
shapeFor: shapeOf,
|
||||
}
|
||||
);
|
||||
|
||||
if (!section) continue;
|
||||
|
||||
responses[capability] = {
|
||||
...section,
|
||||
capability,
|
||||
/* The shape the answer is drawn with, resolved once here so no consumer
|
||||
has to know that `summary` is the one capability with no component. */
|
||||
shape: shapeOf(capability),
|
||||
title: section.title || skillName || null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
owliver: {
|
||||
enabled,
|
||||
suggestions,
|
||||
/* Only capabilities that resolved to a reading are offered. */
|
||||
capabilities: capabilities.filter((c) => responses[c]),
|
||||
responses,
|
||||
},
|
||||
errors,
|
||||
};
|
||||
}
|
||||
416
src/lib/skills/owliverResolver.js
Normal file
416
src/lib/skills/owliverResolver.js
Normal file
@@ -0,0 +1,416 @@
|
||||
import { OWLIVER_CAPABILITIES, dataSourceLabel, owliverCapabilityFor } from './surfaces';
|
||||
import { skillsForContext } from './registry';
|
||||
import { resolveSkillData } from './dataResolver';
|
||||
import { resolvePosition } from './workforceFlow';
|
||||
|
||||
/**
|
||||
* The Owliver half of a skill definition, resolved.
|
||||
*
|
||||
* The page reads a definition through `SkillSurface`; this is the other reader.
|
||||
* Given the page you are on and what you asked, it answers three questions and
|
||||
* nothing else:
|
||||
*
|
||||
* 1. which registered skills apply here,
|
||||
* 2. which of them you are asking for, and which capability,
|
||||
* 3. what the answer is, read from the source the definition names.
|
||||
*
|
||||
* The rule that makes this an architecture rather than a lookup table: **no
|
||||
* skill is named here.** There is no `if (skill.id === …)`, no prompt string
|
||||
* matched against a constant, and no component per skill. A definition is
|
||||
* matched by what it declares — its triggers, its suggestions, its name, its
|
||||
* description — and answered by the shape it declares. A skill written after
|
||||
* this file was last edited resolves exactly as well as one written before it.
|
||||
*/
|
||||
|
||||
/* ── Which skills apply ─────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The Owliver-enabled skills registered for a page context.
|
||||
*
|
||||
* `skillsForContext` is the single answer to "what is attached here", already
|
||||
* honouring `pages:`, `status:` and the account's switched-off list — so the
|
||||
* page's sections and the panel's answers are filtered by one rule, and
|
||||
* switching a skill off in Settings removes both at once.
|
||||
*/
|
||||
export function owliverSkillsForContext(contextId, disabled = [], customSources = []) {
|
||||
return skillsForContext(contextId, disabled, customSources)
|
||||
.filter((skill) => skill.owliver?.enabled && skill.owliver.capabilities.length > 0);
|
||||
}
|
||||
|
||||
/* ── Suggestions ────────────────────────────────────────────────────────── */
|
||||
|
||||
/** How many chips one skill may contribute, and how many all of them may. */
|
||||
const PER_SKILL = 3;
|
||||
const TOTAL = 4;
|
||||
|
||||
/**
|
||||
* The chips a page's skills offer.
|
||||
*
|
||||
* Capped deliberately. A workspace with six skills attached would otherwise
|
||||
* bury the page's own suggestions under twenty of them, and a suggestion nobody
|
||||
* can find is not a suggestion. A skill contributes its first few, and the set
|
||||
* as a whole stays within what the composer can show without becoming a menu.
|
||||
*
|
||||
* A suggestion naming a capability the skill does not offer is dropped rather
|
||||
* than shown and then refused.
|
||||
*/
|
||||
export function owliverSuggestions(contextId, disabled = [], customSources = [], context = {}) {
|
||||
const chips = [];
|
||||
|
||||
for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
|
||||
/**
|
||||
* Can this skill answer without asking a question back?
|
||||
*
|
||||
* A capability reading one position cannot say anything until it knows
|
||||
* which — so on a page with nothing selected, clicking it produces a
|
||||
* question rather than an answer. Those suggestions are still offered, but
|
||||
* they are marked so the panel can rank them behind the ones that will
|
||||
* actually answer. That is a property of the declared source, not of any
|
||||
* particular skill: a definition added tomorrow reading one record is
|
||||
* ranked the same way.
|
||||
*/
|
||||
const needs = (capability) => skill.owliver.responses[capability]?.context || null;
|
||||
const met = (need) => !need
|
||||
|| (need === 'positionId' && Boolean(context.position))
|
||||
|| (need === 'candidateId' && Boolean(context.candidate));
|
||||
|
||||
const offered = skill.owliver.suggestions
|
||||
.filter((s) => !s.capability || skill.owliver.capabilities.includes(s.capability))
|
||||
.slice(0, PER_SKILL)
|
||||
/* Deliberately not carried as `capability`: a chip with that field is one
|
||||
of the *page's* own answers and bypasses routing entirely. A skill's
|
||||
chip is an ordinary question, and is resolved the same way the same
|
||||
words typed by hand would be — one path, so a chip can never answer
|
||||
something the typed form would not. */
|
||||
.map((s) => {
|
||||
const need = needs(s.capability || skill.owliver.capabilities[0]);
|
||||
return {
|
||||
label: s.label,
|
||||
prompt: s.prompt,
|
||||
skillId: skill.id,
|
||||
skillCapability: s.capability || null,
|
||||
/* True when clicking this would have to ask which record first. */
|
||||
deferred: !met(need),
|
||||
};
|
||||
});
|
||||
chips.push(...offered);
|
||||
}
|
||||
|
||||
/* Answerable suggestions first, then the ones that would ask a question
|
||||
back — stable within each group, so a definition's own order is kept. */
|
||||
const ready = chips.filter((c) => !c.deferred);
|
||||
const asking = chips.filter((c) => c.deferred);
|
||||
return [...ready, ...asking].slice(0, TOTAL);
|
||||
}
|
||||
|
||||
/* ── Matching ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const lower = (value) => String(value ?? '').toLowerCase();
|
||||
|
||||
/** Words worth matching on — the ones that carry the subject of a question. */
|
||||
const STOP_WORDS = new Set([
|
||||
'the', 'a', 'an', 'this', 'that', 'these', 'those', 'my', 'our', 'is', 'are', 'was', 'were',
|
||||
'show', 'me', 'as', 'of', 'for', 'in', 'on', 'to', 'and', 'or', 'with', 'what', 'how', 'can',
|
||||
'you', 'i', 'it', 'please', 'give', 'tell', 'about', 'here', 'now', 'current', 'currently',
|
||||
]);
|
||||
|
||||
const words = (value) => lower(value).split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w));
|
||||
|
||||
/**
|
||||
* Does a declared trigger match? `*` stands for anything in between, the same
|
||||
* way the registry's own trigger matching reads it.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* How strongly a question asks for this skill.
|
||||
*
|
||||
* Evidence is weighted by how deliberate it is. A suggestion the author wrote
|
||||
* and the reader clicked is the strongest signal there is; a declared trigger
|
||||
* is next; the skill's own name is next; and shared words with its description
|
||||
* are the weakest — enough to break a tie, never enough to win on their own.
|
||||
*/
|
||||
function scoreSkill(skill, question) {
|
||||
const q = lower(question);
|
||||
|
||||
const suggestion = skill.owliver.suggestions.find((s) => lower(s.prompt) === q || lower(s.label) === q);
|
||||
if (suggestion) return { score: 100, suggestion };
|
||||
|
||||
/**
|
||||
* Deliberate evidence: the author said this skill answers this.
|
||||
*
|
||||
* A suggestion the reader is echoing, a declared trigger, or the skill's own
|
||||
* name. One of these must hold before a definition may claim a question at
|
||||
* all — see the floor below.
|
||||
*/
|
||||
let deliberate = 0;
|
||||
if (skill.owliver.suggestions.some((s) => q.includes(lower(s.label)))) deliberate += 40;
|
||||
if (skill.triggers.some((t) => triggerMatches(t, q))) deliberate += 30;
|
||||
if (skill.name && q.includes(lower(skill.name))) deliberate += 20;
|
||||
|
||||
/**
|
||||
* The floor. Sharing a word with a description is not a claim.
|
||||
*
|
||||
* This is the bug that let "create a position" be answered by a candidate
|
||||
* matching skill: its description happened to contain the word "position",
|
||||
* which scored three points, and three points beat nothing. Corroboration
|
||||
* was being treated as evidence.
|
||||
*
|
||||
* So description overlap can now only *break a tie* between definitions that
|
||||
* already named the subject, and can never qualify one on its own. A skill
|
||||
* about candidates cannot claim a question about creating a role however its
|
||||
* description happens to be worded — which is the general property, not a
|
||||
* fix aimed at these two definitions.
|
||||
*/
|
||||
if (!deliberate) return { score: 0, suggestion: null };
|
||||
|
||||
const overlap = words(skill.description).filter((w) => q.includes(w)).length;
|
||||
return { score: deliberate + Math.min(overlap * 3, 9), suggestion: null };
|
||||
}
|
||||
|
||||
/** The capability a question asks for, from the shape words it uses. */
|
||||
function scoreCapability(capability, question) {
|
||||
const q = lower(question);
|
||||
const definition = owliverCapabilityFor(capability);
|
||||
if (!definition) return 0;
|
||||
|
||||
/* Longest matching term wins, so "as a flow" beats "flow" and a question
|
||||
naming two shapes resolves to the more explicit one. */
|
||||
return definition.terms.reduce(
|
||||
(best, term) => (q.includes(term) && term.length > best ? term.length : best),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The skill and capability a question resolves to, or null.
|
||||
*
|
||||
* Both halves have to hold: a question that names no registered skill is not
|
||||
* this system's to answer, and a skill matched with no capability falls back to
|
||||
* the first one its definition declares — which is what makes "Show hiring
|
||||
* activity" work without the author writing a trigger per shape.
|
||||
*/
|
||||
export function matchOwliverSkill(question, skills = []) {
|
||||
let best = null;
|
||||
|
||||
for (const skill of skills) {
|
||||
const { score, suggestion } = scoreSkill(skill, question);
|
||||
if (score <= 0) continue;
|
||||
if (!best || score > best.score) best = { skill, score, suggestion };
|
||||
}
|
||||
if (!best) return null;
|
||||
|
||||
const { skill, suggestion, score } = best;
|
||||
const available = skill.owliver.capabilities;
|
||||
|
||||
/**
|
||||
* `exact` means the question *is* a suggestion this definition published —
|
||||
* the reader clicked a chip, or typed its words. It is the strongest claim
|
||||
* anything can have on a question, and callers weighing this match against
|
||||
* another matcher need to be able to see that rather than infer it from a
|
||||
* number.
|
||||
*/
|
||||
const exact = Boolean(suggestion);
|
||||
|
||||
/* A chip that declared its capability has already answered this. */
|
||||
if (suggestion?.capability && available.includes(suggestion.capability)) {
|
||||
return { skill, capability: suggestion.capability, score, exact };
|
||||
}
|
||||
|
||||
const asked = available
|
||||
.map((capability) => ({ capability, weight: scoreCapability(capability, question) }))
|
||||
.filter((c) => c.weight > 0)
|
||||
.sort((a, b) => b.weight - a.weight)[0];
|
||||
|
||||
return { skill, capability: asked?.capability || available[0], score, exact };
|
||||
}
|
||||
|
||||
/* ── The record a response is about ─────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The entity a source needs, resolved from the question and the page.
|
||||
*
|
||||
* Sources declare what they need — a position, a candidate, a draft, or
|
||||
* nothing — and this is the one place that need is met. Three orders of
|
||||
* evidence, most specific first:
|
||||
*
|
||||
* 1. the question named a record ("summarize hiring activity for Line Cook"),
|
||||
* 2. the page has one open (the drawer, the form being filled in),
|
||||
* 3. neither, and the answer has to ask.
|
||||
*
|
||||
* A source needing nothing resolves against the workspace and is always met.
|
||||
*
|
||||
* Naming the record wins over the page's selection deliberately: an admin who
|
||||
* says which role they mean has said so, and answering about a different one
|
||||
* because a drawer happened to be open would be worse than asking.
|
||||
*/
|
||||
export function resolveEntity(section, question, context = {}) {
|
||||
const need = section.context;
|
||||
if (!need) return { context, ok: true };
|
||||
|
||||
if (need === 'positionId') {
|
||||
const named = resolvePosition(question, context.positions || [], null);
|
||||
const position = named || context.position || null;
|
||||
return position
|
||||
? { context: { ...context, position }, ok: true }
|
||||
: { ok: false, need: 'position' };
|
||||
}
|
||||
|
||||
if (need === 'candidateId') {
|
||||
const q = lower(question);
|
||||
const named = (context.applications || []).find(
|
||||
(a) => a.applicant_name && q.includes(lower(a.applicant_name))
|
||||
);
|
||||
const candidate = named || context.candidate || null;
|
||||
return candidate
|
||||
? { context: { ...context, candidate }, ok: true }
|
||||
: { ok: false, need: 'candidate' };
|
||||
}
|
||||
|
||||
return { context, ok: true };
|
||||
}
|
||||
|
||||
/* ── The answer ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The rows a reading offers, whatever shape its source returns them in. */
|
||||
const rowsOf = (data) => data?.steps || data?.items || [];
|
||||
|
||||
/**
|
||||
* One row, read back as a line.
|
||||
*
|
||||
* A reading carries a figure, a description of it, or both, depending on the
|
||||
* source — so the line is assembled from what is there rather than from a fixed
|
||||
* template, and a row with a description that already states its figure
|
||||
* ("2 applications") does not repeat it.
|
||||
*/
|
||||
function rowLine(row) {
|
||||
const label = String(row.label || row.title || row.id || '').trim();
|
||||
if (!label) return null;
|
||||
|
||||
const value = row.value == null || row.value === '' ? null : String(row.value);
|
||||
const detail = row.detail ? String(row.detail) : null;
|
||||
|
||||
const tail = detail
|
||||
? (value && !detail.includes(value) ? `${detail} · ${value}` : detail)
|
||||
: value;
|
||||
|
||||
return tail ? `${label} — ${tail}` : label;
|
||||
}
|
||||
|
||||
/**
|
||||
* A summary, built from whatever the source returned.
|
||||
*
|
||||
* Generic on purpose: it reads rows and states them. Nothing here knows what a
|
||||
* period is, what a stage is, or which skill asked — which is precisely why a
|
||||
* definition written tomorrow gets a summary without this function changing.
|
||||
*/
|
||||
export function summaryLines(data) {
|
||||
return rowsOf(data).map((row) => rowLine(row)).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The label a reading is introduced by — the definition's words, then the source's. */
|
||||
export const responseTitle = (skill, section) =>
|
||||
section.title || `${skill.name} — ${dataSourceLabel(section.source)}`;
|
||||
|
||||
/**
|
||||
* Everything an answer needs, resolved: the section, the data, and whether the
|
||||
* page could supply the record the source required.
|
||||
*
|
||||
* Returns `{ skill, capability, section, data, missing }`. `missing` names what
|
||||
* the caller must ask for; when it is null the reading is real and complete.
|
||||
*/
|
||||
export function resolveOwliverResponse({ skill, capability, question, context = {}, now = new Date() }) {
|
||||
const section = skill.owliver.responses[capability];
|
||||
if (!section) return null;
|
||||
|
||||
const entity = resolveEntity(section, question, context);
|
||||
if (!entity.ok) {
|
||||
return { skill, capability, section, data: null, missing: entity.need };
|
||||
}
|
||||
|
||||
/* The same resolver the page's own sections go through, on the same
|
||||
collections — so the panel and the card beside it cannot report different
|
||||
figures for the same position. */
|
||||
const data = resolveSkillData(section, entity.context, now);
|
||||
return { skill, capability, section, data, missing: null, context: entity.context };
|
||||
}
|
||||
|
||||
/**
|
||||
* A reading, reduced to what a drawn section reads.
|
||||
*
|
||||
* A reply is kept in the thread, so it is stored: the records a source counted
|
||||
* are the evidence behind a figure, not part of the answer, and writing every
|
||||
* application into session storage to draw one bar would be paying for the
|
||||
* whole dataset per turn. Nothing the renderers use is dropped.
|
||||
*/
|
||||
export function presentable(data) {
|
||||
if (!data) return data;
|
||||
const strip = ({ records, ...row }) => row;
|
||||
|
||||
return {
|
||||
...data,
|
||||
...(data.steps ? { steps: data.steps.map(strip) } : null),
|
||||
...(data.items ? { items: data.items.map(strip) } : null),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every capability the product understands, for the editor and the previews. */
|
||||
export const CAPABILITY_SUMMARIES = OWLIVER_CAPABILITIES.map(
|
||||
({ id, label, summary }) => ({ id, label, summary })
|
||||
);
|
||||
|
||||
/* ── Suggestions for a record that has just appeared ────────────────────── */
|
||||
|
||||
/**
|
||||
* What can now be asked about a record the conversation just produced.
|
||||
*
|
||||
* Creating a position is the moment "who could do this?" becomes worth asking,
|
||||
* and the panel is the only thing that knows a position now exists. Rather than
|
||||
* naming a skill to offer — which would put a candidate-matching feature inside
|
||||
* the position-creation flow — this asks the registry the general question: of
|
||||
* the skills attached to this page, which declare a capability whose reading is
|
||||
* *about one position*? Those are exactly the ones that can say something about
|
||||
* the record just made.
|
||||
*
|
||||
* The record's own title is appended to each prompt, so the answer resolves
|
||||
* against it directly and the reader is never asked to pick from a list that
|
||||
* includes the position they are looking at. Nothing is named here: a skill
|
||||
* added tomorrow that reads a position is offered on the same terms.
|
||||
*/
|
||||
export function suggestionsForPosition(contextId, disabled = [], customSources = [], position) {
|
||||
if (!position?.title) return [];
|
||||
|
||||
const chips = [];
|
||||
|
||||
for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
|
||||
/* Only capabilities that read one position — a workspace-wide reading has
|
||||
nothing to do with the record that was just created. */
|
||||
const scoped = skill.owliver.capabilities.filter(
|
||||
(capability) => skill.owliver.responses[capability]?.context === 'positionId'
|
||||
);
|
||||
if (!scoped.length) continue;
|
||||
|
||||
const offered = skill.owliver.suggestions
|
||||
.filter((s) => !s.capability || scoped.includes(s.capability))
|
||||
.slice(0, 1)
|
||||
.map((s) => ({
|
||||
label: s.label,
|
||||
/* Named, so the reading resolves against this position rather than
|
||||
asking which one. */
|
||||
prompt: `${s.prompt} for ${position.title}`,
|
||||
skillId: skill.id,
|
||||
skillCapability: s.capability || null,
|
||||
}));
|
||||
|
||||
chips.push(...offered);
|
||||
}
|
||||
|
||||
return chips.slice(0, 2);
|
||||
}
|
||||
@@ -56,6 +56,26 @@ const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere els
|
||||
* 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),
|
||||
@@ -233,10 +253,13 @@ function review(flow, steps) {
|
||||
doc: doc(
|
||||
text('Ready to create this position?'),
|
||||
list(summaryLines(flow, steps)),
|
||||
note('Nothing is saved until you choose Create position.')
|
||||
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: 'Create position', prompt: 'Create position' },
|
||||
{ label: 'Save as Draft', prompt: 'Save as draft' },
|
||||
{ label: 'Publish Job Posting', prompt: 'Publish job posting' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
};
|
||||
@@ -305,9 +328,15 @@ export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
|
||||
|
||||
/* 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)) {
|
||||
/* 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 } };
|
||||
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);
|
||||
|
||||
@@ -322,7 +351,7 @@ export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
|
||||
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.')
|
||||
note('Choose Save as Draft or Publish Job Posting, or tell me what to change.')
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -430,14 +459,19 @@ function applyStatement(flow, steps, said, roles) {
|
||||
|
||||
/** The position exists. Said plainly, with what was created. */
|
||||
export function positionCreatedReply(position) {
|
||||
const isDraft = position.status === 'draft';
|
||||
|
||||
return doc(
|
||||
text('Position created successfully.'),
|
||||
text(isDraft ? 'Saved as a draft.' : 'Position created successfully.'),
|
||||
list([
|
||||
position.company,
|
||||
position.title,
|
||||
position.location,
|
||||
payLabel(position),
|
||||
].filter(Boolean)),
|
||||
note('It is on the Positions list now — applications will start appearing against it.')
|
||||
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.')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -449,7 +483,20 @@ export function positionFailedReply() {
|
||||
);
|
||||
}
|
||||
|
||||
/** What the panel offers after a position is created. */
|
||||
export const createdFollowUp = (position) => [
|
||||
{ label: 'View position', route: `/admin/positions/${position.id}` },
|
||||
];
|
||||
/**
|
||||
* What the panel offers after a position is created.
|
||||
*
|
||||
* A published role has an obvious next question — who can fill it — so it is
|
||||
* offered here rather than left to be typed. The chip carries the position's
|
||||
* own title and the wording the workforce engine already answers, so it is an
|
||||
* ordinary question resolved by the path that was already there: no handler,
|
||||
* no navigation, and the same answer as asking it by hand.
|
||||
*/
|
||||
export const createdFollowUp = (position) => (position.status === 'draft'
|
||||
/* A draft is unfinished, so the way on is the form that finishes it — the
|
||||
same route the Positions card's Continue uses. */
|
||||
? [{ label: 'Continue to save', route: `/admin/positions/new?draft=${encodeURIComponent(position.id)}` }]
|
||||
: [
|
||||
{ label: 'View position', route: `/admin/positions/${position.id}` },
|
||||
{ label: 'Match candidates', prompt: `Who matches ${position.title}?` },
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
import { parseYaml } from './yaml';
|
||||
import { normalizeSkillUi } from './uiConfig';
|
||||
import { normalizeSkillOwliver } from './owliverConfig';
|
||||
import { SUPPORTED_SKILL_PAGES, canonicalPage, surfaceFor, surfaceForRoute } from './surfaces';
|
||||
|
||||
/**
|
||||
* Owliver skill registry.
|
||||
@@ -16,43 +20,25 @@ import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
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.
|
||||
* Frontmatter, as data.
|
||||
*
|
||||
* 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.
|
||||
* Skills grew declarative UI configuration, which is nested, so this reads the
|
||||
* YAML subset in `yaml.js` rather than the flat `key: value` pairs it used to.
|
||||
* The old shapes are a strict subset of the new one — a definition written for
|
||||
* the previous parser parses identically here.
|
||||
*
|
||||
* A file whose frontmatter cannot be read raises rather than registering a
|
||||
* half-understood definition; `parseSkill` decides what to do with that.
|
||||
*/
|
||||
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() };
|
||||
const data = parseYaml(match[1]);
|
||||
return {
|
||||
data: data && typeof data === 'object' && !Array.isArray(data) ? data : {},
|
||||
body: raw.slice(match[0].length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Bullets under a `## Heading`, for the capability list shown in Settings. */
|
||||
@@ -131,10 +117,14 @@ function sectionLevels(body) {
|
||||
/**
|
||||
* 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.
|
||||
* The surface table answers first, because a surface already states its own
|
||||
* route and its key is not always the path tail: `/admin/positions/new` is
|
||||
* `create-position`, not `positions/new`. Falling back to the tail keeps every
|
||||
* route that has no declared surface behaving exactly as it did.
|
||||
*/
|
||||
export function pageKeyForRoute(route) {
|
||||
const surface = surfaceForRoute(route);
|
||||
if (surface) return surface.id;
|
||||
const tail = route.replace(/^\/admin\/?/, '');
|
||||
return tail === '' ? 'control-center' : tail;
|
||||
}
|
||||
@@ -154,6 +144,59 @@ const ROUTE_BY_PAGE_KEY = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route,
|
||||
export const routeForPageKey = (key) => ROUTE_BY_PAGE_KEY[key]?.route ?? null;
|
||||
export const pageKeyForContext = (contextId) => PAGE_KEY_BY_CONTEXT[contextId] ?? null;
|
||||
|
||||
/**
|
||||
* The two management surfaces a definition can belong to.
|
||||
*
|
||||
* `ui` extends a KROW page; `owliver` extends the assistant. They share the
|
||||
* parser, the registry, the validator, the persistence and the data resolver —
|
||||
* only the authoring and management experience is separate, which is what this
|
||||
* classification serves.
|
||||
*/
|
||||
export const SKILL_FACETS = ['ui', 'owliver'];
|
||||
|
||||
/**
|
||||
* Which of them a definition belongs to.
|
||||
*
|
||||
* Declared, never configured: a `ui:` block is a page extension, and an
|
||||
* `owliver:` block, triggers, actions, a prompt or a conversation is an
|
||||
* assistant extension. A definition that declares neither is an assistant
|
||||
* skill — that is what every definition written before the split was, and
|
||||
* reading it any other way would drop it out of both lists.
|
||||
*
|
||||
* Workforce paths are neither. They define a capability the workforce holds
|
||||
* and are managed in Skill Development, so they carry no facet and appear on
|
||||
* neither list.
|
||||
*/
|
||||
export function skillFacets({ data = {}, kind, ui = {}, owliver, conversation = [] }) {
|
||||
if (kind === 'workforce') return [];
|
||||
|
||||
const extendsPage = Object.keys(ui).length > 0;
|
||||
|
||||
/**
|
||||
* Behaviour Owliver actually gains: something to answer with, something to
|
||||
* open, or questions to ask.
|
||||
*
|
||||
* Triggers alone are deliberately not on this list. A trigger is a way of
|
||||
* being *named*, and a page-drawing definition that names itself is still a
|
||||
* page-drawing definition — listing it as an Owliver skill would offer an
|
||||
* author a capability list it never declared. A definition with no `ui:` is
|
||||
* the other way round: triggers are all it has, and they are what it does.
|
||||
*/
|
||||
const teachesOwliver = Boolean(
|
||||
data.owliver
|
||||
|| (Array.isArray(data.actions) && data.actions.length)
|
||||
|| data.prompt
|
||||
|| conversation.length
|
||||
|| owliver?.capabilities?.length
|
||||
|| owliver?.suggestions?.length
|
||||
);
|
||||
|
||||
return [
|
||||
extendsPage ? 'ui' : null,
|
||||
teachesOwliver || !extendsPage ? 'owliver' : null,
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* One Markdown definition → one skill.
|
||||
*
|
||||
@@ -179,9 +222,49 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
*/
|
||||
const kind = data.kind || (levels.length ? 'workforce' : 'assistant');
|
||||
|
||||
/* The declarative UI this definition contributes, checked against the
|
||||
closed vocabulary in `surfaces.js`. A definition with no `ui:` block is
|
||||
exactly what it was before this existed. */
|
||||
const { ui, errors: uiErrors } = normalizeSkillUi(data.ui, {
|
||||
declaredPages: pages,
|
||||
skillId: id,
|
||||
});
|
||||
|
||||
/* The same definition's second consumer. `owliver:` declares what can be
|
||||
asked for in the panel, reading the source the page section already
|
||||
names — so one file answers "what does this page show" and "what can
|
||||
Owliver be asked here" without either being written twice. A definition
|
||||
with no `owliver:` block is exactly what it was before this existed. */
|
||||
const { owliver, errors: owliverErrors } = normalizeSkillOwliver(data.owliver, {
|
||||
ui,
|
||||
skillId: id,
|
||||
skillName: data.name || '',
|
||||
});
|
||||
|
||||
/* The questions this skill asks, when it collects its input in the chat
|
||||
rather than by opening something. */
|
||||
const conversation = sectionSteps(body, 'Conversation');
|
||||
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
ui,
|
||||
uiErrors,
|
||||
owliver,
|
||||
owliverErrors,
|
||||
/**
|
||||
* What this definition extends, derived from what it declares.
|
||||
*
|
||||
* Two things wear the same format and are managed as different lists: a
|
||||
* definition with a `ui:` block extends a *page*, and one that teaches
|
||||
* Owliver — an `owliver:` block, triggers, actions, a conversation —
|
||||
* extends the *assistant*. Reading that off the declaration rather than
|
||||
* off a `type:` field is what makes the split free: every definition
|
||||
* already written classifies itself, nothing stored has to be migrated,
|
||||
* and a definition doing both is listed in both places rather than
|
||||
* losing half of itself to a category.
|
||||
*/
|
||||
facets: skillFacets({ data, kind, ui, owliver, conversation }),
|
||||
name: data.name || 'Untitled skill',
|
||||
description: data.description || '',
|
||||
status: data.status === 'inactive' ? 'inactive' : 'active',
|
||||
@@ -213,12 +296,21 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
? data.triggers
|
||||
: [data.name].filter(Boolean)
|
||||
).map((t) => String(t).toLowerCase()),
|
||||
/**
|
||||
* Whether those triggers were *claimed* or merely inherited.
|
||||
*
|
||||
* The fallback above is convenient and, until this field existed,
|
||||
* indistinguishable from the real thing — so a definition that only draws
|
||||
* a card was silently claiming its own name as a phrase Owliver answers
|
||||
* to, and could take a question from a definition written to answer it.
|
||||
* Keeping the distinction lets the matcher weigh a claim differently from
|
||||
* a default without changing what `triggers` contains.
|
||||
*/
|
||||
declaredTriggers: Boolean(Array.isArray(data.triggers) && data.triggers.length),
|
||||
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'),
|
||||
conversation,
|
||||
path,
|
||||
body,
|
||||
custom,
|
||||
@@ -255,23 +347,41 @@ export function allSkills(customSources = []) {
|
||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Validates a definition before it is stored. Returns an error string or null. */
|
||||
/**
|
||||
* Validates a definition before it is stored. Returns an error string or null.
|
||||
*
|
||||
* Frontmatter first, then the declarative UI — an author is told the first
|
||||
* thing that is wrong, in the order they would fix it.
|
||||
*/
|
||||
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.';
|
||||
} catch (error) {
|
||||
/* The YAML subset reports the line it failed on; that is far more useful
|
||||
than "could not be parsed". */
|
||||
return `That definition could not be parsed. ${error.message || ''}`.trim();
|
||||
}
|
||||
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]);
|
||||
|
||||
const unknown = skill.pages.filter((p) => !surfaceFor(p));
|
||||
if (unknown.length) {
|
||||
return `Unknown page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Known pages: ${Object.keys(ROUTE_BY_PAGE_KEY).join(', ')}.`;
|
||||
return `Unsupported page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`;
|
||||
}
|
||||
|
||||
/* A UI block that names something the product does not offer is refused
|
||||
outright rather than registered with the offending section dropped. */
|
||||
if (skill.uiErrors?.length) return skill.uiErrors[0];
|
||||
|
||||
/* Same rule for the panel half of the definition: a capability, source or
|
||||
step the product cannot honour is refused now rather than registered as
|
||||
something Owliver offers and then cannot answer. */
|
||||
if (skill.owliverErrors?.length) return skill.owliverErrors[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -280,7 +390,10 @@ 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);
|
||||
return skill.pages
|
||||
.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId
|
||||
|| ROUTE_BY_PAGE_KEY[pageKeyForRoute(surfaceFor(key)?.route || '')]?.contextId)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,10 +410,11 @@ export function contextIdsForSkill(skill) {
|
||||
*/
|
||||
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) {
|
||||
if (!pageId) return [];
|
||||
const wanted = canonicalPage(pageId) || pageId;
|
||||
return allSkills(customSources).filter(
|
||||
(s) => s.status === 'active'
|
||||
&& !disabled.includes(s.id)
|
||||
&& s.pages.includes(pageId)
|
||||
&& s.pages.some((p) => (canonicalPage(p) || p) === wanted)
|
||||
&& (!kind || s.kind === kind)
|
||||
);
|
||||
}
|
||||
@@ -338,10 +452,40 @@ function triggerMatches(trigger, question) {
|
||||
return new RegExp(pattern).test(question);
|
||||
}
|
||||
|
||||
/** The first skill on this page whose triggers match the question. */
|
||||
/**
|
||||
* The first skill on this page whose triggers match the question.
|
||||
*
|
||||
* A definition has to be *addressable by the assistant* before its triggers
|
||||
* count, and there are two ways to be: teach Owliver something — an `owliver:`
|
||||
* block, an action, a conversation — or explicitly claim a phrase with
|
||||
* `triggers:`. A definition that does neither is a page extension that happens
|
||||
* to have a name, and matching it here means answering a question with a
|
||||
* restatement of a card's description.
|
||||
*
|
||||
* That was live: `hiring-activity` draws a flow on the Positions page, declares
|
||||
* no triggers, and inherited "hiring activity" from its own name — enough to
|
||||
* take "show hiring activity" from the definition written to answer it.
|
||||
*
|
||||
* Both conditions are read off what the definition declares, so a skill written
|
||||
* tomorrow is admitted or excluded by the same rule, and nothing that claimed a
|
||||
* phrase loses it.
|
||||
*/
|
||||
const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver');
|
||||
|
||||
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))
|
||||
(s) => addressable(s) && s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The definitions belonging to one management surface.
|
||||
*
|
||||
* The single answer to "what belongs on the UI Skills list" and "what belongs
|
||||
* on the Owliver Skills list". Both lists come from `allSkills` — one registry,
|
||||
* two readings of it — so a definition cannot exist on one list and be unknown
|
||||
* to the other system.
|
||||
*/
|
||||
export const skillsWithFacet = (skills = [], facet) =>
|
||||
skills.filter((s) => s.facets?.includes(facet));
|
||||
|
||||
478
src/lib/skills/surfaces.js
Normal file
478
src/lib/skills/surfaces.js
Normal file
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* What a skill definition is allowed to say about the product.
|
||||
*
|
||||
* A skill can extend a KROW page: name a surface, declare a section, and the
|
||||
* page renders it. That only stays safe — and only stays a *product* rather
|
||||
* than a scripting host — because the vocabulary is closed. Everything a
|
||||
* definition may name is in this file: the surfaces, the placements on each
|
||||
* surface, the component types, and the data sources.
|
||||
*
|
||||
* The rule that makes it safe: **nothing here is code, and nothing here is
|
||||
* looked up dynamically from the file.** A definition names a key; this module
|
||||
* says whether that key exists; the renderer maps it to a component the app
|
||||
* already ships. A definition that names something absent is rejected with a
|
||||
* message, never rendered as an unknown thing and never executed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The surfaces a skill can extend.
|
||||
*
|
||||
* `aliases` keep the page keys the existing skills already use — `hired`,
|
||||
* `university` — working under the names the product now shows, so the eight
|
||||
* definitions on disk did not have to be rewritten to gain this feature.
|
||||
*/
|
||||
export const SKILL_SURFACES = [
|
||||
{
|
||||
id: 'control-center',
|
||||
label: 'Control Center',
|
||||
route: '/admin',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'positions',
|
||||
label: 'Positions',
|
||||
route: '/admin/positions',
|
||||
/* Mounted in three places, because Positions is three experiences: the page
|
||||
that lists the roles, the card for each one, and the drawer "View
|
||||
position" opens.
|
||||
|
||||
`after-position-list-summary` and `after-position-list` are the *page*:
|
||||
they render once, above and below the grid, with no position in context.
|
||||
`after-position-card` renders inside each card. The rest render inside
|
||||
the drawer and on the full position page, where one position is being
|
||||
read. Every one of these is mounted — a placement the vocabulary offers
|
||||
and no page provides is a definition that validates and then silently
|
||||
does nothing. */
|
||||
placements: [
|
||||
'after-position-list-summary',
|
||||
'after-position-list',
|
||||
'after-header',
|
||||
'after-position-card',
|
||||
'after-position-summary',
|
||||
'before-candidates',
|
||||
'after-candidates',
|
||||
'before-footer',
|
||||
],
|
||||
},
|
||||
{
|
||||
/* The authoring form, which is a surface in its own right: what a skill has
|
||||
to say there is about the position being specified, not about the ones
|
||||
that already exist. Its placements follow the form's own three parts, so
|
||||
a definition can sit beside the field group it is about. */
|
||||
id: 'create-position',
|
||||
label: 'Create Position',
|
||||
route: '/admin/positions/new',
|
||||
aliases: ['new-position'],
|
||||
placements: [
|
||||
'after-header',
|
||||
'after-job-description',
|
||||
'after-vetting-weights',
|
||||
'before-footer',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'candidates',
|
||||
label: 'Candidates',
|
||||
route: '/admin/candidates',
|
||||
placements: ['after-header', 'after-candidate-summary', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'hired-history',
|
||||
label: 'Hired History',
|
||||
route: '/admin/hired',
|
||||
aliases: ['hired'],
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'talent-pool',
|
||||
label: 'Talent Pool',
|
||||
route: '/admin/talent-pool',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'krow-forge',
|
||||
label: 'KROW Forge',
|
||||
route: '/admin/university',
|
||||
aliases: ['university', 'forge'],
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Analytics',
|
||||
route: '/admin/analytics',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
route: '/admin/activity',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
/* Not in the eight product surfaces, but skills already attach to it and the
|
||||
account page reads them. Kept so nothing that works today stops working. */
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profile',
|
||||
route: '/admin/profile',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'candidates-analysis',
|
||||
label: 'Candidate Analysis',
|
||||
route: '/admin/candidates-analysis',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
];
|
||||
|
||||
const BY_KEY = new Map();
|
||||
for (const surface of SKILL_SURFACES) {
|
||||
BY_KEY.set(surface.id, surface);
|
||||
for (const alias of surface.aliases || []) BY_KEY.set(alias, surface);
|
||||
}
|
||||
|
||||
/** Every name a definition may use for a surface, for error messages. */
|
||||
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
|
||||
|
||||
/** The surface a declared page name refers to, or null. */
|
||||
export const surfaceFor = (page) => BY_KEY.get(String(page || '').trim()) || null;
|
||||
|
||||
/** The canonical id for a declared page name — `hired` → `hired-history`. */
|
||||
export const canonicalPage = (page) => surfaceFor(page)?.id || null;
|
||||
|
||||
/**
|
||||
* The surface an Admin route belongs to — `/admin/positions/new` →
|
||||
* `create-position`.
|
||||
*
|
||||
* The surfaces already carry their routes, so this reads the answer off the
|
||||
* table rather than deriving a page key from the path a second time. That
|
||||
* matters for the surfaces whose key is not their path tail: without it,
|
||||
* `/admin/positions/new` would key as `positions/new`, which is a page nothing
|
||||
* declares and no definition could attach to.
|
||||
*/
|
||||
export const surfaceForRoute = (route) =>
|
||||
SKILL_SURFACES.find((s) => s.route === String(route || '').trim()) || null;
|
||||
|
||||
/**
|
||||
* The section types a definition may ask for.
|
||||
*
|
||||
* Each entry names a component the application already ships. A type is a key
|
||||
* in this table and nothing else: there is no path from a definition to a
|
||||
* component that is not listed here, which is what stops `type:` from being an
|
||||
* import statement in disguise.
|
||||
*/
|
||||
export const SECTION_TYPES = [
|
||||
{ id: 'card', label: 'Card', summary: 'A titled panel of prose and figures.' },
|
||||
{ id: 'stats', label: 'Stats', summary: 'A row of counted figures.' },
|
||||
{ id: 'list', label: 'List', summary: 'A ranked or plain list of records.' },
|
||||
{ id: 'timeline', label: 'Timeline', summary: 'Dated events, most recent first.' },
|
||||
{ id: 'flow', label: 'Flow', summary: 'A sequence of stages or periods.' },
|
||||
{ id: 'table', label: 'Table', summary: 'Rows and columns.' },
|
||||
{ id: 'progress', label: 'Progress', summary: 'Bars against a total.' },
|
||||
{ id: 'insight', label: 'Insight', summary: 'One finding, stated plainly.' },
|
||||
/* Weighted criteria that share a budget. Distinct from `progress`, which is
|
||||
bars against an independent maximum: these bars are shares of one total,
|
||||
and the total is a fact about the set rather than about any one row. */
|
||||
{ id: 'weights', label: 'Weights', summary: 'Weighted criteria as shares of one total.' },
|
||||
];
|
||||
|
||||
export const SUPPORTED_SECTION_TYPES = SECTION_TYPES.map((t) => t.id);
|
||||
|
||||
/**
|
||||
* What a definition may ask *Owliver* to do with the same reading.
|
||||
*
|
||||
* A skill declares `ui:` for the page and `owliver:` for the panel, and both
|
||||
* name the same data source. A capability is the second half of that: the shape
|
||||
* the answer takes when it is asked for in conversation rather than rendered on
|
||||
* the page.
|
||||
*
|
||||
* `shape` is the section type the answer is drawn with, so `flow` in a chat
|
||||
* reply is the *same* component the page renders — there is one flow renderer,
|
||||
* not one per consumer. `summary` has no shape because a summary is prose: the
|
||||
* figures are read back as sentences rather than drawn.
|
||||
*
|
||||
* `terms` are how a question is recognised as asking for this shape. They are
|
||||
* deliberately about the *shape* and never about a subject: "as a flow" belongs
|
||||
* here, "hiring activity" belongs in a definition's `triggers`. That split is
|
||||
* what keeps this table closed while the skills stay open.
|
||||
*/
|
||||
export const OWLIVER_CAPABILITIES = [
|
||||
{
|
||||
id: 'summary',
|
||||
label: 'Summary',
|
||||
shape: null,
|
||||
summary: 'Reads the figures back as sentences.',
|
||||
terms: ['summary', 'summarise', 'summarize', 'summarised', 'summarized', 'summarising',
|
||||
'summarizing', 'sum up', 'recap', 'overview', 'brief me', 'in short', 'tell me about',
|
||||
'what is the', 'how is'],
|
||||
},
|
||||
{
|
||||
id: 'flow',
|
||||
label: 'Flow',
|
||||
shape: 'flow',
|
||||
summary: 'Draws the stages or periods as a sequence.',
|
||||
terms: ['flow', 'as a flow', 'chart', 'graph', 'diagram', 'funnel', 'stages', 'visual',
|
||||
'visualise', 'visualize', 'step by step'],
|
||||
},
|
||||
{
|
||||
id: 'stats',
|
||||
label: 'Stats',
|
||||
shape: 'stats',
|
||||
summary: 'A row of counted figures.',
|
||||
terms: ['stats', 'statistics', 'figures', 'numbers', 'counts', 'how many'],
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
label: 'List',
|
||||
shape: 'list',
|
||||
summary: 'A ranked or plain list of records.',
|
||||
terms: ['list', 'who are', 'which ones', 'show me the records'],
|
||||
},
|
||||
{
|
||||
id: 'table',
|
||||
label: 'Table',
|
||||
shape: 'table',
|
||||
summary: 'Rows and columns.',
|
||||
terms: ['table', 'as a table', 'rows', 'grid', 'spreadsheet'],
|
||||
},
|
||||
{
|
||||
id: 'timeline',
|
||||
label: 'Timeline',
|
||||
shape: 'timeline',
|
||||
summary: 'Dated events, most recent first.',
|
||||
terms: ['timeline', 'history', 'chronology', 'over time', 'what happened'],
|
||||
},
|
||||
{
|
||||
id: 'progress',
|
||||
label: 'Progress',
|
||||
shape: 'progress',
|
||||
summary: 'Bars against a total.',
|
||||
terms: ['progress', 'bars', 'completion', 'how far'],
|
||||
},
|
||||
{
|
||||
id: 'weights',
|
||||
label: 'Weights',
|
||||
shape: 'weights',
|
||||
summary: 'The weighted criteria, adjustable when the page accepts the write.',
|
||||
terms: ['weight', 'weights', 'weighting', 'weightings', 'importance', 'balance',
|
||||
'set the weights', 'adjust the weights', 'screening weight', 'vetting weight',
|
||||
'criteria'],
|
||||
},
|
||||
{
|
||||
id: 'insight',
|
||||
label: 'Insight',
|
||||
shape: 'insight',
|
||||
summary: 'One finding, stated plainly.',
|
||||
terms: ['insight', 'finding', 'takeaway', 'headline', 'what stands out'],
|
||||
},
|
||||
{
|
||||
id: 'card',
|
||||
label: 'Card',
|
||||
shape: 'card',
|
||||
summary: 'A titled panel of figures.',
|
||||
terms: ['card', 'panel', 'at a glance'],
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORTED_OWLIVER_CAPABILITIES = OWLIVER_CAPABILITIES.map((c) => c.id);
|
||||
|
||||
/** The capability a declared name refers to, or null. */
|
||||
export const owliverCapabilityFor = (id) =>
|
||||
OWLIVER_CAPABILITIES.find((c) => c.id === String(id || '').trim()) || null;
|
||||
|
||||
/** `flow` → `Flow`. */
|
||||
export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label || id;
|
||||
|
||||
/**
|
||||
* The data a section may ask for.
|
||||
*
|
||||
* Each source is a named reading of data the application already holds, and
|
||||
* `context` says what a page must know for the reading to be possible — a
|
||||
* position id, a candidate id, or nothing. A source is resolved by
|
||||
* `dataResolver.js`; a definition cannot reach a store directly, cannot write,
|
||||
* and cannot name a field that is not offered here.
|
||||
*/
|
||||
export const DATA_SOURCES = [
|
||||
{
|
||||
id: 'position.activity',
|
||||
label: 'Position activity',
|
||||
context: 'positionId',
|
||||
summary: 'Applications to this position, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'position.pipeline',
|
||||
label: 'Position pipeline',
|
||||
context: 'positionId',
|
||||
summary: 'Applied → screened → shortlisted → interviewed → hired.',
|
||||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'position.candidates',
|
||||
label: 'Position candidates',
|
||||
context: 'positionId',
|
||||
summary: 'Candidates matched to this position, best first.',
|
||||
shapes: ['list', 'table', 'stats', 'card'],
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Who could actually do this work, ranked.
|
||||
*
|
||||
* Distinct from `position.candidates`, which lists the people who *applied
|
||||
* here*. This one reads the whole candidate pool against what the position
|
||||
* states — so a role published a minute ago, with no applications at all,
|
||||
* still has an answer. The reading is `poolFor` in `lib/workforce.js`, the
|
||||
* same engine the panel's workforce conversation and the position page use;
|
||||
* nothing about matching is decided in the resolver.
|
||||
*/
|
||||
id: 'position.matches',
|
||||
label: 'Position candidate matches',
|
||||
context: 'positionId',
|
||||
summary: 'The candidate pool scored against this position, best first.',
|
||||
shapes: ['list', 'table', 'stats', 'card', 'insight'],
|
||||
},
|
||||
{
|
||||
id: 'position.requirements',
|
||||
label: 'Position requirements',
|
||||
context: 'positionId',
|
||||
summary: 'What this position states it needs.',
|
||||
shapes: ['list', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'candidate.readiness',
|
||||
label: 'Candidate readiness',
|
||||
context: 'candidateId',
|
||||
summary: 'Screening dimensions for one candidate.',
|
||||
shapes: ['progress', 'stats', 'list', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'candidate.activity',
|
||||
label: 'Candidate activity',
|
||||
context: 'candidateId',
|
||||
summary: 'What has happened on this candidate’s record.',
|
||||
shapes: ['timeline', 'list', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'candidates.pipeline',
|
||||
label: 'Candidate pipeline',
|
||||
context: null,
|
||||
summary: 'Every candidate, counted by stage.',
|
||||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'candidates.activity',
|
||||
label: 'Candidate activity',
|
||||
context: null,
|
||||
summary: 'Applications across the workspace, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'positions.demand',
|
||||
label: 'Position demand',
|
||||
context: null,
|
||||
summary: 'Open positions and what they still need.',
|
||||
shapes: ['list', 'table', 'stats', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'workforce.training',
|
||||
label: 'Workforce training',
|
||||
context: null,
|
||||
summary: 'Training paths and progress against them.',
|
||||
shapes: ['progress', 'list', 'stats', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
/* The vetting weights a position is being specified with. `positionId`
|
||||
context, but the "position" on Create Position is the draft in the form
|
||||
rather than a saved record — which is the point: the resolver reads the
|
||||
same field either way, so one source serves the form and the position it
|
||||
becomes. */
|
||||
id: 'position.vetting',
|
||||
label: 'Position vetting weights',
|
||||
context: 'positionId',
|
||||
summary: 'How this position weights each screening criterion.',
|
||||
shapes: ['weights', 'flow', 'progress', 'stats', 'table', 'card', 'insight'],
|
||||
/**
|
||||
* This reading can be written back.
|
||||
*
|
||||
* `writable` is what lets a definition declare `editable: true` and get
|
||||
* controls instead of a read-out. It is a property of the *source*, not of
|
||||
* the definition — a skill cannot make a reading writable by asking, and a
|
||||
* source with no page willing to accept the write simply renders read-only.
|
||||
* That keeps the closed vocabulary closed in both directions.
|
||||
*/
|
||||
writable: true,
|
||||
writeSummary: 'Sets the screening weights on the position being specified.',
|
||||
},
|
||||
{
|
||||
id: 'hires.recent',
|
||||
label: 'Recent hires',
|
||||
context: null,
|
||||
summary: 'Who was hired, for which role, and when.',
|
||||
shapes: ['list', 'table', 'timeline', 'stats', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'hires.performance',
|
||||
label: 'Hiring performance',
|
||||
context: null,
|
||||
summary: 'Hires, time-to-hire, quality and conversion, counted together.',
|
||||
shapes: ['stats', 'card', 'table', 'flow', 'insight'],
|
||||
},
|
||||
{
|
||||
id: 'activity.events',
|
||||
label: 'Workspace activity',
|
||||
context: null,
|
||||
summary: 'What has happened across the workspace, most recent first.',
|
||||
shapes: ['timeline', 'list', 'table', 'stats', 'card'],
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id);
|
||||
|
||||
export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null;
|
||||
|
||||
/**
|
||||
* Can this reading be written back?
|
||||
*
|
||||
* The one question `editable:` is checked against. A page opts in by publishing
|
||||
* a handler for the source (see `usePublishPageActions`); a definition opts in
|
||||
* by declaring `editable: true`. Both have to hold before a control is drawn,
|
||||
* so neither the author nor the page can enable editing on its own.
|
||||
*/
|
||||
export const isSourceWritable = (id) => Boolean(dataSourceFor(id)?.writable);
|
||||
|
||||
/**
|
||||
* The periods a time-based section may ask for.
|
||||
*
|
||||
* Only the ones the data layer can actually compute from record timestamps.
|
||||
* A period is a window over `created_date`, resolved at read time against the
|
||||
* current date — never a stored figure and never a hard-coded date.
|
||||
*/
|
||||
export const PERIODS = [
|
||||
{ id: 'today', label: 'Today' },
|
||||
{ id: 'yesterday', label: 'Yesterday' },
|
||||
{ id: 'last-7-days', label: 'Last 7 days' },
|
||||
{ id: 'last-week', label: 'Last week' },
|
||||
{ id: 'this-month', label: 'This month' },
|
||||
{ id: 'previous-month', label: 'Previous month' },
|
||||
];
|
||||
|
||||
export const SUPPORTED_PERIODS = PERIODS.map((p) => p.id);
|
||||
|
||||
export const periodLabel = (id) => PERIODS.find((p) => p.id === id)?.label || id;
|
||||
|
||||
/* ── Human labels ───────────────────────────────────────────────────────────
|
||||
The vocabulary is written in kebab-case because it is configuration; it is
|
||||
read by people, so every id has a label. One place, so the preview, the
|
||||
rendered section and any future surface all say the same words. */
|
||||
|
||||
/** `flow` → `Flow`. */
|
||||
export const sectionTypeLabel = (id) =>
|
||||
SECTION_TYPES.find((t) => t.id === id)?.label || id;
|
||||
|
||||
/** `position.activity` → `Position activity`. */
|
||||
export const dataSourceLabel = (id) => dataSourceFor(id)?.label || id;
|
||||
|
||||
/** `after-position-summary` → `After position summary`. */
|
||||
export const placementLabel = (id) => {
|
||||
const words = String(id || '').replace(/-/g, ' ').trim();
|
||||
return words ? words[0].toUpperCase() + words.slice(1) : id;
|
||||
};
|
||||
293
src/lib/skills/uiConfig.js
Normal file
293
src/lib/skills/uiConfig.js
Normal file
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
|
||||
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, surfaceFor,
|
||||
} from './surfaces';
|
||||
|
||||
/**
|
||||
* The `ui:` block of a skill definition, checked and normalized.
|
||||
*
|
||||
* A definition declares what it wants; this decides whether the product can
|
||||
* honour it, and turns a loose YAML shape into one predictable record. Two
|
||||
* things follow from doing it here rather than in the renderer:
|
||||
*
|
||||
* - **Nothing unknown reaches a component.** Every page, placement, type,
|
||||
* data source and period is checked against the closed vocabulary in
|
||||
* `surfaces.js`. An unrecognised value is an error with a message naming
|
||||
* it, not a silently dropped key and not a rendered blank.
|
||||
* - **Only listed keys survive.** The normalized section carries exactly the
|
||||
* fields the renderers read. Anything else an author writes is ignored
|
||||
* rather than passed through, so no property can arrive at React that this
|
||||
* module did not put there.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A section as the renderers receive it. Nothing else is carried.
|
||||
*
|
||||
* Used by both consumers of a definition. The page passes a `page`, so the
|
||||
* section is checked against that surface's placements; Owliver passes
|
||||
* `placement: false`, because a chat reply has no placement to sit at — the
|
||||
* rest of the checks, and the record that comes out, are identical. That is
|
||||
* deliberate: it is what makes a flow drawn in the panel the same section as
|
||||
* the flow drawn on the page rather than a parallel shape that resembles it.
|
||||
*
|
||||
* `types` narrows what `type:` may be. The page offers the components it can
|
||||
* mount; Owliver offers those plus the shapes that are only answers.
|
||||
*/
|
||||
export function normalizeSection(raw, {
|
||||
page, errors, seen, fallbackId = '', where: label = null,
|
||||
placement: wantPlacement = true, types = SUPPORTED_SECTION_TYPES,
|
||||
shapeFor = (type) => type,
|
||||
}) {
|
||||
const where = label || `ui.${page}`;
|
||||
/* Where an error points. A page section is addressed by the id it was given;
|
||||
a capability response is already addressed by the capability it answers, so
|
||||
appending a generated section id there would name something the author
|
||||
never wrote. */
|
||||
const at = label ? where : null;
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
errors.push(`${where}: each section must be a mapping of options.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* An id is how a section is keyed and de-duplicated, not something an author
|
||||
should have to invent for a definition that declares exactly one. Falls
|
||||
back to the title, then to the skill's own id. */
|
||||
const slug = (value) => String(value || '')
|
||||
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
const id = slug(raw.id) || slug(raw.title) || slug(fallbackId);
|
||||
if (!id) {
|
||||
errors.push(`${where}: a section needs an \`id\`.`);
|
||||
return null;
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
||||
errors.push(`${at || `${where}.${id}`}: \`id\` must be lower-case letters, numbers and dashes.`);
|
||||
return null;
|
||||
}
|
||||
if (seen.has(id)) {
|
||||
errors.push(`${where}: two sections share the id \`${id}\`.`);
|
||||
return null;
|
||||
}
|
||||
seen.add(id);
|
||||
|
||||
const type = String(raw.type || '').trim();
|
||||
if (!type) {
|
||||
errors.push(`${at || `${where}.${id}`}: a section needs a \`type\`.`);
|
||||
return null;
|
||||
}
|
||||
if (!types.includes(type)) {
|
||||
errors.push(
|
||||
`Unsupported skill component: ${type}. Supported types: ${types.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* `position:` is what the format calls it; `placement:` is accepted because
|
||||
it is the word the rest of the system uses. A section that is an answer
|
||||
rather than a panel has nowhere to be placed, and says so with `null`. */
|
||||
let placement = null;
|
||||
if (wantPlacement) {
|
||||
const surface = surfaceFor(page);
|
||||
const declaredPlacement = String(raw.position || raw.placement || '').trim();
|
||||
placement = declaredPlacement || surface.placements[0];
|
||||
if (!surface.placements.includes(placement)) {
|
||||
errors.push(
|
||||
`Unsupported placement: ${placement} on ${page}. Supported placements: ${surface.placements.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* `data.source:` and a flat `source:` mean the same thing. The nested form
|
||||
groups options when a section grows; the flat form is what a one-section
|
||||
definition actually reads like, and refusing it would be the format being
|
||||
precious about punctuation. */
|
||||
const source = String(raw.data?.source ?? raw.source ?? '').trim();
|
||||
if (!source) {
|
||||
errors.push(`${at || `${where}.${id}`}: a section needs \`data.source\`.`);
|
||||
return null;
|
||||
}
|
||||
if (!SUPPORTED_DATA_SOURCES.includes(source)) {
|
||||
errors.push(
|
||||
`Unsupported data source: ${source}. Supported sources: ${SUPPORTED_DATA_SOURCES.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* A source knows which shapes it can fill. Asking for a timeline of something
|
||||
that has no dates is an authoring mistake worth naming now rather than
|
||||
rendering as an empty panel later. */
|
||||
const definition = dataSourceFor(source);
|
||||
/* `shapeFor` is how a type that is not a drawn component is exempted: a
|
||||
summary is prose, so there is no shape for a source to be incompatible
|
||||
with. Every drawn type maps to itself. */
|
||||
const shape = shapeFor(type);
|
||||
if (shape && definition.shapes && !definition.shapes.includes(shape)) {
|
||||
errors.push(
|
||||
`${at || `${where}.${id}`}: \`${source}\` cannot be shown as \`${type}\`. It supports: ${definition.shapes.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const periods = Array.isArray(raw.periods) ? raw.periods.map((p) => String(p).trim()) : [];
|
||||
const unknownPeriod = periods.find((p) => !SUPPORTED_PERIODS.includes(p));
|
||||
if (unknownPeriod) {
|
||||
errors.push(
|
||||
`Unsupported period: ${unknownPeriod}. Supported periods: ${SUPPORTED_PERIODS.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const limit = Number(raw.limit);
|
||||
|
||||
/**
|
||||
* Whether this section offers controls rather than a read-out.
|
||||
*
|
||||
* Two independent things must agree before anything is editable, and this is
|
||||
* the first: the definition asking for it, and the source declaring that it
|
||||
* can be written at all. The second is the page publishing a handler for that
|
||||
* source at render time. A definition asking to edit a reading the product
|
||||
* does not expose for writing is an authoring mistake worth naming here,
|
||||
* rather than a control that silently does nothing.
|
||||
*/
|
||||
const editable = raw.editable === true || raw.editable === 'true';
|
||||
if (editable && !isSourceWritable(source)) {
|
||||
errors.push(
|
||||
`${at || `${where}.${id}`}: \`${source}\` cannot be edited. It is a reading, not a setting.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: String(raw.title || '').trim() || null,
|
||||
description: String(raw.description || '').trim() || null,
|
||||
type,
|
||||
placement,
|
||||
source,
|
||||
/* Declared intent only. A section stays read-only wherever no page offers
|
||||
to accept the write — see `SkillSurface` and `SkillSectionBlock`. */
|
||||
editable,
|
||||
/* Context the page must supply for this source to resolve. */
|
||||
context: definition.context,
|
||||
periods,
|
||||
limit: Number.isFinite(limit) && limit > 0 ? Math.min(50, Math.round(limit)) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys that mean "this object *is* a section".
|
||||
*
|
||||
* How the two shapes are told apart. A definition may write its UI either way:
|
||||
*
|
||||
* ui: ui:
|
||||
* type: flow positions:
|
||||
* placement: … sections:
|
||||
* source: … - type: flow
|
||||
*
|
||||
* The first is one section applying to every page the skill declares; the
|
||||
* second addresses pages by name and can differ per page. Both are legitimate,
|
||||
* and the difference is structural — an object carrying `type` or `source` is a
|
||||
* section, an object whose keys are page names is a page map. Nothing is
|
||||
* decided by a skill id, and neither shape is privileged.
|
||||
*/
|
||||
const SECTION_KEYS = new Set([
|
||||
'id', 'type', 'title', 'description', 'placement', 'position', 'data', 'source', 'periods',
|
||||
'limit', 'editable',
|
||||
]);
|
||||
|
||||
const looksLikeSection = (value) =>
|
||||
Boolean(value)
|
||||
&& typeof value === 'object'
|
||||
&& !Array.isArray(value)
|
||||
&& Object.keys(value).some((key) => SECTION_KEYS.has(key));
|
||||
|
||||
/** The sections a page entry declares, in either the list or single-section form. */
|
||||
function sectionsOf(config) {
|
||||
if (Array.isArray(config?.sections)) return config.sections;
|
||||
if (Array.isArray(config)) return config;
|
||||
if (looksLikeSection(config)) return [config];
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole `ui:` block, normalized per page.
|
||||
*
|
||||
* Returns `{ ui, errors }`. `ui` holds only what validated, so a definition with
|
||||
* one bad section still registers its good ones — and the author still sees why
|
||||
* the other was refused.
|
||||
*/
|
||||
export function normalizeSkillUi(rawUi, { declaredPages = [], skillId = '' } = {}) {
|
||||
const errors = [];
|
||||
const ui = {};
|
||||
|
||||
if (rawUi == null) return { ui, errors };
|
||||
|
||||
if (typeof rawUi !== 'object' || Array.isArray(rawUi)) {
|
||||
return { ui, errors: ['`ui` must be a section, or a mapping of page names to sections.'] };
|
||||
}
|
||||
|
||||
const pages = declaredPages.map(canonicalPage).filter(Boolean);
|
||||
|
||||
/* Shorthand: one section, applied to every page the skill declares. The keys
|
||||
inside it are section options and are never read as page names — which is
|
||||
exactly what this branch exists to prevent. */
|
||||
if (looksLikeSection(rawUi)) {
|
||||
if (!pages.length) {
|
||||
return { ui, errors: ['`ui` is configured but the skill declares no `pages`.'] };
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
const section = normalizeSection(rawUi, {
|
||||
page, errors, seen: new Set(), fallbackId: skillId,
|
||||
});
|
||||
if (section) ui[page] = { sections: [section] };
|
||||
}
|
||||
return { ui, errors };
|
||||
}
|
||||
|
||||
/* Otherwise every key is a page name. */
|
||||
for (const [rawPage, config] of Object.entries(rawUi)) {
|
||||
const page = canonicalPage(rawPage);
|
||||
if (!page) {
|
||||
errors.push(
|
||||
`Unsupported page: ${rawPage}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* A page cannot be extended unless the skill also declares it. Otherwise a
|
||||
definition could render onto a surface it never said it applied to. */
|
||||
if (pages.length && !pages.includes(page)) {
|
||||
errors.push(`\`ui.${rawPage}\` is configured but \`${rawPage}\` is not listed under \`pages\`.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawSections = sectionsOf(config);
|
||||
if (!rawSections) {
|
||||
errors.push(`ui.${rawPage}: expected a \`sections\` list, or a single section.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const sections = rawSections
|
||||
.map((section) => normalizeSection(section, {
|
||||
page: rawPage, errors, seen, fallbackId: skillId,
|
||||
}))
|
||||
.filter(Boolean);
|
||||
|
||||
if (sections.length) ui[page] = { sections };
|
||||
}
|
||||
|
||||
return { ui, errors };
|
||||
}
|
||||
|
||||
/** Every section this skill contributes to a page, in declaration order. */
|
||||
export const sectionsForPage = (skill, page) => {
|
||||
const key = canonicalPage(page);
|
||||
return key ? skill?.ui?.[key]?.sections || [] : [];
|
||||
};
|
||||
|
||||
/** How many UI sections a definition registers, across every page. */
|
||||
export const countSections = (skill) =>
|
||||
Object.values(skill?.ui || {}).reduce((n, page) => n + (page.sections?.length || 0), 0);
|
||||
174
src/lib/skills/yaml.js
Normal file
174
src/lib/skills/yaml.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* The YAML subset skill frontmatter is allowed to use.
|
||||
*
|
||||
* Skills gained declarative UI configuration, which is nested — `ui:` holds a
|
||||
* page, which holds sections, which hold their own options. The old parser read
|
||||
* flat `key: value` pairs and one level of `- item`, so nesting was impossible
|
||||
* to express.
|
||||
*
|
||||
* This is deliberately a *subset*, not a YAML library:
|
||||
*
|
||||
* - block maps and block sequences, nested to any depth
|
||||
* - scalars: strings, integers, floats, booleans, null
|
||||
* - quoted strings, for values containing `:` or `#`
|
||||
* - `- key: value` — a mapping that starts on the dash
|
||||
* - `#` comments, and blank lines
|
||||
*
|
||||
* Everything else — anchors, aliases, merge keys, multi-document files, flow
|
||||
* mappings, block scalars, tags — is not supported and is not silently
|
||||
* half-read: an unparseable line raises, so a definition either means what it
|
||||
* says or is rejected with a line number.
|
||||
*
|
||||
* It returns plain data and nothing else. There is no code path from this file
|
||||
* to evaluation of any kind: no `eval`, no `Function`, no dynamic import, no
|
||||
* JSON with a reviver. A skill definition is configuration, and this is the
|
||||
* boundary that keeps it configuration.
|
||||
*/
|
||||
|
||||
/** `true` / `false` / `null` / numbers, or the string as written. */
|
||||
function toScalar(raw) {
|
||||
const value = String(raw).trim();
|
||||
|
||||
if (value === '' || value === '~' || value === 'null') return null;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
|
||||
/* Quoted: taken literally, which is how a value containing `:` or `#` is
|
||||
written. No escape processing beyond the doubled quote. */
|
||||
const quoted = /^(['"])([\s\S]*)\1$/.exec(value);
|
||||
if (quoted) return quoted[2].replace(new RegExp(quoted[1] + quoted[1], 'g'), quoted[1]);
|
||||
|
||||
if (/^-?\d+$/.test(value)) return Number(value);
|
||||
if (/^-?\d*\.\d+$/.test(value)) return Number(value);
|
||||
|
||||
/* An unquoted trailing comment is a comment. `#` inside a word is not. */
|
||||
return value.replace(/\s+#.*$/, '').trim();
|
||||
}
|
||||
|
||||
/** One line, reduced to what the parser needs to decide. */
|
||||
function readLines(source) {
|
||||
return String(source)
|
||||
.split(/\r?\n/)
|
||||
.map((text, i) => ({ text, line: i + 1 }))
|
||||
.filter(({ text }) => text.trim() !== '' && !/^\s*#/.test(text))
|
||||
.map(({ text, line }) => ({
|
||||
line,
|
||||
indent: text.match(/^\s*/)[0].replace(/\t/g, ' ').length,
|
||||
content: text.trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one block at `indent` or deeper, starting at `cursor.i`.
|
||||
*
|
||||
* Returns a map or an array depending on what the first line at this level is,
|
||||
* which is how YAML itself decides. Recursion handles nesting; the cursor is
|
||||
* shared so a child can consume the lines it owns.
|
||||
*/
|
||||
function parseBlock(lines, cursor, indent) {
|
||||
const first = lines[cursor.i];
|
||||
if (!first) return null;
|
||||
|
||||
return first.content.startsWith('- ')
|
||||
|| first.content === '-'
|
||||
? parseSequence(lines, cursor, indent)
|
||||
: parseMapping(lines, cursor, indent);
|
||||
}
|
||||
|
||||
function parseSequence(lines, cursor, indent) {
|
||||
const out = [];
|
||||
|
||||
while (cursor.i < lines.length) {
|
||||
const { content, indent: at, line } = lines[cursor.i];
|
||||
if (at < indent) break;
|
||||
if (at > indent) throw new Error(`Unexpected indentation on line ${line}`);
|
||||
if (!content.startsWith('-')) break;
|
||||
|
||||
const rest = content.replace(/^-\s*/, '');
|
||||
cursor.i += 1;
|
||||
|
||||
if (rest === '') {
|
||||
/* `-` alone: the item is the indented block beneath it. */
|
||||
out.push(cursor.i < lines.length && lines[cursor.i].indent > indent
|
||||
? parseBlock(lines, cursor, lines[cursor.i].indent)
|
||||
: null);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* `- key: value` opens a mapping whose first key sits on the dash. The
|
||||
remaining keys are indented to where that key started. */
|
||||
const pair = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(rest);
|
||||
if (pair) {
|
||||
const keyIndent = indent + (content.length - rest.length);
|
||||
const item = {};
|
||||
const [, key, value] = pair;
|
||||
|
||||
item[key] = value === ''
|
||||
&& cursor.i < lines.length
|
||||
&& lines[cursor.i].indent > indent
|
||||
? parseBlock(lines, cursor, lines[cursor.i].indent)
|
||||
: toScalar(value);
|
||||
|
||||
while (cursor.i < lines.length && lines[cursor.i].indent === keyIndent
|
||||
&& !lines[cursor.i].content.startsWith('- ')) {
|
||||
Object.assign(item, parseMapping(lines, cursor, keyIndent));
|
||||
}
|
||||
out.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(toScalar(rest));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseMapping(lines, cursor, indent) {
|
||||
const out = {};
|
||||
|
||||
while (cursor.i < lines.length) {
|
||||
const { content, indent: at, line } = lines[cursor.i];
|
||||
if (at < indent) break;
|
||||
if (at > indent) throw new Error(`Unexpected indentation on line ${line}`);
|
||||
if (content.startsWith('- ')) break;
|
||||
|
||||
const pair = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(content);
|
||||
if (!pair) throw new Error(`Line ${line} is not \`key: value\`: ${content}`);
|
||||
|
||||
const [, key, value] = pair;
|
||||
cursor.i += 1;
|
||||
|
||||
if (value !== '') {
|
||||
out[key] = toScalar(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* An empty value means the value is the block below — or nothing. */
|
||||
const next = lines[cursor.i];
|
||||
out[key] = next && next.indent > indent
|
||||
? parseBlock(lines, cursor, next.indent)
|
||||
: null;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A YAML document, as plain data.
|
||||
*
|
||||
* Throws on anything it cannot read rather than guessing, so a malformed
|
||||
* definition is reported to its author instead of being registered in a shape
|
||||
* nobody intended.
|
||||
*/
|
||||
export function parseYaml(source) {
|
||||
const lines = readLines(source);
|
||||
if (!lines.length) return {};
|
||||
|
||||
const cursor = { i: 0 };
|
||||
const value = parseBlock(lines, cursor, lines[0].indent);
|
||||
|
||||
if (cursor.i < lines.length) {
|
||||
throw new Error(`Unexpected indentation on line ${lines[cursor.i].line}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user