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 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:23:46 +02:00
parent b217f18118
commit a8b1bb87a6
21 changed files with 339 additions and 103 deletions
+5 -5
View File
@@ -50,12 +50,12 @@ require_once __DIR__ . '/includes/layout.php';
<small id="advInputCount" class="adv-char-count">0 / 4,000</small>
</div>
<div class="control-row" id="advEngineControl">
<span class="control-label">Engine</span>
<label><input type="radio" name="advEngine" value="azure_mini" checked> &#x2601;&#xFE0F; Claude Haiku 4.5 <small class="control-hint">(fast · ~2-4 min)</small></label>
<label><input type="radio" name="advEngine" value="claude_sonnet"> &#x2601;&#xFE0F; Claude Sonnet 4.6 &#9733;&#9733; <small class="control-hint">(thorough · ~3-5 min)</small></label>
<div class="control-row" id="advTierControl">
<span class="control-label">Quality</span>
<label><input type="radio" name="advTier" value="quick" checked> Quick &#9733; <small class="control-hint">(Claude Haiku · fast · 3 credits)</small></label>
<label><input type="radio" name="advTier" value="pro"> Pro <small class="control-hint">(Claude Sonnet · best · 6 credits)</small></label>
</div>
<p class="upload-hint">Both engines run on AWS Bedrock via Claude. Most of the time is spent on multiple question-answering passes — 610 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.</p>
<p class="upload-hint">Most of the time is spent on multiple question-answering passes — 610 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).</p>
<div class="dr-slice-section">
<p class="control-label">Corpus slices</p>
+4 -3
View File
@@ -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']);
+20 -2
View File
@@ -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;
}
+6 -3
View File
@@ -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;
}
+20 -2
View File
@@ -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;
}
+16 -7
View File
@@ -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 */ }
+6 -3
View File
@@ -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;
}
+12 -12
View File
@@ -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'
? '60180 seconds with Azure gpt-4o'
: (engine === 'gpu' ? '3090 seconds on GPU' : '1545 seconds with Azure gpt-4o-mini');
const tier = getTier();
const expectedDuration = tier === 'pro'
? '35 minutes with Claude Sonnet'
: '24 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,
}),
+9 -11
View File
@@ -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'
? '90180 seconds with Azure gpt-4o'
: (engine === 'gpu' ? '4590 seconds on GPU'
: (engine === 'dbn_legal' ? '60120 seconds with Norwegian specialist'
: '3060 seconds with Azure gpt-4o-mini'));
const expectedDuration = tier === 'pro'
? '90180 seconds with Claude Sonnet'
: '3060 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(),
+9 -9
View File
@@ -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'
? '60180 seconds with Azure gpt-4o'
: (engine === 'gpu' ? '3090 seconds on GPU' : '1545 seconds with Azure gpt-4o-mini');
const tier = getTier();
const expectedDuration = tier === 'pro'
? '60180 seconds with Claude Sonnet'
: '1545 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(),
};
+4 -6
View File
@@ -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();
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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,
};
+5 -7
View File
@@ -36,14 +36,12 @@ require_once __DIR__ . '/includes/layout.php';
<p class="upload-hint">The agent will analyse the document from <em>your</em> perspective identifying supporting statutes, procedural red flags, and ECHR arguments for your position.</p>
</div>
<div class="control-row" id="bvjEngineControl">
<span class="control-label">Engine</span>
<label><input type="radio" name="bvjEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~30-60s)</small></label>
<label><input type="radio" name="bvjEngine" value="azure_full"> Azure gpt-4o <small class="control-hint">(best · ~90-180s)</small></label>
<label><input type="radio" name="bvjEngine" value="gpu"> GPU qwen2.5:14b <small class="control-hint">(local · ~45-90s)</small></label>
<label><input type="radio" name="bvjEngine" value="dbn_legal_v3"> &#x1F1F3;&#x1F1F4;&#9876;&#65039; DBN Legal Agent &#9733; <small class="control-hint">(dbn-legal-agent-v3 fine-tune · ~20-60s)</small></label>
<div class="control-row" id="bvjTierControl">
<span class="control-label">Quality</span>
<label><input type="radio" name="bvjTier" value="quick" checked> Quick &#9733; <small class="control-hint">(Claude Haiku · fast · 3 credits)</small></label>
<label><input type="radio" name="bvjTier" value="pro"> Pro <small class="control-hint">(Claude Sonnet · best · 6 credits)</small></label>
</div>
<p class="upload-hint">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.</p>
<p class="upload-hint">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.</p>
<div class="dr-slice-section">
<p class="control-label">Corpus slices</p>
+5 -7
View File
@@ -16,14 +16,12 @@ require_once __DIR__ . '/includes/layout.php';
<button type="button" class="lang-btn" data-lang="pl">&#127477;&#127473; PL</button>
</div>
<div class="control-row" id="drEngineControl">
<span class="control-label">Engine</span>
<label><input type="radio" name="drEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~15-45s)</small></label>
<label><input type="radio" name="drEngine" value="azure_full"> Azure gpt-4o <small class="control-hint">(best · ~60-180s)</small></label>
<label><input type="radio" name="drEngine" value="gpu"> GPU (cuttlefish) <small class="control-hint">(local · ~30-90s)</small></label>
<label><input type="radio" name="drEngine" value="dbn_legal_v3"> &#x1F1F3;&#x1F1F4;&#9876;&#65039; DBN Legal Agent &#9733; <small class="control-hint">(dbn-legal-agent-v3 fine-tune · ~20-60s)</small></label>
<div class="control-row" id="drTierControl">
<span class="control-label">Quality</span>
<label><input type="radio" name="drTier" value="quick" checked> Quick &#9733; <small class="control-hint">(Claude Haiku · fast · 6 credits)</small></label>
<label><input type="radio" name="drTier" value="pro"> Pro <small class="control-hint">(Claude Sonnet · best · 12 credits)</small></label>
</div>
<p class="upload-hint">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.</p>
<p class="upload-hint">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).</p>
<div class="control-row corpus-persona is-hidden" id="drPersonaControl">
<span class="control-label">Domain</span>
+5 -7
View File
@@ -55,14 +55,12 @@ require_once __DIR__ . '/includes/layout.php';
</div>
<div class="control-row" id="dcEngineControl">
<span class="control-label">Engine</span>
<label><input type="radio" name="dcEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~60-90s)</small></label>
<label><input type="radio" name="dcEngine" value="azure_full"> Azure gpt-4o <small class="control-hint">(best · ~2-3 min)</small></label>
<label><input type="radio" name="dcEngine" value="gpu"> GPU qwen2.5:14b <small class="control-hint">(local · ~90s)</small></label>
<label><input type="radio" name="dcEngine" value="dbn_legal_v3"> &#x1F1F3;&#x1F1F4;&#9876;&#65039; DBN Legal Agent &#9733; <small class="control-hint">(dbn-legal-agent-v3 fine-tune · ~30-60s)</small></label>
<div class="control-row" id="dcTierControl">
<span class="control-label">Quality</span>
<label><input type="radio" name="dcTier" value="quick" checked> Quick &#9733; <small class="control-hint">(Claude Haiku · fast · 4 credits)</small></label>
<label><input type="radio" name="dcTier" value="pro"> Pro <small class="control-hint">(Claude Sonnet · best · 8 credits)</small></label>
</div>
<p class="upload-hint">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.</p>
<p class="upload-hint">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).</p>
<details class="advanced-panel" id="dcSlicePanel">
<summary class="advanced-toggle">Corpus slices <span class="control-hint">(used for legal significance context)</span></summary>
+47 -3
View File
@@ -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) {
+108
View File
@@ -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'];
+44
View File
@@ -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.
+6 -6
View File
@@ -79,13 +79,13 @@ require_once __DIR__ . '/includes/layout.php';
</div>
<p class="upload-hint">Concise: short and to-the-point (2-3 paragraphs). Standard: balanced correspondence. Detailed: full background, all arguments, and complete legal reasoning.</p>
<!-- Engine -->
<div class="control-row" id="korrEngineControl">
<span class="control-label">Engine</span>
<label><input type="radio" name="korrEngine" value="azure_mini" checked> &#x2601;&#xFE0F; Quick <small class="control-hint">(~40-70s)</small></label>
<label><input type="radio" name="korrEngine" value="claude_sonnet"> &#x2601;&#xFE0F; Thorough &#9733;&#9733; <small class="control-hint">(~70-120s)</small></label>
<!-- Quality tier -->
<div class="control-row" id="korrTierControl">
<span class="control-label">Quality</span>
<label><input type="radio" name="korrTier" value="quick" checked> Quick &#9733; <small class="control-hint">(Claude Haiku · fast · 3 credits)</small></label>
<label><input type="radio" name="korrTier" value="pro"> Pro <small class="control-hint">(Claude Sonnet · best · 6 credits)</small></label>
</div>
<p class="upload-hint">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.</p>
<p class="upload-hint">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).</p>
<!-- Domain persona -->
<div class="control-row corpus-persona is-hidden" id="korrPersonaControl">
+5 -7
View File
@@ -16,14 +16,12 @@ require_once __DIR__ . '/includes/layout.php';
<button type="button" class="lang-btn sum-lang-btn" data-lang="pl">&#127477;&#127473; PL</button>
</div>
<div class="control-row" id="sumEngineControl">
<span class="control-label">Engine</span>
<label><input type="radio" name="sumEngine" value="claude_haiku" checked> Standard &#9733; <small class="control-hint">(Claude Haiku · fast)</small></label>
<label><input type="radio" name="sumEngine" value="claude_sonnet"> Deep <small class="control-hint">(Claude Sonnet · best)</small></label>
<label><input type="radio" name="sumEngine" value="azure_mini"> Azure mini <small class="control-hint">(gpt-4o-mini)</small></label>
<label><input type="radio" name="sumEngine" value="azure_full"> Azure full <small class="control-hint">(gpt-4o)</small></label>
<div class="control-row" id="sumTierControl">
<span class="control-label">Quality</span>
<label><input type="radio" name="sumTier" value="quick" checked> Quick &#9733; <small class="control-hint">(Claude Haiku · fast · 1 credit)</small></label>
<label><input type="radio" name="sumTier" value="pro"> Pro <small class="control-hint">(Claude Sonnet · best · 2 credits)</small></label>
</div>
<p class="upload-hint">Standard uses Claude Haiku 4.5 fast and highly accurate. Deep uses Claude Sonnet 4.6 best for complex multi-statute documents.</p>
<p class="upload-hint">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).</p>
<div class="control-row" id="sumDepthControl">
<span class="control-label">Summary depth</span>