679 lines
37 KiB
JavaScript
679 lines
37 KiB
JavaScript
import { plural, verb } from './insights';
|
||
import { prioritise, workforceStatusFor } from '@/lib/workforce';
|
||
import { PERMISSIONS } from '@/pages/admin/permissions';
|
||
|
||
/**
|
||
* 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')}.`;
|
||
},
|
||
|
||
/* The form is the page, so the greeting is about what is being specified
|
||
rather than about the board — read from what this workspace has posted. */
|
||
'admin.createPosition': (f) => {
|
||
if (!f.postings.length) {
|
||
return 'This is the first position in the workspace — the weights you set here become the house pattern.';
|
||
}
|
||
/* Every posted role is a benchmark for pay and experience; only the ones
|
||
carrying weights can be compared on those, so the two are counted
|
||
separately rather than one standing in for the other. */
|
||
const weighted = f.postings.filter((p) => p.vetting_criteria).length;
|
||
return `Specifying a new role. ${plural(f.postings.length, 'position')} on file to benchmark against${weighted ? `, ${weighted} with vetting weights set` : ''}.`;
|
||
},
|
||
|
||
'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.`;
|
||
},
|
||
|
||
/* The line the panel opens with: whatever most needs the author's attention,
|
||
which on an authoring console is an unfinished skill before it is a total. */
|
||
'admin.forge': (f) => {
|
||
const g = f.forge;
|
||
if (!g.library.length) return 'The library is empty — no skill is defined yet.';
|
||
|
||
const unscored = g.library.filter((c) => !c.challenge?.rubric?.length).length;
|
||
if (unscored) {
|
||
return `${plural(unscored, 'skill')} ${verb(unscored, 'has', 'have')} no Owliver criteria, so ${verb(unscored, 'it', 'they')} cannot verify anything yet.`;
|
||
}
|
||
if (g.drafts.length) {
|
||
return `${plural(g.drafts.length, 'skill')} ${verb(g.drafts.length, 'is', 'are')} in draft and not visible to the workforce.`;
|
||
}
|
||
return `${plural(g.published.length, 'skill')} published, ${plural(g.workforceVerified, 'verification')} earned.`;
|
||
},
|
||
|
||
'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.profile': (f) => {
|
||
const mine = f.user
|
||
? f.activity.filter((e) => e.user_email === f.user.email)
|
||
: [];
|
||
const full = PERMISSIONS.filter((p) => p.level === 'Full access').length;
|
||
if (!mine.length) return `${plural(full, 'scope')} at full access on this account.`;
|
||
return `${plural(full, 'scope')} at full access, ${plural(mine.length, 'event')} logged against this account.`;
|
||
},
|
||
|
||
'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.createPosition': () => 'Specifying a position',
|
||
'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}` : ''}`,
|
||
'admin.profile': (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.createPosition': (f) =>
|
||
`Reading this draft against ${plural(f.postings.length, 'posted position')} — vetting weights, pay, experience and the credentials already in use.`,
|
||
'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.library.length, 'skill')} — training, proof, what Owliver evaluates and how the workforce is doing with them.`,
|
||
'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.`,
|
||
'admin.profile': () =>
|
||
'Reading this account — permissions, security, preferences and your own activity.',
|
||
};
|
||
|
||
/**
|
||
* 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.createPosition': () => [
|
||
'Ask Owliver about the vetting weights…',
|
||
'Ask Owliver what comparable roles ask for…',
|
||
'Ask Owliver to explain this form…',
|
||
],
|
||
'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 to create a skill training…',
|
||
'Ask Owliver what the library covers…',
|
||
'Ask Owliver what a skill verifies…',
|
||
],
|
||
'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…',
|
||
],
|
||
'admin.profile': () => [
|
||
'Ask Owliver about your permissions…',
|
||
'Ask Owliver what you can change here…',
|
||
'Ask Owliver about your security settings…',
|
||
],
|
||
};
|
||
|
||
/* ── 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' },
|
||
],
|
||
|
||
/* Create Position asks about the draft in front of the author. Every one is
|
||
answerable from the form and the positions already posted. */
|
||
'admin.createPosition': () => [
|
||
{ label: 'Explain the vetting weights', prompt: 'Summarize these vetting weights', capability: 'vetting-weights' },
|
||
{ label: 'Compare with similar roles', prompt: 'What do comparable roles ask for?', capability: 'position-benchmarks' },
|
||
{ label: 'Credentials in use', prompt: 'Which certifications does this workspace require?', capability: 'position-requirements' },
|
||
{ label: 'How this form works', prompt: 'Explain how this form works', capability: 'position-spec-steps' },
|
||
],
|
||
|
||
/**
|
||
* Positions suggestions are the current workforce situation, phrased as
|
||
* questions.
|
||
*
|
||
* Each one is offered only when the data behind it exists — a prompt about
|
||
* strong matches on a board with none is an invitation to a dead end. None of
|
||
* them names a person: the question is asked generically and the candidates
|
||
* are resolved live, so a suggestion cannot go stale between being rendered
|
||
* and being clicked.
|
||
*
|
||
* Workforce prompts carry no `capability`, which is what sends them through
|
||
* intent routing to the engine rather than to a page analytics responder.
|
||
*/
|
||
'admin.positions': (f, workforce) => {
|
||
const out = [];
|
||
|
||
/**
|
||
* Two things about the position in front of the reader come first, because
|
||
* both are about what they were *just doing* rather than about the board.
|
||
*
|
||
* A draft is unfinished work, so finishing it leads. Then the role the page
|
||
* has open — the drawer, or the one just published, which the form names in
|
||
* the URL on its way here — gets the question its state actually invites:
|
||
* a role with no applicants yet is a matching question, not a pipeline one.
|
||
*
|
||
* Both name the record, so the answer resolves against that position and
|
||
* cannot be a stale reading of a different one.
|
||
*/
|
||
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
|
||
if (drafts.length) {
|
||
out.push(drafts.length === 1
|
||
/* Names the draft, so the answer continues *that* record in the panel
|
||
rather than listing every draft and sending the reader to a form. */
|
||
? {
|
||
label: `Continue the ${drafts[0].title} draft`,
|
||
prompt: `Continue the ${drafts[0].title} draft`,
|
||
}
|
||
: {
|
||
label: `${drafts.length} drafts to finish`,
|
||
prompt: 'Which positions are still in draft?',
|
||
capability: 'position-drafts',
|
||
});
|
||
}
|
||
|
||
const current = workforce?.currentPositionId
|
||
? (workforce.positions || []).find((p) => p.id === workforce.currentPositionId)
|
||
: null;
|
||
if (current && current.status !== 'draft') {
|
||
out.push({
|
||
label: `Find matching candidates for ${current.title}`,
|
||
/* The definition's own words, with the role named. It resolves to the
|
||
candidate-matching skill and reads that position — so switching the
|
||
skill off in Settings takes this answer with it, as it should. */
|
||
prompt: `Find matching candidates for ${current.title}`,
|
||
});
|
||
}
|
||
|
||
if (workforce?.positions?.length) {
|
||
const { positions, context } = workforce;
|
||
const ranked = prioritise(positions, context);
|
||
const board = positions
|
||
.filter((p) => p.status === 'active')
|
||
.map((p) => ({ position: p, status: workforceStatusFor(p, context) }));
|
||
|
||
const withStrong = board.filter((r) => r.status.strong.length);
|
||
const newToday = board.reduce((n, r) => n + r.status.newToday.length, 0);
|
||
const readyCount = board.reduce(
|
||
(n, r) => n + r.status.applicants.filter(
|
||
(a) => ['ai_screened', 'shortlisted'].includes(a.application.status)
|
||
).length,
|
||
0
|
||
);
|
||
|
||
if (withStrong.length) {
|
||
const best = withStrong.sort((a, b) => b.status.strong.length - a.status.strong.length)[0];
|
||
out.push({
|
||
label: `${best.status.strong.length} strong for ${best.position.title}`,
|
||
prompt: `Who matches ${best.position.title}?`,
|
||
});
|
||
}
|
||
if (ranked.length) {
|
||
out.push({ label: 'Which position needs people first?', prompt: 'Which position needs people first?' });
|
||
}
|
||
if (newToday) {
|
||
out.push({ label: `${newToday} applied today`, prompt: 'Who applied today?' });
|
||
}
|
||
if (readyCount) {
|
||
out.push({ label: `${readyCount} ready for interview`, prompt: 'Who is ready for interview?' });
|
||
}
|
||
}
|
||
|
||
/* The page's own analytics questions keep their place behind the workforce
|
||
ones — they are still the right answer to "how is hiring going". */
|
||
out.push(
|
||
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: 'Summarize hiring activity', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' }
|
||
);
|
||
|
||
return out;
|
||
},
|
||
|
||
'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;
|
||
const unscored = g.library.filter((c) => !c.challenge?.rubric?.length).length;
|
||
return [
|
||
/* The chip that creates something goes first: on an authoring console the
|
||
most likely next action is authoring. It carries no capability, so it
|
||
routes through the skill registry rather than the page responder. */
|
||
{ label: 'Create a skill training', prompt: 'Create a skill training' },
|
||
g.drafts.length
|
||
? { label: `${g.drafts.length} in draft`, prompt: 'Which skills are published?', capability: 'forge-published' }
|
||
: { label: `${g.published.length} published`, prompt: 'Which skills are published?', capability: 'forge-published' },
|
||
unscored
|
||
? { label: `${unscored} without Owliver criteria`, prompt: 'What gaps are left in the library?', capability: 'forge-gaps' }
|
||
: { label: 'What does Owliver evaluate?', prompt: 'What does Owliver evaluate?', capability: 'forge-evaluation' },
|
||
{ label: 'What skills do we currently have?', prompt: 'What skills do we currently have?', capability: 'forge-library' },
|
||
g.workforceVerified
|
||
? { label: `${g.workforceVerified} skills verified`, prompt: 'How is the workforce doing with these skills?', capability: 'forge-workforce' }
|
||
: { label: 'How is the workforce using these?', prompt: 'How is the workforce doing with these skills?', capability: 'forge-workforce' },
|
||
];
|
||
},
|
||
|
||
'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.profile': (f) => {
|
||
const mine = f.user ? f.activity.filter((e) => e.user_email === f.user.email) : [];
|
||
const restricted = PERMISSIONS.filter((p) => p.level !== 'Full access').length;
|
||
return [
|
||
{ label: 'What are my permissions?', prompt: 'What are my permissions?', capability: 'profile-permissions' },
|
||
restricted
|
||
? { label: `${restricted} scopes are restricted`, prompt: 'Which scopes am I restricted on?', capability: 'profile-permissions' }
|
||
: { label: 'What can I do here?', prompt: 'What can I do on this page?', capability: 'profile-actions' },
|
||
{ label: 'How is my account secured?', prompt: 'How is my account secured?', capability: 'profile-security' },
|
||
{ label: 'What are my preferences set to?', prompt: 'What are my preferences set to?', capability: 'profile-preferences' },
|
||
mine.length
|
||
? { label: `My last ${mine.length} actions`, prompt: 'What is my recent activity?', capability: 'profile-activity' }
|
||
: { label: 'What can I change here?', prompt: 'What can I change on this page?', capability: 'profile-actions' },
|
||
];
|
||
},
|
||
|
||
'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, workforce = null) {
|
||
return PROMPTS[contextId]?.(facts, workforce) ?? [];
|
||
}
|