578 lines
27 KiB
JavaScript
578 lines
27 KiB
JavaScript
/**
|
|
* Owliver — local response engine
|
|
* -------------------------------
|
|
* The assistant's answers, computed in the browser. There is no model call and
|
|
* no service behind this file: it reads `pageContext.summary` — the same
|
|
* normalised page data the panel already holds — and writes the answer from it.
|
|
*
|
|
* Rules this file keeps to:
|
|
* • It is the ONLY place answers are composed. Nothing canned goes into a
|
|
* component.
|
|
* • It answers from the page's own data, so answers move with the page instead
|
|
* of being fixed strings.
|
|
* • It never rejects. A question always gets something useful back.
|
|
*/
|
|
|
|
/* -------------------------------------------------------------- utilities */
|
|
|
|
/** A considered answer reads better with a beat before it. */
|
|
const LATENCY_MIN_MS = 800;
|
|
const LATENCY_MAX_MS = 1500;
|
|
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const n = (value) => Number(value || 0);
|
|
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
const money = (value) => `$${Math.round(n(value)).toLocaleString("en-US")}`;
|
|
/** Hours read as "0" / "37.5", never "0.0". */
|
|
const hrs = (value) => {
|
|
const v = n(value);
|
|
return Number.isInteger(v) ? String(v) : v.toFixed(1);
|
|
};
|
|
|
|
/** "a, b and c" */
|
|
const list = (parts) => {
|
|
const kept = parts.filter(Boolean);
|
|
if (kept.length <= 1) return kept[0] || "";
|
|
return `${kept.slice(0, -1).join(", ")} and ${kept[kept.length - 1]}`;
|
|
};
|
|
|
|
/* ---------------------------------------------------------------- intents */
|
|
|
|
/**
|
|
* Coarse intent detection. First match wins, so the more specific patterns are
|
|
* listed first. This is deliberately shallow — it only has to be good enough to
|
|
* prove the interaction, not to understand language.
|
|
*/
|
|
const INTENTS = [
|
|
// Order Management, listed first so "partially staffed" is not swallowed by
|
|
// the broader `gaps`/`coverage` patterns below. Deliberately narrow: a looser
|
|
// "requested" or "short of headcount" here would hijack the Schedule page's
|
|
// "shifts that are short of requested headcount" prompt away from `gaps`.
|
|
["partial", /\b(partially staffed|part(ially|ly) filled|not fully staffed)\b/i],
|
|
["pending", /\bpending\b|\bunconfirmed\b|\bawaiting (approval|confirmation)\b/i],
|
|
["conflict", /\bconflicts?\b|\bdouble.?book(ed|ing)?\b|\boverlap(ping)?\b/i],
|
|
// "the most attention" is a request for one record, not a ranked list, so it
|
|
// has to outrank `attention` below — which it would otherwise match.
|
|
["first", /\bthe most attention\b/i],
|
|
["drafts", /\bdrafts?\b/i],
|
|
["no_applicants", /\b(no|without|zero) applicants?\b/i],
|
|
["create", /\b(create|add|set up|draft|post)\b[^.?!]{0,24}\b(position|role|shift|order|event|job posting|job)\b|walk me through creating/i],
|
|
// Allow words between the verb and the noun: "orders need staffing attention"
|
|
// reads as this intent just as much as "what needs attention" does.
|
|
["attention", /\b(needs?|need)\b[^.?!]{0,24}\battention\b|what should i (look at|focus on)|prioriti[sz]e/i],
|
|
["first", /\b(needs? people first|most urgent|most immediate|top priority|urgently)\b|\bwhich .*\bfirst\b/i],
|
|
["ready", /\b(ready for interview|shortlist(ed)?|interviews?)\b/i],
|
|
["pipeline", /\b(pipelines?|strongest|strong matche?s?|best candidates?|top candidates?|score well)\b/i],
|
|
["gaps", /\b(staffing gaps?|needs? coverage|short of|uncovered|open seats?|understaffed)\b/i],
|
|
["coverage", /\b(coverage|am i covered|fully staffed|upcoming coverage)\b/i],
|
|
["risk", /\b(risks?|at risk|exposure|blockers?|going wrong)\b/i],
|
|
["health", /\b(health|stable|how are things|how is everything)\b/i],
|
|
// `\bsummar\b` never matched "summarize" — there is no word boundary mid-word,
|
|
// so a typed "Summarize these orders" fell through to the generic answer.
|
|
["summary", /\bsummar(y|ies|ise|ize|ising|izing)\b|\b(overview|short read|give me a short|recap)\b/i],
|
|
["cost", /\b(cost|spend|budget|labor cost|margin)\b/i],
|
|
["upcoming", /\b(upcoming|next week|coming weeks|next seven days|this week)\b/i],
|
|
];
|
|
|
|
const intentOf = (prompt) => {
|
|
const text = String(prompt || "");
|
|
const hit = INTENTS.find(([, pattern]) => pattern.test(text));
|
|
return hit ? hit[0] : "general";
|
|
};
|
|
|
|
/**
|
|
* A bare continuation ("why?", "show me the first one") rather than a question
|
|
* that stands on its own. Length is the discriminator: a suggestion chip like
|
|
* "Why does this week have no scheduled shifts, and what should I do about it?"
|
|
* opens with "why" but is a full question and must be answered as one.
|
|
*/
|
|
const followUpKind = (prompt) => {
|
|
const text = String(prompt || "").trim();
|
|
if (!text || text.split(/\s+/).length > 8) return null;
|
|
if (/^(show|give) me the (first|top|strongest|biggest|worst)\b/i.test(text)) return "first";
|
|
if (/^(why|how so|how come|and\?|go on|tell me more|expand)\b/i.test(text)) return "why";
|
|
// "Show me those orders" — a deictic follow-up. It carries no subject of its
|
|
// own, so it has to be answered against whatever the previous turn was about.
|
|
if (/^(show|list|give)( me)? (those|them|the ones|that list)\b/i.test(text)) return "those";
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* What "show me those" means after a given intent: the same subject, listed out
|
|
* rather than counted. An intent with no detail view answers itself again.
|
|
*/
|
|
const DETAIL_OF = {
|
|
attention: "partial",
|
|
risk: "partial",
|
|
gaps: "partial",
|
|
coverage: "upcoming",
|
|
health: "summary",
|
|
};
|
|
|
|
/** The subject of the last thing the user asked, for a deictic follow-up. */
|
|
const priorIntent = (conversation) => {
|
|
const lastUser = [...(conversation || [])].reverse().find((m) => m.role === "user");
|
|
return lastUser ? intentOf(lastUser.content) : "general";
|
|
};;
|
|
|
|
/* -------------------------------------------------------- page responders */
|
|
/*
|
|
* Each responder receives the page's own summary and returns an answer, or
|
|
* `null` to fall through to the generic contextual answer below. Keys are the
|
|
* `domain` recorded on the page context (pageRegistry domain / page-context
|
|
* keys), so a page picks up the right voice automatically.
|
|
*/
|
|
|
|
const positions = (intent, d) => {
|
|
const needing = n(d.noApplicants);
|
|
const waiting = n(d.awaitingReview);
|
|
const open = n(d.openPositions);
|
|
const top = d.topStrong;
|
|
const neediest = d.neediest;
|
|
|
|
switch (intent) {
|
|
case "attention": {
|
|
if (!needing && !waiting) {
|
|
return `Nothing on this board is blocked right now. ${plural(open, "position is", "positions are")} open and every one of them has applicants moving through review.`;
|
|
}
|
|
const bits = [
|
|
needing && `${plural(needing, "open position has", "open positions have")} no applicants at all`,
|
|
waiting && `${plural(waiting, "candidate is", "candidates are")} sitting in review`,
|
|
top && `${top.title} is leaning on ${plural(n(top.strong), "strong match", "strong matches")}`,
|
|
].filter(Boolean);
|
|
const lead = bits.length === 1 ? "One thing needs a look" : `${bits.length} things need a look`;
|
|
const advice = waiting
|
|
? " I'd clear the review queue first — it's the cheapest way to unblock the rest."
|
|
: " Sourcing is the constraint, not review.";
|
|
return `${lead}. ${list(bits)}.${advice} [Open positions](/Recruiting)`;
|
|
}
|
|
case "drafts":
|
|
return n(d.drafts)
|
|
? `${plural(n(d.drafts), "posting is", "postings are")} still in draft. They won't collect applications until they're published, so if the demand is real those go out first. [Open positions](/Recruiting)`
|
|
: `Nothing is sitting in draft — every posting on this board is published and collecting applications.`;
|
|
case "no_applicants":
|
|
return needing
|
|
? `${plural(needing, "open position has", "open positions have")} no applicants at all${
|
|
neediest ? `, thinnest of them ${neediest.title}` : ""
|
|
}. Those are the ones that quietly miss their start date — widen the posting or push it to the talent pool. [Open positions](/Recruiting)`
|
|
: `Every open position has at least one applicant. Nothing is starting from zero.`;
|
|
case "first": {
|
|
if (neediest) {
|
|
return `**${neediest.title}** is the one to move on. It has ${plural(n(neediest.applicants), "applicant", "applicants")} against live demand, so it's the thinnest pipeline on the board. [Review it](/Recruiting)`;
|
|
}
|
|
return `Every open position has applicants in flight, so there's no single role starving for people right now.`;
|
|
}
|
|
case "ready": {
|
|
const ready = n(d.readyForInterview);
|
|
const inInterview = n(d.inInterview);
|
|
if (!ready && !inInterview) {
|
|
return `No one is shortlisted yet. ${plural(waiting, "candidate is", "candidates are")} still awaiting review — screen those and the interview list builds itself.`;
|
|
}
|
|
return `${plural(ready, "candidate is", "candidates are")} shortlisted and ready for interview${
|
|
inInterview ? `, with ${plural(inInterview, "more already", "more already")} in interview` : ""
|
|
}. [Review candidates](/Recruiting)`;
|
|
}
|
|
case "pipeline": {
|
|
if (top) {
|
|
return `**${top.title}** carries the strongest pipeline — ${plural(n(top.strong), "strong match", "strong matches")}${
|
|
top.waiting ? ` and ${plural(n(top.waiting), "candidate", "candidates")} still awaiting review` : ""
|
|
}. That's where I'd spend the next hour. [Open it](/Recruiting)`;
|
|
}
|
|
return `Nothing is scoring as a strong match yet across ${plural(n(d.totalApplications), "application", "applications")}. The pipeline is wide but shallow — screening is the bottleneck, not sourcing.`;
|
|
}
|
|
case "create":
|
|
return `Happy to start a position. Give me the role title, location, headcount and pay range, and I'll draft it against your existing postings so the requirements stay consistent. [Create a position](/Recruiting)`;
|
|
case "risk":
|
|
return needing
|
|
? `The live risk is ${plural(needing, "open position", "open positions")} with no applicants — those quietly miss their start date. Second is the ${plural(waiting, "candidate", "candidates")} waiting on review; the longer they sit, the more you lose to other offers.`
|
|
: `No hiring risk stands out. Applications are landing and moving; the only thing to watch is review speed.`;
|
|
case "summary":
|
|
case "health":
|
|
return `${plural(n(d.totalPositions), "position", "positions")} in total, ${open} of them open, against ${plural(n(d.totalApplications), "application", "applications")}. ${
|
|
waiting ? `${plural(waiting, "is", "are")} awaiting review, ` : ""
|
|
}${n(d.readyForInterview)} shortlisted and ${n(d.hired)} hired.`;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const candidates = (intent, d) => {
|
|
switch (intent) {
|
|
case "attention":
|
|
case "first":
|
|
case "risk":
|
|
return n(d.unscored)
|
|
? `${plural(n(d.unscored), "candidate has", "candidates have")} no score yet, so they can't be matched to anything. That's the queue to clear first.`
|
|
: `The candidate pool is scored and moving. ${plural(n(d.shortlisted ?? 0), "candidate is", "candidates are")} shortlisted and ready for the next step.`;
|
|
case "pipeline":
|
|
case "ready":
|
|
return `${plural(n(d.shortlisted ?? 0), "candidate is", "candidates are")} shortlisted out of ${plural(n(d.total ?? d.applications ?? 0), "candidate", "candidates")} in the pool. Those are the ones worth interviewing this week. [Review candidates](/Recruiting)`;
|
|
case "summary":
|
|
return `${plural(n(d.total ?? d.applications ?? 0), "candidate", "candidates")} in the pool${
|
|
d.avgScore ? `, averaging a ${d.avgScore} match score` : ""
|
|
}. ${plural(n(d.shortlisted ?? 0), "is", "are")} shortlisted and ${plural(n(d.hired ?? 0), "has", "have")} been hired.`;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const staffing = (intent, d) => {
|
|
const gaps = n(d.shiftGaps);
|
|
const seats = n(d.openSeats);
|
|
const live = n(d.live ?? d.events);
|
|
const soon = n(d.thisWeek);
|
|
|
|
switch (intent) {
|
|
case "attention":
|
|
case "first":
|
|
case "risk":
|
|
if (!gaps && !n(d.understaffed)) {
|
|
return `Nothing needs intervention on this view. ${
|
|
live ? `${plural(live, "live order is", "live orders are")} at requested headcount` : "There are no active orders in the current filters"
|
|
}, and no conflicts are showing.`;
|
|
}
|
|
return `${plural(gaps || n(d.understaffed), "order needs", "orders need")} attention — ${plural(seats, "open seat", "open seats")} in total${
|
|
soon ? `, and ${plural(soon, "order starts", "orders start")} within seven days` : ""
|
|
}. I'd fill the soonest start first. [Open orders](/Events)`;
|
|
case "gaps":
|
|
return gaps
|
|
? `${plural(gaps, "shift is", "shifts are")} short — ${plural(seats, "open seat", "open seats")} across them. Soonest start first is the right order to work them. [Open orders](/Events)`
|
|
: `No coverage gaps in the loaded orders. Every live order is at its requested headcount.`;
|
|
case "coverage":
|
|
case "upcoming":
|
|
return soon
|
|
? `${plural(soon, "order starts", "orders start")} in the next seven days${gaps ? `, and ${plural(gaps, "of them is", "of them are")} still short` : ", all of them covered"}.`
|
|
: `There are no upcoming orders matching the active filters, so there's nothing to cover in the current window.`;
|
|
case "health":
|
|
case "summary":
|
|
return `${plural(live, "live order", "live orders")} out of ${plural(n(d.events), "total", "total")}. ${
|
|
gaps ? `${plural(gaps, "shift is", "shifts are")} short by ${plural(seats, "seat", "seats")}` : "No active conflicts or coverage gaps"
|
|
}, ${soon} starting in the next week.`;
|
|
case "create":
|
|
return `I can raise a new order. Tell me the client, date, role and headcount and I'll draft it. [Create an order](/CreateEvent)`;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Order Management (/Events). Answers only from what that page holds: order
|
|
* counts, staffing status, order status, dates, hubs and businesses. No
|
|
* candidate, position, hiring or platform-health language belongs here.
|
|
*/
|
|
const orders = (intent, d) => {
|
|
const partial = n(d.partial);
|
|
const atRisk = n(d.atRisk);
|
|
const pending = n(d.requested);
|
|
const drafts = n(d.draft);
|
|
const conflicts = n(d.conflicts);
|
|
const openSeats = n(d.openSeats);
|
|
|
|
/** "Northbay Tech Campus — Vendor Fair (Northbay HQ), 08.31.2026 · 4/12 staffed" */
|
|
const line = (o) =>
|
|
`- **${o.business}** — ${o.name} (${o.hub}), ${o.date}${o.time ? `, ${o.time}` : ""} · ${
|
|
o.requested ? `${o.assigned}/${o.requested} staffed` : o.status
|
|
}${o.id ? ` [id:${o.id}]` : ""}`;
|
|
|
|
const listing = (cards, lead, empty) =>
|
|
cards?.length ? `${lead}\n\n${cards.map(line).join("\n")}` : empty;
|
|
|
|
switch (intent) {
|
|
case "risk":
|
|
return listing(
|
|
d.atRiskOrders,
|
|
`${plural(n(d.atRiskOrders?.length || d.atRisk), "upcoming order is", "upcoming orders are")} at risk of going unstaffed (${plural(openSeats, "open seat", "open seats")} total):`,
|
|
`No upcoming order is at risk. Everything ahead of today is at its requested headcount.`
|
|
);
|
|
|
|
case "partial":
|
|
return listing(
|
|
d.partialOrders,
|
|
`${plural(partial, "order is", "orders are")} partially staffed (${plural(d.partialOrders?.reduce((acc, o) => acc + o.gap, 0), "seat", "seats")} still open):`,
|
|
`No order on this page is partially staffed right now.`
|
|
);
|
|
|
|
case "drafts":
|
|
return listing(
|
|
d.draftOrders,
|
|
`${plural(drafts, "order is", "orders are")} currently in draft status (unpublished, awaiting submission):`,
|
|
`No drafts on this page. Every order has been submitted.`
|
|
);
|
|
|
|
case "pending":
|
|
return listing(
|
|
d.pendingOrders,
|
|
`${plural(pending, "order is", "orders are")} pending confirmation:`,
|
|
`Nothing is pending — every order on this page has been confirmed or closed out.`
|
|
);
|
|
|
|
case "attention":
|
|
case "gaps":
|
|
return listing(
|
|
d.gapsOrders || d.partialOrders,
|
|
`${plural(n(d.gapsOrders?.length || partial), "order needs", "orders need")} staffing attention, ranked by open headcount gap:`,
|
|
`No orders require immediate staffing attention.`
|
|
);
|
|
|
|
case "first": {
|
|
const o = d.neediest;
|
|
if (!o) return `Nothing stands out — no order on this page is short of headcount.`;
|
|
return `**${o.business} — ${o.name}** (${o.hub}), ${o.date}${o.time ? `, ${o.time}` : ""} · ${o.assigned}/${o.requested} staffed${o.id ? ` [id:${o.id}]` : ""}`;
|
|
}
|
|
|
|
case "conflict":
|
|
return conflicts
|
|
? `${plural(conflicts, "scheduling conflict", "scheduling conflicts")} across these orders — overlapping assignments that need resolving before the shifts start.`
|
|
: `No staffing conflicts on this page. Nothing is double-booked across these ${plural(n(d.total), "order", "orders")}.`;
|
|
|
|
case "upcoming":
|
|
case "coverage":
|
|
case "gaps":
|
|
return listing(
|
|
d.upcomingOrders,
|
|
`${plural(n(d.upcoming), "order is", "orders are")} scheduled ahead of today${
|
|
atRisk ? `, ${atRisk} of them still short of headcount` : ", all of them fully staffed"
|
|
}:`,
|
|
`Nothing is scheduled ahead of today in the current view.`
|
|
);
|
|
|
|
case "summary":
|
|
case "health":
|
|
return `${plural(n(d.total), "order", "orders")} on this page — ${list([
|
|
n(d.upcoming) && `${d.upcoming} upcoming`,
|
|
n(d.active) && `${d.active} active`,
|
|
partial && `${partial} partially staffed`,
|
|
n(d.full) && `${d.full} fully staffed`,
|
|
drafts && `${drafts} in draft`,
|
|
n(d.past) && `${d.past} past`,
|
|
])}. ${
|
|
openSeats
|
|
? `${plural(n(d.totalAssignedSeats), "seat", "seats")} filled of ${n(d.totalRequestedSeats)} requested, so ${plural(openSeats, "seat is", "seats are")} still open.`
|
|
: "Every requested seat is filled."
|
|
}`;
|
|
|
|
case "create":
|
|
return `I can raise a new order. Tell me the business, hub, date, role and headcount and I'll draft it. [Create an order](/CreateEvent)`;
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const schedule = (intent, d) => {
|
|
const shifts = n(d.totalShifts);
|
|
const hours = n(d.weekHours);
|
|
const cost = n(d.laborCost);
|
|
const short = n(d.understaffed);
|
|
const seats = n(d.openSeats);
|
|
|
|
switch (intent) {
|
|
case "gaps":
|
|
case "attention":
|
|
case "first":
|
|
if (!shifts) {
|
|
return `There are no shifts on the board this week, so the gap is schedule coverage itself rather than any one staffing conflict. Nothing can be filled until shifts exist. [Open the scheduler](/SmartScheduler)`;
|
|
}
|
|
return short
|
|
? `${plural(short, "shift is", "shifts are")} short of requested headcount — ${plural(seats, "open seat", "open seats")}. [Open the schedule](/Schedule)`
|
|
: `Every one of this week's ${shifts} shifts is at requested headcount. No gaps to close.`;
|
|
case "coverage":
|
|
case "upcoming":
|
|
return shifts
|
|
? `${plural(shifts, "shift is", "shifts are")} scheduled this week${short ? `, ${short} of them still short` : " and fully covered"}. Coverage holds through the displayed week.`
|
|
: `There are no scheduled shifts in the displayed week. Coverage will need to be built before operations begin. [Open the scheduler](/SmartScheduler)`;
|
|
case "cost":
|
|
return cost
|
|
? `This week runs ${money(cost)} in labor across ${hrs(hours)} hours and ${plural(shifts, "shift", "shifts")} — roughly ${money(cost / Math.max(hours, 1))} an hour blended.`
|
|
: `Labor cost is ${money(0)} this week because nothing is scheduled yet.`;
|
|
case "summary":
|
|
case "health":
|
|
return `This week's schedule currently has ${plural(shifts, "shift", "shifts")}, ${hrs(hours)} total hours and ${money(cost)} labor cost.${
|
|
short ? ` ${plural(short, "shift is", "shifts are")} short by ${plural(seats, "seat", "seats")}.` : ""
|
|
}`;
|
|
case "create":
|
|
return `Let's build one. Give me the date, role, headcount and start/end time and I'll place it on this week's board. [Open the scheduler](/SmartScheduler)`;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const command = (intent, d) => {
|
|
const understaffed = n(d.understaffed);
|
|
const overdue = n(d.overdueInvoices);
|
|
|
|
switch (intent) {
|
|
case "attention":
|
|
case "first":
|
|
case "risk":
|
|
if (!understaffed && !overdue) {
|
|
return `Nothing is on fire. ${plural(n(d.activeEvents ?? d.openEvents), "order is", "orders are")} active${
|
|
d.fillRate != null ? ` at a ${d.fillRate}% fill rate` : ""
|
|
}, and no invoices are past due.`;
|
|
}
|
|
return `${list([
|
|
understaffed && `${plural(understaffed, "order still needs", "orders still need")} coverage`,
|
|
overdue && `${plural(overdue, "invoice is", "invoices are")} past due${d.overdueAmount ? ` (${money(d.overdueAmount)})` : ""}`,
|
|
])}. Coverage first — it has a date attached. [Open orders](/Events)`;
|
|
case "health":
|
|
case "summary":
|
|
return `Platform health looks ${understaffed || overdue ? "workable but not clean" : "stable"}. ${plural(n(d.events), "order", "orders")}, ${plural(n(d.workers ?? 0), "worker", "workers")} and ${plural(n(d.invoices ?? 0), "invoice", "invoices")} in view${
|
|
d.fillRate != null ? `, fill rate at ${d.fillRate}%` : ""
|
|
}.`;
|
|
case "cost":
|
|
return d.totalSpend
|
|
? `Total spend is showing $${d.totalSpend}M. The concentration is in staffing hours rather than vendor premiums — that's where any margin recovery has to come from.`
|
|
: `No spend figures are loaded on this view, so I can't break down cost from here.`;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const finance = (intent, d) => {
|
|
const overdue = n(d.overdue ?? d.overdueInvoices);
|
|
switch (intent) {
|
|
case "attention":
|
|
case "first":
|
|
case "risk":
|
|
return overdue
|
|
? `${plural(overdue, "invoice is", "invoices are")} overdue with ${money(d.overdueAmount)} outstanding${
|
|
d.topExposure ? `, and ${d.topExposure.client} carries the largest share at ${money(d.topExposure.amount)}` : ""
|
|
}. [Open invoices](/Invoices)`
|
|
: `No collection risk on this view — nothing is overdue or in dispute.`;
|
|
case "summary":
|
|
case "health":
|
|
return `${plural(n(d.open), "open invoice", "open invoices")} worth ${money(d.openAmount)}${
|
|
overdue ? `, ${overdue} of them overdue` : ", none overdue"
|
|
}.`;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const vendors = (intent, d) => {
|
|
switch (intent) {
|
|
case "attention":
|
|
case "first":
|
|
case "risk":
|
|
return n(d.expiringInsurance) || n(d.missingDocs) || n(d.pending)
|
|
? `${list([
|
|
n(d.expiringInsurance) && `${plural(n(d.expiringInsurance), "vendor has", "vendors have")} insurance expiring within 30 days`,
|
|
n(d.missingDocs) && `${plural(n(d.missingDocs), "is", "are")} missing a W-9 or COI`,
|
|
n(d.pending) && `${plural(n(d.pending), "is", "are")} waiting on approval`,
|
|
])}. Compliance first — an expired COI stops them working. [Open vendors](/VendorManagement)`
|
|
: `Every approved vendor is compliant and documented. Nothing needs chasing.`;
|
|
case "summary":
|
|
case "health":
|
|
return `${plural(n(d.active), "active vendor", "active vendors")} of ${n(d.total)} across ${plural(n(d.regions), "region", "regions")}.`;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const workforce = (intent, d) => {
|
|
switch (intent) {
|
|
case "attention":
|
|
case "first":
|
|
case "risk":
|
|
return n(d.expiredChecks) || n(d.unreliable)
|
|
? `${list([
|
|
n(d.expiredChecks) && `${plural(n(d.expiredChecks), "worker has", "workers have")} an expired background check`,
|
|
n(d.unreliable) && `${plural(n(d.unreliable), "is", "are")} trending below acceptable reliability`,
|
|
])}. The expired checks are the hard blocker — they can't be assigned. [Open the roster](/StaffDirectory)`
|
|
: `No compliance or reliability blockers on the roster right now.`;
|
|
case "summary":
|
|
case "health":
|
|
return `${plural(n(d.workers), "active worker", "active workers")} across ${plural(n(d.hubs), "hub", "hubs")}${
|
|
n(d.pendingChecks) ? `, ${plural(n(d.pendingChecks), "check", "checks")} still pending` : ""
|
|
}.`;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const RESPONDERS = {
|
|
positions,
|
|
candidates,
|
|
recruiting: positions,
|
|
staffing,
|
|
orders,
|
|
schedule,
|
|
command,
|
|
"admin-command": command,
|
|
finance,
|
|
invoices: finance,
|
|
vendors,
|
|
workforce,
|
|
};
|
|
|
|
/* ------------------------------------------------------------- fall-backs */
|
|
|
|
/**
|
|
* "Why?" after an answer. Returns null when there is nothing to be "why" about,
|
|
* so the caller falls through and answers the prompt on its own terms.
|
|
*/
|
|
const whyAnswer = (ctx, conversation) => {
|
|
const answered = (conversation || []).some(
|
|
(m) => m.role && m.role !== "user" && m.content
|
|
);
|
|
if (!answered || !ctx.headline) return null;
|
|
return `Because of what's actually in this view: ${ctx.headline.replace(/\.$/, "")}. That's the constraint driving it — everything else on this page is downstream of that.`;
|
|
};
|
|
|
|
/** Generic but genuinely page-aware answer when no intent matched. */
|
|
const generic = (ctx) => {
|
|
const facts = Object.entries(ctx.summary || {})
|
|
.filter(([, v]) => typeof v === "number" && v > 0)
|
|
.slice(0, 3)
|
|
.map(([k, v]) => `${v} ${k.replace(/([A-Z])/g, " $1").toLowerCase().trim()}`);
|
|
|
|
return `I've read this page's data${facts.length ? ` — ${list(facts)}` : ""}. I can break down priorities, risks, pipeline strength, staffing and operational activity from this view; ask me about any of those.`;
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ entry */
|
|
|
|
/**
|
|
* @param {object} args
|
|
* @param {string} args.prompt what the user asked
|
|
* @param {object} args.pageContext the same context object sent to the agent
|
|
* @param {Array} args.conversation the thread *before* this prompt
|
|
* @returns {Promise<string>} markdown answer — never throws, never empty
|
|
*/
|
|
export async function getOwliverResponse({ prompt, pageContext, conversation, skipLatency } = {}) {
|
|
// The panel now stages its own thinking line, so a caller that is already
|
|
// pacing the turn asks for the answer without the deliberation beat.
|
|
if (!skipLatency) await wait(LATENCY_MIN_MS + Math.random() * (LATENCY_MAX_MS - LATENCY_MIN_MS));
|
|
|
|
const ctx = {
|
|
domain: pageContext?.domain || "command",
|
|
title: pageContext?.page || "this page",
|
|
summary: pageContext?.summary || {},
|
|
headline: pageContext?.headlineInsight || null,
|
|
};
|
|
|
|
// A bare "why?" is answered against the thread; "show me the first one" is
|
|
// just the page's own "what's most urgent" question, asked casually.
|
|
const kind = followUpKind(prompt);
|
|
if (kind === "why") {
|
|
const why = whyAnswer(ctx, conversation);
|
|
if (why) return why;
|
|
}
|
|
|
|
let intent;
|
|
if (kind === "first") {
|
|
intent = "first";
|
|
} else if (kind === "those") {
|
|
const prior = priorIntent(conversation);
|
|
intent = DETAIL_OF[prior] || prior;
|
|
} else {
|
|
intent = intentOf(prompt);
|
|
}
|
|
const responder = RESPONDERS[ctx.domain];
|
|
const answer = responder ? responder(intent, ctx.summary, ctx) : null;
|
|
|
|
return answer || generic(ctx);
|
|
}
|
|
|
|
export default getOwliverResponse;
|