Rev 514 | Autoría | Comparar con el anterior | Ultima modificación | Ver Log |
<?php
declare(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álido
if (!$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 criterios
if (!$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 CVs
if (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 existen
if (!$criteria) {
return new JsonModel([
'success' => false,
'message' => 'No selection criteria found for this vacancy'
]);
}
// 🔹 Procesar los CVs y analizar compatibilidad con los criterios
try {
$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 POST
return new JsonModel([
'success' => false,
'message' => 'Invalid request method'
]);
}
public function getSelectionCriteriaVacancy($vacancyId)
{
// 🔹 Verificar si el ID es válido
if (!$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 vacante
if (!$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álido
if (!$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 temporalmente
file_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 arreglado
return $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 CVs
foreach ($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 CV
foreach ($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 compatibilidad
return $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ón
if ($curlError) {
error_log("cURL Error: " . $curlError);
return [false, "cURL Error: " . $curlError];
}
// Si la API devuelve un código de error (4xx o 5xx), registrarlo
if ($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álida
if (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);
}
}