Add strict linked-product upsell controls, improve retrieval consistency, persist full storefront escalation transcripts, and upgrade ticket APIs/UI for complete thread visibility and safer human handoff workflows. Co-authored-by: Cursor <cursoragent@cursor.com>
140 lines
4.8 KiB
TypeScript
140 lines
4.8 KiB
TypeScript
import { prisma } from "@/lib/prisma"
|
|
import {
|
|
compactText,
|
|
isProductStatusSellable,
|
|
metafieldsToSummary,
|
|
primarySkuFromVariants,
|
|
scoreByTerms,
|
|
tokenizeSearchTerms,
|
|
} from "@/lib/ai/tools/productCatalog"
|
|
|
|
type RetrievedContextItem = {
|
|
source: "KB" | "FAQ" | "PRODUCT"
|
|
title: string
|
|
content: string
|
|
score: number
|
|
}
|
|
|
|
type ProductRow = {
|
|
shopifyProductId?: string
|
|
title?: string
|
|
vendor?: string | null
|
|
productType?: string | null
|
|
status?: string | null
|
|
variants?: unknown
|
|
metafields?: unknown
|
|
}
|
|
|
|
function productPreview(product: ProductRow) {
|
|
const variants = Array.isArray(product?.variants) ? product.variants : []
|
|
const firstVariant = variants[0] || {}
|
|
const price = firstVariant?.price ?? null
|
|
const sku = primarySkuFromVariants(firstVariant ? [firstVariant] : product?.variants)
|
|
const weight =
|
|
firstVariant?.weight !== undefined && firstVariant?.weight !== null
|
|
? `${String(firstVariant.weight)}${firstVariant.weight_unit ? ` ${firstVariant.weight_unit}` : ""}`
|
|
: ""
|
|
const inventory = firstVariant?.inventory_quantity ?? null
|
|
const status = product?.status || "unknown"
|
|
const mfLine = metafieldsToSummary(product?.metafields)
|
|
|
|
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)}` : "",
|
|
weight ? `Weight: ${weight}` : "",
|
|
inventory !== null && inventory !== undefined ? `Inventory: ${String(inventory)}` : "",
|
|
mfLine ? `Details: ${mfLine}` : "",
|
|
].filter(Boolean)
|
|
|
|
return bits.join(" | ")
|
|
}
|
|
|
|
export async function getBlendedStorefrontContext(params: {
|
|
storeId: string
|
|
query: string
|
|
topKb?: number
|
|
topProducts?: number
|
|
includeProducts?: boolean
|
|
allowedProductIds?: string[]
|
|
}) {
|
|
const { storeId, query, topKb = 6, topProducts = 4, includeProducts = true } = params
|
|
const terms = tokenizeSearchTerms(query, 10)
|
|
|
|
const kbChunks = await prisma.knowledgeChunk.findMany({
|
|
where: { storeId },
|
|
orderBy: { createdAt: "desc" },
|
|
take: 120,
|
|
select: {
|
|
id: true,
|
|
sourceType: true,
|
|
content: true,
|
|
metadata: true,
|
|
},
|
|
})
|
|
|
|
const rankedKb: RetrievedContextItem[] = (kbChunks || [])
|
|
.map((c) => {
|
|
const score = scoreByTerms(String(c?.content || ""), terms)
|
|
return {
|
|
source: c?.sourceType === "FAQ" ? "FAQ" : "KB",
|
|
title: c?.sourceType === "FAQ" ? "FAQ entry" : "Knowledge document",
|
|
content: compactText(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)
|
|
|
|
let rankedProducts: RetrievedContextItem[] = []
|
|
if (includeProducts) {
|
|
const allowedIds = Array.isArray(params.allowedProductIds)
|
|
? params.allowedProductIds.filter(Boolean)
|
|
: []
|
|
if (allowedIds.length > 0) {
|
|
const products = await prisma.cachedProduct.findMany({
|
|
where: {
|
|
storeId,
|
|
shopifyProductId: { in: allowedIds.slice(0, 120) },
|
|
},
|
|
select: {
|
|
shopifyProductId: true,
|
|
title: true,
|
|
vendor: true,
|
|
productType: true,
|
|
status: true,
|
|
variants: true,
|
|
images: true,
|
|
metafields: true,
|
|
},
|
|
})
|
|
|
|
rankedProducts = (products || [])
|
|
.filter((prod) => isProductStatusSellable(prod?.status))
|
|
.map((prod) => {
|
|
const text = `${prod?.title || ""} ${prod?.vendor || ""} ${prod?.productType || ""} ${metafieldsToSummary(prod?.metafields)}`
|
|
const score = scoreByTerms(text, terms)
|
|
return {
|
|
source: "PRODUCT",
|
|
title: prod?.title || "Product",
|
|
content: compactText(productPreview(prod)),
|
|
score,
|
|
} as RetrievedContextItem
|
|
})
|
|
.sort((a: RetrievedContextItem, b: RetrievedContextItem) => b.score - a.score)
|
|
.slice(0, topProducts)
|
|
}
|
|
}
|
|
|
|
return {
|
|
items: [...rankedKb, ...rankedProducts],
|
|
kbCount: rankedKb.length,
|
|
productCount: rankedProducts.length,
|
|
}
|
|
}
|
|
|