|
|
|
import os
|
|
import pandas as pd
|
|
import json
|
|
import textwrap
|
|
from scipy import spatial
|
|
from datetime import datetime
|
|
from openai import OpenAI
|
|
|
|
class ProcesadorCV:
|
|
|
|
def __init__(self, api_key, cv_text, job_text, ner_pre_prompt, system_prompt, user_prompt, ner_schema, response_schema,
|
|
inference_model="gpt-4o-mini", embeddings_model="text-embedding-3-small"):
|
|
"""
|
|
Inicializa una instancia de la clase con los parámetros proporcionados.
|
|
|
|
Args:
|
|
api_key (str): La clave de API para autenticar con el cliente OpenAI.
|
|
cv_text (str): contenido del CV en formato de texto.
|
|
job_text (str): título de la oferta de trabajo a evaluar.
|
|
ner_pre_prompt (str): instrucción de "reconocimiento de entidades nombradas" (NER) para el modelo en lenguaje natural.
|
|
system_prompt (str): instrucción en lenguaje natural para la salida estructurada final.
|
|
user_prompt (str): instrucción con los parámetros y datos calculados en el preprocesamiento.
|
|
ner_schema (dict): esquema para la llamada con "structured outputs" al modelo de OpenAI para NER.
|
|
response_schema (dict): esquema para la respuesta final de la aplicación.
|
|
inference_model (str, opcional): El modelo de inferencia a utilizar. Por defecto es "gpt-4o-mini".
|
|
embeddings_model (str, opcional): El modelo de embeddings a utilizar. Por defecto es "text-embedding-3-small".
|
|
|
|
Atributos:
|
|
inference_model (str): Almacena el modelo de inferencia seleccionado.
|
|
embeddings_model (str): Almacena el modelo de embeddings seleccionado.
|
|
client (OpenAI): Instancia del cliente OpenAI inicializada con la clave de API proporcionada.
|
|
cv (str): Almacena el texto del currículum vitae proporcionado.
|
|
|
|
"""
|
|
self.inference_model = inference_model
|
|
self.embeddings_model = embeddings_model
|
|
self.ner_pre_prompt = ner_pre_prompt
|
|
self.user_prompt = user_prompt
|
|
self.system_prompt = system_prompt
|
|
self.ner_schema = ner_schema
|
|
self.response_schema = response_schema
|
|
self.client = OpenAI(api_key=api_key)
|
|
self.cv = cv_text
|
|
self.job_text = job_text
|
|
print("Cliente inicializado como",self.client)
|
|
|
|
def extraer_datos_cv(self, temperature=0.5):
|
|
"""
|
|
Extrae datos estructurados de un CV con OpenAI API.
|
|
Args:
|
|
pre_prompt (str): instrucción para el modelo en lenguaje natural.
|
|
schema (dict): esquema de los parámetros que se espera extraer del CV.
|
|
temperature (float, optional): valor de temperatura para el modelo de lenguaje. Por defecto es 0.5.
|
|
Returns:
|
|
pd.DataFrame: DataFrame con los datos estructurados extraídos del CV.
|
|
Raises:
|
|
ValueError: si no se pueden extraer datos estructurados del CV.
|
|
"""
|
|
response = self.client.chat.completions.create(
|
|
model=self.inference_model,
|
|
temperature=temperature,
|
|
messages=[
|
|
{"role": "system", "content": self.ner_pre_prompt},
|
|
{"role": "user", "content": self.cv}
|
|
],
|
|
functions=[
|
|
{
|
|
"name": "extraer_datos_cv",
|
|
"description": "Extrae tabla con títulos de puesto de trabajo, nombres de empresa y períodos de un CV.",
|
|
"parameters": self.ner_schema
|
|
}
|
|
],
|
|
function_call="auto"
|
|
)
|
|
|
|
if response.choices[0].message.function_call:
|
|
function_call = response.choices[0].message.function_call
|
|
structured_output = json.loads(function_call.arguments)
|
|
if structured_output.get("experiencia"):
|
|
df_cv = pd.DataFrame(structured_output["experiencia"])
|
|
return df_cv
|
|
else:
|
|
raise ValueError(f"No se han podido extraer datos estructurados: {response.choices[0].message.content}")
|
|
else:
|
|
raise ValueError(f"No se han podido extraer datos estructurados: {response.choices[0].message.content}")
|
|
|
|
|
|
def procesar_periodos(self, df):
|
|
"""
|
|
Procesa los períodos en un DataFrame y añade columnas con las fechas de inicio, fin y duración en meses.
|
|
Si no hay fecha de fin, se considera la fecha actual.
|
|
Args:
|
|
df (pandas.DataFrame): DataFrame que contiene una columna 'periodo' con períodos en formato 'YYYYMM-YYYYMM' o 'YYYYMM'.
|
|
Returns:
|
|
pandas.DataFrame: DataFrame con columnas adicionales 'fec_inicio', 'fec_final' y 'duracion'.
|
|
- 'fec_inicio' (datetime.date): Fecha de inicio del período.
|
|
- 'fec_final' (datetime.date): Fecha de fin del período.
|
|
- 'duracion' (int): Duración del período en meses.
|
|
"""
|
|
|
|
def split_periodo(periodo):
|
|
dates = periodo.split('-')
|
|
start_date = datetime.strptime(dates[0], "%Y%m")
|
|
if len(dates) > 1:
|
|
end_date = datetime.strptime(dates[1], "%Y%m")
|
|
else:
|
|
end_date = datetime.now()
|
|
return start_date, end_date
|
|
|
|
df[['fec_inicio', 'fec_final']] = df['periodo'].apply(lambda x: pd.Series(split_periodo(x)))
|
|
|
|
|
|
df['fec_inicio'] = df['fec_inicio'].dt.date
|
|
df['fec_final'] = df['fec_final'].dt.date
|
|
|
|
|
|
df['duracion'] = df.apply(
|
|
lambda row: (row['fec_final'].year - row['fec_inicio'].year) * 12 +
|
|
row['fec_final'].month - row['fec_inicio'].month,
|
|
axis=1
|
|
)
|
|
|
|
return df
|
|
|
|
|
|
def calcular_embeddings(self, df, column='puesto', model_name='text-embedding-3-small'):
|
|
"""
|
|
Calcula los embeddings de una columna de un dataframe con OpenAI API.
|
|
Args:
|
|
cv_df (pandas.DataFrame): DataFrame con los datos de los CV.
|
|
column (str, optional): Nombre de la columna que contiene los datos a convertir en embeddings. Por defecto es 'puesto'.
|
|
model_name (str, optional): Nombre del modelo de embeddings. Por defecto es 'text-embedding-3-small'.
|
|
"""
|
|
df['embeddings'] = df[column].apply(
|
|
lambda puesto: self.client.embeddings.create(
|
|
input=puesto,
|
|
model=model_name
|
|
).data[0].embedding
|
|
)
|
|
return df
|
|
|
|
|
|
def calcular_distancias(self, df, column='embeddings', model_name='text-embedding-3-small'):
|
|
"""
|
|
Calcula la distancia coseno entre los embeddings del texto y los incluidos en una columna del dataframe.
|
|
Params:
|
|
df (pandas.DataFrame): DataFrame que contiene los embeddings.
|
|
column (str, optional): nombre de la columna del DataFrame que contiene los embeddings. Por defecto, 'embeddings'.
|
|
model_name (str, optional): modelo de embeddings de la API de OpenAI. Por defecto "text-embedding-3-small".
|
|
Returns:
|
|
pandas.DataFrame: DataFrame ordenado de menor a mayor distancia, con las distancias en una nueva columna.
|
|
"""
|
|
response = self.client.embeddings.create(
|
|
input=self.job_text,
|
|
model=model_name
|
|
)
|
|
emb_compare = response.data[0].embedding
|
|
|
|
df['distancia'] = df[column].apply(lambda emb: spatial.distance.cosine(emb, emb_compare))
|
|
df.drop(columns=[column], inplace=True)
|
|
df.sort_values(by='distancia', ascending=True, inplace=True)
|
|
return df
|
|
|
|
|
|
def calcular_puntuacion(self, df, req_experience, positions_cap=4, dist_threshold_low=0.6, dist_threshold_high=0.7):
|
|
"""
|
|
Calcula la puntuación de un CV a partir de su tabla de distancias (con respecto a un puesto dado) y duraciones.
|
|
|
|
Params:
|
|
df (pandas.DataFrame): datos de un CV incluyendo diferentes experiencias incluyendo duracies y distancia previamente calculadas sobre los embeddings de un puesto de trabajo
|
|
req_experience (float): experiencia requerida en meses para el puesto de trabajo (valor de referencia para calcular una puntuación entre 0 y 100 en base a diferentes experiencias)
|
|
positions_cap (int, optional): Maximum number of positions to consider for scoring. Defaults to 4.
|
|
dist_threshold_low (float, optional): Distancia entre embeddings a partir de la cual el puesto del CV se considera "equivalente" al de la oferta.
|
|
max_dist_threshold (float, optional): Distancia entre embeddings a partir de la cual el puesto del CV no puntúa.
|
|
|
|
Returns:
|
|
pandas.DataFrame: DataFrame original añadiendo una columna con las puntuaciones individuales contribuidas por cada puesto.
|
|
float: Puntuación total entre 0 y 100.
|
|
"""
|
|
|
|
df['duration_capped'] = df['duracion'].apply(lambda x: min(x, req_experience))
|
|
|
|
df['adjusted_distance'] = df['distancia'].apply(
|
|
lambda x: 0 if x <= dist_threshold_low else (
|
|
1 if x >= dist_threshold_high else (x - dist_threshold_low) / (dist_threshold_high - dist_threshold_low)
|
|
)
|
|
)
|
|
|
|
df['position_score'] = round(((1 - df['adjusted_distance']) * (df['duration_capped']/req_experience) * 100), 2)
|
|
|
|
df.loc[df['distancia'] >= dist_threshold_high, 'position_score'] = 0
|
|
df = df.sort_values(by='position_score', ascending=False)
|
|
|
|
df.iloc[positions_cap:, df.columns.get_loc('position_score')] = 0
|
|
|
|
total_score = round(min(df['position_score'].sum(), 100), 2)
|
|
return df, total_score
|
|
|
|
def filtra_experiencia_relevante(self, df):
|
|
"""
|
|
Filtra las experiencias relevantes del dataframe y las devuelve en formato diccionario.
|
|
Args:
|
|
df (pandas.DataFrame): DataFrame con la información completa de experiencia.
|
|
Returns:
|
|
dict: Diccionario con las experiencias relevantes.
|
|
"""
|
|
df_experiencia = df[df['position_score'] > 0].copy()
|
|
df_experiencia.drop(columns=['periodo', 'fec_inicio', 'fec_final',
|
|
'distancia', 'duration_capped', 'adjusted_distance'], inplace=True)
|
|
experiencia_dict = df_experiencia.to_dict(orient='list')
|
|
return experiencia_dict
|
|
|
|
def llamada_final(self, req_experience, puntuacion, dict_experiencia):
|
|
"""
|
|
Realiza la llamada final al modelo de lenguaje para generar la respuesta final.
|
|
Args:
|
|
req_experience (int): Experiencia requerida en meses para el puesto de trabajo.
|
|
puntuacion (float): Puntuación total del CV.
|
|
dict_experiencia (dict): Diccionario con las experiencias relevantes.
|
|
Returns:
|
|
dict: Diccionario con la respuesta final.
|
|
"""
|
|
messages = [
|
|
{
|
|
"role": "system",
|
|
"content": self.system_prompt
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": self.user_prompt.format(job=self.job_text, req_experience=req_experience,puntuacion=puntuacion, exp=dict_experiencia)
|
|
}
|
|
]
|
|
|
|
functions = [
|
|
{
|
|
"name": "respuesta_formateada",
|
|
"description": "Devuelve el objeto con puntuacion, experiencia y descripcion de la experiencia",
|
|
"parameters": self.response_schema
|
|
}
|
|
]
|
|
|
|
response = self.client.chat.completions.create(
|
|
model=self.inference_model,
|
|
temperature=0.5,
|
|
messages=messages,
|
|
functions=functions,
|
|
function_call={"name": "respuesta_formateada"}
|
|
)
|
|
|
|
if response.choices[0].message.function_call:
|
|
function_call = response.choices[0].message.function_call
|
|
structured_output = json.loads(function_call.arguments)
|
|
print("Respuesta:\n", json.dumps(structured_output, indent=4, ensure_ascii=False))
|
|
wrapped_description = textwrap.fill(structured_output['descripcion de la experiencia'], width=120)
|
|
print(f"Descripción de la experiencia:\n{wrapped_description}")
|
|
return structured_output
|
|
else:
|
|
raise ValueError(f"Error. No se ha podido generar respuesta:\n {response.choices[0].message.content}")
|
|
|
|
def procesar_cv_completo(self, req_experience, positions_cap, dist_threshold_low, dist_threshold_high):
|
|
"""
|
|
Procesa un CV y calcula la puntuación final.
|
|
Args:
|
|
req_experience (int, optional): Experiencia requerida en meses para el puesto de trabajo.
|
|
positions_cap (int, optional): Número máximo de puestos a considerar para la puntuación.
|
|
dist_threshold_low (float, optional): Distancia límite para considerar un puesto equivalente.
|
|
dist_threshold_high (float, optional): Distancia límite para considerar un puesto no relevante.
|
|
Returns:
|
|
pd.DataFrame: DataFrame con las puntuaciones individuales contribuidas por cada puesto.
|
|
float: Puntuación total entre 0 y 100.
|
|
"""
|
|
df_datos_estructurados_cv = self.extraer_datos_cv()
|
|
df_datos_estructurados_cv = self.procesar_periodos(df_datos_estructurados_cv)
|
|
df_con_embeddings = self.calcular_embeddings(df_datos_estructurados_cv)
|
|
df_con_distancias = self.calcular_distancias(df_con_embeddings)
|
|
df_puntuaciones, puntuacion = self.calcular_puntuacion(df_con_distancias,
|
|
req_experience=req_experience,
|
|
positions_cap=positions_cap,
|
|
dist_threshold_low=dist_threshold_low,
|
|
dist_threshold_high=dist_threshold_high)
|
|
dict_experiencia = self.filtra_experiencia_relevante(df_puntuaciones)
|
|
dict_respuesta = self.llamada_final(req_experience, puntuacion, dict_experiencia)
|
|
return dict_respuesta |