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" ]); 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, 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, 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_TAGS, FeedbackStore, improvementAccess, paginate, atomicJson };