update frontend ui chages
This commit is contained in:
575
src/lib/owliver/owliverEngine.js
Normal file
575
src/lib/owliver/owliverEngine.js
Normal file
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* 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 } = {}) {
|
||||
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;
|
||||
Reference in New Issue
Block a user