Files
dobetternorge-tools/includes/CaseResults.php
T
daveadmin 7e6463ed22 Add Legal Analysis tool — two-pass DBN-legal pipeline
Restores the dbn-legal-agent-v3 fine-tune on ocelot (was silently aliased
to plain qwen2.5:14b in LiteLLM since the viper retirement) and ships a
new tool that uses it via a two-pass flow:

  Pass 1 (Azure 4o-mini)  → extract up to 5 distinct legal issues
  Pass 2 (ocelot v3 only) → answer each issue, ≤350 tokens, with corpus
  Pass 3 (Azure 4o-mini)  → synthesise overall assessment + next steps

The 12GB-VRAM constraint motivates the split: dbn-legal-agent-v3 stays
hot in VRAM through the 5 sequential per-issue calls because issue
extraction and synthesis run on Azure, not on ocelot.

New surface:
  - includes/LegalAnalysisAgent.php
  - api/legal-analysis.php           (NDJSON streaming endpoint)
  - legal-analysis.php               (dedicated tool page)
  - assets/js/legal-analysis.js      (streamed UI with per-issue cards)
  - Save-result + case-result.php rendering for legal-analysis output
  - Nav registration in all four UI languages

Add-on integration: a "⚖️🇳🇴 Run deep legal analysis on this text"
button now appears on Summarize, Ask, and Redact result pages and
streams the same pipeline inline below the existing result.

Existing tools relabelled: the misleading "🇳🇴 Norwegian specialist v3 "
option on advocate/deep-research/discrepancy/barnevernet is now honestly
"DBN Legal Agent" — now that the real fine-tune is actually deployed,
the label finally matches reality. The advocate.php v2 option was
removed since the v2 GGUF is retired.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 04:21:01 +02:00

