Add AI translation for comments and video descriptions

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]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-02 12:18:20 +03:00
1 parent 20c8fdcdf3
commit 2d33e39b9b
8 files changed
+749 -22

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Data;
class CountryLanguages
{
private const MAP = [
'AE' => 'ar', 'AF' => 'ps', 'AL' => 'sq', 'AM' => 'hy', 'AO' => 'pt',
'AR' => 'es', 'AT' => 'de', 'AU' => 'en', 'AZ' => 'az',
'BA' => 'bs', 'BD' => 'bn', 'BE' => 'nl', 'BG' => 'bg', 'BH' => 'ar',
'BO' => 'es', 'BR' => 'pt', 'BY' => 'be',
'CA' => 'en', 'CH' => 'de', 'CL' => 'es', 'CN' => 'zh', 'CO' => 'es',
'CR' => 'es', 'CU' => 'es', 'CZ' => 'cs',
'DE' => 'de', 'DK' => 'da', 'DO' => 'es', 'DZ' => 'ar',
'EC' => 'es', 'EE' => 'et', 'EG' => 'ar', 'ES' => 'es', 'ET' => 'am',
'FI' => 'fi', 'FR' => 'fr',
'GB' => 'en', 'GE' => 'ka', 'GR' => 'el', 'GT' => 'es',
'HK' => 'zh', 'HN' => 'es', 'HR' => 'hr', 'HU' => 'hu',
'ID' => 'id', 'IE' => 'en', 'IL' => 'he', 'IN' => 'hi', 'IQ' => 'ar',
'IR' => 'fa', 'IS' => 'is', 'IT' => 'it',
'JO' => 'ar', 'JP' => 'ja',
'KE' => 'sw', 'KG' => 'ky', 'KH' => 'km', 'KR' => 'ko', 'KW' => 'ar',
'KZ' => 'kk',
'LA' => 'lo', 'LB' => 'ar', 'LK' => 'si', 'LT' => 'lt', 'LU' => 'fr',
'LV' => 'lv', 'LY' => 'ar',
'MA' => 'ar', 'MD' => 'ro', 'ME' => 'sr', 'MK' => 'mk', 'MM' => 'my',
'MN' => 'mn', 'MT' => 'mt', 'MX' => 'es', 'MY' => 'ms',
'NG' => 'en', 'NI' => 'es', 'NL' => 'nl', 'NO' => 'no', 'NP' => 'ne',
'NZ' => 'en',
'OM' => 'ar',
'PA' => 'es', 'PE' => 'es', 'PH' => 'tl', 'PK' => 'ur', 'PL' => 'pl',
'PR' => 'es', 'PS' => 'ar', 'PT' => 'pt', 'PY' => 'es',
'QA' => 'ar',
'RO' => 'ro', 'RS' => 'sr', 'RU' => 'ru',
'SA' => 'ar', 'SD' => 'ar', 'SE' => 'sv', 'SG' => 'en', 'SI' => 'sl',
'SK' => 'sk', 'SO' => 'so', 'SV' => 'es', 'SY' => 'ar',
'TH' => 'th', 'TJ' => 'tg', 'TN' => 'ar', 'TR' => 'tr', 'TW' => 'zh',
'TZ' => 'sw',
'UA' => 'uk', 'UG' => 'en', 'US' => 'en', 'UY' => 'es', 'UZ' => 'uz',
'VE' => 'es', 'VN' => 'vi',
'YE' => 'ar',
'ZA' => 'en', 'ZW' => 'en',
];
public static function primary(?string $iso2): ?string
{
if (! $iso2) return null;
return self::MAP[strtoupper($iso2)] ?? null;
}
}
+56 -1
View File
@@ -8,6 +8,7 @@ use App\Models\Video;
use App\Notifications\NewCommentLikeNotification;
use App\Notifications\NewCommentNotification;
use App\Notifications\NewReplyNotification;
use App\Services\LlmLyricsService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -15,7 +16,7 @@ class CommentController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index']);
$this->middleware('auth')->except(['index', 'translate']);
}
public function index(Video $video)
@@ -34,6 +35,29 @@ class CommentController extends Controller
$commenter = Auth::user();
// Idempotency guard: a client that retries (duplicate listeners after an
// SPA swap, fast double-click, a flaky connection) can POST the identical
// body twice. Treat an identical comment from the same user to the same
// video within 5 seconds as the same submission and return the first one.
$existing = $video->comments()
->where('user_id', $commenter->id)
->where('body', $request->body)
->where(function ($q) use ($request) {
$request->parent_id
? $q->where('parent_id', $request->parent_id)
: $q->whereNull('parent_id');
})
->where('created_at', '>=', now()->subSeconds(5))
->latest('id')
->first();
if ($existing) {
return response()->json([
'success' => true,
'comment' => $existing->load('user:id,name,avatar'),
]);
}
$comment = $video->comments()->create([
'user_id' => $commenter->id,
'body' => $request->body,
@@ -89,6 +113,37 @@ class CommentController extends Controller
return response()->json(['success' => true]);
}
public function translate(Request $request, Comment $comment, LlmLyricsService $llm)
{
if (! $llm->isEnabled()) {
return response()->json(['error' => 'Translation is not configured'], 503);
}
$target = (string) $request->input('target', 'en');
// Derive a source-language hint from the comment author's most-frequent
// access country (video_views + profile_visits, falling back to their
// nationality setting). Never trust the client for this — it's used
// to disambiguate scripts the LLM might otherwise misidentify.
$topCountries = \App\Models\User::topCountriesFor([$comment->user_id]);
$sourceCountry = $topCountries[$comment->user_id] ?? null;
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
$translated = $llm->translate((string) $comment->body, $target, $sourceLang);
if ($translated === '') {
return response()->json(['error' => 'Translation failed'], 502);
}
return response()->json([
'success' => true,
'translated' => $translated,
'target' => $target,
'source_hint' => $sourceLang,
'author_country'=> $sourceCountry,
]);
}
public function like(Comment $comment)
{
$userId = Auth::id();
+48 -1
View File
@@ -26,7 +26,7 @@ class VideoController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription']);
}
public function index()
@@ -913,6 +913,53 @@ class VideoController extends Controller
* mirror + the NAS copy and removes any in-flight progress / temp files,
* so the next Generate produces a fresh result.
*/
public function translateDescription(Request $request, Video $video, \App\Services\LlmLyricsService $llm)
{
if (! $llm->isEnabled()) {
return response()->json(['error' => 'Translation is not configured'], 503);
}
$html = (string) $video->description;
if (trim($html) === '') {
return response()->json(['error' => 'Nothing to translate'], 400);
}
// Flatten the rich description → plain text, keeping paragraph breaks
// so the client can render original and translation line-by-line.
$plain = trim(html_entity_decode(strip_tags(
preg_replace(['/<\/p>/i', '/<br\s*\/?>/i'], ["\n", "\n"], $html)
), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
$lines = preg_split('/\R/u', $plain);
$target = (string) $request->input('target', 'en');
// Source language inferred from the owner's access-history country,
// falling back to users.nationality.
$topCountries = \App\Models\User::topCountriesFor([$video->user_id]);
$sourceCountry = $topCountries[$video->user_id] ?? null;
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
$translatedLines = $llm->translateLines($lines, $target, $sourceLang);
if (! $translatedLines || count($translatedLines) !== count($lines)) {
return response()->json(['error' => 'Translation failed'], 502);
}
$segments = [];
foreach ($lines as $i => $src) {
$segments[] = [
'original' => (string) $src,
'translated' => (string) ($translatedLines[$i] ?? ''),
];
}
return response()->json([
'success' => true,
'segments' => $segments,
'target' => $target,
'source_hint' => $sourceLang,
'author_country' => $sourceCountry,
]);
}
public function deleteLyrics(Request $request, Video $video)
{
if (Auth::id() !== $video->user_id) {
+100
View File
@@ -7,6 +7,8 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
@@ -56,8 +58,57 @@ class User extends Authenticatable implements MustVerifyEmail
'password' => 'hashed',
'two_factor_enabled' => 'boolean',
'notification_preferences' => 'array',
// TAKEONE account link. The token is a live credential for that
// platform, so it is encrypted at rest and never sent back to a browser.
'takeone_token' => 'encrypted',
'takeone_connected_at' => 'datetime',
];
/**
* Can this user run TAKEONE lookups?
*
* A super admin always can: the platform holds a TAKEONE super-admin token
* on their behalf, so they are connected with full reach without ever
* pasting anything. Everyone else links their own account, and sees only
* what that account may see.
*/
public function takeoneLinked(): bool
{
return $this->takeoneToken() !== null;
}
/** True when the connection is the platform's, not one this user pasted. */
public function takeoneManagedLink(): bool
{
$own = $this->takeone_token;
return ($own === null || $own === '')
&& $this->isSuperAdmin()
&& ! empty(config('services.takeone.admin_token'));
}
/**
* The token to talk to TAKEONE with. A personally linked account always
* wins, so a super admin who deliberately links their own identity is not
* silently upgraded back to the platform credential.
*/
public function takeoneToken(): ?string
{
$own = $this->takeone_token;
if ($own !== null && $own !== '') {
return $own;
}
if ($this->isSuperAdmin()) {
$platform = config('services.takeone.admin_token');
return ! empty($platform) ? $platform : null;
}
return null;
}
// Defaults for each preference key (true = on, false = off)
private const NOTIF_DEFAULTS = [
'notif_new_comment' => true,
@@ -105,6 +156,55 @@ class User extends Authenticatable implements MustVerifyEmail
});
}
/**
* Resolve the most-frequent ISO2 country for each user id, aggregating
* video_views + profile_visits and falling back to users.nationality.
* Returns [user_id => 'BH', …]; keys without data are omitted.
*/
public static function topCountriesFor(array $userIds): array
{
$userIds = array_values(array_unique(array_filter($userIds, 'is_numeric')));
if (! $userIds) return [];
$tally = [];
$rows = DB::table('video_views')
->selectRaw('user_id, country, COUNT(*) as c')
->whereIn('user_id', $userIds)
->whereNotNull('country')->where('country', '!=', '')
->groupBy('user_id', 'country')->get();
foreach ($rows as $r) {
$cc = strtoupper($r->country);
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
}
if (Schema::hasTable('profile_visits')) {
$rows = DB::table('profile_visits')
->selectRaw('visitor_user_id as user_id, country, COUNT(*) as c')
->whereIn('visitor_user_id', $userIds)
->whereNotNull('country')->where('country', '!=', '')
->groupBy('visitor_user_id', 'country')->get();
foreach ($rows as $r) {
$cc = strtoupper($r->country);
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
}
}
$out = [];
foreach ($tally as $uid => $counts) {
arsort($counts);
$out[$uid] = array_key_first($counts);
}
// Fallback to users.nationality for anyone still unresolved.
$missing = array_values(array_diff($userIds, array_keys($out)));
if ($missing) {
$nats = static::whereIn('id', $missing)
->whereNotNull('nationality')->where('nationality', '!=', '')
->pluck('nationality', 'id');
foreach ($nats as $uid => $cc) $out[(int) $uid] = strtoupper($cc);
}
return $out;
}
public static function generateUniqueUsername(string $name): string
{
$base = substr(Str::slug(trim($name)), 0, 18);
+190 -16
View File
@@ -191,16 +191,176 @@ class LlmLyricsService
}
/**
* 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): string
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),
'openai' => $this->callOpenAI($p, $prompt, $maxTokens),
default => $this->callOllama($p, $prompt, $maxTokens),
'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'] ?? '?']);
@@ -208,24 +368,37 @@ class LlmLyricsService
}
}
private function callOllama(array $p, string $prompt, int $maxTokens): string
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' => $prompt]],
'stream' => false,
'options' => ['num_predict' => $maxTokens, 'temperature' => 0.2],
'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();
return (string) ($j['message']['content'] ?? '');
$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): string
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([
@@ -233,9 +406,10 @@ class LlmLyricsService
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])->post($endpoint . '/v1/messages', [
'model' => $p['model'],
'max_tokens' => $maxTokens,
'messages' => [['role' => 'user', 'content' => $prompt]],
'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)]);
@@ -245,7 +419,7 @@ class LlmLyricsService
return (string) ($j['content'][0]['text'] ?? '');
}
private function callOpenAI(array $p, string $prompt, int $maxTokens): string
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'])
@@ -254,7 +428,7 @@ class LlmLyricsService
'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]],
'max_tokens' => $maxTokens,
'temperature' => 0.2,
'temperature' => $temperature,
]);
if (! $resp->successful()) {
Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);