feat(tools): DB-backed LLM engine admin (owner-only)

Add an owner-gated dashboard to remap any tool/tier's model live without a
code push. Overrides live in dbn_tool_engine_config (dobetternorge_maindb)
and are consulted by dbnToolsResolveToolRun() + dbnToolsReviewerModel();
no row = unchanged code/.env behaviour (fully back-compatible).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 14:55:17 +02:00
parent f270a32056
commit 2f05b84b0f
7 changed files with 546 additions and 2 deletions
+213
View File
@@ -0,0 +1,213 @@
<?php
/**
* /api/dashboard/engines.php — owner-only LLM engine override admin.
*
* GET → { ok, context, tier_engines, reviewer_models, tier_tools, rows }
* POST ?action=set body: { tool_slug, scope, engine } → upsert an override
* POST ?action=clear body: { tool_slug, scope } → delete (revert to default)
*
* Overrides live in dobetternorge_maindb.dbn_tool_engine_config and are consulted by
* dbnToolsResolveToolRun() / dbnToolsReviewerModel(). Owner-gated via dbnToolsIsOwner().
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
require_once dirname(__DIR__, 2) . '/includes/ToolModels.php';
dbnToolsRequireAuth();
if (!dbnToolsIsOwner()) {
dbnToolsError('Owner access required.', 403, 'forbidden');
}
const ENGINES_TIER_TOOLS = ['ask', 'summarize', 'deep-research', 'korrespond', 'barnevernet', 'discrepancy'];
const ENGINES_TIER_SCOPES = ['tier_quick', 'tier_pro', 'legacy'];
const ENGINES_PERSONA_SCOPES = ['persona_family', 'persona_general'];
function enginesTierLabel(string $key): string
{
return [
'claude_haiku' => 'Claude Haiku 4.5',
'claude_sonnet' => 'Claude Sonnet 4.6',
'azure_mini' => 'Azure GPT-4o-mini',
'azure_full' => 'Azure GPT-4o',
'gpu' => 'GPU Qwen 2.5 14B',
'nova_lite' => 'Nova Lite',
'regex' => 'Regex only',
][$key] ?? $key;
}
function enginesReviewerLabel(string $key): string
{
return [
'gpt-4o' => 'GPT-4o (Azure)',
'gpt-4o-mini' => 'GPT-4o-mini (Azure)',
'dbn-legal-agent-v3' => 'DBN Legal v3 (GPU fine-tune)',
'dbn-legal-agent' => 'DBN Legal (GPU)',
'dobetter-norge-v4' => 'Do Better Norge v4 (GPU)',
'qwen2.5:14b' => 'Qwen 2.5 14B',
][$key] ?? $key;
}
/** Code/.env default engine for a given scope (what applies when no override is set). */
function enginesCodeDefault(string $scope): string
{
switch ($scope) {
case 'tier_quick': return ToolModels::tierEngine('quick');
case 'tier_pro': return ToolModels::tierEngine('pro');
case 'legacy': return 'azure_mini';
case 'persona_family':
return trim((string)(dbnToolsEnv('DBN_REVIEW_MODEL_FAMILY', 'dbn-legal-agent-v3') ?? '')) ?: 'dbn-legal-agent-v3';
case 'persona_general':
return trim((string)(dbnToolsEnv('DBN_REVIEW_MODEL_GENERAL', 'gpt-4o') ?? '')) ?: 'gpt-4o';
}
return '';
}
function enginesValidScope(string $scope): bool
{
return in_array($scope, array_merge(ENGINES_TIER_SCOPES, ENGINES_PERSONA_SCOPES), true);
}
function enginesValidEngineForScope(string $scope, string $engine): bool
{
if (in_array($scope, ENGINES_PERSONA_SCOPES, true)) {
return dbnToolsIsValidReviewerModel($engine);
}
return ToolModels::isValidTierEngine($engine);
}
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
try {
if ($method === 'GET') {
enginesGet();
} else {
$action = (string)($_GET['action'] ?? '');
if ($action === 'set') {
enginesSet();
} elseif ($action === 'clear') {
enginesClear();
} else {
dbnToolsError('Unknown action.', 400, 'unknown_action');
}
}
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra ?? []);
} catch (Throwable $e) {
error_log('[dbn-engines] ' . $e->getMessage());
dbnToolsError('Engine admin operation failed.', 500, 'op_failed');
}
function enginesLoadOverrides(): array
{
$map = [];
try {
$rows = dbnmDb()->query('SELECT tool_slug, scope, engine, updated_by, updated_at FROM dbn_tool_engine_config WHERE enabled = 1')->fetchAll();
foreach ($rows as $r) {
$map[$r['tool_slug'] . '|' . $r['scope']] = $r;
}
} catch (Throwable $e) {
// table not yet migrated → no overrides
}
return $map;
}
function enginesGet(): void
{
$overrides = enginesLoadOverrides();
$tierEngines = array_map(
static fn(string $k): array => ['key' => $k, 'label' => enginesTierLabel($k)],
ToolModels::tierEngineKeys()
);
$reviewerModels = array_map(
static fn(string $k): array => ['key' => $k, 'label' => enginesReviewerLabel($k)],
dbnToolsReviewerModelKeys()
);
$rows = [];
$row = static function (string $tool, string $scope) use ($overrides): array {
$key = $tool . '|' . $scope;
$ov = $overrides[$key]['engine'] ?? null;
$def = enginesCodeDefault($scope);
return [
'tool_slug' => $tool,
'scope' => $scope,
'code_default' => $def,
'override' => $ov,
'effective' => $ov ?? $def,
'updated_by' => $overrides[$key]['updated_by'] ?? null,
'updated_at' => $overrides[$key]['updated_at'] ?? null,
];
};
// Global tier defaults (apply to every tier tool unless a per-tool row overrides).
$rows[] = $row('*', 'tier_quick');
$rows[] = $row('*', 'tier_pro');
// Per-tool tier overrides.
foreach (ENGINES_TIER_TOOLS as $tool) {
$rows[] = $row($tool, 'tier_quick');
$rows[] = $row($tool, 'tier_pro');
}
// Persona reviewer (legal-analysis track).
$rows[] = $row('*', 'persona_family');
$rows[] = $row('*', 'persona_general');
dbnToolsRespond([
'ok' => true,
'context' => [
'bedrock_enabled' => filter_var(dbnToolsEnv('DBN_BEDROCK_ENABLED', 'false'), FILTER_VALIDATE_BOOLEAN),
'note' => 'claude_* engines route to AWS Bedrock when DBN_BEDROCK_ENABLED=true, else fall back to Azure GPT. GPU fine-tunes degrade to gpt-4o when the pod is offline.',
],
'tier_engines' => $tierEngines,
'reviewer_models' => $reviewerModels,
'tier_tools' => ENGINES_TIER_TOOLS,
'rows' => $rows,
]);
}
function enginesSet(): void
{
$input = dbnToolsJsonInput(2_000);
$tool = trim((string)($input['tool_slug'] ?? ''));
$scope = trim((string)($input['scope'] ?? ''));
$engine = trim((string)($input['engine'] ?? ''));
if ($tool === '' || !preg_match('/^[a-z0-9*\-_.]+$/', $tool)) {
dbnToolsError('Invalid tool slug.', 400, 'bad_tool');
}
if (!enginesValidScope($scope)) {
dbnToolsError('Invalid scope.', 400, 'bad_scope');
}
if (!enginesValidEngineForScope($scope, $engine)) {
dbnToolsError('Engine is not valid for this scope.', 400, 'bad_engine');
}
$by = strtolower((string)($_SESSION['dbn_tools_user_email'] ?? $_SESSION['dbn_tools_sso_email'] ?? 'owner'));
$stmt = dbnmDb()->prepare(
'INSERT INTO dbn_tool_engine_config (tool_slug, scope, engine, enabled, updated_by)
VALUES (?, ?, ?, 1, ?)
ON DUPLICATE KEY UPDATE engine = VALUES(engine), enabled = 1, updated_by = VALUES(updated_by)'
);
$stmt->execute([$tool, $scope, $engine, $by]);
dbnToolsRespond(['ok' => true, 'tool_slug' => $tool, 'scope' => $scope, 'engine' => $engine]);
}
function enginesClear(): void
{
$input = dbnToolsJsonInput(2_000);
$tool = trim((string)($input['tool_slug'] ?? ''));
$scope = trim((string)($input['scope'] ?? ''));
if ($tool === '' || !enginesValidScope($scope)) {
dbnToolsError('Invalid tool or scope.', 400, 'bad_request');
}
$stmt = dbnmDb()->prepare('DELETE FROM dbn_tool_engine_config WHERE tool_slug = ? AND scope = ?');
$stmt->execute([$tool, $scope]);
dbnToolsRespond(['ok' => true, 'cleared' => true, 'tool_slug' => $tool, 'scope' => $scope]);
}
+143
View File
@@ -0,0 +1,143 @@
(function () {
'use strict';
const d = window.DBN_DASHBOARD || {};
const apiBase = (d.apiBase || '/api/dashboard') + '/engines.php';
const $context = document.getElementById('engContext');
const $tier = document.getElementById('engTierPanel');
const $persona = document.getElementById('engPersonaPanel');
const $refresh = document.getElementById('engRefresh');
const safe = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
let tierEngines = [];
let reviewerModels = [];
function optionsHtml(list, selected) {
return list.map(o =>
'<option value="' + safe(o.key) + '"' + (o.key === selected ? ' selected' : '') + '>' + safe(o.label) + '</option>'
).join('');
}
function toolLabel(row) {
if (row.tool_slug === '*') {
return row.scope.indexOf('persona') === 0 ? 'All personas' : 'All tier tools';
}
return row.tool_slug;
}
function rowHtml(row, list) {
const list2 = list;
const sel = row.override || row.code_default;
const badge = row.override
? '<span class="eng-badge">override</span>'
: '<span class="eng-badge eng-badge--default">default</span>';
const meta = row.override && row.updated_by
? '<div class="eng-meta">by ' + safe(row.updated_by) + (row.updated_at ? ' · ' + safe(row.updated_at) : '') + '</div>'
: '';
return '<div class="eng-row" data-tool="' + safe(row.tool_slug) + '" data-scope="' + safe(row.scope) + '">' +
'<div><span class="eng-row__tool">' + safe(toolLabel(row)) + '</span>' +
'<div class="eng-row__def">default: ' + safe(row.code_default || '—') + '</div>' + meta + '</div>' +
'<div class="eng-row__scope">' + safe(row.scope) + '</div>' +
'<div><select class="eng-select">' + optionsHtml(list2, sel) + '</select></div>' +
'<div class="eng-row__actions">' + badge +
(row.override ? '<button class="dash-btn eng-clear" type="button">Clear</button>' : '') +
'</div>' +
'</div>';
}
function headHtml() {
return '<div class="eng-head"><div>Tool</div><div>Scope</div><div>Engine</div><div></div></div>';
}
function render(data) {
tierEngines = data.tier_engines || [];
reviewerModels = data.reviewer_models || [];
const ctx = data.context || {};
$context.innerHTML = '<strong>Bedrock: ' + (ctx.bedrock_enabled ? 'ON' : 'OFF') + '</strong> — ' + safe(ctx.note || '');
const rows = data.rows || [];
const tierRows = rows.filter(r => r.scope.indexOf('persona') !== 0);
const personaRows = rows.filter(r => r.scope.indexOf('persona') === 0);
$tier.innerHTML = headHtml() + tierRows.map(r => rowHtml(r, tierEngines)).join('');
$persona.innerHTML = headHtml() + personaRows.map(r => rowHtml(r, reviewerModels)).join('');
bind($tier);
bind($persona);
}
function bind(panel) {
panel.querySelectorAll('.eng-row').forEach(rowEl => {
const tool = rowEl.getAttribute('data-tool');
const scope = rowEl.getAttribute('data-scope');
const select = rowEl.querySelector('.eng-select');
const clearBtn = rowEl.querySelector('.eng-clear');
if (select) {
select.addEventListener('change', () => save(tool, scope, select.value, rowEl));
}
if (clearBtn) {
clearBtn.addEventListener('click', () => clear(tool, scope, rowEl));
}
});
}
function setBusy(rowEl, busy) {
rowEl.querySelectorAll('select, button').forEach(el => { el.disabled = busy; });
}
async function post(action, body) {
const r = await fetch(apiBase + '?action=' + action, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await r.json();
if (!data.ok) throw new Error(data.message || 'Request failed');
return data;
}
async function save(tool, scope, engine, rowEl) {
setBusy(rowEl, true);
try {
await post('set', { tool_slug: tool, scope: scope, engine: engine });
await load();
} catch (e) {
alert('Could not save: ' + e.message);
setBusy(rowEl, false);
}
}
async function clear(tool, scope, rowEl) {
setBusy(rowEl, true);
try {
await post('clear', { tool_slug: tool, scope: scope });
await load();
} catch (e) {
alert('Could not clear: ' + e.message);
setBusy(rowEl, false);
}
}
async function load() {
$tier.innerHTML = '<div class="dms-loading"></div>';
$persona.innerHTML = '<div class="dms-loading"></div>';
try {
const r = await fetch(apiBase, { credentials: 'same-origin' });
const data = await r.json();
if (!data.ok) throw new Error(data.message || 'Could not load engine map');
render(data);
} catch (e) {
$context.textContent = 'Could not load: ' + e.message;
$tier.innerHTML = '';
$persona.innerHTML = '';
}
}
if ($refresh) $refresh.addEventListener('click', load);
load();
})();
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
if (!dbnToolsIsAuthenticated()) {
dbnToolsRequirePageAuth($_SERVER['REQUEST_URI'] ?? '/dashboard/llm-engines.php');
}
if (!dbnToolsIsOwner()) {
http_response_code(403);
echo '<!doctype html><meta charset="utf-8"><title>Owner access required</title>'
. '<p style="font-family:sans-serif;max-width:540px;margin:4rem auto;">'
. 'Owner access required. <a href="/dashboard/">Back to dashboard</a></p>';
exit;
}
$dashboardPage = 'llm-engines';
$dashboardTitle = 'LLM Engines';
$dashboardLead = 'Remap which model each tool/tier uses — live, no code push. Overrides fall back to the code/.env default when cleared.';
require_once __DIR__ . '/../includes/layout_dashboard.php';
?>
<section class="dash-card">
<div class="dash-card__head">
<h2>Routing context</h2>
<div class="dash-card__actions">
<button class="dash-btn" type="button" id="engRefresh">↻ Refresh</button>
</div>
</div>
<p id="engContext" style="margin:0; max-width:74ch; line-height:1.6; font-size:0.9rem; color:rgba(22,19,15,0.7);">Loading…</p>
</section>
<section class="dash-card">
<div class="dash-card__head">
<h2>Tier engines (Quick / Pro)</h2>
</div>
<p style="margin-top:0; max-width:74ch; line-height:1.6; font-size:0.86rem; color:rgba(22,19,15,0.6);">
The <code>*</code> rows set the default for every tier tool. A per-tool row overrides just that tool. Credits stay tied to the tier, not the chosen engine.
</p>
<div id="engTierPanel" class="eng-table"><div class="dms-loading"></div></div>
</section>
<section class="dash-card">
<div class="dash-card__head">
<h2>Reviewer personas (legal-analysis track)</h2>
</div>
<div id="engPersonaPanel" class="eng-table"><div class="dms-loading"></div></div>
</section>
<style>
.eng-table { background:#fff; border:1px solid var(--dms-stroke,#e3ddd2); border-radius:var(--dms-radius,10px); overflow:hidden; }
.eng-row { display:grid; grid-template-columns:1.4fr 0.9fr 1.5fr auto; gap:12px; padding:10px 16px; border-bottom:1px solid var(--dms-stroke-soft,#efe9dd); font-size:13px; align-items:center; }
.eng-row:last-child { border-bottom:0; }
.eng-row__tool { font-weight:600; color:var(--dms-navy,#16130f); }
.eng-row__scope { font-family:ui-monospace,Menlo,monospace; font-size:11px; color:rgba(22,19,15,0.55); }
.eng-row__def { font-size:11px; color:rgba(22,19,15,0.55); margin-top:2px; }
.eng-row select { width:100%; padding:5px 8px; border:1px solid var(--dms-stroke,#d9d2c5); border-radius:6px; font-size:12px; background:#fff; }
.eng-row__actions { display:flex; gap:6px; align-items:center; }
.eng-badge { font-size:10px; font-weight:600; padding:2px 7px; border-radius:999px; background:rgba(180,138,44,0.16); color:#6c5212; }
.eng-badge--default { background:rgba(22,19,15,0.06); color:rgba(22,19,15,0.5); }
.eng-meta { font-size:10px; color:rgba(22,19,15,0.45); }
.eng-clear { font-size:11px; padding:4px 8px; }
.eng-head { display:grid; grid-template-columns:1.4fr 0.9fr 1.5fr auto; gap:12px; padding:8px 16px; font-size:10px; text-transform:uppercase; letter-spacing:0.05em; color:rgba(22,19,15,0.4); background:rgba(22,19,15,0.02); border-bottom:1px solid var(--dms-stroke-soft,#efe9dd); }
</style>
<script src="/assets/js/dashboard/llm-engines.js" defer></script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
+16
View File
@@ -91,6 +91,22 @@ final class ToolModels
return self::QUALITY_TIERS[self::qualityTier($tier)]['engine'];
}
/**
* Engine keys the gateway layer can route for the tier/legacy tools. This is the
* allowlist an operator override (dbn_tool_engine_config) is validated against, so a
* bad DB value can never produce an unroutable engine string.
*/
public static function tierEngineKeys(): array
{
return ['nova_lite', 'azure_mini', 'azure_full', 'gpu', 'regex', 'claude_haiku', 'claude_sonnet'];
}
/** True if $engine is a routable tier/legacy engine key. */
public static function isValidTierEngine(string $engine): bool
{
return in_array($engine, self::tierEngineKeys(), true);
}
/** Human label for a quality tier (UI / telemetry). */
public static function tierLabel(string $tier): string
{
+78 -2
View File
@@ -180,6 +180,64 @@ function dbnToolsAuthenticatedUser(): ?array
];
}
/**
* Operator gate for the LLM-engine admin. True when the authenticated session is the
* tenant owner, or when their email is listed in the DBN_ADMIN_EMAILS allowlist (so an
* SSO operator can self-authorise without an 'owner' client_users row).
*/
function dbnToolsIsOwner(): bool
{
if (!dbnToolsIsAuthenticated()) {
return false;
}
if (strtolower((string)($_SESSION['dbn_tools_user_role'] ?? '')) === 'owner') {
return true;
}
$email = strtolower(trim((string)($_SESSION['dbn_tools_user_email'] ?? $_SESSION['dbn_tools_sso_email'] ?? '')));
if ($email === '') {
return false;
}
$allow = array_filter(array_map('trim', explode(',', strtolower((string)(dbnToolsEnv('DBN_ADMIN_EMAILS', '') ?? '')))));
return in_array($email, $allow, true);
}
/**
* Look up an operator engine override for a tool + scope from dbn_tool_engine_config
* (dobetternorge_maindb). Prefers a tool-specific row, then a '*' all-tools row.
* Returns null when no enabled override exists. Statically cached per request; safe
* (returns null) when the table is absent, so behaviour is unchanged pre-migration.
*
* scope: tier_quick | tier_pro | legacy | persona_family | persona_general
*/
function dbnToolsEngineOverride(string $tool, string $scope): ?string
{
static $cache = null;
if ($cache === null) {
$cache = [];
try {
$rows = dbnmDb()->query('SELECT tool_slug, scope, engine FROM dbn_tool_engine_config WHERE enabled = 1')->fetchAll();
foreach ($rows as $r) {
$cache[$r['tool_slug'] . '|' . $r['scope']] = (string)$r['engine'];
}
} catch (Throwable $e) {
$cache = []; // table missing / DB down → no overrides
}
}
return $cache[$tool . '|' . $scope] ?? $cache['*|' . $scope] ?? null;
}
/** Reviewer/persona model ids an operator override may select (validated against this). */
function dbnToolsReviewerModelKeys(): array
{
return ['gpt-4o', 'gpt-4o-mini', 'dbn-legal-agent-v3', 'dbn-legal-agent', 'dobetter-norge-v4', 'qwen2.5:14b'];
}
/** True if $model is an accepted reviewer/persona model id. */
function dbnToolsIsValidReviewerModel(string $model): bool
{
return in_array($model, dbnToolsReviewerModelKeys(), true);
}
function dbnToolsRequiredPackageSlug(): string
{
return dbnToolsEnv('DBN_CAVEAU_PACKAGE_SLUG') ?: 'family-legal';
@@ -506,9 +564,17 @@ function dbnToolsPersonaTrack(?string $slug): string
function dbnToolsReviewerModel(?string $slug = null): string
{
if (dbnToolsPersonaTrack($slug) === 'family') {
$override = dbnToolsEngineOverride('*', 'persona_family');
if ($override !== null && dbnToolsIsValidReviewerModel($override)) {
return $override;
}
return trim((string)(dbnToolsEnv('DBN_REVIEW_MODEL_FAMILY', 'dbn-legal-agent-v3') ?? 'dbn-legal-agent-v3'))
?: 'dbn-legal-agent-v3';
}
$override = dbnToolsEngineOverride('*', 'persona_general');
if ($override !== null && dbnToolsIsValidReviewerModel($override)) {
return $override;
}
return trim((string)(dbnToolsEnv('DBN_REVIEW_MODEL_GENERAL', 'gpt-4o') ?? 'gpt-4o')) ?: 'gpt-4o';
}
@@ -790,6 +856,11 @@ function dbnToolsResolveToolRun(string $tool, array $input, string $legacyDefaul
{
if (isset($input['tier'])) {
$res = ToolModels::resolveTier(dbnToolsFreeTierUid(), $tool, (string)$input['tier']);
$scope = $res['tier'] === 'pro' ? 'tier_pro' : 'tier_quick';
$override = dbnToolsEngineOverride($tool, $scope);
if ($override !== null && ToolModels::isValidTierEngine($override)) {
$res['engine'] = $override; // credits stay tied to the tier; only the engine swaps
}
$ftUid = dbnToolsFreeTierCheckAmount($tool, $res['credits']);
return [
'tier' => $res['tier'],
@@ -800,8 +871,13 @@ function dbnToolsResolveToolRun(string $tool, array $input, string $legacyDefaul
];
}
$ftUid = dbnToolsFreeTierCheck($tool);
$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? $legacyDefaultEngine));
$ftUid = dbnToolsFreeTierCheck($tool);
$requested = (string)($input['engine'] ?? $legacyDefaultEngine);
$override = dbnToolsEngineOverride($tool, 'legacy');
if ($override !== null && ToolModels::isValidTierEngine($override)) {
$requested = $override; // still passes through engineForUser tier gating below
}
$engine = ToolModels::engineForUser($ftUid, $requested);
return [
'tier' => '',
'engine' => $engine,
+3
View File
@@ -51,6 +51,9 @@ $dashboardNav = [
'trash' => ['url' => '/dashboard/trash.php', 'label' => dbnToolsT('dash_nav_trash', $uiLang) ?: 'Trash', 'sub' => 'Restore or purge'],
'settings' => ['url' => '/dashboard/settings.php', 'label' => dbnToolsT('dash_nav_settings', $uiLang), 'sub' => 'Settings'],
];
if (dbnToolsIsOwner()) {
$dashboardNav['llm-engines'] = ['url' => '/dashboard/llm-engines.php', 'label' => 'LLM Engines', 'sub' => 'Model routing (owner)'];
}
?>
<!doctype html>
<html lang="<?= htmlspecialchars($uiLang) ?>">
@@ -0,0 +1,27 @@
-- 001_tool_engine_config.sql
-- Target DB: dobetternorge_maindb (the tools' operational DB, via dbnmDb()).
-- Run manually after a mysqldump backup. Idempotent (IF NOT EXISTS).
--
-- DB-backed engine overrides for the legal tools. When a row exists and is
-- enabled, the matching resolver (dbnToolsResolveToolRun / dbnToolsReviewerModel)
-- uses its `engine` instead of the code/.env default. No row = unchanged behaviour.
--
-- tool_slug : a tool slug (ask|summarize|deep-research|korrespond|barnevernet|
-- discrepancy|legal-analysis...) OR '*' for an all-tools default.
-- scope : tier_quick | tier_pro | legacy | persona_family | persona_general
-- engine : tier/legacy scopes -> a ToolModels engine key (claude_haiku,
-- claude_sonnet, azure_mini, azure_full, gpu, nova_lite, regex);
-- persona scopes -> a reviewer model id (gpt-4o, gpt-4o-mini,
-- dbn-legal-agent-v3, dobetter-norge-v4, ...). Validated in code.
CREATE TABLE IF NOT EXISTS dbn_tool_engine_config (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
tool_slug VARCHAR(64) NOT NULL,
scope VARCHAR(32) NOT NULL,
engine VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
updated_by VARCHAR(190) DEFAULT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_tool_scope (tool_slug, scope)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;