Proyectos de Subversion LeadersLinked - Services

Rev

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

Rev Autor Línea Nro. Línea
512 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\Adapter\Adapter;
15
use Laminas\Db\Sql\Sql;
16
use Laminas\Db\ResultSet\HydratingResultSet;
17
use Laminas\Hydrator\ArraySerializableHydrator;
516 ariadna 18
use LeadersLinked\Mapper\CompetencyMapper;
512 ariadna 19
use LeadersLinked\Mapper\JobDescriptionMapper;
516 ariadna 20
use LeadersLinked\Mapper\JobDescriptionCompetencyMapper;
512 ariadna 21
use LeadersLinked\Mapper\LocationMapper;
22
use LeadersLinked\Mapper\QueryMapper;
23
use ArrayObject;
526 ariadna 24
use Laminas\Db\Sql\Expression;
512 ariadna 25
 
26
class RecruitmentCreateJobDescriptionController extends AbstractActionController
27
{
28
    /**
29
     *
30
     * @var \Laminas\Db\Adapter\AdapterInterface
31
     */
32
    private $adapter;
33
 
34
    /**
35
     *
36
     * @var \LeadersLinked\Cache\CacheInterface
37
     */
38
    private $cache;
39
 
40
 
41
    /**
42
     *
43
     * @var \Laminas\Log\LoggerInterface
44
     */
45
    private $logger;
46
 
47
    /**
48
     *
49
     * @var array
50
     */
51
    private $config;
52
 
53
 
54
    /**
55
     *
56
     * @var \Laminas\Mvc\I18n\Translator
57
     */
58
    private $translator;
59
 
60
 
61
    /**
62
     *
63
     * @param \Laminas\Db\Adapter\AdapterInterface $adapter
64
     * @param \LeadersLinked\Cache\CacheInterface $cache
65
     * @param \Laminas\Log\LoggerInterface LoggerInterface $logger
66
     * @param array $config
67
     * @param \Laminas\Mvc\I18n\Translator $translator
68
     */
69
    public function __construct($adapter, $cache, $logger, $config, $translator)
70
    {
71
        $this->adapter      = $adapter;
72
        $this->cache        = $cache;
73
        $this->logger       = $logger;
74
        $this->config       = $config;
75
        $this->translator   = $translator;
76
    }
77
 
78
    public function indexAction()
79
    {
80
        $request = $this->getRequest();
81
 
82
        // 🔹 Obtener el ID desde la ruta (URL) con el formato /endpoint/:id
516 ariadna 83
        $jobDescriptionId = $this->params()->fromRoute('id');
512 ariadna 84
 
85
        // 🔹 Verificar si el ID es válido
516 ariadna 86
        if (!$jobDescriptionId) {
512 ariadna 87
            return new JsonModel([
88
                'success' => false,
89
                'message' => 'Missing vacancy ID'
90
            ]);
91
        }
92
 
93
        // 🔹 Obtener usuario y permisos ACL
94
        $currentUserPlugin = $this->plugin('currentUserPlugin');
95
        $currentUser = $currentUserPlugin->getUser();
96
        $acl = $this->getEvent()->getViewModel()->getVariable('acl');
97
 
98
        // 🔹 Verificar si el usuario tiene permiso para extraer criterios
516 ariadna 99
        if (!$acl->isAllowed($currentUser->usertype_id, 'recruitment-ai/job-description')) {
512 ariadna 100
            return new JsonModel([
101
                'success' => false,
102
                'message' => 'Access denied'
103
            ]);
104
        }
105
 
106
        if ($request->isGet()) {
107
 
108
            // Llamar a la función que obtiene los archivos de la vacante
538 ariadna 109
            $data = $this->analyzeJobDescriptionCompetencies($jobDescriptionId);
512 ariadna 110
 
111
            return new JsonModel([
112
                'success' => true,
523 ariadna 113
                'data' => $data
512 ariadna 114
            ]);
115
        }
116
 
516 ariadna 117
        // Si el método no es GET ni POST
118
        return new JsonModel([
119
            'success' => false,
120
            'message' => 'Invalid request method'
121
        ]);
122
    }
512 ariadna 123
 
516 ariadna 124
    public function getJobDescriptionCompetencies($jobDescriptionId)
125
    {
126
        // 🔹 Validación básica del ID
127
        if (!is_numeric($jobDescriptionId) || $jobDescriptionId <= 0) {
128
            return null;
129
        }
512 ariadna 130
 
516 ariadna 131
        try {
132
            // 🔹 Crear el QueryMapper
133
            $queryMapper = QueryMapper::getInstance($this->adapter);
134
            $select = $queryMapper->getSql()->select();
512 ariadna 135
 
516 ariadna 136
            // 🔹 FROM JobDescriptionMapper
137
            $select->from(['jd' => JobDescriptionMapper::_TABLE]);
138
            $select->columns([
139
                'name',
140
                'functions',
141
                'objectives'
142
            ]);
143
 
144
            // 🔹 JOIN con JobDescriptionCompetencyMapper
145
            $select->join(
146
                ['jdc' => JobDescriptionCompetencyMapper::_TABLE],
147
                'jd.id = jdc.job_description_id',
148
                [] // No seleccionamos nada directamente de esta tabla
149
            );
150
 
151
            // 🔹 JOIN con CompetencyMapper
152
            $select->join(
153
                ['c' => CompetencyMapper::_TABLE],
154
                'jdc.competency_id = c.id',
155
                [
526 ariadna 156
                    'competency_id' => 'id',
516 ariadna 157
                    'competency_name' => 'name',
158
                    'competency_description' => 'description'
159
                ]
160
            );
161
 
162
            // 🔹 WHERE por ID de descripción de trabajo
163
            $select->where->equalTo('jd.id', $jobDescriptionId);
164
 
165
            // 🔹 Ejecutar la consulta
166
            $statement = $queryMapper->getSql()->prepareStatementForSqlObject($select);
167
            $resultSet = $statement->execute();
168
 
169
            // 🔹 Procesar resultados
170
            $hydrator = new ArraySerializableHydrator();
171
            $hydratingResultSet = new HydratingResultSet($hydrator);
172
            $hydratingResultSet->initialize($resultSet);
173
 
174
            // 🔹 Agrupar resultados por competencias
175
            $jobInfo = null;
176
            $competencies = [];
177
 
178
            foreach ($hydratingResultSet as $row) {
179
                if (!$jobInfo) {
180
                    $jobInfo = [
181
                        'name' => $row['name'],
182
                        'functions' => $row['functions'],
183
                        'objectives' => $row['objectives']
184
                    ];
185
                }
186
 
187
                $competencies[] = [
526 ariadna 188
                    'id' => $row['competency_id'],
516 ariadna 189
                    'name' => $row['competency_name'],
190
                    'description' => $row['competency_description']
191
                ];
512 ariadna 192
            }
193
 
516 ariadna 194
            if (!$jobInfo) {
195
                return null;
196
            }
512 ariadna 197
 
516 ariadna 198
            // 🔹 Construir el texto formateado
199
            $formattedText =
200
                "### Información del Trabajo\n" .
201
                "Nombre: {$jobInfo['name']}\n" .
202
                "Funciones: {$jobInfo['functions']}\n" .
203
                "Objetivos: {$jobInfo['objectives']}\n\n" .
204
                "### Competencias requeridas:\n";
205
 
206
            foreach ($competencies as $index => $comp) {
537 ariadna 207
                $formattedText .= ($index + 1) . ". {$comp['name']} - descripcion: {$comp['description']} - id: {$comp['id']}\n";
512 ariadna 208
            }
516 ariadna 209
 
210
            return $formattedText;
211
        } catch (\Exception $e) {
212
            // Aquí podrías loggear el error si es necesario
213
            return null;
512 ariadna 214
        }
215
    }
518 ariadna 216
 
217
    function cleanContent($text)
218
    {
219
        // Eliminar la palabra "json"
220
        $text = str_replace("json", "", $text);
221
 
222
        // Eliminar los saltos de línea \n
223
        $text = str_replace("\n", "", $text);
224
 
225
        // Eliminar los acentos invertidos (```)
226
        $text = str_replace("```", "", $text);
227
 
228
        // Retornar el contenido arreglado
229
        return $text;
230
    }
231
 
232
    public function analyzeJobDescriptionCompetencies($jobDescriptionId)
233
    {
234
        // 🔹 Obtener texto de la descripción de cargo y competencias
235
        $descriptionText = $this->getJobDescriptionCompetencies($jobDescriptionId);
236
 
237
        if (!$descriptionText) {
238
            return [
239
                'success' => false,
240
                'message' => 'No se encontró la descripción del cargo.',
241
                'data' => null
242
            ];
243
        }
244
 
245
        // 🔹 Crear el mensaje para OpenAI
246
        $messages = [
247
            [
248
                'role' => 'system',
249
                'content' => "Eres un experto en talento humano, análisis de perfiles laborales y gestión de competencias para
250
                el mercado laboral actual."
251
            ],
252
            [
253
                'role' => 'user',
254
                'content' => "A continuación te proporciono una descripción de un cargo con sus funciones, objetivos y competencias requeridas.
255
                Analiza si las competencias listadas están actualizadas para los requerimientos actuales de la industria y el trabajo moderno.
539 ariadna 256
                Si es necesario añade nuevas competencias sugeridas para el cargo y cambia la descripción de las que se te proprcionaron en base al puesto (no coloques
525 ariadna 257
                sugerencias en la descripcion, coloca las funciones de la misma únicamente).
518 ariadna 258
 
259
                Retorna una respuesta estructurada en formato JSON como este:
260
 
261
                {
523 ariadna 262
                \"is_updated\": true o false,
263
                \"list_competencies\": [
518 ariadna 264
                    {
526 ariadna 265
                    \"has_id\": \"{'bool': true o false, 'nro': 'id', si no tiene 0}\",
520 ariadna 266
                    \"name\": \"nombre de la competencia\",
267
                    \"description\": \"recomendación o análisis de si está o no actualizada, o si falta complementar\"
518 ariadna 268
                    }
269
                ]
270
                }
271
 
272
                Texto a analizar:
273
                \"\"\"$descriptionText\"\"\"
274
                "
275
            ]
276
        ];
277
 
278
        // 🔹 Consultar OpenAI
279
        $response = $this->analyzeCvWithAi($messages);
280
 
522 ariadna 281
        // 🔹 Validar y retornar
282
        if (!isset($response)) {
283
            return [
284
                'success' => false,
285
                'message' => 'Error al consultar la API de OpenAI',
286
                'data' => $response['message'] ?? null
287
            ];
288
        }
289
 
290
        // 🔹 Intentar extraer JSON del mensaje generado
291
        $reply = $this->cleanContent($response['choices'][0]['message']['content'] ?? '{}');
292
 
293
        if (!$reply) {
294
            return [
295
                'success' => false,
296
                'message' => 'No se obtuvo una respuesta válida de la IA.',
297
                'data' => null
298
            ];
299
        }
300
 
301
        // 🔹 Intentar decodificar respuesta JSON (por si OpenAI responde directamente con JSON)
302
        $decoded = json_decode($reply, true);
303
 
304
        if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
540 ariadna 305
            //return $decoded;
541 ariadna 306
            return $this->insertOrUpdateCompetenciesFromAIResponse($jobDescriptionId, $decoded['list_competencies']);
522 ariadna 307
        }
