From a8b1bb87a6c514ff0da94626fa666a427e7424fa Mon Sep 17 00:00:00 2001 From: davegilligan Date: Mon, 15 Jun 2026 12:23:46 +0200 Subject: [PATCH] feat(tools): converge two-tier Quick/Pro selector onto .no fork Port the dobetterlegal-tools two-tier quality stack to dobetternorge.no: QUALITY_TIERS registry + resolveTier (ToolModels), dbnToolsResolveToolRun (bootstrap), tier read+charge in the 6 analytical endpoints, Quick/Pro UI + payload.tier on the 6 tool pages/JS, and the bounded corpusContextForSummarize RAG fix (per-passage trim + total budget + reranker_enabled). Back-compat: requests without `tier` keep legacy engine behavior. Co-Authored-By: Claude Opus 4.7 --- advocate.php | 10 ++-- api/ask.php | 7 +-- api/barnevernet.php | 22 +++++++- api/deep-research.php | 9 ++-- api/discrepancy.php | 22 +++++++- api/korrespond.php | 23 +++++--- api/summarize.php | 9 ++-- assets/js/advocate.js | 24 ++++----- assets/js/barnevernet.js | 20 ++++--- assets/js/deep-research.js | 18 +++---- assets/js/discrepancy.js | 10 ++-- assets/js/korrespond.js | 2 +- assets/js/summarize.js | 4 +- barnevernet.php | 12 ++--- deep-research.php | 12 ++--- discrepancy.php | 12 ++--- includes/LegalTools.php | 50 +++++++++++++++-- includes/ToolModels.php | 108 +++++++++++++++++++++++++++++++++++++ includes/bootstrap.php | 44 +++++++++++++++ korrespond.php | 12 ++--- summarize.php | 12 ++--- 21 files changed, 339 insertions(+), 103 deletions(-) diff --git a/advocate.php b/advocate.php index a73a7a0..10dcb6e 100644 --- a/advocate.php +++ b/advocate.php @@ -50,12 +50,12 @@ require_once __DIR__ . '/includes/layout.php'; 0 / 4,000 -
- Engine - - +
+ Quality + +
-

Both engines run on AWS Bedrock via Claude. Most of the time is spent on multiple question-answering passes — 6–10 sub-questions each requiring a full retrieval and answer cycle. Haiku is faster and handles most cases well. Sonnet produces a more thorough brief with deeper ECHR precedent analysis and stronger multi-party argumentation.

+

Most of the time is spent on multiple question-answering passes — 6–10 sub-questions each requiring a full retrieval and answer cycle. Quick uses Claude Haiku 4.5 — faster and handles most cases well (3 credits). Pro uses Claude Sonnet 4.6 — a more thorough brief with deeper ECHR precedent analysis and stronger multi-party argumentation (6 credits).

Corpus slices

