feat(feedback): thumbs up/down + missed-items widget across all tools

New api/feedback.php stores rating + correction text to tool_feedback
table in bnl_admin. renderFeedbackWidget() appended to all tool results
(timeline, redact, transcribe, ask, summarize, search). Thumbs reveal
a textarea for missed/wrong items on click; submit POSTs asynchronously.
Engine from last run is stored alongside the rating.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 01:13:42 +02:00
parent cdd0fb970b
commit d429e785e8
3 changed files with 215 additions and 1 deletions
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$input = dbnToolsJsonInput(8000);
$tool = substr(preg_replace('/[^a-z_]/', '', strtolower((string)($input['tool'] ?? ''))), 0, 30);
$rating = (string)($input['rating'] ?? '');
$missed = substr(trim((string)($input['missed_or_wrong'] ?? '')), 0, 2000);
$engine = substr(preg_replace('/[^a-zA-Z0-9_.() \-]/', '', (string)($input['engine'] ?? '')), 0, 60);
if (!in_array($rating, ['positive', 'negative'], true)) {
dbnToolsAbort('Invalid rating value.', 422, 'invalid_rating');
}
if ($tool === '') {
dbnToolsAbort('Tool name is required.', 422, 'missing_tool');
}
try {
$db = dbnToolsDb();
$stmt = $db->prepare(
'INSERT INTO tool_feedback (session_id, tool, rating, missed_or_wrong, engine)
VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([
substr(session_id(), 0, 40) ?: null,
$tool,
$rating,
$missed !== '' ? $missed : null,
$engine !== '' ? $engine : null,
]);
} catch (Throwable $e) {
error_log('tool_feedback insert failed: ' . $e->getMessage());
dbnToolsAbort('Could not save feedback.', 500, 'db_error');
}
header('Content-Type: application/json');
echo json_encode(['ok' => true]);
+98
View File
@@ -1578,3 +1578,101 @@ p {
opacity: 0.55; opacity: 0.55;
cursor: progress; cursor: progress;
} }
/* ── Feedback widget ──────────────────────────────────────────── */
.feedback-widget {
margin-top: 1.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--line);
}
.feedback-label {
font-size: 0.85rem;
color: var(--muted);
margin: 0 0 0.6rem;
}
.feedback-btns {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.feedback-thumb {
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.35rem 0.75rem;
font-size: 1.2rem;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
line-height: 1;
}
.feedback-thumb:hover { background: var(--soft-teal); border-color: var(--teal); }
.feedback-thumb.is-active { background: var(--soft-teal); border-color: var(--teal); }
.feedback-detail {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.feedback-detail-label {
font-size: 0.83rem;
color: var(--ink);
font-weight: 500;
}
.feedback-optional {
color: var(--muted);
font-weight: 400;
}
.feedback-textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
resize: vertical;
font-family: inherit;
color: var(--ink);
background: var(--panel);
}
.feedback-textarea:focus { outline: 3px solid var(--teal); outline-offset: 1px; }
.feedback-detail-footer {
display: flex;
align-items: center;
gap: 1rem;
}
.feedback-submit-btn {
background: var(--teal);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.4rem 1rem;
font-size: 0.83rem;
font-weight: 600;
cursor: pointer;
transition: background 0.12s;
}
.feedback-submit-btn:hover { background: var(--teal-dark); }
.feedback-submit-btn:disabled { opacity: 0.55; cursor: progress; }
.feedback-status {
font-size: 0.8rem;
color: var(--muted);
margin: 0;
}
.feedback-thanks {
font-size: 0.85rem;
color: var(--teal-dark);
font-weight: 500;
margin: 0;
}
+75 -1
View File
@@ -361,6 +361,7 @@ let lastTimelineEvents = [];
let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}] let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}]
let lastTranscriptData = null; let lastTranscriptData = null;
let lastRedactedText = null; let lastRedactedText = null;
let lastRunEngine = null;
const VOCAB_PRESETS = { const VOCAB_PRESETS = {
barnerett: 'Barnevernet, Fylkesnemnda, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, sakkyndig, advokat, prosessfullmektig, dommer, vitne, tolk, bistandsadvokat, fosterforeldre, fosterhjem, akuttvedtak, statsforvalter, Bufetat, saksbehandler, rettslig medhold, begjæring, samtykke, tilsynsfører', barnerett: 'Barnevernet, Fylkesnemnda, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, sakkyndig, advokat, prosessfullmektig, dommer, vitne, tolk, bistandsadvokat, fosterforeldre, fosterhjem, akuttvedtak, statsforvalter, Bufetat, saksbehandler, rettslig medhold, begjæring, samtykke, tilsynsfører',
@@ -1272,6 +1273,8 @@ function currentRedactionRegion() {
} }
function renderResults(data) { function renderResults(data) {
lastRunEngine = data.trace_metadata?.deployment || null;
const sections = []; const sections = [];
sections.push(sectionHtml('What We Found', renderMainFinding(data))); sections.push(sectionHtml('What We Found', renderMainFinding(data)));
sections.push(sectionHtml('Evidence Trail', renderEvidence(data))); sections.push(sectionHtml('Evidence Trail', renderEvidence(data)));
@@ -1282,7 +1285,9 @@ function renderResults(data) {
sections.push(`<p class="result-disclaimer">${escapeHtml(data.disclaimer)}</p>`); sections.push(`<p class="result-disclaimer">${escapeHtml(data.disclaimer)}</p>`);
} }
sections.push(renderFeedbackWidget());
els.results.innerHTML = sections.join(''); els.results.innerHTML = sections.join('');
setupFeedbackWidget(data.tool || state.activeTool);
} }
function renderMainFinding(data) { function renderMainFinding(data) {
@@ -1381,6 +1386,72 @@ function renderTimeline(events) {
}).join('')}</ol>`; }).join('')}</ol>`;
} }
function renderFeedbackWidget() {
return `
<div class="feedback-widget" id="feedbackWidget">
<p class="feedback-label">Was this result useful?</p>
<div class="feedback-btns" id="feedbackBtns">
<button type="button" class="feedback-thumb" data-rating="positive" aria-label="Helpful">&#128077;</button>
<button type="button" class="feedback-thumb" data-rating="negative" aria-label="Not helpful">&#128078;</button>
</div>
<div class="feedback-detail is-hidden" id="feedbackDetail">
<label class="feedback-detail-label" for="feedbackMissed">What was missed or wrong? <span class="feedback-optional">(optional)</span></label>
<textarea id="feedbackMissed" class="feedback-textarea" rows="3" placeholder="e.g. missed dates: 18.09.25, 6.1. — or names that should have been redacted"></textarea>
<div class="feedback-detail-footer">
<button type="button" id="feedbackSubmit" class="feedback-submit-btn">Submit feedback</button>
<p id="feedbackStatus" class="feedback-status" role="status" aria-live="polite"></p>
</div>
</div>
</div>
`;
}
function setupFeedbackWidget(tool) {
const widget = document.getElementById('feedbackWidget');
const detail = document.getElementById('feedbackDetail');
const submit = document.getElementById('feedbackSubmit');
const status = document.getElementById('feedbackStatus');
const missed = document.getElementById('feedbackMissed');
if (!widget) return;
let chosenRating = null;
widget.querySelectorAll('.feedback-thumb').forEach((btn) => {
btn.addEventListener('click', () => {
chosenRating = btn.dataset.rating;
widget.querySelectorAll('.feedback-thumb').forEach((b) => b.classList.remove('is-active'));
btn.classList.add('is-active');
detail.classList.remove('is-hidden');
missed.focus();
});
});
submit.addEventListener('click', async () => {
if (!chosenRating) return;
submit.disabled = true;
status.textContent = 'Saving…';
try {
const resp = await fetch('api/feedback.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tool,
rating: chosenRating,
missed_or_wrong: missed.value.trim(),
engine: lastRunEngine || '',
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Save failed');
widget.innerHTML = '<p class="feedback-thanks">Thanks for your feedback!</p>';
} catch (err) {
status.textContent = err.message;
submit.disabled = false;
}
});
}
function exportTimelineCSV(events) { function exportTimelineCSV(events) {
const header = ['Date', 'Date Type', 'Actor', 'Event', 'Source Excerpt', 'Confidence']; const header = ['Date', 'Date Type', 'Actor', 'Event', 'Source Excerpt', 'Confidence'];
const rows = events.map((ev) => [ const rows = events.map((ev) => [
@@ -1613,6 +1684,7 @@ function renderTranscriptResults(data) {
<button type="button" class="export-csv-btn" id="dlVtt">Download VTT</button>` <button type="button" class="export-csv-btn" id="dlVtt">Download VTT</button>`
: ''; : '';
lastRunEngine = data.engine || null;
els.results.innerHTML = ` els.results.innerHTML = `
<section class="result-section"> <section class="result-section">
<h3>Transcript</h3> <h3>Transcript</h3>
@@ -1623,7 +1695,9 @@ function renderTranscriptResults(data) {
<button type="button" class="export-csv-btn" id="dlTxt">Download TXT</button> <button type="button" class="export-csv-btn" id="dlTxt">Download TXT</button>
${dlSrtVtt} ${dlSrtVtt}
</div> </div>
</section>`; </section>
${renderFeedbackWidget()}`;
setupFeedbackWidget('transcribe');
const traceMeta = []; const traceMeta = [];
if (data.duration_sec) traceMeta.push({ label: `Duration: ${Math.round(data.duration_sec)}s`, detail: '', status: 'complete' }); if (data.duration_sec) traceMeta.push({ label: `Duration: ${Math.round(data.duration_sec)}s`, detail: '', status: 'complete' });