Просмотр исходного кода

Una/feat/ai proxy operator prompt (#5912)

* AI Proxy: place the operator prompt ahead of the client's prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>

* AI Proxy: prepend the operator prompt inside the UNA proxy model.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix tests

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Aleksandr Trofimov 4 дней назад
Родитель
Сommit
b34da12745

+ 23 - 0
inc/classes/BxDolAIProviderUnaProxy.php

@@ -11,11 +11,15 @@ class BxDolAIProviderUnaProxy extends NeuronAI\Providers\OpenAI\OpenAI
 {
     protected string $baseUri = 'https://api.unacms.com/m/ai_proxy/v1';
 
+    /**
+     * null reads the AI Proxy setting. A string, including empty, is used as given.
+     */
     public function __construct(
         string $sKey,
         string $sSecret,
         string $sModel,
         ?NeuronAI\HttpClient\HttpClientInterface $httpClient = null,
+        protected ?string $sPrompt = null,
     ) {
         if ($sKey === '' || $sSecret === '')
             throw new Exception(_t('_sys_agents_una_proxy_unconfigured'));
@@ -28,6 +32,25 @@ class BxDolAIProviderUnaProxy extends NeuronAI\Providers\OpenAI\OpenAI
             httpClient: $httpClient ?? new BxDolAIUnaProxyHttpClient($sKey, $sSecret),
         );
     }
+
+    public function systemPrompt(?string $prompt): NeuronAI\Providers\AIProviderInterface
+    {
+        $sOperator = trim($this->operatorPrompt());
+        if ($sOperator !== '') {
+            $sClient = trim((string)$prompt);
+            $prompt = $sClient === '' ? $sOperator : $sOperator . "\n\n" . $sClient;
+        }
+
+        return parent::systemPrompt($prompt);
+    }
+
+    protected function operatorPrompt(): string
+    {
+        if ($this->sPrompt !== null)
+            return $this->sPrompt;
+
+        return (string)getParam('bx_ai_proxy_prompt');
+    }
 }
 
 /** @} */

+ 22 - 1
modules/boonex/ai_proxy/classes/BxAiProxyCompletion.php

@@ -24,6 +24,14 @@ class BxAiProxyException extends Exception
 
 class BxAiProxyCompletion
 {
+    /**
+     * Operator instructions. Placed ahead of the system prompt from the request.
+     */
+    public function __construct(protected string $sPrompt = '')
+    {
+        $this->sPrompt = trim($sPrompt);
+    }
+
     /**
      * Studio model id for this turn. The request body model is ignored.
      */
@@ -161,12 +169,25 @@ class BxAiProxyCompletion
         }
 
         return [
-            'system' => implode("\n\n", $aSystem),
+            'system' => $this->systemPrompt($aSystem),
             'messages' => $aMessages,
             'tools' => $this->definedTools($aBody),
         ];
     }
 
+    /**
+     * Operator text is first so it outranks the system prompt sent by the client.
+     *
+     * @param list<string> $aSystem
+     */
+    protected function systemPrompt(array $aSystem): string
+    {
+        if ($this->sPrompt !== '')
+            array_unshift($aSystem, $this->sPrompt);
+
+        return implode("\n\n", $aSystem);
+    }
+
     /**
      * @return list<NeuronAI\Tools\Tool>
      */

+ 1 - 1
modules/boonex/ai_proxy/classes/BxAiProxyModule.php

@@ -62,7 +62,7 @@ class BxAiProxyModule extends BxDolModule
             $fFactory = static function (int $iModelId) {
                 return BxDolAIModelFactory::getModelInstance($iModelId);
             };
