Scope gate — canned intents + refusal threshold
Source: platform/apps/app/src/lib/rag/scope-gate.ts · rendered from main on every deploy — edit in the repo, not here
/** * Scope gate — refuse out-of-KB queries deterministically (FinOps + safety). * * Two problems this solves: * * 1. **Hallucination.** When a tenant's KB has nothing relevant, the LLM * will sometimes confidently answer from its world knowledge. For a * compliance product that's a trust-killer ("Puccha told my customer * our refund policy is 30 days, but we say 14"). Refusing politely * and redirecting to human staff is the only safe default. * * 2. **Cost.** Every LLM call is ~$0.0015 amortized (Haiku 4.5 in/out). * If we already know — from retrieval scores — that the answer * can't be grounded, calling the model anyway is pure burn. Skipping * it on out-of-scope queries cuts ~30-50% of widget chat spend on * a typical small-KB tenant where greetings + off-topic dominate. * * The gate runs in two stages: * * Stage A — pre-retrieval intent classifier. Cheap regex-based check * for greetings / thanks / chit-chat. Returns a canned reply WITHOUT * running embedding, search, rerank, or LLM. This is the biggest * single FinOps win because greetings have zero retrieval value but * the LLM-driven flow would still call the model. * * Stage B — post-retrieval scope check. After hybridSearch + rerank, * if the top result's score is below the confidence threshold (or * there are no results), we refuse without calling the LLM. The * threshold is calibrated for Cohere rerank scores (0..1, semantic * relevance) — when the noop reranker is in use we fall back to * "any result at all" since RRF/fusion scores aren't comparable. */
/** Cohere rerank threshold below which we treat the top hit as off-topic. * 0.30 was picked from the eval corpus: scores below this consistently * paired with citation_recall = 0 in the Promptfoo runs. Tune by tenant * later if needed. */export const SCOPE_RERANK_MIN_SCORE = 0.3;
export type QueryIntent = 'greeting' | 'gratitude' | 'scope';
/** Thai polite particles that commonly suffix a greeting or thanks * (สวัสดี**ครับ**, ขอบคุณ**ค่ะ**). Without these the anchored regex below * rejected the two MOST common Thai greetings — `สวัสดีครับ` / `สวัสดีค่ะ` — * as `scope`, so a bare "hello" got an out-of-scope refusal in prod. */const TH_POLITE = '(?:ครับ|คร้าบ|ครับผม|ค่ะ|คะ|ค่า|จ้า|จ้ะ|จ๋า|นะ|น่ะ|นะคะ|นะครับ)';
/** Anchored alternation — match if the *whole trimmed* query is a greeting/ * thanks token plus optional polite particles and trailing punctuation. The * anchor avoids matching "hello, what's your refund policy?" as a greeting. */const GREETING_RE = new RegExp( `^(?:สวัสดี|หวัดดี|ดีครับ|ดีค่ะ|hi|hello|hey|good\\s+(?:morning|afternoon|evening))(?:\\s*${TH_POLITE})*[\\s.!?]*$`, 'i',);const GRATITUDE_RE = new RegExp( `^(?:ขอบคุณ|ขอบใจ|thanks|thank\\s*you|ty|thx)(?:\\s*(?:มาก|มากๆ|${TH_POLITE}))*[\\s.!?]*$`, 'i',);
export function classifyQueryIntent(query: string): QueryIntent { const trimmed = query.trim(); if (GREETING_RE.test(trimmed)) return 'greeting'; if (GRATITUDE_RE.test(trimmed)) return 'gratitude'; return 'scope';}
/** Commercial / lead intent: the visitor is expressing buying interest, a need, * or asking whether we offer something ("สนใจ software CRM", "interested in X", * "do you build …") rather than asking a precise factual question. A QA reranker * scores these LOW — they aren't questions the KB *answers* — so they fall below * the scope floor and get refused, even when they're squarely on-topic. But a * refused lead is the worst outcome for a sales assistant. When this matches AND * we retrieved at least some context, the caller skips the scope-gate refusal * and lets the grounded LLM engage + offer a handoff. False positives are * harmless: the query was already below the floor, so the alternative was a flat * refusal; a grounded reply (which still says "I don't have that — contact our * team" when context is thin) is strictly better UX. Refines ADR-0112's gate. */const COMMERCIAL_INTENT_RE = /สนใจ|อยาก|ต้องการ|มองหา|ปรึกษา|ว่าจ้าง|จ้าง|ขอราคา|ใบเสนอราคา|พัฒนา|สร้าง|จอง|ซื้อ|สั่งซื้อ|สมัคร|interested|looking\s+for|need|want|do\s+you|can\s+you|could\s+you|quote|hire|consult|build|develop|help\s+me|book|reserve|booking|buy|purchase|sign\s*up|subscribe/i;
export function hasCommercialIntent(query: string): boolean { return COMMERCIAL_INTENT_RE.test(query);}
// Canned greeting / gratitude / out-of-scope wording lives in chat/canned.ts// (ADR-0205) so it follows the persona's voice.
interface ScopedSearchResult { score: number;}
interface ScopeCheckOpts { /** True when the active reranker returns calibrated semantic scores * (Cohere v3.5). When false (LLM reranker, noop), the score scale is * not comparable across tenants, so we fall back to "any hit at all". */ rerankerCalibrated: boolean; /** Override the default threshold per-tenant (e.g. for noisy KBs). */ minScore?: number;}
export function isWithinScope(results: ScopedSearchResult[], opts: ScopeCheckOpts): boolean { if (results.length === 0) return false; if (!opts.rerankerCalibrated) return true; // best-effort when uncalibrated const top = results[0]?.score ?? 0; return top >= (opts.minScore ?? SCOPE_RERANK_MIN_SCORE);}