Comments get a Translate button that calls the active LLM provider in Admin → AI settings. Each commenter shows a small country flag inferred from their video_views + profile_visits history (falls back to users.nationality); that inferred country drives the language hint passed to the LLM so Portuguese "Linda, linda, linda" translates to "Beautiful" instead of pass-through. Description boxes get a line-paired translation grid: a shared Translate button flips #vdbDescSplit into a two-column CSS grid where every source line sits next to its translation on the same row. Backend splits by newline and asks the LLM for a JSON map keyed by index so counts always match. LlmLyricsService now has translate() + translateLines() with echo-retry (if the model returns the input unchanged while source != target, retry once with a stronger directive) and keeps the Ollama model resident via keep_alive=1h. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
441 lines
22 KiB
PHP
441 lines
22 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Setting;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
/**
|
||
* Optional LLM helper for the lyrics pipeline.
|
||
*
|
||
* Supports multiple providers configured in Admin → Settings → AI / LLM
|
||
* (local Ollama, hosted Anthropic Claude, or any OpenAI-compatible endpoint).
|
||
* Picks the provider flagged "Active" and dispatches the request through the
|
||
* matching adapter. Results are cached so a regenerate doesn't re-bill / re-hit
|
||
* the local model.
|
||
*/
|
||
class LlmLyricsService
|
||
{
|
||
public function isEnabled(): bool
|
||
{
|
||
return Setting::get('llm_enabled', 'false') === 'true'
|
||
&& $this->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 <think>…</think>
|
||
// tags into content. Strip them.
|
||
$content = preg_replace('/<think>.*?<\/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'] ?? '');
|
||
}
|
||
}
|