update chatbox

This commit is contained in:
2026-08-08 00:39:16 +05:30
parent 90f14a73b2
commit 0cae250049
15 changed files with 1952 additions and 913 deletions

View File

@@ -1,132 +1,47 @@
import * as React from 'react'; import * as React from 'react';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ResponseDocument } from './ResponseBlocks';
/** /**
* Renders the light markup the capabilities emit — `**bold**`, bullets, * The thinking state, shown between sending and the first block arriving.
* numbered items, `_asides_`, and ✓/⚠ check lines.
* *
* A full markdown renderer is available in the project, but this content is * Labelled as well as animated: three moving dots mean nothing to a screen
* produced locally in a known shape, and parsing five constructs keeps the * reader, and the visually-hidden text is what tells one that a reply is coming.
* 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() { export function ThinkingIndicator() {
return ( 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"> <div className="flex items-center gap-2">
{[0, 1, 2].map((i) => ( <span className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-krow-blue-tint text-krow-blue">
<span <Sparkles className="h-3 w-3" aria-hidden="true" />
key={i} </span>
className="w-1.5 h-1.5 rounded-full bg-ink-4 animate-typing" <span className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-2.5 py-2 shadow-xs">
style={{ animationDelay: `${i * 0.18}s` }} {[0, 1, 2].map((i) => (
/> <span
))} key={i}
<span className="sr-only">Assistant is thinking</span> className="h-1.5 w-1.5 animate-typing rounded-full bg-ink-4"
style={{ animationDelay: `${i * 0.18}s` }}
/>
))}
</span>
<span className="sr-only">Krow AI is preparing a response</span>
</div> </div>
); );
} }
/** /**
* One turn in the conversation. The user's own words sit in a filled bubble; the * One turn.
* 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 * The user's words sit in a filled bubble; the assistant's reply runs the full
* column on padding. * width of the panel. That asymmetry is deliberate — these replies are reports
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
* column on padding around them.
*/ */
export function Message({ role, text, streaming, stopped }) { export const Message = React.memo(({ role, text, blocks, streaming, stopped }) => {
if (role === 'user') { if (role === 'user') {
return ( return (
<div className="flex justify-end animate-slide-up"> <div className="flex justify-end">
<div className="max-w-[85%] rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white"> <div className="max-w-[85%] animate-slide-up rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
{text} {text}
</div> </div>
</div> </div>
@@ -135,16 +50,24 @@ export function Message({ role, text, streaming, stopped }) {
return ( return (
<div className="animate-slide-up"> <div className="animate-slide-up">
<RichText text={text} /> <div className="mb-2 flex items-center gap-1.5">
{streaming && ( <span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-krow-blue-tint text-krow-blue">
<span <Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
className="inline-block w-1.5 h-3.5 ml-0.5 -mb-0.5 bg-krow-blue animate-caret" </span>
aria-hidden="true" <span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Krow AI</span>
/> </div>
)}
{stopped && ( <ResponseDocument blocks={blocks} streaming={streaming} />
<p className="mt-1.5 text-caption italic text-ink-4">Stopped early.</p>
)} {stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
</div> </div>
); );
});
Message.displayName = 'Message';
/** A subtle divider between turns, so a long thread stays readable. */
export function TurnDivider() {
return <div className="h-px bg-border/60" role="presentation" />;
} }
export { cn };

View File

@@ -4,9 +4,27 @@ import { cn } from '@/lib/utils';
import KrowAssistant from './KrowAssistant'; import KrowAssistant from './KrowAssistant';
import { useAssistantPanel } from './AssistantPanelContext'; import { useAssistantPanel } from './AssistantPanelContext';
/** Panel width, plus the gutter separating it from the dashboard. */ const DEFAULT_WIDTH = 380;
const PANEL_WIDTH = 380; const EXPANDED_WIDTH = 620;
/** Gutter separating the panel from the dashboard. */
const GUTTER = 24; const GUTTER = 24;
/** Expanded must never dominate: the dashboard stays the primary experience. */
const MAX_VIEWPORT_SHARE = 0.42;
/** Tracks viewport width so the expanded panel can be clamped on smaller screens. */
function useViewportWidth() {
const [width, setWidth] = React.useState(() =>
typeof window === 'undefined' ? 1440 : window.innerWidth
);
React.useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return width;
}
/** /**
* AssistantPanel — the sliding column the layout renders. * AssistantPanel — the sliding column the layout renders.
@@ -15,23 +33,30 @@ const GUTTER = 24;
* dashboard reflow smoothly instead of being covered, and what lets closing * dashboard reflow smoothly instead of being covered, and what lets closing
* restore the original layout exactly. * restore the original layout exactly.
* *
* Two things about the structure matter, and both were bugs before: * Two structural details matter, and both were bugs at one point:
* *
* 1. The clip lives *inside* the sticky element, not around it. An ancestor with * 1. The clip lives *inside* the sticky element. An ancestor with
* `overflow: hidden` becomes the nearest scroll container, which silently * `overflow: hidden` becomes the nearest scroll container and silently
* disables `position: sticky` on everything beneath it — the panel computed * disables `position: sticky` beneath it.
* as sticky but never actually stuck. * 2. The aside stretches to the shell's full height (`self-stretch`), so its
* sticky context runs the whole page rather than ending partway down and
* leaving empty space beside a long dashboard.
* *
* 2. The aside must stretch to the shell's full height. As a flex item under * Expanding widens the same panel in place — it is an analysis workspace inside
* `items-start` it collapsed to its own height, so its sticky context ended * the application layout, not a fullscreen takeover, and never covers the
* partway down the page and the panel scrolled away, leaving empty space * dashboard.
* 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 }) { export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
const { context, isOpen, close } = useAssistantPanel(); const { context, isOpen, isExpanded, close, expand, restore } = useAssistantPanel();
const viewportWidth = useViewportWidth();
// Clamped so a wide panel cannot squeeze the dashboard on a small screen.
const panelWidth = isExpanded
? Math.max(DEFAULT_WIDTH, Math.min(EXPANDED_WIDTH, Math.round(viewportWidth * MAX_VIEWPORT_SHARE)))
: DEFAULT_WIDTH;
const trackWidth = panelWidth + GUTTER;
const easing = [0.65, 0, 0.35, 1];
return ( return (
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
@@ -40,34 +65,40 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
key="assistant" key="assistant"
aria-label="Krow AI Assistant" aria-label="Krow AI Assistant"
initial={{ width: 0, opacity: 0 }} initial={{ width: 0, opacity: 0 }}
animate={{ width: PANEL_WIDTH + GUTTER, opacity: 1 }} animate={{ width: trackWidth, opacity: 1 }}
exit={{ width: 0, opacity: 0 }} exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }} transition={{ duration: 0.3, ease: easing }}
/* No `overflow-hidden` here — see (1) above. `self-stretch` keeps the /* No `overflow-hidden` here — see (1). `self-stretch` — see (2). */
sticky context running the full height of the shell — see (2). */
className="relative shrink-0 self-stretch" className="relative shrink-0 self-stretch"
> >
<div className={cn('sticky', stickyClassName)}> <div className={cn('sticky', stickyClassName)}>
{/* The clip: contains the slide-in and the fixed panel width while {/* The clip: contains the slide-in and the panel width while the
the track animates open, without breaking the sticky above it. */} track animates, without breaking the sticky above it. */}
<div className="overflow-hidden" style={{ width: PANEL_WIDTH + GUTTER }}> <motion.div
className="overflow-hidden"
animate={{ width: trackWidth }}
transition={{ duration: 0.3, ease: easing }}
>
<motion.div <motion.div
className="pl-6" className="pl-6"
initial={{ x: 32, opacity: 0 }} initial={{ x: 32, opacity: 0 }}
animate={{ x: 0, opacity: 1 }} animate={{ x: 0, opacity: 1 }}
exit={{ x: 32, opacity: 0 }} exit={{ x: 32, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }} transition={{ duration: 0.3, ease: easing }}
> >
{/* Keyed on the context so switching pages gives a clean panel {/* Keyed on the context so switching pages gives a clean panel
and aborts any response still streaming for the last one. */} and aborts any response still streaming for the last one. */}
<KrowAssistant <KrowAssistant
key={context.id} key={context.id}
context={context} context={context}
expanded={isExpanded}
onClose={close} onClose={close}
onExpand={expand}
onRestore={restore}
className={panelHeightClassName} className={panelHeightClassName}
/> />
</motion.div> </motion.div>
</div> </motion.div>
</div> </div>
</motion.aside> </motion.aside>
)} )}

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import { resolveAssistantContext } from './placement'; import { resolveAssistantContext } from './placement';
/** /**
* Open/closed state for the Krow AI Assistant. * Window state for the Krow AI Assistant: open/closed and default/expanded.
* *
* Lives in context rather than in the layout so the trigger can sit anywhere in * 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 * the page — beside that page's own actions — without the layout having to know
@@ -14,23 +14,33 @@ import { resolveAssistantContext } from './placement';
const AssistantPanelContext = React.createContext(null); const AssistantPanelContext = React.createContext(null);
const STORAGE_KEY = 'krow_assistant_open'; const OPEN_KEY = 'krow_assistant_open';
const EXPANDED_KEY = 'krow_assistant_expanded';
/** /**
* Whether the panel was open before. Kept for the session so moving between * Window state is kept for the session so moving between supported pages does
* supported pages does not close a panel the user deliberately opened, while a * not collapse a panel the user deliberately opened or expanded, while a new
* new session still starts clean on the dashboard. * session still starts clean on the dashboard.
*/ */
function readStoredOpen() { function readFlag(key) {
try { try {
return sessionStorage.getItem(STORAGE_KEY) === '1'; return sessionStorage.getItem(key) === '1';
} catch { } catch {
return false; return false;
} }
} }
function writeFlag(key, value) {
try {
sessionStorage.setItem(key, value ? '1' : '0');
} catch {
// Private mode — state simply resets next session.
}
}
export function AssistantPanelProvider({ role, pathname, children }) { export function AssistantPanelProvider({ role, pathname, children }) {
const [open, setOpen] = React.useState(readStoredOpen); const [open, setOpen] = React.useState(() => readFlag(OPEN_KEY));
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
const context = React.useMemo( const context = React.useMemo(
() => resolveAssistantContext(role, pathname), () => resolveAssistantContext(role, pathname),
@@ -38,36 +48,47 @@ export function AssistantPanelProvider({ role, pathname, children }) {
); );
const supported = Boolean(context); const supported = Boolean(context);
// A stored "open" must not force the panel onto a page that has no assistant. // A stored "open" must not force the panel onto a page with no assistant.
const isOpen = supported && open; const isOpen = supported && open;
const isExpanded = isOpen && expanded;
const persist = React.useCallback((next) => { const setOpenState = React.useCallback((next) => {
setOpen(next); setOpen(next);
try { writeFlag(OPEN_KEY, next);
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. */ const setExpandedState = React.useCallback((next) => {
setExpanded(next);
writeFlag(EXPANDED_KEY, next);
}, []);
/**
* Escape steps back one level rather than closing outright: expanded → default
* → closed. Collapsing a workspace and dismissing it are different intentions,
* and one key press should not do both.
*/
React.useEffect(() => { React.useEffect(() => {
if (!isOpen) return undefined; if (!isOpen) return undefined;
const onKey = (e) => { const onKey = (e) => {
if (e.key === 'Escape') persist(false); if (e.key !== 'Escape') return;
if (isExpanded) setExpandedState(false);
else setOpenState(false);
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [isOpen, persist]); }, [isOpen, isExpanded, setExpandedState, setOpenState]);
const value = React.useMemo(() => ({ const value = React.useMemo(() => ({
context, context,
supported, supported,
isOpen, isOpen,
open: () => persist(true), isExpanded,
close: () => persist(false), open: () => setOpenState(true),
toggle: () => persist(!isOpen), close: () => setOpenState(false),
}), [context, supported, isOpen, persist]); toggle: () => setOpenState(!isOpen),
expand: () => setExpandedState(true),
restore: () => setExpandedState(false),
}), [context, supported, isOpen, isExpanded, setOpenState, setExpandedState]);
return ( return (
<AssistantPanelContext.Provider value={value}> <AssistantPanelContext.Provider value={value}>
@@ -79,7 +100,7 @@ export function AssistantPanelProvider({ role, pathname, children }) {
/** /**
* Panel state for triggers and the layout. * Panel state for triggers and the layout.
* *
* Returns a safe inert value outside a provider, so a page rendering an * Returns an inert value outside a provider, so a page rendering an
* `AssistantTrigger` in isolation (a test, a storybook) does not crash. * `AssistantTrigger` in isolation (a test, a storybook) does not crash.
*/ */
export function useAssistantPanel() { export function useAssistantPanel() {
@@ -87,8 +108,11 @@ export function useAssistantPanel() {
context: null, context: null,
supported: false, supported: false,
isOpen: false, isOpen: false,
isExpanded: false,
open: () => {}, open: () => {},
close: () => {}, close: () => {},
toggle: () => {}, toggle: () => {},
expand: () => {},
restore: () => {},
}; };
} }

View File

@@ -1,33 +1,48 @@
import * as React from 'react'; import * as React from 'react';
import { RotateCcw, Sparkles, X } from 'lucide-react'; import { Maximize2, Minimize2, RotateCcw, Sparkles, X } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface'; import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton'; import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert'; import { Alert } from '@/components/ds/Alert';
import { KROW_LOGO_URL } from '@/assets/brand'; import { KROW_LOGO_URL } from '@/assets/brand';
import { useAssistantFacts, useConversation } from './useAssistant'; import { useAssistantFacts, useConversation } from './useAssistant';
import { Message, ThinkingIndicator } from './AssistantMessage'; import { buildIntro, buildPrompts } from './dynamic';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput'; import { PromptInput } from './PromptInput';
import { PromptChips } from './PromptChips';
/** /**
* Krow AI Assistant — the dashboard's contextual assistant. * Krow AI Assistant — the dashboard's contextual assistant.
* *
* One component, reused on every supported page; only `context` differs. * One component, reused on every supported page; only `context` differs.
* *
* The layout is a fixed three-part frame — header, conversation, composer — * The frame is fixed at three parts — header, body, composer — and only the body
* where only the conversation scrolls. That is what lets the panel stay pinned * scrolls. That is what lets the panel stay pinned beside a long dashboard
* beside a long dashboard without ever growing the page or losing its input. * without ever growing the page or scrolling its own input out of reach.
* *
* The empty state is deliberately a conversational entry point rather than a * Two states share that frame:
* 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 * - **Landing.** One centred block: mark, contextual label, greeting,
* later be pointed at an external service without changing anything here. * description, input, suggestions. Deliberately light — it is an invitation,
* not a page, so nothing in it competes with the dashboard beside it.
* - **Conversation.** The thread takes the body and the composer returns to the
* bottom, because from then on the thread is what the user is reading.
*
* `expanded` widens the same panel into an analysis workspace. `data-wide` lets
* individual response blocks use the extra room (a KPI row goes to three
* columns) without any of them needing to know the panel's pixel width.
*
* Independent of Owliver. Answers come from a provider (see provider.js) that
* can later be pointed at an external service without changing anything here.
*/ */
export default function KrowAssistant({ context, className, onClose }) { export default function KrowAssistant({
context,
className,
expanded = false,
onClose,
onExpand,
onRestore,
}) {
const facts = useAssistantFacts({ needsWorkerProfile: context.needsWorkerProfile }); const facts = useAssistantFacts({ needsWorkerProfile: context.needsWorkerProfile });
const { messages, pending, error, busy, send, stop, reset } = useConversation({ const { messages, pending, error, busy, send, stop, reset } = useConversation({
contextId: context.id, contextId: context.id,
@@ -38,23 +53,46 @@ export default function KrowAssistant({ context, className, onClose }) {
const scrollRef = React.useRef(null); const scrollRef = React.useRef(null);
const isEmpty = messages.length === 0 && !pending; const isEmpty = messages.length === 0 && !pending;
/* Follow the newest content as it streams. */ /* Greeting and suggestions come from live data, so they recompute only when
the data or the page actually changes. */
const intro = React.useMemo(
() => buildIntro(context.id, facts, facts.profile?.full_name),
[context.id, facts]
);
const prompts = React.useMemo(() => buildPrompts(context.id, facts), [context.id, facts]);
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
at streaming frequency a smooth scroll never catches up and the thread
visibly lags the text. */
React.useEffect(() => { React.useEffect(() => {
const el = scrollRef.current; const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight; if (el) el.scrollTop = el.scrollHeight;
}, [messages, pending]); }, [messages, pending]);
/** const submit = React.useCallback((text) => {
* A chip's prompt resolves to its capability so the answer is precise, while const question = String(text).trim();
* the same question typed by hand still resolves through keyword matching.
*/
const submit = (text) => {
const question = text.trim();
if (!question || busy) return; if (!question || busy) return;
const chip = context.chips.find((c) => c.prompt === question); send({ question });
send({ question, capability: chip?.capability ?? null });
setInput(''); setInput('');
}; }, [busy, send]);
/* A chip is already a complete question, so it runs on click. */
const runPrompt = React.useCallback((prompt) => {
if (busy) return;
setInput('');
send({ question: prompt.prompt, capability: prompt.capability });
}, [busy, send]);
const composer = (
<PromptInput
value={input}
onChange={setInput}
onSubmit={submit}
onStop={stop}
busy={busy}
placeholders={intro.placeholders}
/>
);
return ( return (
<Surface <Surface
@@ -62,13 +100,15 @@ export default function KrowAssistant({ context, className, onClose }) {
radius="xl" radius="xl"
padding="none" padding="none"
elevation="sm" elevation="sm"
data-wide={expanded || undefined}
className={cn('flex flex-col overflow-hidden', className)} className={cn('flex flex-col overflow-hidden', className)}
role="region"
aria-label="Krow AI Assistant" aria-label="Krow AI Assistant"
> >
{/* Header — fixed */} {/* Header */}
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-4 py-3"> <div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand"> <span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
<Sparkles className="h-4 w-4" aria-hidden="true" /> <Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -76,92 +116,101 @@ export default function KrowAssistant({ context, className, onClose }) {
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p> <p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
</div> </div>
{messages.length > 0 && ( {/* Window controls */}
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} /> <div className="flex items-center gap-0.5">
)} {messages.length > 0 && (
{onClose && ( <IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} /> )}
)} {expanded
? onRestore && (
<IconButton icon={Minimize2} label="Restore panel width" variant="ghost" size="sm" onClick={onRestore} />
)
: onExpand && (
<IconButton icon={Maximize2} label="Expand to workspace" variant="ghost" size="sm" onClick={onExpand} />
)}
{onClose && (
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
)}
</div>
</div> </div>
{/* Conversation — the only scrolling region */} {isEmpty ? (
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto" aria-live="polite"> /* ── Landing: one compact centred block ───────────────────────── */
{isEmpty ? ( <div className="flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-4 py-4">
/* Landing: an invitation to ask, not a list of features. */ {/* Capped so the block stays a tight composition when expanded. */}
<div className="flex h-full flex-col items-center justify-center px-6 py-8 text-center animate-fade-in"> <div className="mx-auto w-full max-w-sm animate-fade-in space-y-3.5">
<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"> <div className="flex flex-col items-center text-center">
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-5 w-auto object-contain" /> <span className="relative mb-2.5 grid h-10 w-10 place-items-center rounded-xl bg-white shadow-brand ring-1 ring-krow-blue/10">
<span <img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-3.5 w-auto object-contain" />
aria-hidden="true" <span
className="absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-krow-blue text-white shadow-sm" aria-hidden="true"
> className="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-krow-blue text-white shadow-sm"
<Sparkles className="h-2.5 w-2.5" /> >
<Sparkles className="h-2 w-2" />
</span>
</span> </span>
</span>
<h3 className="font-heading text-title text-ink-1"> <p className="text-[10px] font-semibold uppercase tracking-wide text-krow-blue">
What would you like to analyze today? {intro.title}
</h3> </p>
<p className="mt-2 max-w-[19rem] text-body-sm leading-relaxed text-ink-3"> <h3 className="mt-1 text-balance font-heading text-body-lg font-semibold leading-snug text-ink-1">
{context.tagline} {intro.greeting}
</p> </h3>
<p className="mt-1 text-caption leading-relaxed text-ink-3">{intro.description}</p>
</div>
{composer}
<PromptChips prompts={prompts} onSelect={runPrompt} />
</div> </div>
) : ( </div>
<div className="space-y-4 px-4 py-4"> ) : (
/* ── Conversation ─────────────────────────────────────────────── */
<>
<div
ref={scrollRef}
className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4"
aria-live="polite"
aria-relevant="additions text"
>
{messages.map((message, i) => ( {messages.map((message, i) => (
<Message key={i} role={message.role} text={message.text} stopped={message.stopped} /> <React.Fragment key={i}>
{i > 0 && message.role === 'user' && <TurnDivider />}
<Message
role={message.role}
text={message.text}
blocks={message.blocks}
stopped={message.stopped}
/>
</React.Fragment>
))} ))}
{pending?.thinking && <ThinkingIndicator />} {pending?.thinking && <ThinkingIndicator />}
{pending && !pending.thinking && <Message role="assistant" text={pending.text} streaming />} {pending && !pending.thinking && (
<Message role="assistant" blocks={pending.blocks} streaming />
)}
{error && <Alert tone="destructive">{error}</Alert>} {error && <Alert tone="destructive">{error}</Alert>}
</div> </div>
)}
</div>
{/* Suggested prompts + composer — fixed */} <div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
<div className="shrink-0 space-y-2.5 border-t border-white/60 px-3 pb-3 pt-2.5"> {/* Capped in conversation: the vertical space belongs to the thread.
{!busy && ( Expanded fits more per line, so it can afford one more. */}
/* On the landing there is room to show every suggestion, so they wrap. {!busy && (
Once a conversation is underway the space belongs to the thread, so <PromptChips
they collapse to a single scrollable row. */ prompts={prompts}
<div onSelect={runPrompt}
className={cn( max={expanded ? 4 : 3}
'flex gap-1.5', align="start"
isEmpty ? 'flex-wrap justify-center' : 'overflow-x-auto no-scrollbar' />
)} )}
> {composer}
{context.chips.map((chip) => ( <p className="px-1 text-[10px] leading-tight text-ink-4">
<button Krow AI reads this page&apos;s data. Check anything you act on.
key={chip.label} </p>
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> </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> </Surface>
); );
} }

View File

@@ -0,0 +1,67 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* PromptChips — suggested prompts.
*
* Clicking a chip runs it immediately. A chip is already a complete question, so
* making the user press Enter afterwards adds a step that buys nothing.
*
* Chips always wrap onto as many lines as they need. The earlier horizontal-scroll
* variant is gone: a scroller can always leave a chip half-visible at the edge,
* and a half-visible button is a bug no amount of fade masking fixes. Wrapping
* makes clipping structurally impossible instead of merely unlikely.
*
* `max` caps how many are offered in a conversation, where vertical space belongs
* to the thread rather than to suggestions.
*
* Arrow keys move between chips, so the whole set is one tab stop.
*/
export function PromptChips({ prompts = [], onSelect, max, align = 'center', className }) {
const chipRefs = React.useRef([]);
const visible = max ? prompts.slice(0, max) : prompts;
const onKeyDown = (event, index) => {
const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
if (!delta) return;
const next = chipRefs.current[index + delta];
if (!next) return;
event.preventDefault();
next.focus();
};
if (!visible.length) return null;
return (
<div
role="group"
aria-label="Suggested prompts"
className={cn(
'flex flex-wrap gap-1.5',
align === 'center' ? 'justify-center' : 'justify-start',
className
)}
>
{visible.map((prompt, i) => (
<button
key={prompt.label}
ref={(el) => { chipRefs.current[i] = el; }}
type="button"
onClick={() => onSelect(prompt)}
onKeyDown={(e) => onKeyDown(e, i)}
title={prompt.prompt}
className={cn(
'rounded-full border border-border bg-surface px-2.5 py-1',
'text-caption font-medium text-ink-2 shadow-xs',
'transition-all duration-base ease-out',
'hover:-translate-y-px hover:border-krow-blue/40 hover:bg-krow-blue-tint hover:text-krow-blue hover:shadow-sm',
'active:translate-y-0 active:shadow-xs',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/60 focus-visible:ring-offset-1'
)}
>
{prompt.label}
</button>
))}
</div>
);
}

View File

@@ -20,8 +20,6 @@ export function PromptInput({
onStop, onStop,
busy = false, busy = false,
placeholders = ['Ask anything…'], placeholders = ['Ask anything…'],
/** Larger treatment for the landing state, where the input is the focus. */
size = 'default',
autoFocus = false, autoFocus = false,
className, className,
}) { }) {
@@ -38,16 +36,29 @@ export function PromptInput({
}, [value]); }, [value]);
/* Rotate the examples only while the field is idle — changing the placeholder /* Rotate the examples only while the field is idle — changing the placeholder
under someone who is mid-thought is distracting. */ under someone who is mid-thought is distracting. The crossfade is done with
a real element rather than the native placeholder, which cannot animate. */
const [fading, setFading] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
if (value || focused || placeholders.length < 2) return undefined; if (value || focused || placeholders.length < 2) return undefined;
const timer = setInterval( const timer = setInterval(() => {
() => setPlaceholderIndex((i) => (i + 1) % placeholders.length), setFading(true);
3800 // Swap the text at the midpoint of the fade so neither string is visible
); // while it changes.
setTimeout(() => {
setPlaceholderIndex((i) => (i + 1) % placeholders.length);
setFading(false);
}, 220);
}, 4000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [value, focused, placeholders.length]); }, [value, focused, placeholders.length]);
/* A shorter list on a context change must not leave the index out of range. */
React.useEffect(() => {
setPlaceholderIndex(0);
}, [placeholders]);
React.useEffect(() => { React.useEffect(() => {
if (autoFocus) textareaRef.current?.focus(); if (autoFocus) textareaRef.current?.focus();
}, [autoFocus]); }, [autoFocus]);
@@ -64,36 +75,52 @@ export function PromptInput({
} }
}; };
const large = size === 'large';
return ( return (
<form <form
onSubmit={(e) => { e.preventDefault(); submit(); }} onSubmit={(e) => { e.preventDefault(); submit(); }}
className={cn( className={cn(
'relative flex items-end gap-2 rounded-2xl border bg-surface transition-all duration-base ease-out', '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', 'px-3 py-2',
focused focused
? 'border-krow-blue/50 shadow-[0_8px_28px_-8px_rgba(10,57,223,0.28)] ring-2 ring-krow-blue/15' ? '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', : 'border-border shadow-md hover:border-krow-blue/30',
className className
)} )}
> >
<textarea <div className="relative min-w-0 flex-1">
ref={textareaRef} <textarea
rows={1} ref={textareaRef}
value={value} rows={1}
onChange={(e) => onChange(e.target.value)} value={value}
onKeyDown={onKeyDown} onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)} onKeyDown={onKeyDown}
onBlur={() => setFocused(false)} onFocus={() => setFocused(true)}
placeholder={placeholders[placeholderIndex]} onBlur={() => setFocused(false)}
aria-label="Ask Krow AI" disabled={busy}
className={cn( aria-label="Ask Krow AI"
'flex-1 min-w-0 resize-none bg-transparent leading-relaxed text-ink-1', aria-busy={busy || undefined}
'placeholder:text-ink-4 outline-none no-scrollbar', className={cn(
large ? 'text-body py-1' : 'text-body-sm py-0.5' 'block w-full resize-none bg-transparent leading-relaxed text-ink-1',
'no-scrollbar outline-none disabled:cursor-wait disabled:text-ink-3',
'py-0.5 text-body-sm'
)}
/>
{/* Animated placeholder. Hidden from assistive tech — the textarea's
aria-label is the accessible name. */}
{!value && (
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-0 truncate text-ink-4 transition-opacity duration-base',
'py-0.5 text-body-sm',
fading ? 'opacity-0' : 'opacity-100'
)}
>
{busy ? 'Thinking…' : placeholders[placeholderIndex]}
</span>
)} )}
/> </div>
{busy ? ( {busy ? (
<button <button
@@ -103,10 +130,10 @@ export function PromptInput({
className={cn( className={cn(
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2', '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', 'transition-colors duration-base hover:bg-surface-sunken focus-ring',
large ? 'w-9 h-9' : 'w-8 h-8' 'h-8 w-8'
)} )}
> >
<Square className={large ? 'w-3.5 h-3.5' : 'w-3 h-3'} aria-hidden="true" /> <Square className="h-3 w-3" aria-hidden="true" />
</button> </button>
) : ( ) : (
<button <button
@@ -117,10 +144,10 @@ export function PromptInput({
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white', 'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
'transition-all duration-base ease-out hover:bg-krow-blue-dark', 'transition-all duration-base ease-out hover:bg-krow-blue-dark',
'disabled:bg-surface-sunken disabled:text-ink-4 focus-ring', 'disabled:bg-surface-sunken disabled:text-ink-4 focus-ring',
large ? 'w-9 h-9' : 'w-8 h-8' 'h-8 w-8'
)} )}
> >
<ArrowUp className={large ? 'w-4 h-4' : 'w-3.5 h-3.5'} aria-hidden="true" /> <ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
</button> </button>
)} )}
</form> </form>

View File

@@ -0,0 +1,409 @@
import * as React from 'react';
import { ArrowDownRight, ArrowUpRight, Check, TriangleAlert } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
/**
* Renderers for response blocks.
*
* Every block is drawn with the same tokens as the dashboard — the ink scale,
* the shared radius, the score-band colours — so a generated report looks like
* it belongs to Krow rather than to a chat library.
*
* Blocks are memoized individually: a streaming response re-renders on every
* snapshot, and settled blocks must not re-render with it.
*/
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_)/g;
/** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is
* carried by blocks, not by markup inside a paragraph. */
function Inline({ value }) {
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={i} className="font-semibold text-ink-1">{part.slice(2, -2)}</strong>;
}
if (part.startsWith('_') && part.endsWith('_')) {
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
}
return part;
});
}
const TONE_TEXT = {
info: 'text-krow-blue',
success: 'text-success',
warning: 'text-warning',
risk: 'text-destructive',
neutral: 'text-ink-3',
};
const TONE_SURFACE = {
info: 'bg-krow-blue-tint border-krow-blue/15',
success: 'bg-success-muted border-success/15',
warning: 'bg-warning-muted border-warning/20',
risk: 'bg-destructive-muted border-destructive/15',
neutral: 'bg-surface-subtle border-border',
};
const TONE_BADGE = {
info: 'info', success: 'success', warning: 'warning', risk: 'destructive', neutral: 'neutral',
};
/* ── Individual blocks ──────────────────────────────────────────────────── */
const TextBlock = React.memo(({ block }) => (
<p className="text-body-sm leading-relaxed text-ink-2">
<Inline value={block.text} />
</p>
));
TextBlock.displayName = 'TextBlock';
const HeadingBlock = React.memo(({ block }) => (
<div className="space-y-0.5">
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
</div>
));
HeadingBlock.displayName = 'HeadingBlock';
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
const KpisBlock = React.memo(({ block }) => (
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
{block.items.map((item) => (
<div
key={item.label}
className="rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs"
>
<p className="truncate text-[10px] uppercase tracking-wide text-ink-4">{item.label}</p>
<div className="mt-0.5 flex items-baseline gap-1.5">
<span className={cn(
'font-heading text-title font-bold tabular-nums',
item.tone ? TONE_TEXT[item.tone] : 'text-ink-1'
)}>
{item.value}
</span>
{typeof item.delta === 'number' && item.delta !== 0 && (
<span className={cn(
'inline-flex items-center text-[10px] font-semibold',
item.delta > 0 ? 'text-success' : 'text-destructive'
)}>
{item.delta > 0
? <ArrowUpRight className="h-2.5 w-2.5" aria-hidden="true" />
: <ArrowDownRight className="h-2.5 w-2.5" aria-hidden="true" />}
{Math.abs(item.delta)}%
</span>
)}
</div>
{item.sub && <p className="mt-0.5 truncate text-[10px] text-ink-4">{item.sub}</p>}
</div>
))}
</div>
));
KpisBlock.displayName = 'KpisBlock';
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
const StatusBlock = React.memo(({ block }) => (
<ul className="space-y-1.5">
{block.items.map((item) => (
<li key={item.label} className="flex items-start gap-2">
<span
className={cn(
'mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full',
item.ok ? 'bg-success-muted text-success' : 'bg-warning-muted text-warning'
)}
>
{item.ok
? <Check className="h-2.5 w-2.5" aria-hidden="true" />
: <TriangleAlert className="h-2.5 w-2.5" aria-hidden="true" />}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-body-sm font-medium text-ink-1">{item.label}</span>
{item.value && (
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-2">
{item.value}
</span>
)}
</div>
{item.note && <p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.note}</p>}
</div>
<span className="sr-only">{item.ok ? 'Passing' : 'Needs attention'}</span>
</li>
))}
</ul>
));
StatusBlock.displayName = 'StatusBlock';
const MetersBlock = React.memo(({ block }) => (
<div className="space-y-2.5">
{block.items.map((item) => (
<div key={item.label} className="space-y-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-caption text-ink-2">{item.label}</span>
<span className="flex shrink-0 items-baseline gap-1.5">
{item.weight != null && <span className="text-[10px] text-ink-4">{item.weight}%</span>}
<span className="text-caption font-semibold tabular-nums text-ink-1">{item.value}</span>
</span>
</div>
<ProgressBar value={item.value} max={item.max ?? 100} tone={item.tone ?? 'score'} size="sm" />
</div>
))}
</div>
));
MetersBlock.displayName = 'MetersBlock';
/**
* A table. Scrolls horizontally inside its own container so a wide comparison
* never widens the panel.
*/
const TableBlock = React.memo(({ block }) => {
const cell = (value) => {
if (value == null || value === '') return <span className="text-ink-4">—</span>;
if (typeof value === 'object') {
if (value.badge) {
return <Badge variant={TONE_BADGE[value.tone] || 'neutral'} size="sm">{value.badge}</Badge>;
}
return (
<span className={cn('tabular-nums', value.tone ? TONE_TEXT[value.tone] : undefined)}>
{value.value}
</span>
);
}
return <span className="tabular-nums">{value}</span>;
};
return (
<div className="overflow-x-auto rounded-xl border border-border bg-surface">
<table className="w-full border-collapse text-caption">
{block.caption && <caption className="sr-only">{block.caption}</caption>}
<thead>
<tr className="border-b border-border bg-surface-subtle">
{block.columns.map((column) => (
<th
key={column.key}
scope="col"
className={cn(
'whitespace-nowrap px-2.5 py-2 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
column.align === 'right' ? 'text-right' : 'text-left'
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, i) => (
<tr key={i} className="border-b border-border last:border-0">
{block.columns.map((column, ci) => (
<td
key={column.key}
className={cn(
'px-2.5 py-2 align-middle',
column.align === 'right' ? 'text-right' : 'text-left',
ci === 0 ? 'font-medium text-ink-1' : 'text-ink-2'
)}
>
{cell(row[column.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
});
TableBlock.displayName = 'TableBlock';
/** Stage progression, widths relative to the largest stage. */
const FunnelBlock = React.memo(({ block }) => {
const max = Math.max(...block.steps.map((s) => s.count), 1);
return (
<div className="space-y-2">
{block.steps.map((step) => (
<div key={step.label}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-caption text-ink-2">{step.label}</span>
<span className="flex shrink-0 items-baseline gap-1.5">
{step.rate != null && <span className="text-[10px] text-ink-4">{step.rate}%</span>}
<span className="text-caption font-semibold tabular-nums text-ink-1">{step.count}</span>
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-surface-sunken">
<div
className="h-full rounded-full bg-krow-blue transition-all duration-slow ease-out"
style={{ width: `${Math.max((step.count / max) * 100, step.count > 0 ? 4 : 0)}%` }}
/>
</div>
</div>
))}
</div>
);
});
FunnelBlock.displayName = 'FunnelBlock';
const ListBlock = React.memo(({ block }) => {
const Tag = block.ordered ? 'ol' : 'ul';
return (
<Tag className="space-y-1.5">
{block.items.map((item, i) => (
<li key={i} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
{block.ordered ? (
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
{i + 1}
</span>
) : (
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-krow-blue" aria-hidden="true" />
)}
<span className="min-w-0"><Inline value={item} /></span>
</li>
))}
</Tag>
);
});
ListBlock.displayName = 'ListBlock';
/** Findings, each in a tinted card so severity is scannable. */
const InsightsBlock = React.memo(({ block }) => (
<div className="space-y-2">
{block.items.map((item, i) => (
<div
key={i}
className={cn('rounded-xl border px-3 py-2.5', TONE_SURFACE[item.tone] || TONE_SURFACE.neutral)}
>
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
{item.body && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
)}
</div>
))}
</div>
));
InsightsBlock.displayName = 'InsightsBlock';
/** Recommended steps — numbered, because order is the recommendation. */
const ActionsBlock = React.memo(({ block }) => (
<ol className="space-y-2">
{block.items.map((item, i) => (
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-[10px] font-bold text-white">
{i + 1}
</span>
<div className="min-w-0">
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
{item.body && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
)}
</div>
</li>
))}
</ol>
));
ActionsBlock.displayName = 'ActionsBlock';
const BadgesBlock = React.memo(({ block }) => (
<div className="flex flex-wrap gap-1.5">
{block.items.map((item) => (
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
{item.label}
</Badge>
))}
</div>
));
BadgesBlock.displayName = 'BadgesBlock';
const TimelineBlock = React.memo(({ block }) => (
<ol className="relative space-y-3">
{block.items.map((item, i) => (
<li key={i} className="flex gap-3">
<div className="flex flex-col items-center">
<span
className={cn(
'mt-1 h-2 w-2 shrink-0 rounded-full',
item.tone === 'success' ? 'bg-success'
: item.tone === 'warning' ? 'bg-warning'
: item.current ? 'bg-krow-blue' : 'bg-ink-4'
)}
aria-hidden="true"
/>
{i < block.items.length - 1 && <span className="mt-1 w-px flex-1 bg-border" aria-hidden="true" />}
</div>
<div className="min-w-0 flex-1 pb-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-body-sm font-medium text-ink-1">{item.title}</span>
{item.timestamp && (
<span className="shrink-0 text-[10px] text-ink-4">{item.timestamp}</span>
)}
</div>
{item.description && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.description}</p>
)}
</div>
</li>
))}
</ol>
));
TimelineBlock.displayName = 'TimelineBlock';
const NoteBlock = React.memo(({ block }) => (
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
<Inline value={block.text} />
</p>
));
NoteBlock.displayName = 'NoteBlock';
const RENDERERS = {
text: TextBlock,
heading: HeadingBlock,
kpis: KpisBlock,
status: StatusBlock,
meters: MetersBlock,
table: TableBlock,
funnel: FunnelBlock,
list: ListBlock,
insights: InsightsBlock,
actions: ActionsBlock,
badges: BadgesBlock,
timeline: TimelineBlock,
note: NoteBlock,
};
/**
* Renders a response document.
*
* Spacing is decided here rather than by the blocks, so any combination of
* blocks has consistent rhythm — a heading hugs what follows it, everything
* else breathes.
*/
export const ResponseDocument = React.memo(({ blocks = [], streaming = false }) => (
<div className="space-y-3">
{blocks.map((block, i) => {
const Renderer = RENDERERS[block.type];
if (!Renderer) return null;
const hugsNext = block.type === 'heading' && i < blocks.length - 1;
const isLast = i === blocks.length - 1;
return (
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
<Renderer block={block} />
{/* The cursor trails the final block only while text is still arriving. */}
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
<span
className="ml-0.5 inline-block h-3.5 w-1.5 -mb-0.5 animate-caret bg-krow-blue align-middle"
aria-hidden="true"
/>
)}
</div>
);
})}
</div>
));
ResponseDocument.displayName = 'ResponseDocument';
export { toneForScore };

View File

@@ -0,0 +1,100 @@
/**
* Response blocks.
*
* Answers are documents, not strings. A capability decides how its information
* is best communicated — a comparison is a table, a health check is a status
* list, a funnel is a funnel — and the renderer draws it with design-system
* components. That is what turns a reply into a small interactive report rather
* than a wall of chat text.
*
* Blocks are plain data, so they serialize: a future remote service can return
* exactly this shape and nothing in the UI changes.
*/
/** Assembles a document, flattening nested arrays and dropping empty slots. */
export const doc = (...blocks) => ({
blocks: blocks.flat(Infinity).filter(Boolean),
});
/** A short paragraph. Supports `**bold**` and `_italic_` inline. */
export const text = (value) => value && { type: 'text', text: value };
/** A section label inside a response. */
export const heading = (value, sub) => value && { type: 'heading', text: value, sub };
/** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */
export const kpis = (items) => items?.length && { type: 'kpis', items };
/** Pass/warn checks. `items: [{ label, ok, value?, note? }]` */
export const status = (items) => items?.length && { type: 'status', items };
/** Labelled bars. `items: [{ label, value, weight?, max?, tone? }]` */
export const meters = (items) => items?.length && { type: 'meters', items };
/**
* A comparison or record table.
* `columns: [{ key, label, align?, width? }]`, `rows: [{ ...cells }]`
* A cell may be a string, a number, or `{ value, tone, badge }`.
*/
export const table = (columns, rows, options = {}) =>
rows?.length && { type: 'table', columns, rows, ...options };
/** Stage progression. `steps: [{ label, count, rate?, lost? }]` */
export const funnel = (steps) => steps?.length && { type: 'funnel', steps };
/** Bulleted or numbered points. */
export const list = (items, options = {}) =>
items?.filter(Boolean).length && { type: 'list', items: items.filter(Boolean), ...options };
/** Findings. `items: [{ tone, title, body }]` — tone: info|success|warning|risk */
export const insights = (items) =>
items?.filter(Boolean).length && { type: 'insights', items: items.filter(Boolean) };
/** Recommended next steps. `items: [{ title, body }]` */
export const actions = (items) =>
items?.filter(Boolean).length && { type: 'actions', items: items.filter(Boolean) };
/** Inline tags. `items: [{ label, tone }]` or plain strings. */
export const badges = (items) =>
items?.filter(Boolean).length && {
type: 'badges',
items: items.filter(Boolean).map((i) => (typeof i === 'string' ? { label: i } : i)),
};
/** Chronology. `items: [{ title, description?, timestamp?, tone?, current? }]` */
export const timeline = (items) => items?.length && { type: 'timeline', items };
/** A caveat. Always the last word on a claim, never the headline. */
export const note = (value) => value && { type: 'note', text: value };
/** A single sentence answer — used by short free-text replies. */
export const answer = (value) => doc(text(value));
/* ── Streaming ──────────────────────────────────────────────────────────── */
/**
* Splits a document into the progressive snapshots the provider streams.
*
* Text and heading blocks reveal word by word; structured blocks appear whole,
* because a half-drawn table reads as broken rather than as arriving. The result
* is a response that composes itself in a readable order.
*/
export function toSnapshots({ blocks }) {
const snapshots = [];
const settled = [];
for (const block of blocks) {
if (block.type === 'text' || block.type === 'heading') {
const tokens = String(block.text).split(/(\s+)/);
// Three words per snapshot: fast enough to feel live, coarse enough to
// avoid a reflow per character.
for (let i = 6; i < tokens.length; i += 6) {
snapshots.push([...settled, { ...block, text: tokens.slice(0, i).join('') }]);
}
}
settled.push(block);
snapshots.push([...settled]);
}
return snapshots.length ? snapshots : [[]];
}

View File

@@ -1,11 +1,12 @@
import { AlertTriangle, Eye, Gauge, Lightbulb, ScrollText, TrendingUp } from 'lucide-react'; import { AlertTriangle, Eye, Gauge, Lightbulb, ScrollText, TrendingUp } from 'lucide-react';
import { bullets, compose, heading, numbered, plural, verb } from '../insights'; import { actions, answer, doc, heading, insights, kpis, note, status, table, text } from '../blocks';
import { plural, verb } from '../insights';
/** /**
* Admin capabilities — Control Center, Candidates Analysis, Activity. * Admin capabilities — Control Center, Candidates Analysis, Activity.
* *
* Admin answers are platform-wide rather than pipeline-specific: data integrity, * Platform-wide rather than pipeline-specific: data integrity, talent supply,
* talent supply, audit reconciliation. * audit reconciliation.
*/ */
/* ── Control Center ─────────────────────────────────────────────────────── */ /* ── Control Center ─────────────────────────────────────────────────────── */
@@ -15,148 +16,184 @@ const platformHealth = (f) => {
{ {
label: 'Data integrity', label: 'Data integrity',
ok: f.applications.every((a) => a.job_posting_id), ok: f.applications.every((a) => a.job_posting_id),
good: 'Every application is linked to a live posting.', good: 'Every application links to a live posting.',
bad: 'Some applications reference a posting that no longer exists.', bad: 'Some applications reference a posting that no longer exists.',
}, },
{ {
label: 'Screening coverage', label: 'Screening coverage',
value: `${f.standardizedPct}%`,
ok: f.standardizedPct >= 80, ok: f.standardizedPct >= 80,
good: `${f.standardizedPct}% of applicants carry a score.`, good: 'Applicants are scored consistently.',
bad: `Only ${f.standardizedPct}% of applicants are scored — ${plural(f.unscreened.length, 'record')} unstandardized.`, bad: `${plural(f.unscreened.length, 'record')} unstandardized.`,
}, },
{ {
label: 'Interview integrity', label: 'Interview integrity',
ok: f.flagged.length === 0, ok: f.flagged.length === 0,
good: 'No interviews flagged.', good: 'No interviews flagged.',
bad: `${plural(f.flagged.length, 'interview')} flagged below full integrity.`, bad: `${plural(f.flagged.length, 'interview')} below full integrity.`,
}, },
{ {
label: 'Profile completeness', label: 'Profile completeness',
value: `${f.profiles.length - f.unscoredProfiles.length}/${f.profiles.length}`,
ok: f.unscoredProfiles.length <= f.profiles.length / 2, 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.`, good: 'Most talent profiles carry 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.`, bad: `${plural(f.unscoredProfiles.length, 'profile')} without a score cannot be matched or ranked.`,
}, },
{ {
label: 'Throughput', label: 'Throughput',
value: `${f.hireRate}%`,
ok: f.hired.length > 0, ok: f.hired.length > 0,
good: `${plural(f.hired.length, 'hire')} closed at a ${f.hireRate}% hire rate.`, good: `${plural(f.hired.length, 'hire')} closed.`,
bad: 'No hires closed yet, so there is no conversion baseline.', bad: 'No hires closed yet, so there is no conversion baseline.',
}, },
]; ].map((c) => ({ label: c.label, value: c.value, ok: c.ok, note: c.ok ? c.good : c.bad }));
const passing = checks.filter((c) => c.ok).length; const passing = checks.filter((c) => c.ok).length;
return compose( return doc(
heading(`Platform health: ${passing}/${checks.length} checks passing`), heading(`Platform health: ${passing}/${checks.length} passing`),
'', status(checks),
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.ok ? c.good : c.bad}`).join('\n'),
'',
heading('Scale'), heading('Scale'),
bullets([ kpis([
`${plural(f.postings.length, 'position')}, ${f.openPositions.length} open`, { label: 'Positions', value: f.postings.length, sub: `${f.openPositions.length} open` },
`${plural(f.total, 'application')}`, { label: 'Applications', value: f.total },
`${plural(f.interviews.length, 'AI interview')}`, { label: 'Interviews', value: f.interviews.length, sub: `${f.completedInterviews.length} scored` },
`${plural(f.profiles.length, 'talent profile')}, ${f.elite.length} Elite`, { label: 'Talent pool', value: f.profiles.length, sub: `${f.elite.length} Elite` },
`${plural(f.activity.length, 'logged event')}`,
]), ]),
'', text(
passing === checks.length passing === checks.length
? 'Nothing needs intervention at the platform level.' ? 'Nothing needs intervention at the platform level.'
: 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.' : 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.'
)
); );
}; };
const recommendations = (f) => { const recommendations = (f) => {
const actions = []; const items = [
f.unscoredProfiles.length && {
title: `Prompt ${plural(f.unscoredProfiles.length, 'worker')} to finish onboarding`,
body: 'Without a career score they are invisible to matching, so employers cannot find them even when they fit.',
},
f.unscreened.length && {
title: `${plural(f.unscreened.length, 'application')} unscored`,
body: 'Each one is a decision made without a standard.',
},
f.starvedPositions.length && {
title: `${plural(f.starvedPositions.length, 'posting')} with zero applicants`,
body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Check distribution and pay bands.`,
},
f.unratedStaff.length && {
title: `${plural(f.unratedStaff.length, 'hire')} unrated`,
body: 'Employer ratings are the only verified performance signal feeding scores; without them the scores stay theoretical.',
},
f.flagged.length && {
title: `Review ${plural(f.flagged.length, 'flagged interview')}`,
body: 'Worth clearing before those candidates progress.',
},
].filter(Boolean);
if (f.unscoredProfiles.length) { if (!items.length) return answer('No platform actions outstanding. Data is complete and nothing is flagged.');
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 doc(heading('Recommended actions', 'Ordered by impact on matching quality'), actions(items));
return compose(heading('Recommended actions'), '', numbered(actions));
}; };
/* ── Candidates Analysis ────────────────────────────────────────────────── */ /* ── Candidates Analysis ────────────────────────────────────────────────── */
const recruitmentInsights = (f) => { const recruitmentInsights = (f) => {
const bands = { const band = (min, max) => f.profiles.filter((p) => {
Elite: f.profiles.filter((p) => (p.krow_score || 0) >= 90).length, const s = p.krow_score || 0;
Excellent: f.profiles.filter((p) => (p.krow_score || 0) >= 75 && (p.krow_score || 0) < 90).length, return s >= min && (max == null || s < max);
Solid: f.profiles.filter((p) => (p.krow_score || 0) >= 60 && (p.krow_score || 0) < 75).length, }).length;
Building: f.profiles.filter((p) => (p.krow_score || 0) > 0 && (p.krow_score || 0) < 60).length,
Unscored: f.unscoredProfiles.length, const bands = [
}; { label: 'Elite', count: band(90), tone: 'info' },
{ label: 'Excellent', count: band(75, 90), tone: 'success' },
{ label: 'Solid', count: band(60, 75) },
{ label: 'Building', count: band(1, 60), tone: 'warning' },
{ label: 'Unscored', count: f.unscoredProfiles.length, tone: 'risk' },
];
const verified = f.profiles.filter((p) => (p.capabilities || []).length).length; const verified = f.profiles.filter((p) => (p.capabilities || []).length).length;
const topHeavy = bands.Elite + bands.Excellent; const topHeavy = band(90) + band(75, 90);
return compose( return doc(
heading('Talent supply'), heading('Talent supply', `${plural(f.profiles.length, 'worker')} in the pool`),
'', table(
bullets(Object.entries(bands).map(([band, count]) => `${band} — ${count}`)), [{ key: 'label', label: 'Band' }, { key: 'count', label: 'Workers', align: 'right' }],
'', bands.map((b) => ({ label: b.label, count: { value: b.count, tone: b.tone } }))
),
heading('Pipeline'), heading('Pipeline'),
bullets([ kpis([
`${plural(f.total, 'applicant')} averaging ${f.avgScore}`, { label: 'Applicants', value: f.total, sub: `avg ${f.avgScore}` },
`${plural(f.ranked.filter((a) => a.ai_score >= 80).length, 'candidate')} at 80+`, { label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'success' },
`${plural(verified, 'worker')} with at least one verified capability`, { label: 'Verified skills', value: verified, sub: 'with evidence' },
`${plural(f.eventCounts.apply_job || 0, 'application event')} logged`, { label: 'Applications', value: f.eventCounts.apply_job || 0, sub: 'logged' },
]),
insights([
f.unscoredProfiles.length > topHeavy
? {
tone: 'warning',
title: 'The pool is bottom-heavy',
body: `${f.unscoredProfiles.length} unscored against ${topHeavy} at Excellent or above. Supply is not the constraint — verification is.`,
}
: {
tone: 'success',
title: 'Verification is keeping pace',
body: 'The scored portion of the pool outweighs the unscored.',
},
]), ]),
'',
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'), heading('By role category'),
bullets( table(
[
{ key: 'title', label: 'Role' },
{ key: 'applied', label: 'Applied', align: 'right' },
{ key: 'qualified', label: '70+', align: 'right' },
],
f.byRole.slice().sort((a, b) => b.applied - a.applied) f.byRole.slice().sort((a, b) => b.applied - a.applied)
.map((r) => `${r.title} — ${plural(r.applied, 'applicant')}, ${r.qualified} at 70+`) .map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified }))
) )
); );
}; };
const candidateRiskAnalysis = (f) => { 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); 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.`); const risks = [
} f.missingCredentials.length && {
if (f.singleCandidateRoles.length) { tone: 'risk',
risks.push(`**Concentration risk** — ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`); title: `${plural(f.missingCredentials.length, 'candidate')} with no credentials`,
body: 'Compliance risk if placed on a role that requires certification.',
},
f.narrowAvailability.length && {
tone: 'warning',
title: `${plural(f.narrowAvailability.length, 'candidate')} with narrow availability`,
body: 'One slot or none — most likely to fall through on the day.',
},
f.flagged.length && {
tone: 'risk',
title: `${plural(f.flagged.length, 'interview')} flagged for integrity`,
body: 'Usually suspiciously fast responses. Worth a human re-interview.',
},
noHistory.length && {
tone: 'warning',
title: `${plural(noHistory.length, 'candidate')} with no recorded experience`,
body: 'Not disqualifying, but unverifiable on paper.',
},
f.singleCandidateRoles.length && {
tone: 'warning',
title: 'Concentration risk',
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`,
},
].filter(Boolean);
if (!risks.length) {
return answer('No material risk flags: credentials, availability and interview integrity all check out across the scored pool.');
} }
if (!risks.length) return 'No material risk flags: credentials, availability and interview integrity all check out across the scored pool.'; return doc(
return compose(
heading(`${plural(risks.length, 'risk flag')}`), heading(`${plural(risks.length, 'risk flag')}`),
'', insights(risks),
numbered(risks), note('Flags to check, not judgements. Each is a question for a human, not a rejection.')
'',
'_Flags to check, not judgements. Each is a question for a human, not a rejection._'
); );
}; };
@@ -166,36 +203,45 @@ const auditSummary = (f) => {
const count = (type) => f.activity.filter((e) => e.event_type === type).length; const count = (type) => f.activity.filter((e) => e.event_type === type).length;
const hires = count('hire_candidate'); const hires = count('hire_candidate');
const screens = count('screen_candidate'); const screens = count('screen_candidate');
const creates = count('create_position');
return compose( return doc(
heading('Audit summary'), heading('Audit summary'),
'', kpis([
bullets([ { label: 'Total events', value: f.activity.length },
`${plural(f.activity.length, 'event')} total`, { label: 'Last 7 days', value: f.activity7d.length },
`${plural(f.activity7d.length, 'event')} in the last 7 days`, { label: 'Last 24 hours', value: f.activity24h.length, tone: f.activity24h.length ? undefined : 'warning' },
`${plural(f.activity24h.length, 'event')} in the last 24 hours`, { label: 'Distinct users', value: new Set(f.activity.map((e) => e.user_email)).size },
`${plural(new Set(f.activity.map((e) => e.user_email)).size, 'distinct user')}`,
]), ]),
'',
heading('Privileged actions'), heading('Privileged actions'),
bullets([ table(
`${plural(creates, 'position creation')}`, [{ key: 'action', label: 'Action' }, { key: 'count', label: 'Events', align: 'right' }],
`${plural(screens, 'screening action')}`, [
`${plural(hires, 'hire')}`, { action: 'Position created', count: count('create_position') },
]), { action: 'Candidate screened', count: screens },
'', { action: 'Candidate hired', count: hires },
{ action: 'Interview started', count: count('start_interview') },
]
),
heading('Reconciliation'), heading('Reconciliation'),
bullets([ status([
hires === f.staff.length {
? `All ${f.staff.length} people on staff have a matching hire event.` label: 'Hires match staff records',
: `${f.staff.length} on staff but ${hires} hire ${verb(hires, 'event', 'events')} — ${Math.abs(f.staff.length - hires)} unexplained.`, ok: hires === f.staff.length,
screens < f.scored.length value: `${hires}/${f.staff.length}`,
? `${f.scored.length} candidates carry scores but only ${plural(screens, 'screening event')} logged. Batch screening records one event per run, not per candidate.` note: hires === f.staff.length
: 'Screening events reconcile with scored candidates.', ? 'Every person on staff has a matching hire event.'
: `${Math.abs(f.staff.length - hires)} unexplained.`,
},
{
label: 'Screening events match scores',
ok: screens >= f.scored.length,
value: `${screens}/${f.scored.length}`,
note: screens < f.scored.length
? 'Batch screening records one event per run, not per candidate.'
: 'Screening events reconcile with scored candidates.',
},
]), ]),
'', note('An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review.')
'_An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review._'
); );
}; };
@@ -208,32 +254,43 @@ const userActivityInsights = (f) => {
}, {}); }, {});
const ranked = Object.entries(perUser).sort((a, b) => b[1].count - a[1].count); const ranked = Object.entries(perUser).sort((a, b) => b[1].count - a[1].count);
if (!ranked.length) return 'No activity to profile yet.'; if (!ranked.length) return answer('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]; const [topEmail, top] = ranked[0];
const share = Math.round((top.count / f.activity.length) * 100);
return compose( return doc(
heading('Most active accounts'), heading('Most active accounts'),
'', table(
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')}` { key: 'name', label: 'Account' },
)), { key: 'role', label: 'Role' },
'', { key: 'events', label: 'Events', align: 'right' },
heading('By role'), ],
bullets(Object.entries(byRole).sort((a, b) => b[1] - a[1]) ranked.slice(0, 6).map(([email, d]) => ({
.map(([role, c]) => `${role.charAt(0).toUpperCase() + role.slice(1)} — ${plural(c, 'event')}`)), name: d.name || email,
'', role: { badge: d.role || 'unknown', tone: d.role === 'employer' ? 'info' : 'neutral' },
`${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.`, events: { value: d.count, tone: d.count === top.count ? 'info' : undefined },
'', }))
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.' insights([
: 'Activity is flowing normally.' share >= 50
? {
tone: 'warning',
title: `One account is ${share}% of all activity`,
body: `${top.name || topEmail} accounts for ${top.count} of ${f.activity.length} events. On a live platform, that concentration is worth confirming is a person and not an integration.`,
}
: {
tone: 'success',
title: 'Activity is spread across accounts',
body: `No single account dominates the log — the busiest is ${share}%.`,
},
f.activity24h.length === 0 && {
tone: 'warning',
title: 'Nothing in the last 24 hours',
body: 'Expected for a demo dataset; on a live platform it would be worth checking ingestion.',
},
])
); );
}; };
@@ -244,6 +301,7 @@ const has = (q, ...words) => words.some((w) => q.includes(w));
export const respondControlCenter = (question, f) => { export const respondControlCenter = (question, f) => {
const q = question.toLowerCase(); const q = question.toLowerCase();
if (has(q, 'recommend', 'should', 'action', 'fix', 'improve')) return recommendations(f); if (has(q, 'recommend', 'should', 'action', 'fix', 'improve')) return recommendations(f);
if (has(q, 'unusual', 'anomal', 'suspicious')) return userActivityInsights(f);
return platformHealth(f); return platformHealth(f);
}; };
@@ -255,7 +313,7 @@ export const respondAdminCandidates = (question, f) => {
export const respondActivity = (question, f) => { export const respondActivity = (question, f) => {
const q = question.toLowerCase(); const q = question.toLowerCase();
if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account')) return userActivityInsights(f); if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account', 'unusual')) return userActivityInsights(f);
return auditSummary(f); return auditSummary(f);
}; };

