Files
doormilxpress_astryx/src/components/ai-assistant/dynamic.js
2026-08-15 23:24:06 +05:30

679 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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) ?? [];
}