fix: cart premlink fixed
This commit is contained in:
parent
26fc46068f
commit
d1b657cba7
@ -91,6 +91,14 @@ type CartResponse = {
|
|||||||
content: string
|
content: string
|
||||||
actions: StorefrontChatAction[]
|
actions: StorefrontChatAction[]
|
||||||
pendingCartProduct: StorefrontActionProduct | null
|
pendingCartProduct: StorefrontActionProduct | null
|
||||||
|
cartLines: StorefrontCartLine[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorefrontCartLine = {
|
||||||
|
variantId: string
|
||||||
|
quantity: number
|
||||||
|
productId?: string
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeStorefrontAction(action: StorefrontChatActionInput | undefined) {
|
function describeStorefrontAction(action: StorefrontChatActionInput | undefined) {
|
||||||
@ -115,11 +123,58 @@ function parsePendingCartProduct(raw: unknown): StorefrontActionProduct | null {
|
|||||||
return normalizeActionProduct(state.pendingCartProduct)
|
return normalizeActionProduct(state.pendingCartProduct)
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextL1State(raw: unknown, pendingCartProduct: StorefrontActionProduct | null) {
|
function parseCartLines(raw: unknown): StorefrontCartLine[] {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []
|
||||||
|
const state = raw as Record<string, unknown>
|
||||||
|
const lines = state.cartLines
|
||||||
|
if (!Array.isArray(lines)) return []
|
||||||
|
return lines
|
||||||
|
.map((line): StorefrontCartLine | null => {
|
||||||
|
if (!line || typeof line !== "object" || Array.isArray(line)) return null
|
||||||
|
const row = line as Record<string, unknown>
|
||||||
|
const variantId = typeof row.variantId === "string" ? row.variantId.trim() : ""
|
||||||
|
if (!variantId) return null
|
||||||
|
const quantity = Math.max(1, Math.min(10, Math.trunc(Number(row.quantity) || 1)))
|
||||||
|
const productId = typeof row.productId === "string" ? row.productId.trim() : ""
|
||||||
|
const title = typeof row.title === "string" ? row.title.trim() : ""
|
||||||
|
return {
|
||||||
|
variantId,
|
||||||
|
quantity,
|
||||||
|
...(productId ? { productId } : {}),
|
||||||
|
...(title ? { title } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((line): line is StorefrontCartLine => Boolean(line))
|
||||||
|
.slice(0, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCartLines(existing: StorefrontCartLine[], next: StorefrontCartLine): StorefrontCartLine[] {
|
||||||
|
const byVariant = new Map<string, StorefrontCartLine>()
|
||||||
|
for (const line of existing) {
|
||||||
|
byVariant.set(line.variantId, { ...line })
|
||||||
|
}
|
||||||
|
const previous = byVariant.get(next.variantId)
|
||||||
|
byVariant.set(next.variantId, {
|
||||||
|
...previous,
|
||||||
|
...next,
|
||||||
|
quantity: Math.max(1, Math.min(10, (previous?.quantity ?? 0) + next.quantity)),
|
||||||
|
})
|
||||||
|
return Array.from(byVariant.values()).slice(0, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextL1State(
|
||||||
|
raw: unknown,
|
||||||
|
pendingCartProduct: StorefrontActionProduct | null,
|
||||||
|
cartLines?: StorefrontCartLine[],
|
||||||
|
) {
|
||||||
const current =
|
const current =
|
||||||
raw && typeof raw === "object" && !Array.isArray(raw)
|
raw && typeof raw === "object" && !Array.isArray(raw)
|
||||||
? { ...(raw as Record<string, unknown>) }
|
? { ...(raw as Record<string, unknown>) }
|
||||||
: {}
|
: {}
|
||||||
|
if (cartLines) {
|
||||||
|
current.cartLines = cartLines
|
||||||
|
current.cartUpdatedAt = new Date().toISOString()
|
||||||
|
}
|
||||||
if (pendingCartProduct) {
|
if (pendingCartProduct) {
|
||||||
current.pendingCartProduct = pendingCartProduct
|
current.pendingCartProduct = pendingCartProduct
|
||||||
current.pendingCartUpdatedAt = new Date().toISOString()
|
current.pendingCartUpdatedAt = new Date().toISOString()
|
||||||
@ -165,6 +220,7 @@ async function buildCartResponse(params: {
|
|||||||
requestedVariantId?: string | null
|
requestedVariantId?: string | null
|
||||||
quantity?: number
|
quantity?: number
|
||||||
message: string
|
message: string
|
||||||
|
existingCartLines: StorefrontCartLine[]
|
||||||
}): Promise<CartResponse> {
|
}): Promise<CartResponse> {
|
||||||
const product = await prisma.cachedProduct.findFirst({
|
const product = await prisma.cachedProduct.findFirst({
|
||||||
where: { storeId: params.storeId, shopifyProductId: params.productId },
|
where: { storeId: params.storeId, shopifyProductId: params.productId },
|
||||||
@ -182,6 +238,7 @@ async function buildCartResponse(params: {
|
|||||||
content: "I could not find that product in this store right now.",
|
content: "I could not find that product in this store right now.",
|
||||||
actions: [],
|
actions: [],
|
||||||
pendingCartProduct: null,
|
pendingCartProduct: null,
|
||||||
|
cartLines: params.existingCartLines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,6 +254,7 @@ async function buildCartResponse(params: {
|
|||||||
content: "I could not verify that product option for this store. Please choose the product again.",
|
content: "I could not verify that product option for this store. Please choose the product again.",
|
||||||
actions: [],
|
actions: [],
|
||||||
pendingCartProduct: null,
|
pendingCartProduct: null,
|
||||||
|
cartLines: params.existingCartLines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,6 +263,7 @@ async function buildCartResponse(params: {
|
|||||||
content: `${product.title} is not available to add to cart right now.`,
|
content: `${product.title} is not available to add to cart right now.`,
|
||||||
actions: [],
|
actions: [],
|
||||||
pendingCartProduct: null,
|
pendingCartProduct: null,
|
||||||
|
cartLines: params.existingCartLines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,21 +278,11 @@ async function buildCartResponse(params: {
|
|||||||
productType: product.productType,
|
productType: product.productType,
|
||||||
quantity: params.quantity ?? 1,
|
quantity: params.quantity ?? 1,
|
||||||
},
|
},
|
||||||
|
cartLines: params.existingCartLines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const quantity = Math.max(1, Math.min(10, Math.trunc(Number(params.quantity) || 1)))
|
const quantity = Math.max(1, Math.min(10, Math.trunc(Number(params.quantity) || 1)))
|
||||||
const cartUrl = buildCartPermalink(params.shopifyDomain, [
|
|
||||||
{ variantId: selected.variant.id, quantity },
|
|
||||||
])
|
|
||||||
if (!cartUrl) {
|
|
||||||
return {
|
|
||||||
content: "I could not create a cart link for that product right now.",
|
|
||||||
actions: [],
|
|
||||||
pendingCartProduct: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionProduct: StorefrontActionProduct = {
|
const actionProduct: StorefrontActionProduct = {
|
||||||
productId: product.shopifyProductId,
|
productId: product.shopifyProductId,
|
||||||
title: product.title,
|
title: product.title,
|
||||||
@ -245,15 +294,33 @@ async function buildCartResponse(params: {
|
|||||||
productType: product.productType,
|
productType: product.productType,
|
||||||
quantity,
|
quantity,
|
||||||
}
|
}
|
||||||
|
const nextCartLines = mergeCartLines(params.existingCartLines, {
|
||||||
|
variantId: selected.variant.id,
|
||||||
|
quantity,
|
||||||
|
productId: product.shopifyProductId,
|
||||||
|
title: product.title,
|
||||||
|
})
|
||||||
|
const cartUrl = buildCartPermalink(params.shopifyDomain, nextCartLines)
|
||||||
|
if (!cartUrl) {
|
||||||
|
return {
|
||||||
|
content: "I could not create a cart link for that product right now.",
|
||||||
|
actions: [],
|
||||||
|
pendingCartProduct: null,
|
||||||
|
cartLines: params.existingCartLines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const variantText =
|
const variantText =
|
||||||
selected.variant.title && !/^default title$/i.test(selected.variant.title)
|
selected.variant.title && !/^default title$/i.test(selected.variant.title)
|
||||||
? ` (${selected.variant.title})`
|
? ` (${selected.variant.title})`
|
||||||
: ""
|
: ""
|
||||||
|
const cartCount = nextCartLines.reduce((sum, line) => sum + line.quantity, 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: `Here is your cart link with ${product.title}${variantText} added: ${cartUrl}`,
|
content: `Added ${product.title}${variantText}. Your cart now has ${cartCount} item${cartCount === 1 ? "" : "s"}: ${cartUrl}`,
|
||||||
actions: [buildOpenCartAction(cartUrl, actionProduct)],
|
actions: [buildOpenCartAction(cartUrl, actionProduct)],
|
||||||
pendingCartProduct: null,
|
pendingCartProduct: null,
|
||||||
|
cartLines: nextCartLines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,6 +328,7 @@ async function resolveDirectAddToCart(params: {
|
|||||||
storeId: string
|
storeId: string
|
||||||
shopifyDomain: string
|
shopifyDomain: string
|
||||||
message: string
|
message: string
|
||||||
|
existingCartLines: StorefrontCartLine[]
|
||||||
}): Promise<CartResponse | null> {
|
}): Promise<CartResponse | null> {
|
||||||
if (!isDirectAddToCartRequest(params.message)) return null
|
if (!isDirectAddToCartRequest(params.message)) return null
|
||||||
const hits = await searchProductsForStore(params.storeId, params.message, 1)
|
const hits = await searchProductsForStore(params.storeId, params.message, 1)
|
||||||
@ -274,6 +342,7 @@ async function resolveDirectAddToCart(params: {
|
|||||||
requestedVariantId: product?.variantId,
|
requestedVariantId: product?.variantId,
|
||||||
quantity: product?.quantity ?? 1,
|
quantity: product?.quantity ?? 1,
|
||||||
message: params.message,
|
message: params.message,
|
||||||
|
existingCartLines: params.existingCartLines,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -355,6 +424,7 @@ export async function POST(req: Request) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const pendingCartProduct = parsePendingCartProduct(conversation.l1State)
|
const pendingCartProduct = parsePendingCartProduct(conversation.l1State)
|
||||||
|
const existingCartLines = parseCartLines(conversation.l1State)
|
||||||
const cartResponse = action
|
const cartResponse = action
|
||||||
? await buildCartResponse({
|
? await buildCartResponse({
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
@ -363,6 +433,7 @@ export async function POST(req: Request) {
|
|||||||
requestedVariantId: action.variantId,
|
requestedVariantId: action.variantId,
|
||||||
quantity: action.quantity,
|
quantity: action.quantity,
|
||||||
message: customerContent,
|
message: customerContent,
|
||||||
|
existingCartLines,
|
||||||
})
|
})
|
||||||
: pendingCartProduct && isCartConfirmationMessage(customerContent)
|
: pendingCartProduct && isCartConfirmationMessage(customerContent)
|
||||||
? await buildCartResponse({
|
? await buildCartResponse({
|
||||||
@ -372,11 +443,13 @@ export async function POST(req: Request) {
|
|||||||
requestedVariantId: pendingCartProduct.variantId,
|
requestedVariantId: pendingCartProduct.variantId,
|
||||||
quantity: pendingCartProduct.quantity,
|
quantity: pendingCartProduct.quantity,
|
||||||
message: customerContent,
|
message: customerContent,
|
||||||
|
existingCartLines,
|
||||||
})
|
})
|
||||||
: await resolveDirectAddToCart({
|
: await resolveDirectAddToCart({
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
shopifyDomain: store.shopifyDomain,
|
shopifyDomain: store.shopifyDomain,
|
||||||
message: customerContent,
|
message: customerContent,
|
||||||
|
existingCartLines,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (cartResponse) {
|
if (cartResponse) {
|
||||||
@ -392,7 +465,15 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
await prisma.storefrontChatConversation.update({
|
await prisma.storefrontChatConversation.update({
|
||||||
where: { id: conversation.id },
|
where: { id: conversation.id },
|
||||||
data: { l1State: toJsonInput(nextL1State(conversation.l1State, cartResponse.pendingCartProduct)) },
|
data: {
|
||||||
|
l1State: toJsonInput(
|
||||||
|
nextL1State(
|
||||||
|
conversation.l1State,
|
||||||
|
cartResponse.pendingCartProduct,
|
||||||
|
cartResponse.cartLines,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@ -1,43 +1,83 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import { rankAutomaticUpsellCandidates } from "./upsells"
|
import { parseAiUpsellChoice, rankAutomaticUpsellCandidates } from "./upsells"
|
||||||
|
|
||||||
describe("automatic storefront upsells", () => {
|
describe("automatic storefront upsells", () => {
|
||||||
it("suggests school socks for school shoes and excludes the base product", () => {
|
it("shortlists cartable active products without the base product", () => {
|
||||||
const results = rankAutomaticUpsellCandidates({
|
const results = rankAutomaticUpsellCandidates({
|
||||||
baseProduct: {
|
baseProduct: {
|
||||||
productId: "shoe-1",
|
productId: "base-1",
|
||||||
title: "School Shoes",
|
title: "Base Product",
|
||||||
variantId: "v-shoe",
|
variantId: "v-base",
|
||||||
productType: "Shoes",
|
productType: "Main",
|
||||||
},
|
},
|
||||||
query: "Do you have school shoes?",
|
query: "Tell me about the base product",
|
||||||
candidates: [
|
candidates: [
|
||||||
{
|
{
|
||||||
shopifyProductId: "shoe-1",
|
shopifyProductId: "base-1",
|
||||||
title: "School Shoes",
|
title: "Base Product",
|
||||||
productType: "Shoes",
|
productType: "Main",
|
||||||
status: "active",
|
status: "active",
|
||||||
variants: [{ id: "v-shoe", title: "Default Title", inventory_quantity: 5 }],
|
variants: [{ id: "v-base", title: "Default Title", inventory_quantity: 5 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shopifyProductId: "sock-1",
|
shopifyProductId: "candidate-1",
|
||||||
title: "School Socks",
|
title: "Candidate Product",
|
||||||
productType: "Accessories",
|
productType: "Accessory",
|
||||||
status: "active",
|
status: "active",
|
||||||
variants: [{ id: "v-sock", title: "Default Title", inventory_quantity: 8, price: "5.00" }],
|
variants: [{ id: "v-candidate", title: "Default Title", inventory_quantity: 8, price: "5.00" }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shopifyProductId: "bag-1",
|
shopifyProductId: "draft-1",
|
||||||
title: "Travel Bag",
|
title: "Draft Product",
|
||||||
productType: "Bags",
|
productType: "Accessory",
|
||||||
status: "active",
|
status: "draft",
|
||||||
variants: [{ id: "v-bag", title: "Default Title", inventory_quantity: 8 }],
|
variants: [{ id: "v-draft", title: "Default Title", inventory_quantity: 8 }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(results).toHaveLength(1)
|
expect(results).toHaveLength(1)
|
||||||
expect(results[0].productId).toBe("sock-1")
|
expect(results[0].productId).toBe("candidate-1")
|
||||||
expect(results[0].variantId).toBe("v-sock")
|
expect(results[0].variantId).toBe("v-candidate")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns no upsell when the AI chooses null", () => {
|
||||||
|
const result = parseAiUpsellChoice(
|
||||||
|
JSON.stringify({ productId: null, reason: "No clearly complementary product." }),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: "Candidate Product",
|
||||||
|
productType: "Accessory",
|
||||||
|
details: null,
|
||||||
|
product: {
|
||||||
|
productId: "candidate-1",
|
||||||
|
title: "Candidate Product",
|
||||||
|
variantId: "v-candidate",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("only accepts AI choices that exist in the candidate list", () => {
|
||||||
|
const result = parseAiUpsellChoice(
|
||||||
|
JSON.stringify({ productId: "unknown-product", reason: "Not in the provided list." }),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: "Candidate Product",
|
||||||
|
productType: "Accessory",
|
||||||
|
details: null,
|
||||||
|
product: {
|
||||||
|
productId: "candidate-1",
|
||||||
|
title: "Candidate Product",
|
||||||
|
variantId: "v-candidate",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
import { aiChatCompletion } from "@/lib/ai/chatCompletion"
|
||||||
import type { StorefrontActionProduct } from "@/lib/storefront-chat/actions"
|
import type { StorefrontActionProduct } from "@/lib/storefront-chat/actions"
|
||||||
import { extractCartableVariants, selectVariantForCart } from "@/lib/storefront-chat/cartLinks"
|
import { extractCartableVariants, selectVariantForCart } from "@/lib/storefront-chat/cartLinks"
|
||||||
|
|
||||||
@ -12,39 +14,11 @@ type CachedProductLike = {
|
|||||||
metafields?: unknown
|
metafields?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
const STOPWORDS = new Set([
|
type UpsellCatalogCandidate = {
|
||||||
"the",
|
product: StorefrontActionProduct
|
||||||
"and",
|
title: string
|
||||||
"for",
|
productType: string | null
|
||||||
"with",
|
details: string | null
|
||||||
"that",
|
|
||||||
"this",
|
|
||||||
"want",
|
|
||||||
"need",
|
|
||||||
"product",
|
|
||||||
"item",
|
|
||||||
"buy",
|
|
||||||
"cart",
|
|
||||||
"add",
|
|
||||||
])
|
|
||||||
|
|
||||||
const ACCESSORY_PAIRS: Array<{ base: RegExp; accessory: RegExp; score: number }> = [
|
|
||||||
{ base: /\bshoe|shoes|sneaker|sneakers|boot|boots\b/i, accessory: /\bsock|socks|laces?|insole|insoles\b/i, score: 8 },
|
|
||||||
{ base: /\bshirt|uniform|dress\b/i, accessory: /\btie|belt|cap|scarf\b/i, score: 5 },
|
|
||||||
{ base: /\bbag|backpack\b/i, accessory: /\bbottle|lunch|pouch|case\b/i, score: 5 },
|
|
||||||
{ base: /\bphone|mobile\b/i, accessory: /\bcase|cover|charger|cable\b/i, score: 6 },
|
|
||||||
]
|
|
||||||
|
|
||||||
function tokenize(text: string) {
|
|
||||||
return text
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/[^a-z0-9]+/g)
|
|
||||||
.map((token) => token.trim())
|
|
||||||
.filter((token) => token.length >= 2 && !STOPWORDS.has(token))
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueTokens(...texts: Array<string | null | undefined>) {
|
|
||||||
return Array.from(new Set(texts.flatMap((text) => tokenize(text ?? ""))))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageUrlFromImages(images: unknown): string | null {
|
function imageUrlFromImages(images: unknown): string | null {
|
||||||
@ -67,24 +41,17 @@ function imageUrlFromImages(images: unknown): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function productText(product: CachedProductLike) {
|
function compactMetafields(metafields: unknown): string | null {
|
||||||
return [
|
if (!Array.isArray(metafields)) return null
|
||||||
product.title,
|
const parts: string[] = []
|
||||||
product.productType,
|
for (const row of metafields) {
|
||||||
JSON.stringify(product.metafields ?? ""),
|
if (!row || typeof row !== "object" || Array.isArray(row)) continue
|
||||||
]
|
const field = row as { key?: unknown; value?: unknown }
|
||||||
.filter(Boolean)
|
const key = typeof field.key === "string" ? field.key.trim() : ""
|
||||||
.join(" ")
|
const value = typeof field.value === "string" ? field.value.trim() : ""
|
||||||
}
|
if (key && value) parts.push(`${key}: ${value.slice(0, 120)}`)
|
||||||
|
|
||||||
function accessoryScore(baseText: string, candidateText: string) {
|
|
||||||
let score = 0
|
|
||||||
for (const pair of ACCESSORY_PAIRS) {
|
|
||||||
if (pair.base.test(baseText) && pair.accessory.test(candidateText)) {
|
|
||||||
score += pair.score
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return score
|
return parts.length ? parts.join(" | ").slice(0, 500) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function toUpsellActionProduct(product: CachedProductLike, query: string): StorefrontActionProduct | null {
|
function toUpsellActionProduct(product: CachedProductLike, query: string): StorefrontActionProduct | null {
|
||||||
@ -111,30 +78,85 @@ export function rankAutomaticUpsellCandidates(params: {
|
|||||||
candidates: CachedProductLike[]
|
candidates: CachedProductLike[]
|
||||||
limit?: number
|
limit?: number
|
||||||
}): StorefrontActionProduct[] {
|
}): StorefrontActionProduct[] {
|
||||||
const { baseProduct, query, candidates, limit = 1 } = params
|
const { baseProduct, candidates, limit = 20 } = params
|
||||||
const baseText = [baseProduct.title, baseProduct.productType, query].filter(Boolean).join(" ")
|
return candidates
|
||||||
const baseTokens = new Set(uniqueTokens(baseText))
|
|
||||||
|
|
||||||
const ranked = candidates
|
|
||||||
.filter((candidate) => candidate.shopifyProductId !== baseProduct.productId)
|
.filter((candidate) => candidate.shopifyProductId !== baseProduct.productId)
|
||||||
.filter((candidate) => !candidate.status || candidate.status.toLowerCase() === "active")
|
.filter((candidate) => !candidate.status || candidate.status.toLowerCase() === "active")
|
||||||
.map((candidate) => {
|
.map((candidate) => toUpsellActionProduct(candidate, params.query))
|
||||||
const candidateText = productText(candidate)
|
.filter((product): product is StorefrontActionProduct => Boolean(product))
|
||||||
const candidateTokens = uniqueTokens(candidateText)
|
.slice(0, limit)
|
||||||
let score = accessoryScore(baseText, candidateText)
|
}
|
||||||
for (const token of candidateTokens) {
|
|
||||||
if (baseTokens.has(token)) score += 2
|
|
||||||
}
|
|
||||||
if (candidate.productType && baseProduct.productType && candidate.productType !== baseProduct.productType) {
|
|
||||||
score += 1
|
|
||||||
}
|
|
||||||
const product = toUpsellActionProduct(candidate, query)
|
|
||||||
return { product, score }
|
|
||||||
})
|
|
||||||
.filter((row): row is { product: StorefrontActionProduct; score: number } => Boolean(row.product) && row.score > 0)
|
|
||||||
.sort((a, b) => b.score - a.score)
|
|
||||||
|
|
||||||
return ranked.slice(0, limit).map((row) => row.product)
|
function buildCatalogCandidate(product: CachedProductLike, query: string): UpsellCatalogCandidate | null {
|
||||||
|
const actionProduct = toUpsellActionProduct(product, query)
|
||||||
|
if (!actionProduct) return null
|
||||||
|
return {
|
||||||
|
product: actionProduct,
|
||||||
|
title: product.title,
|
||||||
|
productType: product.productType ?? null,
|
||||||
|
details: compactMetafields(product.metafields),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAiUpsellChoice(
|
||||||
|
content: string | null,
|
||||||
|
candidates: UpsellCatalogCandidate[],
|
||||||
|
): StorefrontActionProduct | null {
|
||||||
|
if (!content) return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as { productId?: unknown }
|
||||||
|
const productId = typeof parsed.productId === "string" ? parsed.productId.trim() : ""
|
||||||
|
if (!productId) return null
|
||||||
|
return candidates.find((candidate) => candidate.product.productId === productId)?.product ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAiUpsellCandidate(params: {
|
||||||
|
baseProduct: StorefrontActionProduct
|
||||||
|
query: string
|
||||||
|
candidates: UpsellCatalogCandidate[]
|
||||||
|
}): Promise<StorefrontActionProduct | null> {
|
||||||
|
if (params.candidates.length === 0) return null
|
||||||
|
|
||||||
|
const system = [
|
||||||
|
"You select one ecommerce upsell item, or no item.",
|
||||||
|
"Use only the provided catalog candidates. Return JSON only: {\"productId\":\"...\"|null,\"reason\":\"...\"}.",
|
||||||
|
"A valid upsell must be clearly complementary to the base product: accessory, care item, consumable, add-on, matching bundle item, or item normally used together.",
|
||||||
|
"Do not choose substitutes or alternatives to the same requested product type, even if they share words, audience, color, brand, season, or price.",
|
||||||
|
"If the relationship is weak, generic, or uncertain, return productId null.",
|
||||||
|
].join(" ")
|
||||||
|
|
||||||
|
const user = JSON.stringify({
|
||||||
|
customerQuery: params.query,
|
||||||
|
baseProduct: {
|
||||||
|
title: params.baseProduct.title,
|
||||||
|
productType: params.baseProduct.productType,
|
||||||
|
sku: params.baseProduct.sku,
|
||||||
|
},
|
||||||
|
candidates: params.candidates.slice(0, 30).map((candidate) => ({
|
||||||
|
productId: candidate.product.productId,
|
||||||
|
title: candidate.title,
|
||||||
|
productType: candidate.productType,
|
||||||
|
details: candidate.details,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { content, error } = await aiChatCompletion(
|
||||||
|
[
|
||||||
|
{ role: "system", content: system },
|
||||||
|
{ role: "user", content: user },
|
||||||
|
],
|
||||||
|
{ jsonObject: true, temperature: 0.1, maxTokens: 220, timeoutMs: 10_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
logger.warn("selectAiUpsellCandidate: model call failed", { error })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseAiUpsellChoice(content, params.candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findAutomaticUpsellCandidates(params: {
|
export async function findAutomaticUpsellCandidates(params: {
|
||||||
@ -158,10 +180,18 @@ export async function findAutomaticUpsellCandidates(params: {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return rankAutomaticUpsellCandidates({
|
const candidates = products
|
||||||
|
.filter((product) => product.shopifyProductId !== params.baseProduct.productId)
|
||||||
|
.filter((product) => !product.status || product.status.toLowerCase() === "active")
|
||||||
|
.map((product) => buildCatalogCandidate(product, params.query))
|
||||||
|
.filter((candidate): candidate is UpsellCatalogCandidate => Boolean(candidate))
|
||||||
|
.slice(0, 30)
|
||||||
|
|
||||||
|
const selected = await selectAiUpsellCandidate({
|
||||||
baseProduct: params.baseProduct,
|
baseProduct: params.baseProduct,
|
||||||
query: params.query,
|
query: params.query,
|
||||||
candidates: products,
|
candidates,
|
||||||
limit: params.limit,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return selected ? [selected].slice(0, params.limit ?? 1) : []
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user