init project
This commit is contained in:
@@ -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
|
||||
|
||||
38
app/Actions/Embeddings/RecordEmbeddingUsageEvent.php
Normal file
38
app/Actions/Embeddings/RecordEmbeddingUsageEvent.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
37
app/Models/EmbeddingEntry.php
Normal file
37
app/Models/EmbeddingEntry.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Models/EmbeddingUsageEvent.php
Normal file
46
app/Models/EmbeddingUsageEvent.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
153
app/Services/DashboardUsageSummary.php
Normal file
153
app/Services/DashboardUsageSummary.php
Normal 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();
|
||||
}
|
||||
}
|
||||
119
app/Services/EmbeddingVector.php
Normal file
119
app/Services/EmbeddingVector.php
Normal 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);
|
||||
}
|
||||
}
|
||||
404
app/Services/EmbeddingWorkbench.php
Normal file
404
app/Services/EmbeddingWorkbench.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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' => [
|
||||
|
||||
38
database/factories/EmbeddingEntryFactory.php
Normal file
38
database/factories/EmbeddingEntryFactory.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
35
database/factories/EmbeddingUsageEventFactory.php
Normal file
35
database/factories/EmbeddingUsageEventFactory.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
333
resources/views/pages/embeddings/⚡compare.blade.php
Normal file
333
resources/views/pages/embeddings/⚡compare.blade.php
Normal 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>
|
||||
168
resources/views/pages/embeddings/⚡embed.blade.php
Normal file
168
resources/views/pages/embeddings/⚡embed.blade.php
Normal 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>
|
||||
159
resources/views/pages/embeddings/⚡search.blade.php
Normal file
159
resources/views/pages/embeddings/⚡search.blade.php
Normal 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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
235
tests/Feature/EmbeddingToolsTest.php
Normal file
235
tests/Feature/EmbeddingToolsTest.php
Normal 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,
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user