Proyectos de Subversion LeadersLinked - Services

Rev

Rev 499 | 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
98
        if (!$acl->isAllowed($currentUser->usertype_id, 'ia-vacancies-aplications')) {
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) {
500 ariadna 273
            $applications[] = $row;
494 ariadna 274
        }
275
 
500 ariadna 276
        return $applications;
494 ariadna 277
    }
278
 
279
    function extractTextFromBase64Cvs(array $cvs): array
280
    {
281
        $parser = new Parser();
282
        $results = [];
283
 
284
        foreach ($cvs as $cv) {
285
            $decodedFile = base64_decode($cv['file']); // Decodificar el contenido del archivo
286
            $filePath = sys_get_temp_dir() . '/' . uniqid() . '.pdf'; // Guardarlo temporalmente
287
 
288
            file_put_contents($filePath, $decodedFile);
289
 
290
            // Extraer texto del PDF
291
            $pdf = $parser->parseFile($filePath);
292
            $text = $pdf->getText();
293
 
294
            if (!$text) {
295
                throw new \Exception('Could not extract text from CV: ' . $cv['name']);
296
            }
297
 
298
            $results[] = [
299
                'id' => $cv['id'],
300
                'name' => $cv['name'],
301
                'text' => $text,
302
            ];
303
 
304
            unlink($filePath); // Eliminar archivo temporal
305
        }
306
 
307
        return $results;
308
    }
309
 
310
    function cleanContent($text)
311
    {
312
        // Eliminar la palabra "json"
313
        $text = str_replace("json", "", $text);
314
 
315
        // Eliminar los saltos de línea \n
316
        $text = str_replace("\n", "", $text);
317
 
318
        // Eliminar los acentos invertidos (```)
319
        $text = str_replace("```", "", $text);
320
 
321
        // Retornar el contenido arreglado
322
        return $text;
323
    }
324
 
325
    public function processCvs(array $cvs, $criteria, $vacancyId)
