Files
dobetternorge-tools/includes/CaseResults.php
T
2026-05-23 10:17:34 +02:00

289 lines
10 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',
];
/** 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;
}
// Title default: first 80 chars of the most descriptive input field.
$title = 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',
][$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' => '📅',
][$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],
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;
}
}