> */ public static function tools(): array { $lang = self::langSchema(); $text = ['type' => 'string', 'description' => 'Text to process.']; $useCase = ['type' => 'boolean', 'description' => 'Use private My Case context. Defaults to false.']; return [ self::tool('dbn.search_legal', 'Search DBN legal corpus', 'Search the DBN Norwegian family-law corpus.', [ 'query' => ['type' => 'string', 'minLength' => 3], 'language' => $lang, 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 10], 'corpus_scope' => ['type' => 'string', 'enum' => ['shared', 'private', 'both']], ], ['query']), self::tool('dbn.corpus_search', 'Advanced corpus search', 'Search the DBN legal corpus with a chosen retrieval mode (hybrid, bm25, vector, azure) and optional category filter.', [ 'query' => ['type' => 'string', 'minLength' => 3], 'language' => $lang, 'mode' => ['type' => 'string', 'enum' => ['hybrid', 'bm25', 'vector', 'azure']], 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 20], 'category' => ['type' => 'string'], ], ['query']), self::tool('dbn.ask', 'Ask a legal question', 'Answer a legal preparation question with source-grounded DBN context.', [ 'question' => ['type' => 'string', 'minLength' => 5], 'language' => $lang, 'use_case_context' => $useCase, ], ['question']), self::tool('dbn.summarize', 'Summarize document', 'Summarize pasted case text with optional legal-corpus enrichment.', [ 'text' => $text, 'language' => $lang, 'use_legal_corpus' => ['type' => 'boolean'], 'use_case_context' => $useCase, ], ['text']), self::tool('dbn.timeline', 'Extract timeline', 'Extract dates, hearings, milestones, and deadlines from case text.', [ 'text' => $text, 'language' => $lang, 'focus' => ['type' => 'string', 'enum' => ['all', 'deadlines', 'hearings', 'cps']], 'use_case_context' => $useCase, ], ['text']), self::tool('dbn.redact', 'Redact private data', 'Remove or pseudonymize names, IDs, phone numbers, addresses, and places.', [ 'text' => $text, 'language' => $lang, 'mode' => ['type' => 'string', 'enum' => ['standard', 'strict']], 'output_format' => ['type' => 'string', 'enum' => ['contextual', 'generic', 'pseudonym']], ], ['text']), self::tool('dbn.translate', 'Translate legal document', 'Translate Norwegian family-law text with legal terminology annotations.', [ 'text' => $text, 'source_lang' => $lang, 'target_lang' => $lang, 'doc_type' => ['type' => 'string', 'enum' => ['auto', 'barnevernet', 'adopsjon', 'emergency', 'samvaer', 'fylkesnemnd', 'other']], ], ['text', 'target_lang']), self::tool('dbn.legal_analysis', 'Legal analysis', 'Extract legal issues from a document and answer each with DBN legal context.', [ 'text' => $text, 'language' => $lang, 'doc_type' => ['type' => 'string', 'enum' => ['auto', 'barnevernet', 'adopsjon', 'emergency', 'samvaer', 'fylkesnemnd', 'other']], ], ['text']), self::tool('dbn.korrespond', 'Draft authority correspondence', 'Draft a reply or new letter to Norwegian authorities.', [ 'narrative' => ['type' => 'string'], 'received_text' => ['type' => 'string'], 'recipient_body' => ['type' => 'string'], 'goal' => ['type' => 'string'], 'mode' => ['type' => 'string', 'enum' => ['reply', 'initiate']], 'language' => $lang, 'use_case_context' => $useCase, 'force_draft' => ['type' => 'boolean'], ]), self::tool('dbn.korrespond_refine', 'Refine authority correspondence', 'Refine an existing Norwegian draft letter to an authority into a stronger, source-grounded version.', [ 'original_draft' => ['type' => 'string', 'minLength' => 10], 'language' => $lang, 'jurisdiction' => ['type' => 'string', 'enum' => ['norwegian', 'echr', 'both']], 'recipient_body' => ['type' => 'string'], 'output_type' => ['type' => 'string', 'enum' => ['email', 'formal', 'filing', 'call_prep']], 'tone' => ['type' => 'string', 'enum' => ['cooperative', 'neutral', 'firm', 'adversarial', 'warm']], 'goal' => ['type' => 'string'], ], ['original_draft']), self::tool('dbn.barnevernet_analyze', 'Analyze Barnevernet document', 'Analyze child-welfare documents for red flags and legal issues.', [ 'document_text' => $text, 'filename' => ['type' => 'string'], 'advocate_role' => ['type' => 'string'], 'language' => $lang, 'use_case_context' => $useCase, ], ['document_text']), self::tool('dbn.advocate_brief', 'Create advocate brief', 'Generate a source-grounded brief for a chosen party or role.', [ 'query' => ['type' => 'string'], 'paste_text' => ['type' => 'string'], 'advocate_role' => ['type' => 'string'], 'language' => $lang, 'use_case_context' => $useCase, ], ['advocate_role']), self::tool('dbn.deep_research', 'Deep research', 'Expand a legal question into research angles and synthesize a cited brief.', [ 'query' => ['type' => 'string'], 'paste_text' => ['type' => 'string'], 'language' => $lang, 'use_case_context' => $useCase, ]), self::tool('dbn.discrepancy_find', 'Find document discrepancies', 'Compare two document versions for contradictions, deletions, and added claims.', [ 'document_a_text' => ['type' => 'string'], 'document_b_text' => ['type' => 'string'], 'filename_a' => ['type' => 'string'], 'filename_b' => ['type' => 'string'], 'language' => $lang, ], ['document_a_text', 'document_b_text']), self::tool('dbn.transcribe_audio', 'Transcribe audio', 'Transcribe an audio file from base64 or a URL.', [ 'audio_base64' => ['type' => 'string'], 'audio_url' => ['type' => 'string'], 'filename' => ['type' => 'string'], 'language' => ['type' => 'string'], 'diarize' => ['type' => 'boolean'], ]), self::tool('dbn.extract_text', 'Extract document text', 'Extract plain text from a document (PDF, DOCX, TXT, etc.) supplied as base64 or a URL.', [ 'file_base64' => ['type' => 'string'], 'file_url' => ['type' => 'string'], 'filename' => ['type' => 'string'], ]), self::tool('dbn.corpus_stats', 'Corpus statistics', 'Return document/chunk counts and active legal sources.', []), self::tool('dbn.list_documents', 'List corpus documents', 'List DBN legal corpus documents with filters.', [ 'category' => ['type' => 'string'], 'title' => ['type' => 'string'], 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 50], 'offset' => ['type' => 'integer', 'minimum' => 0], ]), self::tool('dbn.get_document', 'Get document chunks', 'Fetch a document and its chunks by document id.', [ 'document_id' => ['type' => 'integer', 'minimum' => 1], ], ['document_id']), self::tool('dbn.citation_graph', 'Explore citation graph', 'Explore cites, cited-by, implementation, or chain relationships.', [ 'doc_id' => ['type' => 'integer', 'minimum' => 1], 'action' => ['type' => 'string', 'enum' => ['cites', 'cited_by', 'implements', 'chain']], 'depth' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 3], ], ['doc_id']), self::tool('dbn.case_workbench_plan', 'Plan next case step', 'Create a stateless preparation plan. Does not save anything.', [ 'situation' => ['type' => 'string'], 'goal' => ['type' => 'string'], 'deadline' => ['type' => 'string'], 'language' => $lang, ], ['situation']), self::tool('dbn.save_to_case', 'Save result to My Case', 'Explicitly save a prior MCP tool result to the user case record.', [ 'tool' => ['type' => 'string'], 'title' => ['type' => 'string'], 'input_payload' => ['type' => 'object'], 'output_payload' => ['type' => 'object'], 'meta' => ['type' => 'object'], ], ['tool', 'input_payload', 'output_payload']), ]; } /** @param array $args */ public static function invoke(string $slug, array $args, array $tokenRow): array { self::establishSession($tokenRow); $started = microtime(true); $result = match ($slug) { 'dbn.search_legal' => self::callJson('api/search.php', [ 'query' => (string)($args['query'] ?? ''), 'language' => self::language($args['language'] ?? 'en'), 'limit' => (int)($args['limit'] ?? $args['top_k'] ?? 8), 'corpus_scope' => self::corpusScope($args['corpus_scope'] ?? 'both'), ]), 'dbn.corpus_search' => self::callJson('api/corpus-search.php', [ 'query' => (string)($args['query'] ?? ''), 'language' => self::language($args['language'] ?? 'en'), 'mode' => in_array($args['mode'] ?? 'hybrid', ['hybrid', 'bm25', 'vector', 'azure'], true) ? (string)$args['mode'] : 'hybrid', 'limit' => (int)($args['limit'] ?? 8), 'category' => (string)($args['category'] ?? ''), ]), 'dbn.ask' => self::callJson('api/ask.php', [ 'question' => (string)($args['question'] ?? ''), 'language' => self::language($args['language'] ?? 'en'), 'use_my_case' => !empty($args['use_case_context']), ]), 'dbn.summarize' => self::callJson('api/summarize.php', [ 'text' => (string)($args['text'] ?? ''), 'language' => self::language($args['language'] ?? 'en'), 'slices' => !empty($args['use_legal_corpus']) ? ['family-legal'] : [], 'use_my_case' => !empty($args['use_case_context']), ]), 'dbn.timeline' => self::callJson('api/timeline.php', [ 'text' => (string)($args['text'] ?? ''), 'language' => self::language($args['language'] ?? 'en'), 'focus' => (string)($args['focus'] ?? 'all'), 'use_my_case' => !empty($args['use_case_context']), ]), 'dbn.redact' => self::callJson('api/redact.php', [ 'text' => (string)($args['text'] ?? ''), 'language' => self::language($args['language'] ?? 'en'), 'mode' => (string)($args['mode'] ?? 'standard'), 'output_format' => (string)($args['output_format'] ?? 'contextual'), ]), 'dbn.translate' => self::callJson('api/translate.php', [ 'text' => (string)($args['text'] ?? ''), 'language' => self::language($args['target_lang'] ?? $args['language'] ?? 'en'), 'source_lang' => self::language($args['source_lang'] ?? 'no'), 'target_lang' => self::language($args['target_lang'] ?? 'en'), 'doc_type' => self::docType($args['doc_type'] ?? 'auto'), ]), 'dbn.legal_analysis' => self::callJson('api/legal-analysis.php', [ 'text' => (string)($args['text'] ?? ''), 'language' => self::language($args['language'] ?? 'en'), 'doc_type' => self::docType($args['doc_type'] ?? 'other'), ]), 'dbn.korrespond' => self::callJson('api/korrespond.php', [ 'mode' => (string)($args['mode'] ?? 'initiate'), 'recipient_body' => (string)($args['recipient_body'] ?? 'other'), 'output_type' => (string)($args['output_type'] ?? 'email'), 'tone' => (string)($args['tone'] ?? 'neutral'), 'language' => self::language($args['language'] ?? 'en'), 'narrative' => (string)($args['narrative'] ?? $args['received_text'] ?? ''), 'goal' => (string)($args['goal'] ?? ''), 'use_my_case' => !empty($args['use_case_context']), 'force_draft' => ($args['force_draft'] ?? true) !== false, ]), 'dbn.korrespond_refine' => self::callJson('api/korrespond-refine.php', [ 'original_draft_no' => (string)($args['original_draft'] ?? ''), 'language' => self::language($args['language'] ?? 'en'), 'jurisdiction' => in_array($args['jurisdiction'] ?? 'norwegian', ['norwegian', 'echr', 'both'], true) ? (string)$args['jurisdiction'] : 'norwegian', 'intake' => [ 'recipient_body' => (string)($args['recipient_body'] ?? 'other'), 'output_type' => (string)($args['output_type'] ?? 'email'), 'tone' => (string)($args['tone'] ?? 'neutral'), 'goal' => (string)($args['goal'] ?? ''), ], 'classify' => [], ]), 'dbn.barnevernet_analyze' => self::callMultipart('api/barnevernet.php', [ 'language' => self::language($args['language'] ?? 'en'), 'advocate_role' => (string)($args['advocate_role'] ?? ''), 'use_my_case' => !empty($args['use_case_context']), ], ['files' => [self::tempTextFile((string)($args['document_text'] ?? ''), (string)($args['filename'] ?? 'barnevernet.txt'))]]), 'dbn.advocate_brief' => self::callJson('api/deep-research.php', [ 'query' => (string)($args['query'] ?? ''), 'paste_text' => (string)($args['paste_text'] ?? ''), 'advocate_role' => (string)($args['advocate_role'] ?? ''), 'language' => self::language($args['language'] ?? 'en'), 'use_my_case' => !empty($args['use_case_context']), ]), 'dbn.deep_research' => self::callJson('api/deep-research.php', [ 'query' => (string)($args['query'] ?? ''), 'paste_text' => (string)($args['paste_text'] ?? ''), 'language' => self::language($args['language'] ?? 'en'), 'use_my_case' => !empty($args['use_case_context']), ]), 'dbn.discrepancy_find' => self::callMultipart('api/discrepancy.php', [ 'language' => self::language($args['language'] ?? 'en'), ], [ 'file_a' => self::tempTextFile((string)($args['document_a_text'] ?? ''), (string)($args['filename_a'] ?? 'document-a.txt')), 'file_b' => self::tempTextFile((string)($args['document_b_text'] ?? ''), (string)($args['filename_b'] ?? 'document-b.txt')), ]), 'dbn.transcribe_audio' => self::invokeTranscribe($args), 'dbn.extract_text' => self::invokeExtract($args), 'dbn.corpus_stats' => self::callGet('api/corpus-stats.php', []), 'dbn.list_documents' => self::callGet('api/corpus-documents.php', [ 'category' => (string)($args['category'] ?? ''), 'title' => (string)($args['title'] ?? ''), 'limit' => (int)($args['limit'] ?? 20), 'offset' => (int)($args['offset'] ?? 0), ]), 'dbn.get_document' => self::callGet('api/document-chunks.php', [ 'document_id' => (int)($args['document_id'] ?? 0), ]), 'dbn.citation_graph' => self::callExternalGraph($args), 'dbn.case_workbench_plan' => self::workbenchPlan($args), 'dbn.save_to_case' => self::saveToCase($args), default => throw new DbnToolsHttpException('Unknown MCP tool: ' . $slug, 404, 'tool_not_found'), }; $result['duration_ms'] = (int)round((microtime(true) - $started) * 1000); return self::mcpResult($slug, $result); } private static function tool(string $slug, string $name, string $description, array $properties, array $required = []): array { return [ 'slug' => $slug, 'display_name' => $name, 'description' => $description, 'version' => 1, 'config' => [ 'input_schema' => [ 'type' => 'object', 'properties' => (object)$properties, 'required' => $required, 'additionalProperties' => true, ], ], ]; } private static function langSchema(): array { return ['type' => 'string', 'enum' => ['en', 'no', 'uk', 'pl', 'auto']]; } private static function establishSession(array $tokenRow): void { $_SESSION['dbn_tools_authenticated'] = true; $_SESSION['dbn_tools_authenticated_at'] = time(); $_SESSION['dbn_tools_sso_uid'] = (int)$tokenRow['user_id']; $_SESSION['dbn_tools_user_id'] = (int)$tokenRow['user_id']; $_SESSION['dbn_tools_user_email'] = (string)($tokenRow['email'] ?? ''); $_SESSION['dbn_tools_user_role'] = 'mcp'; $_SESSION['dbn_tools_tier'] = (string)($tokenRow['tier'] ?? 'plus'); } private static function baseUrl(): string { $configured = (string)(dbnToolsEnv('DBN_TOOLS_BASE_URL') ?? ''); if ($configured !== '') { return rtrim($configured, '/') . '/'; } $host = (string)($_SERVER['HTTP_HOST'] ?? ''); if ($host !== '') { $scheme = dbnToolsIsHttps() ? 'https' : 'http'; $script = (string)($_SERVER['SCRIPT_NAME'] ?? ''); $prefix = ''; $marker = '/api/mcp/user'; $pos = strpos($script, $marker); if ($pos !== false) { $prefix = substr($script, 0, $pos); } return rtrim($scheme . '://' . $host . $prefix, '/') . '/'; } return 'http://localhost/dobetternorge-tools/'; } private static function callJson(string $path, array $payload): array { return self::curl($path, 'POST', json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), [ 'Content-Type: application/json', 'Accept: application/json, application/x-ndjson', ]); } private static function callGet(string $path, array $query): array { $query = array_filter($query, static fn($v) => $v !== '' && $v !== null); return self::curl($path . ($query ? '?' . http_build_query($query) : ''), 'GET', null, [ 'Accept: application/json', ]); } private static function callMultipart(string $path, array $payload, array $files): array { $body = ['payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]; $temps = []; foreach ($files as $field => $fileInfo) { if (is_array($fileInfo) && array_is_list($fileInfo)) { foreach ($fileInfo as $idx => $item) { $temps[] = $item['path']; $body[$field . '[' . $idx . ']'] = new CURLFile($item['path'], $item['mime'], $item['name']); } } else { $temps[] = $fileInfo['path']; $body[$field] = new CURLFile($fileInfo['path'], $fileInfo['mime'], $fileInfo['name']); } } try { return self::curl($path, 'POST', $body, ['Accept: application/json, application/x-ndjson']); } finally { foreach ($temps as $temp) { if (is_string($temp) && is_file($temp)) { @unlink($temp); } } } } private static function curl(string $path, string $method, mixed $body, array $headers): array { if (session_status() === PHP_SESSION_ACTIVE) { session_write_close(); } $url = str_starts_with($path, 'http://') || str_starts_with($path, 'https://') ? $path : self::baseUrl() . ltrim($path, '/'); $ch = curl_init($url); if ($ch === false) { throw new DbnToolsHttpException('Could not initialize internal tool request.', 500, 'curl_init_failed'); } $cookie = session_name() . '=' . session_id(); $headers[] = 'Cookie: ' . $cookie; curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 240, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_FOLLOWLOCATION => false, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $raw = curl_exec($ch); $errno = curl_errno($ch); $err = curl_error($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($errno !== 0) { throw new DbnToolsHttpException('Internal tool request failed: ' . $err, 502, 'tool_request_failed'); } return self::decodeToolResponse((string)$raw, $status); } private static function decodeToolResponse(string $raw, int $status): array { $trim = trim($raw); $decoded = json_decode($trim, true); if (is_array($decoded)) { if ($status >= 400 || (isset($decoded['ok']) && $decoded['ok'] === false)) { $message = is_array($decoded['error'] ?? null) ? (string)($decoded['error']['message'] ?? 'Tool failed.') : (string)($decoded['error'] ?? 'Tool failed.'); throw new DbnToolsHttpException($message, $status ?: 500, 'tool_error', ['tool_response' => $decoded]); } return $decoded; } $events = []; $final = null; foreach (preg_split('/\r?\n/', $trim) ?: [] as $line) { if (trim($line) === '') { continue; } $event = json_decode($line, true); if (!is_array($event)) { continue; } $events[] = $event; if (($event['event'] ?? '') === 'error') { throw new DbnToolsHttpException((string)($event['message'] ?? $event['error'] ?? 'Tool failed.'), (int)($event['status'] ?? 500), (string)($event['code'] ?? 'tool_error')); } if (($event['event'] ?? '') === 'final') { $final = is_array($event['result'] ?? null) ? $event['result'] : $event; } } if ($final !== null) { $final['_events'] = $events; return $final; } if ($status >= 400) { throw new DbnToolsHttpException('Tool returned HTTP ' . $status, $status, 'tool_http_error'); } return ['ok' => true, 'raw' => mb_substr($raw, 0, 12000, 'UTF-8')]; } private static function tempTextFile(string $text, string $name): array { if (mb_strlen(trim($text), 'UTF-8') < 10) { throw new DbnToolsHttpException('Document text is required.', 422, 'empty_text'); } $path = tempnam(sys_get_temp_dir(), 'dbn-mcp-'); if ($path === false) { throw new DbnToolsHttpException('Could not create temporary file.', 500, 'temp_failed'); } file_put_contents($path, $text); return ['path' => $path, 'name' => preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?: 'document.txt', 'mime' => 'text/plain']; } private static function invokeTranscribe(array $args): array { $filename = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)($args['filename'] ?? 'audio.mp3')) ?: 'audio.mp3'; $path = tempnam(sys_get_temp_dir(), 'dbn-audio-'); if ($path === false) { throw new DbnToolsHttpException('Could not create temporary audio file.', 500, 'temp_failed'); } try { if (!empty($args['audio_base64'])) { $data = base64_decode((string)$args['audio_base64'], true); if ($data === false || strlen($data) < 16) { throw new DbnToolsHttpException('audio_base64 is invalid.', 422, 'bad_audio_base64'); } if (strlen($data) > 25 * 1024 * 1024) { throw new DbnToolsHttpException('audio_base64 is too large for MCP upload. Use audio_url.', 413, 'audio_too_large'); } file_put_contents($path, $data); } elseif (!empty($args['audio_url'])) { self::downloadToFile((string)$args['audio_url'], $path, 200 * 1024 * 1024); $filename = basename(parse_url((string)$args['audio_url'], PHP_URL_PATH) ?: $filename) ?: $filename; } else { throw new DbnToolsHttpException('Provide audio_base64 or audio_url.', 422, 'missing_audio'); } $fields = [ 'language' => (string)($args['language'] ?? 'auto'), 'diarize' => !empty($args['diarize']) ? '1' : '0', ]; $body = $fields + ['audio' => new CURLFile($path, mime_content_type($path) ?: 'application/octet-stream', $filename)]; return self::curl('api/transcribe.php', 'POST', $body, ['Accept: application/json']); } finally { if (is_file($path)) { @unlink($path); } } } private static function invokeExtract(array $args): array { $filename = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)($args['filename'] ?? 'document.pdf')) ?: 'document.pdf'; $path = tempnam(sys_get_temp_dir(), 'dbn-extract-'); if ($path === false) { throw new DbnToolsHttpException('Could not create temporary file.', 500, 'temp_failed'); } try { if (!empty($args['file_base64'])) { $data = base64_decode((string)$args['file_base64'], true); if ($data === false || strlen($data) < 8) { throw new DbnToolsHttpException('file_base64 is invalid.', 422, 'bad_file_base64'); } if (strlen($data) > 25 * 1024 * 1024) { throw new DbnToolsHttpException('file_base64 is too large for MCP upload. Use file_url.', 413, 'file_too_large'); } file_put_contents($path, $data); } elseif (!empty($args['file_url'])) { self::downloadToFile((string)$args['file_url'], $path, 100 * 1024 * 1024); $filename = basename(parse_url((string)$args['file_url'], PHP_URL_PATH) ?: $filename) ?: $filename; } else { throw new DbnToolsHttpException('Provide file_base64 or file_url.', 422, 'missing_file'); } $body = [ 'tool' => 'extract', 'file' => new CURLFile($path, mime_content_type($path) ?: 'application/octet-stream', $filename), ]; return self::curl('api/extract.php', 'POST', $body, ['Accept: application/json']); } finally { if (is_file($path)) { @unlink($path); } } } private static function downloadToFile(string $url, string $path, int $maxBytes): void { if (!preg_match('#^https?://#i', $url)) { throw new DbnToolsHttpException('audio_url must start with http:// or https://.', 422, 'bad_audio_url'); } $fh = fopen($path, 'wb'); if (!$fh) { throw new DbnToolsHttpException('Could not open temporary audio file.', 500, 'temp_failed'); } $seen = 0; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_FILE => $fh, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 240, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_WRITEFUNCTION => static function ($ch, string $chunk) use ($fh, &$seen, $maxBytes): int { $seen += strlen($chunk); if ($seen > $maxBytes) { return 0; } return fwrite($fh, $chunk) ?: 0; }, ]); $ok = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $err = curl_error($ch); curl_close($ch); fclose($fh); if ($ok === false || $status >= 400) { throw new DbnToolsHttpException('Could not download audio_url: ' . ($err ?: 'HTTP ' . $status), 502, 'audio_download_failed'); } } private static function callExternalGraph(array $args): array { $params = [ 'action' => in_array($args['action'] ?? 'cites', ['cites', 'cited_by', 'implements', 'chain'], true) ? $args['action'] : 'cites', 'doc_id' => (int)($args['doc_id'] ?? 0), 'limit' => 50, 'depth' => max(1, min(3, (int)($args['depth'] ?? 2))), ]; return self::curl('https://ai.bluenotelogic.com/api/graph-search.php?' . http_build_query($params), 'GET', null, ['Accept: application/json']); } private static function saveToCase(array $args): array { require_once __DIR__ . '/CaseResults.php'; if (!dbnToolsIsFreeTier()) { throw new DbnToolsHttpException('Saving requires a DBN member session.', 403, 'sso_only'); } $userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0); $input = is_array($args['input_payload'] ?? null) ? $args['input_payload'] : []; $output = is_array($args['output_payload'] ?? null) ? $args['output_payload'] : []; $meta = is_array($args['meta'] ?? null) ? $args['meta'] : []; if (!empty($args['title'])) { $meta['title'] = (string)$args['title']; } $ownerId = CaseStore::caseResolveClientId($userId); $id = CaseResults::save($userId, $ownerId, (string)($args['tool'] ?? ''), $input, $output, $meta); if ($id <= 0) { throw new DbnToolsHttpException('Could not save result. Check plan and tool eligibility.', 500, 'save_failed'); } return ['ok' => true, 'result_id' => $id]; } private static function workbenchPlan(array $args): array { $situation = trim((string)($args['situation'] ?? '')); if ($situation === '') { throw new DbnToolsHttpException('situation is required.', 422, 'missing_situation'); } $goal = trim((string)($args['goal'] ?? '')); $deadline = trim((string)($args['deadline'] ?? '')); return [ 'ok' => true, 'summary_text' => "Case preparation plan\n\n1. Clarify the immediate decision or authority action.\n2. Build a dated fact timeline from the source documents.\n3. Search DBN legal sources for the key legal duties and deadlines.\n4. Draft one concrete correspondence or brief.\n5. Save only the final result if you explicitly want it in My Case.", 'structured_result' => [ 'situation' => $situation, 'goal' => $goal, 'deadline' => $deadline, 'recommended_tools' => ['dbn.timeline', 'dbn.search_legal', 'dbn.korrespond', 'dbn.save_to_case'], 'privacy' => 'No data was saved by this planning tool.', ], ]; } private static function mcpResult(string $slug, array $payload): array { $text = self::summaryText($payload); return [ 'content' => [[ 'type' => 'text', 'text' => $text, ]], 'structuredContent' => [ 'ok' => $payload['ok'] ?? true, 'tool' => $slug, 'summary_text' => $text, 'structured_result' => $payload, 'sources' => $payload['sources'] ?? $payload['hits'] ?? $payload['cited_law'] ?? [], 'trace' => $payload['trace'] ?? $payload['_events'] ?? [], 'disclaimer' => $payload['disclaimer'] ?? dbnToolsDisclaimer('en'), ], ]; } private static function summaryText(array $payload): string { foreach (['summary_text', 'answer', 'what_we_found', 'overall_assessment', 'translated_text', 'redacted_text', 'transcript', 'draft_no', 'draft_user', 'text'] as $key) { if (!empty($payload[$key]) && is_string($payload[$key])) { return $payload[$key]; } } if (!empty($payload['document']['title'])) { return 'Document: ' . (string)$payload['document']['title']; } if (isset($payload['hits']) && is_array($payload['hits'])) { return 'Found ' . count($payload['hits']) . ' source excerpt(s) from the legal corpus.'; } if (!empty($payload['stats'])) { return 'Corpus statistics: ' . json_encode($payload['stats'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: 'Tool completed.'; } private static function language(mixed $value): string { return dbnToolsNormalizeLanguage($value ?: 'en'); } private static function corpusScope(mixed $value): string { return in_array($value, ['shared', 'private', 'both'], true) ? (string)$value : 'both'; } private static function docType(mixed $value): string { $value = (string)$value; return in_array($value, ['auto', 'barnevernet', 'adopsjon', 'emergency', 'samvaer', 'samvær', 'fylkesnemnd', 'other'], true) ? $value : 'other'; } }