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
This commit is contained in:
2026-02-09 23:34:50 +01:00
parent 304c87f933
commit 482ca312aa
5 changed files with 431 additions and 34 deletions

View File

@@ -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,

View File

@@ -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 '';
}
}

View File

@@ -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'];

View File

@@ -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.

View File

@@ -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&#324; do wy&#347;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();
}
}