Proyectos de Subversion LeadersLinked - Services

Rev

Rev 501 | Ir a la última revisión | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
494 ariadna 1
<?php
2
 
3
declare(strict_types=1);
4
 
5
namespace LeadersLinked\Controller;
6
 
7
use Smalot\PdfParser\Parser;
8
 
9
use Laminas\Mvc\Controller\AbstractActionController;
10
use Laminas\View\Model\ViewModel;
11
use Laminas\View\Model\JsonModel;
12
use Laminas\Db\Adapter\AdapterInterface;
13
use Laminas\Db\Sql\Select;
497 ariadna 14
use Laminas\Db\Adapter\Adapter;
494 ariadna 15
use Laminas\Db\Sql\Sql;
16
use Laminas\Db\ResultSet\HydratingResultSet;
17
use Laminas\Hydrator\ArraySerializableHydrator;
18
use LeadersLinked\Mapper\RecruitmentSelectionVacancyMapper;
19
use LeadersLinked\Mapper\JobDescriptionMapper;
20
use LeadersLinked\Mapper\JobCategoryMapper;
21
use LeadersLinked\Mapper\LocationMapper;
22
use LeadersLinked\Mapper\QueryMapper;
23
use ArrayObject;
24
 
25
class RecruitmentPreAplicationController extends AbstractActionController
26
{
27
    /**
28
     *
29
     * @var \Laminas\Db\Adapter\AdapterInterface
30
     */
31
    private $adapter;
32
 
33
    /**
34
     *
35
     * @var \LeadersLinked\Cache\CacheInterface
36
     */
37
    private $cache;
38
 
39
 
40
    /**
41
     *
42
     * @var \Laminas\Log\LoggerInterface
43
     */
44
    private $logger;
45
 
46
    /**
47
     *
48
     * @var array
49
     */
50
    private $config;
51
 
52
 
53
    /**
54
     *
55
     * @var \Laminas\Mvc\I18n\Translator
56
     */
57
    private $translator;
58
 
59
 
60
    /**
61
     *
62
     * @param \Laminas\Db\Adapter\AdapterInterface $adapter
63
     * @param \LeadersLinked\Cache\CacheInterface $cache
64
     * @param \Laminas\Log\LoggerInterface LoggerInterface $logger
65
     * @param array $config
66
     * @param \Laminas\Mvc\I18n\Translator $translator
67
     */
68
    public function __construct($adapter, $cache, $logger, $config, $translator)
69
    {
70
        $this->adapter      = $adapter;
71
        $this->cache        = $cache;
72
        $this->logger       = $logger;
73
        $this->config       = $config;
74
        $this->translator   = $translator;
75
    }
76
 
77
    public function indexAction()
78
    {
79
        $request = $this->getRequest();
80
 
81
        // 🔹 Obtener el ID desde la ruta (URL) con el formato /endpoint/:id
82
        $vacancyId = $this->params()->fromRoute('id');
83
 
84
        // 🔹 Verificar si el ID es válido
85
        if (!$vacancyId) {
86
            return new JsonModel([
87
                'success' => false,
88
                'message' => 'Missing vacancy ID'
89
            ]);
90
        }
91
 
92
        // 🔹 Obtener usuario y permisos ACL
93
        $currentUserPlugin = $this->plugin('currentUserPlugin');
94
        $currentUser = $currentUserPlugin->getUser();
95
        $acl = $this->getEvent()->getViewModel()->getVariable('acl');
96
 
97
        // 🔹 Verificar si el usuario tiene permiso para extraer criterios
514 ariadna 98
        if (!$acl->isAllowed($currentUser->usertype_id, 'recruitment-ia/pre-aplications')) {
494 ariadna 99
            return new JsonModel([
100
                'success' => false,
101
                'message' => 'Access denied'
102
            ]);
103
        }
104
 
105
        if ($request->isGet()) {
106
 
107
            // Llamar a la función que obtiene los archivos de la vacante
500 ariadna 108
            $candidates = $this->getSelectionPreAplicationCandidates($vacancyId);
494 ariadna 109
 
110
            return new JsonModel([
111
                'success' => true,
500 ariadna 112
                'data' => $candidates
494 ariadna 113
            ]);
114
        }
115
 
116
        if ($request->isPost()) {
117
            // 🔹 Obtener los CVs en Base64 desde el cuerpo de la solicitud
118
            $bodyParams = json_decode($this->getRequest()->getContent(), true);
119
            $cvs = $bodyParams['cvs'] ?? [];
120
 
121
            // 🔹 Verificar si hay CVs
122
            if (empty($cvs)) {
123
                return new JsonModel([
124
                    'success' => false,
125
                    'message' => 'Missing CVs data'
126
                ]);
127
            }
128
 
129
            // 🔹 Obtener criterios desde la base de datos
130
            $criteria = $this->getSelectionCriteriaVacancy($vacancyId);
131
 
132
            // 🔹 Verificar si los criterios existen
133
            if (!$criteria) {
134
                return new JsonModel([
135
                    'success' => false,
136
                    'message' => 'No selection criteria found for this vacancy'
137
                ]);
138
            }
139
 
140
            // 🔹 Procesar los CVs y analizar compatibilidad con los criterios
141
            try {
500 ariadna 142
                $processedCvs = $this->processCvs($cvs, $criteria, $vacancyId);
494 ariadna 143
 
144
                return new JsonModel([
145
                    'success' => true,
500 ariadna 146
                    'data' => $processedCvs
494 ariadna 147
                ]);
148
            } catch (\Exception $e) {
149
                return new JsonModel([
150
                    'success' => false,
151
                    'message' => 'Error processing CVs: ' . $e->getMessage()
152
                ]);
153
            }
154
        }
155
 
156
        // Si el método no es GET ni POST
157
        return new JsonModel([
158
            'success' => false,
159
            'message' => 'Invalid request method'
160
        ]);
161
    }
162
 
163
    public function getSelectionCriteriaVacancy($vacancyId)
164
    {
165
        // 🔹 Verificar si el ID es válido
166
        if (!$vacancyId) {
167
            return null;
168
        }
169
 
170
        // 🔹 Construcción de la consulta con QueryMapper
171
        $queryMapper = QueryMapper::getInstance($this->adapter);
172
        $select = $queryMapper->getSql()->select();
173
        $select->from(['tb1' => RecruitmentSelectionVacancyMapper::_TABLE]);
174
        $select->columns(['uuid', 'name', 'status']);
175
 
176
        // 🔹 JOIN con JobDescriptionMapper (incluyendo functions y objectives)
177
        $select->join(
178
            ['tb2' => JobDescriptionMapper::_TABLE],
179
            'tb1.job_description_id = tb2.id AND tb1.company_id = tb2.company_id',
180
            [
181
                'job_description_name' => 'name',
182
                'job_description_functions'   => 'functions',
183
                'job_description_objectives'  => 'objectives'
184
            ]
185
        );
186
 
187
        // 🔹 JOIN con JobCategoryMapper (para obtener nombre y descripción de la categoría)
188
        $select->join(
189
            ['tb3' => JobCategoryMapper::_TABLE],
190
            'tb1.job_category_id = tb3.id',
191
            [
192
                'job_category_name'        => 'name',
193
                'job_category_description' => 'description'
194
            ]
195
        );
196
 
197
        // 🔹 JOIN con LocationMapper (para obtener país y dirección formateada)
198
        $select->join(
199
            ['tb4' => LocationMapper::_TABLE],
200
            'tb1.location_id = tb4.id',
201
            [
202
                'job_location_country' => 'country',
203
                'job_location_address' => 'formatted_address'
204
            ]
205
        );
206
 
207
        // 🔹 Filtrar por el ID de la vacante
208
        $select->where->equalTo('tb1.id', $vacancyId);
209
 
210
        // 🔹 Ejecutar la consulta
211
        $statement = $queryMapper->getSql()->prepareStatementForSqlObject($select);
212
        $resultSet = $statement->execute();
213
 
214
        // 🔹 Procesar los resultados
215
        $hydrator = new ArraySerializableHydrator();
216
        $hydratingResultSet = new HydratingResultSet($hydrator);
217
        $hydratingResultSet->initialize($resultSet);
218
 
219
        // 🔹 Obtener un solo resultado como diccionario
220
        $vacancyData = $hydratingResultSet->current();
221
 
222
        // 🔹 Validar si no se encontró la vacante
223
        if (!$vacancyData) {
224
            return null;
225
        }
226
 
227
        // 🔹 Formatear la respuesta en una variable de texto
228
        $formattedText =
229
            "Nombre: {$vacancyData['name']}\n" .
230
            "### Descripción del trabajo\n" .
231
            "Nombre: {$vacancyData['job_description_name']}\n" .
232
            "Funciones: {$vacancyData['job_description_functions']}\n\n" .
233
            "Objetivos: {$vacancyData['job_description_objectives']}\n\n" .
234
            "### Ubicación\n" .
235
            "País: {$vacancyData['job_location_country']}\n" .
236
            "Dirección: {$vacancyData['job_location_address']}\n";
237
 
238
        return $formattedText;
239
    }
240
 
241
 
500 ariadna 242
    public function getSelectionPreAplicationCandidates($vacancyId)
494 ariadna 243
    {
244
        // 🔹 Verificar si el ID es válido
245
        if (!$vacancyId) {
246
            return new JsonModel([
247
                'success' => true,
248
                'message' => 'No existen aplicaciones para la vacante.',
249
            ]);
250
        }
251
 
252
        // 🔹 Construcción de la consulta SQL
253
        $queryMapper = QueryMapper::getInstance($this->adapter);
254
        $select = $queryMapper->getSql()->select();
500 ariadna 255
        $select->from('tbl_recruitment_selection_pre_aplications'); // Se cambia la tabla
256
        $select->columns(['*']); // Obtener todos los campos
494 ariadna 257
 
258
        // 🔹 Filtrar por vacancy_id
259
        $select->where->equalTo('vacancy_id', $vacancyId);
260
 
261
        // 🔹 Ejecutar la consulta
262
        $statement = $queryMapper->getSql()->prepareStatementForSqlObject($select);
263
        $resultSet = $statement->execute();
264
 
265
        // 🔹 Procesar los resultados en un array de diccionarios
266
        $hydrator = new ArraySerializableHydrator();
267
        $hydratingResultSet = new HydratingResultSet($hydrator);
268
        $hydratingResultSet->initialize($resultSet);
269
 
270
        // 🔹 Convertir los resultados en un array de diccionarios
500 ariadna 271
        $applications = [];
494 ariadna 272
        foreach ($hydratingResultSet as $row) {
501 ariadna 273
            $row['skills'] = json_decode($row['skills'], true) ?? [];
274
            $row['previous_positions'] = json_decode($row['previous_positions'], true) ?? [];
500 ariadna 275
            $applications[] = $row;
494 ariadna 276
        }
277
 
500 ariadna 278
        return $applications;
494 ariadna 279
    }
280
 
281
    function extractTextFromBase64Cvs(array $cvs): array
282
    {
283
        $parser = new Parser();
284
        $results = [];
285
 
286
        foreach ($cvs as $cv) {
287
            $decodedFile = base64_decode($cv['file']); // Decodificar el contenido del archivo
288
            $filePath = sys_get_temp_dir() . '/' . uniqid() . '.pdf'; // Guardarlo temporalmente
289
 
290
            file_put_contents($filePath, $decodedFile);
291
 
292
            // Extraer texto del PDF
293
            $pdf = $parser->parseFile($filePath);
294
            $text = $pdf->getText();
295
 
296
            if (!$text) {
297
                throw new \Exception('Could not extract text from CV: ' . $cv['name']);
298
            }
299
 
300
            $results[] = [
301
                'id' => $cv['id'],
302
                'name' => $cv['name'],
303
                'text' => $text,
304
            ];
305
 
306
            unlink($filePath); // Eliminar archivo temporal
307
        }
308
 
309
        return $results;
310
    }
311
 
312
    function cleanContent($text)
313
    {
314
        // Eliminar la palabra "json"
315
        $text = str_replace("json", "", $text);
316
 
317
        // Eliminar los saltos de línea \n
318
        $text = str_replace("\n", "", $text);
319
 
320
        // Eliminar los acentos invertidos (```)
321
        $text = str_replace("```", "", $text);
322
 
323
        // Retornar el contenido arreglado
324
        return $text;
325
    }
326
 
327
    public function processCvs(array $cvs, $criteria, $vacancyId)
328
    {
329
        // Extraer los textos de los CVs desde base64
330
        $extractedCvs = $this->extractTextFromBase64Cvs($cvs);
331
        $extractedData = [];
332
 
333
        // Mensaje inicial para la IA (Contexto del sistema)
334
        $messages = [
335
            ['role' => 'system', 'content' => 'Extrae información clave de los currículums en formato JSON estructurado.']
336
        ];
337
 
338
        // 🔹 Primera consulta: Extraer información clave de los CVs
339
        foreach ($extractedCvs as $cv) {
340
            // Mensaje para la IA con la instrucción de formateo JSON
341
            $messages[] = [
342
                'role' => 'user',
343
                'content' => "Extrae la información del currículum en formato JSON válido y estructurado, sin texto adicional ni explicaciones:
344
                {
345
                    \"name\": \"Nombre del candidato\",
346
                    \"location\": \"Ubicación\",
347
                    \"years_of_experience\": \"Número de años de experiencia\",
348
                    \"previous_positions\": [\"Cargo 1\", \"Cargo 2\", \"Cargo 3\"],
349
                    \"specialization\": \"Especialización en educación\",
350
                    \"skills\": [\"Habilidad1\", \"Habilidad2\", \"Habilidad3\"],
351
                }
352
                Adicionalmente genera una clave uuid para cada aplicante y colocala en el json.
353
                Aquí está el currículum: " . $cv['text']
354
            ];
355
 
356
            // Enviar consulta a la IA
357
            $response = $this->analyzeCvWithAi($messages);
358
 
359
            // Eliminar la palabra json del texto
360
            $content = $this->cleanContent($response['choices'][0]['message']['content'] ?? '{}');
361
 
362
            // Decodificar JSON desde la respuesta de la IA
363
            $aiResponse = json_decode($content, true);
364
 
365
            if ($aiResponse === null) {
366
                return [false, 'Error en JSON: ' . json_last_error_msg()];
367
            }
368
 
369
            // Guardar los datos extraídos de cada CV
370
            $extractedData[] = [
371
                'uuid' => $aiResponse['uuid'] ?? 'Desconocido',
372
                'vacancy_id' => $vacancyId,
373
                'full_name' => $aiResponse['name'] ?? 'Desconocido',
374
                'location' => $aiResponse['location'] ?? 'Desconocida',
375
                'years_of_experience' => $aiResponse['years_of_experience'] ?? '0',
376
                'previous_positions' => $aiResponse['previous_positions'] ?? [],
377
                'specialization' => $aiResponse['specialization'] ?? 'Desconocida',
378
                'skills' => $aiResponse['skills'] ?? 'Desconocida'
379
            ];
380
 
381
            // Agregar la respuesta al contexto para la siguiente consulta
382
            $messages[] = ['role' => 'assistant', 'content' => json_encode($aiResponse)];
383
        }
384
 
385
        // Agregar mensaje de evaluación para la IA
386
        $messages[] = [
387
            'role' => 'user',
388
            'content' => "Añade el puntaje de compatibilidad a las respuestas ya generadas sin modificar el resto de la información.
389
            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.
390
            El JSON debe tener la siguiente estructura:
391
            {
392
                \"uuid1_del_cv_ya_analizado\": puntaje_entre_0_y_100,
393
                \"uuid2_del_cv_ya_analizado\": puntaje_entre_0_y_100,
394
                ...
395
            }
396
            Criterios de la vacante: " . json_encode($criteria)
397
        ];
398
 
399
 
400
        // Enviar consulta a la IA para evaluar la compatibilidad
401
        $evaluationResponse = $this->analyzeCvWithAi($messages);
402
 
403
        $evaluation = $this->cleanContent($evaluationResponse['choices'][0]['message']['content'] ?? '{}');
404
        // Decodificar la respuesta de la IA
405
        $evaluation = json_decode($evaluation, true);
406
 
407
        // Asignar solo el puntaje de compatibilidad correcto a cada CV
408
        foreach ($extractedData as &$data) {
409
            $data['compatibility_score'] = $evaluation[$data['uuid']] ?? 0;
410
        }
411
 
497 ariadna 412
        // Guardar o actualizar los datos en la base de datos
413
        $saveResult = $this->saveOrUpdatePreApplications($extractedData);
414
 
415
        // Validar si la inserción falló
416
        if ($saveResult !== true) {
417
            return [false, 'Error al guardar los datos: ' . json_encode($saveResult)];
418
        }
419
 
494 ariadna 420
        // Retornar los datos procesados con las puntuaciones de compatibilidad
421
        return $extractedData;
422
    }
423
 
497 ariadna 424
    private function saveOrUpdatePreApplications(array $extractedData)
425
    {
426
        $sql = new Sql($this->adapter);
427
        $errors = [];
428
 
429
        foreach ($extractedData as $data) {
499 ariadna 430
            // Convertir los arrays a JSON antes de almacenarlos
431
            $data['previous_positions'] = json_encode($data['previous_positions'], JSON_UNESCAPED_UNICODE);
432
            $data['skills'] = json_encode($data['skills'], JSON_UNESCAPED_UNICODE);
433
 
497 ariadna 434
            $select = $sql->select()
435
                ->from('tbl_recruitment_selection_pre_aplications')
436
                ->where(['uuid' => $data['uuid'], 'full_name' => $data['full_name']]);
437
 
438
            $statement = $sql->prepareStatementForSqlObject($select);
439
            $result = $statement->execute();
440
 
441
            if ($result->current()) {
442
                // Si existe, realizar UPDATE
443
                $update = $sql->update('tbl_recruitment_selection_pre_aplications')
444
                    ->set([
445
                        'vacancy_id' => $data['vacancy_id'],
446
                        'location' => $data['location'],
447
                        'years_experience' => $data['years_of_experience'],
499 ariadna 448
                        'previous_positions' => $data['previous_positions'], // Ahora es JSON
497 ariadna 449
                        'specialization' => $data['specialization'],
499 ariadna 450
                        'skills' => $data['skills'], // Ahora es JSON
497 ariadna 451
                        'vacancy_compatibility_score' => $data['compatibility_score']
452
                    ])
453
                    ->where(['uuid' => $data['uuid'], 'full_name' => $data['full_name']]);
454
 
455
                $updateStatement = $sql->prepareStatementForSqlObject($update);
456
                try {
457
                    $updateStatement->execute();
458
                } catch (\Exception $e) {
459
                    $errors[] = "Error al actualizar: " . $e->getMessage();
460
                }
461
            } else {
462
                // Si no existe, realizar INSERT
463
                $insert = $sql->insert('tbl_recruitment_selection_pre_aplications')
464
                    ->values([
465
                        'uuid' => $data['uuid'],
466
                        'vacancy_id' => $data['vacancy_id'],
467
                        'full_name' => $data['full_name'],
468
                        'location' => $data['location'],
469
                        'years_experience' => $data['years_of_experience'],
499 ariadna 470
                        'previous_positions' => $data['previous_positions'], // Ahora es JSON
497 ariadna 471
                        'specialization' => $data['specialization'],
499 ariadna 472
                        'skills' => $data['skills'], // Ahora es JSON
497 ariadna 473
                        'vacancy_compatibility_score' => $data['compatibility_score']
474
                    ]);
475
 
476
                $insertStatement = $sql->prepareStatementForSqlObject($insert);
477
                try {
478
                    $result = $insertStatement->execute();
479
                    if (!$result->getGeneratedValue()) {
480
                        $errors[] = "Falló la inserción para UUID: " . $data['uuid'];
481
                    }
482
                } catch (\Exception $e) {
483
                    $errors[] = "Error al insertar: " . $e->getMessage();
484
                }
485
            }
486
        }
487
 
488
        return empty($errors) ? true : $errors;
489
    }
490
 
499 ariadna 491
 
494 ariadna 492
    function callExternalApi($url, $payload, $headers)
493
    {
494
        $ch = curl_init($url);
495
        curl_setopt($ch, CURLOPT_CAINFO, '/etc/apache2/ssl/cacert.pem');
496
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
497
        curl_setopt($ch, CURLOPT_POST, true);
498
        curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
499
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
500
 
501
        $response = curl_exec($ch);
502
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
503
        $curlError = curl_error($ch);
504
 
505
        curl_close($ch);
506
 
507
        // Verificar si hubo un error en la petición
508
        if ($curlError) {
509
            error_log("cURL Error: " . $curlError);
510
            return [false, "cURL Error: " . $curlError];
511
        }
512
 
513
        // Si la API devuelve un código de error (4xx o 5xx), registrarlo
514
        if ($httpCode >= 400) {
515
            error_log("Error HTTP {$httpCode}: " . $response);
516
            return [false, "Error HTTP {$httpCode}: " . $response];
517
        }
518
 
519
        // Intentar decodificar la respuesta JSON
520
        $decodedResponse = json_decode($response, true);
521
 
522
        // Verificar si la respuesta es válida
523
        if (json_last_error() !== JSON_ERROR_NONE) {
524
            error_log("Error al decodificar JSON: " . json_last_error_msg());
525
            return [false, "Error al decodificar JSON: " . json_last_error_msg()];
526
        }
527
 
528
        return $decodedResponse;
529
    }
530
 
531
    function analyzeCvWithAi(array $messages)
532
    {
533
        $apiKey = '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';
534
        $url = "https://api.openai.com/v1/chat/completions";
535
 
536
        $payload = json_encode([
537
            'model' => 'gpt-4o-mini',
538
            'messages' => $messages,
539
            'temperature' => 0.7
540
        ]);
541
 
542
        $headers = [
543
            'Content-Type: application/json',
544
            "Authorization: Bearer $apiKey"
545
        ];
546
 
547
        return $this->callExternalApi($url, $payload, $headers);
548
    }
549
}