a8b1bb87a6
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>
293 lines
12 KiB
PHP
293 lines
12 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
require_once __DIR__ . '/bootstrap.php';
|
||
require_once __DIR__ . '/FreeTier.php';
|
||
|
||
/**
|
||
* Tier-aware model routing for tools that expose the existing engine selector.
|
||
*
|
||
* Plus/trial users get the cost-controlled Azure mini path. Pro users get the
|
||
* full Azure path. CaveauAI sessions keep the engine requested by their UI.
|
||
*/
|
||
final class ToolModels
|
||
{
|
||
public const TIMELINE_QUICK_CHAR_LIMIT = 25000;
|
||
public const TIMELINE_STANDARD_CHAR_LIMIT = 55000;
|
||
public const TIMELINE_DEEP_CHAR_LIMIT = 128000;
|
||
public const TIMELINE_QUICK_MAX_CHARS = 100000;
|
||
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'];
|
||
$requestedEngine = in_array($requestedEngine, $valid, true) ? $requestedEngine : 'azure_mini';
|
||
|
||
if ($userId <= 0) {
|
||
return $requestedEngine;
|
||
}
|
||
|
||
return match (FreeTier::tier($userId)) {
|
||
'pro' => $requestedEngine,
|
||
'plus' => $requestedEngine === 'azure_full' ? 'azure_mini' : $requestedEngine,
|
||
default => in_array($requestedEngine, ['nova_lite', 'regex'], true) ? $requestedEngine : 'nova_lite',
|
||
};
|
||
}
|
||
|
||
public static function timelineRoute(int $userId, string $requestedEngine, string $text): array
|
||
{
|
||
$valid = ['nova_lite', 'azure_mini', 'azure_full', 'claude_haiku', 'claude_sonnet'];
|
||
$requestedEngine = in_array($requestedEngine, $valid, true) ? $requestedEngine : 'azure_mini';
|
||
$tierEngine = self::engineForUser($userId, $requestedEngine);
|
||
$charCount = mb_strlen($text, 'UTF-8');
|
||
|
||
if ($charCount > self::TIMELINE_DEEP_MAX_CHARS) {
|
||
throw new DbnToolsHttpException(
|
||
'This timeline input is too large after selected documents or My Case context were added. Split the file or use fewer selected documents.',
|
||
413,
|
||
'timeline_input_too_large',
|
||
['input_char_count' => $charCount, 'max_chars' => self::TIMELINE_DEEP_MAX_CHARS]
|
||
);
|
||
}
|
||
|
||
$effectiveEngine = $tierEngine;
|
||
if ($charCount > self::timelineEngineMaxChars($effectiveEngine)) {
|
||
$effectiveEngine = $charCount <= self::TIMELINE_STANDARD_MAX_CHARS ? 'azure_mini' : 'azure_full';
|
||
} elseif ($charCount > self::TIMELINE_STANDARD_CHAR_LIMIT && $effectiveEngine === 'nova_lite') {
|
||
$effectiveEngine = $charCount <= self::TIMELINE_QUICK_MAX_CHARS ? 'nova_lite' : 'azure_mini';
|
||
}
|
||
|
||
if ($charCount > self::timelineEngineMaxChars($effectiveEngine)) {
|
||
$effectiveEngine = 'azure_full';
|
||
}
|
||
|
||
$credits = self::timelineCreditsForSize($effectiveEngine, $charCount);
|
||
$baseCredits = self::timelineAdvertisedCredits($requestedEngine);
|
||
$requiresConfirmation = $credits > $baseCredits
|
||
|| self::timelineEngineRank($effectiveEngine) > self::timelineEngineRank($requestedEngine);
|
||
$chunked = $charCount > self::timelineEngineLimit($effectiveEngine);
|
||
|
||
return [
|
||
'requested_engine' => $requestedEngine,
|
||
'tier_engine' => $tierEngine,
|
||
'effective_engine' => $effectiveEngine,
|
||
'auto_upgraded_engine' => $effectiveEngine !== $tierEngine,
|
||
'input_char_count' => $charCount,
|
||
'engine_limit_chars' => self::timelineEngineLimit($effectiveEngine),
|
||
'max_char_limit' => self::timelineEngineMaxChars($effectiveEngine),
|
||
'chunked_timeline' => $chunked,
|
||
'timeline_chunk_count' => $chunked ? (int)ceil($charCount / self::timelineChunkSize($effectiveEngine)) : 1,
|
||
'estimated_credits' => $credits,
|
||
'credits' => $credits,
|
||
'base_credits' => $baseCredits,
|
||
'requires_confirmation' => $requiresConfirmation,
|
||
];
|
||
}
|
||
|
||
public static function assertTimelineQuoteAccepted(array $route, array $input): void
|
||
{
|
||
if (empty($route['requires_confirmation'])) {
|
||
return;
|
||
}
|
||
|
||
$accepted = !empty($input['accepted_timeline_quote'])
|
||
&& (int)($input['accepted_credits'] ?? 0) === (int)$route['credits']
|
||
&& (string)($input['accepted_effective_engine'] ?? '') === (string)$route['effective_engine'];
|
||
|
||
if ($accepted) {
|
||
return;
|
||
}
|
||
|
||
$engineLabel = self::timelineEngineLabel((string)$route['effective_engine']);
|
||
throw new DbnToolsHttpException(
|
||
'This timeline is larger than the selected engine can handle at the advertised price. Confirm the quoted engine and credits before running.',
|
||
409,
|
||
'timeline_quote_required',
|
||
['timeline_quote' => array_merge($route, [
|
||
'effective_engine_label' => $engineLabel,
|
||
'message' => 'Timeline will use ' . $engineLabel . ' for '
|
||
. number_format((int)$route['input_char_count'])
|
||
. ' characters across about ' . (int)$route['timeline_chunk_count']
|
||
. ' chunk(s), costing ' . (int)$route['credits'] . ' credit(s).',
|
||
])]
|
||
);
|
||
}
|
||
|
||
public static function timelineCredits(string $engine): int
|
||
{
|
||
return self::timelineAdvertisedCredits($engine);
|
||
}
|
||
|
||
public static function timelineEngineLimit(string $engine): int
|
||
{
|
||
return match ($engine) {
|
||
'nova_lite', 'claude_haiku' => self::TIMELINE_QUICK_CHAR_LIMIT,
|
||
'azure_mini' => self::TIMELINE_STANDARD_CHAR_LIMIT,
|
||
default => self::TIMELINE_DEEP_CHAR_LIMIT,
|
||
};
|
||
}
|
||
|
||
public static function timelineChunkSize(string $engine): int
|
||
{
|
||
return match ($engine) {
|
||
'nova_lite', 'claude_haiku' => 10000,
|
||
'azure_mini' => 16000,
|
||
default => 30000,
|
||
};
|
||
}
|
||
|
||
public static function timelineEngineMaxChars(string $engine): int
|
||
{
|
||
return match ($engine) {
|
||
'nova_lite', 'claude_haiku' => self::TIMELINE_QUICK_MAX_CHARS,
|
||
'azure_mini' => self::TIMELINE_STANDARD_MAX_CHARS,
|
||
default => self::TIMELINE_DEEP_MAX_CHARS,
|
||
};
|
||
}
|
||
|
||
public static function timelineCreditsForSize(string $engine, int $charCount): int
|
||
{
|
||
return match ($engine) {
|
||
'nova_lite', 'claude_haiku'
|
||
=> $charCount <= self::TIMELINE_QUICK_CHAR_LIMIT ? 1 : 2,
|
||
'azure_mini'
|
||
=> $charCount <= self::TIMELINE_STANDARD_CHAR_LIMIT ? 1 : ($charCount <= 180000 ? 2 : 3),
|
||
default
|
||
=> $charCount <= self::TIMELINE_DEEP_CHAR_LIMIT ? 2 : ($charCount <= 350000 ? 4 : 6),
|
||
};
|
||
}
|
||
|
||
public static function timelineAdvertisedCredits(string $engine): int
|
||
{
|
||
return in_array($engine, ['azure_full', 'claude_sonnet'], true) ? 2 : 1;
|
||
}
|
||
|
||
public static function timelineEngineLabel(string $engine): string
|
||
{
|
||
return match ($engine) {
|
||
'nova_lite', 'claude_haiku' => 'Quick',
|
||
'azure_full', 'claude_sonnet' => 'Deep',
|
||
default => 'Standard',
|
||
};
|
||
}
|
||
|
||
private static function timelineEngineRank(string $engine): int
|
||
{
|
||
return match ($engine) {
|
||
'nova_lite', 'claude_haiku' => 1,
|
||
'azure_mini' => 2,
|
||
'azure_full', 'claude_sonnet' => 3,
|
||
default => 0,
|
||
};
|
||
}
|
||
}
|