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

213 lines
6.4 KiB
JavaScript

const fs = require("fs");
const crypto = require("crypto");
const { resolveData } = require("./paths");
const { roleOf } = require("./permissions");
const FEEDBACK_TAGS = Object.freeze([
"good",
"bad",
"wrong_link",
"hallucinated",
"too_generic",
"unsafe",
"should_clarify",
"bad_code",
"wrong_scope",
"wrong_tool_usage"
]);
const FEEDBACK_KINDS = Object.freeze(["strict_correction", "instruction_based"]);
class FeedbackStore {
constructor(options = {}) {
this.file = options.file || resolveData("feedback", "reviews.json");
}
capture(input, actor) {
const tag = FEEDBACK_TAGS.includes(input.feedback_tag) ? input.feedback_tag : null;
if (!tag) throw new Error("Unknown feedback tag.");
const entry = {
id: crypto.randomUUID(),
user_message: clean(input.user_message, 6000),
assistant_answer: clean(input.assistant_answer, 16000),
route_used: clean(input.route_used, 120),
role: normalizeRole(input.role),
origin: clean(input.origin, 80) || "webui",
platform: clean(input.platform, 80) || "webui",
model: clean(input.model, 200),
timestamp: validDate(input.timestamp) || new Date().toISOString(),
feedback_tag: tag,
feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind)
? input.feedback_kind
: "strict_correction",
optional_correction: clean(input.optional_correction, 16000),
status: "pending",
submitted_by: String(actor?.id || "anonymous"),
reviewed_by: null,
reviewed_at: null,
verified_by: null,
verified_at: null,
review_notes: "",
export_approved: false
};
if (!entry.user_message || !entry.assistant_answer) {
throw new Error("Feedback requires the user message and assistant answer.");
}
const store = this.read();
store.entries.unshift(entry);
this.write(store);
return entry;
}
list({ page = 1, pageSize = 20, status = "" } = {}) {
const filtered = this.read().entries.filter((entry) => !status || entry.status === status);
return paginate(filtered, page, pageSize);
}
all() {
return this.read().entries;
}
get(id) {
return this.read().entries.find((entry) => entry.id === id) || null;
}
edit(id, values, actor) {
return this.mutate(id, (entry) => ({
...entry,
feedback_tag: FEEDBACK_TAGS.includes(values.feedback_tag) ? values.feedback_tag : entry.feedback_tag,
feedback_kind: FEEDBACK_KINDS.includes(values.feedback_kind) ? values.feedback_kind : entry.feedback_kind || "strict_correction",
optional_correction: clean(values.optional_correction, 16000),
review_notes: clean(values.review_notes, 4000),
reviewed_by: String(actor.id),
reviewed_at: new Date().toISOString()
}));
}
setStatus(id, status, actor, notes = "") {
if (!["pending", "flagged", "verified", "approved", "rejected"].includes(status)) {
throw new Error("Invalid review status.");
}
return this.mutate(id, (entry) => ({
...entry,
status,
review_notes: clean(notes, 4000) || entry.review_notes,
reviewed_by: String(actor.id),
reviewed_at: new Date().toISOString()
}));
}
verify(id, actor, notes = "") {
return this.mutate(id, (entry) => ({
...entry,
status: entry.status === "rejected" ? "rejected" : "verified",
review_notes: clean(notes, 4000) || entry.review_notes,
verified_by: String(actor.id),
verified_at: new Date().toISOString()
}));
}
markExportApproved(id, actor) {
return this.mutate(id, (entry) => ({
...entry,
export_approved: true,
reviewed_by: entry.reviewed_by || String(actor.id),
reviewed_at: entry.reviewed_at || new Date().toISOString()
}));
}
delete(id) {
const store = this.read();
const before = store.entries.length;
store.entries = store.entries.filter((entry) => entry.id !== id);
if (store.entries.length === before) 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("Feedback review 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 improvementAccess(user, config = {}) {
const role = roleOf(user);
const improvement = config.improvement || {};
const trusted = role === "mod" &&
(improvement.trusted_moderator_reviewers || []).map(String).includes(String(user?.id));
const allowed = role === "admin" ||
(role === "mod" && improvement.allow_moderators_to_review_responses === true);
return {
allowed,
role,
trusted,
can_submit: allowed,
can_flag: allowed,
can_verify: role === "admin" || trusted,
can_approve: role === "admin",
can_edit: role === "admin",
can_delete: role === "admin",
can_implement: role === "admin",
can_export: role === "admin",
can_run_evals: role === "admin"
};
}
function paginate(rows, pageValue, pageSizeValue) {
const pageSize = Math.max(1, Math.min(100, Number.parseInt(pageSizeValue, 10) || 20));
const pages = Math.max(1, Math.ceil(rows.length / pageSize));
const page = Math.min(pages, Math.max(1, Number.parseInt(pageValue, 10) || 1));
const start = (page - 1) * pageSize;
return { entries: rows.slice(start, start + pageSize), page, pages, page_size: pageSize, total: rows.length };
}
function atomicJson(file, value) {
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
try {
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
fs.renameSync(tmp, file);
} finally {
fs.rmSync(tmp, { force: true });
}
}
function clean(value, max) {
return String(value || "").trim().slice(0, max);
}
function normalizeRole(value) {
return ["admin", "mod", "user"].includes(value) ? value : "user";
}
function validDate(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
module.exports = {
FEEDBACK_KINDS,
FEEDBACK_TAGS,
FeedbackStore,
improvementAccess,
paginate,
atomicJson
};