diff --git a/api/ask.php b/api/ask.php index b902a95..1cee7f5 100644 --- a/api/ask.php +++ b/api/ask.php @@ -6,10 +6,11 @@ require_once __DIR__ . '/../includes/ToolModels.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); -$ftUid = dbnToolsFreeTierCheck('ask'); -$engine = ToolModels::engineForUser($ftUid, 'azure_mini'); $input = dbnToolsJsonInput(25000); $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); +$run = dbnToolsResolveToolRun('ask', $input); +$ftUid = $run['ftUid']; +$engine = $run['engine']; dbnToolsWithChargedTelemetry('ask', $language, $ftUid, function () use ($input, $language, $engine): array { $question = dbnToolsInjectDocContent($input, dbnToolsString($input, 'question', 4000, false)); @@ -20,4 +21,4 @@ dbnToolsWithChargedTelemetry('ask', $language, $ftUid, function () use ($input, ? trim($input['profile']) : null; return (new DbnLegalToolsService())->ask($question, $language, $engine, $persona); -}); +}, $run['credits'], $run['metadata']); diff --git a/api/barnevernet.php b/api/barnevernet.php index 6677966..bcd8f12 100644 --- a/api/barnevernet.php +++ b/api/barnevernet.php @@ -53,7 +53,23 @@ try { $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); $advocateRole = trim((string)($input['advocate_role'] ?? '')); - $engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini')); + if (isset($input['tier'])) { + $run = ToolModels::resolveTier(dbnToolsFreeTierUid(), 'barnevernet', (string)$input['tier']); + $engine = $run['engine']; + $tierCredits = $run['credits']; + $tierMeta = ['tier' => $run['tier'], 'engine' => $engine]; + if ($ftUid > 0) { + $gate = FreeTier::checkAmount($ftUid, 'barnevernet', $tierCredits); + if (empty($gate['ok'])) { + $emit('error', ['code' => $gate['reason'] ?? 'no_credits', 'message' => 'Insufficient credits for the selected tier.']); + exit; + } + } + } else { + $engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini')); + $tierCredits = null; + $tierMeta = []; + } $sliceInput = $input['slices'] ?? []; $controls = is_array($input['controls'] ?? null) ? $input['controls'] : []; $additionalNotes = mb_substr(dbnToolsInjectDocContent($input, trim((string)($input['additional_notes'] ?? ''))), 0, 8000, 'UTF-8'); @@ -154,7 +170,9 @@ try { 'bvj_doc_type' => $result['doc_meta']['doc_type'] ?? null, ]); - $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'barnevernet'); + $ftRemaining = $tierCredits === null + ? dbnToolsFreeTierDeduct($ftUid, 'barnevernet') + : dbnToolsFreeTierDeductAmount($ftUid, 'barnevernet', $tierCredits, $tierMeta); if ($ftRemaining >= 0) { $result['balance'] = $ftRemaining; } diff --git a/api/deep-research.php b/api/deep-research.php index 0dfee86..212b59d 100644 --- a/api/deep-research.php +++ b/api/deep-research.php @@ -65,8 +65,9 @@ try { throw new DbnToolsHttpException('advocate_role is too long.', 422, 'advocate_role_too_long'); } $chargeTool = $advocateRole !== '' ? 'advocate' : 'deep-research'; - $ftUid = dbnToolsFreeTierCheck($chargeTool); - $engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini')); + $run = dbnToolsResolveToolRun($chargeTool, $input); + $ftUid = $run['ftUid']; + $engine = $run['engine']; $priorContext = is_array($input['prior_context'] ?? null) ? $input['prior_context'] : null; $branchNotes = mb_substr(trim((string)($input['branch_notes'] ?? '')), 0, 1000, 'UTF-8'); $subQsOverride = is_array($input['sub_questions_override'] ?? null) ? $input['sub_questions_override'] : []; @@ -160,7 +161,9 @@ try { 'advocate_role' => $advocateRole !== '' ? $advocateRole : null, ]); - $ftRemaining = dbnToolsFreeTierDeduct($ftUid, $chargeTool); + $ftRemaining = $run['credits'] === null + ? dbnToolsFreeTierDeduct($ftUid, $chargeTool) + : dbnToolsFreeTierDeductAmount($ftUid, $chargeTool, $run['credits'], $run['metadata']); if ($ftRemaining >= 0) { $result['balance'] = $ftRemaining; } diff --git a/api/discrepancy.php b/api/discrepancy.php index 3e182fb..a108f5c 100644 --- a/api/discrepancy.php +++ b/api/discrepancy.php @@ -41,7 +41,23 @@ try { } $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); - $engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini')); + if (isset($input['tier'])) { + $run = ToolModels::resolveTier(dbnToolsFreeTierUid(), 'discrepancy', (string)$input['tier']); + $engine = $run['engine']; + $tierCredits = $run['credits']; + $tierMeta = ['tier' => $run['tier'], 'engine' => $engine]; + if ($ftUid > 0) { + $gate = FreeTier::checkAmount($ftUid, 'discrepancy', $tierCredits); + if (empty($gate['ok'])) { + $emit('error', ['code' => $gate['reason'] ?? 'no_credits', 'message' => 'Insufficient credits for the selected tier.']); + exit; + } + } + } else { + $engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini')); + $tierCredits = null; + $tierMeta = []; + } $sliceInput = $input['slices'] ?? []; // Extract file A @@ -144,7 +160,9 @@ try { 'deployment' => $result['trace_metadata']['deployment'] ?? null, ]); - $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'discrepancy'); + $ftRemaining = $tierCredits === null + ? dbnToolsFreeTierDeduct($ftUid, 'discrepancy') + : dbnToolsFreeTierDeductAmount($ftUid, 'discrepancy', $tierCredits, $tierMeta); if ($ftRemaining >= 0) { $result['balance'] = $ftRemaining; } diff --git a/api/korrespond.php b/api/korrespond.php index a249c84..7643422 100644 --- a/api/korrespond.php +++ b/api/korrespond.php @@ -165,15 +165,24 @@ try { } // ── Deduct credit now (Pass 2 starts) ─────────────────────────────────────── - $ftUid = dbnToolsFreeTierCheck('korrespond'); - $engine = ToolModels::engineForUser($ftUid, 'azure_mini'); - $inputEngine = (string)($input['engine'] ?? ''); - if (in_array($inputEngine, ['azure_mini', 'claude_sonnet'], true)) { - $engine = $inputEngine; + if (isset($input['tier'])) { + $run = dbnToolsResolveToolRun('korrespond', $input); + $ftUid = $run['ftUid']; + $engine = $run['engine']; + } else { + $ftUid = dbnToolsFreeTierCheck('korrespond'); + $engine = ToolModels::engineForUser($ftUid, 'azure_mini'); + $inputEngine = (string)($input['engine'] ?? ''); + if (in_array($inputEngine, ['azure_mini', 'claude_sonnet'], true)) { + $engine = $inputEngine; + } + $run = ['credits' => null, 'metadata' => []]; } $length = in_array($input['length'] ?? '', ['concise', 'standard', 'detailed'], true) ? (string)$input['length'] : 'standard'; - $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond'); + $ftRemaining = $run['credits'] === null + ? dbnToolsFreeTierDeduct($ftUid, 'korrespond') + : dbnToolsFreeTierDeductAmount($ftUid, 'korrespond', $run['credits'], $run['metadata']); $creditDeducted = true; $personaSlug = (isset($input['profile']) && is_string($input['profile']) && trim($input['profile']) !== '') @@ -212,7 +221,7 @@ try { 'case_doc_ids' => $GLOBALS['dbn_last_case_doc_ids'] ?? [], 'model' => $engine, 'latency_ms' => $result['latency_ms'], - 'credits_charged' => 1, + 'credits_charged' => $run['credits'] ?? 1, ]); } catch (Throwable) { /* non-critical */ } diff --git a/api/summarize.php b/api/summarize.php index 2e6de66..93f7c75 100644 --- a/api/summarize.php +++ b/api/summarize.php @@ -6,11 +6,12 @@ require_once __DIR__ . '/../includes/ToolModels.php'; dbnToolsRequireMethod('POST'); dbnToolsRequireAuth(); -$ftUid = dbnToolsFreeTierCheck('summarize'); $input = dbnToolsJsonInput(400000); $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); -$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini')); +$run = dbnToolsResolveToolRun('summarize', $input); +$ftUid = $run['ftUid']; +$engine = $run['engine']; $depth = in_array($input['depth'] ?? '', ['brief', 'standard', 'detailed'], true) ? (string)$input['depth'] : 'standard'; $slices = is_array($input['slices'] ?? null) ? array_values(array_filter($input['slices'])) : []; @@ -73,7 +74,9 @@ try { $result = (new DbnLegalToolsService())->summarizeWithContext($text, $language, $engine, $corpusContext, $depth); if ($ftUid > 0) { - $balance = dbnToolsFreeTierDeduct($ftUid, 'summarize'); + $balance = $run['credits'] === null + ? dbnToolsFreeTierDeduct($ftUid, 'summarize') + : dbnToolsFreeTierDeductAmount($ftUid, 'summarize', $run['credits'], $run['metadata']); $result['balance'] = $balance; } diff --git a/assets/js/advocate.js b/assets/js/advocate.js index 22048f4..7078cc8 100644 --- a/assets/js/advocate.js +++ b/assets/js/advocate.js @@ -64,7 +64,7 @@ roleCustom: document.getElementById('advRoleCustom'), slices: Array.from(document.querySelectorAll('.adv-slice')), langButtons: Array.from(document.querySelectorAll('#advLangSwitcher .lang-btn')), - engineRadios: Array.from(document.querySelectorAll('input[name="advEngine"]')), + tierRadios: Array.from(document.querySelectorAll('input[name="advTier"]')), subQ: document.getElementById('advSubQ'), subQVal: document.getElementById('advSubQValue'), chunkLimit: document.getElementById('advChunkLimit'), @@ -337,9 +337,9 @@ return out; } - function getEngine() { - const checked = els.engineRadios.find((r) => r.checked); - return checked ? checked.value : 'azure_mini'; + function getTier() { + const checked = els.tierRadios.find((r) => r.checked); + return checked ? checked.value : 'quick'; } function getControls() { @@ -371,10 +371,10 @@ return; } - const engine = getEngine(); - const expectedDuration = engine === 'azure_full' - ? '60–180 seconds with Azure gpt-4o' - : (engine === 'gpu' ? '30–90 seconds on GPU' : '15–45 seconds with Azure gpt-4o-mini'); + const tier = getTier(); + const expectedDuration = tier === 'pro' + ? '3–5 minutes with Claude Sonnet' + : '2–4 minutes with Claude Haiku'; setStatus(`Building advocate brief for ${advocateRole}… (${expectedDuration})`, 'busy'); els.runButton.disabled = true; @@ -387,7 +387,7 @@ query, paste_text: '', slices, - engine, + tier, language: lang, controls: getControls(), advocate_role: advocateRole, @@ -505,7 +505,7 @@ els.runButton.disabled = false; renderTrace(finalResult.trace || []); renderResults(finalResult); - saveToCache(finalResult, { query, role: advocateRole, engine, slices, lang }); + saveToCache(finalResult, { query, role: advocateRole, tier, slices, lang }); function handleStreamEvent(evt) { if (!evt || !evt.event) return; @@ -957,7 +957,7 @@ els.input.value = formState.query || ''; updateCharCount(); if (formState.role) els.roleSelect.value = formState.role; - const radio = els.engineRadios.find((r) => r.value === formState.engine); + const radio = els.tierRadios.find((r) => r.value === formState.tier); if (radio) radio.checked = true; if (formState.slices) { els.slices.forEach((btn) => { @@ -1074,7 +1074,7 @@ body: JSON.stringify({ query, language: lang, - engine: getEngine(), + engine: getTier() === 'pro' ? 'claude_sonnet' : 'claude_haiku', controls: getControls(), advocate_role: advocateRole, }), diff --git a/assets/js/barnevernet.js b/assets/js/barnevernet.js index 1331e2a..4b23637 100644 --- a/assets/js/barnevernet.js +++ b/assets/js/barnevernet.js @@ -55,7 +55,7 @@ roleCustom: document.getElementById('bvjRoleCustom'), slices: Array.from(document.querySelectorAll('.adv-slice')), langButtons: Array.from(document.querySelectorAll('#bvjLangSwitcher .lang-btn')), - engineRadios: Array.from(document.querySelectorAll('input[name="bvjEngine"]')), + tierRadios: Array.from(document.querySelectorAll('input[name="bvjTier"]')), subQ: document.getElementById('bvjSubQ'), subQVal: document.getElementById('bvjSubQValue'), chunkLimit: document.getElementById('bvjChunkLimit'), @@ -186,9 +186,9 @@ }; } - function getEngine() { - const checked = els.engineRadios.find((r) => r.checked); - return checked ? checked.value : 'azure_mini'; + function getTier() { + const checked = els.tierRadios.find((r) => r.checked); + return checked ? checked.value : 'quick'; } // ── File upload ──────────────────────────────────────────────────────────── @@ -385,13 +385,11 @@ return; } - const engine = getEngine(); + const tier = getTier(); const additionalNotes = (els.notes ? els.notes.value : '').trim(); - const expectedDuration = engine === 'azure_full' - ? '90–180 seconds with Azure gpt-4o' - : (engine === 'gpu' ? '45–90 seconds on GPU' - : (engine === 'dbn_legal' ? '60–120 seconds with Norwegian specialist' - : '30–60 seconds with Azure gpt-4o-mini')); + const expectedDuration = tier === 'pro' + ? '90–180 seconds with Claude Sonnet' + : '30–60 seconds with Claude Haiku'; setStatus(`Analysing document for ${advocateRole}… (${expectedDuration})`, 'busy'); els.runButton.disabled = true; @@ -403,7 +401,7 @@ const payload = { advocate_role: advocateRole, - engine, + tier, language: lang, slices, controls: getControls(), diff --git a/assets/js/deep-research.js b/assets/js/deep-research.js index eb5a97b..b6aab20 100644 --- a/assets/js/deep-research.js +++ b/assets/js/deep-research.js @@ -42,7 +42,7 @@ traceList: document.getElementById('traceList'), slices: Array.from(document.querySelectorAll('.dr-slice')), langButtons: Array.from(document.querySelectorAll('#drLangSwitcher .lang-btn')), - engineRadios: Array.from(document.querySelectorAll('input[name="drEngine"]')), + tierRadios: Array.from(document.querySelectorAll('input[name="drTier"]')), personaControl: document.getElementById('drPersonaControl'), personaSelect: document.getElementById('drPersonaSelect'), subQ: document.getElementById('drSubQ'), @@ -280,9 +280,9 @@ return out; } - function getEngine() { - const checked = els.engineRadios.find((r) => r.checked); - return checked ? checked.value : 'azure_mini'; + function getTier() { + const checked = els.tierRadios.find((r) => r.checked); + return checked ? checked.value : 'quick'; } function getControls() { @@ -308,10 +308,10 @@ return; } - const engine = getEngine(); - const expectedDuration = engine === 'azure_full' - ? '60–180 seconds with Azure gpt-4o' - : (engine === 'gpu' ? '30–90 seconds on GPU' : '15–45 seconds with Azure gpt-4o-mini'); + const tier = getTier(); + const expectedDuration = tier === 'pro' + ? '60–180 seconds with Claude Sonnet' + : '15–45 seconds with Claude Haiku'; setStatus(`Running deep research… (${expectedDuration})`, 'busy'); els.runButton.disabled = true; @@ -325,7 +325,7 @@ query, paste_text: '', slices, - engine, + tier, language: lang, controls: getControls(), }; diff --git a/assets/js/discrepancy.js b/assets/js/discrepancy.js index e77a8d6..7fb1841 100644 --- a/assets/js/discrepancy.js +++ b/assets/js/discrepancy.js @@ -49,7 +49,7 @@ results: document.getElementById('dcResults'), traceList: document.getElementById('traceList'), langButtons: Array.from(document.querySelectorAll('#dcLangSwitcher .lang-btn')), - engineRadios: Array.from(document.querySelectorAll('input[name="dcEngine"]')), + tierRadios: Array.from(document.querySelectorAll('input[name="dcTier"]')), slices: Array.from(document.querySelectorAll('.adv-slice')), // File A zoneA: document.getElementById('dcZoneA'), @@ -188,12 +188,10 @@ return; } - const engine = (els.engineRadios.find((r) => r.checked) || {}).value || 'azure_mini'; + const tier = (els.tierRadios.find((r) => r.checked) || {}).value || 'quick'; const slices = getSelectedSlices(); - const expectedDuration = engine === 'azure_full' ? '2-3 minutes' - : engine === 'gpu' ? '~90 seconds' - : '60-90 seconds'; + const expectedDuration = tier === 'pro' ? '2-3 minutes' : '60-90 seconds'; setStatus(`Comparing documents… (${expectedDuration})`, 'busy'); els.runButton.disabled = true; @@ -203,7 +201,7 @@ renderTrace(stepState); const payload = { - engine, language: lang, slices, + tier, language: lang, slices, use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false, }; const form = new FormData(); diff --git a/assets/js/korrespond.js b/assets/js/korrespond.js index 932af30..45a9664 100644 --- a/assets/js/korrespond.js +++ b/assets/js/korrespond.js @@ -280,7 +280,7 @@ clarifications: pendingClarifications, force_draft: !!forceDraft, use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false, - engine: (document.querySelector('[name="korrEngine"]:checked')?.value ?? 'azure_mini'), + tier: (document.querySelector('[name="korrTier"]:checked')?.value ?? 'quick'), length: (document.querySelector('[name="korrLength"]:checked')?.value ?? 'standard'), }; if (korrDocIds.length) payload.doc_ids = korrDocIds; diff --git a/assets/js/summarize.js b/assets/js/summarize.js index 3f69455..e744e92 100644 --- a/assets/js/summarize.js +++ b/assets/js/summarize.js @@ -179,13 +179,13 @@ return; } - var engine = (document.querySelector('input[name="sumEngine"]:checked') || {}).value || 'azure_mini'; + var tier = (document.querySelector('input[name="sumTier"]:checked') || {}).value || 'quick'; var slices = activeSlices(); var payload = { text: combined, language: _currentLang, - engine: engine, + tier: tier, depth: (document.querySelector('input[name="sumDepth"]:checked') || {}).value || 'standard', slices: slices, }; diff --git a/barnevernet.php b/barnevernet.php index a1a0988..60232b6 100644 --- a/barnevernet.php +++ b/barnevernet.php @@ -36,14 +36,12 @@ require_once __DIR__ . '/includes/layout.php';

