Files
crmPRO/autoload/Domain/Tasks/MailToTaskImporter.php
Jacek Pyziak 482ca312aa feat: Enhance MailToTaskImporter with AI error handling and update OpenAI model to gpt-4o-mini
feat: Add Gantt task normalization and filter selection in main_view.php
2026-02-09 23:34:50 +01:00

986 lines
30 KiB
PHP

<?php
namespace Domain\Tasks;
class MailToTaskImporter
{
private const ALLOWED_SENDER = 'jacek.pyziak@project-pro.pl';
private const TASK_USER_ID = 1;
private const TASK_STATUS_ID = 5;
private const TASK_PROJECT_ID = 72;
private $mdb;
private $attachments;
private $last_ai_error;
public function __construct( $mdb = null )
{
if ( $mdb )
$this -> mdb = $mdb;
else
{
global $mdb;
$this -> mdb = $mdb;
}
$this -> attachments = new TaskAttachmentRepository( $this -> mdb );
$this -> last_ai_error = '';
}
public function importFromImap( array $config )
{
if ( !extension_loaded( 'imap' ) )
return [ 'status' => 'error', 'msg' => 'Brak rozszerzenia IMAP w PHP.' ];
$mailbox = $this -> buildMailbox( $config );
$username = isset( $config['username'] ) ? (string)$config['username'] : '';
$password = isset( $config['password'] ) ? (string)$config['password'] : '';
if ( $mailbox === '' or $username === '' or $password === '' )
return [ 'status' => 'error', 'msg' => 'Brak konfiguracji skrzynki IMAP.' ];
$imap = @imap_open( $mailbox, $username, $password );
if ( !$imap )
return [ 'status' => 'error', 'msg' => 'Nie udalo sie polaczyc z IMAP: ' . imap_last_error() ];
$this -> ensureImportTable();
$message_numbers = imap_search( $imap, 'ALL' );
if ( !is_array( $message_numbers ) or !count( $message_numbers ) )
{
imap_close( $imap );
return [ 'status' => 'empty', 'msg' => 'Brak wiadomosci do importu.' ];
}
sort( $message_numbers, SORT_NUMERIC );
$imported = 0;
$skipped = 0;
$errors = 0;
$first_error_msg = null;
foreach ( $message_numbers as $message_no )
{
$header = @imap_headerinfo( $imap, $message_no );
if ( !$header )
{
$errors++;
continue;
}
$sender = $this -> extractSender( $header );
$subject = $this -> decodeHeaderValue( isset( $header -> subject ) ? $header -> subject : '' );
$received_at = $this -> parseReceivedDate( isset( $header -> date ) ? $header -> date : '' );
$message_key = $this -> messageKey( $header, $sender, $subject, $message_no );
if ( $this -> isMessageFinalized( $message_key ) )
{
$skipped++;
@imap_delete( $imap, $message_no );
continue;
}
if ( $sender !== self::ALLOWED_SENDER )
{
$this -> saveImportLog( $message_key, null, $sender, $subject, 'skipped_sender', null );
$skipped++;
@imap_delete( $imap, $message_no );
continue;
}
try
{
$content = $this -> extractMessageContent( $imap, $message_no );
$ai_used = false;
$ai_error_for_log = null;
// Sprawdź czy użyć AI do parsowania
global $settings;
$use_ai = isset( $settings['openai_parse_emails'] ) && $settings['openai_parse_emails'] === true;
$api_key = isset( $settings['openai_api_key'] ) ? trim( (string)$settings['openai_api_key'] ) : '';
if ( $use_ai && $api_key === '' )
{
$ai_error_for_log = 'Brak klucza API OpenAI.';
error_log( '[MailToTaskImporter] AI fallback: ' . $ai_error_for_log );
}
if ( $use_ai && $api_key !== '' )
{
$model = isset( $settings['openai_model'] ) ? trim( (string)$settings['openai_model'] ) : 'gpt-4o-mini';
$ai_result = $this -> parseWithAI( $api_key, $model, $subject, $content['text'] );
if ( is_array( $ai_result ) && isset( $ai_result['task_name'] ) && isset( $ai_result['task_text'] ) )
{
$task_name = $ai_result['task_name'];
$task_text = $ai_result['task_text'];
$ai_used = true;
}
else
{
// Fallback do normalnego parsowania jeśli AI nie zadziała
if ( $this -> last_ai_error !== '' )
{
error_log( '[MailToTaskImporter] AI fallback: ' . $this -> last_ai_error );
$ai_error_for_log = $this -> last_ai_error;
}
$task_name = trim( $subject ) !== '' ? trim( $subject ) : '(bez tematu)';
$task_text = $this -> prepareImportedTaskText( $content['text'] );
}
}
else
{
// Normalne parsowanie
$task_name = trim( $subject ) !== '' ? trim( $subject ) : '(bez tematu)';
$task_text = $this -> prepareImportedTaskText( $content['text'] );
}
$client_id = $this -> resolveClientIdBySenderDomain( $sender );
$task_id = \factory\Tasks::task_save(
null,
null,
self::TASK_USER_ID,
$task_name,
$task_text,
$received_at,
$received_at,
self::TASK_PROJECT_ID,
$client_id,
null,
null,
'off',
0,
0,
[ self::TASK_USER_ID ],
null,
null,
false,
'off',
false,
self::TASK_STATUS_ID,
'on',
0
);
if ( !$task_id )
{
$db_error = $this -> mdb -> error();
$db_error_text = is_array( $db_error ) ? implode( ' | ', array_filter( $db_error ) ) : '';
$error_msg = 'Nie udalo sie utworzyc zadania.' . ( $db_error_text !== '' ? ' DB: ' . $db_error_text : '' );
$this -> saveImportLog( $message_key, null, $sender, $subject, 'error', $error_msg );
if ( $first_error_msg === null )
$first_error_msg = $error_msg;
$errors++;
continue;
}
foreach ( $content['attachments'] as $attachment )
{
$this -> attachments -> uploadFromContent( $task_id, self::TASK_USER_ID, $attachment['name'], $attachment['content'] );
}
$import_status = 'imported';
$import_error = null;
if ( $use_ai && $ai_used )
$import_status = 'imported_ai';
elseif ( $use_ai && !$ai_used )
{
$import_status = 'imported_fallback';
$import_error = $ai_error_for_log;
}
$this -> saveImportLog( $message_key, $task_id, $sender, $subject, $import_status, $import_error );
$imported++;
}
catch ( \Throwable $e )
{
$this -> saveImportLog( $message_key, null, $sender, $subject, 'error', $e -> getMessage() );
if ( $first_error_msg === null )
$first_error_msg = $e -> getMessage();
$errors++;
}
$status_tmp = $this -> getImportStatus( $message_key );
if ( in_array( $status_tmp, [ 'imported', 'imported_ai', 'imported_fallback', 'skipped_sender' ], true ) )
@imap_delete( $imap, $message_no );
}
@imap_expunge( $imap );
@imap_close( $imap );
if ( $imported > 0 )
return [ 'status' => 'ok', 'msg' => 'Zaimportowano: ' . $imported . ', pominieto: ' . $skipped . ', bledy: ' . $errors . '.' ];
if ( $errors > 0 )
{
$msg = 'Brak nowych zadan. Bledy: ' . $errors . '.';
if ( $first_error_msg )
$msg .= ' Ostatni blad: ' . $first_error_msg;
return [ 'status' => 'error', 'msg' => $msg ];
}
return [ 'status' => 'empty', 'msg' => 'Brak nowych zadan. Pominieto: ' . $skipped . '.' ];
}
private function buildMailbox( array $config )
{
$mailbox = isset( $config['mailbox'] ) ? trim( (string)$config['mailbox'] ) : '';
if ( $mailbox !== '' )
return $mailbox;
$host = isset( $config['host'] ) ? trim( (string)$config['host'] ) : '';
if ( $host === '' )
return '';
$port = isset( $config['port'] ) ? (int)$config['port'] : 993;
$flags = isset( $config['flags'] ) ? trim( (string)$config['flags'] ) : '/imap/ssl';
$folder = isset( $config['folder'] ) ? trim( (string)$config['folder'] ) : 'INBOX';
return '{' . $host . ':' . $port . $flags . '}' . $folder;
}
private function resolveClientIdBySenderDomain( $sender )
{
$domain = $this -> extractDomainFromEmail( $sender );
if ( $domain === '' )
return null;
$rows = $this -> mdb -> select( 'crm_client', [ 'id', 'emails' ], [
'emails[!]' => null
] );
if ( !is_array( $rows ) or !count( $rows ) )
return null;
foreach ( $rows as $row )
{
$emails = $this -> parseEmailsField( isset( $row['emails'] ) ? $row['emails'] : '' );
foreach ( $emails as $email )
{
$email_domain = $this -> extractDomainFromEmail( $email );
if ( $email_domain !== '' and $email_domain === $domain )
return (int)$row['id'];
}
}
return null;
}
private function parseEmailsField( $emails_raw )
{
$emails_raw = (string)$emails_raw;
if ( trim( $emails_raw ) === '' )
return [];
preg_match_all( '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', $emails_raw, $matches );
if ( isset( $matches[0] ) and is_array( $matches[0] ) and count( $matches[0] ) )
return array_values( array_unique( array_map( 'strtolower', $matches[0] ) ) );
return [];
}
private function extractDomainFromEmail( $email )
{
$email = strtolower( trim( (string)$email ) );
if ( $email === '' or strpos( $email, '@' ) === false )
return '';
$parts = explode( '@', $email );
$domain = trim( end( $parts ) );
return $domain !== '' ? $domain : '';
}
private function parseReceivedDate( $value )
{
$timestamp = strtotime( (string)$value );
if ( $timestamp === false )
return date( 'Y-m-d' );
return date( 'Y-m-d', $timestamp );
}
private function extractSender( $header )
{
if ( !isset( $header -> from ) or !is_array( $header -> from ) or !isset( $header -> from[0] ) )
return '';
$item = $header -> from[0];
$mailbox = isset( $item -> mailbox ) ? strtolower( trim( $item -> mailbox ) ) : '';
$host = isset( $item -> host ) ? strtolower( trim( $item -> host ) ) : '';
if ( $mailbox === '' or $host === '' )
return '';
return $mailbox . '@' . $host;
}
private function decodeHeaderValue( $value )
{
$value = (string)$value;
if ( $value === '' )
return '';
$chunks = @imap_mime_header_decode( $value );
if ( !is_array( $chunks ) or !count( $chunks ) )
return trim( $value );
$decoded = '';
foreach ( $chunks as $chunk )
$decoded .= isset( $chunk -> text ) ? $chunk -> text : '';
return trim( $decoded );
}
private function messageKey( $header, $sender, $subject, $message_no )
{
$message_id = '';
if ( isset( $header -> message_id ) and trim( (string)$header -> message_id ) !== '' )
$message_id = trim( (string)$header -> message_id );
if ( $message_id === '' )
$message_id = sha1( $sender . '|' . $subject . '|' . ( isset( $header -> date ) ? $header -> date : '' ) . '|' . $message_no );
return $message_id;
}
private function isMessageFinalized( $message_key )
{
$status = $this -> getImportStatus( $message_key );
return in_array( $status, [ 'imported', 'imported_ai', 'imported_fallback', 'skipped_sender' ], true );
}
private function getImportStatus( $message_key )
{
return $this -> mdb -> get( 'tasks_mail_import', 'status', [ 'message_key' => (string)$message_key ] );
}
private function saveImportLog( $message_key, $task_id, $sender, $subject, $status, $error )
{
$data = [
'task_id' => $task_id ? (int)$task_id : null,
'sender' => substr( (string)$sender, 0, 255 ),
'subject' => substr( (string)$subject, 0, 255 ),
'status' => substr( (string)$status, 0, 40 ),
'error_msg' => $error !== null ? (string)$error : null,
'date_add' => date( 'Y-m-d H:i:s' )
];
if ( $this -> mdb -> count( 'tasks_mail_import', [ 'message_key' => (string)$message_key ] ) )
$this -> mdb -> update( 'tasks_mail_import', $data, [ 'message_key' => (string)$message_key ] );
else
$this -> mdb -> insert( 'tasks_mail_import', array_merge( [ 'message_key' => (string)$message_key ], $data ) );
}
private function ensureImportTable()
{
$this -> mdb -> query(
'CREATE TABLE IF NOT EXISTS `tasks_mail_import` (
`id` INT NOT NULL AUTO_INCREMENT,
`message_key` VARCHAR(255) NOT NULL,
`task_id` INT NULL,
`sender` VARCHAR(255) NULL,
`subject` VARCHAR(255) NULL,
`status` VARCHAR(40) NOT NULL,
`error_msg` TEXT NULL,
`date_add` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tasks_mail_import_message_key` (`message_key`),
INDEX `idx_tasks_mail_import_task` (`task_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
);
}
private function extractMessageContent( $imap, $message_no )
{
$structure = @imap_fetchstructure( $imap, $message_no );
$parts = [];
$this -> flattenParts( $imap, $message_no, $structure, '', $parts );
$plain = '';
$html = '';
$attachment_candidates = [];
foreach ( $parts as $part )
{
if ( $part['mime'] === 'text/plain' and $plain === '' )
$plain = $part['body'];
if ( $part['mime'] === 'text/html' and $html === '' )
$html = $part['body'];
if ( $part['is_attachment'] )
$attachment_candidates[] = $part;
}
$cid_refs = $this -> extractReferencedCidValues( $html );
$attachments = [];
foreach ( $attachment_candidates as $part )
{
if ( !$this -> shouldImportAttachment( $part, $cid_refs ) )
continue;
$attachments[] = [
'name' => $part['name'],
'content' => $part['body']
];
}
$text = $plain !== '' ? $plain : $this -> htmlToText( $html );
$text = $this -> cleanBodyText( $text );
return [
'text' => $text,
'attachments' => $attachments
];
}
private function flattenParts( $imap, $message_no, $structure, $prefix, array &$parts )
{
if ( !$structure )
{
$parts[] = [
'mime' => 'text/plain',
'body' => '',
'name' => '',
'is_attachment' => false,
'is_inline' => false
];
return;
}
if ( !isset( $structure -> parts ) or !is_array( $structure -> parts ) or !count( $structure -> parts ) )
{
$part_number = $prefix !== '' ? $prefix : null;
$parts[] = $this -> parseSinglePart( $imap, $message_no, $structure, $part_number );
return;
}
foreach ( $structure -> parts as $index => $subpart )
{
$part_number = $prefix === '' ? (string)( $index + 1 ) : $prefix . '.' . ( $index + 1 );
if ( isset( $subpart -> parts ) and is_array( $subpart -> parts ) and count( $subpart -> parts ) )
$this -> flattenParts( $imap, $message_no, $subpart, $part_number, $parts );
else
$parts[] = $this -> parseSinglePart( $imap, $message_no, $subpart, $part_number );
}
}
private function parseSinglePart( $imap, $message_no, $part, $part_number )
{
$raw = '';
if ( $part_number === null )
$raw = (string)@imap_body( $imap, $message_no, FT_PEEK );
else
$raw = (string)@imap_fetchbody( $imap, $message_no, $part_number, FT_PEEK );
$mime = $this -> mimeType( $part );
$decoded = $this -> decodePartBody( $raw, isset( $part -> encoding ) ? (int)$part -> encoding : 0 );
if ( strpos( $mime, 'text/' ) === 0 )
$decoded = str_replace( [ "\r\n", "\r" ], "\n", (string)$decoded );
$params = $this -> partParams( $part );
$name = '';
if ( isset( $params['filename'] ) )
$name = $params['filename'];
elseif ( isset( $params['name'] ) )
$name = $params['name'];
$name = $this -> decodeHeaderValue( $name );
$disposition = isset( $part -> disposition ) ? strtoupper( trim( (string)$part -> disposition ) ) : '';
$content_id = '';
if ( isset( $part -> id ) and trim( (string)$part -> id ) !== '' )
$content_id = $this -> normalizeContentId( (string)$part -> id );
elseif ( isset( $params['content-id'] ) and trim( (string)$params['content-id'] ) !== '' )
$content_id = $this -> normalizeContentId( (string)$params['content-id'] );
$is_inline = $disposition === 'INLINE' or $content_id !== '';
$is_attachment = trim( $name ) !== '' or $disposition === 'ATTACHMENT';
return [
'mime' => $mime,
'body' => $decoded,
'name' => $name,
'is_attachment' => $is_attachment,
'is_inline' => $is_inline,
'disposition' => $disposition,
'content_id' => $content_id
];
}
private function partParams( $part )
{
$params = [];
if ( isset( $part -> parameters ) and is_array( $part -> parameters ) )
{
foreach ( $part -> parameters as $param )
{
if ( isset( $param -> attribute ) )
$params[ strtolower( (string)$param -> attribute ) ] = isset( $param -> value ) ? (string)$param -> value : '';
}
}
if ( isset( $part -> dparameters ) and is_array( $part -> dparameters ) )
{
foreach ( $part -> dparameters as $param )
{
if ( isset( $param -> attribute ) )
$params[ strtolower( (string)$param -> attribute ) ] = isset( $param -> value ) ? (string)$param -> value : '';
}
}
return $params;
}
private function decodePartBody( $raw, $encoding )
{
if ( $encoding === 3 )
return (string)base64_decode( $raw );
if ( $encoding === 4 )
return (string)quoted_printable_decode( $raw );
return (string)$raw;
}
private function mimeType( $part )
{
$primary = [
0 => 'text',
1 => 'multipart',
2 => 'message',
3 => 'application',
4 => 'audio',
5 => 'image',
6 => 'video',
7 => 'other'
];
$type = isset( $part -> type ) ? (int)$part -> type : 0;
$subtype = isset( $part -> subtype ) ? strtolower( (string)$part -> subtype ) : 'plain';
return ( isset( $primary[$type] ) ? $primary[$type] : 'text' ) . '/' . $subtype;
}
private function htmlToText( $html )
{
$html = (string)$html;
if ( trim( $html ) === '' )
return '';
$html = preg_replace( '/<style\b[^>]*>.*?<\/style>/is', ' ', $html );
$html = preg_replace( '/<script\b[^>]*>.*?<\/script>/is', ' ', $html );
$html = preg_replace( '/<blockquote\b[^>]*>.*?<\/blockquote>/is', ' ', $html );
$html = preg_replace( '/<div[^>]*class="[^"]*(gmail_quote|gmail_signature)[^"]*"[^>]*>.*?<\/div>/is', ' ', $html );
$html = preg_replace( '/<br\s*\/?>/i', "\n", $html );
$html = preg_replace( '/<\/(p|div|li|tr|h[1-6])>/i', "\n", $html );
$text = html_entity_decode( strip_tags( $html ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
return $text;
}
private function cleanBodyText( $text )
{
$text = str_replace( [ "\r\n", "\r" ], "\n", (string)$text );
$text = preg_replace( '/\x{00a0}/u', ' ', $text );
$lines = explode( "\n", $text );
$clean = [];
foreach ( $lines as $line )
{
$line_trim = rtrim( $line );
if ( preg_match( '/^\s*>+/', $line_trim ) )
break;
if ( preg_match( '/^\s*On\s.+wrote:\s*$/i', $line_trim ) )
break;
if ( preg_match( '/^\s*W\s+dniu\s+.+napisal\(a\):\s*$/iu', $line_trim ) )
break;
if ( preg_match( '/^\s*-----Original Message-----\s*$/i', $line_trim ) )
break;
if ( preg_match( '/^\s*Od:\s.+$/iu', $line_trim ) )
break;
if ( preg_match( '/^\s*From:\s.+$/i', $line_trim ) )
break;
if ( preg_match( '/^\s*Sent:\s.+$/i', $line_trim ) )
break;
if ( preg_match( '/^\s*--\s*$/', $line_trim ) )
break;
$clean[] = $line_trim;
}
$text = trim( implode( "\n", $clean ) );
return trim( $text );
}
private function prepareImportedTaskText( $text )
{
$text = trim( (string)$text );
if ( $text === '' )
return '(brak tresci)';
if ( preg_match( '/<\s*[a-z][^>]*>/i', $text ) )
{
$text = $this -> htmlToText( $text );
$text = $this -> cleanBodyText( $text );
}
$text = str_replace( [ "\r\n", "\r" ], "\n", $text );
$text = trim( $text );
if ( $text === '' )
return '(brak tresci)';
return nl2br( $text );
}
private function shouldImportAttachment( array $part, array $cid_refs )
{
$name = trim( isset( $part['name'] ) ? (string)$part['name'] : '' );
if ( $name === '' )
return false;
$mime = strtolower( isset( $part['mime'] ) ? (string)$part['mime'] : '' );
$disposition = strtoupper( isset( $part['disposition'] ) ? (string)$part['disposition'] : '' );
$content_id = $this -> normalizeContentId( isset( $part['content_id'] ) ? (string)$part['content_id'] : '' );
if ( !empty( $part['is_inline'] ) )
return false;
if ( $mime === 'text/plain' or $mime === 'text/html' )
return false;
if ( $content_id !== '' and isset( $cid_refs[$content_id] ) )
return false;
if ( strpos( $mime, 'image/' ) === 0 and $disposition !== 'ATTACHMENT' )
return false;
return true;
}
private function extractReferencedCidValues( $html )
{
$html = (string)$html;
if ( trim( $html ) === '' )
return [];
preg_match_all( '/cid:([^"\'>\s]+)/i', $html, $matches );
if ( !isset( $matches[1] ) or !is_array( $matches[1] ) or !count( $matches[1] ) )
return [];
$refs = [];
foreach ( $matches[1] as $raw )
{
$cid = $this -> normalizeContentId( $raw );
if ( $cid !== '' )
$refs[$cid] = true;
}
return $refs;
}
private function normalizeContentId( $value )
{
$value = trim( (string)$value );
if ( $value === '' )
return '';
$value = trim( $value, '<>' );
return strtolower( $value );
}
private function parseWithAI( $api_key, $model, $subject, $raw_content, $attempt = 1 )
{
$this -> last_ai_error = '';
$api_key = trim( (string)$api_key );
$subject = trim( (string)$subject );
$raw_content = trim( (string)$raw_content );
$model = trim( (string)$model );
if ( $api_key === '' )
{
$this -> last_ai_error = 'Brak klucza API OpenAI.';
return null;
}
if ( $model === '' )
$model = 'gpt-4o-mini';
if ( $raw_content === '' )
{
$this -> last_ai_error = 'Pusta tresc emaila do analizy AI.';
return null;
}
if ( !function_exists( 'curl_init' ) )
{
$this -> last_ai_error = 'Brak rozszerzenia cURL.';
return null;
}
// Przygotuj treść do analizy (usuń HTML jesli jest)
if ( preg_match( '/<\s*[a-z][^>]*>/i', $raw_content ) )
{
$raw_content = $this -> htmlToText( $raw_content );
$raw_content = $this -> cleanBodyText( $raw_content );
}
$content_limit = $attempt > 1 ? 1200 : 1800;
$prompt = "Jesteś asystentem do przetwarzania emaili na zadania w systemie CRM. " .
"Na podstawie poniższego tematu i treści emaila wygeneruj zadanie wdrożeniowe, NIE podsumowanie. " .
"Opis ma być szczegółową listą rzeczy do wykonania dla zespołu.\n\n" .
"Wymagania:\n" .
"- Nie pomijaj żadnych wymagań, liczb, zakresów cen, CTA, pól formularza, kroków i terminów.\n" .
"- Zamień treść emaila na checklistę implementacyjną.\n" .
"- Zachowaj strukturę sekcji i podsekcji.\n" .
"- Pisz konkretnie co zmienić, gdzie zmienić i jakie treści dodać.\n" .
"- Nie używaj ogólników typu: \"zaktualizować stronę\".\n\n" .
"Format pola task_text:\n" .
"1) Cel zmiany\n" .
"2) Zakres zmian (lista punktów)\n" .
"3) Treści do wstawienia (dokładne teksty/liczby)\n" .
"4) Zmiany formularza\n" .
"5) Wersja sezonowa / warunki wyświetlania\n" .
"6) CTA i bonusy\n" .
"7) Kryteria akceptacji\n\n" .
"Temat emaila: " . $subject . "\n\n" .
"Treść emaila:\n" . mb_substr( $raw_content, 0, $content_limit ) . "\n\n" .
"Zwróć TYLKO poprawny JSON:\n" .
'{"task_name": "krótki temat zadania", "task_text": "szczegółowa checklista wdrożeniowa"}';
$payload = [
'model' => $model,
'messages' => [
[
'role' => 'system',
'content' => 'Jesteś asystentem do przetwarzania emaili. Odpowiadasz TYLKO w formacie JSON i tworzysz szczegółowe checklisty wdrożeniowe, nie skróty.'
],
[
'role' => 'user',
'content' => $prompt
]
]
];
if ( stripos( $model, 'gpt-5' ) === 0 )
$payload['response_format'] = [ 'type' => 'json_object' ];
if ( stripos( $model, 'gpt-5' ) === 0 )
$payload['max_completion_tokens'] = $attempt > 1 ? 1600 : 1000;
else
{
$payload['temperature'] = 0.3;
$payload['max_tokens'] = 1200;
}
$ch = curl_init( 'https://api.openai.com/v1/chat/completions' );
curl_setopt_array( $ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode( $payload ),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $api_key
],
CURLOPT_TIMEOUT => 30
] );
$response = curl_exec( $ch );
$curl_error = curl_error( $ch );
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
curl_close( $ch );
if ( $response === false )
{
$this -> last_ai_error = 'Blad cURL: ' . $curl_error;
return null;
}
if ( $http_code !== 200 )
{
$this -> last_ai_error = 'HTTP ' . $http_code . ' z OpenAI: ' . $this -> extractApiErrorMessage( $response );
return null;
}
if ( !$response )
{
$this -> last_ai_error = 'Pusta odpowiedz z OpenAI.';
return null;
}
$data = json_decode( $response, true );
if ( !is_array( $data ) )
{
$this -> last_ai_error = 'Niepoprawny JSON z OpenAI: ' . json_last_error_msg();
return null;
}
if ( isset( $data['error']['message'] ) )
{
$this -> last_ai_error = 'OpenAI error: ' . (string)$data['error']['message'];
return null;
}
if ( !isset( $data['choices'][0] ) || !is_array( $data['choices'][0] ) )
{
$this -> last_ai_error = 'Brak choices[0] w odpowiedzi OpenAI. RAW: ' . $this -> clipForLog( $response );
return null;
}
$choice = $data['choices'][0];
$content = $this -> extractModelMessageContent( $choice );
if ( $content === '' )
{
$finish_reason = isset( $choice['finish_reason'] ) ? (string)$choice['finish_reason'] : '(brak)';
$refusal = isset( $choice['message']['refusal'] ) ? (string)$choice['message']['refusal'] : '';
if ( $finish_reason === 'length' && (int)$attempt === 1 )
{
$retry_content = mb_substr( $raw_content, 0, 1200 );
return $this -> parseWithAI( $api_key, $model, $subject, $retry_content, 2 );
}
$error = 'Pusta tresc odpowiedzi modelu. finish_reason=' . $finish_reason;
if ( $refusal !== '' )
$error .= ', refusal=' . $this -> clipForLog( $refusal );
$this -> last_ai_error = $error . '. RAW: ' . $this -> clipForLog( $response );
return null;
}
// Usuń markdown code block jeśli jest
$content = preg_replace( '/```json\s*|\s*```/', '', $content );
$content = trim( $content );
// Jeśli model zwrócił JSON z komentarzem wokół, wyciągnij tylko obiekt.
$json_start = strpos( $content, '{' );
$json_end = strrpos( $content, '}' );
if ( $json_start !== false && $json_end !== false && $json_end > $json_start )
$content = substr( $content, $json_start, $json_end - $json_start + 1 );
$parsed = json_decode( $content, true );
if ( !is_array( $parsed ) || !isset( $parsed['task_name'] ) || !isset( $parsed['task_text'] ) )
{
$this -> last_ai_error = 'Niepoprawny JSON z modelu. Odpowiedz: ' . $this -> clipForLog( $content );
return null;
}
$task_name = $this -> normalizeAiTextValue( $parsed['task_name'] );
$task_text = $this -> normalizeAiTextValue( $parsed['task_text'] );
if ( $task_name === '' )
$task_name = '(bez tematu)';
if ( $task_text === '' )
$task_text = '(brak treści)';
// Formatuj treść zadania z nl2br
$task_text = nl2br( $task_text );
return [
'task_name' => $task_name,
'task_text' => $task_text
];
}
private function extractApiErrorMessage( $response )
{
$data = json_decode( (string)$response, true );
if ( is_array( $data ) && isset( $data['error']['message'] ) )
return (string)$data['error']['message'];
return $this -> clipForLog( $response );
}
private function extractModelMessageContent( array $choice )
{
if ( isset( $choice['message']['content'] ) && is_string( $choice['message']['content'] ) )
return trim( $choice['message']['content'] );
if ( isset( $choice['message']['content'] ) && is_array( $choice['message']['content'] ) )
{
$chunks = [];
foreach ( $choice['message']['content'] as $part )
{
if ( is_string( $part ) )
$chunks[] = $part;
elseif ( is_array( $part ) )
{
if ( isset( $part['text'] ) && is_string( $part['text'] ) )
$chunks[] = $part['text'];
elseif ( isset( $part['text']['value'] ) && is_string( $part['text']['value'] ) )
$chunks[] = $part['text']['value'];
}
}
return trim( implode( "\n", $chunks ) );
}
if ( isset( $choice['message']['tool_calls'][0]['function']['arguments'] ) && is_string( $choice['message']['tool_calls'][0]['function']['arguments'] ) )
return trim( $choice['message']['tool_calls'][0]['function']['arguments'] );
if ( isset( $choice['message']['function_call']['arguments'] ) && is_string( $choice['message']['function_call']['arguments'] ) )
return trim( $choice['message']['function_call']['arguments'] );
return '';
}
private function clipForLog( $value, $limit = 600 )
{
$text = trim( (string)$value );
if ( $text === '' )
return '';
return mb_substr( $text, 0, (int)$limit );
}
private function normalizeAiTextValue( $value )
{
if ( is_string( $value ) )
return trim( $value );
if ( is_numeric( $value ) || is_bool( $value ) )
return trim( (string)$value );
if ( is_array( $value ) )
{
$lines = [];
foreach ( $value as $item )
{
$item_text = $this -> normalizeAiTextValue( $item );
if ( $item_text !== '' )
$lines[] = $item_text;
}
return trim( implode( "\n", $lines ) );
}
if ( is_object( $value ) )
{
$array_value = json_decode( json_encode( $value ), true );
if ( is_array( $array_value ) )
{
$normalized = $this -> normalizeAiTextValue( $array_value );
if ( $normalized !== '' )
return $normalized;
}
return trim( json_encode( $value ) );
}
return '';
}
}