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
This commit is contained in:
talatops 2026-04-02 22:32:48 +05:00
parent 53c2ed7a4e
commit aa871ff942
2 changed files with 164 additions and 9 deletions

View File

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

View File

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