Files
2026-08-18 13:41:34 +05:30

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;