true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 10, CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', CURLOPT_HTTPHEADER => [ 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language: pl-PL,pl;q=0.9,en;q=0.8', ], CURLOPT_SSL_VERIFYPEER => false ] ); $html = curl_exec( $ch ); $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); curl_close( $ch ); if ( $http_code !== 200 || !$html ) return ''; // Usuń skrypty, style, komentarze $html = preg_replace( '/]*>.*?<\/script>/si', '', $html ); $html = preg_replace( '/]*>.*?<\/style>/si', '', $html ); $html = preg_replace( '//s', '', $html ); $html = preg_replace( '/]*>.*?<\/nav>/si', '', $html ); $html = preg_replace( '/]*>.*?<\/footer>/si', '', $html ); $html = preg_replace( '/]*>.*?<\/header>/si', '', $html ); // Zamień tagi na spacje i wyciągnij tekst $text = strip_tags( $html ); $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ); $text = preg_replace( '/\s+/', ' ', $text ); $text = self::clean_page_content_text( trim( $text ) ); // Ogranicz do ~1800 znaków if ( mb_strlen( $text ) > 1800 ) $text = mb_substr( $text, 0, 1800 ) . '...'; return $text; } static private function clean_page_content_text( $text ) { $text = trim( (string) $text ); if ( $text === '' ) { return ''; } $noise_phrases = [ 'cookies', 'polityce prywatności', 'zaakceptuj wszystkie', 'odrzuć wszystkie', 'dostosuj zgody', 'sklep jest w trybie podglądu', 'pokaż pełną wersję strony', 'wersje językowe', 'strona główna', 'social media', 'darmowa dostawa', 'profesjonalne doradztwo', 'bezpieczne płatności', 'szybka wysyłka', 'produkt miesiąca', 'niezbędne do działania strony', 'analityczne', 'marketingowe', 'funkcjonalne', 'shoper' ]; $parts = preg_split( '/(?<=[\.\!\?])\s+|\s{2,}/u', $text ); $clean_parts = []; $seen = []; foreach ( (array) $parts as $part ) { $part = trim( (string) $part ); if ( mb_strlen( $part ) < 25 ) { continue; } $part_l = mb_strtolower( $part ); $is_noise = false; foreach ( $noise_phrases as $phrase ) { if ( mb_strpos( $part_l, $phrase ) !== false ) { $is_noise = true; break; } } if ( $is_noise ) { continue; } if ( isset( $seen[ $part_l ] ) ) { continue; } $seen[ $part_l ] = true; $clean_parts[] = $part; } $result = trim( implode( '. ', $clean_parts ) ); if ( mb_strlen( $result ) < 80 ) { return ''; } return $result; } static private function call_api( $system_prompt, $user_prompt, $max_tokens = 500, $temperature = 0.7, $extra_payload = [] ) { $api_key = GoogleAdsApi::get_setting( 'openai_api_key' ); $model = GoogleAdsApi::get_setting( 'openai_model' ) ?: 'gpt-5-mini'; $is_gpt5_model = ( strpos( $model, 'gpt-5' ) === 0 ); // GPT-5.x wymaga max_completion_tokens, starsze modele używają max_tokens $tokens_key = $is_gpt5_model ? 'max_completion_tokens' : 'max_tokens'; $payload = [ 'model' => $model, 'messages' => [ [ 'role' => 'system', 'content' => $system_prompt ], [ 'role' => 'user', 'content' => $user_prompt ] ], $tokens_key => $max_tokens ]; // Modele GPT-5 (w tym gpt-5-mini) nie wspierają niestandardowej temperatury. if ( !$is_gpt5_model ) { $payload['temperature'] = $temperature; } if ( is_array( $extra_payload ) && !empty( $extra_payload ) ) { $payload = array_merge( $payload, $extra_payload ); } $ch = curl_init( self::$api_url ); curl_setopt_array( $ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $api_key ], CURLOPT_POSTFIELDS => json_encode( $payload ), CURLOPT_TIMEOUT => 30 ] ); $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 OpenAI (HTTP ' . $http_code . ')' ]; } $data = json_decode( $response, true ); $content = trim( $data['choices'][0]['message']['content'] ?? '' ); 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 private function extract_meaningful_tokens( $text ) { $text = mb_strtolower( (string) $text ); $text = preg_replace( '/[^\p{L}\p{N}]+/u', ' ', $text ); $raw_tokens = preg_split( '/\s+/u', trim( (string) $text ) ); $stopwords = [ 'oraz', 'przez', 'jego', 'jej', 'this', 'that', 'with', 'from', 'jest', 'dla', 'the', 'and', 'lub', 'czy', 'ten', 'ta', 'to', 'tych', 'taki', 'takie', 'jako', 'się', 'sie', 'nad', 'pod', 'bez', 'www', 'http', 'https' ]; $stop_map = array_fill_keys( $stopwords, true ); $tokens = []; foreach ( (array) $raw_tokens as $token ) { $token = trim( (string) $token ); if ( mb_strlen( $token ) < 4 ) { continue; } if ( isset( $stop_map[ $token ] ) ) { continue; } $tokens[ $token ] = true; } return array_keys( $tokens ); } static private function select_relevant_keyword_terms( $terms, $context, $limit = 10 ) { $terms = is_array( $terms ) ? $terms : []; if ( empty( $terms ) ) { return []; } $context_source = trim( (string) ( $context['original_name'] ?? '' ) ) . ' ' . trim( (string) ( $context['page_content'] ?? '' ) ); $context_tokens = self::extract_meaningful_tokens( $context_source ); $context_map = array_fill_keys( $context_tokens, true ); $scored = []; foreach ( $terms as $idx => $term ) { $keyword_text = trim( (string) ( $term['keyword_text'] ?? '' ) ); if ( $keyword_text === '' ) { continue; } $kw_tokens = self::extract_meaningful_tokens( $keyword_text ); $overlap = 0; foreach ( $kw_tokens as $kw_token ) { if ( isset( $context_map[ $kw_token ] ) ) { $overlap++; } } $avg = (int) ( $term['avg_monthly_searches'] ?? 0 ); $score = ( $overlap * 1000000 ) + $avg - (int) $idx; if ( $overlap <= 0 ) { $score -= 5000000; } $term['_score'] = $score; $term['_overlap'] = $overlap; $scored[] = $term; } usort( $scored, function( $a, $b ) { return (int) ( $b['_score'] ?? 0 ) <=> (int) ( $a['_score'] ?? 0 ); } ); $filtered = array_values( array_filter( $scored, function( $row ) { return (int) ( $row['_overlap'] ?? 0 ) > 0; } ) ); if ( empty( $filtered ) ) { $filtered = $scored; } $filtered = array_slice( $filtered, 0, max( 1, (int) $limit ) ); foreach ( $filtered as &$row ) { unset( $row['_score'], $row['_overlap'] ); } unset( $row ); return $filtered; } static private function build_keyword_planner_text( $context, $usage_line, $limit = 10 ) { $terms = self::select_relevant_keyword_terms( (array) ( $context['keyword_planner_terms'] ?? [] ), $context, $limit ); if ( empty( $terms ) ) { return ''; } $keyword_lines = []; $keyword_lines[] = 'Najpopularniejsze frazy z Google Ads Keyword Planner (na bazie URL produktu, posortowane malejąco po średniej liczbie wyszukiwań):'; foreach ( $terms 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 ) { return ''; } $keyword_lines[] = $usage_line; return "\n\n" . implode( "\n", $keyword_lines ); } static public function parse_title_candidates( $raw ) { $raw = trim( (string) $raw ); if ( $raw === '' ) { return []; } $data = json_decode( $raw, true ); if ( is_array( $data ) && !empty( $data['titles'] ) && is_array( $data['titles'] ) ) { return array_values( array_filter( array_map( 'trim', $data['titles'] ) ) ); } if ( preg_match( '/\{[\s\S]*\}/u', $raw, $m ) ) { $data = json_decode( (string) $m[0], true ); if ( is_array( $data ) && !empty( $data['titles'] ) && is_array( $data['titles'] ) ) { return array_values( array_filter( array_map( 'trim', $data['titles'] ) ) ); } } $lines = preg_split( '/\r?\n/u', $raw ); $titles = []; foreach ( (array) $lines as $line ) { $line = trim( (string) $line ); $line = preg_replace( '/^\d+[\)\.\-\s]+/u', '', $line ); $line = trim( (string) $line, " \t\n\r\0\x0B\"'" ); if ( $line !== '' ) { $titles[] = $line; } } return array_values( array_slice( array_unique( $titles ), 0, 5 ) ); } static private function score_title_candidate( $title, $context ) { $title = trim( (string) $title ); if ( $title === '' ) { return -100000; } $score = 0; $len = mb_strlen( $title ); // Kara za przekroczenie limitu Google Merchant Center if ( $len > 150 ) { $score -= 2000; } // Sweet spot: 60-120 znaków — tytuł wzbogacony, ale nie za długi else if ( $len >= 60 && $len <= 120 ) { $score += 180; } else if ( $len >= 40 && $len <= 140 ) { $score += 100; } else if ( $len >= 25 ) { $score += 30; } else { $score -= 200; } $bad_words = [ 'bestseller', 'hit', 'okazja', 'tanio', 'gratis', 'promocja', 'najlepszy', 'polecamy', 'nowość' ]; $title_l = mb_strtolower( $title ); foreach ( $bad_words as $bad_word ) { if ( mb_strpos( $title_l, $bad_word ) !== false ) { $score -= 300; } } $source_tokens = self::extract_meaningful_tokens( (string) ( $context['original_name'] ?? '' ) ); $title_tokens = self::extract_meaningful_tokens( $title ); $source_map = array_fill_keys( $source_tokens, true ); // Overlap z oryginalną nazwą — zmniejszona waga, żeby nie preferować kopii oryginału $overlap = 0; foreach ( $title_tokens as $token ) { if ( isset( $source_map[ $token ] ) ) { $overlap++; } } $score += ( $overlap * 15 ); // Bonus za wzbogacenie tytułu informacjami ze strony produktu $page_content = trim( (string) ( $context['page_content'] ?? '' ) ); if ( $page_content !== '' ) { $page_tokens = self::extract_meaningful_tokens( $page_content ); $page_map = array_fill_keys( $page_tokens, true ); $enrichment = 0; foreach ( $title_tokens as $token ) { // Token jest ze strony produktu, ale NIE z oryginalnej nazwy = wzbogacenie if ( isset( $page_map[ $token ] ) && !isset( $source_map[ $token ] ) ) { $enrichment++; } } $score += min( $enrichment * 20, 100 ); } if ( preg_match( '/\d+\s?(ml|l|g|kg|cm|mm)/iu', (string) ( $context['original_name'] ?? '' ), $m ) ) { if ( preg_match( '/' . preg_quote( $m[0], '/' ) . '/iu', $title ) ) { $score += 30; } } return $score; } static public function pick_best_title_candidate( $candidates, $context ) { $best_title = ''; $best_score = -1000000; foreach ( (array) $candidates as $candidate ) { $candidate = trim( (string) $candidate ); if ( $candidate === '' ) { continue; } $score = self::score_title_candidate( $candidate, $context ); if ( $score > $best_score ) { $best_score = $score; $best_title = $candidate; } } return $best_title; } static public function get_default_title_prompt_template() { return 'Zaproponuj zoptymalizowany tytuł produktu dla Google Merchant Center. WYMAGANIA: - Max 150 znaków, najważniejsze info w pierwszych 70 znakach - Struktura: [Marka jeśli jest w nazwie] [Typ produktu] [Kluczowe cechy z nazwy] [Dla kogo/okazja jeśli wynika z nazwy] - Pisownia zdaniowa (wielka litera tylko na początku i w nazwach własnych/markach) — NIE stosuj Title Case / Camel Case, np. "Preparat do laminacji rzęs 20ml" a NIE "Preparat Do Laminacji Rzęs 20ml" - Tytuł musi brzmieć jak wpis w katalogu produktowym BEZWZGLĘDNY ZAKAZ (odrzucenie przez Google): - Słowa promocyjne: bestseller, hit, idealny, najlepszy, polecamy, okazja, nowość, TOP - Wykrzykniki (!), emotikony, symbole dekoracyjne - WIELKIE LITERY (wyjątek: LED, USB, TV) - Wezwania do działania, informacje o cenie/dostawie - Cechy wymyślone — opisuj TYLKO to co wynika z oryginalnej nazwy lub treści strony produktu - Jeśli podano treść ze strony produktu, wykorzystaj ją do wzbogacenia tytułu o rzeczywiste cechy (marka, materiał, kolor, rozmiar itp.) {{context}}{{keyword_terms}} Zwróć TYLKO tytuł, bez cudzysłowów, bez wyjaśnień.'; } static public function get_default_description_prompt_template() { return 'Napisz zoptymalizowany opis produktu dla Google Merchant Center. WYMAGANIA: {{length_guide}} - Najważniejsze info na początku (pierwsze 160 znaków widoczne w wynikach) - Rzeczowo opisz: co to jest, z czego się składa, do czego służy, dla kogo - Ton neutralny, informacyjny — jak w specyfikacji produktowej - Każdy akapit musi zawierać INNĄ informację niż poprzedni — NIGDY nie powtarzaj tych samych elementów FORMATOWANIE — Google Merchant Center obsługuje podstawowe HTML: - Używaj
do oddzielania akapitów/sekcji - Używaj pogrubienia dla kluczowych cech (np. nazwy elementów zestawu, materiał) - Używaj
  • ...
