update chatbox
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user