Files
krow_talent_app/src/components/ai-assistant/routing.ts
Aravind 3f835eee93 refactor(ts-migration): Phase 11 batch 7 — the assistant's non-component modules
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
2026-09-18 15:35:15 +05:30

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