View File

@@ -1,36 +1,47 @@
import { import {
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare, AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
HeartPulse, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target, Layers, HeartPulse, Layers, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target,
} from 'lucide-react'; } from 'lucide-react';
import { bullets, compose, heading, numbered, plural, verb } from '../insights'; import {
actions, answer, badges, doc, funnel, heading, insights, kpis, list, meters, note,
status, table, text,
} from '../blocks';
import { plural, verb } from '../insights';
/** /**
* Employer capabilities — Overview, Candidates, Analytics. * Employer capabilities — Overview, Candidates, Analytics.
* *
* Each capability is a pure function of the fact sheet. Keeping them pure is * Each returns a block document rather than prose. The shape is chosen by what
* what allows the same code to run against local data today and against a * the information is: a comparison is a table, a health check is a status list,
* remote AI service later without the UI changing. * a pipeline is a funnel. Prose is reserved for the judgement a table cannot
* carry.
*/ */
/* ── Overview ───────────────────────────────────────────────────────────── */ /* ── Overview ───────────────────────────────────────────────────────────── */
const hiringSummary = (f) => { const hiringSummary = (f) => {
if (!f.total) return 'Nothing in the pipeline yet. Publish a position and I will start tracking it here.'; if (!f.total) return answer('Nothing in the pipeline yet. Publish a position and I will start tracking it.');
return compose( return doc(
heading('Where hiring stands'), kpis([
bullets([ { label: 'Applicants', value: f.total, sub: `${f.openPositions.length} open roles` },
`${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}`, { label: 'Screened', value: f.screened.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
`${f.screened.length} screened · ${f.unscreened.length} awaiting a score`, { label: 'Interviewing', value: f.interviewing.length },
`${plural(f.interviewing.length, 'candidate')} mid-interview`, { label: 'Hired', value: f.hired.length, sub: f.hiredAvgScore ? `avg ${f.hiredAvgScore}/100` : undefined, tone: 'info' },
`${plural(f.hired.length, 'hire')} averaging ${f.hiredAvgScore}/100`,
]), ]),
'', heading('Pipeline'),
f.stalled.length funnel(f.funnel.map((stage, i) => ({
? `Most urgent: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still sitting at AI Screened.` label: stage.label,
: f.unscreened.length count: stage.count,
? `Most urgent: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.` rate: i === 0 ? undefined : f.transitions[i - 1]?.rate,
: 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.' }))),
text(
f.stalled.length
? `The first thing I would fix: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still at AI Screened.`
: f.unscreened.length
? `The first thing I would fix: ${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.'
)
); );
}; };
@@ -41,7 +52,7 @@ const hiringHealth = (f) => {
value: `${f.standardizedPct}%`, value: `${f.standardizedPct}%`,
ok: f.standardizedPct >= 80, ok: f.standardizedPct >= 80,
note: f.standardizedPct >= 80 note: f.standardizedPct >= 80
? 'Applicants are being scored consistently.' ? 'Applicants are scored consistently.'
: `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`, : `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`,
}, },
{ {
@@ -72,148 +83,201 @@ const hiringHealth = (f) => {
const passing = checks.filter((c) => c.ok).length; const passing = checks.filter((c) => c.ok).length;
return compose( return doc(
heading(`${passing} of ${checks.length} signals healthy`), heading(`${passing} of ${checks.length} signals healthy`),
'', status(checks),
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.value}\n ${c.note}`).join('\n\n') note('Thresholds are event-staffing norms, not universal benchmarks.')
); );
}; };
const buildActions = (f) => [
f.stalled.length && {
title: 'Move your 80+ candidates',
body: `${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.`,
},
f.unscreened.length && {
title: `Screen ${plural(f.unscreened.length, 'applicant')}`,
body: 'One pass clears the backlog and costs nothing.',
},
f.starvedPositions.length && {
title: 'Fix the postings nobody applies to',
body: `${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`,
},
f.unratedStaff.length && {
title: `Rate ${plural(f.unratedStaff.length, 'hire')}`,
body: 'Ratings feed KROW scores and sharpen future matching.',
},
f.elite.length && {
title: 'Reach out to the pool directly',
body: `${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.`,
},
].filter(Boolean);
const pendingActions = (f) => { const pendingActions = (f) => {
const actions = []; const items = buildActions(f);
if (!items.length) {
if (f.stalled.length) { return answer('Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.');
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) { return doc(heading('Pending actions', 'Ordered by what costs you most'), actions(items.slice(0, 4)));
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 hiringRisks = (f) => {
const risks = []; const risks = [
f.singleCandidateRoles.length && {
tone: 'risk',
title: 'Single-candidate roles',
body: `${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.`,
},
f.stalled.length && {
tone: 'warning',
title: 'Losing strong candidates to delay',
body: `${plural(f.stalled.length, 'candidate')} at 80+ screened but not moved. This is where good hires quietly disappear.`,
},
f.missingCredentials.length && {
tone: 'risk',
title: 'Compliance exposure',
body: `${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.`,
},
f.narrowAvailability.length && {
tone: 'warning',
title: 'Availability risk',
body: `${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none — most likely to fall through on the day.`,
},
f.starvedPositions.length && {
tone: 'warning',
title: 'Unfillable as posted',
body: `${plural(f.starvedPositions.length, 'open role')} with zero applicants after going live.`,
},
f.flagged.length && {
tone: 'risk',
title: 'Interview integrity',
body: `${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`,
},
].filter(Boolean);
if (f.singleCandidateRoles.length) { if (!risks.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.`); return answer('No material risks: coverage is good, no role depends on a single candidate, and credentials check out.');
}
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 doc(
return compose(
heading(`${plural(risks.length, 'risk')} to watch`), heading(`${plural(risks.length, 'risk')} to watch`),
'', insights(risks),
numbered(risks), note('Flags to check, not conclusions. Each is a question for a human.')
'',
'_Flags to check, not conclusions. Each is a question for a human._'
); );
}; };
/* ── Candidates ─────────────────────────────────────────────────────────── */ /* ── Candidates ─────────────────────────────────────────────────────────── */
const resumeAnalysis = (f) => { const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability'];
const subject = f.top[0];
if (!subject) return 'No scored candidates yet. Screen the pipeline and I will analyse the strongest.';
return compose( const resumeAnalysis = (f) => {
heading(`${subject.applicant_name} — ${subject.ai_score}/100`), const c = f.top[0];
`${subject.job_title} · ${plural(subject.years_experience, 'year')} experience · ${subject.english_level} English`, if (!c) return answer('No scored candidates yet. Screen the pipeline and I will analyse the strongest.');
'',
subject.ai_summary || '', return doc(
'', heading(c.applicant_name, `${c.job_title} · ${plural(c.years_experience, 'year')} · ${c.english_level} English`),
subject.ai_strengths?.length ? compose(heading('What holds up'), bullets(subject.ai_strengths)) : '', kpis([
'', { label: 'AI score', value: `${c.ai_score}`, tone: c.ai_score >= 80 ? 'success' : c.ai_score >= 60 ? 'info' : 'warning' },
subject.ai_gaps?.length ? compose(heading('What to probe'), bullets(subject.ai_gaps)) : '', { label: 'Verdict', value: c.ai_match_label || '—' },
'', ]),
subject.certifications?.length c.ai_summary && text(c.ai_summary),
? `**Credentials:** ${subject.certifications.join(', ')} — verify before the first shift. A lapsed card is the most common surprise.` c.certifications?.length
: '**No credentials on file.** Request these before scheduling.' ? [heading('Credentials'), badges(c.certifications.map((label) => ({ label, tone: 'success' })))]
: [heading('Credentials'), badges([{ label: 'None on file', tone: 'risk' }])],
heading('Score breakdown'),
meters(DIMENSIONS.map((d) => ({ label: d[0].toUpperCase() + d.slice(1), value: c.score_breakdown?.[d] ?? 0 }))),
c.ai_strengths?.length && [heading('Strengths'), list(c.ai_strengths)],
c.ai_gaps?.length && [heading('Probe these'), list(c.ai_gaps)],
note(
c.certifications?.length
? 'Verify credentials before the first shift — a lapsed card is the most common surprise.'
: 'Request credentials before scheduling.'
)
); );
}; };
const candidateComparison = (f) => { const candidateComparison = (f) => {
const pair = f.ranked.slice(0, 2); const [a, b] = f.ranked;
if (pair.length < 2) return 'I need at least two scored candidates to compare.'; if (!a || !b) return answer('I need at least two scored candidates to compare.');
const [a, b] = pair;
const gap = a.ai_score - b.ai_score; const gap = a.ai_score - b.ai_score;
const dims = ['experience', 'english', 'reliability', 'certifications', 'availability']; const first = (n) => n.split(' ')[0];
const rows = dims.map((dim) => { const rows = DIMENSIONS.map((d) => {
const av = a.score_breakdown?.[dim] ?? 0; const av = a.score_breakdown?.[d] ?? 0;
const bv = b.score_breakdown?.[dim] ?? 0; const bv = b.score_breakdown?.[d] ?? 0;
const lead = av === bv ? 'tied' : (av > bv ? a : b).applicant_name.split(' ')[0]; return {
return `${dim.charAt(0).toUpperCase() + dim.slice(1)} — ${av} vs ${bv} · ${lead}`; dimension: d[0].toUpperCase() + d.slice(1),
a: { value: av, tone: av > bv ? 'success' : av < bv ? 'neutral' : undefined },
b: { value: bv, tone: bv > av ? 'success' : bv < av ? 'neutral' : undefined },
};
}); });
const biggest = dims.reduce((best, d) => const biggest = DIMENSIONS.reduce((best, d) =>
Math.abs((a.score_breakdown?.[d] ?? 0) - (b.score_breakdown?.[d] ?? 0)) > 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]); Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, DIMENSIONS[0]);
return compose( return doc(
heading(`${a.applicant_name} (${a.ai_score}) vs ${b.applicant_name} (${b.ai_score})`), heading(`${a.applicant_name} vs ${b.applicant_name}`),
'', table(
bullets(rows), [
'', { key: 'dimension', label: '' },
gap <= 3 { key: 'a', label: first(a.applicant_name), align: 'right' },
? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.` { key: 'b', label: first(b.applicant_name), align: 'right' },
: `${a.applicant_name} leads by ${gap}, driven mostly by ${biggest}.` ],
[
{ dimension: 'Overall', a: { value: a.ai_score, tone: 'info' }, b: { value: b.ai_score, tone: 'info' } },
...rows,
],
{ caption: 'Score comparison by dimension' }
),
heading('Where each one wins'),
insights([
{
tone: 'success',
title: `${first(a.applicant_name)} leads on ${biggest}`,
body: a.ai_strengths?.[0] || `Scores ${a.score_breakdown?.[biggest] ?? 0} against ${b.score_breakdown?.[biggest] ?? 0}.`,
},
b.ai_strengths?.[0] && {
tone: 'info',
title: `${first(b.applicant_name)} brings`,
body: b.ai_strengths[0],
},
]),
text(
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} points, driven mostly by ${biggest}.`
),
note('Recommendation only. Neither has been met in person.')
); );
}; };
const interviewQuestions = (f) => { const interviewQuestions = (f) => {
const subject = f.stalled[0] || f.ranked[0]; const c = f.stalled[0] || f.ranked[0];
if (!subject) return 'Screen a candidate and I will build questions around their specific gaps.'; if (!c) return answer('Screen a candidate and I will build questions around their specific gaps.');
const gaps = subject.ai_gaps || []; const gaps = c.ai_gaps || [];
return compose( return doc(
heading(`Questions for ${subject.applicant_name}`), heading(`Questions for ${c.applicant_name}`, 'Built from their gaps, not a generic list'),
'Built from their gaps rather than a generic list, so the interview tests what the score could not.', list([
'', `Walk me through your busiest ${c.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
numbered([
`Walk me through your busiest ${subject.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
gaps[0] gaps[0]
? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?` ? `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?', : '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?', 'Something breaks mid-service and your lead is unreachable. What are your next three moves?',
subject.certifications?.length c.certifications?.length
? `Your ${subject.certifications[0]} is on file — when did you last apply it on a live shift?` ? `Your ${c.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?', : 'Which certifications are you working toward, and why that one?',
'What does your real availability look like over the next eight weeks?', 'What does your real availability look like over the next eight weeks?',
]) ], { ordered: true }),
note('The scenario question is the one that separates candidates. Let them think.')
); );
}; };
const skillGapAnalysis = (f) => { const skillGapAnalysis = (f) => {
if (!f.scored.length) return 'No scored candidates yet, so there are no gaps to analyse.'; if (!f.scored.length) return answer('No scored candidates yet, so there are no gaps to analyse.');
const tally = {}; const tally = {};
f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => { f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => {
@@ -222,54 +286,86 @@ const skillGapAnalysis = (f) => {
})); }));
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5); 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.'; if (!ranked.length) return answer('No recurring gaps — the pool matches your postings well.');
return compose( return doc(
heading('Recurring gaps'), heading('Recurring gaps', `Across ${plural(f.scored.length, 'scored candidate')}`),
'', table(
bullets(ranked.map(([gap, count]) => `${gap} — ${count} of ${f.scored.length}`)), [{ key: 'gap', label: 'Gap' }, { key: 'count', label: 'Candidates', align: 'right' }],
'', ranked.map(([gap, count]) => ({
f.missingCredentials.length >= 2 gap,
? `${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.` count: { value: `${count}/${f.scored.length}`, tone: count > f.scored.length / 2 ? 'risk' : undefined },
: 'Gaps are spread thin, which means your requirements are well matched to who is applying.' }))
),
text(
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) => { const hiringRecommendation = (f) => {
if (!f.scored.length) return 'Nothing scored yet, so I have no basis for a recommendation.'; if (!f.scored.length) return answer('Nothing scored yet, so I have no basis for a recommendation.');
const open = (a) => !['hired', 'rejected'].includes(a.status); const open = (a) => !['hired', 'rejected'].includes(a.status);
const shortlist = f.ranked.filter((a) => a.ai_score >= 80 && open(a)); const groups = [
const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a)); { verdict: 'Shortlist', tone: 'success', people: f.ranked.filter((a) => a.ai_score >= 80 && open(a)) },
const pass = f.weak.filter(open); { verdict: 'Interview', tone: 'info', people: f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a)) },
{ verdict: 'Likely pass', tone: 'risk', people: f.weak.filter(open) },
].filter((g) => g.people.length);
return compose( if (!groups.length) return answer('Every scored candidate has already been actioned.');
return doc(
heading('Recommendation'), heading('Recommendation'),
'', table(
shortlist.length [
? `**Shortlist** — ${shortlist.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` { key: 'name', label: 'Candidate' },
: '**Shortlist** — nobody currently clears 80.', { key: 'score', label: 'Score', align: 'right' },
'', { key: 'verdict', label: 'Verdict', align: 'right' },
interview.length ? `**Interview** — ${interview.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '', ],
'', groups.flatMap((g) => g.people.map((p) => ({
pass.length ? `**Likely pass** — ${pass.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '', name: p.applicant_name,
'', score: { value: p.ai_score, tone: g.tone },
'_A recommendation, not a decision. I score what is on file — I have not met these people._' verdict: { badge: g.verdict, tone: g.tone },
})))
),
note('A recommendation, not a decision. I score what is on file — I have not met these people.')
); );
}; };
/* ── Analytics ──────────────────────────────────────────────────────────── */ /* ── Analytics ──────────────────────────────────────────────────────────── */
const explainCharts = (f) => compose( const explainCharts = (f) => doc(
heading('What these charts are saying'), heading('What these charts are saying'),
'', insights([
`**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.`, {
'', tone: 'info',
`**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.`, title: 'Pipeline Stages',
'', body: `${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
`**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.` tone: 'info',
title: 'Candidate Scores',
body: `${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.`,
},
{
tone: f.interviewCompletion >= 70 ? 'success' : 'warning',
title: 'Interview Completion',
body: `${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
},
{
tone: 'success',
title: 'Cost Saved',
body: `$${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.`,
},
]),
f.bottleneck && [
heading('Biggest drop-off'),
funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
text(`Most people are lost between **${f.bottleneck.from}** and **${f.bottleneck.to}** — only ${f.bottleneck.rate}% pass through, costing ${plural(f.bottleneck.lost, 'candidate')}.`),
]
); );
const forecastHiring = (f) => { const forecastHiring = (f) => {
@@ -278,124 +374,150 @@ const forecastHiring = (f) => {
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0); const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
const short = f.openPositions.length - expected; const short = f.openPositions.length - expected;
return compose( return doc(
heading('Forecast'), heading('Forecast'),
'', kpis([
bullets([ { label: 'In play at 60+', value: available.length },
`${plural(available.length, 'candidate')} at 60+ still in play`, { label: 'Projected hires', value: expected, tone: short > 0 ? 'warning' : 'success' },
`At your ${conversion}% hire rate that projects to about ${plural(expected, 'hire')}`, { label: 'Open roles', value: f.openPositions.length },
f.timeToHire ? `At a ${f.timeToHire}-day average, those land within a week of you deciding` : 'No time-to-hire baseline yet', { label: 'Conversion', value: `${conversion}%` },
`${plural(f.openPositions.length, 'open position')} to fill`,
]), ]),
'',
short > 0 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.` ? insights([{
: 'That covers your open roles, assuming the strong candidates do not go elsewhere first.', tone: 'warning',
'', title: `Roughly ${plural(short, 'role')} short`,
'_Projected from your own conversion rate, not a promise — small pools move a lot._' body: `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.`,
}])
: insights([{
tone: 'success',
title: 'Current pool covers your open roles',
body: 'Assuming the strong candidates do not go elsewhere first.',
}]),
note('Projected from your own conversion rate, not a promise — small pools move a lot.')
); );
}; };
const departmentInsights = (f) => { const departmentInsights = (f) => {
if (!f.byRole.length) return 'No open positions to break down.'; if (!f.byRole.length) return answer('No open positions to break down.');
const byCategory = {}; const byCategory = {};
f.byRole.forEach((r) => { f.byRole.forEach((r) => {
byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: [] }; byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: 0 };
byCategory[r.category].applied += r.applied; byCategory[r.category].applied += r.applied;
byCategory[r.category].hired += r.hired; byCategory[r.category].hired += r.hired;
byCategory[r.category].qualified += r.qualified; byCategory[r.category].qualified += r.qualified;
byCategory[r.category].roles.push(r.title); byCategory[r.category].roles += 1;
}); });
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied); const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
const starved = entries.filter(([, d]) => d.applied === 0); const starved = entries.filter(([, d]) => d.applied === 0);
return compose( return doc(
heading('By role category'), heading('By role category'),
'', table(
entries.map(([category, d]) => [
`**${category}** — ${plural(d.applied, 'applicant')}, ${d.qualified} at 70+, ${plural(d.hired, 'hire')} across ${plural(d.roles.length, 'role')}` { key: 'category', label: 'Category' },
).join('\n'), { key: 'applied', label: 'Applied', align: 'right' },
'', { key: 'qualified', label: '70+', align: 'right' },
starved.length { key: 'hired', label: 'Hired', align: 'right' },
? `${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.' entries.map(([category, d]) => ({
category,
applied: { value: d.applied, tone: d.applied === 0 ? 'risk' : undefined },
qualified: d.qualified,
hired: d.hired,
}))
),
text(
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 generateReports = (f) => doc(
const stamp = f.today.toISOString().slice(0, 10); // Local date, not toISOString — UTC would show yesterday for anyone behind it.
heading('Hiring report', f.today.toLocaleDateString(undefined, {
return compose( year: 'numeric', month: 'long', day: 'numeric',
heading(`Hiring report — ${stamp}`), })),
'', kpis([
heading('Volume'), { label: 'Applicants', value: f.total },
bullets([ { label: 'Screened', value: `${f.standardizedPct}%` },
`${plural(f.total, 'applicant')} received`, { label: 'Hires', value: f.hired.length },
`${f.screened.length} screened (${f.standardizedPct}% coverage)`, { label: 'Hire rate', value: `${f.hireRate}%` },
`${plural(f.interviews.length, 'AI interview')} conducted, ${f.completedInterviews.length} scored`, { label: 'Avg hire score', value: f.hiredAvgScore || '—', tone: 'success' },
`${plural(f.hired.length, 'hire')} closed`, { label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' },
]), ]),
'', heading('Funnel'),
heading('Quality'), funnel(f.funnel.map((s, i) => ({ label: s.label, count: s.count, rate: i === 0 ? undefined : f.transitions[i - 1]?.rate }))),
bullets([ heading('By role'),
`Average candidate score ${f.avgScore}`, table(
`Average hire score ${f.hiredAvgScore}`, [
`Hire rate ${f.hireRate}%`, { key: 'title', label: 'Role' },
`Average time-to-hire ${f.timeToHire ? `${f.timeToHire} days` : 'no baseline'}`, { key: 'applied', label: 'Applied', align: 'right' },
]), { key: 'qualified', label: '70+', align: 'right' },
'', { key: 'hired', label: 'Hired', align: 'right' },
heading('Efficiency'), ],
bullets([ f.byRole.map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified, hired: r.hired }))
`$${f.costSaved.toLocaleString()} saved against manual screening and interviewing`, ),
`≈${f.hoursSaved} recruiter hours returned`, heading('Efficiency'),
]), kpis([
'', { label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
heading('By role'), { label: 'Recruiter hours', value: `${f.hoursSaved}h`, tone: 'success' },
bullets(f.byRole.map((r) => `${r.title} — ${r.applied} applied, ${r.qualified} at 70+, ${r.hired} hired`)), ]),
'', heading('Headline finding'),
insights([
f.bottleneck 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.', tone: 'warning',
'', title: `${f.bottleneck.from} → ${f.bottleneck.to} passes only ${f.bottleneck.rate}%`,
'_Copy this into your own template — export to PDF and CSV is not wired up in this build._' body: `Losing ${plural(f.bottleneck.lost, 'candidate')} — the single biggest recoverable loss in the funnel.`,
); }
}; : { tone: 'success', title: 'No single bottleneck', body: 'The funnel is passing through evenly.' },
]),
buildActions(f).length && [heading('Recommended actions'), actions(buildActions(f).slice(0, 3))],
note('Copy this into your own template — PDF and CSV export are not wired up in this build.')
);
/* ── Free-text responders ───────────────────────────────────────────────── */ /* ── Free-text responders ───────────────────────────────────────────────── */
const has = (q, ...words) => words.some((w) => q.includes(w)); const has = (q, ...words) => words.some((w) => q.includes(w));
const stateOfPlay = (f) => const stateOfPlay = (f) => doc(
`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.`; text('I do not have a specific read on that. Here is where things stand:'),
kpis([
{ label: 'Applicants', value: f.total },
{ label: 'Screened', value: f.screened.length },
{ label: 'Interviewing', value: f.interviewing.length },
{ label: 'Hired', value: f.hired.length },
]),
text('Ask me about any of those and I will go deeper.')
);
const bottleneckAnswer = (f) => { const bottleneckAnswer = (f) => {
if (!f.bottleneck) return 'Not enough pipeline movement yet to locate a bottleneck.'; if (!f.bottleneck) return answer('Not enough pipeline movement yet to locate a bottleneck.');
const b = f.bottleneck; const b = f.bottleneck;
const diagnosis = { 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.`, 'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap 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.', 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.', 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.', Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.',
}[b.to]; }[b.to];
return compose( return doc(
heading(`Bottleneck: ${b.from} → ${b.to}`), heading(`Bottleneck: ${b.from} → ${b.to}`, `${b.rate}% pass through`),
`Only ${b.rate}% pass through, losing ${plural(b.lost, 'candidate')}.`, funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
'', insights([{ tone: 'warning', title: `Losing ${plural(b.lost, 'candidate')} here`, body: diagnosis }])
diagnosis || '',
'',
bullets(f.transitions.map((t) => `${t.from} → ${t.to}: ${t.rate}%${t.lost ? ` (−${t.lost})` : ''}`))
); );
}; };
export const respondOverview = (question, f) => { export const respondOverview = (question, f) => {
const q = question.toLowerCase(); const q = question.toLowerCase();
if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f); 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, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next', 'slow')) return pendingActions(f);
if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(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, 'summar', 'today', 'brief', 'catch me up', 'overview', 'morning', 'afternoon', 'evening')) return hiringSummary(f);
if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f); if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f);
if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f); if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f);
return stateOfPlay(f); return stateOfPlay(f);
@@ -403,36 +525,44 @@ export const respondOverview = (question, f) => {
export const respondCandidates = (question, f) => { export const respondCandidates = (question, f) => {
const q = question.toLowerCase(); 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, 'compare', 'versus', ' vs ', 'between')) return candidateComparison(f);
if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(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, '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, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f);
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze', 'review')) return resumeAnalysis(f);
if (has(q, 'unscreened', 'not screened', 'pending')) { if (has(q, 'unscreened', 'not screened', 'pending')) {
return f.unscreened.length return f.unscreened.length
? compose( ? doc(
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`), heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
'', table(
bullets(f.unscreened.slice(0, 8).map((a) => `${a.applicant_name} — ${a.job_title}`)), [{ key: 'name', label: 'Candidate' }, { key: 'role', label: 'Applied for' }],
f.unscreened.length > 8 ? `…and ${f.unscreened.length - 8} more` : '' f.unscreened.slice(0, 8).map((a) => ({ name: a.applicant_name, role: a.job_title }))
),
f.unscreened.length > 8 && text(`…and ${f.unscreened.length - 8} more.`)
) )
: 'Everyone has been screened.'; : answer('Everyone has been screened.');
} }
return stateOfPlay(f); return stateOfPlay(f);
}; };
export const respondAnalytics = (question, f) => { export const respondAnalytics = (question, f) => {
const q = question.toLowerCase(); const q = question.toLowerCase();
if (has(q, 'forecast', 'predict', 'projection', 'will i', 'expect')) return forecastHiring(f);
if (has(q, 'report', 'export', 'executive', 'download')) return generateReports(f);
if (has(q, 'bottleneck', 'drop', 'stuck', 'lose', 'why do')) return bottleneckAnswer(f);
if (has(q, 'department', 'category', 'role', 'team', 'break down')) return departmentInsights(f);
if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f); 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')) { 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.`; return answer(`${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')) { 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 doc(
kpis([
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
{ label: 'Hours returned', value: `${f.hoursSaved}h`, tone: 'success' },
]),
text(`From ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each.`)
);
} }
return stateOfPlay(f); return stateOfPlay(f);
}; };

