From d1b657cba7e2638d47e645cfaff52d61f158aa73 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 11 May 2026 22:51:33 +0500 Subject: [PATCH] fix: cart premlink fixed --- src/app/api/storefront-chat/messages/route.ts | 109 +++++++++-- src/lib/storefront-chat/upsells.test.ts | 84 ++++++--- src/lib/storefront-chat/upsells.ts | 178 ++++++++++-------- 3 files changed, 261 insertions(+), 110 deletions(-) diff --git a/src/app/api/storefront-chat/messages/route.ts b/src/app/api/storefront-chat/messages/route.ts index 1390415..4de9efb 100644 --- a/src/app/api/storefront-chat/messages/route.ts +++ b/src/app/api/storefront-chat/messages/route.ts @@ -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 + 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 + 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() + 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) } : {} + 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 { 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 { 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({ diff --git a/src/lib/storefront-chat/upsells.test.ts b/src/lib/storefront-chat/upsells.test.ts index 11e63a3..6423807 100644 --- a/src/lib/storefront-chat/upsells.test.ts +++ b/src/lib/storefront-chat/upsells.test.ts @@ -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() }) }) diff --git a/src/lib/storefront-chat/upsells.ts b/src/lib/storefront-chat/upsells.ts index e42c293..5b175ed 100644 --- a/src/lib/storefront-chat/upsells.ts +++ b/src/lib/storefront-chat/upsells.ts @@ -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) { - 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 { + 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) : [] }