Compare commits

..

6 Commits

Author SHA1 Message Date
daveadmin 519bdbb6e5 feat(tools): owner feedback review surface + tool_feedback migration
Adds the missing migration for the tool_feedback table (dobetternorge_maindb)
that the in-result feedback widget writes to, repoints api/feedback.php to
dbnmDb() for consistency with the engine-config table, and adds an owner-only
dashboard (page + read API + nav) summarising ratings and notes by tool/engine.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 15:19:45 +02:00
daveadmin 198f0526cf feat(tools): n8n caveauDbnAgent starter workflow + import guide
Clonable AI Agent workflow driving the DBN legal tools over one MCP Client
Tool node (dynamic catalog, 23 dbn.* tools). Chat Model + Bearer creds are
placeholders Dave wires post-import — no cloud spend baked in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 15:10:30 +02:00
daveadmin 2f05b84b0f 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>
2026-06-21 14:55:17 +02:00
daveadmin f270a32056 fix(tools): tier-aware GPU cloud fallback for ask synthesis
When a persona-pinned GPU fine-tune is offline, degrade to the requested
quality tier's Bedrock model (Quick->Haiku, Pro->Sonnet) instead of a
hardcoded gpt-4o, so Pro genuinely differs from Quick while the pod is off.
Legacy/azure engines keep gpt-4o as the floor. Generalize the degraded
notice/trace wording (no longer asserts gpt-4o).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 18:50:39 +02:00
daveadmin a4b5b6e3f2 fix(tools): route quick/pro tiers to Haiku/Sonnet on Bedrock
Tier engine strings (claude_haiku/claude_sonnet) were stripped back to
azure_mini by per-method whitelists, so both tiers ran gpt-4o-mini and Pro
charged 2x for the same model. Add a shared DbnBedrockModelRouter::
deploymentForEngine() helper and route the cloud path through it across
summarize, ask, barnevernet, discrepancy, deep-research, and korrespond.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 17:35:22 +02:00
daveadmin a8b1bb87a6 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>
2026-06-15 12:23:46 +02:00
37 changed files with 1401 additions and 140 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;
}
+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]);
}
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* /api/dashboard/feedback.php — owner-only tool-feedback review surface (read).
*
* GET → { ok, totals, by_tool, by_engine, recent }
*
* Reads dobetternorge_maindb.tool_feedback (written by api/feedback.php).
* Owner-gated via dbnToolsIsOwner(). Comments are voluntary, case-content-free.
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
dbnToolsRequireAuth();
if (!dbnToolsIsOwner()) {
dbnToolsError('Owner access required.', 403, 'forbidden');
}
try {
$db = dbnmDb();
$totalsRow = $db->query(
"SELECT
COUNT(*) AS total,
SUM(rating = 'positive') AS positive,
SUM(rating = 'negative') AS negative
FROM tool_feedback"
)->fetch();
$byTool = $db->query(
"SELECT
tool,
COUNT(*) AS total,
SUM(rating = 'positive') AS positive,
SUM(rating = 'negative') AS negative,
MAX(created_at) AS last_at
FROM tool_feedback
GROUP BY tool
ORDER BY total DESC, tool ASC"
)->fetchAll();
$byEngine = $db->query(
"SELECT
COALESCE(NULLIF(engine, ''), '(unknown)') AS engine,
COUNT(*) AS total,
SUM(rating = 'positive') AS positive,
SUM(rating = 'negative') AS negative
FROM tool_feedback
GROUP BY engine
ORDER BY total DESC, engine ASC"
)->fetchAll();
$recent = $db->query(
"SELECT tool, rating, engine, missed_or_wrong, created_at
FROM tool_feedback
WHERE missed_or_wrong IS NOT NULL AND missed_or_wrong <> ''
ORDER BY created_at DESC
LIMIT 50"
)->fetchAll();
$toInt = static fn($v): int => (int) ($v ?? 0);
dbnToolsRespond([
'ok' => true,
'totals' => [
'total' => $toInt($totalsRow['total'] ?? 0),
'positive' => $toInt($totalsRow['positive'] ?? 0),
'negative' => $toInt($totalsRow['negative'] ?? 0),
],
'by_tool' => array_map(static fn(array $r): array => [
'tool' => $r['tool'],
'total' => $toInt($r['total']),
'positive' => $toInt($r['positive']),
'negative' => $toInt($r['negative']),
'last_at' => $r['last_at'],
], $byTool),
'by_engine' => array_map(static fn(array $r): array => [
'engine' => $r['engine'],
'total' => $toInt($r['total']),
'positive' => $toInt($r['positive']),
'negative' => $toInt($r['negative']),
], $byEngine),
'recent' => array_map(static fn(array $r): array => [
'tool' => $r['tool'],
'rating' => $r['rating'],
'engine' => $r['engine'],
'missed_or_wrong' => $r['missed_or_wrong'],
'created_at' => $r['created_at'],
], $recent),
]);
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra ?? []);
} catch (Throwable $e) {
error_log('[dbn-feedback] ' . $e->getMessage());
dbnToolsError('Feedback review failed.', 500, 'op_failed');
}
+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;
}
+1 -1
View File
@@ -21,7 +21,7 @@ if ($tool === '') {
}
try {
$db = dbnToolsDb();
$db = dbnmDb();
$stmt = $db->prepare(
'INSERT INTO tool_feedback (session_id, tool, rating, missed_or_wrong, engine)
VALUES (?, ?, ?, ?, ?)'
+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_haiku', '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(),
+88
View File
@@ -0,0 +1,88 @@
(function () {
'use strict';
const d = window.DBN_DASHBOARD || {};
const api = (d.apiBase || '/api/dashboard') + '/feedback.php';
const $totals = document.getElementById('fbTotals');
const $byTool = document.getElementById('fbByTool');
const $byEngine = document.getElementById('fbByEngine');
const $recent = document.getElementById('fbRecent');
const $refresh = document.getElementById('fbRefresh');
const safe = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
function pct(pos, total) {
return total > 0 ? Math.round((pos / total) * 100) : 0;
}
function totalsHtml(t) {
return '' +
'<div class="fb-stat"><div class="fb-stat__num">' + t.total + '</div><div class="fb-stat__lbl">Total</div></div>' +
'<div class="fb-stat fb-stat--pos"><div class="fb-stat__num">' + t.positive + '</div><div class="fb-stat__lbl">Positive</div></div>' +
'<div class="fb-stat fb-stat--neg"><div class="fb-stat__num">' + t.negative + '</div><div class="fb-stat__lbl">Negative</div></div>' +
'<div class="fb-stat"><div class="fb-stat__num">' + pct(t.positive, t.total) + '%</div><div class="fb-stat__lbl">Approval</div></div>';
}
function rowsHtml(rows, nameKey, withMeta) {
const head = '<div class="fb-head"><div>' + (nameKey === 'tool' ? 'Tool' : 'Engine') +
'</div><div>Total</div><div>Up</div><div>Down</div><div>' + (withMeta ? 'Last' : 'Approval') + '</div></div>';
if (!rows.length) return head + '<div class="fb-empty">No feedback yet.</div>';
const body = rows.map(r => {
const p = pct(r.positive, r.total);
const right = withMeta
? '<div class="fb-meta">' + safe(r.last_at || '—') + '</div>'
: '<div><div class="fb-bar"><div class="fb-bar__fill" style="width:' + p + '%"></div></div>' +
'<div class="fb-meta">' + p + '%</div></div>';
return '<div class="fb-row">' +
'<div class="fb-row__name">' + safe(r[nameKey]) + '</div>' +
'<div>' + r.total + '</div>' +
'<div class="fb-pos">' + r.positive + '</div>' +
'<div class="fb-neg">' + r.negative + '</div>' +
right +
'</div>';
}).join('');
return head + body;
}
function recentHtml(rows) {
if (!rows.length) return '<div class="fb-empty">No notes yet.</div>';
return rows.map(n => {
const isPos = n.rating === 'positive';
return '<div class="fb-note">' +
'<div class="fb-note__head">' +
'<span class="fb-note__tool">' + safe(n.tool) + '</span>' +
'<span class="fb-note__rating ' + (isPos ? 'is-pos' : 'is-neg') + '">' + (isPos ? '\u{1F44D}' : '\u{1F44E}') + '</span>' +
(n.engine ? '<span class="fb-meta">' + safe(n.engine) + '</span>' : '') +
'<span class="fb-meta">' + safe(n.created_at || '') + '</span>' +
'</div>' +
'<div class="fb-note__text">' + safe(n.missed_or_wrong) + '</div>' +
'</div>';
}).join('');
}
function render(data) {
$totals.innerHTML = totalsHtml(data.totals || { total: 0, positive: 0, negative: 0 });
$byTool.innerHTML = rowsHtml(data.by_tool || [], 'tool', true);
$byEngine.innerHTML = rowsHtml(data.by_engine || [], 'engine', false);
$recent.innerHTML = recentHtml(data.recent || []);
}
async function load() {
$totals.innerHTML = '<div class="dms-loading"></div>';
try {
const r = await fetch(api, { credentials: 'same-origin' });
const data = await r.json();
if (!data.ok) throw new Error(data.message || 'Could not load feedback');
render(data);
} catch (e) {
$totals.innerHTML = '<div class="fb-empty">Could not load: ' + safe(e.message) + '</div>';
$byTool.innerHTML = '';
$byEngine.innerHTML = '';
$recent.innerHTML = '';
}
}
if ($refresh) $refresh.addEventListener('click', load);
load();
})();
+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();
})();
+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>
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
if (!dbnToolsIsAuthenticated()) {
dbnToolsRequirePageAuth($_SERVER['REQUEST_URI'] ?? '/dashboard/feedback.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 = 'feedback';
$dashboardTitle = 'Tool Feedback';
$dashboardLead = 'Thumbs up/down + optional notes users left on tool results. Voluntary, case-content-free — a signal for what is working per tool and engine.';
require_once __DIR__ . '/../includes/layout_dashboard.php';
?>
<section class="dash-card">
<div class="dash-card__head">
<h2>Overview</h2>
<div class="dash-card__actions">
<button class="dash-btn" type="button" id="fbRefresh">↻ Refresh</button>
</div>
</div>
<div id="fbTotals" class="fb-totals"><div class="dms-loading"></div></div>
</section>
<section class="dash-card">
<div class="dash-card__head"><h2>By tool</h2></div>
<div id="fbByTool" class="fb-table"><div class="dms-loading"></div></div>
</section>
<section class="dash-card">
<div class="dash-card__head"><h2>By engine</h2></div>
<div id="fbByEngine" class="fb-table"><div class="dms-loading"></div></div>
</section>
<section class="dash-card">
<div class="dash-card__head"><h2>Recent notes</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);">
Latest voluntary comments (newest first). These are user-written notes, not pasted case text.
</p>
<div id="fbRecent" class="fb-notes"><div class="dms-loading"></div></div>
</section>
<style>
.fb-totals { display:flex; gap:16px; flex-wrap:wrap; }
.fb-stat { background:#fff; border:1px solid var(--dms-stroke,#e3ddd2); border-radius:var(--dms-radius,10px); padding:14px 20px; min-width:120px; }
.fb-stat__num { font-size:26px; font-weight:700; color:var(--dms-navy,#16130f); }
.fb-stat__lbl { font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:rgba(22,19,15,0.5); margin-top:2px; }
.fb-stat--pos .fb-stat__num { color:#2f7d32; }
.fb-stat--neg .fb-stat__num { color:#b3261e; }
.fb-table { background:#fff; border:1px solid var(--dms-stroke,#e3ddd2); border-radius:var(--dms-radius,10px); overflow:hidden; }
.fb-head, .fb-row { display:grid; grid-template-columns:1.6fr 0.7fr 0.7fr 0.7fr 1.1fr; gap:12px; padding:10px 16px; align-items:center; font-size:13px; }
.fb-head { 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); }
.fb-row { border-bottom:1px solid var(--dms-stroke-soft,#efe9dd); }
.fb-row:last-child { border-bottom:0; }
.fb-row__name { font-weight:600; color:var(--dms-navy,#16130f); }
.fb-pos { color:#2f7d32; font-weight:600; }
.fb-neg { color:#b3261e; font-weight:600; }
.fb-bar { height:6px; border-radius:3px; background:#e9e3d6; overflow:hidden; }
.fb-bar__fill { height:100%; background:#2f7d32; }
.fb-meta { font-size:11px; color:rgba(22,19,15,0.45); }
.fb-notes { display:flex; flex-direction:column; gap:10px; }
.fb-note { background:#fff; border:1px solid var(--dms-stroke,#e3ddd2); border-radius:8px; padding:10px 14px; font-size:13px; }
.fb-note__head { display:flex; gap:10px; align-items:center; margin-bottom:4px; }
.fb-note__tool { font-weight:600; color:var(--dms-navy,#16130f); }
.fb-note__rating { font-size:11px; font-weight:600; padding:2px 8px; border-radius:999px; }
.fb-note__rating.is-pos { background:rgba(47,125,50,0.14); color:#2f7d32; }
.fb-note__rating.is-neg { background:rgba(179,38,30,0.12); color:#b3261e; }
.fb-note__text { color:rgba(22,19,15,0.85); white-space:pre-wrap; }
.fb-empty { padding:18px 16px; color:rgba(22,19,15,0.5); font-size:13px; }
</style>
<script src="/assets/js/dashboard/feedback.js" defer></script>
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
+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'; ?>
+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>
+4 -6
View File
@@ -75,7 +75,7 @@ final class DbnBvjAnalyzerAgent
string $additionalNotes = '',
?callable $emit = null
): array {
$engine = in_array($engine, ['azure_mini', 'azure_full', 'gpu'], true)
$engine = in_array($engine, ['azure_mini', 'azure_full', 'gpu', 'claude_haiku', 'claude_sonnet'], true)
? $engine : 'azure_mini';
$language = dbnToolsNormalizeUiLanguage($language);
$controls = $this->normalizeControls($controls);
@@ -941,11 +941,11 @@ PROMPT;
];
$opts = ['json' => true, 'temperature' => $temperature, 'max_tokens' => 4500, 'timeout' => 240];
$cloudDeploy = DbnBedrockModelRouter::deploymentForEngine($engine, $this->azure instanceof DbnBedrockGateway);
$deployLabel = match ($engine) {
'gpu' => 'GPU (cuttlefish)',
'dbn_legal_v3' => 'dbn-legal-agent-v3',
'azure_full' => 'gpt-4o',
default => $this->azure->chatDeployment(),
default => $cloudDeploy,
};
$raw = '';
@@ -956,10 +956,8 @@ PROMPT;
} elseif ($engine === 'gpu') {
$response = dbnToolsCallGpuLlm($messages, $opts);
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
} elseif ($engine === 'azure_full') {
$raw = $this->azure->withDeployment('gpt-4o')->chatText($messages, $opts);
} else {
$raw = $this->azure->chatText($messages, $opts);
$raw = $this->azure->withDeployment($cloudDeploy)->chatText($messages, $opts);
}
} catch (Throwable $e) {
dbnToolsAbort('Synthesis LLM request failed: ' . $e->getMessage(), 502, 'llm_error');
+16
View File
@@ -73,6 +73,22 @@ final class DbnBedrockModelRouter
return $route['model'] ?? self::LITELLM_SONNET;
}
/**
* Maps a quality-tier engine string to the LiteLLM deployment name passed to
* withDeployment(). claude_haiku/claude_sonnet route to Bedrock Claude when the
* active gateway is Bedrock; otherwise they degrade to the Azure GPT-4o family.
*/
public static function deploymentForEngine(string $engine, bool $isBedrock): string
{
switch ($engine) {
case 'claude_sonnet': return $isBedrock ? self::LITELLM_SONNET : 'gpt-4o';
case 'claude_haiku': return $isBedrock ? self::LITELLM_HAIKU : 'gpt-4o-mini';
case 'azure_full': return 'gpt-4o';
case 'azure_mini':
default: return 'gpt-4o-mini';
}
}
public static function supportsThinking(string $modelName): bool
{
return in_array($modelName, self::THINKING_MODELS, true);
+5 -5
View File
@@ -1167,11 +1167,11 @@ PROMPT;
} elseif ($engine === 'azure_full') {
$raw = $this->azure->withDeployment('gpt-4o')->chatText($messages, $opts);
$deployLabel = 'gpt-4o';
} elseif ($engine === 'azure_mini' && $this->azure instanceof DbnBedrockGateway) {
// When Bedrock enabled, azure_mini → Haiku (fast, ~20-50s synthesis)
$haiku = $this->azure->withDeployment(DbnBedrockModelRouter::LITELLM_HAIKU);
$raw = $haiku->chatText($messages, array_merge($opts, ['timeout' => 90]));
$deployLabel = 'Claude Haiku 4.5 (AWS Bedrock)';
} elseif ($engine === 'claude_haiku' || ($engine === 'azure_mini' && $this->azure instanceof DbnBedrockGateway)) {
// Quick tier (claude_haiku) and azure_mini-under-Bedrock → Haiku (fast, ~20-50s synthesis)
$deploy = DbnBedrockModelRouter::deploymentForEngine('claude_haiku', $this->azure instanceof DbnBedrockGateway);
$raw = $this->azure->withDeployment($deploy)->chatText($messages, array_merge($opts, ['timeout' => 90]));
$deployLabel = $deploy;
$thinkingTrace = null;
} elseif ($engine === 'claude_sonnet' || ($this->azure instanceof DbnBedrockGateway)) {
if (
+4 -6
View File
@@ -49,7 +49,7 @@ final class DbnDiscrepancyAgent
array $sliceSelection,
?callable $emit = null
): array {
$engine = in_array($engine, ['azure_mini', 'azure_full', 'gpu'], true) ? $engine : 'azure_mini';
$engine = in_array($engine, ['azure_mini', 'azure_full', 'gpu', 'claude_haiku', 'claude_sonnet'], true) ? $engine : 'azure_mini';
$language = dbnToolsNormalizeUiLanguage($language);
$textA = mb_substr((string)($fileA['text'] ?? ''), 0, self::MAX_DOC_CHARS, 'UTF-8');
@@ -754,11 +754,11 @@ PROMPT;
): array {
$locale = dbnToolsLanguageName($language);
$sourceCount = count($numberedSources);
$cloudDeploy = DbnBedrockModelRouter::deploymentForEngine($engine, $this->azure instanceof DbnBedrockGateway);
$deployLabel = match ($engine) {
'gpu' => 'GPU (cuttlefish)',
'dbn_legal_v3' => 'dbn-legal-agent-v3',
'azure_full' => 'gpt-4o',
default => $this->azure->chatDeployment(),
default => $cloudDeploy,
};
if (empty($numberedSources)) {
@@ -872,10 +872,8 @@ PROMPT;
} elseif ($engine === 'gpu') {
$response = dbnToolsCallGpuLlm($messages, $opts);
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
} elseif ($engine === 'azure_full') {
$raw = $this->azure->withDeployment('gpt-4o')->chatText($messages, $opts);
} else {
$raw = $this->azure->chatText($messages, $opts);
$raw = $this->azure->withDeployment($cloudDeploy)->chatText($messages, $opts);
}
} catch (Throwable $e) {
dbnToolsAbort('Synthesis LLM request failed: ' . $e->getMessage(), 502, 'llm_error');
+73 -22
View File
@@ -212,7 +212,7 @@ final class DbnLegalToolsService
public function ask(string $question, string $language = 'en', string $engine = 'azure_mini', ?string $persona = null): array
{
$engine = in_array($engine, ['azure_mini', 'azure_full'], true) ? $engine : 'azure_mini';
$engine = in_array($engine, ['azure_mini', 'azure_full', 'claude_haiku', 'claude_sonnet'], true) ? $engine : 'azure_mini';
$client = dbnToolsRequireClient();
$personaResolved = dbnToolsResolvePersona((int)$client['id'], $persona);
$search = $this->search($question, $language, 7, 'disabled', null, 'both', $personaResolved['slug']);
@@ -316,7 +316,7 @@ PROMPT;
}
array_unshift($uncertain, $this->degradedModelNotice($language));
$json['what_remains_uncertain'] = $uncertain;
$trace[] = $this->trace('Model routing', 'Fine-tuned legal model unavailable; answered with gpt-4o fallback (corpus + retrieval unaffected).', 'warning');
$trace[] = $this->trace('Model routing', 'Fine-tuned legal model unavailable; answered with cloud fallback (corpus + retrieval unaffected).', 'warning');
}
$trace[] = $this->trace('Synthesis', 'Azure OpenAI generated an answer using only the retrieved source excerpts.', 'complete');
@@ -1231,7 +1231,7 @@ PROMPT;
error_log('[dbn-persona] gateway init failed for model ' . $model . ': ' . $e->getMessage());
}
}
return [$this->azure, ($engine === 'azure_full') ? 'gpt-4o' : 'gpt-4o-mini'];
return [$this->azure, DbnBedrockModelRouter::deploymentForEngine($engine, $this->azure instanceof DbnBedrockGateway)];
}
/**
@@ -1253,9 +1253,18 @@ PROMPT;
return true;
}
/** The always-up cloud fallback used when a GPU-backed fine-tune is offline. */
private function cloudFallbackGateway(): array
/**
* The always-up cloud fallback used when a GPU-backed fine-tune is offline. Tier-aware:
* a requested quality tier (Quick→Haiku, Pro→Sonnet) degrades to that tier's Bedrock model
* so Pro still buys a stronger model than Quick while the pod is off. Legacy/azure engines
* keep gpt-4o as the capable general floor.
*/
private function cloudFallbackGateway(string $engine): array
{
if (($engine === 'claude_haiku' || $engine === 'claude_sonnet')
&& $this->azure instanceof DbnBedrockGateway) {
return [$this->azure, DbnBedrockModelRouter::deploymentForEngine($engine, true)];
}
return [$this->azure, 'gpt-4o'];
}
@@ -1338,7 +1347,7 @@ PROMPT;
error_log('[dbn-persona] GPU model ' . $model . ' marked unavailable; using cloud fallback.');
$failedModel = $model;
$degraded = true;
[$gw, $model] = $this->cloudFallbackGateway();
[$gw, $model] = $this->cloudFallbackGateway($engine);
}
try {
@@ -1352,7 +1361,7 @@ PROMPT;
$this->writeGpuHealth($model, false);
$failedModel = $model;
$degraded = true;
[$gw, $model] = $this->cloudFallbackGateway();
[$gw, $model] = $this->cloudFallbackGateway($engine);
$raw = $gw->withDeployment($model)->chatText($messages, $options);
}
@@ -1365,14 +1374,14 @@ PROMPT;
];
}
/** Localized notice shown when the fine-tuned legal model was offline and gpt-4o answered. */
/** Localized notice shown when the fine-tuned legal model was offline and a general cloud model answered. */
private function degradedModelNotice(string $language): string
{
return match (dbnToolsNormalizeUiLanguage($language)) {
'no' => 'Den spesialiserte, finjusterte norske juridiske modellen er midlertidig utilgjengelig, så dette svaret ble generert av den generelle modellen (gpt-4o). Det juridiske korpuset, kildene og bevisene er fullt tilgjengelige gjennomgå de siterte kildene som vanlig.',
'uk' => 'Спеціалізована доточена норвезька юридична модель тимчасово недоступна, тому цю відповідь згенерувала загальна модель (gpt-4o). Юридичний корпус, джерела та докази повністю доступні — перегляньте цитовані джерела, як зазвичай.',
'pl' => 'Wyspecjalizowany, dostrojony norweski model prawny jest tymczasowo niedostępny, więc tę odpowiedź wygenerował model ogólny (gpt-4o). Korpus prawny, źródła i dowody są w pełni dostępne — przejrzyj cytowane źródła jak zwykle.',
default => 'The specialized fine-tuned Norwegian legal model is temporarily offline, so this answer was generated by the general model (gpt-4o). The legal corpus, sources, and evidence are fully live — review the cited sources as usual.',
'no' => 'Den spesialiserte, finjusterte norske juridiske modellen er midlertidig utilgjengelig, så dette svaret ble generert av en generell skymodell. Det juridiske korpuset, kildene og bevisene er fullt tilgjengelige gjennomgå de siterte kildene som vanlig.',
'uk' => 'Спеціалізована доточена норвезька юридична модель тимчасово недоступна, тому цю відповідь згенерувала загальна хмарна модель. Юридичний корпус, джерела та докази повністю доступні — перегляньте цитовані джерела, як зазвичай.',
'pl' => 'Wyspecjalizowany, dostrojony norweski model prawny jest tymczasowo niedostępny, więc tę odpowiedź wygenerował ogólny model w chmurze. Korpus prawny, źródła i dowody są w pełni dostępne — przejrzyj cytowane źródła jak zwykle.',
default => 'The specialized fine-tuned Norwegian legal model is temporarily offline, so this answer was generated by a general cloud model. The legal corpus, sources, and evidence are fully live — review the cited sources as usual.',
};
}
@@ -1964,7 +1973,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 +2005,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) {
@@ -2016,7 +2069,7 @@ PROMPT;
string $depth = 'standard'
): array {
$text = $this->requirePasteText($text);
$engine = in_array($engine, ['azure_mini', 'azure_full', 'gpu'], true) ? $engine : 'azure_mini';
$engine = in_array($engine, ['azure_mini', 'azure_full', 'gpu', 'claude_haiku', 'claude_sonnet'], true) ? $engine : 'azure_mini';
$locale = dbnToolsLanguageName($language);
@@ -2070,7 +2123,7 @@ PROMPT;
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $prompt],
];
$maxTok = ($engine === 'azure_full') ? 8000 : 4000;
$maxTok = in_array($engine, ['azure_full', 'claude_sonnet'], true) ? 8000 : 4000;
$chatOpts = ['json' => true, 'temperature' => 0.1, 'max_tokens' => $maxTok, 'timeout' => 120];
$deployLabel = $this->azure->chatDeployment();
@@ -2078,12 +2131,10 @@ PROMPT;
if ($engine === 'gpu') {
$response = $this->callGpuLlm($messages, $chatOpts);
$deployLabel = 'GPU (local)';
} elseif ($engine === 'azure_full') {
$response = $this->azure->withDeployment('gpt-4o')->chat($messages, $chatOpts);
$deployLabel = 'gpt-4o';
} else {
$response = $this->azure->withDeployment('gpt-4o-mini')->chat($messages, $chatOpts);
$deployLabel = 'gpt-4o-mini';
$deploy = DbnBedrockModelRouter::deploymentForEngine($engine, $this->azure instanceof DbnBedrockGateway);
$response = $this->azure->withDeployment($deploy)->chat($messages, $chatOpts);
$deployLabel = $deploy;
}
} catch (Throwable $e) {
dbnToolsAbort('LLM request failed: ' . $e->getMessage(), 502, 'llm_error');
+124
View File
@@ -19,6 +19,130 @@ 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'];
}
/**
* 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
{
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'];
+120
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';
}
@@ -767,6 +833,60 @@ 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']);
$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'],
'engine' => $res['engine'],
'credits' => $res['credits'],
'ftUid' => $ftUid,
'metadata' => ['tier' => $res['tier'], 'engine' => $res['engine']],
];
}
$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,
'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.
+4
View File
@@ -51,6 +51,10 @@ $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)'];
$dashboardNav['feedback'] = ['url' => '/dashboard/feedback.php', 'label' => 'Tool Feedback', 'sub' => 'Ratings & notes (owner)'];
}
?>
<!doctype html>
<html lang="<?= htmlspecialchars($uiLang) ?>">
+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">
@@ -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;
+26
View File
@@ -0,0 +1,26 @@
-- 002_tool_feedback.sql
-- Target DB: dobetternorge_maindb (the tools' operational DB, via dbnmDb()).
-- Run manually after a mysqldump backup. Idempotent (IF NOT EXISTS).
--
-- Per-tool thumbs up/down + optional "what was missed or wrong" note, captured by
-- the in-result feedback widget (assets/js/tools.js) via api/feedback.php.
-- Metadata + voluntary comment only — NO pasted case content (process-and-forget).
--
-- tool : tool slug the result came from (ask|summarize|redact|timeline|...)
-- rating : positive | negative
-- engine : the engine string that produced the result (best-effort, client-supplied)
-- missed_or_wrong : short optional free-text note (what the tool missed/got wrong)
-- session_id : PHP session id (coarse de-dupe / abuse signal), not a user identity
CREATE TABLE IF NOT EXISTS tool_feedback (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
session_id VARCHAR(40) DEFAULT NULL,
tool VARCHAR(30) NOT NULL,
rating ENUM('positive','negative') NOT NULL,
missed_or_wrong TEXT DEFAULT NULL,
engine VARCHAR(60) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_tool (tool),
KEY idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+49
View File
@@ -0,0 +1,49 @@
# caveauDbnAgent — n8n starter workflow
A clonable n8n agent that drives the Do Better Norge legal tools over MCP. One
**MCP Client Tool** node connects to the DBN bridge and enumerates the **whole**
catalog automatically (currently **23** `dbn.*` tools) — the catalog is dynamic
(`includes/DbnMcpRuntime.php::tools()`), so new tools appear without editing this
workflow.
## What's in `caveauDbnAgent.json`
```
When chat message received ─▶ DBN Agent (AI Agent)
├─ ai_languageModel ◀─ Chat Model (placeholder)
└─ ai_tool ◀─ DBN MCP Tools
```
- **DBN Agent** — system prompt steers the default chain
`dbn.search_legal → dbn.legal_analysis → dbn.korrespond` and enforces the
process-and-forget privacy rule (never auto-saves; suggests `dbn.redact`).
- **DBN MCP Tools** — MCP Client Tool node, `httpStreamable` transport,
`https://mcp.dobetternorge.no/mcp`, Bearer auth, `include: all`.
- **Chat Model** — a placeholder `lmChatOpenAi` node. **No cloud spend is wired
by the template** — you attach your own credential.
## Import + wire it up (Dave's step — no n8n API access from here)
1. **n8n → Workflows → Import from File** → pick `caveauDbnAgent.json`.
2. **Mint a DBN MCP token**: log in at <https://tools.dobetternorge.no/mcp.php>,
create a user token (looks like `dbn_user_mcp_...`). Copy it once.
3. **DBN MCP Tools node → Credential → Bearer Auth**: paste the token as the
bearer value. (The node sends `Authorization: Bearer dbn_user_mcp_...`.)
4. **Chat Model node → Credential**: attach your existing OpenAI-compatible
credential — e.g. the colin LiteLLM proxy (`http://10.0.1.10:4000`, base-URL
override on an OpenAI cred) or Azure OpenAI. Set the model to one your key can
serve (e.g. `gpt-4o`).
5. **Open the chat** (the trigger node's built-in chat) and ask, e.g.:
*"Min sønn ble akuttplassert av barnevernet. Hva er fristene og hvilke
rettigheter har jeg?"* The agent should call `dbn.search_legal` /
`dbn.legal_analysis` and answer with cited sources.
## Notes
- The DBN tools require an active DBN member session behind the token; the token
carries the user/tenant + scopes. Keep the token secret (vault it; don't commit).
- If the MCP node lists zero tools, re-check the bearer token and that the bridge
(`mcp.dobetternorge.no`, caveau-mcp container `dobetternorge-mcp:3002` on colin)
is up.
- This starter matches the live catalog as of 2026-06-21. Re-export from n8n if
you customize it so the repo copy stays current.
+120
View File
@@ -0,0 +1,120 @@
{
"name": "caveauDbnAgent",
"nodes": [
{
"parameters": {
"options": {}
},
"id": "a1b2c3d4-0001-4a01-8a01-000000000001",
"name": "When chat message received",
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
"typeVersion": 1.1,
"position": [
-260,
0
],
"webhookId": "caveau-dbn-agent-chat"
},
{
"parameters": {
"options": {
"systemMessage": "You are caveauDbnAgent, a Norwegian family-law preparation assistant. You answer using ONLY the Do Better Norge (DBN) legal tools exposed over MCP — never invent statutes, case numbers, or citations.\n\nTOOLS: a single MCP connection exposes the full DBN catalog (dbn.search_legal, dbn.corpus_search, dbn.ask, dbn.list_personas, dbn.summarize, dbn.timeline, dbn.redact, dbn.translate, dbn.legal_analysis, dbn.korrespond, dbn.korrespond_refine, dbn.barnevernet_analyze, dbn.advocate_brief, dbn.deep_research, dbn.discrepancy_find, dbn.transcribe_audio, dbn.extract_text, dbn.corpus_stats, dbn.list_documents, dbn.get_document, dbn.citation_graph, dbn.case_workbench_plan, dbn.save_to_case).\n\nDEFAULT CHAIN: for a typical case-prep request, (1) call dbn.search_legal to ground yourself in the corpus, (2) call dbn.legal_analysis on the user's document or question, then (3) call dbn.korrespond to draft any letter to a Norwegian authority. Pick a persona with the `profile` argument (family, child-welfare, immigration, labour, consumer-tenancy, general) — default family. Call dbn.list_personas if unsure.\n\nPRIVACY: the DBN tools are process-and-forget. Do NOT call dbn.save_to_case unless the user explicitly asks to save a result. Suggest dbn.redact before sharing anything containing names or IDs.\n\nAlways cite the sources the tools return. If a tool returns no grounded sources, say so plainly rather than guessing."
}
},
"id": "a1b2c3d4-0002-4a01-8a01-000000000002",
"name": "DBN Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 1.7,
"position": [
0,
0
]
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "gpt-4o"
},
"options": {}
},
"id": "a1b2c3d4-0003-4a01-8a01-000000000003",
"name": "Chat Model (wire your LiteLLM/Azure cred)",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
-120,
220
],
"notes": "Placeholder. Attach your existing OpenAI-compatible credential (e.g. colin LiteLLM at :4000, or Azure OpenAI). No new cloud spend is wired by this template."
},
{
"parameters": {
"endpointUrl": "https://mcp.dobetternorge.no/mcp",
"serverTransport": "httpStreamable",
"authentication": "bearerAuth",
"include": "all"
},
"id": "a1b2c3d4-0004-4a01-8a01-000000000004",
"name": "DBN MCP Tools",
"type": "@n8n/n8n-nodes-langchain.mcpClientTool",
"typeVersion": 1,
"position": [
180,
220
],
"notes": "Bearer credential = a DBN user MCP token (dbn_user_mcp_...) minted at https://tools.dobetternorge.no/mcp.php. The catalog is dynamic — this one node enumerates all 23 dbn.* tools."
}
],
"connections": {
"When chat message received": {
"main": [
[
{
"node": "DBN Agent",
"type": "main",
"index": 0
}
]
]
},
"Chat Model (wire your LiteLLM/Azure cred)": {
"ai_languageModel": [
[
{
"node": "DBN Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"DBN MCP Tools": {
"ai_tool": [
[
{
"node": "DBN Agent",
"type": "ai_tool",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"pinData": {},
"meta": {
"templateId": "caveau-dbn-agent-starter"
},
"tags": [
{
"name": "caveauAI"
},
{
"name": "dbn"
}
]
}
+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>