308
 
309
        // 🔹 Si no fue posible decodificar, devolver contenido bruto para revisión
310
        return [
311
            'success' => false,
312
            'message' => 'No se pudo decodificar el JSON de la respuesta',
313
            'data' => $reply
314
        ];
518 ariadna 315
    }
316
 
528 ariadna 317
    public function generateUuid()
318
    {
319
        $data = random_bytes(16);
320
 
321
        // Establecer las versiones y variantes de UUID según la RFC 4122
322
        $data[6] = chr(ord($data[6]) & 0x0f | 0x40); // versión 4
323
        $data[8] = chr(ord($data[8]) & 0x3f | 0x80); // variante RFC 4122
324
 
325
        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
326
    }
526 ariadna 327
    public function insertOrUpdateCompetenciesFromAIResponse($jobDescriptionId, array $aiCompetencies, $competencyTypeId = 1)
328
    {
329
        if (!is_numeric($jobDescriptionId) || $jobDescriptionId <= 0) {
530 ariadna 330
            return [
331
                'success' => false,
332
                'message' => 'ID de descripción inválido',
333
                'data' => []
334
            ];
526 ariadna 335
        }
336
 
540 ariadna 337
        // 🔹 Validar que $aiCompetencies sea un array no vacío y de objetos/datos
338
        if (empty($aiCompetencies) || !is_array($aiCompetencies) || count($aiCompetencies) === 0) {
339
            return [
340
                'success' => false,
341
                'message' => 'No se proporcionaron competencias para procesar.',
342
                'data' => []
343
            ];
344
        }
345
 
526 ariadna 346
        $adapter = $this->adapter;
347
        $sql = new Sql($adapter);
348
        $results = [];
349
 
350
        foreach ($aiCompetencies as $comp) {
540 ariadna 351
            // 🔹 Validar que cada elemento sea un array
352
            if (!is_array($comp)) {
353
                continue; // Ignoramos elementos inválidos
354
            }
355
 
530 ariadna 356
            // 🔹 Preparar datos
526 ariadna 357
            $name = trim($comp['name'] ?? '');
358
            $description = trim($comp['description'] ?? '');
530 ariadna 359
            $hasIdRaw = str_replace("'", '"', $comp['has_id'] ?? '{"bool": false, "nro": 0}');
360
            $hasIdData = json_decode($hasIdRaw, true);
526 ariadna 361
 
362
            if (!$name || !$description || !is_array($hasIdData)) {
540 ariadna 363
                continue; // Datos incompletos o corruptos, ignoramos
526 ariadna 364
            }
365
 
366
            $competencyId = null;
367
            $status = '';
368
 
530 ariadna 369
            // 🔹 Crear nueva competencia si nro == 0
370
            if ((int) ($hasIdData['nro'] ?? 0) === 0) {
526 ariadna 371
                $uuid = $this->generateUuid();
530 ariadna 372
 
532 ariadna 373
                $insert = $sql->insert(CompetencyMapper::_TABLE)
526 ariadna 374
                    ->values([
375
                        'uuid' => $uuid,
376
                        'name' => $name,
377
                        'description' => $description,
378
                        'competency_type_id' => $competencyTypeId,
532 ariadna 379
                        'status' => CompetencyMapper::STATUS_ACTIVE,
526 ariadna 380
                        'added_on' => date('Y-m-d H:i:s'),
381
                        'updated_on' => date('Y-m-d H:i:s')
382
                    ]);
532 ariadna 383
                $stmt = $sql->prepareStatementForSqlObject($insert)->execute();
384
 
385
                if ($stmt->getAffectedRows() === 0) {
386
                    return [
387
                        'success' => false,
388
                        'message' => "Error al insertar la competencia '{$name}'",
389
                        'data' => []
390
                    ];
391
                }
392
 
526 ariadna 393
                $competencyId = $adapter->getDriver()->getLastGeneratedValue();
394
                $status = 'created';
395
            }
530 ariadna 396
            // 🔹 Actualizar competencia existente si nro > 0
526 ariadna 397
            else {
398
                $competencyId = (int) $hasIdData['nro'];
399
 
532 ariadna 400
                $select = $sql->select(CompetencyMapper::_TABLE)
526 ariadna 401
                    ->where(['id' => $competencyId]);
402
                $existing = $sql->prepareStatementForSqlObject($select)->execute()->current();
403
 
530 ariadna 404
                if (!$existing) {
405
                    return [
406
                        'success' => false,
407
                        'message' => "ID de competencia {$competencyId} no encontrado",
408
                        'data' => []
409
                    ];
410
                }
526 ariadna 411
 
540 ariadna 412
                // Solo actualizar si cambia el nombre o la descripción
530 ariadna 413
                if ($existing['name'] !== $name || $existing['description'] !== $description) {
532 ariadna 414
                    $update = $sql->update(CompetencyMapper::_TABLE)
530 ariadna 415
                        ->set([
416
                            'name' => $name,
417
                            'description' => $description,
418
                            'updated_on' => date('Y-m-d H:i:s')
419
                        ])
420
                        ->where(['id' => $competencyId]);
532 ariadna 421
                    $stmt = $sql->prepareStatementForSqlObject($update)->execute();
422
 
423
                    if ($stmt->getAffectedRows() === 0) {
424
                        return [
425
                            'success' => false,
426
                            'message' => "Error al actualizar la competencia '{$name}' (ID {$competencyId})",
427
                            'data' => []
428
                        ];
429
                    }
526 ariadna 430
                }
530 ariadna 431
 
432
                $status = 'updated';
526 ariadna 433
            }
434
 
540 ariadna 435
            // 🔹 Vincular competencia con job description
532 ariadna 436
            $linkCheck = $sql->select(JobDescriptionCompetencyMapper::_TABLE)
437
                ->where([
438
                    'job_description_id' => $jobDescriptionId,
439
                    'competency_id' => $competencyId
440
                ]);
441
            $existsLink = $sql->prepareStatementForSqlObject($linkCheck)->execute()->current();
442
 
443
            if (!$existsLink) {
444
                $linkInsert = $sql->insert(JobDescriptionCompetencyMapper::_TABLE)
445
                    ->values([
446
                        'job_description_id' => $jobDescriptionId,
447
                        'competency_id' => $competencyId
448
                    ]);
449
                $stmt = $sql->prepareStatementForSqlObject($linkInsert)->execute();
450
 
451
                if ($stmt->getAffectedRows() === 0) {
452
                    return [
453
                        'success' => false,
454
                        'message' => "Error al vincular competencia '{$name}' con la descripción {$jobDescriptionId}",
455
                        'data' => []
456
                    ];
457
                }
458
            }
459
 
460
            // 🔹 Guardar resultado exitoso
526 ariadna 461
            $results[] = [
462
                'name' => $name,
463
                'competency_id' => $competencyId,
464
                'status' => $status
465
            ];
466
        }
467
 
530 ariadna 468
        return [
469
            'success' => true,
532 ariadna 470
            'message' => 'Procesamiento completado exitosamente.',
530 ariadna 471
            'data' => $results
472
        ];
526 ariadna 473
    }
