319 lines
10 KiB
JavaScript
319 lines
10 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"]);
|
|
const FINAL_FEEDBACK_STATUSES = Object.freeze(["implemented", "rejected", "reviewed", "archived"]);
|
|
|
|
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", scope = "all", query = "" } = {}) {
|
|
const search = clean(query, 300).toLowerCase();
|
|
const filtered = this.read().entries
|
|
.filter((entry) => roleRank(viewerRole) >= roleRank(entry.role))
|
|
.filter((entry) => scope === "active" ? !isFinalized(entry) : scope === "history" ? isFinalized(entry) : true)
|
|
.filter((entry) => !status || entry.status === status)
|
|
.filter((entry) => !search || searchableFeedback(entry).includes(search));
|
|
return paginate(filtered, page, pageSize);
|
|
}
|
|
|
|
counts(viewerRole = "admin") {
|
|
const visible = this.read().entries.filter((entry) => roleRank(viewerRole) >= roleRank(entry.role));
|
|
const active = visible.filter((entry) => !isFinalized(entry));
|
|
return {
|
|
active: active.length,
|
|
pending: active.filter((entry) => entry.status === "pending").length,
|
|
needs_attention: active.filter((entry) => ["flagged", "verified", "approved"].includes(entry.status)).length,
|
|
history: visible.length - active.length
|
|
};
|
|
}
|
|
|
|
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", "implemented", "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()
|
|
}));
|
|
}
|
|
|
|
markImplemented(id, actor, details = {}) {
|
|
return this.mutate(id, (entry) => ({
|
|
...entry,
|
|
status: "implemented",
|
|
implementation_target: clean(details.target, 80) || "correction",
|
|
implementation_id: clean(details.implementation_id, 200),
|
|
implementation_summary: clean(details.summary, 1000),
|
|
implemented_by: String(actor.id),
|
|
implemented_at: new Date().toISOString(),
|
|
reviewed_by: String(actor.id),
|
|
reviewed_at: new Date().toISOString()
|
|
}));
|
|
}
|
|
|
|
restoreToQueue(id, actor) {
|
|
return this.mutate(id, (entry) => ({
|
|
...entry,
|
|
status: "pending",
|
|
restored_by: String(actor.id),
|
|
restored_at: new Date().toISOString(),
|
|
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;
|
|
}
|
|
|
|
function isFinalized(entry) {
|
|
return FINAL_FEEDBACK_STATUSES.includes(entry?.status);
|
|
}
|
|
|
|
function searchableFeedback(entry) {
|
|
return [
|
|
entry.id,
|
|
entry.user_message,
|
|
entry.assistant_answer,
|
|
entry.optional_correction,
|
|
entry.review_notes,
|
|
entry.feedback_tag,
|
|
entry.feedback_kind,
|
|
entry.status,
|
|
entry.implementation_target,
|
|
entry.implementation_summary,
|
|
entry.linked_okf_correction,
|
|
entry.platform,
|
|
entry.route_used
|
|
].map((value) => String(value || "").toLowerCase()).join("\n");
|
|
}
|
|
|
|
module.exports = {
|
|
FEEDBACK_KINDS,
|
|
FEEDBACK_TAGS,
|
|
FINAL_FEEDBACK_STATUSES,
|
|
FeedbackStore,
|
|
improvementAccess,
|
|
isFinalized,
|
|
normalizeContextSnapshot,
|
|
paginate,
|
|
atomicJson
|
|
};
|