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

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