Files
Investidor-V2/app/Services/SearchTerms.php
T

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);
}
}