activeProvider() !== null; } public function cleanLyricsEnabled(): bool { return $this->isEnabled() && Setting::get('llm_clean_lyrics', 'true') === 'true'; } public function decorateEnabled(): bool { return $this->isEnabled() && Setting::get('llm_decorate_lyrics', 'false') === 'true'; } /** The currently selected provider config, or null if none. */ public function activeProvider(): ?array { $providers = json_decode((string) Setting::get('llm_providers', '[]'), true) ?: []; if (! $providers) return null; $activeId = (string) Setting::get('llm_active_id', ''); foreach ($providers as $p) { if (($p['id'] ?? null) === $activeId) { $kind = $p['kind'] ?? 'ollama'; // An Ollama provider doesn't need a key; the others do. if ($kind !== 'ollama' && trim((string) ($p['api_key'] ?? '')) === '') return null; if (trim((string) ($p['model'] ?? '')) === '') return null; return $p; } } return null; } /** Returns clean lyric lines extracted by the LLM, or [] on any failure. */ public function cleanDescription(?string $description): array { if (! $description || ! $this->cleanLyricsEnabled()) return []; $provider = $this->activeProvider(); // v2 cache key: the prompt was rewritten to stop dropping English/Thai // lyric lines that happened to carry leading/trailing emoji decoration. $cacheKey = 'llm_lyrics_clean_v2:' . ($provider['id'] ?? '') . ':' . sha1($description); return Cache::remember($cacheKey, now()->addDays(30), function () use ($description) { $prompt = "Extract the SUNG lyric lines from this song description, preserving every\n" . "language exactly as written. Songs are often MULTILINGUAL (e.g. mixed English\n" . "and Thai, English and Italian, English and Arabic) โ€” KEEP EVERY LANGUAGE.\n\n" . "KEEP a line when it contains real lyric words, even if it's wrapped in or\n" . " punctuated by emojis. Example: '๐Ÿ›ก๏ธ๐Ÿ’ป Met behind the firewalls ๐ŸŒŒ' โ†’ KEEP.\n" . " Strip ONLY the emojis themselves; the lyric words stay untouched.\n" . "DROP a line ONLY when it is one of:\n" . " โ€ข the song title or artist credit\n" . " โ€ข a pure section header (Verse / Chorus / Bridge / Verso / Ritornello /\n" . " Pre-Chorus / Outro / Intro / ๅ‰ฏๆญŒ / ํ›„๋ ด / ูƒูˆุฑุณ / เธ—เนˆเธญเธ™ / etc.) โ€” typically\n" . " one or two words, possibly numbered\n" . " โ€ข an instrument or production note inside ใ€โ€ฆใ€‘ or ใ€”โ€ฆใ€• brackets\n" . " โ€ข a row that is ONLY emojis / separators / decorative symbols with no words\n" . " โ€ข commentary or social-media call-to-action (subscribe, follow, link in bio)\n\n" . "Hard rules:\n" . " - DO NOT translate. DO NOT re-script (no romanising Thai/Arabic, no converting\n" . " English to Thai). The output of each kept line must be in the SAME language\n" . " and script as the original line.\n" . " - DO NOT merge or split lines. One source lyric line โ†’ one output entry.\n" . " - Preserve original punctuation (drop only the emojis).\n" . " - Maintain the original order.\n\n" . "Respond with ONLY a JSON array of strings. No prose, no markdown, no code fence.\n\n" . "DESCRIPTION:\n" . $description; $raw = $this->call($prompt, 8192); if ($raw === '') return []; $raw = trim(preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $raw)); $arr = json_decode($raw, true); if (! is_array($arr)) return []; $out = []; foreach ($arr as $line) { $line = trim((string) $line); if ($line === '') continue; $out[] = $line; } return $out; }); } /** * Rewrite each lyric line with heavy, expressive emoji styling. Emojis go * inside the line AND at the end; multiple per line where it fits. The * original words are NEVER changed โ€” emojis are layered on top. * * Returns [index => decoratedLineText]. The caller swaps line.text and * re-distributes the word timings across the new tokens. */ public function decorateLines(array $lines): array { if (! $lines || ! $this->decorateEnabled()) return []; $provider = $this->activeProvider(); $cacheKey = 'llm_lyrics_deco_v3:' . ($provider['id'] ?? '') . ':' . sha1(json_encode($lines)); return Cache::remember($cacheKey, now()->addDays(30), function () use ($lines) { $numbered = []; foreach ($lines as $i => $l) $numbered[] = ($i + 1) . '. ' . $l; $prompt = "Decorate the following song lyrics with heavy, expressive emoji styling.\n\n" . "Strict instructions:\n" . "- Add emojis to almost every line (rich and visually striking, not minimal).\n" . "- Place emojis both WITHIN lines and AT THE END where they enhance meaning.\n" . "- Use 2โ€“4 emojis per line on average, more on emotional peaks.\n" . "- Match emojis to the line's specific emotion, action, image, or vibe.\n" . "- VARIETY IS CRITICAL. Across the WHOLE song you must use a wide palette:\n" . " โ€ข Aim for 30+ distinct emojis across the song.\n" . " โ€ข Never reuse the same emoji on two adjacent lines.\n" . " โ€ข Do NOT lean on the same 5โ€“6 staples (๐Ÿ”ฅ๐Ÿ’”โœจ๐ŸŽตโค๏ธ). Reach for less obvious\n" . " ones that fit: โšก๐ŸŒŠ๐ŸŒ™๐Ÿ•ฏ๏ธ๐Ÿชž๐Ÿฅ€๐Ÿฆ‹๐ŸŒช๏ธ๐Ÿ—ก๏ธ๐Ÿ‘๏ธ๐Ÿฉธ๐Ÿฆ…๐ŸŒ€๐Ÿ’Ž๐Ÿชฝ๐ŸŒ‘๐Ÿช๐Ÿฉน๐ŸŒน๐Ÿซง๐ŸŒง๏ธ๐Ÿ”ฎ๐Ÿงจ๐Ÿชž๐Ÿ›ก๏ธ\n" . " โš”๏ธ๐Ÿน๐Ÿช„๐Ÿ’ซ๐Ÿฅท๐Ÿงฟ๐Ÿช™๐Ÿฅ€๐ŸŽญ๐Ÿฉฐ๐Ÿชฆโ›“๏ธ๐ŸŒŒ๐Ÿšช๐ŸงŠ๐ŸŒ ๐Ÿ’ข๐Ÿชถ๐Ÿฉท๐Ÿซ€๐Ÿช๐Ÿ•Š๏ธ and many others.\n" . "- Keep the original lyrics 100% UNCHANGED โ€” no rewriting, no translation, no\n" . " re-spelling, no script conversion. Preserve every original word verbatim.\n" . "- Style should feel bold, dramatic, pop-star, Gen Z, visually addictive โ€” like a\n" . " designed lyric post or viral TikTok caption.\n" . "- Do NOT add section headers, titles, intros, or any new lines. Every input line\n" . " must map to exactly one output line, in the same order, with the same words.\n\n" . "Output format: ONLY a JSON object mapping the 1-based line number to the fully\n" . "decorated line text. No prose, no markdown, no code fence.\n\n" . "LINES:\n" . implode("\n", $numbered); $raw = $this->call($prompt, 8192); if ($raw === '') return []; $raw = trim(preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $raw)); $obj = json_decode($raw, true); if (! is_array($obj)) return []; $out = []; foreach ($obj as $k => $v) { if (! is_string($v)) continue; $v = trim($v); if ($v === '') continue; $idx = ((int) $k) - 1; if ($idx < 0 || ! isset($lines[$idx])) continue; // Cheap safety check: the original words must survive verbatim // (the LLM should only LAYER emojis on top). Drop the // decoration if too many original characters are missing. if (! self::preservesOriginal($lines[$idx], $v)) continue; $out[$idx] = $v; } return $out; }); } /** * Verify the decorated line still contains every alphanumeric character of * the original (in the same order). Stops the LLM from quietly rewording * a line โ€” we keep only decorations that strictly add emojis on top. */ private static function preservesOriginal(string $orig, string $decorated): bool { $strip = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? ''); $a = $strip($orig); $b = $strip($decorated); if ($a === '') return true; // Sequential subsequence check: every char of $a must appear in $b in order. $aLen = mb_strlen($a); $bLen = mb_strlen($b); $j = 0; for ($i = 0; $i < $aLen; $i++) { $needle = mb_substr($a, $i, 1); $found = false; for (; $j < $bLen; $j++) { if (mb_substr($b, $j, 1) === $needle) { $found = true; $j++; break; } } if (! $found) return false; } return true; } /** * Translate arbitrary text (e.g. a user comment) to a target language. * Returns the translated string, or '' on any failure / when disabled. */ public function translate(string $text, string $targetLang = 'en', ?string $sourceLangHint = null): string { $text = trim($text); if ($text === '' || ! $this->isEnabled()) return ''; $provider = $this->activeProvider(); $targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en'; $sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null; $cacheKey = 'llm_translate_v4:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1($text); $cached = Cache::get($cacheKey); if (is_string($cached) && $cached !== '') return $cached; $hint = $sourceLangHint ? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the text is in that language unless it is OBVIOUSLY in a different language (e.g. uses a different script).\n" : ''; $prompt = "Translate the following text into the language with ISO-639 code \"{$targetLang}\".\n" . $hint . "Rules:\n" . " - Output ONLY the translated text. No prose, no quotes, no language label, no notes.\n" . " - Translate EVERY word of the source language, including short exclamations,\n" . " repeated words, and words that happen to also look like a name in the target\n" . " language. Example: Portuguese \"Linda, linda, linda\" โ†’ \"Beautiful, beautiful,\n" . " beautiful\" (NOT the English name \"Linda\"). Spanish \"hola\" โ†’ \"hello\".\n" . " - A word is a proper noun ONLY if clearly a person/place/brand name in context\n" . " (e.g. \"My name is Linda\"). If it stands alone or is repeated as an exclamation,\n" . " it is almost certainly a regular word โ€” translate it.\n" . " - If the text is already in the target language, repeat it unchanged.\n" . " - Preserve emojis, @mentions (e.g. @alice), hashtags, URLs, and timestamp tokens\n" . " like @1:23 or @01:23-02:45 EXACTLY as written.\n" . " - Keep the same tone and line breaks.\n\n" . "TEXT:\n" . $text; // Translation is deterministic โ€” temp 0. If the model echoes the input // (common when a word happens to also exist as a name in the target // language, e.g. Portuguese "Linda" โ†’ English "Linda"), retry once // with a stronger directive. If the retry also echoes, we return it. // Comments cap at 1000 chars โ†’ 400 tokens is plenty of headroom and // keeps the model from drifting into long completions on tiny inputs. $budget = max(128, min(1024, (int) ceil(mb_strlen($text) * 1.5) + 64)); $raw = $this->postProcess($this->call($prompt, $budget, 0.0)); $needsRetry = $sourceLangHint && $sourceLangHint !== $targetLang && self::looksLikeEcho($text, $raw); if ($needsRetry) { $retryPrompt = $prompt . "\n\nIMPORTANT: Your previous attempt was identical to the source. The source is\n" . "DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word."; $retry = $this->postProcess($this->call($retryPrompt, $budget, 0.0)); if ($retry !== '' && ! self::looksLikeEcho($text, $retry)) $raw = $retry; } if ($raw === '') return ''; Cache::put($cacheKey, $raw, now()->addDays(30)); return $raw; } private function postProcess(string $raw): string { $raw = trim($raw); $raw = trim(preg_replace('/^```[a-zA-Z]*\s*|\s*```$/m', '', $raw)); return $raw; } /** Normalised equality: ignore case, whitespace, punctuation, emojis. */ private static function looksLikeEcho(string $src, string $out): bool { if ($out === '') return false; $norm = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? ''); $a = $norm($src); $b = $norm($out); return $a !== '' && $a === $b; } /** * Translate a sequence of lines, preserving the one-to-one mapping so the * caller can render them side-by-side with the originals. Empty entries * are kept as empty strings. Returns an array of the same length as $lines * (or [] on any failure / when disabled). */ public function translateLines(array $lines, string $targetLang = 'en', ?string $sourceLangHint = null): array { if (! $lines || ! $this->isEnabled()) return []; $targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en'; $sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null; $provider = $this->activeProvider(); $cacheKey = 'llm_translate_lines_v2:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1(json_encode($lines)); $cached = Cache::get($cacheKey); if (is_array($cached) && count($cached) === count($lines)) return $cached; // Index non-empty lines so empty separators are preserved verbatim. $toTranslate = []; foreach ($lines as $i => $l) { if (trim((string) $l) !== '') $toTranslate[$i] = (string) $l; } if (! $toTranslate) { Cache::put($cacheKey, $lines, now()->addDays(30)); return $lines; } $numbered = []; foreach ($toTranslate as $i => $l) $numbered[] = $i . "\t" . $l; $hint = $sourceLangHint ? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the lines are in that language unless they are OBVIOUSLY in a different language (different script).\n" : ''; $prompt = "Translate each of the following numbered lines into the language with ISO-639 code \"{$targetLang}\".\n" . $hint . "Rules:\n" . " - Output a JSON object mapping each original index (as string) to its translated line.\n" . " - Keep ONE output line per input line โ€” do NOT merge, split, re-order, or drop any.\n" . " - Translate every word including short exclamations and repeated words that happen\n" . " to look like a name in the target language (Portuguese \"Linda\" โ†’ \"Beautiful\").\n" . " - If a line is already in the target language, repeat it unchanged.\n" . " - Preserve emojis, @mentions, hashtags, URLs, and timestamp tokens like @1:23 EXACTLY.\n" . " - Respond with ONLY the JSON object. No prose, no markdown, no code fence.\n\n" . "LINES (index TAB text):\n" . implode("\n", $numbered); $raw = $this->postProcess($this->call($prompt, 8192, 0.0)); $obj = json_decode($raw, true); if (! is_array($obj)) return []; $out = $lines; $echoCount = 0; $translatedCount = 0; foreach ($toTranslate as $i => $src) { $v = $obj[(string) $i] ?? ($obj[$i] ?? null); if (is_string($v) && trim($v) !== '') { $out[$i] = $v; $translatedCount++; if ($sourceLangHint && $sourceLangHint !== $targetLang && self::looksLikeEcho($src, $v)) { $echoCount++; } } } // If > 30% of the translated lines just echo the source, nudge the model. if ($sourceLangHint && $sourceLangHint !== $targetLang && $translatedCount > 0 && $echoCount / $translatedCount > 0.3) { $retryPrompt = $prompt . "\n\nIMPORTANT: Your previous attempt echoed the source for many lines. " . "The source is DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word."; $raw2 = $this->postProcess($this->call($retryPrompt, 8192, 0.0)); $obj2 = json_decode($raw2, true); if (is_array($obj2)) { foreach ($toTranslate as $i => $src) { $v = $obj2[(string) $i] ?? ($obj2[$i] ?? null); if (is_string($v) && trim($v) !== '' && ! self::looksLikeEcho($src, $v)) { $out[$i] = $v; } } } } Cache::put($cacheKey, $out, now()->addDays(30)); return $out; } /** Dispatch to the active provider's adapter. */ private function call(string $prompt, int $maxTokens, float $temperature = 0.2): string { $p = $this->activeProvider(); if (! $p) return ''; try { return match ($p['kind']) { 'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens, $temperature), 'openai' => $this->callOpenAI($p, $prompt, $maxTokens, $temperature), default => $this->callOllama($p, $prompt, $maxTokens, $temperature), }; } catch (\Throwable $e) { Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']); return ''; } } private function callOllama(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string { $endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/'); // Qwen3-style "thinking" models respect /no_think to skip the hidden // reasoning phase. Without it they can exhaust num_predict inside // the thinking block and emit empty content. $taggedPrompt = $prompt . "\n\n/no_think"; $resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [ 'model' => $p['model'], 'messages' => [['role' => 'user', 'content' => $taggedPrompt]], 'stream' => false, 'think' => false, // Keep the model resident for an hour so follow-up requests skip // the cold-start reload (default is 5 minutes). Dramatically // reduces per-click latency for comment/description translation. 'keep_alive' => '1h', 'options' => ['num_predict' => $maxTokens, 'temperature' => $temperature], ]); if (! $resp->successful()) { Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]); return ''; } $j = $resp->json(); $content = (string) ($j['message']['content'] ?? ''); // Some thinking models still ignore the flag and leak โ€ฆ // tags into content. Strip them. $content = preg_replace('/.*?<\/think>\s*/is', '', $content) ?? $content; return trim($content); } private function callAnthropic(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string { $endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/'); $resp = Http::timeout(120)->withHeaders([ 'x-api-key' => (string) $p['api_key'], 'anthropic-version' => '2023-06-01', 'content-type' => 'application/json', ])->post($endpoint . '/v1/messages', [ 'model' => $p['model'], 'max_tokens' => $maxTokens, 'temperature' => $temperature, 'messages' => [['role' => 'user', 'content' => $prompt]], ]); if (! $resp->successful()) { Log::warning('Anthropic API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]); return ''; } $j = $resp->json(); return (string) ($j['content'][0]['text'] ?? ''); } private function callOpenAI(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string { $endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/'); $resp = Http::timeout(120)->withToken((string) $p['api_key']) ->acceptJson() ->post($endpoint . '/v1/chat/completions', [ 'model' => $p['model'], 'messages' => [['role' => 'user', 'content' => $prompt]], 'max_tokens' => $maxTokens, 'temperature' => $temperature, ]); if (! $resp->successful()) { Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]); return ''; } $j = $resp->json(); return (string) ($j['choices'][0]['message']['content'] ?? ''); } }