feat(tools): owner feedback review surface + tool_feedback migration

Adds the missing migration for the tool_feedback table (dobetternorge_maindb)
that the in-result feedback widget writes to, repoints api/feedback.php to
dbnmDb() for consistency with the engine-config table, and adds an owner-only
dashboard (page + read API + nav) summarising ratings and notes by tool/engine.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 15:19:45 +02:00
parent 198f0526cf
commit 519bdbb6e5
6 changed files with 294 additions and 1 deletions
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* /api/dashboard/feedback.php — owner-only tool-feedback review surface (read).
*
* GET → { ok, totals, by_tool, by_engine, recent }
*
* Reads dobetternorge_maindb.tool_feedback (written by api/feedback.php).
* Owner-gated via dbnToolsIsOwner(). Comments are voluntary, case-content-free.
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireAuth();
if (!dbnToolsIsOwner()) {
dbnToolsError('Owner access required.', 403, 'forbidden');
}
try {
$db = dbnmDb();
$totalsRow = $db->query(
"SELECT
COUNT(*) AS total,
SUM(rating = 'positive') AS positive,
SUM(rating = 'negative') AS negative
FROM tool_feedback"
)->fetch();
$byTool = $db->query(
"SELECT
tool,
COUNT(*) AS total,
SUM(rating = 'positive') AS positive,
SUM(rating = 'negative') AS negative,
MAX(created_at) AS last_at
FROM tool_feedback
GROUP BY tool
ORDER BY total DESC, tool ASC"
)->fetchAll();
$byEngine = $db->query(
"SELECT
COALESCE(NULLIF(engine, ''), '(unknown)') AS engine,
COUNT(*) AS total,
SUM(rating = 'positive') AS positive,
SUM(rating = 'negative') AS negative
FROM tool_feedback
GROUP BY engine
ORDER BY total DESC, engine ASC"
)->fetchAll();
$recent = $db->query(
"SELECT tool, rating, engine, missed_or_wrong, created_at
FROM tool_feedback
WHERE missed_or_wrong IS NOT NULL AND missed_or_wrong <> ''
ORDER BY created_at DESC
LIMIT 50"
)->fetchAll();
$toInt = static fn($v): int => (int) ($v ?? 0);
dbnToolsRespond([
'ok' => true,
'totals' => [
'total' => $toInt($totalsRow['total'] ?? 0),
'positive' => $toInt($totalsRow['positive'] ?? 0),
'negative' => $toInt($totalsRow['negative'] ?? 0),
],
'by_tool' => array_map(static fn(array $r): array => [
'tool' => $r['tool'],
'total' => $toInt($r['total']),
'positive' => $toInt($r['positive']),
'negative' => $toInt($r['negative']),
'last_at' => $r['last_at'],
], $byTool),
'by_engine' => array_map(static fn(array $r): array => [
'engine' => $r['engine'],
'total' => $toInt($r['total']),
'positive' => $toInt($r['positive']),
'negative' => $toInt($r['negative']),
], $byEngine),
'recent' => array_map(static fn(array $r): array => [
'tool' => $r['tool'],
'rating' => $r['rating'],
'engine' => $r['engine'],
'missed_or_wrong' => $r['missed_or_wrong'],
'created_at' => $r['created_at'],
], $recent),
]);
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra ?? []);
} catch (Throwable $e) {
error_log('[dbn-feedback] ' . $e->getMessage());
dbnToolsError('Feedback review failed.', 500, 'op_failed');
}
+1 -1
View File
@@ -21,7 +21,7 @@ if ($tool === '') {
}
try {
$db = dbnToolsDb();
$db = dbnmDb();
$stmt = $db->prepare(
'INSERT INTO tool_feedback (session_id, tool, rating, missed_or_wrong, engine)
VALUES (?, ?, ?, ?, ?)'
+88
View File
@@ -0,0 +1,88 @@
(function () {
'use strict';
const d = window.DBN_DASHBOARD || {};
const api = (d.apiBase || '/api/dashboard') + '/feedback.php';
const $totals = document.getElementById('fbTotals');
const $byTool = document.getElementById('fbByTool');
const $byEngine = document.getElementById('fbByEngine');
const $recent = document.getElementById('fbRecent');
const $refresh = document.getElementById('fbRefresh');
const safe = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
function pct(pos, total) {
return total > 0 ? Math.round((pos / total) * 100) : 0;
}
function totalsHtml(t) {
return '' +
'<div class="fb-stat"><div class="fb-stat__num">' + t.total + '</div><div class="fb-stat__lbl">Total</div></div>' +
'<div class="fb-stat fb-stat--pos"><div class="fb-stat__num">' + t.positive + '</div><div class="fb-stat__lbl">Positive</div></div>' +
'<div class="fb-stat fb-stat--neg"><div class="fb-stat__num">' + t.negative + '</div><div class="fb-stat__lbl">Negative</div></div>' +
'<div class="fb-stat"><div class="fb-stat__num">' + pct(t.positive, t.total) + '%</div><div class="fb-stat__lbl">Approval</div></div>';
}
function rowsHtml(rows, nameKey, withMeta) {
const head = '<div class="fb-head"><div>' + (nameKey === 'tool' ? 'Tool' : 'Engine') +
'</div><div>Total</div><div>Up</div><div>Down</div><div>' + (withMeta ? 'Last' : 'Approval') + '</div></div>';
if (!rows.length) return head + '<div class="fb-empty">No feedback yet.</div>';
const body = rows.map(r => {
const p = pct(r.positive, r.total);
const right = withMeta
? '<div class="fb-meta">' + safe(r.last_at || '—') + '</div>'
: '<div><div class="fb-bar"><div class="fb-bar__fill" style="width:' + p + '%"></div></div>' +
'<div class="fb-meta">' + p + '%</div></div>';
return '<div class="fb-row">' +
'<div class="fb-row__name">' + safe(r[nameKey]) + '</div>' +
'<div>' + r.total + '</div>' +
'<div class="fb-pos">' + r.positive + '</div>' +
'<div class="fb-neg">' + r.negative + '</div>' +
right +
'</div>';
}).join('');
return head + body;
}
function recentHtml(rows) {
if (!rows.length) return '<div class="fb-empty">No notes yet.</div>';
return rows.map(n => {
const isPos = n.rating === 'positive';
return '<div class="fb-note">' +
'<div class="fb-note__head">' +
'<span class="fb-note__tool">' + safe(n.tool) + '</span>' +
'<span class="fb-note__rating ' + (isPos ? 'is-pos' : 'is-neg') + '">' + (isPos ? '\u{1F44D}' : '\u{1F44E}') + '</span>' +
(n.engine ? '<span class="fb-meta">' + safe(n.engine) + '</span>' : '') +
'<span class="fb-meta">' + safe(n.created_at || '') + '</span>' +
'</div>' +
'<div class="fb-note__text">' + safe(n.missed_or_wrong) + '</div>' +
'</div>';
}).join('');
}
function render(data) {
$totals.innerHTML = totalsHtml(data.totals || { total: 0, positive: 0, negative: 0 });
$byTool.innerHTML = rowsHtml(data.by_tool || [], 'tool', true);
$byEngine.innerHTML = rowsHtml(data.by_engine || [], 'engine', false);
$recent.innerHTML = recentHtml(data.recent || []);
}
async function load() {
$totals.innerHTML = '<div class="dms-loading"></div>';
try {
const r = await fetch(api, { credentials: 'same-origin' });
const data = await r.json();
if (!data.ok) throw new Error(data.message || 'Could not load feedback');
render(data);
} catch (e) {
$totals.innerHTML = '<div class="fb-empty">Could not load: ' + safe(e.message) + '</div>';
$byTool.innerHTML = '';
$byEngine.innerHTML = '';
$recent.innerHTML = '';
}
}
if ($refresh) $refresh.addEventListener('click', load);
load();
})();
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
if (!dbnToolsIsAuthenticated()) {
dbnToolsRequirePageAuth($_SERVER['REQUEST_URI'] ?? '/dashboard/feedback.php');
}
if (!dbnToolsIsOwner()) {
http_response_code(403);
echo '<!doctype html><meta charset="utf-8"><title>Owner access required</title>'
. '<p style="font-family:sans-serif;max-width:540px;margin:4rem auto;">'
. 'Owner access required. <a href="/dashboard/">Back to dashboard</a></p>';
exit;
}
$dashboardPage = 'feedback';
$dashboardTitle = 'Tool Feedback';
$dashboardLead = 'Thumbs up/down + optional notes users left on tool results. Voluntary, case-content-free — a signal for what is working per tool and engine.';
require_once __DIR__ . '/../includes/layout_dashboard.php';
?>
<section class="dash-card">
<div class="dash-card__head">
<h2>Overview</h2>
<div class="dash-card__actions">
<button class="dash-btn" type="button" id="fbRefresh">↻ Refresh</button>
</div>
</div>
<div id="fbTotals" class="fb-totals"><div class="dms-loading"></div></div>
</section>
<section class="dash-card">
<div class="dash-card__head"><h2>By tool</h2></div>
<div id="fbByTool" class="fb-table"><div class="dms-loading"></div></div>
</section>
<section class="dash-card">
<div class="dash-card__head"><h2>By engine</h2></div>
<div id="fbByEngine" class="fb-table"><div class="dms-loading"></div></div>
</section>
<section class="dash-card">
<div class="dash-card__head"><h2>Recent notes</h2></div>
<p style="margin-top:0; max-width:74ch; line-height:1.6; font-size:0.86rem; color:rgba(22,19,15,0.6);">
Latest voluntary comments (newest first). These are user-written notes, not pasted case text.
</p>
<div id="fbRecent" class="fb-notes"><div class="dms-loading"></div></div>
</section>
<style>
.fb-totals { display:flex; gap:16px; flex-wrap:wrap; }
.fb-stat { background:#fff; border:1px solid var(--dms-stroke,#e3ddd2); border-radius:var(--dms-radius,10px); padding:14px 20px; min-width:120px; }
.fb-stat__num { font-size:26px; font-weight:700; color:var(--dms-navy,#16130f); }
.fb-stat__lbl { font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:rgba(22,19,15,0.5); margin-top:2px; }
.fb-stat--pos .fb-stat__num { color:#2f7d32; }
.fb-stat--neg .fb-stat__num { color:#b3261e; }
.fb-table { background:#fff; border:1px solid var(--dms-stroke,#e3ddd2); border-radius:var(--dms-radius,10px); overflow:hidden; }
.fb-head, .fb-row { display:grid; grid-template-columns:1.6fr 0.7fr 0.7fr 0.7fr 1.1fr; gap:12px; padding:10px 16px; align-items:center; font-size:13px; }
.fb-head { font-size:10px; text-transform:uppercase; letter-spacing:0.05em; color:rgba(22,19,15,0.4); background:rgba(22,19,15,0.02); border-bottom:1px solid var(--dms-stroke-soft,#efe9dd); }
.fb-row { border-bottom:1px solid var(--dms-stroke-soft,#efe9dd); }
.fb-row:last-child { border-bottom:0; }
.fb-row__name { font-weight:600; color:var(--dms-navy,#16130f); }
.fb-pos { color:#2f7d32; font-weight:600; }
.fb-neg { color:#b3261e; font-weight:600; }
.fb-bar { height:6px; border-radius:3px; background:#e9e3d6; overflow:hidden; }
.fb-bar__fill { height:100%; background:#2f7d32; }
.fb-meta { font-size:11px; color:rgba(22,19,15,0.45); }
.fb-notes { display:flex; flex-direction:column; gap:10px; }
.fb-note { background:#fff; border:1px solid var(--dms-stroke,#e3ddd2); border-radius:8px; padding:10px 14px; font-size:13px; }
.fb-note__head { display:flex; gap:10px; align-items:center; margin-bottom:4px; }
.fb-note__tool { font-weight:600; color:var(--dms-navy,#16130f); }
.fb-note__rating { font-size:11px; font-weight:600; padding:2px 8px; border-radius:999px; }
.fb-note__rating.is-pos { background:rgba(47,125,50,0.14); color:#2f7d32; }
.fb-note__rating.is-neg { background:rgba(179,38,30,0.12); color:#b3261e; }
.fb-note__text { color:rgba(22,19,15,0.85); white-space:pre-wrap; }
.fb-empty { padding:18px 16px; color:rgba(22,19,15,0.5); font-size:13px; }
</style>
<script src="/assets/js/dashboard/feedback.js" defer></script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
+1
View File
@@ -53,6 +53,7 @@ $dashboardNav = [
];
if (dbnToolsIsOwner()) {
$dashboardNav['llm-engines'] = ['url' => '/dashboard/llm-engines.php', 'label' => 'LLM Engines', 'sub' => 'Model routing (owner)'];
$dashboardNav['feedback'] = ['url' => '/dashboard/feedback.php', 'label' => 'Tool Feedback', 'sub' => 'Ratings & notes (owner)'];
}
?>
<!doctype html>
+26
View File
@@ -0,0 +1,26 @@
-- 002_tool_feedback.sql
-- Target DB: dobetternorge_maindb (the tools' operational DB, via dbnmDb()).
-- Run manually after a mysqldump backup. Idempotent (IF NOT EXISTS).
--
-- Per-tool thumbs up/down + optional "what was missed or wrong" note, captured by
-- the in-result feedback widget (assets/js/tools.js) via api/feedback.php.
-- Metadata + voluntary comment only — NO pasted case content (process-and-forget).
--
-- tool : tool slug the result came from (ask|summarize|redact|timeline|...)
-- rating : positive | negative
-- engine : the engine string that produced the result (best-effort, client-supplied)
-- missed_or_wrong : short optional free-text note (what the tool missed/got wrong)
-- session_id : PHP session id (coarse de-dupe / abuse signal), not a user identity
CREATE TABLE IF NOT EXISTS tool_feedback (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
session_id VARCHAR(40) DEFAULT NULL,
tool VARCHAR(30) NOT NULL,
rating ENUM('positive','negative') NOT NULL,
missed_or_wrong TEXT DEFAULT NULL,
engine VARCHAR(60) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_tool (tool),
KEY idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;