From a56111fa842c6b20fde93635878775798f257361 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 1 Jul 2025 17:33:28 +0300 Subject: [PATCH 1/2] feat(nl-search): add natural language search models support - add `NLSearchModel` class with CRUD operations for individual models - add `NLSearchModels` collection class with `ArrayAccess` implementation - integrate nl search models into main `Client` class with getter method - add tests for model creation, retrieval, update, and deletion - add test case base class with OpenAI API key validation --- src/Client.php | 14 +++ src/NLSearchModel.php | 73 +++++++++++++++ src/NLSearchModels.php | 109 ++++++++++++++++++++++ tests/Feature/NLSearchModelsTest.php | 129 +++++++++++++++++++++++++++ tests/NLSearchModelsTestCase.php | 28 ++++++ 5 files changed, 353 insertions(+) create mode 100644 src/NLSearchModel.php create mode 100644 src/NLSearchModels.php create mode 100644 tests/Feature/NLSearchModelsTest.php create mode 100644 tests/NLSearchModelsTestCase.php diff --git a/src/Client.php b/src/Client.php index 6821147b..a4f8a084 100644 --- a/src/Client.php +++ b/src/Client.php @@ -85,6 +85,11 @@ class Client */ public Conversations $conversations; + /** + * @var NLSearchModels + */ + public NLSearchModels $nlSearchModels; + /** * @var ApiCall */ @@ -115,6 +120,7 @@ public function __construct(array $config) $this->analytics = new Analytics($this->apiCall); $this->stemming = new Stemming($this->apiCall); $this->conversations = new Conversations($this->apiCall); + $this->nlSearchModels = new NLSearchModels($this->apiCall); } /** @@ -220,4 +226,12 @@ public function getConversations(): Conversations { return $this->conversations; } + + /** + * @return NLSearchModels + */ + public function getNLSearchModels(): NLSearchModels + { + return $this->nlSearchModels; + } } diff --git a/src/NLSearchModel.php b/src/NLSearchModel.php new file mode 100644 index 00000000..b6750965 --- /dev/null +++ b/src/NLSearchModel.php @@ -0,0 +1,73 @@ +id = $id; + $this->apiCall = $apiCall; + } + + /** + * @param array $params + * + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function update(array $params): array + { + return $this->apiCall->put($this->endPointPath(), $params); + } + + /** + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function retrieve(): array + { + return $this->apiCall->get($this->endPointPath(), []); + } + + /** + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function delete(): array + { + return $this->apiCall->delete($this->endPointPath()); + } + + /** + * @return string + */ + public function endPointPath(): string + { + return sprintf('%s/%s', NLSearchModels::RESOURCE_PATH, encodeURIComponent($this->id)); + } +} \ No newline at end of file diff --git a/src/NLSearchModels.php b/src/NLSearchModels.php new file mode 100644 index 00000000..dad5b063 --- /dev/null +++ b/src/NLSearchModels.php @@ -0,0 +1,109 @@ +apiCall = $apiCall; + } + + /** + * @param $id + * + * @return mixed + */ + public function __get($id) + { + if (isset($this->{$id})) { + return $this->{$id}; + } + if (!isset($this->nlSearchModels[$id])) { + $this->nlSearchModels[$id] = new NLSearchModel($id, $this->apiCall); + } + + return $this->nlSearchModels[$id]; + } + + /** + * @param array $params + * + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function create(array $params): array + { + return $this->apiCall->post(static::RESOURCE_PATH, $params); + } + + /** + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function retrieve(): array + { + return $this->apiCall->get(static::RESOURCE_PATH, []); + } + + /** + * @inheritDoc + */ + public function offsetExists($offset): bool + { + return isset($this->nlSearchModels[$offset]); + } + + /** + * @inheritDoc + */ + public function offsetGet($offset): NLSearchModel + { + if (!isset($this->nlSearchModels[$offset])) { + $this->nlSearchModels[$offset] = new NLSearchModel($offset, $this->apiCall); + } + + return $this->nlSearchModels[$offset]; + } + + /** + * @inheritDoc + */ + public function offsetSet($offset, $value): void + { + $this->nlSearchModels[$offset] = $value; + } + + /** + * @inheritDoc + */ + public function offsetUnset($offset): void + { + unset($this->nlSearchModels[$offset]); + } +} \ No newline at end of file diff --git a/tests/Feature/NLSearchModelsTest.php b/tests/Feature/NLSearchModelsTest.php new file mode 100644 index 00000000..80770b83 --- /dev/null +++ b/tests/Feature/NLSearchModelsTest.php @@ -0,0 +1,129 @@ + "test-collection-model", + "model_name" => "openai/gpt-3.5-turbo", + "api_key" => $_ENV['OPENAI_API_KEY'] ?? getenv('OPENAI_API_KEY'), + "system_prompt" => "You are a helpful search assistant.", + "max_bytes" => 16384, + "temperature" => 0.7 + ]; + + $response = $this->client()->nlSearchModels->create($data); + $this->assertArrayHasKey('id', $response); + $this->assertEquals('test-collection-model', $response['id']); + $this->assertEquals('openai/gpt-3.5-turbo', $response['model_name']); + + $this->client()->nlSearchModels['test-collection-model']->delete(); + } + + public function testCanRetrieveAllModels(): void + { + $testData = [ + "id" => "retrieve-test-model", + "model_name" => "openai/gpt-3.5-turbo", + "api_key" => $_ENV['OPENAI_API_KEY'] ?? getenv('OPENAI_API_KEY'), + "system_prompt" => "Test model for retrieval.", + "max_bytes" => 8192 + ]; + + $this->client()->nlSearchModels->create($testData); + + $response = $this->client()->nlSearchModels->retrieve(); + $this->assertIsArray($response); + + $foundModel = false; + foreach ($response as $model) { + if ($model['id'] === 'retrieve-test-model') { + $foundModel = true; + $this->assertEquals('openai/gpt-3.5-turbo', $model['model_name']); + break; + } + } + $this->assertTrue($foundModel, 'Created test model should be found in the list'); + + $this->client()->nlSearchModels['retrieve-test-model']->delete(); + } + + public function testCreateWithMissingRequiredFields(): void + { + $incompleteData = [ + "model_name" => "openai/gpt-3.5-turbo" + ]; + + $this->expectException(\Typesense\Exceptions\RequestMalformed::class); + $this->client()->nlSearchModels->create($incompleteData); + } + + public function testCreateWithInvalidModelName(): void + { + $invalidData = [ + "id" => "invalid-model-test", + "model_name" => "invalid/model-name", + "api_key" => $_ENV['OPENAI_API_KEY'] ?? getenv('OPENAI_API_KEY'), + "system_prompt" => "This should fail.", + "max_bytes" => 16384 + ]; + + $this->expectException(\Typesense\Exceptions\RequestMalformed::class); + $this->client()->nlSearchModels->create($invalidData); + } + + public function testUpdate(): void + { + $data = [ + "id" => "test-collection-model", + "model_name" => "openai/gpt-3.5-turbo", + "api_key" => $_ENV['OPENAI_API_KEY'] ?? getenv('OPENAI_API_KEY'), + "system_prompt" => "You are a helpful search assistant.", + "max_bytes" => 16384, + "temperature" => 0.7 + ]; + + $response = $this->client()->nlSearchModels->create($data); + $this->assertArrayHasKey('id', $response); + $this->assertEquals('test-collection-model', $response['id']); + $this->assertEquals('openai/gpt-3.5-turbo', $response['model_name']); + + $response = $this->client()->nlSearchModels['test-collection-model']->update([ + "temperature" => 0.5 + ]); + $this->assertArrayHasKey('id', $response); + $this->assertEquals('test-collection-model', $response['id']); + $this->assertEquals(0.5, $response['temperature']); + + $this->client()->nlSearchModels['test-collection-model']->delete(); + } + + public function testDelete(): void + { + $data = [ + "id" => "test-collection-model", + "model_name" => "openai/gpt-3.5-turbo", + "api_key" => $_ENV['OPENAI_API_KEY'] ?? getenv('OPENAI_API_KEY'), + "system_prompt" => "You are a helpful search assistant.", + "max_bytes" => 16384, + "temperature" => 0.7 + ]; + + $response = $this->client()->nlSearchModels->create($data); + $this->assertArrayHasKey('id', $response); + $this->assertEquals('test-collection-model', $response['id']); + $this->assertEquals('openai/gpt-3.5-turbo', $response['model_name']); + + $response = $this->client()->nlSearchModels['test-collection-model']->delete(); + $this->assertArrayHasKey('id', $response); + $this->assertEquals('test-collection-model', $response['id']); + + } +} \ No newline at end of file diff --git a/tests/NLSearchModelsTestCase.php b/tests/NLSearchModelsTestCase.php new file mode 100644 index 00000000..05d76657 --- /dev/null +++ b/tests/NLSearchModelsTestCase.php @@ -0,0 +1,28 @@ +markTestSkipped('OPENAI_API_KEY environment variable is not set. Skipping NL Search Models tests.'); + return; + } + + parent::setUp(); + $this->mockNLSearchModels = new NLSearchModels(parent::mockApiCall()); + } + + protected function mockNLSearchModels(): NLSearchModels + { + return $this->mockNLSearchModels; + } +} \ No newline at end of file From a9123c6e4d3a5be1277a81ee046e3973968f64e3 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 1 Jul 2025 17:58:18 +0300 Subject: [PATCH 2/2] fix(test): return on tear down if client is null --- tests/TestCase.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/TestCase.php b/tests/TestCase.php index dca1d4e8..6a26447b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -89,6 +89,10 @@ protected function setUpDocuments(string $schema): void protected function tearDownTypesense(): void { + if ($this->typesenseClient === null) { + return; + } + $collections = $this->typesenseClient->collections->retrieve(); foreach ($collections as $collection) { $this->typesenseClient->collections[$collection['name']]->delete();