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

View File

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

View File

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