update ui design for all pages
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { PanelLeftOpen, Sparkles } from 'lucide-react';
|
||||
import { PanelLeftOpen } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import KrowAssistant from './KrowAssistant';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { resolveAssistantContext } from './placement';
|
||||
|
||||
/**
|
||||
@@ -56,7 +57,15 @@ function writeFlag(key, value) {
|
||||
}
|
||||
|
||||
export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
const [open, setOpen] = React.useState(() => readFlag(OPEN_KEY, true));
|
||||
/* Two different things, deliberately kept apart: the account preference is
|
||||
whether Owliver *should* open by default, and the session flag is whether
|
||||
this tab currently has it collapsed. The preference is the fallback, so a
|
||||
panel collapsed for the afternoon does not rewrite the account setting, and
|
||||
an account with Owliver off does not get the panel pushed back at it on
|
||||
every new tab. */
|
||||
const [open, setOpen] = React.useState(
|
||||
() => readFlag(OPEN_KEY, base44.auth.preferences().owliverDefault !== false)
|
||||
);
|
||||
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
|
||||
const [width, setWidth] = React.useState(() => {
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import { useCreateJobPosting, usePreferences, useRoleCategories } from '@/lib/krowHooks';
|
||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
|
||||
import { runAction } from '@/lib/skills/actions';
|
||||
import { skillsForContext } from '@/lib/skills/registry';
|
||||
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
|
||||
import { buildIntro, buildPrompts } from './dynamic';
|
||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||
@@ -42,9 +48,83 @@ export default function KrowAssistant({
|
||||
}) {
|
||||
const facts = useAssistantFacts();
|
||||
const userName = useCurrentUserName();
|
||||
const navigate = useNavigate();
|
||||
|
||||
/* The app's own router, not a location assignment: a full page load would
|
||||
discard the thread and the panel state along with it. */
|
||||
const goToPage = React.useCallback(
|
||||
(destination) => navigate(destination.route),
|
||||
[navigate]
|
||||
);
|
||||
|
||||
/* Skills available here, and the roles they can act on — both read from what
|
||||
the app already has rather than from anything written into this component.
|
||||
A page whose skills change needs no edit here. */
|
||||
const preferences = usePreferences();
|
||||
const disabledSkills = React.useMemo(
|
||||
() => preferences.disabledSkills || [],
|
||||
[preferences.disabledSkills]
|
||||
);
|
||||
const { data: customCategories = [] } = useRoleCategories();
|
||||
const roles = React.useMemo(
|
||||
() => [...new Set([...ROLE_CATEGORIES, ...customCategories.map((c) => c.name)])],
|
||||
[customCategories]
|
||||
);
|
||||
/* The categories Forge skills are actually filed under, so "create a kitchen
|
||||
safety training" fills in the category this deployment uses rather than one
|
||||
written into a skill file. */
|
||||
const skillCategories = React.useMemo(
|
||||
() => [...new Set((facts.forge?.library || []).map((c) => c.category).filter(Boolean))],
|
||||
[facts.forge]
|
||||
);
|
||||
const customSkills = React.useMemo(
|
||||
() => preferences.customSkills || [],
|
||||
[preferences.customSkills]
|
||||
);
|
||||
const skills = React.useMemo(
|
||||
() => skillsForContext(context.id, disabledSkills, customSkills),
|
||||
[context.id, disabledSkills, customSkills]
|
||||
);
|
||||
const [addSkillOpen, setAddSkillOpen] = React.useState(false);
|
||||
|
||||
/**
|
||||
* A skill asked for something to happen. The names come from the skill file;
|
||||
* `runAction` decides what they mean and refuses anything the file did not
|
||||
* declare.
|
||||
*/
|
||||
const performAction = React.useCallback((action, skill) => {
|
||||
const result = runAction(action.name, { ...action.payload, skill });
|
||||
if (result?.type === 'navigate' && result.route) {
|
||||
navigate(result.route, result.state ? { state: result.state } : undefined);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
/**
|
||||
* Write the position the conversation collected.
|
||||
*
|
||||
* The same mutation the Create Position form calls, so there is one create
|
||||
* path and the list behind the panel refreshes the way it always did. The
|
||||
* skill still has to declare `create_position` — `runAction` returns nothing
|
||||
* for a skill whose file does not, and then nothing is written.
|
||||
*/
|
||||
const createJob = useCreateJobPosting();
|
||||
const createPosition = React.useCallback(async (draft, skill) => {
|
||||
const result = runAction('create_position', { draft, skill });
|
||||
if (result?.type !== 'create_position') return null;
|
||||
return createJob.mutateAsync(result.data);
|
||||
}, [createJob]);
|
||||
|
||||
const { messages, pending, error, busy, send, stop, reset } = useConversation({
|
||||
contextId: context.id,
|
||||
facts,
|
||||
onNavigate: goToPage,
|
||||
onAction: performAction,
|
||||
onCreatePosition: createPosition,
|
||||
disabledSkills,
|
||||
customSkills,
|
||||
roles,
|
||||
skillCategories,
|
||||
courses: facts.forge?.library || [],
|
||||
});
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
@@ -57,7 +137,17 @@ export default function KrowAssistant({
|
||||
() => buildIntro(context.id, facts, userName),
|
||||
[context.id, facts, userName]
|
||||
);
|
||||
const prompts = React.useMemo(() => buildPrompts(context.id, facts), [context.id, facts]);
|
||||
/* Suggestions are the page's own, with any skill that offers one on the front.
|
||||
The last answer can also propose follow-ups — the role list after "create a
|
||||
position" — which replace the standing set until the next turn. */
|
||||
const followUp = messages[messages.length - 1]?.followUp;
|
||||
const prompts = React.useMemo(() => {
|
||||
if (followUp?.length) return followUp;
|
||||
const skillPrompts = skills
|
||||
.filter((s) => s.prompt)
|
||||
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
|
||||
return [...skillPrompts, ...buildPrompts(context.id, facts)];
|
||||
}, [followUp, skills, context.id, facts]);
|
||||
|
||||
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
|
||||
at streaming frequency a smooth scroll never catches up and the thread
|
||||
@@ -67,19 +157,36 @@ export default function KrowAssistant({
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, pending]);
|
||||
|
||||
const submit = React.useCallback((text) => {
|
||||
const question = String(text).trim();
|
||||
if (!question || busy) return;
|
||||
send({ question });
|
||||
/**
|
||||
* The one entry point for every message, whatever raised it.
|
||||
*
|
||||
* A chip carries a capability — it *is* one of this page's answers, so it
|
||||
* bypasses intent routing. A typed question carries none and is routed. Both
|
||||
* land in the same `send`, so there is a single pipeline to reason about.
|
||||
*/
|
||||
const handleMessage = React.useCallback((question, capability = null) => {
|
||||
const text = String(question).trim();
|
||||
if (!text || busy) return;
|
||||
setInput('');
|
||||
send({ question: text, capability });
|
||||
}, [busy, send]);
|
||||
|
||||
/* A chip is already a complete question, so it runs on click. */
|
||||
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
|
||||
|
||||
/**
|
||||
* A chip is already a complete question, so it runs on click.
|
||||
*
|
||||
* A chip may instead carry a destination — "View position", offered after
|
||||
* Owliver creates one. That is the user choosing to go somewhere after the
|
||||
* work is finished, which is the only navigation any of this does.
|
||||
*/
|
||||
const runPrompt = React.useCallback((prompt) => {
|
||||
if (busy) return;
|
||||
setInput('');
|
||||
send({ question: prompt.prompt, capability: prompt.capability });
|
||||
}, [busy, send]);
|
||||
if (prompt.route) {
|
||||
navigate(prompt.route);
|
||||
return;
|
||||
}
|
||||
handleMessage(prompt.prompt, prompt.capability);
|
||||
}, [handleMessage, navigate]);
|
||||
|
||||
const composer = (
|
||||
<PromptInput
|
||||
@@ -116,6 +223,15 @@ export default function KrowAssistant({
|
||||
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Opens the same dialog Settings does; there is one registry behind
|
||||
both, so a skill added here is the skill added there. */}
|
||||
<IconButton
|
||||
icon={Sparkles}
|
||||
label="Add an Owliver skill"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAddSkillOpen(true)}
|
||||
/>
|
||||
{messages.length > 0 && (
|
||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
||||
)}
|
||||
@@ -179,17 +295,23 @@ export default function KrowAssistant({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddSkillDialog open={addSkillOpen} onOpenChange={setAddSkillOpen} />
|
||||
|
||||
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
|
||||
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
|
||||
{/* All suggestions on the landing screen, where they teach what can be
|
||||
asked. Capped once a thread exists, because from then on the vertical
|
||||
space belongs to the conversation. Expanded fits more per line, so it
|
||||
can afford one more. */}
|
||||
can afford one more.
|
||||
|
||||
Follow-ups are never capped: when Owliver has asked a question, its
|
||||
chips *are* the answers, and hiding three of the six roles would make
|
||||
the flow look broken. */}
|
||||
{!busy && (
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={runPrompt}
|
||||
max={isEmpty ? undefined : expanded ? 4 : 3}
|
||||
max={isEmpty || followUp?.length ? undefined : expanded ? 4 : 3}
|
||||
align="start"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
text, timeline,
|
||||
} from '../blocks';
|
||||
import { pct, plural, verb } from '../insights';
|
||||
/* The proof-method wording is the Forge page's own, so a skill is never named
|
||||
one thing in the library and another in the panel beside it. */
|
||||
import { TYPE_LABEL } from '@/components/forge/challengeMeta';
|
||||
import { toFICO } from '@/lib/talentHome';
|
||||
import { PERMISSIONS, PROFILE_ACTIONS } from '@/pages/admin/permissions';
|
||||
|
||||
/**
|
||||
* Admin capabilities — Control Center, Candidates Analysis, Activity.
|
||||
@@ -1357,161 +1361,162 @@ export const ACTIVITY_CAPABILITIES = [
|
||||
/* ── KROW Forge ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Forge capabilities — workforce skill verification.
|
||||
* Forge capabilities — managing workforce skill verification.
|
||||
*
|
||||
* The Forge questions are not hiring questions: they are about proof. What is
|
||||
* verified, what can be proven next, and what is gating the rest. Every figure
|
||||
* comes from `f.forge`, which is derived with the same unlock gate the page
|
||||
* renders from.
|
||||
* The Forge questions an administrator asks are not a worker's. A worker asks
|
||||
* what to prove next; the person who owns the library asks what is in it, what
|
||||
* is live, what Owliver is checking, and whether any of it is reaching the
|
||||
* workforce. Every figure comes from `f.forge`, derived from the same Course
|
||||
* records and worker profiles the page renders from.
|
||||
*/
|
||||
|
||||
const forgeProgress = (f) => {
|
||||
const forgeLibrary = (f) => {
|
||||
const g = f.forge;
|
||||
return doc(
|
||||
heading('Forge progress', `${plural(g.courses.length, 'challenge')} in the library`),
|
||||
kpis([
|
||||
{ label: 'Verified skills', value: g.verified.length, sub: g.verified.length ? 'proven' : 'nothing proven yet' },
|
||||
{ label: 'In progress', value: g.inProgress.length, sub: 'challenges started' },
|
||||
{ label: 'Ready to start', value: g.ready.length, sub: 'unlocked now' },
|
||||
{ label: 'XP available', value: g.xpAvailable, sub: `across ${plural(g.courses.length - g.completed.length, 'challenge')}` },
|
||||
]),
|
||||
g.verified.length
|
||||
? list(g.verified.map((b) => `**${b.name}** — ${b.level || 'Standard'}, verified`))
|
||||
: text('No skill is verified yet. The first one comes from completing a challenge and submitting proof for Owliver to grade.'),
|
||||
g.byDifficulty.length ? heading('Library by difficulty') : null,
|
||||
meters(g.byDifficulty.map((d) => ({
|
||||
label: d.level.charAt(0).toUpperCase() + d.level.slice(1),
|
||||
value: d.count,
|
||||
max: g.courses.length,
|
||||
}))),
|
||||
g.locked.length
|
||||
? note(`${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} still gated by real-work requirements.`)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const forgeNextChallenge = (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.next) return answer('Every active challenge is complete — there is nothing left to start.');
|
||||
|
||||
const c = g.next;
|
||||
const gate = g.blocked.find((b) => b.course.id === c.id);
|
||||
|
||||
return doc(
|
||||
heading(c.title, `${c.proof_skill || c.category} · ${c.difficulty}`),
|
||||
text(c.description),
|
||||
kpis([
|
||||
{ label: 'Duration', value: `${c.estimated_minutes} min` },
|
||||
{ label: 'XP', value: c.xp },
|
||||
{ label: 'Earns', value: c.badge_reward || '—' },
|
||||
{ label: 'State', value: gate ? 'Locked' : 'Ready', tone: gate ? 'warning' : 'success' },
|
||||
]),
|
||||
gate
|
||||
? insights([{
|
||||
tone: 'warning',
|
||||
title: 'Blocked for now',
|
||||
body: `${gate.reasons.join('. ')}. Those are real-work requirements, so they clear through shifts rather than through study.`,
|
||||
}])
|
||||
: insights([{
|
||||
tone: 'success',
|
||||
title: 'Ready to start',
|
||||
body: `Nothing is gating this one. ${plural(c.estimated_minutes, 'minute')} of work is worth ${c.xp} XP${c.badge_reward ? ` and the ${c.badge_reward} badge` : ''}.`,
|
||||
}]),
|
||||
g.ready.length > 1
|
||||
? actions(g.ready.slice(1, 3).map((alt) => ({
|
||||
title: alt.title,
|
||||
body: `${alt.difficulty} · ${alt.estimated_minutes} min · ${alt.xp} XP. An alternative if this one does not fit today.`,
|
||||
})))
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const forgeBlockers = (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.locked.length) {
|
||||
return answer(`Nothing is locked — all ${plural(g.ready.length, 'remaining challenge')} can be started now.`);
|
||||
if (!g.library.length) {
|
||||
return answer('The library is empty. A skill defines what the workforce learns, how they prove it, and what Owliver checks — start one from Add Skill Training.');
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${plural(g.locked.length, 'challenge')} blocked`, 'and what clears each one'),
|
||||
table(
|
||||
[
|
||||
{ key: 'challenge', label: 'Challenge' },
|
||||
{ key: 'skill', label: 'Skill' },
|
||||
{ key: 'requirement', label: 'Blocked by' },
|
||||
],
|
||||
g.blocked.map(({ course, reasons }) => ({
|
||||
challenge: course.title,
|
||||
skill: course.proof_skill || course.category,
|
||||
requirement: reasons[0] || '—',
|
||||
}))
|
||||
),
|
||||
insights(g.blocked.slice(0, 2).map(({ course, reasons }) => ({
|
||||
tone: 'warning',
|
||||
title: course.title,
|
||||
body: `${reasons.join('. ')}. Worth ${course.xp} XP${course.badge_reward ? ` and the ${course.badge_reward} badge` : ''} once it opens.`,
|
||||
heading('Skill library', `${plural(g.library.length, 'skill')} defined`),
|
||||
kpis([
|
||||
{ label: 'Active skills', value: g.published.length, sub: `of ${g.library.length} in the library` },
|
||||
{ label: 'Training modules', value: g.withTraining.length, sub: 'carrying material' },
|
||||
{ label: 'Challenges', value: g.withChallenge.length, sub: 'evidence-based' },
|
||||
{ label: 'Verified skills', value: g.workforceVerified, sub: g.workforceVerified ? 'earned by the workforce' : 'none earned yet' },
|
||||
]),
|
||||
g.byCategory.length ? heading('By category') : null,
|
||||
meters(g.byCategory.map((c) => ({
|
||||
label: c.category,
|
||||
value: c.count,
|
||||
max: g.library.length,
|
||||
}))),
|
||||
note('Requirements are measured against the profile record, so they update as shifts and reliability are logged.')
|
||||
g.drafts.length
|
||||
? note(`${plural(g.drafts.length, 'skill')} ${verb(g.drafts.length, 'is', 'are')} still in draft and not visible to the workforce.`)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const forgeReadyNow = (f) => {
|
||||
const forgePublished = (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.ready.length) {
|
||||
if (!g.published.length) {
|
||||
return answer(`Nothing is published. ${g.drafts.length ? `${plural(g.drafts.length, 'draft')} ${verb(g.drafts.length, 'is', 'are')} waiting on a decision.` : 'The library has no skills yet.'}`);
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${plural(g.published.length, 'skill')} published`, 'live to the workforce'),
|
||||
table(
|
||||
[
|
||||
{ key: 'skill', label: 'Skill' },
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'proof', label: 'Proof' },
|
||||
{ key: 'minutes', label: 'Min', align: 'right' },
|
||||
],
|
||||
g.published.map((c) => ({
|
||||
skill: c.title,
|
||||
category: c.category || '—',
|
||||
proof: TYPE_LABEL[c.challenge?.type] || 'Not set',
|
||||
minutes: c.estimated_minutes || '—',
|
||||
}))
|
||||
),
|
||||
g.drafts.length
|
||||
? insights([{
|
||||
tone: 'warning',
|
||||
title: `${plural(g.drafts.length, 'skill')} in draft`,
|
||||
body: `${g.drafts.map((c) => c.title).join(', ')}. A draft is invisible to the workforce until it is published.`,
|
||||
}])
|
||||
: note('Every skill in the library is published.')
|
||||
);
|
||||
};
|
||||
|
||||
const forgeEvaluation = (f) => {
|
||||
const g = f.forge;
|
||||
const scored = g.library.filter((c) => c.challenge?.rubric?.length);
|
||||
|
||||
if (!scored.length) {
|
||||
return answer('No skill has evaluation criteria yet. Owliver scores a submission against the criteria set on the skill, so a skill without them cannot verify anything.');
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading('What Owliver evaluates', `across ${plural(scored.length, 'skill')}`),
|
||||
table(
|
||||
[
|
||||
{ key: 'skill', label: 'Skill' },
|
||||
{ key: 'criteria', label: 'Criteria' },
|
||||
{ key: 'pass', label: 'Passes at', align: 'right' },
|
||||
],
|
||||
scored.map((c) => ({
|
||||
skill: c.title,
|
||||
criteria: c.challenge.rubric.map((r) => r.criterion).join(', '),
|
||||
pass: c.pass_score || '—',
|
||||
}))
|
||||
),
|
||||
g.library.length > scored.length
|
||||
? insights([{
|
||||
tone: 'warning',
|
||||
title: 'Skills with nothing to score against',
|
||||
body: `${g.library.filter((c) => !c.challenge?.rubric?.length).map((c) => c.title).join(', ')}. Owliver needs criteria to return a verdict.`,
|
||||
}])
|
||||
: note('Every skill in the library tells Owliver what to check.')
|
||||
);
|
||||
};
|
||||
|
||||
const forgeWorkforce = (f) => {
|
||||
const g = f.forge;
|
||||
const touched = g.usage.filter((u) => u.completed > 0 || u.verified > 0);
|
||||
|
||||
if (!touched.length) {
|
||||
return doc(
|
||||
text('Nothing is unlocked right now.'),
|
||||
g.locked.length ? text(`${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} waiting on real-work requirements.`) : null
|
||||
text('No skill in the library has been completed by anyone yet.'),
|
||||
note('Completion and verification are read from worker profiles, so figures appear here as the workforce works through the library.')
|
||||
);
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${plural(g.ready.length, 'challenge')} ready to start`),
|
||||
table(
|
||||
[
|
||||
{ key: 'challenge', label: 'Challenge' },
|
||||
{ key: 'difficulty', label: 'Difficulty' },
|
||||
{ key: 'minutes', label: 'Min', align: 'right' },
|
||||
{ key: 'xp', label: 'XP', align: 'right' },
|
||||
],
|
||||
g.ready.map((c) => ({
|
||||
challenge: c.title,
|
||||
difficulty: c.difficulty,
|
||||
minutes: c.estimated_minutes,
|
||||
xp: c.xp,
|
||||
}))
|
||||
),
|
||||
text(
|
||||
`Cheapest first: **${[...g.ready].sort((a, b) => a.estimated_minutes - b.estimated_minutes)[0].title}**. `
|
||||
+ `Highest value: **${[...g.ready].sort((a, b) => b.xp - a.xp)[0].title}**.`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const forgeSkillMap = (f) => {
|
||||
const g = f.forge;
|
||||
const skills = g.courses.map((c) => ({
|
||||
skill: c.proof_skill || c.category || 'Workforce skill',
|
||||
state: g.verified.some((b) => b.course_id === c.id)
|
||||
? 'Verified'
|
||||
: g.locked.some((l) => l.id === c.id) ? 'Locked' : 'Ready',
|
||||
xp: c.xp,
|
||||
}));
|
||||
|
||||
return doc(
|
||||
heading('Skills this library can verify', plural(skills.length, 'skill')),
|
||||
heading('Workforce usage', `${plural(touched.length, 'skill')} in use`),
|
||||
table(
|
||||
[
|
||||
{ key: 'skill', label: 'Skill' },
|
||||
{ key: 'state', label: 'State' },
|
||||
{ key: 'xp', label: 'XP', align: 'right' },
|
||||
{ key: 'completed', label: 'Completed', align: 'right' },
|
||||
{ key: 'verified', label: 'Verified', align: 'right' },
|
||||
],
|
||||
skills
|
||||
touched
|
||||
.sort((a, b) => b.verified - a.verified || b.completed - a.completed)
|
||||
.map((u) => ({
|
||||
skill: u.course.title,
|
||||
completed: u.completed,
|
||||
verified: u.verified,
|
||||
}))
|
||||
),
|
||||
text(
|
||||
g.verified.length
|
||||
? `${plural(g.verified.length, 'skill')} already carries evidence an employer can read.`
|
||||
: 'None of these carry evidence yet. Each verified skill adds a record an employer can read against the profile.'
|
||||
)
|
||||
g.library.length > touched.length
|
||||
? note(`${plural(g.library.length - touched.length, 'skill')} ${verb(g.library.length - touched.length, 'has', 'have')} no completions on record.`)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const forgeGaps = (f) => {
|
||||
const g = f.forge;
|
||||
|
||||
const gaps = [
|
||||
...g.drafts.map((c) => ({ skill: c.title, gap: 'Unpublished draft' })),
|
||||
...g.library.filter((c) => !c.challenge?.type).map((c) => ({ skill: c.title, gap: 'No proof method' })),
|
||||
...g.library.filter((c) => !c.challenge?.rubric?.length).map((c) => ({ skill: c.title, gap: 'No Owliver criteria' })),
|
||||
...g.library.filter((c) => !c.training_outline?.length && !c.quiz?.length).map((c) => ({ skill: c.title, gap: 'No training material' })),
|
||||
];
|
||||
|
||||
if (!gaps.length) {
|
||||
return answer(`All ${plural(g.library.length, 'skill')} carry training, a proof method and evaluation criteria, and every one is published.`);
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${plural(gaps.length, 'gap')} to close`, 'before these verify anything'),
|
||||
table(
|
||||
[
|
||||
{ key: 'skill', label: 'Skill' },
|
||||
{ key: 'gap', label: 'Missing' },
|
||||
],
|
||||
gaps
|
||||
),
|
||||
note('A skill needs training, a way to submit evidence, and criteria for Owliver to score against before it can verify a workforce skill.')
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1763,12 +1768,12 @@ const hiringRecentHires = (f) => {
|
||||
|
||||
export const respondForge = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'block', 'locked', 'lock', 'requirement', 'prerequisite', 'why can', 'unlock')) return forgeBlockers(f);
|
||||
if (has(q, 'next', 'should i', 'which challenge', 'recommend', 'best')) return forgeNextChallenge(f);
|
||||
if (has(q, 'ready', 'start now', 'available', 'can i start', 'active challenge')) return forgeReadyNow(f);
|
||||
if (has(q, 'skill', 'verified', 'proof', 'evidence', 'badge')) return forgeSkillMap(f);
|
||||
if (has(q, 'summar', 'progress', 'how am i', 'xp', 'overview')) return forgeProgress(f);
|
||||
return forgeProgress(f);
|
||||
if (has(q, 'publish', 'live', 'draft', 'unpublish', 'archived', 'status')) return forgePublished(f);
|
||||
if (has(q, 'owliver', 'evaluat', 'criteri', 'rubric', 'grade', 'score against', 'pass')) return forgeEvaluation(f);
|
||||
if (has(q, 'gap', 'missing', 'incomplete', 'unfinished', 'needs', 'ready to publish')) return forgeGaps(f);
|
||||
if (has(q, 'workforce', 'usage', 'completed', 'verified', 'earned', 'badge', 'who has', 'uptake')) return forgeWorkforce(f);
|
||||
if (has(q, 'skill', 'library', 'challenge', 'course', 'training', 'category', 'summar', 'overview', 'how many', 'what do we have')) return forgeLibrary(f);
|
||||
return forgeLibrary(f);
|
||||
};
|
||||
|
||||
export const respondTalentPool = (question, f) => {
|
||||
@@ -1793,11 +1798,11 @@ export const respondHiredHistory = (question, f) => {
|
||||
/* ── Capability manifests for the three new contexts ────────────────────── */
|
||||
|
||||
export const FORGE_CAPABILITIES = [
|
||||
{ id: 'forge-progress', label: 'Forge Progress', icon: Gauge, run: forgeProgress },
|
||||
{ id: 'forge-next', label: 'Next Challenge', icon: Target, run: forgeNextChallenge },
|
||||
{ id: 'forge-ready', label: 'Ready To Start', icon: UserCheck, run: forgeReadyNow },
|
||||
{ id: 'forge-blockers', label: 'What Is Blocking Me', icon: Lock, run: forgeBlockers },
|
||||
{ id: 'forge-skills', label: 'Skill Map', icon: ShieldCheck, run: forgeSkillMap },
|
||||
{ id: 'forge-library', label: 'Skill Library', icon: Gauge, run: forgeLibrary },
|
||||
{ id: 'forge-published', label: 'Published Skills', icon: Target, run: forgePublished },
|
||||
{ id: 'forge-evaluation', label: 'Owliver Evaluation', icon: ShieldCheck, run: forgeEvaluation },
|
||||
{ id: 'forge-workforce', label: 'Workforce Usage', icon: UserCheck, run: forgeWorkforce },
|
||||
{ id: 'forge-gaps', label: 'Gaps To Close', icon: Lock, run: forgeGaps },
|
||||
];
|
||||
|
||||
export const TALENT_POOL_CAPABILITIES = [
|
||||
@@ -1813,3 +1818,120 @@ export const HIRED_HISTORY_CAPABILITIES = [
|
||||
{ id: 'hiring-patterns', label: 'What Stands Out', icon: Layers, run: hiringPatterns },
|
||||
{ id: 'hiring-recent', label: 'Recent Hires', icon: ScrollText, run: hiringRecentHires },
|
||||
];
|
||||
|
||||
/* ── Profile ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* The account page answers questions about the operator rather than the
|
||||
* workforce: what this account may do, how it is secured, what it has been
|
||||
* doing, and which switches are on. Everything below reads the same constants
|
||||
* the page renders, so the panel can never describe a scope or a control that
|
||||
* is not on screen.
|
||||
*/
|
||||
|
||||
const profilePermissions = () => doc(
|
||||
heading('Your permissions', `${PERMISSIONS.length} scopes on this account`),
|
||||
table(
|
||||
[{ key: 'scope', label: 'Scope' }, { key: 'level', label: 'Level' }, { key: 'detail', label: 'Covers' }],
|
||||
PERMISSIONS.map((p) => ({
|
||||
scope: p.scope,
|
||||
level: {
|
||||
value: p.level,
|
||||
tone: p.level === 'Full access' ? 'success' : p.level === 'Read only' ? 'info' : 'muted',
|
||||
},
|
||||
detail: p.detail,
|
||||
}))
|
||||
),
|
||||
note('Scopes are set by the account owner. Billing stays restricted to owners.')
|
||||
);
|
||||
|
||||
const profileIdentity = (f) => {
|
||||
const user = f.user || {};
|
||||
return doc(
|
||||
heading('This account'),
|
||||
table(
|
||||
[{ key: 'field', label: 'Field' }, { key: 'value', label: 'Value' }],
|
||||
[
|
||||
{ field: 'Name', value: user.full_name || '—' },
|
||||
{ field: 'Email', value: user.email || '—' },
|
||||
{ field: 'Role', value: 'Platform Administrator' },
|
||||
{ field: 'Department', value: 'Workforce Operations' },
|
||||
{ field: 'Account status', value: { value: 'Active · verified', tone: 'success' } },
|
||||
{
|
||||
field: 'Member since',
|
||||
value: user.created_date
|
||||
? new Date(user.created_date).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||
: '—',
|
||||
},
|
||||
]
|
||||
),
|
||||
text('Use **Edit profile** at the top of the page to change your display name. Email and role are set by the account owner.')
|
||||
);
|
||||
};
|
||||
|
||||
const profileSecurity = () => doc(
|
||||
heading('Security on this account'),
|
||||
status([
|
||||
{ label: 'Two-factor authentication', ok: true, value: 'On', note: 'Required for privileged actions.' },
|
||||
{ label: 'Password age', ok: true, value: '42 days', note: 'Administrator passwords expire every 90 days.' },
|
||||
{ label: 'Session', ok: true, value: 'Browser tab', note: 'Closing the tab signs this account out.' },
|
||||
]),
|
||||
text('**Change password** and **Manage sessions** sit in the Security card on the right.')
|
||||
);
|
||||
|
||||
const profilePreferences = (f) => {
|
||||
const p = f.user?.preferences || {};
|
||||
const state = (on) => ({ value: on ? 'On' : 'Off', tone: on ? 'success' : 'muted' });
|
||||
return doc(
|
||||
heading('Your preferences'),
|
||||
table(
|
||||
[{ key: 'setting', label: 'Setting' }, { key: 'state', label: 'State' }, { key: 'detail', label: 'Effect' }],
|
||||
[
|
||||
{ setting: 'Owliver workspace', state: state(p.owliverDefault !== false), detail: 'Opens this panel by default on supported pages' },
|
||||
{ setting: 'Compact density', state: state(Boolean(p.compactDensity)), detail: 'Tighter row heights across Admin tables' },
|
||||
{ setting: 'Email digest', state: state(p.emailDigest !== false), detail: 'A daily summary of what needs attention' },
|
||||
]
|
||||
),
|
||||
note('Preferences save to your account as soon as you flip a switch.')
|
||||
);
|
||||
};
|
||||
|
||||
const profileActivity = (f) => {
|
||||
const email = f.user?.email;
|
||||
const mine = f.activity.filter((e) => e.user_email === email).slice(0, 6);
|
||||
if (!mine.length) {
|
||||
return doc(text('No activity is recorded against this account yet.'));
|
||||
}
|
||||
return doc(
|
||||
heading('Your recent activity', `${plural(mine.length, 'event')} on this account`),
|
||||
timeline(mine.map((e) => ({
|
||||
title: e.event_type.replace(/_/g, ' '),
|
||||
description: e.details || undefined,
|
||||
timestamp: new Date(e.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
})))
|
||||
);
|
||||
};
|
||||
|
||||
const profileActions = () => doc(
|
||||
heading('What you can do here'),
|
||||
list(PROFILE_ACTIONS.map((a) => `**${a.label}** — ${a.detail}`)),
|
||||
note('Everything above is on this page. Nothing here changes another operator’s account.')
|
||||
);
|
||||
|
||||
export const respondProfile = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'permission', 'scope', 'access', 'allowed', 'can i')) return profilePermissions(f);
|
||||
if (has(q, 'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out', 'log out')) return profileSecurity(f);
|
||||
if (has(q, 'preference', 'setting', 'digest', 'density', 'workspace', 'owliver')) return profilePreferences(f);
|
||||
if (has(q, 'activity', 'recent', 'history', 'what have i')) return profileActivity(f);
|
||||
if (has(q, 'do here', 'change my', 'edit', 'update', 'how do i')) return profileActions(f);
|
||||
return profileIdentity(f);
|
||||
};
|
||||
|
||||
export const PROFILE_CAPABILITIES = [
|
||||
{ id: 'profile-permissions', label: 'My Permissions', icon: ShieldCheck, run: profilePermissions },
|
||||
{ id: 'profile-identity', label: 'Account Details', icon: Users, run: profileIdentity },
|
||||
{ id: 'profile-security', label: 'Security', icon: Lock, run: profileSecurity },
|
||||
{ id: 'profile-preferences', label: 'My Preferences', icon: Gauge, run: profilePreferences },
|
||||
{ id: 'profile-activity', label: 'My Recent Activity', icon: ScrollText, run: profileActivity },
|
||||
{ id: 'profile-actions', label: 'What I Can Do Here', icon: Lightbulb, run: profileActions },
|
||||
];
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
import {
|
||||
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
|
||||
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, FORGE_CAPABILITIES,
|
||||
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, TALENT_POOL_CAPABILITIES,
|
||||
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, PROFILE_CAPABILITIES,
|
||||
TALENT_POOL_CAPABILITIES,
|
||||
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
|
||||
respondControlCenter, respondForge, respondHiredHistory, respondPositions,
|
||||
respondTalentPool,
|
||||
respondProfile, respondTalentPool,
|
||||
} from './capabilities/admin';
|
||||
|
||||
/**
|
||||
@@ -21,6 +22,11 @@ import {
|
||||
*
|
||||
* Krow AI serves Employer and Admin. The talent portal has Owliver, a separate
|
||||
* product with its own UI and workflow — nothing here targets it.
|
||||
*
|
||||
* `topics` is what this page's answers are actually about. A question matching
|
||||
* none of them is not quietly answered with the page's default report — see
|
||||
* routing.js. Contexts without `topics` keep answering everything, so declaring
|
||||
* them is opt-in per page.
|
||||
*/
|
||||
export const ASSISTANT_CONTEXTS = {
|
||||
'employer.overview': {
|
||||
@@ -44,12 +50,14 @@ export const ASSISTANT_CONTEXTS = {
|
||||
'admin.controlCenter': {
|
||||
id: 'admin.controlCenter',
|
||||
page: 'Control Center',
|
||||
topics: ['health', 'platform', 'attention', 'urgent', 'bottleneck', 'funnel', 'pipeline', 'conversion', 'recommend', 'should', 'summary', 'summarize', 'workforce', 'operation', 'velocity', 'speed', 'hiring', 'how many', 'unusual', 'anomal', 'risk', 'overview', 'status'],
|
||||
capabilities: CONTROL_CENTER_CAPABILITIES,
|
||||
respond: respondControlCenter,
|
||||
},
|
||||
'admin.positions': {
|
||||
id: 'admin.positions',
|
||||
page: 'Positions',
|
||||
topics: ['position', 'role', 'posting', 'vacancy', 'fill', 'attention', 'priorit', 'bottleneck', 'funnel', 'waiting', 'review', 'screen', 'applicant', 'pipeline', 'strength', 'activity', 'hiring', 'how many', 'which'],
|
||||
capabilities: POSITIONS_CAPABILITIES,
|
||||
respond: respondPositions,
|
||||
},
|
||||
@@ -59,12 +67,14 @@ export const ASSISTANT_CONTEXTS = {
|
||||
'admin.candidatesList': {
|
||||
id: 'admin.candidatesList',
|
||||
page: 'Candidates',
|
||||
topics: ['candidate', 'applicant', 'attention', 'waiting', 'interview', 'ready', 'score', 'unscored', 'risk', 'flag', 'strongest', 'best', 'top', 'compare', 'shortlist', 'pipeline', 'summar', 'how many', 'who'],
|
||||
capabilities: CANDIDATE_LIST_CAPABILITIES,
|
||||
respond: respondCandidateList,
|
||||
},
|
||||
'admin.candidates': {
|
||||
id: 'admin.candidates',
|
||||
page: 'Candidates Analysis',
|
||||
topics: ['candidate', 'applicant', 'gap', 'missing', 'coverage', 'unscored', 'risk', 'flag', 'compare', 'top', 'best', 'strongest', 'shortlist', 'recommend', 'hire', 'who', 'pool', 'quality'],
|
||||
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
|
||||
respond: respondAdminCandidates,
|
||||
},
|
||||
@@ -73,33 +83,57 @@ export const ASSISTANT_CONTEXTS = {
|
||||
'admin.analytics': {
|
||||
id: 'admin.analytics',
|
||||
page: 'Analytics',
|
||||
topics: ['trend', 'over time', 'month', 'week', 'department', 'category', 'team', 'perform', 'bottleneck', 'funnel', 'conversion', 'position', 'role', 'score', 'attention', 'rate', 'average', 'breakdown', 'report', 'how many', 'compare'],
|
||||
capabilities: ADMIN_ANALYTICS_CAPABILITIES,
|
||||
respond: respondAdminAnalytics,
|
||||
},
|
||||
/* Forge, Talent Pool and Hired History read the same platform records as the
|
||||
contexts above, but ask different questions of them: proof and progression,
|
||||
supply and readiness, and outcomes after the hire. */
|
||||
/* Forge is an authoring console, so its topics are the author's: what the
|
||||
library holds, what is live, what Owliver checks, and how the workforce is
|
||||
doing with it. */
|
||||
'admin.forge': {
|
||||
id: 'admin.forge',
|
||||
page: 'KROW Forge',
|
||||
topics: ['forge', 'skill', 'challenge', 'course', 'training', 'library', 'badge',
|
||||
'certification', 'publish', 'published', 'draft', 'archive', 'status', 'category',
|
||||
'owliver', 'evaluat', 'criteri', 'rubric', 'proof', 'evidence', 'verify', 'verification',
|
||||
'verified', 'workforce', 'gap', 'missing', 'what do we have'],
|
||||
capabilities: FORGE_CAPABILITIES,
|
||||
respond: respondForge,
|
||||
},
|
||||
'admin.talentPool': {
|
||||
id: 'admin.talentPool',
|
||||
page: 'Talent Pool',
|
||||
topics: ['talent', 'pool', 'worker', 'profile', 'priorit', 'availab', 'verif', 'score', 'unscored', 'segment', 'supply', 'summar', 'how many', 'who', 'shortlist'],
|
||||
capabilities: TALENT_POOL_CAPABILITIES,
|
||||
respond: respondTalentPool,
|
||||
},
|
||||
'admin.hiredHistory': {
|
||||
id: 'admin.hiredHistory',
|
||||
page: 'Hired History',
|
||||
topics: ['hire', 'hired', 'outcome', 'department', 'quality', 'recent', 'strongest', 'pattern', 'time to hire', 'retention', 'how many', 'who'],
|
||||
capabilities: HIRED_HISTORY_CAPABILITIES,
|
||||
respond: respondHiredHistory,
|
||||
},
|
||||
/* The account page. A form rather than a workflow, but the questions people
|
||||
ask about it — what may I do, how is this secured, what did I change — are
|
||||
answerable from what the page already shows. */
|
||||
'admin.profile': {
|
||||
id: 'admin.profile',
|
||||
page: 'Profile',
|
||||
topics: ['permission', 'scope', 'access', 'allowed', 'can i', 'account', 'role',
|
||||
'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out',
|
||||
'preference', 'setting', 'digest', 'density', 'workspace', 'owliver', 'profile',
|
||||
'name', 'email', 'activity', 'recent', 'do here', 'how do i', 'edit', 'change my'],
|
||||
capabilities: PROFILE_CAPABILITIES,
|
||||
respond: respondProfile,
|
||||
},
|
||||
'admin.activity': {
|
||||
id: 'admin.activity',
|
||||
page: 'Activity',
|
||||
topics: ['activity', 'audit', 'event', 'log', 'security', 'breach', 'compliance', 'trace', 'unusual', 'anomal', 'suspicious', 'user', 'who', 'account', 'busiest'],
|
||||
capabilities: ACTIVITY_CAPABILITIES,
|
||||
respond: respondActivity,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { plural, verb } from './insights';
|
||||
import { PERMISSIONS } from '@/pages/admin/permissions';
|
||||
|
||||
/**
|
||||
* Dynamic greetings and suggested prompts.
|
||||
@@ -133,16 +134,20 @@ const GREETINGS = {
|
||||
return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`;
|
||||
},
|
||||
|
||||
/* The line the panel opens with: whatever most needs the author's attention,
|
||||
which on an authoring console is an unfinished skill before it is a total. */
|
||||
'admin.forge': (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.courses.length) return 'No challenges published yet, so nothing can be proven here.';
|
||||
if (g.locked.length && !g.ready.length) {
|
||||
return `${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} gated by real-work requirements.`;
|
||||
if (!g.library.length) return 'The library is empty — no skill is defined yet.';
|
||||
|
||||
const unscored = g.library.filter((c) => !c.challenge?.rubric?.length).length;
|
||||
if (unscored) {
|
||||
return `${plural(unscored, 'skill')} ${verb(unscored, 'has', 'have')} no Owliver criteria, so ${verb(unscored, 'it', 'they')} cannot verify anything yet.`;
|
||||
}
|
||||
if (!g.verified.length) {
|
||||
return `Nothing verified yet — ${plural(g.ready.length, 'challenge')} ${verb(g.ready.length, 'is', 'are')} ready to start.`;
|
||||
if (g.drafts.length) {
|
||||
return `${plural(g.drafts.length, 'skill')} ${verb(g.drafts.length, 'is', 'are')} in draft and not visible to the workforce.`;
|
||||
}
|
||||
return `${plural(g.verified.length, 'skill')} verified, ${plural(g.ready.length, 'challenge')} ready to start.`;
|
||||
return `${plural(g.published.length, 'skill')} published, ${plural(g.workforceVerified, 'verification')} earned.`;
|
||||
},
|
||||
|
||||
'admin.talentPool': (f) => {
|
||||
@@ -165,6 +170,15 @@ const GREETINGS = {
|
||||
return `${plural(h.hires.length, 'hire')} placed, averaging ${h.avgQuality} at hire.`;
|
||||
},
|
||||
|
||||
'admin.profile': (f) => {
|
||||
const mine = f.user
|
||||
? f.activity.filter((e) => e.user_email === f.user.email)
|
||||
: [];
|
||||
const full = PERMISSIONS.filter((p) => p.level === 'Full access').length;
|
||||
if (!mine.length) return `${plural(full, 'scope')} at full access on this account.`;
|
||||
return `${plural(full, 'scope')} at full access, ${plural(mine.length, 'event')} logged against this account.`;
|
||||
},
|
||||
|
||||
'admin.activity': (f) => {
|
||||
if (!f.activity.length) return 'No activity logged yet.';
|
||||
const patterns = f.activitySignals.flags.length;
|
||||
@@ -203,6 +217,7 @@ const TITLES = {
|
||||
'admin.forge': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.talentPool': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.hiredHistory': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.profile': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
};
|
||||
|
||||
/** Descriptions state what this page's assistant can actually do with the data. */
|
||||
@@ -223,11 +238,13 @@ const DESCRIPTIONS = {
|
||||
`Reading hiring performance across ${plural(f.openPositions.length, 'position')}, ${plural(f.total, 'candidate')} and ${plural(new Set(f.byRole.map((r) => r.category)).size, 'department')} — conversion, speed and outcomes.`,
|
||||
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
|
||||
'admin.forge': (f) =>
|
||||
`Reading ${plural(f.forge.courses.length, 'challenge')} — verified skills, what is ready to start and what is still locked.`,
|
||||
`Reading ${plural(f.forge.library.length, 'skill')} — training, proof, what Owliver evaluates and how the workforce is doing with them.`,
|
||||
'admin.talentPool': (f) =>
|
||||
`Reading ${plural(f.profiles.length, 'talent profile')} — availability, career score and verification.`,
|
||||
'admin.hiredHistory': (f) =>
|
||||
`Reading ${plural(f.hiring.hires.length, 'hire')} across ${plural(f.hiring.byDepartment.length, 'department')} — quality, speed and outcomes.`,
|
||||
'admin.profile': () =>
|
||||
'Reading this account — permissions, security, preferences and your own activity.',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -284,9 +301,9 @@ const PLACEHOLDERS = {
|
||||
'Ask Owliver about security concerns…',
|
||||
],
|
||||
'admin.forge': () => [
|
||||
'Ask Owliver which challenge to take next…',
|
||||
'Ask Owliver what is blocking a skill…',
|
||||
'Ask Owliver about Forge progress…',
|
||||
'Ask Owliver to create a skill training…',
|
||||
'Ask Owliver what the library covers…',
|
||||
'Ask Owliver what a skill verifies…',
|
||||
],
|
||||
'admin.talentPool': () => [
|
||||
'Ask Owliver who to prioritize…',
|
||||
@@ -298,6 +315,11 @@ const PLACEHOLDERS = {
|
||||
'Ask Owliver which roles hired best…',
|
||||
'Ask Owliver what stands out…',
|
||||
],
|
||||
'admin.profile': () => [
|
||||
'Ask Owliver about your permissions…',
|
||||
'Ask Owliver what you can change here…',
|
||||
'Ask Owliver about your security settings…',
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Suggested prompts ──────────────────────────────────────────────────── */
|
||||
@@ -426,18 +448,22 @@ const PROMPTS = {
|
||||
rather than offering the same questions regardless of state. */
|
||||
'admin.forge': (f) => {
|
||||
const g = f.forge;
|
||||
const unscored = g.library.filter((c) => !c.challenge?.rubric?.length).length;
|
||||
return [
|
||||
g.next
|
||||
? { label: `Should I take ${g.next.title}?`, prompt: `Which challenge should I complete next? Start with ${g.next.title}`, capability: 'forge-next' }
|
||||
: { label: 'Which challenge should I take next?', prompt: 'Which challenge should I complete next?', capability: 'forge-next' },
|
||||
g.locked.length
|
||||
? { label: `${g.locked.length} blocked by requirements`, prompt: 'What is blocking my next skill?', capability: 'forge-blockers' }
|
||||
: { label: 'What is blocking my next skill?', prompt: 'What is blocking my next skill?', capability: 'forge-blockers' },
|
||||
g.ready.length
|
||||
? { label: `${g.ready.length} ready to start`, prompt: 'Which challenges can I start right now?', capability: 'forge-ready' }
|
||||
: { label: 'Which challenges can I start?', prompt: 'Which challenges can I start right now?', capability: 'forge-ready' },
|
||||
{ label: 'Which skills am I closest to unlocking?', prompt: 'Which skills am I closest to unlocking?', capability: 'forge-skills' },
|
||||
{ label: 'Summarize my Forge progress', prompt: 'Summarize my Forge progress', capability: 'forge-progress' },
|
||||
/* The chip that creates something goes first: on an authoring console the
|
||||
most likely next action is authoring. It carries no capability, so it
|
||||
routes through the skill registry rather than the page responder. */
|
||||
{ label: 'Create a skill training', prompt: 'Create a skill training' },
|
||||
g.drafts.length
|
||||
? { label: `${g.drafts.length} in draft`, prompt: 'Which skills are published?', capability: 'forge-published' }
|
||||
: { label: `${g.published.length} published`, prompt: 'Which skills are published?', capability: 'forge-published' },
|
||||
unscored
|
||||
? { label: `${unscored} without Owliver criteria`, prompt: 'What gaps are left in the library?', capability: 'forge-gaps' }
|
||||
: { label: 'What does Owliver evaluate?', prompt: 'What does Owliver evaluate?', capability: 'forge-evaluation' },
|
||||
{ label: 'What skills do we currently have?', prompt: 'What skills do we currently have?', capability: 'forge-library' },
|
||||
g.workforceVerified
|
||||
? { label: `${g.workforceVerified} skills verified`, prompt: 'How is the workforce doing with these skills?', capability: 'forge-workforce' }
|
||||
: { label: 'How is the workforce using these?', prompt: 'How is the workforce doing with these skills?', capability: 'forge-workforce' },
|
||||
];
|
||||
},
|
||||
|
||||
@@ -473,6 +499,22 @@ const PROMPTS = {
|
||||
];
|
||||
},
|
||||
|
||||
'admin.profile': (f) => {
|
||||
const mine = f.user ? f.activity.filter((e) => e.user_email === f.user.email) : [];
|
||||
const restricted = PERMISSIONS.filter((p) => p.level !== 'Full access').length;
|
||||
return [
|
||||
{ label: 'What are my permissions?', prompt: 'What are my permissions?', capability: 'profile-permissions' },
|
||||
restricted
|
||||
? { label: `${restricted} scopes are restricted`, prompt: 'Which scopes am I restricted on?', capability: 'profile-permissions' }
|
||||
: { label: 'What can I do here?', prompt: 'What can I do on this page?', capability: 'profile-actions' },
|
||||
{ label: 'How is my account secured?', prompt: 'How is my account secured?', capability: 'profile-security' },
|
||||
{ label: 'What are my preferences set to?', prompt: 'What are my preferences set to?', capability: 'profile-preferences' },
|
||||
mine.length
|
||||
? { label: `My last ${mine.length} actions`, prompt: 'What is my recent activity?', capability: 'profile-activity' }
|
||||
: { label: 'What can I change here?', prompt: 'What can I change on this page?', capability: 'profile-actions' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.activity': (f) => [
|
||||
f.activitySignals.flags.length
|
||||
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }
|
||||
|
||||
@@ -41,6 +41,7 @@ export function buildFacts({
|
||||
activity = [],
|
||||
courses = [],
|
||||
profile = null,
|
||||
user = null,
|
||||
today = new Date(),
|
||||
}) {
|
||||
const scored = applications.filter((a) => a.ai_score > 0);
|
||||
@@ -207,6 +208,37 @@ export function buildFacts({
|
||||
level,
|
||||
count: forgeCourses.filter((c) => c.difficulty === level).length,
|
||||
})).filter((d) => d.count),
|
||||
|
||||
/* ── The same library, read by the administrator who owns it ──────────
|
||||
Everything above answers "what can I prove"; everything below answers
|
||||
"what have we built, and how is the workforce doing with it". Both are
|
||||
derived from the same records, so the panel cannot disagree with the page
|
||||
whichever question is asked. Publication state is read with the same
|
||||
mapping the Forge page uses: `active` is Published. */
|
||||
library: courses,
|
||||
published: courses.filter((c) => c.status === 'active' || c.status === 'published'),
|
||||
drafts: courses.filter((c) => c.status === 'draft'),
|
||||
archived: courses.filter((c) => c.status === 'archived' || c.status === 'inactive'),
|
||||
withChallenge: courses.filter((c) => c.challenge?.type),
|
||||
withTraining: courses.filter((c) => c.training_outline?.length || c.quiz?.length),
|
||||
byCategory: [...courses.reduce((map, c) => {
|
||||
const key = c.category || 'Uncategorised';
|
||||
map.set(key, (map.get(key) || 0) + 1);
|
||||
return map;
|
||||
}, new Map())]
|
||||
.map(([category, count]) => ({ category, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
/* Verifications the workforce actually holds, counted off worker profiles
|
||||
rather than off the library — a badge is something a person has. */
|
||||
workforceVerified: profiles.reduce((sum, p) => {
|
||||
const ids = new Set(courses.map((c) => c.id));
|
||||
return sum + (p.earned_badges || []).filter((b) => ids.has(b.course_id)).length;
|
||||
}, 0),
|
||||
usage: courses.map((c) => ({
|
||||
course: c,
|
||||
completed: profiles.filter((p) => (p.completed_courses || []).some((x) => x.course_id === c.id)).length,
|
||||
verified: profiles.filter((p) => (p.earned_badges || []).some((b) => b.course_id === c.id)).length,
|
||||
})),
|
||||
};
|
||||
|
||||
/* ── Talent pool ─────────────────────────────────────────────────────────
|
||||
@@ -281,6 +313,10 @@ export function buildFacts({
|
||||
return {
|
||||
today,
|
||||
|
||||
/* The signed-in account — identity and preferences, for the pages that are
|
||||
about the operator rather than the workforce. */
|
||||
user,
|
||||
|
||||
/* Applications */
|
||||
applications,
|
||||
total: applications.length,
|
||||
|
||||
@@ -22,6 +22,7 @@ const PLACEMENT = {
|
||||
'/admin/university': 'admin.forge',
|
||||
'/admin/talent-pool': 'admin.talentPool',
|
||||
'/admin/hired': 'admin.hiredHistory',
|
||||
'/admin/profile': 'admin.profile',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,9 +30,9 @@ const PLACEMENT = {
|
||||
* Pages that must never carry the assistant, listed explicitly so the intent is
|
||||
* documented rather than implied by omission.
|
||||
*
|
||||
* Admin: profile and login only. Every working surface — Control Center,
|
||||
* Positions, Candidates, Candidates Analysis, Analytics, Activity,
|
||||
* KROW Forge, Talent Pool and Hired History — carries the panel.
|
||||
* Admin: login only. Every other surface — including Profile, where the
|
||||
* questions are about the account rather than the workforce — carries
|
||||
* the panel.
|
||||
* Employer: every page. The contextual panel is an Admin capability.
|
||||
* Talent: every page. The talent portal has the standalone Owliver product,
|
||||
* with its own voice experience, branding and workflow.
|
||||
@@ -42,12 +43,19 @@ export const EXCLUDED_ROUTES = [
|
||||
'/hired', '/talent-pool', '/analytics', '/profile', '/apply',
|
||||
'/university', '/university/:id', '/me', '/identity', '/employee',
|
||||
'/owliver', '/design-system',
|
||||
// Admin pages that are a form rather than a workflow.
|
||||
'/admin/profile', '/admin/login',
|
||||
// Sign-in has no page data to reason about.
|
||||
'/admin/login',
|
||||
// The full candidate profile is one person's record, not a page-level question.
|
||||
'/admin/candidates/:id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The Admin route → context table, exported so the skill registry can derive
|
||||
* page keys from it. One table, so a route added here is addressable by a skill
|
||||
* without a second list to keep in step.
|
||||
*/
|
||||
export const PLACEMENT_ROUTES = PLACEMENT.admin;
|
||||
|
||||
/** Returns the context for a role and path, or `null` to render no assistant. */
|
||||
export function resolveAssistantContext(role, pathname) {
|
||||
const id = PLACEMENT[role]?.[pathname];
|
||||
|
||||
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) };
|
||||
}
|
||||
@@ -5,8 +5,14 @@ import {
|
||||
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
|
||||
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { skillsForContext } from '@/lib/skills/registry';
|
||||
import {
|
||||
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
||||
} from '@/lib/skills/positionFlow';
|
||||
import { buildFacts } from './insights';
|
||||
import { createAssistantProvider } from './provider';
|
||||
import { resolveIntent } from './routing';
|
||||
import { toSnapshots } from './blocks';
|
||||
|
||||
/** One provider instance for the app's lifetime. */
|
||||
const provider = createAssistantProvider();
|
||||
@@ -32,9 +38,17 @@ export function useAssistantFacts() {
|
||||
// rather than manufacturing a record as a side effect of opening a panel.
|
||||
const { data: profile = null } = useWorkerProfile({ enabled: false });
|
||||
|
||||
/* The signed-in account, from the shared `['user']` cache the header and the
|
||||
Profile page already read. */
|
||||
const { data: user = null } = useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: () => base44.auth.me().catch(() => null),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
return React.useMemo(
|
||||
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile }),
|
||||
[applications, postings, interviews, staff, profiles, activity, courses, profile]
|
||||
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile, user }),
|
||||
[applications, postings, interviews, staff, profiles, activity, courses, profile, user]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +67,23 @@ export function useCurrentUserName() {
|
||||
return data?.full_name || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a locally built document through the same snapshot pipeline the
|
||||
* provider uses, so a routing reply arrives exactly like any other answer
|
||||
* rather than appearing instantly and reading as a different system.
|
||||
*/
|
||||
async function streamDocument({ document, signal, onFrame, onDone }) {
|
||||
const snapshots = toSnapshots(document);
|
||||
let latest = [];
|
||||
for (const snapshot of snapshots) {
|
||||
if (signal?.aborted) break;
|
||||
latest = snapshot;
|
||||
onFrame(snapshot);
|
||||
await new Promise((resolve) => setTimeout(resolve, 26));
|
||||
}
|
||||
if (latest.length) onDone(latest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation state for one page context.
|
||||
*
|
||||
@@ -60,15 +91,39 @@ export function useCurrentUserName() {
|
||||
* kept per context in sessionStorage so navigating away from Candidates and back
|
||||
* does not silently discard the conversation — while a stale thread from last
|
||||
* week is still not resurrected.
|
||||
*
|
||||
* `onNavigate` is called when a question belongs to another Admin page. Both
|
||||
* entry points — a suggestion chip and a typed question — go through `send`, so
|
||||
* routing applies to both without either knowing it exists.
|
||||
*/
|
||||
export function useConversation({ contextId, facts }) {
|
||||
export function useConversation({
|
||||
contextId, facts, onNavigate, onAction, onCreatePosition, disabledSkills = [], customSkills = [],
|
||||
roles = [], skillCategories = [], courses = [],
|
||||
}) {
|
||||
const storageKey = `krow_assistant:${contextId}`;
|
||||
const flowKey = `${storageKey}:flow`;
|
||||
|
||||
const [messages, setMessages] = React.useState([]);
|
||||
const [pending, setPending] = React.useState(null); // { blocks, thinking }
|
||||
const [error, setError] = React.useState(null);
|
||||
const abortRef = React.useRef(null);
|
||||
|
||||
/* A guided skill in progress — which question it is on and what it has
|
||||
collected. Kept beside the thread rather than inside it, and persisted for
|
||||
the same reason the thread is: leaving Positions and coming back should not
|
||||
silently abandon a position half-described. */
|
||||
const flowRef = React.useRef(null);
|
||||
|
||||
const setFlow = React.useCallback((flow) => {
|
||||
flowRef.current = flow || null;
|
||||
try {
|
||||
if (flow) sessionStorage.setItem(flowKey, JSON.stringify(flow));
|
||||
else sessionStorage.removeItem(flowKey);
|
||||
} catch {
|
||||
// The flow stays in memory only.
|
||||
}
|
||||
}, [flowKey]);
|
||||
|
||||
/* Load the stored thread, and reload when the context changes. */
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
@@ -77,9 +132,15 @@ export function useConversation({ contextId, facts }) {
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
try {
|
||||
const raw = sessionStorage.getItem(flowKey);
|
||||
flowRef.current = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
flowRef.current = null;
|
||||
}
|
||||
setPending(null);
|
||||
setError(null);
|
||||
}, [storageKey]);
|
||||
}, [storageKey, flowKey]);
|
||||
|
||||
const persist = React.useCallback((next) => {
|
||||
setMessages(next);
|
||||
@@ -112,6 +173,104 @@ export function useConversation({ contextId, facts }) {
|
||||
setError(null);
|
||||
setPending({ blocks: [], thinking: true });
|
||||
|
||||
/**
|
||||
* A guided skill in progress takes the message first: while Owliver is
|
||||
* asking the questions, "Chennai" is an answer rather than a question about
|
||||
* the page. The skill has to still be enabled — switching it off in Settings
|
||||
* ends the flow rather than letting it run on without permission.
|
||||
*/
|
||||
let intent = null;
|
||||
if (flowRef.current) {
|
||||
const skill = skillsForContext(contextId, disabledSkills, customSkills)
|
||||
.find((s) => s.id === flowRef.current.skillId);
|
||||
|
||||
if (!skill) setFlow(null);
|
||||
else {
|
||||
intent = {
|
||||
kind: 'flow',
|
||||
skill,
|
||||
...advancePositionFlow({ flow: flowRef.current, answer: text, skill, roles }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* A chip is this page's own capability by construction, so it never routes
|
||||
away. A typed question might belong somewhere else entirely. */
|
||||
if (!intent) {
|
||||
intent = capability
|
||||
? { kind: 'answer' }
|
||||
: resolveIntent({
|
||||
question: text, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The one step that writes. It happens before the reply rather than after,
|
||||
* because the reply is the outcome — "Position created successfully" has to
|
||||
* be true when it is said.
|
||||
*/
|
||||
if (intent.kind === 'flow' && intent.create) {
|
||||
let created = null;
|
||||
try {
|
||||
created = await onCreatePosition?.(intent.create.draft, intent.skill);
|
||||
} catch {
|
||||
created = null;
|
||||
}
|
||||
|
||||
if (created?.id) {
|
||||
intent = {
|
||||
...intent, flow: null, doc: positionCreatedReply(created), followUp: createdFollowUp(created),
|
||||
};
|
||||
} else {
|
||||
/* Keep the answers: the summary is still there to try again from. */
|
||||
intent = {
|
||||
...intent,
|
||||
flow: { ...intent.flow, stage: 'review' },
|
||||
doc: positionFailedReply(),
|
||||
followUp: [
|
||||
{ label: 'Create position', prompt: 'Create position' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* A skill that collects its input in the chat carries the state of that
|
||||
collection on every turn — including the turn that starts it. */
|
||||
if ('flow' in intent) setFlow(intent.flow);
|
||||
|
||||
if (intent.kind !== 'answer') {
|
||||
try {
|
||||
await streamDocument({
|
||||
document: intent.doc,
|
||||
signal: controller.signal,
|
||||
onFrame: (blocks) => setPending({ blocks, thinking: false }),
|
||||
onDone: (blocks) => {
|
||||
const next = [
|
||||
...messagesRef.current,
|
||||
{ role: 'assistant', blocks, followUp: intent.followUp },
|
||||
];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
|
||||
/* Navigate after the reply is on screen, so the user reads why they moved.
|
||||
The panel re-resolves its context from the new route, which is what
|
||||
makes the next question answer from the page they land on. */
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (intent.kind === 'navigate') onNavigate?.(intent.destination);
|
||||
/* A skill's action runs after its reply, for the same reason: the user
|
||||
should read why the form opened before it opens. */
|
||||
if (intent.kind === 'skill' && intent.action) onAction?.(intent.action, intent.skill);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let latest = [];
|
||||
for await (const snapshot of provider.stream({
|
||||
@@ -141,7 +300,8 @@ export function useConversation({ contextId, facts }) {
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, persist]);
|
||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
@@ -151,12 +311,15 @@ export function useConversation({ contextId, facts }) {
|
||||
setMessages([]);
|
||||
setPending(null);
|
||||
setError(null);
|
||||
/* A new conversation abandons a half-collected position too — the questions
|
||||
it was answering are no longer on screen. */
|
||||
setFlow(null);
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}, [storageKey]);
|
||||
}, [storageKey, setFlow]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
|
||||
Reference in New Issue
Block a user