update chatbox
This commit is contained in:
@@ -1,132 +1,47 @@
|
||||
import * as React from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ResponseDocument } from './ResponseBlocks';
|
||||
|
||||
/**
|
||||
* Renders the light markup the capabilities emit — `**bold**`, bullets,
|
||||
* numbered items, `_asides_`, and ✓/⚠ check lines.
|
||||
* The thinking state, shown between sending and the first block arriving.
|
||||
*
|
||||
* A full markdown renderer is available in the project, but this content is
|
||||
* produced locally in a known shape, and parsing five constructs keeps the
|
||||
* assistant's typography inside the design system rather than inheriting a
|
||||
* library's defaults.
|
||||
* Labelled as well as animated: three moving dots mean nothing to a screen
|
||||
* reader, and the visually-hidden text is what tells one that a reply is coming.
|
||||
*/
|
||||
|
||||
const BOLD = /\*\*(.+?)\*\*/g;
|
||||
|
||||
function inline(line, keyPrefix) {
|
||||
const parts = [];
|
||||
let last = 0;
|
||||
let match;
|
||||
BOLD.lastIndex = 0;
|
||||
|
||||
while ((match = BOLD.exec(line)) !== null) {
|
||||
if (match.index > last) parts.push(line.slice(last, match.index));
|
||||
parts.push(
|
||||
<strong key={`${keyPrefix}-${match.index}`} className="font-semibold text-ink-1">
|
||||
{match[1]}
|
||||
</strong>
|
||||
);
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
if (last < line.length) parts.push(line.slice(last));
|
||||
return parts.length ? parts : line;
|
||||
}
|
||||
|
||||
export function RichText({ text, className }) {
|
||||
const lines = React.useMemo(() => String(text).split('\n'), [text]);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1.5 text-body-sm text-ink-2', className)}>
|
||||
{lines.map((line, i) => {
|
||||
if (!line.trim()) return <div key={i} className="h-1.5" aria-hidden="true" />;
|
||||
|
||||
/* Aside — a caveat the capabilities wrap in underscores. */
|
||||
if (line.startsWith('_') && line.endsWith('_')) {
|
||||
return (
|
||||
<p key={i} className="border-l-2 border-border pl-2.5 text-caption italic text-ink-3 leading-relaxed">
|
||||
{inline(line.slice(1, -1), i)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* Bullet. */
|
||||
if (line.startsWith('• ')) {
|
||||
return (
|
||||
<div key={i} className="flex gap-2 leading-relaxed">
|
||||
<span className="text-krow-blue shrink-0" aria-hidden="true">•</span>
|
||||
<span className="min-w-0">{inline(line.slice(2), i)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Indented continuation of a numbered item. */
|
||||
if (line.startsWith(' ')) {
|
||||
return (
|
||||
<p key={i} className="pl-6 text-caption text-ink-3 leading-relaxed">
|
||||
{inline(line.trim(), i)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* Numbered item. */
|
||||
const numbered = line.match(/^(\d+)\.\s+(.*)$/);
|
||||
if (numbered) {
|
||||
return (
|
||||
<div key={i} className="flex gap-2.5 leading-relaxed">
|
||||
<span className="grid place-items-center w-4 h-4 mt-0.5 shrink-0 rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
||||
{numbered[1]}
|
||||
</span>
|
||||
<span className="min-w-0">{inline(numbered[2], i)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Health-check line. */
|
||||
if (line.startsWith('✓ ') || line.startsWith('⚠ ')) {
|
||||
const ok = line.startsWith('✓');
|
||||
return (
|
||||
<div key={i} className="flex gap-2 leading-relaxed">
|
||||
<span className={cn('shrink-0 font-semibold', ok ? 'text-success' : 'text-warning')} aria-hidden="true">
|
||||
{ok ? '✓' : '⚠'}
|
||||
</span>
|
||||
<span className="min-w-0">{inline(line.slice(2), i)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <p key={i} className="leading-relaxed">{inline(line, i)}</p>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The three-dot indicator shown before the first delta arrives. */
|
||||
export function ThinkingIndicator() {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-3 py-2.5 shadow-xs">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-ink-4 animate-typing"
|
||||
style={{ animationDelay: `${i * 0.18}s` }}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Assistant is thinking</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-krow-blue-tint text-krow-blue">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
</span>
|
||||
<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">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One turn in the conversation. The user's own words sit in a filled bubble; the
|
||||
* assistant's answer runs full width, because these answers are structured
|
||||
* multi-paragraph reports and a chat bubble would waste a third of a 380px
|
||||
* column on padding.
|
||||
* One turn.
|
||||
*
|
||||
* The user's words sit in a filled bubble; the assistant's reply runs the full
|
||||
* 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') {
|
||||
return (
|
||||
<div className="flex justify-end animate-slide-up">
|
||||
<div className="max-w-[85%] rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
|
||||
<div className="flex justify-end">
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,16 +50,24 @@ export function Message({ role, text, streaming, stopped }) {
|
||||
|
||||
return (
|
||||
<div className="animate-slide-up">
|
||||
<RichText text={text} />
|
||||
{streaming && (
|
||||
<span
|
||||
className="inline-block w-1.5 h-3.5 ml-0.5 -mb-0.5 bg-krow-blue animate-caret"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{stopped && (
|
||||
<p className="mt-1.5 text-caption italic text-ink-4">Stopped early.</p>
|
||||
)}
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-krow-blue-tint text-krow-blue">
|
||||
<Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Krow AI</span>
|
||||
</div>
|
||||
|
||||
<ResponseDocument blocks={blocks} streaming={streaming} />
|
||||
|
||||
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
|
||||
</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 };
|
||||
|
||||
@@ -4,9 +4,27 @@ import { cn } from '@/lib/utils';
|
||||
import KrowAssistant from './KrowAssistant';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
|
||||
/** Panel width, plus the gutter separating it from the dashboard. */
|
||||
const PANEL_WIDTH = 380;
|
||||
const DEFAULT_WIDTH = 380;
|
||||
const EXPANDED_WIDTH = 620;
|
||||
/** Gutter separating the panel from the dashboard. */
|
||||
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.
|
||||
@@ -15,23 +33,30 @@ const GUTTER = 24;
|
||||
* dashboard reflow smoothly instead of being covered, and what lets closing
|
||||
* restore the original layout exactly.
|
||||
*
|
||||
* Two things about the structure matter, and both were bugs before:
|
||||
* 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
|
||||
* `overflow: hidden` becomes the nearest scroll container, which silently
|
||||
* disables `position: sticky` on everything beneath it — the panel computed
|
||||
* as sticky but never actually stuck.
|
||||
* 1. The clip lives *inside* the sticky element. An ancestor with
|
||||
* `overflow: hidden` becomes the nearest scroll container and silently
|
||||
* disables `position: sticky` beneath it.
|
||||
* 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
|
||||
* `items-start` it collapsed to its own height, so its sticky context ended
|
||||
* partway down the page and the panel scrolled away, leaving empty space
|
||||
* beside a long dashboard.
|
||||
*
|
||||
* With both fixed the panel stays pinned for the whole scroll, and because the
|
||||
* assistant is a fixed-height frame, only its conversation scrolls.
|
||||
* Expanding widens the same panel in place — it is an analysis workspace inside
|
||||
* the application layout, not a fullscreen takeover, and never covers the
|
||||
* dashboard.
|
||||
*/
|
||||
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 (
|
||||
<AnimatePresence initial={false}>
|
||||
@@ -40,34 +65,40 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
||||
key="assistant"
|
||||
aria-label="Krow AI Assistant"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: PANEL_WIDTH + GUTTER, opacity: 1 }}
|
||||
animate={{ width: trackWidth, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }}
|
||||
/* No `overflow-hidden` here — see (1) above. `self-stretch` keeps the
|
||||
sticky context running the full height of the shell — see (2). */
|
||||
transition={{ duration: 0.3, ease: easing }}
|
||||
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2). */
|
||||
className="relative shrink-0 self-stretch"
|
||||
>
|
||||
<div className={cn('sticky', stickyClassName)}>
|
||||
{/* The clip: contains the slide-in and the fixed panel width while
|
||||
the track animates open, without breaking the sticky above it. */}
|
||||
<div className="overflow-hidden" style={{ width: PANEL_WIDTH + GUTTER }}>
|
||||
{/* The clip: contains the slide-in and the panel width while the
|
||||
track animates, without breaking the sticky above it. */}
|
||||
<motion.div
|
||||
className="overflow-hidden"
|
||||
animate={{ width: trackWidth }}
|
||||
transition={{ duration: 0.3, ease: easing }}
|
||||
>
|
||||
<motion.div
|
||||
className="pl-6"
|
||||
initial={{ x: 32, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 32, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1] }}
|
||||
transition={{ duration: 0.3, ease: easing }}
|
||||
>
|
||||
{/* Keyed on the context so switching pages gives a clean panel
|
||||
and aborts any response still streaming for the last one. */}
|
||||
<KrowAssistant
|
||||
key={context.id}
|
||||
context={context}
|
||||
expanded={isExpanded}
|
||||
onClose={close}
|
||||
onExpand={expand}
|
||||
onRestore={restore}
|
||||
className={panelHeightClassName}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.aside>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
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
|
||||
* 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 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
|
||||
* supported pages does not close a panel the user deliberately opened, while a
|
||||
* new session still starts clean on the dashboard.
|
||||
* Window state is kept for the session so moving between supported pages does
|
||||
* not collapse a panel the user deliberately opened or expanded, while a new
|
||||
* session still starts clean on the dashboard.
|
||||
*/
|
||||
function readStoredOpen() {
|
||||
function readFlag(key) {
|
||||
try {
|
||||
return sessionStorage.getItem(STORAGE_KEY) === '1';
|
||||
return sessionStorage.getItem(key) === '1';
|
||||
} catch {
|
||||
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 }) {
|
||||
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(
|
||||
() => resolveAssistantContext(role, pathname),
|
||||
@@ -38,36 +48,47 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
);
|
||||
|
||||
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 isExpanded = isOpen && expanded;
|
||||
|
||||
const persist = React.useCallback((next) => {
|
||||
const setOpenState = React.useCallback((next) => {
|
||||
setOpen(next);
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, next ? '1' : '0');
|
||||
} catch {
|
||||
// Private mode — the panel simply reverts to closed next session.
|
||||
}
|
||||
writeFlag(OPEN_KEY, next);
|
||||
}, []);
|
||||
|
||||
/* 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(() => {
|
||||
if (!isOpen) return undefined;
|
||||
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);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, persist]);
|
||||
}, [isOpen, isExpanded, setExpandedState, setOpenState]);
|
||||
|
||||
const value = React.useMemo(() => ({
|
||||
context,
|
||||
supported,
|
||||
isOpen,
|
||||
open: () => persist(true),
|
||||
close: () => persist(false),
|
||||
toggle: () => persist(!isOpen),
|
||||
}), [context, supported, isOpen, persist]);
|
||||
isExpanded,
|
||||
open: () => setOpenState(true),
|
||||
close: () => setOpenState(false),
|
||||
toggle: () => setOpenState(!isOpen),
|
||||
expand: () => setExpandedState(true),
|
||||
restore: () => setExpandedState(false),
|
||||
}), [context, supported, isOpen, isExpanded, setOpenState, setExpandedState]);
|
||||
|
||||
return (
|
||||
<AssistantPanelContext.Provider value={value}>
|
||||
@@ -79,7 +100,7 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function useAssistantPanel() {
|
||||
@@ -87,8 +108,11 @@ export function useAssistantPanel() {
|
||||
context: null,
|
||||
supported: false,
|
||||
isOpen: false,
|
||||
isExpanded: false,
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
toggle: () => {},
|
||||
expand: () => {},
|
||||
restore: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
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 { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import { KROW_LOGO_URL } from '@/assets/brand';
|
||||
import { useAssistantFacts, useConversation } from './useAssistant';
|
||||
import { Message, ThinkingIndicator } from './AssistantMessage';
|
||||
import { buildIntro, buildPrompts } from './dynamic';
|
||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||
import { PromptInput } from './PromptInput';
|
||||
import { PromptChips } from './PromptChips';
|
||||
|
||||
/**
|
||||
* Krow AI Assistant — the dashboard's contextual assistant.
|
||||
*
|
||||
* One component, reused on every supported page; only `context` differs.
|
||||
*
|
||||
* The layout is a fixed three-part frame — header, conversation, composer —
|
||||
* where only the conversation scrolls. That is what lets the panel stay pinned
|
||||
* beside a long dashboard without ever growing the page or losing its input.
|
||||
* The frame is fixed at three parts — header, body, composer — and only the body
|
||||
* scrolls. That is what lets the panel stay pinned beside a long dashboard
|
||||
* 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
|
||||
* 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.
|
||||
* Two states share that frame:
|
||||
*
|
||||
* Independent of Owliver. It talks to a provider (see provider.js) which can
|
||||
* later be pointed at an external service without changing anything here.
|
||||
* - **Landing.** One centred block: mark, contextual label, greeting,
|
||||
* 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 { messages, pending, error, busy, send, stop, reset } = useConversation({
|
||||
contextId: context.id,
|
||||
@@ -38,23 +53,46 @@ export default function KrowAssistant({ context, className, onClose }) {
|
||||
const scrollRef = React.useRef(null);
|
||||
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(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, pending]);
|
||||
|
||||
/**
|
||||
* A chip's prompt resolves to its capability so the answer is precise, while
|
||||
* the same question typed by hand still resolves through keyword matching.
|
||||
*/
|
||||
const submit = (text) => {
|
||||
const question = text.trim();
|
||||
const submit = React.useCallback((text) => {
|
||||
const question = String(text).trim();
|
||||
if (!question || busy) return;
|
||||
const chip = context.chips.find((c) => c.prompt === question);
|
||||
send({ question, capability: chip?.capability ?? null });
|
||||
send({ question });
|
||||
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 (
|
||||
<Surface
|
||||
@@ -62,13 +100,15 @@ export default function KrowAssistant({ context, className, onClose }) {
|
||||
radius="xl"
|
||||
padding="none"
|
||||
elevation="sm"
|
||||
data-wide={expanded || undefined}
|
||||
className={cn('flex flex-col overflow-hidden', className)}
|
||||
role="region"
|
||||
aria-label="Krow AI Assistant"
|
||||
>
|
||||
{/* Header — fixed */}
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-4 py-3">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{/* Header */}
|
||||
<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-7 w-7 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{messages.length > 0 && (
|
||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
||||
)}
|
||||
{onClose && (
|
||||
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
|
||||
)}
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{messages.length > 0 && (
|
||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* Conversation — the only scrolling region */}
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
{isEmpty ? (
|
||||
/* Landing: an invitation to ask, not a list of features. */
|
||||
<div className="flex h-full flex-col items-center justify-center px-6 py-8 text-center animate-fade-in">
|
||||
<span className="relative mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-white shadow-brand ring-1 ring-krow-blue/10">
|
||||
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-5 w-auto object-contain" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-krow-blue text-white shadow-sm"
|
||||
>
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
{isEmpty ? (
|
||||
/* ── Landing: one compact centred block ───────────────────────── */
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-4 py-4">
|
||||
{/* Capped so the block stays a tight composition when expanded. */}
|
||||
<div className="mx-auto w-full max-w-sm animate-fade-in space-y-3.5">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<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">
|
||||
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-3.5 w-auto object-contain" />
|
||||
<span
|
||||
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 w-2" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<h3 className="font-heading text-title text-ink-1">
|
||||
What would you like to analyze today?
|
||||
</h3>
|
||||
<p className="mt-2 max-w-[19rem] text-body-sm leading-relaxed text-ink-3">
|
||||
{context.tagline}
|
||||
</p>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-krow-blue">
|
||||
{intro.title}
|
||||
</p>
|
||||
<h3 className="mt-1 text-balance font-heading text-body-lg font-semibold leading-snug text-ink-1">
|
||||
{intro.greeting}
|
||||
</h3>
|
||||
<p className="mt-1 text-caption leading-relaxed text-ink-3">{intro.description}</p>
|
||||
</div>
|
||||
|
||||
{composer}
|
||||
|
||||
<PromptChips prompts={prompts} onSelect={runPrompt} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
</div>
|
||||
) : (
|
||||
/* ── 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) => (
|
||||
<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 && !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>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suggested prompts + composer — fixed */}
|
||||
<div className="shrink-0 space-y-2.5 border-t border-white/60 px-3 pb-3 pt-2.5">
|
||||
{!busy && (
|
||||
/* On the landing there is room to show every suggestion, so they wrap.
|
||||
Once a conversation is underway the space belongs to the thread, so
|
||||
they collapse to a single scrollable row. */
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1.5',
|
||||
isEmpty ? 'flex-wrap justify-center' : 'overflow-x-auto no-scrollbar'
|
||||
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
|
||||
{/* Capped in conversation: the vertical space belongs to the thread.
|
||||
Expanded fits more per line, so it can afford one more. */}
|
||||
{!busy && (
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={runPrompt}
|
||||
max={expanded ? 4 : 3}
|
||||
align="start"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{context.chips.map((chip) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
type="button"
|
||||
/* Fills the input — never sends, never navigates. */
|
||||
onClick={() => setInput(chip.prompt)}
|
||||
className="shrink-0 rounded-full border border-border bg-surface px-2.5 py-1 text-caption
|
||||
text-ink-3 transition-colors duration-base hover:border-krow-blue/40
|
||||
hover:bg-krow-blue-tint hover:text-krow-blue focus-ring"
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
{composer}
|
||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
||||
Krow AI reads this page's data. Check anything you act on.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PromptInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={submit}
|
||||
onStop={stop}
|
||||
busy={busy}
|
||||
placeholders={context.placeholders}
|
||||
size={isEmpty ? 'large' : 'default'}
|
||||
/>
|
||||
|
||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
||||
Krow AI reads this page's data. Check anything you act on.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
67
src/components/ai-assistant/PromptChips.jsx
Normal file
67
src/components/ai-assistant/PromptChips.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -20,8 +20,6 @@ export function PromptInput({
|
||||
onStop,
|
||||
busy = false,
|
||||
placeholders = ['Ask anything…'],
|
||||
/** Larger treatment for the landing state, where the input is the focus. */
|
||||
size = 'default',
|
||||
autoFocus = false,
|
||||
className,
|
||||
}) {
|
||||
@@ -38,16 +36,29 @@ export function PromptInput({
|
||||
}, [value]);
|
||||
|
||||
/* 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(() => {
|
||||
if (value || focused || placeholders.length < 2) return undefined;
|
||||
const timer = setInterval(
|
||||
() => setPlaceholderIndex((i) => (i + 1) % placeholders.length),
|
||||
3800
|
||||
);
|
||||
const timer = setInterval(() => {
|
||||
setFading(true);
|
||||
// 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);
|
||||
}, [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(() => {
|
||||
if (autoFocus) textareaRef.current?.focus();
|
||||
}, [autoFocus]);
|
||||
@@ -64,36 +75,52 @@ export function PromptInput({
|
||||
}
|
||||
};
|
||||
|
||||
const large = size === 'large';
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); submit(); }}
|
||||
className={cn(
|
||||
'relative flex items-end gap-2 rounded-2xl border bg-surface transition-all duration-base ease-out',
|
||||
large ? 'px-4 py-3' : 'px-3 py-2.5',
|
||||
'px-3 py-2',
|
||||
focused
|
||||
? 'border-krow-blue/50 shadow-[0_8px_28px_-8px_rgba(10,57,223,0.28)] ring-2 ring-krow-blue/15'
|
||||
: 'border-border shadow-md hover:border-krow-blue/30',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
placeholder={placeholders[placeholderIndex]}
|
||||
aria-label="Ask Krow AI"
|
||||
className={cn(
|
||||
'flex-1 min-w-0 resize-none bg-transparent leading-relaxed text-ink-1',
|
||||
'placeholder:text-ink-4 outline-none no-scrollbar',
|
||||
large ? 'text-body py-1' : 'text-body-sm py-0.5'
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
disabled={busy}
|
||||
aria-label="Ask Krow AI"
|
||||
aria-busy={busy || undefined}
|
||||
className={cn(
|
||||
'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 ? (
|
||||
<button
|
||||
@@ -103,10 +130,10 @@ export function PromptInput({
|
||||
className={cn(
|
||||
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2',
|
||||
'transition-colors duration-base hover:bg-surface-sunken focus-ring',
|
||||
large ? 'w-9 h-9' : 'w-8 h-8'
|
||||
'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
|
||||
@@ -117,10 +144,10 @@ export function PromptInput({
|
||||
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
|
||||
'transition-all duration-base ease-out hover:bg-krow-blue-dark',
|
||||
'disabled:bg-surface-sunken disabled:text-ink-4 focus-ring',
|
||||
large ? 'w-9 h-9' : 'w-8 h-8'
|
||||
'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>
|
||||
)}
|
||||
</form>
|
||||
|
||||
409
src/components/ai-assistant/ResponseBlocks.jsx
Normal file
409
src/components/ai-assistant/ResponseBlocks.jsx
Normal 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 };
|
||||
100
src/components/ai-assistant/blocks.js
Normal file
100
src/components/ai-assistant/blocks.js
Normal 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 : [[]];
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
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 answers are platform-wide rather than pipeline-specific: data integrity,
|
||||
* talent supply, audit reconciliation.
|
||||
* Platform-wide rather than pipeline-specific: data integrity, talent supply,
|
||||
* audit reconciliation.
|
||||
*/
|
||||
|
||||
/* ── Control Center ─────────────────────────────────────────────────────── */
|
||||
@@ -15,148 +16,184 @@ const platformHealth = (f) => {
|
||||
{
|
||||
label: 'Data integrity',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
label: 'Screening coverage',
|
||||
value: `${f.standardizedPct}%`,
|
||||
ok: f.standardizedPct >= 80,
|
||||
good: `${f.standardizedPct}% of applicants carry a score.`,
|
||||
bad: `Only ${f.standardizedPct}% of applicants are scored — ${plural(f.unscreened.length, 'record')} unstandardized.`,
|
||||
good: 'Applicants are scored consistently.',
|
||||
bad: `${plural(f.unscreened.length, 'record')} unstandardized.`,
|
||||
},
|
||||
{
|
||||
label: 'Interview integrity',
|
||||
ok: f.flagged.length === 0,
|
||||
good: 'No interviews flagged.',
|
||||
bad: `${plural(f.flagged.length, 'interview')} flagged below full integrity.`,
|
||||
bad: `${plural(f.flagged.length, 'interview')} below full integrity.`,
|
||||
},
|
||||
{
|
||||
label: 'Profile completeness',
|
||||
value: `${f.profiles.length - f.unscoredProfiles.length}/${f.profiles.length}`,
|
||||
ok: f.unscoredProfiles.length <= f.profiles.length / 2,
|
||||
good: `${f.profiles.length - f.unscoredProfiles.length} of ${f.profiles.length} talent profiles have a career score.`,
|
||||
bad: `${plural(f.unscoredProfiles.length, 'profile')} of ${f.profiles.length} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score, so ${verb(f.unscoredProfiles.length, 'it cannot', 'they cannot')} be matched or ranked.`,
|
||||
good: 'Most talent profiles carry a career score.',
|
||||
bad: `${plural(f.unscoredProfiles.length, 'profile')} without a score cannot be matched or ranked.`,
|
||||
},
|
||||
{
|
||||
label: 'Throughput',
|
||||
value: `${f.hireRate}%`,
|
||||
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.',
|
||||
},
|
||||
];
|
||||
].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;
|
||||
|
||||
return compose(
|
||||
heading(`Platform health: ${passing}/${checks.length} checks passing`),
|
||||
'',
|
||||
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.ok ? c.good : c.bad}`).join('\n'),
|
||||
'',
|
||||
return doc(
|
||||
heading(`Platform health: ${passing}/${checks.length} passing`),
|
||||
status(checks),
|
||||
heading('Scale'),
|
||||
bullets([
|
||||
`${plural(f.postings.length, 'position')}, ${f.openPositions.length} open`,
|
||||
`${plural(f.total, 'application')}`,
|
||||
`${plural(f.interviews.length, 'AI interview')}`,
|
||||
`${plural(f.profiles.length, 'talent profile')}, ${f.elite.length} Elite`,
|
||||
`${plural(f.activity.length, 'logged event')}`,
|
||||
kpis([
|
||||
{ label: 'Positions', value: f.postings.length, sub: `${f.openPositions.length} open` },
|
||||
{ label: 'Applications', value: f.total },
|
||||
{ label: 'Interviews', value: f.interviews.length, sub: `${f.completedInterviews.length} scored` },
|
||||
{ label: 'Talent pool', value: f.profiles.length, sub: `${f.elite.length} Elite` },
|
||||
]),
|
||||
'',
|
||||
passing === checks.length
|
||||
? 'Nothing needs intervention at the platform level.'
|
||||
: 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.'
|
||||
text(
|
||||
passing === checks.length
|
||||
? 'Nothing needs intervention at the platform level.'
|
||||
: 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const recommendations = (f) => {
|
||||
const actions = [];
|
||||
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) {
|
||||
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 (!items.length) return answer('No platform actions outstanding. Data is complete and nothing is flagged.');
|
||||
|
||||
if (!actions.length) return 'No platform actions outstanding. Data is complete and nothing is flagged.';
|
||||
|
||||
return compose(heading('Recommended actions'), '', numbered(actions));
|
||||
return doc(heading('Recommended actions', 'Ordered by impact on matching quality'), actions(items));
|
||||
};
|
||||
|
||||
/* ── Candidates Analysis ────────────────────────────────────────────────── */
|
||||
|
||||
const recruitmentInsights = (f) => {
|
||||
const bands = {
|
||||
Elite: f.profiles.filter((p) => (p.krow_score || 0) >= 90).length,
|
||||
Excellent: f.profiles.filter((p) => (p.krow_score || 0) >= 75 && (p.krow_score || 0) < 90).length,
|
||||
Solid: f.profiles.filter((p) => (p.krow_score || 0) >= 60 && (p.krow_score || 0) < 75).length,
|
||||
Building: f.profiles.filter((p) => (p.krow_score || 0) > 0 && (p.krow_score || 0) < 60).length,
|
||||
Unscored: f.unscoredProfiles.length,
|
||||
};
|
||||
const band = (min, max) => f.profiles.filter((p) => {
|
||||
const s = p.krow_score || 0;
|
||||
return s >= min && (max == null || s < max);
|
||||
}).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 topHeavy = bands.Elite + bands.Excellent;
|
||||
const topHeavy = band(90) + band(75, 90);
|
||||
|
||||
return compose(
|
||||
heading('Talent supply'),
|
||||
'',
|
||||
bullets(Object.entries(bands).map(([band, count]) => `${band} — ${count}`)),
|
||||
'',
|
||||
return doc(
|
||||
heading('Talent supply', `${plural(f.profiles.length, 'worker')} in the pool`),
|
||||
table(
|
||||
[{ 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'),
|
||||
bullets([
|
||||
`${plural(f.total, 'applicant')} averaging ${f.avgScore}`,
|
||||
`${plural(f.ranked.filter((a) => a.ai_score >= 80).length, 'candidate')} at 80+`,
|
||||
`${plural(verified, 'worker')} with at least one verified capability`,
|
||||
`${plural(f.eventCounts.apply_job || 0, 'application event')} logged`,
|
||||
kpis([
|
||||
{ label: 'Applicants', value: f.total, sub: `avg ${f.avgScore}` },
|
||||
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'success' },
|
||||
{ label: 'Verified skills', value: verified, sub: 'with evidence' },
|
||||
{ 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'),
|
||||
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)
|
||||
.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 risks = [];
|
||||
|
||||
if (f.missingCredentials.length) {
|
||||
risks.push(`**No credentials on file** — ${plural(f.missingCredentials.length, 'candidate')}. Compliance risk if placed on a role that requires certification.`);
|
||||
}
|
||||
if (f.narrowAvailability.length) {
|
||||
risks.push(`**Narrow availability** — ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. Most likely to fall through on the day.`);
|
||||
}
|
||||
if (f.flagged.length) {
|
||||
risks.push(`**Interview integrity** — ${plural(f.flagged.length, 'interview')} below full integrity, usually suspiciously fast responses. Worth a human re-interview.`);
|
||||
}
|
||||
const noHistory = f.scored.filter((a) => !a.years_experience);
|
||||
if (noHistory.length) {
|
||||
risks.push(`**No recorded experience** — ${plural(noHistory.length, 'candidate')}. Not disqualifying, but unverifiable on paper.`);
|
||||
}
|
||||
if (f.singleCandidateRoles.length) {
|
||||
risks.push(`**Concentration risk** — ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`);
|
||||
|
||||
const risks = [
|
||||
f.missingCredentials.length && {
|
||||
tone: 'risk',
|
||||
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 compose(
|
||||
return doc(
|
||||
heading(`${plural(risks.length, 'risk flag')}`),
|
||||
'',
|
||||
numbered(risks),
|
||||
'',
|
||||
'_Flags to check, not judgements. Each is a question for a human, not a rejection._'
|
||||
insights(risks),
|
||||
note('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 hires = count('hire_candidate');
|
||||
const screens = count('screen_candidate');
|
||||
const creates = count('create_position');
|
||||
|
||||
return compose(
|
||||
return doc(
|
||||
heading('Audit summary'),
|
||||
'',
|
||||
bullets([
|
||||
`${plural(f.activity.length, 'event')} total`,
|
||||
`${plural(f.activity7d.length, 'event')} in the last 7 days`,
|
||||
`${plural(f.activity24h.length, 'event')} in the last 24 hours`,
|
||||
`${plural(new Set(f.activity.map((e) => e.user_email)).size, 'distinct user')}`,
|
||||
kpis([
|
||||
{ label: 'Total events', value: f.activity.length },
|
||||
{ label: 'Last 7 days', value: f.activity7d.length },
|
||||
{ label: 'Last 24 hours', value: f.activity24h.length, tone: f.activity24h.length ? undefined : 'warning' },
|
||||
{ label: 'Distinct users', value: new Set(f.activity.map((e) => e.user_email)).size },
|
||||
]),
|
||||
'',
|
||||
heading('Privileged actions'),
|
||||
bullets([
|
||||
`${plural(creates, 'position creation')}`,
|
||||
`${plural(screens, 'screening action')}`,
|
||||
`${plural(hires, 'hire')}`,
|
||||
]),
|
||||
'',
|
||||
table(
|
||||
[{ key: 'action', label: 'Action' }, { key: 'count', label: 'Events', align: 'right' }],
|
||||
[
|
||||
{ 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'),
|
||||
bullets([
|
||||
hires === f.staff.length
|
||||
? `All ${f.staff.length} people on staff have a matching hire event.`
|
||||
: `${f.staff.length} on staff but ${hires} hire ${verb(hires, 'event', 'events')} — ${Math.abs(f.staff.length - hires)} unexplained.`,
|
||||
screens < f.scored.length
|
||||
? `${f.scored.length} candidates carry scores but only ${plural(screens, 'screening event')} logged. Batch screening records one event per run, not per candidate.`
|
||||
: 'Screening events reconcile with scored candidates.',
|
||||
status([
|
||||
{
|
||||
label: 'Hires match staff records',
|
||||
ok: hires === f.staff.length,
|
||||
value: `${hires}/${f.staff.length}`,
|
||||
note: hires === f.staff.length
|
||||
? '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.',
|
||||
},
|
||||
]),
|
||||
'',
|
||||
'_An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review._'
|
||||
note('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);
|
||||
if (!ranked.length) return 'No activity to profile yet.';
|
||||
|
||||
const byRole = f.activity.reduce((acc, e) => {
|
||||
const role = e.account_type || 'unknown';
|
||||
acc[role] = (acc[role] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
if (!ranked.length) return answer('No activity to profile yet.');
|
||||
|
||||
const [topEmail, top] = ranked[0];
|
||||
const share = Math.round((top.count / f.activity.length) * 100);
|
||||
|
||||
return compose(
|
||||
return doc(
|
||||
heading('Most active accounts'),
|
||||
'',
|
||||
bullets(ranked.slice(0, 6).map(([email, d]) =>
|
||||
`**${d.name || email}** (${d.role || 'unknown'}) — ${plural(d.count, 'event')} across ${plural(d.types.size, 'action type')}`
|
||||
)),
|
||||
'',
|
||||
heading('By role'),
|
||||
bullets(Object.entries(byRole).sort((a, b) => b[1] - a[1])
|
||||
.map(([role, c]) => `${role.charAt(0).toUpperCase() + role.slice(1)} — ${plural(c, 'event')}`)),
|
||||
'',
|
||||
`${top.name || topEmail} accounts for ${top.count} of ${f.activity.length} events. On a live platform, one account dominating the log is worth confirming is a person and not an integration.`,
|
||||
'',
|
||||
f.activity24h.length === 0
|
||||
? 'No activity in the last day. Expected for a demo dataset; on a live platform it would be worth checking ingestion.'
|
||||
: 'Activity is flowing normally.'
|
||||
table(
|
||||
[
|
||||
{ key: 'name', label: 'Account' },
|
||||
{ key: 'role', label: 'Role' },
|
||||
{ key: 'events', label: 'Events', align: 'right' },
|
||||
],
|
||||
ranked.slice(0, 6).map(([email, d]) => ({
|
||||
name: d.name || email,
|
||||
role: { badge: d.role || 'unknown', tone: d.role === 'employer' ? 'info' : 'neutral' },
|
||||
events: { value: d.count, tone: d.count === top.count ? 'info' : undefined },
|
||||
}))
|
||||
),
|
||||
insights([
|
||||
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) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'recommend', 'should', 'action', 'fix', 'improve')) return recommendations(f);
|
||||
if (has(q, 'unusual', 'anomal', 'suspicious')) return userActivityInsights(f);
|
||||
return platformHealth(f);
|
||||
};
|
||||
|
||||
@@ -255,7 +313,7 @@ export const respondAdminCandidates = (question, f) => {
|
||||
|
||||
export const respondActivity = (question, f) => {
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,36 +1,47 @@
|
||||
import {
|
||||
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
|
||||
HeartPulse, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target, Layers,
|
||||
HeartPulse, Layers, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target,
|
||||
} 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.
|
||||
*
|
||||
* Each capability is a pure function of the fact sheet. Keeping them pure is
|
||||
* what allows the same code to run against local data today and against a
|
||||
* remote AI service later without the UI changing.
|
||||
* Each returns a block document rather than prose. The shape is chosen by what
|
||||
* the information is: a comparison is a table, a health check is a status list,
|
||||
* a pipeline is a funnel. Prose is reserved for the judgement a table cannot
|
||||
* carry.
|
||||
*/
|
||||
|
||||
/* ── Overview ───────────────────────────────────────────────────────────── */
|
||||
|
||||
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(
|
||||
heading('Where hiring stands'),
|
||||
bullets([
|
||||
`${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}`,
|
||||
`${f.screened.length} screened · ${f.unscreened.length} awaiting a score`,
|
||||
`${plural(f.interviewing.length, 'candidate')} mid-interview`,
|
||||
`${plural(f.hired.length, 'hire')} averaging ${f.hiredAvgScore}/100`,
|
||||
return doc(
|
||||
kpis([
|
||||
{ label: 'Applicants', value: f.total, sub: `${f.openPositions.length} open roles` },
|
||||
{ label: 'Screened', value: f.screened.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
|
||||
{ label: 'Interviewing', value: f.interviewing.length },
|
||||
{ label: 'Hired', value: f.hired.length, sub: f.hiredAvgScore ? `avg ${f.hiredAvgScore}/100` : undefined, tone: 'info' },
|
||||
]),
|
||||
'',
|
||||
f.stalled.length
|
||||
? `Most urgent: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still sitting at AI Screened.`
|
||||
: f.unscreened.length
|
||||
? `Most urgent: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
|
||||
: 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
|
||||
heading('Pipeline'),
|
||||
funnel(f.funnel.map((stage, i) => ({
|
||||
label: stage.label,
|
||||
count: stage.count,
|
||||
rate: i === 0 ? undefined : f.transitions[i - 1]?.rate,
|
||||
}))),
|
||||
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}%`,
|
||||
ok: 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.`,
|
||||
},
|
||||
{
|
||||
@@ -72,148 +83,201 @@ const hiringHealth = (f) => {
|
||||
|
||||
const passing = checks.filter((c) => c.ok).length;
|
||||
|
||||
return compose(
|
||||
return doc(
|
||||
heading(`${passing} of ${checks.length} signals healthy`),
|
||||
'',
|
||||
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.value}\n ${c.note}`).join('\n\n')
|
||||
status(checks),
|
||||
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 actions = [];
|
||||
|
||||
if (f.stalled.length) {
|
||||
actions.push(`**Move your 80+ candidates.** ${f.stalled.slice(0, 3).map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`);
|
||||
const items = buildActions(f);
|
||||
if (!items.length) {
|
||||
return answer('Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.');
|
||||
}
|
||||
if (f.unscreened.length) {
|
||||
actions.push(`**Screen the backlog.** ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} no score. One pass clears it.`);
|
||||
}
|
||||
if (f.starvedPositions.length) {
|
||||
actions.push(`**Fix the postings nobody applies to.** ${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`);
|
||||
}
|
||||
if (f.unratedStaff.length) {
|
||||
actions.push(`**Rate your hires.** ${plural(f.unratedStaff.length, 'person', 'people')} ${verb(f.unratedStaff.length, 'has', 'have')} no rating. Ratings sharpen future matching.`);
|
||||
}
|
||||
if (f.elite.length) {
|
||||
actions.push(`**Reach out directly.** ${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`);
|
||||
}
|
||||
|
||||
if (!actions.length) return 'Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.';
|
||||
|
||||
return compose(heading('Pending actions'), '', numbered(actions.slice(0, 4)));
|
||||
return doc(heading('Pending actions', 'Ordered by what costs you most'), actions(items.slice(0, 4)));
|
||||
};
|
||||
|
||||
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) {
|
||||
risks.push(`**Single-candidate roles.** ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`);
|
||||
}
|
||||
if (f.stalled.length) {
|
||||
risks.push(`**Losing strong candidates to delay.** ${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'has', 'have')} been screened but not moved. This is where good hires quietly disappear.`);
|
||||
}
|
||||
if (f.missingCredentials.length) {
|
||||
risks.push(`**Compliance exposure.** ${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`);
|
||||
}
|
||||
if (f.narrowAvailability.length) {
|
||||
risks.push(`**Availability risk.** ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. ${verb(f.narrowAvailability.length, 'This is the placement', 'These are the placements')} most likely to fall through on the day.`);
|
||||
}
|
||||
if (f.starvedPositions.length) {
|
||||
risks.push(`**Unfillable as posted.** ${plural(f.starvedPositions.length, 'open role')} with zero applicants after being live.`);
|
||||
}
|
||||
if (f.flagged.length) {
|
||||
risks.push(`**Interview integrity.** ${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`);
|
||||
if (!risks.length) {
|
||||
return answer('No material risks: coverage is good, no role depends on a single candidate, and credentials check out.');
|
||||
}
|
||||
|
||||
if (!risks.length) return 'No material risks in the current pipeline: coverage is good, no role depends on a single candidate, and credentials check out.';
|
||||
|
||||
return compose(
|
||||
return doc(
|
||||
heading(`${plural(risks.length, 'risk')} to watch`),
|
||||
'',
|
||||
numbered(risks),
|
||||
'',
|
||||
'_Flags to check, not conclusions. Each is a question for a human._'
|
||||
insights(risks),
|
||||
note('Flags to check, not conclusions. Each is a question for a human.')
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Candidates ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const resumeAnalysis = (f) => {
|
||||
const subject = f.top[0];
|
||||
if (!subject) return 'No scored candidates yet. Screen the pipeline and I will analyse the strongest.';
|
||||
const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability'];
|
||||
|
||||
return compose(
|
||||
heading(`${subject.applicant_name} — ${subject.ai_score}/100`),
|
||||
`${subject.job_title} · ${plural(subject.years_experience, 'year')} experience · ${subject.english_level} English`,
|
||||
'',
|
||||
subject.ai_summary || '',
|
||||
'',
|
||||
subject.ai_strengths?.length ? compose(heading('What holds up'), bullets(subject.ai_strengths)) : '',
|
||||
'',
|
||||
subject.ai_gaps?.length ? compose(heading('What to probe'), bullets(subject.ai_gaps)) : '',
|
||||
'',
|
||||
subject.certifications?.length
|
||||
? `**Credentials:** ${subject.certifications.join(', ')} — verify before the first shift. A lapsed card is the most common surprise.`
|
||||
: '**No credentials on file.** Request these before scheduling.'
|
||||
const resumeAnalysis = (f) => {
|
||||
const c = f.top[0];
|
||||
if (!c) return answer('No scored candidates yet. Screen the pipeline and I will analyse the strongest.');
|
||||
|
||||
return doc(
|
||||
heading(c.applicant_name, `${c.job_title} · ${plural(c.years_experience, 'year')} · ${c.english_level} English`),
|
||||
kpis([
|
||||
{ label: 'AI score', value: `${c.ai_score}`, tone: c.ai_score >= 80 ? 'success' : c.ai_score >= 60 ? 'info' : 'warning' },
|
||||
{ label: 'Verdict', value: c.ai_match_label || '—' },
|
||||
]),
|
||||
c.ai_summary && text(c.ai_summary),
|
||||
c.certifications?.length
|
||||
? [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 pair = f.ranked.slice(0, 2);
|
||||
if (pair.length < 2) return 'I need at least two scored candidates to compare.';
|
||||
const [a, b] = f.ranked;
|
||||
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 dims = ['experience', 'english', 'reliability', 'certifications', 'availability'];
|
||||
const first = (n) => n.split(' ')[0];
|
||||
|
||||
const rows = dims.map((dim) => {
|
||||
const av = a.score_breakdown?.[dim] ?? 0;
|
||||
const bv = b.score_breakdown?.[dim] ?? 0;
|
||||
const lead = av === bv ? 'tied' : (av > bv ? a : b).applicant_name.split(' ')[0];
|
||||
return `${dim.charAt(0).toUpperCase() + dim.slice(1)} — ${av} vs ${bv} · ${lead}`;
|
||||
const rows = DIMENSIONS.map((d) => {
|
||||
const av = a.score_breakdown?.[d] ?? 0;
|
||||
const bv = b.score_breakdown?.[d] ?? 0;
|
||||
return {
|
||||
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?.[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(
|
||||
heading(`${a.applicant_name} (${a.ai_score}) vs ${b.applicant_name} (${b.ai_score})`),
|
||||
'',
|
||||
bullets(rows),
|
||||
'',
|
||||
gap <= 3
|
||||
? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
|
||||
: `${a.applicant_name} leads by ${gap}, driven mostly by ${biggest}.`
|
||||
return doc(
|
||||
heading(`${a.applicant_name} vs ${b.applicant_name}`),
|
||||
table(
|
||||
[
|
||||
{ key: 'dimension', label: '' },
|
||||
{ key: 'a', label: first(a.applicant_name), align: 'right' },
|
||||
{ key: 'b', label: first(b.applicant_name), align: 'right' },
|
||||
],
|
||||
[
|
||||
{ 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 subject = f.stalled[0] || f.ranked[0];
|
||||
if (!subject) return 'Screen a candidate and I will build questions around their specific gaps.';
|
||||
const c = f.stalled[0] || f.ranked[0];
|
||||
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(
|
||||
heading(`Questions for ${subject.applicant_name}`),
|
||||
'Built from their gaps rather than a generic list, so the interview tests what the score could not.',
|
||||
'',
|
||||
numbered([
|
||||
`Walk me through your busiest ${subject.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
|
||||
return doc(
|
||||
heading(`Questions for ${c.applicant_name}`, 'Built from their gaps, not a generic list'),
|
||||
list([
|
||||
`Walk me through your busiest ${c.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
|
||||
gaps[0]
|
||||
? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?`
|
||||
: 'Tell me about a shift that went wrong. What was your part in it?',
|
||||
'Something breaks mid-service and your lead is unreachable. What are your next three moves?',
|
||||
subject.certifications?.length
|
||||
? `Your ${subject.certifications[0]} is on file — when did you last apply it on a live shift?`
|
||||
c.certifications?.length
|
||||
? `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?',
|
||||
'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) => {
|
||||
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 = {};
|
||||
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);
|
||||
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(
|
||||
heading('Recurring gaps'),
|
||||
'',
|
||||
bullets(ranked.map(([gap, count]) => `${gap} — ${count} of ${f.scored.length}`)),
|
||||
'',
|
||||
f.missingCredentials.length >= 2
|
||||
? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
|
||||
: 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
|
||||
return doc(
|
||||
heading('Recurring gaps', `Across ${plural(f.scored.length, 'scored candidate')}`),
|
||||
table(
|
||||
[{ key: 'gap', label: 'Gap' }, { key: 'count', label: 'Candidates', align: 'right' }],
|
||||
ranked.map(([gap, count]) => ({
|
||||
gap,
|
||||
count: { value: `${count}/${f.scored.length}`, tone: count > f.scored.length / 2 ? 'risk' : undefined },
|
||||
}))
|
||||
),
|
||||
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) => {
|
||||
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 shortlist = f.ranked.filter((a) => a.ai_score >= 80 && open(a));
|
||||
const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a));
|
||||
const pass = f.weak.filter(open);
|
||||
const groups = [
|
||||
{ verdict: 'Shortlist', tone: 'success', people: f.ranked.filter((a) => a.ai_score >= 80 && open(a)) },
|
||||
{ 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'),
|
||||
'',
|
||||
shortlist.length
|
||||
? `**Shortlist** — ${shortlist.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}`
|
||||
: '**Shortlist** — nobody currently clears 80.',
|
||||
'',
|
||||
interview.length ? `**Interview** — ${interview.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
|
||||
'',
|
||||
pass.length ? `**Likely pass** — ${pass.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
|
||||
'',
|
||||
'_A recommendation, not a decision. I score what is on file — I have not met these people._'
|
||||
table(
|
||||
[
|
||||
{ key: 'name', label: 'Candidate' },
|
||||
{ key: 'score', label: 'Score', align: 'right' },
|
||||
{ key: 'verdict', label: 'Verdict', align: 'right' },
|
||||
],
|
||||
groups.flatMap((g) => g.people.map((p) => ({
|
||||
name: p.applicant_name,
|
||||
score: { value: p.ai_score, tone: g.tone },
|
||||
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 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const explainCharts = (f) => compose(
|
||||
const explainCharts = (f) => doc(
|
||||
heading('What these charts are saying'),
|
||||
'',
|
||||
`**Pipeline Stages** — ${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
|
||||
'',
|
||||
`**Candidate Scores** — ${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length}; that bucket is your real hiring pool.`,
|
||||
'',
|
||||
`**Interview Completion** — ${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
|
||||
'',
|
||||
`**Cost Saved** — $${f.costSaved.toLocaleString()}, from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80. Industry per-unit costs for doing it manually, so treat it as a floor.`
|
||||
insights([
|
||||
{
|
||||
tone: 'info',
|
||||
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.`,
|
||||
},
|
||||
{
|
||||
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) => {
|
||||
@@ -278,124 +374,150 @@ const forecastHiring = (f) => {
|
||||
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
|
||||
const short = f.openPositions.length - expected;
|
||||
|
||||
return compose(
|
||||
return doc(
|
||||
heading('Forecast'),
|
||||
'',
|
||||
bullets([
|
||||
`${plural(available.length, 'candidate')} at 60+ still in play`,
|
||||
`At your ${conversion}% hire rate that projects to about ${plural(expected, 'hire')}`,
|
||||
f.timeToHire ? `At a ${f.timeToHire}-day average, those land within a week of you deciding` : 'No time-to-hire baseline yet',
|
||||
`${plural(f.openPositions.length, 'open position')} to fill`,
|
||||
kpis([
|
||||
{ label: 'In play at 60+', value: available.length },
|
||||
{ label: 'Projected hires', value: expected, tone: short > 0 ? 'warning' : 'success' },
|
||||
{ label: 'Open roles', value: f.openPositions.length },
|
||||
{ label: 'Conversion', value: `${conversion}%` },
|
||||
]),
|
||||
'',
|
||||
short > 0
|
||||
? `That leaves you roughly ${plural(short, 'role')} short. Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`
|
||||
: 'That covers your open roles, assuming the strong candidates do not go elsewhere first.',
|
||||
'',
|
||||
'_Projected from your own conversion rate, not a promise — small pools move a lot._'
|
||||
? insights([{
|
||||
tone: 'warning',
|
||||
title: `Roughly ${plural(short, 'role')} short`,
|
||||
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) => {
|
||||
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 = {};
|
||||
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].hired += r.hired;
|
||||
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 starved = entries.filter(([, d]) => d.applied === 0);
|
||||
|
||||
return compose(
|
||||
return doc(
|
||||
heading('By role category'),
|
||||
'',
|
||||
entries.map(([category, d]) =>
|
||||
`**${category}** — ${plural(d.applied, 'applicant')}, ${d.qualified} at 70+, ${plural(d.hired, 'hire')} across ${plural(d.roles.length, 'role')}`
|
||||
).join('\n'),
|
||||
'',
|
||||
starved.length
|
||||
? `${starved.map(([c]) => c).join(' and ')} ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
|
||||
: 'Every category has applicants, so your reach is working across the board.'
|
||||
table(
|
||||
[
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'applied', label: 'Applied', align: 'right' },
|
||||
{ key: 'qualified', label: '70+', align: 'right' },
|
||||
{ key: 'hired', label: 'Hired', align: 'right' },
|
||||
],
|
||||
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 stamp = f.today.toISOString().slice(0, 10);
|
||||
|
||||
return compose(
|
||||
heading(`Hiring report — ${stamp}`),
|
||||
'',
|
||||
heading('Volume'),
|
||||
bullets([
|
||||
`${plural(f.total, 'applicant')} received`,
|
||||
`${f.screened.length} screened (${f.standardizedPct}% coverage)`,
|
||||
`${plural(f.interviews.length, 'AI interview')} conducted, ${f.completedInterviews.length} scored`,
|
||||
`${plural(f.hired.length, 'hire')} closed`,
|
||||
]),
|
||||
'',
|
||||
heading('Quality'),
|
||||
bullets([
|
||||
`Average candidate score ${f.avgScore}`,
|
||||
`Average hire score ${f.hiredAvgScore}`,
|
||||
`Hire rate ${f.hireRate}%`,
|
||||
`Average time-to-hire ${f.timeToHire ? `${f.timeToHire} days` : 'no baseline'}`,
|
||||
]),
|
||||
'',
|
||||
heading('Efficiency'),
|
||||
bullets([
|
||||
`$${f.costSaved.toLocaleString()} saved against manual screening and interviewing`,
|
||||
`≈${f.hoursSaved} recruiter hours returned`,
|
||||
]),
|
||||
'',
|
||||
heading('By role'),
|
||||
bullets(f.byRole.map((r) => `${r.title} — ${r.applied} applied, ${r.qualified} at 70+, ${r.hired} hired`)),
|
||||
'',
|
||||
const generateReports = (f) => doc(
|
||||
// Local date, not toISOString — UTC would show yesterday for anyone behind it.
|
||||
heading('Hiring report', f.today.toLocaleDateString(undefined, {
|
||||
year: 'numeric', month: 'long', day: 'numeric',
|
||||
})),
|
||||
kpis([
|
||||
{ label: 'Applicants', value: f.total },
|
||||
{ label: 'Screened', value: `${f.standardizedPct}%` },
|
||||
{ label: 'Hires', value: f.hired.length },
|
||||
{ label: 'Hire rate', value: `${f.hireRate}%` },
|
||||
{ label: 'Avg hire score', value: f.hiredAvgScore || '—', tone: 'success' },
|
||||
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' },
|
||||
]),
|
||||
heading('Funnel'),
|
||||
funnel(f.funnel.map((s, i) => ({ label: s.label, count: s.count, rate: i === 0 ? undefined : f.transitions[i - 1]?.rate }))),
|
||||
heading('By role'),
|
||||
table(
|
||||
[
|
||||
{ key: 'title', label: 'Role' },
|
||||
{ key: 'applied', label: 'Applied', align: 'right' },
|
||||
{ key: 'qualified', label: '70+', align: 'right' },
|
||||
{ key: 'hired', label: 'Hired', align: 'right' },
|
||||
],
|
||||
f.byRole.map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified, hired: r.hired }))
|
||||
),
|
||||
heading('Efficiency'),
|
||||
kpis([
|
||||
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
|
||||
{ label: 'Recruiter hours', value: `${f.hoursSaved}h`, tone: 'success' },
|
||||
]),
|
||||
heading('Headline finding'),
|
||||
insights([
|
||||
f.bottleneck
|
||||
? `**Headline finding.** The ${f.bottleneck.from} → ${f.bottleneck.to} step passes only ${f.bottleneck.rate}%, losing ${plural(f.bottleneck.lost, 'candidate')}. That is the single biggest recoverable loss in the funnel.`
|
||||
: '**Headline finding.** The funnel is passing through evenly with no single bottleneck.',
|
||||
'',
|
||||
'_Copy this into your own template — export to PDF and CSV is not wired up in this build._'
|
||||
);
|
||||
};
|
||||
? {
|
||||
tone: 'warning',
|
||||
title: `${f.bottleneck.from} → ${f.bottleneck.to} passes only ${f.bottleneck.rate}%`,
|
||||
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 ───────────────────────────────────────────────── */
|
||||
|
||||
const has = (q, ...words) => words.some((w) => q.includes(w));
|
||||
|
||||
const stateOfPlay = (f) =>
|
||||
`I do not have a specific read on that. What I can see: ${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}, ${f.screened.length} screened, ${plural(f.interviewing.length, 'candidate')} interviewing, ${plural(f.hired.length, 'hire')}. Ask about any of those and I will go deeper.`;
|
||||
const stateOfPlay = (f) => doc(
|
||||
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) => {
|
||||
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 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.',
|
||||
Interviewed: 'Shortlisted candidates are not reaching interview — usually scheduling friction rather than a decision.',
|
||||
Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.',
|
||||
}[b.to];
|
||||
|
||||
return compose(
|
||||
heading(`Bottleneck: ${b.from} → ${b.to}`),
|
||||
`Only ${b.rate}% pass through, losing ${plural(b.lost, 'candidate')}.`,
|
||||
'',
|
||||
diagnosis || '',
|
||||
'',
|
||||
bullets(f.transitions.map((t) => `${t.from} → ${t.to}: ${t.rate}%${t.lost ? ` (−${t.lost})` : ''}`))
|
||||
return doc(
|
||||
heading(`Bottleneck: ${b.from} → ${b.to}`, `${b.rate}% pass through`),
|
||||
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 }])
|
||||
);
|
||||
};
|
||||
|
||||
export const respondOverview = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f);
|
||||
if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next')) return pendingActions(f);
|
||||
if (has(q, '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, '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, 'position', 'posting', 'role', 'opening')) return departmentInsights(f);
|
||||
return stateOfPlay(f);
|
||||
@@ -403,36 +525,44 @@ export const respondOverview = (question, f) => {
|
||||
|
||||
export const respondCandidates = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze')) return resumeAnalysis(f);
|
||||
if (has(q, 'compare', 'versus', ' vs ', 'between')) return candidateComparison(f);
|
||||
if (has(q, 'compare', 'versus', ' vs ', 'between', '&')) return candidateComparison(f);
|
||||
if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(f);
|
||||
if (has(q, 'gap', 'missing', 'weakness', 'skill')) return skillGapAnalysis(f);
|
||||
if (has(q, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f);
|
||||
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze', 'review')) return resumeAnalysis(f);
|
||||
if (has(q, 'unscreened', 'not screened', 'pending')) {
|
||||
return f.unscreened.length
|
||||
? compose(
|
||||
? doc(
|
||||
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
|
||||
'',
|
||||
bullets(f.unscreened.slice(0, 8).map((a) => `${a.applicant_name} — ${a.job_title}`)),
|
||||
f.unscreened.length > 8 ? `…and ${f.unscreened.length - 8} more` : ''
|
||||
table(
|
||||
[{ key: 'name', label: 'Candidate' }, { key: 'role', label: 'Applied for' }],
|
||||
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);
|
||||
};
|
||||
|
||||
export const respondAnalytics = (question, f) => {
|
||||
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, 'forecast', 'predict', 'projection', 'will i')) return forecastHiring(f);
|
||||
if (has(q, 'department', 'category', 'role', 'team')) return departmentInsights(f);
|
||||
if (has(q, 'report', 'export', 'summar', 'download')) return generateReports(f);
|
||||
if (has(q, 'bottleneck', 'drop', 'stuck', 'funnel', 'lose')) return bottleneckAnswer(f);
|
||||
if (has(q, 'hire rate', 'conversion')) {
|
||||
return `${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`;
|
||||
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')) {
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -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 p = f.profile;
|
||||
if (!p) return NO_PROFILE;
|
||||
if (!p) return answer(NO_PROFILE);
|
||||
|
||||
const missing = [];
|
||||
if (!p.phone) missing.push('phone number');
|
||||
if (!p.address) missing.push('location');
|
||||
if (!(p.skills || []).length) missing.push('skills');
|
||||
if (!(p.experience || []).length) missing.push('work history');
|
||||
if (!(p.certifications || []).length) missing.push('certifications');
|
||||
if (!p.career_goals) missing.push('career goals');
|
||||
if (!(p.availability || []).length) missing.push('availability');
|
||||
if (!p.selfie_url) missing.push('photo');
|
||||
const fields = [
|
||||
{ label: 'Photo', ok: Boolean(p.selfie_url), note: 'The KROW ID replaces a résumé — a profile with a face gets opened more.' },
|
||||
{ label: 'Availability', ok: (p.availability || []).length > 0, note: 'Employers filter on this before they read anything else.' },
|
||||
{ label: 'Certifications', ok: (p.certifications || []).length > 0, note: 'A Food Handler Card is the cheapest credential in this industry.' },
|
||||
{ label: 'Skills', ok: (p.skills || []).length > 0, note: 'Without these you do not appear in skill searches.' },
|
||||
{ label: 'Work history', ok: (p.experience || []).length > 0, note: 'Even one role gives employers something to anchor on.' },
|
||||
{ label: 'Career goals', ok: Boolean(p.career_goals), note: 'Lets matching put you in front of the right roles, not every role.' },
|
||||
{ label: 'Verified evidence', ok: (p.capabilities || []).length > 0, note: 'The only part of your profile an employer does not have to take on trust.' },
|
||||
];
|
||||
|
||||
const present = [];
|
||||
if ((p.experience || []).length) present.push(`${plural(p.experience.length, 'role')} of work history`);
|
||||
if ((p.skills || []).length) present.push(`${plural(p.skills.length, 'skill')} listed`);
|
||||
if ((p.certifications || []).length) present.push(p.certifications.join(', '));
|
||||
if ((p.capabilities || []).length) present.push(`${plural(p.capabilities.length, 'capability')} verified by evidence`);
|
||||
const complete = fields.filter((x) => x.ok).length;
|
||||
|
||||
return compose(
|
||||
heading(`Your profile is ${p.profile_completion || 0}% complete`),
|
||||
'',
|
||||
present.length ? compose(heading('What is working'), bullets(present)) : '',
|
||||
'',
|
||||
missing.length
|
||||
? compose(
|
||||
heading('What is missing'),
|
||||
bullets(missing),
|
||||
'',
|
||||
'Employers filter on availability and certifications before they read anything else. If you fix two things, fix those.'
|
||||
)
|
||||
: compose(heading('Nothing is missing'), 'From here, more verified evidence moves you — not more fields.'),
|
||||
'',
|
||||
(p.capabilities || []).length
|
||||
? '_Your verified capabilities are the part employers actually trust — those came with evidence attached, not a checkbox._'
|
||||
: '_Nothing is verified yet. One completed challenge is worth more than ten claimed skills._'
|
||||
return doc(
|
||||
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 }))),
|
||||
(p.certifications || []).length && [
|
||||
heading('Credentials on file'),
|
||||
badges(p.certifications.map((label) => ({ label, tone: 'success' }))),
|
||||
],
|
||||
text(
|
||||
complete === fields.length
|
||||
? 'Nothing is missing. From here, more verified evidence is what moves you — not more fields.'
|
||||
: 'If you fix two things, fix availability and certifications. Those are the two employers filter on first.'
|
||||
),
|
||||
note(
|
||||
(p.capabilities || []).length
|
||||
? 'Your verified capabilities came with evidence attached, not a checkbox. That is why they carry weight.'
|
||||
: 'Nothing is verified yet. One completed challenge is worth more than ten claimed skills.'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const careerGuidance = (f) => {
|
||||
const p = f.profile;
|
||||
if (!p) return NO_PROFILE;
|
||||
if (!p) return answer(NO_PROFILE);
|
||||
|
||||
const score = scoreOf(p);
|
||||
const band = getScoreBand(score);
|
||||
const target = score >= 90 ? null : score >= 75 ? 90 : score >= 60 ? 75 : 60;
|
||||
|
||||
return compose(
|
||||
heading(`You are at ${toFICO(score)} — ${band.label}`),
|
||||
'',
|
||||
bullets([
|
||||
`Reliability ${p.reliability_score || 0}/100`,
|
||||
`Attendance ${p.attendance_score ?? 0}/100`,
|
||||
`${plural((p.completed_courses || []).length, 'course')} completed, ${plural((p.earned_badges || []).length, 'badge')} earned`,
|
||||
`${plural((p.capabilities || []).length, 'verified capability')}`,
|
||||
`${plural(p.shifts_completed || 0, 'shift')} worked`,
|
||||
return doc(
|
||||
heading(`${toFICO(score)} — ${band.label}`, 'Career score, on a 300–850 scale'),
|
||||
kpis([
|
||||
{ label: 'Reliability', value: p.reliability_score || 0, tone: (p.reliability_score || 0) >= 70 ? 'success' : 'warning' },
|
||||
{ label: 'Attendance', value: p.attendance_score ?? 0, tone: (p.attendance_score ?? 0) >= 90 ? 'success' : undefined },
|
||||
{ label: 'Shifts worked', value: p.shifts_completed || 0, tone: (p.shifts_completed || 0) === 0 ? 'warning' : undefined },
|
||||
{ label: 'Verified skills', value: (p.capabilities || []).length },
|
||||
]),
|
||||
'',
|
||||
target
|
||||
? `**To reach ${target}:** verified work is the fastest lever. Courses move the score; verified shifts move it more, because employers weight what someone else confirmed over what you claimed.`
|
||||
: '**You are in the top band.** From here it is about holding it — the score decays if shifts stop.',
|
||||
'',
|
||||
p.desired_position
|
||||
? `You want to be ${p.desired_position}. ${score >= 75 ? 'You are already scoring in the range employers shortlist for that.' : 'The gap to that role is verification, not ambition.'}`
|
||||
: 'You have not set a target role. Setting one lets me tell you which courses matter and which are noise.',
|
||||
'',
|
||||
(p.shifts_completed || 0) === 0
|
||||
? '**One concrete step:** work a single shift. Attendance, ratings and reliability all need a first data point before anything else compounds.'
|
||||
: ''
|
||||
heading("What's driving it"),
|
||||
meters([
|
||||
{ label: 'Reliability', value: p.reliability_score || 0, weight: 25 },
|
||||
{ label: 'Attendance', value: p.attendance_score ?? 0, weight: 20 },
|
||||
{ label: 'Communication', value: p.performance_score || 0, weight: 15 },
|
||||
{ label: 'Leadership', value: p.leadership_potential || 0, weight: 15 },
|
||||
{ label: 'Learning', value: Math.min(100, (p.completed_courses || []).length * 8 + (p.xp || 0) / 10), weight: 15 },
|
||||
{ label: 'Problem solving', value: p.ai_interview_score || 0, weight: 10 },
|
||||
]),
|
||||
insights([
|
||||
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 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 done = (p.completed_courses || []).length;
|
||||
const shifts = p.shifts_completed || 0;
|
||||
const available = (f.courses || []).filter((c) => c.status === 'active' && !completedIds.has(c.id));
|
||||
|
||||
if (!available.length) {
|
||||
return compose(
|
||||
return doc(
|
||||
heading('You have completed everything available'),
|
||||
`${plural(done, 'course')} done, ${plural((p.earned_badges || []).length, 'badge')} earned, ${p.xp || 0} XP.`,
|
||||
'',
|
||||
'The next lever is verified shifts — that is what employers weight most.'
|
||||
kpis([
|
||||
{ label: 'Courses', value: done, tone: 'success' },
|
||||
{ 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 shifts = p.shifts_completed || 0;
|
||||
const ranked = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0));
|
||||
|
||||
return compose(
|
||||
heading('What to learn next'),
|
||||
'',
|
||||
numbered(byValue.slice(0, 3).map((c) => {
|
||||
const gate = c.unlock_requirements?.min_shifts || 0;
|
||||
const locked = gate > shifts;
|
||||
return `**${c.title}** — ${c.difficulty}, ${c.estimated_minutes} min, +${c.xp} XP\n Proves: ${c.proof_skill || c.title}.${locked ? ` Locked until ${plural(gate, 'shift')} (you have ${shifts}).` : ' Available now.'}`;
|
||||
})),
|
||||
'',
|
||||
return doc(
|
||||
heading('What to learn next', 'Ranked by what moves your score most'),
|
||||
table(
|
||||
[
|
||||
{ key: 'title', label: 'Course' },
|
||||
{ key: 'minutes', label: 'Time', align: 'right' },
|
||||
{ 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'),
|
||||
bullets([
|
||||
`${plural(done, 'course')} completed`,
|
||||
`${plural((p.earned_badges || []).length, 'badge')} earned`,
|
||||
`${p.xp || 0} XP`,
|
||||
`${plural((p.capabilities || []).length, 'capability')} verified`,
|
||||
kpis([
|
||||
{ label: 'Courses done', value: done },
|
||||
{ label: 'Badges', value: (p.earned_badges || []).length },
|
||||
{ label: 'XP', value: p.xp || 0 },
|
||||
{ label: 'Verified skills', value: (p.capabilities || []).length },
|
||||
]),
|
||||
'',
|
||||
done === 0
|
||||
? 'Start with the shortest one. The point of the first course is not the XP — it is getting one verified capability on the board.'
|
||||
: done < 3
|
||||
? `Three completions is where the score starts moving noticeably, because education carries real weight in the formula. You are at ${done}.`
|
||||
: 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.',
|
||||
'',
|
||||
'Each of these ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored. That is why the badge means something to an employer.'
|
||||
actions([
|
||||
{
|
||||
title: done === 0 ? `Start with ${ranked[ranked.length - 1].title}` : `Take ${ranked[0].title}`,
|
||||
body: done === 0
|
||||
? 'The point of the first course is not the XP — it is getting one verified capability on the board.'
|
||||
: done < 3
|
||||
? `Three completions is where the score starts moving noticeably. You are at ${done}.`
|
||||
: '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) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'resume', 'résumé', 'cv', 'profile')) return resumeReview(f);
|
||||
if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge')) return learningRecommendations(f);
|
||||
if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next')) return careerGuidance(f);
|
||||
if (has(q, 'resume', 'résumé', 'cv', 'profile', 'improve')) return resumeReview(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', 'started')) return careerGuidance(f);
|
||||
|
||||
const p = f.profile;
|
||||
return p
|
||||
? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?`
|
||||
: NO_PROFILE;
|
||||
return answer(
|
||||
p
|
||||
? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?`
|
||||
: NO_PROFILE
|
||||
);
|
||||
};
|
||||
|
||||
export const TALENT_CAPABILITIES = [
|
||||
|
||||
@@ -11,18 +11,10 @@ import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent';
|
||||
/**
|
||||
* Page contexts for the Krow AI Assistant.
|
||||
*
|
||||
* The component is identical on every page. Only what is in this file changes:
|
||||
* the supporting line, the placeholder examples, the suggested chips, the
|
||||
* capabilities and the responder.
|
||||
*
|
||||
* Chips are phrased as things a person would actually type, because clicking one
|
||||
* fills the input rather than firing an action. The user always sees the
|
||||
* question before it is asked, so the assistant reads as a conversation rather
|
||||
* than a menu of buttons.
|
||||
*
|
||||
* `capability` binds a chip to a specific capability, so a chip gets a precise
|
||||
* answer while an equivalent typed question still resolves through the
|
||||
* responder's keyword matching.
|
||||
* Deliberately thin. What the user sees on the landing screen — the title,
|
||||
* greeting, description, placeholders and suggested prompts — is computed from
|
||||
* 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.
|
||||
*
|
||||
* `needsWorkerProfile` gates the one query with a side effect, so employer and
|
||||
* admin sessions never touch worker-profile records.
|
||||
@@ -31,129 +23,42 @@ export const ASSISTANT_CONTEXTS = {
|
||||
'employer.overview': {
|
||||
id: 'employer.overview',
|
||||
page: 'Overview',
|
||||
tagline: 'Find insights from your hiring data, spot what needs attention, and ask questions about this page.',
|
||||
placeholders: [
|
||||
"Ask about today's hiring…",
|
||||
'What should I do first?',
|
||||
'Which roles are at risk?',
|
||||
'How healthy is my pipeline?',
|
||||
],
|
||||
chips: [
|
||||
{ label: "Today's Hiring", prompt: 'Give me a summary of hiring today', capability: 'hiring-summary' },
|
||||
{ label: 'Pipeline Health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
|
||||
{ label: 'Pending Actions', prompt: 'What is pending and needs my attention?', capability: 'pending-actions' },
|
||||
{ label: 'Hiring Risks', prompt: 'What risks should I know about?', capability: 'hiring-risks' },
|
||||
],
|
||||
capabilities: OVERVIEW_CAPABILITIES,
|
||||
respond: respondOverview,
|
||||
},
|
||||
|
||||
'employer.candidates': {
|
||||
id: 'employer.candidates',
|
||||
page: 'Candidates',
|
||||
tagline: 'Analyse résumés, compare candidates, generate interview questions, and decide who to move forward.',
|
||||
placeholders: [
|
||||
'Compare two candidates…',
|
||||
'Who should I interview next?',
|
||||
'What are this candidate’s gaps?',
|
||||
'Draft interview questions…',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Top Candidates', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
|
||||
{ label: 'Compare Candidates', prompt: 'Compare my top two candidates', capability: 'candidate-comparison' },
|
||||
{ label: 'Interview Questions', prompt: 'Generate interview questions for my top candidate', capability: 'interview-questions' },
|
||||
{ label: 'Skill Gap Analysis', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
|
||||
{ label: 'Hiring Recommendation', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
|
||||
],
|
||||
capabilities: CANDIDATE_CAPABILITIES,
|
||||
respond: respondCandidates,
|
||||
},
|
||||
|
||||
'employer.analytics': {
|
||||
id: 'employer.analytics',
|
||||
page: 'Analytics',
|
||||
tagline: 'Explain the numbers on this page, forecast what your pipeline will yield, and generate reports.',
|
||||
placeholders: [
|
||||
'Explain this chart…',
|
||||
'Generate a hiring report…',
|
||||
'Where do candidates drop off?',
|
||||
'How many hires can I expect?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Explain Charts', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
|
||||
{ label: 'Forecast Hiring', prompt: 'Forecast my hiring from the current pipeline', capability: 'forecast-hiring' },
|
||||
{ label: 'Department Insights', prompt: 'Break down performance by role category', capability: 'department-insights' },
|
||||
{ label: 'Generate Report', prompt: 'Generate a hiring report', capability: 'generate-reports' },
|
||||
],
|
||||
capabilities: ANALYTICS_CAPABILITIES,
|
||||
respond: respondAnalytics,
|
||||
},
|
||||
|
||||
'admin.controlCenter': {
|
||||
id: 'admin.controlCenter',
|
||||
page: 'Control Center',
|
||||
tagline: 'Check platform health, reconcile the data, and see what needs intervention across every account.',
|
||||
placeholders: [
|
||||
'Is anything broken?',
|
||||
'What should I fix first?',
|
||||
'How is the platform performing?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Platform Health', prompt: 'Run a platform health check', capability: 'platform-health' },
|
||||
{ label: 'Recommendations', prompt: 'What should I fix first?', capability: 'recommendations' },
|
||||
],
|
||||
capabilities: CONTROL_CENTER_CAPABILITIES,
|
||||
respond: respondControlCenter,
|
||||
},
|
||||
|
||||
'admin.candidates': {
|
||||
id: 'admin.candidates',
|
||||
page: 'Candidates Analysis',
|
||||
tagline: 'Analyse talent supply across the platform and see where the risk sits in the candidate pool.',
|
||||
placeholders: [
|
||||
'How healthy is talent supply?',
|
||||
'Any candidate risk flags?',
|
||||
'What are the recruitment trends?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Recruitment Insights', prompt: 'Show me recruitment insights across the platform', capability: 'recruitment-insights' },
|
||||
{ label: 'Candidate Risk', prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' },
|
||||
],
|
||||
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
|
||||
respond: respondAdminCandidates,
|
||||
},
|
||||
|
||||
'admin.activity': {
|
||||
id: 'admin.activity',
|
||||
page: 'Activity',
|
||||
tagline: 'Summarize the audit trail, reconcile it against real records, and see how accounts are behaving.',
|
||||
placeholders: [
|
||||
'Summarize the audit trail…',
|
||||
'Who is most active?',
|
||||
'Are there any gaps in the log?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Audit Summary', prompt: 'Summarize the audit trail', capability: 'audit-summary' },
|
||||
{ label: 'User Activity', prompt: 'Which accounts are most active?', capability: 'user-activity' },
|
||||
],
|
||||
capabilities: ACTIVITY_CAPABILITIES,
|
||||
respond: respondActivity,
|
||||
},
|
||||
|
||||
'talent.portal': {
|
||||
id: 'talent.portal',
|
||||
page: 'My Portal',
|
||||
tagline: 'Understand your score, improve your profile, and find the next thing worth learning.',
|
||||
placeholders: [
|
||||
'How do I earn more?',
|
||||
'Review my profile…',
|
||||
'What should I learn next?',
|
||||
],
|
||||
chips: [
|
||||
{ label: 'Review My Profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' },
|
||||
{ label: 'Career Guidance', prompt: 'How do I raise my career score?', capability: 'career-guidance' },
|
||||
{ label: 'What To Learn Next', prompt: 'What should I learn next?', capability: 'learning-recommendations' },
|
||||
],
|
||||
capabilities: TALENT_CAPABILITIES,
|
||||
respond: respondTalent,
|
||||
needsWorkerProfile: true,
|
||||
|
||||
265
src/components/ai-assistant/dynamic.js
Normal file
265
src/components/ai-assistant/dynamic.js
Normal 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) ?? [];
|
||||
}
|
||||
@@ -2,33 +2,32 @@
|
||||
* Provider seam for the Krow AI Assistant.
|
||||
*
|
||||
* The UI never generates an answer and never knows where one came from. It calls
|
||||
* `provider.stream(request)` and renders the chunks. That single boundary is
|
||||
* `provider.stream(request)` and renders the snapshots. That single boundary is
|
||||
* what makes the backend swappable: today a local provider computes answers from
|
||||
* dashboard data, and later an HTTP provider can call a real service without a
|
||||
* single change to the component.
|
||||
* change to the component.
|
||||
*
|
||||
* ── Contract ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* 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 = {
|
||||
* contextId: string, // e.g. 'employer.overview'
|
||||
* capability: string | null, // capability id, or null for free text
|
||||
* question: string, // what the user typed, or the capability label
|
||||
* facts: object, // the dashboard fact sheet (see insights.js)
|
||||
* question: string, // what the user typed, or the chip's prompt
|
||||
* facts: object, // dashboard fact sheet (see insights.js)
|
||||
* signal: AbortSignal, // aborts an in-flight response
|
||||
* }
|
||||
*
|
||||
* Deltas rather than a whole string is deliberate: it is the shape every
|
||||
* streaming completion API returns, so the local provider and a future remote
|
||||
* one are interchangeable without the UI learning a second pattern.
|
||||
*/
|
||||
|
||||
import { getContext } from './contexts';
|
||||
|
||||
/** Words per delta. Chunking by word keeps reflow to once per word. */
|
||||
const WORDS_PER_CHUNK = 3;
|
||||
import { toSnapshots } from './blocks';
|
||||
|
||||
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
|
||||
* 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 {
|
||||
id: 'local',
|
||||
|
||||
@@ -46,21 +45,20 @@ export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
|
||||
const context = getContext(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.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.
|
||||
await sleep(latency);
|
||||
if (signal?.aborted) return;
|
||||
|
||||
const tokens = String(answer).split(/(\s+)/);
|
||||
for (let i = 0; i < tokens.length; i += WORDS_PER_CHUNK * 2) {
|
||||
for (const snapshot of toSnapshots(document)) {
|
||||
if (signal?.aborted) return;
|
||||
yield tokens.slice(i, i + WORDS_PER_CHUNK * 2).join('');
|
||||
await sleep(chunkDelay);
|
||||
yield snapshot;
|
||||
await sleep(frameDelay);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -69,15 +67,15 @@ export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
|
||||
/**
|
||||
* HTTP provider — the path to a real backend.
|
||||
*
|
||||
* Reads Server-Sent-Event style `data:` lines from a streaming endpoint and
|
||||
* yields the text deltas. Unused today; it exists so the shape of the
|
||||
* integration is settled rather than guessed at later. Swapping providers is a
|
||||
* one-line change in `AssistantProvider`.
|
||||
* Reads Server-Sent-Event style `data:` lines and yields a growing block array.
|
||||
* A line may carry a whole block (`{ block: … }`) or a text delta
|
||||
* (`{ delta: "…" }`), which is appended to a trailing text block. Unused today;
|
||||
* it exists so the shape of the integration is settled rather than guessed at.
|
||||
*
|
||||
* The request body sends `contextId`, `capability` and `question` — not the
|
||||
* fact sheet. Dashboard data should be fetched server-side from the caller's own
|
||||
* session rather than posted from the browser, so the client cannot ask about
|
||||
* records it is not entitled to see.
|
||||
* The request body sends `contextId`, `capability` and `question` — not the fact
|
||||
* sheet. Dashboard data should be read server-side from the caller's own session
|
||||
* rather than posted from the browser, so the client cannot ask about records it
|
||||
* is not entitled to see.
|
||||
*/
|
||||
export function createHttpProvider({ endpoint, headers = {} }) {
|
||||
if (!endpoint) throw new Error('createHttpProvider requires an endpoint');
|
||||
@@ -99,28 +97,37 @@ export function createHttpProvider({ endpoint, headers = {} }) {
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const blocks = [];
|
||||
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) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
// The final element may be a partial line; keep it for the next read.
|
||||
// The final element may be a partial line; hold it for the next read.
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
if (parsed.delta) yield parsed.delta;
|
||||
if (parsed.block) blocks.push(parsed.block);
|
||||
else if (parsed.delta) appendDelta(parsed.delta);
|
||||
} catch {
|
||||
// A non-JSON data line is treated as a raw delta.
|
||||
yield payload;
|
||||
appendDelta(payload);
|
||||
}
|
||||
yield [...blocks];
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -128,10 +135,9 @@ export function createHttpProvider({ endpoint, headers = {} }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider the app uses.
|
||||
*
|
||||
* Local by default. Point `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to
|
||||
* switch, with no other code change.
|
||||
* The provider the app uses. Local by default; point
|
||||
* `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to switch, with no other
|
||||
* code change.
|
||||
*/
|
||||
export function createAssistantProvider() {
|
||||
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;
|
||||
|
||||
@@ -48,7 +48,7 @@ export function useConversation({ contextId, facts }) {
|
||||
const storageKey = `krow_assistant:${contextId}`;
|
||||
|
||||
const [messages, setMessages] = React.useState([]);
|
||||
const [pending, setPending] = React.useState(null); // { question, text, thinking }
|
||||
const [pending, setPending] = React.useState(null); // { blocks, thinking }
|
||||
const [error, setError] = React.useState(null);
|
||||
const abortRef = React.useRef(null);
|
||||
|
||||
@@ -93,28 +93,26 @@ export function useConversation({ contextId, facts }) {
|
||||
messagesRef.current = withUser;
|
||||
persist(withUser);
|
||||
setError(null);
|
||||
setPending({ question: text, text: '', thinking: true });
|
||||
setPending({ blocks: [], thinking: true });
|
||||
|
||||
try {
|
||||
let assembled = '';
|
||||
for await (const delta of provider.stream({
|
||||
let latest = [];
|
||||
for await (const snapshot of provider.stream({
|
||||
contextId, capability, question: text, facts, signal: controller.signal,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
assembled += delta;
|
||||
setPending({ question: text, text: assembled, thinking: false });
|
||||
latest = snapshot;
|
||||
setPending({ blocks: snapshot, thinking: false });
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
// Keep whatever streamed before the user stopped it — discarding a
|
||||
// half-written answer loses information the user was already reading.
|
||||
if (assembled) {
|
||||
const next = [...messagesRef.current, { role: 'assistant', text: assembled, stopped: true }];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
}
|
||||
} else {
|
||||
const next = [...messagesRef.current, { role: 'assistant', text: assembled }];
|
||||
// Keep whatever arrived before the user stopped it — discarding a
|
||||
// half-written answer loses what they were already reading. Stopping
|
||||
// before the first block, though, should leave no empty turn behind.
|
||||
if (latest.length) {
|
||||
const next = [
|
||||
...messagesRef.current,
|
||||
{ role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
|
||||
];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user