+ {block.steps.map((step) => (
+
+
+ {step.label}
+
+ {step.rate != null && {step.rate}%}
+ {step.count}
+
+
+
+
0 ? 4 : 0)}%` }}
+ />
+
+
+ ))}
+
+ );
+});
+FunnelBlock.displayName = 'FunnelBlock';
+
+const ListBlock = React.memo(({ block }) => {
+ const Tag = block.ordered ? 'ol' : 'ul';
+ return (
+
+ {block.items.map((item, i) => (
+
+ {block.ordered ? (
+
+ {i + 1}
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ );
+});
+ListBlock.displayName = 'ListBlock';
+
+/** Findings, each in a tinted card so severity is scannable. */
+const InsightsBlock = React.memo(({ block }) => (
+
+ {block.items.map((item, i) => (
+
+
{item.title}
+ {item.body && (
+
+ )}
+
+ ))}
+
+));
+InsightsBlock.displayName = 'InsightsBlock';
+
+/** Recommended steps — numbered, because order is the recommendation. */
+const ActionsBlock = React.memo(({ block }) => (
+
+ {block.items.map((item, i) => (
+ -
+
+ {i + 1}
+
+
+
{item.title}
+ {item.body && (
+
+ )}
+
+
+ ))}
+
+));
+ActionsBlock.displayName = 'ActionsBlock';
+
+const BadgesBlock = React.memo(({ block }) => (
+
+ {block.items.map((item) => (
+
+ {item.label}
+
+ ))}
+
+));
+BadgesBlock.displayName = 'BadgesBlock';
+
+const TimelineBlock = React.memo(({ block }) => (
+
+ {block.items.map((item, i) => (
+ -
+
+
+ {i < block.items.length - 1 && }
+
+
+
+ {item.title}
+ {item.timestamp && (
+ {item.timestamp}
+ )}
+
+ {item.description && (
+
{item.description}
+ )}
+
+
+ ))}
+
+));
+TimelineBlock.displayName = 'TimelineBlock';
+
+const NoteBlock = React.memo(({ block }) => (
+
+
+
+));
+NoteBlock.displayName = 'NoteBlock';
+
+const RENDERERS = {
+ text: TextBlock,
+ heading: HeadingBlock,
+ kpis: KpisBlock,
+ status: StatusBlock,
+ meters: MetersBlock,
+ table: TableBlock,
+ funnel: FunnelBlock,
+ list: ListBlock,
+ insights: InsightsBlock,
+ actions: ActionsBlock,
+ badges: BadgesBlock,
+ timeline: TimelineBlock,
+ note: NoteBlock,
+};
+
+/**
+ * Renders a response document.
+ *
+ * Spacing is decided here rather than by the blocks, so any combination of
+ * blocks has consistent rhythm — a heading hugs what follows it, everything
+ * else breathes.
+ */
+export const ResponseDocument = React.memo(({ blocks = [], streaming = false }) => (
+
+ {blocks.map((block, i) => {
+ const Renderer = RENDERERS[block.type];
+ if (!Renderer) return null;
+ const hugsNext = block.type === 'heading' && i < blocks.length - 1;
+ const isLast = i === blocks.length - 1;
+
+ return (
+
+
+ {/* The cursor trails the final block only while text is still arriving. */}
+ {streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
+
+ )}
+
+ );
+ })}
+
+));
+ResponseDocument.displayName = 'ResponseDocument';
+
+export { toneForScore };
diff --git a/src/components/ai-assistant/blocks.js b/src/components/ai-assistant/blocks.js
new file mode 100644
index 0000000..ee485af
--- /dev/null
+++ b/src/components/ai-assistant/blocks.js
@@ -0,0 +1,100 @@
+/**
+ * Response blocks.
+ *
+ * Answers are documents, not strings. A capability decides how its information
+ * is best communicated — a comparison is a table, a health check is a status
+ * list, a funnel is a funnel — and the renderer draws it with design-system
+ * components. That is what turns a reply into a small interactive report rather
+ * than a wall of chat text.
+ *
+ * Blocks are plain data, so they serialize: a future remote service can return
+ * exactly this shape and nothing in the UI changes.
+ */
+
+/** Assembles a document, flattening nested arrays and dropping empty slots. */
+export const doc = (...blocks) => ({
+ blocks: blocks.flat(Infinity).filter(Boolean),
+});
+
+/** A short paragraph. Supports `**bold**` and `_italic_` inline. */
+export const text = (value) => value && { type: 'text', text: value };
+
+/** A section label inside a response. */
+export const heading = (value, sub) => value && { type: 'heading', text: value, sub };
+
+/** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */
+export const kpis = (items) => items?.length && { type: 'kpis', items };
+
+/** Pass/warn checks. `items: [{ label, ok, value?, note? }]` */
+export const status = (items) => items?.length && { type: 'status', items };
+
+/** Labelled bars. `items: [{ label, value, weight?, max?, tone? }]` */
+export const meters = (items) => items?.length && { type: 'meters', items };
+
+/**
+ * A comparison or record table.
+ * `columns: [{ key, label, align?, width? }]`, `rows: [{ ...cells }]`
+ * A cell may be a string, a number, or `{ value, tone, badge }`.
+ */
+export const table = (columns, rows, options = {}) =>
+ rows?.length && { type: 'table', columns, rows, ...options };
+
+/** Stage progression. `steps: [{ label, count, rate?, lost? }]` */
+export const funnel = (steps) => steps?.length && { type: 'funnel', steps };
+
+/** Bulleted or numbered points. */
+export const list = (items, options = {}) =>
+ items?.filter(Boolean).length && { type: 'list', items: items.filter(Boolean), ...options };
+
+/** Findings. `items: [{ tone, title, body }]` — tone: info|success|warning|risk */
+export const insights = (items) =>
+ items?.filter(Boolean).length && { type: 'insights', items: items.filter(Boolean) };
+
+/** Recommended next steps. `items: [{ title, body }]` */
+export const actions = (items) =>
+ items?.filter(Boolean).length && { type: 'actions', items: items.filter(Boolean) };
+
+/** Inline tags. `items: [{ label, tone }]` or plain strings. */
+export const badges = (items) =>
+ items?.filter(Boolean).length && {
+ type: 'badges',
+ items: items.filter(Boolean).map((i) => (typeof i === 'string' ? { label: i } : i)),
+ };
+
+/** Chronology. `items: [{ title, description?, timestamp?, tone?, current? }]` */
+export const timeline = (items) => items?.length && { type: 'timeline', items };
+
+/** A caveat. Always the last word on a claim, never the headline. */
+export const note = (value) => value && { type: 'note', text: value };
+
+/** A single sentence answer — used by short free-text replies. */
+export const answer = (value) => doc(text(value));
+
+/* ── Streaming ──────────────────────────────────────────────────────────── */
+
+/**
+ * Splits a document into the progressive snapshots the provider streams.
+ *
+ * Text and heading blocks reveal word by word; structured blocks appear whole,
+ * because a half-drawn table reads as broken rather than as arriving. The result
+ * is a response that composes itself in a readable order.
+ */
+export function toSnapshots({ blocks }) {
+ const snapshots = [];
+ const settled = [];
+
+ for (const block of blocks) {
+ if (block.type === 'text' || block.type === 'heading') {
+ const tokens = String(block.text).split(/(\s+)/);
+ // Three words per snapshot: fast enough to feel live, coarse enough to
+ // avoid a reflow per character.
+ for (let i = 6; i < tokens.length; i += 6) {
+ snapshots.push([...settled, { ...block, text: tokens.slice(0, i).join('') }]);
+ }
+ }
+ settled.push(block);
+ snapshots.push([...settled]);
+ }
+
+ return snapshots.length ? snapshots : [[]];
+}
diff --git a/src/components/ai-assistant/capabilities/admin.js b/src/components/ai-assistant/capabilities/admin.js
index 1c611ab..09f3963 100644
--- a/src/components/ai-assistant/capabilities/admin.js
+++ b/src/components/ai-assistant/capabilities/admin.js
@@ -1,11 +1,12 @@
import { AlertTriangle, Eye, Gauge, Lightbulb, ScrollText, TrendingUp } from 'lucide-react';
-import { bullets, compose, heading, numbered, plural, verb } from '../insights';
+import { actions, answer, doc, heading, insights, kpis, note, status, table, text } from '../blocks';
+import { plural, verb } from '../insights';
/**
* Admin capabilities — Control Center, Candidates Analysis, Activity.
*
- * Admin answers are platform-wide rather than pipeline-specific: data integrity,
- * talent supply, audit reconciliation.
+ * Platform-wide rather than pipeline-specific: data integrity, talent supply,
+ * audit reconciliation.
*/
/* ── Control Center ─────────────────────────────────────────────────────── */
@@ -15,148 +16,184 @@ const platformHealth = (f) => {
{
label: 'Data integrity',
ok: f.applications.every((a) => a.job_posting_id),
- good: 'Every application is linked to a live posting.',
+ good: 'Every application links to a live posting.',
bad: 'Some applications reference a posting that no longer exists.',
},
{
label: 'Screening coverage',
+ value: `${f.standardizedPct}%`,
ok: f.standardizedPct >= 80,
- good: `${f.standardizedPct}% of applicants carry a score.`,
- bad: `Only ${f.standardizedPct}% of applicants are scored — ${plural(f.unscreened.length, 'record')} unstandardized.`,
+ good: 'Applicants are scored consistently.',
+ bad: `${plural(f.unscreened.length, 'record')} unstandardized.`,
},
{
label: 'Interview integrity',
ok: f.flagged.length === 0,
good: 'No interviews flagged.',
- bad: `${plural(f.flagged.length, 'interview')} flagged below full integrity.`,
+ bad: `${plural(f.flagged.length, 'interview')} below full integrity.`,
},
{
label: 'Profile completeness',
+ value: `${f.profiles.length - f.unscoredProfiles.length}/${f.profiles.length}`,
ok: f.unscoredProfiles.length <= f.profiles.length / 2,
- good: `${f.profiles.length - f.unscoredProfiles.length} of ${f.profiles.length} talent profiles have a career score.`,
- bad: `${plural(f.unscoredProfiles.length, 'profile')} of ${f.profiles.length} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score, so ${verb(f.unscoredProfiles.length, 'it cannot', 'they cannot')} be matched or ranked.`,
+ good: 'Most talent profiles carry a career score.',
+ bad: `${plural(f.unscoredProfiles.length, 'profile')} without a score cannot be matched or ranked.`,
},
{
label: 'Throughput',
+ value: `${f.hireRate}%`,
ok: f.hired.length > 0,
- good: `${plural(f.hired.length, 'hire')} closed at a ${f.hireRate}% hire rate.`,
+ good: `${plural(f.hired.length, 'hire')} closed.`,
bad: 'No hires closed yet, so there is no conversion baseline.',
},
- ];
+ ].map((c) => ({ label: c.label, value: c.value, ok: c.ok, note: c.ok ? c.good : c.bad }));
const passing = checks.filter((c) => c.ok).length;
- return compose(
- heading(`Platform health: ${passing}/${checks.length} checks passing`),
- '',
- checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.ok ? c.good : c.bad}`).join('\n'),
- '',
+ return doc(
+ heading(`Platform health: ${passing}/${checks.length} passing`),
+ status(checks),
heading('Scale'),
- bullets([
- `${plural(f.postings.length, 'position')}, ${f.openPositions.length} open`,
- `${plural(f.total, 'application')}`,
- `${plural(f.interviews.length, 'AI interview')}`,
- `${plural(f.profiles.length, 'talent profile')}, ${f.elite.length} Elite`,
- `${plural(f.activity.length, 'logged event')}`,
+ kpis([
+ { label: 'Positions', value: f.postings.length, sub: `${f.openPositions.length} open` },
+ { label: 'Applications', value: f.total },
+ { label: 'Interviews', value: f.interviews.length, sub: `${f.completedInterviews.length} scored` },
+ { label: 'Talent pool', value: f.profiles.length, sub: `${f.elite.length} Elite` },
]),
- '',
- passing === checks.length
- ? 'Nothing needs intervention at the platform level.'
- : 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.'
+ text(
+ passing === checks.length
+ ? 'Nothing needs intervention at the platform level.'
+ : 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.'
+ )
);
};
const recommendations = (f) => {
- const actions = [];
+ const items = [
+ f.unscoredProfiles.length && {
+ title: `Prompt ${plural(f.unscoredProfiles.length, 'worker')} to finish onboarding`,
+ body: 'Without a career score they are invisible to matching, so employers cannot find them even when they fit.',
+ },
+ f.unscreened.length && {
+ title: `${plural(f.unscreened.length, 'application')} unscored`,
+ body: 'Each one is a decision made without a standard.',
+ },
+ f.starvedPositions.length && {
+ title: `${plural(f.starvedPositions.length, 'posting')} with zero applicants`,
+ body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Check distribution and pay bands.`,
+ },
+ f.unratedStaff.length && {
+ title: `${plural(f.unratedStaff.length, 'hire')} unrated`,
+ body: 'Employer ratings are the only verified performance signal feeding scores; without them the scores stay theoretical.',
+ },
+ f.flagged.length && {
+ title: `Review ${plural(f.flagged.length, 'flagged interview')}`,
+ body: 'Worth clearing before those candidates progress.',
+ },
+ ].filter(Boolean);
- if (f.unscoredProfiles.length) {
- actions.push(`**Prompt ${plural(f.unscoredProfiles.length, 'worker')} to finish onboarding.** Without a career score they are invisible to matching, so employers cannot find them even when they fit.`);
- }
- if (f.unscreened.length) {
- actions.push(`**${plural(f.unscreened.length, 'application')} unscored.** Each one is a decision made without a standard.`);
- }
- if (f.starvedPositions.length) {
- actions.push(`**${plural(f.starvedPositions.length, 'posting')} with zero applicants** — ${f.starvedPositions.map((p) => p.title).join(', ')}. Check distribution and pay bands.`);
- }
- if (f.unratedStaff.length) {
- actions.push(`**${plural(f.unratedStaff.length, 'hire')} unrated.** Employer ratings are the only verified performance signal feeding scores; without them the scores stay theoretical.`);
- }
- if (f.flagged.length) {
- actions.push(`**Review ${plural(f.flagged.length, 'flagged interview')}** before those candidates progress.`);
- }
+ if (!items.length) return answer('No platform actions outstanding. Data is complete and nothing is flagged.');
- if (!actions.length) return 'No platform actions outstanding. Data is complete and nothing is flagged.';
-
- return compose(heading('Recommended actions'), '', numbered(actions));
+ return doc(heading('Recommended actions', 'Ordered by impact on matching quality'), actions(items));
};
/* ── Candidates Analysis ────────────────────────────────────────────────── */
const recruitmentInsights = (f) => {
- const bands = {
- Elite: f.profiles.filter((p) => (p.krow_score || 0) >= 90).length,
- Excellent: f.profiles.filter((p) => (p.krow_score || 0) >= 75 && (p.krow_score || 0) < 90).length,
- Solid: f.profiles.filter((p) => (p.krow_score || 0) >= 60 && (p.krow_score || 0) < 75).length,
- Building: f.profiles.filter((p) => (p.krow_score || 0) > 0 && (p.krow_score || 0) < 60).length,
- Unscored: f.unscoredProfiles.length,
- };
+ const band = (min, max) => f.profiles.filter((p) => {
+ const s = p.krow_score || 0;
+ return s >= min && (max == null || s < max);
+ }).length;
+
+ const bands = [
+ { label: 'Elite', count: band(90), tone: 'info' },
+ { label: 'Excellent', count: band(75, 90), tone: 'success' },
+ { label: 'Solid', count: band(60, 75) },
+ { label: 'Building', count: band(1, 60), tone: 'warning' },
+ { label: 'Unscored', count: f.unscoredProfiles.length, tone: 'risk' },
+ ];
const verified = f.profiles.filter((p) => (p.capabilities || []).length).length;
- const topHeavy = bands.Elite + bands.Excellent;
+ const topHeavy = band(90) + band(75, 90);
- return compose(
- heading('Talent supply'),
- '',
- bullets(Object.entries(bands).map(([band, count]) => `${band} — ${count}`)),
- '',
+ return doc(
+ heading('Talent supply', `${plural(f.profiles.length, 'worker')} in the pool`),
+ table(
+ [{ key: 'label', label: 'Band' }, { key: 'count', label: 'Workers', align: 'right' }],
+ bands.map((b) => ({ label: b.label, count: { value: b.count, tone: b.tone } }))
+ ),
heading('Pipeline'),
- bullets([
- `${plural(f.total, 'applicant')} averaging ${f.avgScore}`,
- `${plural(f.ranked.filter((a) => a.ai_score >= 80).length, 'candidate')} at 80+`,
- `${plural(verified, 'worker')} with at least one verified capability`,
- `${plural(f.eventCounts.apply_job || 0, 'application event')} logged`,
+ kpis([
+ { label: 'Applicants', value: f.total, sub: `avg ${f.avgScore}` },
+ { label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'success' },
+ { label: 'Verified skills', value: verified, sub: 'with evidence' },
+ { label: 'Applications', value: f.eventCounts.apply_job || 0, sub: 'logged' },
+ ]),
+ insights([
+ f.unscoredProfiles.length > topHeavy
+ ? {
+ tone: 'warning',
+ title: 'The pool is bottom-heavy',
+ body: `${f.unscoredProfiles.length} unscored against ${topHeavy} at Excellent or above. Supply is not the constraint — verification is.`,
+ }
+ : {
+ tone: 'success',
+ title: 'Verification is keeping pace',
+ body: 'The scored portion of the pool outweighs the unscored.',
+ },
]),
- '',
- bands.Unscored > topHeavy
- ? `The pool is bottom-heavy: ${bands.Unscored} unscored against ${topHeavy} at Excellent or above. Supply is not the constraint — verification is.`
- : 'The scored portion outweighs the unscored, so verification is keeping pace with sign-ups.',
- '',
heading('By role category'),
- bullets(
+ table(
+ [
+ { key: 'title', label: 'Role' },
+ { key: 'applied', label: 'Applied', align: 'right' },
+ { key: 'qualified', label: '70+', align: 'right' },
+ ],
f.byRole.slice().sort((a, b) => b.applied - a.applied)
- .map((r) => `${r.title} — ${plural(r.applied, 'applicant')}, ${r.qualified} at 70+`)
+ .map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified }))
)
);
};
const candidateRiskAnalysis = (f) => {
- const risks = [];
-
- if (f.missingCredentials.length) {
- risks.push(`**No credentials on file** — ${plural(f.missingCredentials.length, 'candidate')}. Compliance risk if placed on a role that requires certification.`);
- }
- if (f.narrowAvailability.length) {
- risks.push(`**Narrow availability** — ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. Most likely to fall through on the day.`);
- }
- if (f.flagged.length) {
- risks.push(`**Interview integrity** — ${plural(f.flagged.length, 'interview')} below full integrity, usually suspiciously fast responses. Worth a human re-interview.`);
- }
const noHistory = f.scored.filter((a) => !a.years_experience);
- if (noHistory.length) {
- risks.push(`**No recorded experience** — ${plural(noHistory.length, 'candidate')}. Not disqualifying, but unverifiable on paper.`);
- }
- if (f.singleCandidateRoles.length) {
- risks.push(`**Concentration risk** — ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`);
+
+ const risks = [
+ f.missingCredentials.length && {
+ tone: 'risk',
+ title: `${plural(f.missingCredentials.length, 'candidate')} with no credentials`,
+ body: 'Compliance risk if placed on a role that requires certification.',
+ },
+ f.narrowAvailability.length && {
+ tone: 'warning',
+ title: `${plural(f.narrowAvailability.length, 'candidate')} with narrow availability`,
+ body: 'One slot or none — most likely to fall through on the day.',
+ },
+ f.flagged.length && {
+ tone: 'risk',
+ title: `${plural(f.flagged.length, 'interview')} flagged for integrity`,
+ body: 'Usually suspiciously fast responses. Worth a human re-interview.',
+ },
+ noHistory.length && {
+ tone: 'warning',
+ title: `${plural(noHistory.length, 'candidate')} with no recorded experience`,
+ body: 'Not disqualifying, but unverifiable on paper.',
+ },
+ f.singleCandidateRoles.length && {
+ tone: 'warning',
+ title: 'Concentration risk',
+ body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`,
+ },
+ ].filter(Boolean);
+
+ if (!risks.length) {
+ return answer('No material risk flags: credentials, availability and interview integrity all check out across the scored pool.');
}
- if (!risks.length) return 'No material risk flags: credentials, availability and interview integrity all check out across the scored pool.';
-
- return compose(
+ return doc(
heading(`${plural(risks.length, 'risk flag')}`),
- '',
- numbered(risks),
- '',
- '_Flags to check, not judgements. Each is a question for a human, not a rejection._'
+ insights(risks),
+ note('Flags to check, not judgements. Each is a question for a human, not a rejection.')
);
};
@@ -166,36 +203,45 @@ const auditSummary = (f) => {
const count = (type) => f.activity.filter((e) => e.event_type === type).length;
const hires = count('hire_candidate');
const screens = count('screen_candidate');
- const creates = count('create_position');
- return compose(
+ return doc(
heading('Audit summary'),
- '',
- bullets([
- `${plural(f.activity.length, 'event')} total`,
- `${plural(f.activity7d.length, 'event')} in the last 7 days`,
- `${plural(f.activity24h.length, 'event')} in the last 24 hours`,
- `${plural(new Set(f.activity.map((e) => e.user_email)).size, 'distinct user')}`,
+ kpis([
+ { label: 'Total events', value: f.activity.length },
+ { label: 'Last 7 days', value: f.activity7d.length },
+ { label: 'Last 24 hours', value: f.activity24h.length, tone: f.activity24h.length ? undefined : 'warning' },
+ { label: 'Distinct users', value: new Set(f.activity.map((e) => e.user_email)).size },
]),
- '',
heading('Privileged actions'),
- bullets([
- `${plural(creates, 'position creation')}`,
- `${plural(screens, 'screening action')}`,
- `${plural(hires, 'hire')}`,
- ]),
- '',
+ table(
+ [{ key: 'action', label: 'Action' }, { key: 'count', label: 'Events', align: 'right' }],
+ [
+ { action: 'Position created', count: count('create_position') },
+ { action: 'Candidate screened', count: screens },
+ { action: 'Candidate hired', count: hires },
+ { action: 'Interview started', count: count('start_interview') },
+ ]
+ ),
heading('Reconciliation'),
- bullets([
- hires === f.staff.length
- ? `All ${f.staff.length} people on staff have a matching hire event.`
- : `${f.staff.length} on staff but ${hires} hire ${verb(hires, 'event', 'events')} — ${Math.abs(f.staff.length - hires)} unexplained.`,
- screens < f.scored.length
- ? `${f.scored.length} candidates carry scores but only ${plural(screens, 'screening event')} logged. Batch screening records one event per run, not per candidate.`
- : 'Screening events reconcile with scored candidates.',
+ status([
+ {
+ label: 'Hires match staff records',
+ ok: hires === f.staff.length,
+ value: `${hires}/${f.staff.length}`,
+ note: hires === f.staff.length
+ ? 'Every person on staff has a matching hire event.'
+ : `${Math.abs(f.staff.length - hires)} unexplained.`,
+ },
+ {
+ label: 'Screening events match scores',
+ ok: screens >= f.scored.length,
+ value: `${screens}/${f.scored.length}`,
+ note: screens < f.scored.length
+ ? 'Batch screening records one event per run, not per candidate.'
+ : 'Screening events reconcile with scored candidates.',
+ },
]),
- '',
- '_An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review._'
+ note('An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review.')
);
};
@@ -208,32 +254,43 @@ const userActivityInsights = (f) => {
}, {});
const ranked = Object.entries(perUser).sort((a, b) => b[1].count - a[1].count);
- if (!ranked.length) return 'No activity to profile yet.';
-
- const byRole = f.activity.reduce((acc, e) => {
- const role = e.account_type || 'unknown';
- acc[role] = (acc[role] || 0) + 1;
- return acc;
- }, {});
+ if (!ranked.length) return answer('No activity to profile yet.');
const [topEmail, top] = ranked[0];
+ const share = Math.round((top.count / f.activity.length) * 100);
- return compose(
+ return doc(
heading('Most active accounts'),
- '',
- bullets(ranked.slice(0, 6).map(([email, d]) =>
- `**${d.name || email}** (${d.role || 'unknown'}) — ${plural(d.count, 'event')} across ${plural(d.types.size, 'action type')}`
- )),
- '',
- heading('By role'),
- bullets(Object.entries(byRole).sort((a, b) => b[1] - a[1])
- .map(([role, c]) => `${role.charAt(0).toUpperCase() + role.slice(1)} — ${plural(c, 'event')}`)),
- '',
- `${top.name || topEmail} accounts for ${top.count} of ${f.activity.length} events. On a live platform, one account dominating the log is worth confirming is a person and not an integration.`,
- '',
- f.activity24h.length === 0
- ? 'No activity in the last day. Expected for a demo dataset; on a live platform it would be worth checking ingestion.'
- : 'Activity is flowing normally.'
+ table(
+ [
+ { key: 'name', label: 'Account' },
+ { key: 'role', label: 'Role' },
+ { key: 'events', label: 'Events', align: 'right' },
+ ],
+ ranked.slice(0, 6).map(([email, d]) => ({
+ name: d.name || email,
+ role: { badge: d.role || 'unknown', tone: d.role === 'employer' ? 'info' : 'neutral' },
+ events: { value: d.count, tone: d.count === top.count ? 'info' : undefined },
+ }))
+ ),
+ insights([
+ share >= 50
+ ? {
+ tone: 'warning',
+ title: `One account is ${share}% of all activity`,
+ body: `${top.name || topEmail} accounts for ${top.count} of ${f.activity.length} events. On a live platform, that concentration is worth confirming is a person and not an integration.`,
+ }
+ : {
+ tone: 'success',
+ title: 'Activity is spread across accounts',
+ body: `No single account dominates the log — the busiest is ${share}%.`,
+ },
+ f.activity24h.length === 0 && {
+ tone: 'warning',
+ title: 'Nothing in the last 24 hours',
+ body: 'Expected for a demo dataset; on a live platform it would be worth checking ingestion.',
+ },
+ ])
);
};
@@ -244,6 +301,7 @@ const has = (q, ...words) => words.some((w) => q.includes(w));
export const respondControlCenter = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'recommend', 'should', 'action', 'fix', 'improve')) return recommendations(f);
+ if (has(q, 'unusual', 'anomal', 'suspicious')) return userActivityInsights(f);
return platformHealth(f);
};
@@ -255,7 +313,7 @@ export const respondAdminCandidates = (question, f) => {
export const respondActivity = (question, f) => {
const q = question.toLowerCase();
- if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account')) return userActivityInsights(f);
+ if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account', 'unusual')) return userActivityInsights(f);
return auditSummary(f);
};
diff --git a/src/components/ai-assistant/capabilities/employer.js b/src/components/ai-assistant/capabilities/employer.js
index 181eb38..ebfa461 100644
--- a/src/components/ai-assistant/capabilities/employer.js
+++ b/src/components/ai-assistant/capabilities/employer.js
@@ -1,36 +1,47 @@
import {
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
- HeartPulse, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target, Layers,
+ HeartPulse, Layers, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target,
} from 'lucide-react';
-import { bullets, compose, heading, numbered, plural, verb } from '../insights';
+import {
+ actions, answer, badges, doc, funnel, heading, insights, kpis, list, meters, note,
+ status, table, text,
+} from '../blocks';
+import { plural, verb } from '../insights';
/**
* Employer capabilities — Overview, Candidates, Analytics.
*
- * Each capability is a pure function of the fact sheet. Keeping them pure is
- * what allows the same code to run against local data today and against a
- * remote AI service later without the UI changing.
+ * Each returns a block document rather than prose. The shape is chosen by what
+ * the information is: a comparison is a table, a health check is a status list,
+ * a pipeline is a funnel. Prose is reserved for the judgement a table cannot
+ * carry.
*/
/* ── Overview ───────────────────────────────────────────────────────────── */
const hiringSummary = (f) => {
- if (!f.total) return 'Nothing in the pipeline yet. Publish a position and I will start tracking it here.';
+ if (!f.total) return answer('Nothing in the pipeline yet. Publish a position and I will start tracking it.');
- return compose(
- heading('Where hiring stands'),
- bullets([
- `${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}`,
- `${f.screened.length} screened · ${f.unscreened.length} awaiting a score`,
- `${plural(f.interviewing.length, 'candidate')} mid-interview`,
- `${plural(f.hired.length, 'hire')} averaging ${f.hiredAvgScore}/100`,
+ return doc(
+ kpis([
+ { label: 'Applicants', value: f.total, sub: `${f.openPositions.length} open roles` },
+ { label: 'Screened', value: f.screened.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
+ { label: 'Interviewing', value: f.interviewing.length },
+ { label: 'Hired', value: f.hired.length, sub: f.hiredAvgScore ? `avg ${f.hiredAvgScore}/100` : undefined, tone: 'info' },
]),
- '',
- f.stalled.length
- ? `Most urgent: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still sitting at AI Screened.`
- : f.unscreened.length
- ? `Most urgent: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
- : 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
+ heading('Pipeline'),
+ funnel(f.funnel.map((stage, i) => ({
+ label: stage.label,
+ count: stage.count,
+ rate: i === 0 ? undefined : f.transitions[i - 1]?.rate,
+ }))),
+ text(
+ f.stalled.length
+ ? `The first thing I would fix: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still at AI Screened.`
+ : f.unscreened.length
+ ? `The first thing I would fix: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
+ : 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
+ )
);
};
@@ -41,7 +52,7 @@ const hiringHealth = (f) => {
value: `${f.standardizedPct}%`,
ok: f.standardizedPct >= 80,
note: f.standardizedPct >= 80
- ? 'Applicants are being scored consistently.'
+ ? 'Applicants are scored consistently.'
: `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`,
},
{
@@ -72,148 +83,201 @@ const hiringHealth = (f) => {
const passing = checks.filter((c) => c.ok).length;
- return compose(
+ return doc(
heading(`${passing} of ${checks.length} signals healthy`),
- '',
- checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.value}\n ${c.note}`).join('\n\n')
+ status(checks),
+ note('Thresholds are event-staffing norms, not universal benchmarks.')
);
};
+const buildActions = (f) => [
+ f.stalled.length && {
+ title: 'Move your 80+ candidates',
+ body: `${f.stalled.slice(0, 3).map((a) => `**${a.applicant_name}** (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`,
+ },
+ f.unscreened.length && {
+ title: `Screen ${plural(f.unscreened.length, 'applicant')}`,
+ body: 'One pass clears the backlog and costs nothing.',
+ },
+ f.starvedPositions.length && {
+ title: 'Fix the postings nobody applies to',
+ body: `${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`,
+ },
+ f.unratedStaff.length && {
+ title: `Rate ${plural(f.unratedStaff.length, 'hire')}`,
+ body: 'Ratings feed KROW scores and sharpen future matching.',
+ },
+ f.elite.length && {
+ title: 'Reach out to the pool directly',
+ body: `${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`,
+ },
+].filter(Boolean);
+
const pendingActions = (f) => {
- const actions = [];
-
- if (f.stalled.length) {
- actions.push(`**Move your 80+ candidates.** ${f.stalled.slice(0, 3).map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`);
+ const items = buildActions(f);
+ if (!items.length) {
+ return answer('Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.');
}
- if (f.unscreened.length) {
- actions.push(`**Screen the backlog.** ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} no score. One pass clears it.`);
- }
- if (f.starvedPositions.length) {
- actions.push(`**Fix the postings nobody applies to.** ${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`);
- }
- if (f.unratedStaff.length) {
- actions.push(`**Rate your hires.** ${plural(f.unratedStaff.length, 'person', 'people')} ${verb(f.unratedStaff.length, 'has', 'have')} no rating. Ratings sharpen future matching.`);
- }
- if (f.elite.length) {
- actions.push(`**Reach out directly.** ${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`);
- }
-
- if (!actions.length) return 'Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.';
-
- return compose(heading('Pending actions'), '', numbered(actions.slice(0, 4)));
+ return doc(heading('Pending actions', 'Ordered by what costs you most'), actions(items.slice(0, 4)));
};
const hiringRisks = (f) => {
- const risks = [];
+ const risks = [
+ f.singleCandidateRoles.length && {
+ tone: 'risk',
+ title: 'Single-candidate roles',
+ body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`,
+ },
+ f.stalled.length && {
+ tone: 'warning',
+ title: 'Losing strong candidates to delay',
+ body: `${plural(f.stalled.length, 'candidate')} at 80+ screened but not moved. This is where good hires quietly disappear.`,
+ },
+ f.missingCredentials.length && {
+ tone: 'risk',
+ title: 'Compliance exposure',
+ body: `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`,
+ },
+ f.narrowAvailability.length && {
+ tone: 'warning',
+ title: 'Availability risk',
+ body: `${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none — most likely to fall through on the day.`,
+ },
+ f.starvedPositions.length && {
+ tone: 'warning',
+ title: 'Unfillable as posted',
+ body: `${plural(f.starvedPositions.length, 'open role')} with zero applicants after going live.`,
+ },
+ f.flagged.length && {
+ tone: 'risk',
+ title: 'Interview integrity',
+ body: `${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`,
+ },
+ ].filter(Boolean);
- if (f.singleCandidateRoles.length) {
- risks.push(`**Single-candidate roles.** ${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`);
- }
- if (f.stalled.length) {
- risks.push(`**Losing strong candidates to delay.** ${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'has', 'have')} been screened but not moved. This is where good hires quietly disappear.`);
- }
- if (f.missingCredentials.length) {
- risks.push(`**Compliance exposure.** ${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`);
- }
- if (f.narrowAvailability.length) {
- risks.push(`**Availability risk.** ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. ${verb(f.narrowAvailability.length, 'This is the placement', 'These are the placements')} most likely to fall through on the day.`);
- }
- if (f.starvedPositions.length) {
- risks.push(`**Unfillable as posted.** ${plural(f.starvedPositions.length, 'open role')} with zero applicants after being live.`);
- }
- if (f.flagged.length) {
- risks.push(`**Interview integrity.** ${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`);
+ if (!risks.length) {
+ return answer('No material risks: coverage is good, no role depends on a single candidate, and credentials check out.');
}
- if (!risks.length) return 'No material risks in the current pipeline: coverage is good, no role depends on a single candidate, and credentials check out.';
-
- return compose(
+ return doc(
heading(`${plural(risks.length, 'risk')} to watch`),
- '',
- numbered(risks),
- '',
- '_Flags to check, not conclusions. Each is a question for a human._'
+ insights(risks),
+ note('Flags to check, not conclusions. Each is a question for a human.')
);
};
/* ── Candidates ─────────────────────────────────────────────────────────── */
-const resumeAnalysis = (f) => {
- const subject = f.top[0];
- if (!subject) return 'No scored candidates yet. Screen the pipeline and I will analyse the strongest.';
+const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability'];
- return compose(
- heading(`${subject.applicant_name} — ${subject.ai_score}/100`),
- `${subject.job_title} · ${plural(subject.years_experience, 'year')} experience · ${subject.english_level} English`,
- '',
- subject.ai_summary || '',
- '',
- subject.ai_strengths?.length ? compose(heading('What holds up'), bullets(subject.ai_strengths)) : '',
- '',
- subject.ai_gaps?.length ? compose(heading('What to probe'), bullets(subject.ai_gaps)) : '',
- '',
- subject.certifications?.length
- ? `**Credentials:** ${subject.certifications.join(', ')} — verify before the first shift. A lapsed card is the most common surprise.`
- : '**No credentials on file.** Request these before scheduling.'
+const resumeAnalysis = (f) => {
+ const c = f.top[0];
+ if (!c) return answer('No scored candidates yet. Screen the pipeline and I will analyse the strongest.');
+
+ return doc(
+ heading(c.applicant_name, `${c.job_title} · ${plural(c.years_experience, 'year')} · ${c.english_level} English`),
+ kpis([
+ { label: 'AI score', value: `${c.ai_score}`, tone: c.ai_score >= 80 ? 'success' : c.ai_score >= 60 ? 'info' : 'warning' },
+ { label: 'Verdict', value: c.ai_match_label || '—' },
+ ]),
+ c.ai_summary && text(c.ai_summary),
+ c.certifications?.length
+ ? [heading('Credentials'), badges(c.certifications.map((label) => ({ label, tone: 'success' })))]
+ : [heading('Credentials'), badges([{ label: 'None on file', tone: 'risk' }])],
+ heading('Score breakdown'),
+ meters(DIMENSIONS.map((d) => ({ label: d[0].toUpperCase() + d.slice(1), value: c.score_breakdown?.[d] ?? 0 }))),
+ c.ai_strengths?.length && [heading('Strengths'), list(c.ai_strengths)],
+ c.ai_gaps?.length && [heading('Probe these'), list(c.ai_gaps)],
+ note(
+ c.certifications?.length
+ ? 'Verify credentials before the first shift — a lapsed card is the most common surprise.'
+ : 'Request credentials before scheduling.'
+ )
);
};
const candidateComparison = (f) => {
- const pair = f.ranked.slice(0, 2);
- if (pair.length < 2) return 'I need at least two scored candidates to compare.';
+ const [a, b] = f.ranked;
+ if (!a || !b) return answer('I need at least two scored candidates to compare.');
- const [a, b] = pair;
const gap = a.ai_score - b.ai_score;
- const dims = ['experience', 'english', 'reliability', 'certifications', 'availability'];
+ const first = (n) => n.split(' ')[0];
- const rows = dims.map((dim) => {
- const av = a.score_breakdown?.[dim] ?? 0;
- const bv = b.score_breakdown?.[dim] ?? 0;
- const lead = av === bv ? 'tied' : (av > bv ? a : b).applicant_name.split(' ')[0];
- return `${dim.charAt(0).toUpperCase() + dim.slice(1)} — ${av} vs ${bv} · ${lead}`;
+ const rows = DIMENSIONS.map((d) => {
+ const av = a.score_breakdown?.[d] ?? 0;
+ const bv = b.score_breakdown?.[d] ?? 0;
+ return {
+ dimension: d[0].toUpperCase() + d.slice(1),
+ a: { value: av, tone: av > bv ? 'success' : av < bv ? 'neutral' : undefined },
+ b: { value: bv, tone: bv > av ? 'success' : bv < av ? 'neutral' : undefined },
+ };
});
- const biggest = dims.reduce((best, d) =>
+ const biggest = DIMENSIONS.reduce((best, d) =>
Math.abs((a.score_breakdown?.[d] ?? 0) - (b.score_breakdown?.[d] ?? 0)) >
- Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, dims[0]);
+ Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, DIMENSIONS[0]);
- return compose(
- heading(`${a.applicant_name} (${a.ai_score}) vs ${b.applicant_name} (${b.ai_score})`),
- '',
- bullets(rows),
- '',
- gap <= 3
- ? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
- : `${a.applicant_name} leads by ${gap}, driven mostly by ${biggest}.`
+ return doc(
+ heading(`${a.applicant_name} vs ${b.applicant_name}`),
+ table(
+ [
+ { key: 'dimension', label: '' },
+ { key: 'a', label: first(a.applicant_name), align: 'right' },
+ { key: 'b', label: first(b.applicant_name), align: 'right' },
+ ],
+ [
+ { dimension: 'Overall', a: { value: a.ai_score, tone: 'info' }, b: { value: b.ai_score, tone: 'info' } },
+ ...rows,
+ ],
+ { caption: 'Score comparison by dimension' }
+ ),
+ heading('Where each one wins'),
+ insights([
+ {
+ tone: 'success',
+ title: `${first(a.applicant_name)} leads on ${biggest}`,
+ body: a.ai_strengths?.[0] || `Scores ${a.score_breakdown?.[biggest] ?? 0} against ${b.score_breakdown?.[biggest] ?? 0}.`,
+ },
+ b.ai_strengths?.[0] && {
+ tone: 'info',
+ title: `${first(b.applicant_name)} brings`,
+ body: b.ai_strengths[0],
+ },
+ ]),
+ text(
+ gap <= 3
+ ? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
+ : `${a.applicant_name} leads by ${gap} points, driven mostly by ${biggest}.`
+ ),
+ note('Recommendation only. Neither has been met in person.')
);
};
const interviewQuestions = (f) => {
- const subject = f.stalled[0] || f.ranked[0];
- if (!subject) return 'Screen a candidate and I will build questions around their specific gaps.';
+ const c = f.stalled[0] || f.ranked[0];
+ if (!c) return answer('Screen a candidate and I will build questions around their specific gaps.');
- const gaps = subject.ai_gaps || [];
+ const gaps = c.ai_gaps || [];
- return compose(
- heading(`Questions for ${subject.applicant_name}`),
- 'Built from their gaps rather than a generic list, so the interview tests what the score could not.',
- '',
- numbered([
- `Walk me through your busiest ${subject.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
+ return doc(
+ heading(`Questions for ${c.applicant_name}`, 'Built from their gaps, not a generic list'),
+ list([
+ `Walk me through your busiest ${c.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
gaps[0]
? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?`
: 'Tell me about a shift that went wrong. What was your part in it?',
'Something breaks mid-service and your lead is unreachable. What are your next three moves?',
- subject.certifications?.length
- ? `Your ${subject.certifications[0]} is on file — when did you last apply it on a live shift?`
+ c.certifications?.length
+ ? `Your ${c.certifications[0]} is on file — when did you last apply it on a live shift?`
: 'Which certifications are you working toward, and why that one?',
'What does your real availability look like over the next eight weeks?',
- ])
+ ], { ordered: true }),
+ note('The scenario question is the one that separates candidates. Let them think.')
);
};
const skillGapAnalysis = (f) => {
- if (!f.scored.length) return 'No scored candidates yet, so there are no gaps to analyse.';
+ if (!f.scored.length) return answer('No scored candidates yet, so there are no gaps to analyse.');
const tally = {};
f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => {
@@ -222,54 +286,86 @@ const skillGapAnalysis = (f) => {
}));
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5);
- if (!ranked.length) return 'No recurring gaps across your scored candidates — the pool matches your postings well.';
+ if (!ranked.length) return answer('No recurring gaps — the pool matches your postings well.');
- return compose(
- heading('Recurring gaps'),
- '',
- bullets(ranked.map(([gap, count]) => `${gap} — ${count} of ${f.scored.length}`)),
- '',
- f.missingCredentials.length >= 2
- ? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
- : 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
+ return doc(
+ heading('Recurring gaps', `Across ${plural(f.scored.length, 'scored candidate')}`),
+ table(
+ [{ key: 'gap', label: 'Gap' }, { key: 'count', label: 'Candidates', align: 'right' }],
+ ranked.map(([gap, count]) => ({
+ gap,
+ count: { value: `${count}/${f.scored.length}`, tone: count > f.scored.length / 2 ? 'risk' : undefined },
+ }))
+ ),
+ text(
+ f.missingCredentials.length >= 2
+ ? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
+ : 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
+ )
);
};
const hiringRecommendation = (f) => {
- if (!f.scored.length) return 'Nothing scored yet, so I have no basis for a recommendation.';
+ if (!f.scored.length) return answer('Nothing scored yet, so I have no basis for a recommendation.');
const open = (a) => !['hired', 'rejected'].includes(a.status);
- const shortlist = f.ranked.filter((a) => a.ai_score >= 80 && open(a));
- const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a));
- const pass = f.weak.filter(open);
+ const groups = [
+ { verdict: 'Shortlist', tone: 'success', people: f.ranked.filter((a) => a.ai_score >= 80 && open(a)) },
+ { verdict: 'Interview', tone: 'info', people: f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a)) },
+ { verdict: 'Likely pass', tone: 'risk', people: f.weak.filter(open) },
+ ].filter((g) => g.people.length);
- return compose(
+ if (!groups.length) return answer('Every scored candidate has already been actioned.');
+
+ return doc(
heading('Recommendation'),
- '',
- shortlist.length
- ? `**Shortlist** — ${shortlist.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}`
- : '**Shortlist** — nobody currently clears 80.',
- '',
- interview.length ? `**Interview** — ${interview.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
- '',
- pass.length ? `**Likely pass** — ${pass.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '',
- '',
- '_A recommendation, not a decision. I score what is on file — I have not met these people._'
+ table(
+ [
+ { key: 'name', label: 'Candidate' },
+ { key: 'score', label: 'Score', align: 'right' },
+ { key: 'verdict', label: 'Verdict', align: 'right' },
+ ],
+ groups.flatMap((g) => g.people.map((p) => ({
+ name: p.applicant_name,
+ score: { value: p.ai_score, tone: g.tone },
+ verdict: { badge: g.verdict, tone: g.tone },
+ })))
+ ),
+ note('A recommendation, not a decision. I score what is on file — I have not met these people.')
);
};
/* ── Analytics ──────────────────────────────────────────────────────────── */
-const explainCharts = (f) => compose(
+const explainCharts = (f) => doc(
heading('What these charts are saying'),
- '',
- `**Pipeline Stages** — ${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
- '',
- `**Candidate Scores** — ${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length}; that bucket is your real hiring pool.`,
- '',
- `**Interview Completion** — ${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
- '',
- `**Cost Saved** — $${f.costSaved.toLocaleString()}, from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80. Industry per-unit costs for doing it manually, so treat it as a floor.`
+ insights([
+ {
+ tone: 'info',
+ title: 'Pipeline Stages',
+ body: `${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
+ },
+ {
+ tone: 'info',
+ title: 'Candidate Scores',
+ body: `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length} — that bucket is your real hiring pool.`,
+ },
+ {
+ tone: f.interviewCompletion >= 70 ? 'success' : 'warning',
+ title: 'Interview Completion',
+ body: `${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
+ },
+ {
+ tone: 'success',
+ title: 'Cost Saved',
+ body: `$${f.costSaved.toLocaleString()} from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80 — industry per-unit costs for doing it manually, so treat it as a floor.`,
+ },
+ ]),
+ f.bottleneck && [
+ heading('Biggest drop-off'),
+ funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
+ text(`Most people are lost between **${f.bottleneck.from}** and **${f.bottleneck.to}** — only ${f.bottleneck.rate}% pass through, costing ${plural(f.bottleneck.lost, 'candidate')}.`),
+ ]
);
const forecastHiring = (f) => {
@@ -278,124 +374,150 @@ const forecastHiring = (f) => {
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
const short = f.openPositions.length - expected;
- return compose(
+ return doc(
heading('Forecast'),
- '',
- bullets([
- `${plural(available.length, 'candidate')} at 60+ still in play`,
- `At your ${conversion}% hire rate that projects to about ${plural(expected, 'hire')}`,
- f.timeToHire ? `At a ${f.timeToHire}-day average, those land within a week of you deciding` : 'No time-to-hire baseline yet',
- `${plural(f.openPositions.length, 'open position')} to fill`,
+ kpis([
+ { label: 'In play at 60+', value: available.length },
+ { label: 'Projected hires', value: expected, tone: short > 0 ? 'warning' : 'success' },
+ { label: 'Open roles', value: f.openPositions.length },
+ { label: 'Conversion', value: `${conversion}%` },
]),
- '',
short > 0
- ? `That leaves you roughly ${plural(short, 'role')} short. Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`
- : 'That covers your open roles, assuming the strong candidates do not go elsewhere first.',
- '',
- '_Projected from your own conversion rate, not a promise — small pools move a lot._'
+ ? insights([{
+ tone: 'warning',
+ title: `Roughly ${plural(short, 'role')} short`,
+ body: `Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`,
+ }])
+ : insights([{
+ tone: 'success',
+ title: 'Current pool covers your open roles',
+ body: 'Assuming the strong candidates do not go elsewhere first.',
+ }]),
+ note('Projected from your own conversion rate, not a promise — small pools move a lot.')
);
};
const departmentInsights = (f) => {
- if (!f.byRole.length) return 'No open positions to break down.';
+ if (!f.byRole.length) return answer('No open positions to break down.');
const byCategory = {};
f.byRole.forEach((r) => {
- byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: [] };
+ byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: 0 };
byCategory[r.category].applied += r.applied;
byCategory[r.category].hired += r.hired;
byCategory[r.category].qualified += r.qualified;
- byCategory[r.category].roles.push(r.title);
+ byCategory[r.category].roles += 1;
});
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
const starved = entries.filter(([, d]) => d.applied === 0);
- return compose(
+ return doc(
heading('By role category'),
- '',
- entries.map(([category, d]) =>
- `**${category}** — ${plural(d.applied, 'applicant')}, ${d.qualified} at 70+, ${plural(d.hired, 'hire')} across ${plural(d.roles.length, 'role')}`
- ).join('\n'),
- '',
- starved.length
- ? `${starved.map(([c]) => c).join(' and ')} ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
- : 'Every category has applicants, so your reach is working across the board.'
+ table(
+ [
+ { key: 'category', label: 'Category' },
+ { key: 'applied', label: 'Applied', align: 'right' },
+ { key: 'qualified', label: '70+', align: 'right' },
+ { key: 'hired', label: 'Hired', align: 'right' },
+ ],
+ entries.map(([category, d]) => ({
+ category,
+ applied: { value: d.applied, tone: d.applied === 0 ? 'risk' : undefined },
+ qualified: d.qualified,
+ hired: d.hired,
+ }))
+ ),
+ text(
+ starved.length
+ ? `**${starved.map(([c]) => c).join(' and ')}** ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
+ : 'Every category has applicants, so your reach is working across the board.'
+ )
);
};
-const generateReports = (f) => {
- const stamp = f.today.toISOString().slice(0, 10);
-
- return compose(
- heading(`Hiring report — ${stamp}`),
- '',
- heading('Volume'),
- bullets([
- `${plural(f.total, 'applicant')} received`,
- `${f.screened.length} screened (${f.standardizedPct}% coverage)`,
- `${plural(f.interviews.length, 'AI interview')} conducted, ${f.completedInterviews.length} scored`,
- `${plural(f.hired.length, 'hire')} closed`,
- ]),
- '',
- heading('Quality'),
- bullets([
- `Average candidate score ${f.avgScore}`,
- `Average hire score ${f.hiredAvgScore}`,
- `Hire rate ${f.hireRate}%`,
- `Average time-to-hire ${f.timeToHire ? `${f.timeToHire} days` : 'no baseline'}`,
- ]),
- '',
- heading('Efficiency'),
- bullets([
- `$${f.costSaved.toLocaleString()} saved against manual screening and interviewing`,
- `≈${f.hoursSaved} recruiter hours returned`,
- ]),
- '',
- heading('By role'),
- bullets(f.byRole.map((r) => `${r.title} — ${r.applied} applied, ${r.qualified} at 70+, ${r.hired} hired`)),
- '',
+const generateReports = (f) => doc(
+ // Local date, not toISOString — UTC would show yesterday for anyone behind it.
+ heading('Hiring report', f.today.toLocaleDateString(undefined, {
+ year: 'numeric', month: 'long', day: 'numeric',
+ })),
+ kpis([
+ { label: 'Applicants', value: f.total },
+ { label: 'Screened', value: `${f.standardizedPct}%` },
+ { label: 'Hires', value: f.hired.length },
+ { label: 'Hire rate', value: `${f.hireRate}%` },
+ { label: 'Avg hire score', value: f.hiredAvgScore || '—', tone: 'success' },
+ { label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' },
+ ]),
+ heading('Funnel'),
+ funnel(f.funnel.map((s, i) => ({ label: s.label, count: s.count, rate: i === 0 ? undefined : f.transitions[i - 1]?.rate }))),
+ heading('By role'),
+ table(
+ [
+ { key: 'title', label: 'Role' },
+ { key: 'applied', label: 'Applied', align: 'right' },
+ { key: 'qualified', label: '70+', align: 'right' },
+ { key: 'hired', label: 'Hired', align: 'right' },
+ ],
+ f.byRole.map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified, hired: r.hired }))
+ ),
+ heading('Efficiency'),
+ kpis([
+ { label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
+ { label: 'Recruiter hours', value: `${f.hoursSaved}h`, tone: 'success' },
+ ]),
+ heading('Headline finding'),
+ insights([
f.bottleneck
- ? `**Headline finding.** The ${f.bottleneck.from} → ${f.bottleneck.to} step passes only ${f.bottleneck.rate}%, losing ${plural(f.bottleneck.lost, 'candidate')}. That is the single biggest recoverable loss in the funnel.`
- : '**Headline finding.** The funnel is passing through evenly with no single bottleneck.',
- '',
- '_Copy this into your own template — export to PDF and CSV is not wired up in this build._'
- );
-};
+ ? {
+ tone: 'warning',
+ title: `${f.bottleneck.from} → ${f.bottleneck.to} passes only ${f.bottleneck.rate}%`,
+ body: `Losing ${plural(f.bottleneck.lost, 'candidate')} — the single biggest recoverable loss in the funnel.`,
+ }
+ : { tone: 'success', title: 'No single bottleneck', body: 'The funnel is passing through evenly.' },
+ ]),
+ buildActions(f).length && [heading('Recommended actions'), actions(buildActions(f).slice(0, 3))],
+ note('Copy this into your own template — PDF and CSV export are not wired up in this build.')
+);
/* ── Free-text responders ───────────────────────────────────────────────── */
const has = (q, ...words) => words.some((w) => q.includes(w));
-const stateOfPlay = (f) =>
- `I do not have a specific read on that. What I can see: ${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}, ${f.screened.length} screened, ${plural(f.interviewing.length, 'candidate')} interviewing, ${plural(f.hired.length, 'hire')}. Ask about any of those and I will go deeper.`;
+const stateOfPlay = (f) => doc(
+ text('I do not have a specific read on that. Here is where things stand:'),
+ kpis([
+ { label: 'Applicants', value: f.total },
+ { label: 'Screened', value: f.screened.length },
+ { label: 'Interviewing', value: f.interviewing.length },
+ { label: 'Hired', value: f.hired.length },
+ ]),
+ text('Ask me about any of those and I will go deeper.')
+);
const bottleneckAnswer = (f) => {
- if (!f.bottleneck) return 'Not enough pipeline movement yet to locate a bottleneck.';
+ if (!f.bottleneck) return answer('Not enough pipeline movement yet to locate a bottleneck.');
const b = f.bottleneck;
const diagnosis = {
- 'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap in the funnel to close.`,
+ 'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap to close.`,
Shortlisted: 'Candidates are screened but not shortlisted. Either the scores are not being read, or the bar is above what the pool can deliver.',
Interviewed: 'Shortlisted candidates are not reaching interview — usually scheduling friction rather than a decision.',
Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.',
}[b.to];
- return compose(
- heading(`Bottleneck: ${b.from} → ${b.to}`),
- `Only ${b.rate}% pass through, losing ${plural(b.lost, 'candidate')}.`,
- '',
- diagnosis || '',
- '',
- bullets(f.transitions.map((t) => `${t.from} → ${t.to}: ${t.rate}%${t.lost ? ` (−${t.lost})` : ''}`))
+ return doc(
+ heading(`Bottleneck: ${b.from} → ${b.to}`, `${b.rate}% pass through`),
+ funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
+ insights([{ tone: 'warning', title: `Losing ${plural(b.lost, 'candidate')} here`, body: diagnosis }])
);
};
export const respondOverview = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f);
- if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next')) return pendingActions(f);
+ if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next', 'slow')) return pendingActions(f);
if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(f);
- if (has(q, 'summary', 'today', 'brief', 'catch me up', 'overview')) return hiringSummary(f);
+ if (has(q, 'summar', 'today', 'brief', 'catch me up', 'overview', 'morning', 'afternoon', 'evening')) return hiringSummary(f);
if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f);
if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f);
return stateOfPlay(f);
@@ -403,36 +525,44 @@ export const respondOverview = (question, f) => {
export const respondCandidates = (question, f) => {
const q = question.toLowerCase();
- if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze')) return resumeAnalysis(f);
- if (has(q, 'compare', 'versus', ' vs ', 'between')) return candidateComparison(f);
+ if (has(q, 'compare', 'versus', ' vs ', 'between', '&')) return candidateComparison(f);
if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(f);
if (has(q, 'gap', 'missing', 'weakness', 'skill')) return skillGapAnalysis(f);
if (has(q, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f);
+ if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze', 'review')) return resumeAnalysis(f);
if (has(q, 'unscreened', 'not screened', 'pending')) {
return f.unscreened.length
- ? compose(
+ ? doc(
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
- '',
- bullets(f.unscreened.slice(0, 8).map((a) => `${a.applicant_name} — ${a.job_title}`)),
- f.unscreened.length > 8 ? `…and ${f.unscreened.length - 8} more` : ''
+ table(
+ [{ key: 'name', label: 'Candidate' }, { key: 'role', label: 'Applied for' }],
+ f.unscreened.slice(0, 8).map((a) => ({ name: a.applicant_name, role: a.job_title }))
+ ),
+ f.unscreened.length > 8 && text(`…and ${f.unscreened.length - 8} more.`)
)
- : 'Everyone has been screened.';
+ : answer('Everyone has been screened.');
}
return stateOfPlay(f);
};
export const respondAnalytics = (question, f) => {
const q = question.toLowerCase();
+ if (has(q, 'forecast', 'predict', 'projection', 'will i', 'expect')) return forecastHiring(f);
+ if (has(q, 'report', 'export', 'executive', 'download')) return generateReports(f);
+ if (has(q, 'bottleneck', 'drop', 'stuck', 'lose', 'why do')) return bottleneckAnswer(f);
+ if (has(q, 'department', 'category', 'role', 'team', 'break down')) return departmentInsights(f);
if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f);
- if (has(q, 'forecast', 'predict', 'projection', 'will i')) return forecastHiring(f);
- if (has(q, 'department', 'category', 'role', 'team')) return departmentInsights(f);
- if (has(q, 'report', 'export', 'summar', 'download')) return generateReports(f);
- if (has(q, 'bottleneck', 'drop', 'stuck', 'funnel', 'lose')) return bottleneckAnswer(f);
if (has(q, 'hire rate', 'conversion')) {
- return `${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`;
+ return answer(`${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`);
}
if (has(q, 'cost', 'saved', 'roi', 'money')) {
- return `$${f.costSaved.toLocaleString()} — ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each. Also about ${f.hoursSaved} recruiter hours you did not spend.`;
+ return doc(
+ kpis([
+ { label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
+ { label: 'Hours returned', value: `${f.hoursSaved}h`, tone: 'success' },
+ ]),
+ text(`From ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each.`)
+ );
}
return stateOfPlay(f);
};
diff --git a/src/components/ai-assistant/capabilities/talent.js b/src/components/ai-assistant/capabilities/talent.js
index fa7734d..7f47032 100644
--- a/src/components/ai-assistant/capabilities/talent.js
+++ b/src/components/ai-assistant/capabilities/talent.js
@@ -1,5 +1,6 @@
import { BookOpen, Compass, FileText } from 'lucide-react';
-import { bullets, compose, heading, numbered, plural } from '../insights';
+import { actions, answer, badges, doc, heading, insights, kpis, meters, note, status, table, text } from '../blocks';
+import { plural } from '../insights';
import { getScoreBand, toFICO } from '@/lib/talentHome';
/**
@@ -16,121 +17,165 @@ const NO_PROFILE = 'I do not have your profile yet. Once it exists I can give yo
const resumeReview = (f) => {
const p = f.profile;
- if (!p) return NO_PROFILE;
+ if (!p) return answer(NO_PROFILE);
- const missing = [];
- if (!p.phone) missing.push('phone number');
- if (!p.address) missing.push('location');
- if (!(p.skills || []).length) missing.push('skills');
- if (!(p.experience || []).length) missing.push('work history');
- if (!(p.certifications || []).length) missing.push('certifications');
- if (!p.career_goals) missing.push('career goals');
- if (!(p.availability || []).length) missing.push('availability');
- if (!p.selfie_url) missing.push('photo');
+ const fields = [
+ { label: 'Photo', ok: Boolean(p.selfie_url), note: 'The KROW ID replaces a résumé — a profile with a face gets opened more.' },
+ { label: 'Availability', ok: (p.availability || []).length > 0, note: 'Employers filter on this before they read anything else.' },
+ { label: 'Certifications', ok: (p.certifications || []).length > 0, note: 'A Food Handler Card is the cheapest credential in this industry.' },
+ { label: 'Skills', ok: (p.skills || []).length > 0, note: 'Without these you do not appear in skill searches.' },
+ { label: 'Work history', ok: (p.experience || []).length > 0, note: 'Even one role gives employers something to anchor on.' },
+ { label: 'Career goals', ok: Boolean(p.career_goals), note: 'Lets matching put you in front of the right roles, not every role.' },
+ { label: 'Verified evidence', ok: (p.capabilities || []).length > 0, note: 'The only part of your profile an employer does not have to take on trust.' },
+ ];
- const present = [];
- if ((p.experience || []).length) present.push(`${plural(p.experience.length, 'role')} of work history`);
- if ((p.skills || []).length) present.push(`${plural(p.skills.length, 'skill')} listed`);
- if ((p.certifications || []).length) present.push(p.certifications.join(', '));
- if ((p.capabilities || []).length) present.push(`${plural(p.capabilities.length, 'capability')} verified by evidence`);
+ const complete = fields.filter((x) => x.ok).length;
- return compose(
- heading(`Your profile is ${p.profile_completion || 0}% complete`),
- '',
- present.length ? compose(heading('What is working'), bullets(present)) : '',
- '',
- missing.length
- ? compose(
- heading('What is missing'),
- bullets(missing),
- '',
- 'Employers filter on availability and certifications before they read anything else. If you fix two things, fix those.'
- )
- : compose(heading('Nothing is missing'), 'From here, more verified evidence moves you — not more fields.'),
- '',
- (p.capabilities || []).length
- ? '_Your verified capabilities are the part employers actually trust — those came with evidence attached, not a checkbox._'
- : '_Nothing is verified yet. One completed challenge is worth more than ten claimed skills._'
+ return doc(
+ heading(`Your profile is ${p.profile_completion || 0}% complete`, `${complete} of ${fields.length} things employers look for`),
+ status(fields.map((x) => ({ label: x.label, ok: x.ok, note: x.ok ? undefined : x.note }))),
+ (p.certifications || []).length && [
+ heading('Credentials on file'),
+ badges(p.certifications.map((label) => ({ label, tone: 'success' }))),
+ ],
+ text(
+ complete === fields.length
+ ? 'Nothing is missing. From here, more verified evidence is what moves you — not more fields.'
+ : 'If you fix two things, fix availability and certifications. Those are the two employers filter on first.'
+ ),
+ note(
+ (p.capabilities || []).length
+ ? 'Your verified capabilities came with evidence attached, not a checkbox. That is why they carry weight.'
+ : 'Nothing is verified yet. One completed challenge is worth more than ten claimed skills.'
+ )
);
};
const careerGuidance = (f) => {
const p = f.profile;
- if (!p) return NO_PROFILE;
+ if (!p) return answer(NO_PROFILE);
const score = scoreOf(p);
const band = getScoreBand(score);
const target = score >= 90 ? null : score >= 75 ? 90 : score >= 60 ? 75 : 60;
- return compose(
- heading(`You are at ${toFICO(score)} — ${band.label}`),
- '',
- bullets([
- `Reliability ${p.reliability_score || 0}/100`,
- `Attendance ${p.attendance_score ?? 0}/100`,
- `${plural((p.completed_courses || []).length, 'course')} completed, ${plural((p.earned_badges || []).length, 'badge')} earned`,
- `${plural((p.capabilities || []).length, 'verified capability')}`,
- `${plural(p.shifts_completed || 0, 'shift')} worked`,
+ return doc(
+ heading(`${toFICO(score)} — ${band.label}`, 'Career score, on a 300–850 scale'),
+ kpis([
+ { label: 'Reliability', value: p.reliability_score || 0, tone: (p.reliability_score || 0) >= 70 ? 'success' : 'warning' },
+ { label: 'Attendance', value: p.attendance_score ?? 0, tone: (p.attendance_score ?? 0) >= 90 ? 'success' : undefined },
+ { label: 'Shifts worked', value: p.shifts_completed || 0, tone: (p.shifts_completed || 0) === 0 ? 'warning' : undefined },
+ { label: 'Verified skills', value: (p.capabilities || []).length },
]),
- '',
- target
- ? `**To reach ${target}:** verified work is the fastest lever. Courses move the score; verified shifts move it more, because employers weight what someone else confirmed over what you claimed.`
- : '**You are in the top band.** From here it is about holding it — the score decays if shifts stop.',
- '',
- p.desired_position
- ? `You want to be ${p.desired_position}. ${score >= 75 ? 'You are already scoring in the range employers shortlist for that.' : 'The gap to that role is verification, not ambition.'}`
- : 'You have not set a target role. Setting one lets me tell you which courses matter and which are noise.',
- '',
- (p.shifts_completed || 0) === 0
- ? '**One concrete step:** work a single shift. Attendance, ratings and reliability all need a first data point before anything else compounds.'
- : ''
+ heading("What's driving it"),
+ meters([
+ { label: 'Reliability', value: p.reliability_score || 0, weight: 25 },
+ { label: 'Attendance', value: p.attendance_score ?? 0, weight: 20 },
+ { label: 'Communication', value: p.performance_score || 0, weight: 15 },
+ { label: 'Leadership', value: p.leadership_potential || 0, weight: 15 },
+ { label: 'Learning', value: Math.min(100, (p.completed_courses || []).length * 8 + (p.xp || 0) / 10), weight: 15 },
+ { label: 'Problem solving', value: p.ai_interview_score || 0, weight: 10 },
+ ]),
+ insights([
+ target
+ ? {
+ tone: 'info',
+ title: `To reach ${target}`,
+ body: 'Verified work is the fastest lever. Courses move the score; verified shifts move it more, because employers weight what someone else confirmed over what you claimed.',
+ }
+ : {
+ tone: 'success',
+ title: 'You are in the top band',
+ body: 'From here it is about holding it — the score decays if shifts stop.',
+ },
+ (p.shifts_completed || 0) === 0 && {
+ tone: 'warning',
+ title: 'Work one shift',
+ body: 'Attendance, ratings and reliability all need a first data point before anything else compounds.',
+ },
+ p.desired_position
+ ? {
+ tone: score >= 75 ? 'success' : 'info',
+ title: `Aiming for ${p.desired_position}`,
+ body: score >= 75
+ ? 'You are already scoring in the range employers shortlist for that.'
+ : 'The gap to that role is verification, not ambition.',
+ }
+ : {
+ tone: 'info',
+ title: 'No target role set',
+ body: 'Setting one lets me tell you which courses matter and which are noise.',
+ },
+ ])
);
};
const learningRecommendations = (f) => {
const p = f.profile;
- if (!p) return NO_PROFILE;
+ if (!p) return answer(NO_PROFILE);
const completedIds = new Set((p.completed_courses || []).map((c) => c.course_id));
const done = (p.completed_courses || []).length;
+ const shifts = p.shifts_completed || 0;
const available = (f.courses || []).filter((c) => c.status === 'active' && !completedIds.has(c.id));
if (!available.length) {
- return compose(
+ return doc(
heading('You have completed everything available'),
- `${plural(done, 'course')} done, ${plural((p.earned_badges || []).length, 'badge')} earned, ${p.xp || 0} XP.`,
- '',
- 'The next lever is verified shifts — that is what employers weight most.'
+ kpis([
+ { label: 'Courses', value: done, tone: 'success' },
+ { label: 'Badges', value: (p.earned_badges || []).length, tone: 'success' },
+ { label: 'XP', value: p.xp || 0 },
+ { label: 'Verified', value: (p.capabilities || []).length },
+ ]),
+ text('The next lever is verified shifts — that is what employers weight most.')
);
}
- const byValue = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0));
- const shifts = p.shifts_completed || 0;
+ const ranked = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0));
- return compose(
- heading('What to learn next'),
- '',
- numbered(byValue.slice(0, 3).map((c) => {
- const gate = c.unlock_requirements?.min_shifts || 0;
- const locked = gate > shifts;
- return `**${c.title}** — ${c.difficulty}, ${c.estimated_minutes} min, +${c.xp} XP\n Proves: ${c.proof_skill || c.title}.${locked ? ` Locked until ${plural(gate, 'shift')} (you have ${shifts}).` : ' Available now.'}`;
- })),
- '',
+ return doc(
+ heading('What to learn next', 'Ranked by what moves your score most'),
+ table(
+ [
+ { key: 'title', label: 'Course' },
+ { key: 'minutes', label: 'Time', align: 'right' },
+ { key: 'xp', label: 'XP', align: 'right' },
+ ],
+ ranked.slice(0, 4).map((c) => {
+ const gate = c.unlock_requirements?.min_shifts || 0;
+ const locked = gate > shifts;
+ return {
+ title: c.title,
+ minutes: `${c.estimated_minutes}m`,
+ xp: locked
+ ? { badge: `${gate} shifts`, tone: 'warning' }
+ : { value: `+${c.xp}`, tone: 'success' },
+ };
+ })
+ ),
heading('Where you stand'),
- bullets([
- `${plural(done, 'course')} completed`,
- `${plural((p.earned_badges || []).length, 'badge')} earned`,
- `${p.xp || 0} XP`,
- `${plural((p.capabilities || []).length, 'capability')} verified`,
+ kpis([
+ { label: 'Courses done', value: done },
+ { label: 'Badges', value: (p.earned_badges || []).length },
+ { label: 'XP', value: p.xp || 0 },
+ { label: 'Verified skills', value: (p.capabilities || []).length },
]),
- '',
- done === 0
- ? 'Start with the shortest one. The point of the first course is not the XP — it is getting one verified capability on the board.'
- : done < 3
- ? `Three completions is where the score starts moving noticeably, because education carries real weight in the formula. You are at ${done}.`
- : 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.',
- '',
- 'Each of these ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored. That is why the badge means something to an employer.'
+ actions([
+ {
+ title: done === 0 ? `Start with ${ranked[ranked.length - 1].title}` : `Take ${ranked[0].title}`,
+ body: done === 0
+ ? 'The point of the first course is not the XP — it is getting one verified capability on the board.'
+ : done < 3
+ ? `Three completions is where the score starts moving noticeably. You are at ${done}.`
+ : 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.',
+ },
+ shifts < 5 && {
+ title: `Log ${plural(5 - shifts, 'more shift')}`,
+ body: `Advanced challenges stay locked until 5 shifts — you have ${shifts}. That gate is deliberate: leadership evidence from someone who has never run a floor is not evidence.`,
+ },
+ ]),
+ note('Each course ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored.')
);
};
@@ -140,14 +185,16 @@ const has = (q, ...words) => words.some((w) => q.includes(w));
export const respondTalent = (question, f) => {
const q = question.toLowerCase();
- if (has(q, 'resume', 'résumé', 'cv', 'profile')) return resumeReview(f);
- if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge')) return learningRecommendations(f);
- if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next')) return careerGuidance(f);
+ if (has(q, 'resume', 'résumé', 'cv', 'profile', 'improve')) return resumeReview(f);
+ if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge', 'path')) return learningRecommendations(f);
+ if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next', 'started')) return careerGuidance(f);
const p = f.profile;
- return p
- ? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?`
- : NO_PROFILE;
+ return answer(
+ p
+ ? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?`
+ : NO_PROFILE
+ );
};
export const TALENT_CAPABILITIES = [
diff --git a/src/components/ai-assistant/contexts.js b/src/components/ai-assistant/contexts.js
index 9fce3e7..c5ced82 100644
--- a/src/components/ai-assistant/contexts.js
+++ b/src/components/ai-assistant/contexts.js
@@ -11,18 +11,10 @@ import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent';
/**
* Page contexts for the Krow AI Assistant.
*
- * The component is identical on every page. Only what is in this file changes:
- * the supporting line, the placeholder examples, the suggested chips, the
- * capabilities and the responder.
- *
- * Chips are phrased as things a person would actually type, because clicking one
- * fills the input rather than firing an action. The user always sees the
- * question before it is asked, so the assistant reads as a conversation rather
- * than a menu of buttons.
- *
- * `capability` binds a chip to a specific capability, so a chip gets a precise
- * answer while an equivalent typed question still resolves through the
- * responder's keyword matching.
+ * Deliberately thin. What the user sees on the landing screen — the title,
+ * greeting, description, placeholders and suggested prompts — is computed from
+ * live data in `dynamic.js`, not written here. A context only declares what this
+ * page's assistant can *do*: its capabilities and its free-text responder.
*
* `needsWorkerProfile` gates the one query with a side effect, so employer and
* admin sessions never touch worker-profile records.
@@ -31,129 +23,42 @@ export const ASSISTANT_CONTEXTS = {
'employer.overview': {
id: 'employer.overview',
page: 'Overview',
- tagline: 'Find insights from your hiring data, spot what needs attention, and ask questions about this page.',
- placeholders: [
- "Ask about today's hiring…",
- 'What should I do first?',
- 'Which roles are at risk?',
- 'How healthy is my pipeline?',
- ],
- chips: [
- { label: "Today's Hiring", prompt: 'Give me a summary of hiring today', capability: 'hiring-summary' },
- { label: 'Pipeline Health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
- { label: 'Pending Actions', prompt: 'What is pending and needs my attention?', capability: 'pending-actions' },
- { label: 'Hiring Risks', prompt: 'What risks should I know about?', capability: 'hiring-risks' },
- ],
capabilities: OVERVIEW_CAPABILITIES,
respond: respondOverview,
},
-
'employer.candidates': {
id: 'employer.candidates',
page: 'Candidates',
- tagline: 'Analyse résumés, compare candidates, generate interview questions, and decide who to move forward.',
- placeholders: [
- 'Compare two candidates…',
- 'Who should I interview next?',
- 'What are this candidate’s gaps?',
- 'Draft interview questions…',
- ],
- chips: [
- { label: 'Top Candidates', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
- { label: 'Compare Candidates', prompt: 'Compare my top two candidates', capability: 'candidate-comparison' },
- { label: 'Interview Questions', prompt: 'Generate interview questions for my top candidate', capability: 'interview-questions' },
- { label: 'Skill Gap Analysis', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
- { label: 'Hiring Recommendation', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
- ],
capabilities: CANDIDATE_CAPABILITIES,
respond: respondCandidates,
},
-
'employer.analytics': {
id: 'employer.analytics',
page: 'Analytics',
- tagline: 'Explain the numbers on this page, forecast what your pipeline will yield, and generate reports.',
- placeholders: [
- 'Explain this chart…',
- 'Generate a hiring report…',
- 'Where do candidates drop off?',
- 'How many hires can I expect?',
- ],
- chips: [
- { label: 'Explain Charts', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
- { label: 'Forecast Hiring', prompt: 'Forecast my hiring from the current pipeline', capability: 'forecast-hiring' },
- { label: 'Department Insights', prompt: 'Break down performance by role category', capability: 'department-insights' },
- { label: 'Generate Report', prompt: 'Generate a hiring report', capability: 'generate-reports' },
- ],
capabilities: ANALYTICS_CAPABILITIES,
respond: respondAnalytics,
},
-
'admin.controlCenter': {
id: 'admin.controlCenter',
page: 'Control Center',
- tagline: 'Check platform health, reconcile the data, and see what needs intervention across every account.',
- placeholders: [
- 'Is anything broken?',
- 'What should I fix first?',
- 'How is the platform performing?',
- ],
- chips: [
- { label: 'Platform Health', prompt: 'Run a platform health check', capability: 'platform-health' },
- { label: 'Recommendations', prompt: 'What should I fix first?', capability: 'recommendations' },
- ],
capabilities: CONTROL_CENTER_CAPABILITIES,
respond: respondControlCenter,
},
-
'admin.candidates': {
id: 'admin.candidates',
page: 'Candidates Analysis',
- tagline: 'Analyse talent supply across the platform and see where the risk sits in the candidate pool.',
- placeholders: [
- 'How healthy is talent supply?',
- 'Any candidate risk flags?',
- 'What are the recruitment trends?',
- ],
- chips: [
- { label: 'Recruitment Insights', prompt: 'Show me recruitment insights across the platform', capability: 'recruitment-insights' },
- { label: 'Candidate Risk', prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' },
- ],
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
respond: respondAdminCandidates,
},
-
'admin.activity': {
id: 'admin.activity',
page: 'Activity',
- tagline: 'Summarize the audit trail, reconcile it against real records, and see how accounts are behaving.',
- placeholders: [
- 'Summarize the audit trail…',
- 'Who is most active?',
- 'Are there any gaps in the log?',
- ],
- chips: [
- { label: 'Audit Summary', prompt: 'Summarize the audit trail', capability: 'audit-summary' },
- { label: 'User Activity', prompt: 'Which accounts are most active?', capability: 'user-activity' },
- ],
capabilities: ACTIVITY_CAPABILITIES,
respond: respondActivity,
},
-
'talent.portal': {
id: 'talent.portal',
page: 'My Portal',
- tagline: 'Understand your score, improve your profile, and find the next thing worth learning.',
- placeholders: [
- 'How do I earn more?',
- 'Review my profile…',
- 'What should I learn next?',
- ],
- chips: [
- { label: 'Review My Profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' },
- { label: 'Career Guidance', prompt: 'How do I raise my career score?', capability: 'career-guidance' },
- { label: 'What To Learn Next', prompt: 'What should I learn next?', capability: 'learning-recommendations' },
- ],
capabilities: TALENT_CAPABILITIES,
respond: respondTalent,
needsWorkerProfile: true,
diff --git a/src/components/ai-assistant/dynamic.js b/src/components/ai-assistant/dynamic.js
new file mode 100644
index 0000000..9773c37
--- /dev/null
+++ b/src/components/ai-assistant/dynamic.js
@@ -0,0 +1,265 @@
+import { plural, verb } from './insights';
+
+/**
+ * Dynamic greetings and suggested prompts.
+ *
+ * Nothing here is a fixed string chosen in advance. The greeting states what is
+ * actually true on the page right now, and the suggestions name real records —
+ * the specific candidates, the specific starved role. That is the difference
+ * between an assistant that looks aware of context and one that is.
+ *
+ * Everything derives from the same fact sheet the dashboard renders from, so a
+ * greeting can never contradict the numbers beside it.
+ */
+
+/* ── Greetings ──────────────────────────────────────────────────────────── */
+
+/**
+ * Time of day from the viewer's own clock.
+ *
+ * `getHours()` is local, so this follows the user's timezone rather than the
+ * server's. The late band matters: at 00:30 "morning" is wrong, so 21:00–04:59
+ * reads as evening.
+ *
+ * 05:00–11:59 morning
+ * 12:00–16:59 afternoon
+ * 17:00–20:59 evening
+ * 21:00–04:59 evening
+ */
+function timeOfDay(date) {
+ const hour = date.getHours();
+ if (hour >= 5 && hour < 12) return 'morning';
+ if (hour >= 12 && hour < 17) return 'afternoon';
+ return 'evening';
+}
+
+/**
+ * Picks the single most useful thing to say about the page.
+ *
+ * Ordered by urgency rather than by what sounds cheeriest: something stuck beats
+ * something healthy, because the greeting is the one line guaranteed to be read.
+ */
+const GREETINGS = {
+ 'employer.overview': (f) => {
+ if (!f.total) return 'No applicants yet — publish a position and I will start tracking it.';
+ if (f.stalled.length) {
+ return `${plural(f.stalled.length, 'strong candidate')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`;
+ }
+ if (f.unscreened.length) {
+ return `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'is', 'are')} waiting to be screened.`;
+ }
+ if (f.starvedPositions.length) {
+ return `${plural(f.starvedPositions.length, 'open role')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants yet.`;
+ }
+ return 'Your hiring pipeline looks healthy today.';
+ },
+
+ 'employer.candidates': (f) => {
+ if (!f.total) return 'No candidates in the pipeline yet.';
+ if (f.unscreened.length) {
+ return `You have ${plural(f.unscreened.length, 'candidate')} waiting for review.`;
+ }
+ if (f.stalled.length) {
+ return `${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'is', 'are')} ready for a decision.`;
+ }
+ return `All ${f.total} candidates are scored and actioned.`;
+ },
+
+ 'employer.analytics': (f) => {
+ if (!f.scored.length) return 'Nothing scored yet, so the charts are still empty.';
+ if (f.bottleneck && f.bottleneck.lost > 0) {
+ return `Your funnel loses most people between ${f.bottleneck.from} and ${f.bottleneck.to}.`;
+ }
+ return `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. Worth a closer look.`;
+ },
+
+ 'admin.controlCenter': (f) => {
+ if (f.unscoredProfiles.length) {
+ return `${plural(f.unscoredProfiles.length, 'talent profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score and cannot be matched.`;
+ }
+ if (f.activity24h.length) {
+ return `${plural(f.activity24h.length, 'event')} logged in the last 24 hours.`;
+ }
+ return `Platform is steady across ${plural(f.postings.length, 'position')} and ${plural(f.total, 'application')}.`;
+ },
+
+ 'admin.candidates': (f) => {
+ if (f.missingCredentials.length) {
+ return `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no credentials on file.`;
+ }
+ return `${plural(f.profiles.length, 'worker')} in the pool, ${f.elite.length} at Elite.`;
+ },
+
+ 'admin.activity': (f) => {
+ if (!f.activity.length) return 'No activity logged yet.';
+ if (f.activity24h.length === 0) {
+ return `Quiet day — ${plural(f.activity7d.length, 'event')} this week, none in the last 24 hours.`;
+ }
+ return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week.`;
+ },
+
+ 'talent.portal': (f) => {
+ const p = f.profile;
+ if (!p) return 'Let us get your profile started.';
+ const completion = p.profile_completion || 0;
+ if (completion >= 90) return 'Your profile is nearly complete — nice work.';
+ if (completion > 0) return `You are ${completion}% of the way through your profile.`;
+ return 'Your profile is empty. A few fields will change what employers see.';
+ },
+};
+
+/** Title lines. First names read better than a formal greeting in a small panel. */
+const TITLES = {
+ 'employer.overview': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
+ 'employer.candidates': () => 'Candidate intelligence',
+ 'employer.analytics': () => 'Analytics assistant',
+ 'admin.controlCenter': (f) => `Platform check, ${timeOfDay(f.today)}`,
+ 'admin.candidates': () => 'Talent analysis',
+ 'admin.activity': () => 'Activity review',
+ 'talent.portal': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
+};
+
+/** Descriptions state what this page's assistant can actually do with the data. */
+const DESCRIPTIONS = {
+ 'employer.overview': (f) =>
+ `Reading ${f.total} applicants across ${plural(f.openPositions.length, 'open role')}.`,
+ 'employer.candidates': (f) => `All ${f.total} candidates and their score breakdowns.`,
+ 'employer.analytics': () => 'Every figure on this page, explained or forecast.',
+ 'admin.controlCenter': () => 'Health checks, data integrity and what needs intervention.',
+ 'admin.candidates': () => 'Talent supply and risk across the candidate pool.',
+ 'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
+ 'talent.portal': () => 'Your score, your profile, and what to learn next.',
+};
+
+/** Placeholders phrased as things this page's user would actually type. */
+const PLACEHOLDERS = {
+ 'employer.overview': (f) => [
+ `Summarize ${timeOfDay(f.today)}'s hiring…`,
+ f.starvedPositions.length ? 'Which positions need attention?' : 'What should I do first?',
+ 'Why did hiring slow this week?',
+ ],
+ 'employer.candidates': (f) => [
+ f.ranked.length >= 2 ? 'Compare the top candidates…' : 'Analyse my strongest candidate…',
+ 'Which resumes need review?',
+ 'Recommend interview questions…',
+ ],
+ 'employer.analytics': () => [
+ 'Explain this chart…',
+ 'Forecast hiring…',
+ 'Generate an executive summary…',
+ ],
+ 'admin.controlCenter': () => ['Show unusual activity…', 'Summarize platform usage…'],
+ 'admin.candidates': () => ['Where is the risk in the pool?', 'Summarize recruitment trends…'],
+ 'admin.activity': () => ['Show unusual activity…', 'Summarize platform usage…'],
+ 'talent.portal': () => ['Improve my profile…', 'Recommend learning paths…'],
+};
+
+/* ── Suggested prompts ──────────────────────────────────────────────────── */
+
+/**
+ * Builds suggestions from what is actually on the page.
+ *
+ * A suggestion that names two real candidates is a different product from one
+ * that says "Compare candidates" — it proves the assistant already looked.
+ * Each carries the capability that answers it best, so a click is precise.
+ */
+const PROMPTS = {
+ 'employer.overview': (f) => [
+ { label: `Summarize ${timeOfDay(f.today)}'s hiring`, prompt: `Summarize hiring as of this ${timeOfDay(f.today)}`, capability: 'hiring-summary' },
+ f.starvedPositions.length
+ ? { label: 'Which positions need attention?', prompt: `Which positions need attention? ${f.starvedPositions[0].title} has no applicants`, capability: 'pending-actions' }
+ : { label: 'What needs my attention?', prompt: 'What needs my attention right now?', capability: 'pending-actions' },
+ { label: 'Pipeline health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
+ f.singleCandidateRoles.length || f.missingCredentials.length
+ ? { label: 'Where is the risk?', prompt: 'Where is the risk in my hiring right now?', capability: 'hiring-risks' }
+ : { label: 'Any hiring risks?', prompt: 'Are there any hiring risks I should know about?', capability: 'hiring-risks' },
+ ],
+
+ 'employer.candidates': (f) => {
+ const [a, b] = f.ranked;
+ return [
+ a && b
+ ? { label: `Compare ${a.applicant_name.split(' ')[0]} & ${b.applicant_name.split(' ')[0]}`, prompt: `Compare ${a.applicant_name} and ${b.applicant_name}`, capability: 'candidate-comparison' }
+ : { label: 'Compare top candidates', prompt: 'Compare my top candidates', capability: 'candidate-comparison' },
+ f.unscreened.length
+ ? { label: `${f.unscreened.length} resumes need review`, prompt: 'Which resumes need review?', capability: 'resume-analysis' }
+ : { label: 'Analyse top résumé', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
+ a
+ ? { label: `Questions for ${a.applicant_name.split(' ')[0]}`, prompt: `Recommend interview questions for ${a.applicant_name}`, capability: 'interview-questions' }
+ : null,
+ { label: 'Skill gaps', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
+ { label: 'Who should I hire?', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
+ ].filter(Boolean);
+ },
+
+ 'employer.analytics': (f) => [
+ { label: 'Explain this chart', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
+ { label: 'Forecast hiring', prompt: 'Forecast hiring from the current pipeline', capability: 'forecast-hiring' },
+ { label: 'Generate executive summary', prompt: 'Generate an executive hiring report', capability: 'generate-reports' },
+ f.bottleneck
+ ? {
+ label: `Why the drop to ${f.bottleneck.to}?`,
+ prompt: `Why do candidates drop off between ${f.bottleneck.from} and ${f.bottleneck.to}?`,
+ capability: 'explain-charts',
+ }
+ : null,
+ { label: 'Break down by role', prompt: 'Break down performance by role category', capability: 'department-insights' },
+ ].filter(Boolean),
+
+ 'admin.controlCenter': (f) => [
+ { label: 'Platform health', prompt: 'Run a platform health check', capability: 'platform-health' },
+ { label: 'Summarize platform usage', prompt: 'Summarize platform usage', capability: 'platform-health' },
+ f.unscoredProfiles.length
+ ? { label: `${f.unscoredProfiles.length} unscored profiles`, prompt: 'What should I fix first?', capability: 'recommendations' }
+ : { label: 'What should I fix?', prompt: 'What should I fix first?', capability: 'recommendations' },
+ ],
+
+ 'admin.candidates': (f) => [
+ { label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
+ f.missingCredentials.length
+ ? { label: `${f.missingCredentials.length} missing credentials`, prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' }
+ : { label: 'Candidate risk', prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' },
+ ],
+
+ 'admin.activity': (f) => [
+ { label: 'Audit summary', prompt: 'Summarize the audit trail', capability: 'audit-summary' },
+ { label: 'Show unusual activity', prompt: 'Show me unusual account activity', capability: 'user-activity' },
+ f.activity7d.length
+ ? { label: `${f.activity7d.length} events this week`, prompt: 'Summarize platform usage this week', capability: 'audit-summary' }
+ : null,
+ ].filter(Boolean),
+
+ 'talent.portal': (f) => {
+ const p = f.profile;
+ return [
+ { label: 'Improve my profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' },
+ { label: 'Recommend learning paths', prompt: 'What should I learn next?', capability: 'learning-recommendations' },
+ p && (p.krow_score || 0) > 0
+ ? { label: 'How do I score higher?', prompt: 'How do I raise my career score?', capability: 'career-guidance' }
+ : { label: 'How do I get started?', prompt: 'How do I raise my career score?', capability: 'career-guidance' },
+ ];
+ },
+};
+
+/* ── Public API ─────────────────────────────────────────────────────────── */
+
+const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] || '';
+
+/**
+ * Everything the landing screen needs, computed from current data.
+ * `userName` personalizes the greeting where a name is known.
+ */
+export function buildIntro(contextId, facts, userName) {
+ const name = firstName(userName);
+ return {
+ title: TITLES[contextId]?.(facts, name) ?? 'Krow AI',
+ greeting: GREETINGS[contextId]?.(facts) ?? '',
+ description: DESCRIPTIONS[contextId]?.(facts) ?? '',
+ placeholders: PLACEHOLDERS[contextId]?.(facts) ?? ['Ask anything…'],
+ };
+}
+
+/** Suggested prompts for the current page and data. */
+export function buildPrompts(contextId, facts) {
+ return PROMPTS[contextId]?.(facts) ?? [];
+}
diff --git a/src/components/ai-assistant/provider.js b/src/components/ai-assistant/provider.js
index 1fd498a..a1c71f8 100644
--- a/src/components/ai-assistant/provider.js
+++ b/src/components/ai-assistant/provider.js
@@ -2,33 +2,32 @@
* Provider seam for the Krow AI Assistant.
*
* The UI never generates an answer and never knows where one came from. It calls
- * `provider.stream(request)` and renders the chunks. That single boundary is
+ * `provider.stream(request)` and renders the snapshots. That single boundary is
* what makes the backend swappable: today a local provider computes answers from
* dashboard data, and later an HTTP provider can call a real service without a
- * single change to the component.
+ * change to the component.
*
* ── Contract ────────────────────────────────────────────────────────────────
*
* provider.id: string
- * provider.stream(request): AsyncIterable
// yields text deltas
+ * provider.stream(request): AsyncIterable
+ *
+ * Each yielded value is the response *so far* as an array of blocks (see
+ * blocks.js). Snapshots rather than deltas because a response is structured:
+ * a table or a KPI row has no meaningful half-state, and the renderer stays a
+ * pure function of the latest snapshot.
*
* request = {
* contextId: string, // e.g. 'employer.overview'
* capability: string | null, // capability id, or null for free text
- * question: string, // what the user typed, or the capability label
- * facts: object, // the dashboard fact sheet (see insights.js)
+ * question: string, // what the user typed, or the chip's prompt
+ * facts: object, // dashboard fact sheet (see insights.js)
* signal: AbortSignal, // aborts an in-flight response
* }
- *
- * Deltas rather than a whole string is deliberate: it is the shape every
- * streaming completion API returns, so the local provider and a future remote
- * one are interchangeable without the UI learning a second pattern.
*/
import { getContext } from './contexts';
-
-/** Words per delta. Chunking by word keeps reflow to once per word. */
-const WORDS_PER_CHUNK = 3;
+import { toSnapshots } from './blocks';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -38,7 +37,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
* Deterministic: the same question against the same data returns the same
* answer, which is what makes the assistant demonstrable and testable.
*/
-export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
+export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
return {
id: 'local',
@@ -46,21 +45,20 @@ export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
const context = getContext(contextId);
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
- const answer = capability
+ const document = capability
? context.capabilities.find((c) => c.id === capability)?.run(facts)
?? context.respond(question, facts)
: context.respond(question, facts);
- // A brief pause before the first delta, so the answer reads as considered
+ // A brief pause before the first frame, so the answer reads as considered
// rather than precomputed.
await sleep(latency);
if (signal?.aborted) return;
- const tokens = String(answer).split(/(\s+)/);
- for (let i = 0; i < tokens.length; i += WORDS_PER_CHUNK * 2) {
+ for (const snapshot of toSnapshots(document)) {
if (signal?.aborted) return;
- yield tokens.slice(i, i + WORDS_PER_CHUNK * 2).join('');
- await sleep(chunkDelay);
+ yield snapshot;
+ await sleep(frameDelay);
}
},
};
@@ -69,15 +67,15 @@ export function createLocalProvider({ latency = 420, chunkDelay = 26 } = {}) {
/**
* HTTP provider — the path to a real backend.
*
- * Reads Server-Sent-Event style `data:` lines from a streaming endpoint and
- * yields the text deltas. Unused today; it exists so the shape of the
- * integration is settled rather than guessed at later. Swapping providers is a
- * one-line change in `AssistantProvider`.
+ * Reads Server-Sent-Event style `data:` lines and yields a growing block array.
+ * A line may carry a whole block (`{ block: … }`) or a text delta
+ * (`{ delta: "…" }`), which is appended to a trailing text block. Unused today;
+ * it exists so the shape of the integration is settled rather than guessed at.
*
- * The request body sends `contextId`, `capability` and `question` — not the
- * fact sheet. Dashboard data should be fetched server-side from the caller's own
- * session rather than posted from the browser, so the client cannot ask about
- * records it is not entitled to see.
+ * The request body sends `contextId`, `capability` and `question` — not the fact
+ * sheet. Dashboard data should be read server-side from the caller's own session
+ * rather than posted from the browser, so the client cannot ask about records it
+ * is not entitled to see.
*/
export function createHttpProvider({ endpoint, headers = {} }) {
if (!endpoint) throw new Error('createHttpProvider requires an endpoint');
@@ -99,28 +97,37 @@ export function createHttpProvider({ endpoint, headers = {} }) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
+ const blocks = [];
let buffer = '';
+ const appendDelta = (delta) => {
+ const last = blocks[blocks.length - 1];
+ if (last?.type === 'text') last.text += delta;
+ else blocks.push({ type: 'text', text: delta });
+ };
+
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
- // The final element may be a partial line; keep it for the next read.
+ // The final element may be a partial line; hold it for the next read.
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
+
try {
const parsed = JSON.parse(payload);
- if (parsed.delta) yield parsed.delta;
+ if (parsed.block) blocks.push(parsed.block);
+ else if (parsed.delta) appendDelta(parsed.delta);
} catch {
- // A non-JSON data line is treated as a raw delta.
- yield payload;
+ appendDelta(payload);
}
+ yield [...blocks];
}
}
},
@@ -128,10 +135,9 @@ export function createHttpProvider({ endpoint, headers = {} }) {
}
/**
- * The provider the app uses.
- *
- * Local by default. Point `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to
- * switch, with no other code change.
+ * The provider the app uses. Local by default; point
+ * `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to switch, with no other
+ * code change.
*/
export function createAssistantProvider() {
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;
diff --git a/src/components/ai-assistant/useAssistant.js b/src/components/ai-assistant/useAssistant.js
index 97121fe..38bc6b6 100644
--- a/src/components/ai-assistant/useAssistant.js
+++ b/src/components/ai-assistant/useAssistant.js
@@ -48,7 +48,7 @@ export function useConversation({ contextId, facts }) {
const storageKey = `krow_assistant:${contextId}`;
const [messages, setMessages] = React.useState([]);
- const [pending, setPending] = React.useState(null); // { question, text, thinking }
+ const [pending, setPending] = React.useState(null); // { blocks, thinking }
const [error, setError] = React.useState(null);
const abortRef = React.useRef(null);
@@ -93,28 +93,26 @@ export function useConversation({ contextId, facts }) {
messagesRef.current = withUser;
persist(withUser);
setError(null);
- setPending({ question: text, text: '', thinking: true });
+ setPending({ blocks: [], thinking: true });
try {
- let assembled = '';
- for await (const delta of provider.stream({
+ let latest = [];
+ for await (const snapshot of provider.stream({
contextId, capability, question: text, facts, signal: controller.signal,
})) {
if (controller.signal.aborted) break;
- assembled += delta;
- setPending({ question: text, text: assembled, thinking: false });
+ latest = snapshot;
+ setPending({ blocks: snapshot, thinking: false });
}
- if (controller.signal.aborted) {
- // Keep whatever streamed before the user stopped it — discarding a
- // half-written answer loses information the user was already reading.
- if (assembled) {
- const next = [...messagesRef.current, { role: 'assistant', text: assembled, stopped: true }];
- messagesRef.current = next;
- persist(next);
- }
- } else {
- const next = [...messagesRef.current, { role: 'assistant', text: assembled }];
+ // Keep whatever arrived before the user stopped it — discarding a
+ // half-written answer loses what they were already reading. Stopping
+ // before the first block, though, should leave no empty turn behind.
+ if (latest.length) {
+ const next = [
+ ...messagesRef.current,
+ { role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
+ ];
messagesRef.current = next;
persist(next);
}