fix: cart premlink fixed

This commit is contained in:
root 2026-05-11 22:51:33 +05:00
parent 26fc46068f
commit d1b657cba7
3 changed files with 261 additions and 110 deletions

View File

@ -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({

View File

@ -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()
})
})

View File

@ -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) : []
}