diff --git a/api/barnevernet.php b/api/barnevernet.php index efe93f3..4099ffc 100644 --- a/api/barnevernet.php +++ b/api/barnevernet.php @@ -3,7 +3,6 @@ declare(strict_types=1); require_once __DIR__ . '/../includes/bootstrap.php'; require_once __DIR__ . '/../includes/BvjAnalyzerAgent.php'; -require_once __DIR__ . '/../includes/CaseResults.php'; require_once __DIR__ . '/../includes/ToolModels.php'; dbnToolsRequireMethod('POST'); @@ -151,19 +150,6 @@ try { 'bvj_doc_type' => $result['doc_meta']['doc_type'] ?? null, ]); - if ($ftUid > 0) { - $ownerId = CaseStore::caseResolveClientId($ftUid); - $resultId = CaseResults::save($ftUid, $ownerId, 'barnevernet', $input, $result, [ - 'used_case_context' => $useMyCase ? 1 : 0, - 'case_doc_ids' => dbnToolsLastCaseDocIds(), - 'model' => $result['trace_metadata']['deployment'] ?? $engine, - 'latency_ms' => $result['latency_ms'], - 'credits_charged' => FreeTier::cost('barnevernet'), - ]); - if ($resultId > 0) { - $result['result_id'] = $resultId; - } - } $emit('final', ['result' => $result]); diff --git a/api/case/save-result.php b/api/case/save-result.php new file mode 100644 index 0000000..d291968 --- /dev/null +++ b/api/case/save-result.php @@ -0,0 +1,54 @@ + true, 'result_id' => $resultId]); diff --git a/api/deep-research.php b/api/deep-research.php index 086ac70..135bd82 100644 --- a/api/deep-research.php +++ b/api/deep-research.php @@ -3,7 +3,6 @@ declare(strict_types=1); require_once __DIR__ . '/../includes/bootstrap.php'; require_once __DIR__ . '/../includes/DeepResearchAgent.php'; -require_once __DIR__ . '/../includes/CaseResults.php'; require_once __DIR__ . '/../includes/ToolModels.php'; dbnToolsRequireMethod('POST'); @@ -156,20 +155,6 @@ try { 'advocate_role' => $advocateRole !== '' ? $advocateRole : null, ]); - if ($ftUid > 0) { - $toolSlug = $advocateRole !== '' ? 'advocate' : 'deep-research'; - $ownerId = CaseStore::caseResolveClientId($ftUid); - $resultId = CaseResults::save($ftUid, $ownerId, $toolSlug, $input, $result, [ - 'used_case_context' => $useMyCase ? 1 : 0, - 'case_doc_ids' => dbnToolsLastCaseDocIds(), - 'model' => $result['trace_metadata']['deployment'] ?? $engine, - 'latency_ms' => $result['latency_ms'], - 'credits_charged' => FreeTier::cost($toolSlug), - ]); - if ($resultId > 0) { - $result['result_id'] = $resultId; - } - } $emit('final', ['result' => $result]); diff --git a/api/discrepancy.php b/api/discrepancy.php index 46f1045..135e517 100644 --- a/api/discrepancy.php +++ b/api/discrepancy.php @@ -3,7 +3,6 @@ declare(strict_types=1); require_once __DIR__ . '/../includes/bootstrap.php'; require_once __DIR__ . '/../includes/DiscrepancyAgent.php'; -require_once __DIR__ . '/../includes/CaseResults.php'; require_once __DIR__ . '/../includes/ToolModels.php'; dbnToolsRequireMethod('POST'); @@ -147,19 +146,6 @@ try { 'deployment' => $result['trace_metadata']['deployment'] ?? null, ]); - if ($ftUid > 0) { - $ownerId = CaseStore::caseResolveClientId($ftUid); - $resultId = CaseResults::save($ftUid, $ownerId, 'discrepancy', $input, $result, [ - 'used_case_context' => $useMyCase ? 1 : 0, - 'case_doc_ids' => dbnToolsLastCaseDocIds(), - 'model' => $result['trace_metadata']['deployment'] ?? $engine, - 'latency_ms' => $result['latency_ms'], - 'credits_charged' => FreeTier::cost('discrepancy'), - ]); - if ($resultId > 0) { - $result['result_id'] = $resultId; - } - } $emit('final', ['result' => $result]); diff --git a/api/korrespond.php b/api/korrespond.php index b0982e5..c85df0b 100644 --- a/api/korrespond.php +++ b/api/korrespond.php @@ -3,7 +3,6 @@ declare(strict_types=1); require_once __DIR__ . '/../includes/bootstrap.php'; require_once __DIR__ . '/../includes/KorrespondAgent.php'; -require_once __DIR__ . '/../includes/CaseResults.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); @@ -185,20 +184,6 @@ try { 'deployment' => 'gpt-4o', ]); - // Premium: persist the run for paid (Plus/Pro) users so it shows up in Min Sak → Saved analyses. - if ($ftUid > 0) { - $ownerId = CaseStore::caseResolveClientId($ftUid); - $resultId = CaseResults::save($ftUid, $ownerId, 'korrespond', $input, $result, [ - 'used_case_context' => !empty($intake['use_my_case']) ? 1 : 0, - 'case_doc_ids' => dbnToolsLastCaseDocIds(), - 'model' => 'gpt-4o', - 'latency_ms' => $result['latency_ms'], - 'credits_charged' => FreeTier::cost('korrespond'), - ]); - if ($resultId > 0) { - $result['result_id'] = $resultId; - } - } $emit('final', ['result' => $result]); diff --git a/api/timeline.php b/api/timeline.php index 7e92137..041da65 100644 --- a/api/timeline.php +++ b/api/timeline.php @@ -2,7 +2,6 @@ declare(strict_types=1); require_once __DIR__ . '/../includes/LegalTools.php'; -require_once __DIR__ . '/../includes/CaseResults.php'; require_once __DIR__ . '/../includes/ToolModels.php'; dbnToolsRequireMethod('POST'); @@ -47,19 +46,5 @@ dbnToolsWithTelemetry('timeline', $language, function () use ($input, $language, $result = (new DbnLegalToolsService())->timeline($text, $language, $engine, $focus, $confidenceFilter, $includeRelative, $includeBackground, $userNotes); - // Persist for paid users (silently no-op for free) - if ($ftUid > 0) { - $ownerId = CaseStore::caseResolveClientId($ftUid); - $resultId = CaseResults::save($ftUid, $ownerId, 'timeline', $input, $result, [ - 'used_case_context' => $useMyCase ? 1 : 0, - 'case_doc_ids' => dbnToolsLastCaseDocIds(), - 'model' => $engine, - 'latency_ms' => (int)($result['latency_ms'] ?? 0), - 'credits_charged' => FreeTier::cost('timeline'), - ]); - if ($resultId > 0) { - $result['result_id'] = $resultId; - } - } return $result; }); diff --git a/assets/css/tools.css b/assets/css/tools.css index 61e168c..4e6b7c4 100644 --- a/assets/css/tools.css +++ b/assets/css/tools.css @@ -9173,3 +9173,97 @@ body.lt-landing { .account-survey-cta span { font-size: 0.8rem; opacity: 0.85; } .account-survey-cta:hover { background: #fde68a; } + +/* ── Save result widget ────────────────────────────────────────────────────── */ +.save-result-widget { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.75rem; + margin-bottom: 1rem; + background: #f0f4ff; + border: 1px solid #c7d3f5; + border-radius: 8px; + font-size: 0.85rem; +} + +.save-result-btn { + background: none; + border: 1px solid var(--dbn-blue, #00205b); + color: var(--dbn-blue, #00205b); + border-radius: 6px; + padding: 0.3rem 0.75rem; + font-size: 0.82rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} +.save-result-btn:hover { + background: var(--dbn-blue, #00205b); + color: #fff; +} + +.save-result-prompt { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; + flex-wrap: wrap; +} + +.save-result-input { + flex: 1 1 240px; + min-width: 0; + border: 1px solid var(--line, #d8dde7); + border-radius: 6px; + padding: 0.3rem 0.6rem; + font-size: 0.82rem; + color: var(--dbn-ink, #16130f); + background: #fff; +} +.save-result-input:focus { + outline: 2px solid var(--dbn-blue, #00205b); + outline-offset: 1px; +} + +.save-result-confirm { + background: var(--dbn-blue, #00205b); + color: #fff; + border: none; + border-radius: 6px; + padding: 0.3rem 0.75rem; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; +} +.save-result-confirm:disabled { opacity: 0.55; cursor: default; } + +.save-result-cancel { + background: none; + border: 1px solid var(--line, #d8dde7); + border-radius: 6px; + padding: 0.3rem 0.6rem; + font-size: 0.82rem; + cursor: pointer; + color: var(--muted, #667085); +} +.save-result-cancel:hover { border-color: #aaa; } + +.save-result-done { + width: 100%; +} +.save-result-link { + color: var(--dbn-teal, #0f766e); + font-weight: 600; + text-decoration: none; + font-size: 0.85rem; +} +.save-result-link:hover { text-decoration: underline; } + +.save-result-error { + color: var(--coral, #c2410c); + margin: 0; + font-size: 0.82rem; + width: 100%; +} diff --git a/assets/js/barnevernet.js b/assets/js/barnevernet.js index db51b67..d92a3d3 100644 --- a/assets/js/barnevernet.js +++ b/assets/js/barnevernet.js @@ -570,6 +570,14 @@ els.runButton.disabled = false; renderTrace(finalResult.trace || []); renderFinalResults(finalResult); + if (typeof window.dbnShowSaveResultButton === 'function') { + window.dbnShowSaveResultButton( + finalResult.tool || 'barnevernet', + payload, + finalResult, + { model: (finalResult.trace_metadata || {}).deployment || null, latency_ms: finalResult.latency_ms || 0 } + ); + } } // ── Progressive rendering (renders as stream events arrive) ──────────────── diff --git a/assets/js/summarize.js b/assets/js/summarize.js index f5e71ad..9107543 100644 --- a/assets/js/summarize.js +++ b/assets/js/summarize.js @@ -25,6 +25,7 @@ // ── State ───────────────────────────────────────────────────────────────── var _extractedFiles = []; // [{ name, text, chars }] var _currentLang = 'en'; + var _lastPayload = null; // ── Lang switcher ───────────────────────────────────────────────────────── document.querySelectorAll('.sum-lang-btn').forEach(function (btn) { @@ -177,6 +178,7 @@ slices: slices, }; if (docIds.length) payload.doc_ids = docIds; + _lastPayload = payload; setBusy(true); setStatus('Running…'); @@ -218,6 +220,15 @@ setStatus(data.detail || ''); } else if (data.event === 'final') { renderFinal(data); + if (typeof window.dbnShowSaveResultButton === 'function') { + window.dbnShowSaveResultButton( + 'summarize', + _lastPayload || {}, + data, + { model: data.engine || null, latency_ms: data.latency_ms || 0 }, + resultsEl + ); + } if (data.balance != null) { var credEl = document.getElementById('creditsRemaining'); if (credEl) credEl.textContent = data.balance; diff --git a/assets/js/tools.js b/assets/js/tools.js index 1a38beb..3ed5b40 100644 --- a/assets/js/tools.js +++ b/assets/js/tools.js @@ -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 = ` +
' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ': ' + . htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . '
'; +} ?> @@ -159,11 +176,136 @@ foreach (['draft', 'response', 'answer', 'text', 'summary', 'markdown'] as $k) {= htmlspecialchars((string)$output['what_we_found']) ?>
+ + = crListBlock('Key Facts', (array)($output['key_facts'] ?? [])) ?> + = crListBlock('Dates', (array)($output['dates'] ?? [])) ?> + = crListBlock('Parties', (array)($output['parties'] ?? [])) ?> + = crListBlock('Legal References', (array)($output['legal_references_detected'] ?? [])) ?> + = crListBlock('What Remains Uncertain', (array)($output['what_remains_uncertain'] ?? [])) ?> + += htmlspecialchars((string)$output['next_practical_step']) ?>
+ + + + += htmlspecialchars((string)$output['redacted_text']) ?>+ + +
| = htmlspecialchars((string)$cat) ?> | += (int)$count ?> | +
= htmlspecialchars(json_encode($output['redaction_map'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '{}') ?>
+ Speakers: = implode(', ', array_map( + fn($id, $role) => htmlspecialchars("$id: $role", ENT_QUOTES, 'UTF-8'), + array_keys($speakerRoles), array_values($speakerRoles) + )) ?>
+ + +| = number_format((float)($seg['start'] ?? 0), 1) ?>s | + += htmlspecialchars((string)$seg['speaker']) ?> | + += htmlspecialchars((string)($seg['text'] ?? '')) ?> | +
= htmlspecialchars((string)$output['what_we_found']) ?>
+ + +| Date | +Actor | +Event | +Confidence | +
|---|---|---|---|
| = htmlspecialchars((string)($ev['date'] ?? '—')) ?> | += htmlspecialchars((string)($ev['actor'] ?? '')) ?> | += htmlspecialchars((string)($ev['event'] ?? '')) ?> | ++ = htmlspecialchars((string)($ev['confidence'] ?? '')) ?> + | +
Dette verktøyet returnerer strukturert output — se rådata under.
+ +Dette verktøyet returnerer strukturert output — se rådata under.
+ += htmlspecialchars(json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}') ?>
@@ -235,6 +377,10 @@ foreach (['draft', 'response', 'answer', 'text', 'summary', 'markdown'] as $k) {
'deep-research': '/deep-research.php',
'discrepancy': '/discrepancy.php',
'timeline': '/timeline.php',
+ 'summarize': '/summarize.php',
+ 'ask': '/ask.php',
+ 'redact': '/redact.php',
+ 'transcribe': '/transcribe.php',
}[tool] || '/dashboard.php';
window.location.href = path + '?rerun=' + = (int)$result['id'] ?>;
});
diff --git a/includes/CaseResults.php b/includes/CaseResults.php
index da22501..f4c2899 100644
--- a/includes/CaseResults.php
+++ b/includes/CaseResults.php
@@ -29,6 +29,10 @@ final class CaseResults
'deep-research',
'discrepancy',
'timeline',
+ 'summarize',
+ 'ask',
+ 'redact',
+ 'transcribe',
];
/** True when the user is on a tier that gets saved results (Plus, Pro, or active Plus trial). */
@@ -71,8 +75,10 @@ final class CaseResults
return 0;
}
- // Title default: first 80 chars of the most descriptive input field.
- $title = self::deriveTitle($tool, $input);
+ // Caller may supply an explicit title (e.g. from a manual "Save result" prompt).
+ $title = isset($meta['title']) && trim((string)$meta['title']) !== ''
+ ? mb_substr(trim((string)$meta['title']), 0, 200, 'UTF-8')
+ : self::deriveTitle($tool, $input);
$caseDocIds = $meta['case_doc_ids'] ?? [];
if (!is_array($caseDocIds)) {
@@ -228,6 +234,10 @@ final class CaseResults
'deep-research' => 'Dyp analyse',
'discrepancy' => 'Motstrid',
'timeline' => 'Tidslinje',
+ 'summarize' => 'Sammendrag',
+ 'ask' => 'Spørsmål & svar',
+ 'redact' => 'Anonymisering',
+ 'transcribe' => 'Transkripsjon',
][$tool] ?? ucfirst($tool);
}
@@ -241,6 +251,10 @@ final class CaseResults
'deep-research' => '🔬',
'discrepancy' => '🔍',
'timeline' => '📅',
+ 'summarize' => '📝',
+ 'ask' => '💬',
+ 'redact' => '🖊️',
+ 'transcribe' => '🎙️',
][$tool] ?? '📄';
}
@@ -254,6 +268,10 @@ final class CaseResults
'deep-research' => [$input['question'] ?? null, $input['query'] ?? null, $input['topic'] ?? null],
'discrepancy' => [$input['focus'] ?? null, $input['context'] ?? null],
'timeline' => [$input['context'] ?? null, $input['text'] ?? null],
+ 'summarize' => [$input['text'] ?? null],
+ 'ask' => [$input['question'] ?? null],
+ 'redact' => [$input['text'] ?? null],
+ 'transcribe' => [$input['filename'] ?? null],
default => [$input['title'] ?? null, $input['query'] ?? null, $input['text'] ?? null],
};
foreach ($candidates as $c) {