Lumi/plugins/lumi_ai/backend/corrections.js
2026-06-16 08:30:40 +02:00

232 lines
8.3 KiB
JavaScript

const fs = require("fs");
const crypto = require("crypto");
const { resolveData } = require("./paths");
const { roleAllows } = require("./permissions");
const { similarity, isSensitiveRequest } = require("./gate_provider");
const { atomicJson, paginate } = require("./feedback");
const PROMOTION_TARGETS = Object.freeze([
"correction",
"route_alias",
"predefined_answer",
"eval_case",
"training_export"
]);
class CorrectionStore {
constructor({ getConfig, verifyLink = () => false, file } = {}) {
this.getConfig = getConfig || (() => ({}));
this.verifyLink = verifyLink;
this.file = file || resolveData("corrections", "corrections.json");
}
createFromFeedback(feedback, values, actor) {
if (!feedback || feedback.status !== "approved") {
throw new Error("Only approved feedback can be implemented.");
}
const target = PROMOTION_TARGETS.includes(values.target) ? values.target : "correction";
const answer = clean(values.corrected_answer || feedback.optional_correction, 16000);
if (!answer && target !== "eval_case") throw new Error("A corrected answer is required.");
const expectedLink = clean(values.expected_link, 2000);
this.validateLinks(answer, expectedLink);
if (target === "route_alias" && !expectedLink) throw new Error("Route aliases require a verified Lumi route.");
if (target === "predefined_answer" && isSensitiveRequest(feedback.user_message) && values.explicitly_safe !== true) {
throw new Error("Sensitive or user-specific predefined answers must be explicitly marked safe.");
}
const entry = {
id: crypto.randomUUID(),
source_feedback_id: feedback.id,
feedback_kind: feedback.feedback_kind || "strict_correction",
feedback_tag: feedback.feedback_tag || "",
prompt: feedback.user_message,
corrected_answer: answer,
rejected_answer: feedback.assistant_answer,
target,
route_alias: clean(values.route_alias, 500),
expected_link: expectedLink,
min_role: normalizeRole(values.min_role || feedback.role),
permission_scope: {
origin: clean(values.permission_origin || feedback.origin, 80) || "any",
platform: clean(values.permission_platform || feedback.platform, 80) || "any"
},
explicitly_safe: values.explicitly_safe === true,
enabled: values.enabled !== false,
approved: true,
approved_by: String(actor.id),
approved_at: new Date().toISOString(),
verified_by: null,
verified_at: null,
active: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
const store = this.read();
store.entries.unshift(entry);
this.write(store);
return entry;
}
list({ page = 1, pageSize = 20 } = {}) {
return paginate(this.read().entries, page, pageSize);
}
all() {
return this.read().entries;
}
get(id) {
return this.read().entries.find((entry) => entry.id === id) || null;
}
update(id, values) {
return this.mutate(id, (entry) => {
const correctedAnswer = clean(values.corrected_answer ?? entry.corrected_answer, 16000);
const expectedLink = clean(values.expected_link ?? entry.expected_link, 2000);
this.validateLinks(correctedAnswer, expectedLink);
if (entry.target === "route_alias" && !expectedLink) throw new Error("Route aliases require a verified Lumi route.");
return {
...entry,
corrected_answer: correctedAnswer,
route_alias: clean(values.route_alias ?? entry.route_alias, 500),
expected_link: expectedLink,
min_role: normalizeRole(values.min_role || entry.min_role),
permission_scope: {
origin: clean(values.permission_origin ?? entry.permission_scope?.origin, 80) || "any",
platform: clean(values.permission_platform ?? entry.permission_scope?.platform, 80) || "any"
},
explicitly_safe: values.explicitly_safe ?? entry.explicitly_safe,
enabled: values.enabled ?? entry.enabled,
active: false,
updated_at: new Date().toISOString()
};
});
}
validateLinks(answer, expectedLink) {
const links = [expectedLink, ...internalRouteReferences(answer)].filter(Boolean);
if (links.some((link) => !this.verifyLink(link))) {
throw new Error("Internal correction links must match a verified Lumi route.");
}
}
verify(id, actor) {
return this.mutate(id, (entry) => ({
...entry,
approved: true,
verified_by: String(actor.id),
verified_at: new Date().toISOString(),
active: false,
updated_at: new Date().toISOString()
}));
}
setEnabled(id, enabled) {
return this.mutate(id, (entry) => ({
...entry,
enabled: Boolean(enabled),
active: false,
updated_at: new Date().toISOString()
}));
}
saveCorrections(actor) {
const store = this.read();
let active = 0;
for (const entry of store.entries) {
entry.active = Boolean(entry.enabled && entry.approved);
entry.saved_by = String(actor.id);
entry.saved_at = new Date().toISOString();
if (entry.active) active += 1;
}
this.write(store);
return { total: store.entries.length, active };
}
match(input, limit = 4) {
if (this.getConfig()?.improvement?.corrections_enabled === false) return [];
const role = normalizeRole(input.role);
return this.read().entries
.filter((entry) => entry.active && entry.enabled && entry.approved)
.filter((entry) => roleAllows(role, entry.min_role))
.filter((entry) => scopeAllows(entry.permission_scope, input))
.map((entry) => ({ ...entry, score: similarity(entry.prompt, input.message) }))
.filter((entry) => entry.score >= 0.45)
.sort((left, right) => right.score - left.score)
.slice(0, limit);
}
findPredefined(input) {
return this.match(input, 10).find((entry) =>
["predefined_answer", "route_alias"].includes(entry.target) &&
entry.explicitly_safe === true &&
entry.score >= 0.82
) || null;
}
context(input, limit = 4) {
return this.match(input, limit)
.filter((entry) => ["correction", "route_alias", "predefined_answer"].includes(entry.target))
.map((entry) => [
`Reviewed correction for a similar request (minimum role: ${entry.min_role}):`,
entry.feedback_kind === "instruction_based" || entry.feedback_tag === "wrong_tool_usage"
? `Revision guidance: ${entry.feedback_tag === "wrong_tool_usage" ? "tool-calling behavior" : "instruction"}`
: "",
`Request: ${entry.prompt}`,
`Approved answer: ${entry.corrected_answer}`,
entry.expected_link ? `Verified link: ${entry.expected_link}` : ""
].filter(Boolean).join("\n"));
}
delete(id) {
const store = this.read();
const before = store.entries.length;
store.entries = store.entries.filter((entry) => entry.id !== id);
if (before === store.entries.length) return false;
this.write(store);
return true;
}
mutate(id, updater) {
const store = this.read();
const index = store.entries.findIndex((entry) => entry.id === id);
if (index < 0) throw new Error("Correction was not found.");
store.entries[index] = updater(store.entries[index]);
this.write(store);
return store.entries[index];
}
read() {
try {
const parsed = JSON.parse(fs.readFileSync(this.file, "utf8"));
return { entries: Array.isArray(parsed.entries) ? parsed.entries : [] };
} catch {
return { entries: [] };
}
}
write(store) {
atomicJson(this.file, { entries: store.entries.slice(0, 5000) });
}
}
function scopeAllows(scope = {}, input = {}) {
const originAllowed = !scope.origin || scope.origin === "any" || scope.origin === input.origin;
const platformAllowed = !scope.platform || scope.platform === "any" || scope.platform === input.platform;
return originAllowed && platformAllowed;
}
function normalizeRole(value) {
return ["admin", "mod", "user"].includes(value) ? value : "user";
}
function clean(value, max) {
return String(value || "").trim().slice(0, max);
}
function internalRouteReferences(value) {
return [...String(value || "").matchAll(/(?:^|[\s("'`])((?:GET\s+)?\/[a-z0-9_./:-]+)/gi)]
.map((match) => match[1].replace(/[.,;:!?]+$/, ""));
}
module.exports = { PROMOTION_TARGETS, CorrectionStore, scopeAllows, internalRouteReferences };