$data */ public function ingest(array $data, int $actor): string { return DB::transaction(function () use ($data, $actor): string { $document = DB::table('documents')->where('id', $data['document_id'])->whereNull('deleted_at')->lockForUpdate()->first(); if (! $document || $document->status !== 'active' || $document->malware_scan_status !== 'clean' || ! hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)) || $document->media_type !== 'text/plain' || ! in_array($document->data_classification, ['public', 'internal'], true)) { throw ValidationException::withMessages(['document_id' => 'Documento activo de texto autorizado necessário; fontes confidenciais/restritas não são admitidas.']); } $root = realpath(storage_path('app/private')); $path = realpath(Storage::disk('local')->path($document->storage_key)); if (! $root || ! $path || ! str_starts_with(str_replace('\\', '/', $path), str_replace('\\', '/', $root).'/') || ! is_file($path) || filesize($path) > (int) config('investmoz_ai.max_text_bytes')) { throw ValidationException::withMessages(['document_id' => 'Ficheiro privado inexistente ou acima do limite.']); } $text = file_get_contents($path); if ($text === false || ! mb_check_encoding($text, 'UTF-8') || str_contains($text, "\0") || trim($text) === '' || ! hash_equals($document->sha256, hash('sha256', $text))) { throw ValidationException::withMessages(['document_id' => 'Formato, conteúdo ou checksum inválido.']); } if (DB::table('ai_sources')->where('document_id', $document->id)->where('document_version', $document->version_no)->exists()) { throw ValidationException::withMessages(['document_id' => 'Esta versão já foi registada. Reveja a fonte existente.']); } $id = (string) Str::uuid(); DB::table('ai_sources')->insert([ ...$data, 'id' => $id, 'document_version' => $document->version_no, 'document_checksum' => $document->sha256, 'submitted_by' => $actor, 'status' => 'pending', 'purpose' => 'investment_information', 'created_at' => now(), 'updated_at' => now(), ]); $size = max(100, min(5000, (int) config('investmoz_ai.chunk_characters', 1800))); foreach (mb_str_split($text, $size) as $position => $chunk) { DB::table('ai_chunks')->insert(['id' => (string) Str::uuid(), 'source_id' => $id, 'position' => $position, 'language' => $data['language'], 'content' => $chunk, 'checksum' => hash('sha256', $chunk)]); } DB::table('audit_events')->insert(['actor_user_id' => $actor, 'entity_type' => 'ai_sources', 'entity_id' => $id, 'action' => 'submitted', 'source_system' => 'ai-gateway', 'after_data' => json_encode(['document_version' => $document->version_no], JSON_THROW_ON_ERROR)]); return $id; }); } }