gdy wymieniasz elementy zestawu lub listę cech - NIE używaj innych tagów HTML (h1, p, div, span, img, a, table itp.) - NIE używaj Markdown — tylko dozwolone tagi HTML BEZWZGLĘDNY ZAKAZ (odrzucenie przez Google): - Słowa promocyjne: bestseller, hit, okazja, najlepszy, polecamy, nowość, idealny - Wykrzykniki (!), emotikony, WIELKIE LITERY - Wezwania do działania: kup teraz, zamów, sprawdź, dodaj do koszyka - Informacje o cenie, dostawie, promocjach, nazwie sklepu - Opisy akcesoriów/produktów nie wchodzących w skład oferty - Cechy wymyślone — opisuj TYLKO to co wynika z nazwy lub treści strony produktu {{context}}{{keyword_terms}} Zwróć TYLKO opis w formacie HTML (używając dozwolonych tagów), bez cudzysłowów, bez wyjaśnień.'; } static public function suggest_title( $context ) { $context_text = self::build_context_text( $context ); $keyword_planner_text = self::build_keyword_planner_text( $context, 'Użyj tych fraz WYBIÓRCZO i naturalnie (bez upychania słów kluczowych), tylko jeśli pasują do produktu.', 8 ); $prompt = self::get_default_title_prompt_template(); $prompt_template = self::get_prompt_template( 'ai_prompt_title_template', $prompt ); 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 = self::parse_title_candidates( (string) ( $result['suggestion'] ?? '' ) ); $best_title = self::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 = self::build_keyword_planner_text( $context, 'W opisie wykorzystuj te frazy naturalnie i wyłącznie gdy realnie pasują do produktu (bez keyword stuffing).', 12 ); $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 = self::get_default_description_prompt_template(); $prompt_template = self::get_prompt_template( 'ai_prompt_description_template', $prompt ); 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 ); } static public function suggest_negative_keywords_to_exclude( $search_terms_rows, $analysis_context = [] ) { $rows = is_array( $search_terms_rows ) ? $search_terms_rows : []; $rows = array_slice( $rows, 0, 150 ); $campaign_name = trim( (string) ( $analysis_context['campaign_name'] ?? '' ) ); $campaign_type = trim( (string) ( $analysis_context['campaign_type'] ?? '' ) ); $ad_group_name = trim( (string) ( $analysis_context['ad_group_name'] ?? '' ) ); $ad_group_id = (int) ( $analysis_context['ad_group_id'] ?? 0 ); $context_lines = []; $context_lines[] = 'KONTEKST KAMPANII:'; $context_lines[] = '- Nazwa kampanii: ' . ( $campaign_name !== '' ? $campaign_name : '-' ); $context_lines[] = '- Rodzaj kampanii: ' . ( $campaign_type !== '' ? $campaign_type : '-' ); if ( $ad_group_id > 0 || $ad_group_name !== '' ) { $context_lines[] = '- Wybrana grupa reklam: ' . ( $ad_group_name !== '' ? $ad_group_name : ( '#' . $ad_group_id ) ); } else { $context_lines[] = '- Wybrana grupa reklam: wszystkie'; } $lines = []; foreach ( $rows as $row ) { $id = (int) ( $row['id'] ?? 0 ); if ( $id <= 0 ) { continue; } $lines[] = json_encode( [ 'id' => $id, 'phrase' => trim( (string) ( $row['search_term'] ?? '' ) ), 'ad_group' => trim( (string) ( $row['ad_group_name'] ?? '' ) ), 'clicks_all' => (float) ( $row['clicks_all_time'] ?? 0 ), 'cost_all' => (float) ( $row['cost_all_time'] ?? 0 ), 'value_all' => (float) ( $row['conversion_value_all_time'] ?? 0 ), 'roas_all' => (float) ( $row['roas_all_time'] ?? 0 ), 'clicks_30' => (float) ( $row['clicks_30'] ?? 0 ), 'cost_30' => (float) ( $row['cost_30'] ?? 0 ), 'value_30' => (float) ( $row['conversion_value_30'] ?? 0 ), 'roas_30' => (float) ( $row['roas_30'] ?? 0 ) ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); } $prompt = 'Przeanalizuj frazy wyszukiwane Google Ads i wskaż, które warto wykluczyć jako negatywne słowa kluczowe. ' . implode( "\n", $context_lines ) . ' KRYTERIA OCENY: - Priorytet: frazy z kosztami i kliknięciami bez wartości konwersji, niski/zerowy ROAS, nietrafna intencja. - Nie zaznaczaj wszystkich na siłę. Jeśli fraza ma potencjał, ustaw akcję "keep". - Oceniaj zarówno dane all-time, jak i 30d. - Powód musi być krótki, konkretny i oparty na danych. FORMAT ODPOWIEDZI (BEZWZGLĘDNIE): Zwróć WYŁĄCZNIE poprawny JSON (bez markdown i bez komentarzy), zgodny ze schematem: { "items": [ { "id": 123, "phrase": "fraza", "action": "exclude" lub "keep", "reason": "krótki powód" } ] } Zasady formatu: - Pole id musi być identyczne z wejściowym id. - action może mieć tylko wartości: "exclude" albo "keep". - reason max 120 znaków. - Nie dodawaj żadnych dodatkowych pól. DANE WEJŚCIOWE (JSONL, 1 rekord na linię): ' . implode( "\n", $lines ); $schema = [ 'type' => 'json_schema', 'json_schema' => [ 'name' => 'negative_keyword_recommendations', 'schema' => [ 'type' => 'object', 'additionalProperties' => false, 'properties' => [ 'items' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'additionalProperties' => false, 'properties' => [ 'id' => [ 'type' => 'integer' ], 'phrase' => [ 'type' => 'string' ], 'action' => [ 'type' => 'string', 'enum' => [ 'exclude', 'keep' ] ], 'reason' => [ 'type' => 'string' ] ], 'required' => [ 'id', 'phrase', 'action', 'reason' ] ] ] ], 'required' => [ 'items' ] ], 'strict' => true ] ]; $rows_count = count( $rows ); $max_tokens = min( 6000, max( 2200, $rows_count * 30 ) ); return self::call_api( self::$system_prompt, $prompt, $max_tokens, 0.2, [ 'response_format' => $schema ] ); } }