update Markdown skills
This commit is contained in:
@@ -42,13 +42,21 @@ export function usePreferences() {
|
||||
return { ...base44.auth.preferences(), ...(user?.preferences || {}) };
|
||||
}
|
||||
|
||||
/** Writes one or more preferences and refreshes every consumer of `['user']`. */
|
||||
/**
|
||||
* Writes one or more preferences and refreshes every consumer of `['user']`.
|
||||
*
|
||||
* The mutation resolves to `{ user, persisted, error }`, not to the user — a
|
||||
* caller storing something it needs back after a reload, which is every caller
|
||||
* writing `customSkills`, has to be able to tell a write that landed from one
|
||||
* the browser refused. The cache is updated either way: the change is real for
|
||||
* this session even when it could not be stored.
|
||||
*/
|
||||
export function useUpdatePreferences() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updatePreferences(patch),
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(['user'], user);
|
||||
onSuccess: /** @param {any} result */ (result) => {
|
||||
queryClient.setQueryData(['user'], result.user);
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,7 +31,16 @@ const frontMatter = ({ id, name, description, pages, fallback }) => [
|
||||
/** 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 = [],
|
||||
/**
|
||||
* `candidates.activity`, not `position.activity`.
|
||||
*
|
||||
* The default placement on the default page is `after-position-list-summary`
|
||||
* — above the grid, with no position in context — so the old default composed
|
||||
* a definition that drew a titled card and then reported that it needed a
|
||||
* record the page never had. A template must produce something that works
|
||||
* before it is edited, so the default reads a source that needs nothing.
|
||||
*/
|
||||
type = 'flow', placement = '', source = 'candidates.activity', periods = [],
|
||||
} = {}) => `---
|
||||
${frontMatter({
|
||||
id,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
import { parseYaml } from './yaml';
|
||||
import { normalizeSkillUi } from './uiConfig';
|
||||
import { normalizeSkillUi, slugify } from './uiConfig';
|
||||
import { normalizeSkillOwliver } from './owliverConfig';
|
||||
import { SUPPORTED_SKILL_PAGES, canonicalPage, surfaceFor, surfaceForRoute } from './surfaces';
|
||||
import {
|
||||
SUPPORTED_SKILL_PAGES, canonicalPage, contextLabel, placementProvides, surfaceFor,
|
||||
surfaceForRoute,
|
||||
} from './surfaces';
|
||||
|
||||
/**
|
||||
* Owliver skill registry.
|
||||
@@ -30,14 +33,39 @@ const FILES = import.meta.glob('/src/skills/**/*.md', { query: '?raw', import: '
|
||||
* 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 };
|
||||
/**
|
||||
* A definition's text, as the parser needs to see it.
|
||||
*
|
||||
* Files arrive from editors, from Windows, from copy-paste and from downloads,
|
||||
* and four of the things they arrive with used to take the entire frontmatter
|
||||
* block down: a UTF-8 byte-order mark before the opening fence, a blank line
|
||||
* above it, `\r\n` line endings, and trailing spaces after `---`. In every one
|
||||
* of those cases the fence did not match, `parseFrontmatter` returned an empty
|
||||
* record, and the definition registered as `Untitled skill` with no pages —
|
||||
* the file was read, and none of it was believed.
|
||||
*
|
||||
* None of this is a lenient parser: the YAML subset inside the fences is as
|
||||
* strict as it ever was. This is only about recognising that a fence is a
|
||||
* fence.
|
||||
*/
|
||||
export const normalizeDefinition = (raw) => String(raw ?? '')
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/^\s*\n+/, '');
|
||||
|
||||
/** Whether this text opens with a frontmatter block at all. */
|
||||
export const hasFrontmatter = (raw) => /^---[ \t]*\n[\s\S]*?\n---[ \t]*(?=\n|$)/
|
||||
.test(normalizeDefinition(raw));
|
||||
|
||||
export function parseFrontmatter(raw) {
|
||||
const text = normalizeDefinition(raw);
|
||||
const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?=\n|$)/.exec(text);
|
||||
if (!match) return { data: {}, body: text };
|
||||
|
||||
const data = parseYaml(match[1]);
|
||||
return {
|
||||
data: data && typeof data === 'object' && !Array.isArray(data) ? data : {},
|
||||
body: raw.slice(match[0].length).trim(),
|
||||
body: text.slice(match[0].length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,8 +289,21 @@ export function skillFacets({ data = {}, kind, ui = {}, owliver, conversation =
|
||||
export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
{
|
||||
const { data, body } = parseFrontmatter(raw);
|
||||
const pages = Array.isArray(data.pages) ? data.pages : [];
|
||||
const id = data.id || path.split('/').pop().replace(/\.md$/, '');
|
||||
const declaredPages = Array.isArray(data.pages) ? data.pages : [];
|
||||
/**
|
||||
* The definition's id.
|
||||
*
|
||||
* `id:` when it is written, and it always wins — an explicit id is an
|
||||
* address other definitions and stored preferences refer to, and deriving
|
||||
* over the top of one would silently rename a skill.
|
||||
*
|
||||
* The fallback used to be the filename, which is right for a file in
|
||||
* `src/skills/` and wrong for everything else: an uploaded definition is
|
||||
* parsed with the placeholder path `custom`, so a file omitting `id:` was
|
||||
* registered as the skill `custom` and the ID field filled in with the word
|
||||
* "custom". Slugging the name is what an author means by leaving it out.
|
||||
*/
|
||||
const id = data.id || slugify(data.name) || path.split('/').pop().replace(/\.md$/, '');
|
||||
const levels = sectionLevels(body);
|
||||
|
||||
/**
|
||||
@@ -280,10 +321,21 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
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,
|
||||
declaredPages,
|
||||
skillId: id,
|
||||
});
|
||||
|
||||
/**
|
||||
* Where this definition applies.
|
||||
*
|
||||
* `pages:` when it is written. When it is not, the pages its `ui:` entries
|
||||
* name — because a definition that says "put this on Positions and that on
|
||||
* Analytics" has already declared its reach, and making it repeat the list
|
||||
* above the block is the format asking twice. A definition that declares
|
||||
* neither still has none, which is what `validateSkillSource` refuses on.
|
||||
*/
|
||||
const pages = declaredPages.length ? declaredPages : Object.keys(ui);
|
||||
|
||||
/* 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
|
||||
@@ -474,6 +526,27 @@ export function readSkillRegistry(customSources = []) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A section that registered perfectly and can never draw anything.
|
||||
*
|
||||
* The same check `validateSkillSource` refuses on, run again at load — because
|
||||
* a definition stored before the rule existed was validated under the old one
|
||||
* and is never re-checked. Without this it stays in the workspace as a titled
|
||||
* card reporting that it needs a record the page has no way of giving it, and
|
||||
* the Skills page calls the workspace healthy.
|
||||
*/
|
||||
for (const skill of skills) {
|
||||
for (const problem of unresolvableSections(skill)) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unresolvable',
|
||||
path: skill.path,
|
||||
skillId: skill.id,
|
||||
message: problem.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two definitions claiming one phrase on one page.
|
||||
*
|
||||
@@ -530,6 +603,44 @@ export function skillDiagnostics(customSources = []) {
|
||||
return readSkillRegistry(customSources).diagnostics;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sections that can never resolve where they are attached.
|
||||
*
|
||||
* A source declares the record it needs; a placement either hands one over or
|
||||
* does not. Nothing compared the two, so the commonest authoring mistake in the
|
||||
* product — `position.activity`, the Board editor's own default, on a page with
|
||||
* no position — validated cleanly, registered, drew its title and then reported
|
||||
* "This section needs a position to read" for good. The definition was never
|
||||
* wrong about anything the product had told it to care about.
|
||||
*
|
||||
* Deliberately *only* the `ui:` half. An Owliver response with an unmet need is
|
||||
* not a dead panel: `resolveEntity` asks which position is meant, and answers
|
||||
* once told — which is why `hiring-activity-assistant` reads `position.activity`
|
||||
* on Positions and works. A card cannot ask. That asymmetry is the reason one
|
||||
* is refused and the other is left alone.
|
||||
*/
|
||||
export function unresolvableSections(skill) {
|
||||
const problems = [];
|
||||
|
||||
for (const [page, config] of Object.entries(skill?.ui || {})) {
|
||||
for (const section of config.sections || []) {
|
||||
if (!section.context) continue;
|
||||
if (placementProvides(page, section.placement).includes(section.context)) continue;
|
||||
problems.push({
|
||||
page,
|
||||
placement: section.placement,
|
||||
source: section.source,
|
||||
message: `\`${section.source}\` needs ${contextLabel(section.context)} to read, and `
|
||||
+ `\`${page}\` supplies none at \`${section.placement}\`. `
|
||||
+ `Attach it to a placement that does, or read a source that needs nothing.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a definition before it is stored. Returns an error string or null.
|
||||
*
|
||||
@@ -565,6 +676,29 @@ export function validateSkillSource(raw) {
|
||||
something Owliver offers and then cannot answer. */
|
||||
if (skill.owliverErrors?.length) return skill.owliverErrors[0];
|
||||
|
||||
/* A section attached where its source can never be read. See
|
||||
`unresolvableSections` — this is the difference between a definition that
|
||||
is wrong and one that merely looks right. */
|
||||
const unresolvable = unresolvableSections(skill);
|
||||
if (unresolvable.length) return unresolvable[0].message;
|
||||
|
||||
/**
|
||||
* An Owliver block that can answer nothing.
|
||||
*
|
||||
* `owliver: enabled: true` with no capabilities is what the template produces
|
||||
* before an author fills anything in. It registers, claims its own name as a
|
||||
* trigger, can take a question from a definition written to answer it, and
|
||||
* then reads its own description back. Refused here rather than saved and
|
||||
* reported later as "the skill does not work".
|
||||
*/
|
||||
if (skill.owliver?.enabled
|
||||
&& !skill.owliver.capabilities.length
|
||||
&& !skill.conversation.length
|
||||
&& !skill.actions.length) {
|
||||
return 'This skill declares no capabilities, so Owliver could only read its description '
|
||||
+ 'back. Add a capability, or remove the `owliver:` block.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
32
src/lib/skills/saveFeedback.js
Normal file
32
src/lib/skills/saveFeedback.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { toast } from '@/components/ds';
|
||||
|
||||
/**
|
||||
* Mutation options that tell the truth about whether a write survived.
|
||||
*
|
||||
* Every skill save reported success the moment the in-memory record changed,
|
||||
* because that is all `updatePreferences` used to be able to report. A browser
|
||||
* that refused the write — quota, private browsing, an eviction — produced a
|
||||
* green toast, a populated list, and nothing at all after a reload. A definition
|
||||
* an author spent ten minutes on disappeared with no event they could point at.
|
||||
*
|
||||
* The change is not made conditional on the write: it is real for this session
|
||||
* either way, and refusing to apply it would be worse. What changes is that the
|
||||
* author is told which of the two happened while they can still do something
|
||||
* about it.
|
||||
*/
|
||||
export function reportSave(message) {
|
||||
return {
|
||||
/** @param {any} result */
|
||||
onSuccess: (result) => {
|
||||
if (result?.persisted === false) {
|
||||
toast.error(
|
||||
`${message} — but this browser would not store it, so it will be gone on reload. `
|
||||
+ 'Export it from the Skills page first.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.success(message);
|
||||
},
|
||||
onError: () => toast.error('That could not be saved.'),
|
||||
};
|
||||
}
|
||||
406
src/lib/skills/skillFields.js
Normal file
406
src/lib/skills/skillFields.js
Normal file
@@ -0,0 +1,406 @@
|
||||
import { hasFrontmatter, normalizeDefinition, parseFrontmatter, parseSkill } from './registry';
|
||||
|
||||
/**
|
||||
* A definition, read into editor fields — and edited fields, written back.
|
||||
*
|
||||
* Two things used to be missing here, and between them they are the whole of
|
||||
* "uploading a `.md` does nothing" and "the form fields do not work":
|
||||
*
|
||||
* - **Nothing read a file into the fields.** All three upload buttons set the
|
||||
* Markdown and stopped, so an author who uploaded a complete definition was
|
||||
* looking at a form full of empty boxes beside it. The two editors each had
|
||||
* a reader that did exactly this job — `metaFromSource` and
|
||||
* `draftFromSource` — and neither was reachable from the upload path. They
|
||||
* live here now, so one file is read the same way wherever it arrives.
|
||||
* - **Nothing wrote the fields back.** Both editors regenerated the whole
|
||||
* definition from a template while the Markdown was untouched, and stopped
|
||||
* the moment it was touched — which is every edit of an existing skill,
|
||||
* because `touched` starts true there. Typing a new name updated React
|
||||
* state that nothing saved. `patchFrontmatter` is the missing half: a field
|
||||
* edits the key it owns, in place, and the body is never rewritten.
|
||||
*
|
||||
* Everything here reads the *registered* form of a definition — what
|
||||
* `parseSkill` made of it — rather than the raw text. The form therefore shows
|
||||
* what the product will actually do, including the placement an author left out
|
||||
* and the vocabulary that filled it in.
|
||||
*/
|
||||
|
||||
/* ── Reading a definition into fields ───────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A definition, or nothing.
|
||||
*
|
||||
* `parseSkill` is deliberately forgiving: a file with no frontmatter at all
|
||||
* still parses, taking its id from the path it was given. That is right for the
|
||||
* registry, and wrong here — a text file dropped on the upload button would
|
||||
* otherwise fill the ID field with `custom` and the name with `Untitled skill`,
|
||||
* which reads as a definition the product understood. A form is filled from a
|
||||
* definition or it is left alone.
|
||||
*/
|
||||
function readSkill(source) {
|
||||
/* `hasFrontmatter`, not a second regex of this module's own. The two used to
|
||||
be written out separately and drifted: a file the registry was willing to
|
||||
read could be one this rejected, and rejecting it here empties a form the
|
||||
preview beside it has just filled in. One detector, one answer. */
|
||||
if (!hasFrontmatter(source)) return null;
|
||||
return parseSkill(source, { custom: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* A definition, cleaned up on the way in.
|
||||
*
|
||||
* Re-exported under the name the editors use, so an uploaded file is stored in
|
||||
* the form every reader already agrees on rather than carrying a byte-order
|
||||
* mark into `patchFrontmatter`, which would not recognise the fence and would
|
||||
* write a second one above it.
|
||||
*/
|
||||
export const normalizeUpload = normalizeDefinition;
|
||||
|
||||
/** Whether a definition can be read into the fields at all. */
|
||||
export const isReadableDefinition = (source) => hasFrontmatter(source);
|
||||
|
||||
/** The Board (UI) editor's fields, before a definition is loaded into them. */
|
||||
export const EMPTY_BOARD_FIELDS = {
|
||||
id: '', name: '', description: '', pages: [],
|
||||
type: 'flow', placement: '', source: 'candidates.activity', periods: [],
|
||||
};
|
||||
|
||||
/** The Owliver editor's fields, before a definition is loaded into them. */
|
||||
export const EMPTY_OWLIVER_FIELDS = {
|
||||
id: '', name: '', description: '', pages: [],
|
||||
triggers: [], suggestions: [], capabilities: [], source: '', periods: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* A Board skill, read back into the fields that compose one.
|
||||
*
|
||||
* The section comes from the registry's reading rather than from the Markdown
|
||||
* text, so a definition that omitted `placement:` shows the placement it will
|
||||
* actually render at.
|
||||
*/
|
||||
export function boardFieldsFromSource(source) {
|
||||
try {
|
||||
const skill = readSkill(source);
|
||||
if (!skill) throw new Error('no frontmatter');
|
||||
/* The first section, in declaration order — what the single-valued fields
|
||||
can show. `pages` still carries every page the definition reaches, and
|
||||
`uiIsEditableFromFields` is what stops those fields writing over the
|
||||
ones this record cannot represent. */
|
||||
const section = Object.values(skill.ui || {}).flatMap((p) => p.sections || [])[0];
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
pages: skill.pages,
|
||||
type: section?.type || EMPTY_BOARD_FIELDS.type,
|
||||
placement: section?.placement || '',
|
||||
source: section?.source || EMPTY_BOARD_FIELDS.source,
|
||||
periods: section?.periods || [],
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_BOARD_FIELDS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An Owliver skill, read back into the fields that compose one.
|
||||
*
|
||||
* Reads the resolved capabilities, which includes the ones whose source was
|
||||
* inherited from a `ui:` block rather than written out — so the form shows what
|
||||
* Owliver can be asked for, not what the file happened to spell.
|
||||
*/
|
||||
export function owliverFieldsFromSource(source) {
|
||||
try {
|
||||
const skill = readSkill(source);
|
||||
if (!skill) throw new Error('no frontmatter');
|
||||
const first = skill.owliver.capabilities[0];
|
||||
const response = first ? skill.owliver.responses[first] : null;
|
||||
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
pages: skill.pages,
|
||||
/**
|
||||
* Only the triggers the definition actually claimed.
|
||||
*
|
||||
* `skill.triggers` falls back to the skill's own name when none are
|
||||
* declared, which is right for matching and wrong to put in a form: the
|
||||
* field would fill with an inherited phrase, and the next edit would
|
||||
* write it into the file as a declared one. That flips
|
||||
* `declaredTriggers`, which the matcher weighs differently — and it would
|
||||
* pin the *old* name's phrase the moment the skill is renamed.
|
||||
*/
|
||||
triggers: skill.declaredTriggers ? skill.triggers : [],
|
||||
suggestions: skill.owliver.suggestions.map((s) => s.label),
|
||||
capabilities: skill.owliver.capabilities,
|
||||
source: response?.source || '',
|
||||
periods: response?.periods || [],
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_OWLIVER_FIELDS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which management surfaces this definition belongs to, for the upload handoff.
|
||||
*
|
||||
* A file dropped into the Board editor that declares only an `owliver:` block
|
||||
* is being edited in the wrong half of the product. Knowing that lets the
|
||||
* editor offer the other one rather than showing a form none of whose fields
|
||||
* apply.
|
||||
*/
|
||||
export function facetsFromSource(source) {
|
||||
try {
|
||||
return readSkill(source)?.facets || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the `ui:` shapes a definition uses.
|
||||
*
|
||||
* `shorthand` is one section applying to every declared page — the form the
|
||||
* editors compose, and the only one whose four fields can represent the whole
|
||||
* block. `list` and `per-page` can each hold several sections across several
|
||||
* pages, so the fields would have to throw away everything but the first to
|
||||
* write them back. Telling them apart is what lets the editor show a multi-page
|
||||
* definition without being able to flatten it.
|
||||
*/
|
||||
export function uiShape(source) {
|
||||
let data;
|
||||
try {
|
||||
({ data } = parseFrontmatter(String(source)));
|
||||
} catch {
|
||||
return 'none';
|
||||
}
|
||||
const ui = data?.ui;
|
||||
if (!ui || typeof ui !== 'object') return 'none';
|
||||
if (Array.isArray(ui)) return ui.length ? 'list' : 'none';
|
||||
const SECTION_KEYS = ['type', 'source', 'data', 'placement', 'position', 'periods', 'title'];
|
||||
return SECTION_KEYS.some((key) => key in ui) ? 'shorthand' : 'per-page';
|
||||
}
|
||||
|
||||
/** Whether the section fields can write this definition's `ui:` block back. */
|
||||
export const uiIsEditableFromFields = (source) => uiShape(source) !== 'list'
|
||||
&& uiShape(source) !== 'per-page';
|
||||
|
||||
/**
|
||||
* Whether this definition's pages come from its `ui:` block rather than a
|
||||
* `pages:` key of its own.
|
||||
*
|
||||
* When they do, the Pages field is a read-out and must not be written back.
|
||||
* Writing it would add a `pages:` list that agrees with the block today and
|
||||
* silently governs it tomorrow: `normalizeSkillUi` refuses an entry naming a
|
||||
* page that `pages:` does not list, so the next entry the author adds to the
|
||||
* block would be rejected by a key they never wrote. A derived value is shown,
|
||||
* not owned.
|
||||
*/
|
||||
export function pagesAreDerived(source) {
|
||||
let data;
|
||||
try {
|
||||
({ data } = parseFrontmatter(String(source)));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(data?.pages) && data.pages.length) return false;
|
||||
return uiShape(source) !== 'none';
|
||||
}
|
||||
|
||||
/* ── Writing fields back into the frontmatter ───────────────────────────── */
|
||||
|
||||
/**
|
||||
* A scalar, written so the parser reads back what was meant.
|
||||
*
|
||||
* Quoted whenever the plain form would be read as something else — a value
|
||||
* containing `:` or `#`, one with edge whitespace, or one that looks like a
|
||||
* number, a boolean or null but is a string.
|
||||
*/
|
||||
function writeScalar(value) {
|
||||
if (value === null || value === undefined) return 'null';
|
||||
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
|
||||
|
||||
const text = String(value);
|
||||
const ambiguous = text === ''
|
||||
|| /[:#]/.test(text)
|
||||
|| text !== text.trim()
|
||||
|| /^(true|false|null|~)$/.test(text)
|
||||
|| /^-?\d+$/.test(text)
|
||||
|| /^-?\d*\.\d+$/.test(text)
|
||||
|| /^['"-]/.test(text);
|
||||
|
||||
return ambiguous ? `'${text.replace(/'/g, "''")}'` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* A value, as the block lines that follow its key.
|
||||
*
|
||||
* Returns `null` for a scalar, which is written on the key's own line instead.
|
||||
* Only the subset in `yaml.js` is emitted — block maps and block sequences,
|
||||
* nested to any depth — because that is the only subset the parser reads back.
|
||||
*/
|
||||
function writeBlock(value, indent) {
|
||||
const pad = ' '.repeat(indent);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => {
|
||||
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
||||
const entries = Object.entries(item).filter(([, v]) => v !== undefined);
|
||||
if (!entries.length) return `${pad}- {}`;
|
||||
return entries
|
||||
.map(([k, v], i) => {
|
||||
const prefix = i === 0 ? `${pad}- ` : `${pad} `;
|
||||
const nested = writeBlock(v, indent + 4);
|
||||
return nested === null ? `${prefix}${k}: ${writeScalar(v)}` : `${prefix}${k}:\n${nested}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
return `${pad}- ${writeScalar(item)}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.entries(value)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k, v]) => {
|
||||
const nested = writeBlock(v, indent + 2);
|
||||
return nested === null ? `${pad}${k}: ${writeScalar(v)}` : `${pad}${k}:\n${nested}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The lines belonging to the key at `start`: everything indented under it. */
|
||||
function blockEnd(lines, start, indent) {
|
||||
let end = start + 1;
|
||||
while (end < lines.length) {
|
||||
const line = lines[end];
|
||||
if (line.trim() === '' || /^\s*#/.test(line)) { end += 1; continue; }
|
||||
if (line.match(/^\s*/)[0].replace(/\t/g, ' ').length <= indent) break;
|
||||
end += 1;
|
||||
}
|
||||
/* Trailing blanks and comments belong to whatever comes next, not to this
|
||||
key — a comment written above the following key must not be swallowed by
|
||||
the block above it. */
|
||||
while (end > start + 1 && lines[end - 1].trim() === '') end -= 1;
|
||||
return end;
|
||||
}
|
||||
|
||||
/** The index of `key` at `indent` within `[from, to)`, or -1. */
|
||||
function findKey(lines, key, indent, from, to) {
|
||||
const pattern = new RegExp(`^${' '.repeat(indent)}${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
|
||||
for (let i = from; i < to; i += 1) {
|
||||
if (pattern.test(lines[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* One dotted path, set in a frontmatter line array.
|
||||
*
|
||||
* Scalars replace the value on their own line; anything else replaces the block
|
||||
* beneath it. A key that is not there is appended to the end of its parent, so
|
||||
* a definition that never declared `status:` gains one rather than being
|
||||
* refused. Every line the path does not touch — including comments and key
|
||||
* order — is left exactly as written, which is why this is a patch and not a
|
||||
* re-serialisation.
|
||||
*/
|
||||
function setPath(lines, path, value) {
|
||||
const parts = path.split('.');
|
||||
let from = 0;
|
||||
let to = lines.length;
|
||||
let indent = 0;
|
||||
|
||||
for (let depth = 0; depth < parts.length - 1; depth += 1) {
|
||||
const at = findKey(lines, parts[depth], indent, from, to);
|
||||
/* A parent that does not exist is created empty, then descended into. */
|
||||
if (at === -1) {
|
||||
lines.splice(to, 0, `${' '.repeat(indent)}${parts[depth]}:`);
|
||||
from = to + 1;
|
||||
to = from;
|
||||
indent += 2;
|
||||
continue;
|
||||
}
|
||||
const end = blockEnd(lines, at, indent);
|
||||
from = at + 1;
|
||||
to = end;
|
||||
indent += 2;
|
||||
}
|
||||
|
||||
const key = parts[parts.length - 1];
|
||||
const at = findKey(lines, key, indent, from, to);
|
||||
const block = writeBlock(value, indent + 2);
|
||||
const replacement = block === null
|
||||
? [`${' '.repeat(indent)}${key}: ${writeScalar(value)}`]
|
||||
: [`${' '.repeat(indent)}${key}:`, ...block.split('\n')];
|
||||
|
||||
if (at === -1) {
|
||||
lines.splice(to, 0, ...replacement);
|
||||
return replacement.length;
|
||||
}
|
||||
|
||||
const end = blockEnd(lines, at, indent);
|
||||
lines.splice(at, end - at, ...replacement);
|
||||
return replacement.length - (end - at);
|
||||
}
|
||||
|
||||
/**
|
||||
* A definition with some frontmatter keys changed, and nothing else touched.
|
||||
*
|
||||
* `patch` is keyed by dotted path — `name`, `pages`, `ui.source`,
|
||||
* `owliver.capabilities` — and a value of `undefined` leaves that path alone.
|
||||
* `null` writes `null`; to remove a key, pass `REMOVE`.
|
||||
*
|
||||
* The body below the closing `---` is never read and never rewritten. That is
|
||||
* the property that makes this safe to run on every keystroke: an author's
|
||||
* prose, their comments and the order they wrote their keys in all survive a
|
||||
* change to a field they did not write.
|
||||
*/
|
||||
export const REMOVE = Symbol('remove');
|
||||
|
||||
export function patchFrontmatter(source, patch = {}) {
|
||||
/* Normalized first, for the same reason the parser is: a fence this does not
|
||||
recognise is a fence it writes a second copy of. */
|
||||
const raw = normalizeDefinition(source);
|
||||
const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?=\n|$)/.exec(raw);
|
||||
|
||||
/* No frontmatter to patch: give the definition one rather than silently
|
||||
dropping the edit. */
|
||||
if (!match) {
|
||||
const lines = [];
|
||||
for (const [path, value] of Object.entries(patch)) {
|
||||
if (value === undefined || value === REMOVE) continue;
|
||||
setPath(lines, path, value);
|
||||
}
|
||||
return `---\n${lines.join('\n')}\n---\n\n${raw.trim()}\n`;
|
||||
}
|
||||
|
||||
const lines = match[1].split('\n');
|
||||
|
||||
for (const [path, value] of Object.entries(patch)) {
|
||||
if (value === undefined) continue;
|
||||
if (value === REMOVE) {
|
||||
const parts = path.split('.');
|
||||
const indent = (parts.length - 1) * 2;
|
||||
/* Only a top-level or one-deep key is removable, which is every key the
|
||||
editors own. */
|
||||
let from = 0;
|
||||
let to = lines.length;
|
||||
if (parts.length > 1) {
|
||||
const parent = findKey(lines, parts[0], 0, 0, lines.length);
|
||||
if (parent === -1) continue;
|
||||
from = parent + 1;
|
||||
to = blockEnd(lines, parent, 0);
|
||||
}
|
||||
const at = findKey(lines, parts[parts.length - 1], indent, from, to);
|
||||
if (at !== -1) lines.splice(at, blockEnd(lines, at, indent) - at);
|
||||
continue;
|
||||
}
|
||||
setPath(lines, path, value);
|
||||
}
|
||||
|
||||
return `---\n${lines.join('\n')}\n---${raw.slice(match[0].length)}`;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export const SKILL_SURFACES = [
|
||||
label: 'Control Center',
|
||||
route: '/admin',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'positions',
|
||||
@@ -53,6 +54,26 @@ export const SKILL_SURFACES = [
|
||||
'after-candidates',
|
||||
'before-footer',
|
||||
],
|
||||
/**
|
||||
* What each of those placements actually hands a section, read off the
|
||||
* `<SkillSurface>` call sites rather than assumed from the surface.
|
||||
*
|
||||
* The distinction is the whole point: the two list placements render above
|
||||
* and below the grid with no position in context, while every other one
|
||||
* renders inside a card, a drawer or the position page and passes the
|
||||
* record. A section reading `position.*` is answerable at one and not at
|
||||
* the other, and until this was written down both validated identically.
|
||||
*/
|
||||
provides: {
|
||||
'after-position-list-summary': [],
|
||||
'after-position-list': [],
|
||||
'after-header': ['positionId'],
|
||||
'after-position-card': ['positionId'],
|
||||
'after-position-summary': ['positionId'],
|
||||
'before-candidates': ['positionId'],
|
||||
'after-candidates': ['positionId'],
|
||||
'before-footer': ['positionId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
/* The authoring form, which is a surface in its own right: what a skill has
|
||||
@@ -69,12 +90,24 @@ export const SKILL_SURFACES = [
|
||||
'after-vetting-weights',
|
||||
'before-footer',
|
||||
],
|
||||
/* The draft in the form is the position, so every placement here supplies
|
||||
one — which is what lets `position.vetting` be read and written while the
|
||||
role is still being specified. */
|
||||
provides: {
|
||||
'after-header': ['positionId'],
|
||||
'after-job-description': ['positionId'],
|
||||
'after-vetting-weights': ['positionId'],
|
||||
'before-footer': ['positionId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'candidates',
|
||||
label: 'Candidates',
|
||||
route: '/admin/candidates',
|
||||
placements: ['after-header', 'after-candidate-summary', 'before-footer'],
|
||||
/* Only the summary placement renders against one person — it is mounted on
|
||||
the candidate profile, not on the list. */
|
||||
provides: { 'after-candidate-summary': ['candidateId'] },
|
||||
},
|
||||
{
|
||||
id: 'hired-history',
|
||||
@@ -82,12 +115,15 @@ export const SKILL_SURFACES = [
|
||||
route: '/admin/hired',
|
||||
aliases: ['hired'],
|
||||
placements: ['after-header', 'before-footer'],
|
||||
/* `before-footer` is inside the record drawer; `after-header` is the page. */
|
||||
provides: { 'before-footer': ['candidateId'] },
|
||||
},
|
||||
{
|
||||
id: 'talent-pool',
|
||||
label: 'Talent Pool',
|
||||
route: '/admin/talent-pool',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'krow-forge',
|
||||
@@ -95,18 +131,21 @@ export const SKILL_SURFACES = [
|
||||
route: '/admin/university',
|
||||
aliases: ['university', 'forge'],
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Analytics',
|
||||
route: '/admin/analytics',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
route: '/admin/activity',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
/* 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. */
|
||||
@@ -115,12 +154,14 @@ export const SKILL_SURFACES = [
|
||||
label: 'Profile',
|
||||
route: '/admin/profile',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'candidates-analysis',
|
||||
label: 'Candidate Analysis',
|
||||
route: '/admin/candidates-analysis',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
provides: {},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -133,12 +174,111 @@ for (const surface of SKILL_SURFACES) {
|
||||
/** 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 surface a declared page name refers to, or null.
|
||||
*
|
||||
* Matched on the normalized name, not the literal one. A definition writing
|
||||
* `Positions` or `Talent Pool` — which is how the page is spelled everywhere in
|
||||
* the product — used to resolve to nothing, and a page name that resolves to
|
||||
* nothing takes the whole `ui:` block with it. The keys stay canonical; only
|
||||
* what an author may type to reach them widens.
|
||||
*/
|
||||
const normalizeKey = (page) => String(page ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, '-');
|
||||
|
||||
export const surfaceFor = (page) => BY_KEY.get(normalizeKey(page)) || null;
|
||||
|
||||
/**
|
||||
* Placement names a definition may use, beyond the canonical ones.
|
||||
*
|
||||
* A placement is a position in a page's layout, and the canonical names are
|
||||
* written from the page's point of view — `after-position-card`. Authors write
|
||||
* them from the definition's: `grid-card` is where it goes, `panel` is what it
|
||||
* looks like. An alias only ever resolves to a placement the surface really
|
||||
* offers, so the vocabulary stays exactly as closed as it was; what changes is
|
||||
* how many ways there are to name a member of it.
|
||||
*/
|
||||
const PLACEMENT_ALIASES = {
|
||||
'grid-card': 'after-position-card',
|
||||
card: 'after-position-card',
|
||||
panel: 'after-header',
|
||||
top: 'after-header',
|
||||
header: 'after-header',
|
||||
footer: 'before-footer',
|
||||
bottom: 'before-footer',
|
||||
list: 'after-position-list',
|
||||
summary: 'after-position-summary',
|
||||
};
|
||||
|
||||
/**
|
||||
* The canonical placement a declared name refers to on this surface, or null.
|
||||
*
|
||||
* An alias that points at a placement this surface does not offer resolves to
|
||||
* null rather than to some other surface's placement — `panel` means
|
||||
* `after-header` where there is one and nothing where there is not.
|
||||
*/
|
||||
export function placementFor(page, placement) {
|
||||
const surface = surfaceFor(page);
|
||||
if (!surface) return null;
|
||||
|
||||
const declared = normalizeKey(placement);
|
||||
if (!declared) return null;
|
||||
if (surface.placements.includes(declared)) return declared;
|
||||
|
||||
const aliased = PLACEMENT_ALIASES[declared];
|
||||
return aliased && surface.placements.includes(aliased) ? aliased : null;
|
||||
}
|
||||
|
||||
/** The canonical id for a declared page name — `hired` → `hired-history`. */
|
||||
export const canonicalPage = (page) => surfaceFor(page)?.id || null;
|
||||
|
||||
/**
|
||||
* What a placement hands a section, as the page actually mounts it.
|
||||
*
|
||||
* A source declares the record it needs — a position, a candidate, or nothing —
|
||||
* and until this existed nothing compared that need against the pages a
|
||||
* definition named. So a section reading `position.activity` could be attached
|
||||
* to Analytics, validate cleanly, register, draw its title, and then report
|
||||
* "This section needs a position to read" forever. That is the whole of the
|
||||
* "the skill saved but does nothing" report, and it is an authoring mistake the
|
||||
* product can catch rather than one the reader has to discover.
|
||||
*
|
||||
* Read per *placement*, never per surface: the Positions grid renders one
|
||||
* section inside each card, with a position, and two more above and below the
|
||||
* grid, with none. Both are `positions`.
|
||||
*
|
||||
* With no placement named, a definition is answered for the surface's default —
|
||||
* `surface.placements[0]`, which is what `normalizeSection` fills in.
|
||||
*/
|
||||
export function placementProvides(page, placement = null) {
|
||||
const surface = surfaceFor(page);
|
||||
if (!surface) return [];
|
||||
const at = String(placement || '').trim() || surface.placements[0];
|
||||
return surface.provides?.[at] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the declared pages can supply at this placement, deduplicated.
|
||||
*
|
||||
* One page supplying a position is enough — a definition naming several pages
|
||||
* is saying it belongs on all of them, and refusing it because one cannot
|
||||
* answer would refuse the definition for the pages that can. The load-time
|
||||
* diagnostic is where the partial case is reported.
|
||||
*/
|
||||
export function contextSuppliedBy(pages = [], placement = null) {
|
||||
return [...new Set(
|
||||
(Array.isArray(pages) ? pages : [pages])
|
||||
.flatMap((page) => placementProvides(page, placement))
|
||||
)];
|
||||
}
|
||||
|
||||
/** "a position" / "a candidate", for a message an author can act on. */
|
||||
export const contextLabel = (need) => (
|
||||
need === 'positionId' ? 'a position' : need === 'candidateId' ? 'a candidate' : 'nothing'
|
||||
);
|
||||
|
||||
/**
|
||||
* The surface an Admin route belongs to — `/admin/positions/new` →
|
||||
* `create-position`.
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import {
|
||||
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
|
||||
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, surfaceFor,
|
||||
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, placementFor,
|
||||
surfaceFor,
|
||||
} from './surfaces';
|
||||
|
||||
/**
|
||||
* A name, as an id.
|
||||
*
|
||||
* Exported because two places need the same rule and were carrying their own:
|
||||
* a section falling back to its title, and a definition falling back to its
|
||||
* name when it declares no `id:`. Two slug functions that agree today is a
|
||||
* definition whose id changes the day they stop agreeing.
|
||||
*/
|
||||
export const slugify = (value) => String(value || '')
|
||||
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
|
||||
/**
|
||||
* The `ui:` block of a skill definition, checked and normalized.
|
||||
*
|
||||
@@ -53,9 +65,7 @@ export function normalizeSection(raw, {
|
||||
/* 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);
|
||||
const id = slugify(raw.id) || slugify(raw.title) || slugify(fallbackId);
|
||||
if (!id) {
|
||||
errors.push(`${where}: a section needs an \`id\`.`);
|
||||
return null;
|
||||
@@ -70,7 +80,20 @@ export function normalizeSection(raw, {
|
||||
}
|
||||
seen.add(id);
|
||||
|
||||
const type = String(raw.type || '').trim();
|
||||
/**
|
||||
* The shape this section is drawn with.
|
||||
*
|
||||
* Inferred from the source when it is not written, the same way a placement
|
||||
* left out is filled in from the surface. A source declares the shapes it can
|
||||
* fill, in the order they suit it, so the first is the reading its author
|
||||
* would have chosen — and a definition naming a page, a placement and a
|
||||
* source has already said everything that needs saying. Refusing it for the
|
||||
* one field it can derive would be the format asking for ceremony.
|
||||
*/
|
||||
const declaredSource = String(raw.data?.source ?? raw.source ?? '').trim();
|
||||
const type = String(raw.type || '').trim()
|
||||
|| dataSourceFor(declaredSource)?.shapes?.find((shape) => types.includes(shape))
|
||||
|| '';
|
||||
if (!type) {
|
||||
errors.push(`${at || `${where}.${id}`}: a section needs a \`type\`.`);
|
||||
return null;
|
||||
@@ -89,10 +112,16 @@ export function normalizeSection(raw, {
|
||||
if (wantPlacement) {
|
||||
const surface = surfaceFor(page);
|
||||
const declaredPlacement = String(raw.position || raw.placement || '').trim();
|
||||
placement = declaredPlacement || surface.placements[0];
|
||||
if (!surface.placements.includes(placement)) {
|
||||
/* `placementFor` reads the surface's own list and the alias table, so
|
||||
`grid-card` and `after-position-card` are one placement written two ways
|
||||
and neither resolves to something the surface does not offer. */
|
||||
placement = declaredPlacement
|
||||
? placementFor(page, declaredPlacement)
|
||||
: surface.placements[0];
|
||||
if (!placement) {
|
||||
errors.push(
|
||||
`Unsupported placement: ${placement} on ${page}. Supported placements: ${surface.placements.join(', ')}.`
|
||||
`Unsupported placement: ${declaredPlacement} on ${page}. `
|
||||
+ `Supported placements: ${surface.placements.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -102,7 +131,7 @@ export function normalizeSection(raw, {
|
||||
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();
|
||||
const source = declaredSource;
|
||||
if (!source) {
|
||||
errors.push(`${at || `${where}.${id}`}: a section needs \`data.source\`.`);
|
||||
return null;
|
||||
@@ -190,6 +219,22 @@ export function normalizeSection(raw, {
|
||||
* 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.
|
||||
*
|
||||
* There is a third, and it is the one people actually write:
|
||||
*
|
||||
* ui:
|
||||
* - page: Positions
|
||||
* placement: grid-card
|
||||
* source: position.activity
|
||||
* - page: Analytics
|
||||
* placement: panel
|
||||
* source: hires.performance
|
||||
*
|
||||
* A list of sections, each naming its own page. It reads the way the thing
|
||||
* reads — "this skill puts this here, and that there" — and it was the one
|
||||
* shape the parser refused, with a single error that took the whole block down
|
||||
* and left the definition declaring no pages at all. Several entries may name
|
||||
* the same page; they become several sections on it, in the order written.
|
||||
*/
|
||||
const SECTION_KEYS = new Set([
|
||||
'id', 'type', 'title', 'description', 'placement', 'position', 'data', 'source', 'periods',
|
||||
@@ -223,12 +268,68 @@ export function normalizeSkillUi(rawUi, { declaredPages = [], skillId = '' } = {
|
||||
|
||||
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.'] };
|
||||
if (typeof rawUi !== 'object') {
|
||||
return {
|
||||
ui,
|
||||
errors: ['`ui` must be a section, a list of sections, or a mapping of page names to sections.'],
|
||||
};
|
||||
}
|
||||
|
||||
const pages = declaredPages.map(canonicalPage).filter(Boolean);
|
||||
|
||||
/**
|
||||
* A list, where every entry names the page it belongs to.
|
||||
*
|
||||
* Grouped rather than keyed, so two entries naming one page are two sections
|
||||
* on it rather than the second quietly replacing the first — which is what a
|
||||
* page map would have done. `seen` is shared across the whole list because
|
||||
* section ids are unique per definition, not per page.
|
||||
*/
|
||||
if (Array.isArray(rawUi)) {
|
||||
const seen = new Set();
|
||||
|
||||
rawUi.forEach((entry, index) => {
|
||||
const where = `ui[${index}]`;
|
||||
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
errors.push(`${where}: each entry must be a mapping of options.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawPage = entry.page ?? entry.pages ?? entry.surface;
|
||||
if (rawPage == null || Array.isArray(rawPage)) {
|
||||
errors.push(`${where}: an entry needs a \`page\`.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const page = canonicalPage(rawPage);
|
||||
if (!page) {
|
||||
errors.push(
|
||||
`Unsupported page: ${rawPage}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Declared `pages:` still governs reach when it is written: a definition
|
||||
may not render onto a surface it did not say it applied to. When it is
|
||||
not written, the list is the declaration — see `parseSkill`. */
|
||||
if (pages.length && !pages.includes(page)) {
|
||||
errors.push(`\`${where}\` names \`${rawPage}\`, which is not listed under \`pages\`.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const section = normalizeSection(entry, {
|
||||
page, errors, seen, fallbackId: `${skillId}-${index + 1}`, where,
|
||||
});
|
||||
if (!section) return;
|
||||
|
||||
if (!ui[page]) ui[page] = { sections: [] };
|
||||
ui[page].sections.push(section);
|
||||
});
|
||||
|
||||
return { ui, errors };
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
|
||||
@@ -45,6 +45,12 @@ export function matchWorkforceIntent(question) {
|
||||
if (has(q, 'set up an interview', 'set up interview', 'schedule an interview', 'schedule interview',
|
||||
'interview for ', 'set up their interview')) return 'interview_setup';
|
||||
|
||||
/* Answering "how many people do you need?" — checked before assignment,
|
||||
because the reply that carries the number also carries the position title
|
||||
and would otherwise read as a fresh request to assign against it. */
|
||||
if (has(q, 'set headcount', 'set the headcount', 'headcount for', 'headcount to',
|
||||
'headcount of')) return 'set_headcount';
|
||||
|
||||
if (has(q, 'confirm assignment', 'yes, assign', 'confirm the assignment')) return 'confirm_assign';
|
||||
if (has(q, 'assign ', 'fill this position', 'fill the position', 'assign the best', 'assign them')) return 'assign';
|
||||
if (has(q, 'ready for interview', 'who should i interview', 'interview ready')) return 'interview_ready';
|
||||
@@ -57,6 +63,26 @@ export function matchWorkforceIntent(question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The headcount a sentence states, or null.
|
||||
*
|
||||
* Deliberately narrow. A bare integer anywhere in the question would also match
|
||||
* the digits in a position title, a pay band or a date, and setting demand from
|
||||
* a misread number is not a recoverable mistake — the whole point of asking is
|
||||
* that nobody wants a figure nobody chose. So the number has to be attached to
|
||||
* a phrase that means "this many people".
|
||||
*/
|
||||
export function headcountFrom(question) {
|
||||
const q = String(question).toLowerCase();
|
||||
const match = /\bto\s+(\d{1,3})\b/.exec(q)
|
||||
|| /\bheadcount\s+(?:of\s+|to\s+)?(\d{1,3})\b/.exec(q)
|
||||
|| /\b(\d{1,3})\s+(?:people|person|staff|workers?)\b/.exec(q);
|
||||
if (!match) return null;
|
||||
|
||||
const count = Number(match[1]);
|
||||
return Number.isFinite(count) && count >= 1 && count <= 999 ? count : null;
|
||||
}
|
||||
|
||||
/* ── Which position? ───────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -423,6 +449,83 @@ export function positionPriority(positions, context) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ── The missing headcount ─────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* What to say when a position never stated how many people it wants.
|
||||
*
|
||||
* This used to be the end of the conversation: a sentence explaining that there
|
||||
* was no gap to fill, and no follow-up, on a request the reader had just been
|
||||
* offered a chip for. The engine had in fact already picked somebody — the
|
||||
* refusal was thrown away in front of a viable plan — but the count it would
|
||||
* have been proposing against was the schema's fallback of 1, not a number the
|
||||
* employer had given. Presenting that as their answer is the thing the demand
|
||||
* model exists to avoid.
|
||||
*
|
||||
* So the missing value is asked for rather than assumed. What is offered is
|
||||
* read off this position: one, two, and the number of people who are actually
|
||||
* qualified and free for it, so the shortcut with the most information behind it
|
||||
* is on the list. Nothing is written until one is chosen, and typing a different
|
||||
* number works exactly as well as clicking a chip.
|
||||
*/
|
||||
export function askHeadcount(position, context) {
|
||||
const demand = demandFor(position, context);
|
||||
const status = workforceStatusFor(position, context);
|
||||
|
||||
const strong = status.strong.length;
|
||||
const options = [...new Set([1, 2, strong])]
|
||||
.filter((n) => n >= 1 && n <= 99)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const onIt = demand.assigned
|
||||
? `${demand.assigned} ${demand.assigned === 1 ? 'person is' : 'people are'} already on it`
|
||||
: 'Nobody is on it yet';
|
||||
const free = strong
|
||||
? `${strong} ${strong === 1 ? 'person is' : 'people are'} qualified and free when it starts`
|
||||
: 'nobody is currently both qualified and free';
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
text(`How many people do you need for **${position.title}**?`),
|
||||
note(`${onIt}, and ${free}. It has never stated a headcount, and I will not `
|
||||
+ 'guess one — tell me the number and I will propose against it.')
|
||||
),
|
||||
followUp: options.map((count) => ({
|
||||
label: `${count} ${count === 1 ? 'person' : 'people'}`,
|
||||
prompt: `Set headcount for ${position.title} to ${count}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn after the number is written: the proposal it was asked for.
|
||||
*
|
||||
* Composed from `assignmentPreview` rather than restating it, so the answer the
|
||||
* reader gets here is the same answer the same question gives from then on.
|
||||
*/
|
||||
export function headcountSet(position, context) {
|
||||
const count = Number(position.headcount);
|
||||
const preview = assignmentPreview(position, context);
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
text(`**${position.title}** now asks for **${count}** ${count === 1 ? 'person' : 'people'}.`),
|
||||
preview.doc.blocks
|
||||
),
|
||||
followUp: preview.followUp,
|
||||
};
|
||||
}
|
||||
|
||||
/** The write did not land. Nothing is claimed that did not happen. */
|
||||
export function headcountFailed(position) {
|
||||
return {
|
||||
doc: doc(
|
||||
text(`I could not set the headcount on **${position.title}**.`),
|
||||
note('Nothing was changed. The position still states no demand.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Proposing an assignment ───────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -437,21 +540,60 @@ export function assignmentPreview(position, context) {
|
||||
const plan = prepareAssignment(position, context);
|
||||
const demand = demandFor(position, context);
|
||||
|
||||
if (!demand.declared) {
|
||||
return {
|
||||
doc: doc(
|
||||
text(`**${position.title}** does not state how many people it needs, so there is no gap for me to fill.`),
|
||||
note('Set a headcount on the position and I can propose an assignment against it.')
|
||||
),
|
||||
};
|
||||
}
|
||||
if (!demand.declared) return askHeadcount(position, context);
|
||||
|
||||
if (plan.blocked === 'full') {
|
||||
return { doc: doc(text(`**${position.title}** is already fully staffed — ${demand.assigned} of ${demand.required}.`)) };
|
||||
}
|
||||
|
||||
if (plan.blocked === 'no_available_matches') {
|
||||
const blocked = poolFor(position, context).filter((r) => !r.availability.available);
|
||||
const pool = poolFor(position, context);
|
||||
|
||||
/**
|
||||
* People the engine will not propose automatically, but would accept.
|
||||
*
|
||||
* `prepareAssignment` selects from `strong`, and `strong` requires
|
||||
* *verified* evidence — a deliberate rule, and the right one: a stated
|
||||
* requirement is a claim on an application, not proof somebody can do the
|
||||
* work. What was wrong was the sentence it produced. On Bartender, three
|
||||
* people meet every stated requirement, have no gaps and are free when it
|
||||
* starts, and the reply said nobody was qualified or free. That is not a
|
||||
* cautious answer, it is an untrue one — and it ended the conversation,
|
||||
* because the branch offered no follow-up at all.
|
||||
*
|
||||
* So the rule stands and the reply changes: say which kind of evidence is
|
||||
* missing, show who is actually there, and offer them by name. Naming one
|
||||
* goes through `namedAssignmentPreview`, which already re-checks them and
|
||||
* asks for confirmation before anything is written — the admin makes the
|
||||
* judgement the verified/stated distinction exists to protect, instead of
|
||||
* the distinction quietly making it for them.
|
||||
*/
|
||||
const ready = pool.filter(
|
||||
(row) => row.availability.available && row.match && !row.match.gaps.length
|
||||
);
|
||||
|
||||
if (ready.length) {
|
||||
const shown = ready.slice(0, 3);
|
||||
return {
|
||||
doc: doc(
|
||||
text(`Nobody with **verified** skills is free for **${position.title}**, so I will not `
|
||||
+ 'propose an assignment on my own.'),
|
||||
text(`${ready.length} ${ready.length === 1 ? 'person meets' : 'people meet'} every stated `
|
||||
+ `requirement and ${ready.length === 1 ? 'is' : 'are'} free when it starts:`),
|
||||
...shown.map((row, i) => candidateBlock(row, i + 1, {
|
||||
applications: context.applications, position,
|
||||
})),
|
||||
note('Scored from their applications rather than verified training. Name one and I will '
|
||||
+ 'check them again before anything is written.')
|
||||
),
|
||||
followUp: shown.map((row) => ({
|
||||
label: `Assign ${row.name}`,
|
||||
prompt: `Assign ${row.name} to ${position.title}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const blocked = pool.filter((r) => !r.availability.available);
|
||||
return {
|
||||
doc: doc(
|
||||
text(`I cannot propose anyone for **${position.title}** — nobody is both qualified and free when it starts.`),
|
||||
@@ -464,6 +606,11 @@ export function assignmentPreview(position, context) {
|
||||
: null,
|
||||
note(`${demand.remaining} of ${demand.required} still unfilled.`)
|
||||
),
|
||||
/* Even with nobody to offer, the conversation has somewhere to go. */
|
||||
followUp: [
|
||||
{ label: 'Who is available next?', prompt: `Who is available next for ${position.title}?` },
|
||||
{ label: 'Who matches this role?', prompt: `Who matches ${position.title}?` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user