Files
dobetternorge-tools/includes/FreeTier.php
T
daveadmin e977bbb6b3 Add Document Discrepancy Finder tool
8-step NDJSON-streaming pipeline that compares two Barnevernet documents:
classifies each doc, extracts parties and timelines, cross-references both
for contradictions/deletions/additions, retrieves corpus legal context, and
synthesises a full discrepancy report with tabbed UI.

New files: DiscrepancyAgent.php, api/discrepancy.php, discrepancy.php,
discrepancy.js. Modified: FreeTier.php (cost=4), i18n.php (all 4 langs),
tool-svgs.php (DC icon), tools.css (dc-* component styles).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:30:38 +02:00

128 lines
4.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Credit system for free-tier (Google-authenticated) users of tools.dobetternorge.no.
*
* Credits are stored in dobetternorge_maindb.user_tool_credits.
* Usage is logged in user_tool_usage_log.
*
* CaveauAI client sessions (dbn_tools_user_id + client_id) bypass all checks.
* Only SSO sessions with tier='free' are subject to limits.
*/
final class FreeTier
{
private const HOURLY_LIMIT = 10;
private const COSTS = [
'corpus-search' => 0,
'search' => 0,
'ask' => 1,
'extract' => 1,
'timeline' => 2,
'redact' => 2,
'barnevernet' => 3,
'advocate' => 3,
'deep-research' => 5,
'transcribe' => 2, // flat rate; actual duration unknown upfront
'discrepancy' => 4, // 2 docs × 4 extraction steps + cross-ref + synthesis
];
/** Credit cost for a given tool slug. Returns 1 for unknown tools. */
public static function cost(string $tool): int
{
return self::COSTS[$tool] ?? 1;
}
/**
* Check whether the user may proceed with a tool call.
* Handles monthly reset automatically.
*
* Returns ['ok' => true, 'balance' => int]
* or ['ok' => false, 'balance' => int, 'reason' => 'no_credits'|'rate_limit']
*/
public static function check(int $userId, string $tool): array
{
$db = dbnmDb();
$cost = self::cost($tool);
// Auto-reset balance if a new month has begun since last reset
$db->prepare(
'UPDATE user_tool_credits
SET balance = allowance, last_reset = CURDATE()
WHERE user_id = ?
AND (YEAR(last_reset) < YEAR(CURDATE()) OR MONTH(last_reset) < MONTH(CURDATE()))'
)->execute([$userId]);
$row = $db->prepare(
'SELECT balance FROM user_tool_credits WHERE user_id = ? LIMIT 1'
);
$row->execute([$userId]);
$credits = $row->fetch(PDO::FETCH_ASSOC);
if ($credits === false) {
// No credits row — treat as 0 balance (shouldn't happen after ensureFreeTierCredits)
return ['ok' => false, 'balance' => 0, 'reason' => 'no_credits'];
}
$balance = (int)$credits['balance'];
// Free tools always pass
if ($cost === 0) {
return ['ok' => true, 'balance' => $balance];
}
if ($balance < $cost) {
return ['ok' => false, 'balance' => $balance, 'reason' => 'no_credits'];
}
// Hourly rate limit check (counts any tool that costs > 0)
$hourlyCount = $db->prepare(
'SELECT COUNT(*) FROM user_tool_usage_log
WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR) AND credits_used > 0'
);
$hourlyCount->execute([$userId]);
if ((int)$hourlyCount->fetchColumn() >= self::HOURLY_LIMIT) {
return ['ok' => false, 'balance' => $balance, 'reason' => 'rate_limit'];
}
return ['ok' => true, 'balance' => $balance];
}
/**
* Deduct credits for a completed tool call and log the usage.
* Safe to call even when cost is 0 (logs the call but deducts nothing).
*/
public static function deduct(int $userId, string $tool): int
{
$db = dbnmDb();
$cost = self::cost($tool);
if ($cost > 0) {
$db->prepare(
'UPDATE user_tool_credits
SET balance = GREATEST(0, balance - ?)
WHERE user_id = ?'
)->execute([$cost, $userId]);
}
$db->prepare(
'INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)'
)->execute([$userId, $tool, $cost]);
$row = $db->prepare('SELECT balance FROM user_tool_credits WHERE user_id = ? LIMIT 1');
$row->execute([$userId]);
$r = $row->fetch(PDO::FETCH_ASSOC);
return $r ? (int)$r['balance'] : 0;
}
/**
* Current balance for a user (after any pending monthly reset).
*/
public static function balance(int $userId): int
{
$result = self::check($userId, 'corpus-search'); // cost=0, triggers reset if needed
return $result['balance'];
}
}