293 lines
14 KiB
PHP
293 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Actions\Embeddings\RecordEmbeddingUsageEvent;
|
|
use App\Concerns\InteractsWithEmbeddingWorkbench;
|
|
use App\Models\EmbeddingEntry;
|
|
use App\Services\EmbeddingVector;
|
|
use App\Services\EmbeddingWorkbench;
|
|
use Flux\Flux;
|
|
use Illuminate\Support\Collection;
|
|
use Livewire\Attributes\Title;
|
|
use Livewire\Component;
|
|
|
|
new #[Title('Embedding comparison')] class extends Component
|
|
{
|
|
use InteractsWithEmbeddingWorkbench;
|
|
|
|
/** @var array<int, string> */
|
|
public array $comparisonPhrases = ['', '', ''];
|
|
|
|
/** @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 function mount(EmbeddingWorkbench $workbench): void
|
|
{
|
|
$this->initializeWorkbench($workbench);
|
|
}
|
|
|
|
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'],
|
|
'embeddingModel' => ['required', 'string'],
|
|
]);
|
|
|
|
$phrases = $this->comparisonPhraseValues();
|
|
|
|
if (count($phrases) < 2) {
|
|
$this->addError('comparisonPhrases', __('Enter at least two unique phrases.'));
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* @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 || ! $ollamaAvailable" 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>
|