From d47024ed671ba0d93cb56fb89e6fa1349a79e30a Mon Sep 17 00:00:00 2001 From: davegilligan Date: Mon, 25 May 2026 09:32:28 +0200 Subject: [PATCH] timeline: remove GPU, add SSE status updates, DOCX export, single-file, engine-aware credits - Remove GPU/cuttlefish engine from timeline.php, api/timeline.php, LegalTools.php, tools.js (all 4 langs) - Add engine-aware credit cost: gpt-4o-mini=1 credit, gpt-4o=2 credits (matches redact pattern) - Remove multiple attribute from file input (single document only) - New api/timeline-stream.php: SSE endpoint emitting status events + final result - New api/timeline-download.php: DOCX export of timeline events - LegalTools::timeline() gains ?callable $onProgress for live status updates - tools.js: spinner on run, SSE streaming fetch, Export to Word button - Save to My Docs was already wired (showSaveResultButton at line 1136) Co-Authored-By: Claude Sonnet 4.6 --- api/timeline-download.php | 175 ++++++++++++++++++++++++++++++++++++ api/timeline-stream.php | 121 +++++++++++++++++++++++++ api/timeline.php | 12 ++- assets/js/tools.js | 102 +++++++++++++++++---- includes/LegalTools.php | 32 +++---- includes/PricingCatalog.php | 2 +- timeline.php | 5 +- 7 files changed, 406 insertions(+), 43 deletions(-) create mode 100644 api/timeline-download.php create mode 100644 api/timeline-stream.php diff --git a/api/timeline-download.php b/api/timeline-download.php new file mode 100644 index 0000000..10eadff --- /dev/null +++ b/api/timeline-download.php @@ -0,0 +1,175 @@ + ['message' => 'Unsupported format.']]); + exit; +} + +if (!$events) { + http_response_code(422); + echo json_encode(['error' => ['message' => 'No events provided.']]); + exit; +} + +if (!class_exists('ZipArchive')) { + http_response_code(500); + echo json_encode(['error' => ['message' => 'ZipArchive extension not available.']]); + exit; +} + +$text = buildTimelineText($events, $whatFound); +$docx = buildTimelineDocx($text); + +header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document'); +header('Content-Disposition: attachment; filename="timeline.docx"'); +header('Content-Length: ' . strlen($docx)); +header('Cache-Control: no-store'); +echo $docx; +exit; + +function buildTimelineText(array $events, string $whatFound): string +{ + $lines = []; + if ($whatFound !== '') { + $lines[] = $whatFound; + $lines[] = ''; + $lines[] = str_repeat("\u{2500}", 40); + $lines[] = ''; + } + + foreach ($events as $ev) { + $date = (string)($ev['date'] ?? 'unknown date'); + $time = (string)($ev['time'] ?? ''); + $actor = (string)($ev['actor'] ?? ''); + $event = (string)($ev['event'] ?? ''); + $excerpt = (string)($ev['source_excerpt'] ?? ''); + $conf = (string)($ev['confidence'] ?? ''); + + $dateStr = $time !== '' ? "{$date} {$time}" : $date; + $header = $actor !== '' ? "[{$dateStr}] {$actor}" : "[{$dateStr}]"; + if ($conf !== '' && $conf !== 'high') { + $header .= " ({$conf} confidence)"; + } + + $lines[] = $header; + if ($event !== '') $lines[] = $event; + if ($excerpt !== '') $lines[] = "Source: \u{201C}{$excerpt}\u{201D}"; + $lines[] = ''; + $lines[] = str_repeat("\u{2500}", 40); + $lines[] = ''; + } + + return implode("\n", $lines); +} + +function buildTimelineDocx(string $text): string +{ + $tmp = tempnam(sys_get_temp_dir(), 'dbn_tl_'); + @unlink($tmp); + $tmp .= '.docx'; + + $zip = new ZipArchive(); + $zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE); + + $zip->addFromString('[Content_Types].xml', tlContentTypesXml()); + $zip->addFromString('_rels/.rels', tlRelsXml()); + $zip->addFromString('word/document.xml', tlDocumentXml($text)); + $zip->addFromString('word/_rels/document.xml.rels', tlWordRelsXml()); + $zip->addFromString('docProps/app.xml', tlAppXml()); + $zip->addFromString('docProps/core.xml', tlCoreXml()); + + $zip->close(); + + $bytes = file_get_contents($tmp); + @unlink($tmp); + return $bytes; +} + +function tlDocumentXml(string $text): string +{ + $lines = explode("\n", str_replace("\r\n", "\n", str_replace("\r", "\n", $text))); + $paras = []; + foreach ($lines as $line) { + $safe = htmlspecialchars($line, ENT_XML1 | ENT_COMPAT, 'UTF-8'); + if ($safe === '') { + $paras[] = ''; + } elseif (preg_match('/^\[.+?\]/', $line)) { + // Event header line — bold + $paras[] = '' + . '' . $safe . ''; + } elseif (str_starts_with($line, str_repeat("\u{2500}", 5))) { + // Divider line — light grey + $paras[] = '' + . '' . $safe . ''; + } else { + $paras[] = '' + . '' . $safe . ''; + } + } + return '' + . '' + . '' . implode('', $paras) + . '' + . '' + . ''; +} + +function tlContentTypesXml(): string +{ + return '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; +} + +function tlRelsXml(): string +{ + return '' + . '' + . '' + . '' + . '' + . ''; +} + +function tlWordRelsXml(): string +{ + return '' + . ''; +} + +function tlAppXml(): string +{ + return '' + . '' + . 'DoBetterNorge Timeline' + . ''; +} + +function tlCoreXml(): string +{ + $date = date('Y-m-d\TH:i:s\Z'); + return '' + . '' + . 'DoBetterNorge' + . '' . $date . '' + . ''; +} diff --git a/api/timeline-stream.php b/api/timeline-stream.php new file mode 100644 index 0000000..d68bb7f --- /dev/null +++ b/api/timeline-stream.php @@ -0,0 +1,121 @@ + 0) ob_end_flush(); +ob_implicit_flush(true); + +function sseEmit(string $event, array $data): void +{ + echo "event: {$event}\n"; + echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n"; + if (function_exists('flush')) @flush(); +} + +$start = microtime(true); + +try { + $text = dbnToolsInjectDocContent($input, dbnToolsString($input, 'text', 128000, false)); + if (mb_strlen(trim($text), 'UTF-8') < 10) { + sseEmit('error', ['code' => 'empty_text', 'message' => 'Paste text, upload a file, or select a document before running.']); + exit; + } + + $validEngines = ['azure_mini', 'azure_full']; + $engine = in_array((string)($input['engine'] ?? ''), $validEngines, true) + ? (string)$input['engine'] : 'azure_mini'; + $engine = ToolModels::engineForUser($ftUid, $engine); + + $validFocus = ['all', 'deadlines', 'hearings', 'cps']; + $focus = in_array((string)($input['focus'] ?? ''), $validFocus, true) + ? (string)$input['focus'] : 'all'; + + $confidenceFilter = (string)($input['confidence_filter'] ?? '') === 'high_medium' + ? 'high_medium' : 'all'; + + $includeRelative = ($input['include_relative'] ?? true) !== false; + $includeBackground = ($input['include_background'] ?? true) !== false; + $userNotes = dbnToolsString($input, 'user_notes', 2000, false); + + $useMyCase = !empty($input['use_my_case']); + if ($useMyCase) { + $caseBlock = dbnToolsCaseContext(true, $text, 5); + if ($caseBlock !== '') { + $text = $text . "\n\n" . $caseBlock; + } + } + + $result = (new DbnLegalToolsService())->timeline( + $text, $language, $engine, $focus, $confidenceFilter, + $includeRelative, $includeBackground, $userNotes, + fn(string $msg) => sseEmit('status', ['msg' => $msg]) + ); + + $latency = (int)round((microtime(true) - $start) * 1000); + + if ($ftUid > 0) { + $balance = dbnToolsFreeTierDeductAmount($ftUid, 'timeline', $_engineCredits); + $result['balance'] = $balance; + if (!headers_sent()) { + header('X-Credits-Remaining: ' . $balance); + } + } + + $result['ok'] = true; + $result['latency_ms'] = $latency; + + dbnToolsLogMetadata([ + 'tool' => 'timeline', + 'language' => $language, + 'ok' => true, + 'latency_ms' => $latency, + 'chunk_count' => (int)($result['trace_metadata']['chunk_count'] ?? 0), + 'source_count' => (int)($result['trace_metadata']['source_count'] ?? 0), + 'deployment' => $result['trace_metadata']['deployment'] ?? null, + ]); + + sseEmit('result', $result); + +} catch (DbnToolsHttpException $e) { + $latency = (int)round((microtime(true) - $start) * 1000); + dbnToolsLogMetadata([ + 'tool' => 'timeline', + 'language' => $language, + 'ok' => false, + 'latency_ms' => $latency, + 'error_code' => $e->errorCode, + ]); + sseEmit('error', ['code' => $e->errorCode, 'message' => $e->getMessage()]); +} catch (Throwable $e) { + $latency = (int)round((microtime(true) - $start) * 1000); + dbnToolsLogMetadata([ + 'tool' => 'timeline', + 'language' => $language, + 'ok' => false, + 'latency_ms' => $latency, + 'error_code' => 'internal_error', + ]); + error_log('timeline-stream error: ' . $e->getMessage()); + sseEmit('error', ['code' => 'internal_error', 'message' => 'The tool could not complete this request.']); +} diff --git a/api/timeline.php b/api/timeline.php index 79b5a7a..bdc5d3a 100644 --- a/api/timeline.php +++ b/api/timeline.php @@ -6,8 +6,12 @@ require_once __DIR__ . '/../includes/ToolModels.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); -$ftUid = dbnToolsFreeTierCheck('timeline'); $input = dbnToolsJsonInput(400000); +$_validEngines = ['azure_mini', 'azure_full']; +$_engine = in_array((string)($input['engine'] ?? ''), $_validEngines, true) + ? (string)$input['engine'] : 'azure_mini'; +$_engineCredits = $_engine === 'azure_full' ? 2 : 1; +$ftUid = dbnToolsFreeTierCheckAmount('timeline', $_engineCredits); $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); dbnToolsWithChargedTelemetry('timeline', $language, $ftUid, function () use ($input, $language, $ftUid): array { @@ -16,7 +20,7 @@ dbnToolsWithChargedTelemetry('timeline', $language, $ftUid, function () use ($in dbnToolsAbort('Paste text, upload a file, or select a document before running.', 422, 'empty_text'); } - $validEngines = ['azure_mini', 'azure_full', 'gpu']; + $validEngines = ['azure_mini', 'azure_full']; $engine = in_array((string)($input['engine'] ?? ''), $validEngines, true) ? (string)$input['engine'] : 'azure_mini'; $engine = ToolModels::engineForUser($ftUid, $engine); @@ -42,7 +46,7 @@ dbnToolsWithChargedTelemetry('timeline', $language, $ftUid, function () use ($in } } - $result = (new DbnLegalToolsService())->timeline($text, $language, $engine, $focus, $confidenceFilter, $includeRelative, $includeBackground, $userNotes); + $result = (new DbnLegalToolsService())->timeline($text, $language, $engine, $focus, $confidenceFilter, $includeRelative, $includeBackground, $userNotes, null); return $result; -}); +}, $_engineCredits); diff --git a/assets/js/tools.js b/assets/js/tools.js index 7647ad7..edbcacf 100644 --- a/assets/js/tools.js +++ b/assets/js/tools.js @@ -223,8 +223,7 @@ const TIMELINE_I18N = { timelineEngine: 'Engine', timelineEngineAzureMini: 'Azure gpt-4o-mini', timelineEngineAzureFull: 'Azure gpt-4o', - timelineEngineGpu: 'GPU (cuttlefish)', - timelineEngineHint: 'Azure engines use your BNL Azure credits. GPU runs the local LiteLLM proxy on cuttlefish.', + timelineEngineHint: 'gpt-4o-mini: 1 credit — fast, handles most timelines well. gpt-4o: 2 credits — higher accuracy for complex multi-actor cases.', timelineAdvancedToggle: 'Advanced settings', timelineFocus: 'Focus', timelineFocusAll: 'All events', @@ -240,7 +239,7 @@ const TIMELINE_I18N = { timelineIncludeRelative: 'Include relative / recurring dates', timelineDatesHint: 'When checked, relative references ("three weeks later", "every Monday") are included. Uncheck to return only exact calendar dates.', timelineUploadAria: 'File upload', - timelineUploadDrop: 'Drop up to 5 files here, or', + timelineUploadDrop: 'Drop a file here, or', timelineUploadBrowse: 'browse', timelineUploadHint: 'text extracted in memory, never stored', timelineUploadClear: '× Clear', @@ -265,8 +264,7 @@ const TIMELINE_I18N = { timelineEngine: 'Motor', timelineEngineAzureMini: 'Azure gpt-4o-mini', timelineEngineAzureFull: 'Azure gpt-4o', - timelineEngineGpu: 'GPU (cuttlefish)', - timelineEngineHint: 'Azure-motorer bruker BNL Azure-kreditter. GPU kjører lokal LiteLLM-proxy på cuttlefish.', + timelineEngineHint: 'gpt-4o-mini: 1 kreditt — rask, håndterer de fleste tidslinjer godt. gpt-4o: 2 kreditter — høyere nøyaktighet for komplekse saker med mange aktører.', timelineAdvancedToggle: 'Avanserte innstillinger', timelineFocus: 'Fokus', timelineFocusAll: 'Alle hendelser', @@ -282,7 +280,7 @@ const TIMELINE_I18N = { timelineIncludeRelative: 'Inkluder relative / gjentakende datoer', timelineDatesHint: 'Når avkrysset inkluderes relative referanser ("tre uker senere", "hver mandag"). Fjern haken for å returnere kun eksakte kalenderdatoer.', timelineUploadAria: 'Filopplasting', - timelineUploadDrop: 'Slipp opptil 5 filer her, eller', + timelineUploadDrop: 'Slipp en fil her, eller', timelineUploadBrowse: 'bla', timelineUploadHint: 'tekst hentes i minnet, lagres aldri', timelineUploadClear: '× Tøm', @@ -307,8 +305,7 @@ const TIMELINE_I18N = { timelineEngine: 'Рушій', timelineEngineAzureMini: 'Azure gpt-4o-mini', timelineEngineAzureFull: 'Azure gpt-4o', - timelineEngineGpu: 'GPU (cuttlefish)', - timelineEngineHint: 'Рушії Azure використовують кредити BNL Azure. GPU запускає локальний проксі LiteLLM на cuttlefish.', + timelineEngineHint: 'gpt-4o-mini: 1 кредит — швидко, добре справляється з більшістю хронологій. gpt-4o: 2 кредити — вища точність для складних справ з багатьма учасниками.', timelineAdvancedToggle: 'Розширені налаштування', timelineFocus: 'Фокус', timelineFocusAll: 'Всі події', @@ -324,7 +321,7 @@ const TIMELINE_I18N = { timelineIncludeRelative: 'Включити відносні / повторювані дати', timelineDatesHint: 'Якщо позначено, відносні посилання ("три тижні потому", "щопонеділка") включаються. Зніміть, щоб повертати лише точні календарні дати.', timelineUploadAria: 'Завантаження файлів', - timelineUploadDrop: 'Перетягніть до 5 файлів сюди, або', + timelineUploadDrop: 'Перетягніть один файл сюди, або', timelineUploadBrowse: 'огляд', timelineUploadHint: 'текст обробляється в памʼяті, ніколи не зберігається', timelineUploadClear: '× Очистити', @@ -349,8 +346,7 @@ const TIMELINE_I18N = { timelineEngine: 'Silnik', timelineEngineAzureMini: 'Azure gpt-4o-mini', timelineEngineAzureFull: 'Azure gpt-4o', - timelineEngineGpu: 'GPU (cuttlefish)', - timelineEngineHint: 'Silniki Azure używają kredytów Azure BNL. GPU korzysta z lokalnego proxy LiteLLM na cuttlefish.', + timelineEngineHint: 'gpt-4o-mini: 1 kredyt — szybki, sprawdza się w większości osi czasu. gpt-4o: 2 kredyty — wyższa dokładność dla złożonych spraw z wieloma uczestnikami.', timelineAdvancedToggle: 'Ustawienia zaawansowane', timelineFocus: 'Fokus', timelineFocusAll: 'Wszystkie zdarzenia', @@ -366,7 +362,7 @@ const TIMELINE_I18N = { timelineIncludeRelative: 'Uwzględnij daty względne / cykliczne', timelineDatesHint: 'Gdy zaznaczone, odniesienia względne ("trzy tygodnie później", "co poniedziałek") są uwzględniane. Odznacz, aby zwracać tylko dokładne daty kalendarzowe.', timelineUploadAria: 'Przesyłanie pliku', - timelineUploadDrop: 'Upuść do 5 plików tutaj lub', + timelineUploadDrop: 'Upuść jeden plik tutaj lub', timelineUploadBrowse: 'przeglądaj', timelineUploadHint: 'tekst wyodrębniany w pamięci, nigdy nie przechowywany', timelineUploadClear: '× Wyczyść', @@ -391,6 +387,7 @@ const TIMELINE_I18N = { let lastTimelineEvents = []; let lastTimelineEventsOriginal = []; +let lastTimelineWhatWeFound = ''; let activeActorFilters = new Set(); let timelineSearchTerm = ''; let showSources = true; @@ -957,6 +954,7 @@ document.addEventListener('DOMContentLoaded', () => { } els.results?.addEventListener('click', (e) => { if (e.target.closest('#exportCsvBtn')) exportTimelineCSV(lastTimelineEvents); + if (e.target.closest('#exportDocxBtn')) downloadTimelineDocx(); if (e.target.closest('#txCopy')) copyTranscriptText(); if (e.target.closest('#dlTxt')) downloadTranscriptTxt(); if (e.target.closest('#dlSrt')) downloadTranscriptSrt(); @@ -1121,12 +1119,53 @@ async function runTool(event) { if (state.activeTool === 'redact') { els.results.innerHTML = '

Redacting document…

'; } + if (state.activeTool === 'timeline') { + els.results.innerHTML = '

Building timeline…

'; + } renderTrace([ { label: 'Query interpretation', detail: 'Preparing request.', status: 'running' }, ]); try { - const data = await postJson(tool.endpoint, payload); + let data; + if (state.activeTool === 'timeline') { + const resp = await fetch('api/timeline-stream.php', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let buf = '', event = ''; + outer: while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop(); + for (const line of lines) { + if (line.startsWith('event: ')) { event = line.slice(7).trim(); continue; } + if (line.startsWith('data: ')) { + let parsed; + try { parsed = JSON.parse(line.slice(6)); } catch (_) { continue; } + if (event === 'status') { + const el = document.getElementById('timelineStatusMsg'); + if (el) el.textContent = parsed.msg; + } else if (event === 'result') { + data = parsed; + } else if (event === 'error') { + throw new Error(parsed.message || 'Timeline failed'); + } + event = ''; + } + } + } + if (!data) throw new Error('No result received from timeline.'); + } else { + data = await postJson(tool.endpoint, payload); + } if (!data.ok) { throw new Error(data.error?.message || 'Tool request failed.'); } @@ -1562,13 +1601,14 @@ function renderMainFinding(data) { if (data.tool === 'timeline') { lastTimelineEventsOriginal = data.events || []; lastTimelineEvents = [...lastTimelineEventsOriginal]; + lastTimelineWhatWeFound = data.what_we_found || ''; activeActorFilters = new Set(); timelineSearchTerm = ''; showSources = true; timelineSortMode = 'doc'; const csvLabel = currentTimelineT('timelineExportCsv') || 'Download CSV'; - const csvBtn = lastTimelineEventsOriginal.length - ? `
` + const exportRow = lastTimelineEventsOriginal.length + ? `
` : ''; const countBadge = buildTimelineCountBadge(lastTimelineEventsOriginal); const actorChips = buildActorChips(lastTimelineEventsOriginal); @@ -1579,7 +1619,7 @@ function renderMainFinding(data) { ` : ''; - return `

${escapeHtml(data.what_we_found || '')}

${countBadge}${actorChips}${toolbar}${sortBar}
${renderTimeline(lastTimelineEvents, false)}
${csvBtn}`; + return `

${escapeHtml(lastTimelineWhatWeFound)}

${countBadge}${actorChips}${toolbar}${sortBar}
${renderTimeline(lastTimelineEvents, false)}
${exportRow}`; } if (data.tool === 'summarize') { return [ @@ -1809,6 +1849,36 @@ function exportTimelineCSV(events) { URL.revokeObjectURL(url); } +async function downloadTimelineDocx() { + const btn = document.getElementById('exportDocxBtn'); + if (!btn || !lastTimelineEventsOriginal.length) return; + const origText = btn.textContent; + btn.disabled = true; + btn.textContent = 'Generating…'; + try { + const resp = await fetch('api/timeline-download.php', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ events: lastTimelineEventsOriginal, what_we_found: lastTimelineWhatWeFound, format: 'docx' }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error?.message || 'Download failed'); + } + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = Object.assign(document.createElement('a'), { href: url, download: 'timeline.docx' }); + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + alert('Could not export Word file: ' + err.message); + } finally { + btn.disabled = false; + btn.textContent = origText; + } +} + function currentTask() { const el = document.querySelector('input[name="task"]:checked'); return el ? el.value : 'transcribe'; diff --git a/includes/LegalTools.php b/includes/LegalTools.php index 25acb3a..5dd06ac 100644 --- a/includes/LegalTools.php +++ b/includes/LegalTools.php @@ -350,15 +350,15 @@ PROMPT; string $confidenceFilter = 'all', bool $includeRelative = true, bool $includeBackground = true, - string $userNotes = '' + string $userNotes = '', + ?callable $onProgress = null ): array { $text = $this->requirePasteText($text); - $engine = in_array($engine, ['azure_mini', 'azure_full', 'gpu'], true) ? $engine : 'azure_mini'; + $engine = in_array($engine, ['azure_mini', 'azure_full'], true) ? $engine : 'azure_mini'; $focus = in_array($focus, ['all', 'deadlines', 'hearings', 'cps'], true) ? $focus : 'all'; - if ($engine !== 'gpu') { - $this->azure->requireChat(); - } + $this->azure->requireChat(); + $onProgress && $onProgress("Preparing document\u{2026}"); $locale = dbnToolsLanguageName($language); @@ -451,23 +451,21 @@ PROMPT; ['role' => 'user', 'content' => $prompt], ]; $chatOptions = ['json' => true, 'temperature' => 0.1, 'max_tokens' => ($engine === 'azure_full' ? 8000 : 4000), 'timeout' => 120]; - $deployLabel = $this->azure->chatDeployment(); + $deployLabel = $engine === 'azure_full' ? 'gpt-4o' : 'gpt-4o-mini'; + $onProgress && $onProgress("Calling {$deployLabel}\u{2026}"); try { - if ($engine === 'gpu') { - $response = $this->callGpuLlm($messages, $chatOptions); - $deployLabel = 'GPU (cuttlefish)'; - } elseif ($engine === 'azure_full') { - $response = $this->azure->withDeployment('gpt-4o')->chat($messages, $chatOptions); - $deployLabel = 'gpt-4o'; + if ($engine === 'azure_full') { + $response = $this->azure->withDeployment('gpt-4o')->chat($messages, $chatOptions); } else { - $response = $this->azure->withDeployment('gpt-4o-mini')->chat($messages, $chatOptions); - $deployLabel = 'gpt-4o-mini'; + $response = $this->azure->withDeployment('gpt-4o-mini')->chat($messages, $chatOptions); } } catch (Throwable $e) { dbnToolsAbort('LLM request failed: ' . $e->getMessage(), 502, 'llm_error'); } + $onProgress && $onProgress("Parsing events\u{2026}"); + $raw = (string)($response['choices'][0]['message']['content'] ?? ''); $json = $this->azure->decodeJsonObject($raw); if (!$json) { @@ -486,11 +484,7 @@ PROMPT; $events = array_values(array_filter($events, fn($ev) => ($ev['date_type'] ?? 'absolute') === 'absolute')); } - $engineLabel = match ($engine) { - 'gpu' => 'GPU (cuttlefish)', - 'azure_full' => 'gpt-4o', - default => $deployLabel ?? $this->azure->chatDeployment(), - }; + $engineLabel = $engine === 'azure_full' ? 'gpt-4o' : 'gpt-4o-mini'; $focusLabel = match ($focus) { 'deadlines' => 'legal deadlines', diff --git a/includes/PricingCatalog.php b/includes/PricingCatalog.php index 9169f06..f584a56 100644 --- a/includes/PricingCatalog.php +++ b/includes/PricingCatalog.php @@ -132,7 +132,7 @@ final class PricingCatalog 'summarize' => 1, 'translate' => 1, 'korrespond_refine' => 1, - 'timeline' => 2, + 'timeline' => 1, // minimum (gpt-4o-mini); azure_full overrides to 2 in api/timeline.php 'redact' => 1, // minimum (gpt-4o-mini); azure_full overrides to 2 in api/redact.php 'barnevernet' => 3, 'advocate' => 3, diff --git a/timeline.php b/timeline.php index e44d854..1ed09aa 100644 --- a/timeline.php +++ b/timeline.php @@ -27,7 +27,6 @@ require_once __DIR__ . '/includes/layout.php'; Engine -

Azure engines use your BNL Azure credits. GPU runs the local LiteLLM proxy on cuttlefish.

@@ -74,10 +73,10 @@ require_once __DIR__ . '/includes/layout.php';
- +
-

Drop up to 5 files here, or

+

Drop a file here, or

PDF, DOCX, TXTtext extracted in memory, never stored