update owliver skill
This commit is contained in:
478
src/lib/skills/surfaces.js
Normal file
478
src/lib/skills/surfaces.js
Normal file
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* What a skill definition is allowed to say about the product.
|
||||
*
|
||||
* A skill can extend a KROW page: name a surface, declare a section, and the
|
||||
* page renders it. That only stays safe — and only stays a *product* rather
|
||||
* than a scripting host — because the vocabulary is closed. Everything a
|
||||
* definition may name is in this file: the surfaces, the placements on each
|
||||
* surface, the component types, and the data sources.
|
||||
*
|
||||
* The rule that makes it safe: **nothing here is code, and nothing here is
|
||||
* looked up dynamically from the file.** A definition names a key; this module
|
||||
* says whether that key exists; the renderer maps it to a component the app
|
||||
* already ships. A definition that names something absent is rejected with a
|
||||
* message, never rendered as an unknown thing and never executed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The surfaces a skill can extend.
|
||||
*
|
||||
* `aliases` keep the page keys the existing skills already use — `hired`,
|
||||
* `university` — working under the names the product now shows, so the eight
|
||||
* definitions on disk did not have to be rewritten to gain this feature.
|
||||
*/
|
||||
export const SKILL_SURFACES = [
|
||||
{
|
||||
id: 'control-center',
|
||||
label: 'Control Center',
|
||||
route: '/admin',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'positions',
|
||||
label: 'Positions',
|
||||
route: '/admin/positions',
|
||||
/* Mounted in three places, because Positions is three experiences: the page
|
||||
that lists the roles, the card for each one, and the drawer "View
|
||||
position" opens.
|
||||
|
||||
`after-position-list-summary` and `after-position-list` are the *page*:
|
||||
they render once, above and below the grid, with no position in context.
|
||||
`after-position-card` renders inside each card. The rest render inside
|
||||
the drawer and on the full position page, where one position is being
|
||||
read. Every one of these is mounted — a placement the vocabulary offers
|
||||
and no page provides is a definition that validates and then silently
|
||||
does nothing. */
|
||||
placements: [
|
||||
'after-position-list-summary',
|
||||
'after-position-list',
|
||||
'after-header',
|
||||
'after-position-card',
|
||||
'after-position-summary',
|
||||
'before-candidates',
|
||||
'after-candidates',
|
||||
'before-footer',
|
||||
],
|
||||
},
|
||||
{
|
||||
/* The authoring form, which is a surface in its own right: what a skill has
|
||||
to say there is about the position being specified, not about the ones
|
||||
that already exist. Its placements follow the form's own three parts, so
|
||||
a definition can sit beside the field group it is about. */
|
||||
id: 'create-position',
|
||||
label: 'Create Position',
|
||||
route: '/admin/positions/new',
|
||||
aliases: ['new-position'],
|
||||
placements: [
|
||||
'after-header',
|
||||
'after-job-description',
|
||||
'after-vetting-weights',
|
||||
'before-footer',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'candidates',
|
||||
label: 'Candidates',
|
||||
route: '/admin/candidates',
|
||||
placements: ['after-header', 'after-candidate-summary', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'hired-history',
|
||||
label: 'Hired History',
|
||||
route: '/admin/hired',
|
||||
aliases: ['hired'],
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'talent-pool',
|
||||
label: 'Talent Pool',
|
||||
route: '/admin/talent-pool',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'krow-forge',
|
||||
label: 'KROW Forge',
|
||||
route: '/admin/university',
|
||||
aliases: ['university', 'forge'],
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Analytics',
|
||||
route: '/admin/analytics',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
route: '/admin/activity',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
/* Not in the eight product surfaces, but skills already attach to it and the
|
||||
account page reads them. Kept so nothing that works today stops working. */
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profile',
|
||||
route: '/admin/profile',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'candidates-analysis',
|
||||
label: 'Candidate Analysis',
|
||||
route: '/admin/candidates-analysis',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
];
|
||||
|
||||
const BY_KEY = new Map();
|
||||
for (const surface of SKILL_SURFACES) {
|
||||
BY_KEY.set(surface.id, surface);
|
||||
for (const alias of surface.aliases || []) BY_KEY.set(alias, surface);
|
||||
}
|
||||
|
||||
/** Every name a definition may use for a surface, for error messages. */
|
||||
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
|
||||
|
||||
/** The surface a declared page name refers to, or null. */
|
||||
export const surfaceFor = (page) => BY_KEY.get(String(page || '').trim()) || null;
|
||||
|
||||
/** The canonical id for a declared page name — `hired` → `hired-history`. */
|
||||
export const canonicalPage = (page) => surfaceFor(page)?.id || null;
|
||||
|
||||
/**
|
||||
* The surface an Admin route belongs to — `/admin/positions/new` →
|
||||
* `create-position`.
|
||||
*
|
||||
* The surfaces already carry their routes, so this reads the answer off the
|
||||
* table rather than deriving a page key from the path a second time. That
|
||||
* matters for the surfaces whose key is not their path tail: without it,
|
||||
* `/admin/positions/new` would key as `positions/new`, which is a page nothing
|
||||
* declares and no definition could attach to.
|
||||
*/
|
||||
export const surfaceForRoute = (route) =>
|
||||
SKILL_SURFACES.find((s) => s.route === String(route || '').trim()) || null;
|
||||
|
||||
/**
|
||||
* The section types a definition may ask for.
|
||||
*
|
||||
* Each entry names a component the application already ships. A type is a key
|
||||
* in this table and nothing else: there is no path from a definition to a
|
||||
* component that is not listed here, which is what stops `type:` from being an
|
||||
* import statement in disguise.
|
||||
*/
|
||||
export const SECTION_TYPES = [
|
||||
{ id: 'card', label: 'Card', summary: 'A titled panel of prose and figures.' },
|
||||
{ id: 'stats', label: 'Stats', summary: 'A row of counted figures.' },
|
||||
{ id: 'list', label: 'List', summary: 'A ranked or plain list of records.' },
|
||||
{ id: 'timeline', label: 'Timeline', summary: 'Dated events, most recent first.' },
|
||||
{ id: 'flow', label: 'Flow', summary: 'A sequence of stages or periods.' },
|
||||
{ id: 'table', label: 'Table', summary: 'Rows and columns.' },
|
||||
{ id: 'progress', label: 'Progress', summary: 'Bars against a total.' },
|
||||
{ id: 'insight', label: 'Insight', summary: 'One finding, stated plainly.' },
|
||||
/* Weighted criteria that share a budget. Distinct from `progress`, which is
|
||||
bars against an independent maximum: these bars are shares of one total,
|
||||
and the total is a fact about the set rather than about any one row. */
|
||||
{ id: 'weights', label: 'Weights', summary: 'Weighted criteria as shares of one total.' },
|
||||
];
|
||||
|
||||
export const SUPPORTED_SECTION_TYPES = SECTION_TYPES.map((t) => t.id);
|
||||
|
||||
/**
|
||||
* What a definition may ask *Owliver* to do with the same reading.
|
||||
*
|
||||
* A skill declares `ui:` for the page and `owliver:` for the panel, and both
|
||||
* name the same data source. A capability is the second half of that: the shape
|
||||
* the answer takes when it is asked for in conversation rather than rendered on
|
||||
* the page.
|
||||
*
|
||||
* `shape` is the section type the answer is drawn with, so `flow` in a chat
|
||||
* reply is the *same* component the page renders — there is one flow renderer,
|
||||
* not one per consumer. `summary` has no shape because a summary is prose: the
|
||||
* figures are read back as sentences rather than drawn.
|
||||
*
|
||||
* `terms` are how a question is recognised as asking for this shape. They are
|
||||
* deliberately about the *shape* and never about a subject: "as a flow" belongs
|
||||
* here, "hiring activity" belongs in a definition's `triggers`. That split is
|
||||
* what keeps this table closed while the skills stay open.
|
||||
*/
|
||||
export const OWLIVER_CAPABILITIES = [
|
||||
{
|
||||
id: 'summary',
|
||||
label: 'Summary',
|
||||
shape: null,
|
||||
summary: 'Reads the figures back as sentences.',
|
||||
terms: ['summary', 'summarise', 'summarize', 'summarised', 'summarized', 'summarising',
|
||||
'summarizing', 'sum up', 'recap', 'overview', 'brief me', 'in short', 'tell me about',
|
||||
'what is the', 'how is'],
|
||||
},
|
||||
{
|
||||
id: 'flow',
|
||||
label: 'Flow',
|
||||
shape: 'flow',
|
||||
summary: 'Draws the stages or periods as a sequence.',
|
||||
terms: ['flow', 'as a flow', 'chart', 'graph', 'diagram', 'funnel', 'stages', 'visual',
|
||||
'visualise', 'visualize', 'step by step'],
|
||||
},
|
||||
{
|
||||
id: 'stats',
|
||||
label: 'Stats',
|
||||
shape: 'stats',
|
||||
summary: 'A row of counted figures.',
|
||||
terms: ['stats', 'statistics', 'figures', 'numbers', 'counts', 'how many'],
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
label: 'List',
|
||||
shape: 'list',
|
||||
summary: 'A ranked or plain list of records.',
|
||||
terms: ['list', 'who are', 'which ones', 'show me the records'],
|
||||
},
|
||||
{
|
||||
id: 'table',
|
||||
label: 'Table',
|
||||
shape: 'table',
|
||||
summary: 'Rows and columns.',
|
||||
terms: ['table', 'as a table', 'rows', 'grid', 'spreadsheet'],
|
||||
},
|
||||
{
|
||||
id: 'timeline',
|
||||
label: 'Timeline',
|
||||
shape: 'timeline',
|
||||
summary: 'Dated events, most recent first.',
|
||||
terms: ['timeline', 'history', 'chronology', 'over time', 'what happened'],
|
||||
},
|
||||
{
|
||||
id: 'progress',
|
||||
label: 'Progress',
|
||||
shape: 'progress',
|
||||
summary: 'Bars against a total.',
|
||||
terms: ['progress', 'bars', 'completion', 'how far'],
|
||||
},
|
||||
{
|
||||
id: 'weights',
|
||||
label: 'Weights',
|
||||
shape: 'weights',
|
||||
summary: 'The weighted criteria, adjustable when the page accepts the write.',
|
||||
terms: ['weight', 'weights', 'weighting', 'weightings', 'importance', 'balance',
|
||||
'set the weights', 'adjust the weights', 'screening weight', 'vetting weight',
|
||||
'criteria'],
|
||||
},
|
||||
{
|
||||
id: 'insight',
|
||||
label: 'Insight',
|
||||
shape: 'insight',
|
||||
summary: 'One finding, stated plainly.',
|
||||
terms: ['insight', 'finding', 'takeaway', 'headline', 'what stands out'],
|
||||
},
|
||||
{
|
||||
id: 'card',
|
||||
label: 'Card',
|
||||
shape: 'card',
|
||||
summary: 'A titled panel of figures.',
|
||||
terms: ['card', 'panel', 'at a glance'],
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORTED_OWLIVER_CAPABILITIES = OWLIVER_CAPABILITIES.map((c) => c.id);
|
||||
|
||||
/** The capability a declared name refers to, or null. */
|
||||
export const owliverCapabilityFor = (id) =>
|
||||
OWLIVER_CAPABILITIES.find((c) => c.id === String(id || '').trim()) || null;
|
||||
|
||||
/** `flow` → `Flow`. */
|
||||
export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label || id;
|
||||
|
||||
/**
|
||||
* The data a section may ask for.
|
||||
*
|
||||
* Each source is a named reading of data the application already holds, and
|
||||
* `context` says what a page must know for the reading to be possible — a
|
||||
* position id, a candidate id, or nothing. A source is resolved by
|
||||
* `dataResolver.js`; a definition cannot reach a store directly, cannot write,
|
||||
* and cannot name a field that is not offered here.
|
||||
*/
|
||||
export const DATA_SOURCES = [
|
||||
{
|
||||
id: 'position.activity',
|
||||
label: 'Position activity',
|
||||
context: 'positionId',
|
||||
summary: 'Applications to this position, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'position.pipeline',
|
||||
label: 'Position pipeline',
|
||||
context: 'positionId',
|
||||
summary: 'Applied → screened → shortlisted → interviewed → hired.',
|
||||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'position.candidates',
|
||||
label: 'Position candidates',
|
||||
context: 'positionId',
|
||||
summary: 'Candidates matched to this position, best first.',
|
||||
shapes: ['list', 'table', 'stats', 'card'],
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Who could actually do this work, ranked.
|
||||
*
|
||||
* Distinct from `position.candidates`, which lists the people who *applied
|
||||
* here*. This one reads the whole candidate pool against what the position
|
||||
* states — so a role published a minute ago, with no applications at all,
|
||||
* still has an answer. The reading is `poolFor` in `lib/workforce.js`, the
|
||||
* same engine the panel's workforce conversation and the position page use;
|
||||
* nothing about matching is decided in the resolver.
|
||||
*/
|
||||
id: 'position.matches',
|
||||
label: 'Position candidate matches',
|
||||
context: 'positionId',
|
||||
summary: 'The candidate pool scored against this position, best first.',
|
||||
shapes: ['list', 'table', 'stats', 'card', 'insight'],
|
||||
},
|
||||
{
|
||||
id: 'position.requirements',
|
||||
label: 'Position requirements',
|
||||
context: 'positionId',
|
||||
summary: 'What this position states it needs.',
|
||||
shapes: ['list', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'candidate.readiness',
|
||||
label: 'Candidate readiness',
|
||||
context: 'candidateId',
|
||||
summary: 'Screening dimensions for one candidate.',
|
||||
shapes: ['progress', 'stats', 'list', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'candidate.activity',
|
||||
label: 'Candidate activity',
|
||||
context: 'candidateId',
|
||||
summary: 'What has happened on this candidate’s record.',
|
||||
shapes: ['timeline', 'list', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'candidates.pipeline',
|
||||
label: 'Candidate pipeline',
|
||||
context: null,
|
||||
summary: 'Every candidate, counted by stage.',
|
||||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'candidates.activity',
|
||||
label: 'Candidate activity',
|
||||
context: null,
|
||||
summary: 'Applications across the workspace, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'positions.demand',
|
||||
label: 'Position demand',
|
||||
context: null,
|
||||
summary: 'Open positions and what they still need.',
|
||||
shapes: ['list', 'table', 'stats', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'workforce.training',
|
||||
label: 'Workforce training',
|
||||
context: null,
|
||||
summary: 'Training paths and progress against them.',
|
||||
shapes: ['progress', 'list', 'stats', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
/* The vetting weights a position is being specified with. `positionId`
|
||||
context, but the "position" on Create Position is the draft in the form
|
||||
rather than a saved record — which is the point: the resolver reads the
|
||||
same field either way, so one source serves the form and the position it
|
||||
becomes. */
|
||||
id: 'position.vetting',
|
||||
label: 'Position vetting weights',
|
||||
context: 'positionId',
|
||||
summary: 'How this position weights each screening criterion.',
|
||||
shapes: ['weights', 'flow', 'progress', 'stats', 'table', 'card', 'insight'],
|
||||
/**
|
||||
* This reading can be written back.
|
||||
*
|
||||
* `writable` is what lets a definition declare `editable: true` and get
|
||||
* controls instead of a read-out. It is a property of the *source*, not of
|
||||
* the definition — a skill cannot make a reading writable by asking, and a
|
||||
* source with no page willing to accept the write simply renders read-only.
|
||||
* That keeps the closed vocabulary closed in both directions.
|
||||
*/
|
||||
writable: true,
|
||||
writeSummary: 'Sets the screening weights on the position being specified.',
|
||||
},
|
||||
{
|
||||
id: 'hires.recent',
|
||||
label: 'Recent hires',
|
||||
context: null,
|
||||
summary: 'Who was hired, for which role, and when.',
|
||||
shapes: ['list', 'table', 'timeline', 'stats', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'hires.performance',
|
||||
label: 'Hiring performance',
|
||||
context: null,
|
||||
summary: 'Hires, time-to-hire, quality and conversion, counted together.',
|
||||
shapes: ['stats', 'card', 'table', 'flow', 'insight'],
|
||||
},
|
||||
{
|
||||
id: 'activity.events',
|
||||
label: 'Workspace activity',
|
||||
context: null,
|
||||
summary: 'What has happened across the workspace, most recent first.',
|
||||
shapes: ['timeline', 'list', 'table', 'stats', 'card'],
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id);
|
||||
|
||||
export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null;
|
||||
|
||||
/**
|
||||
* Can this reading be written back?
|
||||
*
|
||||
* The one question `editable:` is checked against. A page opts in by publishing
|
||||
* a handler for the source (see `usePublishPageActions`); a definition opts in
|
||||
* by declaring `editable: true`. Both have to hold before a control is drawn,
|
||||
* so neither the author nor the page can enable editing on its own.
|
||||
*/
|
||||
export const isSourceWritable = (id) => Boolean(dataSourceFor(id)?.writable);
|
||||
|
||||
/**
|
||||
* The periods a time-based section may ask for.
|
||||
*
|
||||
* Only the ones the data layer can actually compute from record timestamps.
|
||||
* A period is a window over `created_date`, resolved at read time against the
|
||||
* current date — never a stored figure and never a hard-coded date.
|
||||
*/
|
||||
export const PERIODS = [
|
||||
{ id: 'today', label: 'Today' },
|
||||
{ id: 'yesterday', label: 'Yesterday' },
|
||||
{ id: 'last-7-days', label: 'Last 7 days' },
|
||||
{ id: 'last-week', label: 'Last week' },
|
||||
{ id: 'this-month', label: 'This month' },
|
||||
{ id: 'previous-month', label: 'Previous month' },
|
||||
];
|
||||
|
||||
export const SUPPORTED_PERIODS = PERIODS.map((p) => p.id);
|
||||
|
||||
export const periodLabel = (id) => PERIODS.find((p) => p.id === id)?.label || id;
|
||||
|
||||
/* ── Human labels ───────────────────────────────────────────────────────────
|
||||
The vocabulary is written in kebab-case because it is configuration; it is
|
||||
read by people, so every id has a label. One place, so the preview, the
|
||||
rendered section and any future surface all say the same words. */
|
||||
|
||||
/** `flow` → `Flow`. */
|
||||
export const sectionTypeLabel = (id) =>
|
||||
SECTION_TYPES.find((t) => t.id === id)?.label || id;
|
||||
|
||||
/** `position.activity` → `Position activity`. */
|
||||
export const dataSourceLabel = (id) => dataSourceFor(id)?.label || id;
|
||||
|
||||
/** `after-position-summary` → `After position summary`. */
|
||||
export const placementLabel = (id) => {
|
||||
const words = String(id || '').replace(/-/g, ' ').trim();
|
||||
return words ? words[0].toUpperCase() + words.slice(1) : id;
|
||||
};
|
||||
Reference in New Issue
Block a user