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
+3 -2
View File
@@ -55,9 +55,10 @@ require_once __DIR__ . '/includes/layout.php';
<label><input type="radio" name="advEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~15-45s)</small></label>
<label><input type="radio" name="advEngine" value="azure_full"> Azure gpt-4o <small class="control-hint">(best · ~60-180s)</small></label>
<label><input type="radio" name="advEngine" value="gpu"> GPU (cuttlefish) <small class="control-hint">(local · ~30-90s)</small></label>
<label><input type="radio" name="advEngine" value="dbn_legal"> &#x1F1F3;&#x1F1F4; Norwegian specialist <small class="control-hint">(dbn-legal-agent · ~40-90s)</small></label>
<label><input type="radio" name="advEngine" value="dbn_legal"> &#x1F1F3;&#x1F1F4; Norwegian specialist v2 <small class="control-hint">(dbn-legal-agent-v2 · ~40-90s)</small></label>
<label><input type="radio" name="advEngine" value="dbn_legal_v3"> &#x1F1F3;&#x1F1F4; Norwegian specialist v3 &#9733; <small class="control-hint">(dbn-legal-agent-v3 · ~20-60s)</small></label>
</div>
<p class="upload-hint">Azure mini finishes fastest. Azure full produces the most thorough advocate brief. Norwegian specialist uses a fine-tuned Norwegian law model for the synthesis — best for Barneloven, barnevern, and ECHR family law cases.</p>
<p class="upload-hint">Azure mini finishes fastest. Azure full produces the most thorough advocate brief. Norwegian specialist v3 is a Qwen2.5 fine-tune trained on barnevernsloven, ECHR, and forvaltningsloven — highest precision for § 4-25, Strand Lobben, and procedural red flags.</p>
<div class="dr-slice-section">
<p class="control-label">Corpus slices</p>
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
/**
* Inter-site endpoint hit by dobetternorge.no after a successful survey submit.
* Awards +25 bonus credits to the matching user_tool_credits row.
*
* Auth: HMAC-SHA256 over (timestamp + "." + payload_json) using INTERSITE_HMAC_SECRET.
* Headers required:
* X-Intersite-Timestamp: unix seconds (must be within ±300s of server time)
* X-Intersite-Signature: hex hmac-sha256
*
* Body: { "email": "...", "user_id": int|null, "credits": 25, "source": "survey" }
*
* Idempotency: refuses to award twice for the same user_id (uses survey_completed_at).
*/
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/FreeTier.php';
dbnToolsRequireMethod('POST');
$secret = dbnToolsIntersiteSecret();
if ($secret === '') {
dbnToolsError('Intersite secret not configured on this server.', 500, 'intersite_misconfigured');
}
$ts = (int)($_SERVER['HTTP_X_INTERSITE_TIMESTAMP'] ?? 0);
$sig = (string)($_SERVER['HTTP_X_INTERSITE_SIGNATURE'] ?? '');
if ($ts === 0 || $sig === '') {
dbnToolsError('Missing intersite auth headers.', 401, 'missing_signature');
}
if (abs(time() - $ts) > 300) {
dbnToolsError('Stale intersite timestamp.', 401, 'stale_timestamp');
}
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
dbnToolsError('Empty body.', 400, 'empty_body');
}
$expected = hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($expected, $sig)) {
dbnToolsError('Bad intersite signature.', 401, 'bad_signature');
}
$data = json_decode($raw, true);
if (!is_array($data)) {
dbnToolsError('Invalid JSON body.', 400, 'invalid_json');
}
$email = strtolower(trim((string)($data['email'] ?? '')));
$userIdHint = isset($data['user_id']) ? (int)$data['user_id'] : 0;
$credits = (int)($data['credits'] ?? 25);
$source = (string)($data['source'] ?? 'survey');
if ($credits < 1 || $credits > 100) {
dbnToolsError('Credits out of range.', 400, 'bad_credits');
}
if ($email === '' && $userIdHint <= 0) {
dbnToolsError('Need email or user_id.', 400, 'missing_user');
}
$db = dbnmDb();
// Resolve user_id from hint or email
$userId = $userIdHint;
if ($userId <= 0 && $email !== '') {
$stmt = $db->prepare('SELECT id FROM users WHERE LOWER(email) = ? LIMIT 1');
$stmt->execute([$email]);
$userId = (int)($stmt->fetchColumn() ?: 0);
}
if ($userId <= 0) {
// No account yet — that's fine, dobetternorge.no will magic-link them; we'll award on next login.
dbnToolsRespond([
'ok' => true,
'awarded' => false,
'pending' => true,
'message' => 'User not yet registered — will award on first login.',
]);
}
// Idempotency: don't double-award
if (FreeTier::hasCompletedSurvey($userId)) {
dbnToolsRespond([
'ok' => true,
'awarded' => false,
'reason' => 'already_completed',
'balance' => FreeTier::balance($userId),
]);
}
FreeTier::ensureRow($userId);
$newBalance = FreeTier::awardBonus($userId, $credits, $source);
FreeTier::markSurveyCompleted($userId);
dbnToolsRespond([
'ok' => true,
'awarded' => true,
'credits' => $credits,
'balance' => $newBalance,
'user_id' => $userId,
]);
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../includes/bootstrap.php';
require_once __DIR__ . '/../../includes/CaseStore.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
if ($userId <= 0) {
dbnToolsError('Auth required.', 401, 'auth_required');
}
$input = dbnToolsJsonInput(500);
$docId = (int)($input['doc_id'] ?? 0);
if ($docId <= 0) {
dbnToolsError('doc_id required.', 400, 'bad_input');
}
$ok = CaseStore::deleteDocument($userId, $docId);
if (!$ok) {
dbnToolsError('Dokumentet finnes ikke, eller er allerede slettet.', 404, 'not_found');
}
dbnToolsRespond(['ok' => true, 'doc_id' => $docId]);
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* Called by the n8n Case Ingest workflow after a document is OCR'd + indexed.
* Auth: HMAC-SHA256 (same as award-survey-credits, shared INTERSITE_HMAC_SECRET).
*
* Body: { doc_id, status, page_count, doc_type, detected_date, parties, error_msg }
*/
require_once __DIR__ . '/../../includes/bootstrap.php';
dbnToolsRequireMethod('POST');
$secret = dbnToolsIntersiteSecret();
if ($secret === '') {
dbnToolsError('Intersite secret not configured.', 500, 'misconfigured');
}
$ts = (int)($_SERVER['HTTP_X_INTERSITE_TIMESTAMP'] ?? 0);
$sig = (string)($_SERVER['HTTP_X_INTERSITE_SIGNATURE'] ?? '');
if ($ts === 0 || $sig === '' || abs(time() - $ts) > 300) {
dbnToolsError('Bad intersite auth.', 401, 'bad_auth');
}
$raw = file_get_contents('php://input');
if (!is_string($raw) || $raw === '') {
dbnToolsError('Empty body.', 400, 'empty');
}
if (!hash_equals(hash_hmac('sha256', $ts . '.' . $raw, $secret), $sig)) {
dbnToolsError('Bad signature.', 401, 'bad_sig');
}
$data = json_decode($raw, true);
if (!is_array($data)) {
dbnToolsError('Invalid JSON.', 400, 'invalid_json');
}
$docId = (int)($data['doc_id'] ?? 0);
$status = (string)($data['status'] ?? 'failed');
$pageCount = isset($data['page_count']) ? (int)$data['page_count'] : null;
$docType = isset($data['doc_type']) ? (string)$data['doc_type'] : null;
$detectedDate = isset($data['detected_date']) ? (string)$data['detected_date'] : null;
$parties = $data['parties'] ?? null;
$errorMsg = isset($data['error_msg']) ? (string)$data['error_msg'] : null;
if ($docId <= 0) {
dbnToolsError('doc_id required.', 400, 'bad_input');
}
$db = dbnmDb();
$db->prepare(
'UPDATE case_documents
SET ocr_status = ?,
page_count = COALESCE(?, page_count),
doc_type = COALESCE(?, doc_type),
detected_date = COALESCE(?, detected_date),
parties = COALESCE(?, parties),
ocr_error = COALESCE(?, ocr_error),
indexed_at = CASE WHEN ? = "ready" THEN NOW() ELSE indexed_at END
WHERE id = ?'
)->execute([
$status,
$pageCount,
$docType,
$detectedDate,
$parties !== null ? json_encode($parties, JSON_UNESCAPED_UNICODE) : null,
$errorMsg,
$status,
$docId,
]);
dbnToolsRespond(['ok' => true]);
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../includes/bootstrap.php';
require_once __DIR__ . '/../../includes/CaseStore.php';
dbnToolsRequireMethod('GET');
dbnToolsRequireAuth();
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
if ($userId <= 0) {
dbnToolsError('Auth required.', 401, 'auth_required');
}
dbnToolsRespond([
'ok' => true,
'docs' => CaseStore::listDocs($userId),
]);
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../includes/bootstrap.php';
require_once __DIR__ . '/../../includes/FreeTier.php';
require_once __DIR__ . '/../../includes/CaseStore.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
if ($userId <= 0) {
dbnToolsError('Auth required.', 401, 'auth_required');
}
if (empty($_FILES['file']) || ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
dbnToolsError('No file uploaded or upload error.', 400, 'no_file');
}
$file = $_FILES['file'];
$size = (int)$file['size'];
$tmp = (string)$file['tmp_name'];
$name = (string)$file['name'];
if ($size <= 0 || $size > 25 * 1024 * 1024) {
dbnToolsError('Filen må være mellom 1 byte og 25 MB.', 413, 'bad_size');
}
// Validate it's actually a PDF (magic number check)
$fh = @fopen($tmp, 'rb');
if ($fh === false) {
dbnToolsError('Kunne ikke lese filen.', 500, 'read_fail');
}
$head = (string)fread($fh, 5);
fclose($fh);
if (strncmp($head, '%PDF-', 5) !== 0) {
dbnToolsError('Filen er ikke en gyldig PDF.', 415, 'not_pdf');
}
try {
$doc = CaseStore::registerUpload($userId, $name, $tmp, $size);
CaseStore::caseEnqueueIngest((int)$doc['doc_id'], $userId);
dbnToolsRespond([
'ok' => true,
'doc_id' => $doc['doc_id'],
'filename' => $doc['filename'],
]);
} catch (Throwable $e) {
dbnToolsError($e->getMessage(), 400, 'upload_failed');
}
+1
View File
@@ -78,6 +78,7 @@ try {
$input['deadlines']
))), 0, 6) : [],
'clarifications' => is_array($input['clarifications'] ?? null) ? $input['clarifications'] : [],
'use_my_case' => !empty($input['use_my_case']),
];
$forceDraft = !empty($input['force_draft']);
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/StripeClient.php';
require_once __DIR__ . '/../includes/FreeTier.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$user = dbnToolsAuthenticatedUser();
$userId = (int)($user['user_id'] ?? 0);
$email = (string)($user['email'] ?? '');
if ($userId <= 0 || $email === '') {
dbnToolsError('User session missing user_id or email.', 401, 'bad_session');
}
$input = dbnToolsJsonInput(2000);
$sku = (string)($input['sku'] ?? '');
$validSubscriptions = ['light', 'pro', 'pro_plus'];
$validTopups = ['topup_s', 'topup_m', 'topup_l'];
if (!in_array($sku, array_merge($validSubscriptions, $validTopups), true)) {
dbnToolsError('Unknown SKU.', 400, 'unknown_sku');
}
try {
$stripe = new StripeClient();
$customerId = $stripe->ensureCustomer($email, $userId);
$baseUrl = (dbnToolsIsHttps() ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'tools.dobetternorge.no');
$successUrl = $baseUrl . '/billing.php?status=success&session_id={CHECKOUT_SESSION_ID}';
$cancelUrl = $baseUrl . '/pricing.php?status=canceled';
$isSub = in_array($sku, $validSubscriptions, true);
$params = [
'mode' => $isSub ? 'subscription' : 'payment',
'customer' => $customerId,
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
'line_items' => [[
'price' => StripeClient::priceId($sku),
'quantity' => 1,
]],
'metadata' => [
'user_id' => (string)$userId,
'sku' => $sku,
],
'allow_promotion_codes' => true,
'billing_address_collection' => 'auto',
'locale' => 'auto',
'automatic_tax' => ['enabled' => false],
];
if ($isSub) {
$params['subscription_data'] = [
'metadata' => ['user_id' => (string)$userId, 'tier' => $sku],
];
} else {
$params['payment_intent_data'] = [
'metadata' => ['user_id' => (string)$userId, 'sku' => $sku, 'credits' => (string)StripeClient::topupCredits($sku)],
];
}
$session = $stripe->createCheckoutSession($params);
$url = (string)($session['url'] ?? '');
if ($url === '') {
dbnToolsError('Stripe did not return a checkout URL.', 502, 'stripe_no_url');
}
dbnToolsRespond(['ok' => true, 'url' => $url, 'session_id' => (string)($session['id'] ?? '')]);
} catch (Throwable $e) {
error_log('[stripe-checkout] ' . $e->getMessage());
dbnToolsError('Could not start checkout: ' . $e->getMessage(), 500, 'stripe_failed');
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/StripeClient.php';
require_once __DIR__ . '/../includes/FreeTier.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
$user = dbnToolsAuthenticatedUser();
$userId = (int)($user['user_id'] ?? 0);
if ($userId <= 0) {
dbnToolsError('User session missing user_id.', 401, 'bad_session');
}
$row = FreeTier::row($userId);
$customerId = (string)($row['stripe_customer_id'] ?? '');
if ($customerId === '') {
// No Stripe customer yet — happens if user never checked out.
dbnToolsError('No Stripe customer on record. Subscribe first.', 400, 'no_customer');
}
try {
$stripe = new StripeClient();
$baseUrl = (dbnToolsIsHttps() ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'tools.dobetternorge.no');
$session = $stripe->createPortalSession($customerId, $baseUrl . '/billing.php');
$url = (string)($session['url'] ?? '');
if ($url === '') {
dbnToolsError('Stripe did not return a portal URL.', 502, 'stripe_no_url');
}
dbnToolsRespond(['ok' => true, 'url' => $url]);
} catch (Throwable $e) {
error_log('[stripe-portal] ' . $e->getMessage());
dbnToolsError('Could not open billing portal: ' . $e->getMessage(), 500, 'stripe_failed');
}
+224
View File
@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/StripeClient.php';
require_once __DIR__ . '/../includes/FreeTier.php';
// Webhook must accept POST only, must verify signature, must be idempotent.
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
echo 'Method not allowed';
exit;
}
$payload = file_get_contents('php://input');
$sig = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret = StripeClient::config('STRIPE_WEBHOOK_SECRET');
if ($payload === false || $payload === '' || $sig === '' || $secret === '') {
http_response_code(400);
error_log('[stripe-webhook] missing payload, signature or secret');
echo 'Bad request';
exit;
}
if (!StripeClient::verifyWebhookSignature($payload, $sig, $secret)) {
http_response_code(400);
error_log('[stripe-webhook] signature verification failed');
echo 'Invalid signature';
exit;
}
$event = json_decode($payload, true);
if (!is_array($event)) {
http_response_code(400);
echo 'Invalid JSON';
exit;
}
$eventId = (string)($event['id'] ?? '');
$type = (string)($event['type'] ?? '');
$object = (array)($event['data']['object'] ?? []);
if ($eventId === '' || $type === '') {
http_response_code(400);
echo 'Missing event metadata';
exit;
}
$db = dbnmDb();
// Idempotency: skip if we've already processed this event_id.
$check = $db->prepare('SELECT 1 FROM stripe_events WHERE stripe_event_id = ? LIMIT 1');
$check->execute([$eventId]);
if ($check->fetchColumn()) {
http_response_code(200);
echo 'Already processed';
exit;
}
try {
$db->prepare('INSERT INTO stripe_events (stripe_event_id, type, payload, processed_at) VALUES (?, ?, ?, NOW())')
->execute([$eventId, $type, json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]);
switch ($type) {
case 'checkout.session.completed':
handleCheckoutCompleted($db, $object);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
handleSubscriptionChange($db, $object);
break;
case 'customer.subscription.deleted':
handleSubscriptionDeleted($db, $object);
break;
case 'invoice.paid':
handleInvoicePaid($db, $object);
break;
case 'invoice.payment_failed':
handleInvoiceFailed($db, $object);
break;
default:
// Unhandled event types are fine — Stripe just needs a 2xx response.
break;
}
http_response_code(200);
echo 'OK';
} catch (Throwable $e) {
error_log('[stripe-webhook] handler error: ' . $e->getMessage());
// Return 500 so Stripe retries — but the stripe_events row already exists, so a retry will be idempotent-skipped.
// To re-process: delete the row before retry.
http_response_code(500);
echo 'Handler failure';
}
// ── Handlers ────────────────────────────────────────────────────────────────
function handleCheckoutCompleted(PDO $db, array $session): void
{
$mode = (string)($session['mode'] ?? '');
$metadata = (array)($session['metadata'] ?? []);
$userId = (int)($metadata['user_id'] ?? 0);
if ($userId <= 0) {
error_log('[stripe-webhook] checkout.session.completed missing user_id metadata');
return;
}
// Store Stripe customer ID for portal access later.
$customerId = (string)($session['customer'] ?? '');
if ($customerId !== '') {
$db->prepare('UPDATE user_tool_credits SET stripe_customer_id = ? WHERE user_id = ? AND (stripe_customer_id IS NULL OR stripe_customer_id = "")')
->execute([$customerId, $userId]);
}
if ($mode === 'payment') {
// One-time topup — grant credits immediately.
$sku = (string)($metadata['sku'] ?? '');
$credits = StripeClient::topupCredits($sku);
if ($credits > 0) {
FreeTier::awardBonus($userId, $credits, 'topup:' . $sku);
}
}
// For mode='subscription', subscription.created will fire and update the tier.
}
function handleSubscriptionChange(PDO $db, array $sub): void
{
$subId = (string)($sub['id'] ?? '');
$status = (string)($sub['status'] ?? '');
$customerId = (string)($sub['customer'] ?? '');
$items = (array)($sub['items']['data'] ?? []);
$priceId = (string)($items[0]['price']['id'] ?? '');
$tier = StripeClient::tierForPrice($priceId);
if ($tier === null) {
error_log('[stripe-webhook] unknown price_id on subscription: ' . $priceId);
return;
}
$metadata = (array)($sub['metadata'] ?? []);
$userId = (int)($metadata['user_id'] ?? 0);
if ($userId <= 0) {
// Fallback: look up by stripe_customer_id
$stmt = $db->prepare('SELECT user_id FROM user_tool_credits WHERE stripe_customer_id = ? LIMIT 1');
$stmt->execute([$customerId]);
$userId = (int)($stmt->fetchColumn() ?: 0);
}
if ($userId <= 0) {
error_log('[stripe-webhook] subscription.* could not resolve user_id (sub=' . $subId . ', customer=' . $customerId . ')');
return;
}
$periodEndTs = (int)($sub['current_period_end'] ?? 0);
$periodStartTs = (int)($sub['current_period_start'] ?? 0);
$periodEndIso = $periodEndTs > 0 ? gmdate('Y-m-d H:i:s', $periodEndTs) : null;
$periodStartIso = $periodStartTs > 0 ? gmdate('Y-m-d H:i:s', $periodStartTs) : null;
// Upsert subscription ledger
$db->prepare(
'INSERT INTO user_subscriptions
(user_id, stripe_customer_id, stripe_subscription_id, tier, status, current_period_start, current_period_end)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
tier = VALUES(tier),
current_period_start = VALUES(current_period_start),
current_period_end = VALUES(current_period_end),
stripe_customer_id = VALUES(stripe_customer_id)'
)->execute([$userId, $customerId, $subId, $tier, $status, $periodStartIso, $periodEndIso]);
// Only flip the live tier flag if subscription is active/trialing.
if (in_array($status, ['active', 'trialing'], true)) {
FreeTier::setTier($userId, $tier, $customerId, $subId, $periodEndIso);
} elseif (in_array($status, ['canceled', 'unpaid', 'incomplete_expired'], true)) {
FreeTier::clearTier($userId);
}
}
function handleSubscriptionDeleted(PDO $db, array $sub): void
{
$subId = (string)($sub['id'] ?? '');
$customerId = (string)($sub['customer'] ?? '');
$stmt = $db->prepare('SELECT user_id FROM user_subscriptions WHERE stripe_subscription_id = ? LIMIT 1');
$stmt->execute([$subId]);
$userId = (int)($stmt->fetchColumn() ?: 0);
if ($userId <= 0 && $customerId !== '') {
$stmt = $db->prepare('SELECT user_id FROM user_tool_credits WHERE stripe_customer_id = ? LIMIT 1');
$stmt->execute([$customerId]);
$userId = (int)($stmt->fetchColumn() ?: 0);
}
if ($userId > 0) {
$db->prepare('UPDATE user_subscriptions SET status = ? WHERE stripe_subscription_id = ?')
->execute(['canceled', $subId]);
FreeTier::clearTier($userId);
}
}
function handleInvoicePaid(PDO $db, array $invoice): void
{
$subId = (string)($invoice['subscription'] ?? '');
if ($subId === '') return;
$stmt = $db->prepare('SELECT user_id, tier FROM user_subscriptions WHERE stripe_subscription_id = ? LIMIT 1');
$stmt->execute([$subId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) return;
$userId = (int)$row['user_id'];
$tier = (string)$row['tier'];
$periodEndTs = (int)($invoice['lines']['data'][0]['period']['end'] ?? 0);
$periodEndIso = $periodEndTs > 0 ? gmdate('Y-m-d H:i:s', $periodEndTs) : null;
FreeTier::refillForRenewal($userId, $tier, $periodEndIso);
}
function handleInvoiceFailed(PDO $db, array $invoice): void
{
$subId = (string)($invoice['subscription'] ?? '');
if ($subId === '') return;
$db->prepare('UPDATE user_subscriptions SET status = ? WHERE stripe_subscription_id = ?')
->execute(['past_due', $subId]);
// Don't downgrade yet — Stripe will retry. subscription.updated will fire if it fully fails.
}
+2 -2
View File
@@ -576,7 +576,7 @@
${(data.legal_check && data.legal_check.length) ? `
<div class="korr-legal-check">
<h4 class="korr-legal-check__title">⚖ Legal threshold check <small>(dbn-legal-agent-v2)</small></h4>
<h4 class="korr-legal-check__title">⚖ Legal threshold check <small>(dbn-legal-agent-v3)</small></h4>
${data.legal_check.map((f) => `
<div class="bvj-red-flag">
<div class="bvj-red-flag__head">
@@ -701,7 +701,7 @@
${(data.legal_check && data.legal_check.length) ? `
<div class="korr-legal-check">
<h4 class="korr-legal-check__title">⚖ Legal threshold check <small>(dbn-legal-agent-v2)</small></h4>
<h4 class="korr-legal-check__title">⚖ Legal threshold check <small>(dbn-legal-agent-v3)</small></h4>
${data.legal_check.map((f) => `
<div class="bvj-red-flag">
<div class="bvj-red-flag__head">
+113
View File
@@ -171,3 +171,116 @@
list.innerHTML = '<p class="workbench-docs__empty">' + t('error') + '</p>';
});
}());
// ── Upload zone ───────────────────────────────────────────────────────────────
(function () {
'use strict';
var zone = document.getElementById('wbUploadZone');
var input = document.getElementById('wbUploadInput');
var status = document.getElementById('wbUploadStatus');
var list = document.getElementById('myDocsList');
if (!zone || !input) return;
var lang = (window.DBN_TOOLS_LANG || 'en');
var statusTimer = 0;
var i18n = {
uploading: { en: 'Uploading…', no: 'Laster opp…', uk: 'Завантаження…', pl: 'Przesyłanie…' },
saved: { en: 'Saved.', no: 'Lagret.', uk: 'Збережено.', pl: 'Zapisano.' },
error: { en: 'Upload failed.', no: 'Opplasting mislyktes.', uk: 'Помилка завантаження.', pl: 'Błąd przesyłania.' },
remove: { en: 'Remove', no: 'Fjern', uk: 'Видалити', pl: 'Usuń' },
empty: { en: 'No documents yet.', no: 'Ingen dokumenter ennå.', uk: 'Документів ще немає.', pl: 'Brak dokumentów.' },
};
function t(key) {
return (i18n[key] && i18n[key][lang]) || (i18n[key] && i18n[key]['en']) || key;
}
function esc(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function setStatus(msg, keep) {
if (!status) return;
status.textContent = msg;
window.clearTimeout(statusTimer);
if (!keep) {
statusTimer = window.setTimeout(function () { status.textContent = ''; }, 2400);
}
}
function prependDoc(doc) {
if (!list) return;
var empty = list.querySelector('.workbench-docs__empty, .workbench-docs__loading');
if (empty) empty.remove();
var el = document.createElement('div');
el.className = 'workbench-docs__item';
el.setAttribute('role', 'listitem');
el.setAttribute('data-doc-id', doc.doc_id);
el.innerHTML = '<span class="workbench-docs__icon">📄</span>'
+ '<span class="workbench-docs__name" title="' + esc(doc.filename) + '">' + esc(doc.filename) + '</span>'
+ '<span class="workbench-docs__meta">' + esc(new Date(doc.created_at).toLocaleDateString()) + ' · workbench</span>'
+ '<button class="workbench-docs__remove" type="button" data-doc-id="' + esc(doc.doc_id) + '" aria-label="' + t('remove') + ' ' + esc(doc.filename) + '">' + t('remove') + '</button>';
el.querySelector('.workbench-docs__remove').addEventListener('click', function () {
var btn = this;
btn.disabled = true;
fetch('/api/user-docs.php?id=' + encodeURIComponent(doc.doc_id), { method: 'DELETE', credentials: 'include' })
.then(function () {
var item = list.querySelector('[data-doc-id="' + doc.doc_id + '"]');
if (item) item.remove();
if (!list.querySelector('.workbench-docs__item')) {
list.innerHTML = '<p class="workbench-docs__empty">' + t('empty') + '</p>';
}
})
.catch(function () { btn.disabled = false; });
});
list.insertBefore(el, list.firstChild);
}
function uploadFile(file) {
var fd = new FormData();
fd.append('file', file);
setStatus(t('uploading'), true);
return fetch('/api/user-docs.php', { method: 'POST', credentials: 'include', body: fd })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.ok && data.doc) {
prependDoc(data.doc);
setStatus(t('saved'));
} else {
setStatus((data.error || t('error')));
}
})
.catch(function () { setStatus(t('error')); });
}
function handleFiles(files) {
if (!files || !files.length) return;
var chain = Promise.resolve();
Array.prototype.forEach.call(files, function (file) {
chain = chain.then(function () { return uploadFile(file); });
});
}
zone.addEventListener('dragover', function (e) {
e.preventDefault();
zone.classList.add('is-drag-over');
});
zone.addEventListener('dragleave', function () {
zone.classList.remove('is-drag-over');
});
zone.addEventListener('drop', function (e) {
e.preventDefault();
zone.classList.remove('is-drag-over');
handleFiles(e.dataTransfer.files);
});
input.addEventListener('change', function () {
handleFiles(input.files);
input.value = '';
});
}());
+2 -1
View File
@@ -41,8 +41,9 @@ require_once __DIR__ . '/includes/layout.php';
<label><input type="radio" name="bvjEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~30-60s)</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="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"> &#x1F1F3;&#x1F1F4; Norwegian specialist v3 &#9733; <small class="control-hint">(dbn-legal-agent-v3 · ~20-60s)</small></label>
</div>
<p class="upload-hint">Engine applies to the final advocacy synthesis only. Document classification, party extraction, and timeline are always fast (azure-mini).</p>
<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>
<div class="dr-slice-section">
<p class="control-label">Corpus slices</p>
+210
View File
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
require_once __DIR__ . '/includes/FreeTier.php';
if (!dbnToolsIsAuthenticated()) {
header('Location: /?return=' . urlencode('/billing.php'));
exit;
}
$uiLang = dbnToolsCurrentLanguage();
$isSso = dbnToolsIsFreeTier();
$userId = $isSso ? (int)$_SESSION['dbn_tools_sso_uid'] : 0;
$email = (string)($_SESSION['dbn_tools_user_email'] ?? '');
$detail = $userId > 0 ? FreeTier::balanceDetail($userId) : [
'balance' => 0, 'bonus_balance' => 0, 'tier' => 'caveau',
'storage_used_bytes' => 0, 'storage_quota_bytes' => 0,
'survey_completed_at' => null, 'subscription_period_end' => null,
];
$tier = (string)$detail['tier'];
$effective = (int)$detail['balance'] + (int)$detail['bonus_balance'];
$storageMb = round($detail['storage_used_bytes'] / 1048576, 1);
$quotaMb = $detail['storage_quota_bytes'] > 0 ? round($detail['storage_quota_bytes'] / 1048576, 0) : 0;
$storagePct = $quotaMb > 0 ? min(100, round(($storageMb / $quotaMb) * 100)) : 0;
$tierLabels = [
'free' => 'Gratis',
'light' => 'Light',
'pro' => 'Pro',
'pro_plus' => 'Pro+ Familie',
'caveau' => 'CaveauAI',
];
// Recent usage
$db = dbnmDb();
$stmt = $db->prepare(
'SELECT tool, credits_used, created_at FROM user_tool_usage_log
WHERE user_id = ? ORDER BY created_at DESC LIMIT 25'
);
$stmt->execute([$userId]);
$recent = $stmt->fetchAll(PDO::FETCH_ASSOC);
$status = (string)($_GET['status'] ?? '');
?>
<!doctype html>
<html lang="<?= htmlspecialchars($uiLang) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Konto & fakturering — tools.dobetternorge.no</title>
<meta name="robots" content="noindex, nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="assets/css/tools.css">
<style>
.billing-shell { max-width: 1000px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
.billing-shell h1 { font-family: 'Crimson Pro', serif; margin: 0 0 0.5rem; }
.billing-shell .sub { color: #6b7280; margin-bottom: 2rem; }
.billing-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; margin-bottom: 2rem; }
@media (max-width: 720px) { .billing-grid { grid-template-columns: 1fr; } }
.billing-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 1.5rem; }
.billing-card h2 { margin: 0 0 0.5rem; font-size: 1.1rem; color: #374151; }
.tier-badge { display: inline-block; padding: 4px 12px; border-radius: 999px; font-size: 0.85rem; font-weight: 600; }
.tier-free { background: #f3f4f6; color: #374151; }
.tier-light { background: #ddd6fe; color: #5b21b6; }
.tier-pro { background: #bfdbfe; color: #1e40af; }
.tier-pro_plus { background: #fde68a; color: #92400e; }
.tier-caveau { background: #d1fae5; color: #065f46; }
.balance-big { font-size: 2.6rem; font-weight: 700; color: #00205B; margin: 0.5rem 0; }
.balance-break { color: #6b7280; font-size: 0.9rem; }
.storage-bar { background: #f3f4f6; border-radius: 999px; height: 10px; overflow: hidden; margin: 0.75rem 0; }
.storage-bar > div { background: #00205B; height: 100%; transition: width 0.3s; }
.billing-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 2rem 0; }
.btn { padding: 0.7rem 1.25rem; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-block; }
.btn-primary { background: #00205B; color: #fff; }
.btn-primary:hover { background: #001740; }
.btn-secondary { background: #f3f4f6; color: #1f2937; }
.btn-secondary:hover { background: #e5e7eb; }
.usage-table { width: 100%; border-collapse: collapse; }
.usage-table th, .usage-table td { padding: 8px 12px; border-bottom: 1px solid #f3f4f6; text-align: left; font-size: 0.9rem; }
.usage-table th { color: #6b7280; font-weight: 500; }
.usage-table .negative { color: #059669; font-weight: 600; }
.usage-table .positive { color: #b91c1c; }
.status-pill { display: inline-block; margin-bottom: 1.5rem; padding: 8px 14px; border-radius: 6px; font-size: 0.9rem; }
.status-success { background: #d1fae5; color: #065f46; }
</style>
</head>
<body>
<main class="billing-shell">
<h1>Konto & fakturering</h1>
<p class="sub"><?= htmlspecialchars($email) ?> · <a href="/dashboard.php">Tilbake til dashbordet</a></p>
<?php if ($status === 'success'): ?>
<p class="status-pill status-success">Betalingen er bekreftet. Hvis du nettopp abonnerte, kan det ta noen sekunder før kontoen oppdateres.</p>
<?php endif; ?>
<div class="billing-grid">
<div class="billing-card">
<h2>Nåværende plan</h2>
<p style="margin:0.5rem 0 1rem;">
<span class="tier-badge tier-<?= htmlspecialchars($tier) ?>"><?= htmlspecialchars($tierLabels[$tier] ?? $tier) ?></span>
</p>
<?php if (!empty($detail['subscription_period_end'])): ?>
<p class="balance-break">Fornyes <?= htmlspecialchars(date('j. F Y', strtotime((string)$detail['subscription_period_end']))) ?></p>
<?php elseif ($tier === 'free'): ?>
<p class="balance-break">Ingen aktiv abonnement. <a href="/pricing.php">Se planer</a></p>
<?php endif; ?>
<div class="billing-actions" style="margin: 1.25rem 0 0;">
<?php if (in_array($tier, ['light','pro','pro_plus'], true)): ?>
<button type="button" id="portalBtn" class="btn btn-secondary">Administrer abonnement</button>
<?php endif; ?>
<a class="btn btn-primary" href="/pricing.php">Se alle planer</a>
</div>
</div>
<div class="billing-card">
<h2>Tilgjengelige kreditter</h2>
<p class="balance-big"><?= number_format($effective, 0, ',', ' ') ?></p>
<p class="balance-break">
<?= (int)$detail['balance'] ?> månedlige · <?= (int)$detail['bonus_balance'] ?> bonus
</p>
<?php if ($tier === 'pro_plus'): ?>
<p style="margin-top:0.5rem; color:#059669; font-size:0.9rem;">✓ Pro+ har ubegrenset bruk (50 kall/time)</p>
<?php endif; ?>
</div>
<?php if (in_array($tier, ['light','pro','pro_plus'], true)): ?>
<div class="billing-card">
<h2>Sak-lagring</h2>
<p class="balance-big" style="font-size:1.8rem;"><?= $storageMb ?> MB <span style="color:#6b7280; font-size:1rem;">/ <?= $quotaMb ?> MB</span></p>
<div class="storage-bar"><div style="width: <?= $storagePct ?>%;"></div></div>
<p class="balance-break"><a href="/min-sak.php">Gå til Min Sak</a></p>
</div>
<?php else: ?>
<div class="billing-card">
<h2>Min Sak</h2>
<p style="color:#6b7280; margin:0 0 1rem;">Last opp dine egne dokumenter — alle verktøyene kan deretter referere til din private sak.</p>
<a class="btn btn-primary" href="/pricing.php">Oppgrader for å bygge sak</a>
</div>
<?php endif; ?>
<div class="billing-card">
<h2>Bonus-kreditter (undersøkelse)</h2>
<?php if (!empty($detail['survey_completed_at'])): ?>
<p style="color:#059669;">✓ Du har allerede mottatt 25 bonuskreditter for å fylle ut undersøkelsen.</p>
<?php else: ?>
<p style="color:#374151; margin:0 0 1rem;">Svar på 5 korte spørsmål om dine behov og motta 25 bonus-kreditter — utløper aldri.</p>
<a class="btn btn-primary" href="https://dobetternorge.no/survey.php">Ta undersøkelsen</a>
<?php endif; ?>
</div>
</div>
<div class="billing-card">
<h2>Nylig bruk (siste 25)</h2>
<?php if (empty($recent)): ?>
<p style="color:#6b7280;">Ingen bruk registrert ennå.</p>
<?php else: ?>
<table class="usage-table">
<thead><tr><th>Verktøy / hendelse</th><th>Kreditter</th><th>Tidspunkt</th></tr></thead>
<tbody>
<?php foreach ($recent as $r): ?>
<?php $credits = (int)$r['credits_used']; ?>
<tr>
<td><?= htmlspecialchars($r['tool']) ?></td>
<td class="<?= $credits < 0 ? 'negative' : 'positive' ?>"><?= $credits < 0 ? '+' . abs($credits) : '-' . $credits ?></td>
<td><?= htmlspecialchars((string)$r['created_at']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</main>
<script>
(function() {
const portalBtn = document.getElementById('portalBtn');
if (portalBtn) {
portalBtn.addEventListener('click', async () => {
portalBtn.disabled = true;
const original = portalBtn.textContent;
portalBtn.textContent = 'Kobler til...';
try {
const res = await fetch('/api/stripe-portal.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
const data = await res.json();
if (data.ok && data.url) {
window.location.href = data.url;
} else {
alert(data.error?.message || 'Kunne ikke åpne portalen.');
}
} catch (e) {
alert('Nettverksfeil: ' + e.message);
} finally {
portalBtn.disabled = false;
portalBtn.textContent = original;
}
});
}
})();
</script>
</body>
</html>
+58
View File
@@ -2,6 +2,7 @@
declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
require_once __DIR__ . '/includes/FreeTier.php';
if (!dbnToolsIsAuthenticated()) {
header('Location: /?return=' . urlencode($_SERVER['REQUEST_URI'] ?? '/dashboard.php'));
@@ -13,6 +14,19 @@ $tools = dbnToolsLaunchedTools($uiLang);
$workbench = dbnToolsWorkbenchMeta($uiLang);
$langPath = '/dashboard.php';
// Tier + balance for SSO users (CaveauAI sessions get no panel)
$dashIsSso = dbnToolsIsFreeTier();
$dashTier = $dashIsSso ? FreeTier::tier((int)$_SESSION['dbn_tools_sso_uid']) : 'caveau';
$dashDetail = $dashIsSso ? FreeTier::balanceDetail((int)$_SESSION['dbn_tools_sso_uid']) : null;
$tierLabels = [
'free' => ['Gratis', '#f3f4f6', '#374151'],
'light' => ['Light', '#ddd6fe', '#5b21b6'],
'pro' => ['Pro', '#bfdbfe', '#1e40af'],
'pro_plus' => ['Pro+ Familie', '#fde68a', '#92400e'],
];
$tierLabel = $tierLabels[$dashTier] ?? ['CaveauAI', '#d1fae5', '#065f46'];
$showSurveyCta = $dashIsSso && empty($dashDetail['survey_completed_at']);
require_once __DIR__ . '/includes/tool-svgs.php';
?>
<!doctype html>
@@ -53,10 +67,54 @@ window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
<?php endforeach; ?>
</nav>
<span id="healthPill" class="status-pill"><?= htmlspecialchars(dbnToolsT('session_active', $uiLang)) ?></span>
<?php if ($dashIsSso): ?>
<a href="/billing.php" class="tier-pill" style="background: <?= htmlspecialchars($tierLabel[1]) ?>; color: <?= htmlspecialchars($tierLabel[2]) ?>; padding: 4px 12px; border-radius: 999px; font-size: 0.82rem; font-weight: 600; text-decoration: none;">
<?= htmlspecialchars($tierLabel[0]) ?>
</a>
<?php endif; ?>
<button id="healthButton" class="secondary-button" type="button"><?= htmlspecialchars(dbnToolsT('health', $uiLang)) ?></button>
</div>
</header>
<?php if ($dashIsSso && $dashDetail): ?>
<section class="dashboard-status-row" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap:1rem; margin: 1.5rem 0;">
<div class="status-card" style="background:#fff; border:1px solid #e5e7eb; border-radius:10px; padding:1.1rem 1.25rem;">
<p style="margin:0; color:#6b7280; font-size:0.85rem; text-transform:uppercase; letter-spacing:0.06em;">Tilgjengelige kreditter</p>
<p style="margin:0.35rem 0 0; font-size:1.8rem; font-weight:700; color:#00205B;">
<?php $eff = (int)$dashDetail['balance'] + (int)$dashDetail['bonus_balance']; ?>
<?php if ($dashTier === 'pro_plus'): ?>∞<?php else: ?><?= number_format($eff, 0, ',', ' ') ?><?php endif; ?>
</p>
<p style="margin:0; color:#6b7280; font-size:0.85rem;"><?= (int)$dashDetail['balance'] ?> månedlige · <?= (int)$dashDetail['bonus_balance'] ?> bonus · <a href="/billing.php">Detaljer</a></p>
</div>
<?php if (in_array($dashTier, ['light','pro','pro_plus'], true)): ?>
<a class="status-card" href="/min-sak.php" style="background:#fff; border:1px solid #e5e7eb; border-radius:10px; padding:1.1rem 1.25rem; text-decoration:none; color:inherit;">
<p style="margin:0; color:#6b7280; font-size:0.85rem; text-transform:uppercase; letter-spacing:0.06em;">Min sak</p>
<p style="margin:0.35rem 0 0; font-size:1.4rem; font-weight:700; color:#00205B;">Bygg din egen sak →</p>
<?php
$used = (int)$dashDetail['storage_used_bytes'];
$quota = (int)$dashDetail['storage_quota_bytes'];
$usedMb = $used > 0 ? round($used / 1048576, 1) : 0;
$quotaMb = $quota > 0 ? round($quota / 1048576, 0) : 0;
?>
<p style="margin:0; color:#6b7280; font-size:0.85rem;"><?= $usedMb ?> MB / <?= $quotaMb ?> MB brukt</p>
</a>
<?php else: ?>
<a class="status-card" href="/pricing.php" style="background:linear-gradient(135deg,#00205B,#003478); color:#fff; border-radius:10px; padding:1.1rem 1.25rem; text-decoration:none;">
<p style="margin:0; opacity:0.85; font-size:0.85rem; text-transform:uppercase; letter-spacing:0.06em;">Bygg din egen sak</p>
<p style="margin:0.35rem 0 0; font-size:1.4rem; font-weight:700;">Last opp dokumenter →</p>
<p style="margin:0; opacity:0.85; font-size:0.85rem;">Tilgjengelig fra Light €9/mo</p>
</a>
<?php endif; ?>
<?php if ($showSurveyCta): ?>
<a class="status-card" href="https://dobetternorge.no/survey.php" style="background:#fef3c7; color:#92400e; border-radius:10px; padding:1.1rem 1.25rem; text-decoration:none;">
<p style="margin:0; font-size:0.85rem; text-transform:uppercase; letter-spacing:0.06em; opacity:0.85;">Tjen 25 ekstra kreditter</p>
<p style="margin:0.35rem 0 0; font-size:1.2rem; font-weight:700;">Ta vår 5-spørsmåls undersøkelse →</p>
<p style="margin:0; font-size:0.85rem; opacity:0.85;">Ingen salgspitch — bare research</p>
</a>
<?php endif; ?>
</section>
<?php endif; ?>
<section class="manifesto dashboard-manifesto">
<div class="manifesto-copy">
<p class="manifesto-eyebrow"><?= htmlspecialchars(dbnToolsT('dashboard_eyebrow', $uiLang)) ?></p>
+2 -1
View File
@@ -21,8 +21,9 @@ require_once __DIR__ . '/includes/layout.php';
<label><input type="radio" name="drEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~15-45s)</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="drEngine" value="gpu"> GPU (cuttlefish) <small class="control-hint">(local · ~30-90s)</small></label>
<label><input type="radio" name="drEngine" value="dbn_legal_v3"> &#x1F1F3;&#x1F1F4; Norwegian specialist v3 &#9733; <small class="control-hint">(dbn-legal-agent-v3 · ~20-60s)</small></label>
</div>
<p class="upload-hint">Azure mini is the default and finishes fastest. Azure full is the most thorough but can take 1-3 minutes. GPU keeps everything inside the BNL fleet. Live progress shown in the right-hand reasoning panel.</p>
<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>
<div class="dr-slice-section">
<p class="control-label">Corpus slices</p>
+2 -1
View File
@@ -60,8 +60,9 @@ require_once __DIR__ . '/includes/layout.php';
<label><input type="radio" name="dcEngine" value="azure_mini" checked> Azure gpt-4o-mini &#9733; <small class="control-hint">(~60-90s)</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="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"> &#x1F1F3;&#x1F1F4; Norwegian specialist v3 &#9733; <small class="control-hint">(dbn-legal-agent-v3 · ~30-60s)</small></label>
</div>
<p class="upload-hint">Engine applies to the final synthesis only. Document classification, party extraction, timelines, and cross-referencing always use azure-mini.</p>
<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>
<details class="advanced-panel" id="dcSlicePanel">
<summary class="advanced-toggle">Corpus slices <span class="control-hint">(used for legal significance context)</span></summary>
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/**
* Azure Document Intelligence (formerly Form Recognizer) — Read API for OCR + layout.
*
* Endpoint: https://bnl-doc-intelligence.cognitiveservices.azure.com/
* Auth: Ocp-Apim-Subscription-Key header
*
* Read API is async: POST to /documentintelligence/documentModels/prebuilt-read:analyze
* → returns 202 + Operation-Location header with poll URL
* → poll until status == "succeeded", then parse analyzeResult.content
*/
final class AzureDocIntelligence
{
private string $endpoint;
private string $key;
public function __construct(?string $endpoint = null, ?string $key = null)
{
$cfg = self::loadConfig();
$this->endpoint = rtrim($endpoint ?? ($cfg['endpoint'] ?? ''), '/');
$this->key = $key ?? ($cfg['key'] ?? '');
if ($this->endpoint === '' || $this->key === '') {
throw new RuntimeException('AzureDocIntelligence: endpoint or key not configured.');
}
}
private static function loadConfig(): array
{
$path = '/etc/bnl/azure.php';
if (is_readable($path)) {
$cfg = require $path;
return [
'endpoint' => (string)($cfg['DOC_INTELLIGENCE_ENDPOINT'] ?? ''),
'key' => (string)($cfg['DOC_INTELLIGENCE_KEY'] ?? ''),
];
}
return [
'endpoint' => (string)(getenv('AZURE_DOC_INTELLIGENCE_ENDPOINT') ?: ''),
'key' => (string)(getenv('AZURE_DOC_INTELLIGENCE_KEY') ?: ''),
];
}
/**
* OCR a local PDF file using the prebuilt-read model.
* Returns: ['content' => string, 'pages' => array, 'languages' => array]
*/
public function readPdf(string $localPath, int $pollTimeoutSeconds = 120): array
{
if (!is_readable($localPath)) {
throw new InvalidArgumentException("Unreadable file: {$localPath}");
}
$url = $this->endpoint . '/documentintelligence/documentModels/prebuilt-read:analyze?api-version=2024-11-30';
$body = file_get_contents($localPath);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Content-Type: application/pdf',
'Ocp-Apim-Subscription-Key: ' . $this->key,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_TIMEOUT => 60,
]);
$response = curl_exec($ch);
$headerSize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 202 || !is_string($response)) {
throw new RuntimeException("DocIntelligence analyze failed: HTTP {$status}");
}
$headers = substr($response, 0, $headerSize);
if (!preg_match('/Operation-Location:\s*(.+?)\r?\n/i', $headers, $m)) {
throw new RuntimeException('DocIntelligence: missing Operation-Location header.');
}
$pollUrl = trim($m[1]);
$deadline = time() + $pollTimeoutSeconds;
while (time() < $deadline) {
usleep(1500_000);
$pollCh = curl_init();
curl_setopt_array($pollCh, [
CURLOPT_URL => $pollUrl,
CURLOPT_HTTPHEADER => ['Ocp-Apim-Subscription-Key: ' . $this->key],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$pollResp = curl_exec($pollCh);
$pollStatus = (int)curl_getinfo($pollCh, CURLINFO_RESPONSE_CODE);
curl_close($pollCh);
if ($pollStatus !== 200 || !is_string($pollResp)) {
throw new RuntimeException("DocIntelligence poll failed: HTTP {$pollStatus}");
}
$data = json_decode($pollResp, true);
$st = (string)($data['status'] ?? '');
if ($st === 'succeeded') {
$result = $data['analyzeResult'] ?? [];
return [
'content' => (string)($result['content'] ?? ''),
'pages' => $result['pages'] ?? [],
'languages' => $result['languages'] ?? [],
'page_count' => count($result['pages'] ?? []),
];
}
if ($st === 'failed') {
$err = $data['error']['message'] ?? 'unknown';
throw new RuntimeException("DocIntelligence analysis failed: {$err}");
}
// 'running' or 'notStarted' — continue polling
}
throw new RuntimeException("DocIntelligence poll timeout after {$pollTimeoutSeconds}s.");
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
/**
* Per-user Azure AI Search index management.
*
* Each user gets their own private index: case-{user_id}
* - Hybrid search (BM25 + vector + Norwegian Bokmål analyzer nb.microsoft)
* - Vector dim 1536 (text-embedding-3-small)
* - Schema: id, doc_id, user_id, filename, page, chunk_text, vector, doc_type, detected_date
*
* Isolation enforced at INDEX level — no shared index. Cross-user data leak is structurally impossible.
*/
final class AzureSearchAdmin
{
private const API_VERSION = '2024-07-01';
private string $endpoint;
private string $adminKey;
public function __construct(?string $endpoint = null, ?string $adminKey = null)
{
$cfg = self::loadConfig();
$this->endpoint = rtrim($endpoint ?? ($cfg['endpoint'] ?? ''), '/');
$this->adminKey = $adminKey ?? ($cfg['admin_key'] ?? '');
if ($this->endpoint === '' || $this->adminKey === '') {
throw new RuntimeException('AzureSearchAdmin: endpoint or admin key not configured.');
}
}
private static function loadConfig(): array
{
$path = '/etc/bnl/azure.php';
if (is_readable($path)) {
$cfg = require $path;
return [
'endpoint' => (string)($cfg['SEARCH_ENDPOINT'] ?? 'https://bnl-legal-search.search.windows.net'),
'admin_key' => (string)($cfg['SEARCH_ADMIN_KEY'] ?? ''),
];
}
return [
'endpoint' => (string)(getenv('AZURE_SEARCH_ENDPOINT') ?: 'https://bnl-legal-search.search.windows.net'),
'admin_key' => (string)(getenv('AZURE_SEARCH_ADMIN_KEY') ?: ''),
];
}
public static function indexName(int $userId): string
{
return 'case-' . $userId;
}
/** Create the per-user index if it does not exist. Idempotent. */
public function ensureUserIndex(int $userId): string
{
$name = self::indexName($userId);
if ($this->indexExists($name)) {
return $name;
}
$body = [
'name' => $name,
'fields' => [
['name' => 'id', 'type' => 'Edm.String', 'key' => true, 'filterable' => true],
['name' => 'doc_id', 'type' => 'Edm.Int32', 'filterable' => true, 'facetable' => true],
['name' => 'user_id', 'type' => 'Edm.Int32', 'filterable' => true],
['name' => 'filename', 'type' => 'Edm.String', 'filterable' => true, 'sortable' => true, 'searchable' => true, 'analyzer' => 'standard.lucene'],
['name' => 'page', 'type' => 'Edm.Int32', 'filterable' => true, 'sortable' => true],
['name' => 'chunk_text', 'type' => 'Edm.String', 'searchable' => true, 'analyzer' => 'nb.microsoft'],
['name' => 'doc_type', 'type' => 'Edm.String', 'filterable' => true, 'facetable' => true],
['name' => 'detected_date', 'type' => 'Edm.DateTimeOffset', 'filterable' => true, 'sortable' => true],
[
'name' => 'vector',
'type' => 'Collection(Edm.Single)',
'searchable' => true,
'dimensions' => 1536,
'vectorSearchProfile' => 'caseVectorProfile',
],
],
'vectorSearch' => [
'algorithms' => [[
'name' => 'caseHnsw',
'kind' => 'hnsw',
'hnswParameters' => ['m' => 4, 'efConstruction' => 400, 'efSearch' => 500, 'metric' => 'cosine'],
]],
'profiles' => [['name' => 'caseVectorProfile', 'algorithm' => 'caseHnsw']],
],
'semantic' => [
'configurations' => [[
'name' => 'caseSemantic',
'prioritizedFields' => [
'contentFields' => [['fieldName' => 'chunk_text']],
'titleField' => ['fieldName' => 'filename'],
],
]],
],
];
$this->request('PUT', '/indexes/' . rawurlencode($name) . '?api-version=' . self::API_VERSION, $body);
return $name;
}
public function indexExists(string $name): bool
{
$code = $this->request('GET', '/indexes/' . rawurlencode($name) . '?api-version=' . self::API_VERSION, null, true);
return $code === 200;
}
/** Upsert a batch of documents (chunks) into the user's index. */
public function upsertChunks(int $userId, array $chunks): void
{
if (empty($chunks)) return;
$name = self::indexName($userId);
$body = [
'value' => array_map(fn($c) => array_merge(['@search.action' => 'mergeOrUpload'], $c), $chunks),
];
$this->request('POST', '/indexes/' . rawurlencode($name) . '/docs/index?api-version=' . self::API_VERSION, $body);
}
/** Delete all chunks for a given doc_id (used on document deletion). */
public function deleteDoc(int $userId, int $docId): void
{
$name = self::indexName($userId);
// First search to get all chunk ids for this doc
$resp = $this->request('POST', '/indexes/' . rawurlencode($name) . '/docs/search?api-version=' . self::API_VERSION, [
'search' => '*',
'filter' => 'doc_id eq ' . $docId,
'select' => 'id',
'top' => 1000,
]);
$ids = array_map(fn($v) => $v['id'] ?? null, $resp['value'] ?? []);
$ids = array_filter($ids);
if (empty($ids)) return;
$body = [
'value' => array_map(fn($id) => ['@search.action' => 'delete', 'id' => $id], array_values($ids)),
];
$this->request('POST', '/indexes/' . rawurlencode($name) . '/docs/index?api-version=' . self::API_VERSION, $body);
}
/** Delete the entire index (account deletion / GDPR). */
public function deleteIndex(int $userId): void
{
$name = self::indexName($userId);
$this->request('DELETE', '/indexes/' . rawurlencode($name) . '?api-version=' . self::API_VERSION, null, true);
}
/**
* Hybrid search: BM25 (Norwegian analyzer) + vector + semantic ranker.
* Returns ['value' => [{id, doc_id, filename, page, chunk_text, @search.score, @search.rerankerScore}, ...]]
*/
public function hybridSearch(int $userId, string $query, array $queryVector, int $k = 5): array
{
$name = self::indexName($userId);
$body = [
'search' => $query,
'queryType' => 'semantic',
'semanticConfiguration' => 'caseSemantic',
'searchFields' => 'chunk_text,filename',
'select' => 'id,doc_id,filename,page,chunk_text,doc_type,detected_date',
'top' => $k,
'vectorQueries' => [[
'kind' => 'vector',
'vector' => $queryVector,
'k' => $k,
'fields' => 'vector',
]],
];
return $this->request('POST', '/indexes/' . rawurlencode($name) . '/docs/search?api-version=' . self::API_VERSION, $body);
}
/** Low-level HTTP. If $returnStatusOnly, returns http code instead of decoded body. */
private function request(string $method, string $path, ?array $body = null, bool $returnStatusOnly = false)
{
$url = $this->endpoint . $path;
$headers = [
'api-key: ' . $this->adminKey,
'Content-Type: application/json',
];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$raw = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$errno = curl_errno($ch);
curl_close($ch);
if ($returnStatusOnly) {
return $status;
}
if ($errno !== 0) {
throw new RuntimeException('AzureSearch curl error: ' . curl_strerror($errno));
}
if ($status >= 400) {
throw new RuntimeException("AzureSearch HTTP {$status}: " . substr((string)$raw, 0, 300));
}
$decoded = json_decode((string)$raw, true);
return is_array($decoded) ? $decoded : [];
}
}
+12 -9
View File
@@ -921,14 +921,18 @@ PROMPT;
$opts = ['json' => true, 'temperature' => $temperature, 'max_tokens' => 4500, 'timeout' => 240];
$deployLabel = match ($engine) {
'gpu' => 'GPU (cuttlefish)',
'azure_full' => 'gpt-4o',
default => $this->azure->chatDeployment(),
'gpu' => 'GPU (cuttlefish)',
'dbn_legal_v3' => 'dbn-legal-agent-v3',
'azure_full' => 'gpt-4o',
default => $this->azure->chatDeployment(),
};
$raw = '';
try {
if ($engine === 'gpu') {
if ($engine === 'dbn_legal_v3') {
$response = dbnToolsCallGpuLlm($messages, array_merge($opts, ['model' => 'dbn-legal-agent-v3', 'timeout' => 180]));
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
} elseif ($engine === 'gpu') {
$response = dbnToolsCallGpuLlm($messages, $opts);
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
} elseif ($engine === 'azure_full') {
@@ -953,10 +957,9 @@ PROMPT;
];
}
// Step 6b: dbn-legal-agent targeted legal Q&A check (azure engines only; silent on failure)
// Asks one focused question about the document's statutory basis to surface domain knowledge
// that Azure reliably misses (klar nødvendighet threshold, Strand Lobben, fvl §17/§41).
if (in_array($engine, ['azure_mini', 'azure_full'], true)) {
// Step 6b: dbn-legal-agent targeted legal Q&A check (azure + gpu engines only;
// skipped when dbn_legal_v3 is the synthesis engine — it already IS the legal model).
if (in_array($engine, ['azure_mini', 'azure_full', 'gpu'], true)) {
$checkFindings = dbnToolsRunLegalCheck(
(string)($json['advocacy_brief'] ?? ''),
$docType
@@ -968,7 +971,7 @@ PROMPT;
foreach ($checkFindings as $cf) {
$json['procedural_red_flags'][] = $cf;
}
$json['check_model'] = 'dbn-legal-agent-v2';
$json['check_model'] = 'dbn-legal-agent-v3';
}
}
+285
View File
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/AzureSearchAdmin.php';
require_once __DIR__ . '/FreeTier.php';
/**
* Build Your Own Case — per-user private RAG corpus.
*
* Storage layout:
* - PDFs: /home/dobetternorge/uploads/case_{user_id}/{doc_id}.pdf (chloe filesystem)
* - Vectors: Azure AI Search index "case-{user_id}" (hybrid BM25 + vector + nb.microsoft analyzer)
* - Metadata: MySQL case_documents rows
*
* Family plan note: members share owner's caveau_client_id but each query is still scoped
* to the OWNER's user_id (and thus their index). Family members' UI-bound user_id resolves
* to owner_user_id via caseResolveClientId.
*/
final class CaseStore
{
/** Storage root on chloe — override via env if needed. */
public static function storageRoot(): string
{
$env = getenv('DBN_CASE_STORAGE_ROOT');
return rtrim($env !== false && $env !== '' ? $env : '/home/dobetternorge/uploads', '/');
}
/** Resolve the effective case-owner user_id for a given session user.
* If the user is a family-plan MEMBER, returns the OWNER's user_id (shared corpus).
* If the user is an owner OR has no seat record, returns their own user_id.
*/
public static function caseResolveClientId(int $userId): int
{
$db = dbnmDb();
$stmt = $db->prepare(
'SELECT owner_user_id FROM case_seats
WHERE member_user_id = ? AND accepted_at IS NOT NULL AND revoked_at IS NULL
LIMIT 1'
);
$stmt->execute([$userId]);
$ownerId = (int)($stmt->fetchColumn() ?: 0);
return $ownerId > 0 ? $ownerId : $userId;
}
/** Ensure storage dir + Azure index exist for a user. Idempotent. */
public static function caseProvisionUser(int $userId): array
{
$rootDir = self::storageRoot() . '/case_' . $userId;
if (!is_dir($rootDir)) {
// 0750: owner rwx, group rx, world none
@mkdir($rootDir, 0750, true);
}
$indexName = '';
try {
$admin = new AzureSearchAdmin();
$indexName = $admin->ensureUserIndex($userId);
} catch (Throwable $e) {
error_log('[CaseStore::caseProvisionUser] index create failed: ' . $e->getMessage());
}
return ['storage_path' => $rootDir, 'index_name' => $indexName];
}
/**
* Register an uploaded file in DB and return the doc row.
* Enforces tier-based storage quota.
*/
public static function registerUpload(int $userId, string $filename, string $tempPath, int $sizeBytes): array
{
// Quota check
$detail = FreeTier::balanceDetail($userId);
$quota = (int)$detail['storage_quota_bytes'];
$used = (int)$detail['storage_used_bytes'];
if ($quota === 0) {
throw new RuntimeException('Min Sak er ikke tilgjengelig på gratis-nivå. Oppgrader for å laste opp dokumenter.');
}
if ($used + $sizeBytes > $quota) {
$remainMb = max(0, ($quota - $used) / 1048576);
throw new RuntimeException(sprintf('Du har %.1f MB lagring igjen, men filen er %.1f MB.', $remainMb, $sizeBytes / 1048576));
}
// Provision (idempotent)
$bundle = self::caseProvisionUser($userId);
$dir = $bundle['storage_path'];
// Sanitize filename
$safeName = preg_replace('/[^A-Za-z0-9._\-]/', '_', $filename);
$safeName = mb_substr((string)$safeName, 0, 100);
$db = dbnmDb();
$db->prepare(
'INSERT INTO case_documents
(user_id, filename, storage_path, size_bytes, ocr_status, qdrant_collection, azure_index_name, uploaded_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())'
)->execute([
$userId, $safeName, '', $sizeBytes, 'pending',
'case_user_' . $userId,
AzureSearchAdmin::indexName($userId),
]);
$docId = (int)$db->lastInsertId();
$finalPath = $dir . '/' . $docId . '.pdf';
if (!@rename($tempPath, $finalPath)) {
// Fallback: copy + unlink
if (!@copy($tempPath, $finalPath)) {
$db->prepare('DELETE FROM case_documents WHERE id = ?')->execute([$docId]);
throw new RuntimeException('Kunne ikke lagre filen på serveren.');
}
@unlink($tempPath);
}
@chmod($finalPath, 0640);
// Save final path + bump storage usage
$db->prepare('UPDATE case_documents SET storage_path = ? WHERE id = ?')
->execute([$finalPath, $docId]);
$db->prepare('UPDATE user_tool_credits SET storage_used_bytes = storage_used_bytes + ? WHERE user_id = ?')
->execute([$sizeBytes, $userId]);
return [
'doc_id' => $docId,
'filename' => $safeName,
'storage_path' => $finalPath,
'size_bytes' => $sizeBytes,
];
}
/** Notify n8n that a new doc is ready for OCR + indexing. */
public static function caseEnqueueIngest(int $docId, int $userId): bool
{
$webhookUrl = getenv('N8N_CASE_INGEST_WEBHOOK') ?: '';
if ($webhookUrl === '') {
error_log('[CaseStore] N8N_CASE_INGEST_WEBHOOK not configured — leaving doc ' . $docId . ' as pending');
return false;
}
$payload = json_encode([
'doc_id' => $docId,
'user_id' => $userId,
'callback_url' => 'https://tools.dobetternorge.no/api/case/ingest-callback.php',
], JSON_UNESCAPED_UNICODE);
$ch = curl_init($webhookUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
curl_exec($ch);
$errno = curl_errno($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return $errno === 0 && $status >= 200 && $status < 300;
}
/**
* Hybrid search across the user's case.
* Embeds the query via LiteLLM (azure-text-embedding-3-small) and hits the per-user Azure Search index.
*
* CRITICAL: $userId here must be the EFFECTIVE owner_user_id (resolved via caseResolveClientId).
* The Azure index is scoped to this user_id at the INDEX NAME level — cross-user leak is structurally
* impossible: index "case-100" cannot return rows from index "case-200".
*/
public static function caseHybridSearch(int $effectiveOwnerUserId, string $query, int $k = 5): array
{
if ($effectiveOwnerUserId <= 0 || trim($query) === '') {
return [];
}
try {
$vector = self::embedQuery($query);
if (empty($vector)) {
return [];
}
$admin = new AzureSearchAdmin();
$resp = $admin->hybridSearch($effectiveOwnerUserId, $query, $vector, $k);
$hits = [];
foreach (($resp['value'] ?? []) as $hit) {
$hits[] = [
'chunk_text' => (string)($hit['chunk_text'] ?? ''),
'filename' => (string)($hit['filename'] ?? ''),
'page' => (int)($hit['page'] ?? 0),
'doc_id' => (int)($hit['doc_id'] ?? 0),
'doc_type' => (string)($hit['doc_type'] ?? ''),
'score' => (float)($hit['@search.score'] ?? 0),
'reranker_score' => (float)($hit['@search.rerankerScore'] ?? 0),
];
}
return $hits;
} catch (Throwable $e) {
error_log('[CaseStore::caseHybridSearch] failed: ' . $e->getMessage());
return [];
}
}
/** Embed a string via LiteLLM (azure-text-embedding-3-small). Returns float[] of dim 1536, or []. */
public static function embedQuery(string $text): array
{
$base = getenv('LITELLM_BASE_URL') ?: 'http://10.0.1.10:4000';
$key = getenv('LITELLM_API_KEY') ?: 'sk-bnl-litellm-26xR9mK4qvN3wL8sTj7pB2d';
$payload = json_encode([
'model' => 'azure-text-embedding-3-small',
'input' => mb_substr($text, 0, 8000),
], JSON_UNESCAPED_UNICODE);
$ch = curl_init($base . '/v1/embeddings');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 200 || !is_string($raw)) {
return [];
}
$data = json_decode($raw, true);
$vec = $data['data'][0]['embedding'] ?? null;
return is_array($vec) ? array_map('floatval', $vec) : [];
}
/** Format chunks for injection into an agent's system prompt. */
public static function formatChunksForPrompt(array $chunks): string
{
if (empty($chunks)) return '';
$out = "\n\n## Brukerens egne dokumenter (private sak):\n";
foreach ($chunks as $i => $c) {
$out .= sprintf(
"\n[%d] %s · side %d%s\n%s\n",
$i + 1,
$c['filename'],
$c['page'],
$c['doc_type'] !== '' ? ' · ' . $c['doc_type'] : '',
mb_substr($c['chunk_text'], 0, 1500)
);
}
$out .= "\n— slutt på brukerens dokumenter —\n";
return $out;
}
/** Soft-delete a doc + remove vectors from Azure index. */
public static function deleteDocument(int $userId, int $docId): bool
{
$db = dbnmDb();
$stmt = $db->prepare('SELECT id, storage_path, size_bytes FROM case_documents WHERE id = ? AND user_id = ? AND deleted_at IS NULL LIMIT 1');
$stmt->execute([$docId, $userId]);
$doc = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$doc) {
return false;
}
// Remove from Azure index
try {
$admin = new AzureSearchAdmin();
$admin->deleteDoc($userId, $docId);
} catch (Throwable $e) {
error_log('[CaseStore::deleteDocument] azure delete: ' . $e->getMessage());
}
// Mark deleted in DB
$db->prepare('UPDATE case_documents SET deleted_at = NOW() WHERE id = ?')->execute([$docId]);
// Refund storage
$db->prepare('UPDATE user_tool_credits SET storage_used_bytes = GREATEST(0, storage_used_bytes - ?) WHERE user_id = ?')
->execute([(int)$doc['size_bytes'], $userId]);
// Remove file from disk
if (!empty($doc['storage_path']) && is_file($doc['storage_path'])) {
@unlink($doc['storage_path']);
}
return true;
}
/** Return all docs for a user (excluding deleted). */
public static function listDocs(int $userId): array
{
$db = dbnmDb();
$stmt = $db->prepare(
'SELECT id, filename, size_bytes, page_count, doc_type, detected_date, ocr_status, ocr_error, uploaded_at, indexed_at
FROM case_documents WHERE user_id = ? AND deleted_at IS NULL ORDER BY uploaded_at DESC'
);
$stmt->execute([$userId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
}
+10 -5
View File
@@ -980,10 +980,11 @@ PROMPT;
'next_practical_step' => 'Try widening slice selection or rephrasing with more specific statutory or party terms.',
],
'deploy_label' => match($engine) {
'gpu' => 'GPU (cuttlefish)',
'dbn_legal' => 'dbn-legal-agent-v2',
'azure_full'=> 'gpt-4o',
default => $this->azure->chatDeployment(),
'gpu' => 'GPU (cuttlefish)',
'dbn_legal' => 'dbn-legal-agent-v2',
'dbn_legal_v3' => 'dbn-legal-agent-v3',
'azure_full' => 'gpt-4o',
default => $this->azure->chatDeployment(),
},
];
}
@@ -1121,7 +1122,11 @@ PROMPT;
$opts = ['json' => true, 'temperature' => $synthTemp, 'max_tokens' => 4000, 'timeout' => 180];
try {
if ($engine === 'dbn_legal') {
if ($engine === 'dbn_legal_v3') {
$response = dbnToolsCallGpuLlm($messages, array_merge($opts, ['model' => 'dbn-legal-agent-v3', 'timeout' => 180]));
$deployLabel = 'dbn-legal-agent-v3';
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
} elseif ($engine === 'dbn_legal') {
$response = dbnToolsCallGpuLlm($messages, array_merge($opts, ['model' => 'dbn-legal-agent-v2', 'timeout' => 180]));
$deployLabel = 'dbn-legal-agent-v2';
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
+8 -4
View File
@@ -754,9 +754,10 @@ PROMPT;
$locale = dbnToolsLanguageName($language);
$sourceCount = count($numberedSources);
$deployLabel = match ($engine) {
'gpu' => 'GPU (cuttlefish)',
'azure_full' => 'gpt-4o',
default => $this->azure->chatDeployment(),
'gpu' => 'GPU (cuttlefish)',
'dbn_legal_v3' => 'dbn-legal-agent-v3',
'azure_full' => 'gpt-4o',
default => $this->azure->chatDeployment(),
};
if (empty($numberedSources)) {
@@ -863,7 +864,10 @@ PROMPT;
$raw = '';
try {
if ($engine === 'gpu') {
if ($engine === 'dbn_legal_v3') {
$response = dbnToolsCallGpuLlm($messages, array_merge($opts, ['model' => 'dbn-legal-agent-v3', 'timeout' => 180]));
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
} elseif ($engine === 'gpu') {
$response = dbnToolsCallGpuLlm($messages, $opts);
$raw = (string)($response['choices'][0]['message']['content'] ?? '');
} elseif ($engine === 'azure_full') {
+259 -55
View File
@@ -2,18 +2,22 @@
declare(strict_types=1);
/**
* Credit system for free-tier (Google-authenticated) users of tools.dobetternorge.no.
* Credit + tier system for users of tools.dobetternorge.no.
*
* Credits are stored in dobetternorge_maindb.user_tool_credits.
* Usage is logged in user_tool_usage_log.
* Tables:
* user_tool_credits — balance (monthly, resets), bonus_balance (never expires), tier, Stripe links
* user_tool_usage_log — every tool call with credits_used
* user_subscriptions — Stripe subscription ledger
*
* Effective balance = balance + bonus_balance.
* Spend order: deduct from balance first, overflow to bonus_balance.
* pro_plus tier bypasses balance checks (still subject to hourly cap).
*
* CaveauAI client sessions (dbn_tools_user_id + client_id) bypass all checks.
* Only SSO sessions with tier='free' are subject to limits.
* Only SSO sessions are subject to limits.
*/
final class FreeTier
{
private const HOURLY_LIMIT = 10;
private const COSTS = [
'corpus-search' => 0,
'search' => 0,
@@ -24,8 +28,33 @@ final class FreeTier
'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
'transcribe' => 2,
'discrepancy' => 4,
'korrespond' => 3,
];
/** Monthly credit allowance per tier. pro_plus is "effectively unlimited" but hourly-capped. */
private const MONTHLY_ALLOWANCE = [
'free' => 30,
'light' => 120,
'pro' => 500,
'pro_plus' => 999999,
];
/** Hourly rate-limit per tier (number of paid tool calls per rolling hour). */
private const HOURLY_CAP = [
'free' => 10,
'light' => 15,
'pro' => 30,
'pro_plus' => 50,
];
/** Per-user case-storage quota in bytes. */
private const STORAGE_QUOTA = [
'free' => 0,
'light' => 104857600, // 100 MB
'pro' => 1073741824, // 1 GB
'pro_plus' => 10737418240, // 10 GB
];
/** Credit cost for a given tool slug. Returns 1 for unknown tools. */
@@ -34,94 +63,269 @@ final class FreeTier
return self::COSTS[$tool] ?? 1;
}
public static function monthlyAllowance(string $tier): int
{
return self::MONTHLY_ALLOWANCE[$tier] ?? self::MONTHLY_ALLOWANCE['free'];
}
public static function hourlyCap(string $tier): int
{
return self::HOURLY_CAP[$tier] ?? self::HOURLY_CAP['free'];
}
public static function storageQuota(string $tier): int
{
return self::STORAGE_QUOTA[$tier] ?? 0;
}
/** Fetch a user's tier (defaults to 'free' if no row). */
public static function tier(int $userId): string
{
$row = self::row($userId);
return $row['tier'] ?? 'free';
}
/** Fetch the full credits row, applying lazy monthly reset. */
public static function row(int $userId): ?array
{
$db = dbnmDb();
// Auto-refill monthly balance based on tier-specific allowance, only if a new calendar month has begun.
$db->prepare(
"UPDATE user_tool_credits
SET balance = CASE tier
WHEN 'free' THEN " . self::MONTHLY_ALLOWANCE['free'] . "
WHEN 'light' THEN " . self::MONTHLY_ALLOWANCE['light'] . "
WHEN 'pro' THEN " . self::MONTHLY_ALLOWANCE['pro'] . "
WHEN 'pro_plus' THEN " . self::MONTHLY_ALLOWANCE['pro_plus'] . "
ELSE balance END,
last_reset = CURDATE()
WHERE user_id = ?
AND (YEAR(last_reset) < YEAR(CURDATE()) OR MONTH(last_reset) < MONTH(CURDATE()))"
)->execute([$userId]);
$stmt = $db->prepare('SELECT * FROM user_tool_credits WHERE user_id = ? LIMIT 1');
$stmt->execute([$userId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
/**
* 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']
* Returns:
* ['ok' => true, 'balance' => int, 'bonus_balance' => int, 'tier' => string]
* ['ok' => false, 'balance' => int, 'bonus_balance' => int, 'tier' => string,
* 'reason' => 'no_credits'|'rate_limit']
*/
public static function check(int $userId, string $tool): array
{
$db = dbnmDb();
$cost = self::cost($tool);
$row = self::row($userId);
// 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'];
if ($row === null) {
return [
'ok' => false, 'balance' => 0, 'bonus_balance' => 0,
'tier' => 'free', 'reason' => 'no_credits',
];
}
$balance = (int)$credits['balance'];
$balance = (int)$row['balance'];
$bonus = (int)$row['bonus_balance'];
$tier = (string)$row['tier'];
// Free tools always pass
if ($cost === 0) {
return ['ok' => true, 'balance' => $balance];
}
$base = [
'balance' => $balance,
'bonus_balance' => $bonus,
'tier' => $tier,
];
if ($balance < $cost) {
return ['ok' => false, 'balance' => $balance, 'reason' => 'no_credits'];
}
// Hourly rate limit check (counts any tool that costs > 0)
$hourlyCount = $db->prepare(
// Hourly rate limit (always applies, even to pro_plus)
$stmt = $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'];
$stmt->execute([$userId]);
$hourly = (int)$stmt->fetchColumn();
if ($hourly >= self::hourlyCap($tier)) {
return $base + ['ok' => false, 'reason' => 'rate_limit'];
}
return ['ok' => true, 'balance' => $balance];
// Free tool (cost=0) always passes credit check
if ($cost === 0) {
return $base + ['ok' => true];
}
// pro_plus bypasses credit check
if ($tier === 'pro_plus') {
return $base + ['ok' => true];
}
if (($balance + $bonus) < $cost) {
return $base + ['ok' => false, 'reason' => 'no_credits'];
}
return $base + ['ok' => true];
}
/**
* 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).
* Spends from `balance` first, then `bonus_balance`.
* pro_plus tier logs the call but does not deduct.
*
* Returns the new effective balance (balance + bonus_balance).
*/
public static function deduct(int $userId, string $tool): int
{
$db = dbnmDb();
$cost = self::cost($tool);
$row = self::row($userId);
$tier = $row['tier'] ?? 'free';
if ($cost > 0 && $tier !== 'pro_plus' && $row !== null) {
$balance = (int)$row['balance'];
$bonus = (int)$row['bonus_balance'];
$fromBalance = min($cost, $balance);
$fromBonus = $cost - $fromBalance;
if ($cost > 0) {
$db->prepare(
'UPDATE user_tool_credits
SET balance = GREATEST(0, balance - ?)
SET balance = GREATEST(0, balance - ?),
bonus_balance = GREATEST(0, bonus_balance - ?)
WHERE user_id = ?'
)->execute([$cost, $userId]);
)->execute([$fromBalance, $fromBonus, $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;
$latest = self::row($userId);
return $latest ? ((int)$latest['balance'] + (int)$latest['bonus_balance']) : 0;
}
/** Effective balance (monthly + bonus). */
public static function balance(int $userId): int
{
$row = self::row($userId);
return $row ? ((int)$row['balance'] + (int)$row['bonus_balance']) : 0;
}
/** Detailed balance breakdown for UI rendering. */
public static function balanceDetail(int $userId): array
{
$row = self::row($userId);
if (!$row) {
return ['balance' => 0, 'bonus_balance' => 0, 'tier' => 'free'];
}
return [
'balance' => (int)$row['balance'],
'bonus_balance' => (int)$row['bonus_balance'],
'tier' => (string)$row['tier'],
'storage_used_bytes' => (int)($row['storage_used_bytes'] ?? 0),
'storage_quota_bytes' => self::storageQuota((string)$row['tier']),
'survey_completed_at' => $row['survey_completed_at'] ?? null,
'subscription_period_end' => $row['subscription_period_end'] ?? null,
];
}
/**
* Current balance for a user (after any pending monthly reset).
* Award one-time bonus credits (survey reward, Stripe topup, manual grant).
* Source is logged via user_tool_usage_log with a negative credits_used value.
*/
public static function balance(int $userId): int
public static function awardBonus(int $userId, int $credits, string $source): int
{
$result = self::check($userId, 'corpus-search'); // cost=0, triggers reset if needed
return $result['balance'];
if ($credits <= 0) {
return self::balance($userId);
}
$db = dbnmDb();
self::ensureRow($userId);
$db->prepare('UPDATE user_tool_credits SET bonus_balance = bonus_balance + ? WHERE user_id = ?')
->execute([$credits, $userId]);
$db->prepare('INSERT INTO user_tool_usage_log (user_id, tool, credits_used) VALUES (?, ?, ?)')
->execute([$userId, 'bonus:' . substr($source, 0, 40), -$credits]);
return self::balance($userId);
}
/**
* Set or upgrade a user's tier (called by Stripe subscription webhook).
* Refills monthly balance to the new tier's allowance.
*/
public static function setTier(
int $userId,
string $tier,
?string $stripeCustomerId,
?string $subscriptionId,
?string $periodEndIso
): void {
$db = dbnmDb();
self::ensureRow($userId);
$allowance = self::monthlyAllowance($tier);
$db->prepare(
'UPDATE user_tool_credits
SET tier = ?, balance = ?, allowance = ?,
stripe_customer_id = COALESCE(?, stripe_customer_id),
subscription_id = ?,
subscription_period_end = ?,
last_reset = CURDATE()
WHERE user_id = ?'
)->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]);
}
/**
* Refill monthly balance at subscription renewal (invoice.paid).
* Does not touch bonus_balance.
*/
public static function refillForRenewal(int $userId, string $tier, ?string $periodEndIso): void
{
$db = dbnmDb();
$allowance = self::monthlyAllowance($tier);
$db->prepare(
'UPDATE user_tool_credits
SET balance = ?,
subscription_period_end = ?,
last_reset = CURDATE()
WHERE user_id = ?'
)->execute([$allowance, $periodEndIso, $userId]);
}
/**
* Revert a user to free tier (subscription canceled or fully ended).
* Preserves bonus_balance and case_documents (handled by 90-day cron).
*/
public static function clearTier(int $userId): void
{
$db = dbnmDb();
$db->prepare(
'UPDATE user_tool_credits
SET tier = ?, allowance = ?, subscription_id = NULL, subscription_period_end = NULL
WHERE user_id = ?'
)->execute(['free', self::monthlyAllowance('free'), $userId]);
}
/** Mark survey as completed so the bonus can only be claimed once per account. */
public static function markSurveyCompleted(int $userId): void
{
$db = dbnmDb();
self::ensureRow($userId);
$db->prepare('UPDATE user_tool_credits SET survey_completed_at = NOW() WHERE user_id = ?')
->execute([$userId]);
}
public static function hasCompletedSurvey(int $userId): bool
{
$row = self::row($userId);
return !empty($row['survey_completed_at']);
}
/** Create the user_tool_credits row if missing (idempotent). */
public static function ensureRow(int $userId): void
{
$db = dbnmDb();
$db->prepare(
'INSERT IGNORE INTO user_tool_credits (user_id, balance, allowance, tier, last_reset, created_at)
VALUES (?, ?, ?, ?, CURDATE(), NOW())'
)->execute([$userId, self::monthlyAllowance('free'), self::monthlyAllowance('free'), 'free']);
}
}
+12
View File
@@ -330,6 +330,18 @@ PROMPT;
$parts[] = ' • ' . $key . ': ' . $value;
}
}
// Inject Min Sak (private case corpus) hits if user opted in and is on a paid tier.
if (!empty($intake['use_my_case'])) {
$caseQuery = trim((string)($intake['goal'] ?? '') . ' ' . (string)($intake['narrative'] ?? ''));
if ($caseQuery !== '') {
$caseBlock = dbnToolsCaseContext(true, $caseQuery, 5);
if ($caseBlock !== '') {
$parts[] = $caseBlock;
}
}
}
return mb_substr(implode("\n\n", $parts), 0, self::MAX_CONTEXT_CHARS, 'UTF-8');
}
+234
View File
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
/**
* Thin Stripe API wrapper — no SDK, pure curl + HMAC.
*
* Configuration is loaded from /etc/bnl/stripe.php (production) or env vars (local).
* Required keys:
* STRIPE_SECRET_KEY sk_live_... or sk_test_...
* STRIPE_PUBLISHABLE_KEY pk_live_... or pk_test_...
* STRIPE_WEBHOOK_SECRET whsec_...
* STRIPE_PRICE_TOPUP_S / _M / _L
* STRIPE_PRICE_LIGHT / _PRO / _PRO_PLUS
*/
final class StripeClient
{
private const API_BASE = 'https://api.stripe.com/v1';
private const TIMEOUT = 30;
private string $secretKey;
public function __construct(?string $secretKey = null)
{
$this->secretKey = $secretKey ?? self::config('STRIPE_SECRET_KEY');
if ($this->secretKey === '') {
throw new RuntimeException('StripeClient: STRIPE_SECRET_KEY not configured.');
}
}
/** Load a Stripe config value from /etc/bnl/stripe.php OR env. */
public static function config(string $key): string
{
static $fileConfig = null;
if ($fileConfig === null) {
$path = '/etc/bnl/stripe.php';
$fileConfig = is_readable($path) ? (require $path) : [];
if (!is_array($fileConfig)) {
$fileConfig = [];
}
}
$envValue = getenv($key);
if ($envValue !== false && $envValue !== '') {
return $envValue;
}
return (string)($fileConfig[$key] ?? '');
}
/** Map an internal SKU to a Stripe price ID. */
public static function priceId(string $sku): string
{
static $map = null;
if ($map === null) {
$map = [
'topup_s' => self::config('STRIPE_PRICE_TOPUP_S'),
'topup_m' => self::config('STRIPE_PRICE_TOPUP_M'),
'topup_l' => self::config('STRIPE_PRICE_TOPUP_L'),
'light' => self::config('STRIPE_PRICE_LIGHT'),
'pro' => self::config('STRIPE_PRICE_PRO'),
'pro_plus' => self::config('STRIPE_PRICE_PRO_PLUS'),
];
}
$id = $map[$sku] ?? '';
if ($id === '') {
throw new InvalidArgumentException("Unknown Stripe SKU: {$sku}");
}
return $id;
}
/** Topup credit grants — must match values shown on pricing.php. */
public static function topupCredits(string $sku): int
{
return match ($sku) {
'topup_s' => 30,
'topup_m' => 100,
'topup_l' => 300,
default => 0,
};
}
/** Map a Stripe price ID back to the internal subscription tier (light/pro/pro_plus). */
public static function tierForPrice(string $priceId): ?string
{
foreach (['light', 'pro', 'pro_plus'] as $tier) {
if (self::config('STRIPE_PRICE_' . strtoupper($tier)) === $priceId) {
return $tier;
}
}
return null;
}
/**
* Create a Checkout Session.
*
* @param array $params Stripe parameters (flat form-encoded — see request() docs).
*/
public function createCheckoutSession(array $params): array
{
return $this->request('POST', '/checkout/sessions', $params);
}
/** Create a Customer Portal session for self-serve subscription management. */
public function createPortalSession(string $customerId, string $returnUrl): array
{
return $this->request('POST', '/billing_portal/sessions', [
'customer' => $customerId,
'return_url' => $returnUrl,
]);
}
/** Retrieve a subscription. */
public function getSubscription(string $subscriptionId): array
{
return $this->request('GET', '/subscriptions/' . urlencode($subscriptionId));
}
/** Find-or-create a Stripe customer for a given email. */
public function ensureCustomer(string $email, ?int $userId = null): string
{
$found = $this->request('GET', '/customers', ['email' => $email, 'limit' => 1]);
if (!empty($found['data'][0]['id'])) {
return (string)$found['data'][0]['id'];
}
$params = ['email' => $email];
if ($userId !== null) {
$params['metadata'] = ['user_id' => (string)$userId];
}
$created = $this->request('POST', '/customers', $params);
return (string)($created['id'] ?? '');
}
/**
* Verify a Stripe webhook signature.
* Stripe-Signature header format: t=<timestamp>,v1=<signature>[,v1=<signature>...]
*/
public static function verifyWebhookSignature(string $payload, string $sigHeader, string $secret, int $toleranceSeconds = 300): bool
{
if ($secret === '' || $sigHeader === '') {
return false;
}
$parts = [];
foreach (explode(',', $sigHeader) as $pair) {
$kv = explode('=', $pair, 2);
if (count($kv) === 2) {
$parts[trim($kv[0])][] = trim($kv[1]);
}
}
$timestamp = isset($parts['t'][0]) ? (int)$parts['t'][0] : 0;
$sigs = $parts['v1'] ?? [];
if ($timestamp === 0 || empty($sigs)) {
return false;
}
if (abs(time() - $timestamp) > $toleranceSeconds) {
return false;
}
$signedPayload = $timestamp . '.' . $payload;
$expected = hash_hmac('sha256', $signedPayload, $secret);
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) {
return true;
}
}
return false;
}
/**
* Low-level HTTP request to Stripe API. Returns decoded JSON or throws on error.
* Stripe uses form-encoded bodies even for nested params (foo[bar]=baz).
*/
public function request(string $method, string $path, array $params = []): array
{
$url = self::API_BASE . $path;
$method = strtoupper($method);
$headers = [
'Authorization: Bearer ' . $this->secretKey,
'Stripe-Version: 2024-10-28.acacia',
];
$ch = curl_init();
$body = '';
if ($method === 'GET' && !empty($params)) {
$url .= '?' . self::flattenFormParams($params);
} elseif (!empty($params)) {
$body = self::flattenFormParams($params);
$headers[] = 'Content-Type: application/x-www-form-urlencoded';
}
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
if ($body !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno !== 0 || $raw === false) {
throw new RuntimeException('Stripe curl error: ' . curl_strerror($errno));
}
$decoded = json_decode((string)$raw, true);
if (!is_array($decoded)) {
throw new RuntimeException('Stripe response was not JSON: ' . substr((string)$raw, 0, 200));
}
if ($status >= 400) {
$msg = $decoded['error']['message'] ?? 'Unknown Stripe error';
$code = $decoded['error']['code'] ?? 'unknown';
throw new RuntimeException("Stripe API error ({$status}/{$code}): {$msg}");
}
return $decoded;
}
/** Flatten nested arrays into Stripe's form-encoding scheme (foo[bar]=baz). */
private static function flattenFormParams(array $params, string $prefix = ''): string
{
$pairs = [];
foreach ($params as $key => $value) {
$name = $prefix === '' ? (string)$key : $prefix . '[' . $key . ']';
if (is_array($value)) {
$pairs[] = self::flattenFormParams($value, $name);
} elseif (is_bool($value)) {
$pairs[] = rawurlencode($name) . '=' . ($value ? 'true' : 'false');
} elseif ($value !== null) {
$pairs[] = rawurlencode($name) . '=' . rawurlencode((string)$value);
}
}
return implode('&', $pairs);
}
}
+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',
]];
}
+228
View File
@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
require_once __DIR__ . '/includes/FreeTier.php';
require_once __DIR__ . '/includes/CaseStore.php';
if (!dbnToolsIsAuthenticated()) {
header('Location: /?return=' . urlencode('/min-sak.php'));
exit;
}
$uiLang = dbnToolsCurrentLanguage();
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
if ($userId <= 0) {
header('Location: /dashboard.php');
exit;
}
$detail = FreeTier::balanceDetail($userId);
$tier = (string)$detail['tier'];
// Free tier: show upgrade gate
if (!in_array($tier, ['light', 'pro', 'pro_plus'], true)) {
require_once __DIR__ . '/includes/footer.php';
$upgradeUrl = '/pricing.php';
?><!doctype html>
<html lang="<?= htmlspecialchars($uiLang) ?>">
<head><meta charset="utf-8"><title>Min Sak — Do Better Norge</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
<link rel="stylesheet" href="assets/css/tools.css">
</head><body><main style="max-width:720px;margin:4rem auto;padding:0 1.5rem;text-align:center;font-family:'IBM Plex Sans',sans-serif;">
<h1 style="font-family:'Crimson Pro',serif;font-size:2.4rem;margin:0 0 0.5rem;color:#00205B;">Min Sak — bygg din egen sak</h1>
<p style="color:#4b5563;font-size:1.1rem;margin-bottom:2rem;">Last opp dokumentene fra saken din én gang, og la <strong>alle</strong> verktøyene jobbe på din private korpus.</p>
<ul style="text-align:left;display:inline-block;line-height:1.9;color:#1f2937;">
<li>📄 Privat dokumentbank med OCR</li>
<li>🔍 Hybrid søk (BM25 + vektor) i din sak</li>
<li>🧠 Alle verktøy kan referere til din egen sak</li>
<li>🇪🇺 Alt lagres i EU (Tyskland/Finland/Norge)</li>
</ul>
<p style="margin:2rem 0 0;"><a href="<?= htmlspecialchars($upgradeUrl) ?>" style="background:#00205B;color:#fff;padding:1rem 2rem;border-radius:8px;font-weight:700;text-decoration:none;display:inline-block;">Se planer fra €9/mo</a></p>
</main></body></html><?php
exit;
}
$docs = CaseStore::listDocs($userId);
$used = (int)$detail['storage_used_bytes'];
$quota = (int)$detail['storage_quota_bytes'];
$usedMb = round($used / 1048576, 1);
$quotaMb = round($quota / 1048576, 0);
$pct = $quota > 0 ? min(100, round(($used / $quota) * 100)) : 0;
?>
<!doctype html>
<html lang="<?= htmlspecialchars($uiLang) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Min Sak — tools.dobetternorge.no</title>
<meta name="robots" content="noindex, nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="assets/css/tools.css">
<style>
.ms-shell { max-width: 980px; margin: 0 auto; padding: 2rem 1.5rem 4rem; font-family: 'IBM Plex Sans', sans-serif; }
.ms-shell h1 { font-family: 'Crimson Pro', serif; margin: 0; }
.ms-shell .lede { color: #6b7280; margin: 0.25rem 0 2rem; }
.ms-status { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem; }
@media (max-width: 640px) { .ms-status { grid-template-columns: 1fr; } }
.ms-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 1.25rem 1.5rem; }
.ms-card h2 { margin: 0 0 0.4rem; font-size: 1rem; color: #374151; text-transform: uppercase; letter-spacing: 0.05em; }
.ms-card .big { font-size: 1.6rem; font-weight: 700; color: #00205B; }
.ms-storage-bar { background: #f3f4f6; height: 10px; border-radius: 999px; overflow: hidden; margin: 0.6rem 0; }
.ms-storage-bar > div { background: linear-gradient(90deg, #00205B, #003478); height: 100%; }
.ms-upload { background: #fff; border: 2px dashed #cbd5e1; border-radius: 12px; padding: 2.5rem 1.5rem; text-align: center; cursor: pointer; transition: all 0.15s; margin-bottom: 2rem; }
.ms-upload:hover, .ms-upload.is-drag { border-color: #00205B; background: #f8fafc; }
.ms-upload input { display: none; }
.ms-upload-icon { font-size: 2.4rem; line-height: 1; margin-bottom: 0.5rem; }
.ms-upload p { margin: 0.25rem 0; color: #374151; }
.ms-upload .hint { color: #6b7280; font-size: 0.85rem; }
.ms-docs { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; }
.ms-doc { padding: 1rem 1.25rem; border-bottom: 1px solid #f3f4f6; display: flex; align-items: center; gap: 1rem; }
.ms-doc:last-child { border-bottom: none; }
.ms-doc-info { flex: 1; min-width: 0; }
.ms-doc-name { font-weight: 600; color: #1f2937; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ms-doc-meta { font-size: 0.85rem; color: #6b7280; }
.ms-status-pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 0.78rem; font-weight: 600; }
.ms-status-pending { background: #fef3c7; color: #92400e; }
.ms-status-running { background: #ddd6fe; color: #5b21b6; }
.ms-status-ready { background: #d1fae5; color: #065f46; }
.ms-status-failed { background: #fee2e2; color: #991b1b; }
.ms-doc-actions button { background: #f3f4f6; border: none; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
.ms-doc-actions button:hover { background: #fee2e2; color: #991b1b; }
.ms-empty { padding: 2rem 1.5rem; text-align: center; color: #6b7280; }
</style>
</head>
<body>
<main class="ms-shell">
<p style="margin:0 0 0.25rem;color:#6b7280;font-size:0.85rem;text-transform:uppercase;letter-spacing:0.06em;">
<a href="/dashboard.php" style="color:inherit;">← Dashbord</a> · <a href="/billing.php" style="color:inherit;">Fakturering</a>
</p>
<h1>Min Sak</h1>
<p class="lede">Last opp dokumentene dine én gang. Alle verktøyene kan deretter referere til din private sak når du krysser av "Bruk min sak som kontekst".</p>
<section class="ms-status">
<div class="ms-card">
<h2>Lagring</h2>
<div class="big"><?= $usedMb ?> MB <span style="color:#6b7280;font-size:1rem;">/ <?= $quotaMb ?> MB</span></div>
<div class="ms-storage-bar"><div style="width: <?= $pct ?>%;"></div></div>
<p style="margin:0;color:#6b7280;font-size:0.85rem;"><?= count($docs) ?> dokumenter</p>
</div>
<div class="ms-card">
<h2>Plan</h2>
<div class="big" style="font-size:1.3rem;">
<?= htmlspecialchars(['light'=>'Light','pro'=>'Pro','pro_plus'=>'Pro+ Familie'][$tier] ?? $tier) ?>
</div>
<p style="margin:0;color:#6b7280;font-size:0.85rem;">
<a href="/pricing.php">Oppgrader</a> · <a href="/billing.php">Administrer</a>
</p>
</div>
</section>
<label for="msFileInput" class="ms-upload" id="msUploadZone">
<input id="msFileInput" type="file" accept="application/pdf" multiple>
<div class="ms-upload-icon">📤</div>
<p><strong>Slipp PDF-filer her, eller klikk for å bla</strong></p>
<p class="hint">Maks 25 MB per fil · OCR kjøres automatisk · alt lagres kryptert i EU</p>
</label>
<div id="msFlash" role="status" aria-live="polite" style="margin-bottom:1rem;"></div>
<section class="ms-docs" aria-label="Dine dokumenter">
<?php if (empty($docs)): ?>
<p class="ms-empty">Ingen dokumenter ennå. Last opp din første PDF over.</p>
<?php else: foreach ($docs as $d): ?>
<div class="ms-doc" data-doc-id="<?= (int)$d['id'] ?>">
<div style="font-size:1.6rem;line-height:1;">📄</div>
<div class="ms-doc-info">
<div class="ms-doc-name"><?= htmlspecialchars($d['filename']) ?></div>
<div class="ms-doc-meta">
<?= round((int)$d['size_bytes'] / 1024, 0) ?> KB
<?php if (!empty($d['page_count'])): ?> · <?= (int)$d['page_count'] ?> sider<?php endif; ?>
<?php if (!empty($d['doc_type'])): ?> · <?= htmlspecialchars($d['doc_type']) ?><?php endif; ?>
· lastet opp <?= htmlspecialchars(date('j. F Y', strtotime((string)$d['uploaded_at']))) ?>
</div>
</div>
<span class="ms-status-pill ms-status-<?= htmlspecialchars($d['ocr_status']) ?>">
<?php
echo htmlspecialchars(['pending'=>'I kø','running'=>'OCR pågår','ready'=>'Klar','failed'=>'Feilet'][$d['ocr_status']] ?? $d['ocr_status']);
?>
</span>
<div class="ms-doc-actions">
<button type="button" class="ms-delete" data-id="<?= (int)$d['id'] ?>">Slett</button>
</div>
</div>
<?php endforeach; endif; ?>
</section>
</main>
<script>
(function() {
const fileInput = document.getElementById('msFileInput');
const dropZone = document.getElementById('msUploadZone');
const flash = document.getElementById('msFlash');
function showFlash(msg, isError) {
flash.style.cssText = 'padding:0.75rem 1rem;border-radius:6px;font-size:0.9rem;'
+ (isError ? 'background:#fee2e2;color:#991b1b;' : 'background:#d1fae5;color:#065f46;');
flash.textContent = msg;
}
async function uploadFile(file) {
if (file.size > 25 * 1024 * 1024) {
showFlash(file.name + ' er over 25 MB — del opp filen først.', true);
return;
}
const data = new FormData();
data.append('file', file);
try {
const res = await fetch('/api/case/upload.php', { method: 'POST', body: data });
const json = await res.json();
if (json.ok) {
showFlash('Lastet opp: ' + file.name + '. OCR starter automatisk.', false);
setTimeout(() => window.location.reload(), 1200);
} else {
showFlash(file.name + ': ' + (json.error?.message || 'Ukjent feil'), true);
}
} catch (e) {
showFlash('Nettverksfeil: ' + e.message, true);
}
}
fileInput.addEventListener('change', () => {
for (const f of fileInput.files) uploadFile(f);
});
['dragenter','dragover'].forEach(ev => dropZone.addEventListener(ev, e => {
e.preventDefault(); dropZone.classList.add('is-drag');
}));
['dragleave','drop'].forEach(ev => dropZone.addEventListener(ev, e => {
e.preventDefault(); dropZone.classList.remove('is-drag');
}));
dropZone.addEventListener('drop', e => {
for (const f of e.dataTransfer.files) {
if (f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')) uploadFile(f);
}
});
document.querySelectorAll('.ms-delete').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Slette dette dokumentet for godt?')) return;
const id = btn.getAttribute('data-id');
try {
const res = await fetch('/api/case/delete.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doc_id: parseInt(id, 10) }),
});
const json = await res.json();
if (json.ok) window.location.reload();
else alert(json.error?.message || 'Sletting feilet.');
} catch (e) { alert('Nettverksfeil: ' + e.message); }
});
});
})();
</script>
</body>
</html>
+308
View File
@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
require_once __DIR__ . '/includes/FreeTier.php';
$uiLang = dbnToolsCurrentLanguage();
$isAuthed = dbnToolsIsAuthenticated();
$currentTier = $isAuthed ? dbnToolsCurrentTier() : 'free';
$surveyDone = false;
if ($isAuthed && dbnToolsIsFreeTier()) {
$surveyDone = FreeTier::hasCompletedSurvey((int)$_SESSION['dbn_tools_sso_uid']);
}
$status = (string)($_GET['status'] ?? '');
$loginUrl = 'https://dobetternorge.no/tools-login.php?return=' . urlencode('/pricing.php');
$surveyUrl = 'https://dobetternorge.no/survey.php';
$tiers = [
[
'sku' => 'free',
'name' => 'Gratis',
'price' => '€0',
'period' => 'alltid',
'credits' => '30 kreditter / måned',
'storage' => 'Ingen sak-lagring',
'seats' => '1 plass',
'cap' => '10 verktøy/time',
'features' => [
'Tilgang til alle 13 verktøy',
'Spørsmål, søk, redaksjon',
'Korrespondanse-utkast',
],
'cta' => $isAuthed ? null : 'Logg inn for å starte',
'highlight' => false,
],
[
'sku' => 'light',
'name' => 'Light',
'price' => '€9',
'period' => '/ måned',
'credits' => '120 kreditter / måned',
'storage' => '100 MB sak-lagring',
'seats' => '1 plass',
'cap' => '15 verktøy/time',
'features' => [
'Alt i Gratis',
'Bygg din egen sak (Min Sak)',
'Privat dokument-RAG i alle verktøy',
'OCR på opplastede PDF-er',
],
'highlight' => false,
],
[
'sku' => 'pro',
'name' => 'Pro',
'price' => '€29',
'period' => '/ måned',
'credits' => '500 kreditter / måned',
'storage' => '1 GB sak-lagring',
'seats' => '1 plass',
'cap' => '30 verktøy/time',
'features' => [
'Alt i Light',
'Hybrid søk (BM25 + vektor) i din sak',
'Prioritert kø ved opplasting',
'Tidslinje-rapport på saken din',
],
'highlight' => true,
'badge' => 'Mest populær',
],
[
'sku' => 'pro_plus',
'name' => 'Pro+ Familie',
'price' => '€79',
'period' => '/ måned',
'credits' => 'Ubegrenset',
'storage' => '10 GB sak-lagring',
'seats' => '3 plasser (familie)',
'cap' => '50 verktøy/time per plass',
'features' => [
'Alt i Pro',
'Inviter 2 familiemedlemmer eller advokat',
'Delt sak-arkiv med revisjonslogg',
'Ubegrensede saksrapporter',
],
'highlight' => false,
'badge' => 'For familier',
],
];
$topups = [
['sku' => 'topup_s', 'price' => '€5', 'credits' => 30, 'note' => 'Impulskjøp'],
['sku' => 'topup_m', 'price' => '€15', 'credits' => 100, 'note' => 'Beste verdi'],
['sku' => 'topup_l', 'price' => '€40', 'credits' => 300, 'note' => 'Tunge brukere'],
];
?>
<!doctype html>
<html lang="<?= htmlspecialchars($uiLang) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Priser — Do Better Norge verktøy</title>
<meta name="description" content="Priser for tools.dobetternorge.no: gratis tier, abonnementer og kreditt-topp-opp. Bygg din egen sak med privat RAG.">
<link rel="canonical" href="https://tools.dobetternorge.no/pricing.php">
<meta name="theme-color" content="#00205B">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="assets/css/tools.css">
<style>
.pricing-shell { max-width: 1200px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
.pricing-hero { text-align: center; margin-bottom: 3rem; }
.pricing-hero h1 { font-family: 'Crimson Pro', serif; font-size: 2.5rem; margin: 0 0 0.75rem; }
.pricing-hero p { color: #4b5563; font-size: 1.1rem; max-width: 640px; margin: 0 auto; }
.pricing-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1.25rem; margin-bottom: 3rem; }
.pricing-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 1.75rem 1.5rem; display: flex; flex-direction: column; position: relative; }
.pricing-card.is-highlight { border-color: #00205B; border-width: 2px; box-shadow: 0 8px 24px rgba(0,32,91,0.08); }
.pricing-card .pricing-badge { position: absolute; top: -10px; right: 14px; background: #00205B; color: #fff; padding: 4px 10px; font-size: 0.72rem; border-radius: 999px; letter-spacing: 0.04em; text-transform: uppercase; font-weight: 600; }
.pricing-card h2 { margin: 0 0 0.25rem; font-size: 1.4rem; font-family: 'Crimson Pro', serif; }
.pricing-price { display: flex; align-items: baseline; gap: 0.4rem; margin: 0.5rem 0 1rem; }
.pricing-price .amount { font-size: 2.2rem; font-weight: 700; color: #00205B; }
.pricing-price .period { color: #6b7280; font-size: 0.95rem; }
.pricing-meta { margin: 0 0 1.25rem; padding: 0; list-style: none; font-size: 0.92rem; color: #374151; }
.pricing-meta li { padding: 6px 0; border-bottom: 1px dashed #f3f4f6; }
.pricing-meta li:last-child { border-bottom: none; }
.pricing-features { list-style: none; padding: 0; margin: 0 0 1.5rem; flex: 1; }
.pricing-features li { padding: 5px 0 5px 1.4rem; position: relative; font-size: 0.92rem; color: #1f2937; }
.pricing-features li::before { content: "✓"; position: absolute; left: 0; color: #059669; font-weight: 700; }
.pricing-cta { display: block; text-align: center; padding: 0.75rem 1rem; border-radius: 8px; font-weight: 600; text-decoration: none; transition: all 0.15s; cursor: pointer; border: none; font-size: 0.95rem; }
.pricing-cta.primary { background: #00205B; color: #fff; }
.pricing-cta.primary:hover { background: #001740; }
.pricing-cta.secondary { background: #f3f4f6; color: #1f2937; }
.pricing-cta.secondary:hover { background: #e5e7eb; }
.pricing-cta.current { background: #d1fae5; color: #065f46; cursor: default; }
.pricing-topups { margin-top: 2rem; padding: 2rem 1.5rem; background: #f9fafb; border-radius: 12px; }
.pricing-topups h2 { font-family: 'Crimson Pro', serif; margin: 0 0 0.5rem; font-size: 1.6rem; }
.pricing-topups p.lead { color: #6b7280; margin: 0 0 1.5rem; }
.topup-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
.topup-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 1.25rem; text-align: center; }
.topup-card .price { font-size: 1.6rem; font-weight: 700; color: #00205B; }
.topup-card .credits { color: #374151; font-size: 0.95rem; margin: 0.25rem 0 0.5rem; }
.topup-card .note { color: #6b7280; font-size: 0.82rem; margin-bottom: 0.75rem; }
.survey-banner { background: linear-gradient(135deg, #00205B, #003478); color: #fff; padding: 1.75rem 1.5rem; border-radius: 12px; margin-bottom: 2rem; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; }
.survey-banner .copy { flex: 1; min-width: 260px; }
.survey-banner h3 { margin: 0 0 0.35rem; font-size: 1.3rem; font-family: 'Crimson Pro', serif; }
.survey-banner p { margin: 0; opacity: 0.9; font-size: 0.95rem; }
.survey-banner a { background: #ffd166; color: #00205B; padding: 0.7rem 1.4rem; border-radius: 8px; font-weight: 700; text-decoration: none; white-space: nowrap; }
.pricing-faq { margin-top: 3rem; }
.pricing-faq details { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 0.6rem; }
.pricing-faq summary { font-weight: 600; cursor: pointer; }
.pricing-faq p { color: #4b5563; margin: 0.75rem 0 0; font-size: 0.92rem; }
.status-pill-info { display: inline-block; margin-bottom: 1.5rem; padding: 6px 12px; background: #fef3c7; color: #92400e; border-radius: 6px; font-size: 0.9rem; }
.status-pill-success { background: #d1fae5; color: #065f46; }
.status-pill-error { background: #fee2e2; color: #991b1b; }
</style>
</head>
<body>
<main class="pricing-shell">
<header class="pricing-hero">
<p style="margin:0 0 0.5rem; text-transform:uppercase; letter-spacing:0.08em; color:#6b7280; font-size:0.85rem;">Do Better Norge — verktøy</p>
<h1>Bygg din egen sak. Bruk hele verktøyboksen.</h1>
<p>13 AI-verktøy for barnevernssaker. Last opp dine egne dokumenter, og la verktøyene jobbe på din private sak — ikke bare generisk lov.</p>
</header>
<?php if ($status === 'success'): ?>
<p class="status-pill-info status-pill-success">Takk! Din betaling er bekreftet. Det kan ta noen sekunder før kontoen oppdateres.</p>
<?php elseif ($status === 'canceled'): ?>
<p class="status-pill-info">Kassen ble avbrutt. Du kan prøve igjen når som helst.</p>
<?php endif; ?>
<?php if ($isAuthed && !$surveyDone): ?>
<div class="survey-banner">
<div class="copy">
<h3>Tjen 25 ekstra kreditter</h3>
<p>Svar på 5 korte spørsmål om hva som hjelper deg mest. Ingen salgspitch — bare research som hjelper oss å forbedre verktøyene.</p>
</div>
<a href="<?= htmlspecialchars($surveyUrl) ?>">Ta undersøkelsen</a>
</div>
<?php endif; ?>
<section class="pricing-grid" aria-label="Abonnementer">
<?php foreach ($tiers as $tier): ?>
<article class="pricing-card<?= !empty($tier['highlight']) ? ' is-highlight' : '' ?>">
<?php if (!empty($tier['badge'])): ?>
<span class="pricing-badge"><?= htmlspecialchars($tier['badge']) ?></span>
<?php endif; ?>
<h2><?= htmlspecialchars($tier['name']) ?></h2>
<div class="pricing-price">
<span class="amount"><?= htmlspecialchars($tier['price']) ?></span>
<span class="period"><?= htmlspecialchars($tier['period']) ?></span>
</div>
<ul class="pricing-meta">
<li><?= htmlspecialchars($tier['credits']) ?></li>
<li><?= htmlspecialchars($tier['storage']) ?></li>
<li><?= htmlspecialchars($tier['seats']) ?></li>
<li><?= htmlspecialchars($tier['cap']) ?></li>
</ul>
<ul class="pricing-features">
<?php foreach ($tier['features'] as $feature): ?>
<li><?= htmlspecialchars($feature) ?></li>
<?php endforeach; ?>
</ul>
<?php if ($tier['sku'] === 'free'): ?>
<?php if (!$isAuthed): ?>
<a class="pricing-cta primary" href="<?= htmlspecialchars($loginUrl) ?>"><?= htmlspecialchars($tier['cta'] ?? 'Logg inn') ?></a>
<?php elseif ($currentTier === 'free'): ?>
<span class="pricing-cta current">Din nåværende plan</span>
<?php else: ?>
<span class="pricing-cta secondary">Tilgjengelig</span>
<?php endif; ?>
<?php else: ?>
<?php if (!$isAuthed): ?>
<a class="pricing-cta primary" href="<?= htmlspecialchars($loginUrl) ?>">Logg inn for å abonnere</a>
<?php elseif ($currentTier === $tier['sku']): ?>
<span class="pricing-cta current">Din nåværende plan</span>
<?php else: ?>
<button type="button" class="pricing-cta primary" data-sku="<?= htmlspecialchars($tier['sku']) ?>" data-checkout="subscription">
Velg <?= htmlspecialchars($tier['name']) ?>
</button>
<?php endif; ?>
<?php endif; ?>
</article>
<?php endforeach; ?>
</section>
<section class="pricing-topups" aria-label="Engangskjøp">
<h2>Topp opp kreditter</h2>
<p class="lead">Trenger du flere kreditter denne måneden? Kjøp en engangspakke — de utløper aldri.</p>
<div class="topup-grid">
<?php foreach ($topups as $topup): ?>
<div class="topup-card">
<div class="price"><?= htmlspecialchars($topup['price']) ?></div>
<div class="credits"><?= (int)$topup['credits'] ?> kreditter</div>
<div class="note"><?= htmlspecialchars($topup['note']) ?></div>
<?php if ($isAuthed): ?>
<button type="button" class="pricing-cta primary" data-sku="<?= htmlspecialchars($topup['sku']) ?>" data-checkout="topup">Kjøp</button>
<?php else: ?>
<a class="pricing-cta primary" href="<?= htmlspecialchars($loginUrl) ?>">Logg inn først</a>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</section>
<section class="pricing-faq" aria-label="Ofte stilte spørsmål">
<h2 style="font-family:'Crimson Pro', serif; margin-bottom:1rem;">Ofte stilte spørsmål</h2>
<details>
<summary>Hva er forskjellen mellom månedlige kreditter og bonuskreditter?</summary>
<p>Månedlige kreditter (fra abonnement eller gratis tier) tilbakestilles første hver måned. Bonuskreditter (fra undersøkelsen eller topp-opp) utløper aldri og brukes etter de månedlige er oppbrukt.</p>
</details>
<details>
<summary>Hva er Min Sak?</summary>
<p>Min Sak er din private dokumentbank. Last opp PDF-er fra saken din, så blir de OCR-ert, analysert og lagret i din egen sikre korpus. Alle verktøyene kan deretter referere til dine egne dokumenter i stedet for bare generisk lov.</p>
</details>
<details>
<summary>Hvor er dataene mine lagret?</summary>
<p>Alt innenfor EU: servere i Falkenstein (Tyskland) og Helsinki (Finland), AI-tjenester i Vest-Europa og Norge Øst. Vi er hostet hos Hetzner og bruker Microsoft Azure for AI. Stripe behandler betalinger gjennom Irland.</p>
</details>
<details>
<summary>Kan jeg dele en konto med advokaten min?</summary>
<p>Ja — Pro+ Familie inkluderer 3 plasser. Du kan invitere advokat, samboer eller en annen familiemedlem. Alle ser de samme dokumentene, men hvem som gjorde hva blir logget.</p>
</details>
<details>
<summary>Hva skjer hvis jeg sier opp?</summary>
<p>Du faller tilbake til gratis-tier. Bonuskredittene dine beholdes. Dokumentene i Min Sak oppbevares i 90 dager før de slettes — så du har tid til å eksportere dem eller fornye.</p>
</details>
<details>
<summary>Tilbyr dere refusjon?</summary>
<p>Ja, full refusjon innen 7 dager hvis du ikke er fornøyd. Send oss en e-post.</p>
</details>
</section>
</main>
<script>
(function() {
const buttons = document.querySelectorAll('button[data-checkout]');
buttons.forEach(btn => {
btn.addEventListener('click', async () => {
const sku = btn.getAttribute('data-sku');
btn.disabled = true;
const original = btn.textContent;
btn.textContent = 'Kobler til...';
try {
const res = await fetch('/api/stripe-checkout.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku })
});
const data = await res.json();
if (data.ok && data.url) {
window.location.href = data.url;
} else {
btn.textContent = 'Feil — prøv igjen';
alert(data.error?.message || 'Kunne ikke starte kassen.');
}
} catch (e) {
btn.textContent = original;
alert('Nettverksfeil: ' + e.message);
} finally {
setTimeout(() => { btn.disabled = false; btn.textContent = original; }, 1500);
}
});
});
})();
</script>
</body>
</html>
+11 -42
View File
@@ -9,19 +9,9 @@ if (!dbnToolsIsAuthenticated()) {
}
$uiLang = dbnToolsCurrentLanguage();
$tools = dbnToolsLaunchedTools($uiLang);
$copy = dbnToolsWorkbenchCopy($uiLang);
$langPath = '/workbench.php';
$toolFlow = [
['slug' => 'transcribe', 'step' => '01', 'bring' => $copy['bring_transcribe']],
['slug' => 'timeline', 'step' => '02', 'bring' => $copy['bring_timeline']],
['slug' => 'redact', 'step' => '03', 'bring' => $copy['bring_redact']],
['slug' => 'barnevernet', 'step' => '04', 'bring' => $copy['bring_barnevernet']],
['slug' => 'advocate', 'step' => '05', 'bring' => $copy['bring_advocate']],
['slug' => 'deep-research', 'step' => '06', 'bring' => $copy['bring_research']],
['slug' => 'corpus', 'step' => '07', 'bring' => $copy['bring_corpus']],
];
?>
<!doctype html>
<html lang="<?= htmlspecialchars($uiLang) ?>">
@@ -154,34 +144,9 @@ foreach ($evidenceFields as [$name, $label, $hint]):
</div>
</section>
<section class="workbench-panel workbench-panel--flow" aria-labelledby="workbenchFlowTitle">
<div class="workbench-section-head">
<p class="workbench-kicker">03</p>
<h2 id="workbenchFlowTitle"><?= htmlspecialchars($copy['flow_title']) ?></h2>
</div>
<div class="workbench-flow">
<?php foreach ($toolFlow as $item):
$tool = $tools[$item['slug']] ?? null;
if (!$tool) {
continue;
}
?>
<article class="workbench-step">
<div class="workbench-step__index"><?= htmlspecialchars($item['step']) ?></div>
<div class="workbench-step__body">
<p><?= htmlspecialchars($tool['sub']) ?></p>
<h3><?= htmlspecialchars($tool['label']) ?></h3>
<span><?= htmlspecialchars($item['bring']) ?></span>
</div>
<a class="workbench-tool-link" href="<?= htmlspecialchars($tool['url']) ?>"><?= htmlspecialchars($copy['open_tool']) ?></a>
</article>
<?php endforeach; ?>
</div>
</section>
<section class="workbench-panel workbench-panel--outputs" aria-labelledby="workbenchOutputsTitle">
<div class="workbench-section-head">
<p class="workbench-kicker">04</p>
<p class="workbench-kicker">03</p>
<h2 id="workbenchOutputsTitle"><?= htmlspecialchars($copy['outputs_title']) ?></h2>
</div>
<div class="workbench-checklist">
@@ -199,22 +164,26 @@ foreach ($evidenceFields as [$name, $label, $hint]):
</section>
</form>
<?php if (dbnToolsIsFreeTier()): ?>
<section class="workbench-panel workbench-panel--docs" aria-labelledby="workbenchDocsTitle"
data-my-docs="true">
<div class="workbench-section-head">
<p class="workbench-kicker">05</p>
<p class="workbench-kicker">04</p>
<h2 id="workbenchDocsTitle"><?= htmlspecialchars(dbnToolsT('my_docs_title', $uiLang)) ?></h2>
</div>
<p class="workbench-docs__desc"><?= htmlspecialchars(dbnToolsT('my_docs_desc', $uiLang)) ?></p>
<div class="upload-zone" id="wbUploadZone" role="region" aria-label="Upload documents">
<input type="file" id="wbUploadInput" multiple accept=".pdf,.docx,.txt" aria-label="Choose files">
<div class="upload-prompt">
<span class="upload-icon" aria-hidden="true">&#8679;</span>
<p>Drop files here or <label for="wbUploadInput" class="upload-browse">browse</label></p>
<p class="upload-hint"><strong>PDF</strong>, <strong>DOCX</strong>, <strong>TXT</strong> &mdash; stored for this case.</p>
</div>
</div>
<p id="wbUploadStatus" class="workbench-status" role="status" aria-live="polite" style="margin:8px 0 4px;"></p>
<div id="myDocsList" class="workbench-docs__list" role="list" aria-live="polite">
<p class="workbench-docs__loading"><?= htmlspecialchars(dbnToolsT('loading', $uiLang)) ?></p>
</div>
<a class="secondary-button workbench-docs__cta" href="https://ai.dobetternorge.no/" target="_blank" rel="noopener noreferrer">
<?= htmlspecialchars(dbnToolsT('my_docs_upload_cta', $uiLang)) ?> ↗
</a>
</section>
<?php endif; ?>
</main>
<?php require_once __DIR__ . '/includes/footer.php'; ?>