PDO::ERRMODE_EXCEPTION]); } catch (PDOException $e) { logMessage("Ошибка подключения к БД: " . $e->getMessage()); die("Ошибка подключения к БД"); } function logMessage($message) { if (!is_dir('logs')) { mkdir('logs', 0777, true); } file_put_contents(LOG_FILE, date('Y-m-d H:i:s') . " - " . $message . "\n", FILE_APPEND | LOCK_EX); } /* ===================== Основной скрипт ===================== */ /** * Функция normalizeFileName нормализует имя файла, заменяя кириллические символы на латинские и удаляя специальные символы. */ function normalizeFileName($filePath) { $pathInfo = pathinfo($filePath); $fileName = $pathInfo['basename']; $transliterationTable = [ 'А' => 'A', 'Б' => 'B', 'В' => 'V', 'Г' => 'G', 'Д' => 'D', 'Е' => 'E', 'Ё' => 'E', 'Ж' => 'Zh', 'З' => 'Z', 'И' => 'I', 'Й' => 'Y', 'К' => 'K', 'Л' => 'L', 'М' => 'M', 'Н' => 'N', 'О' => 'O', 'П' => 'P', 'Р' => 'R', 'С' => 'S', 'Т' => 'T', 'У' => 'U', 'Ф' => 'F', 'Х' => 'Kh', 'Ц' => 'Ts', 'Ч' => 'Ch', 'Ш' => 'Sh', 'Щ' => 'Shch', 'Ы' => 'Y', 'Э' => 'E', 'Ю' => 'Yu', 'Я' => 'Ya', 'а' => 'a', 'б' => 'b', 'в' => 'v', 'г' => 'g', 'д' => 'd', 'е' => 'e', 'ё' => 'e', 'ж' => 'zh', 'з' => 'z', 'и' => 'i', 'й' => 'y', 'к' => 'k', 'л' => 'l', 'м' => 'm', 'н' => 'n', 'о' => 'o', 'п' => 'p', 'р' => 'r', 'с' => 's', 'т' => 't', 'у' => 'u', 'ф' => 'f', 'х' => 'kh', 'ц' => 'ts', 'ч' => 'ch', 'ш' => 'sh', 'щ' => 'shch', 'ы' => 'y', 'э' => 'e', 'ю' => 'yu', 'я' => 'ya' ]; $normalizedFileName = strtr($fileName, $transliterationTable); $normalizedFileName = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $normalizedFileName); return $pathInfo['dirname'] . DIRECTORY_SEPARATOR . $normalizedFileName; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? null; if (!$id) { logMessage("Ошибка: отсутствует ID документа"); die("Ошибка: отсутствует ID документа"); } logMessage("Начало обработки документа с ID: $id"); // Получение данных из CRM $documents = fetchDocumentData($pdo, $id); if (empty($documents)) { logMessage("Документы не найдены для ID: $id"); die("Документы не найдены для ID: $id"); } logMessage("Документы получены из БД: " . json_encode($documents, JSON_UNESCAPED_UNICODE)); // Нормализация имен файлов foreach ($documents as &$doc) { $doc['filepath'] = normalizeFileName($doc['filepath']); } // Получение путей файлов $filePaths = array_column($documents, 'filepath'); // Загрузка файлов в Vector Store и получение mapping (путь → file_id) $uploadResult = createVectorStoreAndUploadFiles($filePaths); if (!$uploadResult) { logMessage("Ошибка создания Vector Store или загрузки файлов"); die("Ошибка создания Vector Store или загрузки файлов"); } $vectorStoreId = $uploadResult['vectorStoreId']; $uploadedFileIds = $uploadResult['fileIds']; // Обновление ассистента с указанием векторного хранилища if (!updateAssistantWithVectorStore($vectorStoreId)) { logMessage("Ошибка обновления ассистента с Vector Store"); die("Ошибка обновления ассистента"); } // Анализ документов с учетом NSFW, OCR, Vision и знаний из базы $allResults = analyzeDocuments($documents, $uploadedFileIds); if (empty($allResults)) { logMessage("Ошибка: анализ документов не вернул результатов"); die("Ошибка: анализ документов не вернул результатов"); } // Формирование итогового отчета $report = generateReport($allResults); logMessage("Итоговый отчет:\n" . $report); echo $report; logMessage("Обработка всех документов завершена."); } else { logMessage("Ошибка: запрос должен быть POST"); die("Ошибка: запрос должен быть POST"); } /* ===================== Функции для работы с CRM и Vector Store ===================== */ function fetchDocumentData($pdo, $id) { logMessage("Получение данных документа из CRM по ID: $id"); $sql = " SELECT n.title, CASE WHEN a.storedname IS NOT NULL THEN CONCAT(a.path, a.attachmentsid, '_', a.storedname) ELSE CONCAT(a.path, a.attachmentsid, '_', a.name) END AS filepath FROM vtiger_senotesrel r LEFT JOIN vtiger_notes n ON n.notesid = r.notesid LEFT JOIN vtiger_crmentity e ON e.crmid = r.notesid LEFT JOIN vtiger_seattachmentsrel r2 ON r2.crmid = r.notesid LEFT JOIN vtiger_attachments a ON a.attachmentsid = r2.attachmentsid WHERE r.crmid = ? AND e.deleted = 0 AND (a.type = 'application/pdf' OR a.type = 'application/octet-stream') "; try { $stmt = $pdo->prepare($sql); $stmt->execute([$id]); $documents = $stmt->fetchAll(PDO::FETCH_ASSOC); logMessage("Документы получены из CRM: " . json_encode($documents, JSON_UNESCAPED_UNICODE)); return $documents; } catch (PDOException $e) { logMessage("Ошибка при выполнении запроса к CRM: " . $e->getMessage()); return []; } } function createVectorStoreAndUploadFiles($filePaths) { logMessage("Создание Vector Store и загрузка файлов..."); $vectorStoreId = createVectorStore(); if (!$vectorStoreId) return null; $uploadedFiles = []; // mapping: путь → file_id foreach ($filePaths as $filePath) { logMessage("Загрузка файла: $filePath"); if (!file_exists($filePath)) { logMessage("Ошибка: Файл не существует: $filePath"); continue; } $fileId = uploadFileToOpenAI($filePath); if (!$fileId) { logMessage("Ошибка загрузки файла: $filePath"); continue; } if (!addFileToVectorStore($vectorStoreId, $fileId)) { logMessage("Ошибка добавления файла в Vector Store: $filePath"); } else { logMessage("Файл успешно добавлен в Vector Store: $filePath"); $uploadedFiles[$filePath] = $fileId; } } return ['vectorStoreId' => $vectorStoreId, 'fileIds' => $uploadedFiles]; } function createVectorStore() { $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => OPENAI_VECTOR_STORES_API, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode(['name' => 'Vector Store']), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . OPENAI_API_KEY, 'OpenAI-Beta: assistants=v2' ] ]); $response = curl_exec($curl); $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); $curlError = curl_error($curl); curl_close($curl); if ($curlError) { logMessage("Ошибка cURL при создании Vector Store: " . $curlError); return null; } logMessage("Ответ OpenAI (создание Vector Store): HTTP $httpCode - " . $response); $decoded = json_decode($response, true); if ($httpCode !== 200 || !isset($decoded['id'])) { logMessage("Ошибка при создании Vector Store: " . json_encode($decoded, JSON_UNESCAPED_UNICODE)); return null; } return $decoded['id']; } function uploadFileToOpenAI($filePath) { logMessage("Загрузка файла в OpenAI: $filePath"); $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => OPENAI_FILES_API, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => [ 'file' => new CURLFile($filePath), 'purpose' => 'assistants' ], CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . OPENAI_API_KEY, 'OpenAI-Beta: assistants=v2' ] ]); $response = curl_exec($curl); $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); $curlError = curl_error($curl); curl_close($curl); if ($curlError) { logMessage("Ошибка cURL при загрузке файла: " . $curlError); return null; } logMessage("Ответ OpenAI (загрузка файла): HTTP $httpCode - " . $response); $decoded = json_decode($response, true); if ($httpCode !== 200 || !isset($decoded['id'])) { logMessage("Ошибка при загрузке файла: " . json_encode($decoded, JSON_UNESCAPED_UNICODE)); return null; } return $decoded['id']; } function addFileToVectorStore($vectorStoreId, $fileId) { $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => OPENAI_VECTOR_STORES_API . "/$vectorStoreId/files", CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode(['file_id' => $fileId]), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . OPENAI_API_KEY, 'OpenAI-Beta: assistants=v2' ] ]); $response = curl_exec($curl); $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); $curlError = curl_error($curl); curl_close($curl); if ($curlError) { logMessage("Ошибка cURL при добавлении файла в Vector Store: " . $curlError); return false; } logMessage("Ответ OpenAI (добавление файла): HTTP $httpCode - " . $response); $decoded = json_decode($response, true); if ($httpCode !== 200 || !isset($decoded['id'])) { logMessage("Ошибка добавления файла: " . json_encode($decoded, JSON_UNESCAPED_UNICODE)); return false; } return true; } function updateAssistantWithVectorStore($vectorStoreId) { $data = [ 'tool_resources' => [ 'file_search' => [ 'vector_store_ids' => [$vectorStoreId] ] ] ]; $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => OPENAI_ASSISTANT_API . "/" . ASSISTANT_ID, CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_POSTFIELDS => json_encode($data), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . OPENAI_API_KEY, 'OpenAI-Beta: assistants=v2' ] ]); $response = curl_exec($curl); $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); $curlError = curl_error($curl); curl_close($curl); if ($curlError) { logMessage("Ошибка обновления ассистента: " . $curlError); return false; } logMessage("Ответ OpenAI (обновление ассистента): HTTP $httpCode - " . $response); $decoded = json_decode($response, true); if ($httpCode !== 200 || !isset($decoded['id'])) { logMessage("Ошибка обновления ассистента: " . json_encode($decoded, JSON_UNESCAPED_UNICODE)); return false; } return true; } /* ===================== Логика анализа документов ===================== */ /** * Функция analyzeDocuments: * – Проверяет документ на NSFW через Vision. * – Если NSFW найден, помечает для ручной модерации. * – Иначе пытается извлечь текст (сначала с помощью встроенного извлечения, затем через OCR). * – Если текста нет, вызывает Vision для описания изображения. * – Получает контекст из базы знаний и отправляет данные в OpenAI Assistants для финального анализа. */ function analyzeDocuments($documents, $uploadedFileIds) { $results = []; foreach ($documents as $doc) { if (empty($doc['filepath']) || strpos($doc['filepath'], '_') === 0) { logMessage("Неверный путь: " . json_encode($doc, JSON_UNESCAPED_UNICODE)); continue; } // Инициализация переменных $isNSFW = false; $extractedText = ''; $imageDescription = ''; // 1. Проверка NSFW через Vision API $isNSFW = checkNSFWWithVision($doc['filepath']); if ($isNSFW === null) { $isNSFW = checkNSFWLocally($doc['filepath']); } if ($isNSFW) { logMessage("NSFW обнаружен: " . $doc['filepath']); $results[] = [ 'document' => $doc['title'], 'status' => 'NSFW', 'message' => 'Файл содержит NSFW-контент и отправлен на ручную модерацию.' ]; continue; } // 2. Попытка извлечь текст напрямую $extractedText = extractText($doc['filepath']); // 3. Если прямое извлечение не дало результата, запускаем OCR if (empty($extractedText)) { $extractedText = doOCR($doc['filepath']); } // 4. Если текста все равно нет – используем Vision для описания изображения if (empty($extractedText)) { $imageDescription = describeImageWithVision($doc['filepath']); } // 5. Получаем контекст из базы знаний $knowledgeContext = getKnowledgeBaseContext($doc['filepath']); // 6. Объединяем извлеченный текст, описание изображения и контекст $finalContent = $extractedText . "\n" . $imageDescription . "\n" . $knowledgeContext; // 7. Получаем file_id для привязки (если имеется) $fileId = $uploadedFileIds[$doc['filepath']] ?? ''; // 8. Анализируем документ через ассистента $threadId = createThread(); if (!$threadId) { logMessage("Ошибка создания треда для " . $doc['filepath']); continue; } $analysis = analyzeDocumentWithAssistant($threadId, ASSISTANT_ID, $fileId, $finalContent); if ($analysis) { logMessage("Анализ завершен: " . json_encode($analysis, JSON_UNESCAPED_UNICODE)); $results[] = [ 'document' => $doc['title'], 'status' => 'Анализ завершен', 'analysis' => $analysis ]; } else { logMessage("Ошибка анализа " . $doc['filepath']); $results[] = [ 'document' => $doc['title'], 'status' => 'Ошибка анализа', 'message' => 'Не удалось проанализировать документ.' ]; } } return $results; } /** * Функция extractText пытается извлечь текст напрямую (например, через pdftotext). * Если извлечение не удалось, возвращает пустую строку. */ function extractText($filePath) { $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); if ($extension !== 'pdf') { return ''; } $outputFile = tempnam(sys_get_temp_dir(), 'txt_') . '.txt'; $command = "pdftotext " . escapeshellarg($filePath) . " " . escapeshellarg($outputFile); exec($command, $output, $returnVar); if ($returnVar !== 0 || !file_exists($outputFile)) { logMessage("Ошибка извлечения текста из PDF: $filePath"); return ''; } $text = file_get_contents($outputFile); unlink($outputFile); return $text; } /** * Функция doOCR использует локальную OCR-систему (например, Tesseract). */ function doOCR($filePath) { logMessage("Запуск OCR для файла: $filePath"); $outputFile = tempnam(sys_get_temp_dir(), 'ocr_') . '.txt'; $command = "tesseract " . escapeshellarg($filePath) . " " . escapeshellarg($outputFile) . " -l rus"; exec($command, $output, $returnVar); if ($returnVar !== 0 || !file_exists($outputFile . ".txt")) { logMessage("Ошибка OCR для файла: $filePath"); return ''; } $text = file_get_contents($outputFile . ".txt"); unlink($outputFile . ".txt"); return $text; } /** * Функция describeImageWithVision вызывает OpenAI Vision для получения описания изображения. */ function describeImageWithVision($filePath) { logMessage("Запуск описания изображения через Vision для файла: $filePath"); // Читаем содержимое файла и кодируем его в base64 $imageData = base64_encode(file_get_contents($filePath)); $data = [ "model" => "gpt-4-vision-preview", "messages" => [ [ "role" => "user", "content" => [ [ "type" => "text", "text" => "Опиши это изображение подробно. Если это документ, прочитай и опиши его содержимое." ], [ "type" => "image_url", "image_url" => [ "url" => "data:image/jpeg;base64,$imageData" ] ] ] ] ], "max_tokens" => 500 ]; $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => "https://api.openai.com/v1/chat/completions", CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($data), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . OPENAI_API_KEY ] ]); $response = curl_exec($curl); $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); $curlError = curl_error($curl); curl_close($curl); if ($curlError) { logMessage("Ошибка cURL в описании изображения: " . $curlError); return ''; } logMessage("Ответ Vision (описание): HTTP $httpCode - " . $response); $decoded = json_decode($response, true); if ($httpCode === 403 && isset($decoded['error']['code']) && $decoded['error']['code'] === 'unsupported_country_region_territory') { logMessage("Ошибка при получении описания изображения: " . json_encode($decoded)); return 'Ошибка: Страна, регион или территория не поддерживаются.'; } if (isset($decoded['choices'][0]['message']['content'])) { return $decoded['choices'][0]['message']['content']; } else { logMessage("Ошибка при получении описания изображения: " . json_encode($decoded)); return ''; } } /** * Функция checkNSFWWithVision использует OpenAI Vision для анализа NSFW-контента. */ function checkNSFWWithVision($filePath) { logMessage("NSFW-проверка через стандартный Vision endpoint для файла: $filePath"); $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => OPENAI_VISION_API . "/analyze", CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => [ 'file' => new CURLFile($filePath) ], CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . OPENAI_API_KEY, 'OpenAI-Beta: vision' ] ]); $response = curl_exec($curl); $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); $curlError = curl_error($curl); curl_close($curl); if ($curlError) { logMessage("Ошибка cURL при проверке NSFW через Vision: " . $curlError); return null; } logMessage("Ответ Vision (анализ): HTTP $httpCode - " . $response); $decoded = json_decode($response, true); if ($httpCode !== 200 || isset($decoded['detail'])) { logMessage("Ошибка анализа NSFW через стандартный Vision endpoint: " . json_encode($decoded, JSON_UNESCAPED_UNICODE)); if (isset($decoded['detail']) && $decoded['detail'] === 'Provider not supported.') { return null; // Провайдер не поддерживается } return null; } return $decoded['nsfw'] ?? null; } function classifyImage($imagePath) { $absolutePath = realpath($imagePath); if (!$absolutePath) { logMessage("ERROR: Не удалось получить абсолютный путь для " . $imagePath); return []; } logMessage("DEBUG: Абсолютный путь для классификации: " . $absolutePath); $escapedPath = escapeshellarg($absolutePath); logMessage("DEBUG: Экранированный путь для классификации: " . $escapedPath); // Выполнение команды Python для запуска NudeClassifier $command = "python3 -c \"import json; from nudenet import NudeClassifier; classifier = NudeClassifier(); print(json.dumps(classifier.classify($escapedPath)))\""; logMessage("DEBUG: Выполнение команды: " . $command); $output = shell_exec($command); logMessage("DEBUG: Вывод команды: " . $output); if ($output === null) { logMessage("ERROR: shell_exec вернул null при выполнении NudeClassifier"); return []; } return json_decode(trim($output), true); } function checkNSFWLocally($filePath) { logMessage("Запуск локальной проверки NSFW для файла: $filePath"); $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); $imageToCheck = $filePath; if ($extension === 'pdf') { $outputImage = tempnam(sys_get_temp_dir(), 'pdf_img_') . '.png'; $command = "convert -density 150 " . escapeshellarg($filePath) . "[0] -quality 90 " . escapeshellarg($outputImage); exec($command, $output, $returnVar); if ($returnVar !== 0) { logMessage("Ошибка конвертации PDF в изображение для локальной NSFW проверки."); return null; } $imageToCheck = $outputImage; } // Используем функцию classifyImage для анализа изображения $classification = classifyImage($imageToCheck); if (empty($classification)) { logMessage("DEBUG: Нет данных проверки NSFW для изображения '$imageToCheck'."); return false; } $absImagePath = realpath($imageToCheck); if (isset($classification[$absImagePath])) { $unsafeProbability = $classification[$absImagePath]['unsafe'] ?? 0; logMessage("DEBUG: Для изображения '$absImagePath' получено unsafeProbability = " . $unsafeProbability); if ($unsafeProbability > 0.8) { logMessage("⚠️ Обнаружено NSFW-изображение: $absImagePath (unsafe = " . $unsafeProbability . ")"); return true; } else { logMessage("DEBUG: unsafeProbability для '$absImagePath' ниже порогового значения (0.8)."); } } else { logMessage("DEBUG: Классификатор не вернул данные для '$absImagePath'."); } return false; } /** * Stub-функция для получения контекста из базы знаний. */ function getKnowledgeBaseContext($filePath) { // Здесь можно реализовать поиск в базе знаний. // Пока возвращаем статическую информацию. return "Статическая информация: нормы и законы РФ, судебные прецеденты..."; } /** * Функция analyzeDocumentWithAssistant отправляет финальный запрос ассистенту. * Дополнительно передаёт извлечённый текст и контекст. */ function analyzeDocumentWithAssistant($threadId, $assistantId, $fileId, $content) { logMessage("Анализ документа: thread_id=$threadId, fileId=$fileId"); $messageContent = "Проанализируй документ"; if (!empty($fileId)) { $messageContent .= " (file_id: $fileId)"; } $messageContent .= ". Содержимое для анализа:\n" . $content; $messageData = [ 'role' => 'user', 'content' => $messageContent ]; // ... (остальной код остается без изменений до получения результата) logMessage("Ответ (сообщения): HTTP $httpCode - " . $response); $decodedMessages = json_decode($response, true); if ($httpCode !== 200 || !isset($decodedMessages['data'])) { logMessage("Ошибка получения сообщений: " . json_encode($decodedMessages, JSON_UNESCAPED_UNICODE)); return null; } // Извлекаем содержимое последнего сообщения ассистента $assistantMessage = $decodedMessages['data'][0]['content'][0]['text']['value'] ?? ''; $moderationVerdict = ""; // Попробуем найти строку с вердиктом if (preg_match('/Вердикт:\s*(Прошло модерацию|Не прошло модерацию)/ui', $assistantMessage, $matches)) { $moderationVerdict = trim($matches[1]); // Получаем сам текст вердикта } // Логируем извлеченный вердикт модерации logMessage("DEBUG: Извлеченный вердикт модерации: " . ($moderationVerdict ?: "Не найден")); $final_output = [ "status" => "complete", "content" => $assistantMessage, "moderationVerdict" => $moderationVerdict ]; logMessage("DEBUG: Извлеченный контент: " . $assistantMessage); logMessage("DEBUG: Извлеченный вердикт модерации: " . $moderationVerdict); logMessage("Результаты анализа: " . json_encode($final_output, JSON_UNESCAPED_UNICODE)); return $final_output; } /* ===================== Формирование отчета ===================== */ function generateReport($allResults) { if (empty($allResults)) { logMessage("Ошибка: Нет данных для отчета"); return "Ошибка: Нет данных для отчета"; } $report = "### Итоговый отчет по документам\n\n"; foreach ($allResults as $result) { $report .= "**Документ:** " . $result['document'] . "\n"; $report .= "**Статус:** " . $result['status'] . "\n"; if (isset($result['analysis'])) { $report .= "**Анализ:**\n"; if (isset($result['analysis']['nsfw_status'])) { $report .= "- NSFW статус: " . ($result['analysis']['nsfw_status'] ? "Обнаружен" : "Не обнаружен") . "\n"; } if (isset($result['analysis']['image_description'])) { $report .= "- Описание изображения: " . $result['analysis']['image_description'] . "\n"; } if (isset($result['analysis']['text_content'])) { $report .= "- Извлеченный текст: " . (strlen($result['analysis']['text_content']) > 100 ? substr($result['analysis']['text_content'], 0, 100) . "..." : $result['analysis']['text_content']) . "\n"; } if (isset($result['analysis']['ai_analysis'])) { $report .= "- AI анализ: " . $result['analysis']['ai_analysis'] . "\n"; } } else { $report .= "**Сообщение:** " . $result['message'] . "\n"; } $report .= "\n"; } return $report; }