BxDolAiSearchParser.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  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 search query -> structured search filter, using a judge AI model (see BxDolAiJudge).
  11. *
  12. * "all events starting on 23/09" ->
  13. * section = bx_events, keyword = '', date = [field => starts, from => 2026-09-23, to => 2026-09-23]
  14. *
  15. * The judge only answers closed questions (which section, which time field, day, month, relative anchor,
  16. * which words are the topic); dates are assembled in code. When no judge model is configured the query
  17. * is returned as a plain keyword, so callers work exactly as before.
  18. */
  19. class BxDolAiSearchParser extends BxDolFactory
  20. {
  21. const CONFIDENCE_MIN = 0.35;
  22. const TOPIC_PROBABILITY_MIN = 0.2;
  23. const TOPIC_TOKENS_MAX = 60;
  24. const NONE = 'none';
  25. protected $_oJudge;
  26. protected $_aAnswers = [];
  27. /**
  28. * Section hints, module name => note from sys_modules. Null until the first load.
  29. * The section title (site language) is always appended.
  30. */
  31. protected $_aSectionHints = null;
  32. protected $_aRelative = [
  33. 'today' => 'Today',
  34. 'yesterday' => 'Yesterday',
  35. 'tomorrow' => 'Tomorrow',
  36. 'weekend' => 'This weekend, Saturday or Sunday',
  37. 'this_week' => 'This week',
  38. 'last_week' => 'Last week, the previous week',
  39. 'next_week' => 'Next week',
  40. 'this_month' => 'This month',
  41. 'last_month' => 'Last month, the previous month',
  42. 'next_month' => 'Next month',
  43. ];
  44. protected function __construct()
  45. {
  46. parent::__construct();
  47. $this->_oJudge = BxDolAiJudge::getInstance();
  48. }
  49. public static function getInstance()
  50. {
  51. if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
  52. $GLOBALS['bxDolClasses'][__CLASS__] = new BxDolAiSearchParser();
  53. return $GLOBALS['bxDolClasses'][__CLASS__];
  54. }
  55. public function isAvailable(): bool
  56. {
  57. return (bool)$this->_oJudge;
  58. }
  59. /**
  60. * @param $sQuery natural language query
  61. * @param $aOptions
  62. * sections - [name => title] search sections the user may search in (required for section detection)
  63. * timezone - IANA timezone of the user, used for "today"/"tomorrow" and day boundaries
  64. * debug - include raw judge answers in the result
  65. * @return array
  66. * query, keyword, section, section_title,
  67. * date => null | [field (starts|ends|created), mode (absolute|relative), anchor, from (Y-m-d), to (Y-m-d)],
  68. * confidence => [section, date, keyword], judge => null | [model, ms, usage]
  69. */
  70. public function parse(string $sQuery, array $aOptions = []): array
  71. {
  72. $sQuery = trim(preg_replace('/\s+/u', ' ', $sQuery));
  73. $aSections = !empty($aOptions['sections']) && is_array($aOptions['sections']) ? $aOptions['sections'] : [];
  74. $oTz = $this->_getTimezone($aOptions['timezone'] ?? '');
  75. $aResult = [
  76. 'query' => $sQuery,
  77. 'keyword' => $sQuery,
  78. 'section' => '',
  79. 'section_title' => '',
  80. 'date' => null,
  81. 'confidence' => ['section' => 0, 'date' => 0, 'keyword' => 0],
  82. 'judge' => null,
  83. ];
  84. if ($sQuery === '' || !$this->_oJudge)
  85. return $aResult;
  86. $aTokens = $this->_getTopicTokens($sQuery);
  87. $aQuestions = $this->_getQuestions($aSections, $aTokens);
  88. $oNow = new DateTime('now', $oTz);
  89. $aState = [
  90. 'today' => $oNow->format('Y-m-d l'),
  91. 'query' => $sQuery,
  92. ];
  93. $fStart = microtime(true);
  94. $aAnswers = $this->_oJudge->askSafe($aState, $aQuestions);
  95. if (!$aAnswers) {
  96. if (!empty($aOptions['debug'])) {
  97. $aResult['request'] = $this->_oJudge->getLastRequest();
  98. $aResult['error'] = $this->_oJudge->getLastError() ?: 'empty answers';
  99. }
  100. return $aResult;
  101. }
  102. $this->_aAnswers = $aAnswers;
  103. $aResult['judge'] = [
  104. 'model' => $this->_oJudge->getLastModel(),
  105. 'ms' => (int)round((microtime(true) - $fStart) * 1000),
  106. 'usage' => $this->_oJudge->getLastUsage(),
  107. ];
  108. if (!empty($aOptions['debug'])) {
  109. $aResult['request'] = $this->_oJudge->getLastRequest();
  110. $aResult['answers'] = $aAnswers;
  111. }
  112. // section
  113. list($sSection, $fConfidence) = $this->_getChoice('section');
  114. if ($sSection && $sSection != 'any' && isset($aSections[$sSection]) && $fConfidence >= self::CONFIDENCE_MIN) {
  115. $aResult['section'] = $sSection;
  116. $aResult['section_title'] = $aSections[$sSection];
  117. }
  118. $aResult['confidence']['section'] = $fConfidence;
  119. // date
  120. $aDate = $this->_getDate($sQuery, $oNow, $aResult['section']);
  121. if ($aDate) {
  122. $aResult['date'] = $aDate['date'];
  123. $aResult['confidence']['date'] = $aDate['confidence'];
  124. // a bare date with no section makes sense for events only
  125. if (!$aResult['section'] && in_array($aDate['date']['field'], ['starts', 'ends']) && isset($aSections['bx_events'])) {
  126. $aResult['section'] = 'bx_events';
  127. $aResult['section_title'] = $aSections['bx_events'];
  128. }
  129. }
  130. // keyword
  131. list($sKeyword, $fConfidence) = $this->_getKeyword($aTokens);
  132. $aResult['keyword'] = $sKeyword;
  133. $aResult['confidence']['keyword'] = $fConfidence;
  134. return $aResult;
  135. }
  136. // questions ------------------------
  137. /**
  138. * Keyword hints published by each module as its note (sys_modules.note).
  139. */
  140. protected function _getSectionHints(): array
  141. {
  142. if (is_array($this->_aSectionHints))
  143. return $this->_aSectionHints;
  144. $this->_aSectionHints = [];
  145. $aModules = BxDolModuleQuery::getInstance()->getModules();
  146. if (!is_array($aModules))
  147. return $this->_aSectionHints;
  148. foreach ($aModules as $aModule)
  149. if (!empty($aModule['name']) && !empty($aModule['note']))
  150. $this->_aSectionHints[$aModule['name']] = $aModule['note'];
  151. return $this->_aSectionHints;
  152. }
  153. protected function _getQuestions(array $aSections, array $aTokens): array
  154. {
  155. $aQuestions = [];
  156. if ($aSections) {
  157. $aCriteria = [];
  158. $aHints = $this->_getSectionHints();
  159. foreach ($aSections as $sName => $sTitle)
  160. $aCriteria[$sName] = (!empty($aHints[$sName]) ? $aHints[$sName] . ' - ' : '') . $sTitle;
  161. $aCriteria['any'] = 'Not specified, everything, or a kind of content not listed';
  162. $aQuestions['section'] = BxDolAiJudge::choice('Which kind of content does the user want to find? The query may be in any language.', $aCriteria);
  163. }
  164. $aQuestions['time_field'] = BxDolAiJudge::choice('If the query mentions a date or time, which moment of the items is meant?', [
  165. 'starts' => 'When the item starts, happens, takes place, is held (e.g. events on a date)',
  166. 'ends' => 'When the item ends, its deadline or closing date',
  167. 'created' => 'When the item was posted, published, created, added',
  168. self::NONE => 'The query has no date or time reference',
  169. ]);
  170. $aQuestions['time_mode'] = BxDolAiJudge::choice('How is the date written in the query?', [
  171. 'absolute' => 'A calendar date: 23/09, 23.09.2026, September 23, 23 sep',
  172. 'relative' => 'Relative to today: today, tomorrow, this week, next week, this weekend, this month',
  173. self::NONE => 'No date in the query',
  174. ]);
  175. $aDays = [];
  176. for ($i = 1; $i <= 31; $i++)
  177. $aDays[(string)$i] = "Day {$i} of the month";
  178. $aDays[self::NONE] = 'No day of month is written';
  179. $aQuestions['day'] = BxDolAiJudge::choice('Which day of the month is written in the query? In numeric dates like 23/09 or 23.09 the day comes first.', $aDays);
  180. $aQuestions['month'] = BxDolAiJudge::choice('Which month is written in the query? In numeric dates like 23/09 or 23.09 the month comes second.', [
  181. '1' => 'January', '2' => 'February', '3' => 'March', '4' => 'April', '5' => 'May', '6' => 'June',
  182. '7' => 'July', '8' => 'August', '9' => 'September', '10' => 'October', '11' => 'November', '12' => 'December',
  183. self::NONE => 'No month is written',
  184. ]);
  185. $aQuestions['relative'] = BxDolAiJudge::choice('If the date is relative to today, which period is meant?', $this->_aRelative + [
  186. self::NONE => 'The date is not relative, or there is no date',
  187. ]);
  188. if ($aTokens) {
  189. $aCriteria = [];
  190. foreach ($aTokens as $sToken)
  191. $aCriteria[$sToken] = 'The word "' . $sToken . '"';
  192. $aCriteria[self::NONE] = 'No topic word: the query only names a kind of content and/or a time';
  193. $aQuestions['topic'] = BxDolAiJudge::choice('Which word names the topic, subject, name or thing to search for? Words that only name a kind of content (events, posts, people, groups, courses...), dates, time words (today, tomorrow, week), verbs like "find/show/start" and function words are NOT topic words.', $aCriteria);
  194. }
  195. return $aQuestions;
  196. }
  197. /**
  198. * Candidate words for the keyword: letters only, 2+ chars, unique, original order.
  199. */
  200. protected function _getTopicTokens(string $sQuery): array
  201. {
  202. $aWords = preg_split('/[^\p{L}\p{N}]+/u', $sQuery, -1, PREG_SPLIT_NO_EMPTY);
  203. $aTokens = [];
  204. foreach ($aWords as $sWord) {
  205. if (mb_strlen($sWord) < 2 || preg_match('/\p{N}/u', $sWord))
  206. continue;
  207. $sKey = mb_strtolower($sWord);
  208. if (isset($aTokens[$sKey]) || $sKey == self::NONE || $sKey == 'any')
  209. continue;
  210. $aTokens[$sKey] = $sWord;
  211. if (count($aTokens) >= self::TOPIC_TOKENS_MAX)
  212. break;
  213. }
  214. return array_values($aTokens);
  215. }
  216. // answers ------------------------
  217. /**
  218. * @return array [choice, confidence]
  219. */
  220. protected function _getChoice(string $sId): array
  221. {
  222. $a = $this->_aAnswers[$sId] ?? null;
  223. if (!is_array($a) || !isset($a['choice']))
  224. return ['', 0.0];
  225. return [(string)$a['choice'], (float)($a['confidence'] ?? 0)];
  226. }
  227. protected function _getProbabilities(string $sId): array
  228. {
  229. $a = $this->_aAnswers[$sId] ?? null;
  230. return is_array($a) && isset($a['probabilities']) && is_array($a['probabilities']) ? $a['probabilities'] : [];
  231. }
  232. /**
  233. * @return array|null ['date' => [field, mode, anchor, from, to], 'confidence' => float]
  234. */
  235. protected function _getDate(string $sQuery, DateTime $oNow, string $sSection)
  236. {
  237. list($sField, $fFieldConfidence) = $this->_getChoice('time_field');
  238. list($sMode, $fModeConfidence) = $this->_getChoice('time_mode');
  239. // exact numeric date in the query beats the model's reading of day/month
  240. $aLiteral = $this->_getLiteralDate($sQuery);
  241. if (!$aLiteral && ($sMode == self::NONE || $sMode == '' || $fModeConfidence < self::CONFIDENCE_MIN))
  242. return null;
  243. // starts/ends/created are the main parser's answers. added/created/date_start/date_end are extended-search columns.
  244. // 'date' is any other extended-search column: a missing year stays the current one.
  245. if (!in_array($sField, ['starts', 'ends', 'created', 'added', 'date_start', 'date_end', 'date']))
  246. $sField = $sSection && $sSection != 'bx_events' ? 'created' : 'starts';
  247. $iYear = (int)$oNow->format('Y');
  248. $sMode = $aLiteral ? 'absolute' : $sMode;
  249. if ($sMode == 'relative') {
  250. list($sAnchor, $fAnchorConfidence) = $this->_getChoice('relative');
  251. if (!isset($this->_aRelative[$sAnchor]) || $fAnchorConfidence < self::CONFIDENCE_MIN)
  252. return null;
  253. list($oFrom, $oTo) = $this->_getRelativeRange($sAnchor, $oNow);
  254. return [
  255. 'date' => ['field' => $sField, 'mode' => 'relative', 'anchor' => $sAnchor, 'from' => $oFrom->format('Y-m-d'), 'to' => $oTo->format('Y-m-d')],
  256. 'confidence' => min($fFieldConfidence, $fModeConfidence, $fAnchorConfidence),
  257. ];
  258. }
  259. // absolute
  260. $fConfidence = min($fFieldConfidence, $fModeConfidence);
  261. if ($aLiteral) {
  262. list($iDay, $iMonth, $iLiteralYear) = $aLiteral;
  263. $fConfidence = max($fConfidence, $fFieldConfidence);
  264. }
  265. else {
  266. list($sDay, $fDayConfidence) = $this->_getChoice('day');
  267. list($sMonth, $fMonthConfidence) = $this->_getChoice('month');
  268. if ($sDay == self::NONE || $sMonth == self::NONE || !is_numeric($sDay) || !is_numeric($sMonth))
  269. return null;
  270. $iDay = (int)$sDay;
  271. $iMonth = (int)$sMonth;
  272. $iLiteralYear = 0;
  273. $fConfidence = min($fConfidence, $fDayConfidence, $fMonthConfidence);
  274. }
  275. if ($fConfidence < self::CONFIDENCE_MIN)
  276. return null;
  277. if ($iLiteralYear)
  278. $iYear = $iLiteralYear;
  279. else {
  280. // no year stated: nearest sensible one
  281. if (checkdate($iMonth, $iDay, $iYear)) {
  282. $oCandidate = (clone $oNow)->setDate($iYear, $iMonth, $iDay)->setTime(0, 0, 0);
  283. $iDiffDays = (int)$oNow->diff($oCandidate)->format('%r%a');
  284. if (in_array($sField, ['created', 'added']) && $iDiffDays > 1)
  285. $iYear--;
  286. elseif (in_array($sField, ['starts', 'ends', 'date_start', 'date_end']) && $iDiffDays < -30)
  287. $iYear++;
  288. }
  289. }
  290. if (!checkdate($iMonth, $iDay, $iYear))
  291. return null;
  292. $sDate = sprintf('%04d-%02d-%02d', $iYear, $iMonth, $iDay);
  293. return [
  294. 'date' => ['field' => $sField, 'mode' => 'absolute', 'anchor' => '', 'from' => $sDate, 'to' => $sDate],
  295. 'confidence' => $fConfidence,
  296. ];
  297. }
  298. /**
  299. * 23/09, 23.09, 23-09, 23/09/2026, 23.09.26 -> [day, month, year|0]; null when absent or impossible.
  300. */
  301. protected function _getLiteralDate(string $sQuery)
  302. {
  303. if (!preg_match('/(?<![\d\/.\-])(\d{1,2})[\/.\-](\d{1,2})(?:[\/.\-](\d{2}|\d{4}))?(?![\d\/.\-])/', $sQuery, $m))
  304. return null;
  305. $iDay = (int)$m[1];
  306. $iMonth = (int)$m[2];
  307. $iYear = isset($m[3]) ? (int)$m[3] : 0;
  308. if ($iYear && $iYear < 100)
  309. $iYear += 2000;
  310. if ($iMonth > 12 && $iDay <= 12) // 09/23 written month-first
  311. list($iDay, $iMonth) = [$iMonth, $iDay];
  312. if ($iDay < 1 || $iDay > 31 || $iMonth < 1 || $iMonth > 12)
  313. return null;
  314. return [$iDay, $iMonth, $iYear];
  315. }
  316. /**
  317. * @return array [DateTime from, DateTime to]
  318. */
  319. protected function _getRelativeRange(string $sAnchor, DateTime $oNow): array
  320. {
  321. $oFrom = (clone $oNow)->setTime(0, 0, 0);
  322. $oTo = clone $oFrom;
  323. $iDow = (int)$oNow->format('N'); // 1 = Monday .. 7 = Sunday
  324. switch ($sAnchor) {
  325. case 'yesterday':
  326. $oFrom->modify('-1 day');
  327. $oTo = clone $oFrom;
  328. break;
  329. case 'tomorrow':
  330. $oFrom->modify('+1 day');
  331. $oTo = clone $oFrom;
  332. break;
  333. case 'last_week':
  334. $oFrom->modify('-' . ($iDow + 6) . ' days');
  335. $oTo = (clone $oFrom)->modify('+6 days');
  336. break;
  337. case 'last_month':
  338. $oFrom->modify('first day of last month');
  339. $oTo = (clone $oFrom)->modify('last day of this month');
  340. break;
  341. case 'next_month':
  342. $oFrom->modify('first day of next month');
  343. $oTo = (clone $oFrom)->modify('last day of this month');
  344. break;
  345. case 'weekend':
  346. $oFrom->modify($iDow == 7 ? '-1 day' : '+' . (6 - $iDow) . ' days');
  347. $oTo = (clone $oFrom)->modify('+1 day');
  348. break;
  349. case 'this_week':
  350. $oFrom->modify('-' . ($iDow - 1) . ' days');
  351. $oTo = (clone $oFrom)->modify('+6 days');
  352. break;
  353. case 'next_week':
  354. $oFrom->modify('+' . (8 - $iDow) . ' days');
  355. $oTo = (clone $oFrom)->modify('+6 days');
  356. break;
  357. case 'this_month':
  358. $oFrom->modify('first day of this month');
  359. $oTo = (clone $oFrom)->modify('last day of this month');
  360. break;
  361. case 'today':
  362. default:
  363. break;
  364. }
  365. return [$oFrom, $oTo];
  366. }
  367. /**
  368. * @return array [keyword, confidence]
  369. */
  370. protected function _getKeyword(array $aTokens): array
  371. {
  372. if (!$aTokens || !isset($this->_aAnswers['topic']))
  373. return ['', 0.0];
  374. list($sChoice, $fConfidence) = $this->_getChoice('topic');
  375. $aProbabilities = $this->_getProbabilities('topic');
  376. if ($sChoice == self::NONE || ($aProbabilities[self::NONE] ?? 0) > 0.5)
  377. return ['', $fConfidence];
  378. $aKeyword = [];
  379. foreach ($aTokens as $sToken)
  380. if ($sToken == $sChoice || ($aProbabilities[$sToken] ?? 0) >= self::TOPIC_PROBABILITY_MIN)
  381. $aKeyword[] = $sToken;
  382. return [implode(' ', $aKeyword), $fConfidence];
  383. }
  384. protected function _getTimezone(string $sTimezone): DateTimeZone
  385. {
  386. if ($sTimezone && in_array($sTimezone, timezone_identifiers_list()))
  387. return new DateTimeZone($sTimezone);
  388. return new DateTimeZone(date_default_timezone_get() ?: 'UTC');
  389. }
  390. }
  391. /** @} */