1000jaus commited on
Commit
cd89586
·
1 Parent(s): 52b3bfb

first comit

Browse files
.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
+ &copy; 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