593 lines
27 KiB
JavaScript
593 lines
27 KiB
JavaScript
import {
|
||
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
|
||
HeartPulse, Layers, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target,
|
||
} from 'lucide-react';
|
||
import {
|
||
actions, answer, badges, doc, funnel, heading, insights, kpis, list, meters, note,
|
||
status, table, text,
|
||
} from '../blocks';
|
||
import { plural, verb } from '../insights';
|
||
|
||
/**
|
||
* Employer capabilities — Overview, Candidates, Analytics.
|
||
*
|
||
* Each returns a block document rather than prose. The shape is chosen by what
|
||
* the information is: a comparison is a table, a health check is a status list,
|
||
* a pipeline is a funnel. Prose is reserved for the judgement a table cannot
|
||
* carry.
|
||
*/
|
||
|
||
/* ── Overview ───────────────────────────────────────────────────────────── */
|
||
|
||
const hiringSummary = (f) => {
|
||
if (!f.total) return answer('Nothing in the pipeline yet. Publish a position and I will start tracking it.');
|
||
|
||
return doc(
|
||
kpis([
|
||
{ label: 'Applicants', value: f.total, sub: `${f.openPositions.length} open roles` },
|
||
{ label: 'Screened', value: f.screened.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
|
||
{ label: 'Interviewing', value: f.interviewing.length },
|
||
{ label: 'Hired', value: f.hired.length, sub: f.hiredAvgScore ? `avg ${f.hiredAvgScore}/100` : undefined, tone: 'info' },
|
||
]),
|
||
heading('Pipeline'),
|
||
funnel(f.funnel.map((stage, i) => ({
|
||
label: stage.label,
|
||
count: stage.count,
|
||
rate: i === 0 ? undefined : f.transitions[i - 1]?.rate,
|
||
}))),
|
||
text(
|
||
f.stalled.length
|
||
? `The first thing I would fix: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still at AI Screened.`
|
||
: f.unscreened.length
|
||
? `The first thing I would fix: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
|
||
: 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
|
||
)
|
||
);
|
||
};
|
||
|
||
const hiringHealth = (f) => {
|
||
const checks = [
|
||
{
|
||
label: 'Screening coverage',
|
||
value: `${f.standardizedPct}%`,
|
||
ok: f.standardizedPct >= 80,
|
||
note: f.standardizedPct >= 80
|
||
? 'Applicants are scored consistently.'
|
||
: `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`,
|
||
},
|
||
{
|
||
label: 'Response speed',
|
||
value: f.timeToHire ? `${f.timeToHire}d` : '—',
|
||
ok: f.timeToHire > 0 && f.timeToHire <= 3,
|
||
note: f.timeToHire && f.timeToHire <= 3
|
||
? 'Inside the window where good candidates are still available.'
|
||
: 'Event staff accept other work within days — past 3 days you lose people.',
|
||
},
|
||
{
|
||
label: 'Quality of hire',
|
||
value: f.hiredAvgScore ? `${f.hiredAvgScore}/100` : '—',
|
||
ok: f.hiredAvgScore >= 80,
|
||
note: f.hiredAvgScore >= 80
|
||
? 'You are hiring from the top of your own pool.'
|
||
: 'Hires are scoring mid-pack — widen the funnel before lowering the bar.',
|
||
},
|
||
{
|
||
label: 'Pipeline supply',
|
||
value: `${f.openPositions.length} open`,
|
||
ok: f.starvedPositions.length === 0,
|
||
note: f.starvedPositions.length
|
||
? `${f.starvedPositions.map((p) => p.title).join(', ')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants.`
|
||
: 'Every open position has applicants.',
|
||
},
|
||
];
|
||
|
||
const passing = checks.filter((c) => c.ok).length;
|
||
|
||
return doc(
|
||
heading(`${passing} of ${checks.length} signals healthy`),
|
||
status(checks),
|
||
note('Thresholds are event-staffing norms, not universal benchmarks.')
|
||
);
|
||
};
|
||
|
||
const buildActions = (f) => [
|
||
f.stalled.length && {
|
||
title: 'Move your 80+ candidates',
|
||
body: `${f.stalled.slice(0, 3).map((a) => `**${a.applicant_name}** (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`,
|
||
},
|
||
f.unscreened.length && {
|
||
title: `Screen ${plural(f.unscreened.length, 'applicant')}`,
|
||
body: 'One pass clears the backlog and costs nothing.',
|
||
},
|
||
f.starvedPositions.length && {
|
||
title: 'Fix the postings nobody applies to',
|
||
body: `${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`,
|
||
},
|
||
f.unratedStaff.length && {
|
||
title: `Rate ${plural(f.unratedStaff.length, 'hire')}`,
|
||
body: 'Ratings feed KROW scores and sharpen future matching.',
|
||
},
|
||
f.elite.length && {
|
||
title: 'Reach out to the pool directly',
|
||
body: `${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`,
|
||
},
|
||
].filter(Boolean);
|
||
|
||
const pendingActions = (f) => {
|
||
const items = buildActions(f);
|
||
if (!items.length) {
|
||
return answer('Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.');
|
||
}
|
||
return doc(heading('Pending actions', 'Ordered by what costs you most'), actions(items.slice(0, 4)));
|
||
};
|
||
|
||
const hiringRisks = (f) => {
|
||
const risks = [
|
||
f.singleCandidateRoles.length && {
|
||
tone: 'risk',
|
||
title: 'Single-candidate roles',
|
||
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`,
|
||
},
|
||
f.stalled.length && {
|
||
tone: 'warning',
|
||
title: 'Losing strong candidates to delay',
|
||
body: `${plural(f.stalled.length, 'candidate')} at 80+ screened but not moved. This is where good hires quietly disappear.`,
|
||
},
|
||
f.missingCredentials.length && {
|
||
tone: 'risk',
|
||
title: 'Compliance exposure',
|
||
body: `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`,
|
||
},
|
||
f.narrowAvailability.length && {
|
||
tone: 'warning',
|
||
title: 'Availability risk',
|
||
body: `${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none — most likely to fall through on the day.`,
|
||
},
|
||
f.starvedPositions.length && {
|
||
tone: 'warning',
|
||
title: 'Unfillable as posted',
|
||
body: `${plural(f.starvedPositions.length, 'open role')} with zero applicants after going live.`,
|
||
},
|
||
f.flagged.length && {
|
||
tone: 'risk',
|
||
title: 'Interview integrity',
|
||
body: `${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`,
|
||
},
|
||
].filter(Boolean);
|
||
|
||
if (!risks.length) {
|
||
return answer('No material risks: coverage is good, no role depends on a single candidate, and credentials check out.');
|
||
}
|
||
|
||
return doc(
|
||
heading(`${plural(risks.length, 'risk')} to watch`),
|
||
insights(risks),
|
||
note('Flags to check, not conclusions. Each is a question for a human.')
|
||
);
|
||
};
|
||
|
||
/* ── Candidates ─────────────────────────────────────────────────────────── */
|
||
|
||
const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability'];
|
||
|
||
const resumeAnalysis = (f) => {
|
||
const c = f.top[0];
|
||
if (!c) return answer('No scored candidates yet. Screen the pipeline and I will analyse the strongest.');
|
||
|
||
return doc(
|
||
heading(c.applicant_name, `${c.job_title} · ${plural(c.years_experience, 'year')} · ${c.english_level} English`),
|
||
kpis([
|
||
{ label: 'AI score', value: `${c.ai_score}`, tone: c.ai_score >= 80 ? 'success' : c.ai_score >= 60 ? 'info' : 'warning' },
|
||
{ label: 'Verdict', value: c.ai_match_label || '—' },
|
||
]),
|
||
c.ai_summary && text(c.ai_summary),
|
||
c.certifications?.length
|
||
? [heading('Credentials'), badges(c.certifications.map((label) => ({ label, tone: 'success' })))]
|
||
: [heading('Credentials'), badges([{ label: 'None on file', tone: 'risk' }])],
|
||
heading('Score breakdown'),
|
||
meters(DIMENSIONS.map((d) => ({ label: d[0].toUpperCase() + d.slice(1), value: c.score_breakdown?.[d] ?? 0 }))),
|
||
c.ai_strengths?.length && [heading('Strengths'), list(c.ai_strengths)],
|
||
c.ai_gaps?.length && [heading('Probe these'), list(c.ai_gaps)],
|
||
note(
|
||
c.certifications?.length
|
||
? 'Verify credentials before the first shift — a lapsed card is the most common surprise.'
|
||
: 'Request credentials before scheduling.'
|
||
)
|
||
);
|
||
};
|
||
|
||
const candidateComparison = (f) => {
|
||
const [a, b] = f.ranked;
|
||
if (!a || !b) return answer('I need at least two scored candidates to compare.');
|
||
|
||
const gap = a.ai_score - b.ai_score;
|
||
const first = (n) => n.split(' ')[0];
|
||
|
||
const rows = DIMENSIONS.map((d) => {
|
||
const av = a.score_breakdown?.[d] ?? 0;
|
||
const bv = b.score_breakdown?.[d] ?? 0;
|
||
return {
|
||
dimension: d[0].toUpperCase() + d.slice(1),
|
||
a: { value: av, tone: av > bv ? 'success' : av < bv ? 'neutral' : undefined },
|
||
b: { value: bv, tone: bv > av ? 'success' : bv < av ? 'neutral' : undefined },
|
||
};
|
||
});
|
||
|
||
const biggest = DIMENSIONS.reduce((best, d) =>
|
||
Math.abs((a.score_breakdown?.[d] ?? 0) - (b.score_breakdown?.[d] ?? 0)) >
|
||
Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, DIMENSIONS[0]);
|
||
|
||
return doc(
|
||
heading(`${a.applicant_name} vs ${b.applicant_name}`),
|
||
table(
|
||
[
|
||
{ key: 'dimension', label: '' },
|
||
{ key: 'a', label: first(a.applicant_name), align: 'right' },
|
||
{ key: 'b', label: first(b.applicant_name), align: 'right' },
|
||
],
|
||
[
|
||
{ dimension: 'Overall', a: { value: a.ai_score, tone: 'info' }, b: { value: b.ai_score, tone: 'info' } },
|
||
...rows,
|
||
],
|
||
{ caption: 'Score comparison by dimension' }
|
||
),
|
||
heading('Where each one wins'),
|
||
insights([
|
||
{
|
||
tone: 'success',
|
||
title: `${first(a.applicant_name)} leads on ${biggest}`,
|
||
body: a.ai_strengths?.[0] || `Scores ${a.score_breakdown?.[biggest] ?? 0} against ${b.score_breakdown?.[biggest] ?? 0}.`,
|
||
},
|
||
b.ai_strengths?.[0] && {
|
||
tone: 'info',
|
||
title: `${first(b.applicant_name)} brings`,
|
||
body: b.ai_strengths[0],
|
||
},
|
||
]),
|
||
text(
|
||
gap <= 3
|
||
? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
|
||
: `${a.applicant_name} leads by ${gap} points, driven mostly by ${biggest}.`
|
||
),
|
||
note('Recommendation only. Neither has been met in person.')
|
||
);
|
||
};
|
||
|
||
const interviewQuestions = (f) => {
|
||
const c = f.stalled[0] || f.ranked[0];
|
||
if (!c) return answer('Screen a candidate and I will build questions around their specific gaps.');
|
||
|
||
const gaps = c.ai_gaps || [];
|
||
|
||
return doc(
|
||
heading(`Questions for ${c.applicant_name}`, 'Built from their gaps, not a generic list'),
|
||
list([
|
||
`Walk me through your busiest ${c.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
|
||
gaps[0]
|
||
? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?`
|
||
: 'Tell me about a shift that went wrong. What was your part in it?',
|
||
'Something breaks mid-service and your lead is unreachable. What are your next three moves?',
|
||
c.certifications?.length
|
||
? `Your ${c.certifications[0]} is on file — when did you last apply it on a live shift?`
|
||
: 'Which certifications are you working toward, and why that one?',
|
||
'What does your real availability look like over the next eight weeks?',
|
||
], { ordered: true }),
|
||
note('The scenario question is the one that separates candidates. Let them think.')
|
||
);
|
||
};
|
||
|
||
const skillGapAnalysis = (f) => {
|
||
if (!f.scored.length) return answer('No scored candidates yet, so there are no gaps to analyse.');
|
||
|
||
const tally = {};
|
||
f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => {
|
||
const key = gap.replace(/^Missing certifications?:\s*/i, 'Missing credential: ');
|
||
tally[key] = (tally[key] || 0) + 1;
|
||
}));
|
||
|
||
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||
if (!ranked.length) return answer('No recurring gaps — the pool matches your postings well.');
|
||
|
||
return doc(
|
||
heading('Recurring gaps', `Across ${plural(f.scored.length, 'scored candidate')}`),
|
||
table(
|
||
[{ key: 'gap', label: 'Gap' }, { key: 'count', label: 'Candidates', align: 'right' }],
|
||
ranked.map(([gap, count]) => ({
|
||
gap,
|
||
count: { value: `${count}/${f.scored.length}`, tone: count > f.scored.length / 2 ? 'risk' : undefined },
|
||
}))
|
||
),
|
||
text(
|
||
f.missingCredentials.length >= 2
|
||
? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
|
||
: 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
|
||
)
|
||
);
|
||
};
|
||
|
||
const hiringRecommendation = (f) => {
|
||
if (!f.scored.length) return answer('Nothing scored yet, so I have no basis for a recommendation.');
|
||
|
||
const open = (a) => !['hired', 'rejected'].includes(a.status);
|
||
const groups = [
|
||
{ verdict: 'Shortlist', tone: 'success', people: f.ranked.filter((a) => a.ai_score >= 80 && open(a)) },
|
||
{ verdict: 'Interview', tone: 'info', people: f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a)) },
|
||
{ verdict: 'Likely pass', tone: 'risk', people: f.weak.filter(open) },
|
||
].filter((g) => g.people.length);
|
||
|
||
if (!groups.length) return answer('Every scored candidate has already been actioned.');
|
||
|
||
return doc(
|
||
heading('Recommendation'),
|
||
table(
|
||
[
|
||
{ key: 'name', label: 'Candidate' },
|
||
{ key: 'score', label: 'Score', align: 'right' },
|
||
{ key: 'verdict', label: 'Verdict', align: 'right' },
|
||
],
|
||
groups.flatMap((g) => g.people.map((p) => ({
|
||
name: p.applicant_name,
|
||
score: { value: p.ai_score, tone: g.tone },
|
||
verdict: { badge: g.verdict, tone: g.tone },
|
||
})))
|
||
),
|
||
note('A recommendation, not a decision. I score what is on file — I have not met these people.')
|
||
);
|
||
};
|
||
|
||
/* ── Analytics ──────────────────────────────────────────────────────────── */
|
||
|
||
const explainCharts = (f) => doc(
|
||
heading('What these charts are saying'),
|
||
insights([
|
||
{
|
||
tone: 'info',
|
||
title: 'Pipeline Stages',
|
||
body: `${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
|
||
},
|
||
{
|
||
tone: 'info',
|
||
title: 'Candidate Scores',
|
||
body: `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length} — that bucket is your real hiring pool.`,
|
||
},
|
||
{
|
||
tone: f.interviewCompletion >= 70 ? 'success' : 'warning',
|
||
title: 'Interview Completion',
|
||
body: `${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
|
||
},
|
||
{
|
||
tone: 'success',
|
||
title: 'Cost Saved',
|
||
body: `$${f.costSaved.toLocaleString()} from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80 — industry per-unit costs for doing it manually, so treat it as a floor.`,
|
||
},
|
||
]),
|
||
f.bottleneck && [
|
||
heading('Biggest drop-off'),
|
||
funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
|
||
text(`Most people are lost between **${f.bottleneck.from}** and **${f.bottleneck.to}** — only ${f.bottleneck.rate}% pass through, costing ${plural(f.bottleneck.lost, 'candidate')}.`),
|
||
]
|
||
);
|
||
|
||
const forecastHiring = (f) => {
|
||
const available = f.ranked.filter((a) => a.ai_score >= 60 && !['hired', 'rejected'].includes(a.status));
|
||
const conversion = f.hireRate || 14;
|
||
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
|
||
const short = f.openPositions.length - expected;
|
||
|
||
return doc(
|
||
heading('Forecast'),
|
||
kpis([
|
||
{ label: 'In play at 60+', value: available.length },
|
||
{ label: 'Projected hires', value: expected, tone: short > 0 ? 'warning' : 'success' },
|
||
{ label: 'Open roles', value: f.openPositions.length },
|
||
{ label: 'Conversion', value: `${conversion}%` },
|
||
]),
|
||
short > 0
|
||
? insights([{
|
||
tone: 'warning',
|
||
title: `Roughly ${plural(short, 'role')} short`,
|
||
body: `Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`,
|
||
}])
|
||
: insights([{
|
||
tone: 'success',
|
||
title: 'Current pool covers your open roles',
|
||
body: 'Assuming the strong candidates do not go elsewhere first.',
|
||
}]),
|
||
note('Projected from your own conversion rate, not a promise — small pools move a lot.')
|
||
);
|
||
};
|
||
|
||
const departmentInsights = (f) => {
|
||
if (!f.byRole.length) return answer('No open positions to break down.');
|
||
|
||
const byCategory = {};
|
||
f.byRole.forEach((r) => {
|
||
byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: 0 };
|
||
byCategory[r.category].applied += r.applied;
|
||
byCategory[r.category].hired += r.hired;
|
||
byCategory[r.category].qualified += r.qualified;
|
||
byCategory[r.category].roles += 1;
|
||
});
|
||
|
||
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
|
||
const starved = entries.filter(([, d]) => d.applied === 0);
|
||
|
||
return doc(
|
||
heading('By role category'),
|
||
table(
|
||
[
|
||
{ key: 'category', label: 'Category' },
|
||
{ key: 'applied', label: 'Applied', align: 'right' },
|
||
{ key: 'qualified', label: '70+', align: 'right' },
|
||
{ key: 'hired', label: 'Hired', align: 'right' },
|
||
],
|
||
entries.map(([category, d]) => ({
|
||
category,
|
||
applied: { value: d.applied, tone: d.applied === 0 ? 'risk' : undefined },
|
||
qualified: d.qualified,
|
||
hired: d.hired,
|
||
}))
|
||
),
|
||
text(
|
||
starved.length
|
||
? `**${starved.map(([c]) => c).join(' and ')}** ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
|
||
: 'Every category has applicants, so your reach is working across the board.'
|
||
)
|
||
);
|
||
};
|
||
|
||
const generateReports = (f) => doc(
|
||
// Local date, not toISOString — UTC would show yesterday for anyone behind it.
|
||
heading('Hiring report', f.today.toLocaleDateString(undefined, {
|
||
year: 'numeric', month: 'long', day: 'numeric',
|
||
})),
|
||
kpis([
|
||
{ label: 'Applicants', value: f.total },
|
||
{ label: 'Screened', value: `${f.standardizedPct}%` },
|
||
{ label: 'Hires', value: f.hired.length },
|
||
{ label: 'Hire rate', value: `${f.hireRate}%` },
|
||
{ label: 'Avg hire score', value: f.hiredAvgScore || '—', tone: 'success' },
|
||
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' },
|
||
]),
|
||
heading('Funnel'),
|
||
funnel(f.funnel.map((s, i) => ({ label: s.label, count: s.count, rate: i === 0 ? undefined : f.transitions[i - 1]?.rate }))),
|
||
heading('By role'),
|
||
table(
|
||
[
|
||
{ key: 'title', label: 'Role' },
|
||
{ key: 'applied', label: 'Applied', align: 'right' },
|
||
{ key: 'qualified', label: '70+', align: 'right' },
|
||
{ key: 'hired', label: 'Hired', align: 'right' },
|
||
],
|
||
f.byRole.map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified, hired: r.hired }))
|
||
),
|
||
heading('Efficiency'),
|
||
kpis([
|
||
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
|
||
{ label: 'Recruiter hours', value: `${f.hoursSaved}h`, tone: 'success' },
|
||
]),
|
||
heading('Headline finding'),
|
||
insights([
|
||
f.bottleneck
|
||
? {
|
||
tone: 'warning',
|
||
title: `${f.bottleneck.from} → ${f.bottleneck.to} passes only ${f.bottleneck.rate}%`,
|
||
body: `Losing ${plural(f.bottleneck.lost, 'candidate')} — the single biggest recoverable loss in the funnel.`,
|
||
}
|
||
: { tone: 'success', title: 'No single bottleneck', body: 'The funnel is passing through evenly.' },
|
||
]),
|
||
buildActions(f).length && [heading('Recommended actions'), actions(buildActions(f).slice(0, 3))],
|
||
note('Copy this into your own template — PDF and CSV export are not wired up in this build.')
|
||
);
|
||
|
||
/* ── Free-text responders ───────────────────────────────────────────────── */
|
||
|
||
const has = (q, ...words) => words.some((w) => q.includes(w));
|
||
|
||
const stateOfPlay = (f) => doc(
|
||
text('I do not have a specific read on that. Here is where things stand:'),
|
||
kpis([
|
||
{ label: 'Applicants', value: f.total },
|
||
{ label: 'Screened', value: f.screened.length },
|
||
{ label: 'Interviewing', value: f.interviewing.length },
|
||
{ label: 'Hired', value: f.hired.length },
|
||
]),
|
||
text('Ask me about any of those and I will go deeper.')
|
||
);
|
||
|
||
const bottleneckAnswer = (f) => {
|
||
if (!f.bottleneck) return answer('Not enough pipeline movement yet to locate a bottleneck.');
|
||
const b = f.bottleneck;
|
||
const diagnosis = {
|
||
'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap to close.`,
|
||
Shortlisted: 'Candidates are screened but not shortlisted. Either the scores are not being read, or the bar is above what the pool can deliver.',
|
||
Interviewed: 'Shortlisted candidates are not reaching interview — usually scheduling friction rather than a decision.',
|
||
Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.',
|
||
}[b.to];
|
||
|
||
return doc(
|
||
heading(`Bottleneck: ${b.from} → ${b.to}`, `${b.rate}% pass through`),
|
||
funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
|
||
insights([{ tone: 'warning', title: `Losing ${plural(b.lost, 'candidate')} here`, body: diagnosis }])
|
||
);
|
||
};
|
||
|
||
export const respondOverview = (question, f) => {
|
||
const q = question.toLowerCase();
|
||
if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f);
|
||
if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next', 'slow')) return pendingActions(f);
|
||
if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(f);
|
||
if (has(q, 'summar', 'today', 'brief', 'catch me up', 'overview', 'morning', 'afternoon', 'evening')) return hiringSummary(f);
|
||
if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f);
|
||
if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f);
|
||
return stateOfPlay(f);
|
||
};
|
||
|
||
export const respondCandidates = (question, f) => {
|
||
const q = question.toLowerCase();
|
||
if (has(q, 'compare', 'versus', ' vs ', 'between', '&')) return candidateComparison(f);
|
||
if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(f);
|
||
if (has(q, 'gap', 'missing', 'weakness', 'skill')) return skillGapAnalysis(f);
|
||
if (has(q, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f);
|
||
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze', 'review')) return resumeAnalysis(f);
|
||
if (has(q, 'unscreened', 'not screened', 'pending')) {
|
||
return f.unscreened.length
|
||
? doc(
|
||
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
|
||
table(
|
||
[{ key: 'name', label: 'Candidate' }, { key: 'role', label: 'Applied for' }],
|
||
f.unscreened.slice(0, 8).map((a) => ({ name: a.applicant_name, role: a.job_title }))
|
||
),
|
||
f.unscreened.length > 8 && text(`…and ${f.unscreened.length - 8} more.`)
|
||
)
|
||
: answer('Everyone has been screened.');
|
||
}
|
||
return stateOfPlay(f);
|
||
};
|
||
|
||
export const respondAnalytics = (question, f) => {
|
||
const q = question.toLowerCase();
|
||
if (has(q, 'forecast', 'predict', 'projection', 'will i', 'expect')) return forecastHiring(f);
|
||
if (has(q, 'report', 'export', 'executive', 'download')) return generateReports(f);
|
||
if (has(q, 'bottleneck', 'drop', 'stuck', 'lose', 'why do')) return bottleneckAnswer(f);
|
||
if (has(q, 'department', 'category', 'role', 'team', 'break down')) return departmentInsights(f);
|
||
if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f);
|
||
if (has(q, 'hire rate', 'conversion')) {
|
||
return answer(`${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`);
|
||
}
|
||
if (has(q, 'cost', 'saved', 'roi', 'money')) {
|
||
return doc(
|
||
kpis([
|
||
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
|
||
{ label: 'Hours returned', value: `${f.hoursSaved}h`, tone: 'success' },
|
||
]),
|
||
text(`From ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each.`)
|
||
);
|
||
}
|
||
return stateOfPlay(f);
|
||
};
|
||
|
||
/* ── Capability manifests ───────────────────────────────────────────────── */
|
||
|
||
export const OVERVIEW_CAPABILITIES = [
|
||
{ id: 'hiring-summary', label: 'Hiring Summary', icon: Sparkles, run: hiringSummary },
|
||
{ id: 'hiring-health', label: 'Hiring Health', icon: HeartPulse, run: hiringHealth },
|
||
{ id: 'pending-actions', label: 'Pending Actions', icon: ListChecks, run: pendingActions },
|
||
{ id: 'hiring-risks', label: 'Hiring Risks', icon: AlertTriangle, run: hiringRisks },
|
||
];
|
||
|
||
export const CANDIDATE_CAPABILITIES = [
|
||
{ id: 'resume-analysis', label: 'Resume Analysis', icon: FileText, run: resumeAnalysis },
|
||
{ id: 'candidate-comparison', label: 'Candidate Comparison', icon: GitCompare, run: candidateComparison },
|
||
{ id: 'interview-questions', label: 'Interview Question Generator', icon: MessageSquareQuote, run: interviewQuestions },
|
||
{ id: 'skill-gap', label: 'Skill Gap Analysis', icon: Target, run: skillGapAnalysis },
|
||
{ id: 'hiring-recommendation', label: 'Hiring Recommendation', icon: ClipboardCheck, run: hiringRecommendation },
|
||
];
|
||
|
||
export const ANALYTICS_CAPABILITIES = [
|
||
{ id: 'explain-charts', label: 'Explain Charts', icon: BarChart3, run: explainCharts },
|
||
{ id: 'forecast-hiring', label: 'Forecast Hiring', icon: LineChart, run: forecastHiring },
|
||
{ id: 'department-insights', label: 'Department Insights', icon: Layers, run: departmentInsights },
|
||
{ id: 'generate-reports', label: 'Generate Reports', icon: FileSpreadsheet, run: generateReports },
|
||
];
|