init project
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user