Files
krow_talent_app/src/components/ai-assistant/routing.js
2026-08-14 17:33:16 +05:30

748 lines
29 KiB
JavaScript

import { doc, heading, insights, list, note, skillSection, text } from './blocks';
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 {
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
candidateDetail, candidateMatches, candidateRoute, interviewReady, matchWorkforceIntent,
namedAssignmentPreview,
interviewPreview, interviewToExecute,
namedAssignmentToExecute, positionPriority, resolveCandidate, resolvePosition,
} from '@/lib/skills/workforceFlow';
import {
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
extractSkillName, findCourseByName,
} from '@/lib/skills/actions';
import { beginPositionFlow } from '@/lib/skills/positionFlow';
/**
* 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('Ask again once the page loads and I will answer from it.')
);
}
/**
* The reply when the question fits neither this page nor another one.
*
* Lists what this page can answer, taken from the capability labels the panel
* already shows as chips — so the offer is always exactly what is on screen.
*/
export function outOfScopeAnswer(context) {
const labels = (context?.capabilities || []).map((c) => c.label);
return doc(
text(`I do not have that on **${context?.page || 'this page'}**.`),
list(labels.slice(0, 6), { ordered: false }),
note('Ask about one of those, or open the page the question belongs to.')
);
}
/**
* 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
*/
/* ── 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) };
}
/**
* 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,
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);
}
/**
* Create Position is collected in the conversation, 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 position 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.
*/
if (skill.id === 'create-position') {
return { kind: 'skill', skill, ...beginPositionFlow({ question, skill, roles }) };
}
/**
* 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.')
),
};
}
export function resolveIntent({
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
courses = [], workforce = null, skillContext = null,
}) {
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
/* 2. Current page skills — specific triggers, ahead of the general reader. */
const skill = resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
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) };
}