BxDolAiSearchExtParser.php 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup UnaCore UNA Core
  7. * @{
  8. */
  9. /**
  10. * Natural language query -> extended search params for ANY extended search object
  11. * (sys_objects_search_extended, fields configured in Studio > Forms > Search fields).
  12. *
  13. * "python course under 50 in Berlin" for object bx_market ->
  14. * search_params = [
  15. * cat => [type => select_multiple, value => [3], operator => in],
  16. * price_single => [type => text_range, value => ['', 50], operator => between],
  17. * location => [type => location, value => [array => [0, 0, '', '', 'Berlin', ''], string => 'Berlin'], operator => locate],
  18. * title => [type => text, value => 'python', operator => like],
  19. * ]
  20. * which is exactly what BxDolSearchExtended::getResults(['search_params' => ...]) / the module's
  21. * get_search_result_extended consume. Questions to the judge model are generated from the
  22. * field list, so a field added or removed in Studio changes the parser automatically.
  23. *
  24. * Field search types handled: select / select_multiple / checkbox_set (pre-values list -> choice),
  25. * checkbox / switcher (noul), text_range / slider (numbers from the query + choice min/max/exact),
  26. * datepicker_range / datetime_range (calendar logic of BxDolAiSearchParser), location (city word +
  27. * country choice), text (topic words -> like). Others are ignored.
  28. */
  29. class BxDolAiSearchExtParser extends BxDolAiSearchParser
  30. {
  31. const LIST_OPTIONS_MAX = 250;
  32. const LIST_PROBABILITY_MIN = 0.3;
  33. const BOOL_MIN = 0.6;
  34. const RADIUS_DEFAULT_KM = 50; // radius search when the query names a place but no distance
  35. const RADIUS_MAX_KM = 500;
  36. const CACHE_TTL_GEO_MISS = 600; // a timeout or an empty answer is not a permanent "not found"
  37. protected $_aFields = [];
  38. protected $_aNumbers = [];
  39. protected $_aTokens = [];
  40. protected $_sQuery = '';
  41. protected $_sObject = '';
  42. protected $_iRadius = 0; // km, when the query says "within 20 km"
  43. public static function getInstance()
  44. {
  45. if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
  46. $GLOBALS['bxDolClasses'][__CLASS__] = new BxDolAiSearchExtParser();
  47. return $GLOBALS['bxDolClasses'][__CLASS__];
  48. }
  49. /**
  50. * Fields of an extended search object that the parser can ask about, for the block (chips legend).
  51. * @return array [name => [caption, kind]]
  52. */
  53. public function getFields(string $sObject): array
  54. {
  55. $aResult = [];
  56. foreach ($this->_loadFields($sObject) as $sName => $aField)
  57. $aResult[$sName] = ['caption' => $aField['caption'], 'kind' => $aField['kind']];
  58. return $aResult;
  59. }
  60. /**
  61. * @param $sObject extended search object name (e.g. bx_market)
  62. * @param $sQuery natural language query
  63. * @param $aOptions timezone, debug
  64. * @return array
  65. * object, query, search_params (ready for get_results), chips [name => [field, caption, label, value]],
  66. * confidence [name => float], judge => null | [model, ms, usage], error / request / answers (debug)
  67. */
  68. public function parseObject(string $sObject, string $sQuery, array $aOptions = []): array
  69. {
  70. $sQuery = trim(preg_replace('/\s+/u', ' ', $sQuery));
  71. $oTz = $this->_getTimezone($aOptions['timezone'] ?? '');
  72. $oNow = new DateTime('now', $oTz);
  73. $aResult = [
  74. 'object' => $sObject,
  75. 'query' => $sQuery,
  76. 'search_params' => [],
  77. 'filters' => [],
  78. 'keyword_field' => '',
  79. 'chips' => [],
  80. 'confidence' => [],
  81. 'judge' => null,
  82. ];
  83. $this->_sObject = $sObject;
  84. $this->_aFields = $this->_loadFields($sObject);
  85. if ($sQuery === '' || !$this->_oJudge || !$this->_aFields)
  86. return $aResult;
  87. $this->_sQuery = $sQuery;
  88. $this->_iRadius = $this->_getRadius($sQuery);
  89. $this->_aNumbers = $this->_getNumbers($sQuery);
  90. $this->_aTokens = $this->_getTopicTokens($sQuery);
  91. $aQuestions = $this->_getObjectQuestions();
  92. if (!$aQuestions)
  93. return $aResult;
  94. $aState = ['today' => $oNow->format('Y-m-d l'), 'query' => $sQuery];
  95. $fStart = microtime(true);
  96. $aAnswers = $this->_oJudge->askSafe($aState, $aQuestions);
  97. if (!$aAnswers) {
  98. if (!empty($aOptions['debug'])) {
  99. $aResult['request'] = $this->_oJudge->getLastRequest();
  100. $aResult['error'] = $this->_oJudge->getLastError() ?: 'empty answers';
  101. }
  102. return $aResult;
  103. }
  104. $this->_aAnswers = $aAnswers;
  105. $aResult['judge'] = [
  106. 'model' => $this->_oJudge->getLastModel(),
  107. 'ms' => (int)round((microtime(true) - $fStart) * 1000),
  108. 'usage' => $this->_oJudge->getLastUsage(),
  109. ];
  110. if (!empty($aOptions['debug'])) {
  111. $aResult['request'] = $this->_oJudge->getLastRequest();
  112. $aResult['answers'] = $aAnswers;
  113. }
  114. foreach ($this->_aFields as $sName => $aField) {
  115. $aParam = null;
  116. switch ($aField['kind']) {
  117. case 'list':
  118. $aParam = $this->_answerList($sName, $aField);
  119. break;
  120. case 'bool':
  121. $aParam = $this->_answerBool($sName, $aField);
  122. break;
  123. case 'number':
  124. continue 2; // handled once per group below
  125. case 'date':
  126. $aParam = $this->_answerDate($sName, $aField, $oNow);
  127. break;
  128. case 'location':
  129. $aParam = $this->_answerLocation($sName, $aField);
  130. break;
  131. case 'text':
  132. $aParam = $this->_answerText($sName, $aField);
  133. break;
  134. }
  135. if (!$aParam)
  136. continue;
  137. $aResult['search_params'][$sName] = [
  138. 'type' => $aField['search_type'],
  139. 'value' => $aParam['value'],
  140. 'operator' => $aField['operator'],
  141. ];
  142. // same shape the search form would submit, for the page's own results block
  143. $aFilters = $this->_getFilters($sName, $aField, $aParam);
  144. foreach ($aFilters as $sKey => $mixedValue)
  145. $aResult['filters'][$sKey] = $mixedValue;
  146. $aResult['chips'][$sName] = [
  147. 'field' => $sName,
  148. 'caption' => $aField['caption'],
  149. 'label' => $aParam['label'],
  150. 'value' => $aParam['value'],
  151. 'filter_keys' => array_keys($aFilters),
  152. ];
  153. $aResult['confidence'][$sName] = $aParam['confidence'];
  154. if ($aField['kind'] == 'text')
  155. $aResult['keyword_field'] = $sName;
  156. }
  157. $this->_applyNumbers($aResult);
  158. return $aResult;
  159. }
  160. /**
  161. * Numeric fields with the same caption are one thing for the user (Market has two "Price" fields:
  162. * one-time and recurring), so the query gets one constraint, one chip and a filter for every field
  163. * of the group. An upper bound / "free" is applied to all of them (conditions are ANDed and the
  164. * price a product does not use is 0); a lower bound or an exact value would then exclude everything,
  165. * so it goes to the field the model picked.
  166. */
  167. protected function _applyNumbers(array &$aResult)
  168. {
  169. $aGroups = $this->_getNumberGroups();
  170. if (!$aGroups)
  171. return;
  172. $sGroup = $this->_getNumberGroup($aGroups);
  173. if ($sGroup === '' || empty($aGroups[$sGroup]))
  174. return;
  175. $aParam = $this->_answerNumber();
  176. if (!$aParam)
  177. return;
  178. $aNames = $aGroups[$sGroup];
  179. if (!in_array($aParam['op'], ['max', 'zero'], true)) {
  180. $sPrimary = $this->_getNumberPrimaryField($aNames);
  181. $aNames = [$sPrimary];
  182. }
  183. $aFilterKeys = [];
  184. foreach ($aNames as $sName) {
  185. $aField = $this->_aFields[$sName];
  186. $aResult['search_params'][$sName] = [
  187. 'type' => $aField['search_type'],
  188. 'value' => $aParam['value'],
  189. 'operator' => $aField['operator'],
  190. ];
  191. foreach ($this->_getFilters($sName, $aField, $aParam) as $sKey => $mixedValue) {
  192. $aResult['filters'][$sKey] = $mixedValue;
  193. $aFilterKeys[] = $sKey;
  194. }
  195. }
  196. $sChipId = reset($aNames);
  197. $aResult['chips'][$sChipId] = [
  198. 'field' => $sChipId,
  199. 'caption' => $sGroup,
  200. 'label' => $aParam['label'],
  201. 'value' => $aParam['value'],
  202. 'filter_keys' => $aFilterKeys,
  203. ];
  204. $aResult['confidence'][$sChipId] = $aParam['confidence'];
  205. }
  206. /**
  207. * Numeric fields grouped by caption: [caption => [field name, ...]]
  208. */
  209. protected function _getNumberGroups(): array
  210. {
  211. $aGroups = [];
  212. foreach ($this->_aFields as $sName => $aField)
  213. if ($aField['kind'] == 'number')
  214. $aGroups[$aField['caption']][] = $sName;
  215. return $aGroups;
  216. }
  217. /**
  218. * Which group the query is about: the only one, or the one the model picked.
  219. */
  220. protected function _getNumberGroup(array $aGroups): string
  221. {
  222. if (count($aGroups) == 1)
  223. return (string)key($aGroups);
  224. list($sChoice, $fConfidence) = $this->_getChoice('num_field');
  225. if ($sChoice === '' || $sChoice == self::NONE || $fConfidence < self::CONFIDENCE_MIN || !isset($aGroups[$sChoice]))
  226. return '';
  227. return $sChoice;
  228. }
  229. /**
  230. * Field of the group a lower / exact bound is applied to: the one the model picked, else the first.
  231. */
  232. protected function _getNumberPrimaryField(array $aNames): string
  233. {
  234. if (count($aNames) > 1 && isset($this->_aAnswers['num_which'])) {
  235. list($sChoice, $fConfidence) = $this->_getChoice('num_which');
  236. if ($sChoice !== '' && $sChoice != self::NONE && $fConfidence >= self::CONFIDENCE_MIN && in_array($sChoice, $aNames, true))
  237. return $sChoice;
  238. }
  239. return reset($aNames);
  240. }
  241. /**
  242. * One parsed field -> values as the search form would submit them ($_POST keys),
  243. * so `get_results` (params.filters) builds exactly the same search as a manual form submit.
  244. * @return array [input name => value]
  245. */
  246. protected function _getFilters(string $sName, array $aField, array $aParam): array
  247. {
  248. switch ($aField['kind']) {
  249. case 'list':
  250. // select posts one value, multiple selects post an array
  251. return [$sName => $aField['multi'] ? (array)$aParam['value'] : (is_array($aParam['value']) ? reset($aParam['value']) : $aParam['value'])];
  252. case 'bool':
  253. return [$sName => 1];
  254. case 'number':
  255. // *_range inputs are rendered as name[] twice (from, to)
  256. return [$sName => [(string)$aParam['value'][0], (string)$aParam['value'][1]]];
  257. case 'date':
  258. // the form checker converts date strings to timestamps itself
  259. return [$sName => [$aParam['date_from'], $aParam['date_to']]];
  260. case 'location':
  261. // the main input holds the address string, the rest is read from {name}_{key} inputs
  262. $aLocation = $aParam['value']['array'];
  263. $aKeys = ['lat', 'lng', 'country', 'state', 'city', 'zip', 'street', 'street_number'];
  264. $aFilters = [$sName => $aParam['value']['string']];
  265. foreach ($aKeys as $iIndex => $sKey)
  266. $aFilters[$sName . '_' . $sKey] = isset($aLocation[$iIndex]) ? $aLocation[$iIndex] : '';
  267. return $aFilters;
  268. case 'text':
  269. default:
  270. return [$sName => $aParam['value']];
  271. }
  272. }
  273. // fields ------------------------
  274. /**
  275. * Field name used as a caption when the language string is missing: price_recurring -> Price recurring.
  276. */
  277. protected function _getFieldCaption(string $sName): string
  278. {
  279. $s = str_replace('_', ' ', $sName);
  280. return mb_strtoupper(mb_substr($s, 0, 1)) . mb_substr($s, 1);
  281. }
  282. /**
  283. * Active fields of the extended search object, normalized:
  284. * [name => [caption, kind (list|bool|number|date|location|text), multi, values, search_type, operator, type]]
  285. */
  286. protected function _loadFields(string $sObject): array
  287. {
  288. bx_import('BxDolForm'); // BX_DATA_LISTS_KEY_PREFIX, used while reading the fields' pre-values
  289. $aObject = BxDolSearchExtendedQuery::getSearchObject($sObject);
  290. if (!$aObject || empty($aObject['fields']) || !is_array($aObject['fields']))
  291. return [];
  292. $aFields = [];
  293. foreach ($aObject['fields'] as $aField) {
  294. if (empty($aField['active']))
  295. continue;
  296. $sName = $aField['name'];
  297. $sSearchType = $aField['search_type'];
  298. $sOperator = $aField['search_operator'];
  299. $sCaption = trim(strip_tags(_t($aField['caption'])));
  300. if ($sCaption === '' || $sCaption == $aField['caption'])
  301. $sCaption = $this->_getFieldCaption($sName);
  302. $a = ['caption' => $sCaption, 'search_type' => $sSearchType, 'operator' => $sOperator, 'type' => $aField['type'], 'multi' => false, 'values' => []];
  303. switch ($sSearchType) {
  304. case 'select':
  305. case 'select_multiple':
  306. case 'checkbox_set':
  307. $aValues = [];
  308. if (!empty($aField['values']) && is_array($aField['values']))
  309. foreach ($aField['values'] as $mixedKey => $mixedValue) {
  310. if ($mixedKey === '' || $mixedKey === 0 || $mixedKey === '0' || is_array($mixedValue))
  311. continue;
  312. $aValues[(string)$mixedKey] = trim(strip_tags(_t((string)$mixedValue)));
  313. }
  314. if (count($aValues) < 1 || count($aValues) > self::LIST_OPTIONS_MAX)
  315. continue 2;
  316. $a['kind'] = 'list';
  317. $a['multi'] = $sSearchType != 'select';
  318. $a['values'] = $aValues;
  319. break;
  320. case 'checkbox':
  321. case 'switcher':
  322. $a['kind'] = 'bool';
  323. break;
  324. case 'text_range':
  325. case 'slider':
  326. $a['kind'] = 'number';
  327. break;
  328. case 'datepicker_range':
  329. case 'datetime_range':
  330. $a['kind'] = 'date';
  331. break;
  332. case 'location':
  333. case 'location_radius':
  334. $a['kind'] = 'location';
  335. break;
  336. case 'text':
  337. if ($sOperator != 'like' || in_array($aField['type'], ['textarea']))
  338. continue 2;
  339. $a['kind'] = 'text';
  340. break;
  341. default:
  342. continue 2;
  343. }
  344. $aFields[$sName] = $a;
  345. }
  346. return $aFields;
  347. }
  348. // questions ------------------------
  349. protected function _getObjectQuestions(): array
  350. {
  351. $aQuestions = [];
  352. $aDateFields = [];
  353. $aTextFields = [];
  354. $aNumberFields = [];
  355. $bLocation = false;
  356. foreach ($this->_aFields as $sName => $aField) {
  357. switch ($aField['kind']) {
  358. case 'list':
  359. $aCriteria = $aField['values'];
  360. $aCriteria[self::NONE] = "The query does not specify {$aField['caption']}";
  361. $aQuestions["f_{$sName}"] = BxDolAiJudge::choice(
  362. "Which {$aField['caption']} does the user ask for? The query may be in any language; match by meaning." . ($aField['multi'] ? ' Several may apply.' : ''),
  363. $aCriteria
  364. );
  365. break;
  366. case 'bool':
  367. $aQuestions["f_{$sName}"] = BxDolAiJudge::noul("Does the user explicitly ask for items where \"{$aField['caption']}\" is true / enabled / yes?");
  368. break;
  369. case 'number':
  370. $aNumberFields[$sName] = $aField['caption'];
  371. break;
  372. case 'date':
  373. $aDateFields[$sName] = $aField['caption'];
  374. break;
  375. case 'location':
  376. $bLocation = true;
  377. break;
  378. case 'text':
  379. $aTextFields[$sName] = $aField['caption'];
  380. break;
  381. }
  382. }
  383. if ($aNumberFields) {
  384. $sCaptions = implode(' / ', array_unique(array_values($aNumberFields)));
  385. // one constraint per query: which numeric thing it is about, and how
  386. $aGroups = $this->_getNumberGroups();
  387. if (count($aGroups) > 1) {
  388. $aCriteria = [];
  389. foreach ($aGroups as $sCaption => $aNames)
  390. $aCriteria[$sCaption] = $sCaption;
  391. $aQuestions['num_field'] = BxDolAiJudge::choice(
  392. 'If the query limits a number, which one does it mean?',
  393. $aCriteria + [self::NONE => 'The query does not limit any of these']
  394. );
  395. }
  396. // fields sharing a caption are alternatives (one-time vs recurring price): for a lower or
  397. // exact bound only one of them can be meant
  398. foreach ($aGroups as $sCaption => $aNames) {
  399. if (count($aNames) < 2)
  400. continue;
  401. $aCriteria = [];
  402. foreach ($aNames as $sName)
  403. $aCriteria[$sName] = $this->_getFieldCaption($sName);
  404. $aQuestions['num_which'] = BxDolAiJudge::choice(
  405. "\"{$sCaption}\" is stored in several ways. Which one does the query mean?",
  406. $aCriteria + [self::NONE => 'Not specified']
  407. );
  408. break;
  409. }
  410. if ($this->_aNumbers) {
  411. $sNumbers = implode(', ', $this->_aNumbers);
  412. $aQuestions['num_op'] = BxDolAiJudge::choice(
  413. "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.",
  414. [
  415. 'max' => 'at most / under / below / cheaper than the number (<, <=, under, up to, max)',
  416. 'min' => 'at least / over / above / more than the number (>, >=, from, at least, min)',
  417. 'exact' => 'exactly the number',
  418. 'range' => 'between two numbers',
  419. self::NONE => 'The number is not a limit for it (a date, a quantity of something else, part of a name)',
  420. ]
  421. );
  422. }
  423. $aQuestions['num_zero'] = BxDolAiJudge::noul("Does the user ask for items that are free — no charge, cost nothing, zero {$sCaptions}?");
  424. }
  425. if ($aDateFields) {
  426. $aQuestions['time_mode'] = BxDolAiJudge::choice('How is the date written in the query?', [
  427. 'absolute' => 'A calendar date: 23/09, 23.09.2026, September 23, 23 sep',
  428. 'relative' => 'Relative to today: today, yesterday, tomorrow, this/last/next week, this weekend, this/last/next month',
  429. self::NONE => 'No date in the query',
  430. ]);
  431. $aDays = [];
  432. for ($i = 1; $i <= 31; $i++)
  433. $aDays[(string)$i] = "Day {$i} of the month";
  434. $aDays[self::NONE] = 'No day of month is written';
  435. $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);
  436. $aQuestions['month'] = BxDolAiJudge::choice('Which month is written in the query? In numeric dates like 23/09 the month comes second.', [
  437. '1' => 'January', '2' => 'February', '3' => 'March', '4' => 'April', '5' => 'May', '6' => 'June',
  438. '7' => 'July', '8' => 'August', '9' => 'September', '10' => 'October', '11' => 'November', '12' => 'December',
  439. self::NONE => 'No month is written',
  440. ]);
  441. $aQuestions['relative'] = BxDolAiJudge::choice('If the date is relative to today, which period is meant?', $this->_aRelative + [
  442. self::NONE => 'The date is not relative, or there is no date',
  443. ]);
  444. if (count($aDateFields) > 1)
  445. $aQuestions['date_field'] = BxDolAiJudge::choice('If the query mentions a date, which of these dates is meant?', $aDateFields + [
  446. self::NONE => 'No date in the query',
  447. ]);
  448. }
  449. if ($bLocation && $this->_aTokens) {
  450. // the word is geocoded afterwards, so any language and spelling works
  451. $aQuestions['loc_city'] = BxDolAiJudge::choice(
  452. 'Which word names a place the items should be at — a city, town, area or country?',
  453. $this->_tokensCriteria() + [self::NONE => 'No place in the query']
  454. );
  455. }
  456. if ($aTextFields && $this->_aTokens) {
  457. $aTopicCriteria = $this->_tokensCriteria(true);
  458. if ($aTopicCriteria)
  459. $aQuestions['topic'] = BxDolAiJudge::choice(
  460. '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.',
  461. $aTopicCriteria + [self::NONE => 'No topic word: the query only has categories, places, numbers or time']
  462. );
  463. }
  464. return $aQuestions;
  465. }
  466. protected function _tokensCriteria(bool $bSkipFieldWords = false): array
  467. {
  468. $aSkip = $bSkipFieldWords ? $this->_getFieldWords() : [];
  469. $aCriteria = [];
  470. foreach ($this->_aTokens as $sToken)
  471. if (!isset($aSkip[mb_strtolower($sToken)]))
  472. $aCriteria[$sToken] = 'The word "' . $sToken . '"';
  473. return $aCriteria;
  474. }
  475. /**
  476. * Words of the field captions ("price", "category", "location"...): they name a filter,
  477. * not the thing being searched for, so they must not become the keyword.
  478. */
  479. protected function _getFieldWords(): array
  480. {
  481. $aWords = [];
  482. foreach ($this->_aFields as $aField) {
  483. foreach (preg_split('/[^\p{L}\p{N}]+/u', $aField['caption'], -1, PREG_SPLIT_NO_EMPTY) as $sWord)
  484. if (mb_strlen($sWord) > 2)
  485. $aWords[mb_strtolower($sWord)] = true;
  486. }
  487. return $aWords;
  488. }
  489. /**
  490. * Numbers written in the query (integers / decimals), in order of appearance; parts of dates (23/09) are skipped.
  491. */
  492. protected function _getNumbers(string $sQuery): array
  493. {
  494. // drop date literals (23/09, 23.09.2026) but keep decimals like 50.5
  495. $sClean = preg_replace_callback('/(?<![\d.])(\d{1,2})[\/.\-](\d{1,2})(?:[\/.\-](\d{2}|\d{4}))?(?![\d.])/', function ($m) {
  496. $iA = (int)$m[1];
  497. $iB = (int)$m[2];
  498. $bDate = !empty($m[3]) || ($iA >= 1 && $iA <= 31 && $iB >= 1 && $iB <= 12) || ($iA >= 1 && $iA <= 12 && $iB >= 1 && $iB <= 31);
  499. return $bDate ? ' ' : $m[0];
  500. }, $sQuery);
  501. if (!preg_match_all('/(?<![\p{L}\d])(\d+(?:[.,]\d+)?)(?![\d])/u', $sClean, $m))
  502. return [];
  503. $aNumbers = [];
  504. foreach ($m[1] as $s) {
  505. $fNumber = (float)str_replace(',', '.', $s);
  506. if ($this->_iRadius > 0 && $fNumber == (float)$this->_iRadius)
  507. continue; // it is the search radius, not a value of a field
  508. $aNumbers[] = $fNumber;
  509. }
  510. return $aNumbers;
  511. }
  512. // answers ------------------------
  513. /**
  514. * @return array|null [value, label, confidence]
  515. */
  516. protected function _answerList(string $sName, array $aField)
  517. {
  518. list($sChoice, $fConfidence) = $this->_getChoice("f_{$sName}");
  519. if ($sChoice === '' || $sChoice == self::NONE || $fConfidence < self::CONFIDENCE_MIN)
  520. return null;
  521. $aKeys = [];
  522. if ($aField['multi']) {
  523. $aProbabilities = $this->_getProbabilities("f_{$sName}");
  524. foreach ($aField['values'] as $sKey => $sTitle)
  525. if ($sKey == $sChoice || ($aProbabilities[$sKey] ?? 0) >= self::LIST_PROBABILITY_MIN)
  526. $aKeys[] = $sKey;
  527. }
  528. if (!$aKeys)
  529. $aKeys = [$sChoice];
  530. $aTitles = [];
  531. foreach ($aKeys as $sKey)
  532. $aTitles[] = $aField['values'][$sKey] ?? $sKey;
  533. return [
  534. 'value' => $aField['operator'] == 'in' || $aField['operator'] == 'and' ? $aKeys : $aKeys[0],
  535. 'label' => implode(', ', $aTitles),
  536. 'confidence' => $fConfidence,
  537. ];
  538. }
  539. protected function _answerBool(string $sName, array $aField)
  540. {
  541. $a = $this->_aAnswers["f_{$sName}"] ?? null;
  542. if (!is_array($a) || !isset($a['noul']) || (float)$a['noul'] < self::BOOL_MIN)
  543. return null;
  544. return ['value' => 1, 'label' => $aField['caption'], 'confidence' => (float)$a['noul']];
  545. }
  546. /**
  547. * The numeric constraint of the query: ['op' => max|min|exact|range|zero, 'value' => [from, to], 'label', 'confidence']
  548. */
  549. protected function _answerNumber()
  550. {
  551. $aZero = $this->_aAnswers['num_zero'] ?? null;
  552. $bZero = is_array($aZero) && isset($aZero['noul']) && (float)$aZero['noul'] >= self::BOOL_MIN;
  553. if (!$this->_aNumbers) {
  554. if (!$bZero)
  555. return null;
  556. return ['op' => 'zero', 'value' => ['', 0], 'label' => '0', 'confidence' => (float)$aZero['noul']];
  557. }
  558. list($sChoice, $fConfidence) = $this->_getChoice('num_op');
  559. if ($bZero && ($sChoice === '' || $sChoice == self::NONE || $fConfidence < self::CONFIDENCE_MIN))
  560. return ['op' => 'zero', 'value' => ['', 0], 'label' => '0', 'confidence' => (float)$aZero['noul']];
  561. if ($sChoice === '' || $sChoice == self::NONE || $fConfidence < self::CONFIDENCE_MIN)
  562. return null;
  563. $fNumber = $this->_aNumbers[0];
  564. // "> 0" / ">= 0" is no constraint at all (UNA's between ignores an empty/zero lower bound)
  565. if ($sChoice == 'min' && $fNumber <= 0)
  566. return null;
  567. $sFormatted = rtrim(rtrim(number_format($fNumber, 2, '.', ''), '0'), '.');
  568. switch ($sChoice) {
  569. case 'max':
  570. return ['op' => 'max', 'value' => ['', $fNumber], 'label' => "≤ {$sFormatted}", 'confidence' => $fConfidence];
  571. case 'min':
  572. return ['op' => 'min', 'value' => [$fNumber, ''], 'label' => "≥ {$sFormatted}", 'confidence' => $fConfidence];
  573. case 'exact':
  574. return ['op' => 'exact', 'value' => [$fNumber, $fNumber], 'label' => "= {$sFormatted}", 'confidence' => $fConfidence];
  575. case 'range':
  576. if (count($this->_aNumbers) < 2)
  577. return null;
  578. $fMin = min($this->_aNumbers[0], $this->_aNumbers[1]);
  579. $fMax = max($this->_aNumbers[0], $this->_aNumbers[1]);
  580. return ['op' => 'range', 'value' => [$fMin, $fMax], 'label' => rtrim(rtrim(number_format($fMin, 2, '.', ''), '0'), '.') . ' – ' . rtrim(rtrim(number_format($fMax, 2, '.', ''), '0'), '.'), 'confidence' => $fConfidence];
  581. }
  582. return null;
  583. }
  584. protected function _answerDate(string $sName, array $aField, DateTime $oNow)
  585. {
  586. // several date fields: the model picks which one the date is about
  587. if (isset($this->_aAnswers['date_field'])) {
  588. list($sChoice, $fConfidence) = $this->_getChoice('date_field');
  589. if ($sChoice != $sName || $fConfidence < self::CONFIDENCE_MIN)
  590. return null;
  591. }
  592. $aDate = $this->_getDate($this->_sQuery, $oNow, $sName);
  593. if (!$aDate)
  594. return null;
  595. $oTz = $oNow->getTimezone();
  596. $oFrom = new DateTime($aDate['date']['from'] . ' 00:00:00', $oTz);
  597. $oTo = new DateTime($aDate['date']['to'] . ' 23:59:59', $oTz);
  598. $bTimestamps = $aField['search_type'] == 'datetime_range' || $aField['type'] == 'datetime';
  599. $mixedFrom = $bTimestamps ? $oFrom->getTimestamp() : $oFrom->format('Y-m-d');
  600. $mixedTo = $bTimestamps ? $oTo->getTimestamp() : $oTo->format('Y-m-d');
  601. $sLabel = $aDate['date']['from'] == $aDate['date']['to'] ? $aDate['date']['from'] : $aDate['date']['from'] . ' – ' . $aDate['date']['to'];
  602. return [
  603. 'value' => [$mixedFrom, $mixedTo],
  604. 'date_from' => $bTimestamps ? $oFrom->format('Y-m-d H:i:s') : $oFrom->format('Y-m-d'),
  605. 'date_to' => $bTimestamps ? $oTo->format('Y-m-d H:i:s') : $oTo->format('Y-m-d'),
  606. 'label' => $sLabel,
  607. 'confidence' => $aDate['confidence'],
  608. ];
  609. }
  610. protected function _answerLocation(string $sName, array $aField)
  611. {
  612. list($sPlace, $fConfidence) = $this->_getChoice('loc_city');
  613. if ($sPlace === '' || $sPlace == self::NONE || $fConfidence < self::CONFIDENCE_MIN)
  614. return null;
  615. $aGeo = $this->_geocode($sPlace);
  616. if (!$aGeo)
  617. return null;
  618. $bRadius = $aField['search_type'] == 'location_radius';
  619. $iRadius = $this->_iRadius > 0 ? $this->_iRadius : self::RADIUS_DEFAULT_KM;
  620. $sLabel = $aGeo['label'] !== '' ? $aGeo['label'] : $sPlace;
  621. if ($bRadius)
  622. $sLabel .= ' +' . $iRadius . ' km';
  623. // location_radius searches a box around the coordinates; plain location matches the stored
  624. // country / city, so the geocoder's normalized names are used instead of the typed word
  625. $aValue = [
  626. (float)$aGeo['lat'],
  627. (float)$aGeo['lng'],
  628. $bRadius ? '' : $aGeo['country'],
  629. '',
  630. $bRadius ? '' : $aGeo['city'],
  631. '',
  632. '',
  633. '',
  634. ];
  635. if ($bRadius)
  636. $aValue[] = $iRadius;
  637. return [
  638. 'value' => [
  639. 'array' => $aValue,
  640. 'string' => $sLabel,
  641. ],
  642. 'label' => $sLabel,
  643. 'confidence' => $fConfidence,
  644. ];
  645. }
  646. /**
  647. * Place name (in any language) -> coordinates and normalized names, through the site's location
  648. * field object (Nominatim by default). A hit is cached; a miss expires, so one timeout does not
  649. * stay "not found" until the cache is cleared.
  650. * @return array|null [lat, lng, country (2 letters), city, label]
  651. */
  652. protected function _geocode(string $sPlace)
  653. {
  654. $sPlace = trim($sPlace);
  655. if ($sPlace === '')
  656. return null;
  657. $oDb = BxDolDb::getInstance();
  658. $sCacheKey = 'sys_ai_search_geo_' . md5(mb_strtolower($sPlace));
  659. $sMissKey = $sCacheKey . '_miss';
  660. $mixedCached = $oDb->getCache($sCacheKey, '');
  661. if (is_array($mixedCached) && isset($mixedCached['lat'], $mixedCached['lng']))
  662. return $mixedCached;
  663. // A failure used to be stored on the hit key as [] and getCache never expired it.
  664. if (is_array($mixedCached))
  665. $oDb->cleanCache($sCacheKey);
  666. $mixedMiss = $oDb->getCache($sMissKey, '', self::CACHE_TTL_GEO_MISS);
  667. if (is_array($mixedMiss) && (int)($mixedMiss['until'] ?? 0) > time())
  668. return null;
  669. $o = BxDolLocationField::getObjectInstance(getParam('sys_location_field_default'));
  670. if (!$o || !method_exists($o, 'getLocation'))
  671. $o = BxDolLocationField::getObjectInstance('sys_plain'); // Nominatim, no API key needed
  672. if (!$o || !method_exists($o, 'getLocation'))
  673. return null;
  674. $aLocation = $o->getLocation(['q' => $sPlace]);
  675. if (empty($aLocation) || !isset($aLocation['lat'], $aLocation['lon'])) {
  676. $oDb->setCache($sMissKey, ['until' => time() + self::CACHE_TTL_GEO_MISS], self::CACHE_TTL_GEO_MISS);
  677. return null;
  678. }
  679. $aAddress = !empty($aLocation['address']) && is_array($aLocation['address']) ? $aLocation['address'] : [];
  680. $sCity = '';
  681. foreach (['city', 'town', 'village', 'municipality', 'county', 'state'] as $sKey)
  682. if (!empty($aAddress[$sKey])) {
  683. $sCity = (string)$aAddress[$sKey];
  684. break;
  685. }
  686. $aResult = [
  687. 'lat' => (float)$aLocation['lat'],
  688. 'lng' => (float)$aLocation['lon'],
  689. 'country' => !empty($aAddress['country_code']) ? mb_strtoupper((string)$aAddress['country_code']) : '',
  690. 'city' => $sCity,
  691. 'label' => trim(implode(', ', array_filter([$sCity, !empty($aAddress['country']) ? $aAddress['country'] : '']))),
  692. ];
  693. $oDb->setCache($sCacheKey, $aResult);
  694. $oDb->cleanCache($sMissKey);
  695. return $aResult;
  696. }
  697. /**
  698. * "in 20 km" / "within 20 km" -> 20; the number is not a value of any field then.
  699. * Unit words cover English and the same words in Cyrillic (code points, so the source stays ASCII).
  700. */
  701. protected function _getRadius(string $sQuery): int
  702. {
  703. if (!preg_match('/(\d{1,4})\s*(km|k\.?m\.?|км|mi|miles?|миль|мили)\b/iu', $sQuery, $m))
  704. return 0;
  705. $iRadius = (int)$m[1];
  706. return $iRadius > 0 && $iRadius <= self::RADIUS_MAX_KM ? $iRadius : 0;
  707. }
  708. protected function _answerText(string $sName, array $aField)
  709. {
  710. // the keyword goes to the first text field only (conditions are ANDed by the engine)
  711. foreach ($this->_aFields as $sOther => $aOther) {
  712. if ($aOther['kind'] == 'text') {
  713. if ($sOther != $sName)
  714. return null;
  715. break;
  716. }
  717. }
  718. list($sKeyword, $fConfidence) = $this->_getKeyword(array_keys($this->_tokensCriteria(true)));
  719. if ($sKeyword === '')
  720. return null;
  721. return ['value' => $sKeyword, 'label' => $sKeyword, 'confidence' => $fConfidence];
  722. }
  723. /**
  724. * No time_field question: the column decides the year. added and created are posting dates,
  725. * date_start and date_end are when the item happens. Any other column keeps the current year.
  726. */
  727. protected function _getDate(string $sQuery, DateTime $oNow, string $sField)
  728. {
  729. $sChoice = in_array($sField, ['added', 'created', 'date_start', 'date_end'], true) ? $sField : 'date';
  730. $this->_aAnswers['time_field'] = ['type' => 'choice', 'choice' => $sChoice, 'confidence' => 1.0, 'probabilities' => [$sChoice => 1.0]];
  731. return parent::_getDate($sQuery, $oNow, '');
  732. }
  733. }
  734. /** @} */