150 lines
4.9 KiB
TypeScript
150 lines
4.9 KiB
TypeScript
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 metafieldsForText(metafields: unknown): string {
|
|
if (!Array.isArray(metafields) || metafields.length === 0) return ""
|
|
const parts: string[] = []
|
|
for (const row of metafields) {
|
|
if (!row || typeof row !== "object") continue
|
|
const r = row as { namespace?: string; key?: string; value?: string | null }
|
|
const k = r.key
|
|
const v = r.value
|
|
if (!k || v === null || v === undefined || String(v).trim() === "") continue
|
|
const ns = r.namespace ? `${r.namespace}.` : ""
|
|
parts.push(`${ns}${k}: ${String(v).slice(0, 200)}`)
|
|
}
|
|
return parts.join(" | ")
|
|
}
|
|
|
|
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 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 = metafieldsForText(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
|
|
}) {
|
|
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,
|
|
metafields: true,
|
|
},
|
|
})
|
|
|
|
const rankedProducts: RetrievedContextItem[] = (products || [])
|
|
.map((prod: any) => {
|
|
const text = `${prod?.title || ""} ${prod?.vendor || ""} ${prod?.productType || ""} ${metafieldsForText(prod?.metafields)}`
|
|
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,
|
|
}
|
|
}
|
|
|