778 lines
32 KiB
JavaScript
778 lines
32 KiB
JavaScript
import { doc, heading, insights, list, note, table, text } from '@/components/ai-assistant/blocks';
|
||
import {
|
||
demandFor, poolFor, prepareAssignment, prioritise, startLabel, workforceStatusFor,
|
||
} from '@/lib/workforce';
|
||
import { levelLabel, skillName } from '@/lib/skillGraph';
|
||
|
||
/**
|
||
* Owliver's workforce layer: the conversation, not the reasoning.
|
||
*
|
||
* Every number, ranking, availability check and eligibility decision below comes
|
||
* from `lib/workforce.js`. This module's entire job is to work out which
|
||
* question was asked, which position it is about, and how to say the engine's
|
||
* answer — plus the one thing a chat can do that a page cannot, which is to
|
||
* propose an action and wait to be told to take it.
|
||
*
|
||
* The rule that shapes the file: **nothing here writes, and nothing here
|
||
* decides.** A preview is built by calling `prepareAssignment`, and executing it
|
||
* is a separate turn that re-derives the plan from live data before the existing
|
||
* mutation runs. That re-derivation is deliberate — between seeing a preview and
|
||
* confirming it, somebody else may have taken the person.
|
||
*/
|
||
|
||
/* ── Which question is this? ───────────────────────────────────────────── */
|
||
|
||
const has = (q, ...terms) => terms.some((t) => q.includes(t));
|
||
|
||
/**
|
||
* The workforce intent behind a question, or null to let the page's own
|
||
* responder answer it.
|
||
*
|
||
* Ordered by specificity: confirmation before proposal, proposal before
|
||
* enquiry. "Assign them" after a preview must not be read as a fresh request
|
||
* for recommendations.
|
||
*/
|
||
export function matchWorkforceIntent(question) {
|
||
const q = String(question).toLowerCase();
|
||
|
||
/* Inspection and record-opening are checked before assignment, so "show X"
|
||
and "open the full profile for X" never read as a request to assign. */
|
||
if (has(q, 'open the full profile', 'view full profile', 'full profile for')) return 'open_profile';
|
||
if (has(q, 'show ', "'s details", 'candidate details', 'tell me about ')) return 'candidate_detail';
|
||
if (has(q, 'can ', 'why not eligible', 'be assigned')) return 'eligibility';
|
||
|
||
if (has(q, 'confirm interview')) return 'confirm_interview';
|
||
if (has(q, 'set up an interview', 'set up interview', 'schedule an interview', 'schedule interview',
|
||
'interview for ', 'set up their interview')) return 'interview_setup';
|
||
|
||
if (has(q, 'confirm assignment', 'yes, assign', 'confirm the assignment')) return 'confirm_assign';
|
||
if (has(q, 'assign ', 'fill this position', 'fill the position', 'assign the best', 'assign them')) return 'assign';
|
||
if (has(q, 'ready for interview', 'who should i interview', 'interview ready')) return 'interview_ready';
|
||
if (has(q, 'applied today', "today's applicant", 'new applicant', 'new application')) return 'applied_today';
|
||
if (has(q, 'available', 'start earliest', 'can start', 'availability', 'free now')) return 'availability';
|
||
if (has(q, 'who matches', 'who can fill', 'find candidates', 'best match', 'strongest candidate',
|
||
'recommend candidate', 'suitable candidate', 'who is suitable', 'candidates for')) return 'matches';
|
||
if (has(q, 'needs people first', 'biggest gap', 'largest gap', 'most understaffed',
|
||
'which position should i fill', 'priority')) return 'priority';
|
||
return null;
|
||
}
|
||
|
||
/* ── Which position? ───────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* The position a question is about.
|
||
*
|
||
* Named titles win, longest match first so "Event Server – Fine Dining" is not
|
||
* resolved as "Event Server". Failing that, the position the reader currently
|
||
* has open. Failing that, nothing — and the caller asks rather than guessing,
|
||
* because assigning somebody to the wrong role is not a recoverable mistake.
|
||
*/
|
||
export function resolvePosition(question, positions = [], currentId = null) {
|
||
const q = String(question).toLowerCase();
|
||
|
||
let best = null;
|
||
for (const position of positions) {
|
||
const title = String(position.title || '').toLowerCase();
|
||
/* Titles use an en dash; people type a hyphen. */
|
||
const loose = title.replace(/[–—]/g, '-');
|
||
const asked = q.replace(/[–—]/g, '-');
|
||
if (title && (q.includes(title) || asked.includes(loose))
|
||
&& (!best || title.length > String(best.title).length)) {
|
||
best = position;
|
||
}
|
||
}
|
||
if (best) return best;
|
||
|
||
return positions.find((p) => p.id === currentId) || null;
|
||
}
|
||
|
||
/** The question Owliver asks when it cannot tell which role is meant. */
|
||
export const askWhichPosition = (positions = []) => ({
|
||
doc: doc(
|
||
text('Which position should I work on?'),
|
||
list(positions.filter((p) => p.status === 'active').slice(0, 6).map((p) => p.title)),
|
||
note('Name the role and I will read its requirements, demand and the people available for it.')
|
||
),
|
||
followUp: positions
|
||
.filter((p) => p.status === 'active')
|
||
.slice(0, 4)
|
||
.map((p) => ({ label: p.title, prompt: `Who matches ${p.title}?` })),
|
||
});
|
||
|
||
/**
|
||
* The person a question names, matched against the pool the engine scored.
|
||
*
|
||
* Full name first, then a unique first name — "assign Maria" is how people
|
||
* actually speak, but only when exactly one Maria is in play. An ambiguous first
|
||
* name resolves to nothing and the caller asks, because assigning the wrong
|
||
* person is not a recoverable mistake either.
|
||
*/
|
||
export function resolveCandidate(question, pool = []) {
|
||
const q = String(question).toLowerCase();
|
||
|
||
const byFull = pool.filter((row) => row.name && q.includes(String(row.name).toLowerCase()));
|
||
if (byFull.length === 1) return { row: byFull[0] };
|
||
if (byFull.length > 1) {
|
||
/* Longest name wins: "Arun Kumar" over a hypothetical "Arun". */
|
||
return { row: [...byFull].sort((a, b) => b.name.length - a.name.length)[0] };
|
||
}
|
||
|
||
const byFirst = pool.filter((row) => {
|
||
const first = String(row.name || '').split(' ')[0].toLowerCase();
|
||
return first.length > 2 && new RegExp(`\\b${first}\\b`).test(q);
|
||
});
|
||
if (byFirst.length === 1) return { row: byFirst[0] };
|
||
if (byFirst.length > 1) return { ambiguous: byFirst };
|
||
|
||
return {};
|
||
}
|
||
|
||
/* ── Saying what the engine found ──────────────────────────────────────── */
|
||
|
||
/**
|
||
* Why this person fits, in the engine's own terms.
|
||
*
|
||
* Requirement lines come from the match; availability and duration come from the
|
||
* commitment check. A gap is stated as a gap — a candidate one level short is
|
||
* useful to see, and calling them a strong match would be the single most
|
||
* damaging thing this assistant could do.
|
||
*/
|
||
function reasonsFor(row) {
|
||
const out = [];
|
||
|
||
if (!row.match) {
|
||
return ['This position states no requirements this candidate can be scored against'];
|
||
}
|
||
|
||
for (const line of row.match.met) {
|
||
out.push(`✓ ${line.name} — ${line.heldLabel}`);
|
||
}
|
||
for (const line of row.match.gaps) {
|
||
out.push(`⚠ ${line.name} — ${line.heldLabel}, needs ${line.requiredLabel}`);
|
||
}
|
||
|
||
/* Availability is only known for people the workforce system holds a record
|
||
for. For everyone else it is reported as unknown rather than as free. */
|
||
if (row.availability.known === false) {
|
||
out.push('Availability not on file');
|
||
} else {
|
||
out.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
|
||
}
|
||
if (row.duration.note) out.push(`⚠ ${row.duration.note}`);
|
||
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The record to open for a recommended person.
|
||
*
|
||
* One id, decided in one place. `poolFor` resolved this row against the
|
||
* Candidates dataset before it was ever ranked, so `candidateId` is the id of
|
||
* an application that genuinely exists — never a name, a rank, or a record
|
||
* invented to make the link work. A row without one never reaches here, because
|
||
* a person with no candidate record is not returned as a match at all.
|
||
*
|
||
* The route is the existing candidate route, opening the existing full profile.
|
||
* The position travels with it so the profile knows what the person was being
|
||
* considered for.
|
||
*/
|
||
export function candidateRoute(row, applications = [], position = null) {
|
||
const id = row?.candidateId;
|
||
if (!id) return null;
|
||
|
||
return position
|
||
? `/admin/candidates/${id}?for=${encodeURIComponent(position.id)}`
|
||
: `/admin/candidates/${id}`;
|
||
}
|
||
|
||
/**
|
||
* One candidate, as a card.
|
||
*
|
||
* Clickable when there is a record behind the name. The reasons are the engine's
|
||
* — met requirements, gaps, and the availability verdict — so a card can never
|
||
* read as a stronger endorsement than the match actually is.
|
||
*/
|
||
const candidateBlock = (row, index, { position = null, applications = [], inspectable = true } = {}) => {
|
||
/**
|
||
* Two different things a reader wants from a match, kept apart.
|
||
*
|
||
* **View Profile** opens that person's record on the Candidates page. It is an
|
||
* explicit control rather than the whole card, so reading the match reasons
|
||
* cannot navigate away by accident — and it resolves for everyone, applied or
|
||
* not, because the profile page reads both kinds of record.
|
||
*
|
||
* **The card itself** still asks Owliver about this person for this role, which
|
||
* is the existing inspect-and-assign conversation, unchanged.
|
||
*
|
||
* The hint stays informational: it explains what opening the profile will and
|
||
* will not show, and is no longer the only way in.
|
||
*/
|
||
const to = candidateRoute(row, applications, position);
|
||
|
||
/**
|
||
* The headline says what kind of evidence the number rests on.
|
||
*
|
||
* A skill match is scored on training this person completed; a requirement
|
||
* match is scored on what their application states. Printing both as "match"
|
||
* would make the weaker claim borrow the authority of the stronger one, and a
|
||
* candidate with nothing to score says so rather than showing a 0.
|
||
*/
|
||
const headline = !row.scored
|
||
? 'not scored'
|
||
: row.basis === 'verified'
|
||
? `${row.score}% match`
|
||
: `${row.score}% requirement fit`;
|
||
|
||
return insights([{
|
||
tone: row.strong ? 'success' : row.scored ? 'info' : 'neutral',
|
||
title: `${index}. ${row.name} — ${headline}`,
|
||
body: reasonsFor(row).join(' · '),
|
||
prompt: !inspectable || !position ? null : `Show ${row.name} for ${position.title}`,
|
||
action: to ? { label: 'View Profile', to } : null,
|
||
/* Two informational states, never a control: what the score rests on, and
|
||
whether they have applied for this role. The profile opens either way. */
|
||
hint: [
|
||
row.basis === 'stated'
|
||
? 'Scored from their application — no verified training on file.'
|
||
: !row.scored ? 'Insufficient profile data to score against this role.' : null,
|
||
row.applied ? null : 'No application for this role yet — assigning them creates one.',
|
||
].filter(Boolean).join(' ') || null,
|
||
}]);
|
||
};
|
||
|
||
/**
|
||
* The people who could do this work, ranked.
|
||
*
|
||
* Shows the unavailable and the under-qualified too, marked as such: "nobody is
|
||
* free" is something an admin needs to see, and filtering it away leaves them
|
||
* asking the same question again tomorrow.
|
||
*/
|
||
export function candidateMatches(position, context) {
|
||
const pool = poolFor(position, context);
|
||
const status = workforceStatusFor(position, context);
|
||
|
||
if (!pool.length) {
|
||
return {
|
||
doc: doc(
|
||
text(`There are no candidates on file to score against **${position.title}**.`),
|
||
note('Candidates come from the Candidates page. Once somebody applies — to this role or any other — they can be scored against this position.')
|
||
),
|
||
};
|
||
}
|
||
|
||
/* Committed elsewhere is a workforce fact, so it can only exclude somebody the
|
||
workforce system holds a record for. A candidate with no such record is
|
||
ranked below the verified ones, never filtered out by an availability
|
||
nobody has recorded. */
|
||
const eligible = pool.filter((r) => r.availability.available);
|
||
const blocked = pool.filter((r) => !r.availability.available);
|
||
const shown = eligible.slice(0, 5);
|
||
const verified = pool.filter((r) => r.basis === 'verified').length;
|
||
|
||
return {
|
||
doc: doc(
|
||
heading(`Best matches for ${position.title}`,
|
||
`${pool.length} candidate${pool.length === 1 ? '' : 's'} · ${verified} with verified skills · ${status.strong.length} strong`),
|
||
...(shown.length
|
||
? shown.map((row, i) => candidateBlock(row, i + 1, { applications: context.applications, position }))
|
||
: [text('Nobody is currently free for this role.')]),
|
||
pool.length > shown.length + blocked.length
|
||
? note(`${pool.length - shown.length - blocked.length} more candidate${pool.length - shown.length - blocked.length === 1 ? '' : 's'} on file — ask for the full list or open Candidates to see them all.`)
|
||
: null,
|
||
blocked.length
|
||
? note(`${blocked.length} other ${blocked.length === 1 ? 'person is' : 'people are'} qualified but committed elsewhere — ask who is available next to see when they free up.`)
|
||
: null,
|
||
status.demand.declared
|
||
? text(`This position needs **${status.demand.remaining}** more of **${status.demand.required}**.`)
|
||
: null
|
||
),
|
||
followUp: shown.length
|
||
? [
|
||
{ label: 'Assign the best', prompt: `Assign the best candidates to ${position.title}` },
|
||
{ label: 'Who is available next?', prompt: `Who is available next for ${position.title}?` },
|
||
]
|
||
: [{ label: 'Who is available next?', prompt: `Who is available next for ${position.title}?` }],
|
||
};
|
||
}
|
||
|
||
/** Who is free, who is not, and when the committed ones come back. */
|
||
export function availability(position, context) {
|
||
const pool = poolFor(position, context);
|
||
if (!pool.length) {
|
||
return { doc: doc(text(`There are no candidates on file to assess for **${position.title}**.`)) };
|
||
}
|
||
|
||
return {
|
||
doc: doc(
|
||
heading(`Availability for ${position.title}`, `starts ${startLabel(position)}`),
|
||
table(
|
||
[
|
||
{ key: 'name', label: 'Person' },
|
||
{ key: 'state', label: 'Availability' },
|
||
{ key: 'match', label: 'Match', align: 'right' },
|
||
],
|
||
pool.slice(0, 8).map((row) => ({
|
||
name: row.name,
|
||
state: row.availability.available ? 'Available' : row.availability.reason,
|
||
match: `${row.score}%`,
|
||
}))
|
||
),
|
||
note('Availability is measured against this position’s start date — somebody finishing a role before it begins counts as free.')
|
||
),
|
||
};
|
||
}
|
||
|
||
/** Applications created today, across a position or the whole board. */
|
||
export function appliedToday(position, context, positions = []) {
|
||
const scope = position ? [position] : positions;
|
||
const rows = scope.flatMap((p) =>
|
||
workforceStatusFor(p, context).newToday.map((row) => ({
|
||
name: row.name,
|
||
position: p.title,
|
||
time: new Date(row.application.created_date).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }),
|
||
status: row.application.status,
|
||
match: row.match ? `${row.match.score}%` : '—',
|
||
}))
|
||
);
|
||
|
||
if (!rows.length) {
|
||
return {
|
||
doc: doc(text(position
|
||
? `No applications for **${position.title}** today.`
|
||
: 'No applications arrived today.')),
|
||
};
|
||
}
|
||
|
||
return {
|
||
doc: doc(
|
||
heading(`${rows.length} new ${rows.length === 1 ? 'application' : 'applications'} today`),
|
||
table(
|
||
[
|
||
{ key: 'name', label: 'Candidate' },
|
||
{ key: 'position', label: 'Position' },
|
||
{ key: 'time', label: 'Applied' },
|
||
{ key: 'status', label: 'Status' },
|
||
{ key: 'match', label: 'Match', align: 'right' },
|
||
],
|
||
rows
|
||
),
|
||
note('Match is shown only for people who carry a worker profile; an applicant without one cannot be scored.')
|
||
),
|
||
};
|
||
}
|
||
|
||
/** Candidates whose application has reached a stage where an interview is next. */
|
||
export function interviewReady(position, context, positions = []) {
|
||
const scope = position ? [position] : positions;
|
||
const rows = scope.flatMap((p) => {
|
||
const status = workforceStatusFor(p, context);
|
||
return status.applicants
|
||
.filter((a) => ['ai_screened', 'shortlisted'].includes(a.application.status))
|
||
.map((a) => ({
|
||
name: a.name,
|
||
position: p.title,
|
||
stage: a.application.status === 'ai_screened' ? 'Screened' : 'Shortlisted',
|
||
match: a.match ? `${a.match.score}%` : '—',
|
||
}));
|
||
});
|
||
|
||
if (!rows.length) {
|
||
return {
|
||
doc: doc(text(position
|
||
? `Nobody is waiting on an interview decision for **${position.title}**.`
|
||
: 'Nobody is currently screened or shortlisted and waiting on an interview.')),
|
||
};
|
||
}
|
||
|
||
return {
|
||
doc: doc(
|
||
heading(`${rows.length} ready for interview`),
|
||
table(
|
||
[
|
||
{ key: 'name', label: 'Candidate' },
|
||
{ key: 'position', label: 'Position' },
|
||
{ key: 'stage', label: 'Stage' },
|
||
{ key: 'match', label: 'Match', align: 'right' },
|
||
],
|
||
rows
|
||
),
|
||
note('Interviews are scheduled from the candidate’s record on the position page — I can tell you who is ready, but I do not create the booking.')
|
||
),
|
||
};
|
||
}
|
||
|
||
/** Which role gets people first, and the engine's stated reasons. */
|
||
export function positionPriority(positions, context) {
|
||
const ranked = prioritise(positions, context);
|
||
if (!ranked.length) {
|
||
return { doc: doc(text('No active position currently has an unfilled headcount.')) };
|
||
}
|
||
|
||
const top = ranked[0];
|
||
return {
|
||
doc: doc(
|
||
heading('Hiring priority', `${ranked.length} ${ranked.length === 1 ? 'position needs' : 'positions need'} people`),
|
||
insights(ranked.slice(0, 3).map((row) => ({
|
||
tone: row.band === 'High' ? 'warning' : 'info',
|
||
title: `${row.position.title} — ${row.band}`,
|
||
body: row.reasons.join(' · '),
|
||
}))),
|
||
text(`Start with **${top.position.title}**.`)
|
||
),
|
||
followUp: [{ label: `Who matches ${top.position.title}?`, prompt: `Who matches ${top.position.title}?` }],
|
||
};
|
||
}
|
||
|
||
/* ── Proposing an assignment ───────────────────────────────────────────── */
|
||
|
||
/**
|
||
* The preview. Nothing is written here.
|
||
*
|
||
* `prepareAssignment` picks the people; this states who, why, and what the
|
||
* position looks like afterwards. The confirmation is a follow-up carrying the
|
||
* position title, so accepting it goes back through the same resolution path a
|
||
* typed sentence would — and re-checks the data before writing.
|
||
*/
|
||
export function assignmentPreview(position, context) {
|
||
const plan = prepareAssignment(position, context);
|
||
const demand = demandFor(position, context);
|
||
|
||
if (!demand.declared) {
|
||
return {
|
||
doc: doc(
|
||
text(`**${position.title}** does not state how many people it needs, so there is no gap for me to fill.`),
|
||
note('Set a headcount on the position and I can propose an assignment against it.')
|
||
),
|
||
};
|
||
}
|
||
|
||
if (plan.blocked === 'full') {
|
||
return { doc: doc(text(`**${position.title}** is already fully staffed — ${demand.assigned} of ${demand.required}.`)) };
|
||
}
|
||
|
||
if (plan.blocked === 'no_available_matches') {
|
||
const blocked = poolFor(position, context).filter((r) => !r.availability.available);
|
||
return {
|
||
doc: doc(
|
||
text(`I cannot propose anyone for **${position.title}** — nobody is both qualified and free when it starts.`),
|
||
blocked.length
|
||
? insights(blocked.slice(0, 3).map((row) => ({
|
||
tone: 'warning',
|
||
title: row.name,
|
||
body: `${row.match.score}% on skills, but ${row.availability.reason.toLowerCase()}`,
|
||
})))
|
||
: null,
|
||
note(`${demand.remaining} of ${demand.required} still unfilled.`)
|
||
),
|
||
};
|
||
}
|
||
|
||
return {
|
||
doc: doc(
|
||
heading(`Assignment preview — ${position.title}`, `${plan.selected.length} proposed`),
|
||
...plan.selected.map((row, i) => candidateBlock(row, i + 1, { applications: context.applications, position })),
|
||
text(
|
||
`**${demand.assigned} of ${demand.required}** assigned now · `
|
||
+ `**${plan.after} of ${plan.required}** after · `
|
||
+ `**${plan.remainingAfter}** remaining`
|
||
),
|
||
note('Nothing is written until you confirm. I re-check availability at that moment, so anyone taken in the meantime drops out.')
|
||
),
|
||
followUp: [
|
||
{ label: 'Confirm assignment', prompt: `Confirm assignment for ${position.title}` },
|
||
{ label: 'Cancel', prompt: `Who matches ${position.title}?` },
|
||
],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* One candidate, examined — inside the panel.
|
||
*
|
||
* Deliberately not a copy of the Candidate Profile page. It carries only what a
|
||
* hiring decision needs: how they match, whether they are free, what they have
|
||
* done, and where their application stands. The full record stays one explicit
|
||
* click away, and remains the source of truth.
|
||
*
|
||
* The actions offered depend on the person's real state, so the panel never
|
||
* offers an assignment it would then have to refuse.
|
||
*/
|
||
export function candidateDetail(position, row, context) {
|
||
const profile = row.profile || {};
|
||
const applications = context.applications || [];
|
||
const application = applications.find(
|
||
(a) => String(a.email || '').toLowerCase() === String(row.email || '').toLowerCase()
|
||
&& a.job_posting_id === position.id
|
||
) || applications.find(
|
||
(a) => String(a.email || '').toLowerCase() === String(row.email || '').toLowerCase()
|
||
) || null;
|
||
|
||
const facts = [
|
||
profile.experience_years ? `${profile.experience_years} years experience` : null,
|
||
profile.current_position || profile.desired_position || null,
|
||
profile.address || null,
|
||
(profile.certifications || []).length ? (profile.certifications || []).join(', ') : null,
|
||
].filter(Boolean);
|
||
|
||
const assignable = row.availability.available && !row.match.gaps.length;
|
||
|
||
/* Actions follow the state. An unavailable person is not offered an
|
||
assignment; somebody with no record is not offered a record to open. */
|
||
const followUp = [
|
||
assignable
|
||
? { label: 'Assign to position', prompt: `Assign ${row.name} to ${position.title}` }
|
||
: { label: 'Why not eligible?', prompt: `Can ${row.name} be assigned to ${position.title}?` },
|
||
{ label: '← Back to matches', prompt: `Who matches ${position.title}?` },
|
||
application
|
||
? { label: 'View full profile', prompt: `Open the full profile for ${row.name}` }
|
||
: null,
|
||
].filter(Boolean);
|
||
|
||
return {
|
||
doc: doc(
|
||
heading(`${row.name} — ${row.score}% match`, `Evaluating for ${position.title}`),
|
||
insights([{
|
||
tone: row.strong ? 'success' : row.availability.available ? 'info' : 'warning',
|
||
title: 'Match',
|
||
body: reasonsFor(row).join(' · '),
|
||
}]),
|
||
facts.length ? insights([{ tone: 'neutral', title: 'Background', body: facts.join(' · ') }]) : null,
|
||
insights([{
|
||
tone: row.availability.available ? 'success' : 'warning',
|
||
title: 'Availability',
|
||
body: row.availability.available
|
||
? `Free when this starts (${startLabel(position)})`
|
||
: row.availability.reason,
|
||
}]),
|
||
insights([{
|
||
tone: application ? 'info' : 'neutral',
|
||
title: 'Application',
|
||
body: application
|
||
? `${application.status.replace(/_/g, ' ')} · applied ${new Date(application.created_date).toLocaleDateString()}`
|
||
: 'No application on file for this role — assigning them creates one.',
|
||
}]),
|
||
assignable
|
||
? null
|
||
: note('I will not offer an assignment the engine would refuse. The reason is above.')
|
||
),
|
||
followUp,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A preview for one named person.
|
||
*
|
||
* Separate from the ranked preview because the question is different: the admin
|
||
* has already chosen, and what they need is whether that choice holds. So this
|
||
* validates rather than recommends — and refuses, with the engine's reason, when
|
||
* the person is committed elsewhere or short of a required level.
|
||
*/
|
||
export function namedAssignmentPreview(position, row, context) {
|
||
const demand = demandFor(position, context);
|
||
|
||
if (!row.availability.available) {
|
||
return {
|
||
doc: doc(
|
||
heading(`${row.name} cannot take ${position.title}`),
|
||
insights([{ tone: 'warning', title: row.availability.reason, body: reasonsFor(row).join(' · ') }]),
|
||
note('Availability is measured against this position’s start date. Ask who is available if you want the people who can take it.')
|
||
),
|
||
followUp: [{ label: 'Who is available?', prompt: `Who is available for ${position.title}?` }],
|
||
};
|
||
}
|
||
|
||
if (row.match.gaps.length) {
|
||
return {
|
||
doc: doc(
|
||
heading(`${row.name} is short of ${position.title}`, `${row.score}% match`),
|
||
insights([{
|
||
tone: 'warning',
|
||
title: row.match.gaps.map((g) => `${g.name}: ${g.heldLabel} → needs ${g.requiredLabel}`).join(' · '),
|
||
body: reasonsFor(row).join(' · '),
|
||
}]),
|
||
note('I will not put somebody forward as qualified when the engine says they are not. Assign them anyway by naming the gap you are willing to accept, or ask who does meet it.')
|
||
),
|
||
followUp: [{ label: 'Who fully matches?', prompt: `Who matches ${position.title}?` }],
|
||
};
|
||
}
|
||
|
||
return {
|
||
doc: doc(
|
||
heading('Assignment ready', `${row.name} → ${position.title}`),
|
||
candidateBlock(row, 1, { applications: context.applications, position }),
|
||
demand.declared
|
||
? text(`**${demand.assigned} of ${demand.required}** assigned now · **${demand.assigned + 1}** after · **${Math.max(0, demand.remaining - 1)}** remaining`)
|
||
: text('This position states no headcount, so there is no coverage figure to move.'),
|
||
note('Nothing is written until you confirm.')
|
||
),
|
||
followUp: [
|
||
{ label: 'Confirm assignment', prompt: `Confirm assignment of ${row.name} to ${position.title}` },
|
||
{ label: 'Cancel', prompt: `Who matches ${position.title}?` },
|
||
],
|
||
};
|
||
}
|
||
|
||
/** The single-person plan, re-derived at confirmation. */
|
||
export function namedAssignmentToExecute(position, row, context) {
|
||
if (!row || !row.availability.available || row.match.gaps.length) return null;
|
||
const demand = demandFor(position, context);
|
||
return {
|
||
position,
|
||
workers: [{ email: row.email, name: row.name, score: row.score }],
|
||
before: demand.assigned,
|
||
after: demand.assigned + 1,
|
||
required: demand.required,
|
||
remainingAfter: Math.max(0, demand.remaining - 1),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The plan, re-derived at the moment of confirmation.
|
||
*
|
||
* Returned to the caller to execute through the existing mutation. Re-deriving
|
||
* rather than replaying a stored preview is what makes the confirmation honest:
|
||
* the people written are the people who are still free.
|
||
*/
|
||
export function assignmentToExecute(position, context) {
|
||
const plan = prepareAssignment(position, context);
|
||
if (plan.blocked || !plan.selected.length) return null;
|
||
return {
|
||
position,
|
||
workers: plan.selected.map((row) => ({ email: row.email, name: row.name, score: row.score })),
|
||
before: plan.before,
|
||
after: plan.after,
|
||
required: plan.required,
|
||
remainingAfter: plan.remainingAfter,
|
||
};
|
||
}
|
||
|
||
/* ── Interview ─────────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* The application a person holds for this role, or null.
|
||
*
|
||
* Everything about interviewing keys on this record: it is the subject the AI
|
||
* interview takes, and the thing a status transition moves.
|
||
*/
|
||
export function applicationFor(row, position, context) {
|
||
const email = String(row?.email || '').toLowerCase();
|
||
if (!email) return null;
|
||
return (context.applications || []).find(
|
||
(a) => a.job_posting_id === position?.id && String(a.email || '').toLowerCase() === email
|
||
) || null;
|
||
}
|
||
|
||
/**
|
||
* Interview setup, previewed.
|
||
*
|
||
* The blockers are real states, not guesses: somebody with no application for
|
||
* this role has not entered its pipeline, and somebody already at interview
|
||
* stage does not need moving there again. Both are reported rather than
|
||
* papered over with a second record.
|
||
*/
|
||
export function interviewPreview(position, row, context) {
|
||
const application = applicationFor(row, position, context);
|
||
|
||
if (!application) {
|
||
return {
|
||
doc: doc(
|
||
text(`**${row.name}** has no application for **${position.title}**, so there is nothing to interview against.`),
|
||
note('Assigning them creates the application, and the interview can be set up straight after.')
|
||
),
|
||
followUp: [{ label: 'Assign to position', prompt: `Assign ${row.name} to ${position.title}` }],
|
||
};
|
||
}
|
||
|
||
if (application.status === 'interview') {
|
||
return {
|
||
doc: doc(
|
||
heading('Already at interview stage', `${row.name} · ${position.title}`),
|
||
text('The AI interview can be started from their candidate record.'),
|
||
insights([{
|
||
tone: 'info',
|
||
title: 'Open the AI interview',
|
||
body: `${row.name} — ${position.title}`,
|
||
to: `/admin/candidates/${application.id}`,
|
||
}])
|
||
),
|
||
followUp: [{ label: '← Back to candidate', prompt: `Show ${row.name} for ${position.title}` }],
|
||
};
|
||
}
|
||
|
||
const requirements = (position.skill_requirements || [])
|
||
.map((r) => `${skillName(r.skill_id)} — ${levelLabel(r.level)}`)
|
||
.join(' · ');
|
||
|
||
return {
|
||
doc: doc(
|
||
heading('Interview setup', `${row.name} → ${position.title}`),
|
||
insights([{
|
||
tone: 'info',
|
||
title: 'AI Interview',
|
||
body: requirements
|
||
? `Scored against this role's own requirements: ${requirements}`
|
||
: 'This position defines no skill requirements, so the interview covers the role generally.',
|
||
}]),
|
||
insights([{
|
||
tone: 'neutral',
|
||
title: 'Application',
|
||
body: `Currently ${String(application.status).replace(/_/g, ' ')} · moving to interview`,
|
||
}]),
|
||
note('This moves the application to interview stage. The interview itself is conducted by the AI interviewer on the candidate’s record, which writes the result.')
|
||
),
|
||
followUp: [
|
||
{ label: 'Confirm interview', prompt: `Confirm interview for ${row.name} at ${position.title}` },
|
||
{ label: '← Back to candidate', prompt: `Show ${row.name} for ${position.title}` },
|
||
],
|
||
};
|
||
}
|
||
|
||
/** The interview step to execute, or null when it cannot proceed. */
|
||
export function interviewToExecute(position, row, context) {
|
||
const application = applicationFor(row, position, context);
|
||
if (!application || application.status === 'interview') return null;
|
||
return { position, row, application };
|
||
}
|
||
|
||
/** What to say once the application has actually moved. */
|
||
export const interviewDone = (plan) => doc(
|
||
heading('Interview ready', `${plan.row.name} · ${plan.position.title}`),
|
||
text(`**${plan.row.name}** is now at interview stage for **${plan.position.title}**.`),
|
||
insights([{
|
||
tone: 'success',
|
||
title: 'Open the AI interview',
|
||
body: 'The AI interviewer scores against this role’s requirements and writes the result to their record.',
|
||
to: `/admin/candidates/${plan.application.id}`,
|
||
}]),
|
||
note('Candidates, the position pipeline and the activity log all reflect this now.')
|
||
);
|
||
|
||
export const interviewFailed = () => doc(
|
||
text('I could not move that application to interview stage.'),
|
||
note('Nothing was changed. Open the candidate’s record and try from there.')
|
||
);
|
||
|
||
/** What to say once the write has actually happened. */
|
||
export const assignmentDone = (plan) => doc(
|
||
heading('Assignment complete', `${plan.workers.length} assigned to ${plan.position.title}`),
|
||
list(plan.workers.map((w) => `${w.name} — ${w.score}% match`)),
|
||
text(
|
||
`**${plan.position.title}** is now **${plan.after} of ${plan.required}** assigned · `
|
||
+ `**${plan.remainingAfter}** remaining.`
|
||
),
|
||
note('The position, the applications and the activity log have been updated.')
|
||
);
|
||
|
||
/** The next step to offer once an assignment lands. */
|
||
export const assignmentFollowUp = (plan) => [
|
||
plan.workers.length === 1
|
||
? { label: 'Set up interview', prompt: `Set up an interview for ${plan.workers[0].name} at ${plan.position.title}` }
|
||
: { label: 'Who is ready for interview?', prompt: 'Who is ready for interview?' },
|
||
{ label: '← Back to matches', prompt: `Who matches ${plan.position.title}?` },
|
||
];
|
||
|
||
/** What to say when the write could not proceed. */
|
||
export const assignmentFailed = () => doc(
|
||
text('I could not complete that assignment.'),
|
||
note('Nothing was written. The people I proposed may have been taken since the preview — ask again and I will re-check.')
|
||
);
|