first comit
Browse files- .gitignore +7 -0
- project/cv_matcher.py +370 -0
- project/gemini_api.py +259 -0
- project/main.py +140 -0
- project/prompts/prompt_cv.txt +61 -0
- project/prompts/prompt_offer.txt +48 -0
- project/static/chart.js +190 -0
- project/static/favicon.ico +0 -0
- project/static/index.html +1701 -0
- requirements.txt +0 -0
.gitignore
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
venv/
|
2 |
+
__pycache__/
|
3 |
+
project/__pycache__
|
4 |
+
*.pyc
|
5 |
+
.env
|
6 |
+
project/ninja_cv.ipynb
|
7 |
+
project/tmp
|
project/cv_matcher.py
ADDED
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import traceback
|
3 |
+
import numpy as np
|
4 |
+
from sentence_transformers import SentenceTransformer
|
5 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
6 |
+
from gemini_api import GeminiAPI
|
7 |
+
import os
|
8 |
+
import dotenv
|
9 |
+
from dotenv import load_dotenv
|
10 |
+
|
11 |
+
# Cargar variables de entorno desde .env
|
12 |
+
load_dotenv()
|
13 |
+
|
14 |
+
apikey = os.getenv("GEMINI_API_KEY")
|
15 |
+
|
16 |
+
# Crear instancia global (o se la pasamos a la clase)
|
17 |
+
gemini = GeminiAPI(api_key=apikey)
|
18 |
+
|
19 |
+
|
20 |
+
class CVMatcher:
|
21 |
+
def __init__(self, model_name="all-MiniLM-L6-v2"):
|
22 |
+
self.model = SentenceTransformer(model_name)
|
23 |
+
self.cv_data = None
|
24 |
+
self.offer_data = None
|
25 |
+
|
26 |
+
# ----------- 1. Sector -----------
|
27 |
+
def preprocess_sector(self, sector):
|
28 |
+
if isinstance(sector, str):
|
29 |
+
output = sector.lower().strip().replace(",", " and")
|
30 |
+
elif isinstance(sector, list):
|
31 |
+
output = " and ".join([s.lower().strip() for s in sector])
|
32 |
+
else:
|
33 |
+
output = ""
|
34 |
+
return f"principal job sector: {output}"
|
35 |
+
|
36 |
+
def sector_similarity(self, offer_dict, cv_dict):
|
37 |
+
offer_sector = offer_dict.get("sector", "")
|
38 |
+
cv_sector = cv_dict.get("primary_sector", "")
|
39 |
+
|
40 |
+
if not offer_sector or not cv_sector:
|
41 |
+
return 0.0
|
42 |
+
|
43 |
+
# Preprocess sectors for better matching
|
44 |
+
offer_sector_processed = self.preprocess_sector(offer_sector)
|
45 |
+
cv_sector_processed = self.preprocess_sector(cv_sector)
|
46 |
+
|
47 |
+
# If sectors are exactly the same after preprocessing
|
48 |
+
if offer_sector_processed == cv_sector_processed:
|
49 |
+
return 1.0
|
50 |
+
|
51 |
+
# Calculate semantic similarity
|
52 |
+
try:
|
53 |
+
cv_emb, offer_emb = self.model.encode([cv_sector_processed, offer_sector_processed])
|
54 |
+
sim_score = cosine_similarity([offer_emb], [cv_emb])[0][0]
|
55 |
+
|
56 |
+
# Add a small boost to the score
|
57 |
+
sim_score = min(1.0, sim_score + 0.1)
|
58 |
+
return sim_score
|
59 |
+
|
60 |
+
except Exception as e:
|
61 |
+
print(f"Error calculating sector similarity: {e}")
|
62 |
+
return 0.5 # Default similarity in case of error
|
63 |
+
|
64 |
+
|
65 |
+
# ----------- 2. Educación -----------
|
66 |
+
def preprocess_field(self, field):
|
67 |
+
return f"field of study: {field.lower().strip().replace(',', ' and')}"
|
68 |
+
|
69 |
+
|
70 |
+
def education_similarity(self, offer_dict, cv_education):
|
71 |
+
offer_field = self.preprocess_field(offer_dict['education']['field'])
|
72 |
+
cv_field = self.preprocess_field(cv_education['field'])
|
73 |
+
offer_emb, cv_emb = self.model.encode([offer_field, cv_field])
|
74 |
+
sim_score = cosine_similarity([offer_emb], [cv_emb])[0][0] + 0.05
|
75 |
+
return float(min(1, sim_score))
|
76 |
+
|
77 |
+
|
78 |
+
|
79 |
+
def education_final_score(self, offer_dict, cv_dict):
|
80 |
+
# Get minimum education level from offer and ensure it's a float
|
81 |
+
min_education = float(offer_dict.get('education', {}).get('number', 0))
|
82 |
+
|
83 |
+
# Get all education entries from CV
|
84 |
+
cv_education = cv_dict.get('education', [])
|
85 |
+
|
86 |
+
if not cv_education:
|
87 |
+
return 0.0
|
88 |
+
|
89 |
+
# Find the highest education level in CV, ensuring all are floats
|
90 |
+
highest_cv_edu = max([float(edu.get('number', 0)) for edu in cv_education])
|
91 |
+
|
92 |
+
# If highest CV education is below minimum required
|
93 |
+
if highest_cv_edu < min_education:
|
94 |
+
return 0.0
|
95 |
+
|
96 |
+
# Calculate base similarity with the closest matching education
|
97 |
+
best_similarity = float(0)
|
98 |
+
same_level_edu = None
|
99 |
+
|
100 |
+
for edu in cv_education:
|
101 |
+
edu_level = float(edu.get('number', 0))
|
102 |
+
if edu_level >= min_education:
|
103 |
+
similarity = self.education_similarity(offer_dict, edu)
|
104 |
+
if similarity > best_similarity:
|
105 |
+
best_similarity = similarity
|
106 |
+
if edu_level == min_education:
|
107 |
+
same_level_edu = edu
|
108 |
+
|
109 |
+
# Calculate addon for higher education
|
110 |
+
higher_education = [edu for edu in cv_education if float(edu.get('number', 0)) > min_education]
|
111 |
+
addon = 0.0
|
112 |
+
|
113 |
+
for edu in higher_education:
|
114 |
+
edu_level = float(edu.get('number', 0))
|
115 |
+
level_diff = edu_level - min_education
|
116 |
+
similarity = self.education_similarity(offer_dict, edu)
|
117 |
+
addon += 0.1 * level_diff * similarity
|
118 |
+
|
119 |
+
# Cap the final score at 1.0
|
120 |
+
return min(1.0, best_similarity + addon)
|
121 |
+
|
122 |
+
|
123 |
+
# ----------- 3. Skills -----------
|
124 |
+
def skills_similarity(self, offer_dict, cv_dict, type="technical"):
|
125 |
+
if type == "technical":
|
126 |
+
cv_skills = [s.lower() for s in cv_dict.get("technical_abilities", [])]
|
127 |
+
offer_skills = [s.lower() for s in offer_dict.get("technical_abilities", [])]
|
128 |
+
elif type == "soft":
|
129 |
+
cv_skills = [s.lower() for s in cv_dict.get("soft_skills", [])]
|
130 |
+
offer_skills = [s.lower() for s in offer_dict.get("soft_skills", [])]
|
131 |
+
else:
|
132 |
+
return {}, 0
|
133 |
+
|
134 |
+
if not offer_skills or not cv_skills:
|
135 |
+
return {}, 0
|
136 |
+
|
137 |
+
cv_embeddings = self.model.encode(cv_skills)
|
138 |
+
offer_embeddings = self.model.encode(offer_skills)
|
139 |
+
|
140 |
+
# Calculate similarity for each offer skill against all CV skills
|
141 |
+
skill_similarities = {}
|
142 |
+
for i, offer_skill in enumerate(offer_skills):
|
143 |
+
if offer_skill in cv_skills:
|
144 |
+
# Exact match
|
145 |
+
skill_similarities[offer_skill] = 1.0
|
146 |
+
else:
|
147 |
+
# Semantic similarity
|
148 |
+
sim_scores = cosine_similarity([offer_embeddings[i]], cv_embeddings)[0]
|
149 |
+
max_sim = np.max(sim_scores)
|
150 |
+
skill_similarities[offer_skill] = min(1, max_sim + 0.1)
|
151 |
+
|
152 |
+
avg_similarity = np.mean(list(skill_similarities.values())) if skill_similarities else 0
|
153 |
+
return skill_similarities, avg_similarity
|
154 |
+
|
155 |
+
|
156 |
+
# ----------- 4. Experiencia en el rol -----------
|
157 |
+
def role_similarity(self, offer_role, cv_roles):
|
158 |
+
cv_embeddings = self.model.encode(cv_roles)
|
159 |
+
offer_embedding = self.model.encode(offer_role)
|
160 |
+
return cosine_similarity([offer_embedding], cv_embeddings)[0]
|
161 |
+
|
162 |
+
def role_experience_similarity(self, offer_dict, cv_dict):
|
163 |
+
total_experience = 0
|
164 |
+
role_similarities = []
|
165 |
+
|
166 |
+
# Extract all roles and their years of experience from CV
|
167 |
+
cv_experience = []
|
168 |
+
for experience in cv_dict.get('experience', []):
|
169 |
+
for role in experience.get('roles', []):
|
170 |
+
position = role.get('position', '')
|
171 |
+
years = float(role.get('years', 0))
|
172 |
+
if position and years > 0:
|
173 |
+
cv_experience.append({
|
174 |
+
'position': position,
|
175 |
+
'years': years,
|
176 |
+
'company': experience.get('company', ''),
|
177 |
+
'duration': experience.get('duration', '')
|
178 |
+
})
|
179 |
+
|
180 |
+
if not cv_experience:
|
181 |
+
return 0, 0, 0, 0
|
182 |
+
|
183 |
+
# Calculate similarity for each role
|
184 |
+
cv_roles = [exp['position'] for exp in cv_experience]
|
185 |
+
offer_role = offer_dict.get("role", "")
|
186 |
+
|
187 |
+
if not offer_role:
|
188 |
+
return 0, 0, 0, 0
|
189 |
+
|
190 |
+
role_similarities = self.role_similarity(offer_role, cv_roles)
|
191 |
+
|
192 |
+
# Calculate weighted experience
|
193 |
+
weighted_experience = 0
|
194 |
+
for i, exp in enumerate(cv_experience):
|
195 |
+
similarity = role_similarities[i]
|
196 |
+
weighted_experience += similarity * exp['years']
|
197 |
+
|
198 |
+
# Get min and max experience from offer
|
199 |
+
min_exp = float(offer_dict.get('experience', {}).get('min', 0))
|
200 |
+
max_exp = float(offer_dict.get('experience', {}).get('max', min_exp + 5)) # Default range if max not specified
|
201 |
+
|
202 |
+
# Calculate experience percentage (capped at 1.0)
|
203 |
+
if min_exp > 0:
|
204 |
+
experience_perc = min(1.0, weighted_experience / min_exp)
|
205 |
+
else:
|
206 |
+
experience_perc = 1.0 if weighted_experience > 0 else 0
|
207 |
+
|
208 |
+
return min_exp, max_exp, weighted_experience, experience_perc
|
209 |
+
|
210 |
+
|
211 |
+
# ----------- 5. Creación del diccionario -----------
|
212 |
+
|
213 |
+
def final_score(self, offer_path, cv_path):
|
214 |
+
"""
|
215 |
+
Calculate final matching scores between an offer and a CV.
|
216 |
+
|
217 |
+
Args:
|
218 |
+
offer_path (str): Path to the job offer file
|
219 |
+
cv_path (str): Path to the CV file
|
220 |
+
|
221 |
+
Returns:
|
222 |
+
dict: Dictionary containing all matching scores and details
|
223 |
+
"""
|
224 |
+
# Parse the offer and CV
|
225 |
+
offer_dict = gemini.parse_offer(offer_path)
|
226 |
+
cv_dict = gemini.parse_cv(cv_path)
|
227 |
+
|
228 |
+
# Return the complete matching results
|
229 |
+
return self.create_dict(offer_dict, cv_dict)
|
230 |
+
|
231 |
+
|
232 |
+
|
233 |
+
|
234 |
+
def create_dict(self, offer_dict, cv_dict):
|
235 |
+
# Get technical skills with similarity scores
|
236 |
+
tech_skills_dict, tech_score = self.skills_similarity(offer_dict, cv_dict, "technical")
|
237 |
+
soft_skills_dict, soft_score = self.skills_similarity(offer_dict, cv_dict, "soft")
|
238 |
+
|
239 |
+
# Process technical skills - check if we should show top/bottom or all
|
240 |
+
tech_skills = {}
|
241 |
+
if tech_skills_dict:
|
242 |
+
sorted_tech = sorted(tech_skills_dict.items(), key=lambda x: x[1], reverse=True)
|
243 |
+
if len(sorted_tech) >= 6:
|
244 |
+
tech_skills = {
|
245 |
+
'top_matches': [skill for skill, _ in sorted_tech[:3]],
|
246 |
+
'bottom_matches': [skill for skill, _ in sorted_tech[-3:]]
|
247 |
+
}
|
248 |
+
else:
|
249 |
+
tech_skills = {
|
250 |
+
'title': 'Technical skills similarity order',
|
251 |
+
'skills': [skill for skill, _ in sorted_tech]
|
252 |
+
}
|
253 |
+
|
254 |
+
# Process soft skills - check if we should show top/bottom or all
|
255 |
+
soft_skills = {}
|
256 |
+
if soft_skills_dict:
|
257 |
+
sorted_soft = sorted(soft_skills_dict.items(), key=lambda x: x[1], reverse=True)
|
258 |
+
if len(sorted_soft) >= 6:
|
259 |
+
soft_skills = {
|
260 |
+
'top_matches': [skill for skill, _ in sorted_soft[:3]],
|
261 |
+
'bottom_matches': [skill for skill, _ in sorted_soft[-3:]]
|
262 |
+
}
|
263 |
+
else:
|
264 |
+
soft_skills = {
|
265 |
+
'title': 'Soft skills similarity order',
|
266 |
+
'skills': [skill for skill, _ in sorted_soft]
|
267 |
+
}
|
268 |
+
|
269 |
+
# Get role experience details
|
270 |
+
min_exp, max_exp, total_exp, exp_score = self.role_experience_similarity(offer_dict, cv_dict)
|
271 |
+
role = offer_dict.get("role", "")
|
272 |
+
|
273 |
+
# Get sector information
|
274 |
+
sector_similarity = self.sector_similarity(offer_dict, cv_dict)
|
275 |
+
offer_sector = offer_dict.get("sector", "")
|
276 |
+
cv_sector = cv_dict.get("primary_sector", "")
|
277 |
+
|
278 |
+
# Get education information
|
279 |
+
education_score = self.education_final_score(offer_dict, cv_dict)
|
280 |
+
min_education = float(offer_dict.get("education", {}).get("number", 0))
|
281 |
+
min_education_level = offer_dict.get("education", {}).get("min", "No especificado")
|
282 |
+
min_education_field = offer_dict.get("education", {}).get("field", "No especificado")
|
283 |
+
|
284 |
+
# Find same level education and higher education
|
285 |
+
same_level_education = []
|
286 |
+
higher_education = []
|
287 |
+
|
288 |
+
for edu in cv_dict.get("education", []):
|
289 |
+
try:
|
290 |
+
edu_level = float(edu.get("number", 0))
|
291 |
+
if edu_level == min_education:
|
292 |
+
same_level_education.append(edu)
|
293 |
+
elif edu_level > min_education:
|
294 |
+
higher_education.append(edu)
|
295 |
+
except (ValueError, TypeError):
|
296 |
+
continue
|
297 |
+
|
298 |
+
# Prepare education explanation
|
299 |
+
same_level_text = ""
|
300 |
+
if same_level_education:
|
301 |
+
same_level_text = f"The candidate has the same required education level: {same_level_education[0].get('degree', 'Similar level')}"
|
302 |
+
else:
|
303 |
+
same_level_text = "The candidate does not have the same required education level"
|
304 |
+
|
305 |
+
higher_education_text = ""
|
306 |
+
if higher_education:
|
307 |
+
higher_education_text = ", ".join([edu.get('degree', '') for edu in higher_education])
|
308 |
+
else:
|
309 |
+
higher_education_text = "No higher education"
|
310 |
+
|
311 |
+
# Format the return dictionary with all the processed information
|
312 |
+
result = {
|
313 |
+
# Scores
|
314 |
+
"technical_skills_score": int(np.round(100 * tech_score, 2)),
|
315 |
+
"soft_skills_score": int(np.round(100 * soft_score, 2)),
|
316 |
+
"role_experience_score": int(np.round(100 * exp_score, 2)),
|
317 |
+
"education_score": int(np.round(100 * education_score, 2)),
|
318 |
+
"sector_score": int(np.round(100 * sector_similarity, 2)),
|
319 |
+
|
320 |
+
# Detailed information
|
321 |
+
"technical_skills": tech_skills,
|
322 |
+
"soft_skills": soft_skills,
|
323 |
+
|
324 |
+
# Role experience details
|
325 |
+
"role_experience": {
|
326 |
+
"explanation": f"You have approximately {round(total_exp, 1)} years of experience in roles similar to '{role}'. "
|
327 |
+
f"The offer is looking for between {min_exp} and {max_exp} years of experience.",
|
328 |
+
"details": {
|
329 |
+
"role": role,
|
330 |
+
"min_years": min_exp,
|
331 |
+
"max_years": max_exp,
|
332 |
+
"total_experience": round(total_exp, 1)
|
333 |
+
}
|
334 |
+
},
|
335 |
+
|
336 |
+
# Education details
|
337 |
+
"education": {
|
338 |
+
"explanation": f"The offer requires at least {min_education_level}. {same_level_text}.",
|
339 |
+
"details": {
|
340 |
+
"minimum_required_level": min_education_level,
|
341 |
+
"minimum_required_field": min_education_field,
|
342 |
+
"equivalent_level_cv": same_level_education[0].get('degree', 'Not available') if same_level_education else 'Not available',
|
343 |
+
"equivalent_field_cv": same_level_education[0].get('field', 'Not available') if same_level_education else 'Not available',
|
344 |
+
"higher_education_degrees": [edu.get('degree', '') for edu in higher_education],
|
345 |
+
"higher_education_fields": [edu.get('field', '') for edu in higher_education],
|
346 |
+
"meets_requirement": education_score >= 0.5
|
347 |
+
}
|
348 |
+
},
|
349 |
+
|
350 |
+
# Sector information
|
351 |
+
"sector": {
|
352 |
+
"explanation": f"The offer's sector is '{offer_sector}' and your main sector is '{cv_sector}'. "
|
353 |
+
f"The similarity between both sectors is {round(sector_similarity * 100, 1)}%.",
|
354 |
+
"details": {
|
355 |
+
"offer_sector": offer_sector,
|
356 |
+
"cv_sector": cv_sector,
|
357 |
+
"similarity": round(sector_similarity * 100, 1)
|
358 |
+
}
|
359 |
+
}
|
360 |
+
}
|
361 |
+
|
362 |
+
|
363 |
+
return result
|
364 |
+
|
365 |
+
|
366 |
+
# instanciamos la clase
|
367 |
+
matcher = CVMatcher()
|
368 |
+
|
369 |
+
|
370 |
+
|
project/gemini_api.py
ADDED
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# gemini_api.py
|
2 |
+
|
3 |
+
import json
|
4 |
+
import pprint
|
5 |
+
import re
|
6 |
+
from datetime import datetime
|
7 |
+
import fitz
|
8 |
+
from google.generativeai import GenerativeModel, configure
|
9 |
+
|
10 |
+
# === TEMPLATES ===
|
11 |
+
CV_TEMPLATE = {
|
12 |
+
"education": [
|
13 |
+
{
|
14 |
+
"degree": str,
|
15 |
+
"number": float,
|
16 |
+
"field": str
|
17 |
+
}
|
18 |
+
],
|
19 |
+
"experience": [
|
20 |
+
{
|
21 |
+
"company": str,
|
22 |
+
"roles": [
|
23 |
+
{
|
24 |
+
"position": str,
|
25 |
+
"years": float
|
26 |
+
}
|
27 |
+
],
|
28 |
+
"total_years": float
|
29 |
+
}
|
30 |
+
],
|
31 |
+
"primary_sector": list,
|
32 |
+
"soft_skills": list,
|
33 |
+
"technical_abilities": list
|
34 |
+
}
|
35 |
+
|
36 |
+
OFFER_TEMPLATE = {
|
37 |
+
"company": str,
|
38 |
+
"education": {
|
39 |
+
"field": str,
|
40 |
+
"number": float,
|
41 |
+
"min": str
|
42 |
+
},
|
43 |
+
"experience": {
|
44 |
+
"max": float,
|
45 |
+
"min": float
|
46 |
+
},
|
47 |
+
"role": str,
|
48 |
+
"sector": str,
|
49 |
+
"soft_skills": list,
|
50 |
+
"technical_abilities": list
|
51 |
+
}
|
52 |
+
|
53 |
+
# === HELPERS ===
|
54 |
+
|
55 |
+
# leer cv
|
56 |
+
def read_cv(file_path: str) -> str:
|
57 |
+
doc = fitz.open(file_path)
|
58 |
+
cv = ""
|
59 |
+
for page in doc:
|
60 |
+
# devuelve bloques con coordenadas
|
61 |
+
text = page.get_text("blocks")
|
62 |
+
# Ordenar los bloques por (y, x) → para mantener coherencia de lectura
|
63 |
+
blocks_sorted = sorted(text, key=lambda b: (b[1], b[0]))
|
64 |
+
for b in blocks_sorted:
|
65 |
+
cv += b[4]
|
66 |
+
if len(cv) > 10000:
|
67 |
+
return -1
|
68 |
+
elif len(cv) < 10:
|
69 |
+
return -2
|
70 |
+
return cv
|
71 |
+
|
72 |
+
|
73 |
+
# parsear la respuesta de gemini
|
74 |
+
def fill_from_template(template, source):
|
75 |
+
"""
|
76 |
+
Llena un diccionario siguiendo la estructura y tipos de 'template'
|
77 |
+
usando los valores de 'source' solo si cumplen el tipo esperado.
|
78 |
+
"""
|
79 |
+
if isinstance(template, dict):
|
80 |
+
result = {}
|
81 |
+
for key, expected in template.items():
|
82 |
+
if key in source:
|
83 |
+
result[key] = fill_from_template(expected, source[key])
|
84 |
+
else:
|
85 |
+
result[key] = None
|
86 |
+
return result
|
87 |
+
|
88 |
+
elif isinstance(template, list):
|
89 |
+
if not isinstance(source, list):
|
90 |
+
return []
|
91 |
+
if not template: # lista vacía como plantilla
|
92 |
+
return source if isinstance(source, list) else []
|
93 |
+
return [
|
94 |
+
fill_from_template(template[0], item)
|
95 |
+
for item in source if isinstance(item, dict)
|
96 |
+
]
|
97 |
+
|
98 |
+
elif isinstance(template, type):
|
99 |
+
if template is float and isinstance(source, (int, float)):
|
100 |
+
return float(source)
|
101 |
+
return source if isinstance(source, template) else None
|
102 |
+
|
103 |
+
return None
|
104 |
+
|
105 |
+
|
106 |
+
def clean_and_validate_json(json_str: str) -> dict:
|
107 |
+
"""
|
108 |
+
Clean and validate JSON string, fixing common formatting issues.
|
109 |
+
|
110 |
+
Args:
|
111 |
+
json_str: The JSON string to clean and validate
|
112 |
+
|
113 |
+
Returns:
|
114 |
+
dict: The parsed and cleaned JSON as a dictionary
|
115 |
+
|
116 |
+
Raises:
|
117 |
+
json.JSONDecodeError: If the JSON cannot be properly parsed after cleaning
|
118 |
+
"""
|
119 |
+
if not json_str or not isinstance(json_str, str):
|
120 |
+
raise ValueError("Input must be a non-empty string")
|
121 |
+
|
122 |
+
# Remove trailing commas before closing brackets/braces
|
123 |
+
json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
|
124 |
+
|
125 |
+
# Fix unescaped quotes within strings
|
126 |
+
json_str = re.sub(r'(?<!\\)"(.*?)(?<!\\)"',
|
127 |
+
lambda m: '"' + m.group(1).replace('"', '\\"') + '"',
|
128 |
+
json_str)
|
129 |
+
|
130 |
+
# Remove any non-printable characters
|
131 |
+
json_str = ''.join(char for char in json_str if char.isprintable() or char.isspace())
|
132 |
+
|
133 |
+
# Attempt to parse the cleaned JSON
|
134 |
+
try:
|
135 |
+
return json.loads(json_str)
|
136 |
+
except json.JSONDecodeError as e:
|
137 |
+
# If parsing still fails, try to extract JSON from the string
|
138 |
+
match = re.search(r'({.*})', json_str, re.DOTALL)
|
139 |
+
if match:
|
140 |
+
try:
|
141 |
+
return json.loads(match.group(1))
|
142 |
+
except json.JSONDecodeError:
|
143 |
+
pass
|
144 |
+
raise json.JSONDecodeError("Failed to parse JSON after cleaning attempts",
|
145 |
+
json_str, e.pos) from e
|
146 |
+
|
147 |
+
|
148 |
+
|
149 |
+
class GeminiAPI:
|
150 |
+
def __init__(self, api_key: str):
|
151 |
+
self.api_key = api_key
|
152 |
+
configure(api_key=self.api_key)
|
153 |
+
self.model = GenerativeModel(
|
154 |
+
"gemini-2.5-flash",
|
155 |
+
generation_config={
|
156 |
+
"temperature": 0.0,
|
157 |
+
"top_p": 0.9,
|
158 |
+
"top_k": 4,
|
159 |
+
"max_output_tokens": 4096,
|
160 |
+
"response_mime_type": "application/json",
|
161 |
+
},
|
162 |
+
)
|
163 |
+
|
164 |
+
|
165 |
+
def load_prompt(self, filename: str) -> str:
|
166 |
+
with open(filename, "r", encoding="utf-8") as f:
|
167 |
+
return f.read()
|
168 |
+
|
169 |
+
def _call_gemini(self, prompt: str):
|
170 |
+
"""Hace la llamada a Gemini y devuelve JSON crudo"""
|
171 |
+
try:
|
172 |
+
# Validar y limpiar el prompt
|
173 |
+
if not isinstance(prompt, str):
|
174 |
+
prompt = str(prompt)
|
175 |
+
|
176 |
+
# Limpiar caracteres no ASCII del prompt
|
177 |
+
prompt = prompt.encode('ascii', 'ignore').decode('ascii')
|
178 |
+
|
179 |
+
response = self.model.generate_content(prompt)
|
180 |
+
|
181 |
+
if not hasattr(response, 'text') or not response.text:
|
182 |
+
return {"error": "La respuesta de la API no contiene texto"}
|
183 |
+
|
184 |
+
response_text = response.text.strip()
|
185 |
+
|
186 |
+
# Limpiar caracteres no ASCII de la respuesta
|
187 |
+
response_text = response_text.encode('ascii', 'ignore').decode('ascii')
|
188 |
+
|
189 |
+
# Extraer JSON de bloques de código markdown
|
190 |
+
if '```json' in response_text:
|
191 |
+
response_text = response_text.split('```json')[1].split('```')[0].strip()
|
192 |
+
elif '```' in response_text:
|
193 |
+
response_text = response_text.split('```')[1].split('```')[0].strip()
|
194 |
+
|
195 |
+
# Validar y limpiar el JSON
|
196 |
+
try:
|
197 |
+
return clean_and_validate_json(response_text)
|
198 |
+
except json.JSONDecodeError as e:
|
199 |
+
print(f"Error al decodificar JSON: {e}")
|
200 |
+
print(f"Respuesta original: {response_text[:500]}...") # Mostrar solo los primeros 500 caracteres
|
201 |
+
return {"error": "No se pudo procesar la respuesta de la API"}
|
202 |
+
|
203 |
+
except Exception as e:
|
204 |
+
error_msg = str(e)
|
205 |
+
print(f"Error en _call_gemini: {error_msg}")
|
206 |
+
|
207 |
+
# Información adicional para depuración
|
208 |
+
if 'response' in locals():
|
209 |
+
try:
|
210 |
+
response_text = getattr(response, 'text', 'No hay texto en la respuesta')
|
211 |
+
print(f"Respuesta cruda: {response_text[:500]}...") # Mostrar solo los primeros 500 caracteres
|
212 |
+
except Exception as debug_e:
|
213 |
+
print(f"Error al obtener la respuesta: {debug_e}")
|
214 |
+
|
215 |
+
return {"error": f"Error en la comunicación con la API: {error_msg}"}
|
216 |
+
|
217 |
+
def parse_cv(self, cv_path: str) -> dict:
|
218 |
+
"""Devuelve CV como diccionario limpio"""
|
219 |
+
cv_text = read_cv(cv_path)
|
220 |
+
prompt = self.load_prompt("./prompts/prompt_cv.txt")
|
221 |
+
now = datetime.now()
|
222 |
+
actual_date = now.strftime("%B, %Y")
|
223 |
+
# añadimos el cv al prompt
|
224 |
+
prompt = prompt.replace("{cv_text}", cv_text)
|
225 |
+
prompt = prompt.replace("{actual_date}", actual_date)
|
226 |
+
raw = self._call_gemini(prompt)
|
227 |
+
return fill_from_template(CV_TEMPLATE, raw)
|
228 |
+
|
229 |
+
def parse_offer(self, offer_path: str) -> dict:
|
230 |
+
"""Devuelve oferta como diccionario limpio"""
|
231 |
+
try:
|
232 |
+
with open(offer_path, 'r', encoding='utf-8') as f:
|
233 |
+
offer_text = f.read().strip()
|
234 |
+
|
235 |
+
# Clean the offer text - remove any trailing periods that might be in the file
|
236 |
+
offer_text = offer_text.rstrip('.').strip()
|
237 |
+
|
238 |
+
prompt = self.load_prompt("./prompts/prompt_offer.txt")
|
239 |
+
prompt = prompt.replace("{offer_text}", offer_text)
|
240 |
+
|
241 |
+
raw = self._call_gemini(prompt)
|
242 |
+
|
243 |
+
# Clean the response - remove any trailing characters that might break JSON parsing
|
244 |
+
if isinstance(raw, str):
|
245 |
+
raw = raw.strip()
|
246 |
+
# Remove any trailing punctuation that might have been included
|
247 |
+
raw = raw.rstrip('.').strip()
|
248 |
+
|
249 |
+
result = fill_from_template(OFFER_TEMPLATE, raw)
|
250 |
+
|
251 |
+
if not result or all(v is None for v in result.values()):
|
252 |
+
print(f"Warning: Empty or invalid response for offer: {offer_path}")
|
253 |
+
|
254 |
+
return result
|
255 |
+
|
256 |
+
except Exception as e:
|
257 |
+
print(f"Error parsing offer from {offer_path}: {str(e)}")
|
258 |
+
print(f"Raw response was: {raw if 'raw' in locals() else 'N/A'}")
|
259 |
+
raise
|
project/main.py
ADDED
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import traceback
|
2 |
+
import sys
|
3 |
+
from fastapi import FastAPI, UploadFile, Form, Request, Response, HTTPException
|
4 |
+
from fastapi.staticfiles import StaticFiles
|
5 |
+
from fastapi.templating import Jinja2Templates
|
6 |
+
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSONResponse
|
7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
8 |
+
import os
|
9 |
+
import uvicorn
|
10 |
+
from pathlib import Path
|
11 |
+
import json
|
12 |
+
from cv_matcher import matcher # tu instancia global de CVMatcher
|
13 |
+
|
14 |
+
# uvicorn main:app --reload
|
15 |
+
|
16 |
+
app = FastAPI()
|
17 |
+
|
18 |
+
# Manejador de excepciones global
|
19 |
+
@app.exception_handler(Exception)
|
20 |
+
async def global_exception_handler(request: Request, exc: Exception):
|
21 |
+
exc_type, exc_value, exc_traceback = sys.exc_info()
|
22 |
+
error_details = {
|
23 |
+
"error": str(exc),
|
24 |
+
"type": exc_type.__name__,
|
25 |
+
"file": exc_traceback.tb_frame.f_code.co_filename,
|
26 |
+
"line": exc_traceback.tb_lineno,
|
27 |
+
"traceback": traceback.format_exc()
|
28 |
+
}
|
29 |
+
print("\n" + "="*50)
|
30 |
+
print("ERROR DETALLADO:")
|
31 |
+
print(json.dumps(error_details, indent=2))
|
32 |
+
print("="*50 + "\n")
|
33 |
+
|
34 |
+
# Para depuración, devolvemos el error completo
|
35 |
+
return JSONResponse(
|
36 |
+
status_code=500,
|
37 |
+
content={"error": "Error interno del servidor", "details": error_details}
|
38 |
+
)
|
39 |
+
|
40 |
+
# Configurar CORS
|
41 |
+
app.add_middleware(
|
42 |
+
CORSMiddleware,
|
43 |
+
allow_origins=["*"], # En producción, reemplaza con el origen de tu frontend
|
44 |
+
allow_credentials=True,
|
45 |
+
allow_methods=["*"],
|
46 |
+
allow_headers=["*"],
|
47 |
+
)
|
48 |
+
|
49 |
+
# Configurar directorios
|
50 |
+
BASE_DIR = Path(__file__).parent
|
51 |
+
TMP_DIR = Path("/tmp")
|
52 |
+
STATIC_DIR = BASE_DIR / "static"
|
53 |
+
TEMPLATES_DIR = BASE_DIR / "templates"
|
54 |
+
|
55 |
+
# Crear directorios si no existen
|
56 |
+
TMP_DIR.mkdir(exist_ok=True)
|
57 |
+
STATIC_DIR.mkdir(exist_ok=True)
|
58 |
+
TEMPLATES_DIR.mkdir(exist_ok=True)
|
59 |
+
|
60 |
+
# Montar archivos estáticos
|
61 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
62 |
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
63 |
+
|
64 |
+
# Servir archivo HTML principal
|
65 |
+
@app.get("/", response_class=HTMLResponse)
|
66 |
+
async def read_root(request: Request):
|
67 |
+
return FileResponse(str(STATIC_DIR / "index.html"))
|
68 |
+
|
69 |
+
# Servir archivos estáticos
|
70 |
+
@app.get("/{filename}")
|
71 |
+
async def get_static(filename: str):
|
72 |
+
if filename == "favicon.ico":
|
73 |
+
return FileResponse(str(STATIC_DIR / "favicon.ico"))
|
74 |
+
return FileResponse(str(STATIC_DIR / filename))
|
75 |
+
|
76 |
+
# Redirigir favicon.ico a la ruta estática
|
77 |
+
@app.get("/favicon.ico")
|
78 |
+
async def favicon():
|
79 |
+
return FileResponse(str(STATIC_DIR / "favicon.ico"))
|
80 |
+
|
81 |
+
@app.post("/match_cv/")
|
82 |
+
async def match_cv(offer_text: str = Form(...),
|
83 |
+
cv_file: UploadFile = None):
|
84 |
+
# Constantes de validación
|
85 |
+
MIN_OFFER_LENGTH = 100
|
86 |
+
MAX_OFFER_LENGTH = 10000
|
87 |
+
MIN_CV_LENGTH = 10
|
88 |
+
|
89 |
+
# Validar longitud del texto de la oferta
|
90 |
+
offer_length = len(offer_text)
|
91 |
+
if offer_length < MIN_OFFER_LENGTH:
|
92 |
+
return {"error": f"The text of the offer is too short. Minimum required: {MIN_OFFER_LENGTH} characters"}
|
93 |
+
if offer_length > MAX_OFFER_LENGTH:
|
94 |
+
return {"error": f"The text of the offer is too long. Maximum allowed: {MAX_OFFER_LENGTH} characters"}
|
95 |
+
|
96 |
+
if cv_file is None:
|
97 |
+
return {"error": "No CV file was uploaded"}
|
98 |
+
|
99 |
+
# Leer el contenido del archivo una sola vez
|
100 |
+
content = await cv_file.read()
|
101 |
+
|
102 |
+
# Validar tamaño mínimo del CV
|
103 |
+
if len(content) < MIN_CV_LENGTH:
|
104 |
+
return {"error": f"The CV file is too small. Minimum required: {MIN_CV_LENGTH} characters"}
|
105 |
+
|
106 |
+
# Validar tamaño máximo del archivo (5MB)
|
107 |
+
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB en bytes
|
108 |
+
if len(content) > MAX_FILE_SIZE:
|
109 |
+
return {"error": "The file is too large. Maximum allowed: 5MB"}
|
110 |
+
|
111 |
+
# Guardar el contenido para su posterior uso
|
112 |
+
file_content = content
|
113 |
+
await cv_file.seek(0)
|
114 |
+
|
115 |
+
# Guardar CV temporalmente cambiando el nombre a cv.pdf
|
116 |
+
cv_file.filename = "cv.pdf"
|
117 |
+
cv_path = os.path.join(TMP_DIR, cv_file.filename)
|
118 |
+
with open(cv_path, "wb") as f:
|
119 |
+
f.write(content)
|
120 |
+
|
121 |
+
# Guardar oferta como txt temporal
|
122 |
+
offer_path = os.path.join(TMP_DIR, "offer.txt")
|
123 |
+
with open(offer_path, "w", encoding="utf-8") as f:
|
124 |
+
f.write(offer_text)
|
125 |
+
|
126 |
+
# Calcular similitud usando tu matcher
|
127 |
+
try:
|
128 |
+
scores = matcher.final_score(offer_path, cv_path)
|
129 |
+
except Exception as e:
|
130 |
+
return {"error": str(e)}
|
131 |
+
|
132 |
+
return scores
|
133 |
+
|
134 |
+
|
135 |
+
if __name__ == "__main__":
|
136 |
+
import os, uvicorn
|
137 |
+
port = int(os.environ.get("PORT", 8000))
|
138 |
+
# reload
|
139 |
+
uvicorn.reload = True
|
140 |
+
uvicorn.run("main:app", host="0.0.0.0", port=port)
|
project/prompts/prompt_cv.txt
ADDED
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
Analiza el siguiente CV.
|
2 |
+
|
3 |
+
CV:
|
4 |
+
{cv_text}
|
5 |
+
|
6 |
+
Tareas:
|
7 |
+
|
8 |
+
1. Extrae todas las habilidades técnicas del candidato.
|
9 |
+
2. Extrae todas las soft skills relevantes.
|
10 |
+
3. Calcula los años de experiencia del candidato por empresa.
|
11 |
+
- Si aparece directamente la antigüedad en años, usa ese valor.
|
12 |
+
- Si aparecen fechas de inicio y fin, resta ambas (fin - inicio).
|
13 |
+
- Si no aparece fecha fin, usa la fecha actual {actual_date} como fin.
|
14 |
+
- Si dentro de una empresa hay varios roles, suma los años de cada rol.
|
15 |
+
Por favor piensa muy bien y toma el tiempo que necesites en hacer esta operación, ya que es muy importante. Formatea todos las fechas al mismo formato si así es más sencillo hacer la operación
|
16 |
+
Sobre todo si la fecha fin no está escrita en el CV, es importante que hagas la resta de la fecha actual {actual_date} - fecha inicio. Siempre hacer la resta año-mes y devolver en años.
|
17 |
+
|
18 |
+
4. Identifica el sector o industria principal de la experiencia del candidato.
|
19 |
+
5. Extrae los grados de educación del candidato, siguiendo la siguiente jerarquía:
|
20 |
+
|
21 |
+
1. Educación secundaria/bachillerato
|
22 |
+
2. Bootcamp
|
23 |
+
3. Formación profesional
|
24 |
+
4. Grado universitario
|
25 |
+
5. Máster
|
26 |
+
6. Doctorado
|
27 |
+
|
28 |
+
En el json mostrar el texto (degree, en inglés americano) y el numero asociado en otro parámetro "number".
|
29 |
+
Además indica el campo de estudio más relevante.
|
30 |
+
|
31 |
+
Devuelve la respuesta en formato JSON en inglés americano y con esta estructura exacta:
|
32 |
+
|
33 |
+
{
|
34 |
+
"technical_abilities": [ ... ],
|
35 |
+
"soft_skills": [ ... ],
|
36 |
+
"experience": [
|
37 |
+
{
|
38 |
+
"company": "nombre empresa",
|
39 |
+
"roles": [
|
40 |
+
{"position": "name", "years": X},
|
41 |
+
{"position": "name", "years": Y},
|
42 |
+
],
|
43 |
+
"total_years": Z (sería X + Y)
|
44 |
+
}
|
45 |
+
],
|
46 |
+
"primary_sector": [ *incluir máximo 2 sectores* ],
|
47 |
+
"education": [{
|
48 |
+
"degree": X,
|
49 |
+
"number": Y,
|
50 |
+
"field": Z
|
51 |
+
},
|
52 |
+
{
|
53 |
+
"degree": X,
|
54 |
+
"number": Y,
|
55 |
+
"field": Z
|
56 |
+
}, ...]
|
57 |
+
}
|
58 |
+
|
59 |
+
Por favor no alterar el orden de ningún elemento, devolverlo exactamente en este formato.
|
60 |
+
No incluyas explicaciones adicionales fuera del JSON.
|
61 |
+
Eliminar ```json y ``` de los extremos de la respuesta.
|
project/prompts/prompt_offer.txt
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
Analiza la siguiente oferta de trabajo.
|
2 |
+
|
3 |
+
Oferta:
|
4 |
+
{offer_text}
|
5 |
+
|
6 |
+
Tareas:
|
7 |
+
|
8 |
+
1. Extrae la compañia de la oferta.
|
9 |
+
2. Extrae el sector al que se refiere la oferta.
|
10 |
+
3. Extrae todas las habilidades técnicas relevantes.
|
11 |
+
4. Extrae la experiencia mínima / máxima requerida
|
12 |
+
- si no hay mínima, establecer la mínima como 0
|
13 |
+
- si no hay máxima, establecer la máxima como mínima
|
14 |
+
5. Extrae la educación mínima requerida y en caso de que haya varias opciones, coger la mínima siguiendo el orden:
|
15 |
+
|
16 |
+
1. Educación secundaria/bachillerato
|
17 |
+
2. Bootcamp
|
18 |
+
3. Formación profesional
|
19 |
+
4. Grado universitario
|
20 |
+
5. Máster
|
21 |
+
6. Doctorado
|
22 |
+
|
23 |
+
Si en la oferta está escrito "grado x ó grado y", coger el grado mínimo. Si por el contrario está escrito "grado x y grado y", coger el grado máximo.
|
24 |
+
|
25 |
+
En el json mostrar el texto y el numero asociado en otro parámetro "number".
|
26 |
+
Además indica el campo de estudio más relevante requerido asociado al nivel educativo.
|
27 |
+
|
28 |
+
6. Extrae las soft skills
|
29 |
+
|
30 |
+
Devuelve la respuesta en formato JSON en inglés americano (el contenido del JSON también debe ser en inglés americano) y con esta estructura exacta:
|
31 |
+
{
|
32 |
+
"company": "nombre empresa",
|
33 |
+
"sector": "nombre sector",
|
34 |
+
"role": "nombre rol",
|
35 |
+
"technical_abilities": [ ... ],
|
36 |
+
"experience": {
|
37 |
+
"min": X,
|
38 |
+
"max": Y
|
39 |
+
},
|
40 |
+
"education": {
|
41 |
+
"min": X,
|
42 |
+
"number": Y,
|
43 |
+
"field": Z
|
44 |
+
},
|
45 |
+
"soft_skills": [ ... ]
|
46 |
+
}
|
47 |
+
|
48 |
+
No incluyas explicaciones adicionales fuera del JSON.
|
project/static/chart.js
ADDED
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
function createRadarChart(scores) {
|
2 |
+
const canvas = document.getElementById('radarChart');
|
3 |
+
if (!canvas) {
|
4 |
+
console.error('No se encontró el elemento canvas con id "radarChart"');
|
5 |
+
return;
|
6 |
+
}
|
7 |
+
|
8 |
+
const ctx = canvas.getContext('2d');
|
9 |
+
if (!ctx) {
|
10 |
+
console.error('No se pudo obtener el contexto 2D del canvas');
|
11 |
+
return;
|
12 |
+
}
|
13 |
+
|
14 |
+
// Destruir el gráfico anterior si existe
|
15 |
+
if (window.radarChartInstance) {
|
16 |
+
window.radarChartInstance.destroy();
|
17 |
+
}
|
18 |
+
|
19 |
+
// Definir las categorías que queremos mostrar en el radar
|
20 |
+
const categoriesToShow = [
|
21 |
+
'technical_skills_score',
|
22 |
+
'soft_skills_score',
|
23 |
+
'role_experience_score',
|
24 |
+
'education_score',
|
25 |
+
'sector_score'
|
26 |
+
];
|
27 |
+
|
28 |
+
// Filtrar y formatear solo las categorías que nos interesan
|
29 |
+
const filteredScores = {};
|
30 |
+
categoriesToShow.forEach(category => {
|
31 |
+
if (scores[category] !== undefined) {
|
32 |
+
filteredScores[category] = scores[category];
|
33 |
+
}
|
34 |
+
});
|
35 |
+
|
36 |
+
// Formatear las etiquetas: quitar 'score', guiones bajos y capitalizar cada palabra
|
37 |
+
const formattedCategories = Object.keys(filteredScores).map(label => {
|
38 |
+
// Eliminar 'score' al final del texto
|
39 |
+
let formatted = label.replace(/score$/i, '');
|
40 |
+
// Reemplazar guiones bajos por espacios
|
41 |
+
formatted = formatted.replace(/_/g, ' ');
|
42 |
+
// Capitalizar la primera letra de cada palabra
|
43 |
+
formatted = formatted.toLowerCase()
|
44 |
+
.split(' ')
|
45 |
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
46 |
+
.join(' ')
|
47 |
+
.trim();
|
48 |
+
return formatted;
|
49 |
+
});
|
50 |
+
|
51 |
+
const values = Object.values(filteredScores).map(Number);
|
52 |
+
|
53 |
+
// Configuración del gráfico
|
54 |
+
const config = {
|
55 |
+
type: 'radar',
|
56 |
+
data: {
|
57 |
+
labels: formattedCategories,
|
58 |
+
datasets: [{
|
59 |
+
label: 'Puntuación',
|
60 |
+
data: values,
|
61 |
+
backgroundColor: 'rgba(67, 97, 238, 0.15)',
|
62 |
+
borderColor: 'rgba(67, 97, 238, 0.8)',
|
63 |
+
borderWidth: 2,
|
64 |
+
borderDash: [5, 5], // Makes the line dashed: [dash length, gap length]
|
65 |
+
pointBackgroundColor: 'rgba(67, 97, 238, 1)',
|
66 |
+
pointBorderColor: '#fff',
|
67 |
+
pointHoverBackgroundColor: '#fff',
|
68 |
+
pointHoverBorderColor: 'rgba(67, 97, 238, 0.8)',
|
69 |
+
pointRadius: 4,
|
70 |
+
pointHoverRadius: 6
|
71 |
+
}]
|
72 |
+
},
|
73 |
+
options: {
|
74 |
+
responsive: true,
|
75 |
+
maintainAspectRatio: false,
|
76 |
+
elements: {
|
77 |
+
line: {
|
78 |
+
borderWidth: 2
|
79 |
+
}
|
80 |
+
},
|
81 |
+
plugins: {
|
82 |
+
legend: {
|
83 |
+
display: false
|
84 |
+
},
|
85 |
+
tooltip: {
|
86 |
+
titleFont: {
|
87 |
+
size: 14,
|
88 |
+
weight: '600'
|
89 |
+
},
|
90 |
+
bodyFont: {
|
91 |
+
size: 13
|
92 |
+
},
|
93 |
+
padding: 10
|
94 |
+
}
|
95 |
+
},
|
96 |
+
scales: {
|
97 |
+
r: {
|
98 |
+
angleLines: {
|
99 |
+
display: true,
|
100 |
+
color: 'rgba(0, 0, 0, 0.03)', // Reduced opacity
|
101 |
+
lineWidth: 1
|
102 |
+
},
|
103 |
+
grid: {
|
104 |
+
color: 'rgba(0, 0, 0, 0.03)', // Reduced opacity
|
105 |
+
circular: true,
|
106 |
+
borderWidth: 0.5
|
107 |
+
},
|
108 |
+
pointLabels: {
|
109 |
+
color: '#2b2d42',
|
110 |
+
font: {
|
111 |
+
size: 16,
|
112 |
+
weight: '600',
|
113 |
+
family: '"Segoe UI", Tahoma, Geneva, Verdana, sans-serif'
|
114 |
+
},
|
115 |
+
padding: 15
|
116 |
+
},
|
117 |
+
// Show only 100% tick
|
118 |
+
ticks: {
|
119 |
+
backdropColor: 'transparent',
|
120 |
+
color: 'rgba(0, 0, 0, 0.5)',
|
121 |
+
stepSize: 20,
|
122 |
+
showLabelBackdrop: false,
|
123 |
+
callback: function(value) {
|
124 |
+
if (value === 100) return '100%';
|
125 |
+
return '';
|
126 |
+
},
|
127 |
+
z: 1
|
128 |
+
},
|
129 |
+
// Grid lines
|
130 |
+
grid: {
|
131 |
+
color: 'rgba(0, 0, 0, 0.2)',
|
132 |
+
circular: true,
|
133 |
+
lineWidth: 1
|
134 |
+
},
|
135 |
+
// Angle lines (spokes)
|
136 |
+
angleLines: {
|
137 |
+
display: true,
|
138 |
+
color: 'rgba(0, 0, 0, 0.2)'
|
139 |
+
},
|
140 |
+
// Show point labels (category names)
|
141 |
+
pointLabels: {
|
142 |
+
color: '#2b2d42',
|
143 |
+
font: {
|
144 |
+
size: 14,
|
145 |
+
weight: '500'
|
146 |
+
},
|
147 |
+
padding: 10
|
148 |
+
},
|
149 |
+
// Chart range
|
150 |
+
beginAtZero: true,
|
151 |
+
min: 0,
|
152 |
+
max: 100,
|
153 |
+
// Remove center label
|
154 |
+
afterFit: function(scale) {
|
155 |
+
// This ensures the center label is not shown
|
156 |
+
scale.drawCenterLabel = function() {};
|
157 |
+
}
|
158 |
+
}
|
159 |
+
},
|
160 |
+
plugins: {
|
161 |
+
legend: {
|
162 |
+
display: false
|
163 |
+
},
|
164 |
+
tooltip: {
|
165 |
+
callbacks: {
|
166 |
+
label: function(context) {
|
167 |
+
return context.dataset.label + ': ' + context.raw.toFixed(2) + '%';
|
168 |
+
}
|
169 |
+
},
|
170 |
+
titleFont: {
|
171 |
+
size: 14,
|
172 |
+
weight: 'bold'
|
173 |
+
},
|
174 |
+
bodyFont: {
|
175 |
+
size: 13
|
176 |
+
},
|
177 |
+
padding: 10,
|
178 |
+
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
179 |
+
titleColor: '#fff',
|
180 |
+
bodyColor: '#fff',
|
181 |
+
cornerRadius: 6,
|
182 |
+
displayColors: false
|
183 |
+
}
|
184 |
+
}
|
185 |
+
}
|
186 |
+
};
|
187 |
+
|
188 |
+
// Crear el gráfico
|
189 |
+
window.radarChartInstance = new Chart(ctx, config);
|
190 |
+
}
|
project/static/favicon.ico
ADDED
|
project/static/index.html
ADDED
@@ -0,0 +1,1701 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="es">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>Comparador de CV</title>
|
7 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
8 |
+
<script src="chart.js"></script>
|
9 |
+
<style>
|
10 |
+
:root {
|
11 |
+
--primary-color: #4361ee;
|
12 |
+
--primary-hover: #3a56d4;
|
13 |
+
--secondary-color: #f8f9fa;
|
14 |
+
--background-color: #f5f7ff;
|
15 |
+
--surface-color: #ffffff;
|
16 |
+
--text-color: #2b2d42;
|
17 |
+
--text-light: #6c757d;
|
18 |
+
--border-color: #dee2e6;
|
19 |
+
--success-color: #4bb543;
|
20 |
+
--error-color: #dc3545;
|
21 |
+
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
22 |
+
--border-radius: 8px;
|
23 |
+
--transition: all 0.3s ease;
|
24 |
+
}
|
25 |
+
|
26 |
+
* {
|
27 |
+
margin: 0;
|
28 |
+
padding: 0;
|
29 |
+
box-sizing: border-box;
|
30 |
+
}
|
31 |
+
|
32 |
+
html {
|
33 |
+
overflow-x: hidden;
|
34 |
+
width: 100%;
|
35 |
+
}
|
36 |
+
|
37 |
+
body {
|
38 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
39 |
+
line-height: 1.6;
|
40 |
+
color: var(--text-color);
|
41 |
+
background-color: var(--background-color);
|
42 |
+
padding: 0;
|
43 |
+
margin: 0;
|
44 |
+
min-height: 100vh;
|
45 |
+
transition: var(--transition);
|
46 |
+
width: 100%;
|
47 |
+
overflow-x: hidden;
|
48 |
+
}
|
49 |
+
|
50 |
+
.full-width {
|
51 |
+
width: 100vw;
|
52 |
+
position: relative;
|
53 |
+
left: 50%;
|
54 |
+
right: 50%;
|
55 |
+
margin-left: -50vw;
|
56 |
+
margin-right: -50vw;
|
57 |
+
}
|
58 |
+
|
59 |
+
.app-container {
|
60 |
+
max-width: 100%;
|
61 |
+
width: 100%;
|
62 |
+
margin: 0 auto;
|
63 |
+
min-height: 100vh;
|
64 |
+
display: flex;
|
65 |
+
flex-direction: column;
|
66 |
+
position: relative;
|
67 |
+
overflow-x: hidden;
|
68 |
+
}
|
69 |
+
|
70 |
+
.main-content {
|
71 |
+
flex: 1;
|
72 |
+
max-width: 1200px;
|
73 |
+
width: 90%;
|
74 |
+
margin: 0 auto;
|
75 |
+
padding: 2rem 0;
|
76 |
+
width: 100%;
|
77 |
+
}
|
78 |
+
|
79 |
+
.hero {
|
80 |
+
background: linear-gradient(135deg, #4361ee, #3a0ca3);
|
81 |
+
color: white;
|
82 |
+
padding: 4rem 1rem;
|
83 |
+
text-align: center;
|
84 |
+
position: relative;
|
85 |
+
overflow: hidden;
|
86 |
+
max-width: 100vw;
|
87 |
+
width: 100%;
|
88 |
+
margin: 0 auto;
|
89 |
+
}
|
90 |
+
|
91 |
+
.hero-content {
|
92 |
+
max-width: 1200px;
|
93 |
+
margin: 0 auto;
|
94 |
+
padding: 0 1rem;
|
95 |
+
}
|
96 |
+
|
97 |
+
.hero::before {
|
98 |
+
content: '';
|
99 |
+
position: absolute;
|
100 |
+
top: 0;
|
101 |
+
left: 0;
|
102 |
+
right: 0;
|
103 |
+
bottom: 0;
|
104 |
+
background: radial-gradient(circle at 30% 50%, rgba(255,255,255,0.1) 0%, transparent 50%);
|
105 |
+
pointer-events: none;
|
106 |
+
}
|
107 |
+
|
108 |
+
.hero-content {
|
109 |
+
position: relative;
|
110 |
+
z-index: 1;
|
111 |
+
max-width: 800px;
|
112 |
+
margin: 0 auto;
|
113 |
+
}
|
114 |
+
|
115 |
+
.hero-title {
|
116 |
+
font-size: 3.5rem;
|
117 |
+
font-weight: 700;
|
118 |
+
margin-bottom: 1rem;
|
119 |
+
letter-spacing: -0.5px;
|
120 |
+
line-height: 1.1;
|
121 |
+
}
|
122 |
+
|
123 |
+
.hero-subtitle {
|
124 |
+
font-size: 1.5rem;
|
125 |
+
font-weight: 400;
|
126 |
+
margin-bottom: 2rem;
|
127 |
+
opacity: 0.9;
|
128 |
+
}
|
129 |
+
|
130 |
+
.hero-cta {
|
131 |
+
display: inline-flex;
|
132 |
+
align-items: center;
|
133 |
+
background: rgba(255, 255, 255, 0.15);
|
134 |
+
backdrop-filter: blur(10px);
|
135 |
+
padding: 0.75rem 1.5rem;
|
136 |
+
border-radius: 50px;
|
137 |
+
font-size: 1rem;
|
138 |
+
font-weight: 500;
|
139 |
+
}
|
140 |
+
|
141 |
+
/* Info Section */
|
142 |
+
.info-section {
|
143 |
+
margin-bottom: 4rem;
|
144 |
+
}
|
145 |
+
|
146 |
+
.info-card {
|
147 |
+
background: var(--surface-color);
|
148 |
+
border-radius: var(--border-radius);
|
149 |
+
padding: 2.5rem;
|
150 |
+
box-shadow: var(--shadow);
|
151 |
+
}
|
152 |
+
|
153 |
+
.info-card h2 {
|
154 |
+
text-align: center;
|
155 |
+
font-size: 2rem;
|
156 |
+
margin-bottom: 2.5rem;
|
157 |
+
color: var(--text-color);
|
158 |
+
font-weight: 700;
|
159 |
+
}
|
160 |
+
|
161 |
+
.info-grid {
|
162 |
+
display: grid;
|
163 |
+
grid-template-columns: repeat(3, 1fr);
|
164 |
+
gap: 2rem;
|
165 |
+
}
|
166 |
+
|
167 |
+
.info-item {
|
168 |
+
text-align: center;
|
169 |
+
padding: 1.5rem;
|
170 |
+
border-radius: var(--border-radius);
|
171 |
+
transition: var(--transition);
|
172 |
+
background: rgba(67, 97, 238, 0.05);
|
173 |
+
border: 1px solid rgba(67, 97, 238, 0.1);
|
174 |
+
}
|
175 |
+
|
176 |
+
.info-item:hover {
|
177 |
+
transform: translateY(-5px);
|
178 |
+
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
179 |
+
}
|
180 |
+
|
181 |
+
.info-icon {
|
182 |
+
width: 50px;
|
183 |
+
height: 50px;
|
184 |
+
background: var(--primary-color);
|
185 |
+
color: white;
|
186 |
+
border-radius: 50%;
|
187 |
+
display: flex;
|
188 |
+
align-items: center;
|
189 |
+
justify-content: center;
|
190 |
+
font-size: 1.5rem;
|
191 |
+
font-weight: 700;
|
192 |
+
margin: 0 auto 1rem;
|
193 |
+
}
|
194 |
+
|
195 |
+
.info-item h3 {
|
196 |
+
font-size: 1.25rem;
|
197 |
+
margin-bottom: 0.75rem;
|
198 |
+
color: var(--text-color);
|
199 |
+
}
|
200 |
+
|
201 |
+
.info-item p {
|
202 |
+
color: var(--text-light);
|
203 |
+
line-height: 1.6;
|
204 |
+
}
|
205 |
+
|
206 |
+
/* Divider */
|
207 |
+
.divider {
|
208 |
+
display: flex;
|
209 |
+
align-items: center;
|
210 |
+
text-align: center;
|
211 |
+
margin: 3rem 0;
|
212 |
+
color: #6c757d;
|
213 |
+
font-size: 0.9rem;
|
214 |
+
text-transform: uppercase;
|
215 |
+
letter-spacing: 1px;
|
216 |
+
position: relative;
|
217 |
+
}
|
218 |
+
|
219 |
+
.divider::before,
|
220 |
+
.divider::after {
|
221 |
+
content: '';
|
222 |
+
flex: 1;
|
223 |
+
height: 1px;
|
224 |
+
background: linear-gradient(90deg, transparent, #dee2e6, transparent);
|
225 |
+
border: none;
|
226 |
+
margin: 0;
|
227 |
+
opacity: 0.5;
|
228 |
+
}
|
229 |
+
|
230 |
+
.divider span {
|
231 |
+
padding: 0 1.5rem;
|
232 |
+
position: relative;
|
233 |
+
z-index: 1;
|
234 |
+
color: #6c757d;
|
235 |
+
}
|
236 |
+
|
237 |
+
/* Form Container */
|
238 |
+
.form-container {
|
239 |
+
background: var(--surface-color);
|
240 |
+
border-radius: var(--border-radius);
|
241 |
+
padding: 2.5rem;
|
242 |
+
box-shadow: var(--shadow);
|
243 |
+
margin: 0 auto 3rem;
|
244 |
+
border: 1px solid var(--border-color);
|
245 |
+
max-width: 1200px;
|
246 |
+
width: 90%;
|
247 |
+
}
|
248 |
+
|
249 |
+
.form-actions {
|
250 |
+
margin-top: 2rem;
|
251 |
+
text-align: center;
|
252 |
+
}
|
253 |
+
|
254 |
+
.btn {
|
255 |
+
display: inline-flex;
|
256 |
+
align-items: center;
|
257 |
+
justify-content: center;
|
258 |
+
padding: 0.75rem 2rem;
|
259 |
+
border: none;
|
260 |
+
border-radius: 50px;
|
261 |
+
font-size: 1rem;
|
262 |
+
font-weight: 600;
|
263 |
+
cursor: pointer;
|
264 |
+
transition: all 0.3s ease;
|
265 |
+
position: relative;
|
266 |
+
overflow: hidden;
|
267 |
+
}
|
268 |
+
|
269 |
+
.btn-primary {
|
270 |
+
background: var(--primary-color);
|
271 |
+
color: white;
|
272 |
+
box-shadow: 0 4px 15px rgba(67, 97, 238, 0.3);
|
273 |
+
}
|
274 |
+
|
275 |
+
.btn-primary:hover {
|
276 |
+
background: var(--primary-hover);
|
277 |
+
transform: translateY(-2px);
|
278 |
+
box-shadow: 0 6px 20px rgba(67, 97, 238, 0.4);
|
279 |
+
}
|
280 |
+
|
281 |
+
.btn-primary:active {
|
282 |
+
transform: translateY(0);
|
283 |
+
}
|
284 |
+
|
285 |
+
.btn .btn-loader {
|
286 |
+
display: none;
|
287 |
+
align-items: center;
|
288 |
+
gap: 0.5rem;
|
289 |
+
}
|
290 |
+
|
291 |
+
.btn.loading .btn-text {
|
292 |
+
display: none;
|
293 |
+
}
|
294 |
+
|
295 |
+
.btn.loading .btn-loader {
|
296 |
+
display: flex;
|
297 |
+
}
|
298 |
+
|
299 |
+
.spinner {
|
300 |
+
width: 18px;
|
301 |
+
height: 18px;
|
302 |
+
border: 3px solid rgba(67, 97, 238, 0.2);
|
303 |
+
border-radius: 50%;
|
304 |
+
border-top-color: var(--primary-color);
|
305 |
+
animation: spin 1s ease-in-out infinite;
|
306 |
+
margin-right: 8px;
|
307 |
+
}
|
308 |
+
|
309 |
+
@keyframes spin {
|
310 |
+
to { transform: rotate(360deg); }
|
311 |
+
}
|
312 |
+
|
313 |
+
/* Footer */
|
314 |
+
.footer {
|
315 |
+
background: var(--surface-color);
|
316 |
+
padding: 3rem 1rem;
|
317 |
+
margin: 0 auto;
|
318 |
+
border-top: 1px solid var(--border-color);
|
319 |
+
max-width: 100vw;
|
320 |
+
width: 100%;
|
321 |
+
}
|
322 |
+
|
323 |
+
.footer-content {
|
324 |
+
max-width: 1200px;
|
325 |
+
margin: 0 auto;
|
326 |
+
padding: 0 2rem;
|
327 |
+
text-align: center;
|
328 |
+
}
|
329 |
+
|
330 |
+
.footer-logo {
|
331 |
+
font-size: 1.5rem;
|
332 |
+
font-weight: 700;
|
333 |
+
color: var(--primary-color);
|
334 |
+
margin-bottom: 1.5rem;
|
335 |
+
}
|
336 |
+
|
337 |
+
.footer-links {
|
338 |
+
display: flex;
|
339 |
+
justify-content: center;
|
340 |
+
gap: 2rem;
|
341 |
+
margin-bottom: 1.5rem;
|
342 |
+
flex-wrap: wrap;
|
343 |
+
}
|
344 |
+
|
345 |
+
.footer-link {
|
346 |
+
color: var(--text-light);
|
347 |
+
text-decoration: none;
|
348 |
+
transition: color 0.2s ease;
|
349 |
+
}
|
350 |
+
|
351 |
+
.footer-link:hover {
|
352 |
+
color: var(--primary-color);
|
353 |
+
}
|
354 |
+
|
355 |
+
.footer-copyright {
|
356 |
+
color: var(--text-light);
|
357 |
+
font-size: 0.9rem;
|
358 |
+
}
|
359 |
+
|
360 |
+
.badge {
|
361 |
+
background: #fff;
|
362 |
+
color: var(--primary-color);
|
363 |
+
padding: 0.25rem 0.75rem;
|
364 |
+
border-radius: 50px;
|
365 |
+
font-size: 0.8rem;
|
366 |
+
font-weight: 600;
|
367 |
+
margin-right: 1rem;
|
368 |
+
text-transform: uppercase;
|
369 |
+
letter-spacing: 0.5px;
|
370 |
+
}
|
371 |
+
|
372 |
+
header::after {
|
373 |
+
content: '';
|
374 |
+
position: absolute;
|
375 |
+
bottom: 0;
|
376 |
+
left: 0;
|
377 |
+
right: 0;
|
378 |
+
height: 4px;
|
379 |
+
background: linear-gradient(90deg, transparent, var(--primary-color), transparent);
|
380 |
+
}
|
381 |
+
|
382 |
+
h1 {
|
383 |
+
font-size: 2.2rem;
|
384 |
+
margin-bottom: 0.5rem;
|
385 |
+
font-weight: 700;
|
386 |
+
}
|
387 |
+
|
388 |
+
.subtitle {
|
389 |
+
color: rgba(255, 255, 255, 0.9);
|
390 |
+
font-size: 1.1rem;
|
391 |
+
margin-bottom: 1.5rem;
|
392 |
+
}
|
393 |
+
|
394 |
+
.form-container {
|
395 |
+
padding: 2rem;
|
396 |
+
}
|
397 |
+
|
398 |
+
.form-group {
|
399 |
+
margin-bottom: 1.5rem;
|
400 |
+
}
|
401 |
+
|
402 |
+
label {
|
403 |
+
display: block;
|
404 |
+
margin-bottom: 0.5rem;
|
405 |
+
font-weight: 600;
|
406 |
+
color: var(--text-color);
|
407 |
+
}
|
408 |
+
|
409 |
+
textarea {
|
410 |
+
width: 100%;
|
411 |
+
min-height: 150px;
|
412 |
+
padding: 14px;
|
413 |
+
border: 1px solid var(--border-color);
|
414 |
+
border-radius: var(--border-radius);
|
415 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
416 |
+
font-size: 1rem;
|
417 |
+
line-height: 1.5;
|
418 |
+
color: var(--text-color);
|
419 |
+
background-color: var(--surface-color);
|
420 |
+
resize: vertical;
|
421 |
+
transition: var(--transition);
|
422 |
+
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
|
423 |
+
}
|
424 |
+
|
425 |
+
textarea:focus {
|
426 |
+
outline: none;
|
427 |
+
border-color: var(--primary-color);
|
428 |
+
box-shadow: 0 0 0 2px rgba(67, 97, 238, 0.25);
|
429 |
+
background-color: #fff;
|
430 |
+
}
|
431 |
+
|
432 |
+
textarea::placeholder {
|
433 |
+
color: var(--text-light);
|
434 |
+
opacity: 0.8;
|
435 |
+
}
|
436 |
+
|
437 |
+
/* Character counter styles */
|
438 |
+
.character-counter {
|
439 |
+
text-align: right;
|
440 |
+
font-size: 0.8rem;
|
441 |
+
color: #666;
|
442 |
+
margin-top: 0.25rem;
|
443 |
+
}
|
444 |
+
|
445 |
+
.character-counter.warning {
|
446 |
+
color: #e67e22;
|
447 |
+
}
|
448 |
+
|
449 |
+
.character-counter.error {
|
450 |
+
color: #e74c3c;
|
451 |
+
font-weight: bold;
|
452 |
+
}
|
453 |
+
|
454 |
+
.file-input-container {
|
455 |
+
position: relative;
|
456 |
+
margin-top: 0.5rem;
|
457 |
+
border: 2px dashed var(--border-color);
|
458 |
+
border-radius: var(--border-radius);
|
459 |
+
padding: 2rem 1rem;
|
460 |
+
text-align: center;
|
461 |
+
transition: var(--transition);
|
462 |
+
background-color: var(--surface-color);
|
463 |
+
}
|
464 |
+
|
465 |
+
.file-input-container.drag-over {
|
466 |
+
background-color: rgba(67, 97, 238, 0.1);
|
467 |
+
border-color: var(--primary-color);
|
468 |
+
}
|
469 |
+
|
470 |
+
.file-input-container .file-label {
|
471 |
+
display: flex;
|
472 |
+
flex-direction: column;
|
473 |
+
align-items: center;
|
474 |
+
justify-content: center;
|
475 |
+
cursor: pointer;
|
476 |
+
padding: 1rem;
|
477 |
+
}
|
478 |
+
|
479 |
+
.file-input-container i {
|
480 |
+
font-size: 2rem;
|
481 |
+
margin-bottom: 0.5rem;
|
482 |
+
display: block;
|
483 |
+
}
|
484 |
+
|
485 |
+
.file-input-container .file-input {
|
486 |
+
position: absolute;
|
487 |
+
width: 100%;
|
488 |
+
height: 100%;
|
489 |
+
top: 0;
|
490 |
+
left: 0;
|
491 |
+
opacity: 0;
|
492 |
+
cursor: pointer;
|
493 |
+
}
|
494 |
+
|
495 |
+
.file-input {
|
496 |
+
width: 0.1px;
|
497 |
+
height: 0.1px;
|
498 |
+
opacity: 0;
|
499 |
+
position: absolute;
|
500 |
+
z-index: -1;
|
501 |
+
}
|
502 |
+
|
503 |
+
.file-label {
|
504 |
+
display: flex;
|
505 |
+
align-items: center;
|
506 |
+
justify-content: center;
|
507 |
+
padding: 12px 20px;
|
508 |
+
background-color: rgba(67, 97, 238, 0.05);
|
509 |
+
border: 2px dashed var(--primary-color);
|
510 |
+
border-radius: var(--border-radius);
|
511 |
+
cursor: pointer;
|
512 |
+
transition: var(--transition);
|
513 |
+
text-align: center;
|
514 |
+
color: var(--text-color);
|
515 |
+
}
|
516 |
+
|
517 |
+
.file-label:hover {
|
518 |
+
background-color: rgba(67, 97, 238, 0.1);
|
519 |
+
border-color: var(--primary-hover);
|
520 |
+
}
|
521 |
+
|
522 |
+
.file-label i {
|
523 |
+
margin-right: 8px;
|
524 |
+
color: var(--primary-color);
|
525 |
+
font-size: 1.2em;
|
526 |
+
}
|
527 |
+
|
528 |
+
.file-name {
|
529 |
+
margin-top: 0.5rem;
|
530 |
+
font-size: 0.9rem;
|
531 |
+
color: var(--text-light);
|
532 |
+
font-style: italic;
|
533 |
+
padding: 0.25rem 0.5rem;
|
534 |
+
background: rgba(255, 255, 255, 0.05);
|
535 |
+
border-radius: 4px;
|
536 |
+
display: inline-block;
|
537 |
+
}
|
538 |
+
|
539 |
+
button {
|
540 |
+
display: inline-flex;
|
541 |
+
align-items: center;
|
542 |
+
justify-content: center;
|
543 |
+
background-color: var(--primary-color);
|
544 |
+
color: white;
|
545 |
+
border: none;
|
546 |
+
padding: 12px 24px;
|
547 |
+
border-radius: var(--border-radius);
|
548 |
+
font-size: 1rem;
|
549 |
+
font-weight: 600;
|
550 |
+
cursor: pointer;
|
551 |
+
transition: var(--transition);
|
552 |
+
width: 100%;
|
553 |
+
}
|
554 |
+
|
555 |
+
button:hover {
|
556 |
+
background-color: var(--primary-hover);
|
557 |
+
transform: translateY(-2px);
|
558 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
559 |
+
}
|
560 |
+
|
561 |
+
button:active {
|
562 |
+
transform: translateY(0);
|
563 |
+
}
|
564 |
+
|
565 |
+
button:disabled {
|
566 |
+
background-color: #cccccc;
|
567 |
+
cursor: not-allowed;
|
568 |
+
transform: none;
|
569 |
+
box-shadow: none;
|
570 |
+
}
|
571 |
+
|
572 |
+
button i {
|
573 |
+
margin-right: 8px;
|
574 |
+
}
|
575 |
+
/* Resultados */
|
576 |
+
#result {
|
577 |
+
margin-top: 2rem;
|
578 |
+
display: none;
|
579 |
+
animation: fadeIn 0.5s ease;
|
580 |
+
}
|
581 |
+
|
582 |
+
.results-container {
|
583 |
+
background: var(--surface-color);
|
584 |
+
border-radius: var(--border-radius);
|
585 |
+
box-shadow: var(--shadow);
|
586 |
+
padding: 2rem;
|
587 |
+
margin-top: 2rem;
|
588 |
+
border: 1px solid var(--border-color);
|
589 |
+
}
|
590 |
+
|
591 |
+
.results-header {
|
592 |
+
display: flex;
|
593 |
+
justify-content: space-between;
|
594 |
+
align-items: center;
|
595 |
+
margin-bottom: 1.5rem;
|
596 |
+
padding-bottom: 1rem;
|
597 |
+
border-bottom: 1px solid rgba(67, 97, 238, 0.15);
|
598 |
+
position: relative;
|
599 |
+
}
|
600 |
+
|
601 |
+
.results-header::after {
|
602 |
+
content: '';
|
603 |
+
position: absolute;
|
604 |
+
bottom: -1px;
|
605 |
+
left: 0;
|
606 |
+
width: 100px;
|
607 |
+
height: 2px;
|
608 |
+
background: var(--primary-color);
|
609 |
+
}
|
610 |
+
|
611 |
+
.results-title {
|
612 |
+
font-size: 1.5rem;
|
613 |
+
color: #fff;
|
614 |
+
font-weight: 600;
|
615 |
+
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
616 |
+
position: relative;
|
617 |
+
padding-left: 15px;
|
618 |
+
}
|
619 |
+
|
620 |
+
.results-title::before {
|
621 |
+
content: '';
|
622 |
+
position: absolute;
|
623 |
+
left: 0;
|
624 |
+
top: 50%;
|
625 |
+
transform: translateY(-50%);
|
626 |
+
width: 4px;
|
627 |
+
height: 24px;
|
628 |
+
background: var(--primary-color);
|
629 |
+
border-radius: 2px;
|
630 |
+
}
|
631 |
+
|
632 |
+
.chart-container {
|
633 |
+
position: relative;
|
634 |
+
width: 100%;
|
635 |
+
height: 500px;
|
636 |
+
margin: 2rem 0;
|
637 |
+
}
|
638 |
+
|
639 |
+
#radarChart {
|
640 |
+
width: 100% !important;
|
641 |
+
height: 100% !important;
|
642 |
+
}
|
643 |
+
|
644 |
+
.scores-container {
|
645 |
+
margin-top: 2rem;
|
646 |
+
width: 100%;
|
647 |
+
}
|
648 |
+
|
649 |
+
.scores-grid {
|
650 |
+
display: grid;
|
651 |
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
652 |
+
gap: 1.5rem;
|
653 |
+
margin-top: 1.5rem;
|
654 |
+
}
|
655 |
+
|
656 |
+
@media (max-width: 768px) {
|
657 |
+
.scores-grid {
|
658 |
+
grid-template-columns: 1fr;
|
659 |
+
}
|
660 |
+
}
|
661 |
+
|
662 |
+
.scores-list ul {
|
663 |
+
list-style: none;
|
664 |
+
padding: 0;
|
665 |
+
margin: 0;
|
666 |
+
display: flex;
|
667 |
+
flex-direction: column;
|
668 |
+
gap: 1rem;
|
669 |
+
width: 100%;
|
670 |
+
}
|
671 |
+
|
672 |
+
.scores-list li {
|
673 |
+
background: var(--secondary-color);
|
674 |
+
padding: 1rem;
|
675 |
+
border-radius: var(--border-radius);
|
676 |
+
display: flex;
|
677 |
+
justify-content: space-between;
|
678 |
+
align-items: center;
|
679 |
+
font-size: 0.95rem;
|
680 |
+
}
|
681 |
+
|
682 |
+
.score-value {
|
683 |
+
font-weight: 700;
|
684 |
+
color: var(--primary-color);
|
685 |
+
background: rgba(67, 97, 238, 0.1);
|
686 |
+
padding: 0.25rem 0.75rem;
|
687 |
+
border-radius: 20px;
|
688 |
+
font-size: 0.9rem;
|
689 |
+
}
|
690 |
+
|
691 |
+
/* Loading */
|
692 |
+
.loading {
|
693 |
+
text-align: center;
|
694 |
+
display: none;
|
695 |
+
margin: 2rem 0;
|
696 |
+
padding: 2rem;
|
697 |
+
background: rgba(67, 97, 238, 0.1);
|
698 |
+
border-radius: var(--border-radius);
|
699 |
+
border: 1px dashed rgba(67, 97, 238, 0.3);
|
700 |
+
}
|
701 |
+
|
702 |
+
.spinner {
|
703 |
+
border: 4px solid rgba(67, 97, 238, 0.1);
|
704 |
+
border-top: 4px solid var(--primary-color);
|
705 |
+
border-radius: 50%;
|
706 |
+
width: 40px;
|
707 |
+
height: 40px;
|
708 |
+
animation: spin 1s linear infinite;
|
709 |
+
margin: 0 auto 1rem;
|
710 |
+
box-shadow: 0 0 10px rgba(67, 97, 238, 0.3);
|
711 |
+
}
|
712 |
+
|
713 |
+
.loading-text {
|
714 |
+
color: var(--text-light);
|
715 |
+
font-size: 1.1rem;
|
716 |
+
margin-top: 1rem;
|
717 |
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
718 |
+
}
|
719 |
+
|
720 |
+
/* Animaciones */
|
721 |
+
@keyframes spin {
|
722 |
+
0% { transform: rotate(0deg); }
|
723 |
+
100% { transform: rotate(360deg); }
|
724 |
+
}
|
725 |
+
|
726 |
+
@keyframes fadeIn {
|
727 |
+
from { opacity: 0; transform: translateY(10px); }
|
728 |
+
to { opacity: 1; transform: translateY(0); }
|
729 |
+
}
|
730 |
+
|
731 |
+
/* Notificaciones */
|
732 |
+
.notification {
|
733 |
+
position: fixed;
|
734 |
+
bottom: 20px;
|
735 |
+
right: 20px;
|
736 |
+
padding: 15px 25px;
|
737 |
+
border-radius: var(--border-radius);
|
738 |
+
color: white;
|
739 |
+
font-weight: 500;
|
740 |
+
box-shadow: var(--shadow);
|
741 |
+
transform: translateY(100px);
|
742 |
+
opacity: 0;
|
743 |
+
transition: all 0.3s ease;
|
744 |
+
z-index: 1000;
|
745 |
+
max-width: 90%;
|
746 |
+
}
|
747 |
+
|
748 |
+
.notification.show {
|
749 |
+
transform: translateY(0);
|
750 |
+
opacity: 1;
|
751 |
+
}
|
752 |
+
|
753 |
+
.notification.success {
|
754 |
+
background-color: var(--success-color);
|
755 |
+
}
|
756 |
+
|
757 |
+
.notification.error {
|
758 |
+
background-color: var(--error-color);
|
759 |
+
}
|
760 |
+
|
761 |
+
.notification.info {
|
762 |
+
background-color: var(--primary-color);
|
763 |
+
}
|
764 |
+
|
765 |
+
/* Barras de progreso */
|
766 |
+
.progress-bar {
|
767 |
+
position: relative;
|
768 |
+
background: #f0f2f5;
|
769 |
+
border-radius: 8px;
|
770 |
+
height: 50px;
|
771 |
+
overflow: hidden;
|
772 |
+
display: flex;
|
773 |
+
align-items: center;
|
774 |
+
padding: 0 20px;
|
775 |
+
border: 1px solid #e1e4e8;
|
776 |
+
transition: all 0.3s ease;
|
777 |
+
width: 100%;
|
778 |
+
box-sizing: border-box;
|
779 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
780 |
+
}
|
781 |
+
|
782 |
+
.progress-bar:hover {
|
783 |
+
transform: translateY(-2px);
|
784 |
+
box-shadow: 0 4px 15px rgba(67, 97, 238, 0.15);
|
785 |
+
border-color: var(--primary-color);
|
786 |
+
}
|
787 |
+
|
788 |
+
.progress-bar-fill {
|
789 |
+
position: absolute;
|
790 |
+
top: 0;
|
791 |
+
left: 0;
|
792 |
+
height: 100%;
|
793 |
+
background: linear-gradient(90deg,
|
794 |
+
rgba(67, 97, 238, 0.15),
|
795 |
+
rgba(67, 97, 238, 0.3));
|
796 |
+
border-radius: 8px 0 0 8px;
|
797 |
+
transition: width 0.7s cubic-bezier(0.4, 0, 0.2, 1);
|
798 |
+
width: 0;
|
799 |
+
box-shadow: 2px 0 10px rgba(67, 97, 238, 0.1);
|
800 |
+
}
|
801 |
+
|
802 |
+
.score-card {
|
803 |
+
background: var(--surface-color);
|
804 |
+
border-radius: var(--border-radius);
|
805 |
+
box-shadow: var(--shadow);
|
806 |
+
margin-bottom: 1rem;
|
807 |
+
overflow: hidden;
|
808 |
+
transition: all 0.3s ease;
|
809 |
+
opacity: 0;
|
810 |
+
transform: translateY(20px);
|
811 |
+
animation: fadeInUp 0.5s ease forwards;
|
812 |
+
}
|
813 |
+
|
814 |
+
.score-card-header {
|
815 |
+
display: flex;
|
816 |
+
justify-content: space-between;
|
817 |
+
align-items: center;
|
818 |
+
padding: 1rem 1.5rem;
|
819 |
+
cursor: pointer;
|
820 |
+
transition: background-color 0.2s ease;
|
821 |
+
}
|
822 |
+
|
823 |
+
.score-card-header:hover {
|
824 |
+
background-color: rgba(67, 97, 238, 0.05);
|
825 |
+
}
|
826 |
+
|
827 |
+
.score-card-header h3 {
|
828 |
+
margin: 0;
|
829 |
+
font-size: 1.1rem;
|
830 |
+
color: var(--text-color);
|
831 |
+
}
|
832 |
+
|
833 |
+
.score-value {
|
834 |
+
font-weight: 600;
|
835 |
+
color: var(--primary-color);
|
836 |
+
margin: 0 1rem;
|
837 |
+
}
|
838 |
+
|
839 |
+
.toggle-icon {
|
840 |
+
transition: transform 0.3s ease;
|
841 |
+
color: var(--text-light);
|
842 |
+
}
|
843 |
+
|
844 |
+
.score-card.active .toggle-icon {
|
845 |
+
transform: rotate(180deg);
|
846 |
+
}
|
847 |
+
|
848 |
+
.score-card-content {
|
849 |
+
max-height: 0;
|
850 |
+
overflow: hidden;
|
851 |
+
transition: max-height 0.3s ease;
|
852 |
+
padding: 0 1.5rem;
|
853 |
+
}
|
854 |
+
|
855 |
+
.score-card.active .score-card-content {
|
856 |
+
max-height: 500px;
|
857 |
+
padding: 0 1.5rem 1.5rem;
|
858 |
+
}
|
859 |
+
|
860 |
+
.score-details {
|
861 |
+
margin-top: 1rem;
|
862 |
+
padding-top: 1rem;
|
863 |
+
border-top: 1px solid var(--border-color);
|
864 |
+
}
|
865 |
+
|
866 |
+
.progress-text {
|
867 |
+
position: relative;
|
868 |
+
z-index: 1;
|
869 |
+
font-weight: 600;
|
870 |
+
color: var(--text-color);
|
871 |
+
font-size: 0.95rem;
|
872 |
+
flex: 1;
|
873 |
+
text-transform: capitalize;
|
874 |
+
letter-spacing: 0.3px;
|
875 |
+
}
|
876 |
+
|
877 |
+
.progress-value {
|
878 |
+
position: relative;
|
879 |
+
font-weight: 700;
|
880 |
+
color: #fff;
|
881 |
+
background: linear-gradient(45deg, var(--primary-color), #5a7aff);
|
882 |
+
padding: 5px 12px;
|
883 |
+
border-radius: 15px;
|
884 |
+
font-size: 0.95rem;
|
885 |
+
z-index: 1;
|
886 |
+
margin-left: 10px;
|
887 |
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
888 |
+
min-width: 70px;
|
889 |
+
text-align: center;
|
890 |
+
box-shadow: 0 2px 8px rgba(67, 97, 238, 0.2);
|
891 |
+
}
|
892 |
+
|
893 |
+
/* Responsive */
|
894 |
+
/* Responsive */
|
895 |
+
@media (max-width: 992px) {
|
896 |
+
.info-grid {
|
897 |
+
grid-template-columns: 1fr 1fr;
|
898 |
+
}
|
899 |
+
}
|
900 |
+
|
901 |
+
@media (max-width: 768px) {
|
902 |
+
.hero-title {
|
903 |
+
font-size: 2.5rem;
|
904 |
+
}
|
905 |
+
|
906 |
+
.hero-subtitle {
|
907 |
+
font-size: 1.25rem;
|
908 |
+
}
|
909 |
+
|
910 |
+
.info-grid {
|
911 |
+
grid-template-columns: 1fr;
|
912 |
+
}
|
913 |
+
|
914 |
+
.form-container {
|
915 |
+
padding: 1.5rem;
|
916 |
+
}
|
917 |
+
body {
|
918 |
+
padding: 10px;
|
919 |
+
}
|
920 |
+
|
921 |
+
.form-container {
|
922 |
+
padding: 1.5rem;
|
923 |
+
}
|
924 |
+
|
925 |
+
.scores-list ul {
|
926 |
+
grid-template-columns: 1fr;
|
927 |
+
}
|
928 |
+
|
929 |
+
.chart-container {
|
930 |
+
height: 400px;
|
931 |
+
}
|
932 |
+
}
|
933 |
+
</style>
|
934 |
+
</head>
|
935 |
+
<body>
|
936 |
+
<div class="app-container">
|
937 |
+
<div class="full-width">
|
938 |
+
<header class="hero">
|
939 |
+
<div class="hero-content">
|
940 |
+
<h1 class="hero-title">Ninja CV</h1>
|
941 |
+
<p class="hero-subtitle">Leverage your job search</p>
|
942 |
+
<div class="hero-cta">
|
943 |
+
<span>Smart • Fast • Precise</span>
|
944 |
+
</div>
|
945 |
+
</div>
|
946 |
+
</header>
|
947 |
+
</div>
|
948 |
+
|
949 |
+
<!-- Main Content -->
|
950 |
+
<main class="main-content">
|
951 |
+
<section class="info-section">
|
952 |
+
<div class="info-card">
|
953 |
+
<h2>How does it work?</h2>
|
954 |
+
<div class="info-grid">
|
955 |
+
<div class="info-item">
|
956 |
+
<div class="info-icon">1</div>
|
957 |
+
<h3>Paste the offer</h3>
|
958 |
+
<p>Insert the full text of the job offer you are interested in</p>
|
959 |
+
</div>
|
960 |
+
<div class="info-item">
|
961 |
+
<div class="info-icon">2</div>
|
962 |
+
<h3>Upload your CV</h3>
|
963 |
+
<p>Upload your CV in PDF format to analyze it</p>
|
964 |
+
</div>
|
965 |
+
<div class="info-item">
|
966 |
+
<div class="info-icon">3</div>
|
967 |
+
<h3>Get results</h3>
|
968 |
+
<p>Receive a detailed analysis of your compatibility with the offer</p>
|
969 |
+
</div>
|
970 |
+
</div>
|
971 |
+
</div>
|
972 |
+
|
973 |
+
<div class="divider">
|
974 |
+
<span>Start analysis</span>
|
975 |
+
</div>
|
976 |
+
|
977 |
+
<div class="form-container">
|
978 |
+
<form id="cvForm" onsubmit="evaluateCV(); return false;">
|
979 |
+
<div class="form-group">
|
980 |
+
<label for="offerText">📋 Offer text</label>
|
981 |
+
<textarea
|
982 |
+
class="form-control"
|
983 |
+
id="offerText"
|
984 |
+
name="offer_text"
|
985 |
+
rows="15"
|
986 |
+
maxlength="10000"
|
987 |
+
placeholder="Pega aquí el texto de la oferta de empleo..."
|
988 |
+
required
|
989 |
+
oninput="updateCharCount(this)"
|
990 |
+
></textarea>
|
991 |
+
<div class="character-counter" id="charCounter">0/10000 characters</div>
|
992 |
+
</div>
|
993 |
+
|
994 |
+
<div class="form-group">
|
995 |
+
<label for="cvFile">📄 Upload your CV (PDF)</label>
|
996 |
+
<div class="file-input-container" id="dropZone">
|
997 |
+
<label for="cvFile" class="file-label">
|
998 |
+
<i>📤</i>
|
999 |
+
<span id="fileName">Click to select or drag & drop your CV here</span>
|
1000 |
+
</label>
|
1001 |
+
<input
|
1002 |
+
type="file"
|
1003 |
+
id="cvFile"
|
1004 |
+
class="file-input"
|
1005 |
+
accept=".pdf"
|
1006 |
+
onchange="updateFileName()"
|
1007 |
+
required
|
1008 |
+
>
|
1009 |
+
</div>
|
1010 |
+
</div>
|
1011 |
+
|
1012 |
+
<div class="form-actions">
|
1013 |
+
<button type="submit" class="btn btn-primary" id="analyzeBtn">
|
1014 |
+
<span class="btn-text">Analyze</span>
|
1015 |
+
<span class="btn-loader" style="display: none;">
|
1016 |
+
<span class="spinner"></span>
|
1017 |
+
Analyzing...
|
1018 |
+
</span>
|
1019 |
+
</div>
|
1020 |
+
|
1021 |
+
<section id="result" class="section" style="display: none;">
|
1022 |
+
<div class="container">
|
1023 |
+
<h2 class="section-title">Results</h2>
|
1024 |
+
<div class="results-container">
|
1025 |
+
<div class="chart-container">
|
1026 |
+
<canvas id="radarChart"></canvas>
|
1027 |
+
</div>
|
1028 |
+
<div class="scores-container">
|
1029 |
+
<h3>Score details</h3>
|
1030 |
+
<div id="scoresList" class="scores-grid">
|
1031 |
+
<!-- Las tarjetas de puntuación se insertarán aquí dinámicamente -->
|
1032 |
+
</div>
|
1033 |
+
</div>
|
1034 |
+
</div>
|
1035 |
+
</div>
|
1036 |
+
</section>
|
1037 |
+
</section>
|
1038 |
+
</main>
|
1039 |
+
|
1040 |
+
<div class="full-width">
|
1041 |
+
<footer class="footer">
|
1042 |
+
<div class="footer-content">
|
1043 |
+
<div class="footer-logo">Ninja CV</div>
|
1044 |
+
<div class="footer-copyright">
|
1045 |
+
© 2025 Ninja CV. All rights reserved.
|
1046 |
+
</div>
|
1047 |
+
</div>
|
1048 |
+
</footer>
|
1049 |
+
</div>
|
1050 |
+
</div>
|
1051 |
+
|
1052 |
+
<script>
|
1053 |
+
// Función para actualizar el nombre del archivo seleccionado
|
1054 |
+
function updateFileName() {
|
1055 |
+
const fileInput = document.getElementById('cvFile');
|
1056 |
+
const fileNameDisplay = document.getElementById('fileName');
|
1057 |
+
const file = fileInput.files[0];
|
1058 |
+
fileNameDisplay.textContent = file ? file.name : 'Click to select or drag & drop your CV here';
|
1059 |
+
}
|
1060 |
+
|
1061 |
+
// Drag and Drop functionality
|
1062 |
+
document.addEventListener('DOMContentLoaded', function() {
|
1063 |
+
const dropZone = document.getElementById('dropZone');
|
1064 |
+
const fileInput = document.getElementById('cvFile');
|
1065 |
+
|
1066 |
+
// Prevent default drag behaviors
|
1067 |
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
1068 |
+
dropZone.addEventListener(eventName, preventDefaults, false);
|
1069 |
+
document.body.addEventListener(eventName, preventDefaults, false);
|
1070 |
+
});
|
1071 |
+
|
1072 |
+
// Highlight drop zone when item is dragged over it
|
1073 |
+
['dragenter', 'dragover'].forEach(eventName => {
|
1074 |
+
dropZone.addEventListener(eventName, highlight, false);
|
1075 |
+
});
|
1076 |
+
|
1077 |
+
['dragleave', 'drop'].forEach(eventName => {
|
1078 |
+
dropZone.addEventListener(eventName, unhighlight, false);
|
1079 |
+
});
|
1080 |
+
|
1081 |
+
// Handle dropped files
|
1082 |
+
dropZone.addEventListener('drop', handleDrop, false);
|
1083 |
+
|
1084 |
+
// File input change handler
|
1085 |
+
fileInput.addEventListener('change', function(e) {
|
1086 |
+
const file = this.files[0];
|
1087 |
+
if (file) {
|
1088 |
+
const maxSize = 5 * 1024 * 1024; // 5MB in bytes
|
1089 |
+
if (file.size > maxSize) {
|
1090 |
+
showNotification('File size must be less than 5MB', 'error');
|
1091 |
+
this.value = ''; // Clear the input
|
1092 |
+
updateFileName();
|
1093 |
+
} else {
|
1094 |
+
updateFileName();
|
1095 |
+
}
|
1096 |
+
}
|
1097 |
+
});
|
1098 |
+
|
1099 |
+
function preventDefaults(e) {
|
1100 |
+
e.preventDefault();
|
1101 |
+
e.stopPropagation();
|
1102 |
+
}
|
1103 |
+
|
1104 |
+
function highlight() {
|
1105 |
+
dropZone.classList.add('drag-over');
|
1106 |
+
}
|
1107 |
+
|
1108 |
+
function unhighlight() {
|
1109 |
+
dropZone.classList.remove('drag-over');
|
1110 |
+
}
|
1111 |
+
|
1112 |
+
function handleDrop(e) {
|
1113 |
+
const dt = e.dataTransfer;
|
1114 |
+
const files = dt.files;
|
1115 |
+
|
1116 |
+
if (files.length) {
|
1117 |
+
const file = files[0];
|
1118 |
+
// Check file type
|
1119 |
+
if (file.type !== 'application/pdf') {
|
1120 |
+
showNotification('Please upload a valid PDF file', 'error');
|
1121 |
+
return;
|
1122 |
+
}
|
1123 |
+
|
1124 |
+
// Check file size (5MB max)
|
1125 |
+
const maxSize = 5 * 1024 * 1024; // 5MB in bytes
|
1126 |
+
if (file.size > maxSize) {
|
1127 |
+
showNotification('File size must be less than 5MB', 'error');
|
1128 |
+
return;
|
1129 |
+
}
|
1130 |
+
|
1131 |
+
fileInput.files = files;
|
1132 |
+
updateFileName();
|
1133 |
+
}
|
1134 |
+
}
|
1135 |
+
|
1136 |
+
});
|
1137 |
+
|
1138 |
+
// Función para actualizar el contador de caracteres
|
1139 |
+
function updateCharCount(textarea) {
|
1140 |
+
const charCount = textarea.value.length;
|
1141 |
+
const maxLength = textarea.maxLength;
|
1142 |
+
const minLength = 100; // Mínimo de caracteres
|
1143 |
+
const counter = document.getElementById('charCounter');
|
1144 |
+
|
1145 |
+
counter.textContent = `${charCount}/${maxLength} characters`;
|
1146 |
+
|
1147 |
+
// Cambiar estilos según el número de caracteres
|
1148 |
+
counter.className = 'character-counter';
|
1149 |
+
|
1150 |
+
if (charCount === 0) {
|
1151 |
+
counter.textContent = `At least ${minLength} characters`;
|
1152 |
+
counter.classList.add('error');
|
1153 |
+
} else if (charCount < minLength) {
|
1154 |
+
counter.textContent = `${charCount}/${minLength} characters`;
|
1155 |
+
counter.classList.add('error');
|
1156 |
+
} else {
|
1157 |
+
const percentage = (charCount / maxLength) * 100;
|
1158 |
+
if (percentage > 90) {
|
1159 |
+
counter.classList.add('error');
|
1160 |
+
} else if (percentage > 75) {
|
1161 |
+
counter.classList.add('warning');
|
1162 |
+
}
|
1163 |
+
}
|
1164 |
+
}
|
1165 |
+
|
1166 |
+
// Inicializar contador al cargar la página
|
1167 |
+
document.addEventListener('DOMContentLoaded', function() {
|
1168 |
+
const textarea = document.getElementById('offerText');
|
1169 |
+
if (textarea) {
|
1170 |
+
updateCharCount(textarea);
|
1171 |
+
}
|
1172 |
+
});
|
1173 |
+
|
1174 |
+
// Función para mostrar notificación
|
1175 |
+
function showNotification(message, type = 'info') {
|
1176 |
+
const notification = document.createElement('div');
|
1177 |
+
notification.className = `notification ${type}`;
|
1178 |
+
notification.textContent = message;
|
1179 |
+
|
1180 |
+
document.body.appendChild(notification);
|
1181 |
+
|
1182 |
+
// Mostrar notificación
|
1183 |
+
setTimeout(() => notification.classList.add('show'), 10);
|
1184 |
+
|
1185 |
+
// Ocultar después de 5 segundos
|
1186 |
+
setTimeout(() => {
|
1187 |
+
notification.classList.remove('show');
|
1188 |
+
setTimeout(() => notification.remove(), 300);
|
1189 |
+
}, 5000);
|
1190 |
+
}
|
1191 |
+
|
1192 |
+
// Function to validate the form
|
1193 |
+
function validateForm() {
|
1194 |
+
// Validate offer text
|
1195 |
+
const offerText = document.getElementById('offerText').value.trim();
|
1196 |
+
const MIN_OFFER_LENGTH = 100;
|
1197 |
+
const MAX_OFFER_LENGTH = 10000;
|
1198 |
+
|
1199 |
+
if (!offerText) {
|
1200 |
+
showNotification('Please enter the offer text', 'error');
|
1201 |
+
return false;
|
1202 |
+
}
|
1203 |
+
|
1204 |
+
if (offerText.length < MIN_OFFER_LENGTH) {
|
1205 |
+
showNotification(`The offer text is too short. Minimum required: ${MIN_OFFER_LENGTH} characters`, 'error');
|
1206 |
+
return false;
|
1207 |
+
}
|
1208 |
+
|
1209 |
+
if (offerText.length > MAX_OFFER_LENGTH) {
|
1210 |
+
showNotification(`The offer text is too long. Maximum allowed: ${MAX_OFFER_LENGTH} characters`, 'error');
|
1211 |
+
return false;
|
1212 |
+
}
|
1213 |
+
|
1214 |
+
// Validate CV file
|
1215 |
+
const cvFile = document.getElementById('cvFile').files[0];
|
1216 |
+
const MIN_CV_SIZE = 10; // characters
|
1217 |
+
const MAX_CV_SIZE = 5 * 1024 * 1024; // 5MB
|
1218 |
+
|
1219 |
+
if (!cvFile) {
|
1220 |
+
showNotification('Please select a PDF file', 'error');
|
1221 |
+
return false;
|
1222 |
+
}
|
1223 |
+
|
1224 |
+
// Check file type
|
1225 |
+
if (cvFile.type !== 'application/pdf') {
|
1226 |
+
showNotification('Only PDF files are allowed', 'error');
|
1227 |
+
return false;
|
1228 |
+
}
|
1229 |
+
|
1230 |
+
// Check file size
|
1231 |
+
if (cvFile.size < MIN_CV_SIZE) {
|
1232 |
+
showNotification(`The CV file is too small. Minimum required: ${MIN_CV_SIZE} characters`, 'error');
|
1233 |
+
return false;
|
1234 |
+
}
|
1235 |
+
|
1236 |
+
if (cvFile.size > MAX_CV_SIZE) {
|
1237 |
+
showNotification('The file is too large. Maximum allowed: 5MB', 'error');
|
1238 |
+
return false;
|
1239 |
+
}
|
1240 |
+
|
1241 |
+
return true;
|
1242 |
+
}
|
1243 |
+
|
1244 |
+
async function evaluateCV() {
|
1245 |
+
// Validate form before submitting
|
1246 |
+
if (!validateForm()) {
|
1247 |
+
return;
|
1248 |
+
}
|
1249 |
+
|
1250 |
+
const form = document.getElementById('cvForm');
|
1251 |
+
const formData = new FormData(form);
|
1252 |
+
const offerText = document.getElementById('offerText').value;
|
1253 |
+
const cvFile = document.getElementById('cvFile').files[0];
|
1254 |
+
|
1255 |
+
// Get DOM elements
|
1256 |
+
const loadingSpinner = document.getElementById('loadingSpinner');
|
1257 |
+
const analyzeBtn = document.getElementById('analyzeBtn');
|
1258 |
+
const btnText = analyzeBtn?.querySelector('.btn-text');
|
1259 |
+
const btnLoader = analyzeBtn?.querySelector('.btn-loader');
|
1260 |
+
const resultDiv = document.getElementById('result');
|
1261 |
+
const loadingDiv = document.getElementById('loading');
|
1262 |
+
|
1263 |
+
// Show loading elements
|
1264 |
+
if (loadingSpinner) loadingSpinner.style.display = 'block';
|
1265 |
+
if (analyzeBtn) analyzeBtn.disabled = true;
|
1266 |
+
if (btnText) btnText.style.display = 'none';
|
1267 |
+
if (btnLoader) btnLoader.style.display = 'flex';
|
1268 |
+
if (loadingDiv) loadingDiv.style.display = 'block';
|
1269 |
+
if (resultDiv) resultDiv.style.display = 'none';
|
1270 |
+
|
1271 |
+
|
1272 |
+
try {
|
1273 |
+
// Deshabilitar botón y mostrar carga
|
1274 |
+
analyzeBtn.disabled = true;
|
1275 |
+
if (btnText) btnText.style.display = 'none';
|
1276 |
+
if (btnLoader) btnLoader.style.display = 'flex';
|
1277 |
+
|
1278 |
+
// Mostrar indicador de carga y ocultar resultados anteriores
|
1279 |
+
if (loadingDiv) loadingDiv.style.display = 'block';
|
1280 |
+
if (resultDiv) resultDiv.style.display = 'none';
|
1281 |
+
|
1282 |
+
// Mostrar notificación de inicio
|
1283 |
+
showNotification('Analyzing your CV, please wait...', 'info');
|
1284 |
+
|
1285 |
+
const formData = new FormData();
|
1286 |
+
formData.append('offer_text', offerText);
|
1287 |
+
formData.append('cv_file', cvFile);
|
1288 |
+
|
1289 |
+
console.log('Sending request to server...');
|
1290 |
+
const response = await fetch('http://localhost:8000/match_cv/', {
|
1291 |
+
method: 'POST',
|
1292 |
+
body: formData
|
1293 |
+
});
|
1294 |
+
|
1295 |
+
console.log('Response received:', response.status, response.statusText);
|
1296 |
+
|
1297 |
+
if (!response.ok) {
|
1298 |
+
const errorText = await response.text();
|
1299 |
+
console.error('Error in response:', errorText);
|
1300 |
+
throw new Error(`Error en la solicitud: ${response.status} ${response.statusText}`);
|
1301 |
+
}
|
1302 |
+
|
1303 |
+
const data = await response.json();
|
1304 |
+
console.log('Received data:', data);
|
1305 |
+
|
1306 |
+
if (data.error) {
|
1307 |
+
throw new Error(data.error);
|
1308 |
+
}
|
1309 |
+
|
1310 |
+
// Mostrar resultados con animación
|
1311 |
+
setTimeout(() => {
|
1312 |
+
displayResults(data);
|
1313 |
+
showNotification('Analysis completed successfully!', 'success');
|
1314 |
+
}, 300);
|
1315 |
+
|
1316 |
+
} catch (error) {
|
1317 |
+
console.error('Error en evaluateCV:', error);
|
1318 |
+
|
1319 |
+
// Mostrar notificación de error
|
1320 |
+
const errorMessage = error.message || 'An error occurred while processing the request';
|
1321 |
+
showNotification(`Error: ${errorMessage}`, 'error');
|
1322 |
+
|
1323 |
+
} finally {
|
1324 |
+
// Asegurarse de que el botón se restablezca incluso si hay un error
|
1325 |
+
if (analyzeBtn) analyzeBtn.disabled = false;
|
1326 |
+
if (btnText) btnText.style.display = 'inline';
|
1327 |
+
if (btnLoader) btnLoader.style.display = 'none';
|
1328 |
+
if (loadingDiv) loadingDiv.style.display = 'none';
|
1329 |
+
}
|
1330 |
+
}
|
1331 |
+
|
1332 |
+
function formatCategoryName(category) {
|
1333 |
+
let formatted = category.replace(/score$/i, '');
|
1334 |
+
formatted = formatted.replace(/_/g, ' ');
|
1335 |
+
return formatted.toLowerCase()
|
1336 |
+
.split(' ')
|
1337 |
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
1338 |
+
.join(' ')
|
1339 |
+
.trim();
|
1340 |
+
}
|
1341 |
+
|
1342 |
+
function displayResults(scores) {
|
1343 |
+
const resultDiv = document.getElementById('result');
|
1344 |
+
const scoresList = document.getElementById('scoresList');
|
1345 |
+
|
1346 |
+
// Limpiar resultados anteriores
|
1347 |
+
scoresList.innerHTML = '';
|
1348 |
+
|
1349 |
+
try {
|
1350 |
+
// Crear el gráfico de radar
|
1351 |
+
createRadarChart(scores);
|
1352 |
+
|
1353 |
+
// Definir las categorías que queremos mostrar (mismas que en el radar)
|
1354 |
+
const categoriesToShow = [
|
1355 |
+
'technical_skills_score',
|
1356 |
+
'soft_skills_score',
|
1357 |
+
'role_experience_score',
|
1358 |
+
'education_score',
|
1359 |
+
'sector_score'
|
1360 |
+
];
|
1361 |
+
|
1362 |
+
// Filtrar y mostrar solo las categorías que nos interesan
|
1363 |
+
categoriesToShow.forEach((category, index) => {
|
1364 |
+
if (scores[category] !== undefined) {
|
1365 |
+
const score = scores[category];
|
1366 |
+
const formattedScore = parseFloat(score).toFixed(2);
|
1367 |
+
const percentage = Math.max(5, Math.round(score));
|
1368 |
+
|
1369 |
+
// Crear tarjeta
|
1370 |
+
const card = document.createElement('div');
|
1371 |
+
card.className = 'score-card';
|
1372 |
+
card.style.animationDelay = `${index * 0.1}s`;
|
1373 |
+
|
1374 |
+
// Crear encabezado de la tarjeta
|
1375 |
+
const cardHeader = document.createElement('div');
|
1376 |
+
cardHeader.className = 'score-card-header';
|
1377 |
+
|
1378 |
+
// Título de la categoría
|
1379 |
+
const title = document.createElement('h3');
|
1380 |
+
title.textContent = formatCategoryName(category);
|
1381 |
+
|
1382 |
+
// Valor del score
|
1383 |
+
const scoreValue = document.createElement('div');
|
1384 |
+
scoreValue.className = 'score-value';
|
1385 |
+
scoreValue.textContent = `${formattedScore}%`;
|
1386 |
+
|
1387 |
+
// Ícono de toggle
|
1388 |
+
const toggleIcon = document.createElement('span');
|
1389 |
+
toggleIcon.className = 'toggle-icon';
|
1390 |
+
toggleIcon.innerHTML = '▼';
|
1391 |
+
|
1392 |
+
// Barra de progreso
|
1393 |
+
const progressBar = document.createElement('div');
|
1394 |
+
progressBar.className = 'progress-bar';
|
1395 |
+
|
1396 |
+
const progressFill = document.createElement('div');
|
1397 |
+
progressFill.className = 'progress-bar-fill';
|
1398 |
+
progressBar.appendChild(progressFill);
|
1399 |
+
|
1400 |
+
// Contenido expandible
|
1401 |
+
const cardContent = document.createElement('div');
|
1402 |
+
cardContent.className = 'score-card-content';
|
1403 |
+
|
1404 |
+
// Detalles específicos de cada categoría
|
1405 |
+
const details = document.createElement('div');
|
1406 |
+
details.className = 'score-details';
|
1407 |
+
|
1408 |
+
// Mostrar habilidades técnicas (top 3 y bottom 3)
|
1409 |
+
if (category === 'technical_skills_score' && scores.technical_skills) {
|
1410 |
+
const skillsContainer = document.createElement('div');
|
1411 |
+
|
1412 |
+
// Mostrar top 3 habilidades
|
1413 |
+
if (scores.technical_skills.top_matches && scores.technical_skills.top_matches.length > 0) {
|
1414 |
+
const topTitle = document.createElement('h4');
|
1415 |
+
topTitle.textContent = 'Top 3 skills (high similarity):';
|
1416 |
+
topTitle.style.margin = '0.5rem 0';
|
1417 |
+
skillsContainer.appendChild(topTitle);
|
1418 |
+
|
1419 |
+
const topList = document.createElement('ul');
|
1420 |
+
topList.style.listStyle = 'none';
|
1421 |
+
topList.style.padding = '0';
|
1422 |
+
topList.style.margin = '0.5rem 0 1rem 0';
|
1423 |
+
|
1424 |
+
scores.technical_skills.top_matches.forEach(skill => {
|
1425 |
+
if (skill) {
|
1426 |
+
const li = document.createElement('li');
|
1427 |
+
li.style.padding = '0.5rem 0';
|
1428 |
+
li.style.borderBottom = '1px solid var(--border-color)';
|
1429 |
+
li.textContent = skill;
|
1430 |
+
topList.appendChild(li);
|
1431 |
+
}
|
1432 |
+
});
|
1433 |
+
skillsContainer.appendChild(topList);
|
1434 |
+
}
|
1435 |
+
|
1436 |
+
// Mostrar bottom 3 habilidades si existen
|
1437 |
+
if (scores.technical_skills.bottom_matches && scores.technical_skills.bottom_matches.length > 0) {
|
1438 |
+
const bottomTitle = document.createElement('h4');
|
1439 |
+
bottomTitle.textContent = 'Bottom 3 skills (low similarity):';
|
1440 |
+
bottomTitle.style.margin = '1rem 0 0.5rem 0';
|
1441 |
+
skillsContainer.appendChild(bottomTitle);
|
1442 |
+
|
1443 |
+
const bottomList = document.createElement('ul');
|
1444 |
+
bottomList.style.listStyle = 'none';
|
1445 |
+
bottomList.style.padding = '0';
|
1446 |
+
bottomList.style.margin = '0.5rem 0';
|
1447 |
+
|
1448 |
+
scores.technical_skills.bottom_matches.forEach(skill => {
|
1449 |
+
if (skill) {
|
1450 |
+
const li = document.createElement('li');
|
1451 |
+
li.style.padding = '0.5rem 0';
|
1452 |
+
li.style.borderBottom = '1px solid var(--border-color)';
|
1453 |
+
li.textContent = skill;
|
1454 |
+
bottomList.appendChild(li);
|
1455 |
+
}
|
1456 |
+
});
|
1457 |
+
skillsContainer.appendChild(bottomList);
|
1458 |
+
}
|
1459 |
+
|
1460 |
+
details.appendChild(skillsContainer);
|
1461 |
+
}
|
1462 |
+
|
1463 |
+
// Mostrar detalles de educación
|
1464 |
+
if (category === 'education_score' && scores.education) {
|
1465 |
+
const eduDetails = document.createElement('div');
|
1466 |
+
eduDetails.style.marginTop = '1rem';
|
1467 |
+
|
1468 |
+
// Explicación principal
|
1469 |
+
//if (scores.education.explanation) {
|
1470 |
+
// const explanation = document.createElement('p');
|
1471 |
+
// explanation.textContent = scores.education.explanation;
|
1472 |
+
// explanation.style.margin = '0.5rem 0';
|
1473 |
+
// eduDetails.appendChild(explanation);
|
1474 |
+
//}
|
1475 |
+
|
1476 |
+
// Detalles adicionales
|
1477 |
+
if (scores.education.details) {
|
1478 |
+
const detailList = document.createElement('ul');
|
1479 |
+
detailList.style.listStyle = 'none';
|
1480 |
+
detailList.style.padding = '0';
|
1481 |
+
detailList.style.margin = '0.5rem 0';
|
1482 |
+
|
1483 |
+
const minReqItem = document.createElement('li');
|
1484 |
+
minReqItem.style.padding = '0.5rem 0';
|
1485 |
+
minReqItem.style.borderBottom = '1px solid var(--border-color)';
|
1486 |
+
minReqItem.innerHTML = `<strong>Required education level:</strong> ${scores.education.details.minimum_required_level || 'Not specified'}`;
|
1487 |
+
|
1488 |
+
const minFieldItem = document.createElement('li');
|
1489 |
+
minFieldItem.style.padding = '0.5rem 0';
|
1490 |
+
minFieldItem.style.borderBottom = '1px solid var(--border-color)';
|
1491 |
+
minFieldItem.innerHTML = `<strong>Required field of study:</strong> ${scores.education.details.minimum_required_field || 'Not specified'}`;
|
1492 |
+
|
1493 |
+
const candidateLevelItem = document.createElement('li');
|
1494 |
+
candidateLevelItem.style.padding = '0.5rem 0';
|
1495 |
+
candidateLevelItem.style.borderBottom = '1px solid var(--border-color)';
|
1496 |
+
candidateLevelItem.innerHTML = `<strong>Candidate education level:</strong> ${scores.education.details.equivalent_level_cv || 'Not specified'}`;
|
1497 |
+
|
1498 |
+
const candidateFieldItem = document.createElement('li');
|
1499 |
+
candidateFieldItem.style.padding = '0.5rem 0';
|
1500 |
+
candidateFieldItem.style.borderBottom = '1px solid var(--border-color)';
|
1501 |
+
candidateFieldItem.innerHTML = `<strong>Candidate field of study:</strong> ${scores.education.details.equivalent_field_cv || 'Not specified'}`;
|
1502 |
+
|
1503 |
+
const higherItem = document.createElement('li');
|
1504 |
+
higherItem.style.padding = '0.5rem 0';
|
1505 |
+
higherItem.style.borderBottom = '1px solid var(--border-color)';
|
1506 |
+
higherItem.innerHTML = `<strong>Higher education:</strong> ${scores.education.details.higher_education_degrees && scores.education.details.higher_education_degrees.length > 0 ? scores.education.details.higher_education_degrees.join(', ') : 'None'}`;
|
1507 |
+
|
1508 |
+
detailList.appendChild(minReqItem);
|
1509 |
+
detailList.appendChild(minFieldItem);
|
1510 |
+
detailList.appendChild(candidateLevelItem);
|
1511 |
+
detailList.appendChild(candidateFieldItem);
|
1512 |
+
detailList.appendChild(higherItem);
|
1513 |
+
|
1514 |
+
eduDetails.appendChild(detailList);
|
1515 |
+
}
|
1516 |
+
|
1517 |
+
details.appendChild(eduDetails);
|
1518 |
+
}
|
1519 |
+
|
1520 |
+
|
1521 |
+
// Mostrar soft skills (formato top/bottom o lista ordenada)
|
1522 |
+
if (category === 'soft_skills_score' && scores.soft_skills) {
|
1523 |
+
const skillsContainer = document.createElement('div');
|
1524 |
+
|
1525 |
+
if (scores.soft_skills.top_matches) {
|
1526 |
+
// Formato con top y bottom matches
|
1527 |
+
if (scores.soft_skills.top_matches && scores.soft_skills.top_matches.length > 0) {
|
1528 |
+
const topTitle = document.createElement('h4');
|
1529 |
+
topTitle.textContent = 'Top 3 skills (high similarity):';
|
1530 |
+
topTitle.style.margin = '0.5rem 0';
|
1531 |
+
skillsContainer.appendChild(topTitle);
|
1532 |
+
|
1533 |
+
const topList = document.createElement('ul');
|
1534 |
+
topList.style.listStyle = 'none';
|
1535 |
+
topList.style.padding = '0';
|
1536 |
+
topList.style.margin = '0.5rem 0 1rem 0';
|
1537 |
+
|
1538 |
+
scores.soft_skills.top_matches.forEach(skill => {
|
1539 |
+
if (skill) {
|
1540 |
+
const li = document.createElement('li');
|
1541 |
+
li.style.padding = '0.5rem 0';
|
1542 |
+
li.style.borderBottom = '1px solid var(--border-color)';
|
1543 |
+
li.textContent = skill;
|
1544 |
+
topList.appendChild(li);
|
1545 |
+
}
|
1546 |
+
});
|
1547 |
+
skillsContainer.appendChild(topList);
|
1548 |
+
}
|
1549 |
+
|
1550 |
+
if (scores.soft_skills.bottom_matches && scores.soft_skills.bottom_matches.length > 0) {
|
1551 |
+
const bottomTitle = document.createElement('h4');
|
1552 |
+
bottomTitle.textContent = 'Bottom 3 skills (low similarity):';
|
1553 |
+
bottomTitle.style.margin = '1rem 0 0.5rem 0';
|
1554 |
+
skillsContainer.appendChild(bottomTitle);
|
1555 |
+
|
1556 |
+
const bottomList = document.createElement('ul');
|
1557 |
+
bottomList.style.listStyle = 'none';
|
1558 |
+
bottomList.style.padding = '0';
|
1559 |
+
bottomList.style.margin = '0.5rem 0';
|
1560 |
+
|
1561 |
+
scores.soft_skills.bottom_matches.forEach(skill => {
|
1562 |
+
if (skill) {
|
1563 |
+
const li = document.createElement('li');
|
1564 |
+
li.style.padding = '0.5rem 0';
|
1565 |
+
li.style.borderBottom = '1px solid var(--border-color)';
|
1566 |
+
li.textContent = skill;
|
1567 |
+
bottomList.appendChild(li);
|
1568 |
+
}
|
1569 |
+
});
|
1570 |
+
skillsContainer.appendChild(bottomList);
|
1571 |
+
}
|
1572 |
+
} else if (scores.soft_skills.title && scores.soft_skills.skills) {
|
1573 |
+
// Formato con lista ordenada
|
1574 |
+
const title = document.createElement('h4');
|
1575 |
+
title.textContent = scores.soft_skills.title;
|
1576 |
+
title.style.margin = '0.5rem 0';
|
1577 |
+
skillsContainer.appendChild(title);
|
1578 |
+
|
1579 |
+
const skillsList = document.createElement('ul');
|
1580 |
+
skillsList.style.listStyle = 'none';
|
1581 |
+
skillsList.style.padding = '0';
|
1582 |
+
skillsList.style.margin = '0.5rem 0';
|
1583 |
+
|
1584 |
+
scores.soft_skills.skills.forEach(skill => {
|
1585 |
+
if (skill) {
|
1586 |
+
const li = document.createElement('li');
|
1587 |
+
li.style.padding = '0.5rem 0';
|
1588 |
+
li.style.borderBottom = '1px solid var(--border-color)';
|
1589 |
+
li.textContent = skill;
|
1590 |
+
skillsList.appendChild(li);
|
1591 |
+
}
|
1592 |
+
});
|
1593 |
+
skillsContainer.appendChild(skillsList);
|
1594 |
+
}
|
1595 |
+
|
1596 |
+
details.appendChild(skillsContainer);
|
1597 |
+
}
|
1598 |
+
|
1599 |
+
// Mostrar detalles de experiencia en el rol
|
1600 |
+
if (category === 'role_experience_score' && scores.role_experience) {
|
1601 |
+
const expDetails = document.createElement('div');
|
1602 |
+
expDetails.style.marginTop = '1rem';
|
1603 |
+
|
1604 |
+
if (scores.role_experience.explanation) {
|
1605 |
+
const explanation = document.createElement('p');
|
1606 |
+
explanation.textContent = scores.role_experience.explanation;
|
1607 |
+
explanation.style.margin = '0.5rem 0';
|
1608 |
+
expDetails.appendChild(explanation);
|
1609 |
+
}
|
1610 |
+
|
1611 |
+
details.appendChild(expDetails);
|
1612 |
+
}
|
1613 |
+
|
1614 |
+
// Mostrar detalles del sector
|
1615 |
+
if (category === 'sector_score' && scores.sector) {
|
1616 |
+
const sectorDetails = document.createElement('div');
|
1617 |
+
sectorDetails.style.marginTop = '1rem';
|
1618 |
+
|
1619 |
+
// Detalles adicionales
|
1620 |
+
if (scores.sector.details) {
|
1621 |
+
const detailList = document.createElement('ul');
|
1622 |
+
detailList.style.listStyle = 'none';
|
1623 |
+
detailList.style.padding = '0';
|
1624 |
+
detailList.style.margin = '0.5rem 0';
|
1625 |
+
|
1626 |
+
const offerSectorItem = document.createElement('li');
|
1627 |
+
offerSectorItem.style.padding = '0.5rem 0';
|
1628 |
+
offerSectorItem.style.borderBottom = '1px solid var(--border-color)';
|
1629 |
+
offerSectorItem.innerHTML = `<strong>Offer sector:</strong> ${scores.sector.details.offer_sector || 'Not specified'}`;
|
1630 |
+
|
1631 |
+
const cvSectorItem = document.createElement('li');
|
1632 |
+
cvSectorItem.style.padding = '0.5rem 0';
|
1633 |
+
cvSectorItem.style.borderBottom = '1px solid var(--border-color)';
|
1634 |
+
cvSectorItem.innerHTML = `<strong>CV sector:</strong> ${scores.sector.details.cv_sector || 'Not specified'}`;
|
1635 |
+
|
1636 |
+
const similarityItem = document.createElement('li');
|
1637 |
+
similarityItem.style.padding = '0.5rem 0';
|
1638 |
+
similarityItem.style.borderBottom = '1px solid var(--border-color)';
|
1639 |
+
similarityItem.innerHTML = `<strong>Similarity:</strong> ${scores.sector.details.similarity || '0'}%`;
|
1640 |
+
|
1641 |
+
detailList.appendChild(offerSectorItem);
|
1642 |
+
detailList.appendChild(cvSectorItem);
|
1643 |
+
detailList.appendChild(similarityItem);
|
1644 |
+
|
1645 |
+
sectorDetails.appendChild(detailList);
|
1646 |
+
}
|
1647 |
+
|
1648 |
+
details.appendChild(sectorDetails);
|
1649 |
+
}
|
1650 |
+
|
1651 |
+
cardContent.appendChild(details);
|
1652 |
+
|
1653 |
+
// Construir la estructura
|
1654 |
+
cardHeader.appendChild(title);
|
1655 |
+
cardHeader.appendChild(scoreValue);
|
1656 |
+
cardHeader.appendChild(toggleIcon);
|
1657 |
+
|
1658 |
+
card.appendChild(cardHeader);
|
1659 |
+
card.appendChild(progressBar);
|
1660 |
+
card.appendChild(cardContent);
|
1661 |
+
|
1662 |
+
scoresList.appendChild(card);
|
1663 |
+
|
1664 |
+
// Animar la entrada
|
1665 |
+
setTimeout(() => {
|
1666 |
+
card.style.opacity = '1';
|
1667 |
+
card.style.transform = 'translateY(0)';
|
1668 |
+
|
1669 |
+
// Animar la barra de progreso
|
1670 |
+
setTimeout(() => {
|
1671 |
+
progressFill.style.width = `${percentage}%`;
|
1672 |
+
}, 50);
|
1673 |
+
|
1674 |
+
// Toggle para expandir/colapsar
|
1675 |
+
cardHeader.addEventListener('click', () => {
|
1676 |
+
card.classList.toggle('active');
|
1677 |
+
});
|
1678 |
+
|
1679 |
+
}, 100 * index);
|
1680 |
+
}
|
1681 |
+
});
|
1682 |
+
|
1683 |
+
// Mostrar resultados con animación
|
1684 |
+
resultDiv.style.display = 'block';
|
1685 |
+
|
1686 |
+
// Desplazarse suavemente a los resultados
|
1687 |
+
setTimeout(() => {
|
1688 |
+
resultDiv.scrollIntoView({
|
1689 |
+
behavior: 'smooth',
|
1690 |
+
block: 'start'
|
1691 |
+
});
|
1692 |
+
}, 100);
|
1693 |
+
|
1694 |
+
} catch (error) {
|
1695 |
+
console.error('Error showing results:', error);
|
1696 |
+
showNotification('Error showing results', 'error');
|
1697 |
+
}
|
1698 |
+
}
|
1699 |
+
</script>
|
1700 |
+
</body>
|
1701 |
+
</html>
|
requirements.txt
ADDED
Binary file (8.02 kB). View file
|
|