311 lines
11 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/FreeTier.php';
require_once __DIR__ . '/CaseStore.php';
/**
* Persistent tool-run results per case (premium "Saved analyses" feature).
*
* Every successful Korrespond / Advocate / BVJ / Deep Research / Discrepancy / Timeline run
* for a paid (Plus or Pro) user — including active trial — is written to `case_tool_results`.
* Free users do not persist results; save() silently no-ops for them.
*
* Storage layout:
* user_id — session user (may be a family member)
* owner_user_id — CaseStore::caseResolveClientId result; whose corpus the run touched
* tool — tool slug
* input_payload — the entire request body (used by Re-run)
* output_payload — the entire tool response
* used_case_context + case_doc_ids — true when the user toggled "Bruk min sak"
*/
final class CaseResults
{
/** Tools that participate in the saved-results system. Other tools (search, corpus, etc.) are not persisted. */
public const ELIGIBLE_TOOLS = [
'korrespond',
'advocate',
'barnevernet',
'deep-research',
'discrepancy',
'timeline',
'summarize',
'ask',
'redact',
'transcribe',
'legal-analysis',
];
/** True when the user is on a tier that gets saved results (Plus, Pro, or active Plus trial). */
public static function isEnabled(int $userId): bool
{
if ($userId <= 0) {
return false;
}
$tier = FreeTier::tier($userId);
return FreeTier::isPaidTier($tier);
}
/**
* Persist a completed tool run. Returns the new row id, or 0 if the user isn't eligible
* (so callers can wrap unconditionally without if-branches).
*
* @param array $meta {
* used_case_context: 0|1,
* case_doc_ids: int[],
* model: string|null,
* latency_ms: int|null,
* credits_charged: int,
* }
*/
public static function save(
int $userId,
int $ownerUserId,
string $tool,
array $input,
array $output,
array $meta = []
): int {
if (!self::isEnabled($userId)) {
return 0;
}
if (!in_array($tool, self::ELIGIBLE_TOOLS, true)) {
return 0;
}
if (!self::tableReady()) {
return 0;
}
// Caller may supply an explicit title (e.g. from a manual "Save result" prompt).
$title = isset($meta['title']) && trim((string)$meta['title']) !== ''
? mb_substr(trim((string)$meta['title']), 0, 200, 'UTF-8')
: self::deriveTitle($tool, $input);
$caseDocIds = $meta['case_doc_ids'] ?? [];
if (!is_array($caseDocIds)) {
$caseDocIds = [];
}
$caseDocIds = array_values(array_unique(array_map('intval', $caseDocIds)));
$db = dbnmDb();
$db->prepare(
'INSERT INTO case_tool_results
(user_id, owner_user_id, tool, title, used_case_context, case_doc_ids,
input_payload, output_payload, model, latency_ms, credits_charged, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())'
)->execute([
$userId,
$ownerUserId > 0 ? $ownerUserId : $userId,
$tool,
$title,
!empty($meta['used_case_context']) ? 1 : 0,
json_encode($caseDocIds, JSON_UNESCAPED_UNICODE),
json_encode($input, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
json_encode($output, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$meta['model'] ?? null,
isset($meta['latency_ms']) ? (int)$meta['latency_ms'] : null,
(int)($meta['credits_charged'] ?? 0),
]);
return (int)$db->lastInsertId();
}
/** List a user's saved results (also visible across family seats). */
public static function listForUser(int $userId, int $limit = 50): array
{
if ($userId <= 0) {
return [];
}
if (!self::tableReady()) {
return [];
}
$ownerId = CaseStore::caseResolveClientId($userId);
$db = dbnmDb();
$stmt = $db->prepare(
'SELECT id, user_id, owner_user_id, tool, title, used_case_context,
model, latency_ms, credits_charged, pinned, created_at
FROM case_tool_results
WHERE owner_user_id = ? AND deleted_at IS NULL
ORDER BY pinned DESC, created_at DESC
LIMIT ' . max(1, min(200, $limit))
);
$stmt->execute([$ownerId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
/** Single result row with full payloads, ownership-checked. */
public static function get(int $userId, int $resultId): ?array
{
if ($userId <= 0 || $resultId <= 0) {
return null;
}
if (!self::tableReady()) {
return null;
}
$ownerId = CaseStore::caseResolveClientId($userId);
$db = dbnmDb();
$stmt = $db->prepare(
'SELECT * FROM case_tool_results
WHERE id = ? AND owner_user_id = ? AND deleted_at IS NULL LIMIT 1'
);
$stmt->execute([$resultId, $ownerId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$row['case_doc_ids'] = json_decode((string)$row['case_doc_ids'], true) ?: [];
$row['input_payload'] = json_decode((string)$row['input_payload'], true) ?: [];
$row['output_payload'] = json_decode((string)$row['output_payload'], true) ?: [];
return $row;
}
/** Soft-delete a result. Returns true if the row was found and deleted. */
public static function softDelete(int $userId, int $resultId): bool
{
if ($userId <= 0 || $resultId <= 0) {
return false;
}
if (!self::tableReady()) {
return false;
}
$ownerId = CaseStore::caseResolveClientId($userId);
$db = dbnmDb();
$stmt = $db->prepare(
'UPDATE case_tool_results
SET deleted_at = NOW()
WHERE id = ? AND owner_user_id = ? AND deleted_at IS NULL'
);
$stmt->execute([$resultId, $ownerId]);
return $stmt->rowCount() > 0;
}
/** Toggle the pinned flag. Returns the new pinned state, or null if not found. */
public static function togglePin(int $userId, int $resultId): ?bool
{
if ($userId <= 0 || $resultId <= 0) {
return null;
}
if (!self::tableReady()) {
return null;
}
$ownerId = CaseStore::caseResolveClientId($userId);
$db = dbnmDb();
$stmt = $db->prepare(
'UPDATE case_tool_results
SET pinned = 1 - pinned
WHERE id = ? AND owner_user_id = ? AND deleted_at IS NULL'
);
$stmt->execute([$resultId, $ownerId]);
if ($stmt->rowCount() === 0) {
return null;
}
$check = $db->prepare('SELECT pinned FROM case_tool_results WHERE id = ?');
$check->execute([$resultId]);
return (bool)$check->fetchColumn();
}
/** Update the user-editable title. Returns true on success. */
public static function updateTitle(int $userId, int $resultId, string $title): bool
{
$title = mb_substr(trim($title), 0, 200, 'UTF-8');
if ($title === '') {
return false;
}
if (!self::tableReady()) {
return false;
}
$ownerId = CaseStore::caseResolveClientId($userId);
$db = dbnmDb();
$stmt = $db->prepare(
'UPDATE case_tool_results
SET title = ?
WHERE id = ? AND owner_user_id = ? AND deleted_at IS NULL'
);
$stmt->execute([$title, $resultId, $ownerId]);
return $stmt->rowCount() > 0;
}
/** Human-readable Norwegian title hint per tool. */
public static function toolLabel(string $tool): string
{
return [
'korrespond' => 'Korrespondanse',
'advocate' => 'Advokatutkast',
'barnevernet' => 'BVJ-analyse',
'deep-research' => 'Dyp analyse',
'discrepancy' => 'Motstrid',
'timeline' => 'Tidslinje',
'summarize' => 'Sammendrag',
'ask' => 'Spørsmål & svar',
'redact' => 'Anonymisering',
'transcribe' => 'Transkripsjon',
'legal-analysis' => 'Juridisk analyse',
][$tool] ?? ucfirst($tool);
}
/** Tool icon (emoji for now, can swap to SVG later). */
public static function toolIcon(string $tool): string
{
return [
'korrespond' => '✉️',
'advocate' => '⚖️',
'barnevernet' => '🛡️',
'deep-research' => '🔬',
'discrepancy' => '🔍',
'timeline' => '📅',
'summarize' => '📝',
'ask' => '💬',
'redact' => '🖊️',
'transcribe' => '🎙️',
'legal-analysis' => '⚖️🇳🇴',
][$tool] ?? '📄';
}
/** Derive a default title from the input payload (best-effort per tool). */
private static function deriveTitle(string $tool, array $input): string
{
$candidates = match ($tool) {
'korrespond' => [$input['goal'] ?? null, $input['narrative'] ?? null, $input['case_ref'] ?? null],
'advocate' => [$input['question'] ?? null, $input['facts'] ?? null, $input['topic'] ?? null],
'barnevernet' => [$input['document_type'] ?? null, $input['summary'] ?? null, $input['text'] ?? null],
'deep-research' => [$input['question'] ?? null, $input['query'] ?? null, $input['topic'] ?? null],
'discrepancy' => [$input['focus'] ?? null, $input['context'] ?? null],
'timeline' => [$input['context'] ?? null, $input['text'] ?? null],
'summarize' => [$input['text'] ?? null],
'ask' => [$input['question'] ?? null],
'redact' => [$input['text'] ?? null],
'transcribe' => [$input['filename'] ?? null],
'legal-analysis' => [$input['doc_type'] ?? null, $input['text'] ?? null],
default => [$input['title'] ?? null, $input['query'] ?? null, $input['text'] ?? null],
};
foreach ($candidates as $c) {
$c = is_string($c) ? trim($c) : '';
if ($c !== '') {
return mb_substr($c, 0, 80, 'UTF-8');
}
}
return self::toolLabel($tool) . ' — ' . date('j. M Y H:i');
}
/** Guard against deployment order: code may arrive just before the DB migration. */
private static function tableReady(): bool
{
static $ready = null;
if ($ready !== null) {
return $ready;
}
try {
$stmt = dbnmDb()->prepare(
'SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = ?'
);
$stmt->execute(['case_tool_results']);
$ready = ((int)$stmt->fetchColumn()) > 0;
} catch (Throwable $e) {
error_log('[CaseResults] table readiness check failed: ' . $e->getMessage());
$ready = false;
}
return $ready;
}
}