agnets done

This commit is contained in:
2026-08-28 11:02:02 +05:30
parent ab9adde6cd
commit eaf08e061d
58 changed files with 3506 additions and 4308 deletions

View File

@@ -1,13 +1,14 @@
import * as React from 'react';
import {
BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus,
SlidersHorizontal, Trash2, X,
SlidersHorizontal, Trash2, Wrench, X,
} from 'lucide-react';
import {
Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger,
SelectValue, Switch, Textarea,
} from '@/components/ds';
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
import { getSkillsForPage } from '@/lib/skills/registry';
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
import {
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
@@ -96,6 +97,16 @@ function useActiveSection(ids) {
/** @param {any} props */
export function AgentConfigure({
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
/* Attaching and detaching a skill goes through the caller's existing write
path, NOT through `set`. Every other control here writes a draft that Save
persists; a skill persists immediately, on purpose — the whole point of the
catalog is that the agent gains the capability there and then. Two write
paths for one field would mean the Configure screen and the catalog
disagreed about when a skill takes effect. */
onToggleSkill = null, pendingSkill = null,
/* The tools this deployment registers, from GET /api/v1/tools. Passed in
rather than fetched here so this component stays a form over `fields`. */
toolCatalogue = [],
}) {
/* Sections are open by default: this is a document, and one that greets its
author with four closed headers hides the thing they came to write. Closing
@@ -107,6 +118,32 @@ export function AgentConfigure({
const [active, setActive] = useActiveSection(SECTION_IDS);
/**
* The skills these pages offer, and the ones not yet attached.
*
* Derived at render from `fields.pages` rather than held in state, for the
* same reason the rail is: a second copy of a list that is already on screen
* is a second thing to keep correct, and the one that drifts is always the
* copy.
*
* Deduplicated by id because an agent covering several pages will be offered
* the same skill by each of them.
*/
const availableSkills = React.useMemo(() => {
const seen = new Map();
for (const page of fields.pages) {
for (const skill of getSkillsForPage(page, { customSources: customSkills })) {
if (!seen.has(skill.id)) seen.set(skill.id, skill);
}
}
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
}, [fields.pages, customSkills]);
const attachableSkills = React.useMemo(
() => availableSkills.filter((sk) => !fields.skills.includes(sk.id)),
[availableSkills, fields.skills]
);
const set = (patch) => onChange({ ...fields, ...patch });
const instructionWords = React.useMemo(
@@ -176,9 +213,10 @@ export function AgentConfigure({
id: 'capabilities',
icon: Boxes,
label: 'Capabilities',
meta: `${fields.pages.length + fields.knowledge.length}`,
meta: `${fields.pages.length + fields.skills.length + fields.knowledge.length}`,
items: [
{ id: 'capabilities-pages', label: 'Pages', meta: fields.pages.length },
{ id: 'capabilities-skills', label: 'Skills', meta: fields.skills.length },
{ id: 'capabilities-knowledge', label: 'Knowledge', meta: fields.knowledge.length },
],
},
@@ -384,6 +422,176 @@ export function AgentConfigure({
</p>
</div>
{/* Skills ── what it can actually do.
The section this screen was missing. Skills were attachable from
the catalog and invisible here, on the one page that lists
everything else an agent carries — so the reading was that this
agent had none.
Availability is bounded by the pages above, which is the rule the
copy under Pages already states: a page decides which skills
exist there, and an agent chooses among them. It can narrow that
list, never widen it. */}
<div className="pt-4">
<GroupHead
id="capabilities-skills"
icon={Boxes}
title="Skills"
count={fields.skills.length}
action={onOpenSkills && (
<Button variant="ghost" size="sm" onClick={onOpenSkills}>
Browse catalog
</Button>
)}
/>
{!fields.pages.length ? (
<p className="mt-2 text-body-sm text-ink-3">
Choose a page first. Skills belong to pages, so there are none to offer until
this agent has somewhere to answer.
</p>
) : (
<div className="mt-2 space-y-3">
{fields.skills.length > 0 && (
<ul className="divide-y divide-border">
{fields.skills.map((id) => {
const skill = availableSkills.find((sk) => sk.id === id);
return (
<ItemRow
key={id}
icon={Boxes}
title={skill?.name || id}
detail={skill?.description}
/* A skill the pages no longer offer is still
attached and still listed — silently dropping it
would edit the definition behind the author's
back. Flagged instead, so removing it is their
decision. */
warning={skill ? null : 'Not available on the pages above.'}
removeLabel={`Remove ${skill?.name || id}`}
onRemove={onToggleSkill ? () => onToggleSkill(id) : undefined}
/>
);
})}
</ul>
)}
{!fields.skills.length && (
<p className="text-body-sm text-ink-3">
No skills attached. This agent answers from the page&apos;s own reader only.
</p>
)}
<Select
value=""
disabled={!onToggleSkill || Boolean(pendingSkill)}
onValueChange={(id) => !fields.skills.includes(id) && onToggleSkill?.(id)}
>
<SelectTrigger className="w-full sm:w-72">
<SelectValue placeholder={
pendingSkill ? 'Saving...' : 'Add a skill...'
} />
</SelectTrigger>
<SelectContent>
{attachableSkills.map((sk) => (
<SelectItem key={sk.id} value={sk.id}>{sk.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-caption leading-relaxed text-ink-4">
{attachableSkills.length
? 'Attaching a skill takes effect immediately — it does not wait for Save.'
: 'Every skill these pages offer is already attached.'}
</p>
</div>
)}
</div>
{/* Tools ── what it can actually do.
Skills are guidance the model reads; tools are the calls it may
make. An agent with skills and no tools discusses the work and
looks nothing up, which is what every agent authored here was
before this section existed — the editor had no field for it and
the parser dropped `tools:` on the way in.
The catalogue is served by the backend rather than listed here,
so a tool renamed or withdrawn cannot leave a stale option in
this form and an agent built from one that no longer exists. */}
<div className="pt-4">
<GroupHead
id="capabilities-tools"
icon={Wrench}
title="Tools"
count={fields.tools.length}
/>
<div className="mt-2 space-y-3">
{fields.tools.length > 0 && (
<ul className="divide-y divide-border">
{fields.tools.map((name) => {
const tool = toolCatalogue.find((t) => t.name === name);
return (
<ItemRow
key={name}
icon={Wrench}
title={tool?.name || name}
detail={tool?.description}
/* A tool this deployment no longer registers is kept
and flagged rather than dropped: the backend now
refuses to save a definition naming one, so the
author needs to see which. */
warning={tool
? (tool.effect === 'write'
? 'Proposes changes — a person is asked to approve each one.'
: null)
: 'Not available on this deployment.'}
removeLabel={`Remove ${name}`}
onRemove={() => set({ tools: fields.tools.filter((t) => t !== name) })}
/>
);
})}
</ul>
)}
{!fields.tools.length && (
<p className="text-body-sm text-ink-3">
No tools attached. This agent can discuss its subject but cannot look
anything up.
</p>
)}
<Select
value=""
onValueChange={(name) =>
!fields.tools.includes(name) && set({ tools: [...fields.tools, name] })}
>
<SelectTrigger className="w-full sm:w-72">
<SelectValue placeholder={
toolCatalogue.length ? 'Add a tool...' : 'Loading tools...'
} />
</SelectTrigger>
<SelectContent>
{toolCatalogue
.filter((t) => !fields.tools.includes(t.name))
.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.name}{t.effect === 'write' ? ' — writes' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-caption leading-relaxed text-ink-4">
A tool marked <em>writes</em> can propose a change. Nothing is written until
somebody approves it, and the agent can only reach what you could reach
yourself.
</p>
</div>
</div>
{/* Knowledge ── what it has been told. */}
<div className="pt-4">
<GroupHead

View File

@@ -87,7 +87,8 @@ function FeedbackControls({ feedback, onFeedback }) {
export const Message = React.memo(
/** @param {any} props */
({ role, text, blocks, streaming, stopped, onPrompt, feedback = null, onFeedback = null }) => {
({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null,
feedback = null, onFeedback = null }) => {
if (role === 'user') {
return (
<div className="flex justify-end">
@@ -105,7 +106,7 @@ export const Message = React.memo(
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
</div>
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} />
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} onConfirm={onConfirm} />
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
} from 'lucide-react';
@@ -9,28 +10,28 @@ import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import {
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
useMarkInterviewReady, useShiftRecords, useUpdateJobPosting,
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
useUpdateJobPosting,
usePreferences, useRoleCategories,
} from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { runAction } from '@/lib/skills/actions';
import { allSkills, skillsForContext } from '@/lib/skills/registry';
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
import { allSkills, pageKeyForContext } from '@/lib/skills/registry';
import { suggestionChips } from '@/lib/skills/serverSuggestions';
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
import { groupByRecency } from './history';
import { useAssistantPanel } from './AssistantPanelContext';
import { usePageContext } from './PageContext';
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
import { buildIntro, buildPrompts } from './dynamic';
import { agentScopedDisabled, agentStarters } from '@/lib/agents/runtime';
import { buildIntro } from './dynamic';
import { agentScopedDisabled } from '@/lib/agents/runtime';
import { buildOwliverContext } from '@/lib/agents/context';
import { AgentBadge } from './AgentBadge';
import { useActiveAgent } from './AgentContext';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
import { PromptChips } from './PromptChips';
import { rankPrompts } from './matchPrompts';
/**
* The chip row's "nothing to offer", as one shared array.
@@ -41,6 +42,51 @@ import { rankPrompts } from './matchPrompts';
*/
const EMPTY_PROMPTS = [];
/**
* The same, for a suggestion response that has not arrived.
*
* Separate from EMPTY_PROMPTS because they are different kinds of empty: one is
* "this page offers nothing", the other is "the server has not answered yet",
* and sharing an array between them would make a pending request and a
* considered refusal indistinguishable in a dependency list.
*/
const EMPTY_SUGGESTIONS = [];
/**
* How long a pause counts as having finished typing.
*
* Short enough that the chips feel like they are keeping up, long enough that a
* word typed at speed is one request rather than eight. The endpoint is cached
* per query, so a reader deleting back to something already asked pays nothing
* either way.
*/
const SUGGEST_DEBOUNCE_MS = 180;
/**
* A value, held still until it stops changing.
*
* Deliberately generic and local: it debounces the composer's contents and
* nothing else, and the alternative — debouncing inside the query hook — would
* make every other caller of that hook pay for a delay it did not ask for.
*/
function useDebounced(value, delay) {
const [settled, setSettled] = React.useState(value);
React.useEffect(() => {
/* An emptied composer settles immediately. Waiting would leave the previous
query's chips under a blank input for a fifth of a second, which reads as
the panel not having noticed. */
if (!value) {
setSettled(value);
return undefined;
}
const timer = setTimeout(() => setSettled(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return settled;
}
/**
* Owliver History — the conversations that came before.
*
@@ -294,10 +340,6 @@ export default function KrowAssistant({
() => preferences.customSkills || [],
[preferences.customSkills]
);
const skills = React.useMemo(
() => skillsForContext(context.id, disabledSkills, customSkills),
[context.id, disabledSkills, customSkills]
);
/**
* What a declared skill reads from.
@@ -307,6 +349,17 @@ export default function KrowAssistant({
* the card on the page and the answer in the panel are two readings of the
* same data rather than two queries that happen to agree.
*/
/**
* The page, as the API names it.
*
* `pageKeyForContext` is the existing translation from an assistant context
* id to a surface key — `admin.positions` → `positions` — and the surface
* keys are the same closed vocabulary the backend validates `page` against.
* So the panel asks about the page it is on without a second table mapping
* one to the other.
*/
const owliverPage = React.useMemo(() => pageKeyForContext(context.id), [context.id]);
const pageContext = usePageContext();
const { statesFor } = useWorkforcePaths();
const trainingPaths = React.useMemo(
@@ -380,6 +433,37 @@ export default function KrowAssistant({
return createJob.mutateAsync(result.data);
}, [createJob]);
/**
* What to ask next, from the server, after something has been written.
*
* The untyped form of the suggestions endpoint: nothing has been typed, so
* the API ranks against the organization's actual state — the rows the
* mutation just changed. Asking it here rather than deriving an answer in the
* panel is the whole point. The panel knows a position now exists; only the
* server knows whether that makes drafts, starved roles or an unscreened
* queue the thing worth raising, and only it knows what this caller's role
* permits.
*
* `fetchQuery` with `staleTime: 0` rather than a bare request: it resolves
* with the fresh answer *and* leaves it in the cache under the key the hook
* reads, so the chips this reply carries and the chips the composer would
* offer are one answer rather than two requests apart. Zero rather than the
* hook's thirty seconds because a cached answer is exactly what must not be
* used here — it was computed before the row existed.
*/
const queryClient = useQueryClient();
const refreshSuggestions = React.useCallback(async () => {
if (!owliverPage) return [];
const fresh = await queryClient.fetchQuery({
/* The empty query string is the untyped request — the same key
`useOwliverSuggestions` uses when the composer is empty. */
queryKey: ['owliverSuggestions', owliverPage, ''],
queryFn: () => fetchOwliverSuggestions({ page: owliverPage }),
staleTime: 0,
});
return suggestionChips(fresh || [], context.id);
}, [queryClient, owliverPage, context.id]);
/**
* Finishing a draft, through the mutations the Create Position form calls.
*
@@ -463,7 +547,7 @@ export default function KrowAssistant({
const {
messages, pending, error, busy, send, announce, stop, reset,
history, conversationId, openConversation, forgetConversation,
submitFeedback, feedback,
submitFeedback, feedback, confirm,
} = useConversation({
contextId: context.id,
facts,
@@ -471,6 +555,7 @@ export default function KrowAssistant({
onNavigate: goToPage,
onAction: performAction,
onCreatePosition: createPosition,
onRefreshSuggestions: refreshSuggestions,
onUpdatePosition,
onGenerateDescription,
onAssignWorkers,
@@ -532,103 +617,26 @@ export default function KrowAssistant({
() => buildIntro(context.id, facts, userName),
[context.id, facts, userName]
);
/**
* Everything this page can be asked, in the chips that already know how to
* ask it.
*
* Unchanged in what it collects and in what order: the agent's starters, the
* declared suggestions of every attached skill, the skills that carry a
* prompt, and the page's own derived prompts, de-duplicated by intent. Each
* chip keeps the metadata that makes it executable — a page `capability`, a
* `skillId`/`skillCapability` pair, a `positionId`, a `route` — because that
* is what `runPrompt` dispatches on.
*
* What changed is only that this is no longer what the panel renders. It is
* the set that `prompts` below chooses from, so an opening panel can offer
* nothing while a typed query can still reach any of it. Building it eagerly
* costs nothing — it is derived from data already in hand and memoized on it
* — and building it lazily would mean the first keystroke paid for the whole
* catalogue.
*/
const catalogue = React.useMemo(() => {
/* A definition's own suggestions come first: they are the only ones written
for this workspace rather than derived from the page, and they are capped
by the resolver so a workspace with several skills attached cannot bury
the page's own. */
const declared = owliverSuggestions(context.id, disabledSkills, customSkills, pageContext);
const skillPrompts = skills
.filter((s) => s.prompt)
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
/* Three sources can propose the same question — a skill's declared
suggestion and the page's own derived one often word it identically —
and two chips reading "Summarize hiring activity" is both a duplicate
React key and a duplicate offer. First wins, so the definition's own
wording survives and the derived copy drops out. */
/* A declared suggestion that cannot answer yet — it reads one record and
none is selected — ranks behind the page's own offers rather than
leading with a question. The lifecycle reads correctly either way:
before a position exists the page's actions lead; once one is open or
has just been created, the readings about it come first. */
const ready = declared.filter((c) => !c.deferred);
const asking = declared.filter((c) => c.deferred);
/**
* One chip per *intent*, not per wording.
*
* De-duplicating on the label alone let two chips through whenever the same
* answer was worded twice — a skill's "Show hiring activity" and its
* "Summarize hiring activity" both resolved to that skill's `summary`
* capability, so the reader was offered the same reading under two names and
* had no way to tell them apart. What a chip *resolves to* is the thing that
* must be unique: a skill capability, the page capability, or, for a chip
* that is neither, the question it sends. The label is compared too, so two
* differently-routed chips still cannot arrive reading identically.
*/
const seen = new Set();
const intentOf = (chip) => {
if (chip?.skillId && chip?.skillCapability) return `skill:${chip.skillId}:${chip.skillCapability}`;
if (chip?.capability) return `page:${chip.capability}`;
return `ask:${String(chip?.prompt ?? '').trim().toLowerCase()}`;
};
/* The agent's own starters lead: they were written for this agent, and they
pass through the same de-duplication below, so a starter worded like a
skill suggestion still yields one chip rather than two. An agent that
does not cover this page offers none. */
return [...agentStarters(agent, context.id), ...ready, ...skillPrompts,
...buildPrompts(context.id, facts, workforce), ...asking]
.filter((chip) => {
const label = String(chip?.label ?? '').trim().toLowerCase();
if (!label) return false;
const intent = intentOf(chip);
if (seen.has(intent) || seen.has(`label:${label}`)) return false;
seen.add(intent);
seen.add(`label:${label}`);
return true;
});
/* `pageContext` decides which suggestions can answer without asking, so the
chips re-rank when a position is opened or closed. */
}, [skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext, agent]);
/**
* What the chip row actually shows, which is one of three separate things.
*
* They are separate states, not one merged list, because they answer to
* different owners. Follow-ups belong to the answer that raised them; typed
* matches belong to the composer; the catalogue belongs to the page. Only one
* of them can be true at a time, and the order below is that precedence.
* different owners. Follow-ups belong to the answer that raised them; the
* suggestions belong to the server. Only one of them can be true at a time,
* and the order below is that precedence.
*
* 1. Follow-ups. When an answer ends by asking something, its chips *are*
* the answers to it — the role list after "create a position". They are
* never capped and never filtered, and they stand until the next turn or
* until the reader starts typing something else.
*
* 2. Typed matches. From the second meaningful character, the catalogue
* above is ranked against the query and the best three are offered.
* These are the catalogue's own chip objects, so clicking one runs the
* same capability or skill it would have run when the panel offered the
* whole list outright.
* 2. The server's suggestions. From the moment there is something in the
* composer, `GET /api/v1/owliver/suggestions` is asked what this page
* can usefully answer for this query, and its reply is rendered in the
* order it arrived. The panel does not rank, score, filter or reorder
* it: which readings exist depends on the caller's role and on what is
* actually in the database, and neither of those is knowable here.
*
* 3. Nothing. An empty composer offers no chips at all. The panel used to
* open on a dozen of them, which taught the range of what could be asked
@@ -639,10 +647,32 @@ export default function KrowAssistant({
*/
const followUp = messages[messages.length - 1]?.followUp;
const typed = input.trim();
/**
* The request behind (2), debounced.
*
* The endpoint is cheap and cached per query, but a keystroke is not a
* decision — a reader typing "positions" would otherwise fire nine requests
* to see the answer to the ninth. A short delay means one request per pause,
* and `placeholderData` in the hook keeps the previous answer on screen
* meanwhile so the row does not empty and refill.
*
* Only asked while there is something in the composer. An empty one offers no
* chips, so there would be nothing to render the answer into — and a reader
* who starts typing has left the follow-up behind, which is why typing
* supersedes it rather than being ranked against it.
*/
const debouncedQuery = useDebounced(typed, SUGGEST_DEBOUNCE_MS);
const { data: suggested = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
page: owliverPage,
query: debouncedQuery,
enabled: Boolean(debouncedQuery),
});
const prompts = React.useMemo(() => {
if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS;
return rankPrompts(catalogue, typed);
}, [typed, followUp, catalogue]);
return suggestionChips(suggested, context.id);
}, [typed, followUp, suggested, context.id]);
/* The newest assistant turn, which is the one that carries the rating. */
const lastAnswerIndex = React.useMemo(
@@ -936,6 +966,10 @@ export default function KrowAssistant({
blocks={message.blocks}
stopped={message.stopped}
onPrompt={askOwliver}
/* Approving a proposed write. Offered on every assistant turn
that carries one, not just the newest: a proposal scrolled
past is still a decision somebody has to make. */
onConfirm={confirm}
/* A rating belongs to the conversation, so it is offered on
the newest answer only — and never while one streams. */
feedback={message.role === 'assistant' && i === lastAnswerIndex ? feedback : null}

View File

@@ -491,8 +491,107 @@ const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
});
SkillSectionBlock.displayName = 'SkillSectionBlock';
/**
* A write the agent has proposed. The only block a person can act on.
*
* Three things it must do, and they are all about not being clicked past:
*
* - **Say what will happen, in the server's words.** The title, summary and
* details are rendered as they arrived. A browser paraphrasing them would
* be describing a different act than the one the token authorises.
* - **Put warnings above the button.** A clash or an over-headcount is
* exactly what somebody is about to approve without noticing, and a warning
* underneath the decision is a warning read afterwards.
* - **Not pretend to be finished.** Once approved, the block stays visible
* and says so. Replacing it with a tick would lose what was agreed to.
*/
const ConfirmationBlock = React.memo(/** @param {any} props */ ({ block, onConfirm }) => {
const [state, setState] = React.useState('pending');
const approve = React.useCallback(() => {
/* Guarded rather than debounced. The token is single-use server-side, so a
double click costs a confusing refusal rather than a duplicate write —
but a button that visibly does nothing the second time is kinder than
one that reports an error somebody caused by being quick. */
if (state !== 'pending') return;
setState('approved');
onConfirm?.(block);
}, [state, block, onConfirm]);
return (
<div className="rounded-xl border border-krow-blue/30 bg-krow-blue/5 p-3.5">
<div className="flex items-start gap-2.5">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-white">
<TriangleAlert className="h-3 w-3" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<p className="text-body-sm font-semibold text-ink-1">{block.title}</p>
{block.summary && (
<p className="mt-1 text-caption leading-relaxed text-ink-2">{block.summary}</p>
)}
{block.details?.length > 0 && (
<dl className="mt-2.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{block.details.map((d, i) => (
<React.Fragment key={i}>
<dt className="text-caption text-ink-3">{d.label}</dt>
<dd className="text-caption font-medium text-ink-1">{d.value}</dd>
</React.Fragment>
))}
</dl>
)}
{/* Above the button, deliberately. */}
{block.warnings?.length > 0 && (
<ul className="mt-2.5 space-y-1">
{block.warnings.map((w, i) => (
<li key={i} className="flex gap-1.5 text-caption text-amber-700 dark:text-amber-400">
<TriangleAlert className="mt-0.5 h-3 w-3 shrink-0" aria-hidden="true" />
<span>{w}</span>
</li>
))}
</ul>
)}
<div className="mt-3 flex items-center gap-2">
{state === 'pending' ? (
<>
<button
type="button"
onClick={approve}
className="rounded-lg bg-krow-blue px-3 py-1.5 text-caption font-semibold text-white
transition hover:opacity-90 focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-krow-blue"
>
Approve
</button>
<button
type="button"
onClick={() => setState('dismissed')}
className="rounded-lg px-3 py-1.5 text-caption font-medium text-ink-3
transition hover:text-ink-1 focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-krow-blue"
>
Not now
</button>
</>
) : (
<p className="flex items-center gap-1.5 text-caption font-medium text-ink-3">
<Check className="h-3.5 w-3.5" aria-hidden="true" />
{state === 'approved' ? 'Approved — carrying on.' : 'Left for now. Nothing was changed.'}
</p>
)}
</div>
</div>
</div>
</div>
);
});
ConfirmationBlock.displayName = 'ConfirmationBlock';
const RENDERERS = {
text: TextBlock,
confirmation: ConfirmationBlock,
skillSection: SkillSectionBlock,
heading: HeadingBlock,
kpis: KpisBlock,
@@ -515,7 +614,7 @@ const RENDERERS = {
* blocks has consistent rhythm — a heading hugs what follows it, everything
* else breathes.
*/
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt }) => (
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt, onConfirm }) => (
<div className="space-y-3">
{blocks.map((block, i) => {
const Renderer = RENDERERS[block.type];
@@ -525,7 +624,7 @@ export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks =
return (
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
<Renderer block={block} onPrompt={onPrompt} />
<Renderer block={block} onPrompt={onPrompt} onConfirm={onConfirm} />
{/* The cursor trails the final block only while text is still arriving. */}
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
<span

View File

@@ -90,6 +90,25 @@ export const skillSection = (section, data) =>
/** A caveat. Always the last word on a claim, never the headline. */
export const note = (value) => value && { type: 'note', text: value };
/**
* A write the agent has proposed and NOT performed.
*
* The only block in this file that carries a decision rather than information,
* and the only one whose renderer has a button. Everything else here describes
* something that already happened; this describes something that will happen if
* a person says so.
*
* The payload comes from the server verbatim — title, summary, details,
* warnings, token — and is rendered rather than reformatted. The wording was
* composed beside the code that will do the writing, so a browser paraphrasing
* it would be describing a different act than the one the token authorises.
*
* `token` is what the approval sends back. It authorises exactly one call, with
* exactly those arguments, and it expires.
*/
export const confirmation = (payload) =>
payload?.token && { type: 'confirmation', ...payload };
/** A single sentence answer — used by short free-text replies. */
export const answer = (value) => doc(text(value));

File diff suppressed because it is too large Load Diff

View File

@@ -1,592 +0,0 @@
import {
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
HeartPulse, Layers, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target,
} from 'lucide-react';
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 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 answer('Nothing in the pipeline yet. Publish a position and I will start tracking it.');
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' },
]),
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.'
)
);
};
const hiringHealth = (f) => {
const checks = [
{
label: 'Screening coverage',
value: `${f.standardizedPct}%`,
ok: f.standardizedPct >= 80,
note: f.standardizedPct >= 80
? 'Applicants are scored consistently.'
: `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`,
},
{
label: 'Response speed',
value: f.timeToHire ? `${f.timeToHire}d` : '—',
ok: f.timeToHire > 0 && f.timeToHire <= 3,
note: f.timeToHire && f.timeToHire <= 3
? 'Inside the window where good candidates are still available.'
: 'Event staff accept other work within days — past 3 days you lose people.',
},
{
label: 'Quality of hire',
value: f.hiredAvgScore ? `${f.hiredAvgScore}/100` : '—',
ok: f.hiredAvgScore >= 80,
note: f.hiredAvgScore >= 80
? 'You are hiring from the top of your own pool.'
: 'Hires are scoring mid-pack — widen the funnel before lowering the bar.',
},
{
label: 'Pipeline supply',
value: `${f.openPositions.length} open`,
ok: f.starvedPositions.length === 0,
note: f.starvedPositions.length
? `${f.starvedPositions.map((p) => p.title).join(', ')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants.`
: 'Every open position has applicants.',
},
];
const passing = checks.filter((c) => c.ok).length;
return doc(
heading(`${passing} of ${checks.length} signals healthy`),
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 items = buildActions(f);
if (!items.length) {
return answer('Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.');
}
return doc(heading('Pending actions', 'Ordered by what costs you most'), actions(items.slice(0, 4)));
};
const hiringRisks = (f) => {
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 (!risks.length) {
return answer('No material risks: coverage is good, no role depends on a single candidate, and credentials check out.');
}
return doc(
heading(`${plural(risks.length, 'risk')} to watch`),
insights(risks),
note('Flags to check, not conclusions. Each is a question for a human.')
);
};
/* ── Candidates ─────────────────────────────────────────────────────────── */
const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability'];
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 [a, b] = f.ranked;
if (!a || !b) return answer('I need at least two scored candidates to compare.');
const gap = a.ai_score - b.ai_score;
const first = (n) => n.split(' ')[0];
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 = 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, DIMENSIONS[0]);
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 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 = c.ai_gaps || [];
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?',
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 answer('No scored candidates yet, so there are no gaps to analyse.');
const tally = {};
f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => {
const key = gap.replace(/^Missing certifications?:\s*/i, 'Missing credential: ');
tally[key] = (tally[key] || 0) + 1;
}));
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5);
if (!ranked.length) return answer('No recurring gaps — the pool matches your postings well.');
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 answer('Nothing scored yet, so I have no basis for a recommendation.');
const open = (a) => !['hired', 'rejected'].includes(a.status);
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);
if (!groups.length) return answer('Every scored candidate has already been actioned.');
return doc(
heading('Recommendation'),
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) => doc(
heading('What these charts are saying'),
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) => {
const available = f.ranked.filter((a) => a.ai_score >= 60 && !['hired', 'rejected'].includes(a.status));
const conversion = f.hireRate || 14;
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
const short = f.openPositions.length - expected;
return doc(
heading('Forecast'),
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
? 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 answer('No open positions to break down.');
const byCategory = {};
f.byRole.forEach((r) => {
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 += 1;
});
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
const starved = entries.filter(([, d]) => d.applied === 0);
return doc(
heading('By role category'),
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) => 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
? {
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) => 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 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 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 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', 'slow')) return pendingActions(f);
if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(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);
};
export const respondCandidates = (question, f) => {
const q = question.toLowerCase();
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
? doc(
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
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.`)
)
: 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, 'hire rate', 'conversion')) {
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 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);
};
/* ── Capability manifests ───────────────────────────────────────────────── */
export const OVERVIEW_CAPABILITIES = [
{ id: 'hiring-summary', label: 'Hiring Summary', icon: Sparkles, run: hiringSummary },
{ id: 'hiring-health', label: 'Hiring Health', icon: HeartPulse, run: hiringHealth },
{ id: 'pending-actions', label: 'Pending Actions', icon: ListChecks, run: pendingActions },
{ id: 'hiring-risks', label: 'Hiring Risks', icon: AlertTriangle, run: hiringRisks },
];
export const CANDIDATE_CAPABILITIES = [
{ id: 'resume-analysis', label: 'Resume Analysis', icon: FileText, run: resumeAnalysis },
{ id: 'candidate-comparison', label: 'Candidate Comparison', icon: GitCompare, run: candidateComparison },
{ id: 'interview-questions', label: 'Interview Question Generator', icon: MessageSquareQuote, run: interviewQuestions },
{ id: 'skill-gap', label: 'Skill Gap Analysis', icon: Target, run: skillGapAnalysis },
{ id: 'hiring-recommendation', label: 'Hiring Recommendation', icon: ClipboardCheck, run: hiringRecommendation },
];
export const ANALYTICS_CAPABILITIES = [
{ id: 'explain-charts', label: 'Explain Charts', icon: BarChart3, run: explainCharts },
{ id: 'forecast-hiring', label: 'Forecast Hiring', icon: LineChart, run: forecastHiring },
{ id: 'department-insights', label: 'Department Insights', icon: Layers, run: departmentInsights },
{ id: 'generate-reports', label: 'Generate Reports', icon: FileSpreadsheet, run: generateReports },
];

View File

@@ -1,552 +0,0 @@
import { doc, heading, list, note, text } from '../blocks';
/* `vocabulary.js` is a leaf — it imports nothing — so naming the reasoning
modes here costs no dependency.
The registries are deliberately **not** imported. `contexts.js` is reached
from `placement.js`, which `skills/registry.js` already depends on, so
importing a registry back into a context closes a cycle and leaves
`PLACEMENT_ROUTES` undefined at module-evaluation time. Counting agents here
would also duplicate what the screen behind this panel already shows. */
import { REASONING_MODES } from '@/lib/agents/vocabulary';
/**
* Owliver on the agent configuration screen.
*
* This page is a **workspace** page, not an operational one. It shows no
* positions, no candidates and no shifts, and Owliver must not behave as though
* it does. What it can genuinely answer from is the two registries — what
* agents exist, what skills exist, what the configuration options mean — which
* is metadata about the workspace itself rather than a reading of workforce
* records.
*
* The distinction matters most when the agent being *edited* is an operational
* one. Configuring the Analytics Agent does not put the reader on Analytics:
* the edited agent is a record being changed, not the page they are standing
* on. So nothing here reaches for analytics data, and no skill declares this
* page — `skillsForContext` returns an empty list, which is the honest answer.
*
* Questions outside these topics are declined by the routing layer rather than
* answered with whatever this page happens to know.
*/
/** What this page's answers are actually about. */
/**
* What this page's answers are actually about.
*
* Deliberately narrow, and it was not narrow enough at first: `what is` and
* `page` were on this list, so "What is our attendance rate?" matched and was
* answered from a screen that holds no attendance records. A topic list on a
* page with no operational data has to name *subjects*, never sentence
* openings — a generic phrase turns the honest decline into a confident answer
* about the wrong thing.
*
* Anything not named here is declined by `routing.js` and pointed at the page
* that holds the records.
*/
export const AGENT_CONFIGURE_TOPICS = [
'agent', 'agents', 'subagent', 'subagents',
'skill', 'skills', 'knowledge',
'reasoning', 'starter', 'starters', 'web search',
'instruction', 'instructions', 'configure', 'configuration', 'setting', 'settings',
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
'workspace',
];
/** What this screen is, and what it can be asked. */
function explainScreen() {
return doc(
heading('Configuring an agent'),
text('An agent decides how Owliver answers on a page: which of that page\u2019s skills it may use, what it knows, and how much work an answer is worth.'),
list([
'It narrows what a page offers. It can never widen it.',
'The same skill can be attached to several agents.',
'Changes are saved to this workspace; publishing puts them into service.',
]),
note('The agents in this workspace are listed on the Agents page, and skills in Workspace \u2192 Skills.')
);
}
/** Where skills come from. */
function skillOverview() {
return doc(
heading('Where skills come from'),
text('Skills are authored once in **Workspace \u2192 Skills** and attached to as many agents as you like. There is one library, so a skill behaves identically wherever it is used.'),
list([
'Add skills \u2014 attaches from the shared library.',
'A skill still only answers on the pages it itself declares.',
'Attaching a skill an agent\u2019s pages do not cover changes nothing on those pages.',
]),
note('Removing a skill here detaches it from this agent. It stays in the library for everything else.')
);
}
/** What the configuration options mean. */
function explainOptions() {
return doc(
heading('What each setting does'),
list([
'Pages it covers — where this agent may answer. It narrows what the page offers; it can never widen it.',
'Skills — what it can do. Attached from the shared library.',
'Knowledge — reference material it can quote, with the source named.',
`Reasoning — how much work an answer is worth: ${REASONING_MODES.map((m) => m.label).join(', ')}.`,
'Conversation starters — the questions offered as chips when this agent opens.',
'Web search — whether it may look outside the workspace.',
'Subagents — other agents whose skills it may also use, still bounded by the current page.',
]),
note('Changes are saved to this workspace. Publishing is what puts them into service.')
);
}
/** The lifecycle, in the words the buttons use. */
function explainLifecycle() {
return doc(
heading('Draft, published, archived'),
list([
'Draft — saved, but not answering anywhere yet.',
'Published — in service, and offered in the Owliver switcher.',
'Archived — taken out of service, definition kept. It can be restored as a draft.',
]),
note('Editing an agent that ships with Krow saves your own version alongside it. The shipped definition is never altered, and reverting brings it back.')
);
}
export const AGENT_CONFIGURE_CAPABILITIES = [
{ id: 'screen', label: 'What this screen configures', run: explainScreen },
{ id: 'skills', label: 'Where skills come from', run: skillOverview },
{ id: 'options', label: 'What each setting does', run: explainOptions },
{ id: 'lifecycle', label: 'Draft, published, archived', run: explainLifecycle },
];
/**
* A free-text question on the configuration screen.
*
* Answers from the registries, or says plainly that this page cannot answer it.
* The one thing it must never do is reach for operational data because the
* agent being edited happens to be an operational agent.
*/
export function respondAgentConfigure(question) {
const q = String(question || '').toLowerCase();
if (/\bskill/.test(q)) return skillOverview();
if (/\b(draft|publish|archive|version|revert|shipped)/.test(q)) return explainLifecycle();
if (/\b(reasoning|knowledge|starter|subagent|web search|instruction|setting|option|page)/.test(q)) {
return explainOptions();
}
if (/\bagent/.test(q)) return explainScreen();
return doc(
text('This is the agent configuration screen, so I can answer about **agents, skills and what each setting does** here.'),
list([
'What agents exist in this workspace',
'What skills I can attach, and where they come from',
'What pages, reasoning, knowledge and subagents mean',
'What draft, published and archived do',
]),
note('For workforce questions — positions, candidates, attendance — open the page that holds those records and ask me there.')
);
}
/* ── The rest of the workspace, and Settings ──────────────────────────────
*
* Every page below is a configuration surface: it holds no positions, no
* candidates and no shifts. That does not make Owliver useless there, and
* treating it as though it did is the bug this section exists to correct — the
* panel did not mount on these pages at all, on a product where Owliver had
* answered perfectly well everywhere before specialised agents existed.
*
* What each page can honestly answer from is the screen itself: what it
* configures, what the controls mean, and which page holds the records a
* workforce question is really about. That is what these produce.
*
* **No skill is invented to make this work.** A skill is a capability over
* records; none of these pages has records, so a skill here would be a fake one
* whose only purpose was to populate a chip. What answers instead is a page
* responder — the same mechanism every context in this table has always used,
* including the eight operational ones.
*/
/**
* One configuration page's answers, from a description of the page.
*
* A factory rather than five near-identical copies. Each section carries the
* questions it answers (`match`), the label the panel offers it under, and the
* document itself — so the capability list shown when a question is declined
* and the routing behind a typed question come from one declaration and cannot
* drift apart.
*
* **Sections are matched in order, specific before general.** The section that
* explains the screen as a whole is deliberately last on every page: it is the
* one whose words appear in every other question, and put first it would answer
* all of them. The same lesson `AGENT_CONFIGURE_TOPICS` records, one level down.
*
* `topics` is what `routing.js` gates on, and it names *subjects* rather than
* sentence openings — a generic opener on a page with no operational data turns
* an honest decline into a confident answer about the wrong thing.
*/
function workspacePage({ label, topics, sections, elsewhere }) {
const capabilities = sections.map((section) => ({
id: section.id,
label: section.label,
run: () => doc(
heading(section.heading),
...(section.text ? [text(section.text)] : []),
...(section.bullets?.length ? [list(section.bullets)] : []),
...(section.note ? [note(section.note)] : [])
),
}));
const byId = new Map(capabilities.map((c) => [c.id, c]));
/**
* A typed question, answered from this page or offered what it can answer.
*
* The default is not "here is the page's report" — these pages have no report.
* It is the offer itself: what can be asked here, and where the records live
* for what cannot. `routing.js` has already declined anything outside
* `topics`, so this only ever sees a question the page is plausibly about.
*/
const respond = (question) => {
const q = String(question || '').toLowerCase();
const matched = sections.find((section) => section.match.test(q));
if (matched) return byId.get(matched.id).run();
return doc(
text(`This is **${label}**, so I can answer about the screen itself — what it configures and what each control does.`),
list(sections.map((section) => section.label)),
note(elsewhere)
);
};
return { topics, capabilities, respond };
}
/** What every configuration page says about where the records are. */
const RECORDS_ELSEWHERE =
'For workforce questions — positions, candidates, attendance, training — open the page that holds those records and ask me there.';
/* ── Settings ───────────────────────────────────────────────────────────── */
const SETTINGS = workspacePage({
label: 'Settings',
topics: [
'setting', 'settings', 'account', 'password', 'credential', 'security', 'two-factor',
'two factor', '2fa', 'permission', 'access', 'organization', 'organisation',
'client', 'user', 'users', 'team', 'audit', 'notification', 'digest', 'email',
'density', 'compact', 'preference', 'automation', 'toggle', 'configure', 'configuration',
'workspace', 'owliver', 'assistant',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'automation',
label: 'What the automation toggles do',
heading: 'Automation controls',
match: /\b(automation|toggle|toggles|digest|notification|notifications|density|compact|assistant)\b/,
bullets: [
'Owliver Workspace Assistant — whether this panel opens by default on the pages that carry it.',
'Compact UI Density — tighter table rows, for high-density monitoring.',
'Daily Executive Email Digest — a scheduled summary to the account address.',
],
note: 'Turning the assistant off closes the panel; it never removes it. The docked control brings it back.',
},
{
id: 'access',
label: 'Permissions and security',
heading: 'Who may do what',
match: /\b(permission|permissions|access|secure|secured|security|password|credential|credentials|audit|2fa|two.factor|user|users|team)\b/,
text: 'Access is configured here and recorded on Activity. The two are deliberately separate: this page states the policy, and the audit trail states what happened under it.',
bullets: [
'Users & Permissions — the roles this workspace grants.',
'Account & Security — credentials and protection for your own account.',
'Audit & System Activity Log — the record of events, read in full on Activity.',
],
note: 'Ask me on Activity for who did what, and when — that page holds the events.',
},
{
id: 'screen',
label: 'What this screen configures',
heading: 'Settings',
match: /\b(configure|configuration|configures|screen|settings|manage|set up|do here|organization|organisation|client|clients)\b/,
text: 'Settings is the account and system side of Krow. It configures who you are and how the workspace behaves — never the workforce records themselves.',
bullets: [
'Account & Security — your profile details and how this account is protected.',
'Organization — the clients, positions and people this workspace is structured around.',
'Users & Permissions — who may do what.',
'Audit & System Activity — the log of what has happened.',
'Automation — the behaviours below, including whether Owliver opens by default.',
],
note: 'Changes here are saved to this account.',
},
],
});
export const SETTINGS_TOPICS = SETTINGS.topics;
export const SETTINGS_CAPABILITIES = SETTINGS.capabilities;
export const respondSettings = SETTINGS.respond;
/* ── Workspace hub ──────────────────────────────────────────────────────── */
const WORKSPACE = workspacePage({
label: 'Workspace',
topics: [
'workspace', 'agent', 'agents', 'skill', 'skills', 'capability', 'capabilities',
'training', 'path', 'paths', 'library', 'registry', 'owliver', 'extend', 'configure',
'configuration', 'govern', 'development',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'agents',
label: 'What an agent is',
heading: 'Agents',
match: /\b(agent|agents|subagent|subagents|reasoning)\b/,
text: 'An agent decides how Owliver answers on a page: which of that page’s skills it may use, what it knows, and how much work an answer is worth.',
bullets: [
'It narrows what a page offers. It can never widen it.',
'A page written for an agent opens on that agent.',
'A page with no agent of its own opens on the general Krow Workforce Agent.',
],
note: 'Agents are listed and edited in Workspace → Agents.',
},
{
id: 'skills',
label: 'Where skills come from',
heading: 'Skills',
match: /\b(skill|skills|library|registry|capability|capabilities)\b/,
text: 'Skills are authored once and attached to as many agents as you like. There is one library, so a skill behaves identically wherever it is used.',
bullets: [
'An Owliver skill answers questions.',
'A Board skill extends a Krow page with a section.',
'A skill only applies on the pages it itself declares.',
],
note: 'Both lists live in Workspace → Skills.',
},
{
id: 'screen',
label: 'What the workspace governs',
heading: 'Workspace',
match: /\b(workspace|govern|governs|overview|configure|configuration|extend)\b/,
text: 'The workspace is where Krow’s capabilities are configured: the agents that answer, the skills they carry, and the training paths the workforce is measured against.',
bullets: [
'Agents — who answers on which page, and how.',
'Skills — what can be answered or drawn, authored once and shared.',
'Skill Development — the training paths the workforce progresses along.',
],
note: 'Nothing configured here reads workforce records on its own. A page decides what is in reach; configuration decides how much of that reach is used.',
},
],
});
export const WORKSPACE_TOPICS = WORKSPACE.topics;
export const WORKSPACE_CAPABILITIES = WORKSPACE.capabilities;
export const respondWorkspace = WORKSPACE.respond;
/* ── Agents list ────────────────────────────────────────────────────────── */
const WORKSPACE_AGENTS = workspacePage({
label: 'Agents',
topics: [
'agent', 'agents', 'subagent', 'subagents', 'skill', 'skills', 'page', 'pages',
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
'configure', 'configuration', 'reasoning', 'workspace', 'owliver',
'constrained', 'cover', 'covers', 'fallback', 'default',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'coverage',
label: 'How a page picks its agent',
heading: 'Which agent answers where',
match: /\b(cover|covers|covered|page|pages|default|fallback|constrained|picks|pick|answers|resolve|resolves)\b/,
bullets: [
'A page with an agent written for it opens on that agent.',
'A page with none opens on the general Krow Workforce Agent.',
'Choosing an agent that does not cover the page you are on shows it as constrained here, and it declines rather than answering.',
],
note: 'An agent is a lens on the page you are standing on. It is never a way to reach a different page.',
},
{
id: 'lifecycle',
label: 'Draft, published, archived',
heading: 'Draft, published, archived',
match: /\b(draft|publish|published|publishing|archive|archived|revert|shipped|version|lifecycle)\b/,
bullets: [
'Draft — saved, but not answering anywhere yet.',
'Published — in service, and offered in the Owliver switcher.',
'Archived — taken out of service, definition kept. It can be restored as a draft.',
],
},
{
id: 'list',
label: 'What this list holds',
heading: 'The agents in this workspace',
match: /\b(list|lists|registered|exist|exists|agent|agents|screen|workspace)\b/,
text: 'Every agent Krow ships, plus anything this account has authored. Opening one configures it; nothing on this screen changes how Owliver is answering right now.',
bullets: [
'A shipped agent can be edited — your version is saved alongside it, and reverting brings the original back.',
'A published agent is offered in the Owliver switcher.',
'An archived agent is out of service, and its definition is kept.',
],
note: 'Configuring an agent never puts you on the page that agent covers.',
},
],
});
export const WORKSPACE_AGENTS_TOPICS = WORKSPACE_AGENTS.topics;
export const WORKSPACE_AGENTS_CAPABILITIES = WORKSPACE_AGENTS.capabilities;
export const respondWorkspaceAgents = WORKSPACE_AGENTS.respond;
/* ── Skills list ────────────────────────────────────────────────────────── */
const WORKSPACE_SKILLS = workspacePage({
label: 'Skills',
topics: [
'skill', 'skills', 'library', 'registry', 'owliver', 'board', 'section', 'surface',
'placement', 'markdown', 'definition', 'upload', 'author', 'attach',
'active', 'draft', 'archive', 'archived', 'category', 'capability', 'capabilities',
'workspace', 'agent', 'agents', 'page', 'pages',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'attaching',
label: 'How skills reach an agent',
heading: 'Attaching a skill',
match: /\b(attach|attached|attaching|agent|agents|reach|reaches|scope)\b/,
bullets: [
'Attaching a skill lets an agent use it — on the pages the skill itself declares.',
'Attaching a skill an agent’s pages do not cover changes nothing on those pages.',
'Removing a skill from an agent leaves it in the library for everything else.',
],
note: 'Agents are configured in Workspace → Agents.',
},
{
id: 'authoring',
label: 'How a skill is written',
heading: 'Writing a skill',
match: /\b(author|authored|authoring|write|written|writing|markdown|definition|upload|edit|edited)\b/,
text: 'A skill is a Markdown file: frontmatter declares what it is and which pages it applies to, and the body documents what it can do.',
bullets: [
'It only applies on the pages it declares.',
'It is attached to an agent from this shared library, never copied into one.',
'A definition naming something the product does not have is refused with a message rather than half-registered.',
],
},
{
id: 'lists',
label: 'The two skill lists',
heading: 'Owliver skills and Board skills',
match: /\b(list|lists|two|owliver|board|kind|kinds|difference|library|registry|skill|skills)\b/,
bullets: [
'An Owliver skill answers questions in this panel.',
'A Board skill extends a Krow page with a section the page renders.',
'A definition can do both; it is filed by what it declares, never by a setting.',
],
note: 'One library behind both lists, so a skill behaves identically wherever it is used.',
},
],
});
export const WORKSPACE_SKILLS_TOPICS = WORKSPACE_SKILLS.topics;
export const WORKSPACE_SKILLS_CAPABILITIES = WORKSPACE_SKILLS.capabilities;
export const respondWorkspaceSkills = WORKSPACE_SKILLS.respond;
/* ── Skill editor ───────────────────────────────────────────────────────── */
const SKILL_CONFIGURE = workspacePage({
label: 'Skill Configure',
topics: [
'skill', 'skills', 'definition', 'markdown', 'frontmatter', 'field', 'fields',
'page', 'pages', 'surface', 'placement', 'section', 'source', 'sources', 'trigger',
'triggers', 'action', 'actions', 'capability', 'capabilities', 'category', 'status',
'active', 'draft', 'archive', 'archived', 'validate', 'validation', 'save', 'publish',
'editor', 'configure', 'configuration', 'owliver', 'board', 'workspace',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'validation',
label: 'Why a definition is refused',
heading: 'Validation',
match: /\b(valid|validate|validated|validation|refuse|refused|error|errors|fail|fails|reject|rejected|wrong)\b/,
text: 'The vocabulary is closed, so everything a definition names is checked before it is stored.',
bullets: [
'A page, placement or source the product does not have is refused with a message.',
'A section that needs a record the placement does not supply is refused rather than drawing an empty card.',
'A definition that parses but loses a field reports what it lost, instead of half-registering.',
],
},
{
id: 'reach',
label: 'Where this skill will apply',
heading: 'Where it applies',
match: /\b(apply|applies|applied|where|reach|reaches|attach|attached|agent|agents|scope|surface|placement)\b/,
bullets: [
'On the pages this definition declares, and nowhere else.',
'For an agent, only once it is attached to that agent.',
'An agent can never use a skill to reach a page the skill does not declare.',
],
note: 'Attach it in Workspace → Agents.',
},
{
id: 'screen',
label: 'What this editor configures',
heading: 'Editing a skill',
match: /\b(editor|edit|editing|configure|configures|configuration|definition|frontmatter|field|fields|status|trigger|triggers|screen|skill|skills)\b/,
text: 'A skill definition, as Markdown. The frontmatter declares what it is and where it applies; the body documents what it can do.',
bullets: [
'Pages — the surfaces this skill applies on. It never applies anywhere else.',
'Triggers — the wordings that reach it.',
'Capabilities — what it can answer or draw.',
'Status — active, draft or archived.',
],
note: 'Nothing is executed from Markdown. A definition names things the product already has, and a name it does not have is refused.',
},
],
});
export const SKILL_CONFIGURE_TOPICS = SKILL_CONFIGURE.topics;
export const SKILL_CONFIGURE_CAPABILITIES = SKILL_CONFIGURE.capabilities;
export const respondSkillConfigure = SKILL_CONFIGURE.respond;
/* ── Skill Development ──────────────────────────────────────────────────── */
const SKILL_DEVELOPMENT = workspacePage({
label: 'Skill Development',
topics: [
'training', 'path', 'paths', 'progression', 'level', 'levels', 'rung', 'ladder',
'development', 'capability', 'capabilities', 'define', 'definition',
'workspace', 'configure', 'configuration', 'owliver',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'levels',
label: 'How the levels work',
heading: 'Levels',
match: /\b(level|levels|rung|rungs|ladder|progress|progression|stage|stages)\b/,
bullets: [
'Levels are ordered, and each states what someone at that level can do.',
'A path with no levels defines a name and nothing measurable.',
'Editing a level changes the definition, never anyone’s recorded progress.',
],
},
{
id: 'screen',
label: 'What a training path is',
heading: 'Skill Development',
match: /\b(training|path|paths|define|defines|definition|development|capability|capabilities|configure|screen)\b/,
text: 'A training path defines a capability the workforce holds and the levels it progresses through. It is a definition, not a reading: nothing here says who currently holds what.',
bullets: [
'Each path names its levels, from first exposure to independent practice.',
'A path is neither an Owliver skill nor a Board skill — it describes people, not the product.',
'It is authored here and read wherever progression is shown.',
],
note: 'For who holds which capability today, ask me on Talent Pool or KROW Forge — those pages hold the records.',
},
],
});
export const SKILL_DEVELOPMENT_TOPICS = SKILL_DEVELOPMENT.topics;
export const SKILL_DEVELOPMENT_CAPABILITIES = SKILL_DEVELOPMENT.capabilities;
export const respondSkillDevelopment = SKILL_DEVELOPMENT.respond;

View File

@@ -1,29 +1,68 @@
import {
ANALYTICS_CAPABILITIES, CANDIDATE_CAPABILITIES, OVERVIEW_CAPABILITIES,
respondAnalytics, respondCandidates, respondOverview,
} from './capabilities/employer';
import {
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, CREATE_POSITION_CAPABILITIES,
FORGE_CAPABILITIES,
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, PROFILE_CAPABILITIES,
TALENT_POOL_CAPABILITIES,
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
respondControlCenter, respondCreatePosition, respondForge, respondHiredHistory,
respondPositions,
respondProfile, respondTalentPool,
} from './capabilities/admin';
import {
AGENT_CONFIGURE_CAPABILITIES, AGENT_CONFIGURE_TOPICS,
SETTINGS_CAPABILITIES, SETTINGS_TOPICS,
SKILL_CONFIGURE_CAPABILITIES, SKILL_CONFIGURE_TOPICS,
SKILL_DEVELOPMENT_CAPABILITIES, SKILL_DEVELOPMENT_TOPICS,
WORKSPACE_AGENTS_CAPABILITIES, WORKSPACE_AGENTS_TOPICS,
WORKSPACE_CAPABILITIES, WORKSPACE_SKILLS_CAPABILITIES, WORKSPACE_SKILLS_TOPICS,
WORKSPACE_TOPICS,
respondAgentConfigure, respondSettings, respondSkillConfigure, respondSkillDevelopment,
respondWorkspace, respondWorkspaceAgents, respondWorkspaceSkills,
} from './capabilities/workspace';
/**
* The words each page's answers are actually about.
*
* Inlined here when the capability modules were deleted. They used to sit
* beside the functions that computed answers from browser data; those functions
* are gone — the agent answers now — but this vocabulary survives them, because
* it decides something different: whether a question belongs to THIS page or to
* another one. A question matching none of a page's topics is a question the
* reader should be taken elsewhere to ask, and that is still true with an agent
* behind the panel.
*/
const AGENT_CONFIGURE_TOPICS = [
'agent', 'agents', 'subagent', 'subagents',
'skill', 'skills', 'knowledge',
'reasoning', 'starter', 'starters', 'web search',
'instruction', 'instructions', 'configure', 'configuration', 'setting', 'settings',
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
'workspace',
];
const SETTINGS_TOPICS = [
'setting', 'settings', 'account', 'password', 'credential', 'security', 'two-factor',
'two factor', '2fa', 'permission', 'access', 'organization', 'organisation',
'client', 'user', 'users', 'team', 'audit', 'notification', 'digest', 'email',
'density', 'compact', 'preference', 'automation', 'toggle', 'configure', 'configuration',
'workspace', 'owliver', 'assistant',
];
const WORKSPACE_TOPICS = [
'workspace', 'agent', 'agents', 'skill', 'skills', 'capability', 'capabilities',
'training', 'path', 'paths', 'library', 'registry', 'owliver', 'extend', 'configure',
'configuration', 'govern', 'development',
];
const WORKSPACE_AGENTS_TOPICS = [
'agent', 'agents', 'subagent', 'subagents', 'skill', 'skills', 'page', 'pages',
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
'configure', 'configuration', 'reasoning', 'workspace', 'owliver',
'constrained', 'cover', 'covers', 'fallback', 'default',
];
const WORKSPACE_SKILLS_TOPICS = [
'skill', 'skills', 'library', 'registry', 'owliver', 'board', 'section', 'surface',
'placement', 'markdown', 'definition', 'upload', 'author', 'attach',
'active', 'draft', 'archive', 'archived', 'category', 'capability', 'capabilities',
'workspace', 'agent', 'agents', 'page', 'pages',
];
const SKILL_CONFIGURE_TOPICS = [
'skill', 'skills', 'definition', 'markdown', 'frontmatter', 'field', 'fields',
'page', 'pages', 'surface', 'placement', 'section', 'source', 'sources', 'trigger',
'triggers', 'action', 'actions', 'capability', 'capabilities', 'category', 'status',
'active', 'draft', 'archive', 'archived', 'validate', 'validation', 'save', 'publish',
'editor', 'configure', 'configuration', 'owliver', 'board', 'workspace',
];
const SKILL_DEVELOPMENT_TOPICS = [
'training', 'path', 'paths', 'progression', 'level', 'levels', 'rung', 'ladder',
'development', 'capability', 'capabilities', 'define', 'definition',
'workspace', 'configure', 'configuration', 'owliver',
];
/**
* Page contexts for the Owliver dashboard panel.
@@ -45,34 +84,24 @@ export const ASSISTANT_CONTEXTS = {
'employer.overview': {
id: 'employer.overview',
page: 'Overview',
capabilities: OVERVIEW_CAPABILITIES,
respond: respondOverview,
},
'employer.candidates': {
id: 'employer.candidates',
page: 'Candidates',
capabilities: CANDIDATE_CAPABILITIES,
respond: respondCandidates,
},
'employer.analytics': {
id: 'employer.analytics',
page: 'Analytics',
capabilities: ANALYTICS_CAPABILITIES,
respond: respondAnalytics,
},
'admin.controlCenter': {
id: 'admin.controlCenter',
page: 'Control Center',
topics: ['health', 'platform', 'attention', 'urgent', 'bottleneck', 'funnel', 'pipeline', 'conversion', 'recommend', 'should', 'summary', 'summarize', 'workforce', 'operation', 'velocity', 'speed', 'hiring', 'how many', 'unusual', 'anomal', 'risk', 'overview', 'status'],
capabilities: CONTROL_CENTER_CAPABILITIES,
respond: respondControlCenter,
},
'admin.positions': {
id: 'admin.positions',
page: 'Positions',
topics: ['position', 'role', 'posting', 'vacancy', 'fill', 'attention', 'priorit', 'bottleneck', 'funnel', 'waiting', 'review', 'screen', 'applicant', 'pipeline', 'strength', 'activity', 'hiring', 'how many', 'which'],
capabilities: POSITIONS_CAPABILITIES,
respond: respondPositions,
},
/* Specifying a role is a different question from managing the ones that
exist, so Create Position is its own context rather than Positions with a
@@ -84,8 +113,6 @@ export const ASSISTANT_CONTEXTS = {
'requirement', 'skill', 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience',
'typical', 'position', 'role', 'job description', 'form', 'field', 'step', 'how do i',
'what do i', 'explain', 'summar', 'flow'],
capabilities: CREATE_POSITION_CAPABILITIES,
respond: respondCreatePosition,
},
/* The Candidates list and Candidates Analysis are separate contexts because they
ask different questions of the same records: the list is triage — who needs a
@@ -94,15 +121,11 @@ export const ASSISTANT_CONTEXTS = {
id: 'admin.candidatesList',
page: 'Candidates',
topics: ['candidate', 'applicant', 'attention', 'waiting', 'interview', 'ready', 'score', 'unscored', 'risk', 'flag', 'strongest', 'best', 'top', 'compare', 'shortlist', 'pipeline', 'summar', 'how many', 'who'],
capabilities: CANDIDATE_LIST_CAPABILITIES,
respond: respondCandidateList,
},
'admin.candidates': {
id: 'admin.candidates',
page: 'Candidates Analysis',
topics: ['candidate', 'applicant', 'gap', 'missing', 'coverage', 'unscored', 'risk', 'flag', 'compare', 'top', 'best', 'strongest', 'shortlist', 'recommend', 'hire', 'who', 'pool', 'quality'],
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
respond: respondAdminCandidates,
},
/* Analytics reads the same records as the Control Center, but as performance
over time rather than as a state to act on — hence its own capabilities. */
@@ -110,8 +133,6 @@ export const ASSISTANT_CONTEXTS = {
id: 'admin.analytics',
page: 'Analytics',
topics: ['trend', 'over time', 'month', 'week', 'department', 'category', 'team', 'perform', 'bottleneck', 'funnel', 'conversion', 'position', 'role', 'score', 'attention', 'rate', 'average', 'breakdown', 'report', 'how many', 'compare'],
capabilities: ADMIN_ANALYTICS_CAPABILITIES,
respond: respondAdminAnalytics,
},
/* Forge, Talent Pool and Hired History read the same platform records as the
contexts above, but ask different questions of them: proof and progression,
@@ -126,22 +147,16 @@ export const ASSISTANT_CONTEXTS = {
'certification', 'publish', 'published', 'draft', 'archive', 'status', 'category',
'owliver', 'evaluat', 'criteri', 'rubric', 'proof', 'evidence', 'verify', 'verification',
'verified', 'workforce', 'gap', 'missing', 'what do we have'],
capabilities: FORGE_CAPABILITIES,
respond: respondForge,
},
'admin.talentPool': {
id: 'admin.talentPool',
page: 'Talent Pool',
topics: ['talent', 'pool', 'worker', 'profile', 'priorit', 'availab', 'verif', 'score', 'unscored', 'segment', 'supply', 'summar', 'how many', 'who', 'shortlist'],
capabilities: TALENT_POOL_CAPABILITIES,
respond: respondTalentPool,
},
'admin.hiredHistory': {
id: 'admin.hiredHistory',
page: 'Hired History',
topics: ['hire', 'hired', 'outcome', 'department', 'quality', 'recent', 'strongest', 'pattern', 'time to hire', 'retention', 'how many', 'who'],
capabilities: HIRED_HISTORY_CAPABILITIES,
respond: respondHiredHistory,
},
/* The account page. A form rather than a workflow, but the questions people
ask about it — what may I do, how is this secured, what did I change — are
@@ -153,8 +168,6 @@ export const ASSISTANT_CONTEXTS = {
'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out',
'preference', 'setting', 'digest', 'density', 'workspace', 'owliver', 'profile',
'name', 'email', 'activity', 'recent', 'do here', 'how do i', 'edit', 'change my'],
capabilities: PROFILE_CAPABILITIES,
respond: respondProfile,
},
/**
* Agent configuration — a workspace page, not an operational one.
@@ -173,8 +186,6 @@ export const ASSISTANT_CONTEXTS = {
id: 'admin.agentConfigure',
page: 'Agent Configure',
topics: AGENT_CONFIGURE_TOPICS,
capabilities: AGENT_CONFIGURE_CAPABILITIES,
respond: respondAgentConfigure,
},
/**
* Settings, and the workspace pages behind it.
@@ -199,50 +210,36 @@ export const ASSISTANT_CONTEXTS = {
id: 'admin.settings',
page: 'Settings',
topics: SETTINGS_TOPICS,
capabilities: SETTINGS_CAPABILITIES,
respond: respondSettings,
},
'admin.workspace': {
id: 'admin.workspace',
page: 'Workspace',
topics: WORKSPACE_TOPICS,
capabilities: WORKSPACE_CAPABILITIES,
respond: respondWorkspace,
},
'admin.workspaceAgents': {
id: 'admin.workspaceAgents',
page: 'Agents',
topics: WORKSPACE_AGENTS_TOPICS,
capabilities: WORKSPACE_AGENTS_CAPABILITIES,
respond: respondWorkspaceAgents,
},
'admin.workspaceSkills': {
id: 'admin.workspaceSkills',
page: 'Skills',
topics: WORKSPACE_SKILLS_TOPICS,
capabilities: WORKSPACE_SKILLS_CAPABILITIES,
respond: respondWorkspaceSkills,
},
'admin.skillConfigure': {
id: 'admin.skillConfigure',
page: 'Skill Configure',
topics: SKILL_CONFIGURE_TOPICS,
capabilities: SKILL_CONFIGURE_CAPABILITIES,
respond: respondSkillConfigure,
},
'admin.skillDevelopment': {
id: 'admin.skillDevelopment',
page: 'Skill Development',
topics: SKILL_DEVELOPMENT_TOPICS,
capabilities: SKILL_DEVELOPMENT_CAPABILITIES,
respond: respondSkillDevelopment,
},
'admin.activity': {
id: 'admin.activity',
page: 'Activity',
topics: ['activity', 'audit', 'event', 'log', 'security', 'breach', 'compliance', 'trace', 'unusual', 'anomal', 'suspicious', 'user', 'who', 'account', 'busiest'],
capabilities: ACTIVITY_CAPABILITIES,
respond: respondActivity,
},
};

View File

@@ -423,6 +423,19 @@ const PLACEHOLDERS = {
/**
* Builds suggestions from what is actually on the page.
*
* NOT what the panel renders, and no longer on the production path. The chips
* Owliver offers now come from `GET /api/v1/owliver/suggestions`, which ranks
* against the rows in PostgreSQL and the caller's permissions — neither of
* which a fact sheet assembled in the browser can speak for. `buildIntro` above
* is unaffected: a greeting states what is on the page, and the page is where
* that is known.
*
* What is left here is the derivation itself, which is still read by
* `scripts/skill-check.mjs` and pinned by the committed Owliver baseline: given
* a fact sheet, which questions does this page's data raise. It is kept because
* the baseline is a record of behaviour rather than of code, and rewriting it
* to match a deletion would erase the comparison it exists to make.
*
* 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.

View File

@@ -29,4 +29,4 @@ export { AssistantPanel } from './AssistantPanel';
export { default as KrowAssistant } from './KrowAssistant';
export { ASSISTANT_CONTEXTS, getContext } from './contexts';
export { EXCLUDED_ROUTES, enabledRoutes, resolveAssistantContext } from './placement';
export { createHttpProvider, createLocalProvider } from './provider';
export { createAgentProvider, createUnconfiguredProvider } from './provider';

View File

@@ -75,7 +75,14 @@ export function buildFacts({
.filter((e) => e.viable.length === 1);
/* Strong candidates nobody has moved on — the most expensive kind of delay. */
const stalled = ranked.filter((a) => a.ai_score >= 80 && a.status === 'ai_screened');
/* Screened and waiting on a decision — which includes the shortlisted, who
are waiting on exactly that. Restricting this to `ai_screened` read as
correct only while nothing was ever shortlisted: the first shortlisted
candidate would have dropped silently out of the count. positionInsights
draws the same set with ['ai_screened', 'shortlisted']. */
const stalled = ranked.filter(
(a) => a.ai_score >= 80 && ['ai_screened', 'shortlisted'].includes(a.status)
);
const funnel = [
{ key: 'applied', label: 'Applied', count: applications.length },

View File

@@ -1,171 +0,0 @@
/**
* Ranking the panel's own suggestions against what is being typed.
*
* This is not a catalogue and deliberately does not own one. Owliver already
* decides what can be asked on a page — the agent's starters, every attached
* skill's declared suggestions, the page's own derived prompts — and each of
* those chips carries the metadata that makes it *executable*: a `capability`
* that names one of the page's answers, a `skillId`/`skillCapability` pair that
* names a skill's reading, a `positionId` that says which record, a `route` for
* the ones that navigate. All this module does is choose which of those
* already-built chips are worth showing for a given query, and hand them back
* unchanged so that clicking one runs exactly what it always ran.
*
* Returning the original object rather than a copy is the whole contract. A
* ranked chip is the same chip; `runPrompt` cannot tell it apart from one that
* arrived unfiltered, so nothing about how a suggestion executes depends on
* whether it was typed towards or offered outright.
*/
/**
* The shortest query worth ranking. Below it the panel shows nothing at all —
* one character matches most of the catalogue, which is the wall of chips this
* replaced.
*/
export const MIN_QUERY_CHARS = 2;
/** Never more than a row. The composer is what the reader came for. */
export const MAX_MATCHES = 3;
/** One shared empty array, so a keystroke that matches nothing is referentially
stable and does not rerender the chip row. */
const NONE = [];
const lower = (value) => String(value ?? '').toLowerCase();
/**
* Letters and digits only, Unicode aware — the same thing the reader would
* count. Punctuation alone never counts as having typed anything.
*/
const meaningful = (value) => (String(value ?? '').match(/[\p{L}\p{N}]/gu) || []).length;
/** A string as the words worth matching on. */
const words = (value) => lower(value).split(/[^\p{L}\p{N}]+/u).filter(Boolean);
/**
* Words too common to carry a subject.
*
* Only used to stop a query made *entirely* of them from matching the whole
* catalogue: "show me the" should offer nothing rather than everything. A
* stop word alongside a real term is still scored, because "show pipeline"
* should rank a chip saying both above one saying only the second.
*/
const STOP_WORDS = new Set([
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'do', 'does', 'did',
'show', 'me', 'my', 'our', 'as', 'of', 'for', 'in', 'on', 'to', 'and', 'or',
'with', 'what', 'how', 'why', 'can', 'you', 'i', 'it', 'please', 'give',
'tell', 'about', 'this', 'that', 'any', 'all',
]);
/**
* How well one term matches one field.
*
* Prefix matching is what makes this feel like typing rather than searching:
* "platf" has to find "Platform health" four characters before the word is
* finished. Infix matching is allowed only from four characters, where a
* fragment is specific enough that finding it mid-word is a hit rather than an
* accident — "line" must not match "pipeline" while "peli" reasonably does.
*/
function fieldScore(term, fieldWords, exact, partial) {
let best = 0;
for (const word of fieldWords) {
if (word === term) return exact;
if (word.startsWith(term)) best = Math.max(best, partial);
else if (term.length >= 4 && word.includes(term)) best = Math.max(best, partial - 1);
}
return best;
}
/**
* What a chip resolves to, as words.
*
* A capability id is not decoration — `pipeline-health` is the name of the
* reading the chip runs, and it is frequently the only place the subject
* appears. The Control Center's bottleneck chip reads "Bottleneck at
* interviewed" and sends a sentence about candidates dropping between stages;
* nothing in either says "pipeline", and typing that word is exactly how a
* reader would look for it. So the chip's own routing metadata is matched too,
* at the lowest weight of the three fields — it is what the chip *is*, not what
* it says, and a chip that says the word should always rank above one that
* merely resolves to it.
*/
const intentWords = (chip) => words(
[chip?.capability, chip?.skillId, chip?.skillCapability].filter(Boolean).join(' ')
);
/**
* A chip's score for a query, or 0 for "do not offer this".
*
* The label is weighted above the prompt because the label is what the reader
* sees: a chip that reads "Platform health" is a better answer to "platform"
* than one that happens to mention the word in the sentence it sends, even
* though both would answer.
*/
function score(chip, terms, phrase) {
const label = lower(chip?.label);
const prompt = lower(chip?.prompt);
if (!label && !prompt) return 0;
let total = 0;
let matched = 0;
/* The whole query as one phrase, which is the strongest signal there is —
"pipeline health" typed in full should beat two chips that each carry one
of those words. */
if (label.includes(phrase)) total += 12;
else if (prompt.includes(phrase)) total += 7;
const labelWords = words(label);
const promptWords = words(prompt);
const routeWords = intentWords(chip);
for (const term of terms) {
const hit = Math.max(
fieldScore(term, labelWords, 6, 4),
fieldScore(term, promptWords, 4, 2),
fieldScore(term, routeWords, 3, 2)
);
if (hit) {
matched += 1;
total += hit;
}
}
/* A chip has to actually be about something that was typed. */
if (!matched) return 0;
/* Every term landing somewhere is worth more than most of them landing. */
if (matched === terms.length) total += 3;
/* A suggestion that would have to ask which record before it could answer
ranks below one that answers — the same order the resolver already puts
them in when they are offered unfiltered. */
if (chip?.deferred) total -= 3;
return total;
}
/**
* The best few of `prompts` for `query`, in the chips' own objects.
*
* Stable: chips scoring equally keep the order they arrived in, which is the
* order the panel already considers most useful — agent starters, then declared
* skill suggestions, then the page's derived prompts.
*/
export function rankPrompts(prompts = [], query = '', max = MAX_MATCHES) {
const text = String(query ?? '').trim();
if (meaningful(text) < MIN_QUERY_CHARS) return NONE;
const terms = words(text);
if (!terms.length || terms.every((term) => STOP_WORDS.has(term))) return NONE;
const phrase = lower(text);
const scored = [];
prompts.forEach((chip, index) => {
const value = score(chip, terms, phrase);
if (value > 0) scored.push({ chip, value, index });
});
scored.sort((a, b) => b.value - a.value || a.index - b.index);
return scored.length ? scored.slice(0, max).map((entry) => entry.chip) : NONE;
}

View File

@@ -26,182 +26,424 @@
* }
*/
import { getContext } from './contexts';
import { note, toSnapshots } from './blocks';
import { confirmation, note } from './blocks';
/**
* An answer, at the depth the agent asked for.
* The agent provider: the real runtime, over the real API.
*
* Three modes, and only two of them do anything: `balanced` is today's
* behaviour exactly, so every existing answer and every agent that declares no
* reasoning is unchanged.
* The only provider that answers. Everything that makes that safe lives on the
* server:
*
* `webSearch` is handled here too, and handled honestly. No search provider is
* configured in this deployment, so an agent with the flag set gets a note
* saying so rather than an answer that quietly came from nowhere. Silently
* ignoring the flag would be worse: the configuration would read as working.
* - **The principal is the session's.** The body carries a question and
* nothing else about who is asking. The browser cannot name a caller, so it
* cannot ask about records it is not entitled to see.
* - **The tools and corpora are the SPEC's.** Not the request's. An agent
* reads what its published definition says it may read, and no field here
* can widen that.
* - **A write cannot happen from a question.** The backend answers a proposed
* write with a confirmation payload and performs nothing. Approving it is a
* second, explicit call carrying the token — see `confirmation` below.
*
* One snapshot, not a stream. The endpoint is not streaming yet, so this yields
* the finished answer once. The signature is the streaming one because that is
* what the seam is, and switching to real streaming later changes this function
* and nothing else.
*/
function shapeForDepth(document, agent, facts) {
if (!agent || !document?.blocks?.length) return document;
const blocks = [...document.blocks];
if (agent.reasoning === 'fast') {
/* The headline and the first supporting block. Enough to answer, without
the breakdown someone asking a quick question did not want. */
const trimmed = blocks.slice(0, 2);
return { ...document, blocks: trimmed.length ? trimmed : blocks };
}
if (agent.reasoning === 'deep') {
const counted = [
facts?.applications?.length != null && `${facts.applications.length} applications`,
facts?.postings?.length != null && `${facts.postings.length} positions`,
facts?.staff?.length != null && `${facts.staff.length} hires`,
].filter(Boolean);
if (counted.length) {
blocks.push(note(`Read from ${counted.join(', ')} on this page.`));
}
}
if (agent.webSearch) {
blocks.push(note(
'Web search is enabled on this agent, but no search provider is configured '
+ 'in this deployment, so nothing outside this workspace was consulted.'
));
}
return { ...document, blocks };
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Local provider — answers computed from the dashboard's own data.
*
* Deterministic: the same question against the same data returns the same
* answer, which is what makes the assistant demonstrable and testable.
*/
export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
return {
id: 'local',
id: 'agent',
async *stream({ contextId, capability, question, facts, agent = null, signal }) {
const context = getContext(contextId);
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
const document = capability
? context.capabilities.find((c) => c.id === capability)?.run(facts)
?? context.respond(question, facts)
: context.respond(question, facts);
/**
* The agent's reasoning mode, as answer depth.
*
* A real configuration effect rather than a label: `fast` returns the
* headline and stops, `deep` says which records the reading counted.
* `balanced` — the default, and what an agent that declares nothing gets
* — is untouched, so this cannot change any existing answer.
*
* Depth never changes *what* was read. The page decided that before the
* provider was called; this only decides how much of it to say.
*/
const shaped = shapeForDepth(document, agent, facts);
// A brief pause before the first frame, so the answer reads as considered
// rather than precomputed.
await sleep(latency);
if (signal?.aborted) return;
for (const snapshot of toSnapshots(shaped)) {
if (signal?.aborted) return;
yield snapshot;
await sleep(frameDelay);
async *stream({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
/* No agent, no run. The panel resolves which agent covers the page before
calling; reaching here without one means the routing layer changed and
this should say so rather than guess at an agent id. */
if (!agent?.id) {
yield [note('No agent is available for this page.')];
return;
}
},
};
}
/**
* HTTP provider — the path to a real backend.
*
* 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 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');
return {
id: 'http',
async *stream({ contextId, capability, question, agent = null, owliverContext = null, signal }) {
const response = await fetch(endpoint, {
const response = await fetch(`${baseUrl}/agents/${encodeURIComponent(agent.id)}/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
/* `agent` and `owliverContext` travel; the fact sheet still does not.
Dashboard records are read server-side from the caller's own session,
so the client cannot ask about records it is not entitled to see —
and an agent cannot widen that by being named in the body. */
body: JSON.stringify({ contextId, capability, question, agent, owliverContext }),
headers: {
'Content-Type': 'application/json',
/* Ask for a stream. The server answers the same run either way — the
final event carries exactly the body the non-streaming path
returns — so a deployment that cannot stream degrades to one late
snapshot rather than to a broken panel. */
Accept: 'text/event-stream, application/json',
},
/* The session cookie. Without it the API answers 401, which is the
correct answer to a browser that is not signed in. */
credentials: 'include',
body: JSON.stringify({
input: question,
confirmation: confirmation || undefined,
/* Pins the conversation to the version it started with. Every answer
comes back carrying its version; sending it on the next turn is what
stops an edit published mid-thread from silently changing which
agent is answering. */
agentVersion: agentVersion || undefined,
}),
signal,
});
if (!response.ok || !response.body) {
throw new Error(`Assistant request failed: ${response.status}`);
if (!response.ok) {
yield [note(await describeFailure(response))];
return;
}
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; 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.block) blocks.push(parsed.block);
else if (parsed.delta) appendDelta(parsed.delta);
} catch {
appendDelta(payload);
}
yield [...blocks];
}
/* Not a stream after all — a proxy that buffers, or a server answering
JSON. Read it whole. */
if (!isEventStream(response) || !response.body) {
yield toBlocks(await response.json());
return;
}
yield* readRunStream(response, signal);
},
};
}
/**
* The provider the app uses. Local by default; point
* `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to switch, with no other
* code change.
* Whether the server actually opened a stream, rather than answering JSON.
*
* Defensive about `headers` because the answer to "is this a stream" must be
* NO when anything is unexpected. A response shape this does not recognise gets
* read whole, which works; assuming a stream and finding none would hang.
*/
function isEventStream(response) {
const type = response?.headers?.get?.('Content-Type') || '';
return type.includes('text/event-stream');
}
/**
* Reads the run's event stream, yielding the answer as it grows.
*
* Two kinds of event and they are handled very differently:
*
* - `delta` is a fragment of assistant text. The accumulated text is
* re-parsed into blocks on every one, so a half-written answer renders as
* far as it makes sense to — a table mid-construction stays as text until
* its rows arrive, which reads better than a broken table.
* - `run` is the finished result: the same body the non-streaming path
* returns, carrying confirmations, the termination and the token cost.
* Whatever text streamed is replaced by it, because the final snapshot is
* authoritative and the deltas were a preview of it.
*
* A client that ignored every delta and read only the last event would be in
* exactly the state it would have reached without streaming. That is what keeps
* the two paths honest rather than merely similar.
*/
async function* readRunStream(response, signal) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let text = '';
let final = null;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
/* The last element may be half a 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;
let event;
try {
event = JSON.parse(payload);
} catch {
/* A malformed frame is dropped rather than rendered. It cannot be
assistant text — the server encodes every event as JSON — so
showing it would put transport noise in front of a reader. */
continue;
}
if (typeof event.delta === 'string') {
text += event.delta;
yield markdownToBlocks(text);
continue;
}
if (event.run) final = event.run;
if (event.error) {
yield [note(event.error.message || 'The agent could not be reached.')];
return;
}
}
if (signal?.aborted) break;
}
} finally {
/* Releasing matters on an abort: a reader still holding the body keeps the
connection open, and a user who pressed Stop expects it to stop. */
try { reader.cancel(); } catch { /* already closed */ }
}
if (final) {
yield toBlocks(final);
return;
}
/* The stream ended without a final event — the run was aborted, or the
connection dropped mid-answer. Whatever arrived is kept: a half-read answer
is worth more to the reader than an empty panel. */
if (text) yield markdownToBlocks(text);
}
/**
* Turns a run result into blocks.
*
* The termination decides the shape, and every one of the six produces
* something a person can act on. A run that ended without completing is not an
* error to swallow: it has an answer-so-far worth keeping and a reason worth
* reading.
*/
function toBlocks(run) {
const blocks = [];
if (run.output) blocks.push(...markdownToBlocks(run.output));
/* Pending writes before the trailing note: somebody scrolling to the bottom
should meet the decision, not a footnote about token cost. */
for (const c of run.confirmations || []) {
blocks.push(confirmation(c));
}
/* The surface layer's wording for a run that did not complete. Rendered as a
note rather than as prose, so it reads as the system speaking rather than
as the agent's own words. */
if (run.message) blocks.push(note(run.message));
if (!blocks.length) {
blocks.push(note('The agent finished without saying anything.'));
}
return blocks;
}
/**
* Turns a model's markdown into the block vocabulary the panel already renders.
*
* A real model writes markdown — headings, numbered steps, tables. The local
* simulator never did: it emitted short single paragraphs, so the text renderer
* only ever handled inline bold and italic. Point the panel at a real model and
* `## What I'd do, in order` arrives on screen with the hashes still attached,
* and a comparison table arrives as pipes.
*
* The fix is NOT to render markdown inside a text block. The panel already has
* a heading block, a list block and a table block, all styled with the same
* tokens as the dashboard cards beside them — so the honest move is to parse
* into those, and let a generated answer look like it belongs to Krow rather
* than like a chat window that happens to be embedded in it.
*
* Deliberately a small parser and not a markdown library. Four constructs is
* what a model actually produces in an answer; anything else falls through as a
* paragraph, which reads correctly even when it is not styled richly. A full
* parser would be a large dependency in exchange for handling footnotes nobody
* writes.
*/
export function markdownToBlocks(markdown) {
const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
const blocks = [];
let paragraph = [];
let listItems = null;
let ordered = false;
const flushParagraph = () => {
const text = paragraph.join(' ').trim();
paragraph = [];
if (text) blocks.push({ type: 'text', text });
};
const flushList = () => {
if (listItems?.length) blocks.push({ type: 'list', items: listItems, ordered });
listItems = null;
};
const flushAll = () => { flushParagraph(); flushList(); };
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) { flushAll(); continue; }
/* A heading. The level is dropped: this panel has one heading style, and
inventing three would give a 380px column a hierarchy it cannot show. */
const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
if (heading) {
flushAll();
blocks.push({ type: 'heading', text: stripInline(heading[2]) });
continue;
}
/* A table: a pipe row followed by a separator row. Checked together,
because a single pipe row is far more likely to be prose. */
if (trimmed.startsWith('|') && isSeparatorRow(lines[i + 1])) {
flushAll();
const { block, next } = parseTable(lines, i);
if (block) { blocks.push(block); i = next; continue; }
}
const bullet = /^[-*]\s+(.+)$/.exec(trimmed);
const numbered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
if (bullet || numbered) {
const wantOrdered = Boolean(numbered);
/* A list that changes kind mid-way is two lists. */
if (listItems && ordered !== wantOrdered) flushList();
flushParagraph();
ordered = wantOrdered;
listItems = listItems || [];
listItems.push((bullet || numbered)[1].trim());
continue;
}
flushList();
paragraph.push(trimmed);
}
flushAll();
return blocks.length ? blocks : [{ type: 'text', text: String(markdown).trim() }];
}
/** `|---|---:|` — the row that makes the one above it a header. */
function isSeparatorRow(line) {
return Boolean(line && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes('-'));
}
function splitRow(line) {
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
}
/**
* Reads a markdown table starting at `start`.
*
* Rows with the wrong number of cells are padded or trimmed rather than
* dropped. A model occasionally miscounts a pipe, and losing a whole row of an
* answer over a formatting slip is worse than showing an empty cell — which the
* renderer already draws as an em dash.
*/
function parseTable(lines, start) {
const header = splitRow(lines[start]);
const columns = header.map((label, i) => ({ key: `c${i}`, label: stripInline(label) }));
const rows = [];
let i = start + 2;
for (; i < lines.length; i += 1) {
const line = lines[i];
if (!line.trim().startsWith('|')) break;
const cells = splitRow(line);
const row = {};
columns.forEach((col, n) => { row[col.key] = stripInline(cells[n] ?? ''); });
rows.push(row);
}
if (!rows.length) return { block: null, next: start };
return { block: { type: 'table', columns, rows }, next: i - 1 };
}
/**
* Removes markdown a cell or heading cannot show.
*
* Table cells and headings are rendered as plain strings by their components,
* so `**Maria**` would appear with the asterisks. Paragraphs and list items are
* left alone — those go through `Inline`, which renders bold properly.
*/
function stripInline(value) {
return String(value).replace(/\*\*(.+?)\*\*/g, '$1').replace(/`(.+?)`/g, '$1').trim();
}
/**
* A failed request, in one sentence a person can act on.
*
* The status is what distinguishes the cases that matter, and they are
* genuinely different actions: sign in again, ask someone for access, or wait.
* Flattening them into "something went wrong" makes the user's next move a
* guess.
*/
async function describeFailure(response) {
let detail = '';
try {
const body = await response.json();
detail = body?.error?.message || '';
} catch {
/* A non-JSON error body is a proxy or a gateway, not this API. The status
still says enough. */
}
switch (response.status) {
case 401:
return 'Your session has expired. Sign in again to keep asking.';
case 404:
/* Deliberately the same answer for "no such agent" and "not yours" — the
API refuses to distinguish them, and repeating the distinction here
would undo that. */
return 'That agent is not available on this workspace.';
case 422:
return detail || 'That agent cannot run right now.';
case 429:
return 'Too many requests just now. Try again in a moment.';
default:
return detail || 'The agent could not be reached. Try again in a moment.';
}
}
/**
* The provider the app uses.
*
* There used to be three: a local simulator that computed answers from data
* already in the browser, a streaming HTTP provider for a deployment that had
* one, and the agent. The simulator is gone, and its removal is the point of
* this file's current shape.
*
* WHY IT WENT
*
* Two answering paths behind one avatar meant the same question got different
* answers depending on phrasing — "assign the strongest free worker" matched a
* template and reported nobody was available, while "put the best free worker
* on" reached the agent, which found somebody and proposed them. A user cannot
* be expected to know which sentence talks to which system, and a product where
* the wording decides the answer is a demo with good manners.
*
* WHAT IT COST, SAID PLAINLY
*
* The simulator was instant, free, and could not be wrong about a figure — it
* read the same cache the page rendered from. The agent takes ten to twenty
* seconds and costs tokens. That is a real regression on speed, accepted in
* exchange for answers that can be followed up, cannot be beaten by a synonym,
* and can act on what they find.
*
* AN UNCONFIGURED DEPLOYMENT NOW SAYS SO
*
* With no VITE_AGENT_API there is nothing to fall back to. That state is
* explicit rather than silent: every question is answered with the reason,
* because a panel that quietly does nothing is the worst of the three
* possibilities and the hardest to diagnose.
*/
export function createAssistantProvider() {
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;
return endpoint ? createHttpProvider({ endpoint }) : createLocalProvider();
const base = import.meta.env?.VITE_AGENT_API;
return base ? createAgentProvider({ baseUrl: base }) : createUnconfiguredProvider();
}
/**
* The provider for a deployment with no agent configured.
*
* Answers every question with the same sentence, which is the honest thing to
* do: nothing here can answer, and pretending otherwise is what the simulator
* was doing.
*/
export function createUnconfiguredProvider() {
return {
id: 'unconfigured',
// eslint-disable-next-line require-yield
async *stream() {
yield [note(
'Owliver is not configured on this deployment. Set VITE_AGENT_API and give the '
+ 'backend a model credential, and this panel will answer from your workspace.'
)];
},
};
}

