Initial commit

This commit is contained in:
2026-08-07 23:53:15 +05:30
commit 90f14a73b2
222 changed files with 35000 additions and 0 deletions

View 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>
);
}

View 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;

View 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: () => {},
};
}

View 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;

View 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&apos;s data. Check anything you act on.
</p>
</div>
</Surface>
);
}

View 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>
);
}

View 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 },
];

View 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 },
];

View 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 },
];

View 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;

View 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';

View 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');

View 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 }))
);
}

View 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();
}

View 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,
};
}