2013648ee0
All tool results can now be saved to My Case manually. Users click 'Save result', type a description, and confirm. This replaces the previous silent auto-save on barnevernet/timeline/etc., giving users control over what stays and what it's called (supports multiple runs of the same tool with different titles). - CaseResults: extend ELIGIBLE_TOOLS to include summarize, ask, redact, transcribe; add toolLabel/toolIcon entries; support explicit title via meta['title'] in save() - api/case/save-result.php: new client-initiated save endpoint; accepts tool + title + input_payload + output_payload + meta - Remove CaseResults::save() auto-save from barnevernet, deep-research, discrepancy, korrespond, timeline API endpoints - tools.js: add showSaveResultButton() (exposed as window.dbnShowSaveResultButton); wire for ask, redact, timeline, transcribe (both file-upload and stored-audio paths) - barnevernet.js: wire save button after final result render - summarize.js: wire save button after renderFinal(); passes sumResults container so widget appears in the correct #sumResults div - case-result.php: rich tool-specific rendering for summarize, ask, redact, transcribe, timeline; update re-run link map to include all new tools - tools.css: styles for .save-result-widget and its states (idle, prompt, done, error) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
307 lines
11 KiB
PHP
307 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',
|
|
];
|
|
|
|
/** 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',
|
|
][$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' => '🎙️',
|
|
][$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],
|
|
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;
|
|
}
|
|
}
|