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 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: { export async function generateStorefrontReply(params: {
storeId: string storeId: string
query: string query: string
@ -17,6 +27,34 @@ export async function generateStorefrontReply(params: {
const { storeId, query, visitorId, history } = params const { storeId, query, visitorId, history } = params
const retrieval = await getBlendedStorefrontContext({ storeId, query }) 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( const contextLines = retrieval.items.map(
(item, idx) => `[${idx + 1}] (${item.source}) ${item.title}: ${item.content}` (item, idx) => `[${idx + 1}] (${item.source}) ${item.title}: ${item.content}`
) )
@ -30,12 +68,13 @@ export async function generateStorefrontReply(params: {
})) as GroqChatMessage[] })) as GroqChatMessage[]
const system = [ const system = [
"You are JUNO storefront assistant for an ecommerce store.", "You are the JUNO storefront assistant. You ONLY answer using the Context block below (products, FAQs, knowledge chunks).",
"Use the provided context first (products, FAQs, knowledge).", "If the Context does not clearly support an answer, say you cannot find it in the catalog or docs you have—do NOT guess.",
"If context is insufficient, ask one concise clarifying question.", "Never claim a product exists, is in stock, or that the store carries a category unless a Context line explicitly supports it.",
"Do not invent exact prices, stock or policy details that are not present.", "Never invent website layout: no tabs, menus, dropdowns, or navigation paths unless they appear verbatim in Context.",
"Keep responses concise, friendly, and sales-supportive.", "Never invent URLs or deep links unless they appear in Context.",
"Return plain text only.", "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(" ") ].join(" ")
const user = [ const user = [
@ -53,13 +92,13 @@ export async function generateStorefrontReply(params: {
] ]
const out = await groqChatCompletion(messages, { const out = await groqChatCompletion(messages, {
temperature: 0.2, temperature: 0.1,
maxTokens: 420, maxTokens: 420,
timeoutMs: 28_000, timeoutMs: 28_000,
}) })
const fallback = 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 const content = (out.content || "").trim() || fallback

View File

@ -7,12 +7,128 @@ type RetrievedContextItem = {
score: number 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) { function tokenize(text: string) {
return text return text
.toLowerCase() .toLowerCase()
.split(/[^a-z0-9]+/g) .split(/[^a-z0-9]+/g)
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => t.length >= 2) .filter((t) => t.length >= 2 && !STOPWORDS.has(t))
} }
function scoreByTerms(text: string, terms: string[]) { function scoreByTerms(text: string, terms: string[]) {