const SAFE_ROUTES = new Set([ "cached_answer", "predefined_answer", "main_llm", "clarification", "refusal", "unavailable" ]); class GateProvider { constructor({ getConfig, runtime, lookupRepo, lookupCorrection, cache, metrics }) { Object.assign(this, { getConfig, runtime, lookupRepo, lookupCorrection, cache, metrics }); this.recentPrompts = new Map(); } async route({ message, user, role, scope, originContext, onStage = () => {} }) { const started = Date.now(); const cfg = this.getConfig(); const gate = cfg.gate || {}; const prepared = stripForcePrefix(message, gate.force_prefix); const context = { message: prepared.message, role, platform: originContext?.platform || originContext?.origin || "webui" }; const requestClass = classifyRequestType(context.message, { role, scope }); onStage("deterministic"); const forceReason = prepared.forced ? "explicit_force_prefix" : this.isRepeat(context, user?.id, scope, gate) ? "repeat_prompt_force" : null; this.remember(context.message, user?.id, scope, gate); if (forceReason) { return this.finish({ route: "main_llm", confidence: 1, reason_code: forceReason, message: context.message, forced: true, request_class: requestClass, deterministic_ms: Date.now() - started, gate_ms: 0 }, started, context); } if (isSensitiveRequest(context.message)) { return this.finish({ route: "main_llm", confidence: 1, reason_code: "sensitive_or_user_specific", message: context.message, request_class: requestClass, deterministic_ms: Date.now() - started, gate_ms: 0 }, started, context); } const reviewed = gate.predefined_enabled !== false ? this.lookupCorrection?.({ ...context, origin: originContext?.origin || context.platform }) : null; if (reviewed) { return this.finish({ route: "predefined_answer", confidence: reviewed.score, reason_code: `approved_${reviewed.target}`, message: context.message, answer: { text: reviewed.corrected_answer, links: reviewed.expected_link ? [{ label: reviewed.route_alias || "Open verified Lumi page", href: reviewed.expected_link }] : [], source: { type: "approved_correction", id: reviewed.id }, safe: true }, request_class: requestClass, deterministic_ms: Date.now() - started, gate_ms: 0 }, started, context); } const cached = gate.predefined_enabled !== false ? this.cache?.get(context) : null; if (cached) { return this.finish({ route: "cached_answer", confidence: 1, reason_code: "exact_cache_hit", message: context.message, answer: cached, request_class: requestClass, deterministic_ms: Date.now() - started, gate_ms: 0 }, started, context); } const repoAnswer = gate.predefined_enabled !== false ? this.lookupRepo?.(context.message) || null : null; if (isExactPredefinedQuery(context.message, repoAnswer)) { const answer = { text: repoAnswer.text, links: repoAnswer.links || [], source: repoAnswer.source || null, safe: true }; this.cache?.set(context, answer); return this.finish({ route: "predefined_answer", confidence: 1, reason_code: `exact_verified_${repoAnswer.type}`, message: context.message, answer, request_class: "navigation_help", deterministic_ms: Date.now() - started, gate_ms: 0 }, started, context); } if (isComplexOrAmbiguous(context.message)) { return this.finish({ route: "main_llm", confidence: 1, reason_code: "deterministic_complexity_escalation", message: context.message, request_class: requestClass, deterministic_ms: Date.now() - started, gate_ms: 0 }, started, context); } const deterministicMs = Date.now() - started; const gateStarted = Date.now(); onStage("gating"); let classification; try { classification = await this.classify(context); } catch (error) { classification = { route: "main_llm", confidence: 0, reason_code: isTimeoutError(error) ? "gate_timeout_escalated" : "gate_error_escalated", gate_error: error.message }; } const normalized = normalizeDecision(classification); const mainThreshold = Math.max(0.1, Math.min(0.95, Number(gate.main_llm_threshold) || 0.72)); const highThreshold = Math.max(0.5, Math.min(0.99, Number(gate.high_confidence_threshold) || 0.88)); let decision = normalized; if (["refusal", "unavailable"].includes(decision.route) && decision.confidence < highThreshold) { decision = { route: "main_llm", confidence: decision.confidence, reason_code: "terminal_route_low_confidence" }; } else if (["cached_answer", "predefined_answer"].includes(decision.route)) { decision = { route: "main_llm", confidence: decision.confidence, reason_code: "gate_cannot_authorize_predefined" }; } else if ( (decision.confidence < mainThreshold || !SAFE_ROUTES.has(decision.route)) && !["gate_timeout_escalated", "gate_error_escalated"].includes(decision.reason_code) ) { decision = { route: "main_llm", confidence: decision.confidence, reason_code: "low_confidence" }; } else if (decision.route === "refusal") { decision.answer = { text: cfg.instructions?.out_of_scope_response || "I cannot help with that request.", links: [], safe: true }; } else if (decision.route === "unavailable") { decision.answer = { text: cfg.commands?.unavailable_message || "Lumi Assistant is currently unavailable.", links: [], safe: true }; } else if (decision.route === "clarification") { decision.route = "main_llm"; decision.reason_code = "clarification_requires_main_llm"; } return this.finish({ ...decision, message: context.message, request_class: requestClass, deterministic_ms: deterministicMs, gate_ms: Date.now() - gateStarted }, started, context); } async classify(context) { if (this.runtime.status().state !== "running") throw new Error("Gate runtime is unavailable."); const timeoutMs = Math.max(1000, Math.min(5000, Number(this.getConfig().gate?.timeout_ms) || 3000)); const prompt = [ "Classify only. JSON only.", "Routes: main_llm, refusal, unavailable.", "Escalate uncertainty or complexity to main_llm.", '{"route":"main_llm","confidence":0.0,"reason_code":"short_code"}' ].join("\n"); const result = await withTimeout(this.runtime.infer([ { role: "system", content: prompt }, { role: "user", content: String(context.message).slice(0, 1000) } ], 64, timeoutMs), timeoutMs); return parseDecision(result.choices?.[0]?.message?.content); } isRepeat(context, userId, scope, gate) { const configuredWindow = Number(gate.repeat_force_window_seconds); const windowMs = Math.max(0, Number.isFinite(configuredWindow) ? configuredWindow : 90) * 1000; if (!windowMs) return false; const key = `${userId || "anonymous"}:${scope || "assistant"}`; const rows = (this.recentPrompts.get(key) || []).filter((entry) => Date.now() - entry.at <= windowMs); const threshold = Math.max(0.5, Math.min(1, Number(gate.similarity_threshold) || 0.86)); return rows.some((entry) => similarity(entry.message, context.message) >= threshold); } remember(message, userId, scope, gate) { const key = `${userId || "anonymous"}:${scope || "assistant"}`; const configuredWindow = Number(gate.repeat_force_window_seconds); const windowMs = Math.max(1, Number.isFinite(configuredWindow) ? configuredWindow : 90) * 1000; const rows = (this.recentPrompts.get(key) || []) .filter((entry) => Date.now() - entry.at <= windowMs) .slice(-9); rows.push({ message, at: Date.now() }); this.recentPrompts.set(key, rows); } finish(decision, started, context) { const output = { route: decision.route, confidence: Number(decision.confidence) || 0, reason_code: decision.reason_code || "unspecified", answer: decision.answer || null, message: decision.message || context.message, forced: Boolean(decision.forced), request_class: normalizeRequestClass(decision.request_class), deterministic_ms: Math.max(0, Number(decision.deterministic_ms) || 0), gate_ms: Math.max(0, Number(decision.gate_ms) || 0), duration_ms: Date.now() - started }; this.metrics.record({ kind: "gate_decision", status: "success", route_used: output.route, confidence: output.confidence, reason_code: output.reason_code, request_class: output.request_class, route_class: output.request_class, deterministic_ms: output.deterministic_ms, gate_ms: output.gate_ms, duration_ms: output.duration_ms, platform: context.platform }); return output; } } function parseDecision(value) { const text = String(value || "").trim(); const match = text.match(/\{[\s\S]*\}/); if (!match) throw new Error("Gate model returned invalid JSON."); return JSON.parse(match[0]); } function normalizeDecision(value = {}) { return { route: SAFE_ROUTES.has(value.route) ? value.route : "main_llm", confidence: Math.max(0, Math.min(1, Number(value.confidence) || 0)), reason_code: /^[a-z0-9_]{2,80}$/.test(String(value.reason_code || "")) ? value.reason_code : "invalid_reason_code" }; } function stripForcePrefix(message, prefix = "force ai:") { const text = String(message || "").trim(); const configured = String(prefix || "").trim(); if (!configured || !text.toLowerCase().startsWith(configured.toLowerCase())) { return { message: text, forced: false }; } return { message: text.slice(configured.length).trim() || text, forced: true }; } function isSensitiveRequest(message) { return /\b(delete|remove|ban|timeout|moderate|transfer|pay|give|balance|inventory|economy|points|currency|database|file|execute|run|install|api|token|password|secret|permission|role|my|mine|our|ours|their|theirs|this user|user id|username)\b/i .test(String(message || "")); } function isCacheSafeRepoAnswer(answer) { if (!answer?.text) return false; if (answer.type === "route") return answer.source?.confidence === "high"; return ["contact", "unknown"].includes(answer.type); } function isExactPredefinedQuery(message, answer) { if (!isCacheSafeRepoAnswer(answer)) return false; if (isComplexOrAmbiguous(message)) return false; if (answer.type === "contact") return true; if (answer.type !== "route" || answer.source?.confidence !== "high") return false; return /\b(where|open|find|navigate|page|screen|menu|settings?|configuration|wizard|location)\b/i .test(String(message || "")); } function isComplexOrAmbiguous(message) { const text = String(message || ""); if (text.length > 500 || text.split(/\s+/).length > 70) return true; return /\b(why|explain|debug|diagnos|troubleshoot|fix|error|failed|failure|code|javascript|python|implement|design|compare|analy[sz]e|step by step|multi[- ]?step|architecture|configure and|set up and|what should|this|that|it)\b/i .test(text); } function isTimeoutError(error) { return error?.name === "TimeoutError" || error?.name === "AbortError" || /timed?\s*out|timeout/i.test(error?.message || ""); } function classifyRequestType(message, { role = "user", scope = "assistant" } = {}) { const text = String(message || "").trim(); if (/\b(explicitly|please|give|write|provide|show)\b[\s\S]{0,60}\b(long|detailed|comprehensive|thorough|in[- ]depth)\b|\b(full (analysis|report|guide|explanation)|in detail|very detailed|long answer)\b/i.test(text)) { return "explicit_long"; } if (/\b(custom command|javascript|python|code block|function run\s*\(|def run\s*\(|implement|write code|script)\b/i.test(text)) { return "code_custom_command"; } if ( role === "admin" && (scope === "model_test" || /\b(debug|diagnos|troubleshoot|stack trace|runtime|backend|database|logs?|metrics?|configuration|config|error|failed|failure)\b/i.test(text)) ) { return "admin_debug"; } if (/\b(where|open|find|navigate|page|screen|menu|settings?|configuration|wizard|location|link|path)\b/i.test(text)) { return "navigation_help"; } return "simple_answer"; } function normalizeRequestClass(value) { return [ "navigation_help", "simple_answer", "code_custom_command", "admin_debug", "explicit_long" ].includes(value) ? value : "simple_answer"; } function withTimeout(promise, timeoutMs) { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => { reject(Object.assign(new Error(`Gate timed out after ${timeoutMs}ms.`), { name: "TimeoutError" })); }, timeoutMs); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } function similarity(left, right) { const a = tokens(left); const b = tokens(right); if (!a.size || !b.size) return 0; let intersection = 0; for (const token of a) if (b.has(token)) intersection += 1; return intersection / (a.size + b.size - intersection); } function tokens(value) { const ignored = new Set([ "a", "an", "are", "can", "could", "do", "find", "for", "how", "i", "in", "is", "me", "of", "please", "the", "this", "to", "where", "would", "you" ]); return new Set( String(value || "").toLowerCase().split(/[^a-z0-9]+/) .filter((token) => token && !ignored.has(token)) ); } module.exports = { GateProvider, parseDecision, stripForcePrefix, isSensitiveRequest, similarity, isCacheSafeRepoAnswer, isExactPredefinedQuery, isComplexOrAmbiguous, classifyRequestType, normalizeRequestClass, withTimeout };