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) ?? []; }