fixed: upselling suggestions

This commit is contained in:
root 2026-05-11 23:44:05 +05:00
parent db9b09e09a
commit a1646f3af9
3 changed files with 73 additions and 11 deletions

View File

@ -504,12 +504,30 @@ export default function StorefrontChatWidgetPage() {
type="button" type="button"
onClick={() => submitCartAction(message, action)} onClick={() => submitCartAction(message, action)}
disabled={sending} disabled={sending}
className="w-full rounded-xl border border-indigo-200 bg-white px-3 py-2 text-left text-xs font-bold text-indigo-700 hover:bg-indigo-50 disabled:opacity-50" className="w-full rounded-xl border border-indigo-200 bg-white px-3 py-2 text-left hover:bg-indigo-50 disabled:opacity-50"
> >
<span className="flex items-center gap-2">
{action.product.imageUrl ? (
<span
aria-hidden="true"
className="h-10 w-10 flex-shrink-0 rounded-lg bg-slate-100 bg-cover bg-center"
style={{ backgroundImage: `url(${action.product.imageUrl})` }}
/>
) : null}
<span className="min-w-0">
<span className="block text-xs font-bold text-indigo-700">
{action.label} {action.label}
</span>
<span className="block truncate text-[11px] font-medium text-slate-500"> <span className="block truncate text-[11px] font-medium text-slate-500">
{action.product.title} {action.product.title}
</span> </span>
{action.product.price ? (
<span className="block text-[11px] font-medium text-slate-400">
{action.product.price}
</span>
) : null}
</span>
</span>
</button> </button>
) )
})} })}

View File

@ -47,6 +47,7 @@ describe("automatic storefront upsells", () => {
productId: null, productId: null,
relationshipType: "uncertain", relationshipType: "uncertain",
confidence: 0, confidence: 0,
catalogEvidence: "",
reason: "No clearly complementary product.", reason: "No clearly complementary product.",
}), }),
[ [
@ -72,6 +73,7 @@ describe("automatic storefront upsells", () => {
productId: "candidate-1", productId: "candidate-1",
relationshipType: "substitute", relationshipType: "substitute",
confidence: 0.9, confidence: 0.9,
catalogEvidence: "Candidate Product",
reason: "This is an alternative, not an add-on.", reason: "This is an alternative, not an add-on.",
}), }),
[ [
@ -96,7 +98,8 @@ describe("automatic storefront upsells", () => {
JSON.stringify({ JSON.stringify({
productId: "candidate-1", productId: "candidate-1",
relationshipType: "complementary_accessory", relationshipType: "complementary_accessory",
confidence: 0.88, confidence: 0.95,
catalogEvidence: "Accessory",
reason: "This is used together with the base product.", reason: "This is used together with the base product.",
}), }),
[ [
@ -122,6 +125,7 @@ describe("automatic storefront upsells", () => {
productId: "unknown-product", productId: "unknown-product",
relationshipType: "complementary_accessory", relationshipType: "complementary_accessory",
confidence: 0.9, confidence: 0.9,
catalogEvidence: "Accessory",
reason: "Not in the provided list.", reason: "Not in the provided list.",
}), }),
[ [
@ -140,4 +144,30 @@ describe("automatic storefront upsells", () => {
expect(result).toBeNull() expect(result).toBeNull()
}) })
it("rejects complementary choices without exact catalog evidence", () => {
const result = parseAiUpsellChoice(
JSON.stringify({
productId: "candidate-1",
relationshipType: "complementary_accessory",
confidence: 0.95,
catalogEvidence: "commonly worn together",
reason: "This uses generic knowledge instead of catalog text.",
}),
[
{
title: "Plain Socks",
productType: "Accessory",
details: null,
product: {
productId: "candidate-1",
title: "Plain Socks",
variantId: "v-candidate",
},
},
],
)
expect(result).toBeNull()
})
}) })

View File

