init project

This commit is contained in:
2026-05-10 13:21:24 +03:30
parent 5f210c6c73
commit dc36701c9d
26 changed files with 2149 additions and 69 deletions

View File

@@ -40,6 +40,11 @@ QUEUE_CONNECTION=database
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
REDIS_CLIENT=phpredis

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Actions\Embeddings;
use App\Models\EmbeddingUsageEvent;
use App\Models\User;
use App\Services\EmbeddingWorkbench;
use Illuminate\Contracts\Auth\Authenticatable;
class RecordEmbeddingUsageEvent
{
public function handle(
?Authenticatable $user,
EmbeddingWorkbench $workbench,
?string $selectedModel,
string $tool,
int $inputCount,
int $resultCount,
?int $tokens = null,
): ?EmbeddingUsageEvent {
if (! $user instanceof User) {
return null;
}
$model = $workbench->modelName($selectedModel);
return EmbeddingUsageEvent::query()->create([
'user_id' => $user->id,
'tool' => $tool,
'provider' => $workbench->providerName(),
'model' => $model,
'embedding_dimensions' => $workbench->dimensions($model),
'input_count' => max(0, $inputCount),
'result_count' => max(0, $resultCount),
'tokens' => $tokens,
]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Database\Factories\EmbeddingEntryFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'source_text',
'content_hash',
'embedding',
'embedding_dimensions',
'provider',
'model',
'tokens',
])]
class EmbeddingEntry extends Model
{
/** @use HasFactory<EmbeddingEntryFactory> */
use HasFactory;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'embedding' => 'array',
'embedding_dimensions' => 'integer',
'tokens' => 'integer',
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Database\Factories\EmbeddingUsageEventFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'user_id',
'tool',
'provider',
'model',
'embedding_dimensions',
'input_count',
'result_count',
'tokens',
])]
class EmbeddingUsageEvent extends Model
{
/** @use HasFactory<EmbeddingUsageEventFactory> */
use HasFactory;
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'embedding_dimensions' => 'integer',
'input_count' => 'integer',
'result_count' => 'integer',
'tokens' => 'integer',
];
}
}

View File

