Initial commit
This commit is contained in:
150
src/components/ai-assistant/AssistantMessage.jsx
Normal file
150
src/components/ai-assistant/AssistantMessage.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Renders the light markup the capabilities emit — `**bold**`, bullets,
|
||||
* numbered items, `_asides_`, and ✓/⚠ check lines.
|
||||
*
|
||||
* A full markdown renderer is available in the project, but this content is
|
||||
* produced locally in a known shape, and parsing five constructs keeps the
|
||||
* assistant's typography inside the design system rather than inheriting a
|
||||
* library's defaults.
|
||||
*/
|
||||
|
||||
const BOLD = /\*\*(.+?)\*\*/g;
|
||||
|
||||
function inline(line, keyPrefix) {
|
||||
const parts = [];
|
||||
let last = 0;
|
||||
let match;
|
||||
BOLD.lastIndex = 0;
|
||||
|
||||
while ((match = BOLD.exec(line)) !== null) {
|
||||
if (match.index > last) parts.push(line.slice(last, match.index));
|
||||
parts.push(
|
||||
<strong key={`${keyPrefix}-${match.index}`} className="font-semibold text-ink-1">
|
||||
{match[1]}
|
||||
</strong>
|
||||
);
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
if (last < line.length) parts.push(line.slice(last));
|
||||
return parts.length ? parts : line;
|
||||
}
|
||||
|
||||
export function RichText({ text, className }) {
|
||||
const lines = React.useMemo(() => String(text).split('\n'), [text]);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1.5 text-body-sm text-ink-2', className)}>
|
||||
{lines.map((line, i) => {
|
||||
if (!line.trim()) return <div key={i} className="h-1.5" aria-hidden="true" />;
|
||||
|
||||
/* Aside — a caveat the capabilities wrap in underscores. */
|
||||
if (line.startsWith('_') && line.endsWith('_')) {
|
||||
return (
|
||||
<p key={i} className="border-l-2 border-border pl-2.5 text-caption italic text-ink-3 leading-relaxed">
|
||||
{inline(line.slice(1, -1), i)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* Bullet. */
|
||||
if (line.startsWith('• ')) {
|
||||
return (
|
||||
<div key={i} className="flex gap-2 leading-relaxed">
|
||||
<span className="text-krow-blue shrink-0" aria-hidden="true">•</span>
|
||||
<span className="min-w-0">{inline(line.slice(2), i)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Indented continuation of a numbered item. */
|
||||
if (line.startsWith(' ')) {
|
||||
return (
|
||||
<p key={i} className="pl-6 text-caption text-ink-3 leading-relaxed">
|
||||
{inline(line.trim(), i)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* Numbered item. */
|
||||
const numbered = line.match(/^(\d+)\.\s+(.*)$/);
|
||||
if (numbered) {
|
||||
return (
|
||||
<div key={i} className="flex gap-2.5 leading-relaxed">
|
||||
<span className="grid place-items-center w-4 h-4 mt-0.5 shrink-0 rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
||||
{numbered[1]}
|
||||
</span>
|
||||
<span className="min-w-0">{inline(numbered[2], i)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Health-check line. */
|
||||
if (line.startsWith('✓ ') || line.startsWith('⚠ ')) {
|
||||
const ok = line.startsWith('✓');
|
||||
return (
|
||||
<div key={i} className="flex gap-2 leading-relaxed">
|
||||
<span className={cn('shrink-0 font-semibold', ok ? 'text-success' : 'text-warning')} aria-hidden="true">
|
||||
{ok ? '✓' : '⚠'}
|
||||
</span>
|
||||
<span className="min-w-0">{inline(line.slice(2), i)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <p key={i} className="leading-relaxed">{inline(line, i)}</p>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The three-dot indicator shown before the first delta arrives. */
|
||||
export function ThinkingIndicator() {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-3 py-2.5 shadow-xs">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-ink-4 animate-typing"
|
||||
style={{ animationDelay: `${i * 0.18}s` }}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Assistant is thinking</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One turn in the conversation. The user's own words sit in a filled bubble; the
|
||||
* assistant's answer runs full width, because these answers are structured
|
||||
* multi-paragraph reports and a chat bubble would waste a third of a 380px
|
||||
* column on padding.
|
||||
*/
|
||||
export function Message({ role, text, streaming, stopped }) {
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end animate-slide-up">
|
||||
<div className="max-w-[85%] rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-slide-up">
|
||||
<RichText text={text} />
|
||||
{streaming && (
|
||||
<span
|
||||
className="inline-block w-1.5 h-3.5 ml-0.5 -mb-0.5 bg-krow-blue animate-caret"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{stopped && (
|
||||
<p className="mt-1.5 text-caption italic text-ink-4">Stopped early.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
src/components/ai-assistant/AssistantPanel.jsx
Normal file
78
src/components/ai-assistant/AssistantPanel.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import KrowAssistant from './KrowAssistant';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
|
||||
/** Panel width, plus the gutter separating it from the dashboard. */
|
||||
const PANEL_WIDTH = 380;
|
||||
const GUTTER = 24;
|
||||
|
||||
/**
|
||||
* AssistantPanel — the sliding column the layout renders.
|
||||
*
|
||||
* Animating the aside's `width` (rather than overlaying) is what makes the
|
||||
* dashboard reflow smoothly instead of being covered, and what lets closing
|
||||
* restore the original layout exactly.
|
||||
*
|
||||
* Two things about the structure matter, and both were bugs before:
|
||||
*
|
||||
* 1. The clip lives *inside* the sticky element, not around it. An ancestor with
|
||||
* `overflow: hidden` becomes the nearest scroll container, which silently
|
||||
* disables `position: sticky` on everything beneath it — the panel computed
|
||||
* as sticky but never actually stuck.
|
||||
*
|
||||
* 2. The aside must stretch to the shell's full height. As a flex item under
|
||||
* `items-start` it collapsed to its own height, so its sticky context ended
|
||||
* partway down the page and the panel scrolled away, leaving empty space
|
||||
* beside a long dashboard.
|
||||
*
|
||||
* With both fixed the panel stays pinned for the whole scroll, and because the
|
||||
* assistant is a fixed-height frame, only its conversation scrolls.
|
||||
*/
|
||||
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
||||
const { context, isOpen, close } = useAssistantPanel();
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && context && (
|
||||
<motion.aside
|
||||
key="assistant"
|
||||
aria-label="Krow AI Assistant"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: PANEL_WIDTH + GUTTER, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }}
|
||||
/* No `overflow-hidden` here — see (1) above. `self-stretch` keeps the
|
||||
sticky context running the full height of the shell — see (2). */
|
||||
className="relative shrink-0 self-stretch"
|
||||
>
|
||||
<div className={cn('sticky', stickyClassName)}>
|
||||
{/* The clip: contains the slide-in and the fixed panel width while
|
||||
the track animates open, without breaking the sticky above it. */}
|
||||
<div className="overflow-hidden" style={{ width: PANEL_WIDTH + GUTTER }}>
|
||||
<motion.div
|
||||
className="pl-6"
|
||||
initial={{ x: 32, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 32, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }}
|
||||
>
|
||||
{/* Keyed on the context so switching pages gives a clean panel
|
||||
and aborts any response still streaming for the last one. */}
|
||||
<KrowAssistant
|
||||
key={context.id}
|
||||
context={context}
|
||||
onClose={close}
|
||||
className={panelHeightClassName}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default AssistantPanel;
|
||||
94
src/components/ai-assistant/AssistantPanelContext.jsx
Normal file
94
src/components/ai-assistant/AssistantPanelContext.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as React from 'react';
|
||||
import { resolveAssistantContext } from './placement';
|
||||
|
||||
/**
|
||||
* Open/closed state for the Krow AI Assistant.
|
||||
*
|
||||
* Lives in context rather than in the layout so the trigger can sit anywhere in
|
||||
* the page — beside that page's own actions — without the layout having to know
|
||||
* where, and without threading props through every page.
|
||||
*
|
||||
* The assistant is closed by default. The dashboard is the primary experience;
|
||||
* the assistant is something you open when you want it.
|
||||
*/
|
||||
|
||||
const AssistantPanelContext = React.createContext(null);
|
||||
|
||||
const STORAGE_KEY = 'krow_assistant_open';
|
||||
|
||||
/**
|
||||
* Whether the panel was open before. Kept for the session so moving between
|
||||
* supported pages does not close a panel the user deliberately opened, while a
|
||||
* new session still starts clean on the dashboard.
|
||||
*/
|
||||
function readStoredOpen() {
|
||||
try {
|
||||
return sessionStorage.getItem(STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
const [open, setOpen] = React.useState(readStoredOpen);
|
||||
|
||||
const context = React.useMemo(
|
||||
() => resolveAssistantContext(role, pathname),
|
||||
[role, pathname]
|
||||
);
|
||||
|
||||
const supported = Boolean(context);
|
||||
// A stored "open" must not force the panel onto a page that has no assistant.
|
||||
const isOpen = supported && open;
|
||||
|
||||
const persist = React.useCallback((next) => {
|
||||
setOpen(next);
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, next ? '1' : '0');
|
||||
} catch {
|
||||
// Private mode — the panel simply reverts to closed next session.
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* Escape closes the panel, as it does for every other dismissible surface. */
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') persist(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, persist]);
|
||||
|
||||
const value = React.useMemo(() => ({
|
||||
context,
|
||||
supported,
|
||||
isOpen,
|
||||
open: () => persist(true),
|
||||
close: () => persist(false),
|
||||
toggle: () => persist(!isOpen),
|
||||
}), [context, supported, isOpen, persist]);
|
||||
|
||||
return (
|
||||
<AssistantPanelContext.Provider value={value}>
|
||||
{children}
|
||||
</AssistantPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel state for triggers and the layout.
|
||||
*
|
||||
* Returns a safe inert value outside a provider, so a page rendering an
|
||||
* `AssistantTrigger` in isolation (a test, a storybook) does not crash.
|
||||
*/
|
||||
export function useAssistantPanel() {
|
||||
return React.useContext(AssistantPanelContext) ?? {
|
||||
context: null,
|
||||
supported: false,
|
||||
isOpen: false,
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
toggle: () => {},
|
||||
};
|
||||
}
|
||||
72
src/components/ai-assistant/AssistantTrigger.jsx
Normal file
72
src/components/ai-assistant/AssistantTrigger.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { KROW_LOGO_URL } from '@/assets/brand';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
|
||||
/**
|
||||
* AssistantTrigger — the KROW mark used as a secondary action.
|
||||
*
|
||||
* This is not the application's branding: the navigation logo is untouched.
|
||||
* Here the mark is deliberately smaller and sits in a page's action row, where
|
||||
* it reads as "the intelligent thing this page can do" rather than as a chrome
|
||||
* element. Pages render it beside their own actions.
|
||||
*
|
||||
* It self-hides on pages with no assistant, so a page can render it
|
||||
* unconditionally and never needs to know the placement rules.
|
||||
*/
|
||||
export function AssistantTrigger({ className, size = 'default' }) {
|
||||
const { supported, isOpen, toggle, context } = useAssistantPanel();
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
const label = isOpen ? 'Close Krow AI' : `Ask Krow AI about ${context.page}`;
|
||||
const heights = { sm: 'h-8 px-2.5', default: 'h-9 px-3' };
|
||||
const logoHeights = { sm: 'h-3.5', default: 'h-4' };
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={label}
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
'group relative inline-flex items-center justify-center rounded-full border',
|
||||
'transition-all duration-base ease-out cursor-pointer',
|
||||
'outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/60 focus-visible:ring-offset-2',
|
||||
'hover:scale-[1.04] active:scale-100',
|
||||
heights[size],
|
||||
isOpen
|
||||
? 'border-krow-blue/40 bg-krow-blue-tint shadow-[0_0_0_3px_rgba(10,57,223,0.10)]'
|
||||
: 'border-border bg-surface shadow-xs hover:border-krow-blue/40 hover:bg-krow-blue-tint hover:shadow-[0_0_18px_-2px_rgba(10,57,223,0.35)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={KROW_LOGO_URL}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={cn('w-auto object-contain', logoHeights[size])}
|
||||
/>
|
||||
|
||||
{/* A quiet indicator that this mark does something intelligent —
|
||||
enough signal without adding a competing icon or label. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute -top-0.5 -right-0.5 block w-2 h-2 rounded-full ring-2 ring-white transition-colors duration-base',
|
||||
isOpen ? 'bg-krow-blue' : 'bg-krow-yellow group-hover:bg-krow-blue'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AssistantTrigger;
|
||||
167
src/components/ai-assistant/KrowAssistant.jsx
Normal file
167
src/components/ai-assistant/KrowAssistant.jsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import * as React from 'react';
|
||||
import { RotateCcw, Sparkles, X } 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 { KROW_LOGO_URL } from '@/assets/brand';
|
||||
import { useAssistantFacts, useConversation } from './useAssistant';
|
||||
import { Message, ThinkingIndicator } from './AssistantMessage';
|
||||
import { PromptInput } from './PromptInput';
|
||||
|
||||
/**
|
||||
* Krow AI Assistant — the dashboard's contextual assistant.
|
||||
*
|
||||
* One component, reused on every supported page; only `context` differs.
|
||||
*
|
||||
* The layout is a fixed three-part frame — header, conversation, composer —
|
||||
* where only the conversation scrolls. That is what lets the panel stay pinned
|
||||
* beside a long dashboard without ever growing the page or losing its input.
|
||||
*
|
||||
* The empty state is deliberately a conversational entry point rather than a
|
||||
* menu: an icon, a question, and an input. Suggested chips fill the input rather
|
||||
* than firing an action, so the user always sees the question before it is asked
|
||||
* and can edit it — which is what makes this read as an assistant instead of a
|
||||
* second dashboard.
|
||||
*
|
||||
* Independent of Owliver. It talks to a provider (see provider.js) which can
|
||||
* later be pointed at an external service without changing anything here.
|
||||
*/
|
||||
export default function KrowAssistant({ context, className, onClose }) {
|
||||
const facts = useAssistantFacts({ needsWorkerProfile: context.needsWorkerProfile });
|
||||
const { messages, pending, error, busy, send, stop, reset } = useConversation({
|
||||
contextId: context.id,
|
||||
facts,
|
||||
});
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
const scrollRef = React.useRef(null);
|
||||
const isEmpty = messages.length === 0 && !pending;
|
||||
|
||||
/* Follow the newest content as it streams. */
|
||||
React.useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, pending]);
|
||||
|
||||
/**
|
||||
* A chip's prompt resolves to its capability so the answer is precise, while
|
||||
* the same question typed by hand still resolves through keyword matching.
|
||||
*/
|
||||
const submit = (text) => {
|
||||
const question = text.trim();
|
||||
if (!question || busy) return;
|
||||
const chip = context.chips.find((c) => c.prompt === question);
|
||||
send({ question, capability: chip?.capability ?? null });
|
||||
setInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="glass"
|
||||
radius="xl"
|
||||
padding="none"
|
||||
elevation="sm"
|
||||
className={cn('flex flex-col overflow-hidden', className)}
|
||||
aria-label="Krow AI Assistant"
|
||||
>
|
||||
{/* Header — fixed */}
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-4 py-3">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Krow AI</h2>
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
|
||||
</div>
|
||||
|
||||
{messages.length > 0 && (
|
||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
||||
)}
|
||||
{onClose && (
|
||||
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conversation — the only scrolling region */}
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
{isEmpty ? (
|
||||
/* Landing: an invitation to ask, not a list of features. */
|
||||
<div className="flex h-full flex-col items-center justify-center px-6 py-8 text-center animate-fade-in">
|
||||
<span className="relative mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-white shadow-brand ring-1 ring-krow-blue/10">
|
||||
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-5 w-auto object-contain" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-krow-blue text-white shadow-sm"
|
||||
>
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<h3 className="font-heading text-title text-ink-1">
|
||||
What would you like to analyze today?
|
||||
</h3>
|
||||
<p className="mt-2 max-w-[19rem] text-body-sm leading-relaxed text-ink-3">
|
||||
{context.tagline}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
{messages.map((message, i) => (
|
||||
<Message key={i} role={message.role} text={message.text} stopped={message.stopped} />
|
||||
))}
|
||||
|
||||
{pending?.thinking && <ThinkingIndicator />}
|
||||
{pending && !pending.thinking && <Message role="assistant" text={pending.text} streaming />}
|
||||
|
||||
{error && <Alert tone="destructive">{error}</Alert>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suggested prompts + composer — fixed */}
|
||||
<div className="shrink-0 space-y-2.5 border-t border-white/60 px-3 pb-3 pt-2.5">
|
||||
{!busy && (
|
||||
/* On the landing there is room to show every suggestion, so they wrap.
|
||||
Once a conversation is underway the space belongs to the thread, so
|
||||
they collapse to a single scrollable row. */
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1.5',
|
||||
isEmpty ? 'flex-wrap justify-center' : 'overflow-x-auto no-scrollbar'
|
||||
)}
|
||||
>
|
||||
{context.chips.map((chip) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
type="button"
|
||||
/* Fills the input — never sends, never navigates. */
|
||||
onClick={() => setInput(chip.prompt)}
|
||||
className="shrink-0 rounded-full border border-border bg-surface px-2.5 py-1 text-caption
|
||||
text-ink-3 transition-colors duration-base hover:border-krow-blue/40
|
||||
hover:bg-krow-blue-tint hover:text-krow-blue focus-ring"
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PromptInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={submit}
|
||||
onStop={stop}
|
||||
busy={busy}
|
||||
placeholders={context.placeholders}
|
||||
size={isEmpty ? 'large' : 'default'}
|
||||
/>
|
||||
|
||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
||||
Krow AI reads this page's data. Check anything you act on.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
128
src/components/ai-assistant/PromptInput.jsx
Normal file
128
src/components/ai-assistant/PromptInput.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowUp, Square } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* PromptInput — the assistant's primary action.
|
||||
*
|
||||
* A textarea rather than an input, because these prompts are sentences: it grows
|
||||
* with the content up to a cap, keeps Enter as send, and leaves Shift+Enter for a
|
||||
* deliberate newline. That combination is what makes typing a real question feel
|
||||
* natural instead of cramped.
|
||||
*
|
||||
* `placeholders` rotate while the field is empty and unfocused, which teaches
|
||||
* the range of what can be asked without spending vertical space on a legend.
|
||||
*/
|
||||
export function PromptInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
busy = false,
|
||||
placeholders = ['Ask anything…'],
|
||||
/** Larger treatment for the landing state, where the input is the focus. */
|
||||
size = 'default',
|
||||
autoFocus = false,
|
||||
className,
|
||||
}) {
|
||||
const textareaRef = React.useRef(null);
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const [placeholderIndex, setPlaceholderIndex] = React.useState(0);
|
||||
|
||||
/* Grow to fit the content, up to a cap, then scroll internally. */
|
||||
React.useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 148)}px`;
|
||||
}, [value]);
|
||||
|
||||
/* Rotate the examples only while the field is idle — changing the placeholder
|
||||
under someone who is mid-thought is distracting. */
|
||||
React.useEffect(() => {
|
||||
if (value || focused || placeholders.length < 2) return undefined;
|
||||
const timer = setInterval(
|
||||
() => setPlaceholderIndex((i) => (i + 1) % placeholders.length),
|
||||
3800
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, [value, focused, placeholders.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoFocus) textareaRef.current?.focus();
|
||||
}, [autoFocus]);
|
||||
|
||||
const submit = () => {
|
||||
if (!value.trim() || busy) return;
|
||||
onSubmit(value);
|
||||
};
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
const large = size === 'large';
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); submit(); }}
|
||||
className={cn(
|
||||
'relative flex items-end gap-2 rounded-2xl border bg-surface transition-all duration-base ease-out',
|
||||
large ? 'px-4 py-3' : 'px-3 py-2.5',
|
||||
focused
|
||||
? 'border-krow-blue/50 shadow-[0_8px_28px_-8px_rgba(10,57,223,0.28)] ring-2 ring-krow-blue/15'
|
||||
: 'border-border shadow-md hover:border-krow-blue/30',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
placeholder={placeholders[placeholderIndex]}
|
||||
aria-label="Ask Krow AI"
|
||||
className={cn(
|
||||
'flex-1 min-w-0 resize-none bg-transparent leading-relaxed text-ink-1',
|
||||
'placeholder:text-ink-4 outline-none no-scrollbar',
|
||||
large ? 'text-body py-1' : 'text-body-sm py-0.5'
|
||||
)}
|
||||
/>
|
||||
|
||||
{busy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
aria-label="Stop generating"
|
||||
className={cn(
|
||||
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2',
|
||||
'transition-colors duration-base hover:bg-surface-sunken focus-ring',
|
||||
large ? 'w-9 h-9' : 'w-8 h-8'
|
||||
)}
|
||||
>
|
||||
<Square className={large ? 'w-3.5 h-3.5' : 'w-3 h-3'} aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim()}
|
||||
aria-label="Send message"
|
||||
className={cn(
|
||||
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
|
||||
'transition-all duration-base ease-out hover:bg-krow-blue-dark',
|
||||
'disabled:bg-surface-sunken disabled:text-ink-4 focus-ring',
|
||||
large ? 'w-9 h-9' : 'w-8 h-8'
|
||||
)}
|
||||
>
|
||||
<ArrowUp className={large ? 'w-4 h-4' : 'w-3.5 h-3.5'} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
277
src/components/ai-assistant/capabilities/admin.js
Normal file
277
src/components/ai-assistant/capabilities/admin.js
Normal file
@@ -0,0 +1,277 @@
|
||||
import { AlertTriangle, Eye, Gauge, Lightbulb, ScrollText, TrendingUp } from 'lucide-react';
|
||||
import { bullets, compose, heading, numbered, plural, verb } from '../insights';
|
||||
|
||||
/**
|
||||
* Admin capabilities — Control Center, Candidates Analysis, Activity.
|
||||
*
|
||||
* Admin answers are platform-wide rather than pipeline-specific: data integrity,
|
||||
* talent supply, audit reconciliation.
|
||||
*/
|
||||
|
||||
/* ── Control Center ─────────────────────────────────────────────────────── */
|
||||
|
||||
const platformHealth = (f) => {
|
||||
const checks = [
|
||||
{
|
||||
label: 'Data integrity',
|
||||
ok: f.applications.every((a) => a.job_posting_id),
|
||||
good: 'Every application is linked to a live posting.',
|
||||
bad: 'Some applications reference a posting that no longer exists.',
|
||||
},
|
||||
{
|
||||
label: 'Screening coverage',
|
||||
ok: f.standardizedPct >= 80,
|
||||
good: `${f.standardizedPct}% of applicants carry a score.`,
|
||||
bad: `Only ${f.standardizedPct}% of applicants are scored — ${plural(f.unscreened.length, 'record')} unstandardized.`,
|
||||
},
|
||||
{
|
||||
label: 'Interview integrity',
|
||||
ok: f.flagged.length === 0,
|
||||
good: 'No interviews flagged.',
|
||||
bad: `${plural(f.flagged.length, 'interview')} flagged below full integrity.`,
|
||||
},
|
||||
{
|
||||
label: 'Profile completeness',
|
||||
ok: f.unscoredProfiles.length <= f.profiles.length / 2,
|
||||
good: `${f.profiles.length - f.unscoredProfiles.length} of ${f.profiles.length} talent profiles have a career score.`,
|
||||
bad: `${plural(f.unscoredProfiles.length, 'profile')} of ${f.profiles.length} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score, so ${verb(f.unscoredProfiles.length, 'it cannot', 'they cannot')} be matched or ranked.`,
|
||||
},
|
||||
{
|
||||
label: 'Throughput',
|
||||
ok: f.hired.length > 0,
|
||||
good: `${plural(f.hired.length, 'hire')} closed at a ${f.hireRate}% hire rate.`,
|
||||
bad: 'No hires closed yet, so there is no conversion baseline.',
|
||||
},
|
||||
];
|
||||
|
||||
const passing = checks.filter((c) => c.ok).length;
|
||||
|
||||
return compose(
|
||||
heading(`Platform health: ${passing}/${checks.length} checks passing`),
|
||||
'',
|
||||
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.ok ? c.good : c.bad}`).join('\n'),
|
||||
'',
|
||||
heading('Scale'),
|
||||
bullets([
|
||||
`${plural(f.postings.length, 'position')}, ${f.openPositions.length} open`,
|
||||
`${plural(f.total, 'application')}`,
|
||||
`${plural(f.interviews.length, 'AI interview')}`,
|
||||
`${plural(f.profiles.length, 'talent profile')}, ${f.elite.length} Elite`,
|
||||
`${plural(f.activity.length, 'logged event')}`,
|
||||
]),
|
||||
'',
|
||||
passing === checks.length
|
||||
? 'Nothing needs intervention at the platform level.'
|
||||
: 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.'
|
||||
);
|
||||
};
|
||||
|
||||
const recommendations = (f) => {
|
||||
const actions = [];
|
||||
|
||||
if (f.unscoredProfiles.length) {
|
||||
actions.push(`**Prompt ${plural(f.unscoredProfiles.length, 'worker')} to finish onboarding.** Without a career score they are invisible to matching, so employers cannot find them even when they fit.`);
|
||||
}
|
||||
if (f.unscreened.length) {
|
||||
actions.push(`**${plural(f.unscreened.length, 'application')} unscored.** Each one is a decision made without a standard.`);
|
||||
}
|
||||
if (f.starvedPositions.length) {
|
||||
actions.push(`**${plural(f.starvedPositions.length, 'posting')} with zero applicants** — ${f.starvedPositions.map((p) => p.title).join(', ')}. Check distribution and pay bands.`);
|
||||
}
|
||||
if (f.unratedStaff.length) {
|
||||
actions.push(`**${plural(f.unratedStaff.length, 'hire')} unrated.** Employer ratings are the only verified performance signal feeding scores; without them the scores stay theoretical.`);
|
||||
}
|
||||
if (f.flagged.length) {
|
||||
actions.push(`**Review ${plural(f.flagged.length, 'flagged interview')}** before those candidates progress.`);
|
||||
}
|
||||
|
||||
if (!actions.length) return 'No platform actions outstanding. Data is complete and nothing is flagged.';
|
||||
|
||||
return compose(heading('Recommended actions'), '', numbered(actions));
|
||||
};
|
||||
|
||||
/* ── Candidates Analysis ────────────────────────────────────────────────── */
|
||||
|
||||
const recruitmentInsights = (f) => {
|
||||
const bands = {
|
||||
Elite: f.profiles.filter((p) => (p.krow_score || 0) >= 90).length,
|
||||
Excellent: f.profiles.filter((p) => (p.krow_score || 0) >= 75 && (p.krow_score || 0) < 90).length,
|
||||
Solid: f.profiles.filter((p) => (p.krow_score || 0) >= 60 && (p.krow_score || 0) < 75).length,
|
||||
Building: f.profiles.filter((p) => (p.krow_score || 0) > 0 && (p.krow_score || 0) < 60).length,
|
||||
Unscored: f.unscoredProfiles.length,
|
||||
};
|
||||
|
||||
const verified = f.profiles.filter((p) => (p.capabilities || []).length).length;
|
||||
const topHeavy = bands.Elite + bands.Excellent;
|
||||
|
||||
return compose(
|
||||
heading('Talent supply'),
|
||||
'',
|
||||
bullets(Object.entries(bands).map(([band, count]) => `${band} — ${count}`)),
|
||||
'',
|
||||
heading('Pipeline'),
|
||||
bullets([
|
||||
`${plural(f.total, 'applicant')} averaging ${f.avgScore}`,
|
||||
`${plural(f.ranked.filter((a) => a.ai_score >= 80).length, 'candidate')} at 80+`,
|
||||
`${plural(verified, 'worker')} with at least one verified capability`,
|
||||
`${plural(f.eventCounts.apply_job || 0, 'application event')} logged`,
|
||||
]),
|
||||
'',
|
||||
bands.Unscored > topHeavy
|
||||
? `The pool is bottom-heavy: ${bands.Unscored} unscored against ${topHeavy} at Excellent or above. Supply is not the constraint — verification is.`
|
||||
: 'The scored portion outweighs the unscored, so verification is keeping pace with sign-ups.',
|
||||
'',
|
||||
heading('By role category'),
|
||||
bullets(
|
||||
f.byRole.slice().sort((a, b) => b.applied - a.applied)
|
||||
.map((r) => `${r.title} — ${plural(r.applied, 'applicant')}, ${r.qualified} at 70+`)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const candidateRiskAnalysis = (f) => {
|
||||
const risks = [];
|
||||
|
||||
if (f.missingCredentials.length) {
|
||||
risks.push(`**No credentials on file** — ${plural(f.missingCredentials.length, 'candidate')}. Compliance risk if placed on a role that requires certification.`);
|
||||
}
|
||||
if (f.narrowAvailability.length) {
|
||||
risks.push(`**Narrow availability** — ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. Most likely to fall through on the day.`);
|
||||
}
|
||||
if (f.flagged.length) {
|
||||
risks.push(`**Interview integrity** — ${plural(f.flagged.length, 'interview')} below full integrity, usually suspiciously fast responses. Worth a human re-interview.`);
|
||||
}
|
||||
const noHistory = f.scored.filter((a) => !a.years_experience);
|
||||
if (noHistory.length) {
|
||||
risks.push(`**No recorded experience** — ${plural(noHistory.length, 'candidate')}. Not disqualifying, but unverifiable on paper.`);
|
||||
}
|
||||
if (f.singleCandidateRoles.length) {
|
||||
risks.push(`**Concentration risk** — ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`);
|
||||
}
|
||||
|
||||
if (!risks.length) return 'No material risk flags: credentials, availability and interview integrity all check out across the scored pool.';
|
||||
|
||||
return compose(
|
||||
heading(`${plural(risks.length, 'risk flag')}`),
|
||||
'',
|
||||
numbered(risks),
|
||||
'',
|
||||
'_Flags to check, not judgements. Each is a question for a human, not a rejection._'
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Activity ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const auditSummary = (f) => {
|
||||
const count = (type) => f.activity.filter((e) => e.event_type === type).length;
|
||||
const hires = count('hire_candidate');
|
||||
const screens = count('screen_candidate');
|
||||
const creates = count('create_position');
|
||||
|
||||
return compose(
|
||||
heading('Audit summary'),
|
||||
'',
|
||||
bullets([
|
||||
`${plural(f.activity.length, 'event')} total`,
|
||||
`${plural(f.activity7d.length, 'event')} in the last 7 days`,
|
||||
`${plural(f.activity24h.length, 'event')} in the last 24 hours`,
|
||||
`${plural(new Set(f.activity.map((e) => e.user_email)).size, 'distinct user')}`,
|
||||
]),
|
||||
'',
|
||||
heading('Privileged actions'),
|
||||
bullets([
|
||||
`${plural(creates, 'position creation')}`,
|
||||
`${plural(screens, 'screening action')}`,
|
||||
`${plural(hires, 'hire')}`,
|
||||
]),
|
||||
'',
|
||||
heading('Reconciliation'),
|
||||
bullets([
|
||||
hires === f.staff.length
|
||||
? `All ${f.staff.length} people on staff have a matching hire event.`
|
||||
: `${f.staff.length} on staff but ${hires} hire ${verb(hires, 'event', 'events')} — ${Math.abs(f.staff.length - hires)} unexplained.`,
|
||||
screens < f.scored.length
|
||||
? `${f.scored.length} candidates carry scores but only ${plural(screens, 'screening event')} logged. Batch screening records one event per run, not per candidate.`
|
||||
: 'Screening events reconcile with scored candidates.',
|
||||
]),
|
||||
'',
|
||||
'_An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review._'
|
||||
);
|
||||
};
|
||||
|
||||
const userActivityInsights = (f) => {
|
||||
const perUser = f.activity.reduce((acc, e) => {
|
||||
acc[e.user_email] ||= { name: e.user_name, role: e.account_type, count: 0, types: new Set() };
|
||||
acc[e.user_email].count += 1;
|
||||
acc[e.user_email].types.add(e.event_type);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const ranked = Object.entries(perUser).sort((a, b) => b[1].count - a[1].count);
|
||||
if (!ranked.length) return 'No activity to profile yet.';
|
||||
|
||||
const byRole = f.activity.reduce((acc, e) => {
|
||||
const role = e.account_type || 'unknown';
|
||||
acc[role] = (acc[role] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const [topEmail, top] = ranked[0];
|
||||
|
||||
return compose(
|
||||
heading('Most active accounts'),
|
||||
'',
|
||||
bullets(ranked.slice(0, 6).map(([email, d]) =>
|
||||
`**${d.name || email}** (${d.role || 'unknown'}) — ${plural(d.count, 'event')} across ${plural(d.types.size, 'action type')}`
|
||||
)),
|
||||
'',
|
||||
heading('By role'),
|
||||
bullets(Object.entries(byRole).sort((a, b) => b[1] - a[1])
|
||||
.map(([role, c]) => `${role.charAt(0).toUpperCase() + role.slice(1)} — ${plural(c, 'event')}`)),
|
||||
'',
|
||||
`${top.name || topEmail} accounts for ${top.count} of ${f.activity.length} events. On a live platform, one account dominating the log is worth confirming is a person and not an integration.`,
|
||||
'',
|
||||
f.activity24h.length === 0
|
||||
? 'No activity in the last day. Expected for a demo dataset; on a live platform it would be worth checking ingestion.'
|
||||
: 'Activity is flowing normally.'
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Free-text responders ───────────────────────────────────────────────── */
|
||||
|
||||
const has = (q, ...words) => words.some((w) => q.includes(w));
|
||||
|
||||
export const respondControlCenter = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'recommend', 'should', 'action', 'fix', 'improve')) return recommendations(f);
|
||||
return platformHealth(f);
|
||||
};
|
||||
|
||||
export const respondAdminCandidates = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'risk', 'flag', 'concern', 'compliance')) return candidateRiskAnalysis(f);
|
||||
return recruitmentInsights(f);
|
||||
};
|
||||
|
||||
export const respondActivity = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account')) return userActivityInsights(f);
|
||||
return auditSummary(f);
|
||||
};
|
||||
|
||||
/* ── Capability manifests ───────────────────────────────────────────────── */
|
||||
|
||||
export const CONTROL_CENTER_CAPABILITIES = [
|
||||
{ id: 'platform-health', label: 'Platform Health', icon: Gauge, run: platformHealth },
|
||||
{ id: 'recommendations', label: 'Recommendations', icon: Lightbulb, run: recommendations },
|
||||
];
|
||||
|
||||
export const ADMIN_CANDIDATE_CAPABILITIES = [
|
||||
{ id: 'recruitment-insights', label: 'Recruitment Insights', icon: TrendingUp, run: recruitmentInsights },
|
||||
{ id: 'candidate-risk', label: 'Candidate Risk Analysis', icon: AlertTriangle, run: candidateRiskAnalysis },
|
||||
];
|
||||
|
||||
export const ACTIVITY_CAPABILITIES = [
|
||||
{ id: 'audit-summary', label: 'Audit Summary', icon: ScrollText, run: auditSummary },
|
||||
{ id: 'user-activity', label: 'User Activity Insights', icon: Eye, run: userActivityInsights },
|
||||
];
|
||||
462
src/components/ai-assistant/capabilities/employer.js
Normal file
462
src/components/ai-assistant/capabilities/employer.js
Normal file
@@ -0,0 +1,462 @@
|
||||
import {
|
||||
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
|
||||
HeartPulse, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target, Layers,
|
||||
} from 'lucide-react';
|
||||
import { bullets, compose, heading, numbered, plural, verb } from '../insights';
|
||||
|
||||
/**
|
||||
* Employer capabilities — Overview, Candidates, Analytics.
|
||||
*
|
||||
* Each capability is a pure function of the fact sheet. Keeping them pure is
|
||||
* what allows the same code to run against local data today and against a
|
||||
* remote AI service later without the UI changing.
|
||||
*/
|
||||
|
||||
/* ── Overview ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const hiringSummary = (f) => {
|
||||
if (!f.total) return 'Nothing in the pipeline yet. Publish a position and I will start tracking it here.';
|
||||
|
||||
return compose(
|
||||
heading('Where hiring stands'),
|
||||
bullets([
|
||||
`${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}`,
|
||||
`${f.screened.length} screened · ${f.unscreened.length} awaiting a score`,
|
||||
`${plural(f.interviewing.length, 'candidate')} mid-interview`,
|
||||
`${plural(f.hired.length, 'hire')} averaging ${f.hiredAvgScore}/100`,
|
||||
]),
|
||||
'',
|
||||
f.stalled.length
|
||||
? `Most urgent: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still sitting at AI Screened.`
|
||||
: f.unscreened.length
|
||||
? `Most urgent: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
|
||||
: 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
|
||||
);
|
||||
};
|
||||
|
||||
const hiringHealth = (f) => {
|
||||
const checks = [
|
||||
{
|
||||
label: 'Screening coverage',
|
||||
value: `${f.standardizedPct}%`,
|
||||
ok: f.standardizedPct >= 80,
|
||||
note: f.standardizedPct >= 80
|
||||
? 'Applicants are being scored consistently.'
|
||||
: `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`,
|
||||
},
|
||||
{
|
||||
label: 'Response speed',
|
||||
value: f.timeToHire ? `${f.timeToHire}d` : '—',
|
||||
ok: f.timeToHire > 0 && f.timeToHire <= 3,
|
||||
note: f.timeToHire && f.timeToHire <= 3
|
||||
? 'Inside the window where good candidates are still available.'
|
||||
: 'Event staff accept other work within days — past 3 days you lose people.',
|
||||
},
|
||||
{
|
||||
label: 'Quality of hire',
|
||||
value: f.hiredAvgScore ? `${f.hiredAvgScore}/100` : '—',
|
||||
ok: f.hiredAvgScore >= 80,
|
||||
note: f.hiredAvgScore >= 80
|
||||
? 'You are hiring from the top of your own pool.'
|
||||
: 'Hires are scoring mid-pack — widen the funnel before lowering the bar.',
|
||||
},
|
||||
{
|
||||
label: 'Pipeline supply',
|
||||
value: `${f.openPositions.length} open`,
|
||||
ok: f.starvedPositions.length === 0,
|
||||
note: f.starvedPositions.length
|
||||
? `${f.starvedPositions.map((p) => p.title).join(', ')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants.`
|
||||
: 'Every open position has applicants.',
|
||||
},
|
||||
];
|
||||
|
||||
const passing = checks.filter((c) => c.ok).length;
|
||||
|
||||
return compose(
|
||||
heading(`${passing} of ${checks.length} signals healthy`),
|
||||
'',
|
||||
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.value}\n ${c.note}`).join('\n\n')
|
||||
);
|
||||
};
|
||||
|
||||
const pendingActions = (f) => {
|
||||
const actions = [];
|
||||
|
||||
if (f.stalled.length) {
|
||||
actions.push(`**Move your 80+ candidates.** ${f.stalled.slice(0, 3).map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`);
|
||||
}
|
||||
if (f.unscreened.length) {
|
||||
actions.push(`**Screen the backlog.** ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} no score. One pass clears it.`);
|
||||
}
|
||||
if (f.starvedPositions.length) {
|
||||
actions.push(`**Fix the postings nobody applies to.** ${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`);
|
||||
}
|
||||
if (f.unratedStaff.length) {
|
||||
actions.push(`**Rate your hires.** ${plural(f.unratedStaff.length, 'person', 'people')} ${verb(f.unratedStaff.length, 'has', 'have')} no rating. Ratings sharpen future matching.`);
|
||||
}
|
||||
if (f.elite.length) {
|
||||
actions.push(`**Reach out directly.** ${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`);
|
||||
}
|
||||
|
||||
if (!actions.length) return 'Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.';
|
||||
|
||||
return compose(heading('Pending actions'), '', numbered(actions.slice(0, 4)));
|
||||
};
|
||||
|
||||
const hiringRisks = (f) => {
|
||||
const risks = [];
|
||||
|
||||
if (f.singleCandidateRoles.length) {
|
||||
risks.push(`**Single-candidate roles.** ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`);
|
||||
}
|
||||
if (f.stalled.length) {
|
||||
risks.push(`**Losing strong candidates to delay.** ${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'has', 'have')} been screened but not moved. This is where good hires quietly disappear.`);
|
||||
}
|
||||
if (f.missingCredentials.length) {
|
||||
risks.push(`**Compliance exposure.** ${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`);
|
||||
}
|
||||
if (f.narrowAvailability.length) {
|
||||
risks.push(`**Availability risk.** ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. ${verb(f.narrowAvailability.length, 'This is the placement', 'These are the placements')} most likely to fall through on the day.`);
|
||||
}
|
||||
if (f.starvedPositions.length) {
|
||||
risks.push(`**Unfillable as posted.** ${plural(f.starvedPositions.length, 'open role')} with zero applicants after being live.`);
|
||||
}
|
||||
if (f.flagged.length) {
|
||||
risks.push(`**Interview integrity.** ${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`);
|
||||
}
|
||||
|
||||
if (!risks.length) return 'No material risks in the current pipeline: coverage is good, no role depends on a single candidate, and credentials check out.';
|
||||
|
||||
return compose(
|
||||
heading(`${plural(risks.length, 'risk')} to watch`),
|
||||
'',
|
||||
numbered(risks),
|
||||
'',
|
||||
'_Flags to check, not conclusions. Each is a question for a human._'
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Candidates ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const resumeAnalysis = (f) => {
|
||||
const subject = f.top[0];
|
||||
if (!subject) return 'No scored candidates yet. Screen the pipeline and I will analyse the strongest.';
|
||||
|
||||
return compose(
|
||||
heading(`${subject.applicant_name} — ${subject.ai_score}/100`),
|
||||
`${subject.job_title} · ${plural(subject.years_experience, 'year')} experience · ${subject.english_level} English`,
|
||||
'',
|
||||
subject.ai_summary || '',
|
||||
'',
|
||||
subject.ai_strengths?.length ? compose(heading('What holds up'), bullets(subject.ai_strengths)) : '',
|
||||
'',
|
||||
subject.ai_gaps?.length ? compose(heading('What to probe'), bullets(subject.ai_gaps)) : '',
|
||||
'',
|
||||
subject.certifications?.length
|
||||
? `**Credentials:** ${subject.certifications.join(', ')} — verify before the first shift. A lapsed card is the most common surprise.`
|
||||
: '**No credentials on file.** Request these before scheduling.'
|
||||
);
|
||||
};
|
||||
|
||||
const candidateComparison = (f) => {
|
||||
const pair = f.ranked.slice(0, 2);
|
||||
if (pair.length < 2) return 'I need at least two scored candidates to compare.';
|
||||
|
||||
const [a, b] = pair;
|
||||
const gap = a.ai_score - b.ai_score;
|
||||
const dims = ['experience', 'english', 'reliability', 'certifications', 'availability'];
|
||||
|
||||
const rows = dims.map((dim) => {
|
||||
const av = a.score_breakdown?.[dim] ?? 0;
|
||||
const bv = b.score_breakdown?.[dim] ?? 0;
|
||||
const lead = av === bv ? 'tied' : (av > bv ? a : b).applicant_name.split(' ')[0];
|
||||
return `${dim.charAt(0).toUpperCase() + dim.slice(1)} — ${av} vs ${bv} · ${lead}`;
|
||||
});
|
||||
|
||||
const biggest = dims.reduce((best, d) =>
|
||||
Math.abs((a.score_breakdown?.[d] ?? 0) - (b.score_breakdown?.[d] ?? 0)) >
|
||||
Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, dims[0]);
|
||||
|
||||
return compose(
|
||||
heading(`${a.applicant_name} (${a.ai_score}) vs ${b.applicant_name} (${b.ai_score})`),
|
||||
'',
|
||||
bullets(rows),
|
||||
'',
|
||||
gap <= 3
|
||||
? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
|
||||
: `${a.applicant_name} leads by ${gap}, driven mostly by ${biggest}.`
|
||||
);
|
||||
};
|
||||
|
||||
const interviewQuestions = (f) => {
|
||||
const subject = f.stalled[0] || f.ranked[0];
|
||||
if (!subject) return 'Screen a candidate and I will build questions around their specific gaps.';
|
||||
|
||||
const gaps = subject.ai_gaps || [];
|
||||
|
||||
return compose(
|
||||
heading(`Questions for ${subject.applicant_name}`),
|
||||
'Built from their gaps rather than a generic list, so the interview tests what the score could not.',
|
||||
'',
|
||||
numbered([
|
||||
`Walk me through your busiest ${subject.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
|
||||
gaps[0]
|
||||
? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?`
|
||||
: 'Tell me about a shift that went wrong. What was your part in it?',
|
||||
'Something breaks mid-service and your lead is unreachable. What are your next three moves?',
|
||||
subject.certifications?.length
|
||||
? `Your ${subject.certifications[0]} is on file — when did you last apply it on a live shift?`
|
||||
: 'Which certifications are you working toward, and why that one?',
|
||||
'What does your real availability look like over the next eight weeks?',
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const skillGapAnalysis = (f) => {
|
||||
if (!f.scored.length) return 'No scored candidates yet, so there are no gaps to analyse.';
|
||||
|
||||
const tally = {};
|
||||
f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => {
|
||||
const key = gap.replace(/^Missing certifications?:\s*/i, 'Missing credential: ');
|
||||
tally[key] = (tally[key] || 0) + 1;
|
||||
}));
|
||||
|
||||
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||
if (!ranked.length) return 'No recurring gaps across your scored candidates — the pool matches your postings well.';
|
||||
|
||||
return compose(
|
||||
heading('Recurring gaps'),
|
||||
'',
|
||||
bullets(ranked.map(([gap, count]) => `${gap} — ${count} of ${f.scored.length}`)),
|
||||
'',
|
||||
f.missingCredentials.length >= 2
|
||||
? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
|
||||
: 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
|
||||
);
|
||||
};
|
||||
|
||||
const hiringRecommendation = (f) => {
|
||||
if (!f.scored.length) return 'Nothing scored yet, so I have no basis for a recommendation.';
|
||||
|
||||
const open = (a) => !['hired', 'rejected'].includes(a.status);
|
||||
const shortlist = f.ranked.filter((a) => a.ai_score >= 80 && open(a));
|
||||
const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a));
|
||||
const pass = f.weak.filter(open);
|
||||
|
||||
return compose(
|
||||
heading('Recommendation'),
|
||||
'',
|
||||
shortlist.length
|
||||
? `**Shortlist** — ${shortlist.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}`
|
||||
: '**Shortlist** — nobody currently clears 80.',
|
||||
'',
|
||||
interview.length ? `**Interview** — ${interview.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
|
||||
'',
|
||||
pass.length ? `**Likely pass** — ${pass.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
|
||||
'',
|
||||
'_A recommendation, not a decision. I score what is on file — I have not met these people._'
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Analytics ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const explainCharts = (f) => compose(
|
||||
heading('What these charts are saying'),
|
||||
'',
|
||||
`**Pipeline Stages** — ${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
|
||||
'',
|
||||
`**Candidate Scores** — ${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length}; that bucket is your real hiring pool.`,
|
||||
'',
|
||||
`**Interview Completion** — ${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
|
||||
'',
|
||||
`**Cost Saved** — $${f.costSaved.toLocaleString()}, from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80. Industry per-unit costs for doing it manually, so treat it as a floor.`
|
||||
);
|
||||
|
||||
const forecastHiring = (f) => {
|
||||
const available = f.ranked.filter((a) => a.ai_score >= 60 && !['hired', 'rejected'].includes(a.status));
|
||||
const conversion = f.hireRate || 14;
|
||||
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
|
||||
const short = f.openPositions.length - expected;
|
||||
|
||||
return compose(
|
||||
heading('Forecast'),
|
||||
'',
|
||||
bullets([
|
||||
`${plural(available.length, 'candidate')} at 60+ still in play`,
|
||||
`At your ${conversion}% hire rate that projects to about ${plural(expected, 'hire')}`,
|
||||
f.timeToHire ? `At a ${f.timeToHire}-day average, those land within a week of you deciding` : 'No time-to-hire baseline yet',
|
||||
`${plural(f.openPositions.length, 'open position')} to fill`,
|
||||
]),
|
||||
'',
|
||||
short > 0
|
||||
? `That leaves you roughly ${plural(short, 'role')} short. Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`
|
||||
: 'That covers your open roles, assuming the strong candidates do not go elsewhere first.',
|
||||
'',
|
||||
'_Projected from your own conversion rate, not a promise — small pools move a lot._'
|
||||
);
|
||||
};
|
||||
|
||||
const departmentInsights = (f) => {
|
||||
if (!f.byRole.length) return 'No open positions to break down.';
|
||||
|
||||
const byCategory = {};
|
||||
f.byRole.forEach((r) => {
|
||||
byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: [] };
|
||||
byCategory[r.category].applied += r.applied;
|
||||
byCategory[r.category].hired += r.hired;
|
||||
byCategory[r.category].qualified += r.qualified;
|
||||
byCategory[r.category].roles.push(r.title);
|
||||
});
|
||||
|
||||
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
|
||||
const starved = entries.filter(([, d]) => d.applied === 0);
|
||||
|
||||
return compose(
|
||||
heading('By role category'),
|
||||
'',
|
||||
entries.map(([category, d]) =>
|
||||
`**${category}** — ${plural(d.applied, 'applicant')}, ${d.qualified} at 70+, ${plural(d.hired, 'hire')} across ${plural(d.roles.length, 'role')}`
|
||||
).join('\n'),
|
||||
'',
|
||||
starved.length
|
||||
? `${starved.map(([c]) => c).join(' and ')} ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
|
||||
: 'Every category has applicants, so your reach is working across the board.'
|
||||
);
|
||||
};
|
||||
|
||||
const generateReports = (f) => {
|
||||
const stamp = f.today.toISOString().slice(0, 10);
|
||||
|
||||
return compose(
|
||||
heading(`Hiring report — ${stamp}`),
|
||||
'',
|
||||
heading('Volume'),
|
||||
bullets([
|
||||
`${plural(f.total, 'applicant')} received`,
|
||||
`${f.screened.length} screened (${f.standardizedPct}% coverage)`,
|
||||
`${plural(f.interviews.length, 'AI interview')} conducted, ${f.completedInterviews.length} scored`,
|
||||
`${plural(f.hired.length, 'hire')} closed`,
|
||||
]),
|
||||
'',
|
||||
heading('Quality'),
|
||||
bullets([
|
||||
`Average candidate score ${f.avgScore}`,
|
||||
`Average hire score ${f.hiredAvgScore}`,
|
||||
`Hire rate ${f.hireRate}%`,
|
||||
`Average time-to-hire ${f.timeToHire ? `${f.timeToHire} days` : 'no baseline'}`,
|
||||
]),
|
||||
'',
|
||||
heading('Efficiency'),
|
||||
bullets([
|
||||
`$${f.costSaved.toLocaleString()} saved against manual screening and interviewing`,
|
||||
`≈${f.hoursSaved} recruiter hours returned`,
|
||||
]),
|
||||
'',
|
||||
heading('By role'),
|
||||
bullets(f.byRole.map((r) => `${r.title} — ${r.applied} applied, ${r.qualified} at 70+, ${r.hired} hired`)),
|
||||
'',
|
||||
f.bottleneck
|
||||
? `**Headline finding.** The ${f.bottleneck.from} → ${f.bottleneck.to} step passes only ${f.bottleneck.rate}%, losing ${plural(f.bottleneck.lost, 'candidate')}. That is the single biggest recoverable loss in the funnel.`
|
||||
: '**Headline finding.** The funnel is passing through evenly with no single bottleneck.',
|
||||
'',
|
||||
'_Copy this into your own template — export to PDF and CSV is not wired up in this build._'
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Free-text responders ───────────────────────────────────────────────── */
|
||||
|
||||
const has = (q, ...words) => words.some((w) => q.includes(w));
|
||||
|
||||
const stateOfPlay = (f) =>
|
||||
`I do not have a specific read on that. What I can see: ${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}, ${f.screened.length} screened, ${plural(f.interviewing.length, 'candidate')} interviewing, ${plural(f.hired.length, 'hire')}. Ask about any of those and I will go deeper.`;
|
||||
|
||||
const bottleneckAnswer = (f) => {
|
||||
if (!f.bottleneck) return 'Not enough pipeline movement yet to locate a bottleneck.';
|
||||
const b = f.bottleneck;
|
||||
const diagnosis = {
|
||||
'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap in the funnel to close.`,
|
||||
Shortlisted: 'Candidates are screened but not shortlisted. Either the scores are not being read, or the bar is above what the pool can deliver.',
|
||||
Interviewed: 'Shortlisted candidates are not reaching interview — usually scheduling friction rather than a decision.',
|
||||
Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.',
|
||||
}[b.to];
|
||||
|
||||
return compose(
|
||||
heading(`Bottleneck: ${b.from} → ${b.to}`),
|
||||
`Only ${b.rate}% pass through, losing ${plural(b.lost, 'candidate')}.`,
|
||||
'',
|
||||
diagnosis || '',
|
||||
'',
|
||||
bullets(f.transitions.map((t) => `${t.from} → ${t.to}: ${t.rate}%${t.lost ? ` (−${t.lost})` : ''}`))
|
||||
);
|
||||
};
|
||||
|
||||
export const respondOverview = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f);
|
||||
if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next')) return pendingActions(f);
|
||||
if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(f);
|
||||
if (has(q, 'summary', 'today', 'brief', 'catch me up', 'overview')) return hiringSummary(f);
|
||||
if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f);
|
||||
if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f);
|
||||
return stateOfPlay(f);
|
||||
};
|
||||
|
||||
export const respondCandidates = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze')) return resumeAnalysis(f);
|
||||
if (has(q, 'compare', 'versus', ' vs ', 'between')) return candidateComparison(f);
|
||||
if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(f);
|
||||
if (has(q, 'gap', 'missing', 'weakness', 'skill')) return skillGapAnalysis(f);
|
||||
if (has(q, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f);
|
||||
if (has(q, 'unscreened', 'not screened', 'pending')) {
|
||||
return f.unscreened.length
|
||||
? compose(
|
||||
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
|
||||
'',
|
||||
bullets(f.unscreened.slice(0, 8).map((a) => `${a.applicant_name} — ${a.job_title}`)),
|
||||
f.unscreened.length > 8 ? `…and ${f.unscreened.length - 8} more` : ''
|
||||
)
|
||||
: 'Everyone has been screened.';
|
||||
}
|
||||
return stateOfPlay(f);
|
||||
};
|
||||
|
||||
export const respondAnalytics = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f);
|
||||
if (has(q, 'forecast', 'predict', 'projection', 'will i')) return forecastHiring(f);
|
||||
if (has(q, 'department', 'category', 'role', 'team')) return departmentInsights(f);
|
||||
if (has(q, 'report', 'export', 'summar', 'download')) return generateReports(f);
|
||||
if (has(q, 'bottleneck', 'drop', 'stuck', 'funnel', 'lose')) return bottleneckAnswer(f);
|
||||
if (has(q, 'hire rate', 'conversion')) {
|
||||
return `${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`;
|
||||
}
|
||||
if (has(q, 'cost', 'saved', 'roi', 'money')) {
|
||||
return `$${f.costSaved.toLocaleString()} — ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each. Also about ${f.hoursSaved} recruiter hours you did not spend.`;
|
||||
}
|
||||
return stateOfPlay(f);
|
||||
};
|
||||
|
||||
/* ── Capability manifests ───────────────────────────────────────────────── */
|
||||
|
||||
export const OVERVIEW_CAPABILITIES = [
|
||||
{ id: 'hiring-summary', label: 'Hiring Summary', icon: Sparkles, run: hiringSummary },
|
||||
{ id: 'hiring-health', label: 'Hiring Health', icon: HeartPulse, run: hiringHealth },
|
||||
{ id: 'pending-actions', label: 'Pending Actions', icon: ListChecks, run: pendingActions },
|
||||
{ id: 'hiring-risks', label: 'Hiring Risks', icon: AlertTriangle, run: hiringRisks },
|
||||
];
|
||||
|
||||
export const CANDIDATE_CAPABILITIES = [
|
||||
{ id: 'resume-analysis', label: 'Resume Analysis', icon: FileText, run: resumeAnalysis },
|
||||
{ id: 'candidate-comparison', label: 'Candidate Comparison', icon: GitCompare, run: candidateComparison },
|
||||
{ id: 'interview-questions', label: 'Interview Question Generator', icon: MessageSquareQuote, run: interviewQuestions },
|
||||
{ id: 'skill-gap', label: 'Skill Gap Analysis', icon: Target, run: skillGapAnalysis },
|
||||
{ id: 'hiring-recommendation', label: 'Hiring Recommendation', icon: ClipboardCheck, run: hiringRecommendation },
|
||||
];
|
||||
|
||||
export const ANALYTICS_CAPABILITIES = [
|
||||
{ id: 'explain-charts', label: 'Explain Charts', icon: BarChart3, run: explainCharts },
|
||||
{ id: 'forecast-hiring', label: 'Forecast Hiring', icon: LineChart, run: forecastHiring },
|
||||
{ id: 'department-insights', label: 'Department Insights', icon: Layers, run: departmentInsights },
|
||||
{ id: 'generate-reports', label: 'Generate Reports', icon: FileSpreadsheet, run: generateReports },
|
||||
];
|
||||
157
src/components/ai-assistant/capabilities/talent.js
Normal file
157
src/components/ai-assistant/capabilities/talent.js
Normal file
@@ -0,0 +1,157 @@
|
||||
import { BookOpen, Compass, FileText } from 'lucide-react';
|
||||
import { bullets, compose, heading, numbered, plural } from '../insights';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
|
||||
/**
|
||||
* Talent capabilities — My Portal.
|
||||
*
|
||||
* The register shifts here: an employer wants a read on a pipeline, a worker
|
||||
* wants to know what to do next to earn more. Answers name the specific course
|
||||
* or step, because "improve your skills" helps nobody.
|
||||
*/
|
||||
|
||||
const scoreOf = (p) => Number(p?.krow_score) || 0;
|
||||
|
||||
const NO_PROFILE = 'I do not have your profile yet. Once it exists I can give you specifics instead of generalities.';
|
||||
|
||||
const resumeReview = (f) => {
|
||||
const p = f.profile;
|
||||
if (!p) return NO_PROFILE;
|
||||
|
||||
const missing = [];
|
||||
if (!p.phone) missing.push('phone number');
|
||||
if (!p.address) missing.push('location');
|
||||
if (!(p.skills || []).length) missing.push('skills');
|
||||
if (!(p.experience || []).length) missing.push('work history');
|
||||
if (!(p.certifications || []).length) missing.push('certifications');
|
||||
if (!p.career_goals) missing.push('career goals');
|
||||
if (!(p.availability || []).length) missing.push('availability');
|
||||
if (!p.selfie_url) missing.push('photo');
|
||||
|
||||
const present = [];
|
||||
if ((p.experience || []).length) present.push(`${plural(p.experience.length, 'role')} of work history`);
|
||||
if ((p.skills || []).length) present.push(`${plural(p.skills.length, 'skill')} listed`);
|
||||
if ((p.certifications || []).length) present.push(p.certifications.join(', '));
|
||||
if ((p.capabilities || []).length) present.push(`${plural(p.capabilities.length, 'capability')} verified by evidence`);
|
||||
|
||||
return compose(
|
||||
heading(`Your profile is ${p.profile_completion || 0}% complete`),
|
||||
'',
|
||||
present.length ? compose(heading('What is working'), bullets(present)) : '',
|
||||
'',
|
||||
missing.length
|
||||
? compose(
|
||||
heading('What is missing'),
|
||||
bullets(missing),
|
||||
'',
|
||||
'Employers filter on availability and certifications before they read anything else. If you fix two things, fix those.'
|
||||
)
|
||||
: compose(heading('Nothing is missing'), 'From here, more verified evidence moves you — not more fields.'),
|
||||
'',
|
||||
(p.capabilities || []).length
|
||||
? '_Your verified capabilities are the part employers actually trust — those came with evidence attached, not a checkbox._'
|
||||
: '_Nothing is verified yet. One completed challenge is worth more than ten claimed skills._'
|
||||
);
|
||||
};
|
||||
|
||||
const careerGuidance = (f) => {
|
||||
const p = f.profile;
|
||||
if (!p) return NO_PROFILE;
|
||||
|
||||
const score = scoreOf(p);
|
||||
const band = getScoreBand(score);
|
||||
const target = score >= 90 ? null : score >= 75 ? 90 : score >= 60 ? 75 : 60;
|
||||
|
||||
return compose(
|
||||
heading(`You are at ${toFICO(score)} — ${band.label}`),
|
||||
'',
|
||||
bullets([
|
||||
`Reliability ${p.reliability_score || 0}/100`,
|
||||
`Attendance ${p.attendance_score ?? 0}/100`,
|
||||
`${plural((p.completed_courses || []).length, 'course')} completed, ${plural((p.earned_badges || []).length, 'badge')} earned`,
|
||||
`${plural((p.capabilities || []).length, 'verified capability')}`,
|
||||
`${plural(p.shifts_completed || 0, 'shift')} worked`,
|
||||
]),
|
||||
'',
|
||||
target
|
||||
? `**To reach ${target}:** verified work is the fastest lever. Courses move the score; verified shifts move it more, because employers weight what someone else confirmed over what you claimed.`
|
||||
: '**You are in the top band.** From here it is about holding it — the score decays if shifts stop.',
|
||||
'',
|
||||
p.desired_position
|
||||
? `You want to be ${p.desired_position}. ${score >= 75 ? 'You are already scoring in the range employers shortlist for that.' : 'The gap to that role is verification, not ambition.'}`
|
||||
: 'You have not set a target role. Setting one lets me tell you which courses matter and which are noise.',
|
||||
'',
|
||||
(p.shifts_completed || 0) === 0
|
||||
? '**One concrete step:** work a single shift. Attendance, ratings and reliability all need a first data point before anything else compounds.'
|
||||
: ''
|
||||
);
|
||||
};
|
||||
|
||||
const learningRecommendations = (f) => {
|
||||
const p = f.profile;
|
||||
if (!p) return NO_PROFILE;
|
||||
|
||||
const completedIds = new Set((p.completed_courses || []).map((c) => c.course_id));
|
||||
const done = (p.completed_courses || []).length;
|
||||
const available = (f.courses || []).filter((c) => c.status === 'active' && !completedIds.has(c.id));
|
||||
|
||||
if (!available.length) {
|
||||
return compose(
|
||||
heading('You have completed everything available'),
|
||||
`${plural(done, 'course')} done, ${plural((p.earned_badges || []).length, 'badge')} earned, ${p.xp || 0} XP.`,
|
||||
'',
|
||||
'The next lever is verified shifts — that is what employers weight most.'
|
||||
);
|
||||
}
|
||||
|
||||
const byValue = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0));
|
||||
const shifts = p.shifts_completed || 0;
|
||||
|
||||
return compose(
|
||||
heading('What to learn next'),
|
||||
'',
|
||||
numbered(byValue.slice(0, 3).map((c) => {
|
||||
const gate = c.unlock_requirements?.min_shifts || 0;
|
||||
const locked = gate > shifts;
|
||||
return `**${c.title}** — ${c.difficulty}, ${c.estimated_minutes} min, +${c.xp} XP\n Proves: ${c.proof_skill || c.title}.${locked ? ` Locked until ${plural(gate, 'shift')} (you have ${shifts}).` : ' Available now.'}`;
|
||||
})),
|
||||
'',
|
||||
heading('Where you stand'),
|
||||
bullets([
|
||||
`${plural(done, 'course')} completed`,
|
||||
`${plural((p.earned_badges || []).length, 'badge')} earned`,
|
||||
`${p.xp || 0} XP`,
|
||||
`${plural((p.capabilities || []).length, 'capability')} verified`,
|
||||
]),
|
||||
'',
|
||||
done === 0
|
||||
? 'Start with the shortest one. The point of the first course is not the XP — it is getting one verified capability on the board.'
|
||||
: done < 3
|
||||
? `Three completions is where the score starts moving noticeably, because education carries real weight in the formula. You are at ${done}.`
|
||||
: 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.',
|
||||
'',
|
||||
'Each of these ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored. That is why the badge means something to an employer.'
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Free-text responder ────────────────────────────────────────────────── */
|
||||
|
||||
const has = (q, ...words) => words.some((w) => q.includes(w));
|
||||
|
||||
export const respondTalent = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'resume', 'résumé', 'cv', 'profile')) return resumeReview(f);
|
||||
if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge')) return learningRecommendations(f);
|
||||
if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next')) return careerGuidance(f);
|
||||
|
||||
const p = f.profile;
|
||||
return p
|
||||
? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?`
|
||||
: NO_PROFILE;
|
||||
};
|
||||
|
||||
export const TALENT_CAPABILITIES = [
|
||||
{ id: 'resume-review', label: 'Resume Review', icon: FileText, run: resumeReview },
|
||||
{ id: 'career-guidance', label: 'Career Guidance', icon: Compass, run: careerGuidance },
|
||||
{ id: 'learning-recommendations', label: 'Learning Recommendations', icon: BookOpen, run: learningRecommendations },
|
||||
];
|
||||
163
src/components/ai-assistant/contexts.js
Normal file
163
src/components/ai-assistant/contexts.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
ANALYTICS_CAPABILITIES, CANDIDATE_CAPABILITIES, OVERVIEW_CAPABILITIES,
|
||||
respondAnalytics, respondCandidates, respondOverview,
|
||||
} from './capabilities/employer';
|
||||
import {
|
||||
ACTIVITY_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, CONTROL_CENTER_CAPABILITIES,
|
||||
respondActivity, respondAdminCandidates, respondControlCenter,
|
||||
} from './capabilities/admin';
|
||||
import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent';
|
||||
|
||||
/**
|
||||
* Page contexts for the Krow AI Assistant.
|
||||
*
|
||||
* The component is identical on every page. Only what is in this file changes:
|
||||
* the supporting line, the placeholder examples, the suggested chips, the
|
||||
* capabilities and the responder.
|
||||
*
|
||||
* Chips are phrased as things a person would actually type, because clicking one
|
||||
* fills the input rather than firing an action. The user always sees the
|
||||
* question before it is asked, so the assistant reads as a conversation rather
|
||||
* than a menu of buttons.
|
||||
*
|
||||
* `capability` binds a chip to a specific capability, so a chip gets a precise
|
||||
* answer while an equivalent typed question still resolves through the
|
||||
* responder's keyword matching.
|
||||
*
|
||||
* `needsWorkerProfile` gates the one query with a side effect, so employer and
|
||||
* admin sessions never touch worker-profile records.
|
||||
*/
|
||||
export const ASSISTANT_CONTEXTS = {
|
||||
'employer.overview': {
|
||||
id: 'employer.overview',
|
||||
page: 'Overview',
|
||||
tagline: 'Find insights from your hiring data, spot what needs attention, and ask questions about this page.',
|
||||
placeholders: [
|
||||
"Ask about today's hiring…",
|
||||
'What should I do first?',
|
||||
'Which roles are at risk?',
|
||||
'How healthy is my pipeline?',
|
||||
],
|
||||
chips: [
|
||||
{ label: "Today's Hiring", prompt: 'Give me a summary of hiring today', capability: 'hiring-summary' },
|
||||
{ label: 'Pipeline Health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
|
||||
{ label: 'Pending Actions', prompt: 'What is pending and needs my attention?', capability: 'pending-actions' },
|
||||
{ label: 'Hiring Risks', prompt: 'What risks should I know about?', capability: 'hiring-risks' },
|
||||
],
|
||||
capabilities: OVERVIEW_CAPABILITIES,
|
||||
respond: respondOverview,
|
||||
},
|
||||
|
||||
'employer.candidates': {
|
||||
id: 'employer.candidates',
|
||||
page: 'Candidates',
|
||||
tagline: 'Analyse résumés, compare candidates, generate interview questions, and decide who to move forward.',
|
||||
placeholders: [
|
||||
'Compare two candidates…',
|
||||
'Who should I interview next?',
|
||||
'What are this candidate’s gaps?',
|
||||
'Draft interview questions…',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Top Candidates', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
|
||||
{ label: 'Compare Candidates', prompt: 'Compare my top two candidates', capability: 'candidate-comparison' },
|
||||
{ label: 'Interview Questions', prompt: 'Generate interview questions for my top candidate', capability: 'interview-questions' },
|
||||
{ label: 'Skill Gap Analysis', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
|
||||
{ label: 'Hiring Recommendation', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
|
||||
],
|
||||
capabilities: CANDIDATE_CAPABILITIES,
|
||||
respond: respondCandidates,
|
||||
},
|
||||
|
||||
'employer.analytics': {
|
||||
id: 'employer.analytics',
|
||||
page: 'Analytics',
|
||||
tagline: 'Explain the numbers on this page, forecast what your pipeline will yield, and generate reports.',
|
||||
placeholders: [
|
||||
'Explain this chart…',
|
||||
'Generate a hiring report…',
|
||||
'Where do candidates drop off?',
|
||||
'How many hires can I expect?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Explain Charts', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
|
||||
{ label: 'Forecast Hiring', prompt: 'Forecast my hiring from the current pipeline', capability: 'forecast-hiring' },
|
||||
{ label: 'Department Insights', prompt: 'Break down performance by role category', capability: 'department-insights' },
|
||||
{ label: 'Generate Report', prompt: 'Generate a hiring report', capability: 'generate-reports' },
|
||||
],
|
||||
capabilities: ANALYTICS_CAPABILITIES,
|
||||
respond: respondAnalytics,
|
||||
},
|
||||
|
||||
'admin.controlCenter': {
|
||||
id: 'admin.controlCenter',
|
||||
page: 'Control Center',
|
||||
tagline: 'Check platform health, reconcile the data, and see what needs intervention across every account.',
|
||||
placeholders: [
|
||||
'Is anything broken?',
|
||||
'What should I fix first?',
|
||||
'How is the platform performing?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Platform Health', prompt: 'Run a platform health check', capability: 'platform-health' },
|
||||
{ label: 'Recommendations', prompt: 'What should I fix first?', capability: 'recommendations' },
|
||||
],
|
||||
capabilities: CONTROL_CENTER_CAPABILITIES,
|
||||
respond: respondControlCenter,
|
||||
},
|
||||
|
||||
'admin.candidates': {
|
||||
id: 'admin.candidates',
|
||||
page: 'Candidates Analysis',
|
||||
tagline: 'Analyse talent supply across the platform and see where the risk sits in the candidate pool.',
|
||||
placeholders: [
|
||||
'How healthy is talent supply?',
|
||||
'Any candidate risk flags?',
|
||||
'What are the recruitment trends?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Recruitment Insights', prompt: 'Show me recruitment insights across the platform', capability: 'recruitment-insights' },
|
||||
{ label: 'Candidate Risk', prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' },
|
||||
],
|
||||
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
|
||||
respond: respondAdminCandidates,
|
||||
},
|
||||
|
||||
'admin.activity': {
|
||||
id: 'admin.activity',
|
||||
page: 'Activity',
|
||||
tagline: 'Summarize the audit trail, reconcile it against real records, and see how accounts are behaving.',
|
||||
placeholders: [
|
||||
'Summarize the audit trail…',
|
||||
'Who is most active?',
|
||||
'Are there any gaps in the log?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Audit Summary', prompt: 'Summarize the audit trail', capability: 'audit-summary' },
|
||||
{ label: 'User Activity', prompt: 'Which accounts are most active?', capability: 'user-activity' },
|
||||
],
|
||||
capabilities: ACTIVITY_CAPABILITIES,
|
||||
respond: respondActivity,
|
||||
},
|
||||
|
||||
'talent.portal': {
|
||||
id: 'talent.portal',
|
||||
page: 'My Portal',
|
||||
tagline: 'Understand your score, improve your profile, and find the next thing worth learning.',
|
||||
placeholders: [
|
||||
'How do I earn more?',
|
||||
'Review my profile…',
|
||||
'What should I learn next?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Review My Profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' },
|
||||
{ label: 'Career Guidance', prompt: 'How do I raise my career score?', capability: 'career-guidance' },
|
||||
{ label: 'What To Learn Next', prompt: 'What should I learn next?', capability: 'learning-recommendations' },
|
||||
],
|
||||
capabilities: TALENT_CAPABILITIES,
|
||||
respond: respondTalent,
|
||||
needsWorkerProfile: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const getContext = (id) => ASSISTANT_CONTEXTS[id] ?? null;
|
||||
22
src/components/ai-assistant/index.jsx
Normal file
22
src/components/ai-assistant/index.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Krow AI Assistant — the dashboard's contextual productivity assistant.
|
||||
*
|
||||
* Architecture:
|
||||
*
|
||||
* AssistantPanelProvider open/closed state, resolved page context
|
||||
* AssistantTrigger the KROW mark, rendered beside a page's actions
|
||||
* AssistantPanel the sliding column the layout renders
|
||||
* KrowAssistant the one assistant UI, reused on every page
|
||||
* provider.js the transport seam — local today, HTTP later
|
||||
*
|
||||
* Independent of Owliver, which remains a separate product. Nothing here imports
|
||||
* Owliver code, and the two never share UI.
|
||||
*/
|
||||
|
||||
export { AssistantPanelProvider, useAssistantPanel } from './AssistantPanelContext';
|
||||
export { AssistantTrigger } from './AssistantTrigger';
|
||||
export { AssistantPanel } from './AssistantPanel';
|
||||
export { default as KrowAssistant } from './KrowAssistant';
|
||||
export { ASSISTANT_CONTEXTS, getContext } from './contexts';
|
||||
export { EXCLUDED_ROUTES, enabledRoutes, resolveAssistantContext } from './placement';
|
||||
export { createHttpProvider, createLocalProvider } from './provider';
|
||||
186
src/components/ai-assistant/insights.js
Normal file
186
src/components/ai-assistant/insights.js
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Dashboard fact sheet.
|
||||
*
|
||||
* Every assistant answer is derived from here, using the same formulas the
|
||||
* dashboard components use — so the assistant can never state a figure that
|
||||
* contradicts the card or chart sitting next to it.
|
||||
*
|
||||
* This module is pure: dashboard data in, derived facts out. No UI, no
|
||||
* transport, no dependency on any AI product.
|
||||
*/
|
||||
|
||||
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
|
||||
|
||||
export const plural = (n, word, irregular) =>
|
||||
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
|
||||
|
||||
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
|
||||
|
||||
const avg = (values) =>
|
||||
values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
|
||||
|
||||
const DAY_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
/** `today` is injected rather than read from the clock so answers are stable. */
|
||||
export function buildFacts({
|
||||
applications = [],
|
||||
postings = [],
|
||||
interviews = [],
|
||||
staff = [],
|
||||
profiles = [],
|
||||
activity = [],
|
||||
courses = [],
|
||||
profile = null,
|
||||
today = new Date(),
|
||||
}) {
|
||||
const scored = applications.filter((a) => a.ai_score > 0);
|
||||
const ranked = [...scored].sort((a, b) => b.ai_score - a.ai_score);
|
||||
const byStatus = (...statuses) => applications.filter((a) => statuses.includes(a.status));
|
||||
|
||||
const unscreened = byStatus('applied');
|
||||
const screened = byStatus('ai_screened', 'shortlisted', 'interview', 'hired');
|
||||
const interviewing = byStatus('interview');
|
||||
const hired = byStatus('hired');
|
||||
|
||||
const openPositions = postings.filter((p) => p.status === 'active');
|
||||
const applicationsFor = (id) => applications.filter((a) => a.job_posting_id === id);
|
||||
const completedInterviews = interviews.filter((i) => i.overall_interview_score > 0);
|
||||
|
||||
/* Time-to-hire — the created→updated span ImpactMetrics uses. */
|
||||
const timeToHire = hired.length
|
||||
? Math.round(avg(hired.map((a) =>
|
||||
Math.max(1, (new Date(a.updated_date) - new Date(a.created_date)) / DAY_MS))))
|
||||
: 0;
|
||||
|
||||
/* Open roles attracting nobody. */
|
||||
const starvedPositions = openPositions.filter((p) => applicationsFor(p.id).length === 0);
|
||||
|
||||
/* Roles resting on a single candidate — one withdrawal and the role reopens. */
|
||||
const singleCandidateRoles = openPositions
|
||||
.map((p) => ({ posting: p, viable: applicationsFor(p.id).filter((a) => a.ai_score >= 60) }))
|
||||
.filter((e) => e.viable.length === 1);
|
||||
|
||||
/* Strong candidates nobody has moved on — the most expensive kind of delay. */
|
||||
const stalled = ranked.filter((a) => a.ai_score >= 80 && a.status === 'ai_screened');
|
||||
|
||||
const funnel = [
|
||||
{ key: 'applied', label: 'Applied', count: applications.length },
|
||||
{ key: 'ai_screened', label: 'AI Screened', count: screened.length },
|
||||
{ key: 'shortlisted', label: 'Shortlisted', count: byStatus('shortlisted', 'interview', 'hired').length },
|
||||
{ key: 'interview', label: 'Interviewed', count: interviewing.length + hired.length },
|
||||
{ key: 'hired', label: 'Hired', count: hired.length },
|
||||
];
|
||||
|
||||
const transitions = funnel.slice(1).map((stage, i) => ({
|
||||
from: funnel[i].label,
|
||||
to: stage.label,
|
||||
rate: pct(stage.count, funnel[i].count),
|
||||
lost: Math.max(0, funnel[i].count - stage.count),
|
||||
}));
|
||||
|
||||
const bottleneck = [...transitions].sort((a, b) => a.rate - b.rate)[0] || null;
|
||||
|
||||
const byRole = openPositions.map((p) => {
|
||||
const apps = applicationsFor(p.id);
|
||||
const roleScored = apps.filter((a) => a.ai_score > 0);
|
||||
return {
|
||||
posting: p,
|
||||
title: p.title,
|
||||
category: p.role_category || 'Uncategorized',
|
||||
applied: apps.length,
|
||||
screened: apps.filter((a) => a.status !== 'applied').length,
|
||||
hired: apps.filter((a) => a.status === 'hired').length,
|
||||
avgScore: avg(roleScored.map((a) => a.ai_score)),
|
||||
qualified: roleScored.filter((a) => a.ai_score >= 70).length,
|
||||
};
|
||||
});
|
||||
|
||||
const since = (days) => {
|
||||
const cutoff = today.getTime() - days * DAY_MS;
|
||||
return activity.filter((e) => new Date(e.created_date).getTime() >= cutoff);
|
||||
};
|
||||
|
||||
const eventCounts = activity.reduce((acc, e) => {
|
||||
acc[e.event_type] = (acc[e.event_type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
today,
|
||||
|
||||
/* Applications */
|
||||
applications,
|
||||
total: applications.length,
|
||||
scored,
|
||||
ranked,
|
||||
top: ranked.slice(0, 3),
|
||||
weak: ranked.filter((a) => a.ai_score > 0 && a.ai_score < 40),
|
||||
unscreened,
|
||||
screened,
|
||||
interviewing,
|
||||
hired,
|
||||
stalled,
|
||||
avgScore: avg(scored.map((a) => a.ai_score)),
|
||||
hiredAvgScore: avg(hired.map((a) => a.ai_score)),
|
||||
missingCredentials: scored.filter((a) => !(a.certifications || []).length),
|
||||
narrowAvailability: scored.filter((a) => (a.availability || []).length <= 1),
|
||||
|
||||
/* Positions */
|
||||
postings,
|
||||
openPositions,
|
||||
starvedPositions,
|
||||
singleCandidateRoles,
|
||||
byRole,
|
||||
applicationsFor,
|
||||
|
||||
/* Interviews */
|
||||
interviews,
|
||||
completedInterviews,
|
||||
avgInterviewScore: avg(completedInterviews.map((i) => i.overall_interview_score)),
|
||||
interviewCompletion: pct(completedInterviews.length, interviews.length),
|
||||
flagged: interviews.filter((i) => (i.integrity_score ?? 100) < 100),
|
||||
|
||||
/* Hires */
|
||||
staff,
|
||||
unratedStaff: staff.filter((s) => !s.client_rating),
|
||||
timeToHire,
|
||||
hireRate: pct(hired.length, applications.length),
|
||||
|
||||
/* Funnel */
|
||||
funnel,
|
||||
transitions,
|
||||
bottleneck,
|
||||
|
||||
/* Talent pool */
|
||||
profiles,
|
||||
elite: profiles.filter((p) => (p.krow_score || 0) >= 90),
|
||||
unscoredProfiles: profiles.filter((p) => !(p.krow_score > 0)),
|
||||
|
||||
/* Activity */
|
||||
activity,
|
||||
activity7d: since(7),
|
||||
activity24h: since(1),
|
||||
eventCounts,
|
||||
|
||||
/* Signed-in worker (talent context only) */
|
||||
profile,
|
||||
courses,
|
||||
|
||||
/* Economics — mirrors ImpactMetrics */
|
||||
costSaved: scored.length * 45 + interviews.length * 80,
|
||||
hoursSaved: Math.round((scored.length * 15 + interviews.length * 30) / 60),
|
||||
standardizedPct: pct(scored.length, applications.length),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Answer formatting ──────────────────────────────────────────────────── */
|
||||
|
||||
export const bullets = (items) => items.filter(Boolean).map((t) => `• ${t}`).join('\n');
|
||||
|
||||
export const numbered = (items) =>
|
||||
items.filter(Boolean).map((t, i) => `${i + 1}. ${t}`).join('\n\n');
|
||||
|
||||
export const heading = (text) => `**${text}**`;
|
||||
|
||||
/** Joins answer blocks, dropping empties so templates can use conditionals. */
|
||||
export const compose = (...blocks) => blocks.filter((b) => b !== null && b !== undefined && b !== '').join('\n');
|
||||
57
src/components/ai-assistant/placement.js
Normal file
57
src/components/ai-assistant/placement.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||
|
||||
/**
|
||||
* Where the Krow AI Assistant appears.
|
||||
*
|
||||
* The assistant occupies a permanent column in the page layout, so enabling it
|
||||
* on a page is a layout decision, not a feature flag — which is exactly why the
|
||||
* decision lives in one table rather than being scattered across pages.
|
||||
*
|
||||
* Matching is exact, not prefix-based: `/candidates` must not leak an assistant
|
||||
* onto a future `/candidates/:id`, and `/positions/:id` must not inherit one
|
||||
* from `/positions`.
|
||||
*/
|
||||
const PLACEMENT = {
|
||||
employer: {
|
||||
'/': 'employer.overview',
|
||||
'/candidates': 'employer.candidates',
|
||||
'/analytics': 'employer.analytics',
|
||||
},
|
||||
admin: {
|
||||
'/admin': 'admin.controlCenter', // Control Center
|
||||
'/candidates': 'admin.candidates', // Candidates Analysis
|
||||
'/tracking': 'admin.activity', // Activity
|
||||
},
|
||||
talent: {
|
||||
'/employee': 'talent.portal', // My Portal
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Pages that must never carry the assistant, listed explicitly so the intent is
|
||||
* documented rather than implied by omission.
|
||||
*
|
||||
* Employer: Positions, Position Detail, Create Position, Hired History,
|
||||
* Talent Pool, Profile.
|
||||
* Admin: everything except the three above.
|
||||
* Talent: everything except My Portal.
|
||||
*/
|
||||
export const EXCLUDED_ROUTES = [
|
||||
'/positions', '/positions/new', '/positions/:id',
|
||||
'/hired', '/talent-pool', '/profile', '/apply',
|
||||
'/university', '/university/:id', '/me', '/identity',
|
||||
'/owliver', '/design-system',
|
||||
];
|
||||
|
||||
/** Returns the context for a role and path, or `null` to render no assistant. */
|
||||
export function resolveAssistantContext(role, pathname) {
|
||||
const id = PLACEMENT[role]?.[pathname];
|
||||
return id ? ASSISTANT_CONTEXTS[id] : null;
|
||||
}
|
||||
|
||||
/** Every enabled role/route pair — used by the placement verification. */
|
||||
export function enabledRoutes() {
|
||||
return Object.entries(PLACEMENT).flatMap(([role, routes]) =>
|
||||
Object.entries(routes).map(([path, contextId]) => ({ role, path, contextId }))
|
||||
);
|
||||
}
|
||||
139
src/components/ai-assistant/provider.js
Normal file
139
src/components/ai-assistant/provider.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Provider seam for the Krow AI Assistant.
|
||||
*
|
||||
* The UI never generates an answer and never knows where one came from. It calls
|
||||
* `provider.stream(request)` and renders the chunks. That single boundary is
|
||||
* what makes the backend swappable: today a local provider computes answers from
|
||||
* dashboard data, and later an HTTP provider can call a real service without a
|
||||
* single change to the component.
|
||||
*
|
||||
* ── Contract ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* provider.id: string
|
||||
* provider.stream(request): AsyncIterable<string> // yields text deltas
|
||||
*
|
||||
* request = {
|
||||
* contextId: string, // e.g. 'employer.overview'
|
||||
* capability: string | null, // capability id, or null for free text
|
||||
* question: string, // what the user typed, or the capability label
|
||||
* facts: object, // the dashboard fact sheet (see insights.js)
|
||||
* signal: AbortSignal, // aborts an in-flight response
|
||||
* }
|
||||
*
|
||||
* Deltas rather than a whole string is deliberate: it is the shape every
|
||||
* streaming completion API returns, so the local provider and a future remote
|
||||
* one are interchangeable without the UI learning a second pattern.
|
||||
*/
|
||||
|
||||
import { getContext } from './contexts';
|
||||
|
||||
/** Words per delta. Chunking by word keeps reflow to once per word. */
|
||||
const WORDS_PER_CHUNK = 3;
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Local provider — answers computed from the dashboard's own data.
|
||||
*
|
||||
* Deterministic: the same question against the same data returns the same
|
||||
* answer, which is what makes the assistant demonstrable and testable.
|
||||
*/
|
||||
export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
|
||||
return {
|
||||
id: 'local',
|
||||
|
||||
async *stream({ contextId, capability, question, facts, signal }) {
|
||||
const context = getContext(contextId);
|
||||
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
|
||||
|
||||
const answer = capability
|
||||
? context.capabilities.find((c) => c.id === capability)?.run(facts)
|
||||
?? context.respond(question, facts)
|
||||
: context.respond(question, facts);
|
||||
|
||||
// A brief pause before the first delta, so the answer reads as considered
|
||||
// rather than precomputed.
|
||||
await sleep(latency);
|
||||
if (signal?.aborted) return;
|
||||
|
||||
const tokens = String(answer).split(/(\s+)/);
|
||||
for (let i = 0; i < tokens.length; i += WORDS_PER_CHUNK * 2) {
|
||||
if (signal?.aborted) return;
|
||||
yield tokens.slice(i, i + WORDS_PER_CHUNK * 2).join('');
|
||||
await sleep(chunkDelay);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP provider — the path to a real backend.
|
||||
*
|
||||
* Reads Server-Sent-Event style `data:` lines from a streaming endpoint and
|
||||
* yields the text deltas. Unused today; it exists so the shape of the
|
||||
* integration is settled rather than guessed at later. Swapping providers is a
|
||||
* one-line change in `AssistantProvider`.
|
||||
*
|
||||
* The request body sends `contextId`, `capability` and `question` — not the
|
||||
* fact sheet. Dashboard data should be fetched server-side from the caller's own
|
||||
* session rather than posted from the browser, so the client cannot ask about
|
||||
* records it is not entitled to see.
|
||||
*/
|
||||
export function createHttpProvider({ endpoint, headers = {} }) {
|
||||
if (!endpoint) throw new Error('createHttpProvider requires an endpoint');
|
||||
|
||||
return {
|
||||
id: 'http',
|
||||
|
||||
async *stream({ contextId, capability, question, signal }) {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify({ contextId, capability, question }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Assistant request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
// The final element may be a partial line; keep it for the next read.
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
if (parsed.delta) yield parsed.delta;
|
||||
} catch {
|
||||
// A non-JSON data line is treated as a raw delta.
|
||||
yield payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider the app uses.
|
||||
*
|
||||
* Local by default. Point `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to
|
||||
* switch, with no other code change.
|
||||
*/
|
||||
export function createAssistantProvider() {
|
||||
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;
|
||||
return endpoint ? createHttpProvider({ endpoint }) : createLocalProvider();
|
||||
}
|
||||
156
src/components/ai-assistant/useAssistant.js
Normal file
156
src/components/ai-assistant/useAssistant.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
|
||||
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { buildFacts } from './insights';
|
||||
import { createAssistantProvider } from './provider';
|
||||
|
||||
/** One provider instance for the app's lifetime. */
|
||||
const provider = createAssistantProvider();
|
||||
|
||||
/**
|
||||
* Reads the same React Query caches the pages render from, so the assistant
|
||||
* costs no extra requests and cannot be looking at a different snapshot than the
|
||||
* card beside it.
|
||||
*/
|
||||
export function useAssistantFacts({ needsWorkerProfile = false } = {}) {
|
||||
const { data: applications = [] } = useApplications();
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
const { data: interviews = [] } = useInterviews();
|
||||
const { data: staff = [] } = useStaff();
|
||||
const { data: profiles = [] } = useWorkerProfiles();
|
||||
const { data: activity = [] } = useUserActivity();
|
||||
const { data: courses = [] } = useCourses();
|
||||
|
||||
// Gated: this query creates the record if absent, so it must not run for
|
||||
// employer or admin sessions.
|
||||
const { data: profile = null } = useWorkerProfile({ enabled: needsWorkerProfile });
|
||||
|
||||
return React.useMemo(
|
||||
() => buildFacts({
|
||||
applications, postings, interviews, staff, profiles, activity, courses,
|
||||
profile: needsWorkerProfile ? profile : null,
|
||||
}),
|
||||
[applications, postings, interviews, staff, profiles, activity, courses, profile, needsWorkerProfile]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation state for one page context.
|
||||
*
|
||||
* Owns the thread, the in-flight response, and abort handling. The thread is
|
||||
* 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.
|
||||
*/
|
||||
export function useConversation({ contextId, facts }) {
|
||||
const storageKey = `krow_assistant:${contextId}`;
|
||||
|
||||
const [messages, setMessages] = React.useState([]);
|
||||
const [pending, setPending] = React.useState(null); // { question, text, thinking }
|
||||
const [error, setError] = React.useState(null);
|
||||
const abortRef = React.useRef(null);
|
||||
|
||||
/* Load the stored thread, and reload when the context changes. */
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey);
|
||||
setMessages(raw ? JSON.parse(raw) : []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
setPending(null);
|
||||
setError(null);
|
||||
}, [storageKey]);
|
||||
|
||||
const persist = React.useCallback((next) => {
|
||||
setMessages(next);
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(next));
|
||||
} catch {
|
||||
// Quota or private mode — the thread stays in memory only.
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
/* Abort any in-flight response when the context changes or we unmount. */
|
||||
React.useEffect(() => () => abortRef.current?.abort(), [storageKey]);
|
||||
|
||||
// The stream callback runs outside render, so it needs the latest thread
|
||||
// rather than the value captured when the request started.
|
||||
const messagesRef = React.useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
const send = React.useCallback(async ({ question, capability = null }) => {
|
||||
const text = String(question).trim();
|
||||
if (!text) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const withUser = [...messagesRef.current, { role: 'user', text }];
|
||||
messagesRef.current = withUser;
|
||||
persist(withUser);
|
||||
setError(null);
|
||||
setPending({ question: text, text: '', thinking: true });
|
||||
|
||||
try {
|
||||
let assembled = '';
|
||||
for await (const delta of provider.stream({
|
||||
contextId, capability, question: text, facts, signal: controller.signal,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
assembled += delta;
|
||||
setPending({ question: text, text: assembled, thinking: false });
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
// Keep whatever streamed before the user stopped it — discarding a
|
||||
// half-written answer loses information the user was already reading.
|
||||
if (assembled) {
|
||||
const next = [...messagesRef.current, { role: 'assistant', text: assembled, stopped: true }];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
}
|
||||
} else {
|
||||
const next = [...messagesRef.current, { role: 'assistant', text: assembled }];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
setError('I could not complete that. Try again in a moment.');
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, persist]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
messagesRef.current = [];
|
||||
setMessages([]);
|
||||
setPending(null);
|
||||
setError(null);
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
pending,
|
||||
error,
|
||||
busy: Boolean(pending),
|
||||
send,
|
||||
stop,
|
||||
reset,
|
||||
providerId: provider.id,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user