The agent will analyse the document from your perspective — identifying supporting statutes, procedural red flags, and ECHR arguments for your position.

-
- Engine - - - - +
+ Quality + +
-

Engine applies to the final advocacy synthesis only. Norwegian specialist v3 is the recommended choice for Barnevernet documents — it is fine-tuned on § 4-25, Strand Lobben, forvaltningsloven § 17/§ 41, and procedural red-flag detection. Classification, party extraction, and timeline always use azure-mini.

+

Quality applies to the final advocacy synthesis only. Quick uses Claude Haiku 4.5 — fast and accurate for most Barnevernet documents (3 credits). Pro uses Claude Sonnet 4.6 — better at § 4-25 threshold errors, Strand Lobben / forvaltningsloven § 17/§ 41 procedural red flags, and ECHR argumentation (6 credits). Classification, party extraction, and timeline always use the quick engine.

Corpus slices

diff --git a/deep-research.php b/deep-research.php index 3de94ba..decd5b6 100644 --- a/deep-research.php +++ b/deep-research.php @@ -16,14 +16,12 @@ require_once __DIR__ . '/includes/layout.php';
-
- Engine - - - - +
+ Quality + +
-

Azure mini is the default and finishes fastest. Azure full is the most thorough. Norwegian specialist v3 is a Qwen2.5 fine-tune optimised for barnevernsloven, ECHR, and forvaltningsloven — best for cases involving § 4-25, Strand Lobben, or procedural challenges.

