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 = '
';
}
+ if (state.activeTool === 'timeline') {
+ els.results.innerHTML = '';
+ }
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, TXT — text extracted in memory, never stored