326
    {
327
        // Extraer los textos de los CVs desde base64
328
        $extractedCvs = $this->extractTextFromBase64Cvs($cvs);
329
        $extractedData = [];
330
 
331
        // Mensaje inicial para la IA (Contexto del sistema)
332
        $messages = [
333
            ['role' => 'system', 'content' => 'Extrae información clave de los currículums en formato JSON estructurado.']
334
        ];
335
 
336
        // 🔹 Primera consulta: Extraer información clave de los CVs
337
        foreach ($extractedCvs as $cv) {
338
            // Mensaje para la IA con la instrucción de formateo JSON
339
            $messages[] = [
340
                'role' => 'user',
341
                'content' => "Extrae la información del currículum en formato JSON válido y estructurado, sin texto adicional ni explicaciones:
342
                {
343
                    \"name\": \"Nombre del candidato\",
344
                    \"location\": \"Ubicación\",
345
                    \"years_of_experience\": \"Número de años de experiencia\",
346
                    \"previous_positions\": [\"Cargo 1\", \"Cargo 2\", \"Cargo 3\"],
347
                    \"specialization\": \"Especialización en educación\",
348
                    \"skills\": [\"Habilidad1\", \"Habilidad2\", \"Habilidad3\"],
349
                }
350
                Adicionalmente genera una clave uuid para cada aplicante y colocala en el json.
351
                Aquí está el currículum: " . $cv['text']
352
            ];
353
 
354
            // Enviar consulta a la IA
355
            $response = $this->analyzeCvWithAi($messages);
356
 
357
            // Eliminar la palabra json del texto
358
            $content = $this->cleanContent($response['choices'][0]['message']['content'] ?? '{}');
359
 
360
            // Decodificar JSON desde la respuesta de la IA
361
            $aiResponse = json_decode($content, true);
362
 
363
            if ($aiResponse === null) {
364
                return [false, 'Error en JSON: ' . json_last_error_msg()];
365
            }
366
 
367
            // Guardar los datos extraídos de cada CV
368
            $extractedData[] = [
369
                'uuid' => $aiResponse['uuid'] ?? 'Desconocido',
370
                'vacancy_id' => $vacancyId,
371
                'full_name' => $aiResponse['name'] ?? 'Desconocido',
372
                'location' => $aiResponse['location'] ?? 'Desconocida',
373
                'years_of_experience' => $aiResponse['years_of_experience'] ?? '0',
374
                'previous_positions' => $aiResponse['previous_positions'] ?? [],
375
                'specialization' => $aiResponse['specialization'] ?? 'Desconocida',
376
                'skills' => $aiResponse['skills'] ?? 'Desconocida'
377
            ];
378
 
379
            // Agregar la respuesta al contexto para la siguiente consulta
380
            $messages[] = ['role' => 'assistant', 'content' => json_encode($aiResponse)];
381
        }
382
 
383
        // Agregar mensaje de evaluación para la IA
384
        $messages[] = [
385
            'role' => 'user',
386
            'content' => "Añade el puntaje de compatibilidad a las respuestas ya generadas sin modificar el resto de la información.
387
            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.
388
            El JSON debe tener la siguiente estructura:
389
            {
390
                \"uuid1_del_cv_ya_analizado\": puntaje_entre_0_y_100,
391
                \"uuid2_del_cv_ya_analizado\": puntaje_entre_0_y_100,
392
                ...
393
            }
394
            Criterios de la vacante: " . json_encode($criteria)
395
        ];
396
 
397
 
398
        // Enviar consulta a la IA para evaluar la compatibilidad
399
        $evaluationResponse = $this->analyzeCvWithAi($messages);
400
 
401
        $evaluation = $this->cleanContent($evaluationResponse['choices'][0]['message']['content'] ?? '{}');
402
        // Decodificar la respuesta de la IA
403
        $evaluation = json_decode($evaluation, true);
404
 
405
        // Asignar solo el puntaje de compatibilidad correcto a cada CV
406
        foreach ($extractedData as &$data) {
407
            $data['compatibility_score'] = $evaluation[$data['uuid']] ?? 0;
408
        }
409
 
497 ariadna 410
        // Guardar o actualizar los datos en la base de datos
411
        $saveResult = $this->saveOrUpdatePreApplications($extractedData);
412
 
413
        // Validar si la inserción falló
414
        if ($saveResult !== true) {
415
            return [false, 'Error al guardar los datos: ' . json_encode($saveResult)];
416
        }
417
 
494 ariadna 418
        // Retornar los datos procesados con las puntuaciones de compatibilidad
419
        return $extractedData;
420
    }
421
 
497 ariadna 422
    private function saveOrUpdatePreApplications(array $extractedData)
