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
+75 -1
View File
@@ -361,6 +361,7 @@ let lastTimelineEvents = [];
let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}]
let lastTranscriptData = null;
let lastRedactedText = null;
let lastRunEngine = null;
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',
@@ -1272,6 +1273,8 @@ function currentRedactionRegion() {
}
function renderResults(data) {
lastRunEngine = data.trace_metadata?.deployment || null;
const sections = [];
sections.push(sectionHtml('What We Found', renderMainFinding(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(renderFeedbackWidget());
els.results.innerHTML = sections.join('');
setupFeedbackWidget(data.tool || state.activeTool);
}
function renderMainFinding(data) {
@@ -1381,6 +1386,72 @@ function renderTimeline(events) {
}).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) {
const header = ['Date', 'Date Type', 'Actor', 'Event', 'Source Excerpt', 'Confidence'];
const rows = events.map((ev) => [
@@ -1613,6 +1684,7 @@ function renderTranscriptResults(data) {
<button type="button" class="export-csv-btn" id="dlVtt">Download VTT</button>`
: '';
lastRunEngine = data.engine || null;
els.results.innerHTML = `
<section class="result-section">
<h3>Transcript</h3>
@@ -1623,7 +1695,9 @@ function renderTranscriptResults(data) {
<button type="button" class="export-csv-btn" id="dlTxt">Download TXT</button>
${dlSrtVtt}
</div>
</section>`;
</section>
${renderFeedbackWidget()}`;
setupFeedbackWidget('transcribe');
const traceMeta = [];
if (data.duration_sec) traceMeta.push({ label: `Duration: ${Math.round(data.duration_sec)}s`, detail: '', status: 'complete' });