Files
krow_talent_app/src/lib/skills/owliverResolver.js
2026-08-28 11:02:02 +05:30

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