Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace App\Services\Vision;
use App\Models\Artwork;
use App\Services\TagNormalizer;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
final class ArtworkLlmTagSuggestionService
{
private const SYSTEM_PROMPT = <<<'PROMPT'
You are a precise visual-art tagging engine for Skinbase.
Analyze the provided artwork thumbnail and generate search-friendly tags that help users discover the work in a gallery.
Rules:
- Use only what is clearly visible or strongly implied by the image.
- Prefer concrete visual concepts over vague opinions.
- Do not include artist names, brands, platform names, or watermarks.
- Do not write sentences, explanations, numbering, or markdown.
- Return concise gallery-style tags only.
- Favor subject, setting, style, mood, palette, medium, and composition when visible.
- Avoid filler tags like "art", "image", "beautiful", "cool", or "design".
- Avoid duplicates and near-duplicates.
PROMPT;
private const USER_PROMPT = <<<'PROMPT'
Analyze this artwork thumbnail and return ONLY a valid JSON array of lowercase strings.
Requirements:
- Return between 10 and 15 tags.
- Each tag must be 1 to 3 words.
- Use only letters, numbers, spaces, and hyphens.
- No markdown, no explanations, no extra text.
- Order tags from most useful to least useful.
Focus on:
1. main subject or scene
2. style or genre
3. mood or atmosphere
4. dominant colours
5. medium or technique
6. notable composition or visual elements
Good example:
["fantasy portrait","digital painting","female warrior","blue tones","dramatic lighting","glowing eyes","detailed armor","cinematic mood","character art","moody background"]
Bad example:
["art","beautiful image","masterpiece","cool fantasy woman"]
PROMPT;
public function __construct(
private readonly TagNormalizer $normalizer,
private readonly ArtworkVisionImageUrl $imageUrl,
) {
}
/**
* @return array{provider: string, tags: list<string>, model: ?string, endpoint: ?string, image_url: ?string, variant: string, raw_content?: string, reason?: string, error?: string}
*/
public function suggestForArtwork(Artwork $artwork, int $minTags = 10, int $maxTags = 15, ?string $providerOverride = null): array
{
$provider = $this->resolveProvider($providerOverride);
$variant = 'md';
$imageUrl = $this->imageUrl->fromHash((string) ($artwork->hash ?? ''), (string) ($artwork->thumb_ext ?: 'webp'), $variant);
if ($imageUrl === null) {
return [
'provider' => $provider,
'tags' => [],
'model' => null,
'endpoint' => null,
'image_url' => null,
'variant' => $variant,
'reason' => 'image_url_unavailable',
];
}
$configuration = $this->providerConfiguration($provider);
$baseUrl = rtrim((string) ($configuration['base_url'] ?? ''), '/');
$endpointPath = (string) ($configuration['endpoint'] ?? '/v1/chat/completions');
$model = trim((string) ($configuration['model'] ?? ''));
if ($baseUrl === '' || $model === '') {
return [
'provider' => $provider,
'tags' => [],
'model' => $model !== '' ? $model : null,
'endpoint' => $baseUrl !== '' ? $baseUrl . '/' . ltrim($endpointPath, '/') : null,
'image_url' => $imageUrl,
'variant' => $variant,
'reason' => $provider . '_not_configured',
];
}
$endpoint = $baseUrl . '/' . ltrim($endpointPath, '/');
$safeMin = min(15, max(1, $minTags));
$safeMax = min(15, max($safeMin, $maxTags));
$payload = [
'model' => $model,
'temperature' => (float) config('vision.lm_studio.temperature', 0.3),
'max_tokens' => (int) config('vision.lm_studio.max_tokens', 300),
'messages' => [
[
'role' => 'system',
'content' => self::SYSTEM_PROMPT,
],
[
'role' => 'user',
'content' => [
['type' => 'image_url', 'image_url' => ['url' => $imageUrl]],
['type' => 'text', 'text' => str_replace(['10 and 15', '10 to 15'], ["{$safeMin} and {$safeMax}", "{$safeMin} to {$safeMax}"], self::USER_PROMPT)],
],
],
],
];
try {
$response = $this->buildRequest($provider, $configuration)
->post($endpoint, $payload);
if (! $response->ok()) {
return [
'provider' => $provider,
'tags' => [],
'model' => $model,
'endpoint' => $endpoint,
'image_url' => $imageUrl,
'variant' => $variant,
'reason' => 'http_' . $response->status(),
'error' => substr($response->body(), 0, 500),
];
}
$body = $response->json();
$content = is_array($body)
? (string) (($body['choices'][0]['message']['content'] ?? ''))
: '';
$tags = $this->parseTags($content, $safeMax);
return [
'provider' => $provider,
'tags' => $tags,
'model' => $model,
'endpoint' => $endpoint,
'image_url' => $imageUrl,
'variant' => $variant,
'raw_content' => $content,
];
} catch (\Throwable $exception) {
return [
'provider' => $provider,
'tags' => [],
'model' => $model,
'endpoint' => $endpoint,
'image_url' => $imageUrl,
'variant' => $variant,
'reason' => 'request_failed',
'error' => $exception->getMessage(),
];
}
}
/**
* @return array{base_url: string, endpoint: string, model: string, timeout: int, connect_timeout: int, api_key?: string}
*/
private function providerConfiguration(string $provider): array
{
return match ($provider) {
'together' => [
'base_url' => (string) config('vision.together.base_url', 'https://api.together.xyz'),
'endpoint' => (string) config('vision.together.endpoint', '/v1/chat/completions'),
'model' => (string) config('vision.together.model', 'meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo'),
'timeout' => (int) config('vision.together.timeout', 90),
'connect_timeout' => (int) config('vision.together.connect_timeout', 5),
'api_key' => (string) config('vision.together.api_key', ''),
],
default => [
'base_url' => (string) config('vision.lm_studio.base_url', ''),
'endpoint' => '/v1/chat/completions',
'model' => (string) config('vision.lm_studio.model', ''),
'timeout' => (int) config('vision.lm_studio.timeout', 60),
'connect_timeout' => (int) config('vision.lm_studio.connect_timeout', 5),
],
};
}
/**
* @param array{base_url: string, endpoint: string, model: string, timeout: int, connect_timeout: int, api_key?: string} $configuration
*/
private function buildRequest(string $provider, array $configuration): PendingRequest
{
$request = Http::acceptJson()
->asJson()
->timeout(max(1, (int) ($configuration['timeout'] ?? 60)))
->connectTimeout(max(1, (int) ($configuration['connect_timeout'] ?? 5)));
if ($provider === 'together') {
$apiKey = trim((string) ($configuration['api_key'] ?? ''));
if ($apiKey !== '') {
$request = $request->withToken($apiKey);
}
}
return $request;
}
private function resolveProvider(?string $providerOverride = null): string
{
$candidate = trim(strtolower((string) ($providerOverride ?? config('vision.tag_suggestions.provider', 'lm_studio'))));
return match ($candidate) {
'together', 'together_ai' => 'together',
'lm-studio', 'local', 'home' => 'lm_studio',
default => 'lm_studio',
};
}
/**
* @return list<string>
*/
private function parseTags(string $content, int $maxTags): array
{
$trimmed = trim($content);
$trimmed = preg_replace('/^```(?:json)?\s*/i', '', $trimmed) ?? $trimmed;
$trimmed = preg_replace('/\s*```$/', '', $trimmed) ?? $trimmed;
if (! preg_match('/(\[.*?\])/s', $trimmed, $matches)) {
return [];
}
$decoded = json_decode($matches[1], true);
if (! is_array($decoded)) {
return [];
}
$tags = [];
foreach ($decoded as $item) {
if (! is_string($item)) {
continue;
}
$normalized = $this->normalizer->normalize($item);
if ($normalized === '') {
continue;
}
$tags[] = $normalized;
}
return array_slice(array_values(array_unique($tags)), 0, $maxTags);
}
}