feat(storefront-chat): add synchronous GROQ AI replies
Add blended KB/FAQ + product context retrieval for storefront chat, generate synchronous AI responses, persist AI messages, and update widget/debug inbox to display threaded CUSTOMER/AI conversations. Made-with: Cursor
This commit is contained in:
parent
ce5049a64f
commit
0b384fcc59
@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { generateStorefrontReply } from "@/lib/ai/storefrontGroq"
|
||||||
|
|
||||||
type StorefrontMessagePayload = {
|
type StorefrontMessagePayload = {
|
||||||
shop: string
|
shop: string
|
||||||
@ -10,6 +11,7 @@ type StorefrontMessagePayload = {
|
|||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
|
const p = prisma as any
|
||||||
const payload = (await req.json().catch(() => ({}))) as Partial<StorefrontMessagePayload>
|
const payload = (await req.json().catch(() => ({}))) as Partial<StorefrontMessagePayload>
|
||||||
const { shop, visitorId, content } = payload
|
const { shop, visitorId, content } = payload
|
||||||
|
|
||||||
@ -38,7 +40,7 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ message: "Invalid visitorId" }, { status: 400 })
|
return NextResponse.json({ message: "Invalid visitorId" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = await prisma.store.findFirst({
|
const store = await p.store.findFirst({
|
||||||
where: { shopifyDomain: trimmedShop },
|
where: { shopifyDomain: trimmedShop },
|
||||||
select: { id: true, storefrontChatEnabled: true }
|
select: { id: true, storefrontChatEnabled: true }
|
||||||
})
|
})
|
||||||
@ -51,7 +53,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find or create conversation for this anonymous visitor.
|
// Find or create conversation for this anonymous visitor.
|
||||||
let conversation = await prisma.storefrontChatConversation.findFirst({
|
let conversation = await p.storefrontChatConversation.findFirst({
|
||||||
where: { storeId: store.id, visitorId: trimmedVisitorId },
|
where: { storeId: store.id, visitorId: trimmedVisitorId },
|
||||||
select: { id: true }
|
select: { id: true }
|
||||||
})
|
})
|
||||||
@ -59,7 +61,7 @@ export async function POST(req: Request) {
|
|||||||
if (conversation) {
|
if (conversation) {
|
||||||
// Very lightweight rate limit: max 5 messages per 30 seconds per conversation.
|
// Very lightweight rate limit: max 5 messages per 30 seconds per conversation.
|
||||||
const windowMs = 30_000
|
const windowMs = 30_000
|
||||||
const recentCount = await prisma.storefrontChatMessage.count({
|
const recentCount = await p.storefrontChatMessage.count({
|
||||||
where: {
|
where: {
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
createdAt: { gte: new Date(Date.now() - windowMs) }
|
createdAt: { gte: new Date(Date.now() - windowMs) }
|
||||||
@ -70,7 +72,7 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ message: "Too many messages, slow down" }, { status: 429 })
|
return NextResponse.json({ message: "Too many messages, slow down" }, { status: 429 })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
conversation = await prisma.storefrontChatConversation.create({
|
conversation = await p.storefrontChatConversation.create({
|
||||||
data: {
|
data: {
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
visitorId: trimmedVisitorId,
|
visitorId: trimmedVisitorId,
|
||||||
@ -80,21 +82,45 @@ export async function POST(req: Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = await prisma.storefrontChatMessage.create({
|
const customerMessage = await p.storefrontChatMessage.create({
|
||||||
data: {
|
data: {
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
senderType: "CUSTOMER",
|
senderType: "CUSTOMER",
|
||||||
content: trimmedContent
|
content: trimmedContent
|
||||||
},
|
},
|
||||||
select: { id: true, createdAt: true }
|
select: { id: true, createdAt: true, senderType: true, content: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const recentHistory = await p.storefrontChatMessage.findMany({
|
||||||
|
where: { conversationId: conversation.id },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
take: 10,
|
||||||
|
select: { senderType: true, content: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const ai = await generateStorefrontReply({
|
||||||
|
storeId: store.id,
|
||||||
|
query: trimmedContent,
|
||||||
|
visitorId: trimmedVisitorId,
|
||||||
|
history: recentHistory.map((m: any) => ({
|
||||||
|
senderType: m.senderType,
|
||||||
|
content: m.content
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const aiMessage = await p.storefrontChatMessage.create({
|
||||||
|
data: {
|
||||||
|
conversationId: conversation.id,
|
||||||
|
senderType: "AI",
|
||||||
|
content: ai.content
|
||||||
|
},
|
||||||
|
select: { id: true, createdAt: true, senderType: true, content: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: {
|
messages: [customerMessage, aiMessage],
|
||||||
id: message.id,
|
meta: ai.meta
|
||||||
createdAt: message.createdAt
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[STORE_FRONT_CHAT_MESSAGES_POST]", error)
|
console.error("[STORE_FRONT_CHAT_MESSAGES_POST]", error)
|
||||||
@ -102,3 +128,52 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const p = prisma as any
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const shop = searchParams.get("shop")?.trim() || ""
|
||||||
|
const visitorId = searchParams.get("visitorId")?.trim() || ""
|
||||||
|
|
||||||
|
if (!shop) {
|
||||||
|
return NextResponse.json({ message: "Missing shop" }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (!visitorId) {
|
||||||
|
return NextResponse.json({ message: "Missing visitorId" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await p.store.findFirst({
|
||||||
|
where: { shopifyDomain: shop },
|
||||||
|
select: { id: true, storefrontChatEnabled: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (!store.storefrontChatEnabled) {
|
||||||
|
return NextResponse.json({ message: "Widget disabled" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = await p.storefrontChatConversation.findFirst({
|
||||||
|
where: { storeId: store.id, visitorId },
|
||||||
|
select: { id: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
return NextResponse.json({ success: true, messages: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await p.storefrontChatMessage.findMany({
|
||||||
|
where: { conversationId: conversation.id },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
take: 120,
|
||||||
|
select: { id: true, content: true, senderType: true, createdAt: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, messages })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[STORE_FRONT_CHAT_MESSAGES_GET]", error)
|
||||||
|
return NextResponse.json({ message: "Internal Error" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,6 +52,8 @@ export default async function StorefrontChatInboxPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const sorted = [...messages].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
const sorted = [...messages].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||||
|
const aiCount = sorted.filter((m) => m.senderType === "AI").length
|
||||||
|
const customerCount = sorted.filter((m) => m.senderType === "CUSTOMER").length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 p-1">
|
<div className="flex flex-col gap-4 p-1">
|
||||||
@ -65,6 +67,9 @@ export default async function StorefrontChatInboxPage() {
|
|||||||
<div className="shrink-0 text-right">
|
<div className="shrink-0 text-right">
|
||||||
<p className="text-sm font-black text-white">{conversations.length} visitors</p>
|
<p className="text-sm font-black text-white">{conversations.length} visitors</p>
|
||||||
<p className="text-xs text-slate-500 mt-1">{sorted.length} messages</p>
|
<p className="text-xs text-slate-500 mt-1">{sorted.length} messages</p>
|
||||||
|
<p className="text-[11px] text-slate-500 mt-1">
|
||||||
|
{customerCount} customer · {aiCount} AI
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -84,11 +89,20 @@ export default async function StorefrontChatInboxPage() {
|
|||||||
<div key={m.id} className="flex flex-col gap-2">
|
<div key={m.id} className="flex flex-col gap-2">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-black uppercase tracking-widest rounded-full px-2 py-1 border ${
|
||||||
|
m.senderType === "AI"
|
||||||
|
? "text-indigo-300 border-indigo-400/30 bg-indigo-500/10"
|
||||||
|
: "text-emerald-300 border-emerald-400/30 bg-emerald-500/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.senderType === "CUSTOMER" ? "CUSTOMER" : "AI"}
|
||||||
|
</span>
|
||||||
<p className="text-xs font-black uppercase tracking-widest text-slate-400 truncate">
|
<p className="text-xs font-black uppercase tracking-widest text-slate-400 truncate">
|
||||||
{m.senderType === "CUSTOMER" ? "CUSTOMER" : m.senderType}
|
|
||||||
{" · "}
|
|
||||||
{m.conversation?.visitorId || "visitor"}
|
{m.conversation?.visitorId || "visitor"}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
{m.conversation?.customerEmail ? (
|
{m.conversation?.customerEmail ? (
|
||||||
<p className="text-[11px] text-slate-500 truncate">{m.conversation.customerEmail}</p>
|
<p className="text-[11px] text-slate-500 truncate">{m.conversation.customerEmail}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ type WidgetMessage = {
|
|||||||
id: string
|
id: string
|
||||||
content: string
|
content: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
senderType: "CUSTOMER" | "AI"
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrCreateVisitorId(shop: string) {
|
function getOrCreateVisitorId(shop: string) {
|
||||||
@ -31,6 +32,7 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
const [composer, setComposer] = useState("")
|
const [composer, setComposer] = useState("")
|
||||||
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 composerRef = useRef<HTMLTextAreaElement | null>(null)
|
const composerRef = useRef<HTMLTextAreaElement | null>(null)
|
||||||
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
||||||
@ -74,6 +76,48 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
setVisitorId(getOrCreateVisitorId(shop))
|
setVisitorId(getOrCreateVisitorId(shop))
|
||||||
}, [shop])
|
}, [shop])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shop || !visitorId) return
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingHistory(true)
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/storefront-chat/messages?shop=${encodeURIComponent(shop)}&visitorId=${encodeURIComponent(visitorId)}`
|
||||||
|
)
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.message || "Failed to load history")
|
||||||
|
}
|
||||||
|
const nextMessages = Array.isArray(data?.messages) ? data.messages : []
|
||||||
|
if (!cancelled) {
|
||||||
|
setMessages(
|
||||||
|
nextMessages.map((m: any) => ({
|
||||||
|
id: String(m.id),
|
||||||
|
content: String(m.content || ""),
|
||||||
|
senderType: (m.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER",
|
||||||
|
createdAt: String(m.createdAt || new Date().toISOString())
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSendError(e?.message || "Failed to load chat history")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoadingHistory(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void run()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [shop, visitorId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
}, [messages])
|
}, [messages])
|
||||||
@ -97,14 +141,28 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
throw new Error(data?.message || "Failed to send message")
|
throw new Error(data?.message || "Failed to send message")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const returned = Array.isArray(data?.messages) ? data.messages : []
|
||||||
|
if (returned.length) {
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
...returned.map((m: any) => ({
|
||||||
|
id: String(m.id || `local_${Date.now()}`),
|
||||||
|
content: String(m.content || ""),
|
||||||
|
senderType: (m.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER",
|
||||||
|
createdAt: String(m.createdAt || new Date().toISOString())
|
||||||
|
}))
|
||||||
|
])
|
||||||
|
} else {
|
||||||
setMessages(prev => [
|
setMessages(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: data?.message?.id || `local_${Date.now()}`,
|
id: `local_${Date.now()}`,
|
||||||
content: text,
|
content: text,
|
||||||
|
senderType: "CUSTOMER",
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
}
|
||||||
setComposer("")
|
setComposer("")
|
||||||
setTimeout(() => composerRef.current?.focus(), 0)
|
setTimeout(() => composerRef.current?.focus(), 0)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -138,7 +196,11 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{messages.length === 0 ? (
|
{loadingHistory ? (
|
||||||
|
<div className="h-full flex items-center justify-center text-center p-4">
|
||||||
|
<p className="text-sm font-medium text-slate-500">Loading chat...</p>
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
<div className="h-full flex items-center justify-center text-center p-4">
|
<div className="h-full flex items-center justify-center text-center p-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-black text-slate-900">Start a conversation</p>
|
<p className="font-black text-slate-900">Start a conversation</p>
|
||||||
@ -150,8 +212,17 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{messages.map((m) => (
|
{messages.map((m) => (
|
||||||
<div key={m.id} className="flex justify-end">
|
<div
|
||||||
<div className="max-w-[85%] px-3 py-2 rounded-2xl bg-indigo-600 text-white text-sm font-medium">
|
key={m.id}
|
||||||
|
className={`flex ${m.senderType === "AI" ? "justify-start" : "justify-end"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[88%] px-3 py-2 rounded-2xl text-sm font-medium whitespace-pre-wrap ${
|
||||||
|
m.senderType === "AI"
|
||||||
|
? "bg-white border border-slate-200 text-slate-800"
|
||||||
|
: "bg-indigo-600 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{m.content}
|
{m.content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -187,9 +258,7 @@ export default function StorefrontChatWidgetPage() {
|
|||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[11px] text-slate-500">
|
<p className="mt-2 text-[11px] text-slate-500">AI replies are generated from products, FAQs and KB.</p>
|
||||||
Customer messages are saved now. AI replies later.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
86
src/lib/ai/storefrontGroq.ts
Normal file
86
src/lib/ai/storefrontGroq.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
import { groqChatCompletion, type GroqChatMessage } from "@/lib/ai/groqClient"
|
||||||
|
import { getBlendedStorefrontContext } from "@/lib/storefront-chat/retrieval"
|
||||||
|
|
||||||
|
type HistoryMessage = {
|
||||||
|
senderType: "CUSTOMER" | "AI"
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStorefrontReply(params: {
|
||||||
|
storeId: string
|
||||||
|
query: string
|
||||||
|
visitorId: string
|
||||||
|
history: HistoryMessage[]
|
||||||
|
}) {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const { storeId, query, visitorId, history } = params
|
||||||
|
|
||||||
|
const retrieval = await getBlendedStorefrontContext({ storeId, query })
|
||||||
|
const contextLines = retrieval.items.map(
|
||||||
|
(item, idx) => `[${idx + 1}] (${item.source}) ${item.title}: ${item.content}`
|
||||||
|
)
|
||||||
|
const contextBlock = contextLines.length
|
||||||
|
? contextLines.join("\n")
|
||||||
|
: "No relevant KB/FAQ/product context found."
|
||||||
|
|
||||||
|
const shortHistory = history.slice(-8).map((m) => ({
|
||||||
|
role: m.senderType === "AI" ? "assistant" : "user",
|
||||||
|
content: m.content,
|
||||||
|
})) as GroqChatMessage[]
|
||||||
|
|
||||||
|
const system = [
|
||||||
|
"You are JUNO storefront assistant for an ecommerce store.",
|
||||||
|
"Use the provided context first (products, FAQs, knowledge).",
|
||||||
|
"If context is insufficient, ask one concise clarifying question.",
|
||||||
|
"Do not invent exact prices, stock or policy details that are not present.",
|
||||||
|
"Keep responses concise, friendly, and sales-supportive.",
|
||||||
|
"Return plain text only.",
|
||||||
|
].join(" ")
|
||||||
|
|
||||||
|
const user = [
|
||||||
|
"Context:",
|
||||||
|
contextBlock,
|
||||||
|
"",
|
||||||
|
`Visitor ID: ${visitorId}`,
|
||||||
|
`Customer question: ${query}`,
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const messages: GroqChatMessage[] = [
|
||||||
|
{ role: "system", content: system },
|
||||||
|
...shortHistory,
|
||||||
|
{ role: "user", content: user },
|
||||||
|
]
|
||||||
|
|
||||||
|
const out = await groqChatCompletion(messages, {
|
||||||
|
temperature: 0.2,
|
||||||
|
maxTokens: 420,
|
||||||
|
timeoutMs: 28_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const fallback =
|
||||||
|
"Thanks for your message. I do not have enough details right now. Could you share the product name or what exactly you want to know?"
|
||||||
|
|
||||||
|
const content = (out.content || "").trim() || fallback
|
||||||
|
|
||||||
|
logger.info("[STOREFRONT_AI_REPLY]", {
|
||||||
|
storeId,
|
||||||
|
visitorId,
|
||||||
|
queryLength: query.length,
|
||||||
|
kbHits: retrieval.kbCount,
|
||||||
|
productHits: retrieval.productCount,
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
fallbackUsed: !out.content,
|
||||||
|
error: out.error || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
meta: {
|
||||||
|
kbHits: retrieval.kbCount,
|
||||||
|
productHits: retrieval.productCount,
|
||||||
|
fallbackUsed: !out.content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
126
src/lib/storefront-chat/retrieval.ts
Normal file
126
src/lib/storefront-chat/retrieval.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
type RetrievedContextItem = {
|
||||||
|
source: "KB" | "FAQ" | "PRODUCT"
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(text: string) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-z0-9]+/g)
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => t.length >= 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreByTerms(text: string, terms: string[]) {
|
||||||
|
if (!text || !terms.length) return 0
|
||||||
|
const hay = text.toLowerCase()
|
||||||
|
let score = 0
|
||||||
|
for (const t of terms) {
|
||||||
|
if (hay.includes(t)) score += 1
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
function compact(value: string, max = 420) {
|
||||||
|
if (value.length <= max) return value
|
||||||
|
return `${value.slice(0, max - 1)}...`
|
||||||
|
}
|
||||||
|
|
||||||
|
function productPreview(product: any) {
|
||||||
|
const variants = Array.isArray(product?.variants) ? product.variants : []
|
||||||
|
const firstVariant = variants[0] || {}
|
||||||
|
const price = firstVariant?.price ?? null
|
||||||
|
const sku = firstVariant?.sku ?? null
|
||||||
|
const inventory = firstVariant?.inventory_quantity ?? null
|
||||||
|
const status = product?.status || "unknown"
|
||||||
|
|
||||||
|
const bits = [
|
||||||
|
`Title: ${product?.title || "Unknown"}`,
|
||||||
|
product?.vendor ? `Vendor: ${product.vendor}` : "",
|
||||||
|
product?.productType ? `Type: ${product.productType}` : "",
|
||||||
|
`Status: ${status}`,
|
||||||
|
price !== null && price !== undefined ? `Price: ${String(price)}` : "",
|
||||||
|
sku ? `SKU: ${String(sku)}` : "",
|
||||||
|
inventory !== null && inventory !== undefined ? `Inventory: ${String(inventory)}` : "",
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
return bits.join(" | ")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBlendedStorefrontContext(params: {
|
||||||
|
storeId: string
|
||||||
|
query: string
|
||||||
|
topKb?: number
|
||||||
|
topProducts?: number
|
||||||
|
}) {
|
||||||
|
const { storeId, query, topKb = 6, topProducts = 4 } = params
|
||||||
|
const terms = tokenize(query).slice(0, 10)
|
||||||
|
|
||||||
|
const p = prisma as any
|
||||||
|
|
||||||
|
const kbChunks = await p.knowledgeChunk.findMany({
|
||||||
|
where: { storeId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 120,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sourceType: true,
|
||||||
|
content: true,
|
||||||
|
metadata: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const rankedKb: RetrievedContextItem[] = (kbChunks || [])
|
||||||
|
.map((c: any) => {
|
||||||
|
const score = scoreByTerms(String(c?.content || ""), terms)
|
||||||
|
return {
|
||||||
|
source: c?.sourceType === "FAQ" ? "FAQ" : "KB",
|
||||||
|
title: c?.sourceType === "FAQ" ? "FAQ entry" : "Knowledge document",
|
||||||
|
content: compact(String(c?.content || "")),
|
||||||
|
score,
|
||||||
|
} as RetrievedContextItem
|
||||||
|
})
|
||||||
|
.filter((x: RetrievedContextItem) => x.score > 0 && x.content.length > 0)
|
||||||
|
.sort((a: RetrievedContextItem, b: RetrievedContextItem) => b.score - a.score)
|
||||||
|
.slice(0, topKb)
|
||||||
|
|
||||||
|
const products = await p.cachedProduct.findMany({
|
||||||
|
where: { storeId },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 200,
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
vendor: true,
|
||||||
|
productType: true,
|
||||||
|
status: true,
|
||||||
|
variants: true,
|
||||||
|
images: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const rankedProducts: RetrievedContextItem[] = (products || [])
|
||||||
|
.map((prod: any) => {
|
||||||
|
const text = `${prod?.title || ""} ${prod?.vendor || ""} ${prod?.productType || ""}`
|
||||||
|
const score = scoreByTerms(text, terms)
|
||||||
|
return {
|
||||||
|
source: "PRODUCT",
|
||||||
|
title: prod?.title || "Product",
|
||||||
|
content: compact(productPreview(prod)),
|
||||||
|
score,
|
||||||
|
} as RetrievedContextItem
|
||||||
|
})
|
||||||
|
.filter((x: RetrievedContextItem) => x.score > 0)
|
||||||
|
.sort((a: RetrievedContextItem, b: RetrievedContextItem) => b.score - a.score)
|
||||||
|
.slice(0, topProducts)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: [...rankedKb, ...rankedProducts],
|
||||||
|
kbCount: rankedKb.length,
|
||||||
|
productCount: rankedProducts.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user