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

una: remote knowledge (#5859)

* remote knowledge
Aleksandr Trofimov 1 неделя назад
Родитель
Сommit
e4d57945f8

+ 3 - 0
inc/classes/BxDolAIVectorStoreFactory.php

@@ -120,6 +120,9 @@ class BxDolAIVectorStoreFactory extends BxDolFactory
                     dimension: $aParameters['dimension'] ?? 1024
                 );
                 break;
+            case 'una-proxy':
+                bx_log('sys_agents', 'Remote knowledge is stored on the master', BX_LOG_ERR);
+                throw new Exception('Remote knowledge is stored on the master');
             default:
                 bx_log('sys_agents', "Vector store type {$a['type']} is not supported", BX_LOG_ERR);
                 throw new Exception("Vector store type {$a['type']} is not supported");

+ 10 - 6
inc/classes/BxDolAi.php

@@ -68,17 +68,21 @@ class BxDolAi extends BxDolFactory implements iBxDolSingleton
             throw new Exception($s);
         }
 
+        $aVectorStore = false;
+        if ($a['vector_store_id']) {
+            $aVectorStore = BxDolAiQuery::getVectorStoreObject($a['vector_store_id']);
+            if (is_array($aVectorStore) && (string)($aVectorStore['type'] ?? '') === 'una-proxy')
+                $a['vector_store_type'] = 'una-proxy';
+        }
+
         $o = BxDolAiAgent::make($a, $aParams);
 
         if ((int)$a['tools_max_run'] > 0)
             $o->toolMaxRuns($a['tools_max_run']);
 
