fixed: AI modules
This commit is contained in:
parent
9ccf3cf08c
commit
43149059d4
@ -8,6 +8,8 @@ import { generateStorefrontReply } from "@/lib/ai/storefrontGroq"
|
|||||||
import { applyRateLimit } from "@/lib/rate-limit"
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
import { storefrontChatPostSchema } from "@/lib/api-schemas/public"
|
import { storefrontChatPostSchema } from "@/lib/api-schemas/public"
|
||||||
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||||
|
import { extractOrderNumberHints } from "@/lib/ai/gmailContext"
|
||||||
|
import { getOrderDetailForVerifiedEmail } from "@/lib/ai/tools/orderTools"
|
||||||
|
|
||||||
function normalizeShopDomain(input: string) {
|
function normalizeShopDomain(input: string) {
|
||||||
return input
|
return input
|
||||||
@ -28,6 +30,32 @@ function shopVariants(cleanDomain: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STOREFRONT_TICKET_TRIGGER = "STOREFRONT_TICKET"
|
const STOREFRONT_TICKET_TRIGGER = "STOREFRONT_TICKET"
|
||||||
|
const ESCALATION_ALREADY_DISPATCHED_REPLY =
|
||||||
|
"Your request is already dispatched to our human support team. Please wait for the follow-up email from our team."
|
||||||
|
const ORDER_VERIFICATION_REQUIRED_REPLY =
|
||||||
|
"To help with order-specific requests, please verify your email first using the Order help section below."
|
||||||
|
const ORDER_NOT_FOUND_FOR_VERIFIED_EMAIL_REPLY =
|
||||||
|
"I could not find that order for your verified email. Please double-check the order number and try again."
|
||||||
|
|
||||||
|
function formatEscalationModuleLabel(moduleName?: string): string {
|
||||||
|
const raw = (moduleName || "CUSTOMER_SUPPORT").trim()
|
||||||
|
return raw
|
||||||
|
.split(/[_\s]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStorefrontTicketSubject(moduleName?: string): string {
|
||||||
|
return `${formatEscalationModuleLabel(moduleName)} Request`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrderScopedTicketSubject(moduleName?: string, orderNumber?: string | null): string {
|
||||||
|
const base = buildStorefrontTicketSubject(moduleName)
|
||||||
|
const clean = (orderNumber || "").replace(/^\s*#?\s*/, "").trim()
|
||||||
|
if (!clean) return base
|
||||||
|
return `${base} - Order ${clean}`
|
||||||
|
}
|
||||||
|
|
||||||
function shouldCreateEscalationTicket(ai: { content: string; meta?: { usedTools?: string[]; module?: string } }): boolean {
|
function shouldCreateEscalationTicket(ai: { content: string; meta?: { usedTools?: string[]; module?: string } }): boolean {
|
||||||
if (Array.isArray(ai.meta?.usedTools) && ai.meta.usedTools.includes("ticket_intake")) {
|
if (Array.isArray(ai.meta?.usedTools) && ai.meta.usedTools.includes("ticket_intake")) {
|
||||||
@ -122,10 +150,11 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const recentHistory = await prisma.storefrontChatMessage.findMany({
|
const recentHistory = await prisma.storefrontChatMessage.findMany({
|
||||||
where: { conversationId: conversation.id },
|
where: { conversationId: conversation.id },
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 10,
|
take: 4,
|
||||||
select: { senderType: true, content: true },
|
select: { senderType: true, content: true },
|
||||||
})
|
})
|
||||||
|
const historyForAi = recentHistory.reverse()
|
||||||
|
|
||||||
const sessionOk =
|
const sessionOk =
|
||||||
Boolean(conversation.emailVerifiedAt) &&
|
Boolean(conversation.emailVerifiedAt) &&
|
||||||
@ -137,7 +166,7 @@ export async function POST(req: Request) {
|
|||||||
storeName: store.businessName || "Our store",
|
storeName: store.businessName || "Our store",
|
||||||
query: trimmedContent,
|
query: trimmedContent,
|
||||||
visitorId,
|
visitorId,
|
||||||
history: recentHistory.map((m) => ({
|
history: historyForAi.map((m) => ({
|
||||||
senderType: m.senderType,
|
senderType: m.senderType,
|
||||||
content: m.content,
|
content: m.content,
|
||||||
})),
|
})),
|
||||||
@ -149,21 +178,6 @@ export async function POST(req: Request) {
|
|||||||
boundShopifyOrderId: conversation.boundShopifyOrderId,
|
boundShopifyOrderId: conversation.boundShopifyOrderId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const aiMessage = await prisma.storefrontChatMessage.create({
|
|
||||||
data: {
|
|
||||||
conversationId: conversation.id,
|
|
||||||
senderType: "AI",
|
|
||||||
content: ai.content
|
|
||||||
},
|
|
||||||
select: { id: true, createdAt: true, senderType: true, content: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (shouldCreateEscalationTicket(ai)) {
|
|
||||||
try {
|
|
||||||
const owner = await prisma.store.findUnique({
|
|
||||||
where: { id: store.id },
|
|
||||||
select: { userId: true },
|
|
||||||
})
|
|
||||||
const convoEmail = await prisma.storefrontChatConversation.findUnique({
|
const convoEmail = await prisma.storefrontChatConversation.findUnique({
|
||||||
where: { id: conversation.id },
|
where: { id: conversation.id },
|
||||||
select: { customerEmail: true },
|
select: { customerEmail: true },
|
||||||
@ -172,28 +186,70 @@ export async function POST(req: Request) {
|
|||||||
convoEmail?.customerEmail?.trim().toLowerCase() ||
|
convoEmail?.customerEmail?.trim().toLowerCase() ||
|
||||||
customerEmail?.trim().toLowerCase() ||
|
customerEmail?.trim().toLowerCase() ||
|
||||||
`visitor+${visitorId}@storefront.local`
|
`visitor+${visitorId}@storefront.local`
|
||||||
const ticketSubject = `[Storefront] ${ai.meta.module} · ${conversation.id}`
|
const isEscalation = shouldCreateEscalationTicket(ai)
|
||||||
|
const verifiedEmail =
|
||||||
|
sessionOk && conversation.assertedEmail
|
||||||
|
? conversation.assertedEmail.trim().toLowerCase()
|
||||||
|
: null
|
||||||
|
const requestedOrderHint = extractOrderNumberHints(trimmedContent)[0] ?? null
|
||||||
|
const hasOrderSpecificRequest = Boolean(requestedOrderHint)
|
||||||
|
const verifiedOrder =
|
||||||
|
isEscalation && verifiedEmail && requestedOrderHint
|
||||||
|
? await getOrderDetailForVerifiedEmail(store.id, verifiedEmail, {
|
||||||
|
orderNumberHint: requestedOrderHint,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
const ticketSubject = buildOrderScopedTicketSubject(ai.meta?.module, verifiedOrder?.orderNumber)
|
||||||
|
|
||||||
const existing = await prisma.emailLog.findFirst({
|
// Security-first guardrail:
|
||||||
|
// For order-specific requests, only escalate when the referenced order is verified for the
|
||||||
|
// currently verified email session.
|
||||||
|
const blockedForMissingVerification = isEscalation && hasOrderSpecificRequest && !verifiedEmail
|
||||||
|
const blockedForOrderMismatch = isEscalation && hasOrderSpecificRequest && verifiedEmail && !verifiedOrder
|
||||||
|
|
||||||
|
const existingEscalation =
|
||||||
|
isEscalation && !blockedForMissingVerification && !blockedForOrderMismatch
|
||||||
|
? await prisma.emailLog.findFirst({
|
||||||
where: {
|
where: {
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
|
trigger: STOREFRONT_TICKET_TRIGGER,
|
||||||
to: ticketTo,
|
to: ticketTo,
|
||||||
subject: ticketSubject,
|
subject: ticketSubject,
|
||||||
trigger: STOREFRONT_TICKET_TRIGGER,
|
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
if (existing) {
|
const alreadyEscalated = isEscalation && Boolean(existingEscalation)
|
||||||
await prisma.emailLog.update({
|
const aiContent = blockedForMissingVerification
|
||||||
where: { id: existing.id },
|
? ORDER_VERIFICATION_REQUIRED_REPLY
|
||||||
|
: blockedForOrderMismatch
|
||||||
|
? ORDER_NOT_FOUND_FOR_VERIFIED_EMAIL_REPLY
|
||||||
|
: alreadyEscalated
|
||||||
|
? ESCALATION_ALREADY_DISPATCHED_REPLY
|
||||||
|
: ai.content
|
||||||
|
|
||||||
|
const aiMessage = await prisma.storefrontChatMessage.create({
|
||||||
data: {
|
data: {
|
||||||
bodyPreview: trimmedContent.slice(0, 2000),
|
conversationId: conversation.id,
|
||||||
sentAt: new Date(),
|
senderType: "AI",
|
||||||
status: "SENT",
|
content: aiContent
|
||||||
},
|
},
|
||||||
|
select: { id: true, createdAt: true, senderType: true, content: true }
|
||||||
})
|
})
|
||||||
} else if (owner?.userId) {
|
|
||||||
|
if (
|
||||||
|
isEscalation &&
|
||||||
|
!alreadyEscalated &&
|
||||||
|
!blockedForMissingVerification &&
|
||||||
|
!blockedForOrderMismatch
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const owner = await prisma.store.findUnique({
|
||||||
|
where: { id: store.id },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
if (owner?.userId) {
|
||||||
await prisma.emailLog.create({
|
await prisma.emailLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId: owner.userId,
|
userId: owner.userId,
|
||||||
|
|||||||
@ -11,6 +11,58 @@ type WidgetMessage = {
|
|||||||
senderType: "CUSTOMER" | "AI"
|
senderType: "CUSTOMER" | "AI"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLikelyHttpUrl(value: string) {
|
||||||
|
return /^https?:\/\/[^\s)]+$/i.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessageContent(content: string) {
|
||||||
|
// Supports both markdown links: [label](https://...)
|
||||||
|
// and plain URLs in free text.
|
||||||
|
const markdownOrUrl = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s)]+)/gi
|
||||||
|
const nodes: Array<React.ReactNode> = []
|
||||||
|
let lastIndex = 0
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((match = markdownOrUrl.exec(content)) !== null) {
|
||||||
|
const [full] = match
|
||||||
|
const start = match.index
|
||||||
|
const end = start + full.length
|
||||||
|
|
||||||
|
if (start > lastIndex) {
|
||||||
|
nodes.push(content.slice(lastIndex, start))
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownLabel = match[1]
|
||||||
|
const markdownUrl = match[2]
|
||||||
|
const plainUrl = match[3]
|
||||||
|
const href = markdownUrl || plainUrl || ""
|
||||||
|
|
||||||
|
if (isLikelyHttpUrl(href)) {
|
||||||
|
nodes.push(
|
||||||
|
<a
|
||||||
|
key={`${start}-${href}`}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline underline-offset-2 break-all text-indigo-600 hover:text-indigo-700"
|
||||||
|
>
|
||||||
|
{markdownLabel || href}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
nodes.push(full)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = end
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < content.length) {
|
||||||
|
nodes.push(content.slice(lastIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
function getOrCreateVisitorId(shop: string) {
|
function getOrCreateVisitorId(shop: string) {
|
||||||
const key = `juno_storefront_visitor_id_${shop}`
|
const key = `juno_storefront_visitor_id_${shop}`
|
||||||
try {
|
try {
|
||||||
@ -34,6 +86,7 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
const [sendError, setSendError] = useState<string | null>(null)
|
const [sendError, setSendError] = useState<string | null>(null)
|
||||||
const [loadingHistory, setLoadingHistory] = useState(false)
|
const [loadingHistory, setLoadingHistory] = useState(false)
|
||||||
|
const [aiTyping, setAiTyping] = useState(false)
|
||||||
|
|
||||||
const [verifyEmail, setVerifyEmail] = useState("")
|
const [verifyEmail, setVerifyEmail] = useState("")
|
||||||
const [otpCode, setOtpCode] = useState("")
|
const [otpCode, setOtpCode] = useState("")
|
||||||
@ -250,6 +303,19 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
if (!shop || !visitorId) return
|
if (!shop || !visitorId) return
|
||||||
const text = composer.trim()
|
const text = composer.trim()
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
const typingStartedAt = Date.now()
|
||||||
|
|
||||||
|
const optimisticCustomerMessage: WidgetMessage = {
|
||||||
|
id: `local_customer_${Date.now()}`,
|
||||||
|
content: text,
|
||||||
|
senderType: "CUSTOMER",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, optimisticCustomerMessage])
|
||||||
|
setComposer("")
|
||||||
|
setAiTyping(true)
|
||||||
|
setTimeout(() => composerRef.current?.focus(), 0)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSendError(null)
|
setSendError(null)
|
||||||
@ -267,31 +333,25 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
|
|
||||||
const returned = Array.isArray(data?.messages) ? data.messages : []
|
const returned = Array.isArray(data?.messages) ? data.messages : []
|
||||||
if (returned.length) {
|
if (returned.length) {
|
||||||
setMessages(prev => [
|
const returnedTyped = returned.map((m: any) => ({
|
||||||
...prev,
|
|
||||||
...returned.map((m: any) => ({
|
|
||||||
id: String(m.id || `local_${Date.now()}`),
|
id: String(m.id || `local_${Date.now()}`),
|
||||||
content: String(m.content || ""),
|
content: String(m.content || ""),
|
||||||
senderType: (m.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER",
|
senderType: (m.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER",
|
||||||
createdAt: String(m.createdAt || new Date().toISOString())
|
createdAt: String(m.createdAt || new Date().toISOString())
|
||||||
}))
|
}))
|
||||||
])
|
const aiReplies = returnedTyped.filter((m: WidgetMessage) => m.senderType === "AI")
|
||||||
} else {
|
const elapsed = Date.now() - typingStartedAt
|
||||||
setMessages(prev => [
|
const minTypingMs = 1000
|
||||||
...prev,
|
if (elapsed < minTypingMs) {
|
||||||
{
|
await new Promise((resolve) => setTimeout(resolve, minTypingMs - elapsed))
|
||||||
id: `local_${Date.now()}`,
|
|
||||||
content: text,
|
|
||||||
senderType: "CUSTOMER",
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
}
|
}
|
||||||
])
|
setMessages(prev => [...prev, ...aiReplies])
|
||||||
}
|
}
|
||||||
setComposer("")
|
|
||||||
setTimeout(() => composerRef.current?.focus(), 0)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setSendError(e?.message || "Failed to send message")
|
setSendError(e?.message || "Failed to send message")
|
||||||
|
setMessages((prev) => prev.filter((m) => m.id !== optimisticCustomerMessage.id))
|
||||||
} finally {
|
} finally {
|
||||||
|
setAiTyping(false)
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -342,10 +402,21 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
: "bg-indigo-600 text-white"
|
: "bg-indigo-600 text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{m.content}
|
{renderMessageContent(m.content)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{aiTyping ? (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="max-w-[88%] px-3 py-3 rounded-2xl bg-white border border-slate-200">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-slate-400 animate-bounce [animation-delay:-0.2s]" />
|
||||||
|
<span className="w-2 h-2 rounded-full bg-slate-400 animate-bounce [animation-delay:-0.1s]" />
|
||||||
|
<span className="w-2 h-2 rounded-full bg-slate-400 animate-bounce" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -36,6 +36,8 @@ const WIDGET_CANCEL_GENERAL_HELP =
|
|||||||
|
|
||||||
const FAQ_DISABLED_FALLBACK =
|
const FAQ_DISABLED_FALLBACK =
|
||||||
"I don't know about this—please contact our support team for help."
|
"I don't know about this—please contact our support team for help."
|
||||||
|
const OUT_OF_SCOPE_WIDGET_FALLBACK =
|
||||||
|
"I can only help with this store's products, orders, shipping, returns, and policies. Please ask a store-related question."
|
||||||
|
|
||||||
/** Limit customer-supplied text size (prompt injection + token cost). */
|
/** Limit customer-supplied text size (prompt injection + token cost). */
|
||||||
const L1_MAX_CUSTOMER_MESSAGE_CHARS = 6000
|
const L1_MAX_CUSTOMER_MESSAGE_CHARS = 6000
|
||||||
@ -201,6 +203,61 @@ function isGenericDeliveryEtaQuestion(message: string): boolean {
|
|||||||
return !orderSpecificSignals
|
return !orderSpecificSignals
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isClearlyOutOfScopeMessage(message: string): boolean {
|
||||||
|
const text = message.trim().toLowerCase()
|
||||||
|
if (!text) return false
|
||||||
|
|
||||||
|
// Basic math / homework / coding assistant requests that are unrelated to storefront support.
|
||||||
|
if (
|
||||||
|
/(^|\b)(solve|calculate|what is|what's)\s+[\d\s+\-*/().^=]+(\?|$)/i.test(text) ||
|
||||||
|
/(^|\b)(math|algebra|geometry|calculus|equation|integral|derivative|prime number)(\b|$)/i.test(
|
||||||
|
text,
|
||||||
|
) ||
|
||||||
|
/(^|\b)(write code|debug|python|javascript|typescript|java|c\+\+|sql query)(\b|$)/i.test(
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storefront-relevant keywords should keep normal handling.
|
||||||
|
if (
|
||||||
|
/(order|track|shipping|delivery|return|refund|exchange|cancel|address|product|size|stock|policy|warranty|payment|store|vendor|supplier)/i.test(
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic knowledge chatter is out of scope for the storefront widget.
|
||||||
|
if (
|
||||||
|
/(who is|who was|capital of|weather|news|joke|translate|recipe|movie|song|history|science|politics)/i.test(
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLastCustomerContext(
|
||||||
|
history: RunL1Input["history"],
|
||||||
|
currentMessage: string,
|
||||||
|
): string | null {
|
||||||
|
const normalizedCurrent = currentMessage.trim().toLowerCase()
|
||||||
|
for (let i = history.length - 1; i >= 0; i--) {
|
||||||
|
const msg = history[i]
|
||||||
|
if (msg.senderType === "AI" || msg.role === "assistant") continue
|
||||||
|
const text = (msg.content || "").trim()
|
||||||
|
if (!text) continue
|
||||||
|
if (text.length < 3) continue
|
||||||
|
if (text.toLowerCase() === normalizedCurrent) continue
|
||||||
|
return text.slice(0, 240)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
async function composeReply(params: {
|
async function composeReply(params: {
|
||||||
storeName: string
|
storeName: string
|
||||||
storeReference: string
|
storeReference: string
|
||||||
@ -281,11 +338,30 @@ function isPolicyLikeReturnOrChangeQuestion(message: string, module: L1Module):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result> {
|
export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result> {
|
||||||
|
if (input.channel === "widget" && isClearlyOutOfScopeMessage(input.message)) {
|
||||||
|
return {
|
||||||
|
text: OUT_OF_SCOPE_WIDGET_FALLBACK,
|
||||||
|
meta: {
|
||||||
|
module: "UNKNOWN",
|
||||||
|
pillar: "K",
|
||||||
|
usedTools: [],
|
||||||
|
kbHits: 0,
|
||||||
|
productHits: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastCustomerContext = extractLastCustomerContext(input.history, input.message)
|
||||||
|
const effectiveMessage =
|
||||||
|
lastCustomerContext
|
||||||
|
? `${input.message}\nPrior customer context from this same conversation: ${lastCustomerContext}`
|
||||||
|
: input.message
|
||||||
|
|
||||||
const modules = parseAiModulesJson(input.aiModulesJson)
|
const modules = parseAiModulesJson(input.aiModulesJson)
|
||||||
const sharedAnswers = parseSharedFieldAnswersJson(input.sharedFieldAnswersJson)
|
const sharedAnswers = parseSharedFieldAnswersJson(input.sharedFieldAnswersJson)
|
||||||
const retrieval = await getBlendedStorefrontContext({
|
const retrieval = await getBlendedStorefrontContext({
|
||||||
storeId: input.storeId,
|
storeId: input.storeId,
|
||||||
query: input.message,
|
query: effectiveMessage,
|
||||||
})
|
})
|
||||||
const kbContext =
|
const kbContext =
|
||||||
retrieval.items.length > 0
|
retrieval.items.length > 0
|
||||||
@ -294,11 +370,11 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
: ""
|
: ""
|
||||||
|
|
||||||
const routed = await routeCustomerIntent(input.message)
|
const routed = await routeCustomerIntent(effectiveMessage)
|
||||||
let activeModule: L1Module = routed.module
|
let activeModule: L1Module = routed.module
|
||||||
if (
|
if (
|
||||||
(activeModule === "SHIPPING_TRACKING" || activeModule === "ORDER_STATUS") &&
|
(activeModule === "SHIPPING_TRACKING" || activeModule === "ORDER_STATUS") &&
|
||||||
isGenericDeliveryEtaQuestion(input.message)
|
isGenericDeliveryEtaQuestion(effectiveMessage)
|
||||||
) {
|
) {
|
||||||
activeModule = "FAQ"
|
activeModule = "FAQ"
|
||||||
}
|
}
|
||||||
@ -324,8 +400,9 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
|||||||
const type1Facts = formatSharedFieldsForModule(activeModule, sharedAnswers)
|
const type1Facts = formatSharedFieldsForModule(activeModule, sharedAnswers)
|
||||||
const preferKnowledgeReplyOverTicket =
|
const preferKnowledgeReplyOverTicket =
|
||||||
activeModule === "RETURN_EXCHANGE" || activeModule === "ORDER_CHANGE"
|
activeModule === "RETURN_EXCHANGE" || activeModule === "ORDER_CHANGE"
|
||||||
? isPolicyLikeReturnOrChangeQuestion(input.message, activeModule)
|
? isPolicyLikeReturnOrChangeQuestion(effectiveMessage, activeModule)
|
||||||
: false
|
: false
|
||||||
|
const forceKnowledgeOnly = activeModule === "WARRANTY"
|
||||||
const categoryMetadetailsOn =
|
const categoryMetadetailsOn =
|
||||||
input.categoryMetadetailsEnabled !== undefined &&
|
input.categoryMetadetailsEnabled !== undefined &&
|
||||||
input.categoryMetadetailsEnabled !== null
|
input.categoryMetadetailsEnabled !== null
|
||||||
@ -408,7 +485,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
|||||||
ordersForEmail.length > 0 &&
|
ordersForEmail.length > 0 &&
|
||||||
(activeModule === "ORDER_SUMMARY" || activeModule === "ORDER_STATUS")
|
(activeModule === "ORDER_SUMMARY" || activeModule === "ORDER_STATUS")
|
||||||
) {
|
) {
|
||||||
const listMode = detectOrderListMode(input.message)
|
const listMode = detectOrderListMode(effectiveMessage)
|
||||||
if (listMode) {
|
if (listMode) {
|
||||||
return {
|
return {
|
||||||
text: buildOrderListText(ordersForEmail, listMode),
|
text: buildOrderListText(ordersForEmail, listMode),
|
||||||
@ -435,7 +512,11 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n")
|
.join("\n\n")
|
||||||
if (pillar === "X" && isAutomationEnabled(modules, activeModule, "ticket")) {
|
if (
|
||||||
|
pillar === "X" &&
|
||||||
|
isAutomationEnabled(modules, activeModule, "ticket") &&
|
||||||
|
!forceKnowledgeOnly
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
text: ticketHandoffText(activeModule, input.storeName),
|
text: ticketHandoffText(activeModule, input.storeName),
|
||||||
meta: {
|
meta: {
|
||||||
@ -449,7 +530,8 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
pillar === "T" &&
|
pillar === "T" &&
|
||||||
isAutomationEnabled(modules, activeModule, "ticket")
|
isAutomationEnabled(modules, activeModule, "ticket") &&
|
||||||
|
!forceKnowledgeOnly
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
text: ticketHandoffText(activeModule, input.storeName),
|
text: ticketHandoffText(activeModule, input.storeName),
|
||||||
@ -465,7 +547,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
|||||||
const text = await composeReply({
|
const text = await composeReply({
|
||||||
storeName: input.storeName,
|
storeName: input.storeName,
|
||||||
storeReference: facts.trim() ? facts : EMPTY_STORE_REFERENCE,
|
storeReference: facts.trim() ? facts : EMPTY_STORE_REFERENCE,
|
||||||
message: input.message,
|
message: effectiveMessage,
|
||||||
history: historyToGroq(input.history),
|
history: historyToGroq(input.history),
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
@ -484,6 +566,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
|||||||
pillar === "X" &&
|
pillar === "X" &&
|
||||||
ordersForEmail.length > 0 &&
|
ordersForEmail.length > 0 &&
|
||||||
isAutomationEnabled(modules, activeModule, "ticket") &&
|
isAutomationEnabled(modules, activeModule, "ticket") &&
|
||||||
|
!forceKnowledgeOnly &&
|
||||||
!preferKnowledgeReplyOverTicket
|
!preferKnowledgeReplyOverTicket
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
@ -498,11 +581,11 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hints = extractOrderNumberHints(input.message)
|
const hints = extractOrderNumberHints(effectiveMessage)
|
||||||
const hintText =
|
const hintText =
|
||||||
input.channel === "email"
|
input.channel === "email"
|
||||||
? `${input.message}\n${(input.history || []).map((h) => h.content).join("\n")}`
|
? `${effectiveMessage}\n${(input.history || []).map((h) => h.content).join("\n")}`
|
||||||
: input.message
|
: effectiveMessage
|
||||||
|
|
||||||
const disambiguate =
|
const disambiguate =
|
||||||
customerEmail &&
|
customerEmail &&
|
||||||
@ -517,18 +600,39 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
|||||||
customerEmail != null &&
|
customerEmail != null &&
|
||||||
ORDER_DETAIL_MODULES.includes(activeModule) &&
|
ORDER_DETAIL_MODULES.includes(activeModule) &&
|
||||||
!input.boundShopifyOrderId
|
!input.boundShopifyOrderId
|
||||||
? pickMentionedOrderId(input.message, ordersForEmail)
|
? pickMentionedOrderId(effectiveMessage, ordersForEmail)
|
||||||
: null
|
: null
|
||||||
const effectiveBoundShopifyOrderId =
|
const effectiveBoundShopifyOrderId =
|
||||||
mentionedOrderId ??
|
mentionedOrderId ??
|
||||||
(shouldAutoPickLatestOrder && ordersForEmail[0]
|
(shouldAutoPickLatestOrder && ordersForEmail[0]
|
||||||
? ordersForEmail[0].shopifyOrderId
|
? ordersForEmail[0].shopifyOrderId
|
||||||
: input.boundShopifyOrderId ?? null)
|
: input.boundShopifyOrderId ?? null)
|
||||||
|
const hasExplicitOrderHint = hints.length > 0
|
||||||
|
|
||||||
|
if (
|
||||||
|
ORDER_DETAIL_MODULES.includes(activeModule) &&
|
||||||
|
customerEmail &&
|
||||||
|
hasExplicitOrderHint &&
|
||||||
|
!mentionedOrderId &&
|
||||||
|
isAutomationEnabled(modules, activeModule, "verifiedLookup")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
text: "I could not find that order number for your verified email. Please double-check the order number and try again.",
|
||||||
|
meta: {
|
||||||
|
module: activeModule,
|
||||||
|
pillar,
|
||||||
|
usedTools: ["list_orders"],
|
||||||
|
kbHits: retrieval.kbCount,
|
||||||
|
productHits: retrieval.productCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
ORDER_DETAIL_MODULES.includes(activeModule) &&
|
ORDER_DETAIL_MODULES.includes(activeModule) &&
|
||||||
customerEmail &&
|
customerEmail &&
|
||||||
disambiguate &&
|
disambiguate &&
|
||||||
|
!hasExplicitOrderHint &&
|
||||||
!effectiveBoundShopifyOrderId &&
|
!effectiveBoundShopifyOrderId &&
|
||||||
isAutomationEnabled(modules, activeModule, "verifiedLookup")
|
isAutomationEnabled(modules, activeModule, "verifiedLookup")
|
||||||
) {
|
) {
|
||||||
@ -602,7 +706,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
|||||||
isAutomationEnabled(modules, activeModule, "knowledge")) ||
|
isAutomationEnabled(modules, activeModule, "knowledge")) ||
|
||||||
(activeModule === "INVENTORY_STOCK" && isModuleEnabled(modules, activeModule))
|
(activeModule === "INVENTORY_STOCK" && isModuleEnabled(modules, activeModule))
|
||||||
if (runProductSearch) {
|
if (runProductSearch) {
|
||||||
const hits = await searchProductsForStore(input.storeId, input.message, 5)
|
const hits = await searchProductsForStore(input.storeId, effectiveMessage, 5)
|
||||||
usedTools.push("search_products")
|
usedTools.push("search_products")
|
||||||
if (hits.length) {
|
if (hits.length) {
|
||||||
productFacts = hits
|
productFacts = hits
|
||||||
@ -635,7 +739,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
|
|||||||
const text = await composeReply({
|
const text = await composeReply({
|
||||||
storeName: input.storeName,
|
storeName: input.storeName,
|
||||||
storeReference: facts.trim() ? facts : EMPTY_STORE_REFERENCE,
|
storeReference: facts.trim() ? facts : EMPTY_STORE_REFERENCE,
|
||||||
message: input.message,
|
message: effectiveMessage,
|
||||||
history: historyToGroq(input.history),
|
history: historyToGroq(input.history),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user