Proyectos de Subversion LeadersLinked - Services

Rev

Rev 494 | Rev 499 | 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
108
            $files = $this->getSelectionFiles($vacancyId);
109
 
110
            return new JsonModel([
111
                'success' => true,
112
                'data' => $files
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 {
497 ariadna 142
                //$processedCvs = $this->processCvs($cvs, $criteria, $vacancyId);
494 ariadna 143
 
497 ariadna 144
                $candidates = [
145
                    [
146
                        "uuid" => "b3e5b5c5-32b2-43b8-8c0a-3f9b9b5e8f3b",
147
                        "vacancy_id" => "5",
148
                        "full_name" => "Stivens Carrasquel",
149
                        "location" => "Caracas, Venezuela",
150
                        "years_of_experience" => 5,
151
                        "previous_positions" => [
152
                            "Desarrollador Frontend en Cesa Management Solutions",
153
                            "Desarrollador Full-Stack en Korvuss",
154
                            "Desarrollador Full-Stack Freelance",
155
                            "Analista TI en Empresas Polar"
156
                        ],
157
                        "specialization" => "Informática",
158
                        "skills" => [
159
                            "JavaScript",
160
                            "TypeScript",
161
                            "C#",
162
                            "React.js",
163
                            "Next.js",
164
                            "Redux",
165
                            "Zustand",
166
                            "Axios",
167
                            "React Native",
168
                            "Node.js",
169
                            "Express.js",
170
                            "MongoDB",
171
                            "SQL",
172
                            "Jest",
173
                            "SCRUM"
174
                        ],
175
                        "compatibility_score" => 75
176
                    ],
177
                    [
178
                        "uuid" => "f7d3b6f1-0c5a-4c2a-9b8e-0e6f2e35d8e3",
179
                        "vacancy_id" => "5",
180
                        "full_name" => "Lisseth Gonzalez",
181
                        "location" => "Caracas, Venezuela",
182
                        "years_of_experience" => 8,
183
                        "previous_positions" => [
184
                            "Docente Suplente en CEIN 'Magdalena de Sucre'",
185
                            "Personal Administrativo en UEN 'Mireya Vanegas'"
186
                        ],
187
                        "specialization" => "Lengua y Literatura",
188
                        "skills" => [
189
                            "Manejo avanzado de herramientas Microsoft Office",
190
                            "Elaboración de blog y Páginas Web",
191
                            "Docente en el área de Castellano y Literatura"
192
                        ],
193
                        "compatibility_score" => 40
194
                    ]
195
                ];
196
 
197
                $data = $this->saveOrUpdatePreApplications($candidates);
198
 
494 ariadna 199
                return new JsonModel([
200
                    'success' => true,
497 ariadna 201
                    'data' => $data
494 ariadna 202
                ]);
203
            } catch (\Exception $e) {
204
                return new JsonModel([
205
                    'success' => false,
206
                    'message' => 'Error processing CVs: ' . $e->getMessage()
207
                ]);
208
            }
209
        }
210
 
211
        // Si el método no es GET ni POST
212
        return new JsonModel([
213
            'success' => false,
214
            'message' => 'Invalid request method'
215
        ]);
216
    }
217
 
218
    public function getSelectionCriteriaVacancy($vacancyId)
