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();
$input = dbnToolsJsonInput(1500000);
$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)
? (string)$input['engine'] : 'azure_mini';
+23
View File
@@ -849,6 +849,29 @@ p {
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 {
font-size: 0.7rem;
font-weight: 600;
+84 -33
View File
@@ -221,9 +221,9 @@ const REDACT_I18N = {
const TIMELINE_I18N = {
en: {
timelineEngine: 'Engine',
timelineEngineAzureMini: 'Azure gpt-4o-mini',
timelineEngineAzureFull: 'Azure gpt-4o',
timelineEngineHint: 'gpt-4o-mini: 1 credit — fast, handles most timelines well. gpt-4o: 2 credits — higher accuracy for complex multi-actor cases.',
timelineEngineAzureMini: 'Standard',
timelineEngineAzureFull: 'Deep',
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',
timelineFocus: 'Focus',
timelineFocusAll: 'All events',
@@ -262,9 +262,9 @@ const TIMELINE_I18N = {
},
no: {
timelineEngine: 'Motor',
timelineEngineAzureMini: 'Azure gpt-4o-mini',
timelineEngineAzureFull: 'Azure gpt-4o',
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.',
timelineEngineAzureMini: 'Standard',
timelineEngineAzureFull: 'Dyp',
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',
timelineFocus: 'Fokus',
timelineFocusAll: 'Alle hendelser',
@@ -303,9 +303,9 @@ const TIMELINE_I18N = {
},
uk: {
timelineEngine: 'Рушій',
timelineEngineAzureMini: 'Azure gpt-4o-mini',
timelineEngineAzureFull: 'Azure gpt-4o',
timelineEngineHint: 'gpt-4o-mini: 1 кредит — швидко, добре справляється з більшістю хронологій. gpt-4o: 2 кредити — вища точність для складних справ з багатьма учасниками.',
timelineEngineAzureMini: 'Стандарт',
timelineEngineAzureFull: 'Глибокий',
timelineEngineHint: 'Стандарт використовує Claude Haiku 4.5 — швидко і точно для більшості документів (1 кредит). Глибокий використовує Claude Sonnet 4.6 — краще для складних справ (2 кредити).',
timelineAdvancedToggle: 'Розширені налаштування',
timelineFocus: 'Фокус',
timelineFocusAll: 'Всі події',
@@ -344,9 +344,9 @@ const TIMELINE_I18N = {
},
pl: {
timelineEngine: 'Silnik',
timelineEngineAzureMini: 'Azure gpt-4o-mini',
timelineEngineAzureFull: 'Azure gpt-4o',
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.',
timelineEngineAzureMini: 'Standard',
timelineEngineAzureFull: 'Zaawansowany',
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',
timelineFocus: 'Fokus',
timelineFocusAll: 'Wszystkie zdarzenia',
@@ -1854,38 +1854,89 @@ function renderTimeline(events, grouped = false) {
}
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;
const items = events.map((ev) => {
const conf = ev.confidence || 'medium';
let groupHeader = '';
if (grouped && /^\d{4}-\d{2}/.test(ev.date || '')) {
const year = ev.date.slice(0, 4);
const mon = ev.date.slice(5, 7);
const items = groups.map((grp) => {
let monthHeader = '';
if (grouped && /^\d{4}-\d{2}/.test(grp.date)) {
const year = grp.date.slice(0, 4);
const mon = grp.date.slice(5, 7);
const key = `${year}-${mon}`;
if (key !== lastGroupKey) {
const prevYear = lastGroupKey ? lastGroupKey.slice(0, 4) : null;
const label = (year !== prevYear)
const label = year !== prevYear
? year
: `${MONTH_NAMES[parseInt(mon, 10) - 1] || mon} ${year}`;
groupHeader = `<li class="timeline-group-header" role="presentation"><span>${escapeHtml(label)}</span></li>`;
: `${MONTHS[parseInt(mon, 10) - 1] || mon} ${year}`;
monthHeader = `<li class="timeline-group-header" role="presentation"><span>${escapeHtml(label)}</span></li>`;
lastGroupKey = key;
}
}
const excerptHtml = ev.source_excerpt
? `<small class="timeline-excerpt${showSources ? '' : ' is-hidden'}">${escapeHtml(ev.source_excerpt)}</small>`
// 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>`
: '';
const copyText = [ev.date, ev.actor, ev.event].filter(Boolean).join(' · ');
return `${groupHeader}<li class="timeline-item confidence-${escapeHtml(conf)}">
// 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
? `<small class="timeline-excerpt${showSources ? '' : ' is-hidden'}">${escapeHtml(ev.source_excerpt)}</small>`
: '';
const copyText = [grp.date, grp.actor, ev.event].filter(Boolean).join(' · ');
return `${monthHeader}<li class="timeline-item 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 event" aria-label="Copy event to clipboard">&#128203;</button>
</div>
<span class="timeline-actor">${escapeHtml(grp.actor || 'unknown actor')}</span>
<p class="timeline-event">${escapeHtml(ev.event || '')}</p>
${excerptHtml}
</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(ev.date || 'unknown')}${ev.time ? `<span class="timeline-time"> ${escapeHtml(ev.time)}</span>` : ''}</strong>
${ev.date_type ? `<span class="date-type-badge">${escapeHtml(ev.date_type)}</span>` : ''}
<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>
<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(ev.actor || 'unknown actor')}</span>
<p class="timeline-event">${escapeHtml(ev.event || '')}</p>
${excerptHtml}
<span class="timeline-actor">${escapeHtml(grp.actor || 'unknown actor')}</span>
<ul class="timeline-bullets">${bullets}</ul>
</li>`;
}).join('');
return `<ol class="timeline-list">${items}</ol>`;
+19 -7
View File
@@ -506,18 +506,25 @@ PROMPT;
['role' => 'system', 'content' => $system],
['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];
$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}");
try {
if ($engine === 'nova_lite') {
$response = dbnToolsCallGpuLlm($messages, ['model' => 'nova-lite', 'max_tokens' => $maxTokens, 'temperature' => 0.1, 'timeout' => 120]);
} elseif ($engine === 'azure_full') {
$response = $this->azure->withDeployment('gpt-4o')->chat($messages, $chatOptions);
} elseif ($engine === 'azure_full' || $engine === 'claude_sonnet') {
$deploy = $isBedrock ? DbnBedrockModelRouter::LITELLM_SONNET : 'gpt-4o';
$response = $this->azure->withDeployment($deploy)->chat($messages, $chatOptions);
} 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) {
$msg = $e->getMessage();
@@ -570,7 +577,7 @@ PROMPT;
$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) {
'deadlines' => 'legal deadlines',
@@ -620,7 +627,12 @@ PROMPT;
?callable $onProgress,
int $inputDateHintCount
): 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);
$chunks = $this->timelineTextChunks($text, $chunkSize, 900);
$chunkCount = count($chunks);
+3 -3
View File
@@ -26,10 +26,10 @@ require_once __DIR__ . '/includes/layout.php';
<div class="control-row" id="timelineEngineControl">
<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="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_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_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">(Claude Sonnet · 2 credits · Pro)</small></label>
</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">
<summary class="advanced-toggle" data-i18n="timelineAdvancedToggle">Advanced settings</summary>