Add manual 'Save result' to all tools — replaces auto-save

All tool results can now be saved to My Case manually. Users click
'Save result', type a description, and confirm. This replaces the
previous silent auto-save on barnevernet/timeline/etc., giving users
control over what stays and what it's called (supports multiple runs
of the same tool with different titles).

- CaseResults: extend ELIGIBLE_TOOLS to include summarize, ask, redact,
  transcribe; add toolLabel/toolIcon entries; support explicit title
  via meta['title'] in save()
- api/case/save-result.php: new client-initiated save endpoint;
  accepts tool + title + input_payload + output_payload + meta
- Remove CaseResults::save() auto-save from barnevernet, deep-research,
  discrepancy, korrespond, timeline API endpoints
- tools.js: add showSaveResultButton() (exposed as window.dbnShowSaveResultButton);
  wire for ask, redact, timeline, transcribe (both file-upload and
  stored-audio paths)
- barnevernet.js: wire save button after final result render
- summarize.js: wire save button after renderFinal(); passes sumResults
  container so widget appears in the correct #sumResults div
- case-result.php: rich tool-specific rendering for summarize, ask,
  redact, transcribe, timeline; update re-run link map to include all
  new tools
- tools.css: styles for .save-result-widget and its states (idle,
  prompt, done, error)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 01:27:26 +02:00
parent 0fcfed1a86
commit 2013648ee0
12 changed files with 473 additions and 80 deletions
+135
View File
@@ -401,6 +401,7 @@ let lastRedactedText = null;
let lastOriginalText = '';
let lastRedactPayload = null;
let lastRunEngine = null;
let lastToolPayload = 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',
@@ -1110,6 +1111,8 @@ async function runTool(event) {
payload.use_my_case = (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false;
}
lastToolPayload = { ...payload };
setBusy(true);
renderTrace([
{ label: 'Query interpretation', detail: 'Preparing request.', status: 'running' },
@@ -1123,6 +1126,12 @@ async function runTool(event) {
renderResults(data);
renderTrace(data.trace || []);
els.status.textContent = `Done in ${data.latency_ms || 0} ms.`;
if (['ask', 'redact', 'timeline'].includes(state.activeTool)) {
showSaveResultButton(state.activeTool, lastToolPayload, data, {
model: data.trace_metadata?.deployment || null,
latency_ms: data.latency_ms || 0,
});
}
} catch (error) {
els.status.textContent = error.message;
renderTrace([
@@ -1846,6 +1855,10 @@ async function runTranscribe() {
if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Transcription failed.');
if (typeof renderTranscribeResult === 'function') renderTranscribeResult(data);
else renderResults(data);
showSaveResultButton('transcribe', { audio_doc_id: storedAudioDocId }, data, {
model: data.model || null,
latency_ms: data.latency_ms || 0,
});
els.status.textContent = 'Done.';
} catch (err) {
els.status.textContent = err.message;
@@ -1976,6 +1989,10 @@ async function runTranscribe() {
lastTranscriptData = merged;
renderTranscriptResults(merged);
showSaveResultButton('transcribe', lastToolPayload || {}, merged, {
model: merged.model || null,
latency_ms: merged.latency_ms || 0,
});
const totalSec = Math.round(cumulativeOffset);
const totalMin = Math.floor(totalSec / 60);
@@ -2417,6 +2434,124 @@ function escapeHtml(value) {
// ── Free-tier credit badge ────────────────────────────────────────────────
// ── Save result widget ─────────────────────────────────────────────────────
const _SAVEABLE_TOOL_LABELS = {
ask: 'Spørsmål & svar',
redact: 'Anonymisering',
timeline: 'Tidslinje',
transcribe: 'Transkripsjon',
summarize: 'Sammendrag',
barnevernet: 'BVJ-analyse',
'deep-research': 'Dyp analyse',
advocate: 'Advokatutkast',
discrepancy: 'Motstrid',
korrespond: 'Korrespondanse',
};
function _isSaveEligibleUser() {
const tier = window.DBN_USER_TIER;
return typeof tier === 'string' && !['free', 'caveau'].includes(tier);
}
function showSaveResultButton(tool, inputPayload, outputPayload, meta, containerEl) {
if (!_isSaveEligibleUser()) return;
if (!Object.prototype.hasOwnProperty.call(_SAVEABLE_TOOL_LABELS, tool)) return;
document.getElementById('saveResultWidget')?.remove();
const label = _SAVEABLE_TOOL_LABELS[tool] || tool;
const now = new Date();
const dateStr = now.toLocaleDateString('no-NO', { day: 'numeric', month: 'short', year: 'numeric' });
const timeStr = now.toLocaleTimeString('no-NO', { hour: '2-digit', minute: '2-digit' });
const dfTitle = `${label}${dateStr} ${timeStr}`;
const widget = document.createElement('div');
widget.id = 'saveResultWidget';
widget.className = 'save-result-widget';
widget.innerHTML = `
<div class="save-result-idle">
<button type="button" class="save-result-btn" id="saveResultTrigger">💾 Save result</button>
</div>
<div class="save-result-prompt is-hidden">
<input type="text" class="save-result-input" id="saveResultTitle" maxlength="200" aria-label="Result title">
<button type="button" class="save-result-confirm" id="saveResultConfirm">Save</button>
<button type="button" class="save-result-cancel" id="saveResultCancel">Cancel</button>
</div>
<div class="save-result-done is-hidden">
<a class="save-result-link" href="min-sak.php">✓ Saved — View in My Case ↗</a>
</div>
<p class="save-result-error is-hidden"></p>`;
const resultsEl = containerEl || document.getElementById('results');
if (!resultsEl) return;
resultsEl.insertBefore(widget, resultsEl.firstChild);
// Set value after inserting so escaping is handled by the DOM
widget.querySelector('#saveResultTitle').value = dfTitle;
const triggerBtn = widget.querySelector('#saveResultTrigger');
const promptDiv = widget.querySelector('.save-result-prompt');
const idleDiv = widget.querySelector('.save-result-idle');
const doneDiv = widget.querySelector('.save-result-done');
const errorP = widget.querySelector('.save-result-error');
const titleInput = widget.querySelector('#saveResultTitle');
const confirmBtn = widget.querySelector('#saveResultConfirm');
const cancelBtn = widget.querySelector('#saveResultCancel');
triggerBtn.addEventListener('click', () => {
idleDiv.classList.add('is-hidden');
promptDiv.classList.remove('is-hidden');
titleInput.focus();
titleInput.select();
});
cancelBtn.addEventListener('click', () => {
promptDiv.classList.add('is-hidden');
idleDiv.classList.remove('is-hidden');
});
titleInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') confirmBtn.click();
if (e.key === 'Escape') cancelBtn.click();
});
confirmBtn.addEventListener('click', async () => {
const title = titleInput.value.trim();
if (!title) { titleInput.focus(); return; }
confirmBtn.disabled = true;
confirmBtn.textContent = 'Saving…';
errorP.classList.add('is-hidden');
try {
const resp = await fetch('api/case/save-result.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tool,
title,
input_payload: inputPayload || {},
output_payload: outputPayload || {},
meta: meta || {},
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Save failed.');
promptDiv.classList.add('is-hidden');
doneDiv.classList.remove('is-hidden');
} catch (err) {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Save';
errorP.textContent = err.message;
errorP.classList.remove('is-hidden');
}
});
}
window.dbnShowSaveResultButton = showSaveResultButton;
let _freeTierBalance = (typeof window.DBN_FREE_TIER_BALANCE === 'number') ? window.DBN_FREE_TIER_BALANCE : -1;
function dbnUpdateCredits(balance) {