512 lines
29 KiB
JavaScript
512 lines
29 KiB
JavaScript
import { plural, verb } from './insights';
|
||
|
||
/**
|
||
* Dynamic greetings and suggested prompts.
|
||
*
|
||
* Nothing here is a fixed string chosen in advance. The greeting states what is
|
||
* actually true on the page right now, and the suggestions name real records —
|
||
* the specific candidates, the specific starved role. That is the difference
|
||
* between an assistant that looks aware of context and one that is.
|
||
*
|
||
* Everything derives from the same fact sheet the dashboard renders from, so a
|
||
* greeting can never contradict the numbers beside it.
|
||
*/
|
||
|
||
/* ── Greetings ──────────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Time of day from the viewer's own clock.
|
||
*
|
||
* `getHours()` is local, so this follows the user's timezone rather than the
|
||
* server's. The late band matters: at 00:30 "morning" is wrong, so 21:00–04:59
|
||
* reads as evening.
|
||
*
|
||
* 05:00–11:59 morning
|
||
* 12:00–16:59 afternoon
|
||
* 17:00–20:59 evening
|
||
* 21:00–04:59 evening
|
||
*/
|
||
function timeOfDay(date) {
|
||
const hour = date.getHours();
|
||
if (hour >= 5 && hour < 12) return 'morning';
|
||
if (hour >= 12 && hour < 17) return 'afternoon';
|
||
return 'evening';
|
||
}
|
||
|
||
/**
|
||
* Picks the single most useful thing to say about the page.
|
||
*
|
||
* Ordered by urgency rather than by what sounds cheeriest: something stuck beats
|
||
* something healthy, because the greeting is the one line guaranteed to be read.
|
||
*/
|
||
const GREETINGS = {
|
||
'employer.overview': (f) => {
|
||
if (!f.total) return 'No applicants yet — publish a position and I will start tracking it.';
|
||
if (f.stalled.length) {
|
||
return `${plural(f.stalled.length, 'strong candidate')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`;
|
||
}
|
||
if (f.unscreened.length) {
|
||
return `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'is', 'are')} waiting to be screened.`;
|
||
}
|
||
if (f.starvedPositions.length) {
|
||
return `${plural(f.starvedPositions.length, 'open role')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants yet.`;
|
||
}
|
||
return 'Your hiring pipeline looks healthy today.';
|
||
},
|
||
|
||
'employer.candidates': (f) => {
|
||
if (!f.total) return 'No candidates in the pipeline yet.';
|
||
if (f.unscreened.length) {
|
||
return `You have ${plural(f.unscreened.length, 'candidate')} waiting for review.`;
|
||
}
|
||
if (f.stalled.length) {
|
||
return `${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'is', 'are')} ready for a decision.`;
|
||
}
|
||
return `All ${f.total} candidates are scored and actioned.`;
|
||
},
|
||
|
||
'employer.analytics': (f) => {
|
||
if (!f.scored.length) return 'Nothing scored yet, so the charts are still empty.';
|
||
if (f.bottleneck && f.bottleneck.lost > 0) {
|
||
return `Your funnel loses most people between ${f.bottleneck.from} and ${f.bottleneck.to}.`;
|
||
}
|
||
return `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. Worth a closer look.`;
|
||
},
|
||
|
||
'admin.controlCenter': (f) => {
|
||
if (f.unscoredProfiles.length) {
|
||
return `${plural(f.unscoredProfiles.length, 'talent profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no score and cannot be matched.`;
|
||
}
|
||
if (f.activity24h.length) {
|
||
return `${plural(f.activity24h.length, 'event')} logged in the last 24 hours.`;
|
||
}
|
||
return `Platform is steady across ${plural(f.postings.length, 'position')} and ${plural(f.total, 'application')}.`;
|
||
},
|
||
|
||
'admin.positions': (f) => {
|
||
if (!f.openPositions.length) return 'No open positions yet — publish a role and I will start tracking it.';
|
||
if (f.starvedPositions.length) {
|
||
return `${plural(f.starvedPositions.length, 'open position')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants and ${verb(f.starvedPositions.length, 'needs', 'need')} attention.`;
|
||
}
|
||
if (f.singleCandidateRoles.length) {
|
||
return `${plural(f.singleCandidateRoles.length, 'role')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on a single viable candidate.`;
|
||
}
|
||
if (f.unscreened.length) {
|
||
return `${plural(f.unscreened.length, 'applicant')} across your roles ${verb(f.unscreened.length, 'is', 'are')} waiting to be screened.`;
|
||
}
|
||
return `All ${f.openPositions.length} open roles have candidates and a moving pipeline.`;
|
||
},
|
||
|
||
'admin.candidatesList': (f) => {
|
||
if (!f.total) return 'No candidates in the pipeline yet.';
|
||
if (f.stalled.length) {
|
||
return `${plural(f.stalled.length, 'strong candidate')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on a decision.`;
|
||
}
|
||
if (f.unscreened.length) {
|
||
return `${plural(f.unscreened.length, 'candidate')} ${verb(f.unscreened.length, 'has', 'have')} no score and cannot be ranked.`;
|
||
}
|
||
if (f.missingCredentials.length) {
|
||
return `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no credentials on file.`;
|
||
}
|
||
return `All ${f.total} candidates are scored and actioned.`;
|
||
},
|
||
|
||
'admin.candidates': (f) => {
|
||
if (!f.total) return 'Nothing in the pipeline yet, so there is no pool to analyse.';
|
||
if (f.needsDeeperReview.length) {
|
||
return `${plural(f.needsDeeperReview.length, 'candidate')} currently ${verb(f.needsDeeperReview.length, 'needs', 'need')} deeper review.`;
|
||
}
|
||
if (f.singleCandidateRoles.length) {
|
||
return `${plural(f.singleCandidateRoles.length, 'role')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on a single viable candidate.`;
|
||
}
|
||
return `All ${f.total} candidates are scored, credentialed and comparable.`;
|
||
},
|
||
|
||
'admin.analytics': (f) => {
|
||
if (!f.total) return 'Nothing has been applied for yet, so the charts have nothing to report.';
|
||
if (f.bottleneck && f.bottleneck.lost > 0) {
|
||
return `Conversion is weakest between ${f.bottleneck.from} and ${f.bottleneck.to}, at ${f.bottleneck.rate}%.`;
|
||
}
|
||
if (f.standardizedPct < 80) {
|
||
return `Screening covers ${f.standardizedPct}% of applications, so the rest of these figures rest on a partial pool.`;
|
||
}
|
||
return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`;
|
||
},
|
||
|
||
'admin.forge': (f) => {
|
||
const g = f.forge;
|
||
if (!g.courses.length) return 'No challenges published yet, so nothing can be proven here.';
|
||
if (g.locked.length && !g.ready.length) {
|
||
return `${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} gated by real-work requirements.`;
|
||
}
|
||
if (!g.verified.length) {
|
||
return `Nothing verified yet — ${plural(g.ready.length, 'challenge')} ${verb(g.ready.length, 'is', 'are')} ready to start.`;
|
||
}
|
||
return `${plural(g.verified.length, 'skill')} verified, ${plural(g.ready.length, 'challenge')} ready to start.`;
|
||
},
|
||
|
||
'admin.talentPool': (f) => {
|
||
if (!f.profiles.length) return 'No talent profiles registered yet.';
|
||
if (f.unscoredProfiles.length) {
|
||
return `${plural(f.unscoredProfiles.length, 'profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no career score and cannot be ranked.`;
|
||
}
|
||
if (f.talent.unverified.length) {
|
||
return `${plural(f.talent.unverified.length, 'profile')} ${verb(f.talent.unverified.length, 'carries', 'carry')} no verified skill.`;
|
||
}
|
||
return `${plural(f.profiles.length, 'worker')} in the pool, ${f.elite.length} Elite.`;
|
||
},
|
||
|
||
'admin.hiredHistory': (f) => {
|
||
const h = f.hiring;
|
||
if (!h.hires.length) return 'Nobody has been hired yet, so there is no history to read.';
|
||
if (h.unrated.length) {
|
||
return `${plural(h.unrated.length, 'hire')} ${verb(h.unrated.length, 'has', 'have')} no client rating on file.`;
|
||
}
|
||
return `${plural(h.hires.length, 'hire')} placed, averaging ${h.avgQuality} at hire.`;
|
||
},
|
||
|
||
'admin.activity': (f) => {
|
||
if (!f.activity.length) return 'No activity logged yet.';
|
||
const patterns = f.activitySignals.flags.length;
|
||
if (patterns) {
|
||
return `${plural(patterns, 'unusual activity pattern')} ${verb(patterns, 'needs', 'need')} attention.`;
|
||
}
|
||
if (f.activity24h.length === 0) {
|
||
return `Quiet week — ${plural(f.activity7d.length, 'event')} logged, nothing out of pattern.`;
|
||
}
|
||
return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week, nothing out of pattern.`;
|
||
},
|
||
|
||
};
|
||
|
||
/**
|
||
* Title lines.
|
||
*
|
||
* Every Admin context greets by the clock. That is not decoration: the greeting
|
||
* is the one line guaranteed to be read, so it has to prove the panel knows when
|
||
* and where it is before it says anything about the data. A fixed "Good morning"
|
||
* on an 11pm shift does the opposite.
|
||
*
|
||
* First names read better than a formal greeting in a small panel, so the name is
|
||
* used where one is known.
|
||
*/
|
||
const TITLES = {
|
||
'employer.overview': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||
'employer.candidates': () => 'Candidate intelligence',
|
||
'employer.analytics': () => 'Analytics assistant',
|
||
'admin.controlCenter': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||
'admin.positions': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||
'admin.candidatesList': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||
'admin.analytics': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||
'admin.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||
'admin.forge': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||
'admin.talentPool': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||
'admin.hiredHistory': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||
};
|
||
|
||
/** Descriptions state what this page's assistant can actually do with the data. */
|
||
const DESCRIPTIONS = {
|
||
'employer.overview': (f) =>
|
||
`Reading ${f.total} applicants across ${plural(f.openPositions.length, 'open role')}.`,
|
||
'employer.candidates': (f) => `All ${f.total} candidates and their score breakdowns.`,
|
||
'employer.analytics': () => 'Every figure on this page, explained or forecast.',
|
||
'admin.controlCenter': (f) =>
|
||
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — health, operations and what needs intervention.`,
|
||
'admin.positions': (f) =>
|
||
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — hiring health, pipeline strength and what needs intervention.`,
|
||
'admin.candidatesList': (f) =>
|
||
`Reading ${plural(f.total, 'candidate')} across ${plural(f.postings.length, 'position')} — match quality, screening and hiring readiness.`,
|
||
'admin.candidates': (f) =>
|
||
`${plural(f.total, 'candidate')} and ${plural(f.profiles.length, 'talent profile')} — supply, quality and risk.`,
|
||
'admin.analytics': (f) =>
|
||
`Reading hiring performance across ${plural(f.openPositions.length, 'position')}, ${plural(f.total, 'candidate')} and ${plural(new Set(f.byRole.map((r) => r.category)).size, 'department')} — conversion, speed and outcomes.`,
|
||
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
|
||
'admin.forge': (f) =>
|
||
`Reading ${plural(f.forge.courses.length, 'challenge')} — verified skills, what is ready to start and what is still locked.`,
|
||
'admin.talentPool': (f) =>
|
||
`Reading ${plural(f.profiles.length, 'talent profile')} — availability, career score and verification.`,
|
||
'admin.hiredHistory': (f) =>
|
||
`Reading ${plural(f.hiring.hires.length, 'hire')} across ${plural(f.hiring.byDepartment.length, 'department')} — quality, speed and outcomes.`,
|
||
};
|
||
|
||
/**
|
||
* Placeholders phrased as things this page's user would actually type.
|
||
*
|
||
* The Admin ones name Owliver and name the page's subject, because the panel sits
|
||
* permanently beside the dashboard: someone glancing at it needs the input itself
|
||
* to say what it is for and what this page can be asked about.
|
||
*/
|
||
const PLACEHOLDERS = {
|
||
'employer.overview': (f) => [
|
||
`Summarize ${timeOfDay(f.today)}'s hiring…`,
|
||
f.starvedPositions.length ? 'Which positions need attention?' : 'What should I do first?',
|
||
'Why did hiring slow this week?',
|
||
],
|
||
'employer.candidates': (f) => [
|
||
f.ranked.length >= 2 ? 'Compare the top candidates…' : 'Analyse my strongest candidate…',
|
||
'Which resumes need review?',
|
||
'Recommend interview questions…',
|
||
],
|
||
'employer.analytics': () => [
|
||
'Explain this chart…',
|
||
'Forecast hiring…',
|
||
'Generate an executive summary…',
|
||
],
|
||
'admin.controlCenter': () => [
|
||
'Ask Owliver about platform health…',
|
||
'Ask Owliver what needs attention…',
|
||
'Ask Owliver about hiring bottlenecks…',
|
||
],
|
||
'admin.positions': () => [
|
||
'Ask Owliver about your positions…',
|
||
'Ask Owliver which role to fill first…',
|
||
'Ask Owliver about hiring bottlenecks…',
|
||
],
|
||
'admin.candidatesList': () => [
|
||
'Ask Owliver about candidates…',
|
||
'Ask Owliver who is interview ready…',
|
||
'Ask Owliver about candidate risks…',
|
||
],
|
||
'admin.candidates': () => [
|
||
'Ask Owliver about candidates…',
|
||
'Ask Owliver about screening gaps…',
|
||
'Ask Owliver where the risk is…',
|
||
],
|
||
'admin.analytics': () => [
|
||
'Ask Owliver about hiring performance…',
|
||
'Ask Owliver about the hiring trend…',
|
||
'Ask Owliver where the bottleneck is…',
|
||
],
|
||
'admin.activity': () => [
|
||
'Ask Owliver about recent activity…',
|
||
'Ask Owliver about unusual activity…',
|
||
'Ask Owliver about security concerns…',
|
||
],
|
||
'admin.forge': () => [
|
||
'Ask Owliver which challenge to take next…',
|
||
'Ask Owliver what is blocking a skill…',
|
||
'Ask Owliver about Forge progress…',
|
||
],
|
||
'admin.talentPool': () => [
|
||
'Ask Owliver who to prioritize…',
|
||
'Ask Owliver about verification gaps…',
|
||
'Ask Owliver who is available…',
|
||
],
|
||
'admin.hiredHistory': () => [
|
||
'Ask Owliver about hiring outcomes…',
|
||
'Ask Owliver which roles hired best…',
|
||
'Ask Owliver what stands out…',
|
||
],
|
||
};
|
||
|
||
/* ── Suggested prompts ──────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Builds suggestions from what is actually on the page.
|
||
*
|
||
* A suggestion that names two real candidates is a different product from one
|
||
* that says "Compare candidates" — it proves the assistant already looked.
|
||
* Each carries the capability that answers it best, so a click is precise.
|
||
*/
|
||
const PROMPTS = {
|
||
'employer.overview': (f) => [
|
||
{ label: `Summarize ${timeOfDay(f.today)}'s hiring`, prompt: `Summarize hiring as of this ${timeOfDay(f.today)}`, capability: 'hiring-summary' },
|
||
f.starvedPositions.length
|
||
? { label: 'Which positions need attention?', prompt: `Which positions need attention? ${f.starvedPositions[0].title} has no applicants`, capability: 'pending-actions' }
|
||
: { label: 'What needs my attention?', prompt: 'What needs my attention right now?', capability: 'pending-actions' },
|
||
{ label: 'Pipeline health', prompt: 'How healthy is my hiring pipeline?', capability: 'hiring-health' },
|
||
f.singleCandidateRoles.length || f.missingCredentials.length
|
||
? { label: 'Where is the risk?', prompt: 'Where is the risk in my hiring right now?', capability: 'hiring-risks' }
|
||
: { label: 'Any hiring risks?', prompt: 'Are there any hiring risks I should know about?', capability: 'hiring-risks' },
|
||
],
|
||
|
||
'employer.candidates': (f) => {
|
||
const [a, b] = f.ranked;
|
||
return [
|
||
a && b
|
||
? { label: `Compare ${a.applicant_name.split(' ')[0]} & ${b.applicant_name.split(' ')[0]}`, prompt: `Compare ${a.applicant_name} and ${b.applicant_name}`, capability: 'candidate-comparison' }
|
||
: { label: 'Compare top candidates', prompt: 'Compare my top candidates', capability: 'candidate-comparison' },
|
||
f.unscreened.length
|
||
? { label: `${f.unscreened.length} resumes need review`, prompt: 'Which resumes need review?', capability: 'resume-analysis' }
|
||
: { label: 'Analyse top résumé', prompt: 'Analyse my strongest candidate', capability: 'resume-analysis' },
|
||
a
|
||
? { label: `Questions for ${a.applicant_name.split(' ')[0]}`, prompt: `Recommend interview questions for ${a.applicant_name}`, capability: 'interview-questions' }
|
||
: null,
|
||
{ label: 'Skill gaps', prompt: 'What skill gaps run across my candidates?', capability: 'skill-gap' },
|
||
{ label: 'Who should I hire?', prompt: 'Who should I hire, interview, or pass on?', capability: 'hiring-recommendation' },
|
||
].filter(Boolean);
|
||
},
|
||
|
||
'employer.analytics': (f) => [
|
||
{ label: 'Explain this chart', prompt: 'Explain the charts on this page', capability: 'explain-charts' },
|
||
{ label: 'Forecast hiring', prompt: 'Forecast hiring from the current pipeline', capability: 'forecast-hiring' },
|
||
{ label: 'Generate executive summary', prompt: 'Generate an executive hiring report', capability: 'generate-reports' },
|
||
f.bottleneck
|
||
? {
|
||
label: `Why the drop to ${f.bottleneck.to}?`,
|
||
prompt: `Why do candidates drop off between ${f.bottleneck.from} and ${f.bottleneck.to}?`,
|
||
capability: 'explain-charts',
|
||
}
|
||
: null,
|
||
{ label: 'Break down by role', prompt: 'Break down performance by role category', capability: 'department-insights' },
|
||
].filter(Boolean),
|
||
|
||
/* Admin. The first chip is whatever the data has made urgent, so the set
|
||
reorders itself as the platform's state changes rather than offering the
|
||
same five questions regardless. */
|
||
'admin.controlCenter': (f) => [
|
||
f.starvedPositions.length
|
||
? { label: `${f.starvedPositions.length} roles with no applicants`, prompt: 'What needs attention right now?', capability: 'attention-required' }
|
||
: { label: 'What needs attention?', prompt: 'What needs attention right now?', capability: 'attention-required' },
|
||
{ label: 'Platform health', prompt: 'Run a platform health check', capability: 'platform-health' },
|
||
f.bottleneck && f.bottleneck.lost > 0
|
||
? { label: `Bottleneck at ${f.bottleneck.to}`, prompt: `Show hiring bottlenecks — why do candidates drop between ${f.bottleneck.from} and ${f.bottleneck.to}?`, capability: 'pipeline-health' }
|
||
: { label: 'Show hiring bottlenecks', prompt: 'Show hiring bottlenecks in the pipeline', capability: 'pipeline-health' },
|
||
{ label: 'Summarize platform usage', prompt: 'Summarize platform usage and workforce scale', capability: 'workforce-summary' },
|
||
{ label: 'Hiring operations', prompt: 'How are hiring operations performing?', capability: 'hiring-operations' },
|
||
{ label: 'Identify operational risks', prompt: 'Identify operational risks and what I should fix first', capability: 'recommendations' },
|
||
],
|
||
|
||
'admin.positions': (f) => [
|
||
f.starvedPositions.length
|
||
? { label: `${f.starvedPositions.length} roles need attention`, prompt: 'What needs attention across my positions?', capability: 'positions-attention' }
|
||
: { label: 'What needs attention?', prompt: 'What needs attention across my positions?', capability: 'positions-attention' },
|
||
{ label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
|
||
f.unscreened.length
|
||
? { label: `${f.unscreened.length} waiting for review`, prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' }
|
||
: { label: 'Show candidates waiting for review', prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' },
|
||
{ label: 'Which position should I hire for first?', prompt: 'Which position should I hire for first?', capability: 'hiring-priority' },
|
||
{ label: 'Summarize hiring activity', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' },
|
||
{ label: 'Identify hiring bottlenecks', prompt: 'Identify hiring bottlenecks', capability: 'pipeline-health' },
|
||
],
|
||
|
||
'admin.candidatesList': (f) => [
|
||
f.stalled.length
|
||
? { label: `${f.stalled.length} waiting on a decision`, prompt: 'Who needs attention?', capability: 'candidates-attention' }
|
||
: { label: 'Who needs attention?', prompt: 'Who needs attention?', capability: 'candidates-attention' },
|
||
{ label: 'Who are the strongest candidates?', prompt: 'Who are the strongest candidates?', capability: 'top-candidates' },
|
||
{ label: 'Which candidates are ready for interview?', prompt: 'Which candidates are ready for interview?', capability: 'interview-ready' },
|
||
f.unscreened.length
|
||
? { label: `${f.unscreened.length} have no score`, prompt: 'Which candidates have no score?', capability: 'screening-gaps' }
|
||
: { label: 'Which candidates have no score?', prompt: 'Which candidates have no score?', capability: 'screening-gaps' },
|
||
{ label: 'Summarize the candidate pipeline', prompt: 'Summarize the candidate pipeline', capability: 'pipeline-summary' },
|
||
{ label: 'Identify candidate risks', prompt: 'Identify candidate risks', capability: 'candidate-risk' },
|
||
],
|
||
|
||
'admin.candidates': (f) => [
|
||
f.ranked.length >= 2
|
||
? { label: `Compare ${f.ranked[0].applicant_name.split(' ')[0]} & ${f.ranked[1].applicant_name.split(' ')[0]}`, prompt: 'Compare the top candidates in the pool', capability: 'top-candidates' }
|
||
: { label: 'Compare top candidates', prompt: 'Compare the top candidates in the pool', capability: 'top-candidates' },
|
||
f.missingCredentials.length
|
||
? { label: `${f.missingCredentials.length} missing credentials`, prompt: 'Show candidate risks across the pool', capability: 'candidate-risk' }
|
||
: { label: 'Show candidate risks', prompt: 'Show candidate risks across the pool', capability: 'candidate-risk' },
|
||
f.unscreened.length
|
||
? { label: `${f.unscreened.length} unscored applicants`, prompt: 'Find screening gaps in the candidate pool', capability: 'screening-gaps' }
|
||
: { label: 'Find screening gaps', prompt: 'Find screening gaps in the candidate pool', capability: 'screening-gaps' },
|
||
{ label: 'Recommend next actions', prompt: 'Recommend next actions for these candidates', capability: 'hiring-recommendations' },
|
||
{ label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
|
||
],
|
||
|
||
'admin.analytics': (f) => [
|
||
{ label: 'What is the hiring trend?', prompt: 'What is the hiring trend?', capability: 'hiring-trend' },
|
||
{ label: 'Which department is performing best?', prompt: 'Which department is performing best?', capability: 'department-performance' },
|
||
f.bottleneck && f.bottleneck.lost > 0
|
||
? { label: `Biggest drop at ${f.bottleneck.to}`, prompt: `Where is the biggest hiring bottleneck — why do candidates drop between ${f.bottleneck.from} and ${f.bottleneck.to}?`, capability: 'pipeline-health' }
|
||
: { label: 'Where is the biggest bottleneck?', prompt: 'Where is the biggest hiring bottleneck?', capability: 'pipeline-health' },
|
||
{ label: 'Which positions are converting best?', prompt: 'Which positions are converting best?', capability: 'position-conversion' },
|
||
{ label: 'Summarize hiring performance', prompt: 'Summarize hiring performance', capability: 'hiring-operations' },
|
||
f.unscreened.length
|
||
? { label: `${f.unscreened.length} unscored applications`, prompt: 'What needs attention?', capability: 'attention-required' }
|
||
: { label: 'What needs attention?', prompt: 'What needs attention?', capability: 'attention-required' },
|
||
],
|
||
|
||
/* Forge, Talent Pool and Hired History. Same rule as the contexts above: the
|
||
first chip is whatever the data has made urgent, so the set reorders itself
|
||
rather than offering the same questions regardless of state. */
|
||
'admin.forge': (f) => {
|
||
const g = f.forge;
|
||
return [
|
||
g.next
|
||
? { label: `Should I take ${g.next.title}?`, prompt: `Which challenge should I complete next? Start with ${g.next.title}`, capability: 'forge-next' }
|
||
: { label: 'Which challenge should I take next?', prompt: 'Which challenge should I complete next?', capability: 'forge-next' },
|
||
g.locked.length
|
||
? { label: `${g.locked.length} blocked by requirements`, prompt: 'What is blocking my next skill?', capability: 'forge-blockers' }
|
||
: { label: 'What is blocking my next skill?', prompt: 'What is blocking my next skill?', capability: 'forge-blockers' },
|
||
g.ready.length
|
||
? { label: `${g.ready.length} ready to start`, prompt: 'Which challenges can I start right now?', capability: 'forge-ready' }
|
||
: { label: 'Which challenges can I start?', prompt: 'Which challenges can I start right now?', capability: 'forge-ready' },
|
||
{ label: 'Which skills am I closest to unlocking?', prompt: 'Which skills am I closest to unlocking?', capability: 'forge-skills' },
|
||
{ label: 'Summarize my Forge progress', prompt: 'Summarize my Forge progress', capability: 'forge-progress' },
|
||
];
|
||
},
|
||
|
||
'admin.talentPool': (f) => {
|
||
const t = f.talent;
|
||
return [
|
||
t.top[0]
|
||
? { label: `Why does ${t.top[0].full_name?.split(' ')[0] || 'the top profile'} rank first?`, prompt: 'Which talent should I prioritize?', capability: 'talent-priorities' }
|
||
: { label: 'Which talent should I prioritize?', prompt: 'Which talent should I prioritize?', capability: 'talent-priorities' },
|
||
f.unscoredProfiles.length
|
||
? { label: `${f.unscoredProfiles.length} profiles unscored`, prompt: 'Which profiles are unscored?', capability: 'talent-summary' }
|
||
: { label: 'Summarize the talent pool', prompt: 'Summarize the talent pool', capability: 'talent-summary' },
|
||
t.unverified.length
|
||
? { label: `${t.unverified.length} need verification`, prompt: 'Which talent needs verification?', capability: 'talent-verification' }
|
||
: { label: 'Which talent needs verification?', prompt: 'Which talent needs verification?', capability: 'talent-verification' },
|
||
{ label: 'Who is currently available?', prompt: 'Who is currently available?', capability: 'talent-availability' },
|
||
{ label: 'Who has the strongest score?', prompt: 'Who has the strongest score?', capability: 'talent-priorities' },
|
||
];
|
||
},
|
||
|
||
'admin.hiredHistory': (f) => {
|
||
const h = f.hiring;
|
||
return [
|
||
h.unrated.length
|
||
? { label: `${h.unrated.length} hires unrated`, prompt: 'What needs attention in hiring outcomes?', capability: 'hiring-patterns' }
|
||
: { label: 'What hiring patterns stand out?', prompt: 'What hiring patterns stand out?', capability: 'hiring-patterns' },
|
||
h.byDepartment[0]
|
||
? { label: `${h.byDepartment[0].name} leads on hires`, prompt: 'Which department has the most hires?', capability: 'hiring-outcomes' }
|
||
: { label: 'Which department has the most hires?', prompt: 'Which department has the most hires?', capability: 'hiring-outcomes' },
|
||
{ label: 'Which positions hired the strongest talent?', prompt: 'Which positions hired the strongest talent?', capability: 'hiring-strongest' },
|
||
{ label: 'Summarize recent hires', prompt: 'Summarize recent hires', capability: 'hiring-recent' },
|
||
{ label: 'What hiring outcomes should I review?', prompt: 'What hiring outcomes should I review?', capability: 'hiring-outcomes' },
|
||
];
|
||
},
|
||
|
||
'admin.activity': (f) => [
|
||
f.activitySignals.flags.length
|
||
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }
|
||
: { label: 'Show unusual activity', prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' },
|
||
f.activity24h.length
|
||
? { label: `Summarize today's ${f.activity24h.length} events`, prompt: "Summarize today's activity", capability: 'audit-summary' }
|
||
: { label: "Summarize today's activity", prompt: "Summarize today's activity", capability: 'audit-summary' },
|
||
{ label: 'What changed recently?', prompt: 'What changed recently on the platform?', capability: 'audit-summary' },
|
||
{ label: 'Identify security concerns', prompt: 'Identify security concerns in the audit trail', capability: 'security-insights' },
|
||
{ label: 'Who is most active?', prompt: 'Which accounts are most active?', capability: 'user-activity' },
|
||
],
|
||
|
||
};
|
||
|
||
/* ── Public API ─────────────────────────────────────────────────────────── */
|
||
|
||
const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] || '';
|
||
|
||
/**
|
||
* Everything the landing screen needs, computed from current data.
|
||
* `userName` personalizes the greeting where a name is known.
|
||
*/
|
||
export function buildIntro(contextId, facts, userName) {
|
||
const name = firstName(userName);
|
||
return {
|
||
title: TITLES[contextId]?.(facts, name) ?? 'Owliver',
|
||
greeting: GREETINGS[contextId]?.(facts) ?? '',
|
||
description: DESCRIPTIONS[contextId]?.(facts) ?? '',
|
||
placeholders: PLACEHOLDERS[contextId]?.(facts) ?? ['Ask anything…'],
|
||
};
|
||
}
|
||
|
||
/** Suggested prompts for the current page and data. */
|
||
export function buildPrompts(contextId, facts) {
|
||
return PROMPTS[contextId]?.(facts) ?? [];
|
||
}
|