DB::table('ai_controls')->orderBy('capability')->get(), 'sources' => DB::table('ai_sources')->orderByDesc('created_at')->limit(50)->get(['id', 'title', 'language', 'status', 'nature', 'valid_until', 'submitted_by']), 'documents' => DB::table('documents')->where('status', 'active')->whereNull('deleted_at')->where('media_type', 'text/plain') ->whereIn('data_classification', ['public', 'internal'])->limit(100)->get(['id', 'file_name']), 'runs' => AiRun::orderByDesc('created_at')->limit(20)->get(['id', 'capability', 'mode', 'review_status', 'review_requested', 'created_at']), 'today' => now()->toDateString(), ]); } public function run(Request $request, AiGateway $gateway): JsonResponse|RedirectResponse { $data = $request->validate([ 'capability' => ['required', Rule::in(config('investmoz_ai.capabilities'))], 'purpose' => ['required', Rule::in(['investment_information', 'interest_triage', 'data_quality', 'execution_monitoring'])], 'language' => ['required', Rule::in(['pt', 'en'])], 'query' => ['required_if:capability,assistant,search', 'nullable', 'string', 'min:3', 'max:1000'], 'source_id' => ['required_if:capability,summary', 'nullable', 'uuid', 'exists:ai_sources,id'], 'interest_id' => ['required_if:capability,classify', 'nullable', 'integer', 'min:1'], 'sector_id' => ['nullable', 'integer', 'exists:sectors,id'], 'province' => ['nullable', 'string', 'max:100'], 'maturity_level' => ['nullable', 'integer', 'between:1,6'], 'nature' => ['nullable', Rule::in(['official', 'historical', 'illustrative', 'pending'])], ]); $expected = match ($data['capability']) { 'classify' => 'interest_triage', 'quality' => 'data_quality', 'sla' => 'execution_monitoring', default => 'investment_information', }; abort_unless($data['purpose'] === $expected, 422, 'Finalidade incompatível com a capacidade.'); $run = $gateway->execute($request->user(), $data); return $request->expectsJson() ? response()->json(['id' => $run->id, 'review_status' => 'pending'], 201) : redirect('/management/ai/runs/'.$run->id); } public function show(Request $request, string $id, EvidenceRepository $evidence): JsonResponse|Response { $run = AiRun::findOrFail($id); $available = $this->resultAvailable($run, $evidence); $data = ['run' => $run->only(['id', 'capability', 'mode', 'rules_version', 'prompt_version', 'latency_ms', 'version_no', 'review_status', 'review_requested', 'created_at']), 'result' => $available ? $run->result : null, 'unavailable' => ! $available]; return $request->expectsJson() ? response()->json($data) : Inertia::render('ai/result', $data); } private function resultAvailable(AiRun $run, EvidenceRepository $evidence): bool { $available = $evidence->allStillEligible($run->source_ids) && DB::table('ai_controls')->where('capability', $run->capability)->where('suspended', false)->exists(); if ($run->capability === 'classify') { $interestId = $run->result['items'][0]['record_id'] ?? null; $reference = DB::table('interest_requests')->where('id', $interestId)->whereNull('deleted_at')->value('reference'); $available = $available && $reference && DB::table('consents')->where('subject_ref', $reference) ->where('purpose', 'ai_assistance')->where('status', 'granted')->whereNotNull('granted_at')->where('granted_at', '<=', now()) ->whereNull('withdrawn_at')->whereNull('deleted_at')->exists(); } if ($run->capability === 'recommend') { $ids = array_column($run->result['items'], 'record_id'); $available = $available && Opportunity::published()->whereIn('id', $ids)->count() === count($ids); } return $available; } public function source(Request $request, SourceIngestion $ingestion): JsonResponse|RedirectResponse { $data = $request->validate([ 'document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'], 'language' => ['required', 'in:pt,en'], 'nature' => ['required', 'in:official,historical,illustrative,pending'], 'source_url' => ['required', 'url:https', 'max:2000'], 'source_owner' => ['required', 'string', 'max:200'], 'evidence_reference' => ['required', 'string', 'min:10', 'max:2000'], 'source_date' => ['required', 'date_format:Y-m-d', 'before_or_equal:today'], 'valid_until' => ['required', 'date_format:Y-m-d', 'after_or_equal:today', 'after_or_equal:source_date'], 'authorised' => ['accepted'], ]); unset($data['authorised']); $ingestion->ingest($data, $request->user()->id); return $this->completed($request, 'Fonte indexada em quarentena lógica; requer revisão por outra pessoa antes de pesquisa.'); } public function document(Request $request): JsonResponse|RedirectResponse { $request->validate(['file' => ['required', 'file', 'mimetypes:text/plain', 'max:195'], 'authorised' => ['accepted']]); $file = $request->file('file'); $text = $file->get(); if ($text === false) { abort(422, 'Não foi possível ler o ficheiro.'); } abort_unless(mb_check_encoding($text, 'UTF-8') && ! str_contains($text, "\0") && trim($text) !== '', 422, 'Apenas texto UTF-8 sem conteúdo binário.'); $id = (string) Str::uuid(); $key = 'ai-documents/'.$id.'.txt'; abort_unless(Storage::disk('local')->put($key, $text) !== false, 500, 'Não foi possível guardar o documento.'); try { DB::transaction(function () use ($request, $file, $text, $id, $key): void { DB::table('documents')->insert(['id' => $id, 'file_name' => basename($file->getClientOriginalName()), 'media_type' => 'text/plain', 'storage_key' => $key, 'sha256' => hash('sha256', $text), 'status' => 'active', 'data_classification' => 'internal']); $this->audit('documents', $id, 'plain_text_registered', $request->user()->id); }); } catch (\Throwable $exception) { Storage::disk('local')->delete($key); throw $exception; } return $request->expectsJson() ? response()->json(['document_id' => $id], 201) : back()->with('success', 'Texto guardado no cadastro documental. Registe a fonte e solicite revisão antes de o pesquisar.'); } public function reviewSource(Request $request, string $id): JsonResponse|RedirectResponse { $data = $request->validate(['status' => ['required', 'in:approved,revoked'], 'expected_status' => ['required', 'in:pending,approved,revoked']]); DB::transaction(function () use ($request, $id, $data): void { $source = DB::table('ai_sources')->where('id', $id)->lockForUpdate()->first(); abort_unless($source !== null, 404); abort_unless($source->status === $data['expected_status'], 409, 'Estado alterado. Recarregue a fonte.'); if ($data['status'] === 'approved') { abort_if((int) $source->submitted_by === $request->user()->id, 403, 'Outra pessoa deve validar a autorização.'); $valid = DB::table('documents')->where('id', $source->document_id)->where('status', 'active') ->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal']) ->where('version_no', $source->document_version)->where('sha256', $source->document_checksum)->exists(); abort_unless($valid && $source->valid_until >= now()->toDateString(), 422, 'Fonte alterada, expirada ou sem autorização.'); } DB::table('ai_sources')->where('id', $id)->update(['status' => $data['status'], 'reviewed_by' => $request->user()->id, 'reviewed_at' => now(), 'updated_at' => now()]); $this->audit('ai_sources', $id, 'source_'.$data['status'], $request->user()->id); }); return $this->completed($request, 'Estado de autorização actualizado; não constitui validação jurídica da fonte.'); } public function feedback(Request $request, string $id): JsonResponse|RedirectResponse { $data = $request->validate(['rating' => ['required', 'in:helpful,unhelpful,unsafe'], 'review_requested' => ['required', 'boolean']]); DB::transaction(function () use ($request, $id, $data): void { $run = AiRun::whereKey($id)->lockForUpdate()->firstOrFail(); DB::table('ai_feedback')->updateOrInsert(['run_id' => $id, 'user_id' => $request->user()->id], ['rating' => $data['rating'], 'created_at' => now(), 'updated_at' => now()]); if ($data['review_requested'] || $data['rating'] === 'unsafe') { $run->update(['review_requested' => true, 'version_no' => $run->version_no + 1]); } if ($data['rating'] === 'unsafe') { DB::table('ai_controls')->where('capability', $run->capability)->update(['suspended' => true, 'updated_at' => now()]); } $this->audit('ai_runs', $id, 'feedback_'.$data['rating'], $request->user()->id); }); return $this->completed($request, 'Feedback registado. Sinalização de risco suspende a capacidade para análise.'); } public function review(Request $request, string $id, EvidenceRepository $evidence): JsonResponse|RedirectResponse { $data = $request->validate(['status' => ['required', 'in:accepted,rejected'], 'version_no' => ['required', 'integer', 'min:1']]); DB::transaction(function () use ($request, $id, $data, $evidence): void { $run = AiRun::whereKey($id)->lockForUpdate()->firstOrFail(); abort_if((int) $run->actor_id === $request->user()->id, 403, 'Outra pessoa deve rever o resultado.'); abort_unless((int) $run->version_no === $data['version_no'], 409); abort_unless($data['status'] === 'rejected' || $this->resultAvailable($run, $evidence), 422, 'Fonte, consentimento ou capacidade já não elegível.'); $run->update(['review_status' => $data['status'], 'reviewed_by' => $request->user()->id, 'reviewed_at' => now(), 'version_no' => $run->version_no + 1]); $this->audit('ai_runs', $id, 'review_'.$data['status'], $request->user()->id); }); return $this->completed($request, 'Revisão registada. Nenhum estado oficial, tarefa ou registo CRM foi alterado.'); } public function control(Request $request, string $capability): JsonResponse|RedirectResponse { $data = $request->validate(['suspended' => ['required', 'boolean']]); abort_unless(in_array($capability, config('investmoz_ai.capabilities'), true), 404); DB::transaction(function () use ($request, $capability, $data): void { DB::table('ai_controls')->where('capability', $capability)->update(['suspended' => $data['suspended'], 'updated_at' => now()]); $this->audit('ai_controls', null, $capability.($data['suspended'] ? '_suspended' : '_resumed'), $request->user()->id); }); return $this->completed($request, 'Controlo actualizado.'); } private function completed(Request $request, string $message): JsonResponse|RedirectResponse { return $request->expectsJson() ? response()->json(['message' => $message]) : back()->with('success', $message); } private function audit(string $entity, ?string $id, string $action, int $actor): void { DB::table('audit_events')->insert(['source_system' => 'ai-gateway', 'entity_type' => $entity, 'entity_id' => $id, 'actor_user_id' => $actor, 'action' => $action]); } }