Files
compras/app/Services/AiPreparationPlan.php
nala c6373365bf
tests / ci (push) Canceled after 0s
commit9
2026-10-30 20:02:30 +02:00

94 lines
5.5 KiB
PHP

<?php
namespace App\Services;
use App\Models\Tenant;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class AiPreparationPlan
{
public const DOMAINS = ['procurement' => 'Compras', 'people' => 'Pessoas e contratações'];
public const SCOPES = ['process_state' => 'Estado e regras do processo', 'comparison_state' => 'Estado da comparação de propostas',
'documentary_state' => 'Versão e actualidade documental', 'fulfilment_counts' => 'Ordem, minuta e contagens de recepção',
'selection_state' => 'Estado da selecção de pessoas', 'candidate_counts' => 'Contagem de candidaturas',
'clarification_counts' => 'Contagem de esclarecimentos', 'financial_counts' => 'Contagens financeiras autorizadas'];
public const DEFAULTS = ['state' => 'draft', 'provider' => 'none', 'model' => '', 'domains' => [], 'data_scopes' => [],
'monthly_budget_usd' => '0.00', 'monthly_requests' => 0, 'max_input_bytes' => 4500, 'max_output_tokens' => 1000];
/** @return array<string,mixed> */
public function company(Tenant $company): array
{
return app(IntelligencePolicy::class)->company($company)['ai_plan'] ?? self::DEFAULTS;
}
/** @return array<string,mixed> */
public function rules(): array
{
return ['provider' => ['required', Rule::in(['none', 'openai'])],
'model' => ['nullable', 'required_if:provider,openai', 'string', 'max:120', 'regex:/^[A-Za-z0-9][A-Za-z0-9_.-]*$/D', 'not_regex:/^sk-/i'],
'domains' => 'present|array|list|max:2', 'domains.*' => ['string', 'distinct', Rule::in(array_keys(self::DOMAINS))],
'data_scopes' => 'present|array|list|max:8', 'data_scopes.*' => ['string', 'distinct', Rule::in(array_keys(self::SCOPES))],
'monthly_budget_usd' => ['required', 'string', 'regex:/^(0|[1-9][0-9]{0,4})(\.[0-9]{1,2})?$/D'],
'monthly_requests' => 'required|integer|between:0,100000', 'max_input_bytes' => 'required|integer|between:512,64000',
'max_output_tokens' => 'required|integer|between:64,4000', 'api_key' => 'prohibited', 'endpoint' => 'prohibited',
'state' => 'prohibited', 'external_processing_enabled' => 'prohibited'];
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function checked(array $input): array
{
$data = Validator::make($input, $this->rules())->validate();
if ($data['provider'] !== 'none' && ($data['domains'] === [] || $data['data_scopes'] === [] || $data['monthly_requests'] < 1 || BigDecimal::of($data['monthly_budget_usd'])->isLessThanOrEqualTo(0))) {
throw ValidationException::withMessages(['provider' => 'Para preparar um fornecedor, escolha percursos, dados e limites mensais positivos.']);
}
return ['state' => 'draft', 'provider' => $data['provider'], 'model' => $data['model'] ?? '', 'domains' => $data['domains'], 'data_scopes' => $data['data_scopes'],
'monthly_budget_usd' => (string) BigDecimal::of($data['monthly_budget_usd'])->toScale(2), 'monthly_requests' => (int) $data['monthly_requests'],
'max_input_bytes' => (int) $data['max_input_bytes'], 'max_output_tokens' => (int) $data['max_output_tokens']];
}
/**
* The caller supplies an already authorised, redacted factual brief.
* This preview neither sends requests nor reserves a spending allowance.
*
* @param array<string,mixed> $brief
* @return array<string,mixed>
*/
public function preview(Tenant $company, string $domain, array $brief): array
{
$plan = $this->company($company);
$base = ['status' => 'not_configured', 'external_execution_enabled' => false, 'usage_checked' => false,
'policy_revision' => app(IntelligencePolicy::class)->company($company)['revision'], 'provider' => $plan['provider'], 'model' => $plan['model'],
'domain' => $domain, 'max_input_bytes' => $plan['max_input_bytes'], 'max_output_tokens' => $plan['max_output_tokens'], 'input_bytes' => 0, 'facts' => []];
if ($plan['provider'] === 'none') {
return $base;
}
if (! in_array($domain, $plan['domains'], true)) {
return [...$base, 'status' => 'outside_scope'];
}
$map = match ($domain) {
'procurement' => ['request' => 'process_state', 'comparison' => 'comparison_state', 'qualification' => 'documentary_state', 'opening' => 'documentary_state',
'order' => 'fulfilment_counts', 'receipts' => 'fulfilment_counts', 'finance' => 'financial_counts'],
'people' => ['rules' => 'process_state', 'candidates' => 'candidate_counts', 'selection' => 'selection_state', 'contract' => 'fulfilment_counts', 'clarifications' => 'clarification_counts'],
default => [],
};
$facts = [];
foreach ($brief['claims'] as $claim) {
if (isset($map[$claim['id']]) && in_array($map[$claim['id']], $plan['data_scopes'], true)) {
$facts[] = ['id' => $claim['id'], 'text' => $claim['text'], 'source' => $claim['source']];
}
}
$bytes = strlen(json_encode(['facts' => $facts], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
return [...$base, 'status' => $facts === [] ? 'no_permitted_facts' : ($bytes > $plan['max_input_bytes'] ? 'input_exceeds_limit' : 'draft'),
'input_bytes' => $bytes, 'facts' => $bytes > $plan['max_input_bytes'] ? [] : $facts];
}
}