Lumi/plugins/lumi_ai/backend/feedback.js
2026-07-17 22:10:00 +02:00

255 lines
8.0 KiB
JavaScript

const fs = require("fs");
const { writeJsonAtomicSync } = require("../../../src/services/safe-files");
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",
"bad_retrieval",
"bad_response",
"missing_knowledge"
]);
const FEEDBACK_KINDS = Object.freeze(["instruction_based", "strict_correction"]);
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(),
rating: input.rating === "up" || input.feedback_tag === "good" ? "up" : "down",
feedback_tag: tag,
feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind)
? input.feedback_kind
: "instruction_based",
optional_correction: clean(input.optional_correction, 16000),
context_snapshot: normalizeContextSnapshot(input.context_snapshot),
linked_okf_correction: null,
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 = "", viewerRole = "admin" } = {}) {
const filtered = this.read().entries
.filter((entry) => roleRank(viewerRole) >= roleRank(entry.role))
.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 || "instruction_based",
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", "reviewed", "archived"].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()
}));
}
linkOkfCorrection(id, correctionPath, actor) {
return this.mutate(id, (entry) => ({
...entry,
linked_okf_correction: clean(correctionPath, 1000),
reviewed_by: entry.reviewed_by || String(actor.id),
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) {
writeJsonAtomicSync(file, value);
}
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();
}
function normalizeContextSnapshot(value) {
const snapshot = value && typeof value === "object" ? value : {};
const retrieval = snapshot.retrieval && typeof snapshot.retrieval === "object" ? snapshot.retrieval : {};
return {
retrieval: {
query: clean(retrieval.query, 500),
candidate_count: safeCount(retrieval.candidate_count),
selected_count: safeCount(retrieval.selected_count),
depth: clean(retrieval.depth, 40)
},
okf: cleanList(snapshot.okf, 4, 1200),
corrections: cleanList(snapshot.corrections, 3, 1200),
repository: cleanList(snapshot.repository, 3, 1200),
tools: cleanList(snapshot.tools, 20, 120)
};
}
function cleanList(value, limit, maxLength) {
return (Array.isArray(value) ? value : []).slice(0, limit).map((entry) => clean(entry, maxLength)).filter(Boolean);
}
function safeCount(value) {
const number = Number(value);
return Number.isFinite(number) ? Math.max(0, Math.min(100000, Math.floor(number))) : 0;
}
function roleRank(role) {
return { user: 1, mod: 2, admin: 3 }[normalizeRole(role)] || 1;
}
module.exports = {
FEEDBACK_KINDS,
FEEDBACK_TAGS,
FeedbackStore,
improvementAccess,
normalizeContextSnapshot,
paginate,
atomicJson
};