diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json index e35e7e6..fc542cc 100644 --- a/.vscode/ftp-kr.sync.cache.json +++ b/.vscode/ftp-kr.sync.cache.json @@ -40,6 +40,92 @@ "lmtime": 0, "modified": false } + }, + "Users": { + "UserRepository.php": { + "type": "-", + "size": 610, + "lmtime": 1770653480229, + "modified": false + } + } + }, + "factory": { + "class.BackendSites.php": { + "type": "-", + "size": 2962, + "lmtime": 0, + "modified": false + }, + "class.Crm.php": { + "type": "-", + "size": 1863, + "lmtime": 0, + "modified": false + }, + "class.Cron.php": { + "type": "-", + "size": 26120, + "lmtime": 0, + "modified": false + }, + "class.Finances.php": { + "type": "-", + "size": 16159, + "lmtime": 0, + "modified": false + }, + "class.Projects.php": { + "type": "-", + "size": 27476, + "lmtime": 0, + "modified": false + }, + "class.Tasks.php": { + "type": "-", + "size": 20921, + "lmtime": 0, + "modified": false + }, + "class.Users.php": { + "type": "-", + "size": 2072, + "lmtime": 1770654026945, + "modified": false + }, + "class.Wiki.php": { + "type": "-", + "size": 1911, + "lmtime": 0, + "modified": false + } + }, + "Controllers": { + "class.TasksController.php": { + "type": "-", + "size": 567, + "lmtime": 0, + "modified": false + }, + "TasksController.php": { + "type": "-", + "size": 3009, + "lmtime": 0, + "modified": false + }, + "UsersController.php": { + "type": "-", + "size": 3914, + "lmtime": 1770653696575, + "modified": false + } + }, + "controls": { + "class.Users.php": { + "type": "-", + "size": 4242, + "lmtime": 1770653518273, + "modified": false } } }, @@ -57,6 +143,12 @@ "modified": false } }, + "CODE_INDEX.md": { + "type": "-", + "size": 16884, + "lmtime": 1770652965090, + "modified": false + }, "config.php": { "type": "-", "size": 1249, @@ -140,9 +232,74 @@ "lmtime": 0, "modified": false } + }, + "users": { + "login-form.php": { + "type": "-", + "size": 3336, + "lmtime": 0, + "modified": false + }, + "main-view.php": { + "type": "-", + "size": 2012, + "lmtime": 1770653623175, + "modified": false + }, + "settings.php": { + "type": "-", + "size": 3457, + "lmtime": 0, + "modified": false + } + }, + "site": { + "layout-cron.php": { + "type": "-", + "size": 6369, + "lmtime": 1770653884637, + "modified": false + }, + "layout-logged.php": { + "type": "-", + "size": 6762, + "lmtime": 1770653877600, + "modified": false + }, + "layout-unlogged.php": { + "type": "-", + "size": 986, + "lmtime": 0, + "modified": false + } + } + }, + "tests": { + "Controllers": { + "UsersControllerTest.php": { + "type": "-", + "size": 1839, + "lmtime": 1770653599232, + "modified": false + } + }, + "Domain": { + "Users": { + "UserRepositoryTest.php": { + "type": "-", + "size": 1540, + "lmtime": 1770653587539, + "modified": false + } + } + }, + "run.php": { + "type": "-", + "size": 851, + "lmtime": 1770653605021, + "modified": false } }, - "tests": {}, "tmp_debug_mail_import.php": { "type": "-", "size": 1238, diff --git a/autoload/Domain/Tasks/MailToTaskImporter.php b/autoload/Domain/Tasks/MailToTaskImporter.php index 0a34310..7e92549 100644 --- a/autoload/Domain/Tasks/MailToTaskImporter.php +++ b/autoload/Domain/Tasks/MailToTaskImporter.php @@ -10,6 +10,7 @@ class MailToTaskImporter private $mdb; private $attachments; + private $last_ai_error; public function __construct( $mdb = null ) { @@ -22,6 +23,7 @@ class MailToTaskImporter } $this -> attachments = new TaskAttachmentRepository( $this -> mdb ); + $this -> last_ai_error = ''; } public function importFromImap( array $config ) @@ -88,24 +90,38 @@ class MailToTaskImporter 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-3.5-turbo'; + $model = isset( $settings['openai_model'] ) ? trim( (string)$settings['openai_model'] ) : 'gpt-4o-mini'; $ai_result = $this -> parseWithAI( $api_key, $model, $subject, $content['text'] ); - if ( $ai_result !== null ) + 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'] ); } @@ -161,7 +177,17 @@ class MailToTaskImporter $this -> attachments -> uploadFromContent( $task_id, self::TASK_USER_ID, $attachment['name'], $attachment['content'] ); } - $this -> saveImportLog( $message_key, $task_id, $sender, $subject, 'imported', null ); + $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 ) @@ -173,7 +199,7 @@ class MailToTaskImporter } $status_tmp = $this -> getImportStatus( $message_key ); - if ( in_array( $status_tmp, [ 'imported', 'skipped_sender' ], true ) ) + if ( in_array( $status_tmp, [ 'imported', 'imported_ai', 'imported_fallback', 'skipped_sender' ], true ) ) @imap_delete( $imap, $message_no ); } @@ -318,7 +344,7 @@ class MailToTaskImporter private function isMessageFinalized( $message_key ) { $status = $this -> getImportStatus( $message_key ); - return in_array( $status, [ 'imported', 'skipped_sender' ], true ); + return in_array( $status, [ 'imported', 'imported_ai', 'imported_fallback', 'skipped_sender' ], true ); } private function getImportStatus( $message_key ) @@ -675,14 +701,34 @@ class MailToTaskImporter return strtolower( $value ); } - private function parseWithAI( $api_key, $model, $subject, $raw_content ) + 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-3.5-turbo'; + $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 ) ) @@ -691,31 +737,54 @@ class MailToTaskImporter $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:\n" . - "1. Zwięzły, konkretny temat zadania (max 100 znaków)\n" . - "2. Czytelny opis zadania zawierający najważniejsze informacje\n\n" . + "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, 2000 ) . "\n\n" . - "Odpowiedz TYLKO w formacie JSON bez żadnych dodatkowych wyjaśnień:\n" . - '{"task_name": "temat zadania", "task_text": "opis zadania"}'; + "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.' + '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 ] - ], - 'temperature' => 0.3, - 'max_tokens' => 500 + ] ]; + 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, @@ -729,28 +798,87 @@ class MailToTaskImporter ] ); $response = curl_exec( $ch ); + $curl_error = curl_error( $ch ); $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); curl_close( $ch ); - if ( $http_code !== 200 || !$response ) + 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 ( !isset( $data['choices'][0]['message']['content'] ) ) + if ( !is_array( $data ) ) + { + $this -> last_ai_error = 'Niepoprawny JSON z OpenAI: ' . json_last_error_msg(); return null; + } - $content = trim( $data['choices'][0]['message']['content'] ); + 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 = trim( (string)$parsed['task_name'] ); - $task_text = trim( (string)$parsed['task_text'] ); + $task_name = $this -> normalizeAiTextValue( $parsed['task_name'] ); + $task_text = $this -> normalizeAiTextValue( $parsed['task_text'] ); if ( $task_name === '' ) $task_name = '(bez tematu)'; @@ -766,4 +894,92 @@ class MailToTaskImporter '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 ''; + } } diff --git a/autoload/factory/class.Tasks.php b/autoload/factory/class.Tasks.php index fc8fe6d..9002811 100644 --- a/autoload/factory/class.Tasks.php +++ b/autoload/factory/class.Tasks.php @@ -58,6 +58,7 @@ class Tasks static public function get_tasks_gantt( $user_id, $projects = null, $users = null ) { global $mdb; + $data = []; if ( $users ) { $sql = ' AND id IN (SELECT task_id FROM task_user WHERE user_id IN (' . implode( ',', $users ) . ')) '; @@ -95,6 +96,7 @@ class Tasks // custom class // if ( $task['date_start'] <= date( 'Y-m-d H:i:s' ) ) // $task_json['custom_class'] = 'gantt-task-backlog'; + $task_json['custom_class'] = ''; if ( $task['parent_id'] ) $task_json['dependencies'] = $task['parent_id']; diff --git a/config.php b/config.php index 8937d0d..74f8faa 100644 --- a/config.php +++ b/config.php @@ -22,4 +22,4 @@ $settings['tasks_auto_start_timer'] = false; // OpenAI ChatGPT API configuration for email task parsing $settings['openai_api_key'] = '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'; // Wklej tutaj swój klucz API OpenAI $settings['openai_parse_emails'] = true; // true = użyj AI do parsowania emaili, false = normalne parsowanie -$settings['openai_model'] = 'gpt-3.5-turbo'; // Model: gpt-3.5-turbo (najtańszy), gpt-4o-mini, gpt-4o, itp. +$settings['openai_model'] = 'gpt-4o-mini'; // Model: gpt-4o-mini, gpt-4o, gpt-5-nano, itp. diff --git a/templates/tasks/main_view.php b/templates/tasks/main_view.php index 9ee4bed..5df726c 100644 --- a/templates/tasks/main_view.php +++ b/templates/tasks/main_view.php @@ -156,11 +156,13 @@ } ?> ]; - if ( tasks.length <= 0 ) { - tasks = [ + + function getEmptyGanttTasks() + { + return [ { - start: new Date().toISOString().split('T')[0], - end: new Date().toISOString().split('T')[0], + start: new Date().toISOString().split( 'T' )[0], + end: new Date().toISOString().split( 'T' )[0], name: "Brak zadań do wyświetlenia", id: "0", progress: 100, @@ -169,6 +171,29 @@ } ]; } + + function normalizeGanttTasks( tasksData ) + { + if ( Array.isArray( tasksData ) && tasksData.length > 0 ) + return tasksData; + + return getEmptyGanttTasks(); + } + + function getSelectedTaskFilters() + { + return { + projects: jQuery( 'input[name="projects"].g-checkbox:checked' ).map(function() { + return this.value; + }).get(), + users: jQuery( 'input[name="users"].g-checkbox:checked' ).map(function() { + return this.value; + }).get() + }; + } + + tasks = normalizeGanttTasks( tasks ); + var gantt_chart = new Gantt(".gantt-target", tasks, { on_click: function (task) { console.log(task); @@ -234,8 +259,7 @@ $( '.tasks_container .tasks_suspended ul' ).empty().append( data.tasks_suspended ); $( '.tasks_container .tasks_to_do ul' ).empty().append( data.tasks_to_do ); $( '.tasks_container .tasks_fvat ul' ).empty().append( data.tasks_fvat ); - if ( data.tasks_gantt ) - gantt_chart.refresh( data.tasks_gantt ); + gantt_chart.refresh( normalizeGanttTasks( data.tasks_gantt ) ); } }); } @@ -1156,10 +1180,8 @@ var data = jQuery.parseJSON( response ); if ( data.status == 'success' ) { - var checkedVals = jQuery( 'input.g-checkbox:checked' ).map(function() { - return this.value; - }).get(); - reload_tasks( checkedVals ); + var selectedFilters = getSelectedTaskFilters(); + reload_tasks( selectedFilters.projects, selectedFilters.users ); close_task_popup(); } }