219
    {
220
        // 🔹 Verificar si el ID es válido
221
        if (!$vacancyId) {
222
            return null;
223
        }
224
 
225
        // 🔹 Construcción de la consulta con QueryMapper
226
        $queryMapper = QueryMapper::getInstance($this->adapter);
227
        $select = $queryMapper->getSql()->select();
228
        $select->from(['tb1' => RecruitmentSelectionVacancyMapper::_TABLE]);
229
        $select->columns(['uuid', 'name', 'status']);
230
 
231
        // 🔹 JOIN con JobDescriptionMapper (incluyendo functions y objectives)
232
        $select->join(
233
            ['tb2' => JobDescriptionMapper::_TABLE],
234
            'tb1.job_description_id = tb2.id AND tb1.company_id = tb2.company_id',
235
            [
236
                'job_description_name' => 'name',
237
                'job_description_functions'   => 'functions',
238
                'job_description_objectives'  => 'objectives'
239
            ]
240
        );
241
 
242
        // 🔹 JOIN con JobCategoryMapper (para obtener nombre y descripción de la categoría)
243
        $select->join(
244
            ['tb3' => JobCategoryMapper::_TABLE],
245
            'tb1.job_category_id = tb3.id',
246
            [
247
                'job_category_name'        => 'name',
248
                'job_category_description' => 'description'
249
            ]
250
        );
251
 
252
        // 🔹 JOIN con LocationMapper (para obtener país y dirección formateada)
253
        $select->join(
254
            ['tb4' => LocationMapper::_TABLE],
255
            'tb1.location_id = tb4.id',
256
            [
257
                'job_location_country' => 'country',
258
                'job_location_address' => 'formatted_address'
259
            ]
260
        );
261
 
262
        // 🔹 Filtrar por el ID de la vacante
263
        $select->where->equalTo('tb1.id', $vacancyId);
264
 
265
        // 🔹 Ejecutar la consulta
266
        $statement = $queryMapper->getSql()->prepareStatementForSqlObject($select);
267
        $resultSet = $statement->execute();
268
 
269
        // 🔹 Procesar los resultados
270
        $hydrator = new ArraySerializableHydrator();
271
        $hydratingResultSet = new HydratingResultSet($hydrator);
272
        $hydratingResultSet->initialize($resultSet);
273
 
274
        // 🔹 Obtener un solo resultado como diccionario
275
        $vacancyData = $hydratingResultSet->current();
276
 
277
        // 🔹 Validar si no se encontró la vacante
278
        if (!$vacancyData) {
279
            return null;
280
        }
281
 
282
        // 🔹 Formatear la respuesta en una variable de texto
283
        $formattedText =
284
            "Nombre: {$vacancyData['name']}\n" .
285
            "### Descripción del trabajo\n" .
286
            "Nombre: {$vacancyData['job_description_name']}\n" .
287
            "Funciones: {$vacancyData['job_description_functions']}\n\n" .
288
            "Objetivos: {$vacancyData['job_description_objectives']}\n\n" .
289
            "### Ubicación\n" .
290
            "País: {$vacancyData['job_location_country']}\n" .
291
            "Dirección: {$vacancyData['job_location_address']}\n";
292
 
293
        return $formattedText;
294
    }
295
 
296
 
297
    public function getSelectionFiles($vacancyId)
298
    {
299
        // 🔹 Verificar si el ID es válido
300
        if (!$vacancyId) {
301
            return new JsonModel([
302
                'success' => true,
303
                'message' => 'No existen aplicaciones para la vacante.',
304
            ]);
305
        }
306
 
307
        // 🔹 Construcción de la consulta SQL
308
        $queryMapper = QueryMapper::getInstance($this->adapter);
309
        $select = $queryMapper->getSql()->select();
310
        $select->from('tbl_recruitment_selection_files');
311
        $select->columns(['uuid', 'id', 'name', 'file']);
312
 
313
        // 🔹 Filtrar por vacancy_id
314
        $select->where->equalTo('vacancy_id', $vacancyId);
315
 
316
        // 🔹 Ejecutar la consulta
317
        $statement = $queryMapper->getSql()->prepareStatementForSqlObject($select);
318
        $resultSet = $statement->execute();
319
 
320
        // 🔹 Procesar los resultados en un array de diccionarios
321
        $hydrator = new ArraySerializableHydrator();
322
        $hydratingResultSet = new HydratingResultSet($hydrator);
323
        $hydratingResultSet->initialize($resultSet);
324
 
325
        // 🔹 Convertir los resultados en un array de diccionarios
326
        $files = [];
327
        foreach ($hydratingResultSet as $row) {
328
            $files[] = $row;
329
        }
330
 
331
        return $files;
332
    }
333
 
334
    function extractTextFromBase64Cvs(array $cvs): array
335
    {
336
        $parser = new Parser();
337
        $results = [];
338
 
339
        foreach ($cvs as $cv) {
340
            $decodedFile = base64_decode($cv['file']); // Decodificar el contenido del archivo
341
            $filePath = sys_get_temp_dir() . '/' . uniqid() . '.pdf'; // Guardarlo temporalmente
342
 
343
            file_put_contents($filePath, $decodedFile);
344
 
345
            // Extraer texto del PDF
346
            $pdf = $parser->parseFile($filePath);
347
            $text = $pdf->getText();
348
 
349
            if (!$text) {
350
                throw new \Exception('Could not extract text from CV: ' . $cv['name']);
351
            }
352
 
353
            $results[] = [
354
                'id' => $cv['id'],
355
                'name' => $cv['name'],
356
                'text' => $text,
357
            ];
358
 
359
            unlink($filePath); // Eliminar archivo temporal
360
        }
361
 
362
        return $results;
363
    }
364
 
365
    function cleanContent($text)
366
    {
367
        // Eliminar la palabra "json"
368
        $text = str_replace("json", "", $text);
369
 
370
        // Eliminar los saltos de línea \n
371
        $text = str_replace("\n", "", $text);
372
 
373
        // Eliminar los acentos invertidos (```)
374
        $text = str_replace("```", "", $text);
375
 
376
        // Retornar el contenido arreglado
377
        return $text;
378
    }
