BxDolAIToolPromptFeedback.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  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. use NeuronAI\Tools\PropertyType;
  10. use NeuronAI\Tools\ToolProperty;
  11. class BxDolAIToolPromptFeedback extends BxDolAITool
  12. {
  13. public const TABLE = 'sys_agents_prompt_feedback';
  14. protected const CLIP_FIELD = 20000;
  15. protected const CLIP_TURN = 1500;
  16. protected const EXCERPT_TURNS = 8;
  17. public function __construct()
  18. {
  19. parent::__construct(
  20. 'prompt_feedback',
  21. 'Log a wrong answer. Call when the user says you were wrong, corrects you, or you notice you broke a rule. Fill error_reason, expected_behavior, suggested_prompt_rule as reusable prompt text — not an apology. Question and wrong reply are taken from this chat if omitted.',
  22. );
  23. }
  24. protected function properties(): array
  25. {
  26. return [
  27. new ToolProperty(
  28. name: 'error_reason',
  29. type: PropertyType::STRING,
  30. description: 'Why the previous answer was wrong. Be specific: what fact, policy, tone, or missing step failed.',
  31. required: true
  32. ),
  33. new ToolProperty(
  34. name: 'expected_behavior',
  35. type: PropertyType::STRING,
  36. description: 'What the agent should have said or done instead. Include the correct answer when it is known.',
  37. required: true
  38. ),
  39. new ToolProperty(
  40. name: 'suggested_prompt_rule',
  41. type: PropertyType::STRING,
  42. description: 'One concise instruction to add or change in the system prompt so this failure does not repeat. Write it as a rule the agent can follow, not a description of this chat.',
  43. required: true
  44. ),
  45. new ToolProperty(
  46. name: 'category',
  47. type: PropertyType::STRING,
  48. description: 'hallucination | wrong_policy | wrong_tone | missing_info | tool_misuse | language | other',
  49. required: false
  50. ),
  51. new ToolProperty(
  52. name: 'severity',
  53. type: PropertyType::STRING,
  54. description: 'low | medium | high. high = wrong fact, leaked private data, or a broken business rule.',
  55. required: false
  56. ),
  57. new ToolProperty(
  58. name: 'user_question',
  59. type: PropertyType::STRING,
  60. description: 'Optional override of the user question that got the wrong answer. Leave empty to take it from chat history.',
  61. required: false
  62. ),
  63. new ToolProperty(
  64. name: 'wrong_answer',
  65. type: PropertyType::STRING,
  66. description: 'Optional override of the wrong assistant reply. Leave empty to take it from chat history.',
  67. required: false
  68. ),
  69. new ToolProperty(
  70. name: 'user_correction',
  71. type: PropertyType::STRING,
  72. description: 'Optional override of the user message that flagged the error. Leave empty to take the latest user line.',
  73. required: false
  74. ),
  75. ];
  76. }
  77. public function __invoke(
  78. string $error_reason,
  79. string $expected_behavior,
  80. string $suggested_prompt_rule,
  81. ?string $category = null,
  82. ?string $severity = null,
  83. ?string $user_question = null,
  84. ?string $wrong_answer = null,
  85. ?string $user_correction = null
  86. ): array {
  87. $oDb = BxDolDb::getInstance();
  88. $sReason = self::clip(trim((string)$error_reason), self::CLIP_FIELD);
  89. $sExpected = self::clip(trim((string)$expected_behavior), self::CLIP_FIELD);
  90. $sRule = self::clip(trim((string)$suggested_prompt_rule), self::CLIP_FIELD);
  91. if ($sReason === '' || $sExpected === '' || $sRule === '')
  92. throw new Exception('error_reason, expected_behavior and suggested_prompt_rule are required.');
  93. $aCtx = self::currentContext();
  94. $aTurns = self::turnsFromHistory($oDb, $aCtx['thread_id']);
  95. $aCase = self::extractCase($aTurns, [
  96. 'user_question' => $user_question,
  97. 'wrong_answer' => $wrong_answer,
  98. 'user_correction' => $user_correction,
  99. ]);
  100. $iAdded = time();
  101. $sCategory = self::normalizeCategory($category);
  102. $sSeverity = self::normalizeSeverity($severity);
  103. $sExcerpt = self::excerpt($aTurns);
  104. $sMarkdown = self::caseMarkdown([
  105. 'added' => $iAdded,
  106. 'agent_id' => $aCtx['agent_id'],
  107. 'agent_name' => $aCtx['agent_name'],
  108. 'category' => $sCategory,
  109. 'severity' => $sSeverity,
  110. 'prompt_hash' => $aCtx['prompt_hash'],
  111. 'prompt_system' => $aCtx['prompt_system'],
  112. 'prompt_steps' => $aCtx['prompt_steps'],
  113. 'prompt_output' => $aCtx['prompt_output'],
  114. 'user_question' => $aCase['user_question'],
  115. 'wrong_answer' => $aCase['wrong_answer'],
  116. 'user_correction' => $aCase['user_correction'],
  117. 'error_reason' => $sReason,
  118. 'expected_behavior' => $sExpected,
  119. 'suggested_prompt_rule' => $sRule,
  120. 'conversation_excerpt' => $sExcerpt,
  121. ]);
  122. $oDb->query("INSERT INTO `" . self::TABLE . "` SET " . $oDb->arrayToSQL([
  123. 'agent_id' => $aCtx['agent_id'],
  124. 'agent_name' => $aCtx['agent_name'],
  125. 'profile_id' => $aCtx['profile_id'],
  126. 'thread_id' => $aCtx['thread_id'],
  127. 'history_id' => $aCtx['history_id'],
  128. 'category' => $sCategory,
  129. 'severity' => $sSeverity,
  130. 'user_question' => $aCase['user_question'],
  131. 'wrong_answer' => $aCase['wrong_answer'],
  132. 'user_correction' => $aCase['user_correction'],
  133. 'error_reason' => $sReason,
  134. 'expected_behavior' => $sExpected,
  135. 'suggested_prompt_rule' => $sRule,
  136. 'conversation_excerpt' => $sExcerpt,
  137. 'prompt_system' => $aCtx['prompt_system'],
  138. 'prompt_hash' => $aCtx['prompt_hash'],
  139. 'markdown' => $sMarkdown,
  140. 'added' => $iAdded,
  141. ]));
  142. return [
  143. 'ok' => 1,
  144. 'id' => (int)$oDb->lastId(),
  145. 'agent_id' => $aCtx['agent_id'],
  146. 'category' => $sCategory,
  147. 'severity' => $sSeverity,
  148. 'msg' => 'Logged. Do not mention the log to the user. Acknowledge the correction and continue with the expected behavior.',
  149. ];
  150. }
  151. /**
  152. * Paste-ready case: dump `markdown` into an AI to improve the prompt.
  153. */
  154. public static function caseMarkdown(array $a): string
  155. {
  156. $sWhen = !empty($a['added']) ? gmdate('Y-m-d H:i', (int)$a['added']) . ' UTC' : '';
  157. $sName = (string)($a['agent_name'] ?? '');
  158. $iId = (int)($a['agent_id'] ?? 0);
  159. $sAgent = $sName !== '' ? '`' . $sName . '` (id ' . $iId . ')' : 'id ' . $iId;
  160. $aOut = [];
  161. $aOut[] = 'Improve this agent prompt from a real failure. Keep correct behavior. Add a concise testable rule. Do not invent product facts.';
  162. $aOut[] = '';
  163. $aOut[] = 'Agent: ' . $sAgent;
  164. $aOut[] = 'When: ' . $sWhen;
  165. $aOut[] = 'Category: ' . ($a['category'] ?? 'other') . ' / ' . ($a['severity'] ?? 'medium');
  166. $aOut[] = 'Prompt hash: ' . ($a['prompt_hash'] ?? '');
  167. $aOut[] = '';
  168. $aOut[] = '## Failure';
  169. $aOut[] = '';
  170. $aOut[] = 'User asked:';
  171. $aOut[] = self::mdBlock($a['user_question'] ?? '');
  172. $aOut[] = '';
  173. $aOut[] = 'Agent answered (wrong):';
  174. $aOut[] = self::mdBlock($a['wrong_answer'] ?? '');
  175. $aOut[] = '';
  176. $aOut[] = 'User correction:';
  177. $aOut[] = self::mdBlock($a['user_correction'] ?? '');
  178. $aOut[] = '';
  179. $aOut[] = 'Why it was wrong:';
  180. $aOut[] = self::mdBlock($a['error_reason'] ?? '');
  181. $aOut[] = '';
  182. $aOut[] = 'Expected behavior / correct answer:';
  183. $aOut[] = self::mdBlock($a['expected_behavior'] ?? '');
  184. $aOut[] = '';
  185. $aOut[] = 'Suggested prompt rule:';
  186. $aOut[] = self::mdBlock($a['suggested_prompt_rule'] ?? '');
  187. $aOut[] = '';
  188. $aOut[] = '## Conversation excerpt';
  189. $aOut[] = '';
  190. $aOut[] = self::mdBlock($a['conversation_excerpt'] ?? '');
  191. $aOut[] = '';
  192. $aOut[] = '## Prompt at the time of failure';
  193. $aOut[] = '';
  194. $aOut[] = 'prompt_system:';
  195. $aOut[] = self::mdBlock($a['prompt_system'] ?? '');
  196. $aOut[] = '';
  197. $aOut[] = 'prompt_steps:';
  198. $aOut[] = self::mdBlock($a['prompt_steps'] ?? '');
  199. $aOut[] = '';
  200. $aOut[] = 'prompt_output:';
  201. $aOut[] = self::mdBlock($a['prompt_output'] ?? '');
  202. return implode("\n", $aOut);
  203. }
  204. /**
  205. * @return array{agent_id:int,agent_name:string,profile_id:int,thread_id:string,history_id:int,prompt_system:string,prompt_hash:string,prompt_steps:string,prompt_output:string,prompt_tools:string}
  206. */
  207. public static function currentContext(): array
  208. {
  209. $oDb = BxDolDb::getInstance();
  210. $iAgentId = 0;
  211. $sThread = '';
  212. $iProfile = (int)bx_get_logged_profile_id();
  213. $aAgent = [];
  214. $iHistory = (int)BxDolAiChat::getInstance()->getCurrentChatHistoryId();
  215. if ($iHistory > 0) {
  216. $aRow = $oDb->getRow("SELECT `thread_id` FROM `sys_agents_chat_history` WHERE `id` = :id", ['id' => $iHistory]);
  217. $sThread = (string)($aRow['thread_id'] ?? '');
  218. $aParts = explode(':', $sThread);
  219. $iAgentId = (int)($aParts[1] ?? 0);
  220. }
  221. if ($iAgentId > 0)
  222. $aAgent = BxDolAiQuery::getAgentObject($iAgentId) ?: [];
  223. $sSystem = (string)($aAgent['prompt_system'] ?? '');
  224. $sSteps = (string)($aAgent['prompt_steps'] ?? '');
  225. $sOutput = (string)($aAgent['prompt_output'] ?? '');
  226. $sTools = (string)($aAgent['prompt_tools'] ?? '');
  227. return [
  228. 'agent_id' => $iAgentId,
  229. 'agent_name' => (string)($aAgent['name'] ?? ''),
  230. 'profile_id' => $iProfile,
  231. 'thread_id' => $sThread,
  232. 'history_id' => $iHistory,
  233. 'prompt_system' => self::clip($sSystem, self::CLIP_FIELD),
  234. 'prompt_hash' => hash('sha256', $sSystem . "\n" . $sSteps . "\n" . $sOutput . "\n" . $sTools),
  235. 'prompt_steps' => self::clip($sSteps, self::CLIP_FIELD),
  236. 'prompt_output' => self::clip($sOutput, self::CLIP_FIELD),
  237. 'prompt_tools' => self::clip($sTools, self::CLIP_FIELD),
  238. ];
  239. }
  240. /**
  241. * @return array<int, array{role:string,text:string}>
  242. */
  243. public static function turnsFromHistory(BxDolDb $oDb, string $sThread): array
  244. {
  245. if ($sThread === '')
  246. return [];
  247. $sJson = (string)$oDb->getOne("SELECT `messages` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
  248. 't' => $sThread,
  249. ]);
  250. $aStored = json_decode($sJson, true);
  251. if (!is_array($aStored))
  252. return [];
  253. $aTurns = [];
  254. foreach ($aStored as $aMessage) {
  255. if (!is_array($aMessage))
  256. continue;
  257. $sType = (string)($aMessage['type'] ?? '');
  258. if ($sType === 'tool_call' || $sType === 'tool_call_result')
  259. continue;
  260. $sRole = (string)($aMessage['role'] ?? '');
  261. if ($sRole === 'model')
  262. $sRole = 'assistant';
  263. if ($sRole !== 'user' && $sRole !== 'assistant')
  264. continue;
  265. $sText = self::textFromContent($aMessage['content'] ?? '');
  266. if ($sText === '')
  267. continue;
  268. $aTurns[] = ['role' => $sRole, 'text' => $sText];
  269. }
  270. return $aTurns;
  271. }
  272. /**
  273. * @param array<int, array{role:string,text:string}> $aTurns
  274. * @param array{user_question?:?string,wrong_answer?:?string,user_correction?:?string} $aOverride
  275. * @return array{user_question:string,wrong_answer:string,user_correction:string}
  276. */
  277. public static function extractCase(array $aTurns, array $aOverride = []): array
  278. {
  279. $sQuestion = self::clip(trim((string)($aOverride['user_question'] ?? '')), self::CLIP_FIELD);
  280. $sWrong = self::clip(trim((string)($aOverride['wrong_answer'] ?? '')), self::CLIP_FIELD);
  281. $sCorrection = self::clip(trim((string)($aOverride['user_correction'] ?? '')), self::CLIP_FIELD);
  282. $sLastUser = '';
  283. $sLastAssistant = '';
  284. $sPrevUser = '';
  285. for ($i = count($aTurns) - 1; $i >= 0; $i--) {
  286. $sRole = $aTurns[$i]['role'];
  287. $sText = $aTurns[$i]['text'];
  288. if ($sRole === 'user' && $sLastUser === '') {
  289. $sLastUser = $sText;
  290. continue;
  291. }
  292. if ($sRole === 'assistant' && $sLastAssistant === '' && $sLastUser !== '') {
  293. $sLastAssistant = $sText;
  294. continue;
  295. }
  296. if ($sRole === 'user' && $sLastAssistant !== '' && $sPrevUser === '') {
  297. $sPrevUser = $sText;
  298. break;
  299. }
  300. }
  301. if ($sCorrection === '')
  302. $sCorrection = self::clip($sLastUser, self::CLIP_FIELD);
  303. if ($sWrong === '')
  304. $sWrong = self::clip($sLastAssistant, self::CLIP_FIELD);
  305. if ($sQuestion === '')
  306. $sQuestion = self::clip($sPrevUser !== '' ? $sPrevUser : $sLastUser, self::CLIP_FIELD);
  307. return [
  308. 'user_question' => $sQuestion,
  309. 'wrong_answer' => $sWrong,
  310. 'user_correction' => $sCorrection,
  311. ];
  312. }
  313. /**
  314. * @param array<int, array{role:string,text:string}> $aTurns
  315. */
  316. public static function excerpt(array $aTurns): string
  317. {
  318. $aSlice = array_slice($aTurns, -self::EXCERPT_TURNS);
  319. $aLines = [];
  320. foreach ($aSlice as $aTurn) {
  321. $sLabel = $aTurn['role'] === 'assistant' ? 'Assistant' : 'User';
  322. $aLines[] = $sLabel . ': ' . self::clip($aTurn['text'], self::CLIP_TURN);
  323. }
  324. return implode("\n\n", $aLines);
  325. }
  326. public static function clip(string $s, int $iMax): string
  327. {
  328. if ($iMax <= 0 || mb_strlen($s) <= $iMax)
  329. return $s;
  330. return mb_substr($s, 0, $iMax - 1) . '…';
  331. }
  332. public static function textFromContent($mixed): string
  333. {
  334. if (is_string($mixed))
  335. return trim($mixed);
  336. if (!is_array($mixed))
  337. return '';
  338. $aParts = [];
  339. foreach ($mixed as $aBlock) {
  340. if (is_string($aBlock)) {
  341. $s = trim($aBlock);
  342. if ($s !== '')
  343. $aParts[] = $s;
  344. continue;
  345. }
  346. if (!is_array($aBlock))
  347. continue;
  348. $sType = (string)($aBlock['type'] ?? '');
  349. $sText = (string)($aBlock['content'] ?? ($aBlock['text'] ?? ''));
  350. if ($sText === '')
  351. continue;
  352. if ($sType === '' || $sType === 'text')
  353. $aParts[] = trim($sText);
  354. }
  355. return trim(implode("\n", $aParts));
  356. }
  357. protected static function mdBlock($mixed): string
  358. {
  359. $s = trim((string)$mixed);
  360. return $s !== '' ? $s : '(empty)';
  361. }
  362. protected static function normalizeCategory(?string $s): string
  363. {
  364. $s = strtolower(trim((string)$s));
  365. $aOk = ['hallucination', 'wrong_policy', 'wrong_tone', 'missing_info', 'tool_misuse', 'language', 'other'];
  366. return in_array($s, $aOk, true) ? $s : 'other';
  367. }
  368. protected static function normalizeSeverity(?string $s): string
  369. {
  370. $s = strtolower(trim((string)$s));
  371. $aOk = ['low', 'medium', 'high'];
  372. return in_array($s, $aOk, true) ? $s : 'medium';
  373. }
  374. }