-        if ($a['vector_store_id']) {
-            $aVectorStore = BxDolAiQuery::getVectorStoreObject($a['vector_store_id']);
-            if ($aVectorStore && $aVectorStore['embedding_provider_id']) {
-                $oEmbedder = self::getAiEmbeddingsProviderInstance($aVectorStore['embedding_provider_id']);
-                $o->setEmbeddingsProvider($oEmbedder);
-            }
+        if (is_array($aVectorStore) && ($a['vector_store_type'] ?? '') !== 'una-proxy' && !empty($aVectorStore['embedding_provider_id'])) {
+            $oEmbedder = self::getAiEmbeddingsProviderInstance($aVectorStore['embedding_provider_id']);
+            $o->setEmbeddingsProvider($oEmbedder);
         }
 
         $GLOBALS['bxDolClasses'][__CLASS__ . '_Agent_' . $iId] = $o;

+ 9 - 2
inc/classes/BxDolAiAgent.php

@@ -102,11 +102,18 @@ class BxDolAiAgent extends RAG
     }
 
     /**
-     * Retrieval is built for every chat. The embeddings provider is set only
-     * when the agent has a knowledge store with an embedding model.
+     * Retrieval is built for every chat. A una-proxy store is searched on the
+     * master. Any other store needs an embedding model on this site.
      */
     protected function retrieval(): NeuronAI\RAG\Retrieval\RetrievalInterface
     {
+        if (($this->aAgent['vector_store_type'] ?? '') === 'una-proxy') {
+            return new BxDolAiRetrievalUnaProxy(
+                (string)getParam('sys_oauth_key'),
+                (string)getParam('sys_oauth_secret')
+            );
+        }
+
         if (!isset($this->embeddingsProvider)) {
             if ((int)($this->aAgent['vector_store_id'] ?? 0) > 0)
                 throw new Exception("Vector store {$this->aAgent['vector_store_id']} has no embedding model");

+ 110 - 0
inc/classes/BxDolAiRetrievalUnaProxy.php

@@ -0,0 +1,110 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * Retrieval that asks the UNA master to embed and search its knowledge store.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiRetrievalUnaProxy implements NeuronAI\RAG\Retrieval\RetrievalInterface
+{
+    public const URL = 'https://unacms.com/m/ai_proxy/v1/knowledge/search';
+
+    /**
+     * @param callable(string, array): array{code:int, body:string}|null $fPost
+     */
+    public function __construct(
+        protected string $sKey,
+        protected string $sSecret,
+        protected $fPost = null,
+    ) {
+    }
+
+    public function retrieve(NeuronAI\Chat\Messages\Message $query): array
+    {
+        $sQuery = trim((string)$query->getContent());
+        if ($sQuery === '' || $this->sKey === '' || $this->sSecret === '')
+            return [];
+
+        $sBody = BxDolAIUnaProxyHttpClient::encodeBody(['query' => $sQuery]);
+        $aSigned = (new BxDolAIUnaProxyHttpClient($this->sKey, $this->sSecret))->headersForBody($sBody);
+        $aHeaders = [
+            'Content-Type: application/json',
+            'X-Una-Key: ' . $aSigned['X-Una-Key'],
+            'X-Una-Sign: ' . $aSigned['X-Una-Sign'],
+        ];
+
+        try {
+            $aResponse = $this->post($sBody, $aHeaders);
+        }
+        catch (Throwable $oException) {
+            bx_log('sys_agents', 'Remote knowledge search failed: ' . $oException->getMessage(), BX_LOG_ERR);
+            return [];
+        }
+
+        if (!is_array($aResponse) || (int)($aResponse['code'] ?? -1) !== 200) {
+            bx_log('sys_agents', 'Remote knowledge search failed, exit code: ' . ($aResponse['code'] ?? -1), BX_LOG_ERR);
+            return [];
+        }
+
+        return $this->documentsFrom((string)($aResponse['body'] ?? ''));
+    }
+
+    /**
+     * @param array<int, string> $aHeaders
+     * @return array{code:int, body:string}
+     */
+    protected function post(string $sBody, array $aHeaders): array
+    {
+        if ($this->fPost !== null)
+            return ($this->fPost)($sBody, $aHeaders);
+
+        $sHttpCode = 0;
+        $sResult = bx_file_get_contents(self::URL, $sBody, 'post-raw', $aHeaders, $sHttpCode, [], 15);
+        if ($sResult === false)
+            $sResult = '';
+
+        return [
+            'code' => (int)$sHttpCode,
+            'body' => (string)$sResult,
+        ];
+    }
+
+    /**
+     * @return NeuronAI\RAG\Document[]
+     */
+    protected function documentsFrom(string $sBody): array
+    {
+        $aBody = json_decode($sBody, true);
+        if (!is_array($aBody) || !isset($aBody['documents']) || !is_array($aBody['documents']))
+            return [];
+
+        $aDocuments = [];
+        foreach ($aBody['documents'] as $aDocument) {
+            if (!is_array($aDocument))
+                continue;
+
+            $sContent = trim((string)($aDocument['content'] ?? ''));
+            if ($sContent === '')
+                continue;
+
+            $oDocument = new NeuronAI\RAG\Document($sContent);
+            $sSourceType = trim((string)($aDocument['sourceType'] ?? ''));
+            $sSourceName = trim((string)($aDocument['sourceName'] ?? ''));
+            if ($sSourceType !== '')
+                $oDocument->sourceType = $sSourceType;
+            if ($sSourceName !== '')
+                $oDocument->sourceName = $sSourceName;
+            if (isset($aDocument['score']))
+                $oDocument->setScore((float)$aDocument['score']);
+            $aDocuments[] = $oDocument;
+        }
+
+        return $aDocuments;
+    }
+}
+
+/** @} */

+ 12 - 1
install/sql/addon.sql

@@ -46,12 +46,23 @@ INSERT INTO `sys_agents_models` SET
     `duplicate` = 0;
 SET @iUnaProxyModel = LAST_INSERT_ID();
 
+INSERT INTO `sys_agents_vector_store` SET
+    `embedding_provider_id` = 0,
+    `type` = 'una-proxy',
+    `title` = 'UNA knowledge',
+    `docs` = 'Remote knowledge on the UNA master. The master embeds and searches.',
+    `topk` = 4,
+    `duplicate` = 0,
+    `active` = 1;
+SET @iUnaProxyStore = LAST_INSERT_ID();
+
 INSERT INTO `sys_agents_agents` SET
     `name` = 'una_operator',
     `title` = 'UNA operator',
     `model_id` = @iUnaProxyModel,
     `profile_id` = @iProfileIdBot,
-    `prompt_system` = 'You are the site operator assistant. Answer questions about this site content. Use content_structure, content_search, content_get, and comments_get. Do not change data.',
+    `vector_store_id` = @iUnaProxyStore,
+    `prompt_system` = 'You are the site operator assistant. Answer questions about this site content. Use content_structure, content_search, content_get, and comments_get. Use the extra context for how UNA works. Do not change data.',
     `tools` = CONCAT_WS(',',
         (SELECT `id` FROM `sys_agents_tools` WHERE `type` = 'content_structure' ORDER BY `id` LIMIT 1),
         (SELECT `id` FROM `sys_agents_tools` WHERE `type` = 'content_search' ORDER BY `id` LIMIT 1),

+ 17 - 0
modules/boonex/ai_proxy/classes/BxAiProxyDb.php

@@ -15,6 +15,23 @@ class BxAiProxyDb extends BxDolModuleDb
     {
         parent::__construct($oConfig);
     }
+
+    /**
+     * Active knowledge stores the master can search. A una-proxy store would call this site again.
+     *
+     * @return array<int, string>
+     */
+    public function getActiveKnowledgeStores(): array
+    {
+        $aStores = $this->getPairs(
+            "SELECT `id`, `title` FROM `sys_agents_vector_store` WHERE `active` = 1 AND `type` <> :type ORDER BY `title` ASC",
+            'id',
+            'title',
+            ['type' => 'una-proxy']
+        );
+
+        return is_array($aStores) ? $aStores : [];
+    }
 }
 
 /** @} */

+ 82 - 0
modules/boonex/ai_proxy/classes/BxAiProxyKnowledge.php

@@ -0,0 +1,82 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    AIProxy AI Proxy
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+class BxAiProxyKnowledge
+{
+    /**
+     * Studio knowledge store for this search. The request body cannot choose a store.
+     *
+     * @param callable(int): array|false $fStore
+     * @param callable(int): object $fEmbedder
+     * @param callable(int): object $fVectorStore
+     * @return array{documents: list<array{content:string, sourceType:string, sourceName:string, score:float}>}
+     */
+    public function run(array $aBody, int $iConfiguredId, callable $fStore, callable $fEmbedder, callable $fVectorStore): array
+    {
+        $sQuery = trim((string)($aBody['query'] ?? ''));
+        if ($sQuery === '')
+            throw new BxAiProxyException(400, 'Bad request');
+
+        $aStore = $fStore($iConfiguredId);
+        if (!$this->usableStore($aStore)) {
+            bx_log('sys_agents', 'AI proxy knowledge store is not available', BX_LOG_ERR);
+            return ['documents' => []];
+        }
+
+        try {
+            $oEmbedder = $fEmbedder((int)$aStore['embedding_provider_id']);
+            $oStore = $fVectorStore($iConfiguredId);
+            $aEmbedding = $oEmbedder->embedText($sQuery);
+            if (!is_array($aEmbedding))
+                throw new BxAiProxyException(503, 'The proxy knowledge is not available');
+
+            $aDocuments = [];
+            foreach ($oStore->similaritySearch($aEmbedding) as $oDocument) {
+                if (!$oDocument instanceof NeuronAI\RAG\Document)
+                    continue;
+
+                $sContent = $oDocument->getContent();
+                if ($sContent === '')
+                    continue;
+
+                $aDocuments[] = [
+                    'content' => $sContent,
+                    'sourceType' => $oDocument->getSourceType(),
+                    'sourceName' => $oDocument->getSourceName(),
+                    'score' => $oDocument->getScore(),
+                ];
+            }
+
+            return ['documents' => $aDocuments];
+        }
+        catch (BxAiProxyException $oException) {
+            throw $oException;
+        }
+        catch (Throwable $oException) {
+            bx_log('sys_agents', 'AI proxy knowledge failed: ' . $oException->getMessage(), BX_LOG_ERR);
+            throw new BxAiProxyException(503, 'The proxy knowledge is not available');
+        }
+    }
+
+    protected function usableStore(mixed $aStore): bool
+    {
+        if (!is_array($aStore) || empty($aStore['active']))
+            return false;
+        if ((int)($aStore['embedding_provider_id'] ?? 0) <= 0)
+            return false;
+        if ((string)($aStore['type'] ?? '') === 'una-proxy')
+            return false;
+
+        return true;
+    }
+}
+
+/** @} */

+ 77 - 16
modules/boonex/ai_proxy/classes/BxAiProxyModule.php

@@ -37,27 +37,24 @@ class BxAiProxyModule extends BxDolModule
         return $aResult;
     }
 
-    public function completions(): void
+    public function serviceGetKnowledgeOptions()
     {
-        bx_import('Auth', $this->_aModule);
-        bx_import('Completion', $this->_aModule);
+        $aResult = [
+            ['key' => '', 'value' => _t('_Select_one')],
+        ];
 
-        $sBody = file_get_contents('php://input');
-        if ($sBody === false)
-            $sBody = '';
+        $aStores = $this->_oDb->getActiveKnowledgeStores();
+        foreach ($aStores as $iId => $sTitle)
+            $aResult[] = ['key' => (int)$iId, 'value' => (string)$sTitle];
 
-        $aAuth = (new BxAiProxyAuth())->authenticateRequest(
-            $this->requestHeader('X-Una-Key'),
-            $this->requestHeader('X-Una-Sign'),
-            $sBody
-        );
-        if (empty($aAuth['ok']))
-            $this->jsonError(401, 'Unauthorized', 'authentication_error');
+        return $aResult;
+    }
 
-        $aBody = json_decode($sBody, true);
-        if (!is_array($aBody))
-            $this->jsonError(400, 'Bad request', 'invalid_request_error');
+    public function completions(): void
+    {
+        bx_import('Completion', $this->_aModule);
 
+        $aBody = $this->authenticatedBody();
         $bStarted = false;
         try {
             $iId = (int)getParam('bx_ai_proxy_model');
@@ -104,6 +101,70 @@ class BxAiProxyModule extends BxDolModule
         }
     }
 
+    public function knowledge(): void
+    {
+        bx_import('Completion', $this->_aModule);
+        bx_import('Knowledge', $this->_aModule);
+
+        $aBody = $this->authenticatedBody();
+        try {
+            $iId = (int)getParam('bx_ai_proxy_knowledge');
+            $aResult = (new BxAiProxyKnowledge())->run(
+                $aBody,
+                $iId,
+                static function (int $iStoreId) {
+                    return BxDolAiQuery::getVectorStoreObject($iStoreId);
+                },
+                static function (int $iModelId) {
+                    return BxDolAIModelFactory::getModelInstance($iModelId);
+                },
+                static function (int $iStoreId) {
+                    return BxDolAIVectorStoreFactory::getVectorStoreInstance($iStoreId);
+                }
+            );
+            header('Content-Type: application/json; charset=utf-8');
+            echo json_encode($aResult, JSON_UNESCAPED_UNICODE);
+        }
+        catch (BxAiProxyException $oException) {
+            $sType = 'server_error';
+            if ($oException->httpCode() === 400)
+                $sType = 'invalid_request_error';
+            elseif ($oException->httpCode() === 401)
+                $sType = 'authentication_error';
+            $this->jsonError($oException->httpCode(), $oException->getMessage(), $sType);
+        }
+        catch (Throwable $oException) {
+            bx_log('sys_agents', 'AI proxy knowledge failed: ' . $oException->getMessage(), BX_LOG_ERR);
+            $this->jsonError(503, 'The proxy knowledge is not available', 'server_error');
+        }
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    protected function authenticatedBody(): array
+    {
+        bx_import('Auth', $this->_aModule);
+
+        $sBody = file_get_contents('php://input');
+        if ($sBody === false)
+            $sBody = '';
+
+        $aAuth = (new BxAiProxyAuth())->authenticateRequest(
+            $this->requestHeader('X-Una-Key'),
+            $this->requestHeader('X-Una-Sign'),
+            $sBody
+        );
+        if (empty($aAuth['ok']))
+            $this->jsonError(401, 'Unauthorized', 'authentication_error');
+
+        $aBody = json_decode($sBody, true);
+        if (!is_array($aBody))
+            $this->jsonError(400, 'Bad request', 'invalid_request_error');
+
+        return $aBody;
+    }
+
     public function requestHeader(string $sName): string
     {
         $sServer = 'HTTP_' . strtoupper(str_replace('-', '_', $sName));

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

@@ -1,6 +1,8 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources name="en" flag="gb" title="English">
     <string name="_bx_ai_proxy"><![CDATA[AI Proxy]]></string>
+    <string name="_bx_ai_proxy_option_knowledge"><![CDATA[Knowledge]]></string>
+    <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>
 </resources>

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

@@ -1,6 +1,8 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources name="ru" flag="ru" title="Russian">
     <string name="_bx_ai_proxy"><![CDATA[Прокси ИИ]]></string>
+    <string name="_bx_ai_proxy_option_knowledge"><![CDATA[База знаний]]></string>
+    <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>
 </resources>

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

@@ -11,4 +11,5 @@ VALUES (@iTypeId, @sName, '_bx_ai_proxy', 10);
 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_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);

+ 11 - 5
modules/boonex/ai_proxy/request.php

@@ -13,11 +13,14 @@ $aRequest = array_values(array_filter($GLOBALS['aRequest'] ?? [], static functio
     return $sPart !== '';
 }));
 
-$bCompletion = ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST'
-    && $aRequest === ['v1', 'chat', 'completions']
-    && !empty($GLOBALS['aModule']['enabled']);
+echoDbgLog($aRequest);
 
-if (!$bCompletion) {
+$bEnabled = !empty($GLOBALS['aModule']['enabled']);
+$bPost = ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST';
+$bCompletion = $bPost && $aRequest === ['v1', 'chat', 'completions'] && $bEnabled;
+$bKnowledge = $bPost && $aRequest === ['v1', 'knowledge', 'search'] && $bEnabled;
+
+if (!$bCompletion && !$bKnowledge) {
     http_response_code(404);
     header('Content-Type: application/json; charset=utf-8');
     echo json_encode([
@@ -33,6 +36,9 @@ $oModule = BxDolModule::getInstance($GLOBALS['aModule']['name']);
 if (!$oModule)
     return;
 
-$oModule->completions();
+if ($bKnowledge)
+    $oModule->knowledge();
+else
+    $oModule->completions();
 
 /** @} */

+ 5 - 5
studio/template/scripts/BxBaseStudioAgentsVectorStore.php

@@ -113,12 +113,12 @@ class BxBaseStudioAgentsVectorStore extends BxDolStudioAgentsInstruments
     {
         $sJsObject = $this->getPageJsObject();
     
-        if (empty($aVectorStore['params_user'])) {
-            $aVectorStore['params_user'] = $aVectorStore['params'];
-        }
+        if (empty($aVectorStore['params_user']))
+            $aVectorStore['params_user'] = $aVectorStore['params'] ?? '';
 
-        $data = json_decode($aVectorStore['params_user'], true);
-        $aVectorStore['params_user'] = json_encode($data, JSON_PRETTY_PRINT);
+        $sParams = is_string($aVectorStore['params_user']) ? $aVectorStore['params_user'] : '';
+        $data = $sParams !== '' ? json_decode($sParams, true) : null;
+        $aVectorStore['params_user'] = is_array($data) ? json_encode($data, JSON_PRETTY_PRINT) : $sParams;
 
         $oParsedown = new Parsedown();
         $oParsedown->setSafeMode(false);

+ 13 - 1
tests/units/inc/classes/BxDolAiAgentRetrievalTest.php

@@ -1,7 +1,8 @@
 <?php
 
 /**
- * The operator agent has no knowledge store, so chat must not read NeuronAI's embeddings provider.
+ * An agent with no knowledge store must not read NeuronAI's embeddings provider.
+ * A una-proxy store searches the master and does not need a local embedding model.
  */
 class BxDolAiAgentRetrievalTest extends \PHPUnit\Framework\TestCase
 {
@@ -14,6 +15,17 @@ class BxDolAiAgentRetrievalTest extends \PHPUnit\Framework\TestCase
         $this->assertSame([], $oRetrieval->retrieve(new NeuronAI\Chat\Messages\UserMessage('hello')));
     }
 
+    public function testUnaProxyStoreSkipsEmbeddings()
+    {
+        $o = new BxDolAiAgent([
+            'id' => 1,
+            'vector_store_id' => 9,
+            'vector_store_type' => 'una-proxy',
+        ]);
+
+        $this->assertInstanceOf(BxDolAiRetrievalUnaProxy::class, $o->resolveRetrieval());
+    }
+
     public function testStoreWithoutEmbeddingModelIsRejected()
     {
         $o = new BxDolAiAgent(['id' => 1, 'vector_store_id' => 9]);

+ 65 - 0
tests/units/inc/classes/BxDolAiRetrievalUnaProxyTest.php

@@ -0,0 +1,65 @@
+<?php
+
+/**
+ * Remote knowledge retrieval signs the raw JSON body and maps master documents.
+ */
+class BxDolAiRetrievalUnaProxyTest extends BxDolTestCase
+{
+    public function testSignedBodyMapsDocuments()
+    {
+        $sSecret = 'client-secret';
+        $sSeenBody = '';
+        $aSeenHeaders = [];
+        $o = new BxDolAiRetrievalUnaProxy('site-key', $sSecret, function (string $sBody, array $aHeaders) use (&$sSeenBody, &$aSeenHeaders) {
+            $sSeenBody = $sBody;
+            $aSeenHeaders = $aHeaders;
+            return [
+                'code' => 200,
+                'body' => json_encode([
+                    'documents' => [[
+                        'content' => 'Pages are blocks.',
+                        'sourceType' => 'file',
+                        'sourceName' => 'pages.md',
+                        'score' => 0.82,
+                        'embedding' => [0.1, 0.2],
+                    ]],
+                ]),
+            ];
+        });
+
+        $aDocuments = $o->retrieve(new NeuronAI\Chat\Messages\UserMessage('how do pages work'));
+        $sExpected = BxDolAIUnaProxyHttpClient::encodeBody(['query' => 'how do pages work']);
+
+        $this->assertSame($sExpected, $sSeenBody);
+        $this->assertContains('X-Una-Key: site-key', $aSeenHeaders);
+        $this->assertContains('X-Una-Sign: ' . BxDolAIUnaProxyHttpClient::sign($sExpected, $sSecret), $aSeenHeaders);
+        $this->assertNotContains($sSecret, $aSeenHeaders);
+        $this->assertCount(1, $aDocuments);
+        $this->assertSame('Pages are blocks.', $aDocuments[0]->getContent());
+        $this->assertSame('file', $aDocuments[0]->getSourceType());
+        $this->assertSame('pages.md', $aDocuments[0]->getSourceName());
+        $this->assertEqualsWithDelta(0.82, $aDocuments[0]->getScore(), 0.0001);
+        $this->assertSame([], $aDocuments[0]->getEmbedding());
+    }
+
+    public function testFailedResponseReturnsNoDocuments()
+    {
+        $o = new BxDolAiRetrievalUnaProxy('site-key', 'secret', function () {
+            return ['code' => 503, 'body' => ''];
+        });
+
+        $this->assertSame([], $o->retrieve(new NeuronAI\Chat\Messages\UserMessage('how do pages work')));
+    }
+
+    public function testEmptySecretDoesNotPost()
+    {
+        $bPosted = false;
+        $o = new BxDolAiRetrievalUnaProxy('site-key', '', function () use (&$bPosted) {
+            $bPosted = true;
+            return ['code' => 200, 'body' => '{"documents":[]}'];
+        });
+
+        $this->assertSame([], $o->retrieve(new NeuronAI\Chat\Messages\UserMessage('how do pages work')));
+        $this->assertFalse($bPosted);
+    }
+}

+ 131 - 1
tests/units/modules/boonex/ai_proxy/classes/BxAiProxyTest.php

@@ -3,12 +3,13 @@
 /**
  * Master proxy auth and model selection. Loaded without enabling bx_ai_proxy.
  */
-class BxAiProxyTest extends \PHPUnit\Framework\TestCase
+class BxAiProxyTest extends BxDolTestCase
 {
     public static function setUpBeforeClass(): void
     {
         require_once BX_DIRECTORY_PATH_MODULES . 'boonex/ai_proxy/classes/BxAiProxyAuth.php';
         require_once BX_DIRECTORY_PATH_MODULES . 'boonex/ai_proxy/classes/BxAiProxyCompletion.php';
+        require_once BX_DIRECTORY_PATH_MODULES . 'boonex/ai_proxy/classes/BxAiProxyKnowledge.php';
     }
 
     public function testMatchingKeyAndSecret()
@@ -142,6 +143,71 @@ class BxAiProxyTest extends \PHPUnit\Framework\TestCase
         $this->assertSame(1, $aProperties['data']['minItems']);
         $this->assertSame(8, $aProperties['data']['maxItems']);
     }
+
+    public function testKnowledgeUsesConfiguredStoreAndDropsEmbeddings()
+    {
+        $iSeenStore = 0;
+        $iSeenEmbedder = 0;
+        $sSeenQuery = '';
+        $oEmbedder = new BxAiProxyTestEmbeddings();
+        $oStore = new BxAiProxyTestVectorStore();
+
+        $aResult = (new BxAiProxyKnowledge())->run([
+            'query' => 'how do pages work',
+            'store_id' => 99,
+        ], 42, function (int $iId) use (&$iSeenStore) {
+            $iSeenStore = $iId;
+            return [
+                'active' => 1,
+                'type' => 'file',
+                'embedding_provider_id' => 7,
+            ];
+        }, function (int $iId) use (&$iSeenEmbedder, &$sSeenQuery, $oEmbedder) {
+            $iSeenEmbedder = $iId;
+            $oEmbedder->fOnEmbed = function (string $sText) use (&$sSeenQuery) {
+                $sSeenQuery = $sText;
+            };
+            return $oEmbedder;
+        }, function (int $iId) use ($oStore, &$iSeenStore) {
+            $this->assertSame($iSeenStore, $iId);
+            return $oStore;
+        });
+
+        $this->assertSame(42, $iSeenStore);
+        $this->assertSame(7, $iSeenEmbedder);
+        $this->assertSame('how do pages work', $sSeenQuery);
+        $this->assertSame([0.5, 0.25], $oStore->aSeenEmbedding);
+        $this->assertCount(1, $aResult['documents']);
+        $this->assertSame(['content', 'sourceType', 'sourceName', 'score'], array_keys($aResult['documents'][0]));
+        $this->assertSame('Pages are blocks.', $aResult['documents'][0]['content']);
+        $this->assertSame('file', $aResult['documents'][0]['sourceType']);
+        $this->assertSame('pages.md', $aResult['documents'][0]['sourceName']);
+        $this->assertEqualsWithDelta(0.9, $aResult['documents'][0]['score'], 0.0001);
+    }
+
+    public function testUnaProxyKnowledgeStoreReturnsNoDocuments()
+    {
+        $bEmbed = false;
+        $aResult = (new BxAiProxyKnowledge())->run([
+            'query' => 'pages',
+            'store_id' => 99,
+        ], 42, function (int $iId) {
+            $this->assertSame(42, $iId);
+            return [
+                'active' => 1,
+                'type' => 'una-proxy',
+                'embedding_provider_id' => 7,
+            ];
+        }, function () use (&$bEmbed) {
+            $bEmbed = true;
+            throw new Exception('should not embed');
+        }, function () {
+            throw new Exception('should not open store');
+        });
+
+        $this->assertFalse($bEmbed);
+        $this->assertSame(['documents' => []], $aResult);
+    }
 }
 
 class BxAiProxyTestProvider implements NeuronAI\Providers\AIProviderInterface
@@ -217,3 +283,67 @@ class BxAiProxyTestProvider implements NeuronAI\Providers\AIProviderInterface
         return false;
     }
 }
+
+class BxAiProxyTestEmbeddings implements NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface
+{
+    /** @var callable(string): void|null */
+    public $fOnEmbed = null;
+
+    public function embedText(string $text): array
+    {
+        if ($this->fOnEmbed !== null)
+            ($this->fOnEmbed)($text);
+
+        return [0.5, 0.25];
+    }
+
+    public function embedDocument(NeuronAI\RAG\Document $document): NeuronAI\RAG\Document
+    {
+        $document->embedding = $this->embedText($document->getContent());
+        return $document;
+    }
+
+    public function embedDocuments(array $documents): array
+    {
+        foreach ($documents as $document)
+            $this->embedDocument($document);
+
+        return $documents;
+    }
+}
+
+class BxAiProxyTestVectorStore implements NeuronAI\RAG\VectorStore\VectorStoreInterface
+{
+    public array $aSeenEmbedding = [];
+
+    public function addDocument(NeuronAI\RAG\Document $document): NeuronAI\RAG\VectorStore\VectorStoreInterface
+    {
+        return $this;
+    }
+
+    public function addDocuments(array $documents): NeuronAI\RAG\VectorStore\VectorStoreInterface
+    {
+        return $this;
+    }
+
+    public function deleteBySource(string $sourceType, string $sourceName): NeuronAI\RAG\VectorStore\VectorStoreInterface
+    {
+        return $this;
+    }
+
+    public function deleteBy(string $sourceType, ?string $sourceName = null): NeuronAI\RAG\VectorStore\VectorStoreInterface
+    {
+        return $this;
+    }
+
+    public function similaritySearch(array $embedding): iterable
+    {
+        $this->aSeenEmbedding = $embedding;
+        $oDocument = new NeuronAI\RAG\Document('Pages are blocks.');
+        $oDocument->sourceType = 'file';
+        $oDocument->sourceName = 'pages.md';
+        $oDocument->embedding = [9.9, 8.8];
+        $oDocument->setScore(0.9);
+        return [$oDocument];
+    }
+}