@ -116,17 +116,30 @@ export function parseAiUpsellChoice(
productId?: unknown productId?: unknown
relationshipType?: unknown relationshipType?: unknown
confidence?: unknown confidence?: unknown
catalogEvidence?: unknown
} }
const productId = typeof parsed.productId === "string" ? parsed.productId.trim() : "" const productId = typeof parsed.productId === "string" ? parsed.productId.trim() : ""
if (!productId) return null if (!productId) return null
const candidate = candidates.find((row) => row.product.productId === productId)
if (!candidate) return null
const relationshipType = const relationshipType =
typeof parsed.relationshipType === "string" typeof parsed.relationshipType === "string"
? parsed.relationshipType.trim().toLowerCase() ? parsed.relationshipType.trim().toLowerCase()
: "" : ""
if (!ACCEPTED_UPSELL_RELATIONSHIPS.has(relationshipType)) return null if (!ACCEPTED_UPSELL_RELATIONSHIPS.has(relationshipType)) return null
const confidence = Number(parsed.confidence) const confidence = Number(parsed.confidence)
if (!Number.isFinite(confidence) || confidence < 0.75) return null if (!Number.isFinite(confidence) || confidence < 0.9) return null
return candidates.find((candidate) => candidate.product.productId === productId)?.product ?? null const catalogEvidence =
typeof parsed.catalogEvidence === "string"
? parsed.catalogEvidence.trim().toLowerCase()
: ""
if (catalogEvidence.length < 8) return null
const candidateCatalogText = [candidate.title, candidate.productType, candidate.details]
.filter(Boolean)
.join(" ")
.toLowerCase()
if (!candidateCatalogText.includes(catalogEvidence)) return null
return candidate.product
} catch { } catch {
return null return null
} }
@ -142,13 +155,14 @@ async function selectAiUpsellCandidate(params: {
const system = [ const system = [
"You select one ecommerce upsell item, or no item.", "You select one ecommerce upsell item, or no item.",
"Use only the provided catalog candidates.", "Use only the provided catalog candidates.",
"Return JSON only: {\"productId\":\"...\"|null,\"relationshipType\":\"complementary_accessory|care_item|consumable|bundle_addon|used_together|substitute|alternative|same_category|unrelated|uncertain\",\"confidence\":0-1,\"reason\":\"...\"}.", "Return JSON only: {\"productId\":\"...\"|null,\"relationshipType\":\"complementary_accessory|care_item|consumable|bundle_addon|used_together|substitute|alternative|same_category|unrelated|uncertain\",\"confidence\":0-1,\"catalogEvidence\":\"exact text copied from the chosen candidate title/type/details, or empty string\",\"reason\":\"...\"}.",
"A valid upsell must be something the customer would reasonably buy in addition to the base product and use together with it.", "A valid upsell must be something the customer would reasonably buy in addition to the base product and use together with it.",
"Choose only these valid relationship types: complementary_accessory, care_item, consumable, bundle_addon, used_together.", "Choose only these valid relationship types: complementary_accessory, care_item, consumable, bundle_addon, used_together.",
"The chosen candidate's own title/type/details must explicitly support the add-on relationship. Do not use general shopping knowledge or assumptions.",
"Do not choose substitutes, alternatives, or same-category replacements, even if they share words, audience, color, brand, season, style, or price.", "Do not choose substitutes, alternatives, or same-category replacements, even if they share words, audience, color, brand, season, style, or price.",
"Invalid examples: sandals -> flip flops, slippers -> shoes, rain boots -> formal shoes, one backpack -> another backpack.", "Invalid examples: sandals -> flip flops, slippers -> shoes, rain boots -> formal shoes, one backpack -> another backpack, ballet flat shoes -> socks when the candidate only says socks.",
"Valid examples: shoes -> socks, shoes -> laces, leather shoes -> polish, phone -> case, backpack -> lunch box.", "Only choose socks/laces/care products when the candidate catalog text explicitly says they match, fit, complement, are for, or are bundled with the base product or its exact use case.",
"If there is no clear add-on with confidence at least 0.75, return productId null with relationshipType uncertain.", "If there is no explicit catalog-backed add-on with confidence at least 0.90, return productId null with relationshipType uncertain and empty catalogEvidence.",
].join(" ") ].join(" ")
const user = JSON.stringify({ const user = JSON.stringify({