countIntent($query)) { return ['type' => 'count', ...$count]; } $normalized = trim(mb_strtolower(Str::ascii($query)), " \t\n\r\0\x0B?!."); if (preg_match('/^(?:como|onde|quero|preciso de)\s+(?:(?:posso|faco para|devo)\s+)?licenciar(?:$|\s+(?:(?:uma|a|minha|o|um|meu)\s+)?(?:empresa|negocio|actividade|atividade|investimento)\b)/', $normalized)) { return ['type' => 'guide', 'category' => 'Páginas e serviços', 'label' => 'Licenciamento da empresa', 'kind' => null, 'filter' => 'licenciamento']; } $product = null; if (preg_match('/^(?:(?:tipos?|lista|catalogo) de |quais (?:sao )?(?:os )?)?produtos?\b(.*)$/', $normalized, $match)) { $product = $match[1]; } elseif (preg_match('/^onde (?:encontrar|produzir|se produz|existem|ha)\s+(.+)$/', $normalized, $match)) { $product = $match[1]; } if ($product !== null) { $category = preg_match('/\bfinanceiros?\b/', $product) ? 'Produtos financeiros' : (preg_match('/\bfisicos?\b/', $product) ? 'Produtos e localizações' : null); $product = preg_replace('/\b(?:e (?:a )?(?:sua )?localizacao|e (?:as )?(?:suas )?localizacoes|financeiros?|fisicos?|existem|disponiveis|no portal)\b/', ' ', $product); return ['type' => 'products', 'category' => $category, 'label' => 'Produtos e localização', 'kind' => null, 'filter' => trim(preg_replace('/\s+/', ' ', $product))]; } if (preg_match('/^(?:listar|mostrar|mostre|ver|quais (?:sao )?(?:os|as))\s+(.+)$/', $normalized, $match) && ($list = $this->countIntent('quantos '.$match[1]))) { return ['type' => 'list', ...$list]; } return null; } /** @return array{category:string,label:string,kind:?string,filter:string}|null */ public function countIntent(string $query): ?array { $query = trim(mb_strtolower(Str::ascii($query)), " \t\n\r\0\x0B?!."); $types = [ 'corredor|corredores' => ['Carteira: projectos, clusters e corredores', 'corredores', 'corridor'], 'cluster|clusters' => ['Carteira: projectos, clusters e corredores', 'clusters', 'cluster'], 'projectos? ancora|projetos? ancora' => ['Carteira: projectos, clusters e corredores', 'projectos âncora', 'project'], 'oportunidade|oportunidades' => ['Oportunidades', 'oportunidades', null], 'negocio|negocios' => ['Negócios', 'negócios', null], 'documento|documentos' => ['Documentos e legislação', 'documentos', null], 'artigo|artigos' => ['Artigos', 'artigos', null], 'sector|sectores|setor|setores' => ['Sectores', 'sectores', null], 'localizacao|localizacoes' => ['Localizações', 'localizações', null], 'zonas? de investimento|zona|zonas' => ['Zonas de investimento', 'zonas de investimento', null], 'parceria|parcerias' => ['Parcerias', 'parcerias', null], ]; foreach ($types as $pattern => [$category, $label, $kind]) { if (preg_match('/^(?:quantos|quantas|numero de|quantidade de|total de|qual (?:e )?o numero de)\s+(?:'.$pattern.')\b(.*)$/u', $query, $match)) { $filter = preg_replace('/\b(?:no portal|no investemoz|em mocambique|de investimento|existem|existe|existentes|existente|ha|temos|estao|sao|disponiveis|disponivel|publicados|publicadas|registados|registadas|cadastrados|cadastradas)\b/u', ' ', $match[1]); return ['category' => $category, 'label' => $label, 'kind' => $kind, 'filter' => trim(preg_replace('/\s+/', ' ', $filter))]; } } return null; } /** @return list */ public function tokens(string $query): array { $normalized = mb_strtolower(Str::ascii($query)); $tokens = array_values(array_filter(array_unique(array_map( fn ($word) => trim($word, ".,;:!?\"'()[]{}"), preg_split('/\s+/u', trim($normalized), -1, PREG_SPLIT_NO_EMPTY) ?: [] )), fn ($word) => $word !== '')); if (count($tokens) > 1) { $meaningful = array_values(array_diff($tokens, ['a', 'as', 'o', 'os', 'de', 'do', 'da', 'dos', 'das', 'em', 'no', 'na', 'nos', 'nas', 'para', 'por', 'e', 'como', 'qual', 'quais', 'um', 'uma'])); return $meaningful === [] ? $tokens : $meaningful; } return $tokens; } /** Known Portuguese spelling and number variants; do not infer semantic equivalence. * @return list */ public function variants(string $token): array { foreach ([['sector', 'sectores', 'setor', 'setores'], ['projecto', 'projectos', 'projeto', 'projetos'], ['investir', 'investimento', 'investimentos'], ['investidor', 'investidores'], ['oportunidade', 'oportunidades'], ['documento', 'documentos'], ['empresa', 'empresas'], ['parceria', 'parcerias'], ['zona', 'zonas'], ['formulario', 'formularios'], ['provincia', 'provincias'], ['corredor', 'corredores'], ['cluster', 'clusters'], ['imposto', 'impostos'], ['licenca', 'licencas'], ['banco', 'bancos'], ['seguro', 'seguros'], ['produto', 'produtos'], ['agricola', 'agricolas']] as $group) { if (in_array($token, $group, true)) { return $group; } } return [$token]; } public function pattern(string $token): string { return '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $token).'%'; } /** Columns are trusted application constants, never user input. * @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query * @param list $columns */ public function orFields($query, array $columns, string $token): void { foreach ($columns as $column) { foreach ($this->variants($token) as $variant) { $query->orWhereRaw($this->normalizedColumn($column)." LIKE ? ESCAPE '!'", [$this->pattern($variant)]); } } } /** @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query * @param list $columns * @param list $tokens */ public function constrain($query, array $columns, array $tokens): void { foreach ($tokens as $token) { $query->where(fn ($match) => $this->orFields($match, $columns, $token)); } } /** @param list $tokens */ public function matches(string $text, array $tokens): bool { $text = mb_strtolower(Str::ascii($text)); foreach ($tokens as $token) { if (! array_filter($this->variants($token), fn ($variant) => str_contains($text, $variant))) { return false; } } return true; } /** @param literal-string $column * @return literal-string */ private function normalizedColumn(string $column): string { return "translate(lower(coalesce({$column}::text, '')), 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn')"; } /** @param list $tokens */ public function score(string $title, string $phrase, array $tokens): int { $title = mb_strtolower(Str::ascii($title)); $phrase = mb_strtolower(Str::ascii(trim($phrase))); $score = $title === $phrase ? 1000 : (str_starts_with($title, $phrase) ? 600 : (str_contains($title, $phrase) ? 400 : 0)); foreach ($tokens as $token) { if ($this->matches($title, [$token])) { $score++; } } return $score; } /** The same title score used for in-memory pages, with bound search terms. * @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query * @param list $tokens * @param literal-string $titleColumn * @param literal-string $tieColumn */ public function orderByRelevance($query, string $titleColumn, string $phrase, array $tokens, string $tieColumn): void { $column = $this->normalizedColumn($titleColumn); $phrase = mb_strtolower(Str::ascii(trim($phrase))); $sql = "CASE WHEN {$column} = ? THEN 1000 WHEN {$column} LIKE ? ESCAPE '!' THEN 600 WHEN {$column} LIKE ? ESCAPE '!' THEN 400 ELSE 0 END"; $bindings = [$phrase, ltrim($this->pattern($phrase), '%'), $this->pattern($phrase)]; foreach ($tokens as $token) { $conditions = []; foreach ($this->variants($token) as $variant) { $conditions[] = "{$column} LIKE ? ESCAPE '!'"; $bindings[] = $this->pattern($variant); } $sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 1 ELSE 0 END'; } $query->orderByRaw('('.$sql.') DESC', $bindings); if (str_contains($titleColumn, '(')) { $query->orderByRaw($titleColumn); } else { $query->orderBy($titleColumn); } $query->orderBy($tieColumn); } }