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:
@@ -1578,3 +1578,101 @@ p {
|
||||
opacity: 0.55;
|
||||
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
@@ -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">👍</button>
|
||||
<button type="button" class="feedback-thumb" data-rating="negative" aria-label="Not helpful">👎</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' });
|
||||
|
||||
Reference in New Issue
Block a user