379
 
380
 
381
    public function processCvs(array $cvs, $criteria, $vacancyId)
382
    {
383
        // Extraer los textos de los CVs desde base64
384
        $extractedCvs = $this->extractTextFromBase64Cvs($cvs);
385
        $extractedData = [];
386
 
387
        // Mensaje inicial para la IA (Contexto del sistema)
388
        $messages = [
389
            ['role' => 'system', 'content' => 'Extrae información clave de los currículums en formato JSON estructurado.']
390
        ];
391
 
392
        // 🔹 Primera consulta: Extraer información clave de los CVs
393
        foreach ($extractedCvs as $cv) {
394
            // Mensaje para la IA con la instrucción de formateo JSON
395
            $messages[] = [
396
                'role' => 'user',
397
                'content' => "Extrae la información del currículum en formato JSON válido y estructurado, sin texto adicional ni explicaciones:
398
                {
399
                    \"name\": \"Nombre del candidato\",
400
                    \"location\": \"Ubicación\",
401
                    \"years_of_experience\": \"Número de años de experiencia\",
402
                    \"previous_positions\": [\"Cargo 1\", \"Cargo 2\", \"Cargo 3\"],
403
                    \"specialization\": \"Especialización en educación\",
404
                    \"skills\": [\"Habilidad1\", \"Habilidad2\", \"Habilidad3\"],
405
                }
406
                Adicionalmente genera una clave uuid para cada aplicante y colocala en el json.
407
                Aquí está el currículum: " . $cv['text']
408
            ];
409
 
410
            // Enviar consulta a la IA
411
            $response = $this->analyzeCvWithAi($messages);
412
 
413
            // Eliminar la palabra json del texto
414
            $content = $this->cleanContent($response['choices'][0]['message']['content'] ?? '{}');
415
 
416
            // Decodificar JSON desde la respuesta de la IA
417
            $aiResponse = json_decode($content, true);
418
 
419
            if ($aiResponse === null) {
420
                return [false, 'Error en JSON: ' . json_last_error_msg()];
421
            }
422
 
423
            // Guardar los datos extraídos de cada CV
424
            $extractedData[] = [
425
                'uuid' => $aiResponse['uuid'] ?? 'Desconocido',
426
                'vacancy_id' => $vacancyId,
427
                'full_name' => $aiResponse['name'] ?? 'Desconocido',
428
                'location' => $aiResponse['location'] ?? 'Desconocida',
429
                'years_of_experience' => $aiResponse['years_of_experience'] ?? '0',
430
                'previous_positions' => $aiResponse['previous_positions'] ?? [],
431
                'specialization' => $aiResponse['specialization'] ?? 'Desconocida',
432
                'skills' => $aiResponse['skills'] ?? 'Desconocida'
433
            ];
434
 
435
            // Agregar la respuesta al contexto para la siguiente consulta
436
            $messages[] = ['role' => 'assistant', 'content' => json_encode($aiResponse)];
437
        }
438
 
439
        // Agregar mensaje de evaluación para la IA
440
        $messages[] = [
441
            'role' => 'user',
442
            'content' => "Añade el puntaje de compatibilidad a las respuestas ya generadas sin modificar el resto de la información.
443
            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.
444
            El JSON debe tener la siguiente estructura:
445
            {
446
                \"uuid1_del_cv_ya_analizado\": puntaje_entre_0_y_100,
447
                \"uuid2_del_cv_ya_analizado\": puntaje_entre_0_y_100,
448
                ...
449
            }
450
            Criterios de la vacante: " . json_encode($criteria)
451
        ];
452
 
453
 
454
        // Enviar consulta a la IA para evaluar la compatibilidad
455
        $evaluationResponse = $this->analyzeCvWithAi($messages);
456
 
457
        $evaluation = $this->cleanContent($evaluationResponse['choices'][0]['message']['content'] ?? '{}');
458
        // Decodificar la respuesta de la IA
459
        $evaluation = json_decode($evaluation, true);
460
 
461
        // Asignar solo el puntaje de compatibilidad correcto a cada CV
462
        foreach ($extractedData as &$data) {
463
            $data['compatibility_score'] = $evaluation[$data['uuid']] ?? 0;
464
        }
465
 
497 ariadna 466
        // Guardar o actualizar los datos en la base de datos
467
        $saveResult = $this->saveOrUpdatePreApplications($extractedData);
468
 
469
        // Validar si la inserción falló
470
        if ($saveResult !== true) {
471
            return [false, 'Error al guardar los datos: ' . json_encode($saveResult)];
472
        }
473
 
494 ariadna 474
        // Retornar los datos procesados con las puntuaciones de compatibilidad
475
        return $extractedData;
476
    }
477
 
