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

104 lines
5.7 KiB
PHP

<?php
namespace App\Services\Ai;
use App\Models\CrmTask;
use App\Models\InterestRequest;
use App\Models\Lead;
use App\Models\Opportunity;
use Illuminate\Support\Facades\DB;
final class AssistiveRules
{
/** @param array<string, mixed> $input
* @return list<array<string, mixed>>
*/
public function recommend(array $input): array
{
$query = Opportunity::published();
$factors = [];
foreach (['sector_id', 'province', 'maturity_level'] as $field) {
if (isset($input[$field]) && $input[$field] !== '') {
$query->where($field, $input[$field]);
$factors[] = $field;
}
}
if ($factors === []) {
return [];
}
return array_values($query->orderBy('id')->limit(10)->get()->map(fn ($opportunity) => [
'record_type' => 'opportunity', 'record_id' => $opportunity->id, 'title' => $opportunity->title,
'source_url' => '/oportunidades/'.$opportunity->slug,
'score' => count($factors), 'score_max' => 3, 'score_meaning' => 'matched_declared_filters',
'favourable_factors' => $factors, 'incompatibilities' => [],
'eliminatory_conditions' => ['not_published', 'mismatch_with_any_selected_filter'],
'missing_data' => array_filter(['budget', 'time_horizon', 'risk_profile',
$opportunity->last_validated_at ? null : 'validation_date']),
'source_date' => $opportunity->last_validated_at?->toIso8601String(),
'semantic_similarity' => null,
])->all());
}
/** @return list<array<string, mixed>> */
public function classify(int $id): array
{
$interest = InterestRequest::findOrFail($id);
$consent = DB::table('consents')->where('subject_ref', $interest->reference)->where('purpose', 'ai_assistance')
->where('status', 'granted')->whereNotNull('granted_at')->where('granted_at', '<=', now())->whereNull('deleted_at')->whereNull('withdrawn_at')->exists();
abort_unless($consent, 422, 'É necessário consentimento específico registado para assistência sobre este pedido.');
// No name, email, phone, message or sensitive attribute is used.
return [[
'record_type' => 'interest', 'record_id' => $interest->id,
'source_url' => '/management/interests/'.$interest->id,
'sector_declared' => $interest->sector_interest,
'investment_range_declared' => $interest->investment_range,
'intention' => $interest->opportunity_id ? 'interest_in_identified_opportunity' : 'general_information',
'priority' => 'human_review_required', 'suggested_owner' => null,
'next_action' => config('investmoz_ai.classification.next_action'),
'missing_data' => ['confirmed_capacity', 'time_horizon', 'responsible_person', 'institutional_priority'],
'source_date' => $interest->created_at?->toIso8601String(),
]];
}
/** @return list<array<string, mixed>> */
public function quality(): array
{
$alerts = [];
foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereNull('owner_user_id')->orderBy('id')->limit(20)->get(['id']) as $lead) {
$alerts[] = ['record_type' => 'lead', 'record_id' => $lead->id, 'rule' => 'missing_owner', 'source_url' => '/management/crm/leads/'.$lead->id];
}
foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereIn('status', ['new', 'qualified'])->whereDoesntHave('tasks', fn ($q) => $q->whereIn('status', ['open', 'in_progress']))
->orderBy('id')->limit(20)->get(['id']) as $lead) {
$alerts[] = ['record_type' => 'lead', 'record_id' => $lead->id, 'rule' => 'missing_next_action', 'source_url' => '/management/crm/leads/'.$lead->id];
}
foreach (DB::table('ai_sources')->where('valid_until', '<', now()->toDateString())->orderBy('id')->limit(20)->get(['id', 'valid_until']) as $source) {
$alerts[] = ['record_type' => 'source', 'record_id' => $source->id, 'rule' => 'expired_source', 'valid_until' => $source->valid_until];
}
foreach (DB::table('documents')->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])
->select('sha256')->selectRaw('count(*) as copies')->groupBy('sha256')->havingRaw('count(*) > 1')->limit(20)->get() as $duplicate) {
$alerts[] = ['record_type' => 'document_group', 'rule' => 'duplicate_document_checksum', 'copies' => (int) $duplicate->copies];
}
foreach (DB::table('geo_geometries')->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])
->whereRaw('NOT ST_IsValid(geom)')->limit(20)->get(['id']) as $geometry) {
$alerts[] = ['record_type' => 'geometry', 'record_id' => $geometry->id, 'rule' => 'invalid_geometry'];
}
return $alerts;
}
/** @return list<array<string, mixed>> */
public function sla(): array
{
return array_values(CrmTask::whereIn('data_classification', ['public', 'internal'])->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())->orderBy('due_at')->limit(50)
->get()->map(fn ($task) => [
'record_type' => 'task', 'record_id' => $task->id, 'rule' => 'overdue_open_task',
'due_at' => $task->getAttribute('due_at')?->toIso8601String(), 'lead_id' => $task->lead_id,
'source_url' => $task->lead_id ? '/management/crm/leads/'.$task->lead_id : null,
'suggested_action' => 'review_owner_deadline_and_dependencies',
'impact' => 'not_estimated', 'priority' => 'human_review_required',
])->all());
}
}