-            $oCompletion = new BxAiProxyCompletion();
+            $oCompletion = new BxAiProxyCompletion((string)getParam('bx_ai_proxy_prompt'));
             if (!empty($aBody['stream'])) {
                 $oCompletion->run($aBody, $iId, $fFactory, function (string $sEvent) use (&$bStarted) {
                     if (!$bStarted) {

+ 2 - 0
modules/boonex/ai_proxy/install/langs/en.xml

@@ -5,4 +5,6 @@
     <string name="_bx_ai_proxy_option_knowledge_info"><![CDATA[Active knowledge store searched for operator proxy requests. The client cannot choose a different store.]]></string>
     <string name="_bx_ai_proxy_option_model"><![CDATA[Chat model]]></string>
     <string name="_bx_ai_proxy_option_model_info"><![CDATA[Active chat model that answers operator proxy requests. The client cannot choose a different model.]]></string>
+    <string name="_bx_ai_proxy_option_prompt"><![CDATA[Prompt]]></string>
+    <string name="_bx_ai_proxy_option_prompt_info"><![CDATA[Instructions placed at the top of every operator proxy request, before the client's prompt. The client cannot remove them.]]></string>
 </resources>

+ 2 - 0
modules/boonex/ai_proxy/install/langs/ru.xml

@@ -5,4 +5,6 @@
     <string name="_bx_ai_proxy_option_knowledge_info"><![CDATA[Активная база знаний для запросов прокси оператора. Сайт клиента не может выбрать другую базу.]]></string>
     <string name="_bx_ai_proxy_option_model"><![CDATA[Модель чата]]></string>
     <string name="_bx_ai_proxy_option_model_info"><![CDATA[Активная модель чата для запросов прокси оператора. Сайт клиента не может выбрать другую модель.]]></string>
+    <string name="_bx_ai_proxy_option_prompt"><![CDATA[Промпт]]></string>
+    <string name="_bx_ai_proxy_option_prompt_info"><![CDATA[Инструкции в начале каждого запроса прокси оператора, перед промптом клиента. Клиент не может их убрать.]]></string>
 </resources>

+ 2 - 1
modules/boonex/ai_proxy/install/sql/enable.sql

@@ -12,4 +12,5 @@ SET @iCategId = LAST_INSERT_ID();
 
 INSERT INTO `sys_options` (`name`, `value`, `category_id`, `caption`, `info`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
 ('bx_ai_proxy_model', '', @iCategId, '_bx_ai_proxy_option_model', '_bx_ai_proxy_option_model_info', 'select', '{"module":"bx_ai_proxy","method":"get_model_options"}', '', '', '', 1),
-('bx_ai_proxy_knowledge', '', @iCategId, '_bx_ai_proxy_option_knowledge', '_bx_ai_proxy_option_knowledge_info', 'select', '{"module":"bx_ai_proxy","method":"get_knowledge_options"}', '', '', '', 2);
+('bx_ai_proxy_knowledge', '', @iCategId, '_bx_ai_proxy_option_knowledge', '_bx_ai_proxy_option_knowledge_info', 'select', '{"module":"bx_ai_proxy","method":"get_knowledge_options"}', '', '', '', 2),
+('bx_ai_proxy_prompt', '', @iCategId, '_bx_ai_proxy_option_prompt', '_bx_ai_proxy_option_prompt_info', 'text', '', '', '', '', 3);

+ 75 - 0
tests/units/inc/classes/BxDolAIProviderUnaProxyTest.php

@@ -40,6 +40,81 @@ class BxDolAIProviderUnaProxyTest extends \PHPUnit\Framework\TestCase
         new BxDolAIProviderUnaProxy('', 'secret', 'una-proxy', $oHttp);
     }
 
+    public function testOperatorPromptIsPrependedToTheModelSystemPrompt()
+    {
+        $oProvider = new BxDolAIProviderUnaProxy(
+            'site-key',
+            'site-secret',
+            'una-proxy',
+            new BxDolAIProxyTestHttpClient(new BxDolAIProxyTestStream([])),
+            'Follow the operator rules.'
+        );
+        $oProvider->systemPrompt("You are the client agent.\nAnswer briefly.");
+
+        $oPrompt = new ReflectionProperty($oProvider, 'system');
+        $this->assertSame("Follow the operator rules.\n\nYou are the client agent.\nAnswer briefly.", $oPrompt->getValue($oProvider));
+    }
+
+    public function testBlankOperatorPromptLeavesTheModelSystemPrompt()
+    {
+        $oProvider = new BxDolAIProviderUnaProxy(
+            'site-key',
+            'site-secret',
+            'una-proxy',
+            new BxDolAIProxyTestHttpClient(new BxDolAIProxyTestStream([])),
+            '   '
+        );
+        $oProvider->systemPrompt('You are the client agent.');
+
+        $oPrompt = new ReflectionProperty($oProvider, 'system');
+        $this->assertSame('You are the client agent.', $oPrompt->getValue($oProvider));
+    }
+
+    public function testSavedOperatorPromptIsPrependedWhenTheArgumentIsOmitted()
+    {
+        $this->withOperatorPrompt('Follow the operator rules.', function () {
+            $oProvider = new BxDolAIProviderUnaProxy(
+                'site-key',
+                'site-secret',
+                'una-proxy',
+                new BxDolAIProxyTestHttpClient(new BxDolAIProxyTestStream([]))
+            );
+            $oProvider->systemPrompt("You are the client agent.\nAnswer briefly.");
+
+            $oPrompt = new ReflectionProperty($oProvider, 'system');
+            $this->assertSame("Follow the operator rules.\n\nYou are the client agent.\nAnswer briefly.", $oPrompt->getValue($oProvider));
+        });
+    }
+
+    public function testEmptyStringOverridesTheSavedOperatorPrompt()
+    {
+        $this->withOperatorPrompt('Follow the operator rules.', function () {
+            $oProvider = new BxDolAIProviderUnaProxy(
+                'site-key',
+                'site-secret',
+                'una-proxy',
+                new BxDolAIProxyTestHttpClient(new BxDolAIProxyTestStream([])),
+                ''
+            );
+            $oProvider->systemPrompt('You are the client agent.');
+
+            $oPrompt = new ReflectionProperty($oProvider, 'system');
+            $this->assertSame('You are the client agent.', $oPrompt->getValue($oProvider));
+        });
+    }
+
+    private function withOperatorPrompt(string $sPrompt, callable $fTest)
+    {
+        $sPrior = getParam('bx_ai_proxy_prompt');
+        setParam('bx_ai_proxy_prompt', $sPrompt);
+        try {
+            return $fTest();
+        }
+        finally {
+            setParam('bx_ai_proxy_prompt', $sPrior);
+        }
+    }
+
     public function testToolCallChunkBecomesNeuronToolMessage()
     {
         $aLines = [

+ 46 - 0
tests/units/modules/boonex/ai_proxy/classes/BxAiProxyTest.php

@@ -54,6 +54,52 @@ class BxAiProxyTest extends BxDolTestCase
         $this->assertSame(401, $aResult['code']);
     }
 
+    public function testOperatorPromptIsPrependedToTheClientSystemPrompt()
+    {
+        $oProvider = new BxAiProxyTestProvider();
+        (new BxAiProxyCompletion('Follow the operator rules.'))->run([
+            'messages' => [
+                ['role' => 'system', 'content' => 'You are the client agent.'],
+                ['role' => 'developer', 'content' => 'Answer briefly.'],
+                ['role' => 'user', 'content' => 'hello'],
+            ],
+        ], 7, function () use ($oProvider) {
+            return $oProvider;
+        });
+
+        $this->assertSame("Follow the operator rules.\n\nYou are the client agent.\n\nAnswer briefly.", $oProvider->sSystem);
+        $this->assertSame('hello', (string)$oProvider->aMessages[0]->getContent());
+    }
+
+    public function testOperatorPromptIsUsedWhenTheClientSendsNoSystemPrompt()
+    {
+        $oProvider = new BxAiProxyTestProvider();
+        (new BxAiProxyCompletion('Follow the operator rules.'))->run([
+            'messages' => [
+                ['role' => 'user', 'content' => 'hello'],
+            ],
+        ], 7, function () use ($oProvider) {
+            return $oProvider;
+        });
+
+        $this->assertSame('Follow the operator rules.', $oProvider->sSystem);
+    }
+
+    public function testBlankOperatorPromptLeavesTheClientSystemPrompt()
+    {
+        $oProvider = new BxAiProxyTestProvider();
+        (new BxAiProxyCompletion('   '))->run([
+            'messages' => [
+                ['role' => 'system', 'content' => 'You are the client agent.'],
+                ['role' => 'user', 'content' => 'hello'],
+            ],
+        ], 7, function () use ($oProvider) {
+            return $oProvider;
+        });
+
+        $this->assertSame('You are the client agent.', $oProvider->sSystem);
+    }
+
     public function testRequestModelDoesNotSelectTheProvider()
     {
         $sBodyModel = 'gpt-not-the-studio-model';