Rev 514 | Autoría | Comparar con el anterior | Ultima modificación | Ver Log |
<?phpdeclare(strict_types=1);namespace LeadersLinked\Controller;use Smalot\PdfParser\Parser;use Laminas\Mvc\Controller\AbstractActionController;use Laminas\View\Model\ViewModel;use Laminas\View\Model\JsonModel;use Laminas\Db\Adapter\AdapterInterface;use Laminas\Db\Sql\Select;use Laminas\Db\Adapter\Adapter;use Laminas\Db\Sql\Sql;use Laminas\Db\ResultSet\HydratingResultSet;use Laminas\Hydrator\ArraySerializableHydrator;use LeadersLinked\Mapper\RecruitmentSelectionVacancyMapper;use LeadersLinked\Mapper\JobDescriptionMapper;use LeadersLinked\Mapper\JobCategoryMapper;use LeadersLinked\Mapper\LocationMapper;use LeadersLinked\Mapper\QueryMapper;use ArrayObject;class RecruitmentPreAplicationController extends AbstractActionController{/**** @var \Laminas\Db\Adapter\AdapterInterface*/private $adapter;/**** @var \LeadersLinked\Cache\CacheInterface*/private $cache;/**** @var \Laminas\Log\LoggerInterface*/private $logger;/**** @var array*/private $config;/**** @var \Laminas\Mvc\I18n\Translator*/private $translator;/**** @param \Laminas\Db\Adapter\AdapterInterface $adapter* @param \LeadersLinked\Cache\CacheInterface $cache* @param \Laminas\Log\LoggerInterface LoggerInterface $logger* @param array $config* @param \Laminas\Mvc\I18n\Translator $translator*/public function __construct($adapter, $cache, $logger, $config, $translator){$this->adapter = $adapter;$this->cache = $cache;$this->logger = $logger;$this->config = $config;$this->translator = $translator;}public function indexAction(){$request = $this->getRequest();// 🔹 Obtener el ID desde la ruta (URL) con el formato /endpoint/:id$vacancyId = $this->params()->fromRoute('id');// 🔹 Verificar si el ID es válidoif (!$vacancyId) {return new JsonModel(['success' => false,'message' => 'Missing vacancy ID']);}// 🔹 Obtener usuario y permisos ACL$currentUserPlugin = $this->plugin('currentUserPlugin');$currentUser = $currentUserPlugin->getUser();$acl = $this->getEvent()->getViewModel()->getVariable('acl');// 🔹 Verificar si el usuario tiene permiso para extraer criteriosif (!$acl->isAllowed($currentUser->usertype_id, 'recruitment-ai/pre-aplications')) {return new JsonModel(['success' => false,'message' => 'Access denied']);}if ($request->isGet()) {// Llamar a la función que obtiene los archivos de la vacante$candidates = $this->getSelectionPreAplicationCandidates($vacancyId);return new JsonModel(['success' => true,'data' => $candidates]);}if ($request->isPost()) {// 🔹 Obtener los CVs en Base64 desde el cuerpo de la solicitud$bodyParams = json_decode($this->getRequest()->getContent(), true);$cvs = $bodyParams['cvs'] ?? [];// 🔹 Verificar si hay CVsif (empty($cvs)) {return new JsonModel(['success' => false,'message' => 'Missing CVs data']);}// 🔹 Obtener criterios desde la base de datos$criteria = $this->getSelectionCriteriaVacancy($vacancyId);// 🔹 Verificar si los criterios existenif (!$criteria) {return new JsonModel(['success' => false,'message' => 'No selection criteria found for this vacancy']);}// 🔹 Procesar los CVs y analizar compatibilidad con los criteriostry {$processedCvs = $this->processCvs($cvs, $criteria, $vacancyId);return new JsonModel(['success' => true,'data' => $processedCvs]);} catch (\Exception $e) {return new JsonModel(['success' => false,'message' => 'Error processing CVs: ' . $e->getMessage()]);}}// Si el método no es GET ni POSTreturn new JsonModel(['success' => false,'message' => 'Invalid request method']);}public function getSelectionCriteriaVacancy($vacancyId){// 🔹 Verificar si el ID es válidoif (!$vacancyId) {return null;}// 🔹 Construcción de la consulta con QueryMapper$queryMapper = QueryMapper::getInstance($this->adapter);$select = $queryMapper->getSql()->select();$select->from(['tb1' => RecruitmentSelectionVacancyMapper::_TABLE]);$select->columns(['uuid', 'name', 'status']);// 🔹 JOIN con JobDescriptionMapper (incluyendo functions y objectives)$select->join(['tb2' => JobDescriptionMapper::_TABLE],'tb1.job_description_id = tb2.id AND tb1.company_id = tb2.company_id',['job_description_name' => 'name','job_description_functions' => 'functions','job_description_objectives' => 'objectives']);// 🔹 JOIN con JobCategoryMapper (para obtener nombre y descripción de la categoría)$select->join(['tb3' => JobCategoryMapper::_TABLE],'tb1.job_category_id = tb3.id',['job_category_name' => 'name','job_category_description' => 'description']);// 🔹 JOIN con LocationMapper (para obtener país y dirección formateada)$select->join(['tb4' => LocationMapper::_TABLE],'tb1.location_id = tb4.id',['job_location_country' => 'country','job_location_address' => 'formatted_address']);// 🔹 Filtrar por el ID de la vacante$select->where->equalTo('tb1.id', $vacancyId);// 🔹 Ejecutar la consulta$statement = $queryMapper->getSql()->prepareStatementForSqlObject($select);$resultSet = $statement->execute();// 🔹 Procesar los resultados$hydrator = new ArraySerializableHydrator();$hydratingResultSet = new HydratingResultSet($hydrator);$hydratingResultSet->initialize($resultSet);// 🔹 Obtener un solo resultado como diccionario$vacancyData = $hydratingResultSet->current();// 🔹 Validar si no se encontró la vacanteif (!$vacancyData) {return null;}// 🔹 Formatear la respuesta en una variable de texto$formattedText ="Nombre: {$vacancyData['name']}\n" ."### Descripción del trabajo\n" ."Nombre: {$vacancyData['job_description_name']}\n" ."Funciones: {$vacancyData['job_description_functions']}\n\n" ."Objetivos: {$vacancyData['job_description_objectives']}\n\n" ."### Ubicación\n" ."País: {$vacancyData['job_location_country']}\n" ."Dirección: {$vacancyData['job_location_address']}\n";return $formattedText;}public function getSelectionPreAplicationCandidates($vacancyId){// 🔹 Verificar si el ID es válidoif (!$vacancyId) {return new JsonModel(['success' => true,'message' => 'No existen aplicaciones para la vacante.',]);}// 🔹 Construcción de la consulta SQL$queryMapper = QueryMapper::getInstance($this->adapter);$select = $queryMapper->getSql()->select();$select->from('tbl_recruitment_selection_pre_aplications'); // Se cambia la tabla$select->columns(['*']); // Obtener todos los campos// 🔹 Filtrar por vacancy_id$select->where->equalTo('vacancy_id', $vacancyId);// 🔹 Ejecutar la consulta$statement = $queryMapper->getSql()->prepareStatementForSqlObject($select);$resultSet = $statement->execute();// 🔹 Procesar los resultados en un array de diccionarios$hydrator = new ArraySerializableHydrator();$hydratingResultSet = new HydratingResultSet($hydrator);$hydratingResultSet->initialize($resultSet);// 🔹 Convertir los resultados en un array de diccionarios$applications = [];foreach ($hydratingResultSet as $row) {$row['skills'] = json_decode($row['skills'], true) ?? [];$row['previous_positions'] = json_decode($row['previous_positions'], true) ?? [];$applications[] = $row;}return $applications;}function extractTextFromBase64Cvs(array $cvs): array{$parser = new Parser();$results = [];foreach ($cvs as $cv) {$decodedFile = base64_decode($cv['file']); // Decodificar el contenido del archivo$filePath = sys_get_temp_dir() . '/' . uniqid() . '.pdf'; // Guardarlo temporalmentefile_put_contents($filePath, $decodedFile);// Extraer texto del PDF$pdf = $parser->parseFile($filePath);$text = $pdf->getText();if (!$text) {throw new \Exception('Could not extract text from CV: ' . $cv['name']);}$results[] = ['id' => $cv['id'],'name' => $cv['name'],'text' => $text,];unlink($filePath); // Eliminar archivo temporal}return $results;}function cleanContent($text){// Eliminar la palabra "json"$text = str_replace("json", "", $text);// Eliminar los saltos de línea \n$text = str_replace("\n", "", $text);// Eliminar los acentos invertidos (```)$text = str_replace("```", "", $text);// Retornar el contenido arregladoreturn $text;}public function processCvs(array $cvs, $criteria, $vacancyId){// Extraer los textos de los CVs desde base64$extractedCvs = $this->extractTextFromBase64Cvs($cvs);$extractedData = [];// Mensaje inicial para la IA (Contexto del sistema)$messages = [['role' => 'system', 'content' => 'Extrae información clave de los currículums en formato JSON estructurado.']];// 🔹 Primera consulta: Extraer información clave de los CVsforeach ($extractedCvs as $cv) {// Mensaje para la IA con la instrucción de formateo JSON$messages[] = ['role' => 'user','content' => "Extrae la información del currículum en formato JSON válido y estructurado, sin texto adicional ni explicaciones:{\"name\": \"Nombre del candidato\",\"location\": \"Ubicación\",\"years_of_experience\": \"Número de años de experiencia\",\"previous_positions\": [\"Cargo 1\", \"Cargo 2\", \"Cargo 3\"],\"specialization\": \"Especialización en educación\",\"skills\": [\"Habilidad1\", \"Habilidad2\", \"Habilidad3\"],}Adicionalmente genera una clave uuid para cada aplicante y colocala en el json.Aquí está el currículum: " . $cv['text']];// Enviar consulta a la IA$response = $this->analyzeCvWithAi($messages);// Eliminar la palabra json del texto$content = $this->cleanContent($response['choices'][0]['message']['content'] ?? '{}');// Decodificar JSON desde la respuesta de la IA$aiResponse = json_decode($content, true);if ($aiResponse === null) {return [false, 'Error en JSON: ' . json_last_error_msg()];}// Guardar los datos extraídos de cada CV$extractedData[] = ['uuid' => $aiResponse['uuid'] ?? 'Desconocido','vacancy_id' => $vacancyId,'full_name' => $aiResponse['name'] ?? 'Desconocido','location' => $aiResponse['location'] ?? 'Desconocida','years_of_experience' => $aiResponse['years_of_experience'] ?? '0','previous_positions' => $aiResponse['previous_positions'] ?? [],'specialization' => $aiResponse['specialization'] ?? 'Desconocida','skills' => $aiResponse['skills'] ?? 'Desconocida'];// Agregar la respuesta al contexto para la siguiente consulta$messages[] = ['role' => 'assistant', 'content' => json_encode($aiResponse)];}// Agregar mensaje de evaluación para la IA$messages[] = ['role' => 'user','content' => "Añade el puntaje de compatibilidad a las respuestas ya generadas sin modificar el resto de la información.Devuelve un JSON donde solo se incluya la clave \"uuid\" y su correspondiente \"compatibility_score\" dentro de cada candidato, asegurando que el puntaje (del 0 al 100) corresponda al candidato correcto según su UUID.El JSON debe tener la siguiente estructura:{\"uuid1_del_cv_ya_analizado\": puntaje_entre_0_y_100,\"uuid2_del_cv_ya_analizado\": puntaje_entre_0_y_100,...}Criterios de la vacante: " . json_encode($criteria)];// Enviar consulta a la IA para evaluar la compatibilidad$evaluationResponse = $this->analyzeCvWithAi($messages);$evaluation = $this->cleanContent($evaluationResponse['choices'][0]['message']['content'] ?? '{}');// Decodificar la respuesta de la IA$evaluation = json_decode($evaluation, true);// Asignar solo el puntaje de compatibilidad correcto a cada CVforeach ($extractedData as &$data) {$data['compatibility_score'] = $evaluation[$data['uuid']] ?? 0;}// Guardar o actualizar los datos en la base de datos$saveResult = $this->saveOrUpdatePreApplications($extractedData);// Validar si la inserción fallóif ($saveResult !== true) {return [false, 'Error al guardar los datos: ' . json_encode($saveResult)];}// Retornar los datos procesados con las puntuaciones de compatibilidadreturn $extractedData;}private function saveOrUpdatePreApplications(array $extractedData){$sql = new Sql($this->adapter);$errors = [];foreach ($extractedData as $data) {// Convertir los arrays a JSON antes de almacenarlos$data['previous_positions'] = json_encode($data['previous_positions'], JSON_UNESCAPED_UNICODE);$data['skills'] = json_encode($data['skills'], JSON_UNESCAPED_UNICODE);$select = $sql->select()->from('tbl_recruitment_selection_pre_aplications')->where(['uuid' => $data['uuid'], 'full_name' => $data['full_name']]);$statement = $sql->prepareStatementForSqlObject($select);$result = $statement->execute();if ($result->current()) {// Si existe, realizar UPDATE$update = $sql->update('tbl_recruitment_selection_pre_aplications')->set(['vacancy_id' => $data['vacancy_id'],'location' => $data['location'],'years_experience' => $data['years_of_experience'],'previous_positions' => $data['previous_positions'], // Ahora es JSON'specialization' => $data['specialization'],'skills' => $data['skills'], // Ahora es JSON'vacancy_compatibility_score' => $data['compatibility_score']])->where(['uuid' => $data['uuid'], 'full_name' => $data['full_name']]);$updateStatement = $sql->prepareStatementForSqlObject($update);try {$updateStatement->execute();} catch (\Exception $e) {$errors[] = "Error al actualizar: " . $e->getMessage();}} else {// Si no existe, realizar INSERT$insert = $sql->insert('tbl_recruitment_selection_pre_aplications')->values(['uuid' => $data['uuid'],'vacancy_id' => $data['vacancy_id'],'full_name' => $data['full_name'],'location' => $data['location'],'years_experience' => $data['years_of_experience'],'previous_positions' => $data['previous_positions'], // Ahora es JSON'specialization' => $data['specialization'],'skills' => $data['skills'], // Ahora es JSON'vacancy_compatibility_score' => $data['compatibility_score']]);$insertStatement = $sql->prepareStatementForSqlObject($insert);try {$result = $insertStatement->execute();if (!$result->getGeneratedValue()) {$errors[] = "Falló la inserción para UUID: " . $data['uuid'];}} catch (\Exception $e) {$errors[] = "Error al insertar: " . $e->getMessage();}}}return empty($errors) ? true : $errors;}function callExternalApi($url, $payload, $headers){$ch = curl_init($url);curl_setopt($ch, CURLOPT_CAINFO, '/etc/apache2/ssl/cacert.pem');curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);curl_setopt($ch, CURLOPT_POST, true);curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);$response = curl_exec($ch);$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);$curlError = curl_error($ch);curl_close($ch);// Verificar si hubo un error en la peticiónif ($curlError) {error_log("cURL Error: " . $curlError);return [false, "cURL Error: " . $curlError];}// Si la API devuelve un código de error (4xx o 5xx), registrarloif ($httpCode >= 400) {error_log("Error HTTP {$httpCode}: " . $response);return [false, "Error HTTP {$httpCode}: " . $response];}// Intentar decodificar la respuesta JSON$decodedResponse = json_decode($response, true);// Verificar si la respuesta es válidaif (json_last_error() !== JSON_ERROR_NONE) {error_log("Error al decodificar JSON: " . json_last_error_msg());return [false, "Error al decodificar JSON: " . json_last_error_msg()];}return $decodedResponse;}function analyzeCvWithAi(array $messages){$apiKey = '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';$url = "https://api.openai.com/v1/chat/completions";$payload = json_encode(['model' => 'gpt-4o-mini','messages' => $messages,'temperature' => 0.7]);$headers = ['Content-Type: application/json',"Authorization: Bearer $apiKey"];return $this->callExternalApi($url, $payload, $headers);}}