import { doc, insights, list, note, text } from './blocks'; import { ASSISTANT_CONTEXTS } from './contexts'; import { poolFor } from '@/lib/workforce'; import { matchSkill } from '@/lib/skills/registry'; 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.' ) ), }; } /** * 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 " 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 = [], workforce = 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, }); 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) }; }