update owliver skill

This commit is contained in:
2026-08-14 17:33:16 +05:30
parent cccada9bd2
commit 4fdfb90326
58 changed files with 8015 additions and 1339 deletions

315
src/lib/hiringRecords.js Normal file
View 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;
}

View File

@@ -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),
}),
/**

View File

@@ -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 ?? '');

View 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.' };
}
}

View 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,
};
}

View 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);
}

View File

@@ -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}?` },
]);

View File

@@ -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
View 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
View 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
View 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;
}