The eleven `.js` files under `src/components/ai-assistant/`: the blocks
format, contexts, routing, history, placement, viewport, the greeting
and prompt tables in `dynamic`, the derivations in `insights`, and
`uiEdit`, whose boundary batch 1 already typed.
79 errors, and two optional markers cleared 63 of them.
`plural(n, word, irregular)` is called with two arguments sixty times in
`dynamic.ts` and its own body reads `irregular || \`${word}s\``, so the
third parameter has always been optional in everything but the
signature. `heading(value, sub)` is the same: `sub` is spread into the
block and `undefined` is what most callers mean. Marking both optional
is a statement about the existing contract, and the markers erase — the
emitted signatures still read `plural=(n,word,irregular)` and
`heading=(value,sub)`, checked in the output rather than assumed.
Those three `heading` errors landed in `lib/skills/workforceFlow.ts`,
already migrated and untouched here. Worth noting how that works: a
function's arity only starts being enforced on its callers once the file
defining it is TypeScript. Migrating a leaf makes claims about every
file that imports it, which is why this phase moves bottom-up.
The remaining nine were two `reduce` accumulators inferring `{}`, so
`Object.values` over them produced `unknown`. Both are now stated —
`{ label, count }` for the score bands, and the five-field hire grouping
— which is more useful than `any` and exactly what the lines below them
build.
Measured against `dde4ba6`:
typecheck 6 errors, unchanged; no new error anywhere
lint exit 0, 0 errors, 289 warnings
npm test 1684/1691, the same 7 failures verbatim
build exit 0, identical bundle hash 74d17e2d…
type erasure 83/83 byte-identical across Phase 11 so far
No baseline artifact touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
1047 lines
43 KiB
TypeScript
1047 lines
43 KiB
TypeScript
import { doc, heading, insights, list, note, skillSection, text } from './blocks';
|
|
import { resolveUiEdit } from './uiEdit';
|
|
import { ASSISTANT_CONTEXTS } from './contexts';
|
|
import { poolFor } from '@/lib/workforce';
|
|
import { matchSkill } from '@/lib/skills/registry';
|
|
import { owliverCapabilityLabel } from '@/lib/skills/surfaces';
|
|
import {
|
|
matchOwliverSkill, owliverSkillsForContext, presentable, resolveOwliverResponse,
|
|
responseTitle, summaryLines,
|
|
} from '@/lib/skills/owliverResolver';
|
|
import {
|
|
draftActions, draftContinuedReply, explainWeightsReply, matchDraftIntent, noDraftReply,
|
|
resolveDraft, weightsFromQuestion, weightsReply, weightsUnchangedReply, whichDraftReply,
|
|
} from '@/lib/skills/draftFlow';
|
|
import {
|
|
appliedToday, askHeadcount, askWhichPosition, assignmentPreview, assignmentToExecute,
|
|
availability, candidateDetail, candidateMatches, candidateRoute, headcountFrom,
|
|
interviewReady, matchWorkforceIntent,
|
|
namedAssignmentPreview,
|
|
interviewPreview, interviewToExecute,
|
|
namedAssignmentToExecute, positionPriority, resolveCandidate, resolvePosition,
|
|
} from '@/lib/skills/workforceFlow';
|
|
import {
|
|
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
|
|
extractSkillName, findCourseByName,
|
|
} from '@/lib/skills/actions';
|
|
import { beginFlow } from '@/lib/skills/conversationFlow';
|
|
import { flowFor } from '@/lib/skills/flows';
|
|
|
|
/**
|
|
* Intent routing — deciding whether a question belongs to the page you are on.
|
|
*
|
|
* Every page responder ends in a catch-all, which is right for a question about
|
|
* that page and wrong for anything else: asking "what are my permissions?" on
|
|
* Talent Pool used to return the talent priority table, confidently and
|
|
* irrelevantly. Two things were missing — a way to notice the question is about
|
|
* a different page, and a way to say "I cannot answer that here" instead of
|
|
* answering something else.
|
|
*
|
|
* This module supplies both, ahead of the responders:
|
|
*
|
|
* 1. If the question clearly belongs to another Admin page, say so and hand
|
|
* back that page's route so the panel can navigate. Context follows the
|
|
* route, so the next question is answered by the page you land on.
|
|
* 2. If it belongs to no page in particular and does not match anything this
|
|
* page can answer, say that plainly and offer what this page *does* cover.
|
|
*
|
|
* Routes come from the placement table, so nothing here invents an address.
|
|
*
|
|
* Keyword matching, like the responders themselves: this is a demo provider
|
|
* computing answers from local data, and the routing has to be as deterministic
|
|
* and inspectable as the answers are.
|
|
*/
|
|
|
|
/**
|
|
* Topics per destination. `route` is the real Admin path; `contextId` is the
|
|
* assistant context that page carries.
|
|
*
|
|
* `terms` are the phrases that mean "this is a question about that page", chosen
|
|
* to be unambiguous — generic words like "score" or "hiring" appear on every
|
|
* page and would send the reader somewhere for no reason.
|
|
*/
|
|
export const DESTINATIONS = [
|
|
{
|
|
contextId: 'admin.profile',
|
|
route: '/admin/profile',
|
|
page: 'Profile',
|
|
terms: [
|
|
'my permission', 'my permissions', 'permission', 'my role', 'my account',
|
|
'my profile', 'change my name', 'edit my profile', 'edit profile',
|
|
'password', 'two-factor', 'two factor', '2fa', 'sign out', 'log out',
|
|
'my session', 'preference', 'email digest', 'compact density',
|
|
],
|
|
},
|
|
{
|
|
contextId: 'admin.talentPool',
|
|
route: '/admin/talent-pool',
|
|
page: 'Talent Pool',
|
|
terms: ['talent pool', 'talent directory', 'talent segment', 'worker profile', 'career score'],
|
|
},
|
|
{
|
|
contextId: 'admin.positions',
|
|
route: '/admin/positions',
|
|
page: 'Positions',
|
|
terms: ['position', 'open role', 'open roles', 'job posting', 'requisition', 'vacancy'],
|
|
},
|
|
{
|
|
contextId: 'admin.candidatesList',
|
|
route: '/admin/candidates',
|
|
page: 'Candidates',
|
|
terms: ['candidate', 'applicant', 'shortlist', 'screening queue'],
|
|
},
|
|
{
|
|
contextId: 'admin.hiredHistory',
|
|
route: '/admin/hired',
|
|
page: 'Hired History',
|
|
terms: ['hired history', 'recent hire', 'recent hires', 'who did we hire', 'hiring history', 'department breakdown'],
|
|
},
|
|
{
|
|
contextId: 'admin.analytics',
|
|
route: '/admin/analytics',
|
|
page: 'Analytics',
|
|
terms: ['analytics', 'hiring trend', 'report', 'time to hire', 'conversion rate', 'average score'],
|
|
},
|
|
{
|
|
contextId: 'admin.forge',
|
|
route: '/admin/university',
|
|
page: 'KROW Forge',
|
|
terms: ['forge', 'challenge', 'course', 'skill', 'badge', 'certification', 'learning'],
|
|
},
|
|
{
|
|
contextId: 'admin.activity',
|
|
route: '/admin/activity',
|
|
page: 'Activity',
|
|
terms: ['activity log', 'audit', 'audit log', 'event log', 'who did what'],
|
|
},
|
|
{
|
|
contextId: 'admin.controlCenter',
|
|
route: '/admin',
|
|
page: 'Control Center',
|
|
terms: ['control center', 'control centre', 'platform health', 'dashboard'],
|
|
},
|
|
];
|
|
|
|
/**
|
|
* The destination a question points at, or `null` for "the page you are on".
|
|
*
|
|
* Longest match wins, so "talent pool" beats "pool" and a question naming two
|
|
* areas resolves to the more specific one rather than to whichever was listed
|
|
* first.
|
|
*/
|
|
export function findDestination(question) {
|
|
const q = String(question).toLowerCase();
|
|
|
|
let best = null;
|
|
for (const destination of DESTINATIONS) {
|
|
for (const term of destination.terms) {
|
|
if (q.includes(term) && (!best || term.length > best.term.length)) {
|
|
best = { ...destination, term };
|
|
}
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/**
|
|
* Does this page's own responder recognise the question?
|
|
*
|
|
* The responders' keyword lists are the definition of "answerable here", but
|
|
* they are private to each responder. Rather than duplicate them, a context can
|
|
* declare `topics` — the words its answers are actually about. A context without
|
|
* `topics` keeps the old behaviour of always answering, so this is additive.
|
|
*/
|
|
export function answerableHere(context, question) {
|
|
if (!context?.topics?.length) return true;
|
|
const q = String(question).toLowerCase();
|
|
return context.topics.some((topic) => q.includes(topic));
|
|
}
|
|
|
|
/** The short reply that accompanies a navigation. */
|
|
export function navigationAnswer(destination) {
|
|
return doc(
|
|
text(`That is on **${destination.page}**. Taking you there now.`),
|
|
note('Bringing your question with me — I will answer it from that page.')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The reply when the question fits neither this page nor another one.
|
|
*
|
|
* Reached only on a deployment with no agent configured. With one, a question
|
|
* this page cannot place is handed to the agent rather than declined — see
|
|
* `preferAgent`, which turns `outOfScope` into `answer`.
|
|
*
|
|
* It used to list the page's capability labels, taken from the chips on screen.
|
|
* Those labels were the local simulator's readings and went with it. The page's
|
|
* TOPICS are what survive, and they are a better offer anyway: a capability
|
|
* label named a canned report, where a topic names a subject somebody can
|
|
* actually ask about in their own words.
|
|
*/
|
|
export function outOfScopeAnswer(context) {
|
|
const subjects = (context?.topics || []).slice(0, 6);
|
|
return doc(
|
|
text(`I do not have that on **${context?.page || 'this page'}**.`),
|
|
subjects.length ? text(`This page covers ${subjects.join(', ')}.`) : null,
|
|
note('Ask about one of those, or open the page the question belongs to.')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The reply when the selected agent does not cover this page.
|
|
*
|
|
* Says three things, because leaving any of them out invites the reader to
|
|
* assume something untrue: which agent is active, that the *page* is what
|
|
* bounds the answer rather than the agent being broken, and which agent is the
|
|
* one for here.
|
|
*
|
|
* It deliberately does not answer from the requested agent's own pages. An
|
|
* agent is a lens on the page you are standing on, never a way to reach another
|
|
* one — reaching would make selecting an agent a way around the page boundary,
|
|
* which is the one thing the design does not permit.
|
|
*/
|
|
export function constrainedAgentAnswer(agent, context, suggestion) {
|
|
const covered = (agent?.pages || []).join(', ');
|
|
return doc(
|
|
text(`**${agent?.name || 'That agent'}** is selected, but it does not cover **${context?.page || 'this page'}**.`),
|
|
note(covered
|
|
? `It works on: ${covered}.`
|
|
: 'It covers no pages yet.'),
|
|
text(suggestion
|
|
? `On this page, **${suggestion.name}** is the one that answers. Switch to it, or open a page ${agent?.name || 'this agent'} covers.`
|
|
: 'Open a page it covers, or ask something this page can answer.'),
|
|
note('An agent narrows what can be asked here. It never widens it, so the records this page holds are the records any agent can read from it.')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve a question to an action, ahead of the page responder.
|
|
*
|
|
* Returns one of:
|
|
* { kind: 'answer' } — let this page respond
|
|
* { kind: 'navigate', destination, doc } — other page; go there
|
|
* { kind: 'outOfScope', doc } — nothing here can answer it
|
|
* { kind: 'constrained', doc } — the agent does not cover this page
|
|
*/
|
|
/* ── Workforce ──────────────────────────────────────────────────────────── */
|
|
|
|
/** The pages that carry enough context to answer a workforce question. */
|
|
const WORKFORCE_CONTEXTS = new Set(['admin.positions']);
|
|
|
|
/**
|
|
* A workforce question, resolved against the engine.
|
|
*
|
|
* Reading questions return their answer directly. The two that lead to a write
|
|
* are shaped differently on purpose:
|
|
*
|
|
* `assign` builds a preview and returns it as a normal answer, with a
|
|
* confirmation offered as a follow-up. Nothing is written.
|
|
* `confirm_assign` re-derives the plan from live data and hands it back as
|
|
* `{ kind: 'workforce', assign }` for `useAssistant` to
|
|
* execute through the existing mutation — the same shape the
|
|
* position-creation flow already uses.
|
|
*
|
|
* A question naming no position, on a page listing many, asks which rather than
|
|
* choosing one.
|
|
*/
|
|
function resolveWorkforce(question, workforce) {
|
|
const intent = matchWorkforceIntent(question);
|
|
if (!intent) return null;
|
|
|
|
const { positions = [], context = {}, currentPositionId = null } = workforce;
|
|
const position = resolvePosition(question, positions, currentPositionId);
|
|
|
|
/* Board-wide questions answer without a position. */
|
|
if (intent === 'priority') {
|
|
return { kind: 'answer-doc', ...positionPriority(positions, context) };
|
|
}
|
|
if (intent === 'applied_today') {
|
|
return { kind: 'answer-doc', ...appliedToday(position, context, positions) };
|
|
}
|
|
if (intent === 'interview_ready') {
|
|
return { kind: 'answer-doc', ...interviewReady(position, context, positions) };
|
|
}
|
|
|
|
/* Everything below is about one role, and guessing which is not acceptable. */
|
|
if (!position) return { kind: 'answer-doc', ...askWhichPosition(positions) };
|
|
|
|
if (intent === 'matches') return { kind: 'answer-doc', ...candidateMatches(position, context) };
|
|
if (intent === 'availability') return { kind: 'answer-doc', ...availability(position, context) };
|
|
|
|
/**
|
|
* Inspecting, explaining and opening a record are all about one person, so
|
|
* they resolve the name first and say so plainly when they cannot.
|
|
*/
|
|
if (intent === 'candidate_detail' || intent === 'eligibility' || intent === 'open_profile') {
|
|
const found = resolveCandidate(question, poolFor(position, context));
|
|
|
|
if (found.ambiguous) {
|
|
return {
|
|
kind: 'answer-doc',
|
|
doc: doc(text('More than one person matches that name.'), list(found.ambiguous.map((r) => r.name))),
|
|
followUp: found.ambiguous.slice(0, 4).map((r) => ({
|
|
label: r.name, prompt: `Show ${r.name} for ${position.title}`,
|
|
})),
|
|
};
|
|
}
|
|
if (!found.row) {
|
|
return { kind: 'answer-doc', ...candidateMatches(position, context) };
|
|
}
|
|
|
|
/* The one case that deliberately leaves the panel — and only because the
|
|
admin asked for the full record by name. */
|
|
if (intent === 'open_profile') {
|
|
const to = candidateRoute(found.row, context.applications || [], position);
|
|
return {
|
|
kind: 'answer-doc',
|
|
doc: to
|
|
? doc(
|
|
text(`Opening the full record for **${found.row.name}**.`),
|
|
insights([{ tone: 'info', title: `${found.row.name} — full profile`, body: 'Match analysis, reputation, experience, endorsements and activity.', to }])
|
|
)
|
|
: doc(
|
|
text(`**${found.row.name}** has no application on file, so there is no candidate record to open.`),
|
|
note('Assigning them creates one, and the full profile becomes available after that.')
|
|
),
|
|
followUp: [{ label: '← Back to candidate', prompt: `Show ${found.row.name} for ${position.title}` }],
|
|
};
|
|
}
|
|
|
|
return { kind: 'answer-doc', ...candidateDetail(position, found.row, context) };
|
|
}
|
|
|
|
/**
|
|
* A named person is a different request from "the best available": the admin
|
|
* has already chosen, and the job is to validate that choice rather than to
|
|
* rank. Both preview and confirmation check for a name first, so
|
|
* "assign Maria" and "confirm assignment of Maria" stay one conversation.
|
|
*/
|
|
const named = (intent === 'assign' || intent === 'confirm_assign')
|
|
? resolveCandidate(question, poolFor(position, context))
|
|
: {};
|
|
|
|
if (named.ambiguous) {
|
|
return {
|
|
kind: 'answer-doc',
|
|
doc: doc(
|
|
text('More than one person matches that name.'),
|
|
list(named.ambiguous.map((r) => r.name)),
|
|
note('Give me the full name and I will check them against this role.')
|
|
),
|
|
followUp: named.ambiguous.slice(0, 4).map((r) => ({
|
|
label: r.name,
|
|
prompt: `Assign ${r.name} to ${position.title}`,
|
|
})),
|
|
};
|
|
}
|
|
|
|
if (intent === 'assign') {
|
|
return named.row
|
|
? { kind: 'answer-doc', ...namedAssignmentPreview(position, named.row, context) }
|
|
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
|
|
}
|
|
|
|
/**
|
|
* The answer to "how many people do you need?".
|
|
*
|
|
* The only write in the workforce path that is not an assignment, and it
|
|
* follows the same contract: the routing layer decides *what* should happen
|
|
* and hands it back, and `useAssistant` performs it through the mutation the
|
|
* Create Position form already uses. A sentence with no number in it is not a
|
|
* failure — it is the question being asked again.
|
|
*/
|
|
if (intent === 'set_headcount') {
|
|
const count = headcountFrom(question);
|
|
return count
|
|
? { kind: 'workforce', headcount: { position, count } }
|
|
: { kind: 'answer-doc', ...askHeadcount(position, context) };
|
|
}
|
|
|
|
/**
|
|
* Interview setup, for one named person against this role. Both steps resolve
|
|
* the name the same way assignment does, so the conversation stays on the same
|
|
* candidate without the admin repeating themselves.
|
|
*/
|
|
if (intent === 'interview_setup' || intent === 'confirm_interview') {
|
|
const found = resolveCandidate(question, poolFor(position, context));
|
|
if (!found.row) {
|
|
return { kind: 'answer-doc', ...interviewReady(position, context, positions) };
|
|
}
|
|
|
|
if (intent === 'interview_setup') {
|
|
return { kind: 'answer-doc', ...interviewPreview(position, found.row, context) };
|
|
}
|
|
|
|
const plan = interviewToExecute(position, found.row, context);
|
|
if (!plan) {
|
|
/* Re-checked at confirmation: if it can no longer proceed, say why rather
|
|
than reporting a step that did not happen. */
|
|
return { kind: 'answer-doc', ...interviewPreview(position, found.row, context) };
|
|
}
|
|
return { kind: 'workforce', interview: plan };
|
|
}
|
|
|
|
if (intent === 'confirm_assign') {
|
|
/* Re-derived from live data at the moment of confirmation, so the people
|
|
written are the people who are still eligible. */
|
|
const plan = named.row
|
|
? namedAssignmentToExecute(position, named.row, context)
|
|
: assignmentToExecute(position, context);
|
|
|
|
if (!plan) {
|
|
/* The preview no longer holds — say so rather than writing something
|
|
different from what was agreed to. */
|
|
return named.row
|
|
? { kind: 'answer-doc', ...namedAssignmentPreview(position, named.row, context) }
|
|
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
|
|
}
|
|
return { kind: 'workforce', assign: plan };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/* ── Forge authoring ────────────────────────────────────────────────────── */
|
|
|
|
/** The five steps the Forge authoring flow actually walks through. */
|
|
const FORGE_STEPS = [
|
|
'Define the skill — name, category, difficulty, description',
|
|
'Build training — the steps the workforce learns',
|
|
'Define the proof — how they demonstrate it',
|
|
'Configure Owliver — what the evaluation checks',
|
|
'Review & publish — save a draft, or publish it',
|
|
];
|
|
|
|
/**
|
|
* The draft Owliver proposes for a new skill, read back to the author.
|
|
*
|
|
* Nothing is opened here. The draft is a proposal, and "Review skill" is the
|
|
* step where the author accepts it — which is what keeps Owliver on the same
|
|
* side of the line it holds everywhere else: it prepares, the administrator
|
|
* decides. There is no publish path from this reply at all.
|
|
*
|
|
* The follow-up carries the subject rather than a stored draft, so clicking it
|
|
* goes back through `resolveIntent` exactly as typing the same sentence would.
|
|
*/
|
|
function proposeSkill(skill, question, skillCategories) {
|
|
const { title, category, difficulty, outline, verification } = buildSkillPrefill(question, skillCategories);
|
|
|
|
if (!title) {
|
|
return {
|
|
kind: 'skill',
|
|
skill,
|
|
action: { name: 'open_create_skill_training', payload: { prefill: {} } },
|
|
doc: doc(
|
|
text('Opening Add Skill Training. Name the skill on the first step and I will take it from there.'),
|
|
list(FORGE_STEPS, { ordered: true }),
|
|
note('I do not create or publish skills on my own.')
|
|
),
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: 'skill',
|
|
skill,
|
|
doc: doc(
|
|
text(`Understood. I can create a workforce skill for **${title}**.`),
|
|
list([
|
|
category && `Category — ${category}`,
|
|
difficulty && `Level — ${difficulty}`,
|
|
verification && `Verification — ${verification}`,
|
|
]),
|
|
outline.length ? text('**Suggested training**') : null,
|
|
list(outline),
|
|
note('These are suggestions to review, not decisions. Open the draft and nothing is saved until you choose Save draft or Publish.')
|
|
),
|
|
followUp: [{ label: 'Review skill', prompt: `Review the ${title} skill` }],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The author accepted the draft — open the flow with it filled in.
|
|
*
|
|
* Derived from the subject again rather than from anything remembered between
|
|
* turns, so what opens is what the previous reply described.
|
|
*/
|
|
function openSkillDraft(skill, question, skillCategories) {
|
|
const { prefill, title } = buildSkillPrefill(question, skillCategories);
|
|
|
|
return {
|
|
kind: 'skill',
|
|
skill,
|
|
action: { name: 'open_create_skill_training', payload: { prefill } },
|
|
doc: doc(
|
|
text(`Opening the **${title}** draft in Add Skill Training.`),
|
|
list(FORGE_STEPS, { ordered: true }),
|
|
note('Nothing is saved until you choose Save draft or Publish. I do not publish skills on my own.')
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Training for a skill that already exists.
|
|
*
|
|
* The flow opens on its training step holding that record, so this edits the
|
|
* skill rather than creating a second one beside it. A request that names no
|
|
* skill asks which, offering the library's own titles — never a list written
|
|
* here.
|
|
*/
|
|
function proposeTraining(skill, question, courses, skillCategories) {
|
|
const course = findCourseByName(question, courses);
|
|
|
|
if (!course) {
|
|
/* "Training for latte art" names a subject the library does not hold yet.
|
|
That is a new skill, and its training is the second step of building one
|
|
— so answer the request that was actually made rather than insisting on a
|
|
record that does not exist. */
|
|
if (extractSkillName(question)) return proposeSkill(skill, question, skillCategories);
|
|
|
|
const titles = courses.slice(0, 5).map((c) => c.title).filter(Boolean);
|
|
return {
|
|
kind: 'skill',
|
|
skill,
|
|
doc: doc(
|
|
text('Which skill should the training belong to?'),
|
|
list(titles),
|
|
note(
|
|
titles.length
|
|
? 'Name one and I will open its training for you to edit.'
|
|
: 'The library is empty — create a skill first and its training comes with it.'
|
|
)
|
|
),
|
|
followUp: titles.map((title) => ({
|
|
label: title,
|
|
prompt: `Create training for ${title}`,
|
|
})),
|
|
};
|
|
}
|
|
|
|
const { prefill, suggested } = buildTrainingPrefill(course);
|
|
|
|
return {
|
|
kind: 'skill',
|
|
skill,
|
|
action: { name: 'open_create_training', payload: { prefill, courseId: course.id } },
|
|
doc: doc(
|
|
text(`Opening the training for **${course.title}**.`),
|
|
suggested.length ? text('**Suggested steps**') : null,
|
|
list(suggested),
|
|
note(
|
|
suggested.length
|
|
? 'These are suggestions to review. Edit them, then save — I do not change a published skill on my own.'
|
|
: 'The steps this skill already carries are loaded. Edit them, then save.'
|
|
)
|
|
),
|
|
};
|
|
}
|
|
|
|
/* ── Declared skills ────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* A skill answering from its own definition.
|
|
*
|
|
* The whole reply is composed from what the `.md` declared and what the shared
|
|
* resolver read: the title is the definition's, the capability is the shape it
|
|
* offers, the figures are the application's own records, and the follow-ups are
|
|
* the other capabilities the same definition carries. Nothing in this function
|
|
* knows which skill it is serving, and nothing may be added that does — the
|
|
* moment a skill id appears here, the definition has stopped being the source
|
|
* of truth.
|
|
*/
|
|
function declaredAnswer({ skill, capability, question, skillContext }) {
|
|
const resolved = resolveOwliverResponse({ skill, capability, question, context: skillContext });
|
|
if (!resolved) return null;
|
|
|
|
const { section, data, missing } = resolved;
|
|
const title = responseTitle(skill, section);
|
|
|
|
/* Other shapes of the same reading, offered rather than explained. A chip
|
|
per remaining capability, worded from the definition's own suggestions
|
|
where it wrote one for that shape. */
|
|
const followUp = skill.owliver.capabilities
|
|
.filter((c) => c !== capability)
|
|
.map((c) => {
|
|
const written = skill.owliver.suggestions.find((s) => s.capability === c);
|
|
return {
|
|
label: written?.label || `${skill.name} as ${owliverCapabilityLabel(c).toLowerCase()}`,
|
|
prompt: written?.prompt || `Show ${skill.name} as a ${c}`,
|
|
};
|
|
})
|
|
.slice(0, 3);
|
|
|
|
/**
|
|
* The reading needs a record the page has not supplied. Say which, and offer
|
|
* the ones there are — an answer about the wrong position would be worse
|
|
* than a question.
|
|
*/
|
|
if (missing === 'position') {
|
|
const open = (skillContext.positions || []).filter((p) => p.status === 'active').slice(0, 6);
|
|
return {
|
|
kind: 'skill',
|
|
skill,
|
|
doc: doc(
|
|
text(`**${title}** reads one position. Which should I read?`),
|
|
list(open.map((p) => p.title)),
|
|
note('Open a position, or name one and I will read it.')
|
|
),
|
|
followUp: open.slice(0, 4).map((p) => ({ label: p.title, prompt: `${question} for ${p.title}` })),
|
|
};
|
|
}
|
|
if (missing === 'candidate') {
|
|
return {
|
|
kind: 'skill',
|
|
skill,
|
|
doc: doc(
|
|
text(`**${title}** reads one candidate.`),
|
|
note('Open a candidate, or name one and I will read their record.')
|
|
),
|
|
};
|
|
}
|
|
/* A reading with nothing in it says so, in the resolver's own words. No
|
|
figure is invented to fill a shape. */
|
|
if (data.unavailable || data.empty) {
|
|
return {
|
|
kind: 'skill',
|
|
skill,
|
|
doc: doc(heading(title), text(data.emptyNote || 'There is nothing to report yet.')),
|
|
followUp,
|
|
};
|
|
}
|
|
|
|
/* A summary is prose; every other capability is the section the page would
|
|
draw, drawn here. */
|
|
const body = capability === 'summary'
|
|
? [
|
|
list(summaryLines(data)),
|
|
data.total != null ? note(`${data.total} record${data.total === 1 ? '' : 's'} in total.`) : null,
|
|
]
|
|
: [skillSection(section, presentable(data))];
|
|
|
|
return {
|
|
kind: 'skill',
|
|
skill,
|
|
doc: doc(heading(title, section.description || undefined), ...body),
|
|
followUp,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Skills that can act on this page, ahead of the page's own responder.
|
|
*
|
|
* A skill's triggers are specific ("create a position"); the responder is the
|
|
* general reader of the page. Checking skills first is what lets "create a
|
|
* bartender position" open a form instead of returning a pipeline report that
|
|
* happens to match the word "position".
|
|
*/
|
|
function resolveSkill({
|
|
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
|
companies = [], postings = null, skillContext = null,
|
|
}) {
|
|
/**
|
|
* One question, one skill, then one way of answering it.
|
|
*
|
|
* This used to be two independent matchers run in sequence: declared skills
|
|
* were resolved first and answered immediately, and only a question that
|
|
* matched none of them reached the trigger matcher and the coded flows below.
|
|
* That ordering was the bug. A declared definition could claim a question
|
|
* belonging to a flow it knew nothing about — "create a position" was
|
|
* answered by a candidate-matching skill — because the first matcher never
|
|
* asked whether anything else had a stronger claim.
|
|
*
|
|
* So both matchers run, and *then* the question is dispatched:
|
|
*
|
|
* 1. A skill with a coded flow owns its own triggers. Create Position and
|
|
* Forge authoring are conversations with state, not readings, and no
|
|
* declaration may take one over.
|
|
* 2. Otherwise a declared answer, if the definition earned the question.
|
|
* 3. Otherwise the definition reads itself back.
|
|
*
|
|
* The property this gives is the one that matters: a skill added tomorrow
|
|
* cannot capture a flow that already exists, whatever it declares.
|
|
*/
|
|
const declared = skillContext
|
|
? matchOwliverSkill(
|
|
question,
|
|
owliverSkillsForContext(contextId, disabledSkills, customSkills)
|
|
)
|
|
: null;
|
|
|
|
const triggered = matchSkill(question, contextId, disabledSkills, customSkills);
|
|
|
|
/**
|
|
* Which of the two named the question.
|
|
*
|
|
* A trigger is an explicit claim on a phrase, so it usually names the skill.
|
|
* Two cases where it must not, both of which were live bugs:
|
|
*
|
|
* - **A verbatim suggestion outranks a trigger.** A definition that
|
|
* published "Show hiring activity" and had the reader click it has the
|
|
* strongest claim there is. A *different* definition whose trigger merely
|
|
* contains those words — including the name every definition falls back
|
|
* to when it declares no triggers — has a weaker one, and was winning.
|
|
* - **An answer outranks a description.** A skill with no declared
|
|
* capabilities can only read its own definition back. If it takes a
|
|
* question another definition can actually answer, the reader gets a
|
|
* restatement of the question instead of the figures.
|
|
*
|
|
* Neither rule names a skill: both compare what the two definitions declared.
|
|
*/
|
|
const canAnswer = (s) => Boolean(s?.owliver?.enabled && s.owliver.capabilities.length);
|
|
/* A definition that collects answers in the chat or names an action to run is
|
|
a flow, not a reading, and owns the phrases it declared — read off what it
|
|
declares rather than from a list of ids, so a flow added later is protected
|
|
by the same rule. */
|
|
const ownsFlow = (s) => Boolean(s?.conversation?.length || s?.actions?.length);
|
|
|
|
const skill = (triggered && ownsFlow(triggered) && (!declared || !declared.exact))
|
|
? triggered
|
|
: declared && (declared.exact || !canAnswer(triggered))
|
|
? declared.skill
|
|
: triggered || declared?.skill || null;
|
|
if (!skill) return null;
|
|
|
|
/* The declared answer is only offered for the skill that actually won. */
|
|
const declaredForSkill = declared && declared.skill.id === skill.id ? declared : null;
|
|
|
|
/**
|
|
* Forge authoring. Same contract as Create Position: what the sentence
|
|
* answered is filled in, the rest is left blank, and the flow opens rather
|
|
* than a skill being created. Owliver publishes nothing on its own.
|
|
*
|
|
* Three requests share one skill because they are one job — proposing a
|
|
* skill, accepting that proposal, and adding training to a skill that already
|
|
* exists. Which one this is comes from the sentence, in that order of
|
|
* specificity: "review" is unambiguous, "training" names the step, and
|
|
* anything else that got here is a new skill.
|
|
*/
|
|
if (skill.id === 'forge-skill-management') {
|
|
const q = String(question).toLowerCase();
|
|
|
|
if (extractReviewSubject(question)) return openSkillDraft(skill, question, skillCategories);
|
|
|
|
/* "a skill training" is the compound noun for a new skill; "training for
|
|
<a skill the library has>" is the existing record. Naming a real skill
|
|
settles it either way. */
|
|
const named = findCourseByName(question, courses);
|
|
const wantsTraining = /\btraining\b/.test(q) && (named || !/\bskill training\b/.test(q));
|
|
|
|
if (wantsTraining) return proposeTraining(skill, question, courses, skillCategories);
|
|
return proposeSkill(skill, question, skillCategories);
|
|
}
|
|
|
|
/**
|
|
* A conversational skill is collected in the chat, not in a form.
|
|
*
|
|
* Nothing opens and nothing is navigated to: the skill's questions come back
|
|
* as a reply and its answers as chips, and the record is written at the end
|
|
* from what the conversation gathered. `flow` is the state that turn carries
|
|
* forward — the panel keeps it and feeds the next answer back in.
|
|
*
|
|
* Which conversation is the SKILL'S OWN `flow:` declaration, resolved through
|
|
* `FLOWS`. This used to be `if (skill.id === 'create-position')`, which made a
|
|
* second conversational skill a change to the router rather than a file on
|
|
* disk — precisely the `if agent_key == ...` shape §2's I6 rules out.
|
|
*/
|
|
const registry = flowFor(skill);
|
|
if (registry) {
|
|
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies, postings } }) };
|
|
}
|
|
|
|
/**
|
|
* No coded flow owns this one, so the definition answers it.
|
|
*
|
|
* A declared `owliver:` block is the richer answer — a real reading, drawn in
|
|
* the shape the author asked for — so it is tried first. A definition without
|
|
* one still reads itself back, which is what makes a Markdown-only skill
|
|
* useful on the page it names without a line of Owliver being changed for it.
|
|
*/
|
|
if (declaredForSkill) {
|
|
const answer = declaredAnswer({ ...declaredForSkill, question, skillContext });
|
|
if (answer) return answer;
|
|
}
|
|
|
|
return {
|
|
kind: 'skill',
|
|
skill,
|
|
doc: doc(
|
|
text(skill.description || `**${skill.name}** is available on this page.`),
|
|
list(skill.capabilities.slice(0, 6)),
|
|
note('Defined as an Owliver skill. Manage it in Settings → Skills.')
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A draft question, resolved against the position records the panel holds.
|
|
*
|
|
* Returns an intent the panel executes. The two that write — generating a
|
|
* description and setting weights, plus publishing — carry a `perform` block
|
|
* rather than performing anything here: this module stays pure, and the write
|
|
* goes through the mutation the Create Position form already uses.
|
|
*/
|
|
function resolveDraftAction(question, workforce, positionId = null) {
|
|
const action = matchDraftIntent(question);
|
|
if (!action) return null;
|
|
|
|
const positions = workforce.positions || [];
|
|
/* `positionId` is the record a control was built from — the Continue button on
|
|
a card, or a chip this flow wrote. It settles the question before any
|
|
guessing from the wording starts. */
|
|
const { position, ambiguous } = resolveDraft(
|
|
question, positions, workforce.currentPositionId, positionId
|
|
);
|
|
|
|
if (ambiguous) return { kind: 'draft', ...whichDraftReply(ambiguous) };
|
|
|
|
/**
|
|
* Nothing resolved. Only a question explicitly about drafts is answered with
|
|
* "there are none" — everything else is handed back.
|
|
*
|
|
* "Summarize these vetting weights" on the Create Position form is a question
|
|
* about the form, and the page has answered it since long before drafts could
|
|
* be continued here. Claiming it because it contains the word "weights" and
|
|
* then reporting that no draft exists would be this flow taking a question it
|
|
* cannot answer.
|
|
*/
|
|
if (!position) {
|
|
return action === 'continue' || action === 'list'
|
|
? { kind: 'draft', doc: noDraftReply() }
|
|
: null;
|
|
}
|
|
|
|
switch (action) {
|
|
/* One draft is continued; several are offered by name. Either way the
|
|
answer is here, and neither opens a form. */
|
|
case 'list':
|
|
return positions.filter((p) => p.status === 'draft').length > 1
|
|
? { kind: 'draft', ...whichDraftReply(positions.filter((p) => p.status === 'draft')) }
|
|
: {
|
|
kind: 'draft',
|
|
doc: draftContinuedReply(position),
|
|
followUp: draftActions(position),
|
|
};
|
|
|
|
case 'continue':
|
|
return {
|
|
kind: 'draft',
|
|
doc: draftContinuedReply(position),
|
|
/* Never "continue" again — the reply *is* the continuation. */
|
|
followUp: draftActions(position),
|
|
};
|
|
|
|
case 'show_weights':
|
|
return { kind: 'draft', doc: weightsReply(position), followUp: draftActions(position) };
|
|
|
|
case 'explain_weights':
|
|
return { kind: 'draft', doc: explainWeightsReply(position), followUp: draftActions(position) };
|
|
|
|
case 'set_weights': {
|
|
const next = weightsFromQuestion(question, position);
|
|
if (!next) {
|
|
return { kind: 'draft', doc: weightsUnchangedReply(position), followUp: draftActions(position) };
|
|
}
|
|
return { kind: 'draft', perform: { action: 'set_weights', position, weights: next } };
|
|
}
|
|
|
|
case 'generate_description':
|
|
return { kind: 'draft', perform: { action: 'generate_description', position } };
|
|
|
|
case 'publish':
|
|
return position.status === 'draft'
|
|
? { kind: 'draft', perform: { action: 'publish', position } }
|
|
: {
|
|
kind: 'draft',
|
|
doc: doc(text(`**${position.title}** is already published.`)),
|
|
followUp: draftActions(position),
|
|
};
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function resolveIntent({
|
|
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
|
courses = [], workforce = null, skillContext = null,
|
|
/* The clients this organization already staffs for, offered as chips on the
|
|
company question. Read off the postings the caller can already see, so it
|
|
expands nobody's view — see the `@companies` note in flows/position.js. */
|
|
companies = [],
|
|
postings = null,
|
|
/**
|
|
* The active agent, and where the reader is.
|
|
*
|
|
* Both optional and both inert when absent, so a caller that knows nothing
|
|
* about agents resolves exactly as it always did. `agent` does not gate the
|
|
* skills considered here — that has already happened, in `disabledSkills`,
|
|
* which arrives carrying the agent's scoping. What it decides is whether this
|
|
* agent should be answering on this page at all.
|
|
*/
|
|
agent = null, agentCoversPage = true, agentSuggestion = null, owliverContext = null,
|
|
/* The record the control that raised this question was built from, when there
|
|
was one. Only the draft flow reads it; a typed question carries none and
|
|
resolves exactly as it always did. */
|
|
positionId = null,
|
|
/**
|
|
* The layout session for this page, when there is one.
|
|
*
|
|
* Carries the tree on screen and whether something is already being
|
|
* previewed. Absent — or on a page that composes no tree — every branch below
|
|
* resolves exactly as it did before this existed.
|
|
*/
|
|
ui = null,
|
|
}) {
|
|
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
|
|
|
|
/**
|
|
* 0. The selected agent does not belong here.
|
|
*
|
|
* Ahead of everything, because every matcher below would otherwise answer
|
|
* from this page while the header names an agent that does not cover it — an
|
|
* answer attributed to the wrong lens. The page still decides what is
|
|
* readable; this only declines to pretend the agent chose it.
|
|
*/
|
|
if (agent && !agentCoversPage) {
|
|
return { kind: 'constrained', agent, doc: constrainedAgentAnswer(agent, context, agentSuggestion) };
|
|
}
|
|
|
|
/**
|
|
* 1. Finishing a draft, wherever the reader is standing.
|
|
*
|
|
* Ahead of everything else because these questions name a record and an act
|
|
* on it — continuing, generating, weighting, publishing — and every other
|
|
* matcher would read them as words about positions in general. It runs only
|
|
* where the panel actually has the positions to act on.
|
|
*/
|
|
/* Resolved even where the panel carries no workforce: a question about
|
|
continuing a draft is answered in the panel or answered as "there is no
|
|
draft", never handed to keyword routing — which is what used to send
|
|
"Continue the Bartender draft" into the authoring form, because Create
|
|
Position lists `position` and `role` among its topics. */
|
|
const draftIntent = resolveDraftAction(question, workforce || { positions: [] }, positionId);
|
|
if (draftIntent) return draftIntent;
|
|
|
|
/**
|
|
* 1b. Changing the page itself.
|
|
*
|
|
* Ahead of the skills because a request to hide a section is about the
|
|
* interface, and a skill trigger reading the same words would answer about
|
|
* the data behind it. Tightly gated: `resolveUiEdit` returns null unless the
|
|
* page composes a tree AND the words name something on it, a registered
|
|
* panel type, or the layout — so an ordinary question is never taken.
|
|
*/
|
|
const uiIntent = resolveUiEdit({ question, ui });
|
|
if (uiIntent) return uiIntent;
|
|
|
|
/* 2. Current page skills — specific triggers, ahead of the general reader. */
|
|
const skill = resolveSkill({
|
|
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies, postings,
|
|
/* The envelope travels beside the collections rather than replacing them:
|
|
a resolver reads records, and the envelope says where the reader is. A
|
|
source that needs a position still finds it exactly where it always was. */
|
|
skillContext: owliverContext ? { ...skillContext, owliver: owliverContext } : skillContext,
|
|
});
|
|
if (skill) return skill;
|
|
|
|
/**
|
|
* 3. Workforce questions, on the pages that hold workforce context.
|
|
*
|
|
* Ahead of the page responder because these are the same words it answers
|
|
* with analytics — "which position needs people" would otherwise return a
|
|
* pipeline report rather than the ranked answer the engine can give.
|
|
*/
|
|
const workforceIntent = workforce && WORKFORCE_CONTEXTS.has(contextId)
|
|
? resolveWorkforce(question, workforce)
|
|
: null;
|
|
if (workforceIntent) return workforceIntent;
|
|
|
|
/* The page you are on wins. Several subjects live on more than one page —
|
|
departments are on both Analytics and Hired History — and moving someone
|
|
off a page that can already answer them is worse than answering. Routing is
|
|
for questions this page genuinely cannot take. */
|
|
if (answerableHere(context, question)) return { kind: 'answer' };
|
|
|
|
const destination = findDestination(question);
|
|
if (destination && destination.contextId !== contextId) {
|
|
return { kind: 'navigate', destination, doc: navigationAnswer(destination) };
|
|
}
|
|
|
|
return { kind: 'outOfScope', doc: outOfScopeAnswer(context) };
|
|
}
|
|
|
|
|
|
/* ── Preferring the agent ─────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Decides whether a resolved intent should defer to the agent.
|
|
*
|
|
* BACKGROUND, because this function only makes sense with it.
|
|
*
|
|
* This panel grew up answering from the browser. Everything above computes an
|
|
* answer out of data React Query already fetched — instant, free, and unable to
|
|
* be wrong about a figure, because it reads the same cache the page renders
|
|
* from. What it cannot do is reason, and it can only answer the question shapes
|
|
* somebody wrote a matcher for.
|
|
*
|
|
* There is now a real agent behind the panel: a model, seventeen authorized
|
|
* tools, permissioned document retrieval, and a write path that asks before it
|
|
* acts. It can answer anything, and it can be asked a follow-up.
|
|
*
|
|
* Both paths wearing the same avatar was the problem. "Assign the strongest
|
|
* free worker to Picker" matched a template and answered "nobody is both
|
|
* qualified and free"; "put the best free worker on Picker" reached the agent,
|
|
* which found somebody and proposed them. Same intent, opposite answers,
|
|
* decided by which verb the user happened to type. That is not a product.
|
|
*
|
|
* WHAT THIS CHANGES, AND WHAT IT DELIBERATELY DOES NOT
|
|
*
|
|
* An intent that only produces TEXT defers to the agent. An intent that DOES
|
|
* something does not, and the distinction is the whole of the rule:
|
|
*
|
|
* - A template answer is one of several possible descriptions of rows the
|
|
* agent can also read. The agent's version can be followed up and cannot be
|
|
* beaten by a synonym, so it wins.
|
|
* - A flow, a draft action, an assignment, a headcount change, an interview
|
|
* being marked ready — these perform work the agent has no tool for. They
|
|
* are kept exactly as they are. Removing them would lose capability, not
|
|
* gimmickry.
|
|
* - A guided form that asks "which position?" one question at a time is an
|
|
* honest form. It stays, and it is not the agent pretending to converse.
|
|
* - `navigate` stays: sending somebody to the page that owns a subject is a
|
|
* product decision, not a failure to answer.
|
|
*
|
|
* `outOfScope` becomes an answer, and that is the clearest win here. It is the
|
|
* panel declining a question the agent could simply have answered.
|
|
*
|
|
* NOTHING IS DELETED. Every matcher above still runs and still returns what it
|
|
* always did; this only chooses not to use the text ones while an agent is
|
|
* live. Turn the agent off and the panel behaves exactly as it shipped — which
|
|
* is what makes this safe to try before anything is removed for good.
|
|
*/
|
|
export function preferAgent(intent, { modelBacked = false } = {}) {
|
|
if (!modelBacked || !intent) return intent;
|
|
|
|
/* Anything that performs work keeps its path. The presence of one of these
|
|
keys IS the definition of "does something" — see the handlers in
|
|
useAssistant, which are the only readers of them. */
|
|
if (intent.create || intent.perform || intent.assign || intent.headcount ||
|
|
intent.interview || intent.action || intent.flow) {
|
|
return intent;
|
|
}
|
|
|
|
switch (intent.kind) {
|
|
/* Pure text. The agent reads the same rows and can be asked a follow-up. */
|
|
case 'answer-doc':
|
|
case 'skill':
|
|
case 'workforce':
|
|
/* A refusal the agent would not have made. */
|
|
case 'outOfScope':
|
|
return { kind: 'answer' };
|
|
|
|
/* 'answer' already goes to the agent. 'navigate' and 'constrained' are
|
|
deliberate product behaviour and are left alone. */
|
|
default:
|
|
return intent;
|
|
}
|
|
}
|