213 lines
6.4 KiB
JavaScript
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(["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(),
|
|
feedback_tag: tag,
|
|
feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind)
|
|
? input.feedback_kind
|
|
: "instruction_based",
|
|
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 || "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"].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
|
|
};
|