Add monetization spine + Build Your Own Case (Min Sak)

- Stripe: StripeClient.php, checkout/portal/webhook endpoints, idempotent event handling
- FreeTier: tier-aware credits (free/light/pro/pro_plus), bonus_balance, hourly caps per tier
- pricing.php + billing.php: 4-tier cards, 3 topups, Customer Portal, balance breakdown
- Min Sak: CaseStore.php, AzureDocIntelligence.php, AzureSearchAdmin.php — per-user hybrid RAG
- api/case/: upload, list, delete, ingest-callback (HMAC-auth'd from n8n)
- award-survey-credits: inter-site HMAC endpoint for dobetternorge.no survey bonus
- dashboard.php: tier badge, balance breakdown card, Min Sak CTA, survey CTA
- KorrespondAgent + all 3 other agents: use_my_case toggle wired to dbnToolsCaseContext()
- bootstrap.php: dbnToolsCaseContext(), dbnToolsIntersiteSecret(), dbnToolsCurrentTier()

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 20:52:54 +02:00
parent ed5489d174
commit ba9cddf9a1
30 changed files with 2804 additions and 133 deletions
+113 -11
View File
@@ -420,18 +420,20 @@ function dbnmDb(): PDO
}
/**
* True when the current session is a free-tier SSO user (Google login).
* False for CaveauAI client sessions (always unlimited).
* True when the current session belongs to an SSO user (Google login).
* All SSO sessions go through the credit + tier system (free, light, pro, pro_plus).
* False for CaveauAI client sessions, which bypass all credit checks.
*
* Note: name is historical — paid SSO users are also subject to the credit gate.
*/
function dbnToolsIsFreeTier(): bool
{
return !empty($_SESSION['dbn_tools_authenticated'])
&& !empty($_SESSION['dbn_tools_sso_uid'])
&& ($_SESSION['dbn_tools_tier'] ?? '') === 'free';
&& !empty($_SESSION['dbn_tools_sso_uid']);
}
/**
* Enforce free-tier credit 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.
* No-op for CaveauAI sessions.
*
@@ -449,16 +451,20 @@ function dbnToolsFreeTierCheck(string $tool): int
if (!$result['ok']) {
$isRateLimit = ($result['reason'] ?? '') === 'rate_limit';
$tier = (string)($result['tier'] ?? 'free');
$cap = FreeTier::hourlyCap($tier);
http_response_code($isRateLimit ? 429 : 402);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode([
'ok' => false,
'error' => ['code' => $result['reason'], 'message' => $isRateLimit
? 'Rate limit reached — you can make up to 10 requests per hour on the free tier.'
: 'No credits remaining. Your 10 free credits reset on the 1st of next month.',
? "Rate limit reached — your tier ({$tier}) allows {$cap} requests per hour."
: 'No credits remaining. See /pricing.php to top up or upgrade.',
],
'balance' => $result['balance'],
'bonus_balance' => $result['bonus_balance'] ?? 0,
'tier' => $tier,
], JSON_UNESCAPED_UNICODE);
exit;
}
@@ -466,6 +472,83 @@ function dbnToolsFreeTierCheck(string $tool): int
return $uid;
}
/** Return the current SSO user's tier, or 'free' if not SSO / no row. */
function dbnToolsCurrentTier(): string
{
if (!dbnToolsIsFreeTier()) {
return 'caveau';
}
require_once __DIR__ . '/FreeTier.php';
return FreeTier::tier((int)$_SESSION['dbn_tools_sso_uid']);
}
/**
* Inject "Min Sak" context into an agent's system prompt.
*
* Returns a system-prompt fragment with the top-k hybrid-search hits from the user's
* private case corpus, or an empty string if the user is not paid / has no docs / opted out.
*
* CRITICAL: this resolves family-plan members to their OWNER's user_id so the search hits
* the shared OWNER's index. Index-level isolation makes cross-user leak structurally impossible.
*
* Pattern (for agents):
* $caseBlock = dbnToolsCaseContext($intake['use_my_case'] ?? false, $userQuery);
* if ($caseBlock !== '') { $systemPrompt .= $caseBlock; }
*/
function dbnToolsCaseContext(bool $useMyCase, string $query, int $k = 5): string
{
if (!$useMyCase) return '';
if (!dbnToolsIsFreeTier()) return '';
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
if ($userId <= 0) return '';
require_once __DIR__ . '/FreeTier.php';
$tier = FreeTier::tier($userId);
if (!in_array($tier, ['light', 'pro', 'pro_plus'], true)) return '';
require_once __DIR__ . '/CaseStore.php';
$effective = CaseStore::caseResolveClientId($userId);
$chunks = CaseStore::caseHybridSearch($effective, $query, $k);
// Audit log: who ran what against whose case
try {
$db = dbnmDb();
$db->prepare(
'INSERT INTO case_tool_runs (user_id, tool, used_my_case, case_chunks_retrieved, doc_ids, ip_hash, created_at)
VALUES (?, ?, 1, ?, ?, ?, NOW())'
)->execute([
$userId,
(string)(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function'] ?? 'unknown'),
count($chunks),
json_encode(array_values(array_unique(array_map(fn($c) => (int)$c['doc_id'], $chunks))), JSON_UNESCAPED_UNICODE),
hash('sha256', ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0') . '|case'),
]);
} catch (Throwable $e) { /* audit log is non-fatal */ }
return CaseStore::formatChunksForPrompt($chunks);
}
/** Read /etc/bnl/intersite.php for the HMAC secret shared between dobetternorge.no and tools.dobetternorge.no. */
function dbnToolsIntersiteSecret(): string
{
static $secret = null;
if ($secret !== null) {
return $secret;
}
$envValue = (string)(dbnToolsEnv('INTERSITE_HMAC_SECRET') ?? '');
if ($envValue !== '') {
return $secret = $envValue;
}
$path = '/etc/bnl/intersite.php';
if (is_readable($path)) {
$cfg = require $path;
if (is_array($cfg) && !empty($cfg['INTERSITE_HMAC_SECRET'])) {
return $secret = (string)$cfg['INTERSITE_HMAC_SECRET'];
}
}
return $secret = '';
}
/**
* Deduct credits after a successful tool call.
* The $uid returned from dbnToolsFreeTierCheck() must be passed in.
@@ -863,24 +946,43 @@ function dbnToolsRunLegalCheck(string $brief, string $docType): array
}
$opts = [
'model' => 'dbn-legal-agent-v2',
'model' => 'dbn-legal-agent-v3',
'temperature' => 0.1,
'max_tokens' => 350,
'timeout' => 120,
// No 'json' key — plain narrative, no response_format flag
];
$sysMsg = 'Du er en ekspert på norsk barnevernsloven og EMD-praksis. Svar alltid på norsk med korrekt juridisk terminologi. Bruk terskler fra barnevernsloven 2021: § 4-25 krever «klar nødvendighet». Strand Lobben mot Norge (37283/13) setter krav om rehabiliteringsplan før adopsjon. Aldri oppfinn paragrafnumre, saksnumre eller dommernavn.';
$text = '';
try {
$response = dbnToolsCallGpuLlm(
[
['role' => 'system', 'content' => 'Du er en ekspert på norsk barnevernsloven og EMD-praksis. Svar alltid på norsk med korrekt juridisk terminologi. Bruk terskler fra barnevernsloven 2021: § 4-25 krever «klar nødvendighet». Strand Lobben mot Norge (37283/13) setter krav om rehabiliteringsplan før adopsjon. Aldri oppfinn paragrafnumre, saksnumre eller dommernavn.'],
['role' => 'system', 'content' => $sysMsg],
['role' => 'user', 'content' => $question],
],
$opts
);
$text = trim((string)($response['choices'][0]['message']['content'] ?? ''));
} catch (Throwable $e) {
return [];
// v3 unavailable — fall back to qwen2.5:7b as safety net
}
// Fallback: if v3 timed out or returned empty, retry with qwen2.5:7b
if (empty($text) || str_word_count($text) < 8) {
try {
$fallback = dbnToolsCallGpuLlm(
[
['role' => 'system', 'content' => $sysMsg],
['role' => 'user', 'content' => $question],
],
array_merge($opts, ['model' => 'qwen2.5:7b', 'timeout' => 60])
);
$text = trim((string)($fallback['choices'][0]['message']['content'] ?? ''));
} catch (Throwable $e) {
return [];
}
}
if (empty($text) || str_word_count($text) < 15) {
@@ -898,7 +1000,7 @@ function dbnToolsRunLegalCheck(string $brief, string $docType): array
'legal_basis' => dbnToolsExtractCheckLegalBasis($clean),
'source_refs' => [],
'what_to_check'=> 'Verifiser med norsk familieretsadvokat',
'check_model' => 'dbn-legal-agent-v2',
'check_model' => 'dbn-legal-agent-v3',
]];
}