Lumi/plugins/lumi_ai/backend/gate_provider.js
2026-06-12 19:27:43 +02:00

399 lines
14 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);
}
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
};