Add chunked timeline routing

This commit is contained in:
2026-05-25 12:34:41 +02:00
parent 75b19f1dcf
commit 17ad54cf36
7 changed files with 521 additions and 31 deletions
+108 -12
View File
@@ -400,6 +400,7 @@ let lastOriginalText = '';
let lastRedactPayload = null;
let lastRunEngine = null;
let lastToolPayload = null;
let pendingTimelineQuote = null;
const VOCAB_PRESETS = {
barnerett: 'Barnevernet, Fylkesnemnda, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, sakkyndig, advokat, prosessfullmektig, dommer, vitne, tolk, bistandsadvokat, fosterforeldre, fosterhjem, akuttvedtak, statsforvalter, Bufetat, saksbehandler, rettslig medhold, begjæring, samtykke, tilsynsfører',
@@ -764,10 +765,60 @@ function timelineEngineLabel(engine) {
}
function timelineClientRoute(engine, charCount) {
let effective = engine;
if (charCount > 55000) effective = 'azure_full';
else if (charCount > 25000 && effective === 'nova_lite') effective = 'azure_mini';
return { effective, upgraded: effective !== engine };
return timelineClientQuote(engine, charCount);
}
function timelineClientQuote(engine, charCount) {
const valid = ['nova_lite', 'azure_mini', 'azure_full'];
const requested = valid.includes(engine) ? engine : 'azure_mini';
const singleLimits = { nova_lite: 25000, azure_mini: 55000, azure_full: 128000 };
const maxLimits = { nova_lite: 100000, azure_mini: 300000, azure_full: 600000 };
const chunkSizes = { nova_lite: 10000, azure_mini: 16000, azure_full: 30000 };
const ranks = { nova_lite: 1, azure_mini: 2, azure_full: 3 };
const baseCredits = requested === 'azure_full' ? 2 : 1;
let effective = requested;
if (charCount > 600000) {
return {
error: true,
message: `This timeline input is ${charCount.toLocaleString()} characters. Split the file or use fewer selected documents; the current maximum is 600,000 characters.`,
};
}
if (charCount > maxLimits[effective]) {
effective = charCount <= maxLimits.azure_mini ? 'azure_mini' : 'azure_full';
}
if (charCount > maxLimits[effective]) effective = 'azure_full';
let credits = 1;
if (effective === 'nova_lite') {
credits = charCount <= singleLimits.nova_lite ? 1 : 2;
} else if (effective === 'azure_mini') {
credits = charCount <= singleLimits.azure_mini ? 1 : (charCount <= 180000 ? 2 : 3);
} else {
credits = charCount <= singleLimits.azure_full ? 2 : (charCount <= 350000 ? 4 : 6);
}
const chunked = charCount > singleLimits[effective];
return {
requested,
effective,
upgraded: ranks[effective] > ranks[requested],
charCount,
credits,
baseCredits,
chunked,
chunkCount: chunked ? Math.ceil(charCount / chunkSizes[effective]) : 1,
requiresConfirmation: credits > baseCredits || ranks[effective] > ranks[requested],
};
}
function timelineQuoteMessage(quote) {
return [
`Timeline will use ${timelineEngineLabel(quote.effective)} for ${Number(quote.charCount || 0).toLocaleString()} characters.`,
quote.chunked ? `It will process about ${quote.chunkCount} chunks.` : 'It can run in a single pass.',
`Cost: ${quote.credits} credit${quote.credits === 1 ? '' : 's'}.`,
'Continue?'
].join('\n');
}
function currentTimelineFocus() {
@@ -1122,15 +1173,36 @@ async function runTool(event) {
let timelineRouteNotice = '';
if (state.activeTool === 'timeline') {
payload.engine = currentTimelineEngine();
const clientRoute = timelineClientRoute(payload.engine, text.length);
const clientRoute = timelineClientQuote(payload.engine, text.length);
if (clientRoute.error) {
els.status.textContent = clientRoute.message;
return;
}
const pendingQuoteApplies = pendingTimelineQuote
&& pendingTimelineQuote.text === text
&& pendingTimelineQuote.requested === payload.engine;
if (pendingQuoteApplies) {
payload.accepted_timeline_quote = true;
payload.accepted_credits = pendingTimelineQuote.credits;
payload.accepted_effective_engine = pendingTimelineQuote.effective;
pendingTimelineQuote = null;
} else if (clientRoute.requiresConfirmation) {
if (!window.confirm(timelineQuoteMessage(clientRoute))) {
els.status.textContent = 'Timeline run cancelled before any credits were charged.';
return;
}
payload.accepted_timeline_quote = true;
payload.accepted_credits = clientRoute.credits;
payload.accepted_effective_engine = clientRoute.effective;
}
payload.focus = currentTimelineFocus();
payload.confidence_filter = currentConfidenceFilter();
payload.include_relative = currentIncludeRelative();
payload.include_background = currentIncludeBackground();
payload.user_notes = (document.getElementById('timelineNotes')?.value || '').trim();
payload.use_my_case = (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false;
timelineRouteNotice = clientRoute.upgraded
? `This input is ${text.length.toLocaleString()} characters, so Timeline will use ${timelineEngineLabel(clientRoute.effective)} for reliability.`
timelineRouteNotice = clientRoute.upgraded || clientRoute.chunked
? `This input is ${text.length.toLocaleString()} characters, so Timeline will use ${timelineEngineLabel(clientRoute.effective)}${clientRoute.chunked ? ` across about ${clientRoute.chunkCount} chunks` : ''}.`
: '';
}
@@ -1157,7 +1229,30 @@ async function runTool(event) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
const quote = errData.timeline_quote;
if (errData.error?.code === 'timeline_quote_required' && quote) {
const confirmQuote = {
effective: quote.effective_engine,
charCount: quote.input_char_count,
credits: quote.credits || quote.estimated_credits,
chunked: Boolean(quote.chunked_timeline),
chunkCount: quote.timeline_chunk_count || 1,
};
if (window.confirm(timelineQuoteMessage(confirmQuote))) {
pendingTimelineQuote = {
text,
requested: payload.engine,
effective: confirmQuote.effective,
credits: Number(confirmQuote.credits || 0),
};
return runTool(event);
}
throw new Error('Timeline run cancelled before any credits were charged.');
}
throw new Error(errData.error?.message || `HTTP ${resp.status}`);
}
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = '', event = '';
@@ -1194,8 +1289,8 @@ async function runTool(event) {
renderResults(data);
renderTrace(data.trace || []);
const routeMeta = data.trace_metadata || {};
const serverRouteNotice = state.activeTool === 'timeline' && routeMeta.auto_upgraded_engine
? ` Used ${timelineEngineLabel(routeMeta.effective_engine)} for ${Number(routeMeta.input_char_count || 0).toLocaleString()} characters.`
const serverRouteNotice = state.activeTool === 'timeline' && (routeMeta.auto_upgraded_engine || routeMeta.chunked_timeline || routeMeta.credits_charged)
? ` Used ${timelineEngineLabel(routeMeta.effective_engine)} for ${Number(routeMeta.input_char_count || 0).toLocaleString()} characters${routeMeta.chunked_timeline ? ` across ${routeMeta.timeline_chunk_count || 1} chunks` : ''}; charged ${routeMeta.credits_charged || routeMeta.estimated_credits || 1} credit(s).`
: '';
els.status.textContent = `Done in ${data.latency_ms || 0} ms.${serverRouteNotice}`;
if (['ask', 'redact', 'timeline'].includes(state.activeTool)) {
@@ -1299,6 +1394,7 @@ async function handleFiles(fileList) {
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
formData.append('tool', state.activeTool);
const resp = await fetch('api/extract.php', {
method: 'POST',
@@ -1318,7 +1414,7 @@ async function handleFiles(fileList) {
const combined = parts[0].text;
const MAX_COMBINED = 128000;
const MAX_COMBINED = state.activeTool === 'timeline' ? 600000 : 128000;
const combinedTruncated = combined.length > MAX_COMBINED;
els.input.value = combinedTruncated ? combined.slice(0, MAX_COMBINED) : combined;
@@ -1328,7 +1424,7 @@ async function handleFiles(fileList) {
els.uploadPrompt.classList.add('is-hidden');
els.uploadFileInfo.classList.remove('is-hidden');
const truncNote = (anyTruncated || combinedTruncated) ? ' — truncated to 128000 char limit' : '';
const truncNote = (anyTruncated || combinedTruncated) ? ` - truncated to ${MAX_COMBINED.toLocaleString()} char limit` : '';
els.status.textContent = `Extracted ${totalChars.toLocaleString()} chars from ${parts[0].filename}${truncNote}.`;
} catch (err) {
els.status.textContent = err.message;