googleCloud = $googleCloud; $this->config = AiConfig::getActiveGemini(); } /** * 실시간 트렌딩 키워드 → 건강 채널용 필터링/리프레이밍 */ public function filterHealthTrending(array $trendingKeywords): array { if (empty($trendingKeywords)) { return []; } $keywordList = collect($trendingKeywords)->map(function ($item, $i) { $news = ! empty($item['news_title']) ? " (뉴스: {$item['news_title']})" : ''; return ($i + 1) . ". {$item['keyword']}{$news}"; })->implode("\n"); $prompt = <<callGemini($prompt); if (! $result) { return []; } $parsed = $this->parseJsonResponse($result); if (! $parsed || empty($parsed['keywords'])) { return []; } // 원본 트렌딩 데이터와 매칭하여 traffic 등 보존 $trendingMap = collect($trendingKeywords)->keyBy('keyword'); return collect($parsed['keywords'])->map(function ($item) use ($trendingMap) { $original = $trendingMap->get($item['keyword']); return [ 'keyword' => $item['keyword'], 'health_angle' => $item['health_angle'] ?? '', 'suggested_topic' => $item['suggested_topic'] ?? $item['keyword'], 'traffic' => $original['traffic'] ?? '', 'news_title' => $original['news_title'] ?? '', 'pub_date' => $original['pub_date'] ?? null, ]; })->values()->toArray(); } /** * 키워드 → 트렌딩 제목 5개 생성 (기본) */ public function generateTrendingTitles(string $keyword): array { $prompt = <<callGemini($prompt); if (! $result) { return []; } $parsed = $this->parseJsonResponse($result); return $parsed['titles'] ?? []; } /** * 트렌딩 키워드 + 컨텍스트 → 후킹 제목 5개 생성 */ public function generateTrendingHookTitles(string $keyword, array $context = []): array { $contextBlock = ''; if (! empty($context)) { $newsTitle = $context['news_title'] ?? ''; $traffic = $context['traffic'] ?? ''; $healthAngle = $context['health_angle'] ?? ''; $suggestedTopic = $context['suggested_topic'] ?? ''; $contextBlock = <<callGemini($prompt); if (! $result) { return []; } $parsed = $this->parseJsonResponse($result); return $parsed['titles'] ?? []; } /** * 제목 → 장면별 시나리오 생성 (Veo 3.1 공식 가이드 기반 프롬프트) */ public function generateScenario(string $title, string $keyword = ''): array { $prompt = <<callGemini($prompt); if (! $result) { return []; } return $this->parseJsonResponse($result) ?: []; } /** * Gemini API 호출 (텍스트 전용 - 기존 호환) */ private function callGemini(string $prompt): ?string { return $this->callGeminiWithParts([['text' => $prompt]]); } /** * Gemini API 호출 (멀티모달 지원 - 텍스트 + 이미지) * * @param array $parts [['text' => '...'], ['inlineData' => ['mimeType' => '...', 'data' => '...']]] */ public function callGeminiWithParts(array $parts, float $temperature = 0.9, int $maxTokens = 4096): ?string { if (! $this->config) { Log::error('GeminiScriptService: 활성화된 Gemini 설정이 없습니다. (시스템 > AI 설정 확인)'); return null; } try { $model = $this->config->model ?: 'gemini-3.0-flash'; $body = [ 'contents' => [ [ 'parts' => $parts, ], ], 'generationConfig' => [ 'temperature' => $temperature, 'maxOutputTokens' => $maxTokens, 'responseMimeType' => 'application/json', ], ]; if ($this->config->isVertexAi()) { $accessToken = $this->googleCloud->getAccessToken(); if (! $accessToken) { Log::error('GeminiScriptService: Vertex AI 액세스 토큰 획득 실패'); return null; } $projectId = $this->config->getProjectId(); $region = $this->config->getRegion(); $url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent"; $body['contents'][0]['role'] = 'user'; $response = Http::withToken($accessToken) ->timeout(120) ->post($url, $body); } else { $apiKey = $this->config->api_key; $baseUrl = $this->config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta'; $url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}"; $response = Http::timeout(120)->post($url, $body); } if (! $response->successful()) { Log::error('GeminiScriptService: API 호출 실패', [ 'status' => $response->status(), 'body' => $response->body(), 'auth_type' => $this->config->isVertexAi() ? 'vertex_ai' : 'api_key', ]); return null; } $data = $response->json(); $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null; return $text; } catch (\Exception $e) { Log::error('GeminiScriptService: 예외 발생', ['error' => $e->getMessage()]); return null; } } /** * JSON 응답 파싱 (public - 외부 서비스에서도 사용) */ public function parseJson(string $text): ?array { return $this->parseJsonResponse($text); } /** * JSON 응답 파싱 (코드블록 제거 포함) */ private function parseJsonResponse(string $text): ?array { // 코드블록 제거 (```json ... ```) $text = preg_replace('/^```(?:json)?\s*/m', '', $text); $text = preg_replace('/```\s*$/m', '', $text); $text = trim($text); $decoded = json_decode($text, true); if (json_last_error() !== JSON_ERROR_NONE) { Log::warning('GeminiScriptService: JSON 파싱 실패', [ 'error' => json_last_error_msg(), 'text' => substr($text, 0, 500), ]); return null; } return $decoded; } }