update ui design for all pages
This commit is contained in:
393
src/components/ai-assistant/routing.js
Normal file
393
src/components/ai-assistant/routing.js
Normal file
@@ -0,0 +1,393 @@
|
||||
import { doc, list, note, text } from './blocks';
|
||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||
import { matchSkill } from '@/lib/skills/registry';
|
||||
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
|
||||
*/
|
||||
/* ── 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.'
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }) {
|
||||
const skill = matchSkill(question, contextId, disabledSkills, customSkills);
|
||||
if (!skill) return 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 }) };
|
||||
}
|
||||
|
||||
/* A skill with no coded handler still answers, from its own definition. That
|
||||
is what makes a Markdown-only skill useful on the page it names without a
|
||||
line of Owliver being changed for it. */
|
||||
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 = [],
|
||||
}) {
|
||||
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,
|
||||
});
|
||||
if (skill) return skill;
|
||||
|
||||
/* 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) };
|
||||
}
|
||||
Reference in New Issue
Block a user