From aa871ff942025fc8da990d4e94160bdd9273463d Mon Sep 17 00:00:00 2001 From: talatops Date: Thu, 2 Apr 2026 22:32:48 +0500 Subject: [PATCH] fix(storefront-chat): ground AI replies in catalog and stop KB noise - Filter stopwords from retrieval term matching so generic words do not score unrelated KB chunks. - For product-availability questions with no product or KB hits, return a deterministic cannot-confirm reply (skip LLM). - Tighten system prompt: no invented inventory, menus, or URLs; lower temperature. Made-with: Cursor --- src/lib/ai/storefrontGroq.ts | 55 +++++++++++-- src/lib/storefront-chat/retrieval.ts | 118 ++++++++++++++++++++++++++- 2 files changed, 164 insertions(+), 9 deletions(-) diff --git a/src/lib/ai/storefrontGroq.ts b/src/lib/ai/storefrontGroq.ts index 08f3632..282638c 100644 --- a/src/lib/ai/storefrontGroq.ts +++ b/src/lib/ai/storefrontGroq.ts @@ -7,6 +7,16 @@ type HistoryMessage = { content: string } +/** Customer is asking whether the store carries / sells a product (not policy/shipping). */ +function isAskingAboutProductAvailability(query: string) { + return /\b(do you have|have any|have you got|carry|carrying|sell|selling|in stock|out of stock|stock|available|got any|do you sell|do you carry|looking for|can i (get|buy)|can you get)\b/i.test( + query.trim() + ) +} + +const NO_EVIDENCE_PRODUCT_REPLY = + "I do not see that item in this store’s catalog or FAQs I have access to, so I can’t confirm we carry it. If you have another product name or category from this shop, I can check again." + export async function generateStorefrontReply(params: { storeId: string query: string @@ -17,6 +27,34 @@ export async function generateStorefrontReply(params: { const { storeId, query, visitorId, history } = params const retrieval = await getBlendedStorefrontContext({ storeId, query }) + + // Hard guard: do not invent inventory. If they ask “do you have X?” and we have no product row and no KB/FAQ hit, answer without the LLM. + if ( + isAskingAboutProductAvailability(query) && + retrieval.productCount === 0 && + retrieval.kbCount === 0 + ) { + logger.info("[STOREFRONT_AI_REPLY]", { + storeId, + visitorId, + queryLength: query.length, + kbHits: retrieval.kbCount, + productHits: retrieval.productCount, + latencyMs: Date.now() - startedAt, + fallbackUsed: true, + groundedSkipLlm: true, + error: null, + }) + return { + content: NO_EVIDENCE_PRODUCT_REPLY, + meta: { + kbHits: retrieval.kbCount, + productHits: retrieval.productCount, + fallbackUsed: true, + }, + } + } + const contextLines = retrieval.items.map( (item, idx) => `[${idx + 1}] (${item.source}) ${item.title}: ${item.content}` ) @@ -30,12 +68,13 @@ export async function generateStorefrontReply(params: { })) as GroqChatMessage[] const system = [ - "You are JUNO storefront assistant for an ecommerce store.", - "Use the provided context first (products, FAQs, knowledge).", - "If context is insufficient, ask one concise clarifying question.", - "Do not invent exact prices, stock or policy details that are not present.", - "Keep responses concise, friendly, and sales-supportive.", - "Return plain text only.", + "You are the JUNO storefront assistant. You ONLY answer using the Context block below (products, FAQs, knowledge chunks).", + "If the Context does not clearly support an answer, say you cannot find it in the catalog or docs you have—do NOT guess.", + "Never claim a product exists, is in stock, or that the store carries a category unless a Context line explicitly supports it.", + "Never invent website layout: no tabs, menus, dropdowns, or navigation paths unless they appear verbatim in Context.", + "Never invent URLs or deep links unless they appear in Context.", + "Do not affirm availability with “yes” unless a PRODUCT line or FAQ/KB line in Context clearly covers what the customer asked.", + "Be concise and friendly. Plain text only.", ].join(" ") const user = [ @@ -53,13 +92,13 @@ export async function generateStorefrontReply(params: { ] const out = await groqChatCompletion(messages, { - temperature: 0.2, + temperature: 0.1, maxTokens: 420, timeoutMs: 28_000, }) const fallback = - "Thanks for your message. I do not have enough details right now. Could you share the product name or what exactly you want to know?" + "I don’t have enough in the store’s catalog or FAQ context to answer that accurately. What product name or topic from this shop should I look for?" const content = (out.content || "").trim() || fallback diff --git a/src/lib/storefront-chat/retrieval.ts b/src/lib/storefront-chat/retrieval.ts index a1f0f5a..35d24b0 100644 --- a/src/lib/storefront-chat/retrieval.ts +++ b/src/lib/storefront-chat/retrieval.ts @@ -7,12 +7,128 @@ type RetrievedContextItem = { score: number } +/** Strip generic words so "do you have socks?" does not match random KB via "you"/"do". */ +const STOPWORDS = new Set([ + "the", + "a", + "an", + "and", + "or", + "but", + "in", + "on", + "at", + "to", + "for", + "of", + "as", + "by", + "with", + "from", + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "have", + "has", + "had", + "do", + "does", + "did", + "will", + "would", + "could", + "should", + "may", + "might", + "must", + "can", + "this", + "that", + "these", + "those", + "i", + "me", + "my", + "you", + "your", + "it", + "its", + "we", + "our", + "they", + "them", + "their", + "what", + "which", + "who", + "whom", + "whose", + "where", + "when", + "why", + "how", + "all", + "each", + "every", + "both", + "few", + "more", + "most", + "other", + "some", + "such", + "no", + "nor", + "not", + "only", + "own", + "same", + "so", + "than", + "too", + "very", + "just", + "any", + "there", + "here", + "about", + "into", + "through", + "during", + "before", + "after", + "above", + "below", + "between", + "under", + "again", + "further", + "then", + "once", + "please", + "thanks", + "thank", + "hello", + "hi", + "hey", + "yes", + "no", + "tell", + "give", + "get", + "got", +]) + function tokenize(text: string) { return text .toLowerCase() .split(/[^a-z0-9]+/g) .map((t) => t.trim()) - .filter((t) => t.length >= 2) + .filter((t) => t.length >= 2 && !STOPWORDS.has(t)) } function scoreByTerms(text: string, terms: string[]) {