Proyectos de Subversion LeadersLinked - Services

Rev

Rev 497 | Ir a la última revisión | | 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;
14
use Laminas\Db\Sql\Sql;
15
use Laminas\Db\ResultSet\HydratingResultSet;
16
use Laminas\Hydrator\ArraySerializableHydrator;
17
use LeadersLinked\Mapper\RecruitmentSelectionVacancyMapper;
18
use LeadersLinked\Mapper\JobDescriptionMapper;
19
use LeadersLinked\Mapper\JobCategoryMapper;
20
use LeadersLinked\Mapper\LocationMapper;
21
use LeadersLinked\Mapper\QueryMapper;
22
use ArrayObject;
23
 
24
class RecruitmentPreAplicationController extends AbstractActionController
25
{
26
    /**
27
     *
28
     * @var \Laminas\Db\Adapter\AdapterInterface
29
     */
30
    private $adapter;
31
 
32
    /**
33
     *
34
     * @var \LeadersLinked\Cache\CacheInterface
35
     */
36
    private $cache;
37
 
38
 
39
    /**
40
     *
41
     * @var \Laminas\Log\LoggerInterface
42
     */
43
    private $logger;
44
 
45
    /**
46
     *
47
     * @var array
48
     */
49
    private $config;
50
 
51
 
52
    /**
53
     *
54
     * @var \Laminas\Mvc\I18n\Translator
55
     */
56
    private $translator;
57
 
58
 
59
    /**
60
     *
61
     * @param \Laminas\Db\Adapter\AdapterInterface $adapter
62
     * @param \LeadersLinked\Cache\CacheInterface $cache
63
     * @param \Laminas\Log\LoggerInterface LoggerInterface $logger
64
     * @param array $config
65
     * @param \Laminas\Mvc\I18n\Translator $translator
66
     */
67
    public function __construct($adapter, $cache, $logger, $config, $translator)
68
    {
69
        $this->adapter      = $adapter;
70
        $this->cache        = $cache;
71
        $this->logger       = $logger;
72
        $this->config       = $config;
73
        $this->translator   = $translator;
74
    }
75
 
76
    public function indexAction()
77
    {
78
        $request = $this->getRequest();
79
 
80
        // 🔹 Obtener el ID desde la ruta (URL) con el formato /endpoint/:id
81
        $vacancyId = $this->params()->fromRoute('id');
82
 
83
        // 🔹 Verificar si el ID es válido
84
        if (!$vacancyId) {
85
            return new JsonModel([
86
                'success' => false,
87
                'message' => 'Missing vacancy ID'
88
            ]);
89
        }
90
 
91
        // 🔹 Obtener usuario y permisos ACL
92
        $currentUserPlugin = $this->plugin('currentUserPlugin');
93
        $currentUser = $currentUserPlugin->getUser();
94
        $acl = $this->getEvent()->getViewModel()->getVariable('acl');
95
 
96
        // 🔹 Verificar si el usuario tiene permiso para extraer criterios
97
        if (!$acl->isAllowed($currentUser->usertype_id, 'ia-vacancies-aplications')) {
98
            return new JsonModel([
99
                'success' => false,
100
                'message' => 'Access denied'
101
            ]);
102
        }
103
 
104
        if ($request->isGet()) {
105
 
106
            // Llamar a la función que obtiene los archivos de la vacante
107
            $files = $this->getSelectionFiles($vacancyId);
108
 
109
            return new JsonModel([
110
                'success' => true,
111
                'data' => $files
112
            ]);
113
        }
114
 
115
        if ($request->isPost()) {
116
            // 🔹 Obtener los CVs en Base64 desde el cuerpo de la solicitud
117
            $bodyParams = json_decode($this->getRequest()->getContent(), true);
118
            $cvs = $bodyParams['cvs'] ?? [];
119
 
120
            // 🔹 Verificar si hay CVs
121
            if (empty($cvs)) {
122
                return new JsonModel([
123
                    'success' => false,
124
                    'message' => 'Missing CVs data'
125
                ]);
126
            }
127
 
128
            // 🔹 Obtener criterios desde la base de datos
129
            $criteria = $this->getSelectionCriteriaVacancy($vacancyId);
130
 
131
            // 🔹 Verificar si los criterios existen
132
            if (!$criteria) {
133
                return new JsonModel([
134
                    'success' => false,
135
                    'message' => 'No selection criteria found for this vacancy'
136
                ]);
137
            }
138
 
139
            // 🔹 Procesar los CVs y analizar compatibilidad con los criterios
140
            try {
141
                $processedCvs = $this->processCvs($cvs, $criteria, $vacancyId);
142
 
143
                return new JsonModel([
144
                    'success' => true,
145
                    'data' => $processedCvs
146
                ]);
147
            } catch (\Exception $e) {
148
                return new JsonModel([
149
                    'success' => false,
150
                    'message' => 'Error processing CVs: ' . $e->getMessage()
151
                ]);
152
            }
153
        }
154
 
155
        // Si el método no es GET ni POST
156
        return new JsonModel([
157
            'success' => false,
158
            'message' => 'Invalid request method'
159
        ]);
160
    }
161
 
162
    public function getSelectionCriteriaVacancy($vacancyId)
163
    {
164
        // 🔹 Verificar si el ID es válido
165
        if (!$vacancyId) {
166
            return null;
167
        }
168
 
169
        // 🔹 Construcción de la consulta con QueryMapper
170
        $queryMapper = QueryMapper::getInstance($this->adapter);
171
        $select = $queryMapper->getSql()->select();
172
        $select->from(['tb1' => RecruitmentSelectionVacancyMapper::_TABLE]);
173
        $select->columns(['uuid', 'name', 'status']);
174
 
175
        // 🔹 JOIN con JobDescriptionMapper (incluyendo functions y objectives)
176
        $select->join(
177
            ['tb2' => JobDescriptionMapper::_TABLE],
178
            'tb1.job_description_id = tb2.id AND tb1.company_id = tb2.company_id',
179
            [
180
                'job_description_name' => 'name',
181
                'job_description_functions'   => 'functions',
182
                'job_description_objectives'  => 'objectives'
183
            ]
184
        );
185
 
186
        // 🔹 JOIN con JobCategoryMapper (para obtener nombre y descripción de la categoría)
187
        $select->join(
188
            ['tb3' => JobCategoryMapper::_TABLE],
189
            'tb1.job_category_id = tb3.id',
190
            [
191
                'job_category_name'        => 'name',
192
                'job_category_description' => 'description'
193
            ]
194
        );
195
 
196
        // 🔹 JOIN con LocationMapper (para obtener país y dirección formateada)
197
        $select->join(
198
            ['tb4' => LocationMapper::_TABLE],
199
            'tb1.location_id = tb4.id',
200
            [
201
                'job_location_country' => 'country',
202
                'job_location_address' => 'formatted_address'
203
            ]
204
        );
205
 
206
        // 🔹 Filtrar por el ID de la vacante
207
        $select->where->equalTo('tb1.id', $vacancyId);
208
 
209
        // 🔹 Ejecutar la consulta
210
        $statement = $queryMapper->getSql()->prepareStatementForSqlObject($select);
211
        $resultSet = $statement->execute();
212
 
213
        // 🔹 Procesar los resultados
214
        $hydrator = new ArraySerializableHydrator();
215
        $hydratingResultSet = new HydratingResultSet($hydrator);
216
        $hydratingResultSet->initialize($resultSet);
217
 
218
        // 🔹 Obtener un solo resultado como diccionario
219
        $vacancyData = $hydratingResultSet->current();
220
 
221
        // 🔹 Validar si no se encontró la vacante
222
        if (!$vacancyData) {
223
            return null;
224
        }
225
 
226
        // 🔹 Formatear la respuesta en una variable de texto
227
        $formattedText =
228
            "Nombre: {$vacancyData['name']}\n" .
229
            "### Descripción del trabajo\n" .
230
            "Nombre: {$vacancyData['job_description_name']}\n" .
231
            "Funciones: {$vacancyData['job_description_functions']}\n\n" .
232
            "Objetivos: {$vacancyData['job_description_objectives']}\n\n" .
233
            "### Ubicación\n" .
234
            "País: {$vacancyData['job_location_country']}\n" .
235
            "Dirección: {$vacancyData['job_location_address']}\n";
236
 
237
        return $formattedText;
238
    }
239
 
240
 
241
    public function getSelectionFiles($vacancyId)
242
    {
243
        // 🔹 Verificar si el ID es válido
244
        if (!$vacancyId) {
245
            return new JsonModel([
246
                'success' => true,
247
                'message' => 'No existen aplicaciones para la vacante.',
248
            ]);
249
        }
250
 
251
        // 🔹 Construcción de la consulta SQL
252
        $queryMapper = QueryMapper::getInstance($this->adapter);
253
        $select = $queryMapper->getSql()->select();
254
        $select->from('tbl_recruitment_selection_files');
255
        $select->columns(['uuid', 'id', 'name', 'file']);
256
 
257
        // 🔹 Filtrar por vacancy_id
258
        $select->where->equalTo('vacancy_id', $vacancyId);
259
 
260
        // 🔹 Ejecutar la consulta
261
        $statement = $queryMapper->getSql()->prepareStatementForSqlObject($select);
262
        $resultSet = $statement->execute();
263
 
264
        // 🔹 Procesar los resultados en un array de diccionarios
265
        $hydrator = new ArraySerializableHydrator();
266
        $hydratingResultSet = new HydratingResultSet($hydrator);
267
        $hydratingResultSet->initialize($resultSet);
268
 
269
        // 🔹 Convertir los resultados en un array de diccionarios
270
        $files = [];
271
        foreach ($hydratingResultSet as $row) {
272
            $files[] = $row;
273
        }
274
 
275
        return $files;
276
    }
277
 
278
    function extractTextFromBase64Cvs(array $cvs): array
279
    {
280
        $parser = new Parser();
281
        $results = [];
282
 
283
        foreach ($cvs as $cv) {
284
            $decodedFile = base64_decode($cv['file']); // Decodificar el contenido del archivo
285
            $filePath = sys_get_temp_dir() . '/' . uniqid() . '.pdf'; // Guardarlo temporalmente
286
 
287
            file_put_contents($filePath, $decodedFile);
288
 
289
            // Extraer texto del PDF
290
            $pdf = $parser->parseFile($filePath);
291
            $text = $pdf->getText();
292
 
293
            if (!$text) {
294
                throw new \Exception('Could not extract text from CV: ' . $cv['name']);
295
            }
296
 
297
            $results[] = [
298
                'id' => $cv['id'],
299
                'name' => $cv['name'],
300
                'text' => $text,
301
            ];
302
 
303
            unlink($filePath); // Eliminar archivo temporal
304
        }
305
 
306
        return $results;
307
    }
308
 
309
    function cleanContent($text)
310
    {
311
        // Eliminar la palabra "json"
312
        $text = str_replace("json", "", $text);
313
 
314
        // Eliminar los saltos de línea \n
315
        $text = str_replace("\n", "", $text);
316
 
317
        // Eliminar los acentos invertidos (```)
318
        $text = str_replace("```", "", $text);
319
 
320
        // Retornar el contenido arreglado
321
        return $text;
322
    }
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
 
410
        // Retornar los datos procesados con las puntuaciones de compatibilidad
411
        return $extractedData;
412
    }
