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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/LegalTools.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
|
||||
$input = dbnToolsJsonInput(200000);
|
||||
$events = is_array($input['events'] ?? null) ? $input['events'] : [];
|
||||
$whatFound = trim((string)($input['what_we_found'] ?? ''));
|
||||
$format = (string)($input['format'] ?? 'docx');
|
||||
|
||||
if ($format !== 'docx') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => ['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[] = '<w:p/>';
|
||||
} elseif (preg_match('/^\[.+?\]/', $line)) {
|
||||
// Event header line — bold
|
||||
$paras[] = '<w:p><w:r><w:rPr><w:b/><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/><w:sz w:val="20"/></w:rPr>'
|
||||
. '<w:t xml:space="preserve">' . $safe . '</w:t></w:r></w:p>';
|
||||
} elseif (str_starts_with($line, str_repeat("\u{2500}", 5))) {
|
||||
// Divider line — light grey
|
||||
$paras[] = '<w:p><w:r><w:rPr><w:color w:val="AAAAAA"/><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/><w:sz w:val="16"/></w:rPr>'
|
||||
. '<w:t xml:space="preserve">' . $safe . '</w:t></w:r></w:p>';
|
||||
} else {
|
||||
$paras[] = '<w:p><w:r><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/><w:sz w:val="20"/></w:rPr>'
|
||||
. '<w:t xml:space="preserve">' . $safe . '</w:t></w:r></w:p>';
|
||||
}
|
||||
}
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
. '<w:body>' . implode('', $paras)
|
||||
. '<w:sectPr><w:pgSz w:w="12240" w:h="15840"/>'
|
||||
. '<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/>'
|
||||
. '</w:sectPr></w:body></w:document>';
|
||||
}
|
||||
|
||||
function tlContentTypesXml(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
. '<Default Extension="xml" ContentType="application/xml"/>'
|
||||
. '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
|
||||
. '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>'
|
||||
. '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
|
||||
. '</Types>';
|
||||
}
|
||||
|
||||
function tlRelsXml(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
|
||||
. '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
|
||||
. '<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
|
||||
. '</Relationships>';
|
||||
}
|
||||
|
||||
function tlWordRelsXml(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>';
|
||||
}
|
||||
|
||||
function tlAppXml(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">'
|
||||
. '<Application>DoBetterNorge Timeline</Application>'
|
||||
. '</Properties>';
|
||||
}
|
||||
|
||||
function tlCoreXml(): string
|
||||
{
|
||||
$date = date('Y-m-d\TH:i:s\Z');
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"'
|
||||
. ' xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"'
|
||||
. ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
|
||||
. '<dc:creator>DoBetterNorge</dc:creator>'
|
||||
. '<dcterms:created xsi:type="dcterms:W3CDTF">' . $date . '</dcterms:created>'
|
||||
. '</cp:coreProperties>';
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/LegalTools.php';
|
||||
require_once __DIR__ . '/../includes/ToolModels.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
|
||||
// Parse input and run credit pre-check BEFORE emitting SSE headers so that
|
||||
// auth/credit errors can still return JSON (dbnToolsError / dbnToolsAbort).
|
||||
$input = dbnToolsJsonInput(400000);
|
||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||
|
||||
$_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);
|
||||
|
||||
// Only switch to SSE mode once auth + credit checks pass.
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache, no-transform');
|
||||
header('X-Accel-Buffering: no');
|
||||
while (ob_get_level() > 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.']);
|
||||
}
|
||||
+8
-4
@@ -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);
|
||||
|
||||
+86
-16
@@ -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 = '<div class="redact-working" role="status" aria-live="polite"><span class="redact-working__spinner" aria-hidden="true"></span><p>Redacting document…</p></div>';
|
||||
}
|
||||
if (state.activeTool === 'timeline') {
|
||||
els.results.innerHTML = '<div class="redact-working" id="timelineWorkingState" role="status" aria-live="polite"><span class="redact-working__spinner" aria-hidden="true"></span><p id="timelineStatusMsg">Building timeline…</p></div>';
|
||||
}
|
||||
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
|
||||
? `<div class="timeline-export"><button type="button" id="exportCsvBtn" class="export-csv-btn">${escapeHtml(csvLabel)}</button></div>`
|
||||
const exportRow = lastTimelineEventsOriginal.length
|
||||
? `<div class="timeline-export"><button type="button" id="exportCsvBtn" class="export-csv-btn">${escapeHtml(csvLabel)}</button><button type="button" id="exportDocxBtn" class="export-csv-btn">Export to Word</button></div>`
|
||||
: '';
|
||||
const countBadge = buildTimelineCountBadge(lastTimelineEventsOriginal);
|
||||
const actorChips = buildActorChips(lastTimelineEventsOriginal);
|
||||
@@ -1579,7 +1619,7 @@ function renderMainFinding(data) {
|
||||
<button type="button" class="sort-btn is-active" id="sortDocOrder">${escapeHtml(currentTimelineT('sortDocOrder') || 'Document order')}</button>
|
||||
<button type="button" class="sort-btn" id="sortChronological">${escapeHtml(currentTimelineT('sortChronological') || 'Chronological')}</button>
|
||||
</div>` : '';
|
||||
return `<p>${escapeHtml(data.what_we_found || '')}</p>${countBadge}${actorChips}${toolbar}${sortBar}<div id="timelineListContainer">${renderTimeline(lastTimelineEvents, false)}</div>${csvBtn}`;
|
||||
return `<p>${escapeHtml(lastTimelineWhatWeFound)}</p>${countBadge}${actorChips}${toolbar}${sortBar}<div id="timelineListContainer">${renderTimeline(lastTimelineEvents, false)}</div>${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';
|
||||
|
||||
+13
-19
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-3
@@ -27,7 +27,6 @@ require_once __DIR__ . '/includes/layout.php';
|
||||
<span class="control-label" data-i18n="timelineEngine">Engine</span>
|
||||
<label><input type="radio" name="timelineEngine" value="azure_mini" checked id="timelineEngineAzureMini"> <span data-i18n="timelineEngineAzureMini">Azure gpt-4o-mini</span> ★ <small class="control-hint">(fast)</small></label>
|
||||
<label><input type="radio" name="timelineEngine" value="azure_full" id="timelineEngineAzureFull"> <span data-i18n="timelineEngineAzureFull">Azure gpt-4o</span> <small class="control-hint">(best)</small></label>
|
||||
<label><input type="radio" name="timelineEngine" value="gpu" id="timelineEngineGpu"> <span data-i18n="timelineEngineGpu">GPU (cuttlefish)</span> <small class="control-hint">(local)</small></label>
|
||||
</div>
|
||||
<p class="upload-hint" data-i18n="timelineEngineHint">Azure engines use your BNL Azure credits. GPU runs the local LiteLLM proxy on cuttlefish.</p>
|
||||
|
||||
@@ -74,10 +73,10 @@ require_once __DIR__ . '/includes/layout.php';
|
||||
</div>
|
||||
|
||||
<div class="upload-zone" id="uploadZone" role="region" aria-label="File upload" data-i18n-aria="timelineUploadAria">
|
||||
<input type="file" id="uploadInput" multiple accept=".pdf,.docx,.txt" aria-label="Choose files">
|
||||
<input type="file" id="uploadInput" accept=".pdf,.docx,.txt" aria-label="Choose files">
|
||||
<div id="uploadPrompt" class="upload-prompt">
|
||||
<span class="upload-icon" aria-hidden="true">⇧</span>
|
||||
<p><span data-i18n="timelineUploadDrop">Drop up to 5 files here, or</span> <label for="uploadInput" class="upload-browse" data-i18n="timelineUploadBrowse">browse</label></p>
|
||||
<p><span data-i18n="timelineUploadDrop">Drop a file here, or</span> <label for="uploadInput" class="upload-browse" data-i18n="timelineUploadBrowse">browse</label></p>
|
||||
<p class="upload-hint"><strong>PDF</strong>, <strong>DOCX</strong>, <strong>TXT</strong> — <span data-i18n="timelineUploadHint">text extracted in memory, never stored</span></p>
|
||||
</div>
|
||||
<div id="uploadFileInfo" class="upload-file is-hidden">
|
||||
|
||||
Reference in New Issue
Block a user