291 lines
9.2 KiB
PHP
291 lines
9.2 KiB
PHP
<?php
|
|
|
|
use App\Models\EmbeddingEntry;
|
|
use App\Models\EmbeddingUsageEvent;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Laravel\Ai\Embeddings;
|
|
use Laravel\Ai\Prompts\EmbeddingsPrompt;
|
|
use Livewire\Livewire;
|
|
|
|
function fakeEmbeddingToolOllamaModels(): void
|
|
{
|
|
Http::fake([
|
|
'http://ollama.test/api/tags' => Http::response([
|
|
'models' => [
|
|
['name' => 'test-embed'],
|
|
['name' => 'alternate-embed'],
|
|
],
|
|
]),
|
|
]);
|
|
}
|
|
|
|
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) {
|
|
$this->get(route($route))->assertRedirect(route('login'));
|
|
})->with([
|
|
'redirect' => 'embeddings.tools',
|
|
'embed' => 'embeddings.embed',
|
|
'search' => 'embeddings.search',
|
|
'compare' => 'embeddings.compare',
|
|
]);
|
|
|
|
test('embeddings index redirects to embed page', function () {
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$this->get(route('embeddings.tools'))->assertRedirect(route('embeddings.embed'));
|
|
});
|
|
|
|
test('authenticated users can open embedding pages', function (string $route, string $text) {
|
|
fakeEmbeddingToolOllamaModels();
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$this->get(route($route))
|
|
->assertOk()
|
|
->assertSee($text);
|
|
})->with([
|
|
'embed' => ['embeddings.embed', 'Embed'],
|
|
'search' => ['embeddings.search', 'Similarity search'],
|
|
'compare' => ['embeddings.compare', 'Proximity comparison'],
|
|
]);
|
|
|
|
test('comparison page renders separate phrase inputs', function () {
|
|
fakeEmbeddingToolOllamaModels();
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$this->get(route('embeddings.compare'))
|
|
->assertOk()
|
|
->assertSee(__('Phrase 1'))
|
|
->assertSee(__('Phrase 2'))
|
|
->assertSee(__('Phrase 3'))
|
|
->assertSee(__('Add phrase'));
|
|
});
|
|
|
|
test('comparison phrase inputs can be added and removed', function () {
|
|
fakeEmbeddingToolOllamaModels();
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
Livewire::test('pages::embeddings.compare')
|
|
->assertSet('comparisonPhrases', ['', '', ''])
|
|
->call('addComparisonPhrase')
|
|
->assertSet('comparisonPhrases', ['', '', '', ''])
|
|
->call('removeComparisonPhrase', 1)
|
|
->assertSet('comparisonPhrases', ['', '', '']);
|
|
});
|
|
|
|
test('app header has a menu item for each embedding tool', function () {
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$this->get(route('dashboard'))
|
|
->assertOk()
|
|
->assertSee(__('Embed'))
|
|
->assertSee(__('Search'))
|
|
->assertSee(__('Compare'))
|
|
->assertDontSee(__('Repository'))
|
|
->assertDontSee(__('Documentation'));
|
|
});
|
|
|
|
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();
|
|
|
|
expect(substr_count($response->getContent(), route('embeddings.embed')))->toBe(2)
|
|
->and(substr_count($response->getContent(), route('embeddings.search')))->toBe(2)
|
|
->and(substr_count($response->getContent(), route('embeddings.compare')))->toBe(2);
|
|
});
|
|
|
|
test('embedding model options are fetched from ollama', function () {
|
|
fakeEmbeddingToolOllamaModels();
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$component = Livewire::test('pages::embeddings.embed');
|
|
$modelNames = collect($component->get('modelOptions'))->pluck('name')->all();
|
|
|
|
expect($modelNames)->toBe(['test-embed', 'alternate-embed']);
|
|
|
|
Http::assertSent(fn ($request): bool => $request->url() === 'http://ollama.test/api/tags');
|
|
});
|
|
|
|
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],
|
|
[0, 1, 0],
|
|
],
|
|
]);
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$component = Livewire::test('pages::embeddings.embed')
|
|
->set('embeddingInput', "apple\nbanana")
|
|
->set('embeddingModel', 'alternate-embed')
|
|
->call('generateEmbeddings')
|
|
->assertHasNoErrors();
|
|
|
|
$usageEvent = EmbeddingUsageEvent::query()->first();
|
|
|
|
expect(EmbeddingEntry::query()->count())->toBe(2)
|
|
->and(EmbeddingUsageEvent::query()->count())->toBe(1)
|
|
->and($usageEvent?->only(['user_id', 'tool', 'model', 'input_count', 'result_count']))->toMatchArray([
|
|
'user_id' => auth()->id(),
|
|
'tool' => 'embed',
|
|
'model' => 'alternate-embed',
|
|
'input_count' => 2,
|
|
'result_count' => 2,
|
|
])
|
|
->and($component->get('storedCount'))->toBe(2)
|
|
->and($component->get('embeddingResults'))->toHaveCount(2);
|
|
|
|
Embeddings::assertGenerated(fn (EmbeddingsPrompt $prompt): bool => $prompt->inputs === ['apple', 'banana']
|
|
&& $prompt->dimensions === 3
|
|
&& $prompt->model === 'alternate-embed');
|
|
});
|
|
|
|
test('similarity search ranks stored embeddings', function () {
|
|
fakeEmbeddingToolOllamaModels();
|
|
|
|
Embeddings::fake([
|
|
[
|
|
[1, 0, 0],
|
|
[0.8, 0.2, 0],
|
|
[0, 1, 0],
|
|
],
|
|
[
|
|
[1, 0, 0],
|
|
],
|
|
]);
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
Livewire::test('pages::embeddings.embed')
|
|
->set('embeddingInput', "alpha\nnear alpha\nbeta")
|
|
->set('embeddingModel', 'alternate-embed')
|
|
->call('generateEmbeddings')
|
|
->assertHasNoErrors();
|
|
|
|
$component = Livewire::test('pages::embeddings.search')
|
|
->set('similarityQuery', 'alpha query')
|
|
->set('embeddingModel', 'alternate-embed')
|
|
->set('minimumSimilarity', '0.70')
|
|
->set('similarityLimit', 5)
|
|
->call('searchSimilar')
|
|
->assertHasNoErrors();
|
|
|
|
$results = $component->get('similarityResults');
|
|
$usageEvent = EmbeddingUsageEvent::query()->where('tool', 'search')->first();
|
|
|
|
expect($results)->toHaveCount(2)
|
|
->and(array_column($results, 'source_text'))->toBe(['alpha', 'near alpha'])
|
|
->and($results[0]['similarity'])->toBe(1.0)
|
|
->and($usageEvent?->only(['model', 'input_count', 'result_count']))->toMatchArray([
|
|
'model' => 'alternate-embed',
|
|
'input_count' => 1,
|
|
'result_count' => 2,
|
|
]);
|
|
});
|
|
|
|
test('comparison builds a pairwise similarity matrix', function () {
|
|
fakeEmbeddingToolOllamaModels();
|
|
|
|
Embeddings::fake([
|
|
[
|
|
[1, 0, 0],
|
|
[0, 1, 0],
|
|
[0.7, 0.7, 0],
|
|
],
|
|
]);
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$component = Livewire::test('pages::embeddings.compare')
|
|
->set('comparisonPhrases', ['red', 'blue', 'purple'])
|
|
->set('embeddingModel', 'alternate-embed')
|
|
->call('comparePhrases')
|
|
->assertHasNoErrors();
|
|
|
|
$matrix = $component->get('comparisonMatrix');
|
|
$pairs = $component->get('comparisonPairs');
|
|
$closestPair = $component->get('closestPair');
|
|
$usageEvent = EmbeddingUsageEvent::query()->where('tool', 'compare')->first();
|
|
|
|
expect($matrix)->toHaveCount(3)
|
|
->and($matrix[0]['scores'][0])->toBe(1.0)
|
|
->and($matrix[0]['scores'][1])->toBe(0.0)
|
|
->and($matrix[0]['scores'][2])->toBe(0.707107)
|
|
->and($pairs)->toHaveCount(3)
|
|
->and(collect($pairs)->contains(fn (array $pair): bool => $pair['first'] === 'red'
|
|
&& $pair['second'] === 'purple'
|
|
&& $pair['similarity'] === 0.707107))->toBeTrue()
|
|
->and($closestPair['first'])->toBe('red')
|
|
->and($closestPair['second'])->toBe('purple')
|
|
->and($usageEvent?->only(['model', 'input_count', 'result_count']))->toMatchArray([
|
|
'model' => 'alternate-embed',
|
|
'input_count' => 3,
|
|
'result_count' => 3,
|
|
]);
|
|
});
|