435 lines
15 KiB
JavaScript
435 lines
15 KiB
JavaScript
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);
|
|
}
|
|
|
|
if (isSimpleKnowledgeLookup(context.message)) {
|
|
return this.finish({
|
|
route: "main_llm",
|
|
confidence: 0.86,
|
|
reason_code: "simple_knowledge_lookup",
|
|
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,
|
|
gate_error: decision.gate_error ? String(decision.gate_error).slice(0, 300) : null,
|
|
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,
|
|
gate_error: output.gate_error,
|
|
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",
|
|
gate_error: value.gate_error ? String(value.gate_error).slice(0, 300) : null
|
|
};
|
|
}
|
|
|
|
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 isSimpleKnowledgeLookup(message) {
|
|
const text = String(message || "").trim();
|
|
if (!text || text.length > 180 || text.split(/\s+/).length > 18) return false;
|
|
if (/\b(who|what)\s+(?:are|r)\s+you\b|\byour\s+(?:name|identity)\b/i.test(text)) return false;
|
|
return (
|
|
/^(?:who|what)\s+(?:is|are|was|were)\s+["'`]?[\p{L}\p{N}_ .'-]{2,80}["'`]?\??$/iu.test(text) ||
|
|
/^(?:tell me about|describe|identify)\s+["'`]?[\p{L}\p{N}_ .'-]{2,80}["'`]?\??$/iu.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 (hasRouteReference(text)) {
|
|
return "navigation_help";
|
|
}
|
|
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 hasRouteReference(text) {
|
|
return /\b(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+\/[^\s]+/i.test(text) ||
|
|
/\b(?:route|webroute|web route|endpoint|request|api)\b[\s\S]{0,80}\/[a-z0-9_/-]+/i.test(text) ||
|
|
/\/(?:admin|api|setup|auth|plugins|commands|feedback|stats|pages)(?:\/[a-z0-9_/-]*)?\b/i.test(text);
|
|
}
|
|
|
|
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,
|
|
isSimpleKnowledgeLookup,
|
|
classifyRequestType,
|
|
normalizeRequestClass,
|
|
hasRouteReference,
|
|
withTimeout
|
|
};
|