2026-02-15 08:46:28 +09:00
< ? php
namespace App\Services\Video ;
2026-02-15 08:50:26 +09:00
use App\Models\System\AiConfig ;
use App\Services\GoogleCloudService ;
2026-02-15 08:46:28 +09:00
use Illuminate\Support\Facades\Http ;
use Illuminate\Support\Facades\Log ;
class GeminiScriptService
{
2026-02-15 08:50:26 +09:00
private ? AiConfig $config = null ;
2026-02-15 08:46:28 +09:00
2026-02-15 08:50:26 +09:00
private GoogleCloudService $googleCloud ;
2026-02-15 08:46:28 +09:00
2026-02-15 08:50:26 +09:00
public function __construct ( GoogleCloudService $googleCloud )
2026-02-15 08:46:28 +09:00
{
2026-02-15 08:50:26 +09:00
$this -> googleCloud = $googleCloud ;
$this -> config = AiConfig :: getActiveGemini ();
2026-02-15 08:46:28 +09:00
}
2026-02-15 13:08:57 +09:00
/**
* 실시간 트렌딩 키워드 → 건강 채널용 필터링 / 리프레이밍
*/
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' ] } ) " : '' ;
2026-02-25 11:45:01 +09:00
return ( $i + 1 ) . " . { $item [ 'keyword' ] } { $news } " ;
2026-02-15 13:08:57 +09:00
}) -> implode ( " \n " );
$prompt = <<< PROMPT
당신은 건강 / 웰빙 전문 YouTube Shorts 채널 기획자입니다 .
아래는 오늘의 한국 실시간 급상승 키워드 목록입니다 :
{ $keywordList }
2026-02-15 14:39:58 +09:00
이 중에서 건강 / 웰빙 / 의료 / 다이어트 / 운동 / 영양 / 수면 / 스트레스 관련 키워드만 선택하세요 .
=== 엄격한 선택 기준 ===
- 키워드 자체가 건강 / 의료 / 음식 / 영양 / 운동 / 질병 / 신체 관련일 때만 포함
- 최소 1 개 , 최대 8 개 선택 ( 해당 없으면 0 개 가능 )
=== 절대 제외 대상 ( 아무리 건강과 연결하려 해도 제외 ) ===
- 연예인 / 아이돌 / 배우 / 가수 이름 ( 예 : 아이유 , 신세경 , BTS 등 )
- 정치인 / 정당 / 선거 / 정치 이슈
- 스포츠 경기 결과 / 선수 이름
- 드라마 / 영화 / 예능 프로그램명
- 사건 / 사고 / 범죄 뉴스
- 게임 / IT기기 / 앱 이름
- 인물 이름이 키워드인 경우 무조건 제외
=== 포함 가능 예시 ===
- " 다이어트 " → 직접 건강 관련 → 포함
- " 김치 " → 음식 / 영양 → 포함 가능
- " 수면 " → 건강 → 포함
- " 설날 " → 명절 건강 / 음식 관련 → 포함 가능
- " 미세먼지 " → 건강 영향 → 포함 가능
=== 제외 예시 ===
- " 아이유 " → 가수 이름 → 제외
- " 신세경 " → 배우 이름 → 제외
- " 손흥민 " → 축구선수 → 제외
- " 더글로리 " → 드라마 → 제외
health_angle은 10 자 이내 핵심 태그 ( 예 : " 장건강 " , " 면역력 " , " 다이어트 " )
2026-02-15 13:08:57 +09:00
반드시 아래 JSON 형식으로만 응답하세요 ( 다른 텍스트 없이 ) :
{
" keywords " : [
{ " keyword " : " 원본 키워드 " , " health_angle " : " 건강 태그 10자 이내 " , " suggested_topic " : " 건강 채널에서 다룰 구체적 주제 20자 이내 " }
]
}
2026-02-15 14:39:58 +09:00
해당하는 키워드가 없으면 빈 배열을 반환하세요 : { " keywords " : []}
2026-02-15 13:08:57 +09:00
PROMPT ;
$result = $this -> 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 ();
}
2026-02-19 06:16:13 +09:00
/**
* 폴백 : 트렌딩 키워드를 건강 관점에서 리프레이밍
* ( filterHealthTrending에서 0 개 반환 시 호출 )
*/
public function reframeAsHealthTrending ( array $trendingKeywords ) : array
{
if ( empty ( $trendingKeywords )) {
return [];
}
$keywordList = collect ( $trendingKeywords ) -> take ( 10 ) -> map ( function ( $item , $i ) {
$news = ! empty ( $item [ 'news_title' ]) ? " (뉴스: { $item [ 'news_title' ] } ) " : '' ;
2026-02-25 11:45:01 +09:00
return ( $i + 1 ) . " . { $item [ 'keyword' ] } { $news } " ;
2026-02-19 06:16:13 +09:00
}) -> implode ( " \n " );
$prompt = <<< PROMPT
당신은 창의적인 건강 / 웰빙 YouTube Shorts 채널 기획자입니다 .
아래는 오늘의 한국 실시간 급상승 키워드입니다 :
{ $keywordList }
이 키워드들은 직접적인 건강 주제가 아닙니다 .
하지만 시청자의 관심을 끌기 위해 이 트렌딩 키워드를 건강 / 웰빙 관점으로 " 리프레이밍 " 하세요 .
=== 리프레이밍 예시 ===
- " 올림픽 " → health_angle : " 선수건강 " , suggested_topic : " 올림픽 선수들의 놀라운 식단 비밀 "
- " 한파 " → health_angle : " 저체온증 " , suggested_topic : " 한파에 절대 하면 안 되는 행동 3가지 "
- " 설날 " → health_angle : " 명절증후군 " , suggested_topic : " 설날 과식 후 속이 편해지는 방법 "
- " 미세먼지 " → health_angle : " 호흡기 " , suggested_topic : " 미세먼지 마스크 효과없는 착용법 "
=== 규칙 ===
- 최대 5 개만 선택하여 리프레이밍
- 너무 억지스럽거나 비윤리적인 연결은 하지 마세요
- 리프레이밍이 자연스러운 것만 선택하세요
- 최소 2 개는 반드시 만들어주세요
반드시 아래 JSON 형식으로만 응답하세요 ( 다른 텍스트 없이 ) :
{
" keywords " : [
{ " keyword " : " 원본 키워드 " , " health_angle " : " 건강 태그 10자 이내 " , " suggested_topic " : " 건강 채널에서 다룰 구체적 주제 25자 이내 " }
]
}
PROMPT ;
$result = $this -> callGemini ( $prompt );
if ( ! $result ) {
return [];
}
$parsed = $this -> parseJsonResponse ( $result );
if ( ! $parsed || empty ( $parsed [ 'keywords' ])) {
return [];
}
$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 ,
'is_reframed' => true ,
];
}) -> values () -> toArray ();
}
2026-02-15 08:46:28 +09:00
/**
2026-02-15 12:36:36 +09:00
* 키워드 → 트렌딩 제목 5 개 생성 ( 기본 )
2026-02-15 08:46:28 +09:00
*/
public function generateTrendingTitles ( string $keyword ) : array
{
$prompt = <<< PROMPT
당신은 YouTube Shorts 전문 크리에이터입니다 .
키워드 : " { $keyword } "
이 키워드로 YouTube Shorts에서 조회수를 극대화할 수 있는 매력적인 제목 5 개를 생성해주세요 .
요구사항 :
- 각 제목은 40 자 이내
- 호기심을 자극하는 제목
- 한국어로 작성
- 이모지 1 - 2 개 포함 가능
반드시 아래 JSON 형식으로만 응답하세요 ( 다른 텍스트 없이 ) :
{
" titles " : [
{ " title " : " 제목1 " , " hook " : " 이 제목이 효과적인 이유 한줄 " },
{ " title " : " 제목2 " , " hook " : " 이유 " },
{ " title " : " 제목3 " , " hook " : " 이유 " },
{ " title " : " 제목4 " , " hook " : " 이유 " },
{ " title " : " 제목5 " , " hook " : " 이유 " }
]
}
PROMPT ;
$result = $this -> callGemini ( $prompt );
if ( ! $result ) {
return [];
}
$parsed = $this -> parseJsonResponse ( $result );
return $parsed [ 'titles' ] ? ? [];
}
/**
2026-02-15 12:36:36 +09:00
* 트렌딩 키워드 + 컨텍스트 → 후킹 제목 5 개 생성
*/
public function generateTrendingHookTitles ( string $keyword , array $context = []) : array
{
$contextBlock = '' ;
if ( ! empty ( $context )) {
$newsTitle = $context [ 'news_title' ] ? ? '' ;
$traffic = $context [ 'traffic' ] ? ? '' ;
2026-02-15 13:08:57 +09:00
$healthAngle = $context [ 'health_angle' ] ? ? '' ;
$suggestedTopic = $context [ 'suggested_topic' ] ? ? '' ;
2026-02-15 12:36:36 +09:00
$contextBlock = <<< CTX
[ 실시간 트렌딩 정보 ]
- 이 키워드는 지금 한국에서 실시간 급상승 중입니다
- 검색량 : { $traffic }
- 관련 뉴스 : { $newsTitle }
2026-02-15 13:08:57 +09:00
- 건강 관점 : { $healthAngle }
- 건강 주제 제안 : { $suggestedTopic }
2026-02-15 12:36:36 +09:00
CTX ;
}
$prompt = <<< PROMPT
2026-02-15 13:08:57 +09:00
당신은 건강 / 웰빙 전문 YouTube Shorts 조회수 1000 만 크리에이터입니다 .
이 채널은 " 건강 채널 " 입니다 . 모든 제목은 반드시 건강 / 웰빙 / 의료 / 영양 / 운동 관점이어야 합니다 .
2026-02-15 12:36:36 +09:00
키워드 : " { $keyword } "
{ $contextBlock }
2026-02-15 13:08:57 +09:00
이 키워드를 건강 관점에서 YouTube Shorts 조회수를 폭발시킬 후킹 제목 5 개를 생성해주세요 .
2026-02-15 12:36:36 +09:00
반드시 아래 5 가지 패턴을 각각 1 개씩 사용 :
1. 충격형 : " 이거 실화임? " / " 소름 돋는 진실 " 스타일
2. 비교형 : " A vs B, 결과가 충격 " / " 전문가도 놀란 " 스타일
3. 숫자형 : " 3가지 이유 " / " 단 10초면 " 스타일
4. 질문형 : " 왜 아무도 안 알려줬을까? " / " 이것도 몰랐어? " 스타일
5. 반전형 : " 알고 보니... " / " 사실은 반대였다 " 스타일
요구사항 :
- 각 제목은 40 자 이내
- 첫 2 초 안에 시청자 호기심 폭발 ( 스크롤 멈춤 유도 )
- 감정 유발 단어 필수 ( 충격 , 소름 , 미친 , 레전드 , 역대급 등 )
- 한국어로 작성
- 이모지 1 - 2 개 포함
- 트렌딩 키워드의 맥락을 정확히 반영
반드시 아래 JSON 형식으로만 응답하세요 ( 다른 텍스트 없이 ) :
{
" titles " : [
{ " title " : " 제목1 " , " hook " : " 이 제목이 효과적인 이유 한줄 " , " pattern " : " 충격형 " },
{ " title " : " 제목2 " , " hook " : " 이유 " , " pattern " : " 비교형 " },
{ " title " : " 제목3 " , " hook " : " 이유 " , " pattern " : " 숫자형 " },
{ " title " : " 제목4 " , " hook " : " 이유 " , " pattern " : " 질문형 " },
{ " title " : " 제목5 " , " hook " : " 이유 " , " pattern " : " 반전형 " }
]
}
PROMPT ;
$result = $this -> callGemini ( $prompt );
if ( ! $result ) {
return [];
}
$parsed = $this -> parseJsonResponse ( $result );
return $parsed [ 'titles' ] ? ? [];
}
/**
* 제목 → 장면별 시나리오 생성 ( Veo 3.1 공식 가이드 기반 프롬프트 )
2026-02-15 08:46:28 +09:00
*/
public function generateScenario ( string $title , string $keyword = '' ) : array
{
$prompt = <<< PROMPT
2026-02-15 12:47:37 +09:00
당신은 조회수 1000 만 이상 YouTube Shorts 전문 크리에이터이자 영화 촬영감독입니다 .
2026-02-15 08:46:28 +09:00
영상 제목 : " { $title } "
키워드 : " { $keyword } "
이 제목으로 40 초 분량의 YouTube Shorts 영상 시나리오를 작성해주세요 .
2026-02-15 12:47:37 +09:00
=== 핵심 원칙 : 초반 3 초가 생사를 결정한다 ===
- Shorts는 스크롤 한 번이면 넘어간다 . 첫 3 초 안에 시청자를 " 멈추게 " 해야 한다 .
- 장면 1 의 나레이션 첫 문장은 반드시 충격 / 의문 / 공감 중 하나로 시작 ( 예 : " 이거 모르면 손해입니다 " , " 방금 소름 돋았어요 " , " 설마 아직도 이렇게 하세요? " )
- 절대 " 안녕하세요 " , " 오늘은~ " , " 여러분~ " 같은 평범한 인사로 시작하지 마세요
=== 나레이션 작성 규칙 ( 매우 중요 ) ===
- 말투 : 반말 or 친근한 존댓말 ( 방송 톤X , 친구한테 신기한 걸 알려주는 톤O )
2026-02-15 14:50:52 +09:00
- 속도감 : TTS가 1.4 배속으로 빠르게 재생됨 . 한 장면당 3 ~ 4 문장 ( 장면당 60 ~ 100 자 ) . 빈 시간 없이 빽빽하게 채워라 .
2026-02-15 12:59:36 +09:00
- 문장 구분 : 반드시 마침표 ( . ) 또는 느낌표 ( ! ) 또는 물음표 ( ? ) 로 문장을 끝내라 . 자막이 문장 단위로 전환된다 .
2026-02-15 14:50:52 +09:00
- 한 문장 길이 : 10 ~ 25 자 이내의 짧고 펀치감 있는 문장 . 긴 문장 금지 .
2026-02-15 12:47:37 +09:00
- 매 장면마다 한 가지 " 놀라운 팩트 " 또는 " 감정 변화 " 가 있어야 한다
- 뻔한 설명 금지 . " ~라고 합니다 " , " ~인데요 " 같은 수동적 표현 대신 단정적이고 강렬한 어투 사용
- 마지막 장면에서 " 좋아요/구독/알림설정 " 같은 CTA 절대 금지 . 대신 여운이 남는 한마디 또는 강렬한 마무리
2026-02-15 13:34:04 +09:00
=== 나레이션 절대 금지 사항 ( TTS가 읽어버림 ) ===
- 이모지 절대 금지 : 😊🔥💪❤️ 등 모든 이모지 / 이모티콘 사용 금지
- 특수 표현 금지 : * 강조 * , ( 효과음 ), [ 동작 ], ~ 물결 , ○기호 등 사용 금지
- 순수 한글 텍스트만 작성 . TTS가 음성으로 변환하므로 사람이 말하는 것처럼 자연스러운 문장만 허용
- 숫자는 한글로 표기 ( 예 : " 3가지 " → " 세 가지 " , " 100% " → " 백 퍼센트 " )
=== 나레이션 좋은 예시 ===
- " 이거 매일 먹어봐요. 얼굴이 확 달라집니다. "
- " 과학자들도 설명 못 한대요. 왜냐면요. "
- " 근데 진짜 무서운 건요. 이 다음이에요. "
2026-02-15 12:47:37 +09:00
=== 나레이션 나쁜 예시 ( 절대 이렇게 쓰지 마세요 ) ===
2026-02-15 13:34:04 +09:00
- " 안녕하세요, 오늘은 ○○에 대해 알아보겠습니다. " ( 평범한 시작 )
- " 맛있게 먹고 행복해지세요!😊 " ( 이모지 포함 - TTS가 읽음 )
- " 이건 진짜 *소름* 돋는 사실인데요~ " ( 특수기호 포함 )
- " 3가지 방법을 알려드릴게요! " ( 숫자 한글 미변환 )
2026-02-15 12:47:37 +09:00
=== 장면 구성 패턴 ( 5 장면 , 총 40 초 ) ===
장면 1 ( 5 초 ) : HOOK - extreme close - up 또는 whip pan → 충격 / 의문 / 공감으로 3 초 안에 시청자 잡기
장면 2 ( 8 초 ) : CONTEXT - medium shot , 왜 이게 중요한지 빠르게 설명
장면 3 ( 10 초 ) : DEVELOPMENT - tracking shot , 핵심 정보 전달 ( 가장 밀도 높은 구간 )
장면 4 ( 10 초 ) : CLIMAX - dramatic camera movement , 반전 또는 가장 충격적인 팩트
장면 5 ( 7 초 ) : PUNCHLINE - close - up , 여운 남기는 강렬한 한마디로 끝 ( CTA 절대 금지 )
2026-02-15 12:36:36 +09:00
=== visual_prompt 작성 규칙 ( Veo 3.1 공식 프롬프팅 가이드 ) ===
반드시 아래 5 개 요소를 모두 포함하여 영어로 작성 :
1. SHOT TYPE ( 필수 1 개 선택 ) :
- extreme close - up , close - up , medium close - up , medium shot , medium wide shot , wide shot , extreme wide shot , bird ' s - eye view , over - the - shoulder shot
2. CAMERA MOVEMENT ( 필수 1 개 선택 ) :
- static , slow pan left / right , tilt up / down , dolly in / out , tracking shot , crane shot , aerial shot , handheld , arc shot , whip pan , zoom in / out
3. LIGHTING ( 필수 , 구체적으로 ) :
- golden hour sunlight , dramatic side lighting , soft diffused light , neon glow , Rembrandt lighting , high - key lighting , low - key lighting , backlit silhouette , warm ambient light , cool blue moonlight
4. STYLE / QUALITY ( 필수 ) :
- cinematic 4 K , shot on 35 mm film , shallow depth of field , film grain , anamorphic lens flare , bokeh background , high contrast , muted color palette , vibrant saturated colors
5. ACTION ( 한 장면에 하나의 주요 동작만 ) :
- 명확한 하나의 동작을 묘사 ( 예 : " picks up the phone " , " turns around slowly " , " looks directly at camera " )
=== 추가 필수 규칙 ===
- 등장인물 : 모든 장면에 동일 인물 = " a young Korean woman in her 20s with shoulder-length black hair, wearing [첫 장면에서 정한 구체적 의상] "
- 의상은 첫 장면에서 구체적으로 설정하고 모든 장면에서 동일하게 유지
- 표정 / 감정 : " with a shocked expression " , " looking curious " , " smiling warmly " 등 구체적 묘사
- 배경 : 구체적 장소 + 소품 묘사 ( 예 : " a modern Seoul café with exposed brick walls and hanging plants " )
- 9 : 16 세로 구도 : 인물을 화면 중앙에 배치 , 상반신 중심 프레이밍
- 장면 간 시각적 연결 : 동일 색상 팔레트 , 동일 의상 , 동일 장소 또는 자연스러운 장소 전환
=== visual_prompt 좋은 예시 ===
" Medium shot, slow dolly in. A young Korean woman in her 20s with shoulder-length black hair, wearing a cream knit sweater, sitting at a cozy café table. She looks down at her phone with a shocked expression, mouth slightly open. Warm golden hour sunlight streams through the window. Shallow depth of field, cinematic 4K, shot on 35mm film. Steam rises from a coffee cup in the foreground. "
=== visual_prompt 나쁜 예시 ( 이렇게 작성하지 마세요 ) ===
" A woman looking at phone in café, 4K cinematic "
2026-02-15 08:46:28 +09:00
반드시 아래 JSON 형식으로만 응답하세요 ( 다른 텍스트 없이 ) :
{
" title " : " { $title } " ,
" scenes " : [
{
" scene_number " : 1 ,
2026-02-15 12:47:37 +09:00
" duration " : 5 ,
2026-02-15 12:36:36 +09:00
" scene_role " : " HOOK " ,
2026-02-15 13:34:04 +09:00
" narration " : " 짧은 문장. 짧은 문장. 짧은 문장! (순수 한글만, 이모지 절대금지, 40~70자, 한 문장 10~20자) " ,
2026-02-15 12:36:36 +09:00
" visual_prompt " : " Shot type, camera movement. Character description with specific clothing, action and expression. Lighting description. Style/quality keywords. Background and props detail. " ,
2026-02-15 12:47:37 +09:00
" mood " : " shocking "
2026-02-15 08:46:28 +09:00
}
],
" total_duration " : 40 ,
2026-02-15 12:36:36 +09:00
" bgm_mood " : " upbeat, energetic " ,
" character_description " : " 첫 장면의 인물 설정을 여기에 요약 (의상, 헤어 등) "
2026-02-15 08:46:28 +09:00
}
PROMPT ;
$result = $this -> callGemini ( $prompt );
if ( ! $result ) {
return [];
}
return $this -> parseJsonResponse ( $result ) ? : [];
}
/**
2026-02-15 15:56:39 +09:00
* Gemini API 호출 ( 텍스트 전용 - 기존 호환 )
2026-02-15 08:46:28 +09:00
*/
private function callGemini ( string $prompt ) : ? string
2026-02-15 15:56:39 +09:00
{
return $this -> callGeminiWithParts ([[ 'text' => $prompt ]]);
}
/**
* Gemini API 호출 ( 멀티모달 지원 - 텍스트 + 이미지 )
*
2026-02-25 11:45:01 +09:00
* @ param array $parts [[ 'text' => '...' ], [ 'inlineData' => [ 'mimeType' => '...' , 'data' => '...' ]]]
2026-02-15 15:56:39 +09:00
*/
public function callGeminiWithParts ( array $parts , float $temperature = 0.9 , int $maxTokens = 4096 ) : ? string
2026-02-15 08:46:28 +09:00
{
2026-02-15 08:50:26 +09:00
if ( ! $this -> config ) {
Log :: error ( 'GeminiScriptService: 활성화된 Gemini 설정이 없습니다. (시스템 > AI 설정 확인)' );
2026-02-15 08:46:28 +09:00
return null ;
}
try {
2026-02-15 08:56:02 +09:00
$model = $this -> config -> model ? : 'gemini-3.0-flash' ;
2026-02-15 08:50:26 +09:00
$body = [
2026-02-15 08:46:28 +09:00
'contents' => [
[
2026-02-15 15:56:39 +09:00
'parts' => $parts ,
2026-02-15 08:46:28 +09:00
],
],
'generationConfig' => [
2026-02-15 15:56:39 +09:00
'temperature' => $temperature ,
'maxOutputTokens' => $maxTokens ,
2026-02-15 08:46:28 +09:00
'responseMimeType' => 'application/json' ,
],
2026-02-15 08:50:26 +09:00
];
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 )
2026-02-15 15:56:39 +09:00
-> timeout ( 120 )
2026-02-15 08:50:26 +09:00
-> 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 } " ;
2026-02-15 15:56:39 +09:00
$response = Http :: timeout ( 120 ) -> post ( $url , $body );
2026-02-15 08:50:26 +09:00
}
2026-02-15 08:46:28 +09:00
if ( ! $response -> successful ()) {
Log :: error ( 'GeminiScriptService: API 호출 실패' , [
'status' => $response -> status (),
'body' => $response -> body (),
2026-02-15 08:50:26 +09:00
'auth_type' => $this -> config -> isVertexAi () ? 'vertex_ai' : 'api_key' ,
2026-02-15 08:46:28 +09:00
]);
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 ;
}
}
2026-02-15 15:56:39 +09:00
/**
* JSON 응답 파싱 ( public - 외부 서비스에서도 사용 )
*/
public function parseJson ( string $text ) : ? array
{
return $this -> parseJsonResponse ( $text );
}
2026-02-15 08:46:28 +09:00
/**
* 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 ;
}
}