diff --git a/.env.example b/.env.example index 788b88b..274aae3 100644 --- a/.env.example +++ b/.env.example @@ -41,8 +41,6 @@ CACHE_STORE=database # CACHE_PREFIX= OLLAMA_URL=http://localhost:11434 -OLLAMA_EMBEDDINGS_MODEL=nomic-embed-text -OLLAMA_EMBEDDINGS_MODELS=nomic-embed-text OLLAMA_EMBEDDINGS_DIMENSIONS=768 MEMCACHED_HOST=127.0.0.1 diff --git a/app/Concerns/InteractsWithEmbeddingWorkbench.php b/app/Concerns/InteractsWithEmbeddingWorkbench.php new file mode 100644 index 0000000..b1bb631 --- /dev/null +++ b/app/Concerns/InteractsWithEmbeddingWorkbench.php @@ -0,0 +1,96 @@ + */ + public array $modelOptions = []; + + public string $providerSummary = ''; + + public int $storedCount = 0; + + public bool $databaseReady = true; + + public bool $ollamaAvailable = false; + + public string $ollamaStatusMessage = ''; + + public string $ollamaUrl = ''; + + public function refreshOllama(EmbeddingWorkbench $workbench): void + { + $this->initializeWorkbench($workbench); + } + + protected function initializeWorkbench(EmbeddingWorkbench $workbench): void + { + $status = $workbench->ollamaStatus(); + + $this->ollamaAvailable = $status['available']; + $this->ollamaStatusMessage = $status['message']; + $this->ollamaUrl = $status['url']; + $this->modelOptions = $workbench->modelOptions(); + + if (! $this->ollamaAvailable) { + $this->embeddingModel = ''; + } elseif ($this->embeddingModel === '' || ! collect($this->modelOptions)->pluck('name')->contains($this->embeddingModel)) { + $this->embeddingModel = $workbench->modelName(); + } + + $this->refreshSummary($workbench); + } + + protected function ensureWorkbenchAvailable(EmbeddingWorkbench $workbench, string $databaseErrorField): bool + { + $this->initializeWorkbench($workbench); + + if (! $this->ollamaAvailable) { + $this->addError('embeddingModel', $this->ollamaStatusMessage); + } + + if (! $this->databaseReady) { + $this->addError($databaseErrorField, __('The embedding table is not ready.')); + } + + return $this->ollamaAvailable && $this->databaseReady && $this->embeddingModel !== ''; + } + + protected function refreshSummary(EmbeddingWorkbench $workbench): void + { + $provider = $workbench->providerName(); + + $this->databaseReady = Schema::hasTable('embedding_entries'); + + if (! $this->ollamaAvailable || $this->embeddingModel === '') { + $this->providerSummary = "{$provider} / unavailable"; + $this->storedCount = 0; + + return; + } + + $model = $workbench->modelName($this->embeddingModel); + $dimensions = $workbench->dimensions($model); + + $this->providerSummary = "{$provider} / {$model} / {$dimensions}d"; + + if (! $this->databaseReady) { + $this->storedCount = 0; + + return; + } + + $this->storedCount = EmbeddingEntry::query() + ->where('provider', $provider) + ->where('model', $model) + ->where('embedding_dimensions', $dimensions) + ->count(); + } +} diff --git a/app/Services/EmbeddingWorkbench.php b/app/Services/EmbeddingWorkbench.php index 5cfcbff..7226e42 100644 --- a/app/Services/EmbeddingWorkbench.php +++ b/app/Services/EmbeddingWorkbench.php @@ -7,7 +7,6 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use InvalidArgumentException; -use Laravel\Ai\Ai; use Laravel\Ai\Embeddings; use Laravel\Ai\Enums\Lab; use RuntimeException; @@ -20,6 +19,8 @@ class EmbeddingWorkbench */ protected ?Collection $ollamaModelNames = null; + protected ?string $ollamaErrorMessage = null; + public function providerName(): string { $provider = config('ai.default_for_embeddings', 'ollama'); @@ -35,45 +36,55 @@ class EmbeddingWorkbench return $this->validateModel($model); } - $default = Ai::embeddingProvider($this->providerName())->defaultEmbeddingsModel(); - $availableModels = $this->availableModelNames(); + $model = $this->availableModelNames()->first(); - if ($availableModels->contains($default)) { - return $default; + if ($model === null) { + throw new InvalidArgumentException($this->unavailableMessage()); } - $firstAvailableModel = $availableModels->first(); - - if ($firstAvailableModel === null) { - throw new InvalidArgumentException('No embedding models are available.'); - } - - return $firstAvailableModel; + return $model; } public function dimensions(?string $model = null): int { - $this->modelName($model); + if (trim((string) $model) !== '') { + $this->validateModel((string) $model); + } - return Ai::embeddingProvider($this->providerName())->defaultEmbeddingsDimensions(); + return (int) config('ai.providers.'.$this->providerName().'.models.embeddings.dimensions', 768); } /** - * @return array + * @return array */ public function modelOptions(): array { - $default = Ai::embeddingProvider($this->providerName())->defaultEmbeddingsModel(); - return $this->availableModelNames() ->map(fn (string $model): array => [ 'name' => $model, 'dimensions' => $this->dimensions($model), - 'default' => $model === $default, ]) ->all(); } + /** + * @return array{available: bool, message: string, url: string, model_count: int} + */ + public function ollamaStatus(): array + { + $models = $this->availableModelNames(); + $modelCount = $models->count(); + + return [ + 'available' => $modelCount > 0, + 'message' => $modelCount > 0 + ? 'Ollama is available and returned '.$modelCount.' model'.($modelCount === 1 ? '' : 's').'.' + : $this->unavailableMessage(), + 'url' => $this->ollamaUrl(), + 'model_count' => $modelCount, + ]; + } + /** * @param list $phrases * @return Collection @@ -318,34 +329,11 @@ class EmbeddingWorkbench */ protected function availableModelNames(): Collection { - if ($this->providerName() === 'ollama') { - $ollamaModels = $this->ollamaModelNames(); - - if ($ollamaModels->isNotEmpty()) { - return $ollamaModels; - } + if ($this->providerName() !== 'ollama') { + return collect(); } - return $this->configuredModelNames(); - } - - /** - * @return Collection - */ - protected function configuredModelNames(): Collection - { - $models = config('ai.providers.'.$this->providerName().'.models.embeddings.available', []); - - if (! is_array($models)) { - $models = []; - } - - return collect($models) - ->push(Ai::embeddingProvider($this->providerName())->defaultEmbeddingsModel()) - ->map(fn (mixed $model): string => trim((string) $model)) - ->filter() - ->unique() - ->values(); + return $this->ollamaModelNames(); } /** @@ -357,6 +345,8 @@ class EmbeddingWorkbench return $this->ollamaModelNames; } + $this->ollamaErrorMessage = null; + try { $models = Http::baseUrl($this->ollamaUrl()) ->acceptJson() @@ -365,19 +355,29 @@ class EmbeddingWorkbench ->get('api/tags') ->throw() ->json('models', []); - } catch (Throwable) { + } catch (Throwable $exception) { + $this->ollamaErrorMessage = 'Ollama is unavailable at '.$this->ollamaUrl().'. Start Ollama and refresh the model list. '.$exception->getMessage(); + return $this->ollamaModelNames = collect(); } if (! is_array($models)) { + $this->ollamaErrorMessage = 'Ollama responded, but /api/tags did not return a valid model list.'; + return $this->ollamaModelNames = collect(); } - return $this->ollamaModelNames = collect($models) + $modelNames = collect($models) ->map(fn (mixed $model): string => is_array($model) ? trim((string) ($model['name'] ?? '')) : '') ->filter() ->unique() ->values(); + + if ($modelNames->isEmpty()) { + $this->ollamaErrorMessage = 'Ollama is reachable at '.$this->ollamaUrl().', but it did not return any models from /api/tags. Pull an embedding model, then refresh the model list.'; + } + + return $this->ollamaModelNames = $modelNames; } protected function ollamaUrl(): string @@ -395,10 +395,20 @@ class EmbeddingWorkbench $availableModels = $this->availableModelNames(); + if ($availableModels->isEmpty()) { + throw new InvalidArgumentException($this->unavailableMessage()); + } + if ($availableModels->doesntContain($model)) { - throw new InvalidArgumentException("The embedding model [{$model}] is not available."); + throw new InvalidArgumentException("The embedding model [{$model}] was not returned by Ollama."); } return $model; } + + protected function unavailableMessage(): string + { + return $this->ollamaErrorMessage + ?: 'Ollama is unavailable or returned no models. Start Ollama, pull an embedding model, then refresh the model list.'; + } } diff --git a/config/ai.php b/config/ai.php index d23f174..bf5e22e 100644 --- a/config/ai.php +++ b/config/ai.php @@ -118,11 +118,6 @@ return [ 'url' => env('OLLAMA_URL', 'http://localhost:11434'), 'models' => [ 'embeddings' => [ - 'default' => env('OLLAMA_EMBEDDINGS_MODEL', 'nomic-embed-text'), - 'available' => array_values(array_filter(array_map( - 'trim', - explode(',', env('OLLAMA_EMBEDDINGS_MODELS', env('OLLAMA_EMBEDDINGS_MODEL', 'nomic-embed-text'))) - ))), 'dimensions' => (int) env('OLLAMA_EMBEDDINGS_DIMENSIONS', 768), ], ], diff --git a/database/factories/EmbeddingEntryFactory.php b/database/factories/EmbeddingEntryFactory.php index 5593221..559e6eb 100644 --- a/database/factories/EmbeddingEntryFactory.php +++ b/database/factories/EmbeddingEntryFactory.php @@ -31,7 +31,7 @@ class EmbeddingEntryFactory extends Factory 'embedding' => $embedding, 'embedding_dimensions' => $dimensions, 'provider' => 'ollama', - 'model' => config('ai.providers.ollama.models.embeddings.default', 'nomic-embed-text'), + 'model' => 'nomic-embed-text', 'tokens' => fake()->numberBetween(1, 100), ]; } diff --git a/database/factories/EmbeddingUsageEventFactory.php b/database/factories/EmbeddingUsageEventFactory.php index a0a5440..271e0b9 100644 --- a/database/factories/EmbeddingUsageEventFactory.php +++ b/database/factories/EmbeddingUsageEventFactory.php @@ -25,7 +25,7 @@ class EmbeddingUsageEventFactory extends Factory 'user_id' => User::factory(), 'tool' => $tool, 'provider' => 'ollama', - 'model' => config('ai.providers.ollama.models.embeddings.default', 'nomic-embed-text'), + 'model' => 'nomic-embed-text', 'embedding_dimensions' => (int) config('ai.providers.ollama.models.embeddings.dimensions', 768), 'input_count' => $inputCount, 'result_count' => $tool === 'search' ? fake()->numberBetween(0, 10) : $inputCount, diff --git a/resources/views/pages/embeddings/partials/navigation.blade.php b/resources/views/pages/embeddings/partials/navigation.blade.php index 0b463a8..c173cb5 100644 --- a/resources/views/pages/embeddings/partials/navigation.blade.php +++ b/resources/views/pages/embeddings/partials/navigation.blade.php @@ -5,27 +5,39 @@
+ + {{ $ollamaAvailable ? __('Ollama online') : __('Ollama offline') }} + {{ $providerSummary }} {{ __(':count stored', ['count' => $storedCount]) }}
-
+
- - @foreach ($modelOptions as $option) + + @forelse ($modelOptions as $option) {{ $option['name'] }} ({{ $option['dimensions'] }}d) - @endforeach + @empty + {{ __('No models returned') }} + @endforelse
+ + + {{ __('Refresh') }} + {{ __('Checking') }} +
+ @if (! $ollamaAvailable) + + @endif + @if (! $databaseReady) -
- {{ __('Embedding storage is waiting for the pgvector migration.') }} -
+ @endif diff --git a/resources/views/pages/embeddings/⚡compare.blade.php b/resources/views/pages/embeddings/⚡compare.blade.php index f8c65cb..93e35a2 100644 --- a/resources/views/pages/embeddings/⚡compare.blade.php +++ b/resources/views/pages/embeddings/⚡compare.blade.php @@ -1,25 +1,22 @@ */ public array $comparisonPhrases = ['', '', '']; - public string $embeddingModel = ''; - - /** @var array */ - public array $modelOptions = []; - /** @var array> */ public array $comparisonEntries = []; @@ -32,12 +29,6 @@ new #[Title('Embedding comparison')] class extends Component /** @var array|null */ public ?array $closestPair = null; - public string $providerSummary = ''; - - public int $storedCount = 0; - - public bool $databaseReady = true; - public function mount(EmbeddingWorkbench $workbench): void { $this->initializeWorkbench($workbench); @@ -45,6 +36,10 @@ new #[Title('Embedding comparison')] class extends Component public function comparePhrases(EmbeddingWorkbench $workbench, RecordEmbeddingUsageEvent $recordUsageEvent): void { + if (! $this->ensureWorkbenchAvailable($workbench, 'comparisonPhrases')) { + return; + } + $this->validate([ 'comparisonPhrases' => ['required', 'array', 'min:2', 'max:12'], 'comparisonPhrases.*' => ['nullable', 'string', 'max:1000'], @@ -59,12 +54,6 @@ new #[Title('Embedding comparison')] class extends Component return; } - if (! $this->databaseReady) { - $this->addError('comparisonPhrases', __('The embedding table is not ready.')); - - return; - } - try { $comparison = $workbench->compare($phrases, $this->embeddingModel); @@ -125,36 +114,6 @@ new #[Title('Embedding comparison')] class extends Component $this->resetComparisonResults(); } - protected function initializeWorkbench(EmbeddingWorkbench $workbench): void - { - $this->modelOptions = $workbench->modelOptions(); - $this->embeddingModel = $this->embeddingModel ?: $workbench->modelName(); - - $this->refreshSummary($workbench); - } - - protected function refreshSummary(EmbeddingWorkbench $workbench): void - { - $provider = $workbench->providerName(); - $model = $workbench->modelName($this->embeddingModel); - $dimensions = $workbench->dimensions($model); - - $this->providerSummary = "{$provider} / {$model} / {$dimensions}d"; - $this->databaseReady = Schema::hasTable('embedding_entries'); - - if (! $this->databaseReady) { - $this->storedCount = 0; - - return; - } - - $this->storedCount = EmbeddingEntry::query() - ->where('provider', $provider) - ->where('model', $model) - ->where('embedding_dimensions', $dimensions) - ->count(); - } - /** * @return list */ @@ -230,7 +189,7 @@ new #[Title('Embedding comparison')] class extends Component @endif
- + {{ __('Compare') }} {{ __('Comparing') }} diff --git a/resources/views/pages/embeddings/⚡embed.blade.php b/resources/views/pages/embeddings/⚡embed.blade.php index f2f268a..72d56c4 100644 --- a/resources/views/pages/embeddings/⚡embed.blade.php +++ b/resources/views/pages/embeddings/⚡embed.blade.php @@ -1,32 +1,23 @@ */ - public array $modelOptions = []; - /** @var array> */ public array $embeddingResults = []; - public string $providerSummary = ''; - - public int $storedCount = 0; - - public bool $databaseReady = true; - public function mount(EmbeddingWorkbench $workbench): void { $this->initializeWorkbench($workbench); @@ -34,6 +25,10 @@ new #[Title('Embed')] class extends Component public function generateEmbeddings(EmbeddingWorkbench $workbench, RecordEmbeddingUsageEvent $recordUsageEvent): void { + if (! $this->ensureWorkbenchAvailable($workbench, 'embeddingInput')) { + return; + } + $this->validate([ 'embeddingInput' => ['required', 'string', 'max:12000'], 'embeddingModel' => ['required', 'string'], @@ -47,12 +42,6 @@ new #[Title('Embed')] class extends Component return; } - if (! $this->databaseReady) { - $this->addError('embeddingInput', __('The embedding table is not ready.')); - - return; - } - try { $entries = $workbench->embed($phrases, $this->embeddingModel); $tokens = (int) $entries->sum(fn (EmbeddingEntry $entry): int => (int) ($entry->tokens ?? 0)); @@ -78,37 +67,6 @@ new #[Title('Embed')] class extends Component $this->addError('embeddingModel', $exception->getMessage()); } } - - protected function initializeWorkbench(EmbeddingWorkbench $workbench): void - { - $this->modelOptions = $workbench->modelOptions(); - $this->embeddingModel = $this->embeddingModel ?: $workbench->modelName(); - - $this->refreshSummary($workbench); - } - - protected function refreshSummary(EmbeddingWorkbench $workbench): void - { - $provider = $workbench->providerName(); - $model = $workbench->modelName($this->embeddingModel); - $dimensions = $workbench->dimensions($model); - - $this->providerSummary = "{$provider} / {$model} / {$dimensions}d"; - $this->databaseReady = Schema::hasTable('embedding_entries'); - - if (! $this->databaseReady) { - $this->storedCount = 0; - - return; - } - - $this->storedCount = EmbeddingEntry::query() - ->where('provider', $provider) - ->where('model', $model) - ->where('embedding_dimensions', $dimensions) - ->count(); - } - }; ?> @@ -124,7 +82,7 @@ new #[Title('Embed')] class extends Component
- + {{ __('Generate') }} {{ __('Generating') }} diff --git a/resources/views/pages/embeddings/⚡search.blade.php b/resources/views/pages/embeddings/⚡search.blade.php index 2cdbbbe..1138da4 100644 --- a/resources/views/pages/embeddings/⚡search.blade.php +++ b/resources/views/pages/embeddings/⚡search.blade.php @@ -1,34 +1,24 @@ */ - public array $modelOptions = []; - /** @var array> */ public array $similarityResults = []; - public string $providerSummary = ''; - - public int $storedCount = 0; - - public bool $databaseReady = true; - public function mount(EmbeddingWorkbench $workbench): void { $this->initializeWorkbench($workbench); @@ -36,6 +26,10 @@ new #[Title('Embedding search')] class extends Component public function searchSimilar(EmbeddingWorkbench $workbench, RecordEmbeddingUsageEvent $recordUsageEvent): void { + if (! $this->ensureWorkbenchAvailable($workbench, 'similarityQuery')) { + return; + } + $this->validate([ 'similarityQuery' => ['required', 'string', 'max:1000'], 'similarityLimit' => ['required', 'integer', 'min:1', 'max:50'], @@ -43,12 +37,6 @@ new #[Title('Embedding search')] class extends Component 'embeddingModel' => ['required', 'string'], ]); - if (! $this->databaseReady) { - $this->addError('similarityQuery', __('The embedding table is not ready.')); - - return; - } - try { $this->similarityResults = $workbench ->search($this->similarityQuery, $this->similarityLimit, (float) $this->minimumSimilarity, $this->embeddingModel) @@ -75,37 +63,6 @@ new #[Title('Embedding search')] class extends Component $this->addError('embeddingModel', $exception->getMessage()); } } - - protected function initializeWorkbench(EmbeddingWorkbench $workbench): void - { - $this->modelOptions = $workbench->modelOptions(); - $this->embeddingModel = $this->embeddingModel ?: $workbench->modelName(); - - $this->refreshSummary($workbench); - } - - protected function refreshSummary(EmbeddingWorkbench $workbench): void - { - $provider = $workbench->providerName(); - $model = $workbench->modelName($this->embeddingModel); - $dimensions = $workbench->dimensions($model); - - $this->providerSummary = "{$provider} / {$model} / {$dimensions}d"; - $this->databaseReady = Schema::hasTable('embedding_entries'); - - if (! $this->databaseReady) { - $this->storedCount = 0; - - return; - } - - $this->storedCount = EmbeddingEntry::query() - ->where('provider', $provider) - ->where('model', $model) - ->where('embedding_dimensions', $dimensions) - ->count(); - } - }; ?> @@ -126,7 +83,7 @@ new #[Title('Embedding search')] class extends Component
- + {{ __('Search') }} {{ __('Searching') }} diff --git a/tests/Feature/EmbeddingToolsTest.php b/tests/Feature/EmbeddingToolsTest.php index 8ad27b6..8813019 100644 --- a/tests/Feature/EmbeddingToolsTest.php +++ b/tests/Feature/EmbeddingToolsTest.php @@ -8,16 +8,8 @@ use Laravel\Ai\Embeddings; use Laravel\Ai\Prompts\EmbeddingsPrompt; use Livewire\Livewire; -beforeEach(function () { - config([ - 'ai.default_for_embeddings' => 'ollama', - 'ai.providers.ollama.url' => 'http://ollama.test', - 'ai.providers.ollama.models.embeddings.default' => 'test-embed', - 'ai.providers.ollama.models.embeddings.available' => ['test-embed'], - 'ai.providers.ollama.models.embeddings.dimensions' => 3, - ]); - - Http::preventStrayRequests(); +function fakeEmbeddingToolOllamaModels(): void +{ Http::fake([ 'http://ollama.test/api/tags' => Http::response([ 'models' => [ @@ -26,6 +18,23 @@ beforeEach(function () { ], ]), ]); +} + +function fakeUnavailableEmbeddingToolOllama(): void +{ + Http::fake([ + 'http://ollama.test/api/tags' => Http::response(['error' => 'offline'], 500), + ]); +} + +beforeEach(function () { + config([ + 'ai.default_for_embeddings' => 'ollama', + 'ai.providers.ollama.url' => 'http://ollama.test', + 'ai.providers.ollama.models.embeddings.dimensions' => 3, + ]); + + Http::preventStrayRequests(); }); test('guests are redirected from embedding pages', function (string $route) { @@ -44,6 +53,8 @@ test('embeddings index redirects to embed page', function () { }); test('authenticated users can open embedding pages', function (string $route, string $text) { + fakeEmbeddingToolOllamaModels(); + $this->actingAs(User::factory()->create()); $this->get(route($route)) @@ -56,6 +67,8 @@ test('authenticated users can open embedding pages', function (string $route, st ]); test('comparison page renders separate phrase inputs', function () { + fakeEmbeddingToolOllamaModels(); + $this->actingAs(User::factory()->create()); $this->get(route('embeddings.compare')) @@ -67,6 +80,8 @@ test('comparison page renders separate phrase inputs', function () { }); test('comparison phrase inputs can be added and removed', function () { + fakeEmbeddingToolOllamaModels(); + $this->actingAs(User::factory()->create()); Livewire::test('pages::embeddings.compare') @@ -90,6 +105,8 @@ test('app header has a menu item for each embedding tool', function () { }); test('embedding pages do not duplicate tool navigation below the header', function () { + fakeEmbeddingToolOllamaModels(); + $this->actingAs(User::factory()->create()); $response = $this->get(route('embeddings.embed'))->assertOk(); @@ -100,6 +117,8 @@ test('embedding pages do not duplicate tool navigation below the header', functi }); test('embedding model options are fetched from ollama', function () { + fakeEmbeddingToolOllamaModels(); + $this->actingAs(User::factory()->create()); $component = Livewire::test('pages::embeddings.embed'); @@ -110,16 +129,48 @@ test('embedding model options are fetched from ollama', function () { Http::assertSent(fn ($request): bool => $request->url() === 'http://ollama.test/api/tags'); }); -test('first ollama model is selected when configured default is unavailable', function () { - config(['ai.providers.ollama.models.embeddings.default' => 'missing-default']); +test('first ollama model is selected from the api model list', function () { + fakeEmbeddingToolOllamaModels(); $this->actingAs(User::factory()->create()); Livewire::test('pages::embeddings.embed') + ->assertSet('ollamaAvailable', true) ->assertSet('embeddingModel', 'test-embed'); }); +test('embedding pages show when ollama is unavailable', function () { + fakeUnavailableEmbeddingToolOllama(); + + $this->actingAs(User::factory()->create()); + + Livewire::test('pages::embeddings.embed') + ->assertSet('ollamaAvailable', false) + ->assertSet('embeddingModel', '') + ->assertSee(__('Ollama is unavailable')) + ->assertSee(__('Ollama offline')); +}); + +test('embedding actions stop when ollama model list is unavailable', function () { + fakeUnavailableEmbeddingToolOllama(); + + Embeddings::fake(); + + $this->actingAs(User::factory()->create()); + + Livewire::test('pages::embeddings.embed') + ->set('embeddingInput', 'apple') + ->call('generateEmbeddings') + ->assertHasErrors('embeddingModel'); + + expect(EmbeddingEntry::query()->count())->toBe(0); + + Embeddings::assertNothingGenerated(); +}); + test('phrases can be embedded and stored', function () { + fakeEmbeddingToolOllamaModels(); + Embeddings::fake([ [ [1, 0, 0], @@ -155,6 +206,8 @@ test('phrases can be embedded and stored', function () { }); test('similarity search ranks stored embeddings', function () { + fakeEmbeddingToolOllamaModels(); + Embeddings::fake([ [ [1, 0, 0], @@ -196,6 +249,8 @@ test('similarity search ranks stored embeddings', function () { }); test('comparison builds a pairwise similarity matrix', function () { + fakeEmbeddingToolOllamaModels(); + Embeddings::fake([ [ [1, 0, 0],