+

Quick uses Claude Haiku 4.5 — fast and accurate for most research (6 credits). Pro uses Claude Sonnet 4.6 — the most thorough synthesis, best for cases involving § 4-25, Strand Lobben, or procedural challenges (12 credits).

-
- Engine - - - - +
+ Quality + +
-

Engine applies to the final synthesis only. Norwegian specialist v3 excels at identifying legally significant discrepancies in Barnevernet documents — procedural violations, threshold errors, and missing statutory justifications. Classification, party extraction, timelines, and cross-referencing always use azure-mini.

+

Quick uses Claude Haiku 4.5 — fast and accurate for most document comparisons (4 credits). Pro uses Claude Sonnet 4.6 — better at legally significant discrepancies in Barnevernet documents, procedural violations, and threshold errors (8 credits).

Corpus slices (used for legal significance context) diff --git a/includes/LegalTools.php b/includes/LegalTools.php index 54c311f..2489fdb 100644 --- a/includes/LegalTools.php +++ b/includes/LegalTools.php @@ -1964,7 +1964,14 @@ PROMPT; * Search the shared legal corpus and return top-N passages as a formatted * context string. Returns '' on failure so the caller can degrade gracefully. */ - public function corpusContextForSummarize(string $query, int $limit = 8, ?string $persona = null): string + public function corpusContextForSummarize( + string $query, + int $limit = 8, + ?string $persona = null, + int $maxCharsPerPassage = 1800, + int $maxTotalChars = 9000, + ?array &$debug = null + ): string { try { $client = dbnToolsRequireClient(); @@ -1989,14 +1996,51 @@ PROMPT; 'search_method' => $searchMethod, 'min_private' => 0, 'include_beta_website' => true, + 'reranker_enabled' => true, ])); + // Bound the injected context: trim each passage and cap the total so a single + // oversized chunk cannot eat the budget and starve relevant lower-ranked passages. $parts = []; + $total = 0; foreach ($chunks as $c) { $title = (string)($c['title'] ?? ($c['source'] ?? 'Legal source')); $content = (string)($c['content'] ?? ($c['text'] ?? '')); - if ($content !== '') { - $parts[] = "=== {$title} ===\n{$content}"; + $rawChars = mb_strlen($content, 'UTF-8'); + $clean = trim(strip_tags($content)); + if (mb_strlen($clean, 'UTF-8') > $maxCharsPerPassage) { + $clean = rtrim(mb_substr($clean, 0, $maxCharsPerPassage - 1, 'UTF-8')) . '…'; } + $keptChars = $clean === '' ? 0 : mb_strlen($clean, 'UTF-8'); + $included = false; + if ($clean !== '' && ($total + $keptChars) <= $maxTotalChars) { + $parts[] = "=== {$title} ===\n{$clean}"; + $total += $keptChars; + $included = true; + } + if ($debug !== null) { + $debug['chunks'][] = [ + 'title' => $title, + 'source_name' => $c['source_name'] ?? null, + 'source_type' => $c['source_type'] ?? null, + 'source_group' => $c['source_group'] ?? ($c['meta']['source_group'] ?? null), + 'category' => $c['category'] ?? null, + 'similarity' => $c['similarity'] ?? null, + 'reranker_score' => $c['reranker_score'] ?? null, + 'raw_chars' => $rawChars, + 'kept_chars' => $included ? $keptChars : 0, + 'included' => $included, + ]; + } + } + if ($debug !== null) { + $debug['raw_total'] = array_sum(array_column($debug['chunks'] ?? [], 'raw_chars')); + $debug['used_total'] = $total; + $debug['chunk_count'] = count($chunks); + $debug['search_method'] = $searchMethod; + $debug['reranked'] = !empty(array_filter( + $debug['chunks'] ?? [], + static fn($r) => $r['reranker_score'] !== null + )); } return implode("\n\n", $parts); } catch (Throwable $e) { diff --git a/includes/ToolModels.php b/includes/ToolModels.php index 14f649d..5954d8e 100644 --- a/includes/ToolModels.php +++ b/includes/ToolModels.php @@ -19,6 +19,114 @@ final class ToolModels public const TIMELINE_STANDARD_MAX_CHARS = 300000; public const TIMELINE_DEEP_MAX_CHARS = 600000; + /** + * Canonical redact engine registry. Server-side source of truth for which engines + * exist, their credit cost, and whether the client UI may offer them. Credits are + * ALWAYS resolved from here — never from a client-supplied value. + * + * gpu_legal is the fine-tuned legal model (Phase 6 roadmap) — wired but not yet + * client-selectable until the harness proves a lift. + */ + public const REDACT_ENGINES = [ + 'azure_mini' => ['label' => 'Azure GPT-4o-mini', 'credits' => 1, 'client_selectable' => true, 'speed' => 'fast'], + 'azure_full' => ['label' => 'Azure GPT-4o', 'credits' => 2, 'client_selectable' => true, 'speed' => 'medium'], + 'claude_haiku' => ['label' => 'Claude Haiku 4.5', 'credits' => 1, 'client_selectable' => true, 'speed' => 'fast'], + 'claude_sonnet' => ['label' => 'Claude Sonnet 4.6', 'credits' => 2, 'client_selectable' => true, 'speed' => 'medium'], + 'gpu' => ['label' => 'GPU Qwen 2.5 14B', 'credits' => 1, 'client_selectable' => true, 'speed' => 'slow'], + 'gpu_legal' => ['label' => 'GPU Legal (Qwen FT)','credits' => 1, 'client_selectable' => false, 'speed' => 'slow'], + 'regex' => ['label' => 'Regex only', 'credits' => 0, 'client_selectable' => false, 'speed' => 'instant'], + ]; + + public static function isValidRedactEngine(string $engine): bool + { + return isset(self::REDACT_ENGINES[$engine]); + } + + /** Normalise an incoming engine to a valid key, defaulting to claude_haiku. */ + public static function redactEngine(string $engine): string + { + return self::isValidRedactEngine($engine) ? $engine : 'claude_haiku'; + } + + /** Server-side credit cost for a redact engine (never trust the client). */ + public static function redactCredits(string $engine): int + { + return self::REDACT_ENGINES[self::redactEngine($engine)]['credits']; + } + + /** Engine keys the client UI is allowed to offer. */ + public static function redactSelectableEngines(): array + { + return array_keys(array_filter( + self::REDACT_ENGINES, + static fn(array $e) => $e['client_selectable'] + )); + } + + /** + * Unified quality-tier registry for the Quick/Pro selector exposed on the analytical + * tools (summarize, ask, legal-analysis, barnevernet, korrespond, discrepancy, + * deep-research). Each tier maps to an existing engine string that the service layer + * already resolves to a gateway (resolveChatGateway/personaGateway) — so no service + * change is needed. Credits = the tool's base cost × tier multiplier, resolved + * server-side ONLY. + * + * Anchors picked from the 2026-06-15 tier benchmark (credits-first, Bedrock): + * quick → claude_haiku (96.7% quality, ~3.8s); pro → claude_sonnet (100%, ~13s). + */ + public const QUALITY_TIERS = [ + 'quick' => ['label' => 'Quick', 'engine' => 'claude_haiku', 'credit_mult' => 1], + 'pro' => ['label' => 'Pro', 'engine' => 'claude_sonnet', 'credit_mult' => 2], + ]; + + /** Normalise an incoming tier to a valid key, defaulting to quick. */ + public static function qualityTier(string $tier): string + { + return isset(self::QUALITY_TIERS[$tier]) ? $tier : 'quick'; + } + + /** The existing engine string backing a quality tier. */ + public static function tierEngine(string $tier): string + { + return self::QUALITY_TIERS[self::qualityTier($tier)]['engine']; + } + + /** Human label for a quality tier (UI / telemetry). */ + public static function tierLabel(string $tier): string + { + return self::QUALITY_TIERS[self::qualityTier($tier)]['label']; + } + + /** Server-side credit cost for a tool at a given tier (base cost × tier multiplier). */ + public static function tierCredits(string $tool, string $tier): int + { + $mult = self::QUALITY_TIERS[self::qualityTier($tier)]['credit_mult']; + return max(1, PricingCatalog::toolCost($tool) * $mult); + } + + /** + * Resolve a requested quality tier for a user + tool. Applies the subscription gate + * (free / anonymous → quick only; plus & pro may pick pro) and returns the backing + * engine plus the server-side credit cost. Credits are NEVER trusted from the client. + * + * @return array{tier:string,engine:string,credits:int,label:string} + */ + public static function resolveTier(int $userId, string $tool, string $requestedTier): array + { + $tier = self::qualityTier($requestedTier); + + if ($tier === 'pro' && $userId > 0 && FreeTier::tier($userId) === 'free') { + $tier = 'quick'; + } + + return [ + 'tier' => $tier, + 'engine' => self::tierEngine($tier), + 'credits' => self::tierCredits($tool, $tier), + 'label' => self::tierLabel($tier), + ]; + } + public static function engineForUser(int $userId, string $requestedEngine): string { $valid = ['nova_lite', 'azure_mini', 'azure_full', 'gpu', 'regex', 'claude_haiku', 'claude_sonnet']; diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 090da9c..0e5e209 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -767,6 +767,50 @@ function dbnToolsIsFreeTier(): bool && !empty($_SESSION['dbn_tools_sso_uid']); } +/** + * Current free-tier SSO user id, or 0 for CaveauAI / non-SSO sessions. + * Lets an endpoint resolve a quality tier (subscription gate) BEFORE charging, + * without reaching into the session directly. Mirrors the uid the check/deduct + * helpers use. + */ +function dbnToolsFreeTierUid(): int +{ + return dbnToolsIsFreeTier() ? (int)$_SESSION['dbn_tools_sso_uid'] : 0; +} + +/** + * Resolve the model + credit gate for a tool run. When the request carries a `tier` + * param, honour the Quick/Pro quality tier (subscription-gated, server-priced); otherwise + * fall back to the legacy engine-selector behaviour. Runs the credit gate (exits 402/429 + * if over limit) and returns the context an endpoint needs to run + deduct. + * + * @return array{tier:string,engine:string,credits:?int,ftUid:int,metadata:array} + */ +function dbnToolsResolveToolRun(string $tool, array $input, string $legacyDefaultEngine = 'azure_mini'): array +{ + if (isset($input['tier'])) { + $res = ToolModels::resolveTier(dbnToolsFreeTierUid(), $tool, (string)$input['tier']); + $ftUid = dbnToolsFreeTierCheckAmount($tool, $res['credits']); + return [ + 'tier' => $res['tier'], + 'engine' => $res['engine'], + 'credits' => $res['credits'], + 'ftUid' => $ftUid, + 'metadata' => ['tier' => $res['tier'], 'engine' => $res['engine']], + ]; + } + + $ftUid = dbnToolsFreeTierCheck($tool); + $engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? $legacyDefaultEngine)); + return [ + 'tier' => '', + 'engine' => $engine, + 'credits' => null, + 'ftUid' => $ftUid, + 'metadata' => [], + ]; +} + /** * Enforce credit + tier gate before a tool call. * Exits with JSON 402/429 if the user is over limit or out of credits. diff --git a/korrespond.php b/korrespond.php index 4ba2a92..ae75d1d 100644 --- a/korrespond.php +++ b/korrespond.php @@ -79,13 +79,13 @@ require_once __DIR__ . '/includes/layout.php';

Concise: short and to-the-point (2-3 paragraphs). Standard: balanced correspondence. Detailed: full background, all arguments, and complete legal reasoning.

- -
- Engine - - + +
+ Quality + +
-

Quick uses Claude Haiku 4.5 for drafting — fast and solid for standard correspondence. Thorough uses Claude Sonnet 4.6 — better at multi-statute cases, complex appeal grounds, and ECHR framing.

+

Quick uses Claude Haiku 4.5 for drafting — fast and solid for standard correspondence (3 credits). Pro uses Claude Sonnet 4.6 — better at multi-statute cases, complex appeal grounds, and ECHR framing (6 credits).

-
- Engine - - - - +
+ Quality + +
-

Standard uses Claude Haiku 4.5 — fast and highly accurate. Deep uses Claude Sonnet 4.6 — best for complex multi-statute documents.

+

Quick uses Claude Haiku 4.5 — fast and highly accurate (1 credit). Pro uses Claude Sonnet 4.6 — best for complex multi-statute documents (2 credits).

Summary depth