413
 
414
    function callExternalApi($url, $payload, $headers)
415
    {
416
        $ch = curl_init($url);
417
        curl_setopt($ch, CURLOPT_CAINFO, '/etc/apache2/ssl/cacert.pem');
418
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
419
        curl_setopt($ch, CURLOPT_POST, true);
420
        curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
421
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
422
 
423
        $response = curl_exec($ch);
424
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
425
        $curlError = curl_error($ch);
426
 
427
        curl_close($ch);
428
 
429
        // Verificar si hubo un error en la petición
430
        if ($curlError) {
431
            error_log("cURL Error: " . $curlError);
432
            return [false, "cURL Error: " . $curlError];
433
        }
434
 
435
        // Si la API devuelve un código de error (4xx o 5xx), registrarlo
436
        if ($httpCode >= 400) {
437
            error_log("Error HTTP {$httpCode}: " . $response);
438
            return [false, "Error HTTP {$httpCode}: " . $response];
439
        }
440
 
441
        // Intentar decodificar la respuesta JSON
442
        $decodedResponse = json_decode($response, true);
443
 
444
        // Verificar si la respuesta es válida
445
        if (json_last_error() !== JSON_ERROR_NONE) {
446
            error_log("Error al decodificar JSON: " . json_last_error_msg());
447
            return [false, "Error al decodificar JSON: " . json_last_error_msg()];
448
        }
449
 
450
        return $decodedResponse;
451
    }
452
 
453
    function analyzeCvWithAi(array $messages)
454
    {
455
        $apiKey = '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';
456
        $url = "https://api.openai.com/v1/chat/completions";
457
 
458
        $payload = json_encode([
459
            'model' => 'gpt-4o-mini',
460
            'messages' => $messages,
461
            'temperature' => 0.7
462
        ]);
463
 
464
        $headers = [
465
            'Content-Type: application/json',
466
            "Authorization: Bearer $apiKey"
467
        ];
468
 
469
        return $this->callExternalApi($url, $payload, $headers);
470
    }
471
}