extended search params for ANY extended search object * (sys_objects_search_extended, fields configured in Studio > Forms > Search fields). * * "python course under 50 in Berlin" for object bx_market -> * search_params = [ * cat => [type => select_multiple, value => [3], operator => in], * price_single => [type => text_range, value => ['', 50], operator => between], * location => [type => location, value => [array => [0, 0, '', '', 'Berlin', ''], string => 'Berlin'], operator => locate], * title => [type => text, value => 'python', operator => like], * ] * which is exactly what BxDolSearchExtended::getResults(['search_params' => ...]) / the module's * get_search_result_extended consume. Questions to the judge model are generated from the * field list, so a field added or removed in Studio changes the parser automatically. * * Field search types handled: select / select_multiple / checkbox_set (pre-values list -> choice), * checkbox / switcher (noul), text_range / slider (numbers from the query + choice min/max/exact), * datepicker_range / datetime_range (calendar logic of BxDolAiSearchParser), location (city word + * country choice), text (topic words -> like). Others are ignored. */ class BxDolAiSearchExtParser extends BxDolAiSearchParser { const LIST_OPTIONS_MAX = 250; const LIST_PROBABILITY_MIN = 0.3; const BOOL_MIN = 0.6; const RADIUS_DEFAULT_KM = 50; // radius search when the query names a place but no distance const RADIUS_MAX_KM = 500; const CACHE_TTL_GEO_MISS = 600; // a timeout or an empty answer is not a permanent "not found" protected $_aFields = []; protected $_aNumbers = []; protected $_aTokens = []; protected $_sQuery = ''; protected $_sObject = ''; protected $_iRadius = 0; // km, when the query says "within 20 km" public static function getInstance() { if (!isset($GLOBALS['bxDolClasses'][__CLASS__])) $GLOBALS['bxDolClasses'][__CLASS__] = new BxDolAiSearchExtParser(); return $GLOBALS['bxDolClasses'][__CLASS__]; } /** * Fields of an extended search object that the parser can ask about, for the block (chips legend). * @return array [name => [caption, kind]] */ public function getFields(string $sObject): array { $aResult = []; foreach ($this->_loadFields($sObject) as $sName => $aField) $aResult[$sName] = ['caption' => $aField['caption'], 'kind' => $aField['kind']]; return $aResult; } /** * @param $sObject extended search object name (e.g. bx_market) * @param $sQuery natural language query * @param $aOptions timezone, debug * @return array * object, query, search_params (ready for get_results), chips [name => [field, caption, label, value]], * confidence [name => float], judge => null | [model, ms, usage], error / request / answers (debug) */ public function parseObject(string $sObject, string $sQuery, array $aOptions = []): array { $sQuery = trim(preg_replace('/\s+/u', ' ', $sQuery)); $oTz = $this->_getTimezone($aOptions['timezone'] ?? ''); $oNow = new DateTime('now', $oTz); $aResult = [ 'object' => $sObject, 'query' => $sQuery, 'search_params' => [], 'filters' => [], 'keyword_field' => '', 'chips' => [], 'confidence' => [], 'judge' => null, ]; $this->_sObject = $sObject; $this->_aFields = $this->_loadFields($sObject); if ($sQuery === '' || !$this->_oJudge || !$this->_aFields) return $aResult; $this->_sQuery = $sQuery; $this->_iRadius = $this->_getRadius($sQuery); $this->_aNumbers = $this->_getNumbers($sQuery); $this->_aTokens = $this->_getTopicTokens($sQuery); $aQuestions = $this->_getObjectQuestions(); if (!$aQuestions) return $aResult; $aState = ['today' => $oNow->format('Y-m-d l'), 'query' => $sQuery]; $fStart = microtime(true); $aAnswers = $this->_oJudge->askSafe($aState, $aQuestions); if (!$aAnswers) { if (!empty($aOptions['debug'])) { $aResult['request'] = $this->_oJudge->getLastRequest(); $aResult['error'] = $this->_oJudge->getLastError() ?: 'empty answers'; } return $aResult; } $this->_aAnswers = $aAnswers; $aResult['judge'] = [ 'model' => $this->_oJudge->getLastModel(), 'ms' => (int)round((microtime(true) - $fStart) * 1000), 'usage' => $this->_oJudge->getLastUsage(), ]; if (!empty($aOptions['debug'])) { $aResult['request'] = $this->_oJudge->getLastRequest(); $aResult['answers'] = $aAnswers; } foreach ($this->_aFields as $sName => $aField) { $aParam = null; switch ($aField['kind']) { case 'list': $aParam = $this->_answerList($sName, $aField); break; case 'bool': $aParam = $this->_answerBool($sName, $aField); break; case 'number': continue 2; // handled once per group below case 'date': $aParam = $this->_answerDate($sName, $aField, $oNow); break; case 'location': $aParam = $this->_answerLocation($sName, $aField); break; case 'text': $aParam = $this->_answerText($sName, $aField); break; } if (!$aParam) continue; $aResult['search_params'][$sName] = [ 'type' => $aField['search_type'], 'value' => $aParam['value'], 'operator' => $aField['operator'], ]; // same shape the search form would submit, for the page's own results block $aFilters = $this->_getFilters($sName, $aField, $aParam); foreach ($aFilters as $sKey => $mixedValue) $aResult['filters'][$sKey] = $mixedValue; $aResult['chips'][$sName] = [ 'field' => $sName, 'caption' => $aField['caption'], 'label' => $aParam['label'], 'value' => $aParam['value'], 'filter_keys' => array_keys($aFilters), ]; $aResult['confidence'][$sName] = $aParam['confidence']; if ($aField['kind'] == 'text') $aResult['keyword_field'] = $sName; } $this->_applyNumbers($aResult); return $aResult; } /** * Numeric fields with the same caption are one thing for the user (Market has two "Price" fields: * one-time and recurring), so the query gets one constraint, one chip and a filter for every field * of the group. An upper bound / "free" is applied to all of them (conditions are ANDed and the * price a product does not use is 0); a lower bound or an exact value would then exclude everything, * so it goes to the field the model picked. */ protected function _applyNumbers(array &$aResult) { $aGroups = $this->_getNumberGroups(); if (!$aGroups) return; $sGroup = $this->_getNumberGroup($aGroups); if ($sGroup === '' || empty($aGroups[$sGroup])) return; $aParam = $this->_answerNumber(); if (!$aParam) return; $aNames = $aGroups[$sGroup]; if (!in_array($aParam['op'], ['max', 'zero'], true)) { $sPrimary = $this->_getNumberPrimaryField($aNames); $aNames = [$sPrimary]; } $aFilterKeys = []; foreach ($aNames as $sName) { $aField = $this->_aFields[$sName]; $aResult['search_params'][$sName] = [ 'type' => $aField['search_type'], 'value' => $aParam['value'], 'operator' => $aField['operator'], ]; foreach ($this->_getFilters($sName, $aField, $aParam) as $sKey => $mixedValue) { $aResult['filters'][$sKey] = $mixedValue; $aFilterKeys[] = $sKey; } } $sChipId = reset($aNames); $aResult['chips'][$sChipId] = [ 'field' => $sChipId, 'caption' => $sGroup, 'label' => $aParam['label'], 'value' => $aParam['value'], 'filter_keys' => $aFilterKeys, ]; $aResult['confidence'][$sChipId] = $aParam['confidence']; } /** * Numeric fields grouped by caption: [caption => [field name, ...]] */ protected function _getNumberGroups(): array { $aGroups = []; foreach ($this->_aFields as $sName => $aField) if ($aField['kind'] == 'number') $aGroups[$aField['caption']][] = $sName; return $aGroups; } /** * Which group the query is about: the only one, or the one the model picked. */ protected function _getNumberGroup(array $aGroups): string { if (count($aGroups) == 1) return (string)key($aGroups); list($sChoice, $fConfidence) = $this->_getChoice('num_field'); if ($sChoice === '' || $sChoice == self::NONE || $fConfidence < self::CONFIDENCE_MIN || !isset($aGroups[$sChoice])) return ''; return $sChoice; } /** * Field of the group a lower / exact bound is applied to: the one the model picked, else the first. */ protected function _getNumberPrimaryField(array $aNames): string { if (count($aNames) > 1 && isset($this->_aAnswers['num_which'])) { list($sChoice, $fConfidence) = $this->_getChoice('num_which'); if ($sChoice !== '' && $sChoice != self::NONE && $fConfidence >= self::CONFIDENCE_MIN && in_array($sChoice, $aNames, true)) return $sChoice; } return reset($aNames); } /** * One parsed field -> values as the search form would submit them ($_POST keys), * so `get_results` (params.filters) builds exactly the same search as a manual form submit. * @return array [input name => value] */ protected function _getFilters(string $sName, array $aField, array $aParam): array { switch ($aField['kind']) { case 'list': // select posts one value, multiple selects post an array return [$sName => $aField['multi'] ? (array)$aParam['value'] : (is_array($aParam['value']) ? reset($aParam['value']) : $aParam['value'])]; case 'bool': return [$sName => 1]; case 'number': // *_range inputs are rendered as name[] twice (from, to) return [$sName => [(string)$aParam['value'][0], (string)$aParam['value'][1]]]; case 'date': // the form checker converts date strings to timestamps itself return [$sName => [$aParam['date_from'], $aParam['date_to']]]; case 'location': // the main input holds the address string, the rest is read from {name}_{key} inputs $aLocation = $aParam['value']['array']; $aKeys = ['lat', 'lng', 'country', 'state', 'city', 'zip', 'street', 'street_number']; $aFilters = [$sName => $aParam['value']['string']]; foreach ($aKeys as $iIndex => $sKey) $aFilters[$sName . '_' . $sKey] = isset($aLocation[$iIndex]) ? $aLocation[$iIndex] : ''; return $aFilters; case 'text': default: return [$sName => $aParam['value']]; } } // fields ------------------------ /** * Field name used as a caption when the language string is missing: price_recurring -> Price recurring. */ protected function _getFieldCaption(string $sName): string { $s = str_replace('_', ' ', $sName); return mb_strtoupper(mb_substr($s, 0, 1)) . mb_substr($s, 1); } /** * Active fields of the extended search object, normalized: * [name => [caption, kind (list|bool|number|date|location|text), multi, values, search_type, operator, type]] */ protected function _loadFields(string $sObject): array { bx_import('BxDolForm'); // BX_DATA_LISTS_KEY_PREFIX, used while reading the fields' pre-values $aObject = BxDolSearchExtendedQuery::getSearchObject($sObject); if (!$aObject || empty($aObject['fields']) || !is_array($aObject['fields'])) return []; $aFields = []; foreach ($aObject['fields'] as $aField) { if (empty($aField['active'])) continue; $sName = $aField['name']; $sSearchType = $aField['search_type']; $sOperator = $aField['search_operator']; $sCaption = trim(strip_tags(_t($aField['caption']))); if ($sCaption === '' || $sCaption == $aField['caption']) $sCaption = $this->_getFieldCaption($sName); $a = ['caption' => $sCaption, 'search_type' => $sSearchType, 'operator' => $sOperator, 'type' => $aField['type'], 'multi' => false, 'values' => []]; switch ($sSearchType) { case 'select': case 'select_multiple': case 'checkbox_set': $aValues = []; if (!empty($aField['values']) && is_array($aField['values'])) foreach ($aField['values'] as $mixedKey => $mixedValue) { if ($mixedKey === '' || $mixedKey === 0 || $mixedKey === '0' || is_array($mixedValue)) continue; $aValues[(string)$mixedKey] = trim(strip_tags(_t((string)$mixedValue))); } if (count($aValues) < 1 || count($aValues) > self::LIST_OPTIONS_MAX) continue 2; $a['kind'] = 'list'; $a['multi'] = $sSearchType != 'select'; $a['values'] = $aValues; break; case 'checkbox': case 'switcher': $a['kind'] = 'bool'; break; case 'text_range': case 'slider': $a['kind'] = 'number'; break; case 'datepicker_range': case 'datetime_range': $a['kind'] = 'date'; break; case 'location': case 'location_radius': $a['kind'] = 'location'; break; case 'text': if ($sOperator != 'like' || in_array($aField['type'], ['textarea'])) continue 2; $a['kind'] = 'text'; break; default: continue 2; } $aFields[$sName] = $a; } return $aFields; } // questions ------------------------ protected function _getObjectQuestions(): array { $aQuestions = []; $aDateFields = []; $aTextFields = []; $aNumberFields = []; $bLocation = false; foreach ($this->_aFields as $sName => $aField) { switch ($aField['kind']) { case 'list': $aCriteria = $aField['values']; $aCriteria[self::NONE] = "The query does not specify {$aField['caption']}"; $aQuestions["f_{$sName}"] = BxDolAiJudge::choice( "Which {$aField['caption']} does the user ask for? The query may be in any language; match by meaning." . ($aField['multi'] ? ' Several may apply.' : ''), $aCriteria ); break; case 'bool': $aQuestions["f_{$sName}"] = BxDolAiJudge::noul("Does the user explicitly ask for items where \"{$aField['caption']}\" is true / enabled / yes?"); break; case 'number': $aNumberFields[$sName] = $aField['caption']; break; case 'date': $aDateFields[$sName] = $aField['caption']; break; case 'location': $bLocation = true; break; case 'text': $aTextFields[$sName] = $aField['caption']; break; } } if ($aNumberFields) { $sCaptions = implode(' / ', array_unique(array_values($aNumberFields))); // one constraint per query: which numeric thing it is about, and how $aGroups = $this->_getNumberGroups(); if (count($aGroups) > 1) { $aCriteria = []; foreach ($aGroups as $sCaption => $aNames) $aCriteria[$sCaption] = $sCaption; $aQuestions['num_field'] = BxDolAiJudge::choice( 'If the query limits a number, which one does it mean?', $aCriteria + [self::NONE => 'The query does not limit any of these'] ); } // fields sharing a caption are alternatives (one-time vs recurring price): for a lower or // exact bound only one of them can be meant foreach ($aGroups as $sCaption => $aNames) { if (count($aNames) < 2) continue; $aCriteria = []; foreach ($aNames as $sName) $aCriteria[$sName] = $this->_getFieldCaption($sName); $aQuestions['num_which'] = BxDolAiJudge::choice( "\"{$sCaption}\" is stored in several ways. Which one does the query mean?", $aCriteria + [self::NONE => 'Not specified'] ); break; } if ($this->_aNumbers) { $sNumbers = implode(', ', $this->_aNumbers); $aQuestions['num_op'] = BxDolAiJudge::choice( "The query contains the number(s) {$sNumbers}. How do they limit {$sCaptions}? Signs >, <, >=, <= and words under, over, from, up to, at least, max, min say how.", [ 'max' => 'at most / under / below / cheaper than the number (<, <=, under, up to, max)', 'min' => 'at least / over / above / more than the number (>, >=, from, at least, min)', 'exact' => 'exactly the number', 'range' => 'between two numbers', self::NONE => 'The number is not a limit for it (a date, a quantity of something else, part of a name)', ] ); } $aQuestions['num_zero'] = BxDolAiJudge::noul("Does the user ask for items that are free — no charge, cost nothing, zero {$sCaptions}?"); } if ($aDateFields) { $aQuestions['time_mode'] = BxDolAiJudge::choice('How is the date written in the query?', [ 'absolute' => 'A calendar date: 23/09, 23.09.2026, September 23, 23 sep', 'relative' => 'Relative to today: today, yesterday, tomorrow, this/last/next week, this weekend, this/last/next month', self::NONE => 'No date in the query', ]); $aDays = []; for ($i = 1; $i <= 31; $i++) $aDays[(string)$i] = "Day {$i} of the month"; $aDays[self::NONE] = 'No day of month is written'; $aQuestions['day'] = BxDolAiJudge::choice('Which day of the month is written in the query? In numeric dates like 23/09 the day comes first.', $aDays); $aQuestions['month'] = BxDolAiJudge::choice('Which month is written in the query? In numeric dates like 23/09 the month comes second.', [ '1' => 'January', '2' => 'February', '3' => 'March', '4' => 'April', '5' => 'May', '6' => 'June', '7' => 'July', '8' => 'August', '9' => 'September', '10' => 'October', '11' => 'November', '12' => 'December', self::NONE => 'No month is written', ]); $aQuestions['relative'] = BxDolAiJudge::choice('If the date is relative to today, which period is meant?', $this->_aRelative + [ self::NONE => 'The date is not relative, or there is no date', ]); if (count($aDateFields) > 1) $aQuestions['date_field'] = BxDolAiJudge::choice('If the query mentions a date, which of these dates is meant?', $aDateFields + [ self::NONE => 'No date in the query', ]); } if ($bLocation && $this->_aTokens) { // the word is geocoded afterwards, so any language and spelling works $aQuestions['loc_city'] = BxDolAiJudge::choice( 'Which word names a place the items should be at — a city, town, area or country?', $this->_tokensCriteria() + [self::NONE => 'No place in the query'] ); } if ($aTextFields && $this->_aTokens) { $aTopicCriteria = $this->_tokensCriteria(true); if ($aTopicCriteria) $aQuestions['topic'] = BxDolAiJudge::choice( 'Which word names the topic, subject, product, title or name to search for in the text of the items? Words naming a kind of content, categories, places, dates, numbers, prices, time words, verbs like "find/show" and function words are NOT topic words.', $aTopicCriteria + [self::NONE => 'No topic word: the query only has categories, places, numbers or time'] ); } return $aQuestions; } protected function _tokensCriteria(bool $bSkipFieldWords = false): array { $aSkip = $bSkipFieldWords ? $this->_getFieldWords() : []; $aCriteria = []; foreach ($this->_aTokens as $sToken) if (!isset($aSkip[mb_strtolower($sToken)])) $aCriteria[$sToken] = 'The word "' . $sToken . '"'; return $aCriteria; } /** * Words of the field captions ("price", "category", "location"...): they name a filter, * not the thing being searched for, so they must not become the keyword. */ protected function _getFieldWords(): array { $aWords = []; foreach ($this->_aFields as $aField) { foreach (preg_split('/[^\p{L}\p{N}]+/u', $aField['caption'], -1, PREG_SPLIT_NO_EMPTY) as $sWord) if (mb_strlen($sWord) > 2) $aWords[mb_strtolower($sWord)] = true; } return $aWords; } /** * Numbers written in the query (integers / decimals), in order of appearance; parts of dates (23/09) are skipped. */ protected function _getNumbers(string $sQuery): array { // drop date literals (23/09, 23.09.2026) but keep decimals like 50.5 $sClean = preg_replace_callback('/(?= 1 && $iA <= 31 && $iB >= 1 && $iB <= 12) || ($iA >= 1 && $iA <= 12 && $iB >= 1 && $iB <= 31); return $bDate ? ' ' : $m[0]; }, $sQuery); if (!preg_match_all('/(?_iRadius > 0 && $fNumber == (float)$this->_iRadius) continue; // it is the search radius, not a value of a field $aNumbers[] = $fNumber; } return $aNumbers; } // answers ------------------------ /** * @return array|null [value, label, confidence] */ protected function _answerList(string $sName, array $aField) { list($sChoice, $fConfidence) = $this->_getChoice("f_{$sName}"); if ($sChoice === '' || $sChoice == self::NONE || $fConfidence < self::CONFIDENCE_MIN) return null; $aKeys = []; if ($aField['multi']) { $aProbabilities = $this->_getProbabilities("f_{$sName}"); foreach ($aField['values'] as $sKey => $sTitle) if ($sKey == $sChoice || ($aProbabilities[$sKey] ?? 0) >= self::LIST_PROBABILITY_MIN) $aKeys[] = $sKey; } if (!$aKeys) $aKeys = [$sChoice]; $aTitles = []; foreach ($aKeys as $sKey) $aTitles[] = $aField['values'][$sKey] ?? $sKey; return [ 'value' => $aField['operator'] == 'in' || $aField['operator'] == 'and' ? $aKeys : $aKeys[0], 'label' => implode(', ', $aTitles), 'confidence' => $fConfidence, ]; } protected function _answerBool(string $sName, array $aField) { $a = $this->_aAnswers["f_{$sName}"] ?? null; if (!is_array($a) || !isset($a['noul']) || (float)$a['noul'] < self::BOOL_MIN) return null; return ['value' => 1, 'label' => $aField['caption'], 'confidence' => (float)$a['noul']]; } /** * The numeric constraint of the query: ['op' => max|min|exact|range|zero, 'value' => [from, to], 'label', 'confidence'] */ protected function _answerNumber() { $aZero = $this->_aAnswers['num_zero'] ?? null; $bZero = is_array($aZero) && isset($aZero['noul']) && (float)$aZero['noul'] >= self::BOOL_MIN; if (!$this->_aNumbers) { if (!$bZero) return null; return ['op' => 'zero', 'value' => ['', 0], 'label' => '0', 'confidence' => (float)$aZero['noul']]; } list($sChoice, $fConfidence) = $this->_getChoice('num_op'); if ($bZero && ($sChoice === '' || $sChoice == self::NONE || $fConfidence < self::CONFIDENCE_MIN)) return ['op' => 'zero', 'value' => ['', 0], 'label' => '0', 'confidence' => (float)$aZero['noul']]; if ($sChoice === '' || $sChoice == self::NONE || $fConfidence < self::CONFIDENCE_MIN) return null; $fNumber = $this->_aNumbers[0]; // "> 0" / ">= 0" is no constraint at all (UNA's between ignores an empty/zero lower bound) if ($sChoice == 'min' && $fNumber <= 0) return null; $sFormatted = rtrim(rtrim(number_format($fNumber, 2, '.', ''), '0'), '.'); switch ($sChoice) { case 'max': return ['op' => 'max', 'value' => ['', $fNumber], 'label' => "≤ {$sFormatted}", 'confidence' => $fConfidence]; case 'min': return ['op' => 'min', 'value' => [$fNumber, ''], 'label' => "≥ {$sFormatted}", 'confidence' => $fConfidence]; case 'exact': return ['op' => 'exact', 'value' => [$fNumber, $fNumber], 'label' => "= {$sFormatted}", 'confidence' => $fConfidence]; case 'range': if (count($this->_aNumbers) < 2) return null; $fMin = min($this->_aNumbers[0], $this->_aNumbers[1]); $fMax = max($this->_aNumbers[0], $this->_aNumbers[1]); return ['op' => 'range', 'value' => [$fMin, $fMax], 'label' => rtrim(rtrim(number_format($fMin, 2, '.', ''), '0'), '.') . ' – ' . rtrim(rtrim(number_format($fMax, 2, '.', ''), '0'), '.'), 'confidence' => $fConfidence]; } return null; } protected function _answerDate(string $sName, array $aField, DateTime $oNow) { // several date fields: the model picks which one the date is about if (isset($this->_aAnswers['date_field'])) { list($sChoice, $fConfidence) = $this->_getChoice('date_field'); if ($sChoice != $sName || $fConfidence < self::CONFIDENCE_MIN) return null; } $aDate = $this->_getDate($this->_sQuery, $oNow, $sName); if (!$aDate) return null; $oTz = $oNow->getTimezone(); $oFrom = new DateTime($aDate['date']['from'] . ' 00:00:00', $oTz); $oTo = new DateTime($aDate['date']['to'] . ' 23:59:59', $oTz); $bTimestamps = $aField['search_type'] == 'datetime_range' || $aField['type'] == 'datetime'; $mixedFrom = $bTimestamps ? $oFrom->getTimestamp() : $oFrom->format('Y-m-d'); $mixedTo = $bTimestamps ? $oTo->getTimestamp() : $oTo->format('Y-m-d'); $sLabel = $aDate['date']['from'] == $aDate['date']['to'] ? $aDate['date']['from'] : $aDate['date']['from'] . ' – ' . $aDate['date']['to']; return [ 'value' => [$mixedFrom, $mixedTo], 'date_from' => $bTimestamps ? $oFrom->format('Y-m-d H:i:s') : $oFrom->format('Y-m-d'), 'date_to' => $bTimestamps ? $oTo->format('Y-m-d H:i:s') : $oTo->format('Y-m-d'), 'label' => $sLabel, 'confidence' => $aDate['confidence'], ]; } protected function _answerLocation(string $sName, array $aField) { list($sPlace, $fConfidence) = $this->_getChoice('loc_city'); if ($sPlace === '' || $sPlace == self::NONE || $fConfidence < self::CONFIDENCE_MIN) return null; $aGeo = $this->_geocode($sPlace); if (!$aGeo) return null; $bRadius = $aField['search_type'] == 'location_radius'; $iRadius = $this->_iRadius > 0 ? $this->_iRadius : self::RADIUS_DEFAULT_KM; $sLabel = $aGeo['label'] !== '' ? $aGeo['label'] : $sPlace; if ($bRadius) $sLabel .= ' +' . $iRadius . ' km'; // location_radius searches a box around the coordinates; plain location matches the stored // country / city, so the geocoder's normalized names are used instead of the typed word $aValue = [ (float)$aGeo['lat'], (float)$aGeo['lng'], $bRadius ? '' : $aGeo['country'], '', $bRadius ? '' : $aGeo['city'], '', '', '', ]; if ($bRadius) $aValue[] = $iRadius; return [ 'value' => [ 'array' => $aValue, 'string' => $sLabel, ], 'label' => $sLabel, 'confidence' => $fConfidence, ]; } /** * Place name (in any language) -> coordinates and normalized names, through the site's location * field object (Nominatim by default). A hit is cached; a miss expires, so one timeout does not * stay "not found" until the cache is cleared. * @return array|null [lat, lng, country (2 letters), city, label] */ protected function _geocode(string $sPlace) { $sPlace = trim($sPlace); if ($sPlace === '') return null; $oDb = BxDolDb::getInstance(); $sCacheKey = 'sys_ai_search_geo_' . md5(mb_strtolower($sPlace)); $sMissKey = $sCacheKey . '_miss'; $mixedCached = $oDb->getCache($sCacheKey, ''); if (is_array($mixedCached) && isset($mixedCached['lat'], $mixedCached['lng'])) return $mixedCached; // A failure used to be stored on the hit key as [] and getCache never expired it. if (is_array($mixedCached)) $oDb->cleanCache($sCacheKey); $mixedMiss = $oDb->getCache($sMissKey, '', self::CACHE_TTL_GEO_MISS); if (is_array($mixedMiss) && (int)($mixedMiss['until'] ?? 0) > time()) return null; $o = BxDolLocationField::getObjectInstance(getParam('sys_location_field_default')); if (!$o || !method_exists($o, 'getLocation')) $o = BxDolLocationField::getObjectInstance('sys_plain'); // Nominatim, no API key needed if (!$o || !method_exists($o, 'getLocation')) return null; $aLocation = $o->getLocation(['q' => $sPlace]); if (empty($aLocation) || !isset($aLocation['lat'], $aLocation['lon'])) { $oDb->setCache($sMissKey, ['until' => time() + self::CACHE_TTL_GEO_MISS], self::CACHE_TTL_GEO_MISS); return null; } $aAddress = !empty($aLocation['address']) && is_array($aLocation['address']) ? $aLocation['address'] : []; $sCity = ''; foreach (['city', 'town', 'village', 'municipality', 'county', 'state'] as $sKey) if (!empty($aAddress[$sKey])) { $sCity = (string)$aAddress[$sKey]; break; } $aResult = [ 'lat' => (float)$aLocation['lat'], 'lng' => (float)$aLocation['lon'], 'country' => !empty($aAddress['country_code']) ? mb_strtoupper((string)$aAddress['country_code']) : '', 'city' => $sCity, 'label' => trim(implode(', ', array_filter([$sCity, !empty($aAddress['country']) ? $aAddress['country'] : '']))), ]; $oDb->setCache($sCacheKey, $aResult); $oDb->cleanCache($sMissKey); return $aResult; } /** * "in 20 km" / "within 20 km" -> 20; the number is not a value of any field then. * Unit words cover English and the same words in Cyrillic (code points, so the source stays ASCII). */ protected function _getRadius(string $sQuery): int { if (!preg_match('/(\d{1,4})\s*(km|k\.?m\.?|км|mi|miles?|миль|мили)\b/iu', $sQuery, $m)) return 0; $iRadius = (int)$m[1]; return $iRadius > 0 && $iRadius <= self::RADIUS_MAX_KM ? $iRadius : 0; } protected function _answerText(string $sName, array $aField) { // the keyword goes to the first text field only (conditions are ANDed by the engine) foreach ($this->_aFields as $sOther => $aOther) { if ($aOther['kind'] == 'text') { if ($sOther != $sName) return null; break; } } list($sKeyword, $fConfidence) = $this->_getKeyword(array_keys($this->_tokensCriteria(true))); if ($sKeyword === '') return null; return ['value' => $sKeyword, 'label' => $sKeyword, 'confidence' => $fConfidence]; } /** * No time_field question: the column decides the year. added and created are posting dates, * date_start and date_end are when the item happens. Any other column keeps the current year. */ protected function _getDate(string $sQuery, DateTime $oNow, string $sField) { $sChoice = in_array($sField, ['added', 'created', 'date_start', 'date_end'], true) ? $sField : 'date'; $this->_aAnswers['time_field'] = ['type' => 'choice', 'choice' => $sChoice, 'confidence' => 1.0, 'probabilities' => [$sChoice => 1.0]]; return parent::_getDate($sQuery, $oNow, ''); } } /** @} */