update owliver skill
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
import { parseYaml } from './yaml';
|
||||
import { normalizeSkillUi } from './uiConfig';
|
||||
import { normalizeSkillOwliver } from './owliverConfig';
|
||||
import { SUPPORTED_SKILL_PAGES, canonicalPage, surfaceFor, surfaceForRoute } from './surfaces';
|
||||
|
||||
/**
|
||||
* Owliver skill registry.
|
||||
@@ -16,43 +20,25 @@ import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
const FILES = import.meta.glob('/src/skills/**/*.md', { query: '?raw', import: 'default', eager: true });
|
||||
|
||||
/**
|
||||
* Frontmatter, parsed to the subset the format actually uses: `key: value` and
|
||||
* `key:` followed by an indented `- item` list.
|
||||
* Frontmatter, as data.
|
||||
*
|
||||
* Deliberately not a YAML library — the app has none, this needs no dependency,
|
||||
* and a skill file that reaches for anchors or nested maps has outgrown being a
|
||||
* declaration anyway.
|
||||
* Skills grew declarative UI configuration, which is nested, so this reads the
|
||||
* YAML subset in `yaml.js` rather than the flat `key: value` pairs it used to.
|
||||
* The old shapes are a strict subset of the new one — a definition written for
|
||||
* the previous parser parses identically here.
|
||||
*
|
||||
* A file whose frontmatter cannot be read raises rather than registering a
|
||||
* half-understood definition; `parseSkill` decides what to do with that.
|
||||
*/
|
||||
function parseFrontmatter(raw) {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
|
||||
if (!match) return { data: {}, body: raw };
|
||||
|
||||
const data = {};
|
||||
let listKey = null;
|
||||
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const item = /^\s*-\s+(.*)$/.exec(line);
|
||||
if (item && listKey) {
|
||||
data[listKey].push(item[1].trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
||||
if (!pair) continue;
|
||||
|
||||
const [, key, value] = pair;
|
||||
if (value === '') {
|
||||
listKey = key;
|
||||
data[key] = [];
|
||||
} else {
|
||||
listKey = null;
|
||||
data[key] = value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return { data, body: raw.slice(match[0].length).trim() };
|
||||
const data = parseYaml(match[1]);
|
||||
return {
|
||||
data: data && typeof data === 'object' && !Array.isArray(data) ? data : {},
|
||||
body: raw.slice(match[0].length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Bullets under a `## Heading`, for the capability list shown in Settings. */
|
||||
@@ -131,10 +117,14 @@ function sectionLevels(body) {
|
||||
/**
|
||||
* The page key a route belongs to — `/admin/positions` → `positions`.
|
||||
*
|
||||
* Derived from the placement table rather than written down again, so a route
|
||||
* added there is addressable by a skill without touching this file.
|
||||
* The surface table answers first, because a surface already states its own
|
||||
* route and its key is not always the path tail: `/admin/positions/new` is
|
||||
* `create-position`, not `positions/new`. Falling back to the tail keeps every
|
||||
* route that has no declared surface behaving exactly as it did.
|
||||
*/
|
||||
export function pageKeyForRoute(route) {
|
||||
const surface = surfaceForRoute(route);
|
||||
if (surface) return surface.id;
|
||||
const tail = route.replace(/^\/admin\/?/, '');
|
||||
return tail === '' ? 'control-center' : tail;
|
||||
}
|
||||
@@ -154,6 +144,59 @@ const ROUTE_BY_PAGE_KEY = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route,
|
||||
export const routeForPageKey = (key) => ROUTE_BY_PAGE_KEY[key]?.route ?? null;
|
||||
export const pageKeyForContext = (contextId) => PAGE_KEY_BY_CONTEXT[contextId] ?? null;
|
||||
|
||||
/**
|
||||
* The two management surfaces a definition can belong to.
|
||||
*
|
||||
* `ui` extends a KROW page; `owliver` extends the assistant. They share the
|
||||
* parser, the registry, the validator, the persistence and the data resolver —
|
||||
* only the authoring and management experience is separate, which is what this
|
||||
* classification serves.
|
||||
*/
|
||||
export const SKILL_FACETS = ['ui', 'owliver'];
|
||||
|
||||
/**
|
||||
* Which of them a definition belongs to.
|
||||
*
|
||||
* Declared, never configured: a `ui:` block is a page extension, and an
|
||||
* `owliver:` block, triggers, actions, a prompt or a conversation is an
|
||||
* assistant extension. A definition that declares neither is an assistant
|
||||
* skill — that is what every definition written before the split was, and
|
||||
* reading it any other way would drop it out of both lists.
|
||||
*
|
||||
* Workforce paths are neither. They define a capability the workforce holds
|
||||
* and are managed in Skill Development, so they carry no facet and appear on
|
||||
* neither list.
|
||||
*/
|
||||
export function skillFacets({ data = {}, kind, ui = {}, owliver, conversation = [] }) {
|
||||
if (kind === 'workforce') return [];
|
||||
|
||||
const extendsPage = Object.keys(ui).length > 0;
|
||||
|
||||
/**
|
||||
* Behaviour Owliver actually gains: something to answer with, something to
|
||||
* open, or questions to ask.
|
||||
*
|
||||
* Triggers alone are deliberately not on this list. A trigger is a way of
|
||||
* being *named*, and a page-drawing definition that names itself is still a
|
||||
* page-drawing definition — listing it as an Owliver skill would offer an
|
||||
* author a capability list it never declared. A definition with no `ui:` is
|
||||
* the other way round: triggers are all it has, and they are what it does.
|
||||
*/
|
||||
const teachesOwliver = Boolean(
|
||||
data.owliver
|
||||
|| (Array.isArray(data.actions) && data.actions.length)
|
||||
|| data.prompt
|
||||
|| conversation.length
|
||||
|| owliver?.capabilities?.length
|
||||
|| owliver?.suggestions?.length
|
||||
);
|
||||
|
||||
return [
|
||||
extendsPage ? 'ui' : null,
|
||||
teachesOwliver || !extendsPage ? 'owliver' : null,
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* One Markdown definition → one skill.
|
||||
*
|
||||
@@ -179,9 +222,49 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
*/
|
||||
const kind = data.kind || (levels.length ? 'workforce' : 'assistant');
|
||||
|
||||
/* The declarative UI this definition contributes, checked against the
|
||||
closed vocabulary in `surfaces.js`. A definition with no `ui:` block is
|
||||
exactly what it was before this existed. */
|
||||
const { ui, errors: uiErrors } = normalizeSkillUi(data.ui, {
|
||||
declaredPages: pages,
|
||||
skillId: id,
|
||||
});
|
||||
|
||||
/* The same definition's second consumer. `owliver:` declares what can be
|
||||
asked for in the panel, reading the source the page section already
|
||||
names — so one file answers "what does this page show" and "what can
|
||||
Owliver be asked here" without either being written twice. A definition
|
||||
with no `owliver:` block is exactly what it was before this existed. */
|
||||
const { owliver, errors: owliverErrors } = normalizeSkillOwliver(data.owliver, {
|
||||
ui,
|
||||
skillId: id,
|
||||
skillName: data.name || '',
|
||||
});
|
||||
|
||||
/* The questions this skill asks, when it collects its input in the chat
|
||||
rather than by opening something. */
|
||||
const conversation = sectionSteps(body, 'Conversation');
|
||||
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
ui,
|
||||
uiErrors,
|
||||
owliver,
|
||||
owliverErrors,
|
||||
/**
|
||||
* What this definition extends, derived from what it declares.
|
||||
*
|
||||
* Two things wear the same format and are managed as different lists: a
|
||||
* definition with a `ui:` block extends a *page*, and one that teaches
|
||||
* Owliver — an `owliver:` block, triggers, actions, a conversation —
|
||||
* extends the *assistant*. Reading that off the declaration rather than
|
||||
* off a `type:` field is what makes the split free: every definition
|
||||
* already written classifies itself, nothing stored has to be migrated,
|
||||
* and a definition doing both is listed in both places rather than
|
||||
* losing half of itself to a category.
|
||||
*/
|
||||
facets: skillFacets({ data, kind, ui, owliver, conversation }),
|
||||
name: data.name || 'Untitled skill',
|
||||
description: data.description || '',
|
||||
status: data.status === 'inactive' ? 'inactive' : 'active',
|
||||
@@ -213,12 +296,21 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
? data.triggers
|
||||
: [data.name].filter(Boolean)
|
||||
).map((t) => String(t).toLowerCase()),
|
||||
/**
|
||||
* Whether those triggers were *claimed* or merely inherited.
|
||||
*
|
||||
* The fallback above is convenient and, until this field existed,
|
||||
* indistinguishable from the real thing — so a definition that only draws
|
||||
* a card was silently claiming its own name as a phrase Owliver answers
|
||||
* to, and could take a question from a definition written to answer it.
|
||||
* Keeping the distinction lets the matcher weigh a claim differently from
|
||||
* a default without changing what `triggers` contains.
|
||||
*/
|
||||
declaredTriggers: Boolean(Array.isArray(data.triggers) && data.triggers.length),
|
||||
prompt: data.prompt || null,
|
||||
capabilities: sectionBullets(body, 'Capabilities'),
|
||||
purpose: sectionBullets(body, 'Purpose'),
|
||||
/* The questions this skill asks, when it collects its input in the chat
|
||||
rather than by opening something. */
|
||||
conversation: sectionSteps(body, 'Conversation'),
|
||||
conversation,
|
||||
path,
|
||||
body,
|
||||
custom,
|
||||
@@ -255,23 +347,41 @@ export function allSkills(customSources = []) {
|
||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Validates a definition before it is stored. Returns an error string or null. */
|
||||
/**
|
||||
* Validates a definition before it is stored. Returns an error string or null.
|
||||
*
|
||||
* Frontmatter first, then the declarative UI — an author is told the first
|
||||
* thing that is wrong, in the order they would fix it.
|
||||
*/
|
||||
export function validateSkillSource(raw) {
|
||||
if (!String(raw).trim()) return 'Paste or upload a Markdown definition.';
|
||||
let skill;
|
||||
try {
|
||||
skill = parseSkill(raw, { custom: true });
|
||||
} catch {
|
||||
return 'That definition could not be parsed.';
|
||||
} catch (error) {
|
||||
/* The YAML subset reports the line it failed on; that is far more useful
|
||||
than "could not be parsed". */
|
||||
return `That definition could not be parsed. ${error.message || ''}`.trim();
|
||||
}
|
||||
if (!skill.id) return 'The frontmatter needs an `id`.';
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(skill.id)) return '`id` must be lower-case letters, numbers and dashes.';
|
||||
if (!skill.name) return 'The frontmatter needs a `name`.';
|
||||
if (!skill.pages.length) return 'The frontmatter needs at least one `pages` entry.';
|
||||
const unknown = skill.pages.filter((p) => !ROUTE_BY_PAGE_KEY[p]);
|
||||
|
||||
const unknown = skill.pages.filter((p) => !surfaceFor(p));
|
||||
if (unknown.length) {
|
||||
return `Unknown page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Known pages: ${Object.keys(ROUTE_BY_PAGE_KEY).join(', ')}.`;
|
||||
return `Unsupported page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`;
|
||||
}
|
||||
|
||||
/* A UI block that names something the product does not offer is refused
|
||||
outright rather than registered with the offending section dropped. */
|
||||
if (skill.uiErrors?.length) return skill.uiErrors[0];
|
||||
|
||||
/* Same rule for the panel half of the definition: a capability, source or
|
||||
step the product cannot honour is refused now rather than registered as
|
||||
something Owliver offers and then cannot answer. */
|
||||
if (skill.owliverErrors?.length) return skill.owliverErrors[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -280,7 +390,10 @@ export const PAGE_KEYS = Object.keys(ROUTE_BY_PAGE_KEY).sort();
|
||||
|
||||
/** Context ids a skill applies to, resolved through the placement table. */
|
||||
export function contextIdsForSkill(skill) {
|
||||
return skill.pages.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId).filter(Boolean);
|
||||
return skill.pages
|
||||
.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId
|
||||
|| ROUTE_BY_PAGE_KEY[pageKeyForRoute(surfaceFor(key)?.route || '')]?.contextId)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,10 +410,11 @@ export function contextIdsForSkill(skill) {
|
||||
*/
|
||||
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) {
|
||||
if (!pageId) return [];
|
||||
const wanted = canonicalPage(pageId) || pageId;
|
||||
return allSkills(customSources).filter(
|
||||
(s) => s.status === 'active'
|
||||
&& !disabled.includes(s.id)
|
||||
&& s.pages.includes(pageId)
|
||||
&& s.pages.some((p) => (canonicalPage(p) || p) === wanted)
|
||||
&& (!kind || s.kind === kind)
|
||||
);
|
||||
}
|
||||
@@ -338,10 +452,40 @@ function triggerMatches(trigger, question) {
|
||||
return new RegExp(pattern).test(question);
|
||||
}
|
||||
|
||||
/** The first skill on this page whose triggers match the question. */
|
||||
/**
|
||||
* The first skill on this page whose triggers match the question.
|
||||
*
|
||||
* A definition has to be *addressable by the assistant* before its triggers
|
||||
* count, and there are two ways to be: teach Owliver something — an `owliver:`
|
||||
* block, an action, a conversation — or explicitly claim a phrase with
|
||||
* `triggers:`. A definition that does neither is a page extension that happens
|
||||
* to have a name, and matching it here means answering a question with a
|
||||
* restatement of a card's description.
|
||||
*
|
||||
* That was live: `hiring-activity` draws a flow on the Positions page, declares
|
||||
* no triggers, and inherited "hiring activity" from its own name — enough to
|
||||
* take "show hiring activity" from the definition written to answer it.
|
||||
*
|
||||
* Both conditions are read off what the definition declares, so a skill written
|
||||
* tomorrow is admitted or excluded by the same rule, and nothing that claimed a
|
||||
* phrase loses it.
|
||||
*/
|
||||
const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver');
|
||||
|
||||
export function matchSkill(question, contextId, disabled = [], customSources = []) {
|
||||
const q = String(question).toLowerCase();
|
||||
return skillsForContext(contextId, disabled, customSources).find(
|
||||
(s) => s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
|
||||
(s) => addressable(s) && s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The definitions belonging to one management surface.
|
||||
*
|
||||
* The single answer to "what belongs on the UI Skills list" and "what belongs
|
||||
* on the Owliver Skills list". Both lists come from `allSkills` — one registry,
|
||||
* two readings of it — so a definition cannot exist on one list and be unknown
|
||||
* to the other system.
|
||||
*/
|
||||
export const skillsWithFacet = (skills = [], facet) =>
|
||||
skills.filter((s) => s.facets?.includes(facet));
|
||||
|
||||
Reference in New Issue
Block a user