Save workspace changes
This commit is contained in:
255
app/Services/Vision/ArtworkLlmTagSuggestionService.php
Normal file
255
app/Services/Vision/ArtworkLlmTagSuggestionService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user