- Implemented Gemini API service for generating optimized product titles and descriptions based on Google Merchant Center guidelines. - Added settings for Gemini API key and model selection in user settings. - Enhanced product management views to support AI-generated suggestions for titles and descriptions. - Enabled state saving for various data tables across campaign, terms, logs, and products views. - Introduced AI prompt templates for generating product descriptions and categories.
350 lines
13 KiB
PHP
350 lines
13 KiB
PHP
<?php
|
|
namespace services;
|
|
class GeminiApi
|
|
{
|
|
static private $api_base = 'https://generativelanguage.googleapis.com/v1beta/models/';
|
|
|
|
static private $system_prompt = 'Jesteś ekspertem od optymalizacji feedów produktowych Google Merchant Center i reklam Google Ads (Performance Max, Shopping).
|
|
|
|
OFICJALNE ZASADY GOOGLE MERCHANT CENTER — BEZWZGLĘDNIE PRZESTRZEGAJ:
|
|
|
|
TYTUŁY (title) — max 150 znaków:
|
|
Zalecana struktura: [Marka] [Typ produktu] [Kluczowe atrybuty: kolor, rozmiar, materiał — TYLKO jeśli wynikają z nazwy]
|
|
- Najważniejsze informacje umieść w pierwszych 70 znakach (reszta może być obcięta na mobile)
|
|
- Każdy wariant produktu musi mieć unikalny tytuł (różne kolory/rozmiary = różne tytuły)
|
|
- Tytuł MUSI być spójny z nazwą produktu na stronie docelowej
|
|
- Używaj naturalnego języka — pisz dla ludzi, nie dla algorytmów
|
|
ZAKAZANE w tytułach:
|
|
- Tekst promocyjny: bestseller, hit, okazja, wyprzedaż, rabat, najlepszy, TOP, #1, idealny, polecamy, nowość
|
|
- Wykrzykniki (!) i nadmierna interpunkcja
|
|
- WIELKIE LITERY (wyjątek: ustalone skróty jak LED, USB, TV)
|
|
- Emotikony, emoji, symbole dekoracyjne (★, ♥, ✓)
|
|
- Informacje o cenie, dostawie, promocji
|
|
- Wezwania do działania (kup teraz, sprawdź, zamów)
|
|
- Upychanie słów kluczowych (keyword stuffing) — np. powtarzanie tych samych fraz
|
|
- Kody wewnętrzne, SKU, numery katalogowe
|
|
- Cechy produktu których NIE MA w oryginalnej nazwie (nie wymyślaj koloru, materiału, rozmiaru)
|
|
|
|
OPISY (description) — max 5000 znaków, ale najważniejsze info w pierwszych 160-500 znakach:
|
|
- Opisz cechy, specyfikacje techniczne, zastosowanie, grupę docelową
|
|
- Wymień atrybuty niewidoczne na zdjęciu (materiał, wzór, przeznaczenie wiekowe)
|
|
- Ton neutralny, informacyjny — jak w katalogu produktowym
|
|
ZAKAZANE w opisach:
|
|
- Tekst promocyjny i reklamowy (te same słowa co w tytułach)
|
|
- Nazwa firmy/sklepu (chyba że to marka produktu)
|
|
- Informacje o wysyłce, cenach, promocjach
|
|
- Wezwania do działania
|
|
- Opisy innych produktów, akcesoriów nie wchodzących w skład oferty
|
|
- Tekst zastępczy (lorem ipsum, placeholder)
|
|
- Powielanie tego samego opisu dla wielu produktów
|
|
- HTML, tagi formatowania
|
|
|
|
OGÓLNE:
|
|
- Pisz poprawną polszczyzną (ortografia, gramatyka, interpunkcja)
|
|
- Używaj pisowni zdaniowej lub tytułowej — NIE CAPS
|
|
- Dane w feedzie MUSZĄ zgadzać się z danymi na stronie produktowej
|
|
- Opisuj TYLKO cechy wynikające z nazwy produktu — nie wymyślaj
|
|
|
|
Twoje odpowiedzi muszą być:
|
|
- Ściśle zgodne z polityką Google Merchant Center
|
|
- Zgodne z prawdą (opisuj tylko cechy widoczne w nazwie produktu)
|
|
- Zoptymalizowane pod kątem trafności wyszukiwań w Google Ads
|
|
- W języku polskim';
|
|
|
|
static public function is_configured()
|
|
{
|
|
return (bool) GoogleAdsApi::get_setting( 'gemini_api_key' );
|
|
}
|
|
|
|
static private function call_api( $system_prompt, $user_prompt, $max_tokens = 500, $temperature = 0.7 )
|
|
{
|
|
$api_key = GoogleAdsApi::get_setting( 'gemini_api_key' );
|
|
$model = GoogleAdsApi::get_setting( 'gemini_model' ) ?: 'gemini-2.5-flash';
|
|
$is_thinking_model = ( strpos( $model, 'gemini-2.5' ) !== false );
|
|
|
|
$url = self::$api_base . $model . ':generateContent?key=' . urlencode( $api_key );
|
|
|
|
// Modele Gemini 2.5 z thinking potrzebują więcej tokenów (thinking liczy się do limitu)
|
|
$effective_max_tokens = $is_thinking_model ? max( $max_tokens * 6, 4096 ) : $max_tokens;
|
|
|
|
$payload = [
|
|
'systemInstruction' => [
|
|
'parts' => [ [ 'text' => $system_prompt ] ]
|
|
],
|
|
'contents' => [
|
|
[
|
|
'role' => 'user',
|
|
'parts' => [ [ 'text' => $user_prompt ] ]
|
|
]
|
|
],
|
|
'generationConfig' => [
|
|
'maxOutputTokens' => $effective_max_tokens,
|
|
'temperature' => $temperature
|
|
]
|
|
];
|
|
|
|
$ch = curl_init( $url );
|
|
curl_setopt_array( $ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Content-Type: application/json'
|
|
],
|
|
CURLOPT_POSTFIELDS => json_encode( $payload ),
|
|
CURLOPT_TIMEOUT => 60
|
|
] );
|
|
|
|
$response = curl_exec( $ch );
|
|
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
|
curl_close( $ch );
|
|
|
|
if ( $http_code !== 200 )
|
|
{
|
|
$error = json_decode( $response, true );
|
|
return [ 'status' => 'error', 'message' => $error['error']['message'] ?? 'Błąd API Gemini (HTTP ' . $http_code . ')' ];
|
|
}
|
|
|
|
$data = json_decode( $response, true );
|
|
$parts = $data['candidates'][0]['content']['parts'] ?? [];
|
|
$content = '';
|
|
|
|
foreach ( $parts as $part )
|
|
{
|
|
if ( !empty( $part['thought'] ) )
|
|
{
|
|
continue;
|
|
}
|
|
if ( isset( $part['text'] ) && trim( (string) $part['text'] ) !== '' )
|
|
{
|
|
$content = trim( (string) $part['text'] );
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ( $content === '' )
|
|
{
|
|
$finish_reason = $data['candidates'][0]['finishReason'] ?? '';
|
|
$message = 'Gemini nie zwróciło treści.';
|
|
if ( $finish_reason !== '' && $finish_reason !== 'STOP' )
|
|
{
|
|
$message .= ' (finishReason: ' . $finish_reason . ')';
|
|
}
|
|
return [ 'status' => 'error', 'message' => $message ];
|
|
}
|
|
|
|
return [ 'status' => 'ok', 'suggestion' => $content ];
|
|
}
|
|
|
|
static private function build_context_text( $context )
|
|
{
|
|
$lines = [];
|
|
$lines[] = 'Nazwa produktu: ' . ( $context['original_name'] ?? '—' );
|
|
|
|
if ( !empty( $context['current_title'] ) )
|
|
$lines[] = 'Obecny tytuł (custom): ' . $context['current_title'];
|
|
|
|
if ( !empty( $context['current_description'] ) )
|
|
$lines[] = 'Obecny opis: ' . $context['current_description'];
|
|
|
|
if ( !empty( $context['current_category'] ) )
|
|
$lines[] = 'Obecna kategoria Google: ' . $context['current_category'];
|
|
|
|
if ( !empty( $context['offer_id'] ) )
|
|
$lines[] = 'ID oferty: ' . $context['offer_id'];
|
|
|
|
if ( !empty( $context['custom_label_4'] ) )
|
|
$lines[] = 'Status produktu: ' . $context['custom_label_4'];
|
|
|
|
$lines[] = '';
|
|
$lines[] = 'Metryki reklamowe (ostatnie 30 dni):';
|
|
$lines[] = '- Wyświetlenia: ' . ( $context['impressions_30'] ?? 0 );
|
|
$lines[] = '- Kliknięcia: ' . ( $context['clicks_30'] ?? 0 );
|
|
$lines[] = '- CTR: ' . ( $context['ctr'] ?? 0 ) . '%';
|
|
$lines[] = '- Koszt: ' . ( $context['cost'] ?? 0 ) . ' PLN';
|
|
$lines[] = '- Konwersje: ' . ( $context['conversions'] ?? 0 );
|
|
$lines[] = '- Wartość konwersji: ' . ( $context['conversions_value'] ?? 0 ) . ' PLN';
|
|
$lines[] = '- ROAS: ' . ( $context['roas'] ?? 0 ) . '%';
|
|
|
|
if ( !empty( $context['page_content'] ) )
|
|
{
|
|
$lines[] = '';
|
|
$lines[] = 'Treść ze strony produktu (użyj tych informacji do stworzenia dokładniejszego opisu):';
|
|
$lines[] = $context['page_content'];
|
|
}
|
|
|
|
return implode( "\n", $lines );
|
|
}
|
|
|
|
static private function get_prompt_template( $setting_key, $default_template )
|
|
{
|
|
$template = trim( (string) GoogleAdsApi::get_setting( $setting_key ) );
|
|
if ( $template === '' )
|
|
{
|
|
return $default_template;
|
|
}
|
|
|
|
return $template;
|
|
}
|
|
|
|
static private function expand_prompt_template( $template, $vars )
|
|
{
|
|
$result = (string) $template;
|
|
foreach ( (array) $vars as $key => $value )
|
|
{
|
|
$result = str_replace( '{{' . $key . '}}', (string) $value, $result );
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
static public function suggest_title( $context )
|
|
{
|
|
$context_text = self::build_context_text( $context );
|
|
$keyword_planner_text = '';
|
|
|
|
if ( !empty( $context['keyword_planner_terms'] ) && is_array( $context['keyword_planner_terms'] ) )
|
|
{
|
|
$keyword_lines = [];
|
|
$keyword_lines[] = 'Najpopularniejsze frazy z Google Ads Keyword Planner (na bazie URL produktu, posortowane malejąco po średniej liczbie wyszukiwań):';
|
|
|
|
foreach ( array_slice( $context['keyword_planner_terms'], 0, 15 ) as $term )
|
|
{
|
|
$text = trim( (string) ( $term['keyword_text'] ?? '' ) );
|
|
if ( $text === '' )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
$avg_monthly = (int) ( $term['avg_monthly_searches'] ?? 0 );
|
|
$keyword_lines[] = '- ' . $text . ' (avg miesięcznie: ' . $avg_monthly . ')';
|
|
}
|
|
|
|
if ( count( $keyword_lines ) > 1 )
|
|
{
|
|
$keyword_lines[] = 'Użyj tych fraz WYBIÓRCZO i naturalnie (bez upychania słów kluczowych), tylko jeśli pasują do produktu.';
|
|
$keyword_planner_text = "\n\n" . implode( "\n", $keyword_lines );
|
|
}
|
|
}
|
|
|
|
$prompt_template = self::get_prompt_template( 'ai_prompt_title_template', OpenAiApi::get_default_title_prompt_template() );
|
|
|
|
if ( strpos( $prompt_template, '{{context}}' ) === false )
|
|
{
|
|
$prompt_template .= "\n\n{{context}}";
|
|
}
|
|
if ( strpos( $prompt_template, '{{keyword_terms}}' ) === false )
|
|
{
|
|
$prompt_template .= "{{keyword_terms}}";
|
|
}
|
|
|
|
$prompt = self::expand_prompt_template( $prompt_template, [
|
|
'context' => $context_text,
|
|
'keyword_terms' => $keyword_planner_text
|
|
] );
|
|
$prompt .= "\n\nWygeneruj 3 różne warianty tytułu (A/B/C). Zwróć WYŁĄCZNIE poprawny JSON: {\"titles\":[\"wariant A\",\"wariant B\",\"wariant C\"]}.";
|
|
|
|
$result = self::call_api( self::$system_prompt, $prompt, 1200 );
|
|
if ( ( $result['status'] ?? '' ) !== 'ok' )
|
|
{
|
|
return $result;
|
|
}
|
|
|
|
$candidates = OpenAiApi::parse_title_candidates( (string) ( $result['suggestion'] ?? '' ) );
|
|
$best_title = OpenAiApi::pick_best_title_candidate( $candidates, $context );
|
|
if ( $best_title !== '' )
|
|
{
|
|
$result['suggestion'] = $best_title;
|
|
$result['title_candidates'] = $candidates;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
static public function suggest_description( $context )
|
|
{
|
|
$context_text = self::build_context_text( $context );
|
|
$has_page = !empty( $context['page_content'] );
|
|
$keyword_planner_text = '';
|
|
|
|
if ( !empty( $context['keyword_planner_terms'] ) && is_array( $context['keyword_planner_terms'] ) )
|
|
{
|
|
$keyword_lines = [];
|
|
$keyword_lines[] = 'Najpopularniejsze frazy z Google Ads Keyword Planner (na bazie URL produktu, posortowane malejąco po średniej liczbie wyszukiwań):';
|
|
|
|
foreach ( array_slice( $context['keyword_planner_terms'], 0, 15 ) as $term )
|
|
{
|
|
$text = trim( (string) ( $term['keyword_text'] ?? '' ) );
|
|
if ( $text === '' )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
$avg_monthly = (int) ( $term['avg_monthly_searches'] ?? 0 );
|
|
$keyword_lines[] = '- ' . $text . ' (avg miesięcznie: ' . $avg_monthly . ')';
|
|
}
|
|
|
|
if ( count( $keyword_lines ) > 1 )
|
|
{
|
|
$keyword_lines[] = 'W opisie wykorzystuj te frazy naturalnie i wyłącznie gdy realnie pasują do produktu (bez keyword stuffing).';
|
|
$keyword_planner_text = "\n\n" . implode( "\n", $keyword_lines );
|
|
}
|
|
}
|
|
|
|
$length_guide = $has_page
|
|
? '- Napisz rozbudowany, szczegółowy opis: ok. 1000 znaków (800-1200)
|
|
- Wykorzystaj szczegóły ze strony produktu: skład zestawu, materiały, wymiary, kolory, przeznaczenie
|
|
- Każdy akapit/punkt powinien wnosić NOWĄ informację — NIE powtarzaj tych samych elementów'
|
|
: '- Napisz opis o długości ok. 1000 znaków (800-1200) — jeśli brak szczegółów, opisz ogólnie zastosowanie i grupę docelową
|
|
- Opisuj TYLKO to co wynika z oryginalnej nazwy — nie wymyślaj konkretnych parametrów';
|
|
|
|
$prompt_template = self::get_prompt_template( 'ai_prompt_description_template', OpenAiApi::get_default_description_prompt_template() );
|
|
|
|
if ( strpos( $prompt_template, '{{length_guide}}' ) === false )
|
|
{
|
|
$prompt_template .= "\n\n{{length_guide}}";
|
|
}
|
|
if ( strpos( $prompt_template, '{{context}}' ) === false )
|
|
{
|
|
$prompt_template .= "\n\n{{context}}";
|
|
}
|
|
if ( strpos( $prompt_template, '{{keyword_terms}}' ) === false )
|
|
{
|
|
$prompt_template .= "{{keyword_terms}}";
|
|
}
|
|
|
|
$prompt = self::expand_prompt_template( $prompt_template, [
|
|
'length_guide' => $length_guide,
|
|
'context' => $context_text,
|
|
'keyword_terms' => $keyword_planner_text
|
|
] );
|
|
|
|
$tokens = $has_page ? 1500 : 1000;
|
|
return self::call_api( self::$system_prompt, $prompt, $tokens );
|
|
}
|
|
|
|
static public function suggest_category( $context, $categories = [] )
|
|
{
|
|
$context_text = self::build_context_text( $context );
|
|
|
|
$cats_text = '';
|
|
if ( !empty( $categories ) )
|
|
{
|
|
$cats_list = array_slice( $categories, 0, 50 );
|
|
$cats_text = "\n\nDostępne kategorie Google Product Taxonomy (format: id - tekst):\n";
|
|
foreach ( $cats_list as $cat )
|
|
{
|
|
$cats_text .= $cat['id'] . ' - ' . $cat['text'] . "\n";
|
|
}
|
|
}
|
|
|
|
$prompt = 'Dopasuj produkt do najlepszej kategorii Google Product Taxonomy.
|
|
Wybierz najbardziej szczegółową (najgłębszą) kategorię pasującą do produktu.
|
|
|
|
' . $context_text . $cats_text . '
|
|
|
|
Zwróć TYLKO ID kategorii (liczbę), bez wyjaśnień.';
|
|
|
|
return self::call_api( self::$system_prompt, $prompt );
|
|
}
|
|
}
|