Compare commits
6 Commits
master
..
519bdbb6e5
| Author | SHA1 | Date | |
|---|---|---|---|
| 519bdbb6e5 | |||
| 198f0526cf | |||
| 2f05b84b0f | |||
| f270a32056 | |||
| a4b5b6e3f2 | |||
| a8b1bb87a6 |
+5
-5
@@ -50,12 +50,12 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
<small id="advInputCount" class="adv-char-count">0 / 4,000</small>
|
<small id="advInputCount" class="adv-char-count">0 / 4,000</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control-row" id="advEngineControl">
|
<div class="control-row" id="advTierControl">
|
||||||
<span class="control-label">Engine</span>
|
<span class="control-label">Quality</span>
|
||||||
<label><input type="radio" name="advEngine" value="azure_mini" checked> ☁️ Claude Haiku 4.5 <small class="control-hint">(fast · ~2-4 min)</small></label>
|
<label><input type="radio" name="advTier" value="quick" checked> Quick ★ <small class="control-hint">(Claude Haiku · fast · 3 credits)</small></label>
|
||||||
<label><input type="radio" name="advEngine" value="claude_sonnet"> ☁️ Claude Sonnet 4.6 ★★ <small class="control-hint">(thorough · ~3-5 min)</small></label>
|
<label><input type="radio" name="advTier" value="pro"> Pro <small class="control-hint">(Claude Sonnet · best · 6 credits)</small></label>
|
||||||
</div>
|
</div>
|
||||||
<p class="upload-hint">Both engines run on AWS Bedrock via Claude. Most of the time is spent on multiple question-answering passes — 6–10 sub-questions each requiring a full retrieval and answer cycle. Haiku is faster and handles most cases well. Sonnet produces a more thorough brief with deeper ECHR precedent analysis and stronger multi-party argumentation.</p>
|
<p class="upload-hint">Most of the time is spent on multiple question-answering passes — 6–10 sub-questions each requiring a full retrieval and answer cycle. Quick uses Claude Haiku 4.5 — faster and handles most cases well (3 credits). Pro uses Claude Sonnet 4.6 — a more thorough brief with deeper ECHR precedent analysis and stronger multi-party argumentation (6 credits).</p>
|
||||||
|
|
||||||
<div class="dr-slice-section">
|
<div class="dr-slice-section">
|
||||||
<p class="control-label">Corpus slices</p>
|
<p class="control-label">Corpus slices</p>
|
||||||
|
|||||||
+4
-3
@@ -6,10 +6,11 @@ require_once __DIR__ . '/../includes/ToolModels.php';
|
|||||||
|
|
||||||
dbnToolsRequireMethod('POST');
|
dbnToolsRequireMethod('POST');
|
||||||
dbnToolsRequireAuth();
|
dbnToolsRequireAuth();
|
||||||
$ftUid = dbnToolsFreeTierCheck('ask');
|
|
||||||
$engine = ToolModels::engineForUser($ftUid, 'azure_mini');
|
|
||||||
$input = dbnToolsJsonInput(25000);
|
$input = dbnToolsJsonInput(25000);
|
||||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
$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 {
|
dbnToolsWithChargedTelemetry('ask', $language, $ftUid, function () use ($input, $language, $engine): array {
|
||||||
$question = dbnToolsInjectDocContent($input, dbnToolsString($input, 'question', 4000, false));
|
$question = dbnToolsInjectDocContent($input, dbnToolsString($input, 'question', 4000, false));
|
||||||
@@ -20,4 +21,4 @@ dbnToolsWithChargedTelemetry('ask', $language, $ftUid, function () use ($input,
|
|||||||
? trim($input['profile'])
|
? trim($input['profile'])
|
||||||
: null;
|
: null;
|
||||||
return (new DbnLegalToolsService())->ask($question, $language, $engine, $persona);
|
return (new DbnLegalToolsService())->ask($question, $language, $engine, $persona);
|
||||||
});
|
}, $run['credits'], $run['metadata']);
|
||||||
|
|||||||
+20
-2
@@ -53,7 +53,23 @@ try {
|
|||||||
|
|
||||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||||
$advocateRole = trim((string)($input['advocate_role'] ?? ''));
|
$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'] ?? [];
|
$sliceInput = $input['slices'] ?? [];
|
||||||
$controls = is_array($input['controls'] ?? null) ? $input['controls'] : [];
|
$controls = is_array($input['controls'] ?? null) ? $input['controls'] : [];
|
||||||
$additionalNotes = mb_substr(dbnToolsInjectDocContent($input, trim((string)($input['additional_notes'] ?? ''))), 0, 8000, 'UTF-8');
|
$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,
|
'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) {
|
if ($ftRemaining >= 0) {
|
||||||
$result['balance'] = $ftRemaining;
|
$result['balance'] = $ftRemaining;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -65,8 +65,9 @@ try {
|
|||||||
throw new DbnToolsHttpException('advocate_role is too long.', 422, 'advocate_role_too_long');
|
throw new DbnToolsHttpException('advocate_role is too long.', 422, 'advocate_role_too_long');
|
||||||
}
|
}
|
||||||
$chargeTool = $advocateRole !== '' ? 'advocate' : 'deep-research';
|
$chargeTool = $advocateRole !== '' ? 'advocate' : 'deep-research';
|
||||||
$ftUid = dbnToolsFreeTierCheck($chargeTool);
|
$run = dbnToolsResolveToolRun($chargeTool, $input);
|
||||||
$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini'));
|
$ftUid = $run['ftUid'];
|
||||||
|
$engine = $run['engine'];
|
||||||
$priorContext = is_array($input['prior_context'] ?? null) ? $input['prior_context'] : null;
|
$priorContext = is_array($input['prior_context'] ?? null) ? $input['prior_context'] : null;
|
||||||
$branchNotes = mb_substr(trim((string)($input['branch_notes'] ?? '')), 0, 1000, 'UTF-8');
|
$branchNotes = mb_substr(trim((string)($input['branch_notes'] ?? '')), 0, 1000, 'UTF-8');
|
||||||
$subQsOverride = is_array($input['sub_questions_override'] ?? null) ? $input['sub_questions_override'] : [];
|
$subQsOverride = is_array($input['sub_questions_override'] ?? null) ? $input['sub_questions_override'] : [];
|
||||||
@@ -160,7 +161,9 @@ try {
|
|||||||
'advocate_role' => $advocateRole !== '' ? $advocateRole : null,
|
'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) {
|
if ($ftRemaining >= 0) {
|
||||||
$result['balance'] = $ftRemaining;
|
$result['balance'] = $ftRemaining;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-2
@@ -41,7 +41,23 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
$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'] ?? [];
|
$sliceInput = $input['slices'] ?? [];
|
||||||
|
|
||||||
// Extract file A
|
// Extract file A
|
||||||
@@ -144,7 +160,9 @@ try {
|
|||||||
'deployment' => $result['trace_metadata']['deployment'] ?? null,
|
'deployment' => $result['trace_metadata']['deployment'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'discrepancy');
|
$ftRemaining = $tierCredits === null
|
||||||
|
? dbnToolsFreeTierDeduct($ftUid, 'discrepancy')
|
||||||
|
: dbnToolsFreeTierDeductAmount($ftUid, 'discrepancy', $tierCredits, $tierMeta);
|
||||||
if ($ftRemaining >= 0) {
|
if ($ftRemaining >= 0) {
|
||||||
$result['balance'] = $ftRemaining;
|
$result['balance'] = $ftRemaining;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ if ($tool === '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = dbnToolsDb();
|
$db = dbnmDb();
|
||||||
$stmt = $db->prepare(
|
$stmt = $db->prepare(
|
||||||
'INSERT INTO tool_feedback (session_id, tool, rating, missed_or_wrong, engine)
|
'INSERT INTO tool_feedback (session_id, tool, rating, missed_or_wrong, engine)
|
||||||
VALUES (?, ?, ?, ?, ?)'
|
VALUES (?, ?, ?, ?, ?)'
|
||||||
|
|||||||
+16
-7
@@ -165,15 +165,24 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Deduct credit now (Pass 2 starts) ───────────────────────────────────────
|
// ── Deduct credit now (Pass 2 starts) ───────────────────────────────────────
|
||||||
$ftUid = dbnToolsFreeTierCheck('korrespond');
|
if (isset($input['tier'])) {
|
||||||
$engine = ToolModels::engineForUser($ftUid, 'azure_mini');
|
$run = dbnToolsResolveToolRun('korrespond', $input);
|
||||||
$inputEngine = (string)($input['engine'] ?? '');
|
$ftUid = $run['ftUid'];
|
||||||
if (in_array($inputEngine, ['azure_mini', 'claude_sonnet'], true)) {
|
$engine = $run['engine'];
|
||||||
$engine = $inputEngine;
|
} 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)
|
$length = in_array($input['length'] ?? '', ['concise', 'standard', 'detailed'], true)
|
||||||
? (string)$input['length'] : 'standard';
|
? (string)$input['length'] : 'standard';
|
||||||
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond');
|
$ftRemaining = $run['credits'] === null
|
||||||
|
? dbnToolsFreeTierDeduct($ftUid, 'korrespond')
|
||||||
|
: dbnToolsFreeTierDeductAmount($ftUid, 'korrespond', $run['credits'], $run['metadata']);
|
||||||
$creditDeducted = true;
|
$creditDeducted = true;
|
||||||
|
|
||||||
$personaSlug = (isset($input['profile']) && is_string($input['profile']) && trim($input['profile']) !== '')
|
$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'] ?? [],
|
'case_doc_ids' => $GLOBALS['dbn_last_case_doc_ids'] ?? [],
|
||||||
'model' => $engine,
|
'model' => $engine,
|
||||||
'latency_ms' => $result['latency_ms'],
|
'latency_ms' => $result['latency_ms'],
|
||||||
'credits_charged' => 1,
|
'credits_charged' => $run['credits'] ?? 1,
|
||||||
]);
|
]);
|
||||||
} catch (Throwable) { /* non-critical */ }
|
} catch (Throwable) { /* non-critical */ }
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -6,11 +6,12 @@ require_once __DIR__ . '/../includes/ToolModels.php';
|
|||||||
|
|
||||||
dbnToolsRequireMethod('POST');
|
dbnToolsRequireMethod('POST');
|
||||||
dbnToolsRequireAuth();
|
dbnToolsRequireAuth();
|
||||||
$ftUid = dbnToolsFreeTierCheck('summarize');
|
|
||||||
|
|
||||||
$input = dbnToolsJsonInput(400000);
|
$input = dbnToolsJsonInput(400000);
|
||||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
$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)
|
$depth = in_array($input['depth'] ?? '', ['brief', 'standard', 'detailed'], true)
|
||||||
? (string)$input['depth'] : 'standard';
|
? (string)$input['depth'] : 'standard';
|
||||||
$slices = is_array($input['slices'] ?? null) ? array_values(array_filter($input['slices'])) : [];
|
$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);
|
$result = (new DbnLegalToolsService())->summarizeWithContext($text, $language, $engine, $corpusContext, $depth);
|
||||||
|
|
||||||
if ($ftUid > 0) {
|
if ($ftUid > 0) {
|
||||||
$balance = dbnToolsFreeTierDeduct($ftUid, 'summarize');
|
$balance = $run['credits'] === null
|
||||||
|
? dbnToolsFreeTierDeduct($ftUid, 'summarize')
|
||||||
|
: dbnToolsFreeTierDeductAmount($ftUid, 'summarize', $run['credits'], $run['metadata']);
|
||||||
$result['balance'] = $balance;
|
$result['balance'] = $balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -64,7 +64,7 @@
|
|||||||
roleCustom: document.getElementById('advRoleCustom'),
|
roleCustom: document.getElementById('advRoleCustom'),
|
||||||
slices: Array.from(document.querySelectorAll('.adv-slice')),
|
slices: Array.from(document.querySelectorAll('.adv-slice')),
|
||||||
langButtons: Array.from(document.querySelectorAll('#advLangSwitcher .lang-btn')),
|
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'),
|
subQ: document.getElementById('advSubQ'),
|
||||||
subQVal: document.getElementById('advSubQValue'),
|
subQVal: document.getElementById('advSubQValue'),
|
||||||
chunkLimit: document.getElementById('advChunkLimit'),
|
chunkLimit: document.getElementById('advChunkLimit'),
|
||||||
@@ -337,9 +337,9 @@
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEngine() {
|
function getTier() {
|
||||||
const checked = els.engineRadios.find((r) => r.checked);
|
const checked = els.tierRadios.find((r) => r.checked);
|
||||||
return checked ? checked.value : 'azure_mini';
|
return checked ? checked.value : 'quick';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getControls() {
|
function getControls() {
|
||||||
@@ -371,10 +371,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const engine = getEngine();
|
const tier = getTier();
|
||||||
const expectedDuration = engine === 'azure_full'
|
const expectedDuration = tier === 'pro'
|
||||||
? '60–180 seconds with Azure gpt-4o'
|
? '3–5 minutes with Claude Sonnet'
|
||||||
: (engine === 'gpu' ? '30–90 seconds on GPU' : '15–45 seconds with Azure gpt-4o-mini');
|
: '2–4 minutes with Claude Haiku';
|
||||||
|
|
||||||
setStatus(`Building advocate brief for ${advocateRole}… (${expectedDuration})`, 'busy');
|
setStatus(`Building advocate brief for ${advocateRole}… (${expectedDuration})`, 'busy');
|
||||||
els.runButton.disabled = true;
|
els.runButton.disabled = true;
|
||||||
@@ -387,7 +387,7 @@
|
|||||||
query,
|
query,
|
||||||
paste_text: '',
|
paste_text: '',
|
||||||
slices,
|
slices,
|
||||||
engine,
|
tier,
|
||||||
language: lang,
|
language: lang,
|
||||||
controls: getControls(),
|
controls: getControls(),
|
||||||
advocate_role: advocateRole,
|
advocate_role: advocateRole,
|
||||||
@@ -505,7 +505,7 @@
|
|||||||
els.runButton.disabled = false;
|
els.runButton.disabled = false;
|
||||||
renderTrace(finalResult.trace || []);
|
renderTrace(finalResult.trace || []);
|
||||||
renderResults(finalResult);
|
renderResults(finalResult);
|
||||||
saveToCache(finalResult, { query, role: advocateRole, engine, slices, lang });
|
saveToCache(finalResult, { query, role: advocateRole, tier, slices, lang });
|
||||||
|
|
||||||
function handleStreamEvent(evt) {
|
function handleStreamEvent(evt) {
|
||||||
if (!evt || !evt.event) return;
|
if (!evt || !evt.event) return;
|
||||||
@@ -957,7 +957,7 @@
|
|||||||
els.input.value = formState.query || '';
|
els.input.value = formState.query || '';
|
||||||
updateCharCount();
|
updateCharCount();
|
||||||
if (formState.role) els.roleSelect.value = formState.role;
|
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 (radio) radio.checked = true;
|
||||||
if (formState.slices) {
|
if (formState.slices) {
|
||||||
els.slices.forEach((btn) => {
|
els.slices.forEach((btn) => {
|
||||||
@@ -1074,7 +1074,7 @@
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
query,
|
query,
|
||||||
language: lang,
|
language: lang,
|
||||||
engine: getEngine(),
|
engine: getTier() === 'pro' ? 'claude_sonnet' : 'claude_haiku',
|
||||||
controls: getControls(),
|
controls: getControls(),
|
||||||
advocate_role: advocateRole,
|
advocate_role: advocateRole,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
roleCustom: document.getElementById('bvjRoleCustom'),
|
roleCustom: document.getElementById('bvjRoleCustom'),
|
||||||
slices: Array.from(document.querySelectorAll('.adv-slice')),
|
slices: Array.from(document.querySelectorAll('.adv-slice')),
|
||||||
langButtons: Array.from(document.querySelectorAll('#bvjLangSwitcher .lang-btn')),
|
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'),
|
subQ: document.getElementById('bvjSubQ'),
|
||||||
subQVal: document.getElementById('bvjSubQValue'),
|
subQVal: document.getElementById('bvjSubQValue'),
|
||||||
chunkLimit: document.getElementById('bvjChunkLimit'),
|
chunkLimit: document.getElementById('bvjChunkLimit'),
|
||||||
@@ -186,9 +186,9 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEngine() {
|
function getTier() {
|
||||||
const checked = els.engineRadios.find((r) => r.checked);
|
const checked = els.tierRadios.find((r) => r.checked);
|
||||||
return checked ? checked.value : 'azure_mini';
|
return checked ? checked.value : 'quick';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── File upload ────────────────────────────────────────────────────────────
|
// ── File upload ────────────────────────────────────────────────────────────
|
||||||
@@ -385,13 +385,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const engine = getEngine();
|
const tier = getTier();
|
||||||
const additionalNotes = (els.notes ? els.notes.value : '').trim();
|
const additionalNotes = (els.notes ? els.notes.value : '').trim();
|
||||||
const expectedDuration = engine === 'azure_full'
|
const expectedDuration = tier === 'pro'
|
||||||
? '90–180 seconds with Azure gpt-4o'
|
? '90–180 seconds with Claude Sonnet'
|
||||||
: (engine === 'gpu' ? '45–90 seconds on GPU'
|
: '30–60 seconds with Claude Haiku';
|
||||||
: (engine === 'dbn_legal' ? '60–120 seconds with Norwegian specialist'
|
|
||||||
: '30–60 seconds with Azure gpt-4o-mini'));
|
|
||||||
|
|
||||||
setStatus(`Analysing document for ${advocateRole}… (${expectedDuration})`, 'busy');
|
setStatus(`Analysing document for ${advocateRole}… (${expectedDuration})`, 'busy');
|
||||||
els.runButton.disabled = true;
|
els.runButton.disabled = true;
|
||||||
@@ -403,7 +401,7 @@
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
advocate_role: advocateRole,
|
advocate_role: advocateRole,
|
||||||
engine,
|
tier,
|
||||||
language: lang,
|
language: lang,
|
||||||
slices,
|
slices,
|
||||||
controls: getControls(),
|
controls: getControls(),
|
||||||
|
|||||||
@@ -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 => ({ '&':'&','<':'<','>':'>','"':'"' }[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();
|
||||||
|
})();
|
||||||
@@ -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 => ({ '&':'&','<':'<','>':'>','"':'"' }[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();
|
||||||
|
})();
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
traceList: document.getElementById('traceList'),
|
traceList: document.getElementById('traceList'),
|
||||||
slices: Array.from(document.querySelectorAll('.dr-slice')),
|
slices: Array.from(document.querySelectorAll('.dr-slice')),
|
||||||
langButtons: Array.from(document.querySelectorAll('#drLangSwitcher .lang-btn')),
|
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'),
|
personaControl: document.getElementById('drPersonaControl'),
|
||||||
personaSelect: document.getElementById('drPersonaSelect'),
|
personaSelect: document.getElementById('drPersonaSelect'),
|
||||||
subQ: document.getElementById('drSubQ'),
|
subQ: document.getElementById('drSubQ'),
|
||||||
@@ -280,9 +280,9 @@
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEngine() {
|
function getTier() {
|
||||||
const checked = els.engineRadios.find((r) => r.checked);
|
const checked = els.tierRadios.find((r) => r.checked);
|
||||||
return checked ? checked.value : 'azure_mini';
|
return checked ? checked.value : 'quick';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getControls() {
|
function getControls() {
|
||||||
@@ -308,10 +308,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const engine = getEngine();
|
const tier = getTier();
|
||||||
const expectedDuration = engine === 'azure_full'
|
const expectedDuration = tier === 'pro'
|
||||||
? '60–180 seconds with Azure gpt-4o'
|
? '60–180 seconds with Claude Sonnet'
|
||||||
: (engine === 'gpu' ? '30–90 seconds on GPU' : '15–45 seconds with Azure gpt-4o-mini');
|
: '15–45 seconds with Claude Haiku';
|
||||||
|
|
||||||
setStatus(`Running deep research… (${expectedDuration})`, 'busy');
|
setStatus(`Running deep research… (${expectedDuration})`, 'busy');
|
||||||
els.runButton.disabled = true;
|
els.runButton.disabled = true;
|
||||||
@@ -325,7 +325,7 @@
|
|||||||
query,
|
query,
|
||||||
paste_text: '',
|
paste_text: '',
|
||||||
slices,
|
slices,
|
||||||
engine,
|
tier,
|
||||||
language: lang,
|
language: lang,
|
||||||
controls: getControls(),
|
controls: getControls(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
results: document.getElementById('dcResults'),
|
results: document.getElementById('dcResults'),
|
||||||
traceList: document.getElementById('traceList'),
|
traceList: document.getElementById('traceList'),
|
||||||
langButtons: Array.from(document.querySelectorAll('#dcLangSwitcher .lang-btn')),
|
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')),
|
slices: Array.from(document.querySelectorAll('.adv-slice')),
|
||||||
// File A
|
// File A
|
||||||
zoneA: document.getElementById('dcZoneA'),
|
zoneA: document.getElementById('dcZoneA'),
|
||||||
@@ -188,12 +188,10 @@
|
|||||||
return;
|
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 slices = getSelectedSlices();
|
||||||
|
|
||||||
const expectedDuration = engine === 'azure_full' ? '2-3 minutes'
|
const expectedDuration = tier === 'pro' ? '2-3 minutes' : '60-90 seconds';
|
||||||
: engine === 'gpu' ? '~90 seconds'
|
|
||||||
: '60-90 seconds';
|
|
||||||
|
|
||||||
setStatus(`Comparing documents… (${expectedDuration})`, 'busy');
|
setStatus(`Comparing documents… (${expectedDuration})`, 'busy');
|
||||||
els.runButton.disabled = true;
|
els.runButton.disabled = true;
|
||||||
@@ -203,7 +201,7 @@
|
|||||||
renderTrace(stepState);
|
renderTrace(stepState);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
engine, language: lang, slices,
|
tier, language: lang, slices,
|
||||||
use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false,
|
use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false,
|
||||||
};
|
};
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
|
|||||||
@@ -280,7 +280,7 @@
|
|||||||
clarifications: pendingClarifications,
|
clarifications: pendingClarifications,
|
||||||
force_draft: !!forceDraft,
|
force_draft: !!forceDraft,
|
||||||
use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false,
|
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'),
|
length: (document.querySelector('[name="korrLength"]:checked')?.value ?? 'standard'),
|
||||||
};
|
};
|
||||||
if (korrDocIds.length) payload.doc_ids = korrDocIds;
|
if (korrDocIds.length) payload.doc_ids = korrDocIds;
|
||||||
|
|||||||
@@ -179,13 +179,13 @@
|
|||||||
return;
|
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 slices = activeSlices();
|
||||||
|
|
||||||
var payload = {
|
var payload = {
|
||||||
text: combined,
|
text: combined,
|
||||||
language: _currentLang,
|
language: _currentLang,
|
||||||
engine: engine,
|
tier: tier,
|
||||||
depth: (document.querySelector('input[name="sumDepth"]:checked') || {}).value || 'standard',
|
depth: (document.querySelector('input[name="sumDepth"]:checked') || {}).value || 'standard',
|
||||||
slices: slices,
|
slices: slices,
|
||||||
};
|
};
|
||||||
|
|||||||
+5
-7
@@ -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>
|
<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>
|
||||||
|
|
||||||
<div class="control-row" id="bvjEngineControl">
|
<div class="control-row" id="bvjTierControl">
|
||||||
<span class="control-label">Engine</span>
|
<span class="control-label">Quality</span>
|
||||||
<label><input type="radio" name="bvjEngine" value="azure_mini" checked> Azure gpt-4o-mini ★ <small class="control-hint">(~30-60s)</small></label>
|
<label><input type="radio" name="bvjTier" value="quick" checked> Quick ★ <small class="control-hint">(Claude Haiku · fast · 3 credits)</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="bvjTier" value="pro"> Pro <small class="control-hint">(Claude Sonnet · best · 6 credits)</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"> 🇳🇴⚔️ DBN Legal Agent ★ <small class="control-hint">(dbn-legal-agent-v3 fine-tune · ~20-60s)</small></label>
|
|
||||||
</div>
|
</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">
|
<div class="dr-slice-section">
|
||||||
<p class="control-label">Corpus slices</p>
|
<p class="control-label">Corpus slices</p>
|
||||||
|
|||||||
@@ -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'; ?>
|
||||||
@@ -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
@@ -16,14 +16,12 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
<button type="button" class="lang-btn" data-lang="pl">🇵🇱 PL</button>
|
<button type="button" class="lang-btn" data-lang="pl">🇵🇱 PL</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control-row" id="drEngineControl">
|
<div class="control-row" id="drTierControl">
|
||||||
<span class="control-label">Engine</span>
|
<span class="control-label">Quality</span>
|
||||||
<label><input type="radio" name="drEngine" value="azure_mini" checked> Azure gpt-4o-mini ★ <small class="control-hint">(~15-45s)</small></label>
|
<label><input type="radio" name="drTier" value="quick" checked> Quick ★ <small class="control-hint">(Claude Haiku · fast · 6 credits)</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="drTier" value="pro"> Pro <small class="control-hint">(Claude Sonnet · best · 12 credits)</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"> 🇳🇴⚔️ DBN Legal Agent ★ <small class="control-hint">(dbn-legal-agent-v3 fine-tune · ~20-60s)</small></label>
|
|
||||||
</div>
|
</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">
|
<div class="control-row corpus-persona is-hidden" id="drPersonaControl">
|
||||||
<span class="control-label">Domain</span>
|
<span class="control-label">Domain</span>
|
||||||
|
|||||||
+5
-7
@@ -55,14 +55,12 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control-row" id="dcEngineControl">
|
<div class="control-row" id="dcTierControl">
|
||||||
<span class="control-label">Engine</span>
|
<span class="control-label">Quality</span>
|
||||||
<label><input type="radio" name="dcEngine" value="azure_mini" checked> Azure gpt-4o-mini ★ <small class="control-hint">(~60-90s)</small></label>
|
<label><input type="radio" name="dcTier" value="quick" checked> Quick ★ <small class="control-hint">(Claude Haiku · fast · 4 credits)</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="dcTier" value="pro"> Pro <small class="control-hint">(Claude Sonnet · best · 8 credits)</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"> 🇳🇴⚔️ DBN Legal Agent ★ <small class="control-hint">(dbn-legal-agent-v3 fine-tune · ~30-60s)</small></label>
|
|
||||||
</div>
|
</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">
|
<details class="advanced-panel" id="dcSlicePanel">
|
||||||
<summary class="advanced-toggle">Corpus slices <span class="control-hint">(used for legal significance context)</span></summary>
|
<summary class="advanced-toggle">Corpus slices <span class="control-hint">(used for legal significance context)</span></summary>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ final class DbnBvjAnalyzerAgent
|
|||||||
string $additionalNotes = '',
|
string $additionalNotes = '',
|
||||||
?callable $emit = null
|
?callable $emit = null
|
||||||
): array {
|
): 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';
|
? $engine : 'azure_mini';
|
||||||
$language = dbnToolsNormalizeUiLanguage($language);
|
$language = dbnToolsNormalizeUiLanguage($language);
|
||||||
$controls = $this->normalizeControls($controls);
|
$controls = $this->normalizeControls($controls);
|
||||||
@@ -941,11 +941,11 @@ PROMPT;
|
|||||||
];
|
];
|
||||||
$opts = ['json' => true, 'temperature' => $temperature, 'max_tokens' => 4500, 'timeout' => 240];
|
$opts = ['json' => true, 'temperature' => $temperature, 'max_tokens' => 4500, 'timeout' => 240];
|
||||||
|
|
||||||
|
$cloudDeploy = DbnBedrockModelRouter::deploymentForEngine($engine, $this->azure instanceof DbnBedrockGateway);
|
||||||
$deployLabel = match ($engine) {
|
$deployLabel = match ($engine) {
|
||||||
'gpu' => 'GPU (cuttlefish)',
|
'gpu' => 'GPU (cuttlefish)',
|
||||||
'dbn_legal_v3' => 'dbn-legal-agent-v3',
|
'dbn_legal_v3' => 'dbn-legal-agent-v3',
|
||||||
'azure_full' => 'gpt-4o',
|
default => $cloudDeploy,
|
||||||
default => $this->azure->chatDeployment(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$raw = '';
|
$raw = '';
|
||||||
@@ -956,10 +956,8 @@ PROMPT;
|
|||||||
} elseif ($engine === 'gpu') {
|
} elseif ($engine === 'gpu') {
|
||||||
$response = dbnToolsCallGpuLlm($messages, $opts);
|
$response = dbnToolsCallGpuLlm($messages, $opts);
|
||||||
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
|
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
|
||||||
} elseif ($engine === 'azure_full') {
|
|
||||||
$raw = $this->azure->withDeployment('gpt-4o')->chatText($messages, $opts);
|
|
||||||
} else {
|
} else {
|
||||||
$raw = $this->azure->chatText($messages, $opts);
|
$raw = $this->azure->withDeployment($cloudDeploy)->chatText($messages, $opts);
|
||||||
}
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
dbnToolsAbort('Synthesis LLM request failed: ' . $e->getMessage(), 502, 'llm_error');
|
dbnToolsAbort('Synthesis LLM request failed: ' . $e->getMessage(), 502, 'llm_error');
|
||||||
|
|||||||
@@ -73,6 +73,22 @@ final class DbnBedrockModelRouter
|
|||||||
return $route['model'] ?? self::LITELLM_SONNET;
|
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
|
public static function supportsThinking(string $modelName): bool
|
||||||
{
|
{
|
||||||
return in_array($modelName, self::THINKING_MODELS, true);
|
return in_array($modelName, self::THINKING_MODELS, true);
|
||||||
|
|||||||
@@ -1167,11 +1167,11 @@ PROMPT;
|
|||||||
} elseif ($engine === 'azure_full') {
|
} elseif ($engine === 'azure_full') {
|
||||||
$raw = $this->azure->withDeployment('gpt-4o')->chatText($messages, $opts);
|
$raw = $this->azure->withDeployment('gpt-4o')->chatText($messages, $opts);
|
||||||
$deployLabel = 'gpt-4o';
|
$deployLabel = 'gpt-4o';
|
||||||
} elseif ($engine === 'azure_mini' && $this->azure instanceof DbnBedrockGateway) {
|
} elseif ($engine === 'claude_haiku' || ($engine === 'azure_mini' && $this->azure instanceof DbnBedrockGateway)) {
|
||||||
// When Bedrock enabled, azure_mini → Haiku (fast, ~20-50s synthesis)
|
// Quick tier (claude_haiku) and azure_mini-under-Bedrock → Haiku (fast, ~20-50s synthesis)
|
||||||
$haiku = $this->azure->withDeployment(DbnBedrockModelRouter::LITELLM_HAIKU);
|
$deploy = DbnBedrockModelRouter::deploymentForEngine('claude_haiku', $this->azure instanceof DbnBedrockGateway);
|
||||||
$raw = $haiku->chatText($messages, array_merge($opts, ['timeout' => 90]));
|
$raw = $this->azure->withDeployment($deploy)->chatText($messages, array_merge($opts, ['timeout' => 90]));
|
||||||
$deployLabel = 'Claude Haiku 4.5 (AWS Bedrock)';
|
$deployLabel = $deploy;
|
||||||
$thinkingTrace = null;
|
$thinkingTrace = null;
|
||||||
} elseif ($engine === 'claude_sonnet' || ($this->azure instanceof DbnBedrockGateway)) {
|
} elseif ($engine === 'claude_sonnet' || ($this->azure instanceof DbnBedrockGateway)) {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ final class DbnDiscrepancyAgent
|
|||||||
array $sliceSelection,
|
array $sliceSelection,
|
||||||
?callable $emit = null
|
?callable $emit = null
|
||||||
): array {
|
): 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);
|
$language = dbnToolsNormalizeUiLanguage($language);
|
||||||
|
|
||||||
$textA = mb_substr((string)($fileA['text'] ?? ''), 0, self::MAX_DOC_CHARS, 'UTF-8');
|
$textA = mb_substr((string)($fileA['text'] ?? ''), 0, self::MAX_DOC_CHARS, 'UTF-8');
|
||||||
@@ -754,11 +754,11 @@ PROMPT;
|
|||||||
): array {
|
): array {
|
||||||
$locale = dbnToolsLanguageName($language);
|
$locale = dbnToolsLanguageName($language);
|
||||||
$sourceCount = count($numberedSources);
|
$sourceCount = count($numberedSources);
|
||||||
|
$cloudDeploy = DbnBedrockModelRouter::deploymentForEngine($engine, $this->azure instanceof DbnBedrockGateway);
|
||||||
$deployLabel = match ($engine) {
|
$deployLabel = match ($engine) {
|
||||||
'gpu' => 'GPU (cuttlefish)',
|
'gpu' => 'GPU (cuttlefish)',
|
||||||
'dbn_legal_v3' => 'dbn-legal-agent-v3',
|
'dbn_legal_v3' => 'dbn-legal-agent-v3',
|
||||||
'azure_full' => 'gpt-4o',
|
default => $cloudDeploy,
|
||||||
default => $this->azure->chatDeployment(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (empty($numberedSources)) {
|
if (empty($numberedSources)) {
|
||||||
@@ -872,10 +872,8 @@ PROMPT;
|
|||||||
} elseif ($engine === 'gpu') {
|
} elseif ($engine === 'gpu') {
|
||||||
$response = dbnToolsCallGpuLlm($messages, $opts);
|
$response = dbnToolsCallGpuLlm($messages, $opts);
|
||||||
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
|
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
|
||||||
} elseif ($engine === 'azure_full') {
|
|
||||||
$raw = $this->azure->withDeployment('gpt-4o')->chatText($messages, $opts);
|
|
||||||
} else {
|
} else {
|
||||||
$raw = $this->azure->chatText($messages, $opts);
|
$raw = $this->azure->withDeployment($cloudDeploy)->chatText($messages, $opts);
|
||||||
}
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
dbnToolsAbort('Synthesis LLM request failed: ' . $e->getMessage(), 502, 'llm_error');
|
dbnToolsAbort('Synthesis LLM request failed: ' . $e->getMessage(), 502, 'llm_error');
|
||||||
|
|||||||
+73
-22
@@ -212,7 +212,7 @@ final class DbnLegalToolsService
|
|||||||
|
|
||||||
public function ask(string $question, string $language = 'en', string $engine = 'azure_mini', ?string $persona = null): array
|
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();
|
$client = dbnToolsRequireClient();
|
||||||
$personaResolved = dbnToolsResolvePersona((int)$client['id'], $persona);
|
$personaResolved = dbnToolsResolvePersona((int)$client['id'], $persona);
|
||||||
$search = $this->search($question, $language, 7, 'disabled', null, 'both', $personaResolved['slug']);
|
$search = $this->search($question, $language, 7, 'disabled', null, 'both', $personaResolved['slug']);
|
||||||
@@ -316,7 +316,7 @@ PROMPT;
|
|||||||
}
|
}
|
||||||
array_unshift($uncertain, $this->degradedModelNotice($language));
|
array_unshift($uncertain, $this->degradedModelNotice($language));
|
||||||
$json['what_remains_uncertain'] = $uncertain;
|
$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');
|
$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());
|
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;
|
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'];
|
return [$this->azure, 'gpt-4o'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1338,7 +1347,7 @@ PROMPT;
|
|||||||
error_log('[dbn-persona] GPU model ' . $model . ' marked unavailable; using cloud fallback.');
|
error_log('[dbn-persona] GPU model ' . $model . ' marked unavailable; using cloud fallback.');
|
||||||
$failedModel = $model;
|
$failedModel = $model;
|
||||||
$degraded = true;
|
$degraded = true;
|
||||||
[$gw, $model] = $this->cloudFallbackGateway();
|
[$gw, $model] = $this->cloudFallbackGateway($engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1352,7 +1361,7 @@ PROMPT;
|
|||||||
$this->writeGpuHealth($model, false);
|
$this->writeGpuHealth($model, false);
|
||||||
$failedModel = $model;
|
$failedModel = $model;
|
||||||
$degraded = true;
|
$degraded = true;
|
||||||
[$gw, $model] = $this->cloudFallbackGateway();
|
[$gw, $model] = $this->cloudFallbackGateway($engine);
|
||||||
$raw = $gw->withDeployment($model)->chatText($messages, $options);
|
$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
|
private function degradedModelNotice(string $language): string
|
||||||
{
|
{
|
||||||
return match (dbnToolsNormalizeUiLanguage($language)) {
|
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.',
|
'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' => 'Спеціалізована доточена норвезька юридична модель тимчасово недоступна, тому цю відповідь згенерувала загальна модель (gpt-4o). Юридичний корпус, джерела та докази повністю доступні — перегляньте цитовані джерела, як зазвичай.',
|
'uk' => 'Спеціалізована доточена норвезька юридична модель тимчасово недоступна, тому цю відповідь згенерувала загальна хмарна модель. Юридичний корпус, джерела та докази повністю доступні — перегляньте цитовані джерела, як зазвичай.',
|
||||||
'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.',
|
'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 the general model (gpt-4o). The legal corpus, sources, and evidence are fully live — review the cited sources as usual.',
|
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
|
* Search the shared legal corpus and return top-N passages as a formatted
|
||||||
* context string. Returns '' on failure so the caller can degrade gracefully.
|
* 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 {
|
try {
|
||||||
$client = dbnToolsRequireClient();
|
$client = dbnToolsRequireClient();
|
||||||
@@ -1989,14 +2005,51 @@ PROMPT;
|
|||||||
'search_method' => $searchMethod,
|
'search_method' => $searchMethod,
|
||||||
'min_private' => 0,
|
'min_private' => 0,
|
||||||
'include_beta_website' => true,
|
'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 = [];
|
$parts = [];
|
||||||
|
$total = 0;
|
||||||
foreach ($chunks as $c) {
|
foreach ($chunks as $c) {
|
||||||
$title = (string)($c['title'] ?? ($c['source'] ?? 'Legal source'));
|
$title = (string)($c['title'] ?? ($c['source'] ?? 'Legal source'));
|
||||||
$content = (string)($c['content'] ?? ($c['text'] ?? ''));
|
$content = (string)($c['content'] ?? ($c['text'] ?? ''));
|
||||||
if ($content !== '') {
|
$rawChars = mb_strlen($content, 'UTF-8');
|
||||||
$parts[] = "=== {$title} ===\n{$content}";
|
$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);
|
return implode("\n\n", $parts);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -2016,7 +2069,7 @@ PROMPT;
|
|||||||
string $depth = 'standard'
|
string $depth = 'standard'
|
||||||
): array {
|
): array {
|
||||||
$text = $this->requirePasteText($text);
|
$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);
|
$locale = dbnToolsLanguageName($language);
|
||||||
|
|
||||||
@@ -2070,7 +2123,7 @@ PROMPT;
|
|||||||
['role' => 'system', 'content' => $system],
|
['role' => 'system', 'content' => $system],
|
||||||
['role' => 'user', 'content' => $prompt],
|
['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];
|
$chatOpts = ['json' => true, 'temperature' => 0.1, 'max_tokens' => $maxTok, 'timeout' => 120];
|
||||||
|
|
||||||
$deployLabel = $this->azure->chatDeployment();
|
$deployLabel = $this->azure->chatDeployment();
|
||||||
@@ -2078,12 +2131,10 @@ PROMPT;
|
|||||||
if ($engine === 'gpu') {
|
if ($engine === 'gpu') {
|
||||||
$response = $this->callGpuLlm($messages, $chatOpts);
|
$response = $this->callGpuLlm($messages, $chatOpts);
|
||||||
$deployLabel = 'GPU (local)';
|
$deployLabel = 'GPU (local)';
|
||||||
} elseif ($engine === 'azure_full') {
|
|
||||||
$response = $this->azure->withDeployment('gpt-4o')->chat($messages, $chatOpts);
|
|
||||||
$deployLabel = 'gpt-4o';
|
|
||||||
} else {
|
} else {
|
||||||
$response = $this->azure->withDeployment('gpt-4o-mini')->chat($messages, $chatOpts);
|
$deploy = DbnBedrockModelRouter::deploymentForEngine($engine, $this->azure instanceof DbnBedrockGateway);
|
||||||
$deployLabel = 'gpt-4o-mini';
|
$response = $this->azure->withDeployment($deploy)->chat($messages, $chatOpts);
|
||||||
|
$deployLabel = $deploy;
|
||||||
}
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
dbnToolsAbort('LLM request failed: ' . $e->getMessage(), 502, 'llm_error');
|
dbnToolsAbort('LLM request failed: ' . $e->getMessage(), 502, 'llm_error');
|
||||||
|
|||||||
@@ -19,6 +19,130 @@ final class ToolModels
|
|||||||
public const TIMELINE_STANDARD_MAX_CHARS = 300000;
|
public const TIMELINE_STANDARD_MAX_CHARS = 300000;
|
||||||
public const TIMELINE_DEEP_MAX_CHARS = 600000;
|
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
|
public static function engineForUser(int $userId, string $requestedEngine): string
|
||||||
{
|
{
|
||||||
$valid = ['nova_lite', 'azure_mini', 'azure_full', 'gpu', 'regex', 'claude_haiku', 'claude_sonnet'];
|
$valid = ['nova_lite', 'azure_mini', 'azure_full', 'gpu', 'regex', 'claude_haiku', 'claude_sonnet'];
|
||||||
|
|||||||
@@ -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
|
function dbnToolsRequiredPackageSlug(): string
|
||||||
{
|
{
|
||||||
return dbnToolsEnv('DBN_CAVEAU_PACKAGE_SLUG') ?: 'family-legal';
|
return dbnToolsEnv('DBN_CAVEAU_PACKAGE_SLUG') ?: 'family-legal';
|
||||||
@@ -506,9 +564,17 @@ function dbnToolsPersonaTrack(?string $slug): string
|
|||||||
function dbnToolsReviewerModel(?string $slug = null): string
|
function dbnToolsReviewerModel(?string $slug = null): string
|
||||||
{
|
{
|
||||||
if (dbnToolsPersonaTrack($slug) === 'family') {
|
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'))
|
return trim((string)(dbnToolsEnv('DBN_REVIEW_MODEL_FAMILY', 'dbn-legal-agent-v3') ?? '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';
|
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']);
|
&& !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.
|
* Enforce credit + tier gate before a tool call.
|
||||||
* Exits with JSON 402/429 if the user is over limit or out of credits.
|
* Exits with JSON 402/429 if the user is over limit or out of credits.
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ $dashboardNav = [
|
|||||||
'trash' => ['url' => '/dashboard/trash.php', 'label' => dbnToolsT('dash_nav_trash', $uiLang) ?: 'Trash', 'sub' => 'Restore or purge'],
|
'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'],
|
'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>
|
<!doctype html>
|
||||||
<html lang="<?= htmlspecialchars($uiLang) ?>">
|
<html lang="<?= htmlspecialchars($uiLang) ?>">
|
||||||
|
|||||||
+6
-6
@@ -79,13 +79,13 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
</div>
|
</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>
|
<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 -->
|
<!-- Quality tier -->
|
||||||
<div class="control-row" id="korrEngineControl">
|
<div class="control-row" id="korrTierControl">
|
||||||
<span class="control-label">Engine</span>
|
<span class="control-label">Quality</span>
|
||||||
<label><input type="radio" name="korrEngine" value="azure_mini" checked> ☁️ Quick <small class="control-hint">(~40-70s)</small></label>
|
<label><input type="radio" name="korrTier" value="quick" checked> Quick ★ <small class="control-hint">(Claude Haiku · fast · 3 credits)</small></label>
|
||||||
<label><input type="radio" name="korrEngine" value="claude_sonnet"> ☁️ Thorough ★★ <small class="control-hint">(~70-120s)</small></label>
|
<label><input type="radio" name="korrTier" value="pro"> Pro <small class="control-hint">(Claude Sonnet · best · 6 credits)</small></label>
|
||||||
</div>
|
</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 -->
|
<!-- Domain persona -->
|
||||||
<div class="control-row corpus-persona is-hidden" id="korrPersonaControl">
|
<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;
|
||||||
@@ -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;
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -16,14 +16,12 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
<button type="button" class="lang-btn sum-lang-btn" data-lang="pl">🇵🇱 PL</button>
|
<button type="button" class="lang-btn sum-lang-btn" data-lang="pl">🇵🇱 PL</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control-row" id="sumEngineControl">
|
<div class="control-row" id="sumTierControl">
|
||||||
<span class="control-label">Engine</span>
|
<span class="control-label">Quality</span>
|
||||||
<label><input type="radio" name="sumEngine" value="claude_haiku" checked> Standard ★ <small class="control-hint">(Claude Haiku · fast)</small></label>
|
<label><input type="radio" name="sumTier" value="quick" checked> Quick ★ <small class="control-hint">(Claude Haiku · fast · 1 credit)</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="sumTier" value="pro"> Pro <small class="control-hint">(Claude Sonnet · best · 2 credits)</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>
|
</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">
|
<div class="control-row" id="sumDepthControl">
|
||||||
<span class="control-label">Summary depth</span>
|
<span class="control-label">Summary depth</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user