View File

@@ -166,14 +166,21 @@ export function navigationAnswer(destination) {
/**
* The reply when the question fits neither this page nor another one.
*
* Lists what this page can answer, taken from the capability labels the panel
* already shows as chips — so the offer is always exactly what is on screen.
* Reached only on a deployment with no agent configured. With one, a question
* this page cannot place is handed to the agent rather than declined — see
* `preferAgent`, which turns `outOfScope` into `answer`.
*
* It used to list the page's capability labels, taken from the chips on screen.
* Those labels were the local simulator's readings and went with it. The page's
* TOPICS are what survive, and they are a better offer anyway: a capability
* label named a canned report, where a topic names a subject somebody can
* actually ask about in their own words.
*/
export function outOfScopeAnswer(context) {
const labels = (context?.capabilities || []).map((c) => c.label);
const subjects = (context?.topics || []).slice(0, 6);
return doc(
text(`I do not have that on **${context?.page || 'this page'}**.`),
list(labels.slice(0, 6), { ordered: false }),
subjects.length ? text(`This page covers ${subjects.join(', ')}.`) : null,
note('Ask about one of those, or open the page the question belongs to.')
);
}
@@ -928,3 +935,79 @@ export function resolveIntent({
return { kind: 'outOfScope', doc: outOfScopeAnswer(context) };
}
/* ── Preferring the agent ─────────────────────────────────────────────────── */
/**
* Decides whether a resolved intent should defer to the agent.
*
* BACKGROUND, because this function only makes sense with it.
*
* This panel grew up answering from the browser. Everything above computes an
* answer out of data React Query already fetched — instant, free, and unable to
* be wrong about a figure, because it reads the same cache the page renders
* from. What it cannot do is reason, and it can only answer the question shapes
* somebody wrote a matcher for.
*
* There is now a real agent behind the panel: a model, seventeen authorized
* tools, permissioned document retrieval, and a write path that asks before it
* acts. It can answer anything, and it can be asked a follow-up.
*
* Both paths wearing the same avatar was the problem. "Assign the strongest
* free worker to Picker" matched a template and answered "nobody is both
* qualified and free"; "put the best free worker on Picker" reached the agent,
* which found somebody and proposed them. Same intent, opposite answers,
* decided by which verb the user happened to type. That is not a product.
*
* WHAT THIS CHANGES, AND WHAT IT DELIBERATELY DOES NOT
*
* An intent that only produces TEXT defers to the agent. An intent that DOES
* something does not, and the distinction is the whole of the rule:
*
* - A template answer is one of several possible descriptions of rows the
* agent can also read. The agent's version can be followed up and cannot be
* beaten by a synonym, so it wins.
* - A flow, a draft action, an assignment, a headcount change, an interview
* being marked ready — these perform work the agent has no tool for. They
* are kept exactly as they are. Removing them would lose capability, not
* gimmickry.
* - A guided form that asks "which position?" one question at a time is an
* honest form. It stays, and it is not the agent pretending to converse.
* - `navigate` stays: sending somebody to the page that owns a subject is a
* product decision, not a failure to answer.
*
* `outOfScope` becomes an answer, and that is the clearest win here. It is the
* panel declining a question the agent could simply have answered.
*
* NOTHING IS DELETED. Every matcher above still runs and still returns what it
* always did; this only chooses not to use the text ones while an agent is
* live. Turn the agent off and the panel behaves exactly as it shipped — which
* is what makes this safe to try before anything is removed for good.
*/
export function preferAgent(intent, { modelBacked = false } = {}) {
if (!modelBacked || !intent) return intent;
/* Anything that performs work keeps its path. The presence of one of these
keys IS the definition of "does something" — see the handlers in
useAssistant, which are the only readers of them. */
if (intent.create || intent.perform || intent.assign || intent.headcount ||
intent.interview || intent.action || intent.flow) {
return intent;
}
switch (intent.kind) {
/* Pure text. The agent reads the same rows and can be asked a follow-up. */
case 'answer-doc':
case 'skill':
case 'workforce':
/* A refusal the agent would not have made. */
case 'outOfScope':
return { kind: 'answer' };
/* 'answer' already goes to the agent. 'navigate' and 'constrained' are
deliberate product behaviour and are left alone. */
default:
return intent;
}
}

View File

@@ -6,7 +6,6 @@ import {
useUserActivity, useWorkerProfile, useWorkerProfiles,
} from '@/lib/krowHooks';
import { skillsForContext } from '@/lib/skills/registry';
import { suggestionsForPosition } from '@/lib/skills/owliverResolver';
import {
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
} from '@/lib/skills/positionFlow';
@@ -25,12 +24,22 @@ import { storableContext } from '@/lib/agents/context';
import { buildFacts } from './insights';
import { agentRequest } from '@/lib/agents/runtime';
import { createAssistantProvider } from './provider';
import { resolveIntent } from './routing';
import { preferAgent, resolveIntent } from './routing';
import { doc, text as textBlock, toSnapshots } from './blocks';
/** One provider instance for the app's lifetime. */
const provider = createAssistantProvider();
/**
* Whether a real agent is answering, as opposed to the local simulator.
*
* Read once, from the provider the app actually built. Not a separate flag: a
* second switch could disagree with the first, and "the panel thought it had an
* agent and did not" is a failure mode with no visible symptom beyond worse
* answers.
*/
const agentBacked = provider.id === 'agent';
/**
* Reads the same React Query caches the pages render from, so the assistant
* costs no extra requests and cannot be looking at a different snapshot than the
@@ -228,7 +237,8 @@ function withoutAuthoringActions(messages = []) {
* routing applies to both without either knowing it exists.
*/
export function useConversation({
contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview,
contextId, facts, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
onAssignWorkers, onScheduleInterview,
/* Finishing a draft: the same two mutations the Create Position form calls.
Passed in rather than reached for, so this layer still writes nothing
itself and there is one update path for a position. */
@@ -414,6 +424,18 @@ export function useConversation({
* thread, is persisted and archived like any other, and nothing about it is
* simulated — it is the live pipeline pointed at another surface.
*/
/* The most recent question, for the confirmation path. A ref rather than
state: nothing renders from it, and making it state would re-render the
whole panel on every keystroke-completed turn for no visible reason. */
const lastQuestionRef = React.useRef('');
/* The agent version this conversation started on.
§3: running conversations pin the version they started with. Held in a ref
rather than state because nothing renders from it — and reset with the
thread, so a NEW conversation picks up whatever is current rather than
inheriting a version somebody has since moved on from. */
const pinnedVersionRef = React.useRef(0);
const send = React.useCallback(async ({
question, capability = null, positionId = null, scope = null,
}) => {
@@ -463,16 +485,22 @@ export function useConversation({
if (!intent) {
intent = capability
? { kind: 'answer' }
: resolveIntent({
question: text,
contextId: turnContext,
disabledSkills: turnDisabled,
customSkills, roles, skillCategories,
courses, workforce, skillContext, positionId,
agent: turnAgent,
agentCoversPage: turnCovers,
agentSuggestion, owliverContext,
});
: preferAgent(
resolveIntent({
question: text,
contextId: turnContext,
disabledSkills: turnDisabled,
customSkills, roles, skillCategories,
courses, workforce, skillContext, positionId,
agent: turnAgent,
agentCoversPage: turnCovers,
agentSuggestion, owliverContext,
}),
/* Only while a real agent is behind the panel. With the local
simulator there is nothing better to defer TO, and deferring
would turn every template answer into a worse one. */
{ modelBacked: agentBacked },
);
}
/**
@@ -482,41 +510,56 @@ export function useConversation({
*/
if (intent.kind === 'flow' && intent.create) {
let created = null;
/* Kept, not swallowed. The reply states the outcome, and "it did not
work" is a worse outcome to state than the reason it did not: a
required field, a refused role, or an API that is not running. */
let failure = null;
try {
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
} catch {
} catch (error) {
created = null;
failure = error;
}
if (created?.id) {
/**
* What can now be asked about the position that was just made.
* What can now be asked, with the position in the database.
*
* A live position is something the page's skills can read; a draft is
* not finished being specified, so offering to analyse it would be
* answering about a record the admin has not committed to. The registry
* decides *which* skills can say something — see
* `suggestionsForPosition` — and this decides only whether it is yet
* the moment to ask them.
* The record exists, so the organization is materially different from
* what it was one turn ago — there is one more role to fill, or one
* more unfinished draft — and what is worth asking has changed with it.
* `onRefreshSuggestions` asks the server that question again rather
* than deriving an answer here: the ranking is the API's, against the
* rows it has just written, filtered by the caller's role.
*
* A draft is offered nothing. It is not finished being specified, and
* suggesting readings of a record the admin has not committed to would
* be answering about something that is not yet true.
*/
const ready = created.status === 'active';
let refreshed = [];
if (ready) {
try {
refreshed = (await onRefreshSuggestions?.()) || [];
} catch {
/* The position was created; failing to fetch what to ask next is
not a reason to report that it was not. */
refreshed = [];
}
}
intent = {
...intent,
flow: null,
doc: positionCreatedReply(created),
followUp: [
...createdFollowUp(created),
...(ready
? suggestionsForPosition(turnContext, turnDisabled, customSkills, created)
: []),
],
followUp: [...createdFollowUp(created), ...refreshed],
};
} else {
/* Keep the answers: the summary is still there to try again from. */
intent = {
...intent,
flow: { ...intent.flow, stage: 'review' },
doc: positionFailedReply(),
doc: positionFailedReply(failure?.message),
followUp: [
{ label: 'Create position', prompt: 'Create position' },
{ label: 'Change details', prompt: 'Change details' },
@@ -694,12 +737,16 @@ export function useConversation({
try {
let latest = [];
/* Held for the confirmation path: approving a proposed write resumes the
run, and a resumed run needs the question that produced the proposal. */
lastQuestionRef.current = text;
for await (const snapshot of provider.stream({
contextId: turnContext, capability, question: text, facts, signal: controller.signal,
/* What the agent *is*, never what it may read. The page settled that
before this call, and `agentRequest` carries no records. */
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
owliverContext,
agentVersion: pinnedVersionRef.current,
})) {
if (controller.signal.aborted) break;
latest = snapshot;
@@ -730,7 +777,8 @@ export function useConversation({
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onUpdatePosition,
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
onUpdatePosition,
onGenerateDescription, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
@@ -739,6 +787,68 @@ export function useConversation({
const stop = React.useCallback(() => abortRef.current?.abort(), []);
/**
* Approves a proposed write, and lets the run finish.
*
* The second half of the confirmation flow. The first half ended with the
* agent describing something and doing nothing; this carries the person's
* decision back and the server performs exactly the call that description was
* issued against — same tool, same arguments, same caller. A token authorises
* one write and expires; it is not a mode.
*
* The original question is re-sent alongside it, because the run that resumes
* is a NEW run: it has to be able to reach the same tool call again for the
* token to match. That is why the token is not bound to a run id — see
* tools/confirm.go.
*
* Nothing happens locally. This layer does not write, does not optimistically
* mark anything done, and does not tell the user it worked: the answer that
* comes back says what actually happened, including a refusal if the world
* moved between the asking and the answering.
*/
const confirm = React.useCallback(async (block) => {
if (!block?.token) return;
/* The question this confirmation was raised for. Read from the thread
rather than held in state, so approving an older proposal still resends
the right question rather than whatever was typed most recently. */
const question = lastQuestionRef.current;
if (!question) return;
const controller = new AbortController();
abortRef.current = controller;
setError(null);
setPending({ blocks: [], thinking: true });
try {
let latest = [];
for await (const snapshot of provider.stream({
contextId, capability: null, question, facts,
agent: agent ? agentRequest(agent, contextId) : null,
owliverContext,
confirmation: block.token,
agentVersion: pinnedVersionRef.current,
signal: controller.signal,
})) {
if (controller.signal.aborted) break;
latest = snapshot;
setPending({ blocks: snapshot, thinking: false });
}
if (latest.length) {
const next = [...messagesRef.current, { role: 'assistant', blocks: latest }];
messagesRef.current = next;
persist(next);
}
} catch (e) {
if (e?.name !== 'AbortError') {
setError('That approval could not be completed. Nothing was changed.');
}
} finally {
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, agent, owliverContext, persist]);
/**
* States something in the thread without a question having been asked.
*
@@ -859,6 +969,8 @@ export function useConversation({
send,
announce,
stop,
/** Approves a proposed write and resumes the run. See `confirm`. */
confirm,
reset,
submitFeedback,
feedback,

View File

@@ -29,8 +29,12 @@ export default function HiringAnalytics({ applications, staff }) {
const totalApps = applications.length;
const hireRate = totalApps ? Math.round((hiredApps.length / totalApps) * 100) : 0;
const avgHireScore = hiredApps.length
? Math.round(hiredApps.reduce((s, a) => s + (a.ai_score || 0), 0) / hiredApps.length)
/* Averaged over the hires that carry a score. A hire can reach 'hired'
without ever being scored, and counting that as a 0 drags the figure down
by the number of people nobody scored rather than by anything about them. */
const scoredHires = hiredApps.filter(a => a.ai_score > 0);
const avgHireScore = scoredHires.length
? Math.round(scoredHires.reduce((s, a) => s + a.ai_score, 0) / scoredHires.length)
: 0;
const sevenDaysAgo = new Date();
@@ -48,7 +52,7 @@ export default function HiringAnalytics({ applications, staff }) {
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">HIRE RATE</div>
</div>
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
<div className="text-2xl font-heading font-bold text-[#333F48]">{avgHireScore}</div>
<div className="text-2xl font-heading font-bold text-[#333F48]">{avgHireScore || '—'}</div>
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">AVG SCORE</div>
</div>
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">

View File

@@ -4,7 +4,10 @@ import { Clock, DollarSign, Award, Zap, Scale, Heart } from 'lucide-react';
export default function ImpactMetrics({ applications, interviews, staff: _staff }) {
const hired = applications.filter(a => a.status === 'hired');
const screened = applications.filter(a => a.ai_score > 0);
/* Named for what it is: the applications the AI actually scored. Elsewhere
in the product "screened" means the stage (status <> 'applied'), which is a
different and larger set — every saving below is per AI screen performed. */
const aiScreened = applications.filter(a => a.ai_score > 0);
// 1. Reduce time-to-hire: avg days from application created to hired
const timeToHire = hired.length
@@ -20,19 +23,20 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff
const tthDisplay = timeToHire ? `${timeToHire}d avg` : 'Instant screen';
// 2. Lower recruiting costs: $45 per manual screen + $80 per manual interview saved
const costSaved = screened.length * 45 + interviews.length * 80;
const costSaved = aiScreened.length * 45 + interviews.length * 80;
// 3. Improve quality of hire: avg AI score of hired candidates
const qualityOfHire = hired.length
? Math.round(hired.reduce((s, a) => s + (a.ai_score || 0), 0) / hired.length)
const scoredHires = hired.filter(a => a.ai_score > 0);
const qualityOfHire = scoredHires.length
? Math.round(scoredHires.reduce((s, a) => s + a.ai_score, 0) / scoredHires.length)
: 0;
// 4. Increase recruiter productivity: hours saved (15 min per AI screen + 30 min per interview)
const hoursSaved = Math.round((screened.length * 15 + interviews.length * 30) / 60);
const hoursSaved = Math.round((aiScreened.length * 15 + interviews.length * 30) / 60);
// 5. Standardize hiring decisions: % of applicants with standardized AI scoring applied
const standardizedPct = applications.length
? Math.round((screened.length / applications.length) * 100)
? Math.round((aiScreened.length / applications.length) * 100)
: 0;
// 6. Improve candidate experience: AI responds instantly (interview completion rate as proxy)
@@ -53,7 +57,7 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff
icon: DollarSign,
label: 'Cost Saved',
value: `$${costSaved.toLocaleString()}`,
sub: `${screened.length} screens · ${interviews.length} interviews automated`,
sub: `${aiScreened.length} screens · ${interviews.length} interviews automated`,
color: '#333F48',
bg: '#F3F4F6',
},
@@ -61,7 +65,11 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff
icon: Award,
label: 'Quality of Hire',
value: qualityOfHire ? `${qualityOfHire}/100` : '—',
sub: hired.length ? `${hired.length} hired candidates` : 'Awaiting first hire',
/* The basis is the scored hires, not every hire, so the caption counts
the ones the figure is actually made of. */
sub: scoredHires.length
? `${scoredHires.length} scored ${scoredHires.length === 1 ? 'hire' : 'hires'}`
: hired.length ? 'No hire has been scored' : 'Awaiting first hire',
color: '#0838E0',
bg: '#FEFCE8',
},
@@ -77,7 +85,7 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff
icon: Scale,
label: 'Decisions Standardized',
value: `${standardizedPct}%`,
sub: `${screened.length} of ${applications.length} applicants scored`,
sub: `${aiScreened.length} of ${applications.length} applicants scored`,
color: '#0838E0',
bg: '#EEF3FE',
},

View File

@@ -23,7 +23,11 @@ export default function ScoreDistribution({ applications }) {
return scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;
}, [applications]);
const screened = applications.filter(a => a.ai_score > 0).length;
/* Deliberately "scored", not "screened": everywhere else screened means the
stage (status <> 'applied'), and on this corpus that is 14 against 9 here.
Calling both "screened" made this card and the unscreened count on the
dashboard fail to add up to the pool. */
const scored = applications.filter(a => a.ai_score > 0).length;
return (
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
@@ -34,7 +38,9 @@ export default function ScoreDistribution({ applications }) {
<div className="text-[10px] text-[#9CA3AF] tracking-wide">AVG SCORE</div>
</div>
</div>
<p className="text-[12px] text-[#6B7280] mb-5">{screened} candidates screened</p>
<p className="text-[12px] text-[#6B7280] mb-5">
{scored} {scored === 1 ? 'candidate' : 'candidates'} scored
</p>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data}>