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:
@@ -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
@@ -21,7 +21,7 @@ if ($tool === '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = dbnToolsDb();
|
$db = dbnmDb();
|
||||||
$stmt = $db->prepare(
|
$stmt = $db->prepare(
|
||||||
'INSERT INTO tool_feedback (session_id, tool, rating, missed_or_wrong, engine)
|
'INSERT INTO tool_feedback (session_id, tool, rating, missed_or_wrong, engine)
|
||||||
VALUES (?, ?, ?, ?, ?)'
|
VALUES (?, ?, ?, ?, ?)'
|
||||||
|
|||||||
@@ -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 => ({ '&':'&','<':'<','>':'>','"':'"' }[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();
|
||||||
|
})();
|
||||||
@@ -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'; ?>
|
||||||
@@ -53,6 +53,7 @@ $dashboardNav = [
|
|||||||
];
|
];
|
||||||
if (dbnToolsIsOwner()) {
|
if (dbnToolsIsOwner()) {
|
||||||
$dashboardNav['llm-engines'] = ['url' => '/dashboard/llm-engines.php', 'label' => 'LLM Engines', 'sub' => 'Model routing (owner)'];
|
$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>
|
<!doctype html>
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user