@@ -7,6 +7,7 @@ use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
@@ -43,4 +44,12 @@ class User extends Authenticatable
->map(fn ($word) => Str::substr($word, 0, 1))
->implode('');
}
/**
* @return HasMany<EmbeddingUsageEvent, $this>
*/
public function embeddingUsageEvents(): HasMany
{
return $this->hasMany(EmbeddingUsageEvent::class);
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Services;
use App\Models\EmbeddingUsageEvent;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class DashboardUsageSummary
{
/**
* @return array{
* days: int,
* totals: array{actions: int, inputs: int, results: int, models: int},
* daily: array<int, array{date: string, label: string, actions: int, inputs: int, height: int}>,
* tools: array<int, array{key: string, label: string, actions: int, inputs: int, results: int, percentage: int}>,
* models: array<int, array{label: string, actions: int, inputs: int, percentage: int}>,
* recent: array<int, array{tool: string, tool_label: string, model: string, inputs: int, results: int, created_at: string}>
* }
*/
public function forUser(User $user, int $days = 14): array
{
$days = max(7, min(30, $days));
$startDate = CarbonImmutable::today()->subDays($days - 1);
$events = EmbeddingUsageEvent::query()
->whereBelongsTo($user)
->where('created_at', '>=', $startDate->startOfDay())
->latest()
->get(['tool', 'model', 'input_count', 'result_count', 'created_at']);
return [
'days' => $days,
'totals' => $this->totals($events),
'daily' => $this->dailyActivity($events, $startDate, $days),
'tools' => $this->toolUsage($events),
'models' => $this->modelUsage($events),
'recent' => $this->recentActivity($events),
];
}
/**
* @param Collection<int, EmbeddingUsageEvent> $events
* @return array{actions: int, inputs: int, results: int, models: int}
*/
protected function totals(Collection $events): array
{
return [
'actions' => $events->count(),
'inputs' => (int) $events->sum('input_count'),
'results' => (int) $events->sum('result_count'),
'models' => $events->pluck('model')->unique()->count(),
];
}
/**
* @param Collection<int, EmbeddingUsageEvent> $events
* @return array<int, array{date: string, label: string, actions: int, inputs: int, height: int}>
*/
protected function dailyActivity(Collection $events, CarbonImmutable $startDate, int $days): array
{
$daily = collect(range(0, $days - 1))
->map(function (int $offset) use ($events, $startDate): array {
$date = $startDate->addDays($offset);
$dayEvents = $events->filter(fn (EmbeddingUsageEvent $event): bool => $event->created_at->isSameDay($date));
return [
'date' => $date->toDateString(),
'label' => $date->format('M j'),
'actions' => $dayEvents->count(),
'inputs' => (int) $dayEvents->sum('input_count'),
'height' => 0,
];
});
$maxActions = max(1, (int) $daily->max('actions'));
return $daily
->map(fn (array $day): array => [
...$day,
'height' => $day['actions'] > 0 ? max(8, (int) round(($day['actions'] / $maxActions) * 100)) : 0,
])
->values()
->all();
}
/**
* @param Collection<int, EmbeddingUsageEvent> $events
* @return array<int, array{key: string, label: string, actions: int, inputs: int, results: int, percentage: int}>
*/
protected function toolUsage(Collection $events): array
{
$maxActions = max(1, $events->groupBy('tool')->map->count()->max() ?? 0);
return $events
->groupBy('tool')
->map(fn (Collection $toolEvents, string $tool): array => [
'key' => $tool,
'label' => Str::of($tool)->replace('_', ' ')->headline()->value(),
'actions' => $toolEvents->count(),
'inputs' => (int) $toolEvents->sum('input_count'),
'results' => (int) $toolEvents->sum('result_count'),
'percentage' => (int) round(($toolEvents->count() / $maxActions) * 100),
])
->sortByDesc('actions')
->values()
->all();
}
/**
* @param Collection<int, EmbeddingUsageEvent> $events
* @return array<int, array{label: string, actions: int, inputs: int, percentage: int}>
*/
protected function modelUsage(Collection $events): array
{
$maxActions = max(1, $events->groupBy('model')->map->count()->max() ?? 0);
return $events
->groupBy('model')
->map(fn (Collection $modelEvents, string $model): array => [
'label' => $model,
'actions' => $modelEvents->count(),
'inputs' => (int) $modelEvents->sum('input_count'),
'percentage' => (int) round(($modelEvents->count() / $maxActions) * 100),
])
->sortByDesc('actions')
->take(5)
->values()
->all();
}
/**
* @param Collection<int, EmbeddingUsageEvent> $events
* @return array<int, array{tool: string, tool_label: string, model: string, inputs: int, results: int, created_at: string}>
*/
protected function recentActivity(Collection $events): array
{
return $events
->take(6)
->map(fn (EmbeddingUsageEvent $event): array => [
'tool' => $event->tool,
'tool_label' => Str::of($event->tool)->replace('_', ' ')->headline()->value(),
'model' => $event->model,
'inputs' => $event->input_count,
'results' => $event->result_count,
'created_at' => $event->created_at->diffForHumans(short: true),
])
->values()
->all();
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
use InvalidArgumentException;
class EmbeddingVector
{
/**
* Parse one phrase per line and return unique normalized display values.
*
* @return list<string>
*/
public static function phrasesFromText(string $text, int $limit = 50): array
{
$lines = preg_split('/\R/u', $text) ?: [];
$phrases = [];
$seen = [];
foreach ($lines as $line) {
$phrase = self::cleanPhrase($line);
if ($phrase === '') {
continue;
}
$hash = self::hashPhrase($phrase);
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$phrases[] = $phrase;
if (count($phrases) >= $limit) {
break;
}
}
return $phrases;
}
public static function cleanPhrase(string $phrase): string
{
return Str::of($phrase)->squish()->value();
}
public static function hashPhrase(string $phrase): string
{
return hash('sha256', Str::of($phrase)->squish()->lower()->value());
}
/**
* @param array<int, int|float> $vector
* @return array<int, float>
*/
public static function normalize(array $vector): array
{
$magnitude = sqrt(array_sum(array_map(
fn (int|float $value): float => (float) $value * (float) $value,
$vector
)));
if ($magnitude == 0.0) {
return array_map(fn (): float => 0.0, $vector);
}
return array_map(fn (int|float $value): float => (float) $value / $magnitude, $vector);
}
/**
* @param array<int, int|float> $first
* @param array<int, int|float> $second
*/
public static function cosineSimilarity(array $first, array $second): float
{
if (count($first) !== count($second)) {
throw new InvalidArgumentException('Vectors must have the same number of dimensions.');
}
$dotProduct = 0.0;
$firstMagnitude = 0.0;
$secondMagnitude = 0.0;
foreach ($first as $index => $firstValue) {
$secondValue = $second[$index];
$dotProduct += (float) $firstValue * (float) $secondValue;
$firstMagnitude += (float) $firstValue * (float) $firstValue;
$secondMagnitude += (float) $secondValue * (float) $secondValue;
}
if ($firstMagnitude == 0.0 || $secondMagnitude == 0.0) {
return 0.0;
}
return max(-1.0, min(1.0, $dotProduct / (sqrt($firstMagnitude) * sqrt($secondMagnitude))));
}
/**
* @param array<int, int|float> $vector
* @return array<int, float>
*/
public static function rounded(array $vector, int $precision = 6): array
{
return array_map(fn (int|float $value): float => round((float) $value, $precision), $vector);
}
/**
* @param array<int, int|float> $vector
* @return array<int, float>
*/
public static function preview(array $vector, int $limit = 12): array
{
return array_slice(self::rounded($vector), 0, $limit);
}
}

View File

@@ -0,0 +1,404 @@
<?php
namespace App\Services;
use App\Models\EmbeddingEntry;
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;
use Throwable;
class EmbeddingWorkbench
{
/**
* @var Collection<int, string>|null
*/
protected ?Collection $ollamaModelNames = null;
public function providerName(): string
{
$provider = config('ai.default_for_embeddings', 'ollama');
return $provider instanceof Lab ? $provider->value : (string) $provider;
}
public function modelName(?string $model = null): string
{
$model = trim((string) $model);
if ($model !== '') {
return $this->validateModel($model);
}
$default = Ai::embeddingProvider($this->providerName())->defaultEmbeddingsModel();
$availableModels = $this->availableModelNames();
if ($availableModels->contains($default)) {
return $default;
}
$firstAvailableModel = $availableModels->first();
if ($firstAvailableModel === null) {
throw new InvalidArgumentException('No embedding models are available.');
}
return $firstAvailableModel;
}
public function dimensions(?string $model = null): int
{
$this->modelName($model);
return Ai::embeddingProvider($this->providerName())->defaultEmbeddingsDimensions();
}
/**
* @return array<int, array{name: string, dimensions: int, default: bool}>
*/
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();
}
/**
* @param list<string> $phrases
* @return Collection<int, EmbeddingEntry>
*/
public function embed(array $phrases, ?string $model = null): Collection
{
$phrases = $this->uniquePhrases($phrases);
if ($phrases === []) {
return collect();
}
$provider = $this->providerName();
$model = $this->modelName($model);
$dimensions = $this->dimensions($model);
$hashes = collect($phrases)->mapWithKeys(fn (string $phrase): array => [
EmbeddingVector::hashPhrase($phrase) => $phrase,
]);
$entries = EmbeddingEntry::query()
->where('provider', $provider)
->where('model', $model)
->where('embedding_dimensions', $dimensions)
->whereIn('content_hash', $hashes->keys())
->get()
->keyBy('content_hash');
$missingPhrases = $hashes
->reject(fn (string $phrase, string $hash): bool => $entries->has($hash))
->values()
->all();
if ($missingPhrases !== []) {
$response = Embeddings::for($missingPhrases)
->dimensions($dimensions)
->cache()
->timeout(60)
->generate($provider, $model);
foreach ($missingPhrases as $index => $phrase) {
$embedding = array_map('floatval', $response->embeddings[$index] ?? []);
$this->ensureExpectedDimensions($embedding, $dimensions);
$entry = EmbeddingEntry::query()->updateOrCreate(
[
'content_hash' => EmbeddingVector::hashPhrase($phrase),
'provider' => $response->meta->provider,
'model' => $response->meta->model,
'embedding_dimensions' => $dimensions,
],
[
'source_text' => $phrase,
'embedding' => $embedding,
'tokens' => $response->tokens,
]
);
$entries->put($entry->content_hash, $entry);
}
}
return collect($phrases)
->map(fn (string $phrase): ?EmbeddingEntry => $entries->get(EmbeddingVector::hashPhrase($phrase)))
->filter()
->values();
}
/**
* @return Collection<int, array{entry: EmbeddingEntry, similarity: float, distance: float}>
*/
public function search(string $query, int $limit = 10, float $minimumSimilarity = 0.3, ?string $model = null): Collection
{
$query = EmbeddingVector::cleanPhrase($query);
if ($query === '') {
return collect();
}
$limit = max(1, min(50, $limit));
$minimumSimilarity = max(0.0, min(1.0, $minimumSimilarity));
$provider = $this->providerName();
$model = $this->modelName($model);
$dimensions = $this->dimensions($model);
$queryEmbedding = $this->generateEmbedding($query, $model);
if ($this->canUseVectorQueries()) {
return EmbeddingEntry::query()
->select('embedding_entries.*')
->selectVectorDistance('embedding', $queryEmbedding, as: 'distance')
->where('provider', $provider)
->where('model', $model)
->where('embedding_dimensions', $dimensions)
->whereVectorSimilarTo('embedding', $queryEmbedding, minSimilarity: $minimumSimilarity)
->limit($limit)
->get()
->map(fn (EmbeddingEntry $entry): array => [
'entry' => $entry,
'similarity' => round(1 - (float) $entry->distance, 6),
'distance' => round((float) $entry->distance, 6),
]);
}
return EmbeddingEntry::query()
->where('provider', $provider)
->where('model', $model)
->where('embedding_dimensions', $dimensions)
->get()
->map(function (EmbeddingEntry $entry) use ($queryEmbedding): array {
$similarity = EmbeddingVector::cosineSimilarity($queryEmbedding, $entry->embedding);
return [
'entry' => $entry,
'similarity' => round($similarity, 6),
'distance' => round(1 - $similarity, 6),
];
})
->filter(fn (array $result): bool => $result['similarity'] >= $minimumSimilarity)
->sortByDesc('similarity')
->take($limit)
->values();
}
/**
* @param list<string> $phrases
* @return array{entries: Collection<int, EmbeddingEntry>, matrix: array<int, array{entry: EmbeddingEntry, scores: array<int, float>}>, closest_pair: array{first: EmbeddingEntry, second: EmbeddingEntry, similarity: float}|null}
*/
public function compare(array $phrases, ?string $model = null): array
{
$entries = $this->embed($phrases, $model)->values();
$matrix = [];
$closestPair = null;
foreach ($entries as $rowIndex => $entry) {
$scores = [];
foreach ($entries as $columnIndex => $comparedEntry) {
$similarity = round(EmbeddingVector::cosineSimilarity($entry->embedding, $comparedEntry->embedding), 6);
$scores[] = $similarity;
if ($rowIndex < $columnIndex && (
$closestPair === null || $similarity > $closestPair['similarity']
)) {
$closestPair = [
'first' => $entry,
'second' => $comparedEntry,
'similarity' => $similarity,
];
}
}
$matrix[] = [
'entry' => $entry,
'scores' => $scores,
];
}
return [
'entries' => $entries,
'matrix' => $matrix,
'closest_pair' => $closestPair,
];
}
/**
* @return array{id: int, source_text: string, provider: string, model: string, dimensions: int, tokens: int|null, vector_preview: array<int, float>, vector_json: string}
*/
public function presentEntry(EmbeddingEntry $entry): array
{
return [
'id' => $entry->id,
'source_text' => $entry->source_text,
'provider' => $entry->provider,
'model' => $entry->model,
'dimensions' => $entry->embedding_dimensions,
'tokens' => $entry->tokens,
'vector_preview' => EmbeddingVector::preview($entry->embedding),
'vector_json' => json_encode(EmbeddingVector::rounded($entry->embedding), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
];
}
/**
* @param array<int, string> $phrases
* @return list<string>
*/
protected function uniquePhrases(array $phrases): array
{
$unique = [];
foreach ($phrases as $phrase) {
$phrase = EmbeddingVector::cleanPhrase($phrase);
if ($phrase === '') {
continue;
}
$unique[EmbeddingVector::hashPhrase($phrase)] = $phrase;
}
return array_values($unique);
}
/**
* @return array<int, float>
*/
protected function generateEmbedding(string $text, ?string $model = null): array
{
$model = $this->modelName($model);
$dimensions = $this->dimensions($model);
$embedding = array_map(
'floatval',
Embeddings::for([$text])
->dimensions($dimensions)
->cache()
->timeout(60)
->generate($this->providerName(), $model)
->first()
);
$this->ensureExpectedDimensions($embedding, $dimensions);
return $embedding;
}
/**
* @param array<int, float> $embedding
*/
protected function ensureExpectedDimensions(array $embedding, int $dimensions): void
{
if (count($embedding) !== $dimensions) {
throw new RuntimeException('The embedding model returned '.count($embedding)." dimensions, but the database is configured for {$dimensions}.");
}
}
protected function canUseVectorQueries(): bool
{
return DB::connection()->getDriverName() === 'pgsql';
}
/**
* @return Collection<int, string>
*/
protected function availableModelNames(): Collection
{
if ($this->providerName() === 'ollama') {
$ollamaModels = $this->ollamaModelNames();
if ($ollamaModels->isNotEmpty()) {
return $ollamaModels;
}
}
return $this->configuredModelNames();
}
/**
* @return Collection<int, string>
*/
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 Collection<int, string>
*/
protected function ollamaModelNames(): Collection
{
if ($this->ollamaModelNames !== null) {
return $this->ollamaModelNames;
}
try {
$models = Http::baseUrl($this->ollamaUrl())
->acceptJson()
->connectTimeout(2)
->timeout(5)
->get('api/tags')
->throw()
->json('models', []);
} catch (Throwable) {
return $this->ollamaModelNames = collect();
}
if (! is_array($models)) {
return $this->ollamaModelNames = collect();
}
return $this->ollamaModelNames = collect($models)
->map(fn (mixed $model): string => is_array($model) ? trim((string) ($model['name'] ?? '')) : '')
->filter()
->unique()
->values();
}
protected function ollamaUrl(): string
{
return rtrim((string) config('ai.providers.ollama.url', 'http://localhost:11434'), '/');
}
protected function validateModel(string $model): string
{
$model = trim($model);
if ($model === '') {
throw new InvalidArgumentException('Choose an embedding model.');
}
$availableModels = $this->availableModelNames();
if ($availableModels->doesntContain($model)) {
throw new InvalidArgumentException("The embedding model [{$model}] is not available.");
}
return $model;
}
}

View File

@@ -17,7 +17,7 @@ return [
'default_for_images' => 'gemini',
'default_for_audio' => 'openai',
'default_for_transcription' => 'openai',
'default_for_embeddings' => 'openai',
'default_for_embeddings' => 'ollama',
'default_for_reranking' => 'cohere',
/*
@@ -116,6 +116,16 @@ return [
'driver' => 'ollama',
'key' => env('OLLAMA_API_KEY', ''),
'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),
],
],
],
'openai' => [

View File

@@ -0,0 +1,38 @@
<?php
namespace Database\Factories;
use App\Models\EmbeddingEntry;
use App\Services\EmbeddingVector;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<EmbeddingEntry>
*/
class EmbeddingEntryFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$sourceText = fake()->sentence();
$dimensions = (int) config('ai.providers.ollama.models.embeddings.dimensions', 768);
$embedding = EmbeddingVector::normalize(
array_map(fn () => fake()->randomFloat(6, -1, 1), range(1, $dimensions))
);
return [
'source_text' => $sourceText,
'content_hash' => hash('sha256', Str::of($sourceText)->squish()->lower()->value()),
'embedding' => $embedding,
'embedding_dimensions' => $dimensions,
'provider' => 'ollama',
'model' => config('ai.providers.ollama.models.embeddings.default', 'nomic-embed-text'),
'tokens' => fake()->numberBetween(1, 100),
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Database\Factories;
use App\Models\EmbeddingUsageEvent;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<EmbeddingUsageEvent>
*/
class EmbeddingUsageEventFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$tool = fake()->randomElement(['embed', 'search', 'compare']);
$inputCount = fake()->numberBetween(1, 8);
return [
'user_id' => User::factory(),
'tool' => $tool,
'provider' => 'ollama',
'model' => config('ai.providers.ollama.models.embeddings.default', '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,
'tokens' => fake()->optional()->numberBetween(5, 500),
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$driver = Schema::getConnection()->getDriverName();
$dimensions = (int) config('ai.providers.ollama.models.embeddings.dimensions', 768);
if ($driver === 'pgsql') {
Schema::ensureVectorExtensionExists();
}
Schema::create('embedding_entries', function (Blueprint $table) use ($dimensions, $driver) {
$table->id();
$table->text('source_text');
$table->string('content_hash', 64);
$table->string('provider');
$table->string('model');
$table->unsignedSmallInteger('embedding_dimensions')->default($dimensions);
$table->unsignedInteger('tokens')->nullable();
if ($driver === 'pgsql') {
$table->vector('embedding', dimensions: $dimensions);
$table->vectorIndex('embedding');
} else {
$table->json('embedding');
}
$table->timestamps();
$table->unique(
['content_hash', 'provider', 'model', 'embedding_dimensions'],
'embedding_entries_unique_embedding'
);
$table->index(['provider', 'model', 'embedding_dimensions']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('embedding_entries');
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('embedding_usage_events', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('tool', 32);
$table->string('provider');
$table->string('model');
$table->unsignedSmallInteger('embedding_dimensions');
$table->unsignedInteger('input_count')->default(0);
$table->unsignedInteger('result_count')->default(0);
$table->unsignedInteger('tokens')->nullable();
$table->timestamps();
$table->index(['user_id', 'created_at']);
$table->index(['user_id', 'tool', 'created_at']);
$table->index(['user_id', 'model']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('embedding_usage_events');
}
};

View File

@@ -3,13 +3,13 @@
])
@if($sidebar)
<flux:sidebar.brand name="Laravel Starter Kit" {{ $attributes }}>
<flux:sidebar.brand :name="config('app.name')" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:sidebar.brand>
@else
<flux:brand name="Laravel Starter Kit" {{ $attributes }}>
<flux:brand :name="config('app.name')" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>

View File

@@ -1,9 +1,8 @@
<flux:dropdown position="bottom" align="start">
<flux:sidebar.profile
:name="auth()->user()->name"
<flux:dropdown position="bottom" align="end" {{ $attributes }}>
<flux:profile
:initials="auth()->user()->initials()"
icon:trailing="chevrons-up-down"
data-test="sidebar-menu-button"
icon-trailing="chevron-down"
data-test="user-menu-button"
/>
<flux:menu>

View File

@@ -1,18 +1,146 @@
<x-layouts::app :title="__('Dashboard')">
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
@php
$stats = [
['label' => __('Actions'), 'value' => number_format($usage['totals']['actions']), 'meta' => __('Last :days days', ['days' => $usage['days']]), 'color' => 'bg-emerald-500'],
['label' => __('Phrases'), 'value' => number_format($usage['totals']['inputs']), 'meta' => __('Submitted inputs'), 'color' => 'bg-sky-500'],
['label' => __('Results'), 'value' => number_format($usage['totals']['results']), 'meta' => __('Returned rows'), 'color' => 'bg-amber-500'],
['label' => __('Models'), 'value' => number_format($usage['totals']['models']), 'meta' => __('Used locally'), 'color' => 'bg-zinc-500'],
];
@endphp
<section class="flex w-full flex-col gap-6">
<div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
<div>
<flux:heading size="xl">{{ __('Usage overview') }}</flux:heading>
<flux:text>{{ __('Last :days days', ['days' => $usage['days']]) }}</flux:text>
</div>
<flux:button icon="sparkles" variant="primary" :href="route('embeddings.embed')" wire:navigate>
{{ __('New embedding') }}
</flux:button>
</div>
<div class="relative h-full flex-1 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($stats as $stat)
<section class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div class="flex items-start justify-between gap-3">
<div>
<flux:text class="text-sm">{{ $stat['label'] }}</flux:text>
<div class="mt-2 text-3xl font-semibold tabular-nums text-zinc-950 dark:text-zinc-50">{{ $stat['value'] }}</div>
</div>
<span class="mt-1 size-2.5 rounded-full {{ $stat['color'] }}"></span>
</div>
<div class="mt-3 text-xs text-zinc-500 dark:text-zinc-400">{{ $stat['meta'] }}</div>
</section>
@endforeach
</div>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(280px,1fr)]">
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<div class="flex items-center justify-between gap-3">
<flux:heading>{{ __('Activity') }}</flux:heading>
<flux:badge>{{ __('Actions') }}</flux:badge>
</div>
<div class="mt-5 flex h-56 items-end gap-2">
@foreach ($usage['daily'] as $day)
<div class="flex min-w-0 flex-1 flex-col items-center gap-2" title="{{ $day['label'] }}: {{ $day['actions'] }} {{ __('actions') }}, {{ $day['inputs'] }} {{ __('inputs') }}">
<div class="flex h-44 w-full items-end rounded-md bg-zinc-100 px-1 dark:bg-zinc-800">
<div
class="w-full rounded-t-md bg-emerald-500 transition-[height] dark:bg-emerald-400"
style="height: {{ $day['height'] }}%"
></div>
</div>
<div class="w-full truncate text-center text-[11px] text-zinc-500 dark:text-zinc-400">{{ $day['label'] }}</div>
</div>
@endforeach
</div>
</section>
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<flux:heading>{{ __('Tool mix') }}</flux:heading>
<div class="mt-5 flex flex-col gap-4">
@forelse ($usage['tools'] as $tool)
<div>
<div class="flex items-center justify-between gap-3 text-sm">
<span class="font-medium text-zinc-900 dark:text-zinc-100">{{ $tool['label'] }}</span>
<span class="tabular-nums text-zinc-500 dark:text-zinc-400">{{ number_format($tool['actions']) }}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800">
<div class="h-2 rounded-full bg-sky-500 dark:bg-sky-400" style="width: {{ $tool['percentage'] }}%"></div>
</div>
<div class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
{{ __(':inputs inputs, :results results', ['inputs' => number_format($tool['inputs']), 'results' => number_format($tool['results'])]) }}
</div>
</div>
@empty
<div class="rounded-lg border border-dashed border-zinc-200 p-4 text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
{{ __('No usage yet.') }}
</div>
@endforelse
</div>
</section>
</div>
<div class="grid gap-4 xl:grid-cols-2">
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<flux:heading>{{ __('Models') }}</flux:heading>
<div class="mt-5 flex flex-col gap-4">
@forelse ($usage['models'] as $model)
<div>
<div class="flex items-center justify-between gap-3 text-sm">
<span class="truncate font-medium text-zinc-900 dark:text-zinc-100" title="{{ $model['label'] }}">{{ $model['label'] }}</span>
<span class="tabular-nums text-zinc-500 dark:text-zinc-400">{{ number_format($model['actions']) }}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800">
<div class="h-2 rounded-full bg-amber-500 dark:bg-amber-400" style="width: {{ $model['percentage'] }}%"></div>
</div>
<div class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
{{ __(':inputs inputs', ['inputs' => number_format($model['inputs'])]) }}
</div>
</div>
@empty
<div class="rounded-lg border border-dashed border-zinc-200 p-4 text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
{{ __('No model usage yet.') }}
</div>
@endforelse
</div>
</section>
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<flux:heading>{{ __('Recent activity') }}</flux:heading>
<div class="mt-5 overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="text-zinc-500 dark:text-zinc-400">
<tr>
<th class="py-2 text-start font-medium">{{ __('Tool') }}</th>
<th class="py-2 text-start font-medium">{{ __('Model') }}</th>
<th class="py-2 text-end font-medium">{{ __('Inputs') }}</th>
<th class="py-2 text-end font-medium">{{ __('Results') }}</th>
<th class="py-2 text-end font-medium">{{ __('When') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@forelse ($usage['recent'] as $event)
<tr>
<td class="py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $event['tool_label'] }}</td>
<td class="max-w-48 truncate py-3 text-zinc-600 dark:text-zinc-300" title="{{ $event['model'] }}">{{ $event['model'] }}</td>
<td class="py-3 text-end tabular-nums text-zinc-600 dark:text-zinc-300">{{ number_format($event['inputs']) }}</td>
<td class="py-3 text-end tabular-nums text-zinc-600 dark:text-zinc-300">{{ number_format($event['results']) }}</td>
<td class="py-3 text-end text-zinc-500 dark:text-zinc-400">{{ $event['created_at'] }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="py-6 text-center text-sm text-zinc-500 dark:text-zinc-400">{{ __('No recent activity.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</section>
</div>
</section>
</x-layouts::app>

View File

@@ -1,5 +1,5 @@
<x-layouts::app.sidebar :title="$title ?? null">
<x-layouts::app.header :title="$title ?? null">
<flux:main>
{{ $slot }}
</flux:main>
</x-layouts::app.sidebar>
</x-layouts::app.header>

View File

@@ -13,34 +13,22 @@
<flux:navbar.item icon="layout-grid" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
{{ __('Dashboard') }}
</flux:navbar.item>
<flux:navbar.item icon="sparkles" :href="route('embeddings.embed')" :current="request()->routeIs('embeddings.embed')" wire:navigate>
{{ __('Embed') }}
</flux:navbar.item>
<flux:navbar.item icon="magnifying-glass" :href="route('embeddings.search')" :current="request()->routeIs('embeddings.search')" wire:navigate>
{{ __('Search') }}
</flux:navbar.item>
<flux:navbar.item icon="arrows-right-left" :href="route('embeddings.compare')" :current="request()->routeIs('embeddings.compare')" wire:navigate>
{{ __('Compare') }}
</flux:navbar.item>
</flux:navbar>
<flux:spacer />
<flux:navbar class="me-1.5 space-x-0.5 rtl:space-x-reverse py-0!">
<flux:tooltip :content="__('Search')" position="bottom">
<flux:navbar.item class="!h-10 [&>div>svg]:size-5" icon="magnifying-glass" href="#" :label="__('Search')" />
</flux:tooltip>
<flux:tooltip :content="__('Repository')" position="bottom">
<flux:navbar.item
class="h-10 max-lg:hidden [&>div>svg]:size-5"
icon="folder-git-2"
href="https://github.com/laravel/livewire-starter-kit"
target="_blank"
:label="__('Repository')"
/>
</flux:tooltip>
<flux:tooltip :content="__('Documentation')" position="bottom">
<flux:navbar.item
class="h-10 max-lg:hidden [&>div>svg]:size-5"
icon="book-open-text"
href="https://laravel.com/docs/starter-kits#livewire"
target="_blank"
:label="__('Documentation')"
/>
</flux:tooltip>
</flux:navbar>
<x-desktop-user-menu />
</flux:header>
@@ -56,19 +44,21 @@
<flux:sidebar.item icon="layout-grid" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
{{ __('Dashboard') }}
</flux:sidebar.item>
<flux:sidebar.item icon="sparkles" :href="route('embeddings.embed')" :current="request()->routeIs('embeddings.embed')" wire:navigate>
{{ __('Embed') }}
</flux:sidebar.item>
<flux:sidebar.item icon="magnifying-glass" :href="route('embeddings.search')" :current="request()->routeIs('embeddings.search')" wire:navigate>
{{ __('Search') }}
</flux:sidebar.item>
<flux:sidebar.item icon="arrows-right-left" :href="route('embeddings.compare')" :current="request()->routeIs('embeddings.compare')" wire:navigate>
{{ __('Compare') }}
</flux:sidebar.item>
</flux:sidebar.group>
</flux:sidebar.nav>
<flux:spacer />
<flux:sidebar.nav>
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
{{ __('Repository') }}
</flux:sidebar.item>
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
{{ __('Documentation') }}
</flux:sidebar.item>
</flux:sidebar.nav>
</flux:sidebar>
{{ $slot }}

View File

@@ -15,21 +15,15 @@
<flux:sidebar.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
{{ __('Dashboard') }}
</flux:sidebar.item>
<flux:sidebar.item icon="circle-stack" :href="route('embeddings.embed')" :current="request()->routeIs('embeddings.*')" wire:navigate>
{{ __('Embeddings') }}
</flux:sidebar.item>
</flux:sidebar.group>
</flux:sidebar.nav>
<flux:spacer />
<flux:sidebar.nav>
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
{{ __('Repository') }}
</flux:sidebar.item>
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
{{ __('Documentation') }}
</flux:sidebar.item>
</flux:sidebar.nav>
<x-desktop-user-menu class="hidden lg:block" :name="auth()->user()->name" />
</flux:sidebar>

View File

@@ -0,0 +1,31 @@
<header class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-3 sm:flex-row sm:items-end">
<div>
<flux:heading>{{ $heading }}</flux:heading>
</div>
<div class="flex flex-wrap gap-2">
<flux:badge icon="cpu-chip">{{ $providerSummary }}</flux:badge>
<flux:badge icon="circle-stack">{{ __(':count stored', ['count' => $storedCount]) }}</flux:badge>
</div>
</div>
<div class="flex justify-end">
<div class="w-full max-w-sm">
<flux:select wire:model="embeddingModel" :label="__('Embedding model')">
@foreach ($modelOptions as $option)
<flux:select.option value="{{ $option['name'] }}">
{{ $option['name'] }} ({{ $option['dimensions'] }}d)
</flux:select.option>
@endforeach
</flux:select>
<flux:error name="embeddingModel" />
</div>
</div>
@if (! $databaseReady)
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-100">
{{ __('Embedding storage is waiting for the pgvector migration.') }}
</div>
@endif
</header>

View File

@@ -0,0 +1,333 @@
<?php
use App\Actions\Embeddings\RecordEmbeddingUsageEvent;
use App\Models\EmbeddingEntry;
use App\Services\EmbeddingVector;
use App\Services\EmbeddingWorkbench;
use Flux\Flux;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Title('Embedding comparison')] class extends Component
{
/** @var array<int, string> */
public array $comparisonPhrases = ['', '', ''];
public string $embeddingModel = '';
/** @var array<int, array{name: string, dimensions: int, default: bool}> */
public array $modelOptions = [];
/** @var array<int, array<string, mixed>> */
public array $comparisonEntries = [];
/** @var array<int, array<string, mixed>> */
public array $comparisonMatrix = [];
/** @var array<int, array{first: string, second: string, similarity: float, distance: float}> */
public array $comparisonPairs = [];
/** @var array<string, mixed>|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);
}
public function comparePhrases(EmbeddingWorkbench $workbench, RecordEmbeddingUsageEvent $recordUsageEvent): void
{
$this->validate([
'comparisonPhrases' => ['required', 'array', 'min:2', 'max:12'],
'comparisonPhrases.*' => ['nullable', 'string', 'max:1000'],
'embeddingModel' => ['required', 'string'],
]);
$phrases = $this->comparisonPhraseValues();
if (count($phrases) < 2) {
$this->addError('comparisonPhrases', __('Enter at least two unique phrases.'));
return;
}
if (! $this->databaseReady) {
$this->addError('comparisonPhrases', __('The embedding table is not ready.'));
return;
}
try {
$comparison = $workbench->compare($phrases, $this->embeddingModel);
$this->comparisonEntries = $comparison['entries']
->map(fn (EmbeddingEntry $entry): array => $workbench->presentEntry($entry))
->all();
$this->comparisonMatrix = collect($comparison['matrix'])
->map(fn (array $row): array => [
'source_text' => $row['entry']->source_text,
'scores' => $row['scores'],
])
->all();
$this->comparisonPairs = $this->comparisonPairsFromMatrix($comparison['entries'], $comparison['matrix']);
$this->closestPair = $comparison['closest_pair'] === null ? null : [
'first' => $comparison['closest_pair']['first']->source_text,
'second' => $comparison['closest_pair']['second']->source_text,
'similarity' => $comparison['closest_pair']['similarity'],
];
$recordUsageEvent->handle(
user: auth()->user(),
workbench: $workbench,
selectedModel: $this->embeddingModel,
tool: 'compare',
inputCount: count($phrases),
resultCount: count($this->comparisonPairs),
);
$this->refreshSummary($workbench);
Flux::toast(variant: 'success', text: __('Comparison ready.'));
} catch (\Throwable $exception) {
$this->addError('embeddingModel', $exception->getMessage());
}
}
public function addComparisonPhrase(): void
{
if (count($this->comparisonPhrases) >= 12) {
return;
}
$this->comparisonPhrases[] = '';
$this->resetComparisonResults();
}
public function removeComparisonPhrase(int $index): void
{
if (count($this->comparisonPhrases) <= 2 || ! array_key_exists($index, $this->comparisonPhrases)) {
return;
}
unset($this->comparisonPhrases[$index]);
$this->comparisonPhrases = array_values($this->comparisonPhrases);
$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<string>
*/
protected function comparisonPhraseValues(): array
{
return EmbeddingVector::phrasesFromText(implode("\n", $this->comparisonPhrases), 12);
}
/**
* @param Collection<int, EmbeddingEntry> $entries
* @param array<int, array{entry: EmbeddingEntry, scores: array<int, float>}> $matrix
* @return array<int, array{first: string, second: string, similarity: float, distance: float}>
*/
protected function comparisonPairsFromMatrix(Collection $entries, array $matrix): array
{
$pairs = [];
foreach ($matrix as $rowIndex => $row) {
foreach ($row['scores'] as $columnIndex => $similarity) {
if ($rowIndex >= $columnIndex) {
continue;
}
$firstEntry = $entries->get($rowIndex);
$secondEntry = $entries->get($columnIndex);
if (! $firstEntry instanceof EmbeddingEntry || ! $secondEntry instanceof EmbeddingEntry) {
continue;
}
$pairs[] = [
'first' => $firstEntry->source_text,
'second' => $secondEntry->source_text,
'similarity' => $similarity,
'distance' => round(1 - $similarity, 6),
];
}
}
return collect($pairs)
->sortByDesc('similarity')
->values()
->all();
}
protected function resetComparisonResults(): void
{
$this->comparisonEntries = [];
$this->comparisonMatrix = [];
$this->comparisonPairs = [];
$this->closestPair = null;
}
};
?>
<section class="flex w-full flex-col gap-6">
@include('pages.embeddings.partials.navigation', ['heading' => __('Proximity comparison')])
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<form wire:submit="comparePhrases" class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
<div>
@if ($closestPair !== null)
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm dark:border-emerald-800 dark:bg-emerald-950">
<div class="font-medium text-emerald-900 dark:text-emerald-100">{{ __('Closest pair') }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-emerald-800 dark:text-emerald-200">
<span class="max-w-80 truncate" title="{{ $closestPair['first'] }}">{{ $closestPair['first'] }}</span>
<span class="text-emerald-600 dark:text-emerald-400">{{ __('vs') }}</span>
<span class="max-w-80 truncate" title="{{ $closestPair['second'] }}">{{ $closestPair['second'] }}</span>
<flux:badge color="emerald">{{ number_format($closestPair['similarity'], 3) }}</flux:badge>
</div>
</div>
@endif
</div>
<flux:button type="submit" variant="primary" icon="arrows-right-left" :disabled="! $databaseReady" class="data-loading:opacity-60">
<span wire:loading.remove wire:target="comparePhrases">{{ __('Compare') }}</span>
<span wire:loading wire:target="comparePhrases">{{ __('Comparing') }}</span>
</flux:button>
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<flux:label>{{ __('Phrases') }}</flux:label>
<flux:button type="button" variant="filled" icon="plus" wire:click="addComparisonPhrase" :disabled="count($comparisonPhrases) >= 12">
{{ __('Add phrase') }}
</flux:button>
</div>
<flux:error name="comparisonPhrases" />
<div class="grid gap-3">
@foreach ($comparisonPhrases as $index => $phrase)
<div wire:key="comparison-phrase-{{ $index }}" class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end">
<flux:field>
<flux:label>{{ __('Phrase :number', ['number' => $index + 1]) }}</flux:label>
<flux:input wire:model="comparisonPhrases.{{ $index }}" placeholder="{{ __('PostgreSQL vectors') }}" />
<flux:error name="comparisonPhrases.{{ $index }}" />
</flux:field>
<flux:button
type="button"
variant="ghost"
icon="trash"
aria-label="{{ __('Remove phrase') }}"
wire:click="removeComparisonPhrase({{ $index }})"
:disabled="count($comparisonPhrases) <= 2"
/>
</div>
@endforeach
</div>
</div>
@if ($comparisonEntries !== [])
<div class="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="bg-zinc-50 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-3 py-2 text-start font-medium">{{ __('First phrase') }}</th>
<th class="px-3 py-2 text-start font-medium">{{ __('Second phrase') }}</th>
<th class="px-3 py-2 text-end font-medium">{{ __('Similarity') }}</th>
<th class="px-3 py-2 text-end font-medium">{{ __('Distance') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@foreach ($comparisonPairs as $pair)
<tr wire:key="comparison-pair-{{ $loop->index }}">
<td class="max-w-72 truncate px-3 py-3 font-medium text-zinc-900 dark:text-zinc-100" title="{{ $pair['first'] }}">
{{ $pair['first'] }}
</td>
<td class="max-w-72 truncate px-3 py-3 text-zinc-700 dark:text-zinc-200" title="{{ $pair['second'] }}">
{{ $pair['second'] }}
</td>
<td class="px-3 py-3 text-end tabular-nums {{ $pair['similarity'] >= 0.8 ? 'text-emerald-700 dark:text-emerald-300' : ($pair['similarity'] >= 0.5 ? 'text-amber-700 dark:text-amber-300' : 'text-zinc-600 dark:text-zinc-300') }}">
{{ number_format($pair['similarity'], 3) }}
</td>
<td class="px-3 py-3 text-end tabular-nums text-zinc-600 dark:text-zinc-300">
{{ number_format($pair['distance'], 3) }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table class="min-w-max divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="bg-zinc-50 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="sticky left-0 bg-zinc-50 px-3 py-2 text-start font-medium dark:bg-zinc-800">{{ __('Compared phrase') }}</th>
@foreach ($comparisonEntries as $entry)
<th class="max-w-48 truncate px-3 py-2 text-center font-medium" title="{{ $entry['source_text'] }}">{{ $entry['source_text'] }}</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@foreach ($comparisonMatrix as $row)
<tr wire:key="comparison-row-{{ $loop->index }}">
<th class="sticky left-0 max-w-64 truncate bg-white px-3 py-3 text-start font-medium text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
#{{ $loop->iteration }} {{ $row['source_text'] }}
</th>
@foreach ($row['scores'] as $score)
<td class="px-3 py-3 text-center tabular-nums {{ $score >= 0.8 ? 'bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200' : ($score >= 0.5 ? 'bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-200' : 'text-zinc-600 dark:text-zinc-300') }}">
{{ number_format($score, 3) }}
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</form>
</section>
</section>

View File

@@ -0,0 +1,168 @@
<?php
use App\Actions\Embeddings\RecordEmbeddingUsageEvent;
use App\Models\EmbeddingEntry;
use App\Services\EmbeddingVector;
use App\Services\EmbeddingWorkbench;
use Flux\Flux;
use Illuminate\Support\Facades\Schema;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Title('Embed')] class extends Component
{
public string $embeddingInput = '';
public string $embeddingModel = '';
/** @var array<int, array{name: string, dimensions: int, default: bool}> */
public array $modelOptions = [];
/** @var array<int, array<string, mixed>> */
public array $embeddingResults = [];
public string $providerSummary = '';
public int $storedCount = 0;
public bool $databaseReady = true;
public function mount(EmbeddingWorkbench $workbench): void
{
$this->initializeWorkbench($workbench);
}
public function generateEmbeddings(EmbeddingWorkbench $workbench, RecordEmbeddingUsageEvent $recordUsageEvent): void
{
$this->validate([
'embeddingInput' => ['required', 'string', 'max:12000'],
'embeddingModel' => ['required', 'string'],
]);
$phrases = EmbeddingVector::phrasesFromText($this->embeddingInput);
if ($phrases === []) {
$this->addError('embeddingInput', __('Enter at least one phrase.'));
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));
$this->embeddingResults = $entries
->map(fn (EmbeddingEntry $entry): array => $workbench->presentEntry($entry))
->all();
$recordUsageEvent->handle(
user: auth()->user(),
workbench: $workbench,
selectedModel: $this->embeddingModel,
tool: 'embed',
inputCount: count($phrases),
resultCount: $entries->count(),
tokens: $tokens > 0 ? $tokens : null,
);
$this->refreshSummary($workbench);
Flux::toast(variant: 'success', text: __('Embeddings generated.'));
} catch (\Throwable $exception) {
$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();
}
};
?>
<section class="flex w-full flex-col gap-6">
@include('pages.embeddings.partials.navigation', ['heading' => __('Embed')])
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<form wire:submit="generateEmbeddings" class="flex flex-col gap-4">
<flux:field>
<flux:label>{{ __('Phrases') }}</flux:label>
<flux:textarea wire:model="embeddingInput" rows="8" placeholder="{{ __('Laravel vectors') }}" />
<flux:error name="embeddingInput" />
</flux:field>
<div>
<flux:button type="submit" variant="primary" icon="sparkles" :disabled="! $databaseReady" class="data-loading:opacity-60">
<span wire:loading.remove wire:target="generateEmbeddings">{{ __('Generate') }}</span>
<span wire:loading wire:target="generateEmbeddings">{{ __('Generating') }}</span>
</flux:button>
</div>
@if ($embeddingResults !== [])
<div class="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="bg-zinc-50 text-start text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-3 py-2 text-start font-medium">{{ __('Phrase') }}</th>
<th class="px-3 py-2 text-start font-medium">{{ __('Vector') }}</th>
<th class="px-3 py-2 text-start font-medium">{{ __('Meta') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@foreach ($embeddingResults as $result)
<tr wire:key="embedding-result-{{ $result['id'] }}">
<td class="max-w-xs px-3 py-3 align-top font-medium text-zinc-900 dark:text-zinc-100">
{{ $result['source_text'] }}
</td>
<td class="px-3 py-3 align-top">
<code class="block max-w-md truncate text-xs text-zinc-700 dark:text-zinc-300">[{{ implode(', ', $result['vector_preview']) }}...]</code>
<details class="mt-2">
<summary class="cursor-pointer text-xs text-zinc-500 dark:text-zinc-400">{{ __('Full vector') }}</summary>
<pre class="mt-2 max-h-52 overflow-auto rounded-md bg-zinc-950 p-3 text-xs text-zinc-100">{{ $result['vector_json'] }}</pre>
</details>
</td>
<td class="px-3 py-3 align-top text-zinc-600 dark:text-zinc-300">
<div>{{ $result['dimensions'] }}d</div>
<div class="text-xs">{{ $result['provider'] }} / {{ $result['model'] }}</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</form>
</section>
</section>

View File

@@ -0,0 +1,159 @@
<?php
use App\Actions\Embeddings\RecordEmbeddingUsageEvent;
use App\Models\EmbeddingEntry;
use App\Services\EmbeddingWorkbench;
use Illuminate\Support\Facades\Schema;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Title('Embedding search')] class extends Component
{
public string $similarityQuery = '';
public int $similarityLimit = 10;
public string $minimumSimilarity = '0.30';
public string $embeddingModel = '';
/** @var array<int, array{name: string, dimensions: int, default: bool}> */
public array $modelOptions = [];
/** @var array<int, array<string, mixed>> */
public array $similarityResults = [];
public string $providerSummary = '';
public int $storedCount = 0;
public bool $databaseReady = true;
public function mount(EmbeddingWorkbench $workbench): void
{
$this->initializeWorkbench($workbench);
}
public function searchSimilar(EmbeddingWorkbench $workbench, RecordEmbeddingUsageEvent $recordUsageEvent): void
{
$this->validate([
'similarityQuery' => ['required', 'string', 'max:1000'],
'similarityLimit' => ['required', 'integer', 'min:1', 'max:50'],
'minimumSimilarity' => ['required', 'numeric', 'min:0', 'max:1'],
'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)
->map(function (array $result) use ($workbench): array {
return [
...$workbench->presentEntry($result['entry']),
'similarity' => $result['similarity'],
'distance' => $result['distance'],
];
})
->all();
$recordUsageEvent->handle(
user: auth()->user(),
workbench: $workbench,
selectedModel: $this->embeddingModel,
tool: 'search',
inputCount: 1,
resultCount: count($this->similarityResults),
);
$this->refreshSummary($workbench);
} catch (\Throwable $exception) {
$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();
}
};
?>
<section class="flex w-full flex-col gap-6">
@include('pages.embeddings.partials.navigation', ['heading' => __('Similarity search')])
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
<form wire:submit="searchSimilar" class="flex flex-col gap-4">
<flux:field>
<flux:label>{{ __('Query') }}</flux:label>
<flux:textarea wire:model="similarityQuery" rows="4" placeholder="{{ __('semantic search') }}" />
<flux:error name="similarityQuery" />
</flux:field>
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="similarityLimit" :label="__('Limit')" type="number" min="1" max="50" />
<flux:input wire:model="minimumSimilarity" :label="__('Minimum similarity')" type="number" min="0" max="1" step="0.05" />
</div>
<div>
<flux:button type="submit" variant="primary" icon="magnifying-glass" :disabled="! $databaseReady" class="data-loading:opacity-60">
<span wire:loading.remove wire:target="searchSimilar">{{ __('Search') }}</span>
<span wire:loading wire:target="searchSimilar">{{ __('Searching') }}</span>
</flux:button>
</div>
@if ($similarityResults !== [])
<div class="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="bg-zinc-50 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-3 py-2 text-start font-medium">{{ __('Phrase') }}</th>
<th class="px-3 py-2 text-start font-medium">{{ __('Similarity') }}</th>
<th class="px-3 py-2 text-start font-medium">{{ __('Distance') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@foreach ($similarityResults as $result)
<tr wire:key="similarity-result-{{ $result['id'] }}">
<td class="px-3 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $result['source_text'] }}</td>
<td class="px-3 py-3 text-emerald-700 dark:text-emerald-300">{{ number_format($result['similarity'], 3) }}</td>
<td class="px-3 py-3 text-zinc-600 dark:text-zinc-300">{{ number_format($result['distance'], 3) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</form>
</section>
</section>

View File

@@ -1,11 +1,22 @@
<?php
use App\Services\DashboardUsageSummary;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::view('/', 'welcome')->name('home');
Route::middleware(['auth', 'verified'])->group(function () {
Route::view('dashboard', 'dashboard')->name('dashboard');
Route::get('dashboard', function (Request $request, DashboardUsageSummary $summary) {
return view('dashboard', [
'usage' => $summary->forUser($request->user()),
]);
})->name('dashboard');
Route::redirect('embeddings', 'embeddings/embed')->name('embeddings.tools');
Route::livewire('embeddings/embed', 'pages::embeddings.embed')->name('embeddings.embed');
Route::livewire('embeddings/search', 'pages::embeddings.search')->name('embeddings.search');
Route::livewire('embeddings/compare', 'pages::embeddings.compare')->name('embeddings.compare');
});
require __DIR__.'/settings.php';

View File

@@ -1,5 +1,6 @@
<?php
use App\Models\EmbeddingUsageEvent;
use App\Models\User;
test('guests are redirected to the login page', function () {
@@ -12,5 +13,49 @@ test('authenticated users can visit the dashboard', function () {
$this->actingAs($user);
$response = $this->get(route('dashboard'));
$response->assertOk();
});
$response->assertOk()
->assertSee(__('Usage overview'))
->assertSee(__('Tool mix'))
->assertSee(__('Recent activity'));
});
test('dashboard shows usage charts for the authenticated user', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
EmbeddingUsageEvent::factory()->for($user)->create([
'tool' => 'embed',
'model' => 'nomic-embed-text',
'input_count' => 3,
'result_count' => 3,
'created_at' => now(),
]);
EmbeddingUsageEvent::factory()->for($user)->create([
'tool' => 'search',
'model' => 'nomic-embed-text',
'input_count' => 1,
'result_count' => 2,
'created_at' => now()->subDay(),
]);
EmbeddingUsageEvent::factory()->for($otherUser)->create([
'tool' => 'compare',
'model' => 'other-user-model',
'input_count' => 9,
'result_count' => 36,
'created_at' => now(),
]);
$this->actingAs($user);
$this->get(route('dashboard'))
->assertOk()
->assertSee(__('Actions'))
->assertSee(__('Phrases'))
->assertSee(__('Models'))
->assertSee('nomic-embed-text')
->assertSee(__('Embed'))
->assertSee(__('Search'))
->assertDontSee('other-user-model');
});

View File

@@ -0,0 +1,235 @@
<?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;
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();
Http::fake([
'http://ollama.test/api/tags' => Http::response([
'models' => [
['name' => 'test-embed'],
['name' => 'alternate-embed'],
],
]),
]);
});
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) {
$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 () {
$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 () {
$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 () {
$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 () {
$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 when configured default is unavailable', function () {
config(['ai.providers.ollama.models.embeddings.default' => 'missing-default']);
$this->actingAs(User::factory()->create());
Livewire::test('pages::embeddings.embed')
->assertSet('embeddingModel', 'test-embed');
});
test('phrases can be embedded and stored', function () {
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 () {
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 () {
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,
]);
});