feliponi commited on
Commit
de99208
·
verified ·
1 Parent(s): 69aa5bc

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +161 -0
  2. requirements.txt +4 -0
  3. scripts/inference.py +162 -0
app.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py
3
+
4
+ Interface Gradio para o Modelo de Extração de Entidades (SKILL, EXPERIENCE_DURATION)
5
+ Carrega o modelo treinado e fornece uma UI para comparar CV e JD.
6
+ """
7
+
8
+ import gradio as gr
9
+ import re
10
+ from typing import List, Dict, Set
11
+
12
+ # Importa o extrator que já criamos
13
+ # HF Spaces irá executar isso da raiz, então o caminho 'scripts' está correto.
14
+ from scripts.inference import EntityExtractor
15
+
16
+ # --- 1. Carregamento do Modelo ---
17
+ # Carrega o modelo na memória apenas uma vez, quando o app inicia.
18
+ # Certifique-se de que este caminho está correto para onde o HF Spaces irá encontrá-lo.
19
+ MODEL_PATH = "feliponi/hirly-ner-multi"
20
+ try:
21
+ extractor = EntityExtractor(MODEL_PATH)
22
+ print(f"Modelo carregado com sucesso de {MODEL_PATH}")
23
+ except Exception as e:
24
+ print(f"ERRO CRÍTICO: Não foi possível carregar o modelo de {MODEL_PATH}.")
25
+ print("Certifique-se de que o modelo treinado está no diretório correto.")
26
+ # Se o modelo não carregar, o Gradio falhará, o que é esperado.
27
+ extractor = None
28
+
29
+ # --- 2. Lógica de Negócio (Nova) ---
30
+
31
+ def parse_and_sum_experience(entities: List[Dict]) -> float:
32
+ """
33
+ Analisa os spans de 'EXPERIENCE_DURATION' e os soma em anos.
34
+ Esta é uma lógica de negócio e pode ser complexa.
35
+
36
+ Exemplos de conversão:
37
+ - "5+ years" -> 5.0
38
+ - "6 months" -> 0.5
39
+ - "3-5 anos" -> 3.0 (pegamos o primeiro número)
40
+ - "two years" -> 2.0
41
+ """
42
+ total_experience = 0.0
43
+
44
+ # Mapeamento simples de palavras para números
45
+ num_words = {
46
+ 'one': 1, 'two': 2, 'three': 3, 'four': 4, 'five': 5,
47
+ 'six': 6, 'seven': 7, 'eight': 8, 'nine': 9, 'ten': 10
48
+ }
49
+
50
+ # Filtra apenas as entidades de experiência
51
+ durations = [e['entity'].lower() for e in entities if e['label'] == 'EXPERIENCE_DURATION']
52
+
53
+ for text in durations:
54
+ found_number = None
55
+
56
+ # 1. Tenta encontrar números (dígitos, ex: "5", "5.5", "3-5")
57
+ # Pega o primeiro número que encontrar
58
+ match = re.search(r'(\d+[\.,]\d+|\d+)', text)
59
+ if match:
60
+ found_number = float(match.group(1).replace(',', '.'))
61
+ else:
62
+ # 2. Tenta encontrar números por extenso
63
+ for word, number in num_words.items():
64
+ if word in text:
65
+ found_number = number
66
+ break
67
+
68
+ if found_number is not None:
69
+ # 3. Verifica a unidade (meses ou anos)
70
+ if 'month' in text or 'mes' in text:
71
+ total_experience += found_number / 12
72
+ else:
73
+ # Assume "anos" (years) como padrão
74
+ total_experience += found_number
75
+
76
+ return round(total_experience, 1)
77
+
78
+
79
+ def analyze_cv_and_jd(cv_text: str, jd_text: str) -> (str, str, str, List[str], List[str]):
80
+ """
81
+ Função principal que o Gradio irá chamar.
82
+ Processa o CV e o JD, encontra skills, soma experiências e compara.
83
+ """
84
+ if not extractor:
85
+ return "ERRO: Modelo não carregado.", "", "", [], []
86
+
87
+ # 1. Processa ambos os textos
88
+ cv_entities = extractor.extract_entities_with_details(cv_text, confidence_threshold=0.7)
89
+ jd_entities = extractor.extract_entities_with_details(jd_text, confidence_threshold=0.7)
90
+
91
+ # 2. Soma a experiência (lógica de negócio)
92
+ cv_exp = parse_and_sum_experience(cv_entities)
93
+ jd_exp = parse_and_sum_experience(jd_entities)
94
+
95
+ # 3. Compara as skills
96
+ cv_skills = {e['entity'].lower() for e in cv_entities if e['label'] == 'SKILL'}
97
+ jd_skills = {e['entity'].lower() for e in jd_entities if e['label'] == 'SKILL'}
98
+
99
+ matching_skills = cv_skills.intersection(jd_skills)
100
+ cv_only_skills = cv_skills - jd_skills
101
+ jd_only_skills = jd_skills - cv_skills
102
+
103
+ # 4. Formata a saída
104
+ match_output = f"""
105
+ ## 🚀 Análise de Match
106
+
107
+ **Skills Compatíveis (Encontradas em ambos): {len(matching_skills)}**
108
+ ---
109
+ {', '.join(sorted(list(matching_skills))) if matching_skills else 'Nenhuma skill compatível encontrada.'}
110
+ """
111
+
112
+ cv_exp_str = f"{cv_exp} anos"
113
+ jd_exp_str = f"{jd_exp} anos (Nota: A extração de JDs pode listar o requisito, ex: '5+ anos')"
114
+
115
+ return (
116
+ match_output,
117
+ cv_exp_str,
118
+ jd_exp_str,
119
+ sorted(list(cv_only_skills)),
120
+ sorted(list(jd_only_skills))
121
+ )
122
+
123
+ # --- 3. Definição da Interface Gradio ---
124
+
125
+ with gr.Blocks(title="Hirly - Analisador de CV/JD") as demo:
126
+ gr.Markdown("# 🚀 Analisador de CV vs. Job Description")
127
+ gr.Markdown(
128
+ "Forneça o texto de um CV e de um Job Description (JD) para extrair "
129
+ "skills, anos de experiência e ver a compatibilidade entre eles."
130
+ )
131
+
132
+ with gr.Row():
133
+ with gr.Column():
134
+ cv_input = gr.Textbox(lines=20, label="Texto do Currículo (CV)")
135
+ with gr.Column():
136
+ jd_input = gr.Textbox(lines=20, label="Texto do Job Description (JD)")
137
+
138
+ analyze_button = gr.Button("Analisar Compatibilidade", variant="primary")
139
+
140
+ gr.Markdown("---")
141
+
142
+ with gr.Row():
143
+ with gr.Column(scale=2):
144
+ match_output = gr.Markdown(label="Resultado do Match")
145
+ with gr.Column(scale=1):
146
+ cv_exp_output = gr.Textbox(label="Experiência Total (CV)", interactive=False)
147
+ jd_exp_output = gr.Textbox(label="Experiência Total (JD)", interactive=False)
148
+
149
+ with gr.Row():
150
+ cv_only_output = gr.JSON(label="Skills Encontradas Apenas no CV")
151
+ jd_only_output = gr.JSON(label="Skills Exigidas Apenas no JD")
152
+
153
+ # Conecta o botão à função
154
+ analyze_button.click(
155
+ fn=analyze_cv_and_jd,
156
+ inputs=[cv_input, jd_input],
157
+ outputs=[match_output, cv_exp_output, jd_exp_output, cv_only_output, jd_only_output]
158
+ )
159
+
160
+ if __name__ == "__main__":
161
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ torch>=2.0.0
2
+ transformers>=4.35.0
3
+ gradio>=4.0.0
4
+ accelerate
scripts/inference.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ inference.py (MULTI-LABEL)
3
+
4
+ Inferência de extração de MÚLTIPLAS ENTIDADES (SKILL, EXPERIENCE_DURATION).
5
+
6
+ Execução:
7
+ python scripts/inference.py --model-path models/skill_ner_multi --text "Experienced Python developer with 5+ years of experience."
8
+ """
9
+
10
+ import argparse
11
+ import logging
12
+ from typing import List, Dict
13
+ import torch
14
+ from transformers import AutoTokenizer, AutoModelForTokenClassification, pipeline
15
+
16
+ logging.basicConfig(
17
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
18
+ )
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class EntityExtractor:
23
+ """Extrator de entidades (SKILL, EXPERIENCE_DURATION) usando modelo NER treinado."""
24
+
25
+ def __init__(self, model_path: str, device: str = None):
26
+ self.model_path = model_path
27
+
28
+ if device is None:
29
+ self.device = 0 if torch.cuda.is_available() else -1
30
+ else:
31
+ self.device = 0 if device == "cuda" else -1
32
+
33
+ logger.info(f"Carregando modelo de {model_path}...")
34
+
35
+ try:
36
+ self.tokenizer = AutoTokenizer.from_pretrained(model_path)
37
+ self.model = AutoModelForTokenClassification.from_pretrained(model_path)
38
+ except Exception as e:
39
+ logger.error(
40
+ f"Erro ao carregar modelo de {model_path}. Verifique o caminho."
41
+ )
42
+ raise e
43
+
44
+ logger.info(f"Usando device: {'GPU' if self.device == 0 else 'CPU'}")
45
+
46
+ # Cria pipeline de NER
47
+ # A estratégia 'simple' agrupará B-SKILL/I-SKILL em SKILL
48
+ # e B-EXPERIENCE_DURATION/I-EXPERIENCE_DURATION em EXPERIENCE_DURATION
49
+ self.ner_pipeline = pipeline(
50
+ "ner",
51
+ model=self.model,
52
+ tokenizer=self.tokenizer,
53
+ aggregation_strategy="simple",
54
+ device=self.device,
55
+ )
56
+
57
+ logger.info("Modelo carregado com sucesso!")
58
+
59
+ def extract_skills(self, text: str, confidence_threshold: float = 0.5) -> List[str]:
60
+ """
61
+ Extrai APENAS skills (para manter compatibilidade).
62
+ """
63
+ entities = self.extract_entities_with_details(text, confidence_threshold)
64
+
65
+ # Filtra apenas por SKILL
66
+ skills = [e["entity"] for e in entities if e["label"] == "SKILL"]
67
+
68
+ # Remove duplicatas
69
+ seen = set()
70
+ unique_skills = []
71
+ for skill in skills:
72
+ skill_lower = skill.lower()
73
+ if skill_lower not in seen:
74
+ seen.add(skill_lower)
75
+ unique_skills.append(skill)
76
+ return unique_skills
77
+
78
+ def extract_entities_with_details(
79
+ self, text: str, confidence_threshold: float = 0.5
80
+ ) -> List[Dict]:
81
+ """
82
+ Extrai TODAS as entidades com detalhes (SKILL, EXPERIENCE_DURATION, etc.)
83
+ """
84
+ if not text or not isinstance(text, str):
85
+ return []
86
+
87
+ text = " ".join(text.split())
88
+ if not text:
89
+ return []
90
+
91
+ try:
92
+ # O pipeline retorna todas as entidades agrupadas
93
+ entities = self.ner_pipeline(text)
94
+ except Exception as e:
95
+ logger.error(f"Erro durante a inferência do pipeline: {e}")
96
+ return []
97
+
98
+ detailed_entities = []
99
+ for entity in entities:
100
+ if entity["score"] >= confidence_threshold:
101
+ detailed_entities.append(
102
+ {
103
+ "entity": entity["word"].strip(" .,;:"),
104
+ "label": entity[
105
+ "entity_group"
106
+ ], # Ex: 'SKILL' ou 'EXPERIENCE_DURATION'
107
+ "start": entity["start"],
108
+ "end": entity["end"],
109
+ "confidence": round(float(entity["score"]), 3),
110
+ }
111
+ )
112
+
113
+ return detailed_entities
114
+
115
+
116
+ def main():
117
+ parser = argparse.ArgumentParser(description="Extrai entidades de textos")
118
+ parser.add_argument(
119
+ "--model-path", type=str, required=True, help="Caminho do modelo treinado"
120
+ )
121
+ parser.add_argument("--text", type=str, help="Texto para extrair entidades")
122
+ parser.add_argument("--file", type=str, help="Arquivo de texto para processar")
123
+ parser.add_argument(
124
+ "--confidence", type=float, default=0.5, help="Threshold de confidence (0-1)"
125
+ )
126
+
127
+ # Removido --detailed, pois a saída padrão agora é detalhada
128
+
129
+ args = parser.parse_args()
130
+
131
+ try:
132
+ extractor = EntityExtractor(args.model_path)
133
+ except Exception as e:
134
+ logger.error(f"Falha ao inicializar EntityExtractor: {e}")
135
+ return
136
+
137
+ if args.file:
138
+ logger.info(f"Lendo texto de {args.file}...")
139
+ try:
140
+ with open(args.file, "r", encoding="utf-8") as f:
141
+ text = f.read()
142
+ except FileNotFoundError:
143
+ logger.error(f"Arquivo não encontrado: {args.file}")
144
+ return
145
+ elif args.text:
146
+ text = args.text
147
+ else:
148
+ logger.error("Error: Especifique --text ou --file")
149
+ return
150
+
151
+ # Extrai todas as entidades
152
+ results = extractor.extract_entities_with_details(text, args.confidence)
153
+
154
+ print(f"\nExtracted {len(results)} entities:\n")
155
+ for result in results:
156
+ print(
157
+ f" [{result['label']:<21}] {result['entity']:<30} confidence: {result['confidence']:.3f}"
158
+ )
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()