497 ariadna 478
    private function saveOrUpdatePreApplications(array $extractedData)
479
    {
480
        $sql = new Sql($this->adapter);
481
        $errors = [];
482
 
483
        foreach ($extractedData as $data) {
484
            $select = $sql->select()
485
                ->from('tbl_recruitment_selection_pre_aplications')
486
                ->where(['uuid' => $data['uuid'], 'full_name' => $data['full_name']]);
487
 
488
            $statement = $sql->prepareStatementForSqlObject($select);
489
            $result = $statement->execute();
490
 
491
            if ($result->current()) {
492
                // Si existe, realizar UPDATE
493
                $update = $sql->update('tbl_recruitment_selection_pre_aplications')
494
                    ->set([
495
                        'vacancy_id' => $data['vacancy_id'],
496
                        'location' => $data['location'],
497
                        'years_experience' => $data['years_of_experience'],
498
                        'previous_positions' => $data['previous_positions'],
499
                        'specialization' => $data['specialization'],
500
                        'skills' => $data['skills'],
501
                        'vacancy_compatibility_score' => $data['compatibility_score']
502
                    ])
503
                    ->where(['uuid' => $data['uuid'], 'full_name' => $data['full_name']]);
504
 
505
                $updateStatement = $sql->prepareStatementForSqlObject($update);
506
                try {
507
                    $updateStatement->execute();
508
                } catch (\Exception $e) {
509
                    $errors[] = "Error al actualizar: " . $e->getMessage();
510
                }
511
            } else {
512
                // Si no existe, realizar INSERT
513
                $insert = $sql->insert('tbl_recruitment_selection_pre_aplications')
514
                    ->values([
515
                        'uuid' => $data['uuid'],
516
                        'vacancy_id' => $data['vacancy_id'],
517
                        'full_name' => $data['full_name'],
518
                        'location' => $data['location'],
519
                        'years_experience' => $data['years_of_experience'],
520
                        'previous_positions' => $data['previous_positions'],
521
                        'specialization' => $data['specialization'],
522
                        'skills' => $data['skills'],
523
                        'vacancy_compatibility_score' => $data['compatibility_score']
524
                    ]);
525
 
526
                $insertStatement = $sql->prepareStatementForSqlObject($insert);
527
                try {
528
                    $result = $insertStatement->execute();
529
                    if (!$result->getGeneratedValue()) {
530
                        $errors[] = "Falló la inserción para UUID: " . $data['uuid'];
531
                    }
532
                } catch (\Exception $e) {
533
                    $errors[] = "Error al insertar: " . $e->getMessage();
534
                }
535
            }
536
        }
537
 
538
        return empty($errors) ? true : $errors;
539
    }
540
 
494 ariadna 541
    function callExternalApi($url, $payload, $headers)
542
    {
543
        $ch = curl_init($url);
544
        curl_setopt($ch, CURLOPT_CAINFO, '/etc/apache2/ssl/cacert.pem');
545
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
546
        curl_setopt($ch, CURLOPT_POST, true);
547
        curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
548
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
549
 
550
        $response = curl_exec($ch);
551
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
552
        $curlError = curl_error($ch);
553
 
554
        curl_close($ch);
555
 
556
        // Verificar si hubo un error en la petición
557
        if ($curlError) {
558
            error_log("cURL Error: " . $curlError);
559
            return [false, "cURL Error: " . $curlError];
560
        }
561
 
562
        // Si la API devuelve un código de error (4xx o 5xx), registrarlo
563
        if ($httpCode >= 400) {
564
            error_log("Error HTTP {$httpCode}: " . $response);
565
            return [false, "Error HTTP {$httpCode}: " . $response];
566
        }
567
 
568
        // Intentar decodificar la respuesta JSON
569
        $decodedResponse = json_decode($response, true);
570
 
571
        // Verificar si la respuesta es válida
572
        if (json_last_error() !== JSON_ERROR_NONE) {
573
            error_log("Error al decodificar JSON: " . json_last_error_msg());
574
            return [false, "Error al decodificar JSON: " . json_last_error_msg()];
575
        }
576
 
577
        return $decodedResponse;
578
    }
579
 
580
    function analyzeCvWithAi(array $messages)
581
    {
582
        $apiKey = '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';
583
        $url = "https://api.openai.com/v1/chat/completions";
584
 
585
        $payload = json_encode([
586
            'model' => 'gpt-4o-mini',
587
            'messages' => $messages,
588
            'temperature' => 0.7
589
        ]);
590
 
591
        $headers = [
592
            'Content-Type: application/json',
593
            "Authorization: Bearer $apiKey"
594
        ];
595
 
596
        return $this->callExternalApi($url, $payload, $headers);
597
    }
598
}