init project

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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