380 lines
16 KiB
JavaScript
380 lines
16 KiB
JavaScript
import { OWLIVER_CAPABILITIES, dataSourceLabel, owliverCapabilityFor } from './surfaces';
|
|
import { skillsForContext } from './registry';
|
|
import { resolveSkillData } from './dataResolver';
|
|
import { resolvePosition } from './workforceFlow';
|
|
|
|
/**
|
|
* The Owliver half of a skill definition, resolved.
|
|
*
|
|
* The page reads a definition through `SkillSurface`; this is the other reader.
|
|
* Given the page you are on and what you asked, it answers three questions and
|
|
* nothing else:
|
|
*
|
|
* 1. which registered skills apply here,
|
|
* 2. which of them you are asking for, and which capability,
|
|
* 3. what the answer is, read from the source the definition names.
|
|
*
|
|
* The rule that makes this an architecture rather than a lookup table: **no
|
|
* skill is named here.** There is no `if (skill.id === …)`, no prompt string
|
|
* matched against a constant, and no component per skill. A definition is
|
|
* matched by what it declares — its triggers, its suggestions, its name, its
|
|
* description — and answered by the shape it declares. A skill written after
|
|
* this file was last edited resolves exactly as well as one written before it.
|
|
*/
|
|
|
|
/* ── Which skills apply ─────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* The Owliver-enabled skills registered for a page context.
|
|
*
|
|
* `skillsForContext` is the single answer to "what is attached here", already
|
|
* honouring `pages:`, `status:` and the account's switched-off list — so the
|
|
* page's sections and the panel's answers are filtered by one rule, and
|
|
* switching a skill off in Settings removes both at once.
|
|
*/
|
|
export function owliverSkillsForContext(contextId, disabled = [], customSources = []) {
|
|
return skillsForContext(contextId, disabled, customSources)
|
|
.filter((skill) => skill.owliver?.enabled && skill.owliver.capabilities.length > 0);
|
|
}
|
|
|
|
/* ── Suggestions ────────────────────────────────────────────────────────── */
|
|
|
|
/** How many chips one skill may contribute, and how many all of them may. */
|
|
const PER_SKILL = 3;
|
|
const TOTAL = 4;
|
|
|
|
/**
|
|
* The chips a page's skills *declare*.
|
|
*
|
|
* NOT what the panel renders, and no longer on the production path. Which
|
|
* suggestions Owliver offers is decided by `GET /api/v1/owliver/suggestions` —
|
|
* the backend filters against the caller's role and ranks against the rows in
|
|
* PostgreSQL, and `lib/skills/serverSuggestions.js` turns the reply into chips.
|
|
* Nothing in `src/` ranks a suggestion any more.
|
|
*
|
|
* What is left here is the reading of a *definition*: given a skill file, what
|
|
* did its author write under `owliver.suggestions`, and which of those name a
|
|
* capability the skill actually offers. That is a question about the Markdown
|
|
* rather than about the workspace, and it is what the editor's preview and the
|
|
* definition checks in `scripts/skill-check.mjs` ask.
|
|
*
|
|
* Capped deliberately. A workspace with six skills attached would otherwise
|
|
* bury the page's own suggestions under twenty of them, and a suggestion nobody
|
|
* can find is not a suggestion. A skill contributes its first few, and the set
|
|
* as a whole stays within what the composer can show without becoming a menu.
|
|
*
|
|
* A suggestion naming a capability the skill does not offer is dropped rather
|
|
* than shown and then refused.
|
|
*/
|
|
export function owliverSuggestions(contextId, disabled = [], customSources = [], context = {}) {
|
|
const chips = [];
|
|
|
|
for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
|
|
/**
|
|
* Can this skill answer without asking a question back?
|
|
*
|
|
* A capability reading one position cannot say anything until it knows
|
|
* which — so on a page with nothing selected, clicking it produces a
|
|
* question rather than an answer. Those suggestions are still offered, but
|
|
* they are marked so the panel can rank them behind the ones that will
|
|
* actually answer. That is a property of the declared source, not of any
|
|
* particular skill: a definition added tomorrow reading one record is
|
|
* ranked the same way.
|
|
*/
|
|
const needs = (capability) => skill.owliver.responses[capability]?.context || null;
|
|
const met = (need) => !need
|
|
|| (need === 'positionId' && Boolean(context.position))
|
|
|| (need === 'candidateId' && Boolean(context.candidate));
|
|
|
|
const offered = skill.owliver.suggestions
|
|
.filter((s) => !s.capability || skill.owliver.capabilities.includes(s.capability))
|
|
.slice(0, PER_SKILL)
|
|
/* Deliberately not carried as `capability`: a chip with that field is one
|
|
of the *page's* own answers and bypasses routing entirely. A skill's
|
|
chip is an ordinary question, and is resolved the same way the same
|
|
words typed by hand would be — one path, so a chip can never answer
|
|
something the typed form would not. */
|
|
.map((s) => {
|
|
const need = needs(s.capability || skill.owliver.capabilities[0]);
|
|
return {
|
|
label: s.label,
|
|
prompt: s.prompt,
|
|
skillId: skill.id,
|
|
skillCapability: s.capability || null,
|
|
/* True when clicking this would have to ask which record first. */
|
|
deferred: !met(need),
|
|
};
|
|
});
|
|
chips.push(...offered);
|
|
}
|
|
|
|
/* Answerable suggestions first, then the ones that would ask a question
|
|
back — stable within each group, so a definition's own order is kept. */
|
|
const ready = chips.filter((c) => !c.deferred);
|
|
const asking = chips.filter((c) => c.deferred);
|
|
return [...ready, ...asking].slice(0, TOTAL);
|
|
}
|
|
|
|
/* ── Matching ───────────────────────────────────────────────────────────── */
|
|
|
|
const lower = (value) => String(value ?? '').toLowerCase();
|
|
|
|
/** Words worth matching on — the ones that carry the subject of a question. */
|
|
const STOP_WORDS = new Set([
|
|
'the', 'a', 'an', 'this', 'that', 'these', 'those', 'my', 'our', 'is', 'are', 'was', 'were',
|
|
'show', 'me', 'as', 'of', 'for', 'in', 'on', 'to', 'and', 'or', 'with', 'what', 'how', 'can',
|
|
'you', 'i', 'it', 'please', 'give', 'tell', 'about', 'here', 'now', 'current', 'currently',
|
|
]);
|
|
|
|
const words = (value) => lower(value).split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w));
|
|
|
|
/**
|
|
* Does a declared trigger match? `*` stands for anything in between, the same
|
|
* way the registry's own trigger matching reads it.
|
|
*/
|
|
function triggerMatches(trigger, question) {
|
|
if (!trigger.includes('*')) return question.includes(trigger);
|
|
const pattern = trigger
|
|
.split('*')
|
|
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
.join('[\\s\\S]{0,40}?');
|
|
return new RegExp(pattern).test(question);
|
|
}
|
|
|
|
/**
|
|
* How strongly a question asks for this skill.
|
|
*
|
|
* Evidence is weighted by how deliberate it is. A suggestion the author wrote
|
|
* and the reader clicked is the strongest signal there is; a declared trigger
|
|
* is next; the skill's own name is next; and shared words with its description
|
|
* are the weakest — enough to break a tie, never enough to win on their own.
|
|
*/
|
|
function scoreSkill(skill, question) {
|
|
const q = lower(question);
|
|
|
|
const suggestion = skill.owliver.suggestions.find((s) => lower(s.prompt) === q || lower(s.label) === q);
|
|
if (suggestion) return { score: 100, suggestion };
|
|
|
|
/**
|
|
* Deliberate evidence: the author said this skill answers this.
|
|
*
|
|
* A suggestion the reader is echoing, a declared trigger, or the skill's own
|
|
* name. One of these must hold before a definition may claim a question at
|
|
* all — see the floor below.
|
|
*/
|
|
let deliberate = 0;
|
|
if (skill.owliver.suggestions.some((s) => q.includes(lower(s.label)))) deliberate += 40;
|
|
if (skill.triggers.some((t) => triggerMatches(t, q))) deliberate += 30;
|
|
if (skill.name && q.includes(lower(skill.name))) deliberate += 20;
|
|
|
|
/**
|
|
* The floor. Sharing a word with a description is not a claim.
|
|
*
|
|
* This is the bug that let "create a position" be answered by a candidate
|
|
* matching skill: its description happened to contain the word "position",
|
|
* which scored three points, and three points beat nothing. Corroboration
|
|
* was being treated as evidence.
|
|
*
|
|
* So description overlap can now only *break a tie* between definitions that
|
|
* already named the subject, and can never qualify one on its own. A skill
|
|
* about candidates cannot claim a question about creating a role however its
|
|
* description happens to be worded — which is the general property, not a
|
|
* fix aimed at these two definitions.
|
|
*/
|
|
if (!deliberate) return { score: 0, suggestion: null };
|
|
|
|
const overlap = words(skill.description).filter((w) => q.includes(w)).length;
|
|
return { score: deliberate + Math.min(overlap * 3, 9), suggestion: null };
|
|
}
|
|
|
|
/** The capability a question asks for, from the shape words it uses. */
|
|
function scoreCapability(capability, question) {
|
|
const q = lower(question);
|
|
const definition = owliverCapabilityFor(capability);
|
|
if (!definition) return 0;
|
|
|
|
/* Longest matching term wins, so "as a flow" beats "flow" and a question
|
|
naming two shapes resolves to the more explicit one. */
|
|
return definition.terms.reduce(
|
|
(best, term) => (q.includes(term) && term.length > best ? term.length : best),
|
|
0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The skill and capability a question resolves to, or null.
|
|
*
|
|
* Both halves have to hold: a question that names no registered skill is not
|
|
* this system's to answer, and a skill matched with no capability falls back to
|
|
* the first one its definition declares — which is what makes "Show hiring
|
|
* activity" work without the author writing a trigger per shape.
|
|
*/
|
|
export function matchOwliverSkill(question, skills = []) {
|
|
let best = null;
|
|
|
|
for (const skill of skills) {
|
|
const { score, suggestion } = scoreSkill(skill, question);
|
|
if (score <= 0) continue;
|
|
if (!best || score > best.score) best = { skill, score, suggestion };
|
|
}
|
|
if (!best) return null;
|
|
|
|
const { skill, suggestion, score } = best;
|
|
const available = skill.owliver.capabilities;
|
|
|
|
/**
|
|
* `exact` means the question *is* a suggestion this definition published —
|
|
* the reader clicked a chip, or typed its words. It is the strongest claim
|
|
* anything can have on a question, and callers weighing this match against
|
|
* another matcher need to be able to see that rather than infer it from a
|
|
* number.
|
|
*/
|
|
const exact = Boolean(suggestion);
|
|
|
|
/* A chip that declared its capability has already answered this. */
|
|
if (suggestion?.capability && available.includes(suggestion.capability)) {
|
|
return { skill, capability: suggestion.capability, score, exact };
|
|
}
|
|
|
|
const asked = available
|
|
.map((capability) => ({ capability, weight: scoreCapability(capability, question) }))
|
|
.filter((c) => c.weight > 0)
|
|
.sort((a, b) => b.weight - a.weight)[0];
|
|
|
|
return { skill, capability: asked?.capability || available[0], score, exact };
|
|
}
|
|
|
|
/* ── The record a response is about ─────────────────────────────────────── */
|
|
|
|
/**
|
|
* The entity a source needs, resolved from the question and the page.
|
|
*
|
|
* Sources declare what they need — a position, a candidate, a draft, or
|
|
* nothing — and this is the one place that need is met. Three orders of
|
|
* evidence, most specific first:
|
|
*
|
|
* 1. the question named a record ("summarize hiring activity for Line Cook"),
|
|
* 2. the page has one open (the drawer, the form being filled in),
|
|
* 3. neither, and the answer has to ask.
|
|
*
|
|
* A source needing nothing resolves against the workspace and is always met.
|
|
*
|
|
* Naming the record wins over the page's selection deliberately: an admin who
|
|
* says which role they mean has said so, and answering about a different one
|
|
* because a drawer happened to be open would be worse than asking.
|
|
*/
|
|
export function resolveEntity(section, question, context = {}) {
|
|
const need = section.context;
|
|
if (!need) return { context, ok: true };
|
|
|
|
if (need === 'positionId') {
|
|
const named = resolvePosition(question, context.positions || [], null);
|
|
const position = named || context.position || null;
|
|
return position
|
|
? { context: { ...context, position }, ok: true }
|
|
: { ok: false, need: 'position' };
|
|
}
|
|
|
|
if (need === 'candidateId') {
|
|
const q = lower(question);
|
|
const named = (context.applications || []).find(
|
|
(a) => a.applicant_name && q.includes(lower(a.applicant_name))
|
|
);
|
|
const candidate = named || context.candidate || null;
|
|
return candidate
|
|
? { context: { ...context, candidate }, ok: true }
|
|
: { ok: false, need: 'candidate' };
|
|
}
|
|
|
|
return { context, ok: true };
|
|
}
|
|
|
|
/* ── The answer ─────────────────────────────────────────────────────────── */
|
|
|
|
/** The rows a reading offers, whatever shape its source returns them in. */
|
|
const rowsOf = (data) => data?.steps || data?.items || [];
|
|
|
|
/**
|
|
* One row, read back as a line.
|
|
*
|
|
* A reading carries a figure, a description of it, or both, depending on the
|
|
* source — so the line is assembled from what is there rather than from a fixed
|
|
* template, and a row with a description that already states its figure
|
|
* ("2 applications") does not repeat it.
|
|
*/
|
|
function rowLine(row) {
|
|
const label = String(row.label || row.title || row.id || '').trim();
|
|
if (!label) return null;
|
|
|
|
const value = row.value == null || row.value === '' ? null : String(row.value);
|
|
const detail = row.detail ? String(row.detail) : null;
|
|
|
|
const tail = detail
|
|
? (value && !detail.includes(value) ? `${detail} · ${value}` : detail)
|
|
: value;
|
|
|
|
return tail ? `${label} — ${tail}` : label;
|
|
}
|
|
|
|
/**
|
|
* A summary, built from whatever the source returned.
|
|
*
|
|
* Generic on purpose: it reads rows and states them. Nothing here knows what a
|
|
* period is, what a stage is, or which skill asked — which is precisely why a
|
|
* definition written tomorrow gets a summary without this function changing.
|
|
*/
|
|
export function summaryLines(data) {
|
|
return rowsOf(data).map((row) => rowLine(row)).filter(Boolean);
|
|
}
|
|
|
|
/** The label a reading is introduced by — the definition's words, then the source's. */
|
|
export const responseTitle = (skill, section) =>
|
|
section.title || `${skill.name} — ${dataSourceLabel(section.source)}`;
|
|
|
|
/**
|
|
* Everything an answer needs, resolved: the section, the data, and whether the
|
|
* page could supply the record the source required.
|
|
*
|
|
* Returns `{ skill, capability, section, data, missing }`. `missing` names what
|
|
* the caller must ask for; when it is null the reading is real and complete.
|
|
*/
|
|
export function resolveOwliverResponse({ skill, capability, question, context = {}, now = new Date() }) {
|
|
const section = skill.owliver.responses[capability];
|
|
if (!section) return null;
|
|
|
|
const entity = resolveEntity(section, question, context);
|
|
if (!entity.ok) {
|
|
return { skill, capability, section, data: null, missing: entity.need };
|
|
}
|
|
|
|
/* The same resolver the page's own sections go through, on the same
|
|
collections — so the panel and the card beside it cannot report different
|
|
figures for the same position. */
|
|
const data = resolveSkillData(section, entity.context, now);
|
|
return { skill, capability, section, data, missing: null, context: entity.context };
|
|
}
|
|
|
|
/**
|
|
* A reading, reduced to what a drawn section reads.
|
|
*
|
|
* A reply is kept in the thread, so it is stored: the records a source counted
|
|
* are the evidence behind a figure, not part of the answer, and writing every
|
|
* application into session storage to draw one bar would be paying for the
|
|
* whole dataset per turn. Nothing the renderers use is dropped.
|
|
*/
|
|
export function presentable(data) {
|
|
if (!data) return data;
|
|
const strip = ({ records, ...row }) => row;
|
|
|
|
return {
|
|
...data,
|
|
...(data.steps ? { steps: data.steps.map(strip) } : null),
|
|
...(data.items ? { items: data.items.map(strip) } : null),
|
|
};
|
|
}
|
|
|
|
/** Every capability the product understands, for the editor and the previews. */
|
|
export const CAPABILITY_SUMMARIES = OWLIVER_CAPABILITIES.map(
|
|
({ id, label, summary }) => ({ id, label, summary })
|
|
);
|