View File

@@ -1,5 +1,6 @@
import { BookOpen, Compass, FileText } from 'lucide-react'; import { BookOpen, Compass, FileText } from 'lucide-react';
import { bullets, compose, heading, numbered, plural } from '../insights'; import { actions, answer, badges, doc, heading, insights, kpis, meters, note, status, table, text } from '../blocks';
import { plural } from '../insights';
import { getScoreBand, toFICO } from '@/lib/talentHome'; import { getScoreBand, toFICO } from '@/lib/talentHome';
/** /**
@@ -16,121 +17,165 @@ const NO_PROFILE = 'I do not have your profile yet. Once it exists I can give yo
const resumeReview = (f) => { const resumeReview = (f) => {
const p = f.profile; const p = f.profile;
if (!p) return NO_PROFILE; if (!p) return answer(NO_PROFILE);
const missing = []; const fields = [
if (!p.phone) missing.push('phone number'); { label: 'Photo', ok: Boolean(p.selfie_url), note: 'The KROW ID replaces a résumé — a profile with a face gets opened more.' },
if (!p.address) missing.push('location'); { label: 'Availability', ok: (p.availability || []).length > 0, note: 'Employers filter on this before they read anything else.' },
if (!(p.skills || []).length) missing.push('skills'); { label: 'Certifications', ok: (p.certifications || []).length > 0, note: 'A Food Handler Card is the cheapest credential in this industry.' },
if (!(p.experience || []).length) missing.push('work history'); { label: 'Skills', ok: (p.skills || []).length > 0, note: 'Without these you do not appear in skill searches.' },
if (!(p.certifications || []).length) missing.push('certifications'); { label: 'Work history', ok: (p.experience || []).length > 0, note: 'Even one role gives employers something to anchor on.' },
if (!p.career_goals) missing.push('career goals'); { label: 'Career goals', ok: Boolean(p.career_goals), note: 'Lets matching put you in front of the right roles, not every role.' },
if (!(p.availability || []).length) missing.push('availability'); { label: 'Verified evidence', ok: (p.capabilities || []).length > 0, note: 'The only part of your profile an employer does not have to take on trust.' },
if (!p.selfie_url) missing.push('photo'); ];
const present = []; const complete = fields.filter((x) => x.ok).length;
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( return doc(
heading(`Your profile is ${p.profile_completion || 0}% complete`), heading(`Your profile is ${p.profile_completion || 0}% complete`, `${complete} of ${fields.length} things employers look for`),
'', status(fields.map((x) => ({ label: x.label, ok: x.ok, note: x.ok ? undefined : x.note }))),
present.length ? compose(heading('What is working'), bullets(present)) : '', (p.certifications || []).length && [
'', heading('Credentials on file'),
missing.length badges(p.certifications.map((label) => ({ label, tone: 'success' }))),
? compose( ],
heading('What is missing'), text(
bullets(missing), complete === fields.length
'', ? 'Nothing is missing. From here, more verified evidence is what moves you — not more fields.'
'Employers filter on availability and certifications before they read anything else. If you fix two things, fix those.' : 'If you fix two things, fix availability and certifications. Those are the two employers filter on first.'
) ),
: compose(heading('Nothing is missing'), 'From here, more verified evidence moves you — not more fields.'), note(
'', (p.capabilities || []).length
(p.capabilities || []).length ? 'Your verified capabilities came with evidence attached, not a checkbox. That is why they carry weight.'
? '_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.'
: '_Nothing is verified yet. One completed challenge is worth more than ten claimed skills._' )
); );
}; };
const careerGuidance = (f) => { const careerGuidance = (f) => {
const p = f.profile; const p = f.profile;
if (!p) return NO_PROFILE; if (!p) return answer(NO_PROFILE);
const score = scoreOf(p); const score = scoreOf(p);
const band = getScoreBand(score); const band = getScoreBand(score);
const target = score >= 90 ? null : score >= 75 ? 90 : score >= 60 ? 75 : 60; const target = score >= 90 ? null : score >= 75 ? 90 : score >= 60 ? 75 : 60;
return compose( return doc(
heading(`You are at ${toFICO(score)} — ${band.label}`), heading(`${toFICO(score)} — ${band.label}`, 'Career score, on a 300–850 scale'),
'', kpis([
bullets([ { label: 'Reliability', value: p.reliability_score || 0, tone: (p.reliability_score || 0) >= 70 ? 'success' : 'warning' },
`Reliability ${p.reliability_score || 0}/100`, { label: 'Attendance', value: p.attendance_score ?? 0, tone: (p.attendance_score ?? 0) >= 90 ? 'success' : undefined },
`Attendance ${p.attendance_score ?? 0}/100`, { label: 'Shifts worked', value: p.shifts_completed || 0, tone: (p.shifts_completed || 0) === 0 ? 'warning' : undefined },
`${plural((p.completed_courses || []).length, 'course')} completed, ${plural((p.earned_badges || []).length, 'badge')} earned`, { label: 'Verified skills', value: (p.capabilities || []).length },
`${plural((p.capabilities || []).length, 'verified capability')}`,
`${plural(p.shifts_completed || 0, 'shift')} worked`,
]), ]),
'', heading("What's driving it"),
target meters([
? `**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.` { label: 'Reliability', value: p.reliability_score || 0, weight: 25 },
: '**You are in the top band.** From here it is about holding it — the score decays if shifts stop.', { label: 'Attendance', value: p.attendance_score ?? 0, weight: 20 },
'', { label: 'Communication', value: p.performance_score || 0, weight: 15 },
p.desired_position { label: 'Leadership', value: p.leadership_potential || 0, weight: 15 },
? `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.'}` { label: 'Learning', value: Math.min(100, (p.completed_courses || []).length * 8 + (p.xp || 0) / 10), weight: 15 },
: 'You have not set a target role. Setting one lets me tell you which courses matter and which are noise.', { label: 'Problem solving', value: p.ai_interview_score || 0, weight: 10 },
'', ]),
(p.shifts_completed || 0) === 0 insights([
? '**One concrete step:** work a single shift. Attendance, ratings and reliability all need a first data point before anything else compounds.' target
: '' ? {
tone: 'info',
title: `To reach ${target}`,
body: '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.',
}
: {
tone: 'success',
title: 'You are in the top band',
body: 'From here it is about holding it — the score decays if shifts stop.',
},
(p.shifts_completed || 0) === 0 && {
tone: 'warning',
title: 'Work one shift',
body: 'Attendance, ratings and reliability all need a first data point before anything else compounds.',
},
p.desired_position
? {
tone: score >= 75 ? 'success' : 'info',
title: `Aiming for ${p.desired_position}`,
body: score >= 75
? 'You are already scoring in the range employers shortlist for that.'
: 'The gap to that role is verification, not ambition.',
}
: {
tone: 'info',
title: 'No target role set',
body: 'Setting one lets me tell you which courses matter and which are noise.',
},
])
); );
}; };
const learningRecommendations = (f) => { const learningRecommendations = (f) => {
const p = f.profile; const p = f.profile;
if (!p) return NO_PROFILE; if (!p) return answer(NO_PROFILE);
const completedIds = new Set((p.completed_courses || []).map((c) => c.course_id)); const completedIds = new Set((p.completed_courses || []).map((c) => c.course_id));
const done = (p.completed_courses || []).length; const done = (p.completed_courses || []).length;
const shifts = p.shifts_completed || 0;
const available = (f.courses || []).filter((c) => c.status === 'active' && !completedIds.has(c.id)); const available = (f.courses || []).filter((c) => c.status === 'active' && !completedIds.has(c.id));
if (!available.length) { if (!available.length) {
return compose( return doc(
heading('You have completed everything available'), heading('You have completed everything available'),
`${plural(done, 'course')} done, ${plural((p.earned_badges || []).length, 'badge')} earned, ${p.xp || 0} XP.`, kpis([
'', { label: 'Courses', value: done, tone: 'success' },
'The next lever is verified shifts — that is what employers weight most.' { label: 'Badges', value: (p.earned_badges || []).length, tone: 'success' },
{ label: 'XP', value: p.xp || 0 },
{ label: 'Verified', value: (p.capabilities || []).length },
]),
text('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 ranked = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0));
const shifts = p.shifts_completed || 0;
return compose( return doc(
heading('What to learn next'), heading('What to learn next', 'Ranked by what moves your score most'),
'', table(
numbered(byValue.slice(0, 3).map((c) => { [
const gate = c.unlock_requirements?.min_shifts || 0; { key: 'title', label: 'Course' },
const locked = gate > shifts; { key: 'minutes', label: 'Time', align: 'right' },
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.'}`; { key: 'xp', label: 'XP', align: 'right' },
})), ],
'', ranked.slice(0, 4).map((c) => {
const gate = c.unlock_requirements?.min_shifts || 0;
const locked = gate > shifts;
return {
title: c.title,
minutes: `${c.estimated_minutes}m`,
xp: locked
? { badge: `${gate} shifts`, tone: 'warning' }
: { value: `+${c.xp}`, tone: 'success' },
};
})
),
heading('Where you stand'), heading('Where you stand'),
bullets([ kpis([
`${plural(done, 'course')} completed`, { label: 'Courses done', value: done },
`${plural((p.earned_badges || []).length, 'badge')} earned`, { label: 'Badges', value: (p.earned_badges || []).length },
`${p.xp || 0} XP`, { label: 'XP', value: p.xp || 0 },
`${plural((p.capabilities || []).length, 'capability')} verified`, { label: 'Verified skills', value: (p.capabilities || []).length },
]), ]),
'', actions([
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.' title: done === 0 ? `Start with ${ranked[ranked.length - 1].title}` : `Take ${ranked[0].title}`,
: done < 3 body: done === 0
? `Three completions is where the score starts moving noticeably, because education carries real weight in the formula. You are at ${done}.` ? 'The point of the first course is not the XP — it is getting one verified capability on the board.'
: 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.', : done < 3
'', ? `Three completions is where the score starts moving noticeably. You are at ${done}.`
'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.' : 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.',
},
shifts < 5 && {
title: `Log ${plural(5 - shifts, 'more shift')}`,
body: `Advanced challenges stay locked until 5 shifts — you have ${shifts}. That gate is deliberate: leadership evidence from someone who has never run a floor is not evidence.`,
},
]),
note('Each course ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored.')
); );
}; };
@@ -140,14 +185,16 @@ const has = (q, ...words) => words.some((w) => q.includes(w));
export const respondTalent = (question, f) => { export const respondTalent = (question, f) => {
const q = question.toLowerCase(); const q = question.toLowerCase();
if (has(q, 'resume', 'résumé', 'cv', 'profile')) return resumeReview(f); if (has(q, 'resume', 'résumé', 'cv', 'profile', 'improve')) return resumeReview(f);
if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge')) return learningRecommendations(f); if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge', 'path')) return learningRecommendations(f);
if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next')) return careerGuidance(f); if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next', 'started')) return careerGuidance(f);
const p = f.profile; const p = f.profile;
return p return answer(
? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?` p
: NO_PROFILE; ? `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 = [ export const TALENT_CAPABILITIES = [

View File

@@ -11,18 +11,10 @@ import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent';
/** /**
* Page contexts for the Krow AI Assistant. * Page contexts for the Krow AI Assistant.
* *
* The component is identical on every page. Only what is in this file changes: * Deliberately thin. What the user sees on the landing screen — the title,
* the supporting line, the placeholder examples, the suggested chips, the * greeting, description, placeholders and suggested prompts — is computed from
* capabilities and the responder. * live data in `dynamic.js`, not written here. A context only declares what this
* * page's assistant can *do*: its capabilities and its free-text 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 * `needsWorkerProfile` gates the one query with a side effect, so employer and
* admin sessions never touch worker-profile records. * admin sessions never touch worker-profile records.
@@ -31,129 +23,42 @@ export const ASSISTANT_CONTEXTS = {
'employer.overview': { 'employer.overview': {
id: 'employer.overview', id: 'employer.overview',
page: '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, capabilities: OVERVIEW_CAPABILITIES,
respond: respondOverview, respond: respondOverview,
}, },
'employer.candidates': { 'employer.candidates': {
id: 'employer.candidates', id: 'employer.candidates',
page: '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, capabilities: CANDIDATE_CAPABILITIES,
respond: respondCandidates, respond: respondCandidates,
}, },
'employer.analytics': { 'employer.analytics': {
id: 'employer.analytics', id: 'employer.analytics',
page: '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, capabilities: ANALYTICS_CAPABILITIES,
respond: respondAnalytics, respond: respondAnalytics,
}, },
'admin.controlCenter': { 'admin.controlCenter': {
id: 'admin.controlCenter', id: 'admin.controlCenter',
page: 'Control Center', 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, capabilities: CONTROL_CENTER_CAPABILITIES,
respond: respondControlCenter, respond: respondControlCenter,
}, },
'admin.candidates': { 'admin.candidates': {
id: 'admin.candidates', id: 'admin.candidates',
page: 'Candidates Analysis', 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, capabilities: ADMIN_CANDIDATE_CAPABILITIES,
respond: respondAdminCandidates, respond: respondAdminCandidates,
}, },
'admin.activity': { 'admin.activity': {
id: 'admin.activity', id: 'admin.activity',
page: '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, capabilities: ACTIVITY_CAPABILITIES,
respond: respondActivity, respond: respondActivity,
}, },
'talent.portal': { 'talent.portal': {
id: 'talent.portal', id: 'talent.portal',
page: 'My 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, capabilities: TALENT_CAPABILITIES,
respond: respondTalent, respond: respondTalent,
needsWorkerProfile: true, needsWorkerProfile: true,

View File

@@ -0,0 +1,265 @@
import { plural, verb } from './insights';
/**
* Dynamic greetings and suggested prompts.
*
* Nothing here is a fixed string chosen in advance. The greeting states what is
* actually true on the page right now, and the suggestions name real records —
* the specific candidates, the specific starved role. That is the difference
* between an assistant that looks aware of context and one that is.
*
* Everything derives from the same fact sheet the dashboard renders from, so a
* greeting can never contradict the numbers beside it.
*/
/* ── Greetings ──────────────────────────────────────────────────────────── */
/**
* Time of day from the viewer's own clock.
*
* `getHours()` is local, so this follows the user's timezone rather than the
* server's. The late band matters: at 00:30 "morning" is wrong, so 21:00–04:59
* reads as evening.
*
* 05:00–11:59 morning
* 12:00–16:59 afternoon
* 17:00–20:59 evening
* 21:00–04:59 evening
*/
function timeOfDay(date) {
const hour = date.getHours();
if (hour >= 5 && hour < 12) return 'morning';
if (hour >= 12 && hour < 17) return 'afternoon';
return 'evening';
}
/**
* Picks the single most useful thing to say about the page.
*
* Ordered by urgency rather than by what sounds cheeriest: something stuck beats
* something healthy, because the greeting is the one line guaranteed to be read.
*/
const GREETINGS = {
'employer.overview': (f) => {
if (!f.total) return 'No applicants yet — publish a position and I will start tracking it.';
if (f.stalled.length) {
return `${plural(f.stalled.length, 'strong candidate')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`;
}
if (f.unscreened.length) {
return `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'is', 'are')} waiting to be screened.`;
}
if (f.starvedPositions.length) {
return `${plural(f.starvedPositions.length, 'open role')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants yet.`;
}
return 'Your hiring pipeline looks healthy today.';
},
'employer.candidates': (f) => {
if (!f.total) return 'No candidates in the pipeline yet.';
if (f.unscreened.length) {
return `You have ${plural(f.unscreened.length, 'candidate')} waiting for review.`;
}
if (f.stalled.length) {
return `${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'is', 'are')} ready for a decision.`;
}
return `All ${f.total} candidates are scored and actioned.`;
},
'employer.analytics': (f) => {
if (!f.scored.length) return 'Nothing scored yet, so the charts are still empty.';
if (f.bottleneck && f.bottleneck.lost > 0) {
return `Your funnel loses most people between ${f.bottleneck.from} and ${f.bottleneck.to}.`;
}
return `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. Worth a closer look.`;
},
'admin.controlCenter': (f) => {
if (f.unscoredProfiles.length) {
return `${plural(f.unscoredProfiles.length, 'talent profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score and cannot be matched.`;
}
if (f.activity24h.length) {
return `${plural(f.activity24h.length, 'event')} logged in the last 24 hours.`;
}
return `Platform is steady across ${plural(f.postings.length, 'position')} and ${plural(f.total, 'application')}.`;
},
'admin.candidates': (f) => {
if (f.missingCredentials.length) {
return `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no credentials on file.`;
}
return `${plural(f.profiles.length, 'worker')} in the pool, ${f.elite.length} at Elite.`;
},
'admin.activity': (f) => {
if (!f.activity.length) return 'No activity logged yet.';
if (f.activity24h.length === 0) {
return `Quiet day — ${plural(f.activity7d.length, 'event')} this week, none in the last 24 hours.`;
}
return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week.`;
},
'talent.portal': (f) => {
const p = f.profile;
if (!p) return 'Let us get your profile started.';
const completion = p.profile_completion || 0;
if (completion >= 90) return 'Your profile is nearly complete — nice work.';
if (completion > 0) return `You are ${completion}% of the way through your profile.`;
return 'Your profile is empty. A few fields will change what employers see.';
},
};
/** Title lines. First names read better than a formal greeting in a small panel. */
const TITLES = {
'employer.overview': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'employer.candidates': () => 'Candidate intelligence',
'employer.analytics': () => 'Analytics assistant',
'admin.controlCenter': (f) => `Platform check, ${timeOfDay(f.today)}`,
'admin.candidates': () => 'Talent analysis',
'admin.activity': () => 'Activity review',
'talent.portal': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
};
/** Descriptions state what this page's assistant can actually do with the data. */
const DESCRIPTIONS = {
'employer.overview': (f) =>
`Reading ${f.total} applicants across ${plural(f.openPositions.length, 'open role')}.`,
'employer.candidates': (f) => `All ${f.total} candidates and their score breakdowns.`,
'employer.analytics': () => 'Every figure on this page, explained or forecast.',
'admin.controlCenter': () => 'Health checks, data integrity and what needs intervention.',
'admin.candidates': () => 'Talent supply and risk across the candidate pool.',
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
'talent.portal': () => 'Your score, your profile, and what to learn next.',
};
/** Placeholders phrased as things this page's user would actually type. */
const PLACEHOLDERS = {
'employer.overview': (f) => [
`Summarize ${timeOfDay(f.today)}'s hiring…`,
f.starvedPositions.length ? 'Which positions need attention?' : 'What should I do first?',
'Why did hiring slow this week?',
],
'employer.candidates': (f) => [
f.ranked.length >= 2 ? 'Compare the top candidates…' : 'Analyse my strongest candidate…',
'Which resumes need review?',
'Recommend interview questions…',
],
'employer.analytics': () => [
'Explain this chart…',
'Forecast hiring…',
'Generate an executive summary…',
],
'admin.controlCenter': () => ['Show unusual activity…', 'Summarize platform usage…'],
'admin.candidates': () => ['Where is the risk in the pool?', 'Summarize recruitment trends…'],
'admin.activity': () => ['Show unusual activity…', 'Summarize platform usage…'],
'talent.portal': () => ['Improve my profile…', 'Recommend learning paths…'],
};
/* ── Suggested prompts ──────────────────────────────────────────────────── */
/**
* Builds suggestions from what is actually on the page.
*
* A suggestion that names two real candidates is a different product from one
* that says "Compare candidates" — it proves the assistant already looked.
* Each carries the capability that answers it best, so a click is precise.
*/
const PROMPTS = {
'employer.overview': (f) => [
{ label: `Summarize ${timeOfDay(f.today)}'s hiring`, prompt: `Summarize hiring as of this ${timeOfDay(f.today)}`, capability: 'hiring-summary' },
f.starvedPositions.length
? { label: 'Which positions need attention?', prompt: `Which positions need attention? ${f.starvedPositions[0].title} has no applicants`, capability: 'pending-actions' }
: { label: 'What needs my attention?', prompt: 'What needs my attention right now?', capability: 'pending-actions' },
{ label: 'Pipeline health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
f.singleCandidateRoles.length || f.missingCredentials.length
? { label: 'Where is the risk?', prompt: 'Where is the risk in my hiring right now?', capability: 'hiring-risks' }
: { label: 'Any hiring risks?', prompt: 'Are there any hiring risks I should know about?', capability: 'hiring-risks' },
],
'employer.candidates': (f) => {
const [a, b] = f.ranked;
return [
a && b
? { label: `Compare ${a.applicant_name.split(' ')[0]} & ${b.applicant_name.split(' ')[0]}`, prompt: `Compare ${a.applicant_name} and ${b.applicant_name}`, capability: 'candidate-comparison' }
: { label: 'Compare top candidates', prompt: 'Compare my top candidates', capability: 'candidate-comparison' },
f.unscreened.length
? { label: `${f.unscreened.length} resumes need review`, prompt: 'Which resumes need review?', capability: 'resume-analysis' }
: { label: 'Analyse top résumé', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
a
? { label: `Questions for ${a.applicant_name.split(' ')[0]}`, prompt: `Recommend interview questions for ${a.applicant_name}`, capability: 'interview-questions' }
: null,
{ label: 'Skill gaps', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
{ label: 'Who should I hire?', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
].filter(Boolean);
},
'employer.analytics': (f) => [
{ label: 'Explain this chart', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
{ label: 'Forecast hiring', prompt: 'Forecast hiring from the current pipeline', capability: 'forecast-hiring' },
{ label: 'Generate executive summary', prompt: 'Generate an executive hiring report', capability: 'generate-reports' },
f.bottleneck
? {
label: `Why the drop to ${f.bottleneck.to}?`,
prompt: `Why do candidates drop off between ${f.bottleneck.from} and ${f.bottleneck.to}?`,
capability: 'explain-charts',
}
: null,
{ label: 'Break down by role', prompt: 'Break down performance by role category', capability: 'department-insights' },
].filter(Boolean),
'admin.controlCenter': (f) => [
{ label: 'Platform health', prompt: 'Run a platform health check', capability: 'platform-health' },
{ label: 'Summarize platform usage', prompt: 'Summarize platform usage', capability: 'platform-health' },
f.unscoredProfiles.length
? { label: `${f.unscoredProfiles.length} unscored profiles`, prompt: 'What should I fix first?', capability: 'recommendations' }
: { label: 'What should I fix?', prompt: 'What should I fix first?', capability: 'recommendations' },
],
'admin.candidates': (f) => [
{ label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
f.missingCredentials.length
? { label: `${f.missingCredentials.length} missing credentials`, prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' }
: { label: 'Candidate risk', prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' },
],
'admin.activity': (f) => [
{ label: 'Audit summary', prompt: 'Summarize the audit trail', capability: 'audit-summary' },
{ label: 'Show unusual activity', prompt: 'Show me unusual account activity', capability: 'user-activity' },
f.activity7d.length
? { label: `${f.activity7d.length} events this week`, prompt: 'Summarize platform usage this week', capability: 'audit-summary' }
: null,
].filter(Boolean),
'talent.portal': (f) => {
const p = f.profile;
return [
{ label: 'Improve my profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' },
{ label: 'Recommend learning paths', prompt: 'What should I learn next?', capability: 'learning-recommendations' },
p && (p.krow_score || 0) > 0
? { label: 'How do I score higher?', prompt: 'How do I raise my career score?', capability: 'career-guidance' }
: { label: 'How do I get started?', prompt: 'How do I raise my career score?', capability: 'career-guidance' },
];
},
};
/* ── Public API ─────────────────────────────────────────────────────────── */
const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] || '';
/**
* Everything the landing screen needs, computed from current data.
* `userName` personalizes the greeting where a name is known.
*/
export function buildIntro(contextId, facts, userName) {
const name = firstName(userName);
return {
title: TITLES[contextId]?.(facts, name) ?? 'Krow AI',
greeting: GREETINGS[contextId]?.(facts) ?? '',
description: DESCRIPTIONS[contextId]?.(facts) ?? '',
placeholders: PLACEHOLDERS[contextId]?.(facts) ?? ['Ask anything…'],
};
}
/** Suggested prompts for the current page and data. */
export function buildPrompts(contextId, facts) {
return PROMPTS[contextId]?.(facts) ?? [];
}

View File

@@ -2,33 +2,32 @@
* Provider seam for the Krow AI Assistant. * Provider seam for the Krow AI Assistant.
* *
* The UI never generates an answer and never knows where one came from. It calls * 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 * `provider.stream(request)` and renders the snapshots. That single boundary is
* what makes the backend swappable: today a local provider computes answers from * 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 * dashboard data, and later an HTTP provider can call a real service without a
* single change to the component. * change to the component.
* *
* ── Contract ──────────────────────────────────────────────────────────────── * ── Contract ────────────────────────────────────────────────────────────────
* *
* provider.id: string * provider.id: string
* provider.stream(request): AsyncIterable<string> // yields text deltas * provider.stream(request): AsyncIterable<Block[]>
*
* Each yielded value is the response *so far* as an array of blocks (see
* blocks.js). Snapshots rather than deltas because a response is structured:
* a table or a KPI row has no meaningful half-state, and the renderer stays a
* pure function of the latest snapshot.
* *
* request = { * request = {
* contextId: string, // e.g. 'employer.overview' * contextId: string, // e.g. 'employer.overview'
* capability: string | null, // capability id, or null for free text * capability: string | null, // capability id, or null for free text
* question: string, // what the user typed, or the capability label * question: string, // what the user typed, or the chip's prompt
* facts: object, // the dashboard fact sheet (see insights.js) * facts: object, // dashboard fact sheet (see insights.js)
* signal: AbortSignal, // aborts an in-flight response * 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'; import { getContext } from './contexts';
import { toSnapshots } from './blocks';
/** 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)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -38,7 +37,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
* Deterministic: the same question against the same data returns the same * Deterministic: the same question against the same data returns the same
* answer, which is what makes the assistant demonstrable and testable. * answer, which is what makes the assistant demonstrable and testable.
*/ */
export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) { export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
return { return {
id: 'local', id: 'local',
@@ -46,21 +45,20 @@ export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
const context = getContext(contextId); const context = getContext(contextId);
if (!context) throw new Error(`Unknown assistant context: ${contextId}`); if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
const answer = capability const document = capability
? context.capabilities.find((c) => c.id === capability)?.run(facts) ? context.capabilities.find((c) => c.id === capability)?.run(facts)
?? context.respond(question, facts) ?? context.respond(question, facts)
: context.respond(question, facts); : context.respond(question, facts);
// A brief pause before the first delta, so the answer reads as considered // A brief pause before the first frame, so the answer reads as considered
// rather than precomputed. // rather than precomputed.
await sleep(latency); await sleep(latency);
if (signal?.aborted) return; if (signal?.aborted) return;
const tokens = String(answer).split(/(\s+)/); for (const snapshot of toSnapshots(document)) {
for (let i = 0; i < tokens.length; i += WORDS_PER_CHUNK * 2) {
if (signal?.aborted) return; if (signal?.aborted) return;
yield tokens.slice(i, i + WORDS_PER_CHUNK * 2).join(''); yield snapshot;
await sleep(chunkDelay); await sleep(frameDelay);
} }
}, },
}; };
@@ -69,15 +67,15 @@ export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
/** /**
* HTTP provider — the path to a real backend. * HTTP provider — the path to a real backend.
* *
* Reads Server-Sent-Event style `data:` lines from a streaming endpoint and * Reads Server-Sent-Event style `data:` lines and yields a growing block array.
* yields the text deltas. Unused today; it exists so the shape of the * A line may carry a whole block (`{ block: … }`) or a text delta
* integration is settled rather than guessed at later. Swapping providers is a * (`{ delta: "…" }`), which is appended to a trailing text block. Unused today;
* one-line change in `AssistantProvider`. * it exists so the shape of the integration is settled rather than guessed at.
* *
* The request body sends `contextId`, `capability` and `question` — not the * The request body sends `contextId`, `capability` and `question` — not the fact
* fact sheet. Dashboard data should be fetched server-side from the caller's own * sheet. Dashboard data should be read server-side from the caller's own session
* session rather than posted from the browser, so the client cannot ask about * rather than posted from the browser, so the client cannot ask about records it
* records it is not entitled to see. * is not entitled to see.
*/ */
export function createHttpProvider({ endpoint, headers = {} }) { export function createHttpProvider({ endpoint, headers = {} }) {
if (!endpoint) throw new Error('createHttpProvider requires an endpoint'); if (!endpoint) throw new Error('createHttpProvider requires an endpoint');
@@ -99,28 +97,37 @@ export function createHttpProvider({ endpoint, headers = {} }) {
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const blocks = [];
let buffer = ''; let buffer = '';
const appendDelta = (delta) => {
const last = blocks[blocks.length - 1];
if (last?.type === 'text') last.text += delta;
else blocks.push({ type: 'text', text: delta });
};
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n'); const lines = buffer.split('\n');
// The final element may be a partial line; keep it for the next read. // The final element may be a partial line; hold it for the next read.
buffer = lines.pop() ?? ''; buffer = lines.pop() ?? '';
for (const line of lines) { for (const line of lines) {
if (!line.startsWith('data:')) continue; if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim(); const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue; if (!payload || payload === '[DONE]') continue;
try { try {
const parsed = JSON.parse(payload); const parsed = JSON.parse(payload);
if (parsed.delta) yield parsed.delta; if (parsed.block) blocks.push(parsed.block);
else if (parsed.delta) appendDelta(parsed.delta);
} catch { } catch {
// A non-JSON data line is treated as a raw delta. appendDelta(payload);
yield payload;
} }
yield [...blocks];
} }
} }
}, },
@@ -128,10 +135,9 @@ export function createHttpProvider({ endpoint, headers = {} }) {
} }
/** /**
* The provider the app uses. * The provider the app uses. Local by default; point
* * `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to switch, with no other
* Local by default. Point `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to * code change.
* switch, with no other code change.
*/ */
export function createAssistantProvider() { export function createAssistantProvider() {
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT; const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;

View File

@@ -48,7 +48,7 @@ export function useConversation({ contextId, facts }) {
const storageKey = `krow_assistant:${contextId}`; const storageKey = `krow_assistant:${contextId}`;
const [messages, setMessages] = React.useState([]); const [messages, setMessages] = React.useState([]);
const [pending, setPending] = React.useState(null); // { question, text, thinking } const [pending, setPending] = React.useState(null); // { blocks, thinking }
const [error, setError] = React.useState(null); const [error, setError] = React.useState(null);
const abortRef = React.useRef(null); const abortRef = React.useRef(null);
@@ -93,28 +93,26 @@ export function useConversation({ contextId, facts }) {
messagesRef.current = withUser; messagesRef.current = withUser;
persist(withUser); persist(withUser);
setError(null); setError(null);
setPending({ question: text, text: '', thinking: true }); setPending({ blocks: [], thinking: true });
try { try {
let assembled = ''; let latest = [];
for await (const delta of provider.stream({ for await (const snapshot of provider.stream({
contextId, capability, question: text, facts, signal: controller.signal, contextId, capability, question: text, facts, signal: controller.signal,
})) { })) {
if (controller.signal.aborted) break; if (controller.signal.aborted) break;
assembled += delta; latest = snapshot;
setPending({ question: text, text: assembled, thinking: false }); setPending({ blocks: snapshot, thinking: false });
} }
if (controller.signal.aborted) { // Keep whatever arrived before the user stopped it — discarding a
// Keep whatever streamed before the user stopped it — discarding a // half-written answer loses what they were already reading. Stopping
// half-written answer loses information the user was already reading. // before the first block, though, should leave no empty turn behind.
if (assembled) { if (latest.length) {
const next = [...messagesRef.current, { role: 'assistant', text: assembled, stopped: true }]; const next = [
messagesRef.current = next; ...messagesRef.current,
persist(next); { role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
} ];
} else {
const next = [...messagesRef.current, { role: 'assistant', text: assembled }];
messagesRef.current = next; messagesRef.current = next;
persist(next); persist(next);
} }