update chatbox

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

View File

@@ -1,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);
};