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