update owliver skill
This commit is contained in:
416
src/lib/skills/owliverResolver.js
Normal file
416
src/lib/skills/owliverResolver.js
Normal file
@@ -0,0 +1,416 @@
|
||||
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 offer.
|
||||
*
|
||||
* 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 })
|
||||
);
|
||||
|
||||
/* ── Suggestions for a record that has just appeared ────────────────────── */
|
||||
|
||||
/**
|
||||
* What can now be asked about a record the conversation just produced.
|
||||
*
|
||||
* Creating a position is the moment "who could do this?" becomes worth asking,
|
||||
* and the panel is the only thing that knows a position now exists. Rather than
|
||||
* naming a skill to offer — which would put a candidate-matching feature inside
|
||||
* the position-creation flow — this asks the registry the general question: of
|
||||
* the skills attached to this page, which declare a capability whose reading is
|
||||
* *about one position*? Those are exactly the ones that can say something about
|
||||
* the record just made.
|
||||
*
|
||||
* The record's own title is appended to each prompt, so the answer resolves
|
||||
* against it directly and the reader is never asked to pick from a list that
|
||||
* includes the position they are looking at. Nothing is named here: a skill
|
||||
* added tomorrow that reads a position is offered on the same terms.
|
||||
*/
|
||||
export function suggestionsForPosition(contextId, disabled = [], customSources = [], position) {
|
||||
if (!position?.title) return [];
|
||||
|
||||
const chips = [];
|
||||
|
||||
for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
|
||||
/* Only capabilities that read one position — a workspace-wide reading has
|
||||
nothing to do with the record that was just created. */
|
||||
const scoped = skill.owliver.capabilities.filter(
|
||||
(capability) => skill.owliver.responses[capability]?.context === 'positionId'
|
||||
);
|
||||
if (!scoped.length) continue;
|
||||
|
||||
const offered = skill.owliver.suggestions
|
||||
.filter((s) => !s.capability || scoped.includes(s.capability))
|
||||
.slice(0, 1)
|
||||
.map((s) => ({
|
||||
label: s.label,
|
||||
/* Named, so the reading resolves against this position rather than
|
||||
asking which one. */
|
||||
prompt: `${s.prompt} for ${position.title}`,
|
||||
skillId: skill.id,
|
||||
skillCapability: s.capability || null,
|
||||
}));
|
||||
|
||||
chips.push(...offered);
|
||||
}
|
||||
|
||||
return chips.slice(0, 2);
|
||||
}
|
||||
Reference in New Issue
Block a user