+ Open a module to turn each automation on or off and edit policy fields. Use the{" "}
+ info icon in the dialog for how verification
+ and ticketing work on the platform. Global voice:{" "}
+
+ Knowledge base
+
+ .
+
+ )
+}
diff --git a/src/app/dashboard/storefront-chat/page.tsx b/src/app/dashboard/storefront-chat/page.tsx
index ec3b286..7e8b2af 100644
--- a/src/app/dashboard/storefront-chat/page.tsx
+++ b/src/app/dashboard/storefront-chat/page.tsx
@@ -4,7 +4,7 @@ import { prisma } from "@/lib/prisma"
import { storeSelectDashboardStorefrontInbox } from "@/lib/prisma/storeSelects"
import { auth } from "@/auth.vendor"
import { redirect } from "next/navigation"
-import { StorefrontAiSettingsCard } from "@/components/storefront/StorefrontAiSettingsCard"
+import Link from "next/link"
export default async function StorefrontChatInboxPage() {
const session = await auth()
@@ -73,10 +73,16 @@ export default async function StorefrontChatInboxPage() {
-
+
+ L1 AI modules and catalog metafields:{" "}
+
+ JUNO Engine → Modules
+
+ .
+
{sorted.length === 0 ? (
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 1c09b96..9211b32 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -14,7 +14,7 @@ import {
Ticket,
Flag,
Mail,
- FileText,
+ Layers,
HelpCircle,
Store
} from "lucide-react"
@@ -76,7 +76,7 @@ const routes = [
color: "text-cyan-500",
subRoutes: [
{ label: "Knowledge Base", href: "/dashboard/juno-engine/knowledge-base", icon: HelpCircle },
- { label: "Templates", href: "/dashboard/juno-engine/email-templates", icon: FileText },
+ { label: "Modules", href: "/dashboard/juno-engine/modules", icon: Layers },
{ label: "Tickets", href: "/dashboard/juno-engine/tickets", icon: Ticket },
{ label: "Flagged Emails", href: "/dashboard/juno-engine/flagged-email", icon: Flag },
{ label: "Gmail", href: "/dashboard/juno-engine/gmail-connect", icon: Mail },
diff --git a/src/components/kb/StructuredKnowledgeModal.tsx b/src/components/kb/StructuredKnowledgeModal.tsx
index 8df930d..742da26 100644
--- a/src/components/kb/StructuredKnowledgeModal.tsx
+++ b/src/components/kb/StructuredKnowledgeModal.tsx
@@ -3,22 +3,14 @@
import { useCallback, useEffect, useState } from "react"
import { createPortal } from "react-dom"
import {
- KNOWLEDGE_TEMPLATE_FIELDS,
+ STORE_VOICE_FIELDS,
KNOWLEDGE_TEMPLATE_ANSWER_MAX,
- KNOWLEDGE_TEMPLATE_MISC_MAX,
- type KnowledgeTemplateFieldKey,
+ type StoreVoiceFieldKey,
type KnowledgeTemplateStored,
} from "@/lib/kb/knowledgeTemplate"
-import { sharedFieldsByGroup } from "@/lib/kb/sharedFieldCatalog"
import { cn } from "@/lib/utils"
import { Loader2, Save, X } from "lucide-react"
-type KbCounts = {
- documents: number
- faqs: number
- structuredChunks: number
-}
-
type StructuredKnowledgeModalProps = {
open: boolean
onOpenChange: (open: boolean) => void
@@ -41,23 +33,19 @@ export function StructuredKnowledgeModal({
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
- const [answers, setAnswers] = useState>(
+ const [answers, setAnswers] = useState>(
() =>
- KNOWLEDGE_TEMPLATE_FIELDS.reduce(
+ STORE_VOICE_FIELDS.reduce(
(acc, f) => {
acc[f.key] = ""
return acc
},
- {} as Record,
+ {} as Record,
),
)
- const [misc, setMisc] = useState("")
- const [sharedFields, setSharedFields] = useState>({})
const loadForm = useCallback(async () => {
- const res = await fetch(
- `/api/stores/${encodeURIComponent(storeId)}/knowledge-template`,
- )
+ const res = await fetch(`/api/stores/${encodeURIComponent(storeId)}/knowledge-template`)
if (!res.ok) return
const data = (await res.json()) as {
knowledgeTemplate: KnowledgeTemplateStored | null
@@ -65,22 +53,12 @@ export function StructuredKnowledgeModal({
const tmpl = data.knowledgeTemplate
setAnswers((prev) => {
const next = { ...prev }
- for (const f of KNOWLEDGE_TEMPLATE_FIELDS) {
- const v = tmpl?.legacyAnswers?.[f.key]
+ for (const f of STORE_VOICE_FIELDS) {
+ const v = tmpl?.genericAnswers?.[f.key]
next[f.key] = typeof v === "string" ? v : ""
}
return next
})
- setMisc(typeof tmpl?.misc === "string" ? tmpl.misc : "")
- if (tmpl?.sharedFields && typeof tmpl.sharedFields === "object") {
- const sf: Record = {}
- for (const [k, v] of Object.entries(tmpl.sharedFields)) {
- if (typeof v === "string") sf[k] = v
- }
- setSharedFields(sf)
- } else {
- setSharedFields({})
- }
}, [storeId])
useEffect(() => {
@@ -132,8 +110,6 @@ export function StructuredKnowledgeModal({
const payload: Record = {
template: {
answers,
- sharedFields,
- misc: misc.trim() || undefined,
},
}
@@ -146,14 +122,11 @@ export function StructuredKnowledgeModal({
...payload,
...(confirmDestructiveSwitch ? { confirmDestructiveSwitch: true } : {}),
}
- const res = await fetch(
- `/api/stores/${encodeURIComponent(storeId)}/knowledge-template`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- },
- )
+ const res = await fetch(`/api/stores/${encodeURIComponent(storeId)}/knowledge-template`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
const j = await res.json().catch(() => ({}))
if (res.status === 400 && j.code === "CONFIRM_REQUIRED") {
const ok = window.confirm(
@@ -203,7 +176,7 @@ export function StructuredKnowledgeModal({
e.stopPropagation()}
@@ -222,7 +195,7 @@ export function StructuredKnowledgeModal({
isLight ? "text-slate-900" : "text-white",
)}
>
- Structured store profile
+ Store voice for AI
- Answers are embedded for AI retrieval. Skip any field you
- don't need.
+ A few basics about tone and boundaries. Per-topic policies live under{" "}
+ JUNO Engine → AI modules.
- When enabled, the storefront assistant may use synced product/category metafields
- from Shopify for factual answers. When off, it falls back to your knowledge base
- only.
-
-
- {message ? (
-
{message}
- ) : null}
-
- )
-}
diff --git a/src/lib/ai/gmailGroq.ts b/src/lib/ai/gmailGroq.ts
index 5a67810..c578c02 100644
--- a/src/lib/ai/gmailGroq.ts
+++ b/src/lib/ai/gmailGroq.ts
@@ -1,12 +1,8 @@
import { groqChatCompletion } from "@/lib/ai/groqClient"
import {
- GMAIL_INTENTS,
- GMAIL_MOODS,
normalizeClassification,
parseJsonObject,
type GmailClassification,
- type GmailIntent,
- type GmailMood,
} from "@/lib/ai/gmailTaxonomy"
import { logger } from "@/lib/logger"
@@ -70,82 +66,3 @@ export async function classifyCustomerEmail(
source: "groq",
}
}
-
-export type GeneratedReplyPayload = {
- html_body: string
- name: string
- description: string
- intent: GmailIntent
- mood: GmailMood
-}
-
-const GENERATE_SYSTEM = `You write professional HTML email replies for an e-commerce support team.
-
-Rules:
-- Output JSON only with keys: html_body, name, description, intent, mood
-- html_body: a single customer-ready email as HTML (use simple tags: p, br, div). No external scripts. Be empathetic and concise.
-- Use ONLY facts from order_context when present. If order_context is empty, do NOT invent order numbers, tracking URLs, or amounts.
-- name: short internal template title (max 80 chars)
-- description: one line for staff
-- intent and mood must be one of the allowed values exactly.
-
-Allowed intents: ${GMAIL_INTENTS.join(", ")}
-Allowed moods: ${GMAIL_MOODS.join(", ")}`
-
-export async function generateReplyAndTemplateFields(input: {
- subject: string
- bodyExcerpt: string
- classification: GmailClassification
- orderSummary: string
- storeName: string
- customerFirstName: string
-}): Promise {
- const user = JSON.stringify({
- subject: input.subject.slice(0, 500),
- message_excerpt: input.bodyExcerpt.slice(0, 3500),
- classified_intent: input.classification.intent,
- classified_mood: input.classification.mood,
- short_summary: input.classification.short_summary,
- order_context: input.orderSummary || "(none — do not invent order details)",
- store_name: input.storeName,
- customer_name_hint: input.customerFirstName,
- })
-
- const { content, error } = await groqChatCompletion(
- [
- { role: "system", content: GENERATE_SYSTEM },
- { role: "user", content: user },
- ],
- { jsonObject: true, temperature: 0.35, maxTokens: 1500 }
- )
-
- if (!content) {
- logger.warn("Gmail reply generation failed", { error })
- return null
- }
-
- const parsed = parseJsonObject>(content)
- if (!parsed || typeof parsed.html_body !== "string") {
- logger.warn("Gmail reply generation invalid JSON")
- return null
- }
-
- const normalized = normalizeClassification({
- intent: typeof parsed.intent === "string" ? parsed.intent : input.classification.intent,
- mood: typeof parsed.mood === "string" ? parsed.mood : input.classification.mood,
- })
-
- return {
- html_body: parsed.html_body.trim(),
- name:
- typeof parsed.name === "string"
- ? parsed.name.slice(0, 120)
- : `AI reply — ${input.classification.intent}`,
- description:
- typeof parsed.description === "string"
- ? parsed.description.slice(0, 500)
- : "Auto-generated from customer email",
- intent: normalized.intent,
- mood: normalized.mood,
- }
-}
diff --git a/src/lib/ai/gmailTaxonomy.ts b/src/lib/ai/gmailTaxonomy.ts
index 229959c..76654a3 100644
--- a/src/lib/ai/gmailTaxonomy.ts
+++ b/src/lib/ai/gmailTaxonomy.ts
@@ -1,5 +1,5 @@
/**
- * Single source of truth for Gmail auto-reply intent/mood (aligned with seeds + JUNO templates UI).
+ * Gmail triage: intent/mood strings used by `classifyCustomerEmail` and auto-reply guardrails.
*/
export const GMAIL_INTENTS = [
"general",
diff --git a/src/lib/ai/orchestrator/aiModules.ts b/src/lib/ai/orchestrator/aiModules.ts
index e7fcbb0..b894f8b 100644
--- a/src/lib/ai/orchestrator/aiModules.ts
+++ b/src/lib/ai/orchestrator/aiModules.ts
@@ -1,40 +1,158 @@
import type { L1Module } from "@/lib/ai/orchestrator/types"
+import { L1_MODULE_IDS } from "@/lib/ai/orchestrator/types"
+import type { L1Pillar } from "@/lib/ai/orchestrator/types"
+import { modulePillar } from "@/lib/ai/orchestrator/vendorModuleGroups"
-const DEFAULT_ENABLED: Record = {
- FAQ: true,
- ORDER_STATUS: true,
- ORDER_SUMMARY: true,
- SHIPPING_TRACKING: true,
- INVENTORY_STOCK: true,
- CATEGORY_METADETAILS: true,
- PRODUCT_FIT: true,
- RETURN_EXCHANGE: true,
- ORDER_CHANGE: true,
- COMPLAINT: true,
- NOT_RECEIVED_MARKED_DELIVERED: true,
- SHIPMENT_STUCK_OR_DELAYED: true,
- PAYMENT_PROBLEM: true,
- UNKNOWN: true,
+export type AutomationKey = "knowledge" | "verifiedLookup" | "ticket"
+
+export type AutomationFlags = Record
+
+const ALL_OFF: AutomationFlags = {
+ knowledge: false,
+ verifiedLookup: false,
+ ticket: false,
}
-export function parseAiModulesJson(raw: unknown): Record {
- if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
- return { ...DEFAULT_ENABLED }
+const ALL_ON: AutomationFlags = {
+ knowledge: true,
+ verifiedLookup: true,
+ ticket: true,
+}
+
+function pillarDefaults(pillar: L1Pillar): AutomationFlags {
+ if (pillar === "K") {
+ return { knowledge: true, verifiedLookup: false, ticket: false }
}
- const o = raw as Record
- const out: Record = { ...DEFAULT_ENABLED }
- for (const k of Object.keys(DEFAULT_ENABLED) as L1Module[]) {
- if (k in o && typeof o[k] === "boolean") {
- out[k] = o[k] as boolean
+ if (pillar === "T") {
+ return { knowledge: false, verifiedLookup: true, ticket: false }
+ }
+ return { knowledge: false, verifiedLookup: true, ticket: true }
+}
+
+function categoryDefaults(on: boolean): AutomationFlags {
+ return on
+ ? { knowledge: false, verifiedLookup: true, ticket: false }
+ : ALL_OFF
+}
+
+function normalizeModuleValue(mod: L1Module, raw: unknown): AutomationFlags {
+ if (mod === "UNKNOWN") {
+ return ALL_ON
+ }
+
+ if (mod === "CATEGORY_METADETAILS") {
+ if (raw === false) return ALL_OFF
+ if (raw === true || raw == null) return categoryDefaults(true)
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
+ const o = raw as Record
+ const base = categoryDefaults(true)
+ return {
+ knowledge: typeof o.knowledge === "boolean" ? o.knowledge : base.knowledge,
+ verifiedLookup:
+ typeof o.verifiedLookup === "boolean" ? o.verifiedLookup : base.verifiedLookup,
+ ticket: typeof o.ticket === "boolean" ? o.ticket : base.ticket,
+ }
}
+ return categoryDefaults(true)
+ }
+
+ const pillar = modulePillar(mod)
+ const d = pillarDefaults(pillar)
+
+ let result: AutomationFlags
+ if (raw === false) {
+ result = ALL_OFF
+ } else if (raw === true || raw == null) {
+ result = { ...d }
+ } else if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
+ const o = raw as Record
+ result = {
+ knowledge: typeof o.knowledge === "boolean" ? o.knowledge : d.knowledge,
+ verifiedLookup:
+ typeof o.verifiedLookup === "boolean" ? o.verifiedLookup : d.verifiedLookup,
+ ticket: typeof o.ticket === "boolean" ? o.ticket : d.ticket,
+ }
+ } else {
+ result = { ...d }
+ }
+ if (pillar === "X" && result.ticket) {
+ result = { ...result, verifiedLookup: true }
+ }
+ return result
+}
+
+/**
+ * Parse `Store.aiModules` JSON into per-module automation flags.
+ * Legacy `true` / `false` booleans map to pillar defaults or all-off.
+ */
+export function parseAiModulesJson(raw: unknown): Record {
+ const o =
+ raw != null && typeof raw === "object" && !Array.isArray(raw)
+ ? (raw as Record)
+ : {}
+
+ const out = {} as Record
+ for (const id of L1_MODULE_IDS) {
+ out[id] = normalizeModuleValue(id, o[id])
}
return out
}
+export function moduleAnyEnabled(flags: AutomationFlags): boolean {
+ return flags.knowledge || flags.verifiedLookup || flags.ticket
+}
+
export function isModuleEnabled(
- modules: Record,
+ modules: Record,
module: L1Module,
): boolean {
if (module === "UNKNOWN") return true
- return modules[module] !== false
+ return moduleAnyEnabled(modules[module])
+}
+
+export function isAutomationEnabled(
+ modules: Record,
+ module: L1Module,
+ key: AutomationKey,
+): boolean {
+ if (module === "UNKNOWN") return true
+ return Boolean(modules[module][key])
+}
+
+/** Identity gate (OTP / inbox) for transactional and ticket pillars. */
+export function needsIdentityGate(pillar: L1Pillar, flags: AutomationFlags): boolean {
+ if (pillar === "K") return false
+ if (pillar === "X") {
+ return flags.ticket
+ }
+ return flags.verifiedLookup || flags.ticket
+}
+
+/**
+ * Serialize for PATCH: use `false`, `true`, or explicit `{ knowledge, verifiedLookup, ticket }` when non-default.
+ */
+export function serializeAiModules(
+ modules: Record,
+): Record {
+ const out: Record = {}
+ for (const id of L1_MODULE_IDS) {
+ if (id === "UNKNOWN") continue
+ const f = modules[id]
+ const d = id === "CATEGORY_METADETAILS" ? categoryDefaults(true) : pillarDefaults(modulePillar(id))
+
+ if (!moduleAnyEnabled(f)) {
+ out[id] = false
+ continue
+ }
+ if (f.knowledge === d.knowledge && f.verifiedLookup === d.verifiedLookup && f.ticket === d.ticket) {
+ out[id] = true
+ continue
+ }
+ out[id] = {
+ knowledge: f.knowledge,
+ verifiedLookup: f.verifiedLookup,
+ ticket: f.ticket,
+ }
+ }
+ return out
}
diff --git a/src/lib/ai/orchestrator/intentRouter.ts b/src/lib/ai/orchestrator/intentRouter.ts
index 55182b8..8dc5b7e 100644
--- a/src/lib/ai/orchestrator/intentRouter.ts
+++ b/src/lib/ai/orchestrator/intentRouter.ts
@@ -2,39 +2,52 @@ import { groqChatCompletion } from "@/lib/ai/groqClient"
import { logger } from "@/lib/logger"
import type { L1Module } from "@/lib/ai/orchestrator/types"
+/** All routable modules except CATEGORY_METADETAILS (store toggle only; sizing routes via PRODUCT_FIT). */
const MODULES: L1Module[] = [
"FAQ",
+ "STORE_LOCAL",
+ "FEEDBACK",
+ "HUMAN_ESCALATION",
+ "PRODUCT_FIT",
"ORDER_STATUS",
"ORDER_SUMMARY",
"SHIPPING_TRACKING",
+ "ORDER_CANCEL",
+ "REFUND_STATUS",
"INVENTORY_STOCK",
- "CATEGORY_METADETAILS",
- "PRODUCT_FIT",
"RETURN_EXCHANGE",
"ORDER_CHANGE",
"COMPLAINT",
"NOT_RECEIVED_MARKED_DELIVERED",
"SHIPMENT_STUCK_OR_DELAYED",
"PAYMENT_PROBLEM",
+ "WARRANTY",
+ "WRONG_ITEM",
"UNKNOWN",
]
const ROUTER_PROMPT = `You route ecommerce customer messages to exactly one module.
Modules:
-- FAQ: general policies, how shipping works, refund policy (not status), hours, contact.
+- FAQ: general policies, how shipping works, refund policy wording (not live refund/payout status for an order).
+- STORE_LOCAL: hours, pickup, local/in-store, physical location.
+- FEEDBACK: thanks, praise, short satisfaction (not a complaint).
+- HUMAN_ESCALATION: wants a real person, speak to someone, agent.
+- PRODUCT_FIT: sizing, fit, compatibility, materials for a product.
- ORDER_STATUS: where is my order, processing time, not shipped yet.
- ORDER_SUMMARY: receipt, what did I order, line items recap.
- SHIPPING_TRACKING: tracking number, carrier, delivered, ETA.
+- ORDER_CANCEL: cancel my order, can I still cancel.
+- REFUND_STATUS: has my refund been issued, refund timeline for MY order (not generic policy).
- INVENTORY_STOCK: is item in stock, availability.
-- CATEGORY_METADETAILS: materials, voltage, category-level specs from catalog.
-- PRODUCT_FIT: sizing, fit, compatibility (may overlap FAQ).
- RETURN_EXCHANGE: start a return or exchange.
- ORDER_CHANGE: change address/items/qty.
-- COMPLAINT: strong dissatisfaction.
+- COMPLAINT: strong dissatisfaction, angry escalation.
- NOT_RECEIVED_MARKED_DELIVERED: tracking says delivered but customer did not receive.
- SHIPMENT_STUCK_OR_DELAYED: package not moving / very late.
-- PAYMENT_PROBLEM: double charge, wrong amount, promo failed.
+- PAYMENT_PROBLEM: double charge, wrong amount, promo failed, payment declined.
+- WARRANTY: warranty claim, defect after return window.
+- WRONG_ITEM: wrong item, damaged item, missing item from order.
- UNKNOWN: does not fit above.
Return JSON only: {"module":"","missing_slots":["order_ref"|"product_query"|"reason"|"issue"]}
@@ -58,7 +71,7 @@ export async function routeCustomerIntent(userMessage: string): Promise typeof x === "string")
: []
- return { module, missing_slots: slots }
+ return { module: mod, missing_slots: slots }
} catch {
logger.warn("intentRouter: invalid JSON", { preview: content.slice(0, 120) })
return { module: "FAQ", missing_slots: [] }
diff --git a/src/lib/ai/orchestrator/manifests.ts b/src/lib/ai/orchestrator/manifests.ts
index 397f1af..75902b0 100644
--- a/src/lib/ai/orchestrator/manifests.ts
+++ b/src/lib/ai/orchestrator/manifests.ts
@@ -8,18 +8,25 @@ export type ModuleManifest = {
export const MODULE_MANIFESTS: Record = {
FAQ: { pillar: "K", typicalSlots: [] },
+ CATEGORY_METADETAILS: { pillar: "K", typicalSlots: [] },
+ STORE_LOCAL: { pillar: "K", typicalSlots: [] },
+ FEEDBACK: { pillar: "K", typicalSlots: [] },
+ HUMAN_ESCALATION: { pillar: "K", typicalSlots: [] },
+ PRODUCT_FIT: { pillar: "K", typicalSlots: ["product_query"] },
ORDER_STATUS: { pillar: "T", typicalSlots: ["order_ref"] },
ORDER_SUMMARY: { pillar: "T", typicalSlots: ["order_ref"] },
SHIPPING_TRACKING: { pillar: "T", typicalSlots: ["order_ref"] },
+ ORDER_CANCEL: { pillar: "T", typicalSlots: ["order_ref"] },
+ REFUND_STATUS: { pillar: "T", typicalSlots: ["order_ref"] },
INVENTORY_STOCK: { pillar: "T", typicalSlots: ["product_query"] },
- CATEGORY_METADETAILS: { pillar: "CAT", typicalSlots: ["product_query"] },
- PRODUCT_FIT: { pillar: "K", typicalSlots: ["product_query"] },
RETURN_EXCHANGE: { pillar: "X", typicalSlots: ["order_ref", "reason"] },
ORDER_CHANGE: { pillar: "X", typicalSlots: ["order_ref", "change_request"] },
COMPLAINT: { pillar: "X", typicalSlots: ["issue"] },
NOT_RECEIVED_MARKED_DELIVERED: { pillar: "X", typicalSlots: ["order_ref"] },
SHIPMENT_STUCK_OR_DELAYED: { pillar: "X", typicalSlots: ["order_ref"] },
PAYMENT_PROBLEM: { pillar: "X", typicalSlots: ["order_ref", "issue"] },
+ WARRANTY: { pillar: "X", typicalSlots: ["order_ref", "issue"] },
+ WRONG_ITEM: { pillar: "X", typicalSlots: ["order_ref", "issue"] },
UNKNOWN: { pillar: "K", typicalSlots: [] },
}
diff --git a/src/lib/ai/orchestrator/moduleAutomationUi.ts b/src/lib/ai/orchestrator/moduleAutomationUi.ts
new file mode 100644
index 0000000..b18ef9c
--- /dev/null
+++ b/src/lib/ai/orchestrator/moduleAutomationUi.ts
@@ -0,0 +1,76 @@
+import {
+ L1_MODULE_CARD_TITLE,
+ L1_MODULE_CUSTOMER_LABEL,
+ modulePillar,
+} from "@/lib/ai/orchestrator/vendorModuleGroups"
+import type { L1Module } from "@/lib/ai/orchestrator/types"
+import type { AutomationKey } from "@/lib/ai/orchestrator/aiModules"
+
+export type UiAutomationRow = {
+ key: AutomationKey
+ label: string
+ subtitle?: string
+ /** When true, checkbox is on and disabled (platform requirement). */
+ lockedOn?: boolean
+}
+
+type VendorModule = Exclude
+
+export function automationRowsForModule(mod: VendorModule): UiAutomationRow[] {
+ const pillar = modulePillar(mod)
+ const cardTitle = L1_MODULE_CARD_TITLE[mod]
+ const customer = L1_MODULE_CUSTOMER_LABEL[mod]
+
+ if (mod === "CATEGORY_METADETAILS") {
+ return [
+ {
+ key: "verifiedLookup",
+ label: "Use category fields from your synced catalog",
+ subtitle:
+ "When on, product-fit answers may use category metadetails from your database when tools provide them.",
+ },
+ ]
+ }
+
+ if (pillar === "K") {
+ return [
+ {
+ key: "knowledge",
+ label: `Automated answers — ${cardTitle}`,
+ subtitle: customer,
+ },
+ ]
+ }
+
+ if (pillar === "T") {
+ return [
+ {
+ key: "verifiedLookup",
+ label: `Verified data lookup — ${cardTitle}`,
+ subtitle:
+ "Uses live order or product data after the shopper matches inbox email or completes chat verification.",
+ },
+ {
+ key: "ticket",
+ label: `Support ticket when automation can’t finish — ${cardTitle}`,
+ subtitle:
+ "If data is missing or unclear, hand off to your team instead of only replying from the knowledge base.",
+ },
+ ]
+ }
+
+ // X — ticket pillar
+ return [
+ {
+ key: "verifiedLookup",
+ label: "Verify identity before a ticket",
+ subtitle: "Email match on inbound mail, or email + OTP on the storefront widget.",
+ lockedOn: true,
+ },
+ {
+ key: "ticket",
+ label: `Support ticket — ${cardTitle}`,
+ subtitle: customer,
+ },
+ ]
+}
diff --git a/src/lib/ai/orchestrator/moduleThreeTypes.ts b/src/lib/ai/orchestrator/moduleThreeTypes.ts
new file mode 100644
index 0000000..efc10ec
--- /dev/null
+++ b/src/lib/ai/orchestrator/moduleThreeTypes.ts
@@ -0,0 +1,749 @@
+import type { L1Module } from "@/lib/ai/orchestrator/types"
+
+/** One Type-1 questionnaire row inside a module (several rows per broad module). */
+export type ModuleType1Row = {
+ /** Doc label e.g. OS1, FQ3 */
+ code: string
+ /** Vendor-facing prompt */
+ label: string
+ /** shared_field_id keys; one textarea each (SSoT across modules). */
+ fieldKeys: string[]
+}
+
+export type ModuleThreeTypeSpec = {
+ /** Show link to Knowledge base for G1–G12 (ALL). */
+ referencesGlobalAll: boolean
+ type1Rows: ModuleType1Row[]
+ /** Read-only: verification & prerequisites (platform). No vendor text here. */
+ type2Bullets: string[]
+ /** Read-only: what L1/tickets do. No vendor script — tone from Type 1 + store voice. */
+ type3Bullets: string[]
+}
+
+const T2_NONE_ANONYMOUS_KB = [
+ "None for anonymous FAQ on the widget — answers use knowledge only (§1.2).",
+]
+
+const T2_ORDER_SCOPED = [
+ "Email: From must match the email on the order in our database; multi-order disambiguation when needed (§1.1).",
+ "Widget: email + OTP + session TTL before any order-specific data (§1.2).",
+ "Slots: order reference or pick from a short candidate list.",
+]
+
+const T2_ORDER_SCOPED_STRICT = [
+ ...T2_ORDER_SCOPED,
+ "Same strict verification as other sensitive (fiber) flows.",
+]
+
+const T2_PRODUCT_SKU = [
+ "Slots: product name and/or SKU; disambiguate when many products match.",
+ "Widget: may allow anonymous stock checks, or require OTP if tied to “my order” (product policy).",
+]
+
+const T2_OPTIONAL_PRODUCT = [
+ "Optional product name/SKU for specificity; no order required for general fit questions.",
+]
+
+const T2_CATEGORY_ENABLED = [
+ "You enable this store-wide feature; sync must persist category metadetails in the database.",
+ "Slots: resolve product → category (e.g. name/SKU → category) before tool calls.",
+]
+
+const T2_MINIMAL = [
+ "Minimal prerequisites — optional context only; no order read unless combined with another intent.",
+]
+
+const T2_FEEDBACK = [
+ "Optional link to prior ticket or context if your product supports it.",
+]
+
+const T2_REFUND_STATUS = [
+ "Verified identity plus order or return reference (per your schema).",
+]
+
+const T2_PAYMENT = [
+ "Order-scoped payment issues: verified identity + order id.",
+ "Pre-purchase / checkout failures: may only have email + narrative → ticket with available context.",
+]
+
+const T2_ORDER_CHANGE = [
+ ...T2_ORDER_SCOPED,
+ "Capture requested change (address / items / qty) as slots for the ticket.",
+]
+
+const T2_RETURN = [
+ ...T2_ORDER_SCOPED,
+ "Optional reason / return intent slot.",
+]
+
+const T2_WRONG_ITEM = [
+ ...T2_ORDER_SCOPED,
+ "Issue type slot; email may include photo attachments.",
+]
+
+const T2_WARRANTY = [
+ "Verified identity plus proof-of-purchase / order reference.",
+]
+
+export const MODULE_THREE_TYPES: Record, ModuleThreeTypeSpec> = {
+ FAQ: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "FQ1",
+ label: "Shipping — regions, methods, costs, timelines",
+ fieldKeys: [
+ "sf_shipping_regions_served",
+ "sf_shipping_regions_excluded",
+ "sf_shipping_methods_and_transit",
+ "sf_shipping_cost_model",
+ "sf_processing_time_before_ship",
+ ],
+ },
+ {
+ code: "FQ2",
+ label: "Returns — window, condition, non-returnables, who pays shipping",
+ fieldKeys: [
+ "sf_return_window",
+ "sf_return_condition_requirements",
+ "sf_non_returnable_categories",
+ "sf_return_shipping_payer",
+ "sf_how_to_start_return",
+ ],
+ },
+ {
+ code: "FQ3",
+ label: "Refund policy (wording only — not live refund status)",
+ fieldKeys: [
+ "sf_refund_methods",
+ "sf_refund_timeline_after_approval",
+ "sf_partial_refund_rules",
+ "sf_store_credit_rules",
+ ],
+ },
+ {
+ code: "FQ4",
+ label: "Payment methods & failed payment guidance",
+ fieldKeys: ["sf_payment_methods_accepted", "sf_failed_payment_guidance"],
+ },
+ {
+ code: "FQ5",
+ label: "Promo / discount rules",
+ fieldKeys: ["sf_promo_discount_rules"],
+ },
+ {
+ code: "FQ6",
+ label: "Tax display at checkout",
+ fieldKeys: ["sf_tax_display_policy"],
+ },
+ {
+ code: "FQ7",
+ label: "How customers reach you",
+ fieldKeys: ["sf_support_channels", "sf_support_first_response_sla"],
+ },
+ {
+ code: "FQ8",
+ label: "Policy links (terms, privacy, returns)",
+ fieldKeys: [
+ "sf_policy_link_terms",
+ "sf_policy_link_privacy",
+ "sf_policy_link_returns",
+ ],
+ },
+ ],
+ type2Bullets: T2_NONE_ANONYMOUS_KB,
+ type3Bullets: [
+ "L1: retrieve answers from your Type-1 text and the knowledge base (+ global store voice).",
+ "Out-of-scope questions: suggest email or ticket using your support channels — no fixed script from you here.",
+ ],
+ },
+ CATEGORY_METADETAILS: {
+ referencesGlobalAll: false,
+ type1Rows: [],
+ type2Bullets: T2_CATEGORY_ENABLED,
+ type3Bullets: [
+ "When enabled: tool calls may read category metadetails from the database for all synced categories.",
+ "When disabled or empty: fall back to Product fit / FAQ, PDP links, or escalation — no metadetails tools.",
+ "If data is missing or unclear: clarify or ticket per global / product-fit policy.",
+ ],
+ },
+ STORE_LOCAL: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "SL1",
+ label: "Physical locations, hours, holidays",
+ fieldKeys: ["sf_physical_locations_hours"],
+ },
+ {
+ code: "SL2",
+ label: "Pickup rules",
+ fieldKeys: ["sf_pickup_rules"],
+ },
+ {
+ code: "SL3",
+ label: "In-store vs online inventory",
+ fieldKeys: ["sf_instore_vs_online_inventory"],
+ },
+ {
+ code: "SL4",
+ label: "Local returns (how to start)",
+ fieldKeys: ["sf_how_to_start_return"],
+ },
+ ],
+ type2Bullets: ["None — typical knowledge replies need no identity verification."],
+ type3Bullets: [
+ "L1: KB-only from Type-1 and store voice; no order tools.",
+ ],
+ },
+ FEEDBACK: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "FB1",
+ label: "CSAT / survey process",
+ fieldKeys: ["sf_feedback_csat_process"],
+ },
+ {
+ code: "FB2",
+ label: "Tone & words to avoid (also in global voice)",
+ fieldKeys: ["sf_tone_and_words_to_avoid"],
+ },
+ ],
+ type2Bullets: T2_FEEDBACK,
+ type3Bullets: [
+ "L1: share CSAT / survey link or one-shot ask from Type-1.",
+ "Negative follow-up: route to human when your process requires it.",
+ ],
+ },
+ HUMAN_ESCALATION: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "HE1",
+ label: "When to offer a human",
+ fieldKeys: ["sf_human_escalation_when"],
+ },
+ {
+ code: "HE2",
+ label: "Support channels & SLA",
+ fieldKeys: ["sf_support_channels", "sf_support_first_response_sla"],
+ },
+ {
+ code: "HE3",
+ label: "First message checklist",
+ fieldKeys: ["sf_first_message_checklist"],
+ },
+ ],
+ type2Bullets: T2_MINIMAL,
+ type3Bullets: [
+ "L1: acknowledgement + routing copy grounded in Type-1 and store voice.",
+ "Ticket: created when your rules require — message wording is model-chosen, not vendor-scripted.",
+ ],
+ },
+ PRODUCT_FIT: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "PF1",
+ label: "Sizing & fit guidance",
+ fieldKeys: ["sf_sizing_fit_guidance"],
+ },
+ {
+ code: "PF2",
+ label: "Compatibility (devices, voltage, region)",
+ fieldKeys: ["sf_compatibility_notes"],
+ },
+ {
+ code: "PF3",
+ label: "Materials, care, limitations",
+ fieldKeys: ["sf_materials_care_limitations"],
+ },
+ {
+ code: "PF4",
+ label: "When to escalate to a human",
+ fieldKeys: ["sf_human_escalation_when"],
+ },
+ ],
+ type2Bullets: T2_OPTIONAL_PRODUCT,
+ type3Bullets: [
+ "L1: KB + product search; category metadetails tools only when Category metadetails is enabled.",
+ "Edge cases: escalate per Type-1 — phrasing follows your store voice.",
+ ],
+ },
+ ORDER_STATUS: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "OS1",
+ label: "Normal processing time before shipment",
+ fieldKeys: ["sf_processing_time_before_ship"],
+ },
+ {
+ code: "OS3",
+ label: "If delayed before shipment, what we tell customers",
+ fieldKeys: ["sf_major_delay_communication"],
+ },
+ {
+ code: "OS4",
+ label: "Ship to / do not ship to",
+ fieldKeys: ["sf_shipping_regions_served", "sf_shipping_regions_excluded"],
+ },
+ {
+ code: "OS5",
+ label: "Order # help & first-message checklist",
+ fieldKeys: ["sf_order_id_help", "sf_first_message_checklist"],
+ },
+ ],
+ type2Bullets: T2_ORDER_SCOPED,
+ type3Bullets: [
+ "L1: read order/fulfillment from DB after verification; blend Type-1 messaging.",
+ "Ticket: data missing, anomaly, or outside playbook — no vendor-authored ticket script.",
+ ],
+ },
+ ORDER_SUMMARY: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "SM1",
+ label: "Tax / invoice disclaimer",
+ fieldKeys: ["sf_order_summary_tax_invoice_note"],
+ },
+ {
+ code: "SM2",
+ label: "Order # help & checklist",
+ fieldKeys: ["sf_order_id_help", "sf_first_message_checklist"],
+ },
+ ],
+ type2Bullets: T2_ORDER_SCOPED,
+ type3Bullets: [
+ "L1: read line items/totals; wrap with Type-1 disclaimers.",
+ "Ticket: tax/invoice edge cases per your policy — human decides.",
+ ],
+ },
+ SHIPPING_TRACKING: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "ST1",
+ label: "Carriers & tracking policy",
+ fieldKeys: ["sf_carriers_and_tracking_policy"],
+ },
+ {
+ code: "ST2",
+ label: "Shipping methods & transit expectations",
+ fieldKeys: ["sf_shipping_methods_and_transit"],
+ },
+ {
+ code: "ST3",
+ label: "Shipping cost model & dispatch cutoff",
+ fieldKeys: ["sf_shipping_cost_model", "sf_dispatch_cutoff"],
+ },
+ {
+ code: "ST4",
+ label: "Regions & customs",
+ fieldKeys: ["sf_shipping_regions_served", "sf_customs_duties_policy"],
+ },
+ ],
+ type2Bullets: T2_ORDER_SCOPED,
+ type3Bullets: [
+ "L1: read tracking fields for the verified order + Type-1 carrier copy.",
+ "Ticket: carrier claim/dispute handoff — wording is adaptive.",
+ ],
+ },
+ ORDER_CANCEL: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "CX1",
+ label: "Cancellation window & how to request",
+ fieldKeys: ["sf_cancellation_window", "sf_cancellation_how_to_request"],
+ },
+ {
+ code: "CX2",
+ label: "Pre-order / digital exceptions",
+ fieldKeys: ["sf_cancellation_preorder_digital"],
+ },
+ {
+ code: "CX3",
+ label: "If change/cancel no longer possible — next step",
+ fieldKeys: ["sf_order_change_not_possible_next_step"],
+ },
+ {
+ code: "CX4",
+ label: "Refund timing (policy wording)",
+ fieldKeys: ["sf_refund_timeline_after_approval", "sf_refund_methods"],
+ },
+ ],
+ type2Bullets: T2_ORDER_SCOPED,
+ type3Bullets: [
+ "L1: eligibility + cancel when DB/rules allow; else explain from Type-1.",
+ "Ticket: ambiguous or disallowed cases — human judgment.",
+ ],
+ },
+ REFUND_STATUS: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "RF1",
+ label: "Refund methods & timeline (policy)",
+ fieldKeys: ["sf_refund_methods", "sf_refund_timeline_after_approval"],
+ },
+ {
+ code: "RF2",
+ label: "Partial refunds & store credit",
+ fieldKeys: ["sf_partial_refund_rules", "sf_store_credit_rules"],
+ },
+ ],
+ type2Bullets: T2_REFUND_STATUS,
+ type3Bullets: [
+ "L1: read refund/return state from DB when modeled; align with Type-1 timelines.",
+ "Ticket: state missing or ambiguous after verification.",
+ ],
+ },
+ INVENTORY_STOCK: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "IV1",
+ label: "Back in stock",
+ fieldKeys: ["sf_back_in_stock_policy"],
+ },
+ {
+ code: "IV2",
+ label: "Pre-orders",
+ fieldKeys: ["sf_preorder_policy"],
+ },
+ {
+ code: "IV3",
+ label: "Discontinued products",
+ fieldKeys: ["sf_discontinued_product_policy"],
+ },
+ {
+ code: "IV4",
+ label: "In-store vs online inventory",
+ fieldKeys: ["sf_instore_vs_online_inventory"],
+ },
+ ],
+ type2Bullets: T2_PRODUCT_SKU,
+ type3Bullets: [
+ "L1: inventory read from DB + Type-1 snippets.",
+ "Heavy disambiguation: clarify or ticket — replies use your tone from global + Type-1.",
+ ],
+ },
+ RETURN_EXCHANGE: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "RE1",
+ label: "Return window & condition",
+ fieldKeys: ["sf_return_window", "sf_return_condition_requirements"],
+ },
+ {
+ code: "RE2",
+ label: "Non-returnables & return shipping payer",
+ fieldKeys: ["sf_non_returnable_categories", "sf_return_shipping_payer"],
+ },
+ {
+ code: "RE3",
+ label: "Exchange policy & how to start",
+ fieldKeys: ["sf_exchange_policy", "sf_how_to_start_return"],
+ },
+ {
+ code: "RE4",
+ label: "Refund policy (wording)",
+ fieldKeys: ["sf_refund_methods", "sf_refund_timeline_after_approval"],
+ },
+ ],
+ type2Bullets: T2_RETURN,
+ type3Bullets: [
+ "L1: policy in reply from Type-1 + open ticket (or portal later).",
+ "Human/ops fulfills RMA — no fixed customer script from you.",
+ ],
+ },
+ ORDER_CHANGE: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "OC1",
+ label: "Until when changes are sometimes possible",
+ fieldKeys: ["sf_order_change_allowed_until"],
+ },
+ {
+ code: "OC2",
+ label: "How to request a change",
+ fieldKeys: ["sf_order_change_how_to_request"],
+ },
+ {
+ code: "OC3",
+ label: "When change is not possible — next step",
+ fieldKeys: ["sf_order_change_not_possible_next_step"],
+ },
+ {
+ code: "OC4",
+ label: "Cancel alternative",
+ fieldKeys: ["sf_cancellation_window", "sf_cancellation_how_to_request"],
+ },
+ ],
+ type2Bullets: T2_ORDER_CHANGE,
+ type3Bullets: [
+ "L1 v1: no auto-mutate order — acknowledgement + ticket only.",
+ "Human decides feasibility; tone from Type-1 + store voice.",
+ ],
+ },
+ COMPLAINT: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "CP1",
+ label: "Complaint acknowledgement SLA & escalation",
+ fieldKeys: [
+ "sf_complaint_acknowledgement_sla",
+ "sf_complaint_supervisor_escalation",
+ ],
+ },
+ {
+ code: "CP2",
+ label: "Abusive contact policy",
+ fieldKeys: ["sf_abusive_contact_policy"],
+ },
+ {
+ code: "CP3",
+ label: "Remedies summary & support channels",
+ fieldKeys: ["sf_wrong_item_remedies", "sf_refund_methods", "sf_support_channels"],
+ },
+ ],
+ type2Bullets: T2_ORDER_SCOPED_STRICT,
+ type3Bullets: [
+ "L1: empathetic acknowledgement + severity intake; Type-1 informs boundaries.",
+ "Ticket: always created; human owns resolution — model chooses wording within your tone.",
+ ],
+ },
+ NOT_RECEIVED_MARKED_DELIVERED: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "NR1",
+ label: "“Delivered” but not received — steps & expectations",
+ fieldKeys: ["sf_delivered_not_received_policy"],
+ },
+ {
+ code: "NR2",
+ label: "Carrier / claim expectations",
+ fieldKeys: ["sf_carriers_and_tracking_policy"],
+ },
+ ],
+ type2Bullets: T2_ORDER_SCOPED,
+ type3Bullets: [
+ "L1: tracking snapshot + Type-1, then always open ticket for investigation.",
+ "Human handles carrier/claim.",
+ ],
+ },
+ SHIPMENT_STUCK_OR_DELAYED: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "SD1",
+ label: "What customer should do if shipment seems stuck",
+ fieldKeys: ["sf_stuck_shipment_customer_steps"],
+ },
+ {
+ code: "SD2",
+ label: "Major delay communication",
+ fieldKeys: ["sf_major_delay_communication"],
+ },
+ {
+ code: "SD3",
+ label: "Carriers reference",
+ fieldKeys: ["sf_carriers_and_tracking_policy"],
+ },
+ ],
+ type2Bullets: T2_ORDER_SCOPED,
+ type3Bullets: [
+ "L1: last scan + Type-1 + open ticket for ops/carrier follow-up.",
+ ],
+ },
+ PAYMENT_PROBLEM: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "PP1",
+ label: "Payment methods & failed payment",
+ fieldKeys: ["sf_payment_methods_accepted", "sf_failed_payment_guidance"],
+ },
+ {
+ code: "PP2",
+ label: "Double charge intake",
+ fieldKeys: ["sf_double_charge_intake"],
+ },
+ {
+ code: "PP3",
+ label: "Promo, tax, price adjustment",
+ fieldKeys: [
+ "sf_promo_discount_rules",
+ "sf_tax_display_policy",
+ "sf_price_adjustment_policy",
+ ],
+ },
+ ],
+ type2Bullets: T2_PAYMENT,
+ type3Bullets: [
+ "L1: structured intake + ticket; no automated money movement.",
+ "Finance/human resolves — replies follow your tone, not a fixed script.",
+ ],
+ },
+ WARRANTY: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "WA1",
+ label: "Warranty scope & duration",
+ fieldKeys: ["sf_warranty_duration_scope"],
+ },
+ {
+ code: "WA2",
+ label: "Voiders",
+ fieldKeys: ["sf_warranty_voiders"],
+ },
+ {
+ code: "WA3",
+ label: "Claim process",
+ fieldKeys: ["sf_warranty_claim_process"],
+ },
+ {
+ code: "WA4",
+ label: "Supervisor escalation",
+ fieldKeys: ["sf_complaint_supervisor_escalation"],
+ },
+ ],
+ type2Bullets: T2_WARRANTY,
+ type3Bullets: [
+ "L1: intake + Type-1 + ticket.",
+ "Human adjudicates.",
+ ],
+ },
+ WRONG_ITEM: {
+ referencesGlobalAll: true,
+ type1Rows: [
+ {
+ code: "WI1",
+ label: "Report deadline & evidence",
+ fieldKeys: ["sf_wrong_item_report_deadline", "sf_wrong_item_evidence"],
+ },
+ {
+ code: "WI2",
+ label: "Remedies & carrier vs warehouse damage",
+ fieldKeys: ["sf_wrong_item_remedies", "sf_damage_carrier_vs_warehouse"],
+ },
+ {
+ code: "WI3",
+ label: "Return shipping & how to start return",
+ fieldKeys: ["sf_return_shipping_payer", "sf_how_to_start_return"],
+ },
+ {
+ code: "WI4",
+ label: "Returns policy link",
+ fieldKeys: ["sf_policy_link_returns"],
+ },
+ ],
+ type2Bullets: T2_WRONG_ITEM,
+ type3Bullets: [
+ "L1: acknowledgement + Type-1 + ticket with structured fields.",
+ "Human executes reship/refund/return.",
+ ],
+ },
+}
+
+/** Short labels for textarea placeholders (from vendor doc §5). */
+export const SHARED_FIELD_SHORT_LABEL: Record = {
+ sf_shipping_regions_served: "Where you ship",
+ sf_shipping_regions_excluded: "Where you do not ship",
+ sf_shipping_methods_and_transit: "Methods & transit ranges",
+ sf_shipping_cost_model: "Free / paid / flat / dynamic",
+ sf_processing_time_before_ship: "Handling time before shipment",
+ sf_major_delay_communication: "What you say for major delays / backlog",
+ sf_carriers_and_tracking_policy: "Carriers & when tracking appears",
+ sf_dispatch_cutoff: "Same-day dispatch rules",
+ sf_customs_duties_policy: "Duties / international disclaimer",
+ sf_order_id_help: "How customers find order #",
+ sf_first_message_checklist: "What to include when contacting support",
+ sf_return_window: "Return window",
+ sf_return_condition_requirements: "Condition requirements",
+ sf_non_returnable_categories: "Non-returnable categories",
+ sf_return_shipping_payer: "Who pays return shipping",
+ sf_exchange_policy: "Exchange vs credit vs refund",
+ sf_how_to_start_return: "How to start a return",
+ sf_refund_methods: "Refund methods",
+ sf_refund_timeline_after_approval: "Timeline after approval",
+ sf_partial_refund_rules: "Partial refund rules",
+ sf_store_credit_rules: "Store credit rules",
+ sf_payment_methods_accepted: "Payment methods accepted",
+ sf_failed_payment_guidance: "What customer should retry",
+ sf_promo_discount_rules: "Promo / discount rules",
+ sf_tax_display_policy: "Tax display at checkout",
+ sf_price_adjustment_policy: "Price match / adjustments",
+ sf_policy_link_terms: "Terms of sale URL",
+ sf_policy_link_privacy: "Privacy policy URL",
+ sf_policy_link_returns: "Returns policy URL",
+ sf_support_channels: "Support channels & hours",
+ sf_support_first_response_sla: "First response SLA",
+ sf_tone_and_words_to_avoid: "Tone & words to avoid",
+ sf_feedback_csat_process: "CSAT / survey process",
+ sf_physical_locations_hours: "Locations & hours",
+ sf_pickup_rules: "Pickup rules",
+ sf_instore_vs_online_inventory: "In-store vs online stock",
+ sf_sizing_fit_guidance: "Sizing & fit / size chart",
+ sf_compatibility_notes: "Compatibility",
+ sf_materials_care_limitations: "Materials & care",
+ sf_human_escalation_when: "When to ask a human",
+ sf_cancellation_window: "Cancellation window",
+ sf_cancellation_how_to_request: "How to request cancel",
+ sf_cancellation_preorder_digital: "Pre-order / digital cancel rules",
+ sf_order_change_allowed_until: "Until when edits are possible",
+ sf_order_change_how_to_request: "How to request a change",
+ sf_order_change_not_possible_next_step: "After cutoff / shipped — what you say",
+ sf_order_summary_tax_invoice_note: "Tax / invoice / VAT note",
+ sf_delivered_not_received_policy: "Delivered but not received",
+ sf_stuck_shipment_customer_steps: "Stuck shipment — customer steps",
+ sf_double_charge_intake: "Double charge — what to send",
+ sf_wrong_item_report_deadline: "Wrong item report deadline",
+ sf_wrong_item_evidence: "Evidence (photos, etc.)",
+ sf_wrong_item_remedies: "Remedies you offer",
+ sf_damage_carrier_vs_warehouse: "Carrier vs warehouse damage",
+ sf_warranty_duration_scope: "Warranty length & scope",
+ sf_warranty_voiders: "Warranty voiders",
+ sf_warranty_claim_process: "Warranty claim process",
+ sf_complaint_acknowledgement_sla: "Complaint acknowledgement SLA",
+ sf_complaint_supervisor_escalation: "When a lead reviews",
+ sf_abusive_contact_policy: "Abusive contact boundary",
+ sf_back_in_stock_policy: "Back in stock / notify",
+ sf_preorder_policy: "Pre-order policy",
+ sf_discontinued_product_policy: "Discontinued products",
+}
+
+export function collectFieldKeysForModule(mod: Exclude): string[] {
+ const spec = MODULE_THREE_TYPES[mod]
+ const s = new Set()
+ for (const row of spec.type1Rows) {
+ for (const k of row.fieldKeys) s.add(k)
+ }
+ return Array.from(s)
+}
+
+export function formatSharedFieldsForModule(
+ mod: L1Module,
+ answers: Record,
+): string {
+ if (mod === "UNKNOWN") return ""
+ const keys = collectFieldKeysForModule(mod)
+ if (!keys.length) return ""
+ const lines: string[] = []
+ for (const k of keys) {
+ const v = answers[k]?.trim()
+ if (v) {
+ const lab = SHARED_FIELD_SHORT_LABEL[k] || k
+ lines.push(`${lab} (${k}): ${v}`)
+ }
+ }
+ return lines.length ? `Module policy (Type 1 — ${mod}):\n${lines.join("\n")}` : ""
+}
diff --git a/src/lib/ai/orchestrator/runL1.ts b/src/lib/ai/orchestrator/runL1.ts
index 3aea0dc..e7258b3 100644
--- a/src/lib/ai/orchestrator/runL1.ts
+++ b/src/lib/ai/orchestrator/runL1.ts
@@ -8,13 +8,17 @@ import {
listOrdersForCustomerEmail,
orderNeedsDisambiguation,
} from "@/lib/ai/tools/orderTools"
+import { searchProductsForStore } from "@/lib/ai/tools/productTools"
import {
- getProductMetafieldsForL1,
- searchProductsForStore,
-} from "@/lib/ai/tools/productTools"
-import { isModuleEnabled, parseAiModulesJson } from "@/lib/ai/orchestrator/aiModules"
+ isAutomationEnabled,
+ isModuleEnabled,
+ needsIdentityGate,
+ parseAiModulesJson,
+} from "@/lib/ai/orchestrator/aiModules"
import { pillarForModule } from "@/lib/ai/orchestrator/manifests"
import { routeCustomerIntent } from "@/lib/ai/orchestrator/intentRouter"
+import { formatSharedFieldsForModule } from "@/lib/ai/orchestrator/moduleThreeTypes"
+import { parseSharedFieldAnswersJson } from "@/lib/kb/sharedFieldAnswers"
import type { L1Channel, L1Module, L1RunMeta } from "@/lib/ai/orchestrator/types"
const GENERIC_NO_ORDER_EMAIL =
@@ -23,6 +27,18 @@ const GENERIC_NO_ORDER_EMAIL =
const WIDGET_VERIFY_PROMPT =
"To look up order details or account-specific information, please enter the email you used when placing your order. We will send you a one-time code to verify."
+const FAQ_DISABLED_FALLBACK =
+ "I don't know about this—please contact our support team for help."
+
+/** Transactional modules that load a single order row after verification + disambiguation. */
+const ORDER_DETAIL_MODULES: L1Module[] = [
+ "ORDER_STATUS",
+ "ORDER_SUMMARY",
+ "SHIPPING_TRACKING",
+ "ORDER_CANCEL",
+ "REFUND_STATUS",
+]
+
function isWidgetSessionValid(
verifiedEmail: string | null | undefined,
expiresAt: Date | null | undefined,
@@ -38,8 +54,11 @@ export type RunL1Input = {
storeName: string
message: string
history: Array<{ senderType?: "CUSTOMER" | "AI"; role?: string; content: string }>
- categoryMetadetailsEnabled: boolean
aiModulesJson: unknown
+ /** Type-1 vendor answers keyed by sf_* (optional). */
+ sharedFieldAnswersJson?: unknown
+ /** DB flag; when set, overrides aiModules.CATEGORY_METADETAILS for metadetails tooling. */
+ categoryMetadetailsEnabled?: boolean
emailFrom?: string | null
widgetVerifiedEmail?: string | null
widgetSessionExpiresAt?: Date | null
@@ -107,6 +126,7 @@ function ticketHandoffText(module: L1Module, storeName: string): string {
export async function runL1Orchestrator(input: RunL1Input): Promise {
const modules = parseAiModulesJson(input.aiModulesJson)
+ const sharedAnswers = parseSharedFieldAnswersJson(input.sharedFieldAnswersJson)
const retrieval = await getBlendedStorefrontContext({
storeId: input.storeId,
query: input.message,
@@ -119,13 +139,32 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
: ""
const routed = await routeCustomerIntent(input.message)
- let module: L1Module = routed.module
- if (!isModuleEnabled(modules, module)) {
- module = "FAQ"
+ let activeModule: L1Module = routed.module
+ if (!isModuleEnabled(modules, activeModule)) {
+ activeModule = "FAQ"
+ }
+ if (!isModuleEnabled(modules, activeModule)) {
+ return {
+ text: FAQ_DISABLED_FALLBACK,
+ meta: {
+ module: "UNKNOWN",
+ pillar: "K",
+ usedTools: [],
+ kbHits: retrieval.kbCount,
+ productHits: retrieval.productCount,
+ },
+ }
}
- const pillar = pillarForModule(module)
+ const pillar = pillarForModule(activeModule)
+ const moduleFlags = modules[activeModule]
const usedTools: string[] = []
+ const type1Facts = formatSharedFieldsForModule(activeModule, sharedAnswers)
+ const categoryMetadetailsOn =
+ input.categoryMetadetailsEnabled !== undefined &&
+ input.categoryMetadetailsEnabled !== null
+ ? Boolean(input.categoryMetadetailsEnabled)
+ : isModuleEnabled(modules, "CATEGORY_METADETAILS")
let customerEmail: string | null = null
if (input.channel === "email") {
@@ -136,14 +175,13 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
}
}
- const needsFiber =
- pillar === "T" || pillar === "X" || module === "CATEGORY_METADETAILS"
+ const needsFiber = needsIdentityGate(pillar, moduleFlags)
if (input.channel === "widget" && needsFiber && !customerEmail) {
return {
text: WIDGET_VERIFY_PROMPT,
meta: {
- module,
+ module: activeModule,
pillar,
usedTools: [],
kbHits: retrieval.kbCount,
@@ -152,15 +190,11 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
}
}
- if (
- (pillar === "T" || pillar === "X") &&
- input.channel === "email" &&
- !customerEmail
- ) {
+ if (needsFiber && input.channel === "email" && !customerEmail) {
return {
text: GENERIC_NO_ORDER_EMAIL,
meta: {
- module,
+ module: activeModule,
pillar,
usedTools,
kbHits: retrieval.kbCount,
@@ -169,9 +203,15 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
}
}
- const ordersForEmail = customerEmail
- ? await listOrdersForCustomerEmail(input.storeId, customerEmail)
- : []
+ const shouldListOrders =
+ Boolean(customerEmail) &&
+ (pillar === "X" ||
+ (pillar === "T" && isAutomationEnabled(modules, activeModule, "verifiedLookup")))
+
+ const ordersForEmail =
+ customerEmail && shouldListOrders
+ ? await listOrdersForCustomerEmail(input.storeId, customerEmail)
+ : []
if (
(pillar === "T" || pillar === "X") &&
@@ -179,16 +219,32 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
customerEmail
) {
const facts = [
+ type1Facts,
kbContext ? `Knowledge:\n${kbContext}` : "",
"No orders on file for this email in our cache.",
]
.filter(Boolean)
.join("\n\n")
- if (pillar === "X") {
+ if (pillar === "X" && isAutomationEnabled(modules, activeModule, "ticket")) {
return {
- text: ticketHandoffText(module, input.storeName),
+ text: ticketHandoffText(activeModule, input.storeName),
meta: {
- module,
+ module: activeModule,
+ pillar,
+ usedTools: ["ticket_intake"],
+ kbHits: retrieval.kbCount,
+ productHits: retrieval.productCount,
+ },
+ }
+ }
+ if (
+ pillar === "T" &&
+ isAutomationEnabled(modules, activeModule, "ticket")
+ ) {
+ return {
+ text: ticketHandoffText(activeModule, input.storeName),
+ meta: {
+ module: activeModule,
pillar,
usedTools: ["ticket_intake"],
kbHits: retrieval.kbCount,
@@ -205,7 +261,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
return {
text,
meta: {
- module,
+ module: activeModule,
pillar,
usedTools,
kbHits: retrieval.kbCount,
@@ -214,11 +270,15 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
}
}
- if (pillar === "X" && ordersForEmail.length > 0) {
+ if (
+ pillar === "X" &&
+ ordersForEmail.length > 0 &&
+ isAutomationEnabled(modules, activeModule, "ticket")
+ ) {
return {
- text: ticketHandoffText(module, input.storeName),
+ text: ticketHandoffText(activeModule, input.storeName),
meta: {
- module,
+ module: activeModule,
pillar,
usedTools: ["ticket_intake"],
kbHits: retrieval.kbCount,
@@ -238,18 +298,17 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
orderNeedsDisambiguation(customerEmail, hintText, ordersForEmail)
if (
- (module === "ORDER_STATUS" ||
- module === "ORDER_SUMMARY" ||
- module === "SHIPPING_TRACKING") &&
+ ORDER_DETAIL_MODULES.includes(activeModule) &&
customerEmail &&
disambiguate &&
- !input.boundShopifyOrderId
+ !input.boundShopifyOrderId &&
+ isAutomationEnabled(modules, activeModule, "verifiedLookup")
) {
const list = formatOrderCandidatesForPrompt(ordersForEmail)
return {
text: `We found more than one order for your email. Which order do you mean?\n${list}\n\nReply with the order number (e.g. #1002).`,
meta: {
- module,
+ module: activeModule,
pillar,
usedTools: ["list_orders"],
kbHits: retrieval.kbCount,
@@ -261,9 +320,8 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
let orderFacts = ""
if (
customerEmail &&
- (module === "ORDER_STATUS" ||
- module === "ORDER_SUMMARY" ||
- module === "SHIPPING_TRACKING")
+ ORDER_DETAIL_MODULES.includes(activeModule) &&
+ isAutomationEnabled(modules, activeModule, "verifiedLookup")
) {
const detail = await getOrderDetailForVerifiedEmail(input.storeId, customerEmail, {
shopifyOrderId: input.boundShopifyOrderId ?? undefined,
@@ -271,7 +329,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
})
usedTools.push("get_order_detail")
if (detail) {
- orderFacts = [
+ const lines = [
`Order ${detail.orderNumber} (${detail.shopifyOrderId})`,
`Created: ${detail.shopifyCreatedAt ?? "unknown"}`,
`Financial: ${detail.financialStatus ?? "unknown"}`,
@@ -281,7 +339,18 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
? `Tracking: ${detail.trackingCompany ?? ""} ${detail.trackingNumber ?? ""} ${detail.trackingUrl ?? ""}`.trim()
: "Tracking: not available in our cache yet.",
`Line items (JSON): ${JSON.stringify(detail.lineItems).slice(0, 4000)}`,
- ].join("\n")
+ ]
+ if (activeModule === "ORDER_CANCEL") {
+ lines.push(
+ "Context: customer asked about cancellation — use only the statuses above; do not claim the order was cancelled unless Facts show it.",
+ )
+ }
+ if (activeModule === "REFUND_STATUS") {
+ lines.push(
+ "Context: customer asked about refund/payout status for this order — explain only what Financial/Fulfillment statuses imply; if unclear, say the team can confirm.",
+ )
+ }
+ orderFacts = lines.join("\n")
} else {
orderFacts =
"Order detail could not be loaded (missing order reference or no match for this email)."
@@ -289,11 +358,11 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
}
let productFacts = ""
- if (
- module === "INVENTORY_STOCK" ||
- module === "CATEGORY_METADETAILS" ||
- module === "PRODUCT_FIT"
- ) {
+ const runProductSearch =
+ (activeModule === "PRODUCT_FIT" &&
+ isAutomationEnabled(modules, activeModule, "knowledge")) ||
+ (activeModule === "INVENTORY_STOCK" && isModuleEnabled(modules, activeModule))
+ if (runProductSearch) {
const hits = await searchProductsForStore(input.storeId, input.message, 5)
usedTools.push("search_products")
if (hits.length) {
@@ -304,23 +373,19 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
)
.join("\n")
}
- if (module === "CATEGORY_METADETAILS" && input.categoryMetadetailsEnabled && hits[0]) {
- const mf = await getProductMetafieldsForL1(
- input.storeId,
- hits[0].shopifyProductId,
- true,
- )
- usedTools.push("product_metafields")
- if (mf.metafieldsSummary) {
- productFacts += `\n\nMetafield details (category/catalog):\n${mf.metafieldsSummary}`
- }
- } else if (module === "CATEGORY_METADETAILS" && !input.categoryMetadetailsEnabled) {
- productFacts +=
- "\n\n(Store has category metadetails disabled — use KB only for category facts.)"
+ if (activeModule === "PRODUCT_FIT" && categoryMetadetailsOn) {
+ usedTools.push("category_metadetails_enabled")
+ productFacts = [
+ productFacts,
+ "Store setting: category metadetails from the database are enabled for this shop when tooling provides them (use only if present in Facts).",
+ ]
+ .filter(Boolean)
+ .join("\n\n")
}
}
const facts = [
+ type1Facts,
kbContext ? `Knowledge:\n${kbContext}` : "",
orderFacts ? `Order data:\n${orderFacts}` : "",
productFacts ? `Product search:\n${productFacts}` : "",
@@ -338,7 +403,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
logger.info("[L1_ORCHESTRATOR]", {
storeId: input.storeId,
channel: input.channel,
- module,
+ module: activeModule,
pillar,
usedTools,
})
@@ -346,7 +411,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise
return {
text,
meta: {
- module,
+ module: activeModule,
pillar,
usedTools,
kbHits: retrieval.kbCount,
diff --git a/src/lib/ai/orchestrator/types.ts b/src/lib/ai/orchestrator/types.ts
index 8f06200..1897a75 100644
--- a/src/lib/ai/orchestrator/types.ts
+++ b/src/lib/ai/orchestrator/types.ts
@@ -1,23 +1,54 @@
export type L1Channel = "widget" | "email"
-export type L1Pillar = "K" | "T" | "X" | "CAT"
+export type L1Pillar = "K" | "T" | "X"
export type L1Module =
| "FAQ"
+ | "CATEGORY_METADETAILS"
+ | "STORE_LOCAL"
+ | "FEEDBACK"
+ | "HUMAN_ESCALATION"
+ | "PRODUCT_FIT"
| "ORDER_STATUS"
| "ORDER_SUMMARY"
| "SHIPPING_TRACKING"
+ | "ORDER_CANCEL"
+ | "REFUND_STATUS"
| "INVENTORY_STOCK"
- | "CATEGORY_METADETAILS"
- | "PRODUCT_FIT"
| "RETURN_EXCHANGE"
| "ORDER_CHANGE"
| "COMPLAINT"
| "NOT_RECEIVED_MARKED_DELIVERED"
| "SHIPMENT_STUCK_OR_DELAYED"
| "PAYMENT_PROBLEM"
+ | "WARRANTY"
+ | "WRONG_ITEM"
| "UNKNOWN"
+export const L1_MODULE_IDS: L1Module[] = [
+ "FAQ",
+ "CATEGORY_METADETAILS",
+ "STORE_LOCAL",
+ "FEEDBACK",
+ "HUMAN_ESCALATION",
+ "PRODUCT_FIT",
+ "ORDER_STATUS",
+ "ORDER_SUMMARY",
+ "SHIPPING_TRACKING",
+ "ORDER_CANCEL",
+ "REFUND_STATUS",
+ "INVENTORY_STOCK",
+ "RETURN_EXCHANGE",
+ "ORDER_CHANGE",
+ "COMPLAINT",
+ "NOT_RECEIVED_MARKED_DELIVERED",
+ "SHIPMENT_STUCK_OR_DELAYED",
+ "PAYMENT_PROBLEM",
+ "WARRANTY",
+ "WRONG_ITEM",
+ "UNKNOWN",
+]
+
export type L1RunMeta = {
module: L1Module
pillar: L1Pillar
diff --git a/src/lib/ai/orchestrator/vendorModuleGroups.ts b/src/lib/ai/orchestrator/vendorModuleGroups.ts
new file mode 100644
index 0000000..c345279
--- /dev/null
+++ b/src/lib/ai/orchestrator/vendorModuleGroups.ts
@@ -0,0 +1,98 @@
+import type { L1Module } from "@/lib/ai/orchestrator/types"
+import { MODULE_MANIFESTS } from "@/lib/ai/orchestrator/manifests"
+import type { L1Pillar } from "@/lib/ai/orchestrator/types"
+
+/** Short customer-intent labels aligned with intentRouter modules. */
+export const L1_MODULE_CUSTOMER_LABEL: Record = {
+ FAQ: "General policies, shipping basics, refund policy (not live order/refund status)",
+ CATEGORY_METADETAILS:
+ "Store-wide: allow DB-backed category metadetails in answers (when synced). Sizing/fit flows use this with Product fit.",
+ STORE_LOCAL: "Store hours, pickup, local / in-person info",
+ FEEDBACK: "After-resolution feedback, surveys, thank-yous",
+ HUMAN_ESCALATION: "Customer wants a real person; routing and handoff copy",
+ PRODUCT_FIT:
+ "Sizing, fit, compatibility — knowledge search; category metadetails when that toggle is on",
+ ORDER_STATUS: "Where is my order, processing time, not shipped yet",
+ ORDER_SUMMARY: "Receipt recap, what did I order, line items",
+ SHIPPING_TRACKING: "Tracking number, carrier, delivery / ETA",
+ ORDER_CANCEL: "Cancel order eligibility and requests (DB-backed when available)",
+ REFUND_STATUS: "Status of my refund or return payout (not refund policy text)",
+ INVENTORY_STOCK: "Is this item in stock, availability",
+ RETURN_EXCHANGE: "Start a return or exchange (ticket)",
+ ORDER_CHANGE: "Change address, items, or quantity (ticket)",
+ COMPLAINT: "Strong dissatisfaction (ticket)",
+ NOT_RECEIVED_MARKED_DELIVERED: "Tracking shows delivered but parcel not received (ticket)",
+ SHIPMENT_STUCK_OR_DELAYED: "Shipment not moving or very late (ticket)",
+ PAYMENT_PROBLEM: "Double charge, wrong amount, promo failed (ticket)",
+ WARRANTY: "Warranty claim after return window (ticket)",
+ WRONG_ITEM: "Wrong, damaged, or missing item (ticket)",
+ UNKNOWN: "Other / uncategorized",
+}
+
+type VendorUiModule = Exclude
+
+/** Knowledge modules shown under “Knowledge” (FAQ is a separate card on the dashboard). */
+export const L1_KNOWLEDGE_MODULES_UI = [
+ "STORE_LOCAL",
+ "FEEDBACK",
+ "HUMAN_ESCALATION",
+ "PRODUCT_FIT",
+] as const satisfies readonly VendorUiModule[]
+
+export const L1_TRANSACTIONAL_MODULES_UI = [
+ "ORDER_STATUS",
+ "ORDER_SUMMARY",
+ "SHIPPING_TRACKING",
+ "ORDER_CANCEL",
+ "REFUND_STATUS",
+ "INVENTORY_STOCK",
+] as const satisfies readonly VendorUiModule[]
+
+export const L1_TICKET_MODULES_UI = [
+ "RETURN_EXCHANGE",
+ "ORDER_CHANGE",
+ "COMPLAINT",
+ "NOT_RECEIVED_MARKED_DELIVERED",
+ "SHIPMENT_STUCK_OR_DELAYED",
+ "PAYMENT_PROBLEM",
+ "WARRANTY",
+ "WRONG_ITEM",
+] as const satisfies readonly VendorUiModule[]
+
+/** Short dashboard card titles (grid + modal heading). */
+export const L1_MODULE_CARD_TITLE: Record, string> = {
+ FAQ: "General knowledge (FAQ)",
+ CATEGORY_METADETAILS: "Category metadetails",
+ STORE_LOCAL: "Store hours & local",
+ FEEDBACK: "Feedback & thanks",
+ HUMAN_ESCALATION: "Talk to a human",
+ PRODUCT_FIT: "Product fit & sizing",
+ ORDER_STATUS: "Order status",
+ ORDER_SUMMARY: "Order summary",
+ SHIPPING_TRACKING: "Shipping & tracking",
+ ORDER_CANCEL: "Order cancellation",
+ REFUND_STATUS: "Refund / payout status",
+ INVENTORY_STOCK: "Stock & availability",
+ RETURN_EXCHANGE: "Returns & exchanges",
+ ORDER_CHANGE: "Order changes",
+ COMPLAINT: "Complaints",
+ NOT_RECEIVED_MARKED_DELIVERED: "Delivered, not received",
+ SHIPMENT_STUCK_OR_DELAYED: "Stuck or delayed shipment",
+ PAYMENT_PROBLEM: "Payment problems",
+ WARRANTY: "Warranty",
+ WRONG_ITEM: "Wrong / damaged / missing item",
+}
+
+/** Pillars where the vendor may toggle automation per intent (not FAQ-style knowledge routing). */
+export function pillarHasAutomationToggle(pillar: L1Pillar): boolean {
+ return pillar === "T" || pillar === "X"
+}
+
+export function modulePillar(module: L1Module): L1Pillar {
+ return MODULE_MANIFESTS[module]?.pillar ?? "K"
+}
+
+export function moduleShowsPerIntentToggle(module: L1Module): boolean {
+ if (module === "UNKNOWN") return false
+ return pillarHasAutomationToggle(modulePillar(module))
+}
diff --git a/src/lib/ai/storefrontGroq.ts b/src/lib/ai/storefrontGroq.ts
index 56a2a1e..70bf52e 100644
--- a/src/lib/ai/storefrontGroq.ts
+++ b/src/lib/ai/storefrontGroq.ts
@@ -12,8 +12,9 @@ export async function generateStorefrontReply(params: {
query: string
visitorId: string
history: HistoryMessage[]
- categoryMetadetailsEnabled: boolean
aiModulesJson: unknown
+ sharedFieldAnswersJson?: unknown
+ categoryMetadetailsEnabled?: boolean
widgetVerifiedEmail: string | null
widgetSessionExpiresAt: Date | null
boundShopifyOrderId: string | null
@@ -25,8 +26,9 @@ export async function generateStorefrontReply(params: {
query,
visitorId,
history,
- categoryMetadetailsEnabled,
aiModulesJson,
+ sharedFieldAnswersJson,
+ categoryMetadetailsEnabled,
widgetVerifiedEmail,
widgetSessionExpiresAt,
boundShopifyOrderId,
@@ -38,8 +40,9 @@ export async function generateStorefrontReply(params: {
storeName,
message: query,
history,
- categoryMetadetailsEnabled,
aiModulesJson,
+ sharedFieldAnswersJson,
+ categoryMetadetailsEnabled,
widgetVerifiedEmail,
widgetSessionExpiresAt,
boundShopifyOrderId,
diff --git a/src/lib/api-schemas/schemas.test.ts b/src/lib/api-schemas/schemas.test.ts
index 97bd541..e18f4be 100644
--- a/src/lib/api-schemas/schemas.test.ts
+++ b/src/lib/api-schemas/schemas.test.ts
@@ -13,11 +13,7 @@ import {
supplierProfileUpsertSchema,
productSyncDecisionSchema,
} from "./business"
-import {
- junoTemplateCreateSchema,
- junoTemplatePatchSchema,
- notificationsMarkReadSchema,
-} from "./vendorMutations"
+import { notificationsMarkReadSchema } from "./vendorMutations"
import { webPushSubscribeSchema, webPushUnsubscribeSchema } from "./notificationsPush"
import {
authVerifyMfaSchema,
@@ -124,26 +120,6 @@ describe("productSyncDecisionSchema", () => {
})
})
-describe("junoTemplateCreateSchema", () => {
- it("requires core fields", () => {
- expect(
- junoTemplateCreateSchema.safeParse({
- name: "N",
- intent: "i",
- mood: "m",
- body: "b",
- }).success,
- ).toBe(true)
- expect(junoTemplateCreateSchema.safeParse({ name: "N" }).success).toBe(false)
- })
-})
-
-describe("junoTemplatePatchSchema", () => {
- it("allows partial body", () => {
- expect(junoTemplatePatchSchema.safeParse({ body: "x" }).success).toBe(true)
- })
-})
-
describe("notificationsMarkReadSchema", () => {
it("requires markAll or ids", () => {
expect(notificationsMarkReadSchema.safeParse({ markAllAsRead: true }).success).toBe(true)
diff --git a/src/lib/api-schemas/vendorMutations.ts b/src/lib/api-schemas/vendorMutations.ts
index f1a08f1..07ad28d 100644
--- a/src/lib/api-schemas/vendorMutations.ts
+++ b/src/lib/api-schemas/vendorMutations.ts
@@ -1,60 +1,5 @@
import { z } from "zod"
-const TEMPLATE_VARS_JSON_MAX = 50_000
-
-function refineTemplateVariables(variables: unknown, ctx: z.RefinementCtx, path: (string | number)[]) {
- try {
- const s = JSON.stringify(variables)
- if (s.length > TEMPLATE_VARS_JSON_MAX) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path,
- message: "variables JSON too large",
- })
- }
- } catch {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path,
- message: "Invalid variables",
- })
- }
-}
-
-/** POST /api/juno-engine/templates */
-export const junoTemplateCreateSchema = z
- .object({
- name: z.string().trim().min(1).max(200),
- intent: z.string().trim().min(1).max(100),
- mood: z.string().trim().min(1).max(100),
- description: z.string().trim().max(2000).nullable().optional(),
- body: z.string().max(200_000),
- variables: z.unknown().optional(),
- isHtml: z.boolean().optional(),
- })
- .superRefine((data, ctx) => {
- if (data.variables !== undefined) {
- refineTemplateVariables(data.variables, ctx, ["variables"])
- }
- })
-
-/** PUT /api/juno-engine/templates/[templateId] — partial update */
-export const junoTemplatePatchSchema = z
- .object({
- name: z.string().trim().min(1).max(200).optional(),
- intent: z.string().trim().min(1).max(100).optional(),
- mood: z.string().trim().min(1).max(100).optional(),
- description: z.string().trim().max(2000).nullable().optional(),
- body: z.string().max(200_000).optional(),
- variables: z.unknown().optional(),
- isHtml: z.boolean().optional(),
- })
- .superRefine((data, ctx) => {
- if (data.variables !== undefined) {
- refineTemplateVariables(data.variables, ctx, ["variables"])
- }
- })
-
/** POST /api/notifications — mark read */
export const notificationsMarkReadSchema = z
.object({
@@ -70,6 +15,4 @@ export const notificationsMarkReadSchema = z
})
})
-export type JunoTemplateCreateInput = z.infer
-export type JunoTemplatePatchInput = z.infer
export type NotificationsMarkReadInput = z.infer
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 498eebf..74bfac2 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -2,7 +2,7 @@ import nodemailer from "nodemailer"
import { logger } from "@/lib/logger"
import { getBaseUrl } from "@/lib/utils"
-/** Returns the absolute URL for the JUNO logo (for use in email templates) */
+/** Returns the absolute URL for the JUNO logo (for use in outbound HTML emails) */
export function getEmailLogoUrl(): string {
return `${getBaseUrl()}/icon.png`
}
diff --git a/src/lib/gmail/autoReplyPreview.ts b/src/lib/gmail/autoReplyPreview.ts
index 0aff500..2a621db 100644
--- a/src/lib/gmail/autoReplyPreview.ts
+++ b/src/lib/gmail/autoReplyPreview.ts
@@ -1,7 +1,7 @@
import { prisma } from "@/lib/prisma"
import { storeSelectGmailL1Context } from "@/lib/prisma/storeSelects"
import { logger } from "@/lib/logger"
-import { escapeHtmlForEmail, escapeTemplateContextForHtml, renderTemplate } from "@/lib/templates/simpleRenderer"
+import { escapeHtmlForEmail } from "@/lib/templates/simpleRenderer"
import { classifyCustomerEmail, type ClassifyCustomerEmailResult } from "@/lib/ai/gmailGroq"
import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety"
import {
@@ -11,17 +11,9 @@ import {
} from "@/lib/ai/gmailContext"
import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure"
import { parseEnvFloat } from "@/lib/gmail/gmailAutoReplyGuards"
-import { findTemplateWithFallback, type TemplateRecord } from "@/lib/juno/templatesRepo"
import type { GmailClassification } from "@/lib/ai/gmailTaxonomy"
import { runL1Orchestrator } from "@/lib/ai/orchestrator/runL1"
-/** Local-part of email, title-cased for greetings (templates use {{customer_name}}). */
-function greetingNameFromEmail(email: string): string {
- const local = email.split("@")[0]?.trim() || ""
- if (!local) return "there"
- return local.charAt(0).toUpperCase() + local.slice(1).toLowerCase()
-}
-
export function buildMinimalFallbackHtml(emailBody: string, storeName: string): string {
const safeSnippet = escapeHtmlForEmail(emailBody.replace(/<[^>]+>/g, "").slice(0, 400))
return `
@@ -49,7 +41,7 @@ ${safeSnippet || "No additional message content was detected."}
`
}
-/** When no template exists but we need an order # before sharing order rows. */
+/** Fallback when we need an order number before sharing order rows from cache. */
export function buildNeedOrderIdFallbackHtml(storeName: string, storeEmail: string): string {
const contact = storeEmail.trim()
? `Reply with your order number, or email ${storeEmail}.`
@@ -109,7 +101,7 @@ function buildL1HtmlEmail(plainBody: string, storeName: string): string {
`
}
-/** When no off_topic template exists — polite boundary, no order data. */
+/** Off-topic fallback: polite boundary, no order data. */
export function buildOffTopicFallbackHtml(storeName: string, storeEmail: string): string {
const contact = storeEmail.trim()
? `If you have a question about an order from ${storeName}, reply with details or email us at ${storeEmail}.`
@@ -132,23 +124,6 @@ export function buildOffTopicFallbackHtml(storeName: string, storeEmail: string)
`
}
-function renderFromTemplate(
- template: TemplateRecord,
- context: Record,
-): string {
- const safeContext = escapeTemplateContextForHtml(context)
- return template.isHtml
- ? renderTemplate(template.body, safeContext)
- : `
-
- `
-}
-
export type AutoReplyPreviewInput = {
storeId: string
fromEmail: string
@@ -172,12 +147,11 @@ export type AutoReplyPreviewOutput = {
orderSummaryRaw: string
orderSummaryForContext: string
ticketSummary: string
- chosenTemplateId: string | null
usedFallback: "none" | "off_topic" | "need_order_id" | "hint_unmatched" | "minimal"
html: string
flagged: boolean
flaggedReason?: string
- /** True when reply body came from the L1 orchestrator instead of templates. */
+ /** True when reply body came from the L1 orchestrator. */
l1Used?: boolean
}
@@ -213,7 +187,6 @@ export async function generateAutoReplyPreview(
orderSummaryRaw: "",
orderSummaryForContext: "",
ticketSummary: "",
- chosenTemplateId: null,
usedFallback: "minimal",
html: buildMinimalFallbackHtml(emailBody, storeName),
flagged: true,
@@ -336,16 +309,6 @@ export async function generateAutoReplyPreview(
orderSummaryForContext = orderSummaryRaw || noOrdersFallback
}
- const context: Record = {
- customer_name: greetingNameFromEmail(senderEmail),
- customer_email: senderEmail,
- subject: emailSubject,
- store_name: storeName,
- store_email: store?.email || "",
- ticket_summary: ticketSummary,
- order_summary: orderSummaryForContext,
- }
-
const skipL1Orchestrator =
isOffTopic ||
messageTooSparse ||
@@ -353,7 +316,6 @@ export async function generateAutoReplyPreview(
hintUnmatched
let html: string | null = null
- let chosenTemplateId: string | null = null
let l1Used = false
if (!skipL1Orchestrator) {
@@ -364,8 +326,9 @@ export async function generateAutoReplyPreview(
storeName,
message: plainForAi,
history: [],
- categoryMetadetailsEnabled: store?.categoryMetadetailsEnabled ?? false,
aiModulesJson: store?.aiModules ?? null,
+ sharedFieldAnswersJson: store?.sharedFieldAnswers ?? null,
+ categoryMetadetailsEnabled: store?.categoryMetadetailsEnabled,
emailFrom: senderEmail,
})
if (l1.text.trim().length > 0) {
@@ -377,22 +340,6 @@ export async function generateAutoReplyPreview(
}
}
- if (!html) {
- try {
- const template = await findTemplateWithFallback(
- input.storeId,
- classification.intent,
- classification.mood,
- )
- if (template) {
- chosenTemplateId = template.id
- html = renderFromTemplate(template, context)
- }
- } catch (e) {
- logger.error("generateAutoReplyPreview: template load/render failed", e)
- }
- }
-
let usedFallback: AutoReplyPreviewOutput["usedFallback"] = "none"
if (!html) {
if (isOffTopic) {
@@ -426,7 +373,6 @@ export async function generateAutoReplyPreview(
orderSummaryRaw,
orderSummaryForContext,
ticketSummary,
- chosenTemplateId,
usedFallback,
html,
flagged: false,
diff --git a/src/lib/gmail/emailProcessor.ts b/src/lib/gmail/emailProcessor.ts
index f3e0b6e..968c48d 100644
--- a/src/lib/gmail/emailProcessor.ts
+++ b/src/lib/gmail/emailProcessor.ts
@@ -2,23 +2,13 @@ import { getGmailAccessToken } from "../gmail"
import { logger } from "../logger"
import { sendGmailReply } from "./emailService"
import { prisma } from "../prisma"
-import { escapeHtmlForEmail, escapeTemplateContextForHtml, renderTemplate } from "@/lib/templates/simpleRenderer"
-import { classifyCustomerEmail } from "@/lib/ai/gmailGroq"
import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety"
-import {
- buildOrderSummaryForEmail,
- extractOrderNumberHints,
- stripQuotedEmailTrailForHints,
-} from "@/lib/ai/gmailContext"
-import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure"
import { generateAutoReplyPreview } from "@/lib/gmail/autoReplyPreview"
import {
checkGmailAutoReplyRateLimits,
- parseEnvFloat,
releaseGmailInboundLease,
tryAcquireGmailInboundLease,
} from "@/lib/gmail/gmailAutoReplyGuards"
-import { findTemplateWithFallback, type TemplateRecord } from "@/lib/juno/templatesRepo"
export type ProcessIncomingEmailResult = {
success: boolean
@@ -50,128 +40,8 @@ function parseSenderAddress(fromHeader: string): string {
return lone.toLowerCase()
}
-/** Local-part of email, title-cased for greetings (templates use {{customer_name}}). */
-function greetingNameFromEmail(email: string): string {
- const local = email.split("@")[0]?.trim() || ""
- if (!local) return "there"
- return local.charAt(0).toUpperCase() + local.slice(1).toLowerCase()
-}
-
-function buildMinimalFallbackHtml(emailBody: string, storeName: string): string {
- const safeSnippet = escapeHtmlForEmail(emailBody.replace(/<[^>]+>/g, "").slice(0, 400))
- return `
-
-
-
Thanks for reaching out
-
- We have received your message and our team will review it shortly.
-
-
Summary of what you sent:
-
-
-${safeSnippet || "No additional message content was detected."}
-
-
-
- You can reply to this email if you have more details to share.
-
-
- Best regards,
- ${storeName} Support
-
-
-
- `
-}
-
-/** When no template exists but we need an order # before sharing order rows. */
-function buildNeedOrderIdFallbackHtml(storeName: string, storeEmail: string): string {
- const contact = storeEmail.trim()
- ? `Reply with your order number, or email ${storeEmail}.`
- : `Reply with your order number from your confirmation email.`
- return `
-
-
-
Hello,
-
- We didn't see an order number in your message. To look up the right order for ${storeName},
- please reply with your order number (for example #1002 or 1002) from your order confirmation.
-
-
${contact}
-
- Best regards,
- ${storeName} Support
-
-
-
- `
-}
-
-/** When hints did not match cache — no unrelated order rows. */
-function buildHintUnmatchedFallbackHtml(storeName: string, storeEmail: string): string {
- const contact = storeEmail.trim()
- ? `Reply with the correct order number or email us at ${storeEmail}.`
- : `Reply with the order number from your confirmation email.`
- return `
-
-
-
Hello,
-
- We could not match the order number in your message to an order on file for ${storeName} under this email address.
- Please double-check the number or confirm the email you used when placing the order.
-
-
${contact}
-
- Best regards,
- ${storeName} Support
-
-
-
- `
-}
-
-/** When no off_topic template exists — polite boundary, no order data. */
-function buildOffTopicFallbackHtml(storeName: string, storeEmail: string): string {
- const contact = storeEmail.trim()
- ? `If you have a question about an order from ${storeName}, reply with details or email us at ${storeEmail}.`
- : `If you have a question about an order from ${storeName}, reply with your question and order number if you have one.`
- return `
-
-
-
Hello,
-
- This inbox only handles orders, products, shipping, returns, and billing for ${storeName}.
- We can't answer unrelated requests (news, weather, other websites, or general topics).
-