423
    {
424
        $sql = new Sql($this->adapter);
425
        $errors = [];
426
 
427
        foreach ($extractedData as $data) {
499 ariadna 428
            // Convertir los arrays a JSON antes de almacenarlos
429
            $data['previous_positions'] = json_encode($data['previous_positions'], JSON_UNESCAPED_UNICODE);
430
            $data['skills'] = json_encode($data['skills'], JSON_UNESCAPED_UNICODE);
431
 
497 ariadna 432
            $select = $sql->select()
433
                ->from('tbl_recruitment_selection_pre_aplications')
434
                ->where(['uuid' => $data['uuid'], 'full_name' => $data['full_name']]);
435
 
436
            $statement = $sql->prepareStatementForSqlObject($select);
437
            $result = $statement->execute();
438
 
439
            if ($result->current()) {
440
                // Si existe, realizar UPDATE
441
                $update = $sql->update('tbl_recruitment_selection_pre_aplications')
442
                    ->set([
443
                        'vacancy_id' => $data['vacancy_id'],
444
                        'location' => $data['location'],
445
                        'years_experience' => $data['years_of_experience'],
499 ariadna 446
                        'previous_positions' => $data['previous_positions'], // Ahora es JSON
497 ariadna 447
                        'specialization' => $data['specialization'],
499 ariadna 448
                        'skills' => $data['skills'], // Ahora es JSON
497 ariadna 449
                        'vacancy_compatibility_score' => $data['compatibility_score']
450
                    ])
451
                    ->where(['uuid' => $data['uuid'], 'full_name' => $data['full_name']]);
452
 
453
                $updateStatement = $sql->prepareStatementForSqlObject($update);
454
                try {
455
                    $updateStatement->execute();
456
                } catch (\Exception $e) {
457
                    $errors[] = "Error al actualizar: " . $e->getMessage();
458
                }
459
            } else {
460
                // Si no existe, realizar INSERT
461
                $insert = $sql->insert('tbl_recruitment_selection_pre_aplications')
462
                    ->values([
463
                        'uuid' => $data['uuid'],
464
                        'vacancy_id' => $data['vacancy_id'],
465
                        'full_name' => $data['full_name'],
466
                        'location' => $data['location'],
467
                        'years_experience' => $data['years_of_experience'],
499 ariadna 468
                        'previous_positions' => $data['previous_positions'], // Ahora es JSON
497 ariadna 469
                        'specialization' => $data['specialization'],
499 ariadna 470
                        'skills' => $data['skills'], // Ahora es JSON
497 ariadna 471
                        'vacancy_compatibility_score' => $data['compatibility_score']
472
                    ]);
473
 
474
                $insertStatement = $sql->prepareStatementForSqlObject($insert);
475
                try {
476
                    $result = $insertStatement->execute();
477
                    if (!$result->getGeneratedValue()) {
478
                        $errors[] = "Falló la inserción para UUID: " . $data['uuid'];
479
                    }
480
                } catch (\Exception $e) {
481
                    $errors[] = "Error al insertar: " . $e->getMessage();
482
                }
483
            }
484
        }
485
 
486
        return empty($errors) ? true : $errors;
487
    }
488
 
499 ariadna 489
 
494 ariadna 490
    function callExternalApi($url, $payload, $headers)
491
    {
492
        $ch = curl_init($url);
493
        curl_setopt($ch, CURLOPT_CAINFO, '/etc/apache2/ssl/cacert.pem');
494
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
495
        curl_setopt($ch, CURLOPT_POST, true);
496
        curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
497
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
498
 
499
        $response = curl_exec($ch);
500
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
501
        $curlError = curl_error($ch);
502
 
503
        curl_close($ch);
504
 
505
        // Verificar si hubo un error en la petición
506
        if ($curlError) {
507
            error_log("cURL Error: " . $curlError);
508
            return [false, "cURL Error: " . $curlError];
509
        }
510
 
511
        // Si la API devuelve un código de error (4xx o 5xx), registrarlo
512
        if ($httpCode >= 400) {
513
            error_log("Error HTTP {$httpCode}: " . $response);
514
            return [false, "Error HTTP {$httpCode}: " . $response];
515
        }
516
 
517
        // Intentar decodificar la respuesta JSON
518
        $decodedResponse = json_decode($response, true);
519
 
520
        // Verificar si la respuesta es válida
521
        if (json_last_error() !== JSON_ERROR_NONE) {
522
            error_log("Error al decodificar JSON: " . json_last_error_msg());
523
            return [false, "Error al decodificar JSON: " . json_last_error_msg()];
524
        }
525
 
526
        return $decodedResponse;
527
    }
528
 
529
    function analyzeCvWithAi(array $messages)
530
    {
531
        $apiKey = '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';
532
        $url = "https://api.openai.com/v1/chat/completions";
533
 
534
        $payload = json_encode([
535
            'model' => 'gpt-4o-mini',
536
            'messages' => $messages,
537
            'temperature' => 0.7
538
        ]);
539
 
540
        $headers = [
541
            'Content-Type: application/json',
542
            "Authorization: Bearer $apiKey"
543
        ];
544
 
545
        return $this->callExternalApi($url, $payload, $headers);
546
    }
547
}