197 lines
9.3 KiB
PHP
197 lines
9.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Str;
|
|
|
|
/** Text matching shared by the database and the built-in page catalogue. */
|
|
final class SearchTerms
|
|
{
|
|
/** @return array{type:string,category:?string,label:string,kind:?string,filter:string}|null */
|
|
public function requestIntent(string $query): ?array
|
|
{
|
|
if ($count = $this->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<string> */
|
|
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<string>
|
|
*/
|
|
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<literal-string> $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<literal-string> $columns
|
|
* @param list<string> $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<string> $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<string> $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<string> $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);
|
|
}
|
|
}
|