BxDolAiJudge.php 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  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. * "Judge" AI models (capabilities = judge).
  11. *
  12. * Unlike chat (chatllm/chatvlm) and embeddings models a judge does not generate text.
  13. * It evaluates typed questions against a state (string / array) and returns structured,
  14. * machine-readable answers: booleans as probabilities (noul), a choice from a fixed list
  15. * with a probability distribution (choice) or a score on a rubric (score).
  16. *
  17. * Judge models are NOT NeuronAI providers and can not be assigned to agents;
  18. * use them from code: moderation, guardrails, intent routing, search query parsing, etc.
  19. *
  20. * Usage:
  21. * @code
  22. * $oJudge = BxDolAiJudge::getInstance(); // default (first active) judge model, or getInstance($iModelId)
  23. * $aAnswers = $oJudge->ask($sText, [
  24. * 'spam' => BxDolAiJudge::noul('Is this message unsolicited advertising?'),
  25. * 'topic' => BxDolAiJudge::choice('What is the message about?', ['billing' => 'Payments', 'support' => 'Bugs, help requests', 'other' => 'Anything else']),
  26. * 'anger' => BxDolAiJudge::score('How angry is the author?', ['Calm', 'Irritated', 'Furious']),
  27. * ]);
  28. * // $aAnswers['spam']['noul'] => 0.93
  29. * // $aAnswers['topic']['choice'] => 'support', ['confidence'] => 0.81, ['probabilities'] => [...]
  30. * // $aAnswers['anger']['score'] => 1, ['confidence'] => 0.9
  31. * @endcode
  32. */
  33. abstract class BxDolAiJudge extends BxDolFactory
  34. {
  35. const CAPABILITY = 'judge';
  36. const Q_NOUL = 'noul';
  37. const Q_CHOICE = 'choice';
  38. const Q_SCORE = 'score';
  39. protected $_aModel;
  40. protected $_aParams;
  41. protected $_aLastUsage = [];
  42. protected $_sLastModel = '';
  43. protected $_sLastError = '';
  44. protected $_aLastRequest = [];
  45. protected $_aLastAnswers = [];
  46. public function __construct(array $aModel, array $aParams)
  47. {
  48. $this->_aModel = $aModel;
  49. $this->_aParams = $aParams;
  50. }
  51. /**
  52. * Get judge instance by model id; when id is empty the first active judge model is used.
  53. * Returns false when no judge model is configured/active (errors are logged).
  54. */
  55. public static function getInstance(int $iModelId = 0)
  56. {
  57. if (!$iModelId)
  58. $iModelId = self::getDefaultModelId();
  59. if (!$iModelId) {
  60. bx_log('sys_agents', "No active judge AI model is configured", BX_LOG_WARN);
  61. return false;
  62. }
  63. try {
  64. return BxDolAIModelFactory::getJudgeInstance($iModelId);
  65. }
  66. catch (Exception $oException) {
  67. bx_log('sys_agents', "Judge AI model {$iModelId} can't be instantiated: " . $oException->getMessage(), BX_LOG_ERR);
  68. return false;
  69. }
  70. }
  71. /**
  72. * Id of the first active model with capabilities = judge, 0 if none.
  73. */
  74. public static function getDefaultModelId(): int
  75. {
  76. $aPairs = BxDolAi::getInstance()->getModels(['active' => true, 'capabilities' => self::CAPABILITY]);
  77. if (empty($aPairs) || !is_array($aPairs))
  78. return 0;
  79. reset($aPairs);
  80. return (int)key($aPairs);
  81. }
  82. public function getModel(): array
  83. {
  84. return $this->_aModel;
  85. }
  86. public function getModelId(): int
  87. {
  88. return (int)$this->_aModel['id'];
  89. }
  90. /**
  91. * Token usage reported by the provider for the last ask() call: ['input_tokens' => N, 'output_tokens' => N]
  92. */
  93. public function getLastUsage(): array
  94. {
  95. return $this->_aLastUsage;
  96. }
  97. /**
  98. * Exact model version reported by the provider for the last ask() call (e.g. jev-1.13.0)
  99. */
  100. public function getLastModel(): string
  101. {
  102. return $this->_sLastModel;
  103. }
  104. /**
  105. * Request body sent to the provider by the last ask() call (for debugging)
  106. */
  107. public function getLastRequest(): array
  108. {
  109. return $this->_aLastRequest;
  110. }
  111. /**
  112. * Raw answers returned by the provider for the last ask() call (for debugging)
  113. */
  114. public function getLastAnswers(): array
  115. {
  116. return $this->_aLastAnswers;
  117. }
  118. /**
  119. * Error message of the last failed askSafe() call, '' when it succeeded
  120. */
  121. public function getLastError(): string
  122. {
  123. return $this->_sLastError;
  124. }
  125. // question builders ------------------------
  126. /**
  127. * Yes/no question, answered as a probability 0..1
  128. * @param $mixedInstructions string|array question text (or structured instructions)
  129. * @param $aCriteria optional ['true' => 'what counts as yes', 'false' => 'what counts as no']
  130. */
  131. public static function noul($mixedInstructions, array $aCriteria = []): array
  132. {
  133. $a = ['type' => self::Q_NOUL, 'instructions' => $mixedInstructions];
  134. if ($aCriteria)
  135. $a['criteria'] = $aCriteria;
  136. return $a;
  137. }
  138. /**
  139. * Pick one option, answered as choice + probabilities + confidence
  140. * @param $mixedInstructions string|array question text
  141. * @param $aCriteria ['option_key' => 'description', ...] (2..255 options)
  142. */
  143. public static function choice($mixedInstructions, array $aCriteria): array
  144. {
  145. return ['type' => self::Q_CHOICE, 'instructions' => $mixedInstructions, 'criteria' => $aCriteria];
  146. }
  147. /**
  148. * Rate on a rubric, answered as score (index of level, 0-based) + probabilities + confidence
  149. * @param $mixedInstructions string|array question text
  150. * @param $aCriteria list of 2..10 level descriptions, from lowest to highest
  151. */
  152. public static function score($mixedInstructions, array $aCriteria): array
  153. {
  154. return ['type' => self::Q_SCORE, 'instructions' => $mixedInstructions, 'criteria' => array_values($aCriteria)];
  155. }
  156. // shortcuts ------------------------
  157. /**
  158. * Ask a single yes/no question.
  159. * @return float|null probability 0..1 or null on failure
  160. */
  161. public function isTrue($mixedState, $mixedInstructions, array $aCriteria = [])
  162. {
  163. $aAnswers = $this->askSafe($mixedState, ['q' => self::noul($mixedInstructions, $aCriteria)]);
  164. return isset($aAnswers['q']['noul']) ? (float)$aAnswers['q']['noul'] : null;
  165. }
  166. /**
  167. * Ask a single choice question.
  168. * @return array|null ['choice' => key, 'confidence' => 0..1, 'probabilities' => [...]] or null on failure
  169. */
  170. public function pick($mixedState, $mixedInstructions, array $aCriteria)
  171. {
  172. $aAnswers = $this->askSafe($mixedState, ['q' => self::choice($mixedInstructions, $aCriteria)]);
  173. return isset($aAnswers['q']['choice']) ? $aAnswers['q'] : null;
  174. }
  175. /**
  176. * Ask a single score question.
  177. * @return array|null ['score' => int, 'confidence' => 0..1, 'probabilities' => [...], 'legend' => [...]] or null on failure
  178. */
  179. public function rate($mixedState, $mixedInstructions, array $aCriteria)
  180. {
  181. $aAnswers = $this->askSafe($mixedState, ['q' => self::score($mixedInstructions, $aCriteria)]);
  182. return isset($aAnswers['q']['score']) ? $aAnswers['q'] : null;
  183. }
  184. /**
  185. * Same as ask() but never throws: logs the error and returns empty array.
  186. */
  187. public function askSafe($mixedState, array $aQuestions): array
  188. {
  189. $this->_sLastError = '';
  190. try {
  191. return $this->ask($mixedState, $aQuestions);
  192. }
  193. catch (Exception $oException) {
  194. $this->_sLastError = $oException->getMessage();
  195. bx_log('sys_agents', "Judge AI model {$this->getModelId()} ask failed: " . $this->_sLastError, BX_LOG_ERR);
  196. return [];
  197. }
  198. }
  199. /**
  200. * Evaluate questions against the state.
  201. * @param $mixedState string|array text or structured data to evaluate
  202. * @param $aQuestions ['question_id' => question built with noul()/choice()/score(), ...]
  203. * @return array ['question_id' => answer, ...] where answer is
  204. * noul: ['type' => 'noul', 'noul' => 0..1]
  205. * choice: ['type' => 'choice', 'choice' => key, 'confidence' => 0..1, 'probabilities' => [key => 0..1]]
  206. * score: ['type' => 'score', 'score' => int, 'confidence' => 0..1, 'probabilities' => [level => 0..1], 'legend' => [level => text]]
  207. * @throws Exception on transport / provider errors
  208. */
  209. abstract public function ask($mixedState, array $aQuestions): array;
  210. }
  211. /** @} */