Files
embedd-hub/resources/views/pages/embeddings/⚡compare.blade.php
2026-05-10 13:21:24 +03:30

334 lines
15 KiB
PHP

<?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>