Files
krow_talent_app/src/components/ai-assistant/insights.ts

387 lines
16 KiB
TypeScript

/**
* Dashboard fact sheet.
*
* Every assistant answer is derived from here, using the same formulas the
* dashboard components use — so the assistant can never state a figure that
* contradicts the card or chart sitting next to it.
*
* This module is pure: dashboard data in, derived facts out. No UI, no
* transport, no dependency on any AI product.
*/
import { isUnlocked } from '@/lib/provingGround';
import { getScoreBand } from '@/lib/talentHome';
import { PRIVILEGED_EVENTS, activitySignals } from '@/lib/activitySignals';
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
export const plural = (n, word, irregular?) =>
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
const avg = (values) =>
values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
const DAY_MS = 1000 * 60 * 60 * 24;
/**
* Events an auditor looks at first: they change who is employed or what is being
* hired for. Defined in `lib/activitySignals.js` alongside the detection that
* uses it, and re-exported here because the fact sheet, the Activity page's
* severity column and the assistant's security answers all import it from this
* module and must keep agreeing on what "privileged" means.
*/
export { PRIVILEGED_EVENTS };
/** `today` is injected rather than read from the clock so answers are stable. */
export function buildFacts({
applications = [],
postings = [],
interviews = [],
staff = [],
profiles = [],
activity = [],
courses = [],
profile = null,
user = null,
today = new Date(),
}) {
const scored = applications.filter((a) => a.ai_score > 0);
const ranked = [...scored].sort((a, b) => b.ai_score - a.ai_score);
const byStatus = (...statuses) => applications.filter((a) => statuses.includes(a.status));
const unscreened = byStatus('applied');
const screened = byStatus('ai_screened', 'shortlisted', 'interview', 'hired');
const interviewing = byStatus('interview');
const hired = byStatus('hired');
const openPositions = postings.filter((p) => p.status === 'active');
const applicationsFor = (id) => applications.filter((a) => a.job_posting_id === id);
const completedInterviews = interviews.filter((i) => i.overall_interview_score > 0);
/* Time-to-hire — the created→updated span ImpactMetrics uses. */
const timeToHire = hired.length
? Math.round(avg(hired.map((a) =>
Math.max(1, (new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / DAY_MS))))
: 0;
/* Open roles attracting nobody. */
const starvedPositions = openPositions.filter((p) => applicationsFor(p.id).length === 0);
/* Roles resting on a single candidate — one withdrawal and the role reopens. */
const singleCandidateRoles = openPositions
.map((p) => ({ posting: p, viable: applicationsFor(p.id).filter((a) => a.ai_score >= 60) }))
.filter((e) => e.viable.length === 1);
/* Strong candidates nobody has moved on — the most expensive kind of delay. */
/* Screened and waiting on a decision — which includes the shortlisted, who
are waiting on exactly that. Restricting this to `ai_screened` read as
correct only while nothing was ever shortlisted: the first shortlisted
candidate would have dropped silently out of the count. positionInsights
draws the same set with ['ai_screened', 'shortlisted']. */
const stalled = ranked.filter(
(a) => a.ai_score >= 80 && ['ai_screened', 'shortlisted'].includes(a.status)
);
const funnel = [
{ key: 'applied', label: 'Applied', count: applications.length },
{ key: 'ai_screened', label: 'AI Screened', count: screened.length },
{ key: 'shortlisted', label: 'Shortlisted', count: byStatus('shortlisted', 'interview', 'hired').length },
{ key: 'interview', label: 'Interviewed', count: interviewing.length + hired.length },
{ key: 'hired', label: 'Hired', count: hired.length },
];
const transitions = funnel.slice(1).map((stage, i) => ({
from: funnel[i].label,
to: stage.label,
/* The same two stages by KEY. `from`/`to` are English labels and several
callers render them directly; the keys are what a translated surface
needs, because a label cannot be looked up in a catalogue — asking for
`stage.Interviewed` returns that string verbatim, which is how a raw key
reached the screen. Both are carried rather than one replaced, so no
existing reader changes behaviour. */
fromKey: funnel[i].key,
toKey: stage.key,
rate: pct(stage.count, funnel[i].count),
lost: Math.max(0, funnel[i].count - stage.count),
}));
const bottleneck = [...transitions].sort((a, b) => a.rate - b.rate)[0] || null;
const byRole = openPositions.map((p) => {
const apps = applicationsFor(p.id);
const roleScored = apps.filter((a) => a.ai_score > 0);
return {
posting: p,
title: p.title,
category: p.role_category || 'Uncategorized',
applied: apps.length,
screened: apps.filter((a) => a.status !== 'applied').length,
hired: apps.filter((a) => a.status === 'hired').length,
avgScore: avg(roleScored.map((a) => a.ai_score)),
qualified: roleScored.filter((a) => a.ai_score >= 70).length,
};
});
const since = (days) => {
const cutoff = today.getTime() - days * DAY_MS;
return activity.filter((e) => new Date(e.created_date).getTime() >= cutoff);
};
const eventCounts = activity.reduce((acc, e) => {
acc[e.event_type] = (acc[e.event_type] || 0) + 1;
return acc;
}, {});
/**
* Out-of-pattern activity.
*
* Computed by `lib/activitySignals.js` rather than here, because it now has
* a second reader: the `activity.signals` data source resolves it for a card
* or an answer, and `dataResolver.js` cannot import from this directory
* without a library reaching up into the component tree.
*
* Moved, not changed. "Two unusual patterns" has to mean the same two
* everywhere it is said, and one function is the only way to guarantee that.
*/
const signals = activitySignals(activity, today);
/* ── KROW Forge ──────────────────────────────────────────────────────────
Derived with the same gate (`isUnlocked`) and the same completion record the
Forge page renders from, so the panel and the page cannot disagree about
what is ready, what is blocked, or why. `profile` is the signed-in worker's
record when one is already in cache; without it the counts are the library's
rather than the person's, which is still true — just less personal. */
const forgeCourses = courses.filter((c) => c.status === 'active');
const forgeCompletedIds = new Set((profile?.completed_courses || []).map((c) => c.course_id));
const forgeGate = (course) => isUnlocked(course, profile || {});
const forgeUnfinished = forgeCourses.filter((c) => !forgeCompletedIds.has(c.id));
const forgeReady = forgeUnfinished.filter((c) => forgeGate(c).unlocked);
const forgeLocked = forgeUnfinished.filter((c) => !forgeGate(c).unlocked);
const forge = {
courses: forgeCourses,
verified: profile?.earned_badges || [],
completed: (profile?.completed_courses || []).filter((c) => c.status !== 'in_progress'),
inProgress: (profile?.completed_courses || []).filter((c) => c.status === 'in_progress'),
ready: forgeReady,
locked: forgeLocked,
/* The page features the first unfinished challenge whether or not it is
unlocked, so the panel names the same one — a chip that recommends a
different challenge than the one on screen reads as a second opinion. */
next: forgeUnfinished[0] || null,
xpAvailable: forgeUnfinished.reduce((sum, c) => sum + (c.xp || 0), 0),
/* Every blocked challenge with the reasons that block it, in page order. */
blocked: forgeLocked.map((course) => ({ course, reasons: forgeGate(course).reasons })),
byDifficulty: ['beginner', 'intermediate', 'advanced'].map((level) => ({
level,
count: forgeCourses.filter((c) => c.difficulty === level).length,
})).filter((d) => d.count),
/* ── The same library, read by the administrator who owns it ──────────
Everything above answers "what can I prove"; everything below answers
"what have we built, and how is the workforce doing with it". Both are
derived from the same records, so the panel cannot disagree with the page
whichever question is asked. Publication state is read with the same
mapping the Forge page uses: `active` is Published. */
library: courses,
published: courses.filter((c) => c.status === 'active' || c.status === 'published'),
drafts: courses.filter((c) => c.status === 'draft'),
archived: courses.filter((c) => c.status === 'archived' || c.status === 'inactive'),
withChallenge: courses.filter((c) => c.challenge?.type),
withTraining: courses.filter((c) => c.training_outline?.length || c.quiz?.length),
byCategory: [...courses.reduce((map, c) => {
const key = c.category || 'Uncategorised';
map.set(key, (map.get(key) || 0) + 1);
return map;
}, new Map())]
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count),
/* Verifications the workforce actually holds, counted off worker profiles
rather than off the library — a badge is something a person has. */
workforceVerified: profiles.reduce((sum, p) => {
const ids = new Set(courses.map((c) => c.id));
return sum + (p.earned_badges || []).filter((b) => ids.has(b.course_id)).length;
}, 0),
usage: courses.map((c) => ({
course: c,
completed: profiles.filter((p) => (p.completed_courses || []).some((x) => x.course_id === c.id)).length,
verified: profiles.filter((p) => (p.earned_badges || []).some((b) => b.course_id === c.id)).length,
})),
};
/* ── Talent pool ─────────────────────────────────────────────────────────
The same score bands the Talent Pool page labels profiles with. */
const rankedProfiles = [...profiles].sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0));
const talent = {
ranked: rankedProfiles,
top: rankedProfiles.slice(0, 3),
available: profiles.filter((p) => (p.availability || []).length > 0),
unverified: profiles.filter(
(p) => !(p.earned_badges || []).length
&& !(p.capabilities || []).some((c) => c.status === 'verified')
),
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
avgScore: avg(profiles.filter((p) => p.krow_score > 0).map((p) => p.krow_score)),
bands: Object.values(profiles.reduce<Record<string, { label: string; count: number }>>((acc, p) => {
const { label } = getScoreBand(p.krow_score || 0);
acc[label] ||= { label, count: 0 };
acc[label].count += 1;
return acc;
}, {})).sort((a, b) => b.count - a.count),
};
/* ── Hired history ───────────────────────────────────────────────────────
Staff joined to the application and posting they came from — the same join
the Hired History page performs, so department, speed and score match. */
const hires = staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
return {
...s,
department: posting?.role_category || '—',
timeToHire: app
? Math.max(1, Math.round(
(new Date(app.updated_date).getTime() - new Date(app.created_date).getTime()) / DAY_MS
))
: null,
score: s.ai_score || app?.ai_score || 0,
};
});
const groupHires = (key) => Object.values(hires.reduce<Record<string, {
name: string; count: number; scores: any[]; days: any[]; ratings: any[];
}>>((acc, h) => {
const name = h[key] || 'Unspecified';
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
acc[name].count += 1;
if (h.score) acc[name].scores.push(h.score);
if (h.timeToHire) acc[name].days.push(h.timeToHire);
if (h.client_rating) acc[name].ratings.push(h.client_rating);
return acc;
}, {})).map((e) => ({
name: e.name,
count: e.count,
avgScore: avg(e.scores),
avgDays: avg(e.days),
avgRating: e.ratings.length
? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1))
: null,
})).sort((a, b) => b.count - a.count);
const hiring = {
hires,
byDepartment: groupHires('department'),
byRoleTitle: groupHires('role'),
unrated: hires.filter((h) => !h.client_rating),
rated: hires.filter((h) => h.client_rating),
avgQuality: avg(hires.map((h) => h.score).filter(Boolean)),
avgSpeed: avg(hires.map((h) => h.timeToHire).filter(Boolean)),
strongest: [...hires].sort((a, b) => (b.score || 0) - (a.score || 0))[0] || null,
onboarding: hires.filter((h) => h.status === 'onboarding'),
};
return {
today,
/* The signed-in account — identity and preferences, for the pages that are
about the operator rather than the workforce. */
user,
/* Applications */
applications,
total: applications.length,
scored,
ranked,
top: ranked.slice(0, 3),
weak: ranked.filter((a) => a.ai_score > 0 && a.ai_score < 40),
unscreened,
screened,
interviewing,
hired,
stalled,
avgScore: avg(scored.map((a) => a.ai_score)),
hiredAvgScore: avg(hired.map((a) => a.ai_score)),
missingCredentials: scored.filter((a) => !(a.certifications || []).length),
narrowAvailability: scored.filter((a) => (a.availability || []).length <= 1),
/**
* Candidates a human should look at more closely — either because there is
* no score to judge them on, or because the record has a gap the score does
* not reflect. Deliberately not "weak candidates": every one of these is
* under-assessed rather than assessed and found wanting.
*/
needsDeeperReview: applications.filter((a) => {
if (!(a.ai_score > 0)) return true;
const flaggedInterview = interviews.some(
(i) => i.application_id === a.id && (i.integrity_score ?? 100) < 100
);
return flaggedInterview
|| !(a.certifications || []).length
|| !a.years_experience;
}),
/* Positions */
postings,
openPositions,
starvedPositions,
singleCandidateRoles,
byRole,
applicationsFor,
/* Interviews */
interviews,
completedInterviews,
avgInterviewScore: avg(completedInterviews.map((i) => i.overall_interview_score)),
interviewCompletion: pct(completedInterviews.length, interviews.length),
flagged: interviews.filter((i) => (i.integrity_score ?? 100) < 100),
/* Hires */
staff,
unratedStaff: staff.filter((s) => !s.client_rating),
timeToHire,
hireRate: pct(hired.length, applications.length),
/* Funnel */
funnel,
transitions,
bottleneck,
/* Talent pool */
profiles,
elite: profiles.filter((p) => (p.krow_score || 0) >= 90),
unscoredProfiles: profiles.filter((p) => !(p.krow_score > 0)),
/* Activity */
activity,
activity7d: since(7),
activity24h: since(1),
eventCounts,
activitySignals: signals,
/* Signed-in worker, and the pages built on these records */
profile,
courses,
forge,
talent,
hiring,
/* Economics — mirrors ImpactMetrics */
costSaved: scored.length * 45 + interviews.length * 80,
hoursSaved: Math.round((scored.length * 15 + interviews.length * 30) / 60),
standardizedPct: pct(scored.length, applications.length),
};
}
/* ── Answer formatting ──────────────────────────────────────────────────── */
export const bullets = (items) => items.filter(Boolean).map((t) => `• ${t}`).join('\n');
export const numbered = (items) =>
items.filter(Boolean).map((t, i) => `${i + 1}. ${t}`).join('\n\n');
export const heading = (text) => `**${text}**`;
/** Joins answer blocks, dropping empties so templates can use conditionals. */
export const compose = (...blocks) => blocks.filter((b) => b !== null && b !== undefined && b !== '').join('\n');