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