474
 
540 ariadna 475
 
518 ariadna 476
    function callExternalApi($url, $payload, $headers)
477
    {
478
        $ch = curl_init($url);
479
        curl_setopt($ch, CURLOPT_CAINFO, '/etc/apache2/ssl/cacert.pem');
480
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
481
        curl_setopt($ch, CURLOPT_POST, true);
482
        curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
483
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
484
 
485
        $response = curl_exec($ch);
486
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
487
        $curlError = curl_error($ch);
488
 
489
        curl_close($ch);
490
 
491
        // Verificar si hubo un error en la petición
492
        if ($curlError) {
493
            error_log("cURL Error: " . $curlError);
494
            return [false, "cURL Error: " . $curlError];
495
        }
496
 
497
        // Si la API devuelve un código de error (4xx o 5xx), registrarlo
498
        if ($httpCode >= 400) {
499
            error_log("Error HTTP {$httpCode}: " . $response);
500
            return [false, "Error HTTP {$httpCode}: " . $response];
501
        }
502
 
503
        // Intentar decodificar la respuesta JSON
504
        $decodedResponse = json_decode($response, true);
505
 
506
        // Verificar si la respuesta es válida
507
        if (json_last_error() !== JSON_ERROR_NONE) {
508
            error_log("Error al decodificar JSON: " . json_last_error_msg());
509
            return [false, "Error al decodificar JSON: " . json_last_error_msg()];
510
        }
511
 
512
        return $decodedResponse;
513
    }
514
 
515
    function analyzeCvWithAi(array $messages)
516
    {
517
        $apiKey = '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';
518
        $url = "https://api.openai.com/v1/chat/completions";
519
 
520
        $payload = json_encode([
521
            'model' => 'gpt-4o-mini',
522
            'messages' => $messages,
523
            'temperature' => 0.7
524
        ]);
525
 
526
        $headers = [
527
            'Content-Type: application/json',
528
            "Authorization: Bearer $apiKey"
529
        ];
530
 
531
        return $this->callExternalApi($url, $payload, $headers);
532
    }
512 ariadna 533
}