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
-14
View File
@@ -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]);
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../includes/bootstrap.php';
require_once __DIR__ . '/../../includes/CaseResults.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
if (!dbnToolsIsFreeTier()) {
dbnToolsError('Saved analyses are SSO-only.', 403, 'sso_only');
}
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
if ($userId <= 0) {
dbnToolsError('Missing user id.', 401, 'no_user');
}
if (!CaseResults::isEnabled($userId)) {
dbnToolsError('Saving results requires a Plus or Pro plan.', 403, 'not_paid');
}
$input = dbnToolsJsonInput(800000);
$tool = (string)($input['tool'] ?? '');
$title = mb_substr(trim((string)($input['title'] ?? '')), 0, 200, 'UTF-8');
$inputPayload = $input['input_payload'] ?? [];
$outputPayload = $input['output_payload'] ?? [];
$meta = is_array($input['meta'] ?? null) ? $input['meta'] : [];
if (!in_array($tool, CaseResults::ELIGIBLE_TOOLS, true)) {
dbnToolsError('Unknown tool: ' . $tool, 400, 'bad_tool');
}
if (!is_array($inputPayload) || !is_array($outputPayload)) {
dbnToolsError('input_payload and output_payload must be objects.', 400, 'bad_payload');
}
if ($title !== '') {
$meta['title'] = $title;
}
// Normalise case_doc_ids from the input payload if not provided in meta.
if (empty($meta['case_doc_ids'])) {
$ids = $inputPayload['doc_ids'] ?? [];
$meta['case_doc_ids'] = is_array($ids) ? $ids : [];
}
$ownerId = CaseStore::caseResolveClientId($userId);
$resultId = CaseResults::save($userId, $ownerId, $tool, $inputPayload, $outputPayload, $meta);
if ($resultId <= 0) {
dbnToolsError('Could not save result. Check that your plan supports saved analyses.', 500, 'save_failed');
}
dbnToolsRespond(['ok' => true, 'result_id' => $resultId]);
-15
View File
@@ -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]);
-14
View File
@@ -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]);
-15
View File
@@ -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]);
-15
View File
@@ -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;
});
+94
View File
@@ -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%;
}
+8
View File
@@ -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) ────────────────
+11
View File
@@ -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;
+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) {
+148 -2
View File
@@ -65,14 +65,31 @@ if (!empty($caseDocIds)) {
}
}
// Best-effort extraction of the primary human-readable output
// Best-effort extraction of the primary human-readable output (fallback for unknown tools)
$primaryOutput = '';
foreach (['draft', 'response', 'answer', 'text', 'summary', 'markdown'] as $k) {
foreach (['draft', 'response', 'answer', 'what_we_found', 'text', 'transcript', 'redacted_text', 'summary', 'markdown'] as $k) {
if (!empty($output[$k]) && is_string($output[$k])) {
$primaryOutput = (string)$output[$k];
break;
}
}
/**
* Render a list of strings as an unordered list within a collapsible block.
*/
function crListBlock(string $heading, array $items): string {
if (empty($items)) return '';
$lis = implode('', array_map(fn($v) => '<li>' . htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8') . '</li>', $items));
return '<h3 style="font-family:\'Crimson Pro\',serif;font-size:1.05rem;color:#00205B;margin:1rem 0 0.35rem;">'
. htmlspecialchars($heading, ENT_QUOTES, 'UTF-8')
. '</h3><ul style="margin:0 0 0.5rem;padding-left:1.3rem;line-height:1.6;">' . $lis . '</ul>';
}
function crField(string $label, string $value): string {
if ($value === '') return '';
return '<p><strong>' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ':</strong> '
. htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . '</p>';
}
?><!doctype html>
<html lang="<?= htmlspecialchars($uiLang) ?>">
<head>
@@ -159,11 +176,136 @@ foreach (['draft', 'response', 'answer', 'text', 'summary', 'markdown'] as $k) {
<section class="cr-section">
<h2>Resultat</h2>
<?php if ($toolSlug === 'summarize'): ?>
<?php if (!empty($output['what_we_found'])): ?>
<p style="line-height:1.7;"><?= htmlspecialchars((string)$output['what_we_found']) ?></p>
<?php endif; ?>
<?= 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'] ?? [])) ?>
<?php if (!empty($output['next_practical_step'])): ?>
<h3 style="font-family:'Crimson Pro',serif;font-size:1.05rem;color:#00205B;margin:1rem 0 0.35rem;">Next Practical Step</h3>
<p><?= htmlspecialchars((string)$output['next_practical_step']) ?></p>
<?php endif; ?>
<?php elseif ($toolSlug === 'ask'): ?>
<?php if (!empty($output['answer'])): ?>
<div class="cr-output" style="margin-bottom:1rem;"><?= htmlspecialchars((string)$output['answer']) ?></div>
<?php endif; ?>
<?php if (!empty($output['citation_notes']) && is_array($output['citation_notes'])): ?>
<h3 style="font-family:'Crimson Pro',serif;font-size:1.05rem;color:#00205B;margin:1rem 0 0.5rem;">Citations</h3>
<?php foreach ($output['citation_notes'] as $cite): if (!is_array($cite)) continue; ?>
<div style="border-left:3px solid #d8dde7;padding:0.4rem 0.75rem;margin-bottom:0.6rem;">
<?php if (!empty($cite['title'])): ?><strong><?= htmlspecialchars((string)$cite['title']) ?></strong><br><?php endif; ?>
<?php if (!empty($cite['citation'])): ?><em style="font-size:0.85rem;color:#667085;"><?= htmlspecialchars((string)$cite['citation']) ?></em><br><?php endif; ?>
<?php if (!empty($cite['why_it_matters'])): ?><span style="font-size:0.9rem;"><?= htmlspecialchars((string)$cite['why_it_matters']) ?></span><?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?= crListBlock('What Remains Uncertain', (array)($output['what_remains_uncertain'] ?? [])) ?>
<?php elseif ($toolSlug === 'redact'): ?>
<?php if (!empty($output['redacted_text'])): ?>
<pre class="cr-output" style="white-space:pre-wrap;background:#f9fafb;padding:1rem;border-radius:6px;max-height:500px;overflow-y:auto;"><?= htmlspecialchars((string)$output['redacted_text']) ?></pre>
<?php endif; ?>
<?php if (!empty($output['entity_counts']) && is_array($output['entity_counts'])): ?>
<h3 style="font-family:'Crimson Pro',serif;font-size:1.05rem;color:#00205B;margin:1rem 0 0.5rem;">Entities Redacted</h3>
<table style="border-collapse:collapse;font-size:0.9rem;width:auto;">
<?php foreach ($output['entity_counts'] as $cat => $count): ?>
<tr>
<td style="padding:0.2rem 1rem 0.2rem 0;font-weight:600;"><?= htmlspecialchars((string)$cat) ?></td>
<td style="padding:0.2rem 0;color:#374151;"><?= (int)$count ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<?php if (!empty($output['redaction_map'])): ?>
<details class="cr-collapse" style="margin-top:1rem;">
<summary>Redaction Map</summary>
<pre><?= htmlspecialchars(json_encode($output['redaction_map'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '{}') ?></pre>
</details>
<?php endif; ?>
<?php elseif ($toolSlug === 'transcribe'): ?>
<?php
$speakerRoles = is_array($output['speaker_roles'] ?? null) ? $output['speaker_roles'] : [];
$segments = is_array($output['segments'] ?? null) ? $output['segments'] : [];
?>
<?= crField('Engine', (string)($output['model'] ?? '')) ?>
<?= crField('Language', strtoupper((string)($output['language'] ?? ''))) ?>
<?php if (!empty($output['duration_sec'])): ?>
<?php $dur = (int)$output['duration_sec']; $m = intdiv($dur, 60); $s = $dur % 60; ?>
<?= crField('Duration', ($m > 0 ? "{$m}m " : '') . "{$s}s") ?>
<?php endif; ?>
<?php if (!empty($speakerRoles)): ?>
<p><strong>Speakers:</strong> <?= implode(', ', array_map(
fn($id, $role) => htmlspecialchars("$id: $role", ENT_QUOTES, 'UTF-8'),
array_keys($speakerRoles), array_values($speakerRoles)
)) ?></p>
<?php endif; ?>
<?php if (!empty($output['transcript'])): ?>
<div class="cr-output" style="max-height:420px;overflow-y:auto;background:#f9fafb;padding:1rem;border-radius:6px;margin-top:0.75rem;white-space:pre-wrap;"><?= htmlspecialchars((string)$output['transcript']) ?></div>
<?php endif; ?>
<?php if (!empty($segments)): ?>
<details class="cr-collapse" style="margin-top:1rem;">
<summary>Segments (<?= count($segments) ?>)</summary>
<div style="overflow-x:auto;max-height:400px;overflow-y:auto;">
<table style="border-collapse:collapse;font-size:0.83rem;width:100%;">
<?php foreach ($segments as $seg): if (!is_array($seg)) continue; ?>
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:0.3rem 0.7rem 0.3rem 0;white-space:nowrap;color:#6b7280;font-family:'JetBrains Mono',monospace;"><?= number_format((float)($seg['start'] ?? 0), 1) ?>s</td>
<?php if (!empty($seg['speaker'])): ?>
<td style="padding:0.3rem 0.7rem;white-space:nowrap;font-weight:600;color:#374151;"><?= htmlspecialchars((string)$seg['speaker']) ?></td>
<?php endif; ?>
<td style="padding:0.3rem 0;"><?= htmlspecialchars((string)($seg['text'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</table>
</div>
</details>
<?php endif; ?>
<?php elseif ($toolSlug === 'timeline'): ?>
<?php if (!empty($output['what_we_found'])): ?>
<p style="line-height:1.7;"><?= htmlspecialchars((string)$output['what_we_found']) ?></p>
<?php endif; ?>
<?php if (!empty($output['events']) && is_array($output['events'])): ?>
<div style="overflow-x:auto;margin-top:0.75rem;">
<table style="border-collapse:collapse;width:100%;font-size:0.88rem;">
<thead><tr style="border-bottom:2px solid #e5e7eb;text-align:left;">
<th style="padding:0.4rem 0.75rem 0.4rem 0;">Date</th>
<th style="padding:0.4rem 0.75rem;">Actor</th>
<th style="padding:0.4rem 0.75rem;">Event</th>
<th style="padding:0.4rem 0 0.4rem 0.75rem;">Confidence</th>
</tr></thead>
<tbody>
<?php foreach ($output['events'] as $ev): if (!is_array($ev)) continue; ?>
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:0.4rem 0.75rem 0.4rem 0;white-space:nowrap;font-family:'JetBrains Mono',monospace;font-size:0.82rem;"><?= htmlspecialchars((string)($ev['date'] ?? '—')) ?></td>
<td style="padding:0.4rem 0.75rem;color:#374151;"><?= htmlspecialchars((string)($ev['actor'] ?? '')) ?></td>
<td style="padding:0.4rem 0.75rem;"><?= htmlspecialchars((string)($ev['event'] ?? '')) ?></td>
<td style="padding:0.4rem 0 0.4rem 0.75rem;">
<span style="font-size:0.78rem;padding:1px 6px;border-radius:999px;background:<?= ($ev['confidence'] ?? '') === 'high' ? '#dcfce7' : (($ev['confidence'] ?? '') === 'medium' ? '#fef9c3' : '#fee2e2') ?>;color:#374151;"><?= htmlspecialchars((string)($ev['confidence'] ?? '')) ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?= crListBlock('What Remains Uncertain', (array)($output['what_remains_uncertain'] ?? [])) ?>
<?php else: ?>
<?php if ($primaryOutput !== ''): ?>
<div class="cr-output"><?= htmlspecialchars($primaryOutput) ?></div>
<?php else: ?>
<p class="cr-output-empty">Dette verktøyet returnerer strukturert output se rådata under.</p>
<?php endif; ?>
<?php endif; ?>
<details class="cr-collapse" style="margin-top:1.25rem;">
<summary>Vis rådata (JSON)</summary>
<pre><?= htmlspecialchars(json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}') ?></pre>
@@ -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'] ?>;
});
+20 -2
View File
@@ -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) {