Streamline Lumi AI feedback reviews

This commit is contained in:
Franz Rolfsvaag 2026-07-18 17:47:41 +02:00
parent 9647f11bf9
commit e710469492
20 changed files with 679 additions and 285 deletions

View File

@ -1,5 +1,10 @@
# Lumi changelog
## 0.2.7
- Reworked the Lumi AI Improvement Center into a live review queue with one-step apply, dismiss, and no-change outcomes.
- Added searchable finalized-feedback history with restore, edit, and permanent-delete controls while preserving existing feedback data.
## 0.2.6
- Fixed plugin updates on Windows and network shares when a running plugin keeps its preserved `data` directory open.

View File

@ -680,6 +680,7 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no
- 2026-07-18: Fixed private reverse-proxy HTTPS recognition for production diagnostics in core 0.2.5 without globally trusting client forwarding headers, and taught the local client to consume the repository's env-style `.secrets` file and short diagnostic variable names directly.
- 2026-07-18: Fixed Windows/network-share plugin updates in core 0.2.6 by leaving live preserved plugin data in place and transactionally replacing only code, including automatic code rollback coverage.
- 2026-07-18: Retired the one-off core repair ZIP build scripts after repository updates proved reliable; core 0.2.6 is distributed through the normal immutable Git release flow.
- 2026-07-18: Reworked Lumi AI 0.8.3's Improvement Center into a live, non-technical review queue with one-step activation, direct final outcomes, automatic queue cleanup, and searchable/restorable/editable finalized-feedback history.
- 2026-07-18: Updated production diagnostics examples in core 0.2.4 to derive the full endpoint and client base URL from the administrator's current Lumi request instead of showing a placeholder hostname.
- 2026-07-18: Fixed the shared submit-action resolver in core 0.2.3: ordinary buttons now inherit their parent form endpoint unless they explicitly declare `formaction`, restoring timed diagnostics-key creation and preventing async update actions from posting back to the Updates page.
- 2026-07-18: Added production-stage plugin update diagnostics in core 0.2.2: selected plugin source is verified before snapshotting, failures record their exact stage and target in update state, and the affected plugin row displays the server error directly.

View File

@ -0,0 +1,34 @@
# Lumi AI Improvement Center
Open **Community > AI Improvement Center** to process feedback about Lumi's
answers.
## Normal review
1. Read the user's question, Lumi's answer, and any suggested improvement.
2. Choose **Apply improvement**, **No change needed**, or **Dismiss feedback**.
3. Applied improvements are activated immediately. Every finished record moves
out of the active queue automatically.
**Apply improvement** asks for the answer or behavior Lumi should use for
similar requests. Audience and activation are the only normal settings. More
technical targets, route scopes, links, and evaluation options remain under
**Advanced options**.
## Finished feedback
Choose **Search finished feedback** to open the history dialog. It can search
questions, answers, tags, notes, implementation details, and outcomes. Admins
can restore a record to the active queue, edit its retained details, or
permanently delete it through Lumi's timed confirmation.
The page subscribes to Lumi's authenticated event stream. New feedback and
review actions from another administrator or trusted reviewer update the queue
without a full-page refresh and reconnect automatically after a temporary
connection loss.
## Advanced tools
The collapsed **Advanced tools and access** section contains moderator access,
manual correction-bank maintenance, evaluation cases, and training exports.
These controls are not required for ordinary feedback processing.

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime.
## Runtime
Package: lumi-bot
Version: 0.2.6
Version: 0.2.7
## Routes
- GET /api/events
- POST /api/destructive-confirmations

View File

@ -14,7 +14,7 @@ editable: false
Managed local AI provider and scoped WebUI assistant for Lumi.
## Metadata
Plugin ID: lumi_ai
Version: 0.8.2
Version: 0.8.3
Default state: enabled
## Web Routes
- /plugins/lumi_ai

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "lumi-bot",
"version": "0.2.6",
"version": "0.2.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
"version": "0.2.6",
"version": "0.2.7",
"dependencies": {
"adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{
"name": "lumi-bot",
"version": "0.2.6",
"version": "0.2.7",
"private": true,
"type": "commonjs",
"scripts": {

View File

@ -1,5 +1,13 @@
# Lumi AI changelog
## 0.8.3
- Replaced the multi-step approve, promote, and save workflow with a guided one-step “Apply improvement” action that activates reviewed corrections immediately.
- Added direct dismiss and no-change decisions, automatically moving all finalized feedback out of the active queue.
- Added a searchable feedback-history modal with outcome filters, full record inspection, restoration, editing, and timed permanent deletion.
- Added live queue synchronization for new feedback and reviewer actions, including clean reconnect behavior without full-page refreshes.
- Moved correction-bank, evaluation, export, and reviewer-access controls into a clearly labeled advanced area.
## 0.8.2
- Added OKF-first, role-aware retrieval with compact fast-model answers, conversational subject resolution, deterministic verified links, and main-model fallback when local evidence is insufficient.

View File

@ -20,6 +20,7 @@ const FEEDBACK_TAGS = Object.freeze([
]);
const FEEDBACK_KINDS = Object.freeze(["instruction_based", "strict_correction"]);
const FINAL_FEEDBACK_STATUSES = Object.freeze(["implemented", "rejected", "reviewed", "archived"]);
class FeedbackStore {
constructor(options = {}) {
@ -65,13 +66,27 @@ class FeedbackStore {
return entry;
}
list({ page = 1, pageSize = 20, status = "", viewerRole = "admin" } = {}) {
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) => !status || entry.status === status);
.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;
}
@ -93,7 +108,7 @@ class FeedbackStore {
}
setStatus(id, status, actor, notes = "") {
if (!["pending", "flagged", "verified", "approved", "rejected", "reviewed", "archived"].includes(status)) {
if (!["pending", "flagged", "verified", "approved", "implemented", "rejected", "reviewed", "archived"].includes(status)) {
throw new Error("Invalid review status.");
}
return this.mutate(id, (entry) => ({
@ -105,6 +120,31 @@ class FeedbackStore {
}));
}
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,
@ -243,11 +283,35 @@ 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

View File

@ -39,7 +39,7 @@ function approvedExamples(feedbackRows, correctionRows) {
.map((entry) => [entry.source_feedback_id, entry])
);
return feedbackRows
.filter((entry) => entry.export_approved && entry.status === "approved")
.filter((entry) => entry.export_approved && ["approved", "implemented"].includes(entry.status))
.map((entry) => {
const correction = byFeedback.get(entry.id);
const preferred = correction?.corrected_answer || entry.optional_correction;

View File

@ -26,7 +26,7 @@ const { AiRateLimiter, mergeLimits } = require("./backend/rate_limits");
const { buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("./backend/commands");
const { AssistantPanelDiagnostics } = require("./backend/assistant_panel_diagnostics");
const { formatAssistantResponse } = require("./backend/response_formatter");
const { FeedbackStore, FEEDBACK_KINDS, FEEDBACK_TAGS, improvementAccess } = require("./backend/feedback");
const { FeedbackStore, FEEDBACK_KINDS, FEEDBACK_TAGS, FINAL_FEEDBACK_STATUSES, improvementAccess, isFinalized } = require("./backend/feedback");
const { CorrectionStore, PROMOTION_TARGETS } = require("./backend/corrections");
const { EvalStore } = require("./backend/evals");
const { TrainingExporter } = require("./backend/training_export");
@ -1053,6 +1053,12 @@ module.exports = {
optional_correction: req.body.optional_correction,
context_snapshot: req.body.context_snapshot
}, req.session.user);
web.emitEvent?.("ai:improvement_changed", {
kind: "review",
review_id: entry.id,
status: entry.status,
message: "New AI feedback is waiting for review."
}, { role: "mod" });
return res.status(201).json({ success: true, id: entry.id });
} catch (error) {
return res.status(400).json({ error: error.message });
@ -1300,9 +1306,21 @@ module.exports = {
}
});
const announceImprovementChange = (review, message, kind = "review") => {
web.emitEvent?.("ai:improvement_changed", {
kind,
review_id: review?.id || null,
status: review?.status || null,
message: message || "The AI feedback queue changed."
}, { role: "mod" });
};
router.get("/improvement_center", (req, res) => {
const access = improvementAccess(req.session.user, config);
if (!access.allowed) return deniedImprovement(res);
const historyStatus = FINAL_FEEDBACK_STATUSES.includes(cleanText(req.query.history_status, 30))
? cleanText(req.query.history_status, 30)
: "";
return res.render(path.join(__dirname, "views", "improvement-center.ejs"), {
title: "Lumi AI Improvement Center",
config,
@ -1313,9 +1331,20 @@ module.exports = {
reviews: feedbackStore.list({
page: req.query.review_page,
pageSize: 15,
status: cleanText(req.query.status, 30),
scope: "active",
viewerRole: access.role
}),
historyReviews: feedbackStore.list({
page: req.query.history_page,
pageSize: 15,
scope: "history",
query: req.query.history_q,
status: historyStatus,
viewerRole: access.role
}),
reviewCounts: feedbackStore.counts(access.role),
historyQuery: cleanText(req.query.history_q, 300),
historyStatus,
corrections: correctionStore.list({ page: req.query.correction_page, pageSize: 15 }),
evalCases: evalStore.list({ page: req.query.eval_page, pageSize: 15 }),
evalResults: evalStore.results(25),
@ -1336,6 +1365,7 @@ module.exports = {
}
});
ensureSidebarNavItem(settings);
announceImprovementChange(null, "Improvement Center settings changed.", "settings");
return improvementFlash(req, res, "success", "Improvement Center settings saved.");
});
@ -1346,24 +1376,38 @@ module.exports = {
const review = feedbackStore.get(req.params.id);
if (!canReviewAiFeedback(review, access)) return deniedImprovement(res);
const action = cleanText(req.body.action, 30);
let updated = null;
if (action === "flag" && access.can_flag) {
feedbackStore.setStatus(req.params.id, "flagged", req.session.user, req.body.review_notes);
updated = feedbackStore.setStatus(req.params.id, "flagged", req.session.user, req.body.review_notes);
} else if (action === "verify" && access.can_verify) {
feedbackStore.verify(req.params.id, req.session.user, req.body.review_notes);
updated = feedbackStore.verify(req.params.id, req.session.user, req.body.review_notes);
} else if (action === "approve" && access.can_approve) {
feedbackStore.setStatus(req.params.id, "approved", req.session.user, req.body.review_notes);
updated = feedbackStore.setStatus(req.params.id, "approved", req.session.user, req.body.review_notes);
} else if (action === "reject" && access.can_approve) {
feedbackStore.setStatus(req.params.id, "rejected", req.session.user, req.body.review_notes);
updated = feedbackStore.setStatus(req.params.id, "rejected", req.session.user, req.body.review_notes);
} else if (action === "reviewed" && access.can_approve) {
feedbackStore.setStatus(req.params.id, "reviewed", req.session.user, req.body.review_notes);
updated = feedbackStore.setStatus(req.params.id, "reviewed", req.session.user, req.body.review_notes);
} else if (action === "restore" && access.can_approve) {
updated = feedbackStore.restoreToQueue(req.params.id, req.session.user);
} else if (action === "edit" && access.can_edit) {
feedbackStore.edit(req.params.id, req.body, req.session.user);
updated = feedbackStore.edit(req.params.id, req.body, req.session.user);
} else if (action === "export" && access.can_export) {
feedbackStore.markExportApproved(req.params.id, req.session.user);
updated = feedbackStore.markExportApproved(req.params.id, req.session.user);
} else {
return deniedImprovement(res);
}
return improvementFlash(req, res, "success", `Review ${action} completed.`);
const messages = {
flag: "Feedback marked for closer review.",
verify: "Feedback reviewed and sent to an administrator.",
approve: "Feedback approved and ready to apply.",
reject: "Feedback dismissed and moved to history.",
reviewed: "Feedback marked as requiring no change and moved to history.",
restore: "Feedback restored to the active queue.",
edit: "Feedback details saved.",
export: "Feedback approved for training export."
};
announceImprovementChange(updated, messages[action]);
return improvementFlash(req, res, "success", messages[action] || "Feedback updated.", { review: updated });
} catch (error) {
return improvementFlash(req, res, "error", error.message);
}
@ -1373,8 +1417,10 @@ module.exports = {
const access = improvementAccess(req.session.user, config);
if (!access.can_delete) return deniedImprovement(res);
try {
const deleted = feedbackStore.get(req.params.id);
feedbackStore.delete(req.params.id);
return improvementFlash(req, res, "success", "Review delete completed.");
announceImprovementChange(deleted, "Feedback record permanently deleted.");
return improvementFlash(req, res, "success", "Feedback record permanently deleted.");
} catch (error) {
return improvementFlash(req, res, "error", error.message);
}
@ -1384,8 +1430,9 @@ module.exports = {
const access = improvementAccess(req.session.user, config);
if (!access.can_delete) return deniedImprovement(res);
try {
feedbackStore.setStatus(req.params.id, "archived", req.session.user, req.body.review_notes);
return improvementFlash(req, res, "success", "Feedback record archived.");
const updated = feedbackStore.setStatus(req.params.id, "archived", req.session.user, req.body.review_notes);
announceImprovementChange(updated, "Feedback archived and moved to history.");
return improvementFlash(req, res, "success", "Feedback archived and moved to history.", { review: updated });
} catch (error) {
return improvementFlash(req, res, "error", error.message);
}
@ -1395,11 +1442,16 @@ module.exports = {
const access = improvementAccess(req.session.user, config);
if (!access.can_implement) return deniedImprovement(res);
try {
const review = feedbackStore.get(req.params.id);
if (!review || review.status !== "approved") throw new Error("Approve the review before implementing it.");
let review = feedbackStore.get(req.params.id);
if (!review || isFinalized(review)) throw new Error("This feedback is already finalized. Restore it from history before applying it again.");
if (review.status !== "approved") {
review = feedbackStore.setStatus(review.id, "approved", req.session.user, req.body.review_notes);
}
const target = PROMOTION_TARGETS.includes(req.body.target) ? req.body.target : "correction";
let implementationId = "";
let implementationSummary = "";
if (target === "eval_case") {
evalStore.add({
const evalCase = evalStore.add({
prompt: review.user_message,
role: req.body.min_role || review.role,
origin: req.body.permission_origin || review.origin,
@ -1408,8 +1460,11 @@ module.exports = {
expected_link: req.body.expected_link,
notes: req.body.notes
}, req.session.user);
implementationId = evalCase.id;
implementationSummary = "Added as an evaluation case.";
} else if (target === "training_export") {
feedbackStore.markExportApproved(review.id, req.session.user);
implementationSummary = "Approved for the next manual training export.";
} else {
const correction = correctionStore.createFromFeedback(review, {
...req.body,
@ -1427,8 +1482,19 @@ module.exports = {
throw error;
}
}
correctionStore.saveCorrections(req.session.user);
implementationId = correction.id;
implementationSummary = target === "correction"
? "Created a searchable OKF correction and activated it for Lumi AI."
: `Created and activated a ${target.replaceAll("_", " ")}.`;
}
return improvementFlash(req, res, "success", "Approved feedback was promoted. Save Corrections before it becomes active.");
const updated = feedbackStore.markImplemented(review.id, req.session.user, {
target,
implementation_id: implementationId,
summary: implementationSummary
});
announceImprovementChange(updated, "Improvement applied and moved to history.");
return improvementFlash(req, res, "success", "Improvement applied and moved to history.", { review: updated });
} catch (error) {
return improvementFlash(req, res, "error", error.message);
}
@ -1438,6 +1504,7 @@ module.exports = {
const access = improvementAccess(req.session.user, config);
if (!access.can_implement) return deniedImprovement(res);
const result = correctionStore.saveCorrections(req.session.user);
announceImprovementChange(null, "AI corrections were activated.", "correction");
return improvementFlash(req, res, "success", `Corrections saved. ${result.active} of ${result.total} are active.`);
});
@ -1462,6 +1529,7 @@ module.exports = {
} else {
return deniedImprovement(res);
}
announceImprovementChange(null, `Correction ${action} completed.`, "correction");
return improvementFlash(req, res, "success", `Correction ${action} completed. Save Corrections to activate changes.`);
} catch (error) {
return improvementFlash(req, res, "error", error.message);
@ -1473,6 +1541,7 @@ module.exports = {
if (!access.can_delete) return deniedImprovement(res);
try {
correctionStore.delete(req.params.id);
announceImprovementChange(null, "Correction deleted.", "correction");
return improvementFlash(req, res, "success", "Correction delete completed. Save Corrections to activate changes.");
} catch (error) {
return improvementFlash(req, res, "error", error.message);
@ -1487,6 +1556,7 @@ module.exports = {
throw new Error("Expected links must match a verified Lumi route.");
}
evalStore.add(req.body, req.session.user);
announceImprovementChange(null, "Evaluation case added.", "eval");
return improvementFlash(req, res, "success", "Eval case added.");
} catch (error) {
return improvementFlash(req, res, "error", error.message);
@ -1497,6 +1567,7 @@ module.exports = {
const access = improvementAccess(req.session.user, config);
if (!access.can_run_evals) return deniedImprovement(res);
evalStore.delete(req.params.id);
announceImprovementChange(null, "Evaluation case deleted.", "eval");
return improvementFlash(req, res, "success", "Eval case deleted.");
});
@ -1505,6 +1576,7 @@ module.exports = {
if (!access.can_run_evals) return deniedImprovement(res);
try {
const results = await evalStore.runAll({ provider, actor: req.session.user });
announceImprovementChange(null, "Evaluation run completed.", "eval");
return improvementFlash(req, res, "success", `Eval run completed with ${results.length} result(s).`);
} catch (error) {
return improvementFlash(req, res, "error", error.message);
@ -1697,7 +1769,14 @@ function toolDenied(req, res) {
if (req.accepts(["json", "html"]) === "json") return res.status(403).json({ error: "Access denied." });
return denied(res);
}
function improvementFlash(req, res, type, message) {
function improvementFlash(req, res, type, message, result = {}) {
if (req.accepts(["json", "html"]) === "json") {
return res.status(type === "error" ? 400 : 200).json({
success: type !== "error",
message,
result
});
}
req.session.flash = { type, message };
return res.redirect(`/plugins/${PLUGIN_ID}/improvement_center`);
}

View File

@ -1,11 +1,11 @@
{
"id": "lumi_ai",
"name": "Lumi AI",
"version": "0.8.2",
"version": "0.8.3",
"description": "Managed local AI provider and scoped WebUI assistant for Lumi.",
"main": "index.js",
"channel": "stable",
"compatible_from": "0.8.1",
"migration_notes": "Existing models, runtimes, settings, feedback, metrics, and tool data are retained. No manual migration is required.",
"compatible_from": "0.8.2",
"migration_notes": "Existing models, runtimes, settings, feedback, corrections, metrics, and tool data are retained. Existing finalized feedback appears in searchable history; no manual migration is required.",
"rollback_safe": true
}

View File

@ -1,21 +1,66 @@
.improvement-titlebar { align-items: center; }
.improvement-filters, .improvement-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; }
.improvement-actions form { margin: 0; }
.improvement-list { display: grid; gap: 12px; }
.improvement-card { padding: 13px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); }
.improvement-card > header { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 10px; color: var(--ink-soft); font-size: 12px; }
.improvement-card > header strong { color: var(--ink); font-size: 14px; }
.improvement-live-status { position: sticky; top: 10px; z-index: 15; margin: 0 0 12px; padding: 10px 13px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); box-shadow: 0 8px 24px rgba(0,0,0,.18); }
.improvement-live-status[hidden] { display: none; }
.improvement-live-status[data-tone="success"] { border-color: var(--sea); }
.improvement-live-status[data-tone="error"] { border-color: var(--danger); }
.improvement-guide { background: linear-gradient(135deg, color-mix(in srgb, var(--sea) 9%, var(--card)), var(--card)); }
.improvement-steps { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 12px; margin: 16px 0; padding: 0; list-style: none; }
.improvement-steps li { display: flex; gap: 10px; min-width: 0; padding: 12px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-2); }
.improvement-steps li > span { display: grid; place-items: center; flex: 0 0 28px; width: 28px; height: 28px; border-radius: 50%; background: var(--sea); color: var(--button-text, #081317); font-weight: 800; }
.improvement-steps strong, .improvement-steps small { display: block; }
.improvement-steps small { margin-top: 4px; color: var(--ink-soft); line-height: 1.4; }
.improvement-counts { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; }
.improvement-counts > div { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); }
.improvement-counts span { color: var(--ink-soft); }
.improvement-counts strong { font-size: 1.35rem; }
.improvement-filters, .improvement-actions, .improvement-decision-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.improvement-actions form, .improvement-decision-row form { margin: 0; }
.improvement-decision-row { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.improvement-decision-row > .button:first-child { min-width: 150px; }
.improvement-list { display: grid; gap: 14px; }
.improvement-card { min-width: 0; padding: 15px; border: 1px solid var(--border); border-radius: 10px; background: var(--card); box-shadow: 0 4px 14px rgba(0,0,0,.08); }
.improvement-card > header { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 12px; color: var(--ink-soft); font-size: 12px; }
.improvement-card > header strong { color: var(--ink); font-size: 15px; }
.improvement-card-labels { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; }
.improvement-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
.improvement-pair > div, .improvement-correction { min-width: 0; padding: 9px; border-radius: 6px; background: var(--surface-2); }
.improvement-pair span { display: block; margin-bottom: 5px; color: var(--ink-soft); font-size: 11px; font-weight: 700; text-transform: uppercase; }
.improvement-card pre, .table pre { max-height: 240px; margin: 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink); font: inherit; }
.improvement-pair > div, .improvement-correction, .improvement-review-note { min-width: 0; padding: 10px; border-radius: 7px; background: var(--surface-2); }
.improvement-pair span { display: block; margin-bottom: 5px; color: var(--ink-soft); font-size: 11px; font-weight: 700; letter-spacing: .025em; text-transform: uppercase; }
.improvement-card pre, .table pre { max-height: 260px; margin: 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink); font: inherit; }
.improvement-correction { margin-bottom: 10px; border-left: 3px solid var(--sea); }
.improvement-correction strong { display: block; margin-bottom: 5px; }
.improvement-dialog { width: min(760px, calc(100vw - 28px)); max-height: calc(100vh - 28px); padding: 18px; overflow: auto; border: 1px solid var(--border); border-radius: 9px; background: var(--card); color: var(--ink); box-shadow: 0 20px 60px rgba(0, 0, 0, .35); }
.improvement-dialog::backdrop { background: rgba(0, 0, 0, .55); }
.improvement-correction strong, .improvement-review-note strong { display: block; margin-bottom: 5px; }
.improvement-review-note { margin-bottom: 10px; border-left: 3px solid var(--warning, #d1a43a); }
.improvement-review-note p { margin: 0; }
.improvement-card .button, .improvement-actions .button { min-height: 38px; white-space: nowrap; }
.improvement-empty { padding: 28px 18px; border: 1px dashed var(--border); border-radius: 10px; text-align: center; color: var(--ink-soft); }
.improvement-empty strong { display: block; color: var(--ink); font-size: 1.1rem; }
.improvement-dialog { width: min(780px, calc(100vw - 28px)); max-height: calc(100vh - 28px); padding: 18px; overflow: auto; border: 1px solid var(--border); border-radius: 10px; background: var(--card); color: var(--ink); box-shadow: 0 20px 60px rgba(0,0,0,.4); }
.improvement-history-dialog { width: min(1100px, calc(100vw - 28px)); }
.improvement-dialog::backdrop { background: rgba(0,0,0,.62); }
.improvement-dialog .ai-form { margin: 0; }
.improvement-card .button, .improvement-actions .button { white-space: nowrap; }
.improvement-dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 16px; }
.improvement-dialog-heading h2, .improvement-dialog-heading h3, .improvement-dialog-heading p { margin-top: 0; }
.improvement-dialog-heading p { margin-bottom: 0; color: var(--ink-soft); }
.improvement-advanced-fields { margin-top: 12px; }
.improvement-history-search { display: grid; grid-template-columns: minmax(220px,1fr) minmax(180px,auto) auto auto; align-items: end; gap: 10px; margin-bottom: 15px; }
.improvement-history-list { display: grid; gap: 10px; }
.history-card > details > summary { cursor: pointer; color: var(--ink); font-weight: 650; }
.history-card > details[open] > summary { margin-bottom: 12px; }
.improvement-advanced { padding: 0; overflow: clip; }
.improvement-advanced > summary { cursor: pointer; padding: 16px; list-style-position: inside; }
.improvement-advanced > summary span { display: inline-flex; flex-direction: column; gap: 3px; margin-left: 5px; }
.improvement-advanced > summary small { color: var(--ink-soft); font-weight: 400; }
.improvement-advanced-body { display: grid; gap: 24px; padding: 0 16px 18px; }
.improvement-advanced-body > section { padding-top: 20px; border-top: 1px solid var(--border); }
.ai-tag.status-implemented { border-color: var(--sea); }
.ai-tag.status-rejected, .ai-tag.status-archived { opacity: .82; }
@media (max-width: 850px) {
.improvement-steps, .improvement-counts { grid-template-columns: 1fr; }
.improvement-history-search { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 760px) {
.improvement-pair { grid-template-columns: 1fr; }
.improvement-titlebar { align-items: flex-start; }
.improvement-history-search { grid-template-columns: 1fr; }
.improvement-dialog { width: calc(100vw - 16px); max-height: calc(100vh - 16px); padding: 14px; }
.improvement-decision-row > *, .improvement-decision-row form, .improvement-decision-row .button { width: 100%; }
}

View File

@ -1,12 +1,167 @@
(() => {
let stream = null;
let refreshTimer = null;
let refreshing = false;
let queuedRefresh = false;
const status = () => document.querySelector("[data-improvement-status]");
const workspace = () => document.querySelector("[data-improvement-workspace]");
const historyDialog = () => document.getElementById("improvement-history");
function showStatus(message, tone = "info") {
const target = status();
if (!target) return;
target.textContent = message || "";
target.dataset.tone = tone;
target.hidden = !message;
if (message && tone !== "error") window.setTimeout(() => {
if (target.textContent === message) target.hidden = true;
}, 5000);
}
function historyParams() {
const dialog = historyDialog();
const form = dialog?.querySelector("[data-improvement-history-search]");
const params = new URLSearchParams();
const query = form?.elements?.history_q?.value?.trim();
const outcome = form?.elements?.history_status?.value;
if (query) params.set("history_q", query);
if (outcome) params.set("history_status", outcome);
return params;
}
async function refreshWorkspace(url = null, options = {}) {
if (refreshing) {
queuedRefresh = true;
return;
}
refreshing = true;
const wasHistoryOpen = options.openHistory ?? Boolean(historyDialog()?.open);
const activeElementId = document.activeElement?.id || "";
try {
const params = url ? new URL(url, window.location.origin).searchParams : historyParams();
const response = await fetch(`/plugins/lumi_ai/improvement_center?${params.toString()}`, {
headers: { Accept: "text/html", "X-Lumi-Live-Refresh": "1" },
cache: "no-store"
});
if (!response.ok) throw new Error("The latest feedback queue could not be loaded.");
const html = await response.text();
const documentCopy = new DOMParser().parseFromString(html, "text/html");
const next = documentCopy.querySelector("[data-improvement-workspace]");
const current = workspace();
if (!next || !current) throw new Error("The refreshed feedback view was incomplete.");
current.replaceWith(next);
window.LumiInteractions?.init?.(next);
if (wasHistoryOpen) historyDialog()?.showModal();
if (activeElementId) document.getElementById(activeElementId)?.focus?.();
} catch (error) {
showStatus(error.message || "The feedback queue could not be refreshed.", "error");
} finally {
refreshing = false;
if (queuedRefresh) {
queuedRefresh = false;
refreshWorkspace(null, { openHistory: wasHistoryOpen });
}
}
}
function scheduleRefresh(message) {
if (message) showStatus(message);
window.clearTimeout(refreshTimer);
refreshTimer = window.setTimeout(() => refreshWorkspace(), 120);
}
document.addEventListener("click", (event) => {
const opener = event.target.closest("[data-open-dialog]");
if (opener) {
document.getElementById(opener.dataset.openDialog)?.showModal();
return;
}
if (event.target.closest("[data-open-history]")) {
historyDialog()?.showModal();
historyDialog()?.querySelector("input[type='search']")?.focus();
return;
}
const closer = event.target.closest("[data-close-dialog]");
if (closer) closer.closest("dialog")?.close();
if (closer) {
closer.closest("dialog")?.close();
return;
}
if (event.target.closest("[data-history-clear]")) {
const form = historyDialog()?.querySelector("[data-improvement-history-search]");
form?.reset();
refreshWorkspace("/plugins/lumi_ai/improvement_center", { openHistory: true });
return;
}
const pageLink = event.target.closest("[data-improvement-page], [data-improvement-history-page]");
if (pageLink && !pageLink.classList.contains("disabled")) {
event.preventDefault();
refreshWorkspace(pageLink.href, { openHistory: pageLink.matches("[data-improvement-history-page]") });
}
});
document.addEventListener("submit", async (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) return;
if (form.matches("[data-improvement-history-search]")) {
event.preventDefault();
const params = new URLSearchParams(new FormData(form));
params.delete("history_page");
return refreshWorkspace(`/plugins/lumi_ai/improvement_center?${params.toString()}`, { openHistory: true });
}
if (!form.matches("[data-improvement-action]")) return;
// The shared timed-confirmation handler runs in the capture phase. Let it
// issue a token before AJAX handles permanent deletes.
if (event.defaultPrevented && !form.elements.confirmation_token?.value) return;
event.preventDefault();
const submitter = event.submitter || form.querySelector("button[type='submit']");
const originalLabel = submitter?.textContent || "";
if (submitter) {
submitter.disabled = true;
submitter.textContent = "Saving…";
}
try {
const body = new URLSearchParams();
for (const [key, value] of new FormData(form).entries()) {
if (typeof value === "string") body.append(key, value);
}
const response = await fetch(form.action, {
method: form.method || "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
},
body
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) throw new Error(result.message || result.error || "The feedback could not be updated.");
form.closest("dialog")?.close();
showStatus(result.message || "Feedback updated.", "success");
await refreshWorkspace(null, { openHistory: Boolean(historyDialog()?.open) });
} catch (error) {
showStatus(error.message || "The feedback could not be updated.", "error");
if (submitter) {
submitter.disabled = false;
submitter.textContent = originalLabel;
}
}
});
function connectLiveUpdates() {
if (!window.EventSource || stream) return;
stream = new EventSource("/api/events");
stream.addEventListener("ai:improvement_changed", (event) => {
try {
const data = JSON.parse(event.data || "{}");
scheduleRefresh(data.message || "The feedback queue was updated by another reviewer.");
} catch {
scheduleRefresh("The feedback queue was updated by another reviewer.");
}
});
stream.onerror = () => {
showStatus("Live updates are reconnecting. Your saved actions are still safe.", "warning");
};
}
connectLiveUpdates();
})();

View File

@ -46,7 +46,7 @@ const { AiRateLimiter, mergeLimits } = require("../backend/rate_limits");
const { PLATFORM_DEFAULTS, buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("../backend/commands");
const { AssistantPanelDiagnostics } = require("../backend/assistant_panel_diagnostics");
const { formatAssistantResponse, normalizeLink, normalizeCodeFences } = require("../backend/response_formatter");
const { FeedbackStore, FEEDBACK_TAGS, improvementAccess } = require("../backend/feedback");
const { FeedbackStore, FEEDBACK_TAGS, FINAL_FEEDBACK_STATUSES, improvementAccess, isFinalized } = require("../backend/feedback");
const { CorrectionStore } = require("../backend/corrections");
const { EvalStore, evaluateCase } = require("../backend/evals");
const { TrainingExporter, approvedExamples } = require("../backend/training_export");
@ -234,12 +234,16 @@ async function run() {
assert(settingsTemplate.includes("Export for Codex"));
assert(settingsTemplate.includes("raw diagnostics, and tool payloads removed"));
const improvementTemplate = fs.readFileSync(path.join(PLUGIN_ROOT, "views", "improvement-center.ejs"), "utf8");
for (const control of ["Review queue", "Save Corrections", "Run all evals", "Export instruction JSONL", "Export DPO JSONL"]) {
for (const control of ["Feedback waiting for review", "Apply improvement", "No change needed", "Dismiss feedback", "Finished feedback history", "Activate staged changes", "Run all tests", "Export instruction data"]) {
assert(improvementTemplate.includes(control));
}
assert(improvementTemplate.includes('data-confirm-title="Delete eval case"'));
assert(improvementTemplate.includes("Sources used for this answer"));
assert(improvementTemplate.includes("Searchable OKF title"));
assert(improvementTemplate.includes("What Lumi used to answer"));
assert(improvementTemplate.includes("Searchable knowledge title"));
assert(improvementTemplate.includes("data-improvement-workspace"));
const improvementFrontend = fs.readFileSync(path.join(PLUGIN_ROOT, "public", "improvement-center.js"), "utf8");
assert(improvementFrontend.includes('ai:improvement_changed'));
assert(improvementFrontend.includes("data-improvement-history-search"));
const assistantFeedbackScript = fs.readFileSync(path.join(PLUGIN_ROOT, "public", "assistant.js"), "utf8");
for (const tag of FEEDBACK_TAGS) assert(assistantFeedbackScript.includes(`"${tag}"`));
assert(assistantFeedbackScript.includes("/assistant/feedback"));
@ -357,6 +361,17 @@ async function run() {
platform: "webui"
}).length, 0);
correctionConfig.improvement.corrections_enabled = true;
const implemented = feedbackStore.markImplemented(review.id, { id: "admin" }, {
target: "predefined_answer",
implementation_id: correction.id,
summary: "Activated a verified settings answer."
});
assert.equal(implemented.status, "implemented");
assert.equal(isFinalized(implemented), true);
assert(FINAL_FEEDBACK_STATUSES.includes("implemented"));
assert.equal(feedbackStore.list({ scope: "active", viewerRole: "admin" }).total, 0);
assert.equal(feedbackStore.list({ scope: "history", query: "verified settings", viewerRole: "admin" }).total, 1);
assert.deepEqual(feedbackStore.counts("admin"), { active: 0, pending: 0, needs_attention: 0, history: 1 });
const evalStore = new EvalStore({
casesFile: path.join(improvementTemp, "eval-cases.json"),

View File

@ -1,247 +1,192 @@
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
<link rel="stylesheet" href="/plugins/lumi_ai/assets/settings.css?v=0.6.0" />
<link rel="stylesheet" href="/plugins/lumi_ai/assets/improvement-center.css?v=0.6.0" />
<link rel="stylesheet" href="/plugins/lumi_ai/assets/settings.css?v=0.8.3" />
<link rel="stylesheet" href="/plugins/lumi_ai/assets/improvement-center.css?v=0.8.3" />
<% const statusLabel = (status) => ({ pending: "New", flagged: "Needs attention", verified: "Reviewed by moderator", approved: "Ready to apply", implemented: "Applied", rejected: "Dismissed", reviewed: "No change needed", archived: "Archived" }[status] || status); %>
<section class="ai-titlebar improvement-titlebar">
<div>
<h1>Lumi AI Improvement Center</h1>
<p>Review assistant feedback, stage approved corrections, run evals, and create manual training exports.</p>
<h1>Improve Lumi AI</h1>
<p>Review feedback and either apply an improvement or close it. Finished items move to searchable history automatically.</p>
</div>
<span class="ai-tag"><%= access.role %><%= access.trusted ? " · trusted reviewer" : "" %></span>
<% if (access.can_approve) { %><a class="button subtle" href="/plugins/lumi_ai">Lumi AI settings</a><% } %>
<% if (access.can_approve) { %><a class="button subtle" href="/plugins/lumi_ai">AI settings</a><% } %>
</section>
<nav class="ai-tabs" aria-label="Improvement Center sections">
<a href="#reviews">Review queue</a>
<a href="#corrections">Corrections</a>
<a href="#evals">Evals</a>
<% if (access.can_export) { %><a href="#exports">Exports</a><% } %>
</nav>
<div class="improvement-live-status" data-improvement-status role="status" aria-live="polite"></div>
<% if (access.can_approve) { %>
<section class="ai-band">
<div class="ai-section-heading"><div><h2>Access and activation</h2><p>Corrections remain inactive until an administrator selects Save Corrections.</p></div></div>
<form method="post" action="/plugins/lumi_ai/improvement_center/settings" class="form-grid ai-form">
<div class="field"><label><input type="checkbox" name="allow_moderators_to_review_responses" <%= config.improvement.allow_moderators_to_review_responses ? "checked" : "" %> /> Allow moderators to review responses</label></div>
<div class="field"><label><input type="checkbox" name="corrections_enabled" <%= config.improvement.corrections_enabled ? "checked" : "" %> /> Use active approved corrections</label></div>
<div class="field full"><label>Trusted moderator reviewer IDs</label><textarea name="trusted_moderator_reviewers" rows="2"><%= config.improvement.trusted_moderator_reviewers.join("\n") %></textarea></div>
<div class="field full"><button class="button" type="submit">Save access settings</button></div>
</form>
</section>
<% } %>
<section class="ai-band" id="reviews">
<div class="ai-section-heading">
<div><h2>Review queue</h2><p>Review ratings, the answer, and a privacy-limited snapshot of the knowledge sources that informed it. Only admins can turn approved feedback into searchable OKF corrections.</p></div>
<div class="improvement-filters">
<% ["", "pending", "flagged", "verified", "approved", "reviewed", "rejected", "archived"].forEach((status) => { %>
<a class="button subtle" href="?status=<%= status %>#reviews"><%= status || "All" %></a>
<% }) %>
<div data-improvement-workspace>
<section class="ai-band improvement-guide" aria-labelledby="improvement-guide-title">
<div class="ai-section-heading">
<div><h2 id="improvement-guide-title">A simpler review process</h2><p>The usual path takes one decision and no manual activation step.</p></div>
<button class="button subtle" type="button" data-open-history>Search finished feedback (<%= reviewCounts.history %>)</button>
</div>
</div>
<div class="improvement-list">
<% reviews.entries.forEach((review) => { %>
<article class="improvement-card" id="review-<%= review.id %>">
<header>
<div><strong><%= review.rating === "up" ? "Helpful" : "Needs work" %></strong> <span class="ai-tag"><%= review.feedback_tag.replaceAll("_", " ") %></span> <span class="ai-tag"><%= review.feedback_kind || "strict_correction" %></span> <span class="ai-tag"><%= review.status %></span></div>
<span><%= formatDate(review.timestamp) %> · <%= review.role %> · <%= review.platform %> · <%= review.route_used || "unknown route" %></span>
</header>
<div class="improvement-pair">
<div><span>User message</span><pre><%= review.user_message %></pre></div>
<div><span>Assistant answer</span><pre><%= review.assistant_answer %></pre></div>
</div>
<% if (review.optional_correction) { %><div class="improvement-correction"><strong><%= review.feedback_kind === "instruction_based" ? "Instruction guidance" : "Suggested correction" %></strong><pre><%= review.optional_correction %></pre></div><% } %>
<% const snapshot = review.context_snapshot || {}; const retrieval = snapshot.retrieval || {}; const snapshotRows = [...(snapshot.okf || []), ...(snapshot.corrections || []), ...(snapshot.repository || [])]; %>
<% if (snapshotRows.length || retrieval.query || (snapshot.tools || []).length) { %>
<details class="lumi-expandable-settings">
<summary><strong>Sources used for this answer</strong><span class="hint"><%= retrieval.selected_count || snapshotRows.length %> selected · <%= retrieval.candidate_count || 0 %> considered</span></summary>
<div class="lumi-expandable-body">
<% if (retrieval.query) { %><p class="hint"><strong>Lookup:</strong> <%= retrieval.query %> · depth <%= retrieval.depth || "unknown" %></p><% } %>
<% snapshotRows.forEach((source) => { %><pre><%= source %></pre><% }) %>
<% if ((snapshot.tools || []).length) { %><p class="hint"><strong>Available/selected tools:</strong> <%= snapshot.tools.join(", ") %></p><% } %>
<ol class="improvement-steps">
<li><span>1</span><div><strong>Read the report</strong><small>Compare the question, Lumi's answer, and the user's suggestion.</small></div></li>
<li><span>2</span><div><strong>Choose an outcome</strong><small>Apply the improvement, dismiss it, or mark that no change is needed.</small></div></li>
<li><span>3</span><div><strong>Done automatically</strong><small>Applied corrections become active immediately. Finished reports move to history.</small></div></li>
</ol>
<div class="improvement-counts" aria-label="Feedback queue summary">
<div><span>Active queue</span><strong><%= reviewCounts.active %></strong></div>
<div><span>New</span><strong><%= reviewCounts.pending %></strong></div>
<div><span>Needs attention</span><strong><%= reviewCounts.needs_attention %></strong></div>
</div>
</section>
<section class="ai-band" id="reviews">
<div class="ai-section-heading">
<div><h2>Feedback waiting for review</h2><p>Finished feedback is hidden from this queue and remains available in history.</p></div>
<button class="button subtle" type="button" data-open-history>Open history</button>
</div>
<div class="improvement-list">
<% reviews.entries.forEach((review) => { const snapshot = review.context_snapshot || {}; const retrieval = snapshot.retrieval || {}; const snapshotRows = [...(snapshot.okf || []), ...(snapshot.corrections || []), ...(snapshot.repository || [])]; %>
<article class="improvement-card" id="review-<%= review.id %>" data-review-id="<%= review.id %>">
<header>
<div class="improvement-card-labels"><strong><%= review.rating === "up" ? "Helpful response" : "Response needs work" %></strong><span class="ai-tag status-<%= review.status %>"><%= statusLabel(review.status) %></span><span class="ai-tag"><%= review.feedback_tag.replaceAll("_", " ") %></span></div>
<span><%= formatDate(review.timestamp) %> · <%= review.role %> · <%= review.platform %></span>
</header>
<div class="improvement-pair">
<div><span>User asked</span><pre><%= review.user_message %></pre></div>
<div><span>Lumi answered</span><pre><%= review.assistant_answer %></pre></div>
</div>
</details>
<% } %>
<% if (review.linked_okf_correction) { %><p class="hint"><strong>Searchable correction:</strong> <a href="/plugins/okf?q=<%= encodeURIComponent(review.user_message.slice(0, 80)) %>"><%= review.linked_okf_correction %></a></p><% } %>
<% if (review.review_notes) { %><p class="hint"><strong>Review notes:</strong> <%= review.review_notes %></p><% } %>
<div class="improvement-actions">
<% if (access.can_flag) { %>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>">
<input type="hidden" name="action" value="flag" />
<button class="button subtle" type="submit">Flag</button>
</form>
<% if (review.optional_correction) { %><div class="improvement-correction"><strong>User's suggested improvement</strong><pre><%= review.optional_correction %></pre></div><% } %>
<% if (review.review_notes) { %><div class="improvement-review-note"><strong>Reviewer note</strong><p><%= review.review_notes %></p></div><% } %>
<% if (snapshotRows.length || retrieval.query || (snapshot.tools || []).length) { %>
<details class="lumi-expandable-settings">
<summary><strong>What Lumi used to answer</strong><span class="hint"><%= retrieval.selected_count || snapshotRows.length %> source(s) selected</span></summary>
<div class="lumi-expandable-body">
<% if (retrieval.query) { %><p class="hint"><strong>Knowledge lookup:</strong> <%= retrieval.query %></p><% } %>
<% snapshotRows.forEach((source) => { %><pre><%= source %></pre><% }) %>
<% if ((snapshot.tools || []).length) { %><p class="hint"><strong>Tools available:</strong> <%= snapshot.tools.join(", ") %></p><% } %>
</div>
</details>
<% } %>
<% if (access.can_verify && !["approved", "rejected"].includes(review.status)) { %>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>">
<input type="hidden" name="action" value="verify" />
<button class="button subtle" type="submit">Verify</button>
</form>
<% } %>
<% if (access.can_approve) { %>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>">
<input type="hidden" name="action" value="reviewed" />
<button class="button subtle" type="submit">No action needed</button>
</form>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>">
<input type="hidden" name="action" value="approve" />
<button class="button" type="submit">Approve</button>
</form>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>">
<input type="hidden" name="action" value="reject" />
<button class="button subtle" type="submit">Reject</button>
</form>
<button class="button subtle" type="button" data-open-dialog="edit-<%= review.id %>">Edit</button>
<% if (review.status === "approved") { %><button class="button" type="button" data-open-dialog="implement-<%= review.id %>">Implement</button><% } %>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>">
<input type="hidden" name="action" value="export" />
<button class="button subtle" type="submit"><%= review.export_approved ? "Export approved" : "Approve for export" %></button>
</form>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete feedback record" data-confirm-text="Delete this feedback record permanently?" data-confirm-label="Delete feedback">
<button class="button danger" type="submit">Delete</button>
</form>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>/archive" data-confirm-mode="modal" data-confirm-title="Archive feedback record" data-confirm-text="Archive this feedback record and hide it from the active review queue?" data-confirm-label="Archive feedback">
<button class="button danger" type="submit">Archive</button>
</form>
<% } %>
</div>
</article>
<% if (access.can_edit) { %>
<dialog class="improvement-dialog" id="edit-<%= review.id %>">
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" class="form-grid ai-form">
<input type="hidden" name="action" value="edit" />
<div class="field"><label>Feedback tag</label><select name="feedback_tag"><% feedbackTags.forEach((tag) => { %><option value="<%= tag %>" <%= tag === review.feedback_tag ? "selected" : "" %>><%= tag %></option><% }) %></select></div>
<div class="field"><label>Feedback type</label><select name="feedback_kind"><% feedbackKinds.forEach((kind) => { %><option value="<%= kind %>" <%= kind === (review.feedback_kind || "instruction_based") ? "selected" : "" %>><%= kind.replaceAll("_", " ") %></option><% }) %></select></div>
<div class="field full"><label>Correction or instruction</label><textarea name="optional_correction" rows="7"><%= review.optional_correction %></textarea></div>
<div class="field full"><label>Review notes</label><textarea name="review_notes" rows="3"><%= review.review_notes %></textarea></div>
<div class="field full improvement-actions"><button class="button" type="submit">Save review</button><button class="button subtle" type="button" data-close-dialog>Cancel</button></div>
</form>
</dialog>
<% } %>
<% if (access.can_implement && review.status === "approved") { %>
<dialog class="improvement-dialog" id="implement-<%= review.id %>">
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>/implement" class="form-grid ai-form">
<div class="field"><label>Promotion target</label><select name="target"><% promotionTargets.forEach((target) => { %><option value="<%= target %>"><%= target.replaceAll("_", " ") %></option><% }) %></select></div>
<div class="field"><label>Minimum role</label><select name="min_role"><% ["user", "mod", "admin"].forEach((role) => { %><option value="<%= role %>" <%= role === review.role ? "selected" : "" %>><%= role %></option><% }) %></select></div>
<div class="field full"><label><%= review.feedback_kind === "instruction_based" ? "Instruction to apply" : "Corrected / expected answer" %></label><textarea name="corrected_answer" rows="7" required><%= review.optional_correction %></textarea></div>
<div class="field full"><label>Searchable OKF title</label><input name="okf_title" placeholder="Short title for this correction" /><span class="hint">When the target is Correction, Lumi saves an editable, searchable OKF file after approval.</span></div>
<div class="field"><label>Origin scope</label><input name="permission_origin" value="<%= review.origin || "any" %>" /></div>
<div class="field"><label>Platform scope</label><input name="permission_platform" value="<%= review.platform || "any" %>" /></div>
<div class="field"><label>Route alias</label><input name="route_alias" /></div>
<div class="field"><label>Verified expected link</label><input name="expected_link" placeholder="/verified/path" /></div>
<div class="field full"><label>Forbidden eval behavior</label><textarea name="forbidden_behavior" rows="2"></textarea></div>
<div class="field full"><label>Notes</label><textarea name="notes" rows="2"></textarea></div>
<div class="field"><label><input type="checkbox" name="enabled" checked /> Enabled in staged bank</label></div>
<div class="field"><label><input type="checkbox" name="explicitly_safe" /> Safe for predefined answer</label></div>
<div class="field full improvement-actions"><button class="button" type="submit">Promote review</button><button class="button subtle" type="button" data-close-dialog>Cancel</button></div>
</form>
</dialog>
<% } %>
<% }) %>
<% if (!reviews.entries.length) { %><div class="callout">No feedback matches this filter.</div><% } %>
</div>
<div class="table-pagination">
<a class="button subtle <%= reviews.page <= 1 ? "disabled" : "" %>" href="?review_page=<%= Math.max(1, reviews.page - 1) %>#reviews">Previous</a>
<span class="table-page-label">Page <%= reviews.page %> of <%= reviews.pages %> (<%= reviews.total %> reviews)</span>
<a class="button subtle <%= reviews.page >= reviews.pages ? "disabled" : "" %>" href="?review_page=<%= Math.min(reviews.pages, reviews.page + 1) %>#reviews">Next</a>
</div>
</section>
<section class="ai-band" id="corrections">
<div class="ai-section-heading">
<div><h2>Correction bank</h2><p>Edits and toggles are staged. Save Corrections is required before they become active.</p></div>
<% if (access.can_implement) { %><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/save"><button class="button" type="submit">Save Corrections</button></form><% } %>
</div>
<div class="table-wrap">
<table class="table">
<thead><tr><th>Target</th><th>Prompt / answer</th><th>Permission</th><th>State</th><th>Actions</th></tr></thead>
<tbody>
<% corrections.entries.forEach((entry) => { %>
<tr>
<td><%= entry.target.replaceAll("_", " ") %></td>
<td><details><summary><%= entry.prompt.slice(0, 100) %></summary><pre><%= entry.corrected_answer %></pre></details></td>
<td><%= entry.min_role %> · <%= entry.permission_scope.origin %>/<%= entry.permission_scope.platform %></td>
<td><span class="ai-tag"><%= entry.active ? "active" : entry.enabled ? "staged" : "disabled" %></span></td>
<td class="improvement-actions">
<% if (access.can_verify) { %><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>"><input type="hidden" name="action" value="verify" /><button class="button subtle" type="submit">Verify</button></form><% } %>
<% if (entry.linked_okf_path) { %><a class="button subtle" href="/plugins/okf?q=<%= encodeURIComponent(entry.prompt.slice(0, 80)) %>">Open OKF</a><% } %>
<% if (access.can_edit) { %><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>"><input type="hidden" name="action" value="toggle" /><input type="hidden" name="enabled" value="<%= entry.enabled ? "off" : "on" %>" /><button class="button subtle" type="submit"><%= entry.enabled ? "Disable" : "Enable" %></button></form><button class="button subtle" type="button" data-open-dialog="correction-<%= entry.id %>">Edit</button><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete correction" data-confirm-text="Delete this correction permanently?" data-confirm-label="Delete correction"><button class="button danger" type="submit">Delete</button></form><% } %>
</td>
</tr>
<% if (access.can_edit) { %>
<dialog class="improvement-dialog" id="correction-<%= entry.id %>">
<form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>" class="form-grid ai-form">
<input type="hidden" name="action" value="edit" />
<div class="field full"><label>Corrected answer</label><textarea name="corrected_answer" rows="8"><%= entry.corrected_answer %></textarea></div>
<div class="field"><label>Minimum role</label><select name="min_role"><% ["user", "mod", "admin"].forEach((role) => { %><option value="<%= role %>" <%= role === entry.min_role ? "selected" : "" %>><%= role %></option><% }) %></select></div>
<div class="field"><label>Verified expected link</label><input name="expected_link" value="<%= entry.expected_link %>" /></div>
<div class="field"><label>Origin scope</label><input name="permission_origin" value="<%= entry.permission_scope.origin %>" /></div>
<div class="field"><label>Platform scope</label><input name="permission_platform" value="<%= entry.permission_scope.platform %>" /></div>
<div class="field"><label>Route alias</label><input name="route_alias" value="<%= entry.route_alias %>" /></div>
<div class="field"><label><input type="checkbox" name="enabled" <%= entry.enabled ? "checked" : "" %> /> Enabled</label></div>
<div class="field"><label><input type="checkbox" name="explicitly_safe" <%= entry.explicitly_safe ? "checked" : "" %> /> Safe for predefined answer</label></div>
<div class="field full improvement-actions"><button class="button" type="submit">Stage changes</button><button class="button subtle" type="button" data-close-dialog>Cancel</button></div>
<div class="improvement-decision-row">
<% if (access.can_implement) { %>
<button class="button" type="button" data-open-dialog="implement-<%= review.id %>">Apply improvement</button>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" data-improvement-action>
<input type="hidden" name="action" value="reviewed" />
<button class="button subtle" type="submit">No change needed</button>
</form>
</dialog>
<% } %>
<% }) %>
<% if (!corrections.entries.length) { %><tr><td colspan="5">No corrections have been promoted.</td></tr><% } %>
</tbody>
</table>
</div>
<div class="table-pagination">
<a class="button subtle <%= corrections.page <= 1 ? "disabled" : "" %>" href="?correction_page=<%= Math.max(1, corrections.page - 1) %>#corrections">Previous</a>
<span class="table-page-label">Page <%= corrections.page %> of <%= corrections.pages %> (<%= corrections.total %> corrections)</span>
<a class="button subtle <%= corrections.page >= corrections.pages ? "disabled" : "" %>" href="?correction_page=<%= Math.min(corrections.pages, corrections.page + 1) %>#corrections">Next</a>
</div>
</section>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" data-improvement-action>
<input type="hidden" name="action" value="reject" />
<button class="button subtle" type="submit">Dismiss feedback</button>
</form>
<button class="button subtle" type="button" data-open-dialog="edit-<%= review.id %>">Edit details</button>
<% } else { %>
<% if (access.can_verify) { %><form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" data-improvement-action><input type="hidden" name="action" value="verify" /><button class="button" type="submit">Reviewed — send to admin</button></form><% } %>
<% if (access.can_flag) { %><form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" data-improvement-action><input type="hidden" name="action" value="flag" /><button class="button subtle" type="submit">Needs closer review</button></form><% } %>
<% } %>
</div>
</article>
<section class="ai-band" id="evals">
<div class="ai-section-heading">
<div><h2>Evals</h2><p>Stored cases can be run manually against the current Lumi AI configuration.</p></div>
<% if (access.can_run_evals) { %><form method="post" action="/plugins/lumi_ai/improvement_center/evals/run"><button class="button" type="submit">Run all evals</button></form><% } %>
</div>
<% if (access.can_run_evals) { %>
<details class="ai-settings-group"><summary>Add eval case</summary>
<form method="post" action="/plugins/lumi_ai/improvement_center/evals" class="form-grid ai-form">
<div class="field full"><label>Prompt</label><textarea name="prompt" rows="3" required></textarea></div>
<div class="field"><label>Role</label><select name="role"><option>user</option><option>mod</option><option>admin</option></select></div>
<div class="field"><label>Origin</label><input name="origin" value="webui" /></div>
<div class="field full"><label>Expected behavior</label><textarea name="expected_behavior" rows="3"></textarea></div>
<div class="field full"><label>Forbidden behavior</label><textarea name="forbidden_behavior" rows="3"></textarea></div>
<div class="field"><label>Expected verified link</label><input name="expected_link" /></div>
<div class="field"><label>Notes</label><input name="notes" /></div>
<div class="field full"><button class="button" type="submit">Add eval</button></div>
<% if (access.can_edit) { %>
<dialog class="improvement-dialog" id="edit-<%= review.id %>">
<div class="improvement-dialog-heading"><div><h3>Edit feedback details</h3><p>Clarify the report without changing its outcome.</p></div><button class="icon-button" type="button" data-close-dialog aria-label="Close">&times;</button></div>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" class="form-grid ai-form" data-improvement-action>
<input type="hidden" name="action" value="edit" />
<div class="field"><label>Problem type</label><select name="feedback_tag"><% feedbackTags.forEach((tag) => { %><option value="<%= tag %>" <%= tag === review.feedback_tag ? "selected" : "" %>><%= tag.replaceAll("_", " ") %></option><% }) %></select></div>
<div class="field"><label>Improvement style</label><select name="feedback_kind"><% feedbackKinds.forEach((kind) => { %><option value="<%= kind %>" <%= kind === (review.feedback_kind || "instruction_based") ? "selected" : "" %>><%= kind === "instruction_based" ? "Behavior guidance" : "Exact correction" %></option><% }) %></select></div>
<div class="field full"><label>Suggested improvement</label><textarea name="optional_correction" rows="6"><%= review.optional_correction %></textarea></div>
<div class="field full"><label>Private reviewer note</label><textarea name="review_notes" rows="3"><%= review.review_notes %></textarea></div>
<div class="field full improvement-actions"><button class="button" type="submit">Save details</button><button class="button subtle" type="button" data-close-dialog>Cancel</button></div>
</form>
</dialog>
<% } %>
<% if (access.can_implement) { %>
<dialog class="improvement-dialog" id="implement-<%= review.id %>">
<div class="improvement-dialog-heading"><div><h3>Apply this improvement</h3><p>Tell Lumi what it should do instead. Saving here activates the change and finishes the feedback.</p></div><button class="icon-button" type="button" data-close-dialog aria-label="Close">&times;</button></div>
<form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>/implement" class="form-grid ai-form" data-improvement-action>
<div class="field full"><label>What should Lumi do instead?</label><textarea name="corrected_answer" rows="8" required placeholder="Write the answer or behavior Lumi should use for similar requests."><%= review.optional_correction %></textarea><span class="hint">Use clear, user-facing wording. This becomes the reviewed guidance for similar requests.</span></div>
<div class="field"><label>Who may receive this improvement?</label><select name="min_role"><% [["user", "Everyone"], ["mod", "Moderators and admins"], ["admin", "Admins only"]].forEach(([role, label]) => { %><option value="<%= role %>" <%= role === review.role ? "selected" : "" %>><%= label %></option><% }) %></select></div>
<div class="field"><label class="checkbox-inline"><input type="checkbox" name="enabled" checked /><span>Start using it immediately</span></label></div>
<details class="field full ai-settings-group">
<summary>Advanced options</summary>
<div class="form-grid ai-form improvement-advanced-fields">
<div class="field"><label>Apply as</label><select name="target"><option value="correction">Answer or behavior guidance</option><option value="predefined_answer">Exact predefined answer</option><option value="route_alias">Route/link answer</option><option value="eval_case">Test case only</option><option value="training_export">Training export only</option></select></div>
<div class="field"><label>Searchable knowledge title</label><input name="okf_title" placeholder="Short title for this improvement" /></div>
<div class="field"><label>Origin</label><input name="permission_origin" value="<%= review.origin || "any" %>" /></div>
<div class="field"><label>Platform</label><input name="permission_platform" value="<%= review.platform || "any" %>" /></div>
<div class="field"><label>Route alias</label><input name="route_alias" /></div>
<div class="field"><label>Verified Lumi link</label><input name="expected_link" placeholder="/verified/path" /></div>
<div class="field full"><label>Behavior Lumi must avoid</label><textarea name="forbidden_behavior" rows="2"></textarea></div>
<div class="field full"><label>Implementation note</label><textarea name="notes" rows="2"></textarea></div>
<div class="field"><label class="checkbox-inline"><input type="checkbox" name="explicitly_safe" /><span>Safe as an exact predefined answer</span></label></div>
</div>
</details>
<div class="field full improvement-actions"><button class="button" type="submit">Apply and finish</button><button class="button subtle" type="button" data-close-dialog>Cancel</button></div>
</form>
</dialog>
<% } %>
<% }) %>
<% if (!reviews.entries.length) { %><div class="improvement-empty"><strong>The queue is clear.</strong><p>New AI feedback will appear here automatically.</p><button class="button subtle" type="button" data-open-history>Search finished feedback</button></div><% } %>
</div>
<% if (reviews.pages > 1) { %><div class="table-pagination"><a class="button subtle <%= reviews.page <= 1 ? "disabled" : "" %>" href="?review_page=<%= Math.max(1, reviews.page - 1) %>#reviews" data-improvement-page>Previous</a><span class="table-page-label">Page <%= reviews.page %> of <%= reviews.pages %></span><a class="button subtle <%= reviews.page >= reviews.pages ? "disabled" : "" %>" href="?review_page=<%= Math.min(reviews.pages, reviews.page + 1) %>#reviews" data-improvement-page>Next</a></div><% } %>
</section>
<dialog class="improvement-dialog improvement-history-dialog" id="improvement-history">
<div class="improvement-dialog-heading"><div><h2>Finished feedback history</h2><p>Search, inspect, restore, edit, or permanently delete earlier decisions.</p></div><button class="icon-button" type="button" data-close-dialog aria-label="Close history">&times;</button></div>
<form class="improvement-history-search" method="get" action="/plugins/lumi_ai/improvement_center" data-improvement-history-search>
<label class="field"><span>Search feedback</span><input type="search" name="history_q" value="<%= historyQuery %>" placeholder="Question, answer, tag, note, or status" /></label>
<label class="field"><span>Outcome</span><select name="history_status"><option value="">All finished feedback</option><% [["implemented", "Applied"], ["rejected", "Dismissed"], ["reviewed", "No change needed"], ["archived", "Archived"]].forEach(([value, label]) => { %><option value="<%= value %>" <%= historyStatus === value ? "selected" : "" %>><%= label %></option><% }) %></select></label>
<button class="button" type="submit">Search</button>
<button class="button subtle" type="button" data-history-clear>Clear</button>
</form>
<div class="improvement-history-list">
<% historyReviews.entries.forEach((review) => { %>
<article class="improvement-card history-card">
<header><div class="improvement-card-labels"><strong><%= statusLabel(review.status) %></strong><span class="ai-tag status-<%= review.status %>"><%= statusLabel(review.status) %></span><span class="ai-tag"><%= review.feedback_tag.replaceAll("_", " ") %></span></div><span><%= formatDate(review.reviewed_at || review.timestamp) %></span></header>
<details>
<summary><%= review.user_message.slice(0, 180) %><%= review.user_message.length > 180 ? "…" : "" %></summary>
<div class="improvement-pair"><div><span>User asked</span><pre><%= review.user_message %></pre></div><div><span>Lumi answered</span><pre><%= review.assistant_answer %></pre></div></div>
<% if (review.optional_correction) { %><div class="improvement-correction"><strong>Suggested improvement</strong><pre><%= review.optional_correction %></pre></div><% } %>
<% if (review.implementation_summary) { %><div class="callout success"><strong>What was applied</strong><p><%= review.implementation_summary %></p></div><% } %>
<% if (review.review_notes) { %><p><strong>Reviewer note:</strong> <%= review.review_notes %></p><% } %>
<% if (review.linked_okf_correction) { %><p><a href="/plugins/okf?q=<%= encodeURIComponent(review.user_message.slice(0, 80)) %>">Open searchable knowledge entry</a></p><% } %>
</details>
<% if (access.can_edit) { %><div class="improvement-actions"><form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" data-improvement-action><input type="hidden" name="action" value="restore" /><button class="button subtle" type="submit">Restore to queue</button></form><button class="button subtle" type="button" data-open-dialog="history-edit-<%= review.id %>">Edit details</button><form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>/delete" data-improvement-action data-confirm-mode="modal" data-confirm-title="Delete feedback record" data-confirm-text="Permanently delete this finished feedback record?" data-confirm-label="Delete feedback"><button class="button danger" type="submit">Delete permanently</button></form></div><% } %>
</article>
<% if (access.can_edit) { %><dialog class="improvement-dialog" id="history-edit-<%= review.id %>"><div class="improvement-dialog-heading"><h3>Edit finished feedback</h3><button class="icon-button" type="button" data-close-dialog aria-label="Close">&times;</button></div><form method="post" action="/plugins/lumi_ai/improvement_center/reviews/<%= review.id %>" class="form-grid ai-form" data-improvement-action><input type="hidden" name="action" value="edit" /><div class="field"><label>Problem type</label><select name="feedback_tag"><% feedbackTags.forEach((tag) => { %><option value="<%= tag %>" <%= tag === review.feedback_tag ? "selected" : "" %>><%= tag.replaceAll("_", " ") %></option><% }) %></select></div><input type="hidden" name="feedback_kind" value="<%= review.feedback_kind || "instruction_based" %>" /><div class="field full"><label>Suggested improvement</label><textarea name="optional_correction" rows="6"><%= review.optional_correction %></textarea></div><div class="field full"><label>Reviewer note</label><textarea name="review_notes" rows="3"><%= review.review_notes %></textarea></div><div class="field full improvement-actions"><button class="button" type="submit">Save details</button><button class="button subtle" type="button" data-close-dialog>Cancel</button></div></form></dialog><% } %>
<% }) %>
<% if (!historyReviews.entries.length) { %><div class="improvement-empty"><strong>No finished feedback found.</strong><p>Try a different search or status.</p></div><% } %>
</div>
<% if (historyReviews.pages > 1) { %><div class="table-pagination"><a class="button subtle <%= historyReviews.page <= 1 ? "disabled" : "" %>" href="?history_q=<%= encodeURIComponent(historyQuery) %>&history_status=<%= historyStatus %>&history_page=<%= Math.max(1, historyReviews.page - 1) %>" data-improvement-history-page>Previous</a><span class="table-page-label">Page <%= historyReviews.page %> of <%= historyReviews.pages %> (<%= historyReviews.total %> records)</span><a class="button subtle <%= historyReviews.page >= historyReviews.pages ? "disabled" : "" %>" href="?history_q=<%= encodeURIComponent(historyQuery) %>&history_status=<%= historyStatus %>&history_page=<%= Math.min(historyReviews.pages, historyReviews.page + 1) %>" data-improvement-history-page>Next</a></div><% } %>
</dialog>
<% if (access.can_approve) { %>
<details class="ai-band improvement-advanced" id="advanced-tools">
<summary><span><strong>Advanced tools and access</strong><small>Correction bank, moderator access, evaluation cases, and training exports</small></span></summary>
<div class="improvement-advanced-body">
<section>
<div class="ai-section-heading"><div><h2>Review access</h2><p>Choose whether selected moderators may help triage feedback.</p></div></div>
<form method="post" action="/plugins/lumi_ai/improvement_center/settings" class="form-grid ai-form" data-improvement-action>
<div class="field"><label class="checkbox-inline"><input type="checkbox" name="allow_moderators_to_review_responses" <%= config.improvement.allow_moderators_to_review_responses ? "checked" : "" %> /><span>Allow moderators to review responses</span></label></div>
<div class="field"><label class="checkbox-inline"><input type="checkbox" name="corrections_enabled" <%= config.improvement.corrections_enabled ? "checked" : "" %> /><span>Use active reviewed corrections</span></label></div>
<div class="field full"><label>Trusted moderator reviewer IDs</label><textarea name="trusted_moderator_reviewers" rows="2"><%= config.improvement.trusted_moderator_reviewers.join("\n") %></textarea></div>
<div class="field full"><button class="button" type="submit">Save access</button></div>
</form>
</section>
<section id="corrections">
<div class="ai-section-heading"><div><h2>Correction bank</h2><p>Normal “Apply improvement” actions are saved immediately. Use this table for manual maintenance.</p></div><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/save" data-improvement-action><button class="button subtle" type="submit">Activate staged changes</button></form></div>
<div class="table-wrap"><table class="table"><thead><tr><th>Type</th><th>Prompt / answer</th><th>Audience</th><th>State</th><th>Actions</th></tr></thead><tbody>
<% corrections.entries.forEach((entry) => { %><tr><td><%= entry.target.replaceAll("_", " ") %></td><td><details><summary><%= entry.prompt.slice(0, 100) %></summary><pre><%= entry.corrected_answer %></pre></details></td><td><%= entry.min_role %> · <%= entry.permission_scope.origin %>/<%= entry.permission_scope.platform %></td><td><span class="ai-tag"><%= entry.active ? "active" : entry.enabled ? "staged" : "disabled" %></span></td><td class="improvement-actions"><% if (entry.linked_okf_path) { %><a class="button subtle" href="/plugins/okf?q=<%= encodeURIComponent(entry.prompt.slice(0, 80)) %>">Open knowledge</a><% } %><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>" data-improvement-action><input type="hidden" name="action" value="toggle" /><input type="hidden" name="enabled" value="<%= entry.enabled ? "off" : "on" %>" /><button class="button subtle" type="submit"><%= entry.enabled ? "Disable" : "Enable" %></button></form><form method="post" action="/plugins/lumi_ai/improvement_center/corrections/<%= entry.id %>/delete" data-improvement-action data-confirm-mode="modal" data-confirm-title="Delete correction" data-confirm-text="Delete this correction permanently?" data-confirm-label="Delete correction"><button class="button danger" type="submit">Delete</button></form></td></tr><% }) %>
<% if (!corrections.entries.length) { %><tr><td colspan="5">No corrections have been created.</td></tr><% } %>
</tbody></table></div>
</section>
<section id="evals">
<div class="ai-section-heading"><div><h2>Evaluation cases</h2><p>Optional technical tests for checking the current AI configuration.</p></div><form method="post" action="/plugins/lumi_ai/improvement_center/evals/run" data-improvement-action><button class="button subtle" type="submit">Run all tests</button></form></div>
<details class="ai-settings-group"><summary>Add a test case</summary><form method="post" action="/plugins/lumi_ai/improvement_center/evals" class="form-grid ai-form" data-improvement-action><div class="field full"><label>Prompt</label><textarea name="prompt" rows="3" required></textarea></div><div class="field"><label>Role</label><select name="role"><option>user</option><option>mod</option><option>admin</option></select></div><div class="field"><label>Origin</label><input name="origin" value="webui" /></div><div class="field full"><label>Expected behavior</label><textarea name="expected_behavior" rows="3"></textarea></div><div class="field full"><label>Forbidden behavior</label><textarea name="forbidden_behavior" rows="3"></textarea></div><div class="field"><label>Expected verified link</label><input name="expected_link" /></div><div class="field"><label>Notes</label><input name="notes" /></div><div class="field full"><button class="button" type="submit">Add test</button></div></form></details>
<div class="table-wrap"><table class="table"><thead><tr><th>Prompt</th><th>Role / origin</th><th>Expected</th><th>Must avoid</th><th>Actions</th></tr></thead><tbody><% evalCases.entries.forEach((entry) => { %><tr><td><%= entry.prompt %></td><td><%= entry.role %> / <%= entry.origin %></td><td><%= entry.expected_behavior || "-" %></td><td><%= entry.forbidden_behavior || "-" %></td><td><form method="post" action="/plugins/lumi_ai/improvement_center/evals/<%= entry.id %>/delete" data-improvement-action data-confirm-mode="modal" data-confirm-title="Delete eval case" data-confirm-text="Delete this evaluation case?" data-confirm-label="Delete test"><button class="button danger" type="submit">Delete</button></form></td></tr><% }) %><% if (!evalCases.entries.length) { %><tr><td colspan="5">No evaluation cases.</td></tr><% } %></tbody></table></div>
<details class="ai-settings-group"><summary>Recent test results</summary><div class="table-wrap"><table class="table"><thead><tr><th>Time</th><th>Case</th><th>Result</th><th>Notes</th></tr></thead><tbody><% evalResults.forEach((result) => { %><tr><td><%= formatDate(result.run_at) %></td><td><%= result.case_id %></td><td><span class="ai-tag"><%= result.status %></span></td><td><%= result.notes || "-" %></td></tr><% }) %><% if (!evalResults.length) { %><tr><td colspan="4">No test results.</td></tr><% } %></tbody></table></div></details>
</section>
<% if (access.can_export) { %><section id="exports"><div class="ai-section-heading"><div><h2>Training exports</h2><p>Manual exports include only approved examples. Lumi does not start training.</p></div></div><div class="improvement-actions"><form method="post" action="/plugins/lumi_ai/improvement_center/exports/instruction"><button class="button subtle" type="submit">Export instruction data</button></form><form method="post" action="/plugins/lumi_ai/improvement_center/exports/dpo"><button class="button subtle" type="submit">Export preference data</button></form></div></section><% } %>
</div>
</details>
<% } %>
<div class="table-wrap"><table class="table"><thead><tr><th>Prompt</th><th>Role / origin</th><th>Expected</th><th>Forbidden</th><th>Expected link</th><th>Actions</th></tr></thead><tbody>
<% evalCases.entries.forEach((entry) => { %><tr><td><%= entry.prompt %></td><td><%= entry.role %> / <%= entry.origin %></td><td><%= entry.expected_behavior || "-" %></td><td><%= entry.forbidden_behavior || "-" %></td><td><%= entry.expected_link || "-" %></td><td><% if (access.can_run_evals) { %><form method="post" action="/plugins/lumi_ai/improvement_center/evals/<%= entry.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete eval case" data-confirm-text="Delete this eval case?" data-confirm-label="Delete eval"><button class="button danger" type="submit">Delete</button></form><% } %></td></tr><% }) %>
<% if (!evalCases.entries.length) { %><tr><td colspan="6">No eval cases.</td></tr><% } %>
</tbody></table></div>
<div class="table-pagination">
<a class="button subtle <%= evalCases.page <= 1 ? "disabled" : "" %>" href="?eval_page=<%= Math.max(1, evalCases.page - 1) %>#evals">Previous</a>
<span class="table-page-label">Page <%= evalCases.page %> of <%= evalCases.pages %> (<%= evalCases.total %> cases)</span>
<a class="button subtle <%= evalCases.page >= evalCases.pages ? "disabled" : "" %>" href="?eval_page=<%= Math.min(evalCases.pages, evalCases.page + 1) %>#evals">Next</a>
</div>
<details class="ai-settings-group"><summary>Recent eval results</summary>
<div class="table-wrap"><table class="table"><thead><tr><th>Time</th><th>Case</th><th>Result</th><th>Notes</th></tr></thead><tbody>
<% evalResults.forEach((result) => { %><tr><td><%= formatDate(result.run_at) %></td><td><%= result.case_id %></td><td><span class="ai-tag"><%= result.status %></span></td><td><%= result.notes || "-" %></td></tr><% }) %>
<% if (!evalResults.length) { %><tr><td colspan="4">No eval results.</td></tr><% } %>
</tbody></table></div>
</details>
</section>
</div>
<% if (access.can_export) { %>
<section class="ai-band" id="exports">
<div class="ai-section-heading"><div><h2>Training exports</h2><p>Manual JSONL exports include approved examples only. Lumi does not start training.</p></div></div>
<div class="improvement-actions">
<form method="post" action="/plugins/lumi_ai/improvement_center/exports/instruction"><button class="button" type="submit">Export instruction JSONL</button></form>
<form method="post" action="/plugins/lumi_ai/improvement_center/exports/dpo"><button class="button subtle" type="submit">Export DPO JSONL</button></form>
</div>
</section>
<% } %>
<script src="/plugins/lumi_ai/assets/improvement-center.js?v=0.6.0" defer></script>
<script src="/plugins/lumi_ai/assets/improvement-center.js?v=0.8.3" defer></script>
<%- include("../../../src/web/views/partials/layout-bottom") %>

View File

@ -2,6 +2,36 @@
"schema_version": 1,
"channel": "stable",
"releases": [
{
"version": "0.2.7",
"ref": "refs/tags/v0.2.7",
"released_at": "2026-07-18",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Adds a live one-step Lumi AI feedback workflow and searchable finalized-feedback history while preserving existing review and correction data.",
"plugins": {
"auto-vc": "0.1.6",
"birthday": "0.1.3",
"economy-framework": "0.2.10",
"economy-games": "0.1.7",
"expression-interaction": "0.2.1",
"lumi_ai": "0.8.3",
"moderation": "0.1.5",
"okf": "0.1.1",
"quotes": "0.1.2",
"sample-plugin": "0.1.0",
"throne_wishlist": "0.1.2",
"welcome_messages": "0.1.1"
},
"tools": {
"lumi_ai_web_search": "0.1.1"
}
},
{
"version": "0.2.6",
"ref": "refs/tags/v0.2.6",

View File

@ -4,15 +4,15 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.2.6";
const previousCoreVersion = "0.2.5";
const releaseVersion = "0.2.7";
const previousCoreVersion = "0.2.6";
const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
birthday: { from: "0.1.2", to: "0.1.3", knowledge: "birthday" },
"economy-framework": { from: "0.2.9", to: "0.2.10", knowledge: "economy-framework" },
"expression-interaction": { from: "0.2.0", to: "0.2.1", knowledge: "expression-interaction" },
lumi_ai: { from: "0.8.1", to: "0.8.2", knowledge: "lumi-ai" },
lumi_ai: { from: "0.8.2", to: "0.8.3", knowledge: "lumi-ai" },
moderation: { from: "0.1.4", to: "0.1.5", knowledge: "moderation" },
okf: { from: "0.1.0", to: "0.1.1", knowledge: "okf" },
quotes: { from: "0.1.1", to: "0.1.2", knowledge: "quotes" },
@ -84,7 +84,7 @@ for (const [pluginId, expected] of Object.entries(changedPlugins)) {
const webSearch = readJson("plugins/lumi_ai_web_search/tool_info.json");
assert.equal(webSearch.version, "0.1.1");
assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, changedPlugins.lumi_ai.to);
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: core 0.2.6 and 11 changed plugin/tool packages.");
console.log("Release metadata verification passed: core 0.2.7, Lumi AI 0.8.3, and synchronized package metadata.");

View File

@ -16,7 +16,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.deepEqual(releaseVersions, ["0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.4",
available_versions: [
{ version: "0.2.7", ref: "refs/tags/v0.2.7", rollback_safe: true },
{ version: "0.2.6", ref: "refs/tags/v0.2.6", rollback_safe: true },
{ version: "0.2.5", ref: "refs/tags/v0.2.5", rollback_safe: true },
{ version: "0.2.4", ref: "refs/tags/v0.2.4", rollback_safe: true },
@ -66,7 +67,7 @@ const corrected = buildStatus({
channel: "stable"
});
assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.2.6");
assert.equal(corrected.safe_target_version, "0.2.7");
assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false);

View File

@ -1,6 +1,6 @@
{
"name": "Lumi Core",
"version": "0.2.6",
"version": "0.2.7",
"channel": "stable",
"released_at": "2026-07-18",
"compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [
"1.2.0"
],
"migration_notes": "Includes the 1.2.0 version correction, production plugin-update diagnostics, secured read-only production diagnostics with private-proxy HTTPS support, the shared form-action fix, and Windows/network-share-safe plugin code replacement. Lumi synchronizes runtime dependencies on restart and preserves settings, databases, plugin data, community knowledge, AI models, runtimes, uploads, logs, and secrets.",
"migration_notes": "Includes the 1.2.0 version correction, production plugin-update diagnostics, secured read-only production diagnostics, Windows/network-share-safe plugin code replacement, and the live streamlined Lumi AI feedback workflow. Lumi synchronizes runtime dependencies on restart and preserves settings, databases, plugin data, community knowledge, AI models, runtimes, uploads, logs, feedback, and secrets.",
"rollback_safe": true,
"requirements": [
"Node.js 18 or newer"
@ -97,6 +97,18 @@
],
"rollback_safe": true,
"migration_notes": "Updates plugin code without renaming a running plugin's preserved data directory, avoiding Windows and network-share locks; preserved local data is not replaced."
},
{
"version": "0.2.7",
"channel": "stable",
"released_at": "2026-07-18",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Adds the live streamlined Lumi AI feedback queue and searchable finalized-feedback history; existing feedback and plugin data are preserved."
}
]
}