/** * 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} 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;