fix: cart premlink fixed
This commit is contained in:
parent
26fc46068f
commit
d1b657cba7
@ -91,6 +91,14 @@ type CartResponse = {
|
||||
content: string
|
||||
actions: StorefrontChatAction[]
|
||||
pendingCartProduct: StorefrontActionProduct | null
|
||||
cartLines: StorefrontCartLine[]
|
||||
}
|
||||
|
||||
type StorefrontCartLine = {
|
||||
variantId: string
|
||||
quantity: number
|
||||
productId?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
function describeStorefrontAction(action: StorefrontChatActionInput | undefined) {
|
||||
@ -115,11 +123,58 @@ function parsePendingCartProduct(raw: unknown): StorefrontActionProduct | null {
|
||||
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 =
|
||||
raw && typeof raw === "object" && !Array.isArray(raw)
|
||||
? { ...(raw as Record<string, unknown>) }
|
||||
: {}
|
||||
if (cartLines) {
|
||||
current.cartLines = cartLines
|
||||
current.cartUpdatedAt = new Date().toISOString()
|
||||
}
|
||||
if (pendingCartProduct) {
|
||||
current.pendingCartProduct = pendingCartProduct
|
||||
current.pendingCartUpdatedAt = new Date().toISOString()
|
||||
@ -165,6 +220,7 @@ async function buildCartResponse(params: {
|
||||
requestedVariantId?: string | null
|
||||
quantity?: number
|
||||
message: string
|
||||
existingCartLines: StorefrontCartLine[]
|
||||
}): Promise<CartResponse> {
|
||||
const product = await prisma.cachedProduct.findFirst({
|
||||
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.",
|
||||
actions: [],
|
||||
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.",
|
||||
actions: [],
|
||||
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.`,
|
||||
actions: [],
|
||||
pendingCartProduct: null,
|
||||
cartLines: params.existingCartLines,
|
||||
}
|
||||
}
|
||||
|
||||
@ -219,21 +278,11 @@ async function buildCartResponse(params: {
|
||||
productType: product.productType,
|
||||
quantity: params.quantity ?? 1,
|
||||
},
|
||||
cartLines: params.existingCartLines,
|
||||
}
|
||||
}
|
||||
|
||||
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 = {
|
||||
productId: product.shopifyProductId,
|
||||
title: product.title,
|
||||
@ -245,15 +294,33 @@ async function buildCartResponse(params: {
|
||||
productType: product.productType,
|
||||
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 =
|
||||
selected.variant.title && !/^default title$/i.test(selected.variant.title)
|
||||
? ` (${selected.variant.title})`
|
||||
: ""
|
||||
const cartCount = nextCartLines.reduce((sum, line) => sum + line.quantity, 0)
|
||||
|
||||
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)],
|
||||
pendingCartProduct: null,
|
||||
cartLines: nextCartLines,
|
||||
}
|
||||
}
|
||||
|
||||
@ -261,6 +328,7 @@ async function resolveDirectAddToCart(params: {
|
||||
storeId: string
|
||||
shopifyDomain: string
|
||||
message: string
|
||||
existingCartLines: StorefrontCartLine[]
|
||||
}): Promise<CartResponse | null> {
|
||||
if (!isDirectAddToCartRequest(params.message)) return null
|
||||
const hits = await searchProductsForStore(params.storeId, params.message, 1)
|
||||
@ -274,6 +342,7 @@ async function resolveDirectAddToCart(params: {
|
||||
requestedVariantId: product?.variantId,
|
||||
quantity: product?.quantity ?? 1,
|
||||
message: params.message,
|
||||
existingCartLines: params.existingCartLines,
|
||||
})
|
||||
}
|
||||
|
||||
@ -355,6 +424,7 @@ export async function POST(req: Request) {
|
||||
})
|
||||
|
||||
const pendingCartProduct = parsePendingCartProduct(conversation.l1State)
|
||||
const existingCartLines = parseCartLines(conversation.l1State)
|
||||
const cartResponse = action
|
||||
? await buildCartResponse({
|
||||
storeId: store.id,
|
||||
@ -363,6 +433,7 @@ export async function POST(req: Request) {
|
||||
requestedVariantId: action.variantId,
|
||||
quantity: action.quantity,
|
||||
message: customerContent,
|
||||
existingCartLines,
|
||||
})
|
||||
: pendingCartProduct && isCartConfirmationMessage(customerContent)
|
||||
? await buildCartResponse({
|
||||
@ -372,11 +443,13 @@ export async function POST(req: Request) {
|
||||
requestedVariantId: pendingCartProduct.variantId,
|
||||
quantity: pendingCartProduct.quantity,
|
||||
message: customerContent,
|
||||
existingCartLines,
|
||||
})
|
||||
: await resolveDirectAddToCart({
|
||||
storeId: store.id,
|
||||
shopifyDomain: store.shopifyDomain,
|
||||
message: customerContent,
|
||||
existingCartLines,
|
||||
})
|
||||
|
||||
if (cartResponse) {
|
||||
@ -392,7 +465,15 @@ export async function POST(req: Request) {
|
||||
|
||||
await prisma.storefrontChatConversation.update({
|
||||
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({
|
||||
|
||||
@ -1,43 +1,83 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { rankAutomaticUpsellCandidates } from "./upsells"
|
||||
import { parseAiUpsellChoice, rankAutomaticUpsellCandidates } from "./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({
|
||||
baseProduct: {
|
||||
productId: "shoe-1",
|
||||
title: "School Shoes",
|
||||
variantId: "v-shoe",
|
||||
productType: "Shoes",
|
||||
productId: "base-1",
|
||||
title: "Base Product",
|
||||
variantId: "v-base",
|
||||
productType: "Main",
|
||||
},
|
||||
query: "Do you have school shoes?",
|
||||
query: "Tell me about the base product",
|
||||
candidates: [
|
||||
{
|
||||
shopifyProductId: "shoe-1",
|
||||
title: "School Shoes",
|
||||
productType: "Shoes",
|
||||
shopifyProductId: "base-1",
|
||||
title: "Base Product",
|
||||
productType: "Main",
|
||||
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",
|
||||
title: "School Socks",
|
||||
productType: "Accessories",
|
||||
shopifyProductId: "candidate-1",
|
||||
title: "Candidate Product",
|
||||
productType: "Accessory",
|
||||
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",
|
||||
title: "Travel Bag",
|
||||
productType: "Bags",
|
||||
status: "active",
|
||||
variants: [{ id: "v-bag", title: "Default Title", inventory_quantity: 8 }],
|
||||
shopifyProductId: "draft-1",
|
||||
title: "Draft Product",
|
||||
productType: "Accessory",
|
||||
status: "draft",
|
||||
variants: [{ id: "v-draft", title: "Default Title", inventory_quantity: 8 }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].productId).toBe("sock-1")
|
||||
expect(results[0].variantId).toBe("v-sock")
|
||||
expect(results[0].productId).toBe("candidate-1")
|
||||
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 { logger } from "@/lib/logger"
|
||||
import { aiChatCompletion } from "@/lib/ai/chatCompletion"
|
||||
import type { StorefrontActionProduct } from "@/lib/storefront-chat/actions"
|
||||
import { extractCartableVariants, selectVariantForCart } from "@/lib/storefront-chat/cartLinks"
|
||||
|
||||
@ -12,39 +14,11 @@ type CachedProductLike = {
|
||||
metafields?: unknown
|
||||
}
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
"the",
|
||||
"and",
|
||||
"for",
|
||||
"with",
|
||||
"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 ?? ""))))
|
||||
type UpsellCatalogCandidate = {
|
||||
product: StorefrontActionProduct
|
||||
title: string
|
||||
productType: string | null
|
||||
details: string | null
|
||||
}
|
||||
|
||||
function imageUrlFromImages(images: unknown): string | null {
|
||||
@ -67,24 +41,17 @@ function imageUrlFromImages(images: unknown): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function productText(product: CachedProductLike) {
|
||||
return [
|
||||
product.title,
|
||||
product.productType,
|
||||
JSON.stringify(product.metafields ?? ""),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
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
|
||||
function compactMetafields(metafields: unknown): string | null {
|
||||
if (!Array.isArray(metafields)) return null
|
||||
const parts: string[] = []
|
||||
for (const row of metafields) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue
|
||||
const field = row as { key?: unknown; value?: unknown }
|
||||
const key = typeof field.key === "string" ? field.key.trim() : ""
|
||||
const value = typeof field.value === "string" ? field.value.trim() : ""
|
||||
if (key && value) parts.push(`${key}: ${value.slice(0, 120)}`)
|
||||
}
|
||||
}
|
||||
return score
|
||||
return parts.length ? parts.join(" | ").slice(0, 500) : null
|
||||
}
|
||||
|
||||
function toUpsellActionProduct(product: CachedProductLike, query: string): StorefrontActionProduct | null {
|
||||
@ -111,30 +78,85 @@ export function rankAutomaticUpsellCandidates(params: {
|
||||
candidates: CachedProductLike[]
|
||||
limit?: number
|
||||
}): StorefrontActionProduct[] {
|
||||
const { baseProduct, query, candidates, limit = 1 } = params
|
||||
const baseText = [baseProduct.title, baseProduct.productType, query].filter(Boolean).join(" ")
|
||||
const baseTokens = new Set(uniqueTokens(baseText))
|
||||
|
||||
const ranked = candidates
|
||||
const { baseProduct, candidates, limit = 20 } = params
|
||||
return candidates
|
||||
.filter((candidate) => candidate.shopifyProductId !== baseProduct.productId)
|
||||
.filter((candidate) => !candidate.status || candidate.status.toLowerCase() === "active")
|
||||
.map((candidate) => {
|
||||
const candidateText = productText(candidate)
|
||||
const candidateTokens = uniqueTokens(candidateText)
|
||||
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)
|
||||
.map((candidate) => toUpsellActionProduct(candidate, params.query))
|
||||
.filter((product): product is StorefrontActionProduct => Boolean(product))
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
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: {
|
||||
@ -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,
|
||||
query: params.query,
|
||||
candidates: products,
|
||||
limit: params.limit,
|
||||
candidates,
|
||||
})
|
||||
|
||||
return selected ? [selected].slice(0, params.limit ?? 1) : []
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user