From 519bdbb6e5e3ce4e0ad74302fd4e78b826a1e52a Mon Sep 17 00:00:00 2001 From: davegilligan Date: Sun, 21 Jun 2026 15:19:45 +0200 Subject: [PATCH] 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 --- api/dashboard/feedback.php | 98 +++++++++++++++++++++++++ api/feedback.php | 2 +- assets/js/dashboard/feedback.js | 88 ++++++++++++++++++++++ dashboard/feedback.php | 80 ++++++++++++++++++++ includes/layout_dashboard.php | 1 + migrations/maindb/002_tool_feedback.sql | 26 +++++++ 6 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 api/dashboard/feedback.php create mode 100644 assets/js/dashboard/feedback.js create mode 100644 dashboard/feedback.php create mode 100644 migrations/maindb/002_tool_feedback.sql diff --git a/api/dashboard/feedback.php b/api/dashboard/feedback.php new file mode 100644 index 0000000..d278766 --- /dev/null +++ b/api/dashboard/feedback.php @@ -0,0 +1,98 @@ +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'); +} diff --git a/api/feedback.php b/api/feedback.php index ea6959a..3e66d8f 100644 --- a/api/feedback.php +++ b/api/feedback.php @@ -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 (?, ?, ?, ?, ?)' diff --git a/assets/js/dashboard/feedback.js b/assets/js/dashboard/feedback.js new file mode 100644 index 0000000..e0fb7ef --- /dev/null +++ b/assets/js/dashboard/feedback.js @@ -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 '' + + '
' + t.total + '
Total
' + + '
' + t.positive + '
Positive
' + + '
' + t.negative + '
Negative
' + + '
' + pct(t.positive, t.total) + '%
Approval
'; + } + + function rowsHtml(rows, nameKey, withMeta) { + const head = '
' + (nameKey === 'tool' ? 'Tool' : 'Engine') + + '
Total
Up
Down
' + (withMeta ? 'Last' : 'Approval') + '
'; + if (!rows.length) return head + '
No feedback yet.
'; + const body = rows.map(r => { + const p = pct(r.positive, r.total); + const right = withMeta + ? '
' + safe(r.last_at || '—') + '
' + : '
' + + '
' + p + '%
'; + return '
' + + '
' + safe(r[nameKey]) + '
' + + '
' + r.total + '
' + + '
' + r.positive + '
' + + '
' + r.negative + '
' + + right + + '
'; + }).join(''); + return head + body; + } + + function recentHtml(rows) { + if (!rows.length) return '
No notes yet.
'; + return rows.map(n => { + const isPos = n.rating === 'positive'; + return '
' + + '
' + + '' + safe(n.tool) + '' + + '' + (isPos ? '\u{1F44D}' : '\u{1F44E}') + '' + + (n.engine ? '' + safe(n.engine) + '' : '') + + '' + safe(n.created_at || '') + '' + + '
' + + '
' + safe(n.missed_or_wrong) + '
' + + '
'; + }).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 = '
'; + 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 = '
Could not load: ' + safe(e.message) + '
'; + $byTool.innerHTML = ''; + $byEngine.innerHTML = ''; + $recent.innerHTML = ''; + } + } + + if ($refresh) $refresh.addEventListener('click', load); + load(); +})(); diff --git a/dashboard/feedback.php b/dashboard/feedback.php new file mode 100644 index 0000000..52749f2 --- /dev/null +++ b/dashboard/feedback.php @@ -0,0 +1,80 @@ +Owner access required' + . '

' + . 'Owner access required. Back to dashboard

'; + 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'; +?> +
+
+

Overview

+
+ +
+
+
+
+ +
+

By tool

+
+
+ +
+

By engine

+
+
+ +
+

Recent notes

+

+ Latest voluntary comments (newest first). These are user-written notes, not pasted case text. +

+
+
+ + + + + + diff --git a/includes/layout_dashboard.php b/includes/layout_dashboard.php index ed75b42..0e73e66 100644 --- a/includes/layout_dashboard.php +++ b/includes/layout_dashboard.php @@ -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)']; } ?> diff --git a/migrations/maindb/002_tool_feedback.sql b/migrations/maindb/002_tool_feedback.sql new file mode 100644 index 0000000..cc40d31 --- /dev/null +++ b/migrations/maindb/002_tool_feedback.sql @@ -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;