Timeline: group same-date/actor events, clean badges, Bedrock routing

- renderTimeline(): group consecutive same-date+actor events into one card
  with a bullet list; single events keep their current layout
- Date format: YYYY-MM-DD → "1 Jun 2023" (3-letter month, international)
- Time shown in header when available
- Remove date_type badge; confidence badge replaced by amber ⚠ flag on
  low-confidence events only (high/medium border colour still shows)
- LegalTools.php: resolve azure_full/azure_mini to Bedrock Sonnet/Haiku
  when DbnBedrockGateway is active; claude_sonnet/claude_haiku also handled
- timeline.php + api/timeline.php: engine labels updated (Claude Haiku/Sonnet);
  claude_haiku + claude_sonnet added to valid engine list
- i18n engine labels updated in all 4 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 23:21:35 +02:00
parent 25c2cf826d
commit 234ab7278b
5 changed files with 130 additions and 44 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ dbnToolsRequireMethod('POST');
dbnToolsRequireAuth(); dbnToolsRequireAuth();
$input = dbnToolsJsonInput(1500000); $input = dbnToolsJsonInput(1500000);
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
$_validEngines = ['nova_lite', 'azure_mini', 'azure_full']; $_validEngines = ['nova_lite', 'azure_mini', 'azure_full', 'claude_haiku', 'claude_sonnet'];
$_requestedEngine = in_array((string)($input['engine'] ?? ''), $_validEngines, true) $_requestedEngine = in_array((string)($input['engine'] ?? ''), $_validEngines, true)
? (string)$input['engine'] : 'azure_mini'; ? (string)$input['engine'] : 'azure_mini';
+23
View File
@@ -849,6 +849,29 @@ p {
font-size: 0.82rem; font-size: 0.82rem;
} }
.confidence-low-flag {
color: #d97706;
font-size: 0.88rem;
margin-left: 4px;
vertical-align: middle;
flex-shrink: 0;
}
.timeline-bullets {
margin: 4px 0 0 0;
padding-left: 1.25em;
list-style: disc;
}
.timeline-bullet {
margin: 4px 0;
line-height: 1.45;
}
.timeline-bullet .timeline-excerpt {
margin-top: 2px;
}
.confidence-badge { .confidence-badge {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
+79 -28
View File
@@ -221,9 +221,9 @@ const REDACT_I18N = {
const TIMELINE_I18N = { const TIMELINE_I18N = {
en: { en: {
timelineEngine: 'Engine', timelineEngine: 'Engine',
timelineEngineAzureMini: 'Azure gpt-4o-mini', timelineEngineAzureMini: 'Standard',
timelineEngineAzureFull: 'Azure gpt-4o', timelineEngineAzureFull: 'Deep',
timelineEngineHint: 'gpt-4o-mini: 1 credit — fast, handles most timelines well. gpt-4o: 2 credits — higher accuracy for complex multi-actor cases.', timelineEngineHint: 'Standard uses Claude Haiku 4.5 — fast and accurate for most documents (1 credit). Deep uses Claude Sonnet 4.6 — better for complex multi-actor cases (2 credits).',
timelineAdvancedToggle: 'Advanced settings', timelineAdvancedToggle: 'Advanced settings',
timelineFocus: 'Focus', timelineFocus: 'Focus',
timelineFocusAll: 'All events', timelineFocusAll: 'All events',
@@ -262,9 +262,9 @@ const TIMELINE_I18N = {
}, },
no: { no: {
timelineEngine: 'Motor', timelineEngine: 'Motor',
timelineEngineAzureMini: 'Azure gpt-4o-mini', timelineEngineAzureMini: 'Standard',
timelineEngineAzureFull: 'Azure gpt-4o', timelineEngineAzureFull: 'Dyp',
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.', timelineEngineHint: 'Standard bruker Claude Haiku 4.5 — rask og nøyaktig for de fleste dokumenter (1 kreditt). Dyp bruker Claude Sonnet 4.6 — bedre for komplekse saker med mange aktører (2 kreditter).',
timelineAdvancedToggle: 'Avanserte innstillinger', timelineAdvancedToggle: 'Avanserte innstillinger',
timelineFocus: 'Fokus', timelineFocus: 'Fokus',
timelineFocusAll: 'Alle hendelser', timelineFocusAll: 'Alle hendelser',
@@ -303,9 +303,9 @@ const TIMELINE_I18N = {
}, },
uk: { uk: {
timelineEngine: 'Рушій', timelineEngine: 'Рушій',
timelineEngineAzureMini: 'Azure gpt-4o-mini', timelineEngineAzureMini: 'Стандарт',
timelineEngineAzureFull: 'Azure gpt-4o', timelineEngineAzureFull: 'Глибокий',
timelineEngineHint: 'gpt-4o-mini: 1 кредит — швидко, добре справляється з більшістю хронологій. gpt-4o: 2 кредити — вища точність для складних справ з багатьма учасниками.', timelineEngineHint: 'Стандарт використовує Claude Haiku 4.5 — швидко і точно для більшості документів (1 кредит). Глибокий використовує Claude Sonnet 4.6 — краще для складних справ (2 кредити).',
timelineAdvancedToggle: 'Розширені налаштування', timelineAdvancedToggle: 'Розширені налаштування',
timelineFocus: 'Фокус', timelineFocus: 'Фокус',
timelineFocusAll: 'Всі події', timelineFocusAll: 'Всі події',
@@ -344,9 +344,9 @@ const TIMELINE_I18N = {
}, },
pl: { pl: {
timelineEngine: 'Silnik', timelineEngine: 'Silnik',
timelineEngineAzureMini: 'Azure gpt-4o-mini', timelineEngineAzureMini: 'Standard',
timelineEngineAzureFull: 'Azure gpt-4o', timelineEngineAzureFull: 'Zaawansowany',
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.', timelineEngineHint: 'Standard używa Claude Haiku 4.5 — szybki i dokładny dla większości dokumentów (1 kredyt). Zaawansowany używa Claude Sonnet 4.6 — lepszy dla złożonych spraw z wieloma uczestnikami (2 kredyty).',
timelineAdvancedToggle: 'Ustawienia zaawansowane', timelineAdvancedToggle: 'Ustawienia zaawansowane',
timelineFocus: 'Fokus', timelineFocus: 'Fokus',
timelineFocusAll: 'Wszystkie zdarzenia', timelineFocusAll: 'Wszystkie zdarzenia',
@@ -1854,39 +1854,90 @@ function renderTimeline(events, grouped = false) {
} }
return '<p class="timeline-empty">No matching events.</p>'; return '<p class="timeline-empty">No matching events.</p>';
} }
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtDate(d) {
if (!d || !/^\d{4}-\d{2}-\d{2}/.test(d)) return d || 'unknown';
const [y, m, day] = d.split('-');
return `${parseInt(day, 10)} ${MONTHS[parseInt(m, 10) - 1] || m} ${y}`;
}
// Group consecutive events with the same date + actor into one card
const groups = [];
for (const ev of events) {
const key = `${ev.date || ''}||${ev.actor || ''}`;
const last = groups[groups.length - 1];
if (last && last.key === key) {
last.events.push(ev);
} else {
groups.push({ key, date: ev.date || '', actor: ev.actor || '', events: [ev] });
}
}
const CONF_RANK = { high: 0, medium: 1, low: 2 };
let lastGroupKey = null; let lastGroupKey = null;
const items = events.map((ev) => { const items = groups.map((grp) => {
const conf = ev.confidence || 'medium'; let monthHeader = '';
let groupHeader = ''; if (grouped && /^\d{4}-\d{2}/.test(grp.date)) {
if (grouped && /^\d{4}-\d{2}/.test(ev.date || '')) { const year = grp.date.slice(0, 4);
const year = ev.date.slice(0, 4); const mon = grp.date.slice(5, 7);
const mon = ev.date.slice(5, 7);
const key = `${year}-${mon}`; const key = `${year}-${mon}`;
if (key !== lastGroupKey) { if (key !== lastGroupKey) {
const prevYear = lastGroupKey ? lastGroupKey.slice(0, 4) : null; const prevYear = lastGroupKey ? lastGroupKey.slice(0, 4) : null;
const label = (year !== prevYear) const label = year !== prevYear
? year ? year
: `${MONTH_NAMES[parseInt(mon, 10) - 1] || mon} ${year}`; : `${MONTHS[parseInt(mon, 10) - 1] || mon} ${year}`;
groupHeader = `<li class="timeline-group-header" role="presentation"><span>${escapeHtml(label)}</span></li>`; monthHeader = `<li class="timeline-group-header" role="presentation"><span>${escapeHtml(label)}</span></li>`;
lastGroupKey = key; lastGroupKey = key;
} }
} }
// Worst confidence in the group (drives border colour)
const worstConf = grp.events.reduce(
(w, ev) => (CONF_RANK[ev.confidence || 'medium'] ?? 1) > (CONF_RANK[w] ?? 1) ? (ev.confidence || 'medium') : w,
'high'
);
const lowFlag = worstConf === 'low'
? `<span class="confidence-low-flag" title="Low confidence — date or event may be approximate">&#9888;</span>`
: '';
// Collect unique times across the group
const times = [...new Set(grp.events.map((ev) => ev.time).filter(Boolean))];
const timeHtml = times.length ? `<span class="timeline-time">${escapeHtml(times.join(', '))}</span>` : '';
const displayDate = fmtDate(grp.date);
if (grp.events.length === 1) {
const ev = grp.events[0];
const excerptHtml = ev.source_excerpt const excerptHtml = ev.source_excerpt
? `<small class="timeline-excerpt${showSources ? '' : ' is-hidden'}">${escapeHtml(ev.source_excerpt)}</small>` ? `<small class="timeline-excerpt${showSources ? '' : ' is-hidden'}">${escapeHtml(ev.source_excerpt)}</small>`
: ''; : '';
const copyText = [ev.date, ev.actor, ev.event].filter(Boolean).join(' · '); const copyText = [grp.date, grp.actor, ev.event].filter(Boolean).join(' · ');
return `${groupHeader}<li class="timeline-item confidence-${escapeHtml(conf)}"> return `${monthHeader}<li class="timeline-item confidence-${escapeHtml(worstConf)}">
<div class="timeline-header"> <div class="timeline-header">
<strong class="timeline-date">${escapeHtml(ev.date || 'unknown')}${ev.time ? `<span class="timeline-time"> ${escapeHtml(ev.time)}</span>` : ''}</strong> <strong class="timeline-date">${escapeHtml(displayDate)}${timeHtml}</strong>
${ev.date_type ? `<span class="date-type-badge">${escapeHtml(ev.date_type)}</span>` : ''} ${lowFlag}
<span class="confidence-badge confidence-badge--${escapeHtml(conf)}">${escapeHtml(conf)}</span>
<button type="button" class="timeline-copy-btn" data-copy="${escapeHtml(copyText)}" title="Copy event" aria-label="Copy event to clipboard">&#128203;</button> <button type="button" class="timeline-copy-btn" data-copy="${escapeHtml(copyText)}" title="Copy event" aria-label="Copy event to clipboard">&#128203;</button>
</div> </div>
<span class="timeline-actor">${escapeHtml(ev.actor || 'unknown actor')}</span> <span class="timeline-actor">${escapeHtml(grp.actor || 'unknown actor')}</span>
<p class="timeline-event">${escapeHtml(ev.event || '')}</p> <p class="timeline-event">${escapeHtml(ev.event || '')}</p>
${excerptHtml} ${excerptHtml}
</li>`; </li>`;
}
// Multi-event group: render as bullet list
const bullets = grp.events.map((ev) => {
const excerptHtml = ev.source_excerpt
? `<small class="timeline-excerpt${showSources ? '' : ' is-hidden'}">${escapeHtml(ev.source_excerpt)}</small>`
: '';
const evLow = ev.confidence === 'low'
? `<span class="confidence-low-flag" title="Low confidence">&#9888;</span>`
: '';
return `<li class="timeline-bullet">${escapeHtml(ev.event || '')}${evLow}${excerptHtml}</li>`;
}).join('');
const copyText = [grp.date, grp.actor, ...grp.events.map((ev) => ev.event)].filter(Boolean).join('\n');
return `${monthHeader}<li class="timeline-item timeline-item--grouped confidence-${escapeHtml(worstConf)}">
<div class="timeline-header">
<strong class="timeline-date">${escapeHtml(displayDate)}${timeHtml}</strong>
${lowFlag}
<button type="button" class="timeline-copy-btn" data-copy="${escapeHtml(copyText)}" title="Copy events" aria-label="Copy events to clipboard">&#128203;</button>
</div>
<span class="timeline-actor">${escapeHtml(grp.actor || 'unknown actor')}</span>
<ul class="timeline-bullets">${bullets}</ul>
</li>`;
}).join(''); }).join('');
return `<ol class="timeline-list">${items}</ol>`; return `<ol class="timeline-list">${items}</ol>`;
} }
+19 -7
View File
@@ -506,18 +506,25 @@ PROMPT;
['role' => 'system', 'content' => $system], ['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $prompt], ['role' => 'user', 'content' => $prompt],
]; ];
$maxTokens = match ($engine) { 'azure_full' => 8000, 'nova_lite' => 2000, default => 4000 }; $isBedrock = $this->azure instanceof DbnBedrockGateway;
$maxTokens = match ($engine) { 'azure_full', 'claude_sonnet' => 8000, 'nova_lite' => 2000, default => 4000 };
$chatOptions = ['json' => true, 'temperature' => 0.1, 'max_tokens' => $maxTokens, 'timeout' => 120]; $chatOptions = ['json' => true, 'temperature' => 0.1, 'max_tokens' => $maxTokens, 'timeout' => 120];
$deployLabel = match ($engine) { 'azure_full' => 'gpt-4o', 'nova_lite' => 'nova-lite', default => 'gpt-4o-mini' }; $deployLabel = match (true) {
$engine === 'nova_lite' => 'nova-lite',
$engine === 'azure_full' || $engine === 'claude_sonnet' => $isBedrock ? 'claude-sonnet-bedrock' : 'gpt-4o',
default => $isBedrock ? 'claude-haiku-bedrock' : 'gpt-4o-mini',
};
$onProgress && $onProgress("Calling {$deployLabel}\u{2026}"); $onProgress && $onProgress("Calling {$deployLabel}\u{2026}");
try { try {
if ($engine === 'nova_lite') { if ($engine === 'nova_lite') {
$response = dbnToolsCallGpuLlm($messages, ['model' => 'nova-lite', 'max_tokens' => $maxTokens, 'temperature' => 0.1, 'timeout' => 120]); $response = dbnToolsCallGpuLlm($messages, ['model' => 'nova-lite', 'max_tokens' => $maxTokens, 'temperature' => 0.1, 'timeout' => 120]);
} elseif ($engine === 'azure_full') { } elseif ($engine === 'azure_full' || $engine === 'claude_sonnet') {
$response = $this->azure->withDeployment('gpt-4o')->chat($messages, $chatOptions); $deploy = $isBedrock ? DbnBedrockModelRouter::LITELLM_SONNET : 'gpt-4o';
$response = $this->azure->withDeployment($deploy)->chat($messages, $chatOptions);
} else { } else {
$response = $this->azure->withDeployment('gpt-4o-mini')->chat($messages, $chatOptions); $deploy = $isBedrock ? DbnBedrockModelRouter::LITELLM_HAIKU : 'gpt-4o-mini';
$response = $this->azure->withDeployment($deploy)->chat($messages, $chatOptions);
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$msg = $e->getMessage(); $msg = $e->getMessage();
@@ -570,7 +577,7 @@ PROMPT;
$events = array_values(array_filter($events, fn($ev) => ($ev['date_type'] ?? 'absolute') === 'absolute')); $events = array_values(array_filter($events, fn($ev) => ($ev['date_type'] ?? 'absolute') === 'absolute'));
} }
$engineLabel = match ($engine) { 'azure_full' => 'gpt-4o', 'nova_lite' => 'nova-lite', default => 'gpt-4o-mini' }; $engineLabel = $deployLabel;
$focusLabel = match ($focus) { $focusLabel = match ($focus) {
'deadlines' => 'legal deadlines', 'deadlines' => 'legal deadlines',
@@ -620,7 +627,12 @@ PROMPT;
?callable $onProgress, ?callable $onProgress,
int $inputDateHintCount int $inputDateHintCount
): array { ): array {
$engineLabel = match ($engine) { 'azure_full' => 'gpt-4o', 'nova_lite' => 'nova-lite', default => 'gpt-4o-mini' }; $isBedrock = $this->azure instanceof DbnBedrockGateway;
$engineLabel = match (true) {
$engine === 'nova_lite' => 'nova-lite',
$engine === 'azure_full' || $engine === 'claude_sonnet' => $isBedrock ? 'claude-sonnet-bedrock' : 'gpt-4o',
default => $isBedrock ? 'claude-haiku-bedrock' : 'gpt-4o-mini',
};
$chunkSize = $this->timelineChunkSize($engine); $chunkSize = $this->timelineChunkSize($engine);
$chunks = $this->timelineTextChunks($text, $chunkSize, 900); $chunks = $this->timelineTextChunks($text, $chunkSize, 900);
$chunkCount = count($chunks); $chunkCount = count($chunks);
+3 -3
View File
@@ -26,10 +26,10 @@ require_once __DIR__ . '/includes/layout.php';
<div class="control-row" id="timelineEngineControl"> <div class="control-row" id="timelineEngineControl">
<span class="control-label" data-i18n="timelineEngine">Mode</span> <span class="control-label" data-i18n="timelineEngine">Mode</span>
<label><input type="radio" name="timelineEngine" value="nova_lite" id="timelineEngineNovaLite"> <span>Quick</span> <small class="control-hint">(nova-lite · 1 credit · fastest)</small></label> <label><input type="radio" name="timelineEngine" value="nova_lite" id="timelineEngineNovaLite"> <span>Quick</span> <small class="control-hint">(nova-lite · 1 credit · fastest)</small></label>
<label><input type="radio" name="timelineEngine" value="azure_mini" checked id="timelineEngineAzureMini"> <span data-i18n="timelineEngineAzureMini">Standard</span> &#9733; <small class="control-hint">(gpt-4o-mini · 1 credit)</small></label> <label><input type="radio" name="timelineEngine" value="azure_mini" checked id="timelineEngineAzureMini"> <span data-i18n="timelineEngineAzureMini">Standard</span> &#9733; <small class="control-hint">(Claude Haiku · 1 credit)</small></label>
<label><input type="radio" name="timelineEngine" value="azure_full" id="timelineEngineAzureFull"> <span data-i18n="timelineEngineAzureFull">Deep</span> <small class="control-hint">(gpt-4o · 2 credits · Pro)</small></label> <label><input type="radio" name="timelineEngine" value="azure_full" id="timelineEngineAzureFull"> <span data-i18n="timelineEngineAzureFull">Deep</span> <small class="control-hint">(Claude Sonnet · 2 credits · Pro)</small></label>
</div> </div>
<p class="upload-hint" data-i18n="timelineEngineHint">Quick uses Amazon Bedrock nova-lite (fast, cheap). Standard and Deep use Azure OpenAI.</p> <p class="upload-hint" data-i18n="timelineEngineHint">Standard uses Claude Haiku 4.5 fast and accurate for most documents. Deep uses Claude Sonnet 4.6 better for complex multi-actor cases.</p>
<details class="advanced-panel" id="timelineAdvanced"> <details class="advanced-panel" id="timelineAdvanced">
<summary class="advanced-toggle" data-i18n="timelineAdvancedToggle">Advanced settings</summary> <summary class="advanced-toggle" data-i18n="timelineAdvancedToggle">Advanced settings</summary>