Files
doormilxpress_astryx/src/components/ai-assistant/capabilities/employer.js
2026-08-07 23:53:15 +05:30

463 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
HeartPulse, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target, Layers,
} from 'lucide-react';
import { bullets, compose, heading, numbered, 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.
*/
/* ── Overview ───────────────────────────────────────────────────────────── */
const hiringSummary = (f) => {
if (!f.total) return 'Nothing in the pipeline yet. Publish a position and I will start tracking it here.';
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`,
]),
'',
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.'
);
};
const hiringHealth = (f) => {
const checks = [
{
label: 'Screening coverage',
value: `${f.standardizedPct}%`,
ok: f.standardizedPct >= 80,
note: f.standardizedPct >= 80
? 'Applicants are being 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 compose(
heading(`${passing} of ${checks.length} signals healthy`),
'',
checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.value}\n ${c.note}`).join('\n\n')
);
};
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.`);
}
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)));
};
const hiringRisks = (f) => {
const risks = [];
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 'No material risks in the current pipeline: coverage is good, no role depends on a single candidate, and credentials check out.';
return compose(
heading(`${plural(risks.length, 'risk')} to watch`),
'',
numbered(risks),
'',
'_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.';
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 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] = pair;
const gap = a.ai_score - b.ai_score;
const dims = ['experience', 'english', 'reliability', 'certifications', 'availability'];
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 biggest = dims.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]);
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}.`
);
};
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 gaps = subject.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?`,
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?`
: 'Which certifications are you working toward, and why that one?',
'What does your real availability look like over the next eight weeks?',
])
);
};
const skillGapAnalysis = (f) => {
if (!f.scored.length) return '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 'No recurring gaps across your scored candidates — 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.'
);
};
const hiringRecommendation = (f) => {
if (!f.scored.length) return '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);
return compose(
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._'
);
};
/* ── Analytics ──────────────────────────────────────────────────────────── */
const explainCharts = (f) => compose(
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.`
);
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 compose(
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`,
]),
'',
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._'
);
};
const departmentInsights = (f) => {
if (!f.byRole.length) return '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 += r.applied;
byCategory[r.category].hired += r.hired;
byCategory[r.category].qualified += r.qualified;
byCategory[r.category].roles.push(r.title);
});
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
const starved = entries.filter(([, d]) => d.applied === 0);
return compose(
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.'
);
};
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`)),
'',
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._'
);
};
/* ── 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 bottleneckAnswer = (f) => {
if (!f.bottleneck) return '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.`,
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})` : ''}`))
);
};
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, '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, '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, 'resume', 'résumé', 'cv', 'analyse', 'analyze')) return resumeAnalysis(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, 'unscreened', 'not screened', 'pending')) {
return f.unscreened.length
? compose(
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` : ''
)
: 'Everyone has been screened.';
}
return stateOfPlay(f);
};
export const respondAnalytics = (question, f) => {
const q = question.toLowerCase();
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.`;
}
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 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 },
];