Files
Investidor-V2/app/Http/Controllers/AiController.php
T
nala 250ac35fd1
tests / ci (push) Canceled after 0s
first commit
2026-09-28 10:01:09 +02:00

213 lines
12 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\AiRun;
use App\Models\Opportunity;
use App\Services\Ai\AiGateway;
use App\Services\Ai\EvidenceRepository;
use App\Services\Ai\SourceIngestion;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
final class AiController extends Controller
{
public function index(): Response
{
return Inertia::render('ai/index', [
'controls' => 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]);
}
}