diff --git a/app/Commands/KnowledgeSearchStatusCommand.php b/app/Commands/KnowledgeSearchStatusCommand.php index 5b0505a..3a9bdab 100644 --- a/app/Commands/KnowledgeSearchStatusCommand.php +++ b/app/Commands/KnowledgeSearchStatusCommand.php @@ -4,8 +4,8 @@ namespace App\Commands; -use App\Contracts\EmbeddingServiceInterface; use LaravelZero\Framework\Commands\Command; +use TheShit\Vector\Contracts\EmbeddingClient; use function Termwind\render; @@ -21,7 +21,7 @@ class KnowledgeSearchStatusCommand extends Command */ protected $description = 'Show search capabilities and configuration status'; - public function handle(EmbeddingServiceInterface $embeddingService): int + public function handle(EmbeddingClient $embeddingService): int { // Gather data /** @var bool $semanticEnabled */ @@ -31,7 +31,7 @@ public function handle(EmbeddingServiceInterface $embeddingService): int /** @var string|null $embeddingProvider */ $embeddingProvider = config('search.embedding_provider') ?: 'none'; - $testEmbedding = $embeddingService->generate('test'); + $testEmbedding = $embeddingService->embed('test'); $hasEmbeddingSupport = $testEmbedding !== []; $qdrant = app(\App\Services\QdrantService::class); diff --git a/app/Contracts/EmbeddingServiceInterface.php b/app/Contracts/EmbeddingServiceInterface.php deleted file mode 100644 index 9a07122..0000000 --- a/app/Contracts/EmbeddingServiceInterface.php +++ /dev/null @@ -1,25 +0,0 @@ - The embedding vector - */ - public function generate(string $text): array; - - /** - * Calculate the cosine similarity between two embedding vectors. - * - * @param array $a First embedding vector - * @param array $b Second embedding vector - * @return float Similarity score between 0 and 1 - */ - public function similarity(array $a, array $b): float; -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 84938b2..f5e168e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,6 @@ namespace App\Providers; -use App\Contracts\EmbeddingServiceInterface; use App\Contracts\HealthCheckInterface; use App\Services\DailyLogService; use App\Services\DeletionTracker; @@ -17,10 +16,10 @@ use App\Services\QdrantService; use App\Services\RemoteSyncService; use App\Services\RuntimeEnvironment; -use App\Services\StubEmbeddingService; use App\Services\TieredSearchService; use App\Services\WriteGateService; use Illuminate\Support\ServiceProvider; +use TheShit\Vector\Contracts\EmbeddingClient; use TheShit\Vector\Qdrant; class AppServiceProvider extends ServiceProvider @@ -95,9 +94,24 @@ private function loadUserConfig(): void config(['search.qdrant.collection' => $userConfig['qdrant']['collection']]); } - // embeddings.url -> search.qdrant.embedding_server + // embeddings.provider -> vector.embeddings.provider + if (isset($userConfig['embeddings']['provider']) && is_string($userConfig['embeddings']['provider'])) { + config(['vector.embeddings.provider' => $userConfig['embeddings']['provider']]); + } + + // embeddings.model -> vector.embeddings.model + if (isset($userConfig['embeddings']['model']) && is_string($userConfig['embeddings']['model'])) { + config(['vector.embeddings.model' => $userConfig['embeddings']['model']]); + } + + // embeddings.url -> vector.embeddings.url if (isset($userConfig['embeddings']['url']) && is_string($userConfig['embeddings']['url'])) { - config(['search.qdrant.embedding_server' => $userConfig['embeddings']['url']]); + config(['vector.embeddings.url' => $userConfig['embeddings']['url']]); + } + + // embeddings.api_key -> vector.embeddings.api_key + if (isset($userConfig['embeddings']['api_key']) && is_string($userConfig['embeddings']['api_key'])) { + config(['vector.embeddings.api_key' => $userConfig['embeddings']['api_key']]); } // write_gate.criteria -> write-gate.criteria (per-project overrides) @@ -128,17 +142,6 @@ public function register(): void $app->make(RuntimeEnvironment::class) )); - // Embedding service - $this->app->singleton(EmbeddingServiceInterface::class, function (): \App\Services\StubEmbeddingService|\App\Services\EmbeddingService { - if (config('search.embedding_provider') === 'none') { - return new StubEmbeddingService; - } - - return new \App\Services\EmbeddingService( - config('search.qdrant.embedding_server', 'http://localhost:8001') - ); - }); - // Knowledge cache service $this->app->singleton(KnowledgeCacheService::class, fn (): \App\Services\KnowledgeCacheService => new KnowledgeCacheService); @@ -157,7 +160,7 @@ public function register(): void // Qdrant vector database service $this->app->singleton(QdrantService::class, fn ($app): \App\Services\QdrantService => new QdrantService( - embeddingService: $app->make(EmbeddingServiceInterface::class), + embeddingService: $app->make(EmbeddingClient::class), qdrant: $app->make(Qdrant::class), vectorSize: (int) config('search.embedding_dimension', 1024), scoreThreshold: (float) config('search.minimum_similarity', 0.7), diff --git a/app/Services/CodeIndexerService.php b/app/Services/CodeIndexerService.php index 50fbce2..8a716e5 100644 --- a/app/Services/CodeIndexerService.php +++ b/app/Services/CodeIndexerService.php @@ -4,9 +4,9 @@ namespace App\Services; -use App\Contracts\EmbeddingServiceInterface; use Saloon\Exceptions\Request\RequestException; use Symfony\Component\Finder\Finder; +use TheShit\Vector\Contracts\EmbeddingClient; use TheShit\Vector\Data\ScoredPoint; use TheShit\Vector\Qdrant; @@ -34,7 +34,7 @@ class CodeIndexerService private const FILE_EXTENSIONS = ['php', 'py', 'js', 'ts', 'tsx', 'jsx', 'vue']; public function __construct( - private readonly EmbeddingServiceInterface $embeddingService, + private readonly EmbeddingClient $embeddingService, private readonly Qdrant $qdrant, private readonly int $vectorSize = 1024, ) {} @@ -119,7 +119,7 @@ public function indexFile(string $filepath, string $repo): array $id = md5($filepath.'_'.$index); $text = $this->buildSearchableText($chunk['content'], $filepath, $functions); - $vector = $this->embeddingService->generate($text); + $vector = $this->embeddingService->embed($text); if ($vector === []) { continue; @@ -164,7 +164,7 @@ public function indexFile(string $filepath, string $repo): array */ public function search(string $query, int $limit = 10, array $filters = []): array { - $vector = $this->embeddingService->generate($query); + $vector = $this->embeddingService->embed($query); if ($vector === []) { return []; @@ -212,7 +212,7 @@ public function indexSymbol( int $line, string $signature, ): array { - $vector = $this->embeddingService->generate($text); + $vector = $this->embeddingService->embed($text); if ($vector === []) { return ['success' => false, 'error' => 'Empty embedding']; diff --git a/app/Services/EmbeddingService.php b/app/Services/EmbeddingService.php deleted file mode 100644 index f9e5356..0000000 --- a/app/Services/EmbeddingService.php +++ /dev/null @@ -1,86 +0,0 @@ -client = new Client([ - 'base_uri' => rtrim($serverUrl, '/'), - 'timeout' => 30, - 'connect_timeout' => 5, - 'http_errors' => false, - ]); - } - - /** - * @return array - * - * @codeCoverageIgnore Requires external embedding server - */ - public function generate(string $text): array - { - if (trim($text) === '') { - return []; - } - - try { - $response = $this->client->post('/embed', [ - 'json' => ['text' => $text], - ]); - - if ($response->getStatusCode() !== 200) { - return []; - } - - $data = json_decode((string) $response->getBody(), true); - - if (! is_array($data) || ! isset($data['embeddings'][0])) { - return []; - } - - return array_map(fn ($val): float => (float) $val, $data['embeddings'][0]); - } catch (\Throwable) { - return []; - } - } - - /** - * @param array $a - * @param array $b - * - * @codeCoverageIgnore Same logic tested in StubEmbeddingServiceTest - */ - public function similarity(array $a, array $b): float - { - if ($a === [] || $b === [] || count($a) !== count($b)) { - return 0.0; - } - - $dotProduct = 0.0; - $normA = 0.0; - $normB = 0.0; - - foreach ($a as $i => $valA) { - $valB = $b[$i]; - $dotProduct += $valA * $valB; - $normA += $valA * $valA; - $normB += $valB * $valB; - } - - if ($normA === 0.0 || $normB === 0.0) { - return 0.0; - } - - return $dotProduct / (sqrt($normA) * sqrt($normB)); - } -} diff --git a/app/Services/QdrantService.php b/app/Services/QdrantService.php index 299a937..e88625d 100644 --- a/app/Services/QdrantService.php +++ b/app/Services/QdrantService.php @@ -4,7 +4,6 @@ namespace App\Services; -use App\Contracts\EmbeddingServiceInterface; use App\Contracts\SparseEmbeddingServiceInterface; use App\Exceptions\Qdrant\CollectionCreationException; use App\Exceptions\Qdrant\ConnectionException; @@ -14,6 +13,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Saloon\Exceptions\Request\RequestException; +use TheShit\Vector\Contracts\EmbeddingClient; use TheShit\Vector\Data\ScoredPoint; use TheShit\Vector\Qdrant; @@ -22,7 +22,7 @@ class QdrantService private ?SparseEmbeddingServiceInterface $sparseEmbeddingService = null; public function __construct( - private readonly EmbeddingServiceInterface $embeddingService, + private readonly EmbeddingClient $embeddingService, private readonly Qdrant $qdrant, private readonly int $vectorSize = 384, private readonly float $scoreThreshold = 0.7, @@ -572,11 +572,11 @@ public function getCollectionName(string $project): string private function getCachedEmbedding(string $text): array { if (! config('search.qdrant.cache_embeddings', true)) { - return $this->embeddingService->generate($text); + return $this->embeddingService->embed($text); } if ($this->cacheService instanceof KnowledgeCacheService) { - return $this->cacheService->rememberEmbedding($text, fn (): array => $this->embeddingService->generate($text)); + return $this->cacheService->rememberEmbedding($text, fn (): array => $this->embeddingService->embed($text)); } $cacheKey = 'embedding:'.hash('xxh128', $text); @@ -585,7 +585,7 @@ private function getCachedEmbedding(string $text): array return Cache::remember( $cacheKey, $this->cacheTtl, - fn (): array => $this->embeddingService->generate($text) + fn (): array => $this->embeddingService->embed($text) ); } diff --git a/app/Services/StubEmbeddingService.php b/app/Services/StubEmbeddingService.php deleted file mode 100644 index b015419..0000000 --- a/app/Services/StubEmbeddingService.php +++ /dev/null @@ -1,35 +0,0 @@ - Empty array (stub implementation) - */ - public function generate(string $text): array - { - return []; - } - - /** - * Calculate the cosine similarity between two embedding vectors. - * This is a stub implementation that returns 0.0. - * - * @param array $a First embedding vector - * @param array $b Second embedding vector - * @return float Always returns 0.0 (stub implementation) - */ - public function similarity(array $a, array $b): float - { - return 0.0; - } -} diff --git a/composer.json b/composer.json index 9beaf4a..748ac53 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "laravel/mcp": "^0.6.0", "saloonphp/saloon": "^4.0", "symfony/uid": "^8.0", - "the-shit/vector": "^0.1.1" + "the-shit/vector": "^0.2" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 7b37479..8b2b00d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a4ad8e1bb7e1e6d06fb643d78e9ed073", + "content-hash": "fca5186e90e3889558a21ff0b436cffb", "packages": [ { "name": "brick/math", @@ -6689,16 +6689,16 @@ }, { "name": "the-shit/vector", - "version": "v0.1.1", + "version": "v0.2.0", "source": { "type": "git", "url": "https://github.com/the-shit/vector.git", - "reference": "1bd248e9adc6a63332ecbe97817695bf7c4a17cd" + "reference": "47d1db39b0cc741de7175d2bec4cceb53bd09ad7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/the-shit/vector/zipball/1bd248e9adc6a63332ecbe97817695bf7c4a17cd", - "reference": "1bd248e9adc6a63332ecbe97817695bf7c4a17cd", + "url": "https://api.github.com/repos/the-shit/vector/zipball/47d1db39b0cc741de7175d2bec4cceb53bd09ad7", + "reference": "47d1db39b0cc741de7175d2bec4cceb53bd09ad7", "shasum": "" }, "require": { @@ -6750,9 +6750,9 @@ ], "support": { "issues": "https://github.com/the-shit/vector/issues", - "source": "https://github.com/the-shit/vector/tree/v0.1.1" + "source": "https://github.com/the-shit/vector/tree/v0.2.0" }, - "time": "2026-04-06T04:02:03+00:00" + "time": "2026-04-06T20:13:14+00:00" }, { "name": "vlucas/phpdotenv", diff --git a/docker-compose.remote.yml b/docker-compose.remote.yml index 217f489..0d8aa15 100644 --- a/docker-compose.remote.yml +++ b/docker-compose.remote.yml @@ -41,32 +41,11 @@ services: timeout: 10s retries: 3 - embeddings: - build: - context: ./docker/embedding-server - dockerfile: Dockerfile - container_name: knowledge-embeddings - restart: unless-stopped - ports: - - "${BIND_ADDR:-127.0.0.1}:8001:8001" - volumes: - - embedding_cache:/root/.cache - environment: - - EMBEDDING_MODEL=BAAI/bge-large-en-v1.5 - - DEVICE=cpu - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')"] - interval: 30s - timeout: 10s - retries: 3 - volumes: qdrant_storage: driver: local redis_data: driver: local - embedding_cache: - driver: local networks: default: diff --git a/docker-compose.yml b/docker-compose.yml index 5bdbc88..8324401 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,19 +17,5 @@ services: timeout: 10s retries: 3 - embedding-server: - build: - context: ./docker/embedding-server - dockerfile: Dockerfile - container_name: knowledge-embeddings - restart: unless-stopped - ports: - - "8001:8001" - volumes: - - embedding_cache:/root/.cache - depends_on: - - qdrant - volumes: qdrant_storage: - embedding_cache: diff --git a/docker/embedding-server/Dockerfile b/docker/embedding-server/Dockerfile deleted file mode 100644 index 4921d34..0000000 --- a/docker/embedding-server/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install dependencies first (cached layer) -RUN pip install --no-cache-dir \ - sentence-transformers \ - flask \ - gunicorn - -# Copy server code -COPY server.py . - -# Pre-download the model during build -RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')" - -EXPOSE 8001 - -CMD ["gunicorn", "-w", "4", "--preload", "--timeout", "120", "-b", "0.0.0.0:8001", "server:app"] diff --git a/docker/embedding-server/server.py b/docker/embedding-server/server.py deleted file mode 100644 index 004a34a..0000000 --- a/docker/embedding-server/server.py +++ /dev/null @@ -1,81 +0,0 @@ -""" -Embedding server for conduit-ui/knowledge using sentence-transformers. -Provides a REST API for generating text embeddings. -""" - -import os -from flask import Flask, request, jsonify -from sentence_transformers import SentenceTransformer - -app = Flask(__name__) - -# Load model at module level — with gunicorn --preload this runs once before -# workers fork, so all workers share the weights via copy-on-write. -MODEL_NAME = os.environ.get('EMBEDDING_MODEL', 'all-MiniLM-L6-v2') -print(f"Loading model: {MODEL_NAME}") -model = SentenceTransformer(MODEL_NAME) -print(f"Model loaded. Embedding dimension: {model.get_sentence_embedding_dimension()}") - - -def get_model(): - return model - - -@app.route('/health', methods=['GET']) -def health(): - """Health check endpoint.""" - return jsonify({'status': 'healthy', 'model': MODEL_NAME}) - - -@app.route('/embed', methods=['POST']) -def embed(): - """Generate embeddings for text input. - - Request body: - { - "texts": ["text1", "text2", ...] or - "text": "single text" - } - - Response: - { - "embeddings": [[0.1, 0.2, ...], [0.3, 0.4, ...]], - "model": "all-MiniLM-L6-v2", - "dimension": 384 - } - """ - data = request.get_json() - - if not data: - return jsonify({'error': 'No JSON body provided'}), 400 - - # Accept either 'texts' (array) or 'text' (single string) - texts = data.get('texts') or [data.get('text')] - - if not texts or texts == [None]: - return jsonify({'error': 'No text provided'}), 400 - - m = get_model() - embeddings = m.encode(texts, convert_to_numpy=True).tolist() - - return jsonify({ - 'embeddings': embeddings, - 'model': MODEL_NAME, - 'dimension': m.get_sentence_embedding_dimension() - }) - - -@app.route('/info', methods=['GET']) -def info(): - """Get model information.""" - m = get_model() - return jsonify({ - 'model': MODEL_NAME, - 'dimension': m.get_sentence_embedding_dimension() - }) - - -if __name__ == '__main__': - # Preload model - get_model() - app.run(host='0.0.0.0', port=8001, debug=False) diff --git a/tests/Feature/AppServiceProviderTest.php b/tests/Feature/AppServiceProviderTest.php index 37d0f44..d038d44 100644 --- a/tests/Feature/AppServiceProviderTest.php +++ b/tests/Feature/AppServiceProviderTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -use App\Contracts\EmbeddingServiceInterface; use App\Contracts\HealthCheckInterface; -use App\Services\EmbeddingService; use App\Services\HealthCheckService; use App\Services\KnowledgePathService; use App\Services\QdrantService; use App\Services\RuntimeEnvironment; -use App\Services\StubEmbeddingService; +use TheShit\Vector\Contracts\EmbeddingClient; +use TheShit\Vector\Embeddings\NullEmbeddings; +use TheShit\Vector\Embeddings\OllamaEmbeddings; describe('AppServiceProvider', function (): void { it('registers RuntimeEnvironment', function (): void { @@ -24,29 +24,32 @@ expect($service)->toBeInstanceOf(KnowledgePathService::class); }); - it('registers StubEmbeddingService by default', function (): void { - config(['search.embedding_provider' => 'none']); + it('resolves NullEmbeddings when provider is none', function (): void { + config(['vector.embeddings.provider' => 'none']); - app()->forgetInstance(EmbeddingServiceInterface::class); + app()->forgetInstance(EmbeddingClient::class); - $service = app(EmbeddingServiceInterface::class); + $service = app(EmbeddingClient::class); - expect($service)->toBeInstanceOf(StubEmbeddingService::class); + expect($service)->toBeInstanceOf(NullEmbeddings::class); }); - it('registers EmbeddingService when provider is qdrant', function (): void { - config(['search.embedding_provider' => 'qdrant']); + it('resolves OllamaEmbeddings when provider is ollama', function (): void { + config([ + 'vector.embeddings.provider' => 'ollama', + 'vector.embeddings.url' => 'http://localhost:11434', + ]); - app()->forgetInstance(EmbeddingServiceInterface::class); + app()->forgetInstance(EmbeddingClient::class); - $service = app(EmbeddingServiceInterface::class); + $service = app(EmbeddingClient::class); - expect($service)->toBeInstanceOf(EmbeddingService::class); + expect($service)->toBeInstanceOf(OllamaEmbeddings::class); }); it('registers QdrantService with mocked embedding service', function (): void { - $mockEmbedding = Mockery::mock(EmbeddingServiceInterface::class); - app()->instance(EmbeddingServiceInterface::class, $mockEmbedding); + $mockEmbedding = Mockery::mock(EmbeddingClient::class); + app()->instance(EmbeddingClient::class, $mockEmbedding); config([ 'search.embedding_dimension' => 384, @@ -63,8 +66,8 @@ }); it('registers QdrantService with secure connection configuration', function (): void { - $mockEmbedding = Mockery::mock(EmbeddingServiceInterface::class); - app()->instance(EmbeddingServiceInterface::class, $mockEmbedding); + $mockEmbedding = Mockery::mock(EmbeddingClient::class); + app()->instance(EmbeddingClient::class, $mockEmbedding); config([ 'search.embedding_dimension' => 1536, @@ -86,18 +89,10 @@ expect($service)->toBeInstanceOf(HealthCheckService::class); }); - it('uses custom embedding server configuration for qdrant provider', function (): void { - config([ - 'search.embedding_provider' => 'qdrant', - 'search.qdrant.embedding_server' => 'http://custom-server:8001', - 'search.qdrant.model' => 'custom-model', - ]); - - app()->forgetInstance(EmbeddingServiceInterface::class); - - $service = app(EmbeddingServiceInterface::class); + it('resolves EmbeddingClient from container', function (): void { + $service = app(EmbeddingClient::class); - expect($service)->toBeInstanceOf(EmbeddingService::class); + expect($service)->toBeInstanceOf(EmbeddingClient::class); }); }); @@ -187,11 +182,14 @@ expect(config('search.qdrant.collection'))->toBe('my-custom-collection'); }); - it('loads embeddings url into embedding_server config', function (): void { + it('loads embeddings config into vector.embeddings', function (): void { $configPath = $this->testConfigDir.'/config.json'; $config = [ 'embeddings' => [ - 'url' => 'http://custom-embeddings:9001', + 'provider' => 'openai', + 'model' => 'text-embedding-3-large', + 'url' => 'https://api.openai.com', + 'api_key' => 'sk-test-key', ], ]; file_put_contents($configPath, json_encode($config)); @@ -206,7 +204,10 @@ $provider = new \App\Providers\AppServiceProvider(app()); $provider->boot(); - expect(config('search.qdrant.embedding_server'))->toBe('http://custom-embeddings:9001'); + expect(config('vector.embeddings.provider'))->toBe('openai'); + expect(config('vector.embeddings.model'))->toBe('text-embedding-3-large'); + expect(config('vector.embeddings.url'))->toBe('https://api.openai.com'); + expect(config('vector.embeddings.api_key'))->toBe('sk-test-key'); }); it('loads all config values together', function (): void { @@ -217,7 +218,8 @@ 'collection' => 'test-knowledge', ], 'embeddings' => [ - 'url' => 'http://embedding-server:8002', + 'provider' => 'ollama', + 'url' => 'http://ollama:11434', ], ]; file_put_contents($configPath, json_encode($config)); @@ -235,7 +237,8 @@ expect(config('search.qdrant.host'))->toBe('qdrant-server'); expect(config('search.qdrant.port'))->toBe(6334); expect(config('search.qdrant.collection'))->toBe('test-knowledge'); - expect(config('search.qdrant.embedding_server'))->toBe('http://embedding-server:8002'); + expect(config('vector.embeddings.provider'))->toBe('ollama'); + expect(config('vector.embeddings.url'))->toBe('http://ollama:11434'); }); it('does not modify config when config file does not exist', function (): void { diff --git a/tests/Feature/KnowledgeSearchStatusCommandTest.php b/tests/Feature/KnowledgeSearchStatusCommandTest.php index 47d7508..664dd31 100644 --- a/tests/Feature/KnowledgeSearchStatusCommandTest.php +++ b/tests/Feature/KnowledgeSearchStatusCommandTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -use App\Contracts\EmbeddingServiceInterface; use App\Services\QdrantService; +use TheShit\Vector\Contracts\EmbeddingClient; describe('KnowledgeSearchStatusCommand', function (): void { beforeEach(function (): void { - $this->embeddingService = mock(EmbeddingServiceInterface::class); + $this->embeddingService = mock(EmbeddingClient::class); $this->qdrant = mock(QdrantService::class); - app()->instance(EmbeddingServiceInterface::class, $this->embeddingService); + app()->instance(EmbeddingClient::class, $this->embeddingService); app()->instance(QdrantService::class, $this->qdrant); }); @@ -18,7 +18,7 @@ config(['search.semantic_enabled' => false]); config(['search.embedding_provider' => 'none']); - $this->embeddingService->shouldReceive('generate') + $this->embeddingService->shouldReceive('embed') ->once() ->with('test') ->andReturn([]); @@ -36,7 +36,7 @@ config(['search.semantic_enabled' => true]); config(['search.embedding_provider' => 'ollama']); - $this->embeddingService->shouldReceive('generate') + $this->embeddingService->shouldReceive('embed') ->once() ->with('test') ->andReturn([0.1, 0.2, 0.3]); // Non-empty embedding @@ -56,7 +56,7 @@ config(['search.semantic_enabled' => false]); config(['search.embedding_provider' => 'none']); - $this->embeddingService->shouldReceive('generate') + $this->embeddingService->shouldReceive('embed') ->once() ->with('test') ->andReturn([]); @@ -74,7 +74,7 @@ config(['search.semantic_enabled' => true]); config(['search.embedding_provider' => 'openai']); - $this->embeddingService->shouldReceive('generate') + $this->embeddingService->shouldReceive('embed') ->once() ->with('test') ->andReturn([]); // Empty embedding @@ -91,7 +91,7 @@ it('displays database statistics', function (): void { config(['search.semantic_enabled' => false]); - $this->embeddingService->shouldReceive('generate') + $this->embeddingService->shouldReceive('embed') ->once() ->with('test') ->andReturn([]); @@ -115,7 +115,7 @@ config(['search.semantic_enabled' => true]); config(['search.embedding_provider' => 'ollama']); - $this->embeddingService->shouldReceive('generate') + $this->embeddingService->shouldReceive('embed') ->once() ->with('test') ->andReturn([0.1, 0.2, 0.3]); @@ -132,7 +132,7 @@ it('displays usage instructions with semantic search disabled', function (): void { config(['search.semantic_enabled' => false]); - $this->embeddingService->shouldReceive('generate') + $this->embeddingService->shouldReceive('embed') ->once() ->with('test') ->andReturn([]); @@ -149,7 +149,7 @@ it('handles empty database', function (): void { config(['search.semantic_enabled' => false]); - $this->embeddingService->shouldReceive('generate') + $this->embeddingService->shouldReceive('embed') ->once() ->with('test') ->andReturn([]); diff --git a/tests/Support/MockEmbeddingService.php b/tests/Support/MockEmbeddingService.php index a586ebf..e41561a 100644 --- a/tests/Support/MockEmbeddingService.php +++ b/tests/Support/MockEmbeddingService.php @@ -4,21 +4,15 @@ namespace Tests\Support; -use App\Contracts\EmbeddingServiceInterface; +use TheShit\Vector\Contracts\EmbeddingClient; -class MockEmbeddingService implements EmbeddingServiceInterface +class MockEmbeddingService implements EmbeddingClient { /** - * Generate a simple mock embedding vector based on text. - * This creates a deterministic embedding for testing purposes. - * - * @param string $text The text to generate an embedding for - * @return array Mock embedding vector + * @return array */ - public function generate(string $text): array + public function embed(string $text): array { - // Generate a simple deterministic embedding based on the text - // Use string length and character codes to create variation $hash = md5($text); $embedding = []; @@ -30,36 +24,11 @@ public function generate(string $text): array } /** - * Calculate cosine similarity between two embedding vectors. - * - * @param array $a First embedding vector - * @param array $b Second embedding vector - * @return float Similarity score between 0 and 1 + * @param array $texts + * @return array> */ - public function similarity(array $a, array $b): float + public function embedBatch(array $texts): array { - if (count($a) !== count($b) || $a === []) { - return 0.0; - } - - $dotProduct = 0.0; - $magnitudeA = 0.0; - $magnitudeB = 0.0; - $counter = count($a); - - for ($i = 0; $i < $counter; $i++) { - $dotProduct += $a[$i] * $b[$i]; - $magnitudeA += $a[$i] * $a[$i]; - $magnitudeB += $b[$i] * $b[$i]; - } - - $magnitudeA = sqrt($magnitudeA); - $magnitudeB = sqrt($magnitudeB); - - if ($magnitudeA === 0.0 || $magnitudeB === 0.0) { - return 0.0; - } - - return $dotProduct / ($magnitudeA * $magnitudeB); + return array_map(fn (string $text): array => $this->embed($text), $texts); } } diff --git a/tests/Unit/Services/CodeIndexerServiceTest.php b/tests/Unit/Services/CodeIndexerServiceTest.php index 9c066bc..f113597 100644 --- a/tests/Unit/Services/CodeIndexerServiceTest.php +++ b/tests/Unit/Services/CodeIndexerServiceTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -use App\Contracts\EmbeddingServiceInterface; use App\Services\CodeIndexerService; use Saloon\Exceptions\Request\RequestException; use Saloon\Http\Response as SaloonResponse; +use TheShit\Vector\Contracts\EmbeddingClient; use TheShit\Vector\Data\CollectionInfo; use TheShit\Vector\Data\ScoredPoint; use TheShit\Vector\Data\ScrollResult; @@ -15,7 +15,7 @@ uses()->group('code-indexer-unit'); beforeEach(function (): void { - $this->mockEmbedding = Mockery::mock(EmbeddingServiceInterface::class); + $this->mockEmbedding = Mockery::mock(EmbeddingClient::class); $this->mockQdrant = Mockery::mock(Qdrant::class); $this->service = new CodeIndexerService($this->mockEmbedding, $this->mockQdrant, 1024); }); @@ -176,7 +176,7 @@ function testFunction() { } '); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -211,7 +211,7 @@ function testFunction() { $filepath = $tempDir.'/test.php'; file_put_contents($filepath, 'mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn([]); @@ -233,7 +233,7 @@ function testFunction() { $filepath = $tempDir.'/test.php'; file_put_contents($filepath, 'mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -260,7 +260,7 @@ function testFunction() { $content = "mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->atLeast()->times(2) ->andReturn(array_fill(0, 1024, 0.1)); @@ -288,7 +288,7 @@ private function privateMethod() {} protected function protectedMethod() {} '); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->withArgs(function ($text) { return str_contains($text, 'globalFunction') && str_contains($text, 'publicMethod') @@ -322,7 +322,7 @@ protected function protectedMethod() {} pass '); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->withArgs(function ($text) { return str_contains($text, 'regular_function') && str_contains($text, 'async_function'); @@ -352,7 +352,7 @@ function regularFunction() {} async arrowAsync() {} '); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->withArgs(function ($text) { return str_contains($text, 'regularFunction'); }) @@ -380,7 +380,7 @@ function typescriptFunction() {} const constFunction = async () => {}; '); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->withArgs(function ($text) { return str_contains($text, 'typescriptFunction'); }) @@ -408,7 +408,7 @@ function typescriptFunction() {} function vueFunction() {} '); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -430,7 +430,7 @@ function vueFunction() {} $filepath = $tempDir.'/unknown.xyz'; file_put_contents($filepath, 'some content'); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -456,7 +456,7 @@ function ReactComponent() { } '); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->withArgs(function ($text) { return str_contains($text, 'ReactComponent'); }) @@ -485,7 +485,7 @@ function JsxComponent() { } '); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -508,7 +508,7 @@ function JsxComponent() { $content = "mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->twice() ->andReturn([], array_fill(0, 1024, 0.1)); @@ -528,7 +528,7 @@ function JsxComponent() { describe('search', function (): void { it('successfully searches code with results', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('find authentication function') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -563,7 +563,7 @@ function JsxComponent() { }); it('returns empty array when embedding generation fails', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('query') ->once() ->andReturn([]); @@ -574,7 +574,7 @@ function JsxComponent() { }); it('returns empty array when search request fails', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('query') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -589,7 +589,7 @@ function JsxComponent() { }); it('handles empty result set', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('nonexistent code') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -604,7 +604,7 @@ function JsxComponent() { }); it('applies repo filter', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('search query') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -619,7 +619,7 @@ function JsxComponent() { }); it('applies language filter', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('search query') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -634,7 +634,7 @@ function JsxComponent() { }); it('applies both repo and language filters', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('search query') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -652,7 +652,7 @@ function JsxComponent() { }); it('handles custom limit', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('search') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -667,7 +667,7 @@ function JsxComponent() { }); it('handles missing payload fields gracefully', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('query') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -694,7 +694,7 @@ function JsxComponent() { }); it('handles missing score gracefully', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('query') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -714,7 +714,7 @@ function JsxComponent() { }); it('handles empty filter array', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('search') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -731,7 +731,7 @@ function JsxComponent() { describe('indexSymbol', function (): void { it('successfully indexes a symbol', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -754,7 +754,7 @@ function JsxComponent() { }); it('returns error when embedding is empty', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn([]); @@ -773,7 +773,7 @@ function JsxComponent() { }); it('returns error when upsert fails', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -798,7 +798,7 @@ function JsxComponent() { it('truncates content to 4000 chars', function (): void { $longText = str_repeat('x', 5000); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -886,7 +886,7 @@ function JsxComponent() { ->once() ->andReturn('class UserController { }'); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -914,7 +914,7 @@ function JsxComponent() { $symbolIndex = Mockery::mock(\App\Services\SymbolIndexService::class); $symbolIndex->shouldReceive('getSymbolSource')->once()->andReturnNull(); - $this->mockEmbedding->shouldReceive('generate')->once()->andReturn(array_fill(0, 1024, 0.1)); + $this->mockEmbedding->shouldReceive('embed')->once()->andReturn(array_fill(0, 1024, 0.1)); $this->mockQdrant->shouldReceive('upsert') ->once() @@ -941,7 +941,7 @@ function JsxComponent() { $symbolIndex = Mockery::mock(\App\Services\SymbolIndexService::class); $symbolIndex->shouldReceive('getSymbolSource')->once()->andReturnNull(); - $this->mockEmbedding->shouldReceive('generate')->once()->andReturn(array_fill(0, 1024, 0.1)); + $this->mockEmbedding->shouldReceive('embed')->once()->andReturn(array_fill(0, 1024, 0.1)); $this->mockQdrant->shouldReceive('upsert') ->once() @@ -967,7 +967,7 @@ function JsxComponent() { $symbolIndex = Mockery::mock(\App\Services\SymbolIndexService::class); $symbolIndex->shouldReceive('getSymbolSource')->once()->andReturnNull(); - $this->mockEmbedding->shouldReceive('generate')->once()->andReturn(array_fill(0, 1024, 0.1)); + $this->mockEmbedding->shouldReceive('embed')->once()->andReturn(array_fill(0, 1024, 0.1)); $this->mockQdrant->shouldReceive('upsert') ->once() @@ -1002,7 +1002,7 @@ function (int $success, int $failed, int $total) use (&$progressCalled): void { $symbolIndex = Mockery::mock(\App\Services\SymbolIndexService::class); $symbolIndex->shouldReceive('getSymbolSource')->once()->andReturnNull(); - $this->mockEmbedding->shouldReceive('generate')->once()->andReturn([]); + $this->mockEmbedding->shouldReceive('embed')->once()->andReturn([]); $result = $this->service->vectorizeFromIndex($tempFile, 'local/test', $symbolIndex); @@ -1069,7 +1069,7 @@ function (int $success, int $failed, int $total) use (&$progressCalled): void { ->once() ->andReturn('class Foo { public function bar() {} }'); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->withArgs(function (string $text): bool { return str_contains($text, 'class Foo { public function bar() {} }'); }) @@ -1277,7 +1277,7 @@ function (int $success, int $failed, int $total) use (&$progressCalled): void { $symbolIndex = Mockery::mock(\App\Services\SymbolIndexService::class); // buildSymbolText produces "class \n\n\nfile: " which isn't empty, so it proceeds $symbolIndex->shouldReceive('getSymbolSource')->once()->andReturnNull(); - $this->mockEmbedding->shouldReceive('generate')->once()->andReturn([]); + $this->mockEmbedding->shouldReceive('embed')->once()->andReturn([]); $result = $this->service->vectorizeFromIndex($tempFile, 'local/test', $symbolIndex); diff --git a/tests/Unit/Services/HybridSearchTest.php b/tests/Unit/Services/HybridSearchTest.php index 0eb7201..47007b2 100644 --- a/tests/Unit/Services/HybridSearchTest.php +++ b/tests/Unit/Services/HybridSearchTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -use App\Contracts\EmbeddingServiceInterface; use App\Contracts\SparseEmbeddingServiceInterface; use App\Services\QdrantService; use Illuminate\Support\Facades\Cache; use Saloon\Exceptions\Request\RequestException; use Saloon\Http\Response as SaloonResponse; +use TheShit\Vector\Contracts\EmbeddingClient; use TheShit\Vector\Data\CollectionInfo; use TheShit\Vector\Data\ScoredPoint; use TheShit\Vector\Data\UpsertResult; @@ -18,7 +18,7 @@ beforeEach(function (): void { Cache::flush(); - $this->mockEmbedding = Mockery::mock(EmbeddingServiceInterface::class); + $this->mockEmbedding = Mockery::mock(EmbeddingClient::class); $this->mockSparseEmbedding = Mockery::mock(SparseEmbeddingServiceInterface::class); $this->mockQdrant = Mockery::mock(Qdrant::class); $this->mockQdrantDense = Mockery::mock(Qdrant::class); @@ -80,7 +80,7 @@ function mockHybridCollectionExists(Mockery\MockInterface $qdrant, int $times = it('performs hybrid search with RRF fusion', function (): void { mockHybridCollectionExists($this->mockQdrant); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('test query') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -114,7 +114,7 @@ function mockHybridCollectionExists(Mockery\MockInterface $qdrant, int $times = it('falls back to dense search when hybrid not enabled', function (): void { mockHybridCollectionExists($this->mockQdrantDense); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('test query') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -137,7 +137,7 @@ function mockHybridCollectionExists(Mockery\MockInterface $qdrant, int $times = it('falls back to dense search when sparse embedding fails', function (): void { mockHybridCollectionExists($this->mockQdrant, 2); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('test query') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -165,7 +165,7 @@ function mockHybridCollectionExists(Mockery\MockInterface $qdrant, int $times = it('returns empty collection when dense embedding fails', function (): void { mockHybridCollectionExists($this->mockQdrant); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('test query') ->once() ->andReturn([]); @@ -178,7 +178,7 @@ function mockHybridCollectionExists(Mockery\MockInterface $qdrant, int $times = it('returns empty collection when search fails', function (): void { mockHybridCollectionExists($this->mockQdrant); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('test query') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -200,7 +200,7 @@ function mockHybridCollectionExists(Mockery\MockInterface $qdrant, int $times = it('applies filters to hybrid search', function (): void { mockHybridCollectionExists($this->mockQdrant); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('test query') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -223,7 +223,7 @@ function mockHybridCollectionExists(Mockery\MockInterface $qdrant, int $times = it('respects custom limit and prefetch limit', function (): void { mockHybridCollectionExists($this->mockQdrant); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn(array_fill(0, 1024, 0.1)); @@ -262,7 +262,7 @@ function mockHybridCollectionExists(Mockery\MockInterface $qdrant, int $times = it('upserts with both dense and sparse vectors when hybrid enabled', function (): void { mockHybridCollectionExists($this->mockQdrant); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content') ->once() ->andReturn(array_fill(0, 1024, 0.1)); diff --git a/tests/Unit/Services/QdrantServiceTest.php b/tests/Unit/Services/QdrantServiceTest.php index 77928ec..1672128 100644 --- a/tests/Unit/Services/QdrantServiceTest.php +++ b/tests/Unit/Services/QdrantServiceTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use App\Contracts\EmbeddingServiceInterface; use App\Exceptions\Qdrant\CollectionCreationException; use App\Exceptions\Qdrant\ConnectionException; use App\Exceptions\Qdrant\DuplicateEntryException; @@ -13,6 +12,7 @@ use Illuminate\Support\Facades\Cache; use Saloon\Exceptions\Request\RequestException; use Saloon\Http\Response as SaloonResponse; +use TheShit\Vector\Contracts\EmbeddingClient; use TheShit\Vector\Data\CollectionInfo; use TheShit\Vector\Data\ScoredPoint; use TheShit\Vector\Data\ScrollResult; @@ -24,7 +24,7 @@ beforeEach(function (): void { Cache::flush(); - $this->mockEmbedding = Mockery::mock(EmbeddingServiceInterface::class); + $this->mockEmbedding = Mockery::mock(EmbeddingClient::class); $this->mockQdrant = Mockery::mock(Qdrant::class); $this->service = new QdrantService($this->mockEmbedding, $this->mockQdrant); }); @@ -140,7 +140,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo describe('upsert', function (): void { it('successfully upserts an entry with all fields', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content here') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -168,7 +168,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('successfully upserts an entry with minimal fields', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Minimal Title Minimal content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -189,7 +189,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('throws exception when embedding generation fails', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content') ->once() ->andReturn([]); @@ -207,7 +207,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('throws exception when upsert request fails', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -231,7 +231,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo it('uses cached embeddings when caching is enabled', function (): void { config(['search.qdrant.cache_embeddings' => true]); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -258,7 +258,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo it('does not cache embeddings when caching is disabled', function (): void { config(['search.qdrant.cache_embeddings' => false]); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content') ->twice() ->andReturn([0.1, 0.2, 0.3]); @@ -285,7 +285,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo describe('search', function (): void { it('successfully searches entries with query and filters', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('laravel testing') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -328,7 +328,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('returns empty collection when search fails', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('query') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -345,7 +345,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('returns empty collection when embedding generation fails', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('query') ->once() ->andReturn([]); @@ -358,7 +358,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('handles search with tag filter', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('test query') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -377,7 +377,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('handles search with custom limit', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('test') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -394,7 +394,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('handles search with custom project', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('test') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -603,7 +603,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo ]), ]); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Entry Test content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -655,7 +655,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo ]), ]); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -680,7 +680,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo ]), ]); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -715,7 +715,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo ]), ]); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Updated Title Original content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -748,7 +748,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo ]), ]); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -798,7 +798,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo ]), ]); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -826,7 +826,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo ]), ]); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Original Title Original Content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -855,7 +855,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo ]), ]); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -871,7 +871,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo describe('upsert duplicate detection', function (): void { it('throws hash match exception when exact duplicate content exists', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -899,7 +899,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('throws similarity match exception when similar entry exists', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content here') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -927,7 +927,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('skips duplicate detection when checkDuplicates is false', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -951,7 +951,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('proceeds when no similar entries found', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Unique Title Unique content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -977,7 +977,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('throws when fingerprint tag matches existing entry', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -1003,7 +1003,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('throws when title and commit hash match existing entry', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -1029,7 +1029,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('proceeds when fingerprint has no match', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Unique Title Unique content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -1061,7 +1061,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('stores commit field in payload', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -1083,7 +1083,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('stores superseded fields in payload', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -1174,7 +1174,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo ]), ]); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -1429,7 +1429,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo it('returns results from any collection by name', function (): void { $embedding = array_fill(0, 384, 0.1); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->with('punk rock') ->andReturn($embedding); @@ -1450,7 +1450,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo }); it('returns empty collection when embedding fails', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn([]); @@ -1462,7 +1462,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo it('returns empty collection on failed response', function (): void { $embedding = array_fill(0, 384, 0.1); - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->once() ->andReturn($embedding); @@ -1507,7 +1507,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo describe('findByFingerprint error handling', function (): void { it('returns null when scroll fails during fingerprint lookup', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content') ->once() ->andReturn([0.1, 0.2, 0.3]); @@ -1542,7 +1542,7 @@ function mockCollectionExists(Mockery\MockInterface $qdrant, int $times = 1): vo describe('findByTitleAndCommit error handling', function (): void { it('returns null when scroll fails during commit lookup', function (): void { - $this->mockEmbedding->shouldReceive('generate') + $this->mockEmbedding->shouldReceive('embed') ->with('Test Title Test content') ->once() ->andReturn([0.1, 0.2, 0.3]); diff --git a/tests/Unit/Services/StubEmbeddingServiceTest.php b/tests/Unit/Services/StubEmbeddingServiceTest.php deleted file mode 100644 index 14513b9..0000000 --- a/tests/Unit/Services/StubEmbeddingServiceTest.php +++ /dev/null @@ -1,76 +0,0 @@ -service = new StubEmbeddingService; -}); - -describe('generate', function (): void { - it('returns empty array for any input', function (): void { - $result = $this->service->generate('test text'); - - expect($result)->toBe([]); - }); - - it('returns empty array for empty string', function (): void { - $result = $this->service->generate(''); - - expect($result)->toBe([]); - }); - - it('returns empty array for long text', function (): void { - $longText = str_repeat('Lorem ipsum dolor sit amet. ', 1000); - $result = $this->service->generate($longText); - - expect($result)->toBe([]); - }); - - it('returns empty array for special characters', function (): void { - $result = $this->service->generate('Special chars: !@#$%^&*()'); - - expect($result)->toBe([]); - }); -}); - -describe('similarity', function (): void { - it('returns zero for any two vectors', function (): void { - $vector1 = [0.1, 0.2, 0.3]; - $vector2 = [0.4, 0.5, 0.6]; - - $result = $this->service->similarity($vector1, $vector2); - - expect($result)->toBe(0.0); - }); - - it('returns zero for empty vectors', function (): void { - $result = $this->service->similarity([], []); - - expect($result)->toBe(0.0); - }); - - it('returns zero for identical vectors', function (): void { - $vector = [0.5, 0.5, 0.5]; - - $result = $this->service->similarity($vector, $vector); - - expect($result)->toBe(0.0); - }); - - it('returns zero for different sized vectors', function (): void { - $vector1 = [0.1, 0.2]; - $vector2 = [0.3, 0.4, 0.5]; - - $result = $this->service->similarity($vector1, $vector2); - - expect($result)->toBe(0.0); - }); - - it('always returns float type', function (): void { - $result = $this->service->similarity([1.0], [2.0]); - - expect($result)->toBeFloat(); - }); -});