232 lines
8.3 KiB
JavaScript
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 };
|