Daniel Tatar StanSava pixel-pirat3 commited on
Commit
07273d8
·
1 Parent(s): c398d3e

Cv reader + matching project (#13)

Browse files

Co-authored-by: Stanislav Sava <[email protected]>
Co-authored-by: Dragos Dit <[email protected]>

LLM/llm_models.py CHANGED
@@ -16,6 +16,9 @@ orchestrator_model = LLMProvider(base_provider, base_model).get_model()
16
  supabase_model = LLMProvider(base_provider, base_model).get_model()
17
  websearch_model = LLMProvider(base_provider, base_model).get_model()
18
 
 
 
 
19
  context_compression_model = LLMProvider(LLMProviderType.OPENAI, LLMModelType.openai.gpt_5_1).get_model()
20
  metaprompting_model = LLMProvider(LLMProviderType.OPENAI, LLMModelType.openai.gpt_5_1).get_model()
21
 
 
16
  supabase_model = LLMProvider(base_provider, base_model).get_model()
17
  websearch_model = LLMProvider(base_provider, base_model).get_model()
18
 
19
+ # CV analyzer uses the same HF/opensource model as orchestrator
20
+ cv_analyzer_model = LLMProvider(base_provider, base_model).get_model()
21
+
22
  context_compression_model = LLMProvider(LLMProviderType.OPENAI, LLMModelType.openai.gpt_5_1).get_model()
23
  metaprompting_model = LLMProvider(LLMProviderType.OPENAI, LLMModelType.openai.gpt_5_1).get_model()
24
 
UI/app_interface.py CHANGED
@@ -7,9 +7,11 @@ from typing import Any, Callable, Deque, Dict, Optional
7
 
8
  import gradio as gr # type: ignore
9
 
10
- from .render_plan_html import render_plan_html
11
  from third_party_tools.text_to_audio_file import text_to_audio_file
12
 
 
 
 
13
 
14
  class EastSyncInterface:
15
  """
@@ -32,21 +34,69 @@ class EastSyncInterface:
32
  )
33
 
34
  self._app_css = self._compose_css()
35
-
 
 
 
 
 
 
 
 
 
 
36
  # ---------------------- HELPER METHODS ----------------------
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  def register_agent_action(self, action: str, args: Optional[Dict[str, Any]] = None):
39
  import datetime
40
  timestamp = datetime.datetime.now().strftime("%H:%M:%S")
41
  with self._action_log_lock:
42
  # Keep the high-contrast aesthetic but clean up the formatting
43
- msg = f'<span class="console-timestamp">{timestamp}</span> <span style="color:var(--arc-yellow)">>></span> {html.escape(str(action))}'
44
  if args:
45
  args_str = str(args)
46
  if len(args_str) > 80:
47
  args_str = args_str[:80] + "..."
48
- msg += f' <span style="color:var(--text-dim)">:: {html.escape(args_str)}</span>'
49
  self._action_log.appendleft(f'<div class="console-line">{msg}</div>')
 
 
 
 
 
 
 
 
50
 
51
  def get_action_log_text(self) -> str:
52
  with self._action_log_lock:
@@ -65,6 +115,52 @@ class EastSyncInterface:
65
  audio_out = gr.update(value=audio_path, visible=is_audio)
66
  html_out = render_plan_html(result)
67
  return html_out, audio_out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  def render_idle_state(self) -> str:
70
  # The style is handled inside render_plan_html.py CSS for consistency.
@@ -85,8 +181,173 @@ class EastSyncInterface:
85
 
86
  def reset_prompt_value(self) -> str:
87
  return self.SAMPLE_PROMPT
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
- # ---------------------- TACTICAL CSS (UNCHANGED) ----------------------
90
 
91
  def _token_css(self) -> str:
92
  return """
@@ -301,7 +562,7 @@ class EastSyncInterface:
301
  min-height: 300px;
302
  max-height: 40vh;
303
  overflow-y: auto;
304
- color: var(--text-dim);
305
  }
306
  .console-line {
307
  margin-bottom: 8px;
@@ -314,6 +575,17 @@ class EastSyncInterface:
314
  .console-wrapper::-webkit-scrollbar { width: 8px; }
315
  .console-wrapper::-webkit-scrollbar-track { background: #08090D; }
316
  .console-wrapper::-webkit-scrollbar-thumb { background: var(--border-dim); border-radius: 4px; }
 
 
 
 
 
 
 
 
 
 
 
317
  """
318
 
319
  def _compose_css(self) -> str:
@@ -367,7 +639,7 @@ class EastSyncInterface:
367
  with gr.Row(equal_height=True, elem_classes=["main-container"]):
368
 
369
  # --- LEFT COLUMN: INPUTS ---
370
- with gr.Column(scale=3, elem_classes=["input-panel"]):
371
  gr.HTML("<div class='ent-header-label'>PROJECT PARAMETERS</div>")
372
 
373
  input_box = gr.TextArea(
@@ -386,27 +658,175 @@ class EastSyncInterface:
386
 
387
  with gr.Row():
388
  btn_cancel = gr.Button("STOP ANALYSIS", elem_classes=["btn-tac-secondary"])
 
 
389
 
390
  gr.HTML("<div style='height:30px'></div>") # Flexible Spacer
391
 
392
  gr.HTML("<div class='ent-header-label'>ACTIVITY LOG</div>")
393
  console = gr.HTML(self.get_action_log_text())
394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  # --- RIGHT COLUMN: OUTPUT ---
396
  with gr.Column(scale=7, elem_classes=["output-panel"]):
397
  output_display = gr.HTML(self.render_idle_state())
398
 
399
  # --- Event Bindings ---
400
- btn_run.click(self.clear_action_log, outputs=console, queue=False) \
401
- .then(lambda: self.get_action_log_text(), outputs=console) \
402
- .then(analyze_callback, inputs=input_box, outputs=[output_display, output_audio]) \
403
- .then(self.get_action_log_text, outputs=console)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
 
405
  btn_reset.click(self.reset_prompt_value, outputs=input_box)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
 
407
  btn_cancel.click(cancel_run_callback)
408
 
409
- # Live log updates
410
- gr.Timer(2).tick(self.get_action_log_text, outputs=console)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
 
412
  return demo
 
7
 
8
  import gradio as gr # type: ignore
9
 
 
10
  from third_party_tools.text_to_audio_file import text_to_audio_file
11
 
12
+ from .cv_interface import CVInterface
13
+ from .render_plan_html import render_plan_html
14
+
15
 
16
  class EastSyncInterface:
17
  """
 
34
  )
35
 
36
  self._app_css = self._compose_css()
37
+ self._cv_interface = CVInterface(self)
38
+ self._analysis_result: Optional[Any] = None # Store analysis result for async updates
39
+ self._analysis_error: Optional[str] = None # Store analysis error if any
40
+ self._analysis_running: bool = False # Track if analysis is currently running
41
+ self._cached_processing_state: Optional[str] = None # Cache processing state HTML
42
+
43
+ # Dynamic processing steps tracking
44
+ self._processing_steps: list[str] = [] # Current processing steps
45
+ self._processing_steps_lock = Lock()
46
+ self._processing_mode: Optional[str] = None # "project", "extract", or "match"
47
+
48
  # ---------------------- HELPER METHODS ----------------------
49
 
50
+ def start_processing(self, mode: str):
51
+ """Start processing mode and reset steps. Mode: 'project', 'extract', or 'match'."""
52
+ with self._processing_steps_lock:
53
+ self._processing_steps = []
54
+ self._processing_mode = mode
55
+ self._analysis_running = True
56
+ self._analysis_result = None
57
+ self._analysis_error = None
58
+ self._cached_processing_state = None
59
+
60
+ def stop_processing(self):
61
+ """Stop processing mode and clear steps."""
62
+ with self._processing_steps_lock:
63
+ self._processing_mode = None
64
+ self._analysis_running = False
65
+
66
+ def add_processing_step(self, step: str):
67
+ """Add a new processing step to the dynamic list."""
68
+ with self._processing_steps_lock:
69
+ # Avoid duplicates
70
+ if step not in self._processing_steps:
71
+ self._processing_steps.append(step)
72
+ # Invalidate cache so next render picks up new steps
73
+ self._cached_processing_state = None
74
+
75
+ def get_processing_steps(self) -> list[str]:
76
+ """Get current processing steps."""
77
+ with self._processing_steps_lock:
78
+ return list(self._processing_steps)
79
+
80
  def register_agent_action(self, action: str, args: Optional[Dict[str, Any]] = None):
81
  import datetime
82
  timestamp = datetime.datetime.now().strftime("%H:%M:%S")
83
  with self._action_log_lock:
84
  # Keep the high-contrast aesthetic but clean up the formatting
85
+ msg = f'<span class="console-timestamp">{timestamp}</span> <span style="color:var(--arc-yellow)">>></span> <span style="color:var(--text-main); opacity:0.9;">{html.escape(str(action))}</span>'
86
  if args:
87
  args_str = str(args)
88
  if len(args_str) > 80:
89
  args_str = args_str[:80] + "..."
90
+ msg += f' <span style="color:var(--text-main); opacity:0.7;">:: {html.escape(args_str)}</span>'
91
  self._action_log.appendleft(f'<div class="console-line">{msg}</div>')
92
+
93
+ # Add to processing steps if we're in processing mode (check INSIDE lock to avoid race condition)
94
+ with self._processing_steps_lock:
95
+ if self._processing_mode is not None:
96
+ action_str = str(action)
97
+ if action_str not in self._processing_steps:
98
+ self._processing_steps.append(action_str)
99
+ self._cached_processing_state = None
100
 
101
  def get_action_log_text(self) -> str:
102
  with self._action_log_lock:
 
115
  audio_out = gr.update(value=audio_path, visible=is_audio)
116
  html_out = render_plan_html(result)
117
  return html_out, audio_out
118
+
119
+ def set_analysis_result(self, result: Any):
120
+ """Store analysis result for async display."""
121
+ self._analysis_result = result
122
+ self._analysis_error = None
123
+
124
+ def set_analysis_error(self, error: str):
125
+ """Store analysis error for async display."""
126
+ self._analysis_error = error
127
+ self._analysis_result = None
128
+
129
+ def get_analysis_output(self) -> Optional[str]:
130
+ """Get the current analysis output (result, error, or processing state).
131
+ Returns None if no update is needed."""
132
+ # Check result/error first (these are set by the agent thread)
133
+ if self._analysis_result is not None:
134
+ self.stop_processing() # Mark as complete
135
+ return self.render_analysis_result(self._analysis_result)
136
+ elif self._analysis_error is not None:
137
+ self.stop_processing() # Mark as complete
138
+ return self.render_error_state(self._analysis_error)
139
+
140
+ # Check processing mode inside the lock for thread safety
141
+ with self._processing_steps_lock:
142
+ mode = self._processing_mode
143
+ is_running = self._analysis_running
144
+
145
+ # If not running and no result/error, don't update
146
+ if not is_running:
147
+ return None
148
+
149
+ # Render dynamic processing state based on mode
150
+ if mode == "project":
151
+ return self.render_project_processing_state()
152
+ elif mode in ("extract", "match"):
153
+ return self.render_processing_state(mode)
154
+ elif mode is not None:
155
+ return self.render_project_processing_state() # fallback
156
+ else:
157
+ return None # No processing mode set
158
+ summary_text = result.get('corny_summary', '')
159
+ audio_path = text_to_audio_file(summary_text)
160
+ is_audio = audio_path is not None
161
+ audio_out = gr.update(value=audio_path, visible=is_audio)
162
+ html_out = render_plan_html(result)
163
+ return html_out, audio_out
164
 
165
  def render_idle_state(self) -> str:
166
  # The style is handled inside render_plan_html.py CSS for consistency.
 
181
 
182
  def reset_prompt_value(self) -> str:
183
  return self.SAMPLE_PROMPT
184
+
185
+ def render_project_processing_state(self) -> str:
186
+ """Render animated processing state for project analysis with dynamic steps."""
187
+ # Get current processing steps (dynamic)
188
+ current_steps = self.get_processing_steps()
189
+
190
+ # Build steps HTML - show only actual steps that have been added
191
+ if current_steps:
192
+ steps_html = "".join([
193
+ f'<div style="padding: 12px; margin: 8px 0; background: rgba(85,255,0,0.08); border-left: 3px solid var(--arc-green); color: #FFFFFF; font-size: 14px; font-weight: 500;">✓ {html.escape(step)}</div>'
194
+ for step in current_steps[:-1] # Previous steps (completed) - WHITE text
195
+ ])
196
+ # Current step (in progress) - with animation
197
+ if current_steps:
198
+ current_step = current_steps[-1]
199
+ steps_html += f'<div style="padding: 12px; margin: 8px 0; background: rgba(255,127,0,0.15); border-left: 3px solid var(--arc-orange); color: var(--arc-orange); font-size: 14px; font-weight: 600; animation: pulse 1.5s ease-in-out infinite;">⏳ {html.escape(current_step)}</div>'
200
+ else:
201
+ steps_html = '<div style="padding: 12px; margin: 8px 0; background: rgba(255,255,255,0.02); border-left: 3px solid var(--arc-cyan); color: var(--text-main); opacity:0.9; font-size: 14px; animation: pulse 1.5s ease-in-out infinite;">⏳ Initializing analysis...</div>'
202
+
203
+ step_count = len(current_steps) if current_steps else 0
204
+
205
+ return f"""
206
+ <style>
207
+ @keyframes pulse {{
208
+ 0%, 100% {{ opacity: 1; }}
209
+ 50% {{ opacity: 0.5; }}
210
+ }}
211
+ @keyframes spin {{
212
+ from {{ transform: rotate(0deg); }}
213
+ to {{ transform: rotate(360deg); }}
214
+ }}
215
+ @keyframes progress {{
216
+ 0% {{ width: 0%; }}
217
+ 100% {{ width: 100%; }}
218
+ }}
219
+ </style>
220
+ <div style="display: flex; align-items: center; justify-content: center; min-height: 70vh; padding: 40px;">
221
+ <div style="max-width: 800px; width: 100%;">
222
+ <div style="background: var(--bg-panel); border: 2px solid var(--arc-orange); border-radius: 4px; padding: 48px; text-align: center;">
223
+
224
+ <!-- Animated Icon -->
225
+ <div style="margin-bottom: 32px;">
226
+ <div style="width: 80px; height: 80px; margin: 0 auto; border: 4px solid var(--arc-orange); border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div>
227
+ </div>
228
+
229
+ <!-- Title -->
230
+ <h2 style="color: var(--arc-orange); margin: 0 0 16px 0; font-size: 24px; font-weight: 700; animation: pulse 2s ease-in-out infinite;">
231
+ 🚀 INITIATING ANALYSIS
232
+ </h2>
233
+
234
+ <!-- Subtitle -->
235
+ <p style="color: var(--text-main); font-size: 15px; margin-bottom: 32px; opacity: 0.9;">
236
+ Analyzing project requirements and generating deployment plan... Monitor system logs for real-time updates.
237
+ </p>
238
+
239
+ <!-- Progress Bar -->
240
+ <div style="width: 100%; height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; margin-bottom: 32px;">
241
+ <div style="height: 100%; background: linear-gradient(90deg, var(--arc-red), var(--arc-orange), var(--arc-yellow), var(--arc-green)); animation: progress 4s ease-in-out infinite;"></div>
242
+ </div>
243
+
244
+ <!-- Processing Steps (DYNAMIC) -->
245
+ <div style="text-align: left; max-width: 600px; margin: 0 auto; padding: 24px; background: rgba(0,0,0,0.3); border-radius: 4px; min-height: 150px;">
246
+ <div style="color: var(--arc-orange); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; font-weight: 600; display: flex; justify-content: space-between;">
247
+ <span>ANALYSIS PIPELINE:</span>
248
+ <span style="color: var(--arc-cyan);">{step_count} step{"s" if step_count != 1 else ""}</span>
249
+ </div>
250
+ <div style="max-height: 300px; overflow-y: auto;">
251
+ {steps_html}
252
+ </div>
253
+ </div>
254
+
255
+ <!-- Info Note -->
256
+ <div style="margin-top: 32px; padding: 16px; background: rgba(255,127,0,0.05); border: 1px solid rgba(255,127,0,0.2); border-radius: 4px;">
257
+ <div style="color: var(--arc-orange); font-size: 13px;">
258
+ ⏱️ <strong style="color: var(--text-main);">Estimated Time:</strong> <span style="color: var(--text-main); opacity: 0.9;">45-90 seconds (depending on project complexity)</span>
259
+ </div>
260
+ </div>
261
+ </div>
262
+ </div>
263
+ </div>
264
+ """
265
+
266
+ def render_processing_state(self, mode: str = "extract") -> str:
267
+ """Render animated processing state for CV analysis with dynamic steps."""
268
+ title = "📊 EXTRACTING SKILLS" if mode == "extract" else "🎯 ANALYZING CV + MATCHING PROJECTS"
269
+
270
+ # Get current processing steps (dynamic)
271
+ current_steps = self.get_processing_steps()
272
+
273
+ # Build steps HTML - show only actual steps that have been added
274
+ if current_steps:
275
+ steps_html = "".join([
276
+ f'<div style="padding: 12px; margin: 8px 0; background: rgba(85,255,0,0.08); border-left: 3px solid var(--arc-green); color: #FFFFFF; font-size: 14px; font-weight: 500;">✓ {html.escape(step)}</div>'
277
+ for step in current_steps[:-1] # Previous steps (completed) - WHITE text
278
+ ])
279
+ # Current step (in progress) - with animation
280
+ if current_steps:
281
+ current_step = current_steps[-1]
282
+ steps_html += f'<div style="padding: 12px; margin: 8px 0; background: rgba(0,255,255,0.15); border-left: 3px solid var(--arc-cyan); color: var(--arc-cyan); font-size: 14px; font-weight: 600; animation: pulse 1.5s ease-in-out infinite;">⏳ {html.escape(current_step)}</div>'
283
+ else:
284
+ steps_html = '<div style="padding: 12px; margin: 8px 0; background: rgba(255,255,255,0.02); border-left: 3px solid var(--arc-cyan); color: var(--text-main); opacity:0.9; font-size: 14px; animation: pulse 1.5s ease-in-out infinite;">⏳ Initializing...</div>'
285
+
286
+ step_count = len(current_steps) if current_steps else 0
287
+
288
+ return f"""
289
+ <style>
290
+ @keyframes pulse {{
291
+ 0%, 100% {{ opacity: 1; }}
292
+ 50% {{ opacity: 0.5; }}
293
+ }}
294
+ @keyframes spin {{
295
+ from {{ transform: rotate(0deg); }}
296
+ to {{ transform: rotate(360deg); }}
297
+ }}
298
+ @keyframes progress {{
299
+ 0% {{ width: 0%; }}
300
+ 100% {{ width: 100%; }}
301
+ }}
302
+ </style>
303
+ <div style="display: flex; align-items: center; justify-content: center; min-height: 70vh; padding: 40px;">
304
+ <div style="max-width: 800px; width: 100%;">
305
+ <div style="background: var(--bg-panel); border: 2px solid var(--arc-orange); border-radius: 4px; padding: 48px; text-align: center;">
306
+
307
+ <!-- Animated Icon -->
308
+ <div style="margin-bottom: 32px;">
309
+ <div style="width: 80px; height: 80px; margin: 0 auto; border: 4px solid var(--arc-orange); border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div>
310
+ </div>
311
+
312
+ <!-- Title -->
313
+ <h2 style="color: var(--arc-orange); margin: 0 0 16px 0; font-size: 24px; font-weight: 700; animation: pulse 2s ease-in-out infinite;">
314
+ {title}
315
+ </h2>
316
+
317
+ <!-- Subtitle -->
318
+ <p style="color: var(--text-main); font-size: 15px; margin-bottom: 32px; opacity: 0.9;">
319
+ Processing document... Please monitor the system logs for real-time updates.
320
+ </p>
321
+
322
+ <!-- Progress Bar -->
323
+ <div style="width: 100%; height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; margin-bottom: 32px;">
324
+ <div style="height: 100%; background: linear-gradient(90deg, var(--arc-orange), var(--arc-yellow), var(--arc-green)); animation: progress 3s ease-in-out infinite;"></div>
325
+ </div>
326
+
327
+ <!-- Processing Steps (DYNAMIC) -->
328
+ <div style="text-align: left; max-width: 600px; margin: 0 auto; padding: 24px; background: rgba(0,0,0,0.3); border-radius: 4px; min-height: 150px;">
329
+ <div style="color: var(--arc-cyan); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; font-weight: 600; display: flex; justify-content: space-between;">
330
+ <span>PROCESSING PIPELINE:</span>
331
+ <span style="color: var(--arc-orange);">{step_count} step{"s" if step_count != 1 else ""}</span>
332
+ </div>
333
+ <div style="max-height: 300px; overflow-y: auto;">
334
+ {steps_html}
335
+ </div>
336
+ </div>
337
+
338
+ <!-- Info Note -->
339
+ <div style="margin-top: 32px; padding: 16px; background: rgba(0,255,255,0.05); border: 1px solid rgba(0,255,255,0.2); border-radius: 4px;">
340
+ <div style="color: var(--arc-cyan); font-size: 13px;">
341
+ ⏱️ <strong style="color: var(--text-main);">Estimated Time:</strong> <span style="color: var(--text-main); opacity: 0.9;">{('20-30 seconds' if mode == 'extract' else '30-45 seconds')}</span>
342
+ </div>
343
+ </div>
344
+ </div>
345
+ </div>
346
+ </div>
347
+ """
348
+
349
 
350
+ # ---------------------- TACTICAL CSS ----------------------
351
 
352
  def _token_css(self) -> str:
353
  return """
 
562
  min-height: 300px;
563
  max-height: 40vh;
564
  overflow-y: auto;
565
+ color: var(--text-main);
566
  }
567
  .console-line {
568
  margin-bottom: 8px;
 
575
  .console-wrapper::-webkit-scrollbar { width: 8px; }
576
  .console-wrapper::-webkit-scrollbar-track { background: #08090D; }
577
  .console-wrapper::-webkit-scrollbar-thumb { background: var(--border-dim); border-radius: 4px; }
578
+
579
+ /* DISABLE GRADIO DEFAULT LOADING OVERLAY */
580
+ .generating {
581
+ display: none !important;
582
+ }
583
+ .pending {
584
+ opacity: 1 !important;
585
+ }
586
+ .eta-bar {
587
+ display: none !important;
588
+ }
589
  """
590
 
591
  def _compose_css(self) -> str:
 
639
  with gr.Row(equal_height=True, elem_classes=["main-container"]):
640
 
641
  # --- LEFT COLUMN: INPUTS ---
642
+ with gr.Column(scale=3, elem_classes=["input-panel"]) as mission_panel:
643
  gr.HTML("<div class='ent-header-label'>PROJECT PARAMETERS</div>")
644
 
645
  input_box = gr.TextArea(
 
658
 
659
  with gr.Row():
660
  btn_cancel = gr.Button("STOP ANALYSIS", elem_classes=["btn-tac-secondary"])
661
+ with gr.Row():
662
+ btn_cv = gr.Button("📄 CV ANALYSIS", elem_classes=["btn-tac-primary"])
663
 
664
  gr.HTML("<div style='height:30px'></div>") # Flexible Spacer
665
 
666
  gr.HTML("<div class='ent-header-label'>ACTIVITY LOG</div>")
667
  console = gr.HTML(self.get_action_log_text())
668
 
669
+ # --- LEFT COLUMN: CV UPLOAD (hidden by default) ---
670
+ with gr.Column(scale=3, elem_classes=["input-panel"], visible=False) as cv_upload_panel:
671
+ gr.HTML("<div class='ent-header-label'>📄 CV ANALYSIS</div>")
672
+
673
+ gr.HTML("""
674
+ <div style="padding: 16px; background: rgba(255,127,0,0.1); border: 1px solid var(--arc-orange); border-radius: 4px; margin-bottom: 16px;">
675
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
676
+ <div style="padding: 10px; background: rgba(255,255,255,0.05); border-radius: 4px; border-left: 3px solid var(--arc-cyan);">
677
+ <div style="color: var(--arc-cyan); font-weight: 600; margin-bottom: 6px; font-size: 12px;">📊 EXTRACT SKILLS</div>
678
+ <div style="color: var(--text-dim); font-size: 10px;">
679
+ Parse CV to identify technical skills, experience, certifications.
680
+ </div>
681
+ </div>
682
+ <div style="padding: 10px; background: rgba(255,255,255,0.05); border-radius: 4px; border-left: 3px solid var(--arc-orange);">
683
+ <div style="color: var(--arc-orange); font-weight: 600; margin-bottom: 6px; font-size: 12px;">🎯 EXTRACT + MATCH</div>
684
+ <div style="color: var(--text-dim); font-size: 10px;">
685
+ Parse CV, rank projects, identify skill gaps.
686
+ </div>
687
+ </div>
688
+ </div>
689
+ </div>
690
+ """)
691
+
692
+ cv_file_input = gr.File(
693
+ label="SELECT CV FILE",
694
+ file_types=[".pdf", ".docx", ".doc"],
695
+ type="filepath"
696
+ )
697
+
698
+ with gr.Row():
699
+ btn_process_cv = gr.Button("📊 EXTRACT SKILLS", elem_classes=["btn-tac-secondary"])
700
+ btn_process_cv_match = gr.Button("🎯 EXTRACT + MATCH PROJECTS", elem_classes=["btn-tac-primary"])
701
+
702
+ with gr.Row():
703
+ btn_close_cv = gr.Button("← BACK", elem_classes=["btn-tac-secondary"])
704
+
705
+ gr.HTML("<div style='height:30px'></div>")
706
+
707
+ gr.HTML("<div class='ent-header-label'>SYSTEM LOGS</div>")
708
+ console_cv = gr.HTML(self.get_action_log_text())
709
+
710
  # --- RIGHT COLUMN: OUTPUT ---
711
  with gr.Column(scale=7, elem_classes=["output-panel"]):
712
  output_display = gr.HTML(self.render_idle_state())
713
 
714
  # --- Event Bindings ---
715
+
716
+ # Project Analysis
717
+ def start_project_analysis():
718
+ self.start_processing("project")
719
+ return self.render_project_processing_state()
720
+
721
+ btn_run.click(
722
+ start_project_analysis,
723
+ outputs=output_display,
724
+ queue=False
725
+ ).then(
726
+ self.clear_action_log, outputs=console, queue=False
727
+ ).then(
728
+ lambda: self.get_action_log_text(), outputs=console
729
+ ).then(
730
+ analyze_callback, inputs=input_box, outputs=[output_display, output_audio]
731
+ ).then(
732
+ self.get_action_log_text, outputs=console
733
+ )
734
 
735
  btn_reset.click(self.reset_prompt_value, outputs=input_box)
736
+
737
+ # CV Button - Toggle panels
738
+ def show_cv_interface():
739
+ return (
740
+ gr.update(visible=False), # Hide mission_panel
741
+ gr.update(visible=True), # Show cv_upload_panel
742
+ self._cv_interface.render_cv_upload_interface() # Update output_display
743
+ )
744
+
745
+ btn_cv.click(
746
+ show_cv_interface,
747
+ outputs=[mission_panel, cv_upload_panel, output_display]
748
+ )
749
+
750
+ # Close CV Section - Back to mission panel
751
+ def close_cv_interface():
752
+ return (
753
+ gr.update(visible=True), # Show mission_panel
754
+ gr.update(visible=False), # Hide cv_upload_panel
755
+ self.render_idle_state() # Reset output_display
756
+ )
757
+
758
+ btn_close_cv.click(
759
+ close_cv_interface,
760
+ outputs=[mission_panel, cv_upload_panel, output_display]
761
+ )
762
+
763
+ # Standard CV analysis (no project matching)
764
+ def start_cv_extract():
765
+ self.start_processing("extract")
766
+ return self.render_processing_state("extract")
767
+
768
+ def finish_cv_processing():
769
+ self.stop_processing()
770
+ return self.get_action_log_text()
771
+
772
+ btn_process_cv.click(
773
+ start_cv_extract,
774
+ outputs=output_display,
775
+ queue=False
776
+ ).then(
777
+ self.clear_action_log, outputs=console_cv, queue=False
778
+ ).then(
779
+ self._cv_interface.process_cv_upload,
780
+ inputs=cv_file_input,
781
+ outputs=output_display
782
+ ).then(
783
+ finish_cv_processing, outputs=console_cv
784
+ )
785
+
786
+ # CV + Project Matching
787
+ def start_cv_match():
788
+ self.start_processing("match")
789
+ return self.render_processing_state("match")
790
+
791
+ btn_process_cv_match.click(
792
+ start_cv_match,
793
+ outputs=output_display,
794
+ queue=False
795
+ ).then(
796
+ self.clear_action_log, outputs=console_cv, queue=False
797
+ ).then(
798
+ self._cv_interface.process_cv_with_matching,
799
+ inputs=cv_file_input,
800
+ outputs=output_display
801
+ ).then(
802
+ finish_cv_processing, outputs=console_cv
803
+ )
804
 
805
  btn_cancel.click(cancel_run_callback)
806
 
807
+ # Live log updates (both consoles)
808
+ def update_both_consoles():
809
+ log_text = self.get_action_log_text()
810
+ return (log_text, log_text) # Return same log for both consoles
811
+
812
+ gr.Timer(2).tick(update_both_consoles, outputs=[console, console_cv])
813
+
814
+ # Check for analysis result updates (poll every 1 second)
815
+ def check_analysis_result():
816
+ """Check if analysis is complete and update output display."""
817
+ output = self.get_analysis_output()
818
+ # Only return if there's an update (None means no update needed)
819
+ if output is not None:
820
+ return output
821
+ # Return None to skip update (Gradio will handle this)
822
+ return None
823
+
824
+ # Poll for results - Gradio will skip None returns
825
+ def poll_with_skip():
826
+ result = check_analysis_result()
827
+ # Return the result if not None, otherwise skip update
828
+ return result if result is not None else gr.update()
829
+
830
+ gr.Timer(1).tick(poll_with_skip, outputs=output_display)
831
 
832
  return demo
UI/cv_interface.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ from typing import Any, Callable, Dict, Optional
5
+
6
+ from utils.cv_parser import parse_cv
7
+ from utils.cv_project_matcher import match_cv_to_projects
8
+ from utils.skill_extractor import extract_skills_from_cv_text
9
+
10
+ from .render_cv_html import render_cv_analysis_html
11
+ from .render_cv_matching import render_cv_matching_html
12
+
13
+
14
+ class CVInterface:
15
+ """
16
+ Handles all CV-related functionality including upload, parsing, skill extraction,
17
+ and project matching.
18
+ """
19
+
20
+ def __init__(self, main_interface):
21
+ """
22
+ Initialize CV interface with reference to main interface for shared functionality.
23
+
24
+ Args:
25
+ main_interface: Reference to EastSyncInterface instance for accessing
26
+ shared methods like register_agent_action, start_processing, etc.
27
+ """
28
+ self._main_interface = main_interface
29
+ self._cv_skills_data: Optional[Dict[str, Any]] = None
30
+ self._cv_filename: str = "Unknown"
31
+
32
+ def render_cv_upload_interface(self) -> str:
33
+ """Render the CV upload interface instructions for the right panel."""
34
+ return """
35
+ <div style="display: flex; align-items: center; justify-content: center; min-height: 60vh; padding: 40px;">
36
+ <div style="max-width: 900px; width: 100%;">
37
+ <div style="background: var(--bg-panel); border: 2px solid var(--arc-orange); border-radius: 4px; padding: 40px;">
38
+ <div style="display: flex; align-items: center; gap: 16px; margin-bottom: 24px;">
39
+ <div style="font-size: 48px;">📄</div>
40
+ <div>
41
+ <h2 style="color: var(--arc-orange); margin: 0; font-size: 28px; font-weight: 700;">CV ANALYSIS OPTIONS</h2>
42
+ <p style="color: var(--text-dim); margin: 8px 0 0 0; font-size: 15px;">
43
+ Upload a candidate's CV (PDF or DOCX format) to extract skills and optionally match against projects.
44
+ </p>
45
+ </div>
46
+ </div>
47
+
48
+ <div style="margin-top: 32px; padding: 24px; background: rgba(0,0,0,0.4); border-radius: 4px; border: 1px solid var(--border-dim);">
49
+ <div style="color: var(--arc-cyan); font-size: 14px; margin-bottom: 20px; text-transform: uppercase; letter-spacing: 1px;">
50
+ <strong>TWO ANALYSIS MODES:</strong>
51
+ </div>
52
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px;">
53
+ <div style="padding: 20px; background: rgba(255,255,255,0.05); border-radius: 4px; border-left: 4px solid var(--arc-cyan);">
54
+ <div style="color: var(--arc-cyan); font-weight: 700; margin-bottom: 10px; font-size: 15px;">📊 EXTRACT SKILLS ONLY</div>
55
+ <div style="color: var(--text-dim); font-size: 13px; line-height: 1.5;">
56
+ Parse CV to identify technical skills, experience, certifications, and education.
57
+ </div>
58
+ </div>
59
+ <div style="padding: 20px; background: rgba(255,255,255,0.05); border-radius: 4px; border-left: 4px solid var(--arc-orange);">
60
+ <div style="color: var(--arc-orange); font-weight: 700; margin-bottom: 10px; font-size: 15px;">🎯 EXTRACT + MATCH PROJECTS</div>
61
+ <div style="color: var(--text-dim); font-size: 13px; line-height: 1.5;">
62
+ Parse CV, rank matching projects by skill compatibility, and identify skill gaps.
63
+ </div>
64
+ </div>
65
+ </div>
66
+ <div style="color: var(--arc-yellow); font-size: 13px; padding: 16px; background: rgba(255,185,0,0.1); border-radius: 4px; border: 1px solid rgba(255,185,0,0.3);">
67
+ <strong>⚠️ Instructions:</strong> Use the file upload on the left panel to select a CV, then choose your desired analysis mode.
68
+ </div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ """
74
+
75
+ def process_cv_upload(self, file_obj) -> str:
76
+ """Process uploaded CV file and extract skills. Returns main output HTML."""
77
+ if file_obj is None:
78
+ return '<div style="color: var(--arc-red); padding: 40px; text-align: center;">⚠️ No file uploaded. Please select a CV file (PDF or DOCX).</div>'
79
+
80
+ try:
81
+ import os
82
+ file_name = os.path.basename(file_obj.name) if hasattr(file_obj, 'name') else "unknown"
83
+ self._cv_filename = file_name
84
+
85
+ # Terminal logging
86
+ print("\n" + "="*80)
87
+ print(f"[CV UPLOAD] Processing CV: {file_name}")
88
+ print("="*80)
89
+
90
+ self._main_interface.register_agent_action("📤 CV Upload Started", {"file": file_name})
91
+
92
+ # Read file content
93
+ if hasattr(file_obj, 'name'):
94
+ # Gradio file object
95
+ file_path = file_obj.name
96
+ with open(file_path, 'rb') as f:
97
+ file_content = f.read()
98
+ else:
99
+ # Direct file path
100
+ file_path = str(file_obj)
101
+ with open(file_path, 'rb') as f:
102
+ file_content = f.read()
103
+
104
+ file_size_kb = len(file_content) / 1024
105
+ self._main_interface.register_agent_action("📄 Parsing Document", {
106
+ "size": f"{file_size_kb:.1f}KB",
107
+ "format": os.path.splitext(file_name)[1].upper()
108
+ })
109
+
110
+ # Extract text from CV
111
+ cv_text = parse_cv(file_path=file_path, file_content=file_content, log_callback=self._main_interface.register_agent_action)
112
+
113
+ if not cv_text or len(cv_text.strip()) < 50:
114
+ self._main_interface.register_agent_action("⚠️ Text Extraction Failed", {"extracted_chars": len(cv_text) if cv_text else 0})
115
+ print(f"[CV UPLOAD] ❌ ERROR: Text extraction failed - only {len(cv_text) if cv_text else 0} chars extracted")
116
+ return '<div style="color: var(--arc-red); padding: 40px; text-align: center;">⚠️ Could not extract meaningful text from the CV. Please ensure the file is not corrupted.</div>'
117
+
118
+ word_count = len(cv_text.split())
119
+ char_count = len(cv_text)
120
+ self._main_interface.register_agent_action("✅ Text Extracted", {
121
+ "words": word_count,
122
+ "characters": char_count,
123
+ "pages_est": max(1, word_count // 300) # Rough estimate
124
+ })
125
+
126
+ self._main_interface.register_agent_action("🤖 AI Analysis Starting", {"status": "Initializing AI-powered skill extraction..."})
127
+
128
+ # Extract skills using LLM with logging callback
129
+ skills_data = extract_skills_from_cv_text(cv_text, log_callback=self._main_interface.register_agent_action)
130
+ self._cv_skills_data = skills_data
131
+
132
+ if "error" not in skills_data:
133
+ total_skills = len(skills_data.get("technical_skills", [])) + len(skills_data.get("soft_skills", []))
134
+ self._main_interface.register_agent_action("🎯 Skills Extracted", {
135
+ "technical_skills": len(skills_data.get("technical_skills", [])),
136
+ "soft_skills": len(skills_data.get("soft_skills", [])),
137
+ "total": total_skills
138
+ })
139
+ print(f"[CV UPLOAD] ✅ SUCCESS: Extracted {total_skills} total skills")
140
+ else:
141
+ print(f"[CV UPLOAD] ⚠️ WARNING: Skills extraction completed with errors")
142
+
143
+ print("="*80 + "\n")
144
+
145
+ # Render CV analysis HTML for main display
146
+ main_output = render_cv_analysis_html(skills_data, file_name)
147
+
148
+ return main_output
149
+
150
+ except Exception as e:
151
+ error_msg = str(e)
152
+ self._main_interface.register_agent_action("CV Processing Error", {"error": error_msg})
153
+ print(f"[CV UPLOAD] ❌ EXCEPTION: {type(e).__name__} - {error_msg}")
154
+ print("="*80 + "\n")
155
+ return f'<div style="color: var(--arc-red); padding: 40px; text-align: center;">⚠️ Error processing CV: {html.escape(error_msg)}</div>'
156
+
157
+ def get_extracted_skills(self) -> Optional[Dict[str, Any]]:
158
+ """Get the most recently extracted skills data."""
159
+ return self._cv_skills_data
160
+
161
+ def process_cv_with_matching(self, file_obj) -> str:
162
+ """Process CV and match against projects. Returns main output HTML."""
163
+ if file_obj is None:
164
+ return '<div style="color: var(--arc-red); padding: 40px; text-align: center;">⚠️ No file uploaded. Please select a CV file (PDF or DOCX).</div>'
165
+
166
+ try:
167
+ import os
168
+ file_name = os.path.basename(file_obj.name) if hasattr(file_obj, 'name') else "unknown"
169
+ self._cv_filename = file_name
170
+
171
+ # Terminal logging
172
+ print("\n" + "="*80)
173
+ print(f"[CV MATCHING] Processing CV with Project Matching: {file_name}")
174
+ print("="*80)
175
+
176
+ self._main_interface.register_agent_action("📤 CV Upload + Matching Started", {"file": file_name})
177
+
178
+ # Read file content
179
+ if hasattr(file_obj, 'name'):
180
+ file_path = file_obj.name
181
+ with open(file_path, 'rb') as f:
182
+ file_content = f.read()
183
+ else:
184
+ file_path = str(file_obj)
185
+ with open(file_path, 'rb') as f:
186
+ file_content = f.read()
187
+
188
+ file_size_kb = len(file_content) / 1024
189
+ self._main_interface.register_agent_action("📄 Parsing Document", {
190
+ "size": f"{file_size_kb:.1f}KB",
191
+ "format": os.path.splitext(file_name)[1].upper()
192
+ })
193
+
194
+ # Extract text from CV
195
+ cv_text = parse_cv(file_path=file_path, file_content=file_content, log_callback=self._main_interface.register_agent_action)
196
+
197
+ if not cv_text or len(cv_text.strip()) < 50:
198
+ self._main_interface.register_agent_action("⚠️ Text Extraction Failed", {"extracted_chars": len(cv_text) if cv_text else 0})
199
+ print(f"[CV MATCHING] ❌ ERROR: Text extraction failed")
200
+ return '<div style="color: var(--arc-red); padding: 40px; text-align: center;">⚠️ Could not extract meaningful text from the CV.</div>'
201
+
202
+ word_count = len(cv_text.split())
203
+ char_count = len(cv_text)
204
+ self._main_interface.register_agent_action("✅ Text Extracted", {
205
+ "words": word_count,
206
+ "characters": char_count,
207
+ "pages_est": max(1, word_count // 300)
208
+ })
209
+
210
+ self._main_interface.register_agent_action("🤖 AI Analysis Starting", {"status": "Extracting skills from CV..."})
211
+
212
+ # Extract skills using LLM
213
+ skills_data = extract_skills_from_cv_text(cv_text, log_callback=self._main_interface.register_agent_action)
214
+ self._cv_skills_data = skills_data
215
+
216
+ if "error" in skills_data:
217
+ print(f"[CV MATCHING] ⚠️ WARNING: Skills extraction completed with errors")
218
+ return '<div style="color: var(--arc-red); padding: 40px; text-align: center;">⚠️ Error extracting skills from CV.</div>'
219
+
220
+ total_skills = len(skills_data.get("technical_skills", [])) + len(skills_data.get("soft_skills", []))
221
+ self._main_interface.register_agent_action("🎯 Skills Extracted", {
222
+ "technical_skills": len(skills_data.get("technical_skills", [])),
223
+ "soft_skills": len(skills_data.get("soft_skills", [])),
224
+ "total": total_skills
225
+ })
226
+ print(f"[CV MATCHING] ✅ Skills extracted: {total_skills} total skills")
227
+
228
+ # Match against projects
229
+ self._main_interface.register_agent_action("🔍 Starting Project Matching", {"status": "Comparing skills with project requirements..."})
230
+
231
+ matched_projects = match_cv_to_projects(skills_data, log_callback=self._main_interface.register_agent_action)
232
+
233
+ if not matched_projects:
234
+ print(f"[CV MATCHING] ⚠️ No projects found for matching")
235
+ self._main_interface.register_agent_action("⚠️ No Projects Found", {"status": "No projects available in database"})
236
+ # Still show CV analysis
237
+ main_output = render_cv_analysis_html(skills_data, file_name)
238
+ return main_output
239
+
240
+ # Skip training costs - just show matching results
241
+ # Set empty training plans for all projects
242
+ for project in matched_projects:
243
+ project['training_plans'] = []
244
+
245
+ print(f"[CV MATCHING] ✅ SUCCESS: Matched {len(matched_projects)} projects")
246
+ self._main_interface.register_agent_action("✅ Matching Complete", {
247
+ "total_matches": len(matched_projects),
248
+ "best_match": f"{matched_projects[0]['project_name']} ({matched_projects[0]['match_percentage']}%)"
249
+ })
250
+
251
+ print("="*80 + "\n")
252
+
253
+ # Render CV matching results for main display
254
+ main_output = render_cv_matching_html(skills_data, matched_projects, file_name)
255
+
256
+ return main_output
257
+
258
+ except Exception as e:
259
+ error_msg = str(e)
260
+ self._main_interface.register_agent_action("CV Matching Error", {"error": error_msg})
261
+ print(f"[CV MATCHING] ❌ EXCEPTION: {type(e).__name__} - {error_msg}")
262
+ print("="*80 + "\n")
263
+ return f'<div style="color: var(--arc-red); padding: 40px; text-align: center;">⚠️ Error processing CV: {html.escape(error_msg)}</div>'
UI/render_cv_html.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render CV analysis results in tactical-themed HTML format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ from typing import Any, Dict
7
+
8
+
9
+ def render_cv_analysis_html(skills_data: Dict[str, Any], filename: str = "Unknown") -> str:
10
+ """
11
+ Renders CV analysis data into a Tactical-themed card layout similar to project analysis.
12
+
13
+ Args:
14
+ skills_data: Dictionary containing extracted skills and candidate information
15
+ filename: Name of the CV file
16
+
17
+ Returns:
18
+ HTML string for display
19
+ """
20
+
21
+ # Internal CSS
22
+ css = """
23
+ <style>
24
+ .cv-report-wrapper {
25
+ width: 100%;
26
+ min-height: 70vh;
27
+ display: flex;
28
+ flex-direction: column;
29
+ }
30
+
31
+ .cv-header-section {
32
+ margin-bottom: 24px;
33
+ padding: 0 4px;
34
+ border-bottom: 1px solid var(--border-dim);
35
+ padding-bottom: 16px;
36
+ }
37
+
38
+ .cv-main-card {
39
+ background: var(--bg-card);
40
+ border: 1px solid var(--border-dim);
41
+ border-left: 4px solid var(--arc-orange);
42
+ padding: 24px;
43
+ margin-bottom: 24px;
44
+ }
45
+
46
+ .cv-grid {
47
+ display: grid;
48
+ grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
49
+ gap: 20px;
50
+ margin-top: 24px;
51
+ }
52
+
53
+ .cv-section-card {
54
+ background: var(--bg-card);
55
+ border: 1px solid var(--border-dim);
56
+ padding: 20px;
57
+ transition: border-color 0.2s;
58
+ }
59
+
60
+ .cv-section-card:hover {
61
+ border-color: var(--arc-yellow);
62
+ box-shadow: 0 4px 24px rgba(0,0,0,0.4);
63
+ }
64
+
65
+ .cv-section-title {
66
+ font-size: 13px;
67
+ color: var(--arc-yellow);
68
+ text-transform: uppercase;
69
+ letter-spacing: 1.5px;
70
+ margin-bottom: 16px;
71
+ font-weight: 700;
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 8px;
75
+ }
76
+
77
+ .cv-section-title::before {
78
+ content: "";
79
+ width: 4px;
80
+ height: 16px;
81
+ background: var(--arc-yellow);
82
+ box-shadow: 0 0 8px var(--arc-yellow);
83
+ }
84
+
85
+ .cv-skill-tag {
86
+ display: inline-block;
87
+ background: var(--bg-panel);
88
+ border: 1px solid var(--border-dim);
89
+ padding: 8px 14px;
90
+ margin: 4px;
91
+ border-radius: 4px;
92
+ font-family: var(--font-mono);
93
+ font-size: 13px;
94
+ font-weight: 600;
95
+ transition: all 0.2s;
96
+ }
97
+
98
+ .cv-skill-tag.technical {
99
+ color: var(--arc-green);
100
+ border-color: var(--arc-green);
101
+ }
102
+
103
+ .cv-skill-tag.soft {
104
+ color: var(--arc-cyan);
105
+ border-color: var(--arc-cyan);
106
+ }
107
+
108
+ .cv-skill-tag.domain {
109
+ color: var(--arc-orange);
110
+ border-color: var(--arc-orange);
111
+ }
112
+
113
+ .cv-skill-tag:hover {
114
+ background: var(--bg-void);
115
+ box-shadow: 0 0 12px currentColor;
116
+ }
117
+
118
+ .cv-info-item {
119
+ display: flex;
120
+ align-items: flex-start;
121
+ padding: 10px 0;
122
+ border-bottom: 1px solid rgba(255,255,255,0.05);
123
+ }
124
+
125
+ .cv-info-item:last-child {
126
+ border-bottom: none;
127
+ }
128
+
129
+ .cv-info-label {
130
+ font-size: 12px;
131
+ color: var(--text-main);
132
+ font-weight: 600;
133
+ text-transform: uppercase;
134
+ min-width: 120px;
135
+ margin-right: 16px;
136
+ opacity: 0.9;
137
+ }
138
+
139
+ .cv-info-value {
140
+ color: var(--text-main);
141
+ font-size: 14px;
142
+ flex: 1;
143
+ }
144
+
145
+ .cv-summary-box {
146
+ background: rgba(255, 127, 0, 0.08);
147
+ border: 1px solid var(--arc-orange);
148
+ border-radius: 4px;
149
+ padding: 16px;
150
+ margin-bottom: 24px;
151
+ }
152
+
153
+ .cv-summary-text {
154
+ color: var(--text-main);
155
+ line-height: 1.7;
156
+ font-size: 15px;
157
+ opacity: 0.95;
158
+ }
159
+
160
+ .cv-stats-bar {
161
+ display: flex;
162
+ gap: 24px;
163
+ background: rgba(255,255,255,0.05);
164
+ border: 1px solid var(--border-dim);
165
+ padding: 16px 24px;
166
+ margin-bottom: 24px;
167
+ flex-wrap: wrap;
168
+ }
169
+
170
+ .cv-stat-item {
171
+ display: flex;
172
+ flex-direction: column;
173
+ gap: 4px;
174
+ }
175
+
176
+ .cv-stat-label {
177
+ font-size: 11px;
178
+ color: var(--text-main);
179
+ font-weight: 600;
180
+ letter-spacing: 0.5px;
181
+ text-transform: uppercase;
182
+ opacity: 0.9;
183
+ }
184
+
185
+ .cv-stat-value {
186
+ font-size: 20px;
187
+ color: var(--text-main);
188
+ font-weight: 700;
189
+ font-family: var(--font-mono);
190
+ }
191
+
192
+ .cv-list {
193
+ list-style: none;
194
+ padding: 0;
195
+ margin: 0;
196
+ }
197
+
198
+ .cv-list-item {
199
+ padding: 8px 0;
200
+ padding-left: 24px;
201
+ position: relative;
202
+ color: var(--text-main);
203
+ font-size: 14px;
204
+ opacity: 0.9;
205
+ }
206
+
207
+ .cv-list-item::before {
208
+ content: "▸";
209
+ position: absolute;
210
+ left: 0;
211
+ color: var(--arc-orange);
212
+ font-weight: bold;
213
+ }
214
+
215
+ .cv-error-box {
216
+ background: rgba(255, 42, 42, 0.08);
217
+ border: 1px solid var(--arc-red);
218
+ border-radius: 4px;
219
+ padding: 20px;
220
+ color: var(--arc-red);
221
+ text-align: center;
222
+ font-family: var(--font-mono);
223
+ }
224
+ </style>
225
+ """
226
+
227
+ # Check for errors
228
+ if "error" in skills_data:
229
+ error_msg = html.escape(skills_data.get("summary", "Unknown error occurred"))
230
+ return f"""
231
+ {css}
232
+ <div class="cv-report-wrapper">
233
+ <div class="cv-error-box">
234
+ <h3 style="margin: 0 0 12px 0;">⚠️ CV ANALYSIS FAILED</h3>
235
+ <p style="margin: 0;">{error_msg}</p>
236
+ </div>
237
+ </div>
238
+ """
239
+
240
+ # Extract data
241
+ tech_skills = skills_data.get("technical_skills", [])
242
+ soft_skills = skills_data.get("soft_skills", [])
243
+ experience_years = skills_data.get("experience_years", "unknown")
244
+ recent_roles = skills_data.get("recent_roles", [])
245
+ education = skills_data.get("education", [])
246
+ certifications = skills_data.get("certifications", [])
247
+ domain_expertise = skills_data.get("domain_expertise", [])
248
+ summary = skills_data.get("summary", "")
249
+
250
+ # Calculate stats
251
+ total_skills = len(tech_skills) + len(soft_skills)
252
+
253
+ # Build HTML
254
+ html_parts = [css, '<div class="cv-report-wrapper">']
255
+
256
+ # Header
257
+ safe_filename = html.escape(filename)
258
+ html_parts.append(f"""
259
+ <div class="cv-header-section">
260
+ <div style="display: flex; justify-content: space-between; align-items: flex-end; flex-wrap: wrap; gap: 16px;">
261
+ <div>
262
+ <h2 style="margin:0; color:white; font-size:24px; letter-spacing: 0.5px;">CV ANALYSIS REPORT</h2>
263
+ <div style="color:var(--arc-yellow); font-family:var(--font-mono); font-size: 14px; margin-top:6px;">SOURCE: {safe_filename}</div>
264
+ </div>
265
+ <div style="font-family: var(--font-mono); font-size: 12px; color: var(--text-main); text-align: right;">
266
+ <div>STATUS: <span style="color:var(--arc-green)">PROCESSED</span></div>
267
+ <div>TIMESTAMP: {html.escape(str(__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M')))}</div>
268
+ </div>
269
+ </div>
270
+ </div>
271
+ """)
272
+
273
+ # Stats Bar
274
+ html_parts.append(f"""
275
+ <div class="cv-stats-bar">
276
+ <div class="cv-stat-item">
277
+ <div class="cv-stat-label">Total Skills</div>
278
+ <div class="cv-stat-value" style="color: var(--arc-cyan);">{total_skills}</div>
279
+ </div>
280
+ <div style="width: 1px; height: 32px; background: var(--border-dim);"></div>
281
+ <div class="cv-stat-item">
282
+ <div class="cv-stat-label">Technical</div>
283
+ <div class="cv-stat-value" style="color: var(--arc-green);">{len(tech_skills)}</div>
284
+ </div>
285
+ <div style="width: 1px; height: 32px; background: var(--border-dim);"></div>
286
+ <div class="cv-stat-item">
287
+ <div class="cv-stat-label">Soft Skills</div>
288
+ <div class="cv-stat-value" style="color: var(--arc-cyan);">{len(soft_skills)}</div>
289
+ </div>
290
+ <div style="width: 1px; height: 32px; background: var(--border-dim);"></div>
291
+ <div class="cv-stat-item">
292
+ <div class="cv-stat-label">Experience</div>
293
+ <div class="cv-stat-value" style="color: var(--arc-orange);">{html.escape(str(experience_years))}</div>
294
+ </div>
295
+ </div>
296
+ """)
297
+
298
+ # Summary
299
+ if summary:
300
+ safe_summary = html.escape(summary)
301
+ html_parts.append(f"""
302
+ <div class="cv-summary-box">
303
+ <div style="font-size: 12px; color: var(--arc-orange); font-weight: 700; text-transform: uppercase; margin-bottom: 12px; letter-spacing: 1px;">
304
+ 📄 CANDIDATE PROFILE SUMMARY
305
+ </div>
306
+ <div class="cv-summary-text">{safe_summary}</div>
307
+ </div>
308
+ """)
309
+
310
+ # Skills Grid
311
+ html_parts.append('<div class="cv-grid">')
312
+
313
+ # Technical Skills Card
314
+ if tech_skills:
315
+ html_parts.append('<div class="cv-section-card">')
316
+ html_parts.append('<div class="cv-section-title">💻 Technical Skills</div>')
317
+ html_parts.append('<div style="margin-top: 12px;">')
318
+ for skill in tech_skills:
319
+ safe_skill = html.escape(skill)
320
+ html_parts.append(f'<span class="cv-skill-tag technical">{safe_skill}</span>')
321
+ html_parts.append('</div></div>')
322
+
323
+ # Soft Skills Card
324
+ if soft_skills:
325
+ html_parts.append('<div class="cv-section-card">')
326
+ html_parts.append('<div class="cv-section-title">🤝 Soft Skills</div>')
327
+ html_parts.append('<div style="margin-top: 12px;">')
328
+ for skill in soft_skills:
329
+ safe_skill = html.escape(skill)
330
+ html_parts.append(f'<span class="cv-skill-tag soft">{safe_skill}</span>')
331
+ html_parts.append('</div></div>')
332
+
333
+ # Domain Expertise Card
334
+ if domain_expertise:
335
+ html_parts.append('<div class="cv-section-card">')
336
+ html_parts.append('<div class="cv-section-title">🎯 Domain Expertise</div>')
337
+ html_parts.append('<div style="margin-top: 12px;">')
338
+ for domain in domain_expertise:
339
+ safe_domain = html.escape(domain)
340
+ html_parts.append(f'<span class="cv-skill-tag domain">{safe_domain}</span>')
341
+ html_parts.append('</div></div>')
342
+
343
+ # Experience Card
344
+ if recent_roles or experience_years != "unknown":
345
+ html_parts.append('<div class="cv-section-card">')
346
+ html_parts.append('<div class="cv-section-title">💼 Professional Experience</div>')
347
+ if experience_years != "unknown":
348
+ html_parts.append(f'<div class="cv-info-item">')
349
+ html_parts.append(f'<div class="cv-info-label">Years</div>')
350
+ html_parts.append(f'<div class="cv-info-value" style="color: var(--arc-orange); font-weight: 600;">{html.escape(str(experience_years))}</div>')
351
+ html_parts.append('</div>')
352
+ if recent_roles:
353
+ html_parts.append('<div style="margin-top: 12px;"><ul class="cv-list">')
354
+ for role in recent_roles[:5]: # Limit to 5 roles
355
+ safe_role = html.escape(role)
356
+ html_parts.append(f'<li class="cv-list-item">{safe_role}</li>')
357
+ html_parts.append('</ul></div>')
358
+ html_parts.append('</div>')
359
+
360
+ # Education Card
361
+ if education:
362
+ html_parts.append('<div class="cv-section-card">')
363
+ html_parts.append('<div class="cv-section-title">🎓 Education</div>')
364
+ html_parts.append('<ul class="cv-list">')
365
+ for edu in education:
366
+ safe_edu = html.escape(edu)
367
+ html_parts.append(f'<li class="cv-list-item">{safe_edu}</li>')
368
+ html_parts.append('</ul></div>')
369
+
370
+ # Certifications Card
371
+ if certifications:
372
+ html_parts.append('<div class="cv-section-card">')
373
+ html_parts.append('<div class="cv-section-title">📜 Certifications</div>')
374
+ html_parts.append('<ul class="cv-list">')
375
+ for cert in certifications:
376
+ safe_cert = html.escape(cert)
377
+ html_parts.append(f'<li class="cv-list-item">{safe_cert}</li>')
378
+ html_parts.append('</ul></div>')
379
+
380
+ html_parts.append('</div>') # Close grid
381
+ html_parts.append('</div>') # Close wrapper
382
+
383
+ return ''.join(html_parts)
384
+
UI/render_cv_matching.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render CV project matching results in 3-panel tactical layout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ from typing import Any, Dict, List
7
+
8
+
9
+ def render_cv_matching_html(
10
+ cv_skills_data: Dict[str, Any],
11
+ matched_projects: List[Dict[str, Any]],
12
+ filename: str = "Unknown"
13
+ ) -> str:
14
+ """
15
+ Renders CV matching results in a 3-panel tactical layout.
16
+
17
+ Panel 1: CV Analysis
18
+ Panel 2: Project Matches (ranked)
19
+ Panel 3: Training Costs per Project
20
+
21
+ Args:
22
+ cv_skills_data: Extracted CV skills
23
+ matched_projects: Projects with match data and training plans
24
+ filename: CV filename
25
+
26
+ Returns:
27
+ HTML string for display
28
+ """
29
+
30
+ # CSS for 3-panel layout
31
+ css = """
32
+ <style>
33
+ .cv-matching-wrapper {
34
+ width: 100%;
35
+ min-height: 70vh;
36
+ display: flex;
37
+ flex-direction: column;
38
+ }
39
+
40
+ .cv-matching-header {
41
+ margin-bottom: 24px;
42
+ padding: 0 4px;
43
+ border-bottom: 1px solid var(--border-dim);
44
+ padding-bottom: 16px;
45
+ }
46
+
47
+ .cv-matching-panels {
48
+ display: grid;
49
+ grid-template-columns: 1fr 1.5fr 1fr;
50
+ gap: 20px;
51
+ min-height: 60vh;
52
+ }
53
+
54
+ @media (max-width: 1400px) {
55
+ .cv-matching-panels {
56
+ grid-template-columns: 1fr;
57
+ }
58
+ }
59
+
60
+ .cv-panel {
61
+ background: var(--bg-card);
62
+ border: 1px solid var(--border-dim);
63
+ padding: 20px;
64
+ display: flex;
65
+ flex-direction: column;
66
+ max-height: 80vh;
67
+ overflow-y: auto;
68
+ }
69
+
70
+ .cv-panel::-webkit-scrollbar { width: 8px; }
71
+ .cv-panel::-webkit-scrollbar-track { background: var(--bg-void); }
72
+ .cv-panel::-webkit-scrollbar-thumb { background: var(--border-dim); border-radius: 4px; }
73
+
74
+ .panel-header {
75
+ font-size: 14px;
76
+ color: var(--arc-yellow);
77
+ text-transform: uppercase;
78
+ letter-spacing: 1.5px;
79
+ margin-bottom: 20px;
80
+ font-weight: 700;
81
+ padding-bottom: 12px;
82
+ border-bottom: 2px solid var(--arc-yellow);
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 8px;
86
+ }
87
+
88
+ .cv-summary-compact {
89
+ background: rgba(255, 127, 0, 0.08);
90
+ border: 1px solid var(--arc-orange);
91
+ border-radius: 4px;
92
+ padding: 12px;
93
+ margin-bottom: 16px;
94
+ font-size: 13px;
95
+ color: var(--text-main);
96
+ line-height: 1.5;
97
+ opacity: 0.95;
98
+ }
99
+
100
+ .skill-badge-small {
101
+ display: inline-block;
102
+ background: var(--bg-panel);
103
+ border: 1px solid var(--border-dim);
104
+ padding: 4px 8px;
105
+ margin: 2px;
106
+ border-radius: 3px;
107
+ font-size: 11px;
108
+ font-weight: 600;
109
+ font-family: var(--font-mono);
110
+ }
111
+
112
+ .skill-badge-small.technical { color: var(--arc-green); border-color: var(--arc-green); }
113
+ .skill-badge-small.soft { color: var(--arc-cyan); border-color: var(--arc-cyan); }
114
+
115
+ .project-match-card {
116
+ background: var(--bg-panel);
117
+ border: 1px solid var(--border-dim);
118
+ border-left: 4px solid var(--arc-orange);
119
+ padding: 16px;
120
+ margin-bottom: 12px;
121
+ transition: all 0.2s;
122
+ cursor: pointer;
123
+ }
124
+
125
+ .project-match-card:hover {
126
+ border-left-color: var(--arc-yellow);
127
+ box-shadow: 0 4px 16px rgba(0,0,0,0.3);
128
+ }
129
+
130
+ .project-match-card.perfect { border-left-color: var(--arc-green); }
131
+ .project-match-card.good { border-left-color: var(--arc-cyan); }
132
+ .project-match-card.partial { border-left-color: var(--arc-yellow); }
133
+ .project-match-card.low { border-left-color: var(--arc-red); }
134
+
135
+ .match-percentage {
136
+ font-size: 32px;
137
+ font-weight: 800;
138
+ font-family: var(--font-mono);
139
+ color: var(--text-main);
140
+ margin-bottom: 8px;
141
+ }
142
+
143
+ .match-percentage.perfect { color: var(--arc-green); }
144
+ .match-percentage.good { color: var(--arc-cyan); }
145
+ .match-percentage.partial { color: var(--arc-yellow); }
146
+ .match-percentage.low { color: var(--arc-red); }
147
+
148
+ .project-name {
149
+ font-size: 16px;
150
+ font-weight: 700;
151
+ color: var(--text-main);
152
+ margin-bottom: 8px;
153
+ }
154
+
155
+ .project-meta {
156
+ font-size: 11px;
157
+ color: var(--text-main);
158
+ font-family: var(--font-mono);
159
+ margin-bottom: 12px;
160
+ opacity: 0.9;
161
+ }
162
+
163
+ .skill-match-bar {
164
+ display: flex;
165
+ gap: 8px;
166
+ align-items: center;
167
+ font-size: 12px;
168
+ color: var(--text-main);
169
+ }
170
+
171
+ .training-cost-card {
172
+ background: var(--bg-panel);
173
+ border: 1px solid var(--border-dim);
174
+ padding: 12px;
175
+ margin-bottom: 10px;
176
+ }
177
+
178
+ .training-cost-card.low { border-left: 3px solid var(--arc-green); }
179
+ .training-cost-card.medium { border-left: 3px solid var(--arc-yellow); }
180
+ .training-cost-card.high { border-left: 3px solid var(--arc-red); }
181
+
182
+ .cost-project-name {
183
+ font-size: 13px;
184
+ font-weight: 700;
185
+ color: var(--arc-orange);
186
+ margin-bottom: 8px;
187
+ }
188
+
189
+ .cost-summary {
190
+ font-size: 12px;
191
+ color: var(--text-main);
192
+ margin-bottom: 10px;
193
+ opacity: 0.9;
194
+ }
195
+
196
+ .cost-value {
197
+ font-size: 20px;
198
+ font-weight: 700;
199
+ font-family: var(--font-mono);
200
+ color: var(--text-main);
201
+ }
202
+
203
+ .cost-breakdown {
204
+ font-size: 11px;
205
+ color: var(--text-main);
206
+ font-family: var(--font-mono);
207
+ opacity: 0.9;
208
+ }
209
+
210
+ .stat-inline {
211
+ display: inline-flex;
212
+ align-items: center;
213
+ gap: 6px;
214
+ background: rgba(255,255,255,0.05);
215
+ padding: 4px 8px;
216
+ border-radius: 3px;
217
+ font-size: 11px;
218
+ margin: 2px;
219
+ }
220
+
221
+ .no-data-message {
222
+ text-align: center;
223
+ padding: 40px 20px;
224
+ color: var(--text-main);
225
+ font-family: var(--font-mono);
226
+ font-size: 13px;
227
+ opacity: 0.9;
228
+ }
229
+ </style>
230
+ """
231
+
232
+ # Extract data
233
+ tech_skills = cv_skills_data.get("technical_skills", [])
234
+ soft_skills = cv_skills_data.get("soft_skills", [])
235
+ experience_years = cv_skills_data.get("experience_years", "unknown")
236
+ summary = cv_skills_data.get("summary", "")
237
+
238
+ total_skills = len(tech_skills) + len(soft_skills)
239
+
240
+ # Build HTML
241
+ html_parts = [css, '<div class="cv-matching-wrapper">']
242
+
243
+ # Header
244
+ safe_filename = html.escape(filename)
245
+ html_parts.append(f"""
246
+ <div class="cv-matching-header">
247
+ <div style="display: flex; justify-content: space-between; align-items: flex-end; flex-wrap: wrap; gap: 16px;">
248
+ <div>
249
+ <h2 style="margin:0; color:white; font-size:24px; letter-spacing: 0.5px;">CV + PROJECT MATCHING REPORT</h2>
250
+ <div style="color:var(--arc-yellow); font-family:var(--font-mono); font-size: 14px; margin-top:6px;">SOURCE: {safe_filename}</div>
251
+ </div>
252
+ <div style="font-family: var(--font-mono); font-size: 12px; color: var(--text-main); text-align: right;">
253
+ <div>PROJECTS ANALYZED: <span style="color:var(--arc-cyan); font-weight:700;">{len(matched_projects)}</span></div>
254
+ <div>CANDIDATE SKILLS: <span style="color:var(--arc-green); font-weight:700;">{total_skills}</span></div>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ """)
259
+
260
+ # 3-Panel Grid
261
+ html_parts.append('<div class="cv-matching-panels">')
262
+
263
+ # ============== PANEL 1: CV ANALYSIS ==============
264
+ html_parts.append('<div class="cv-panel">')
265
+ html_parts.append('<div class="panel-header">📄 CANDIDATE PROFILE</div>')
266
+
267
+ # Summary
268
+ if summary:
269
+ safe_summary = html.escape(summary)
270
+ html_parts.append(f'<div class="cv-summary-compact">{safe_summary}</div>')
271
+
272
+ # Stats
273
+ html_parts.append(f"""
274
+ <div style="margin-bottom: 20px;">
275
+ <div class="stat-inline"><strong>Experience:</strong> {html.escape(str(experience_years))}</div>
276
+ <div class="stat-inline"><strong>Tech Skills:</strong> {len(tech_skills)}</div>
277
+ <div class="stat-inline"><strong>Soft Skills:</strong> {len(soft_skills)}</div>
278
+ </div>
279
+ """)
280
+
281
+ # Skills
282
+ if tech_skills:
283
+ html_parts.append('<div style="margin-bottom: 16px;"><div style="font-size:12px; color:var(--arc-yellow); margin-bottom:8px; font-weight:600;">💻 TECHNICAL</div>')
284
+ for skill in tech_skills[:15]: # Limit display
285
+ html_parts.append(f'<span class="skill-badge-small technical">{html.escape(skill)}</span>')
286
+ if len(tech_skills) > 15:
287
+ html_parts.append(f'<span class="skill-badge-small technical">+{len(tech_skills) - 15} more</span>')
288
+ html_parts.append('</div>')
289
+
290
+ if soft_skills:
291
+ html_parts.append('<div><div style="font-size:12px; color:var(--arc-cyan); margin-bottom:8px; font-weight:600;">🤝 SOFT SKILLS</div>')
292
+ for skill in soft_skills[:10]:
293
+ html_parts.append(f'<span class="skill-badge-small soft">{html.escape(skill)}</span>')
294
+ if len(soft_skills) > 10:
295
+ html_parts.append(f'<span class="skill-badge-small soft">+{len(soft_skills) - 10} more</span>')
296
+ html_parts.append('</div>')
297
+
298
+ html_parts.append('</div>') # Close Panel 1
299
+
300
+ # ============== PANEL 2: PROJECT MATCHES ==============
301
+ html_parts.append('<div class="cv-panel">')
302
+ html_parts.append('<div class="panel-header">🎯 PROJECT MATCHES (RANKED)</div>')
303
+
304
+ if matched_projects:
305
+ for project in matched_projects:
306
+ match_pct = project['match_percentage']
307
+
308
+ # Determine match level
309
+ if match_pct >= 90:
310
+ match_class = "perfect"
311
+ elif match_pct >= 70:
312
+ match_class = "good"
313
+ elif match_pct >= 50:
314
+ match_class = "partial"
315
+ else:
316
+ match_class = "low"
317
+
318
+ project_name = html.escape(project['project_name'])
319
+ matched_count = project['matched_skills_count']
320
+ required_count = project['required_skills_count']
321
+ missing_count = project['missing_skills_count']
322
+ status = html.escape(project.get('status', 'Unknown'))
323
+
324
+ # Convert budget to int/float for formatting
325
+ budget = project.get("budget", 0)
326
+ try:
327
+ budget_num = float(budget) if budget else 0
328
+ except (ValueError, TypeError):
329
+ budget_num = 0
330
+
331
+ html_parts.append(f'<div class="project-match-card {match_class}">')
332
+ html_parts.append(f'<div class="match-percentage {match_class}">{match_pct}%</div>')
333
+ html_parts.append(f'<div class="project-name">{project_name}</div>')
334
+ html_parts.append(f'<div class="project-meta">STATUS: {status} | BUDGET: ${budget_num:,.0f}</div>')
335
+ html_parts.append(f'<div class="skill-match-bar">')
336
+ html_parts.append(f'<span style="color:var(--arc-green);">✓ {matched_count} Match</span>')
337
+ html_parts.append(f'<span style="color:var(--text-main); opacity:0.6;">|</span>')
338
+ html_parts.append(f'<span style="color:var(--arc-red);">✗ {missing_count} Gap</span>')
339
+ html_parts.append(f'<span style="color:var(--text-main); opacity:0.6;">|</span>')
340
+ html_parts.append(f'<span style="color:var(--text-main);">{required_count} Required</span>')
341
+ html_parts.append('</div></div>')
342
+ else:
343
+ html_parts.append('<div class="no-data-message">No projects found for matching.</div>')
344
+
345
+ html_parts.append('</div>') # Close Panel 2
346
+
347
+ # ============== PANEL 3: SKILL GAPS SUMMARY ==============
348
+ html_parts.append('<div class="cv-panel">')
349
+ html_parts.append('<div class="panel-header">⚠️ SKILL GAPS</div>')
350
+
351
+ if matched_projects:
352
+ # Show missing skills for each project
353
+ has_gaps = False
354
+ for project in matched_projects:
355
+ if project['missing_skills_count'] == 0:
356
+ continue # Skip projects with 100% match
357
+
358
+ has_gaps = True
359
+ project_name = html.escape(project['project_name'])
360
+ missing_skills = project.get('missing_skills', [])
361
+
362
+ html_parts.append('<div class="training-cost-card">')
363
+ html_parts.append(f'<div class="cost-project-name">{project_name}</div>')
364
+ html_parts.append(f'<div class="cost-summary">{project["missing_skills_count"]} skill gap(s)</div>')
365
+
366
+ # List missing skills
367
+ html_parts.append('<div style="margin-top: 10px;">')
368
+ for skill in missing_skills[:5]: # Show max 5
369
+ skill_name = html.escape(skill)
370
+ html_parts.append(f'<div style="font-size:12px; color:var(--arc-red); padding:4px 0;">• {skill_name}</div>')
371
+ if len(missing_skills) > 5:
372
+ html_parts.append(f'<div style="font-size:12px; color:var(--text-main); opacity:0.8; padding:4px 0;">... and {len(missing_skills) - 5} more</div>')
373
+ html_parts.append('</div>')
374
+ html_parts.append('</div>')
375
+
376
+ if not has_gaps:
377
+ html_parts.append('<div class="no-data-message">✅ Candidate is fully qualified for all projects!<br>No skill gaps detected.</div>')
378
+ else:
379
+ html_parts.append('<div class="no-data-message">No data available.</div>')
380
+
381
+ html_parts.append('</div>') # Close Panel 3
382
+
383
+ html_parts.append('</div>') # Close panels grid
384
+ html_parts.append('</div>') # Close wrapper
385
+
386
+ return ''.join(html_parts)
387
+
UI/render_plan_html.py CHANGED
@@ -59,9 +59,10 @@ def render_plan_html(result: Any) -> str:
59
  .sec-role-badge {
60
  font-size: 12px;
61
  font-weight: 700;
62
- color: var(--text-dim);
63
  letter-spacing: 1px;
64
  text-transform: uppercase;
 
65
  }
66
 
67
  /* BODY */
@@ -83,7 +84,7 @@ def render_plan_html(result: Any) -> str:
83
  font-size: 12px;
84
  color: var(--arc-orange);
85
  margin-bottom: 20px;
86
- opacity: 0.8;
87
  }
88
 
89
  /* SKILL GAPS (Binary State) */
@@ -153,7 +154,7 @@ def render_plan_html(result: Any) -> str:
153
 
154
  .sec-item-details { flex-grow: 1; }
155
  .sec-item-title { font-size: 14px; color: #fff; font-weight: 600; margin-bottom: 4px; line-height: 1.4;}
156
- .sec-item-meta { font-size: 12px; color: #99AAB5; font-family: var(--font-mono); }
157
 
158
  /* STATS BAR */
159
  .sec-stats-bar {
@@ -171,10 +172,11 @@ def render_plan_html(result: Any) -> str:
171
  }
172
  .sec-stat-label {
173
  font-size: 11px;
174
- color: var(--text-dim);
175
  font-weight: 600;
176
  letter-spacing: 0.5px;
177
  text-transform: uppercase;
 
178
  }
179
  .sec-stat-value {
180
  font-size: 18px;
@@ -256,7 +258,7 @@ def render_plan_html(result: Any) -> str:
256
  <h2 style="margin:0; color:white; font-size:24px; letter-spacing: 0.5px;">CAPABILITY ANALYSIS REPORT</h2>
257
  <div style="color:var(--arc-yellow); font-family:var(--font-mono); font-size: 14px; margin-top:6px;">PROJECT: {project_name}</div>
258
  </div>
259
- <div style="font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); text-align: right;">
260
  <div>STATUS: <span style="color:var(--arc-green)">ACTIVE</span></div>
261
  <div>HEADCOUNT: {len(team)}</div>
262
  </div>
@@ -273,7 +275,7 @@ def render_plan_html(result: Any) -> str:
273
  <div class="sec-divider"></div>
274
  <div class="sec-stat-group">
275
  <div class="sec-stat-label">Est. Timeline</div>
276
- <div class="sec-stat-value" style="color: var(--arc-orange);">{max_duration:.0f} HRS <span style="font-size:11px; color:var(--text-dim)">(CONCURRENT)</span></div>
277
  </div>
278
  <div class="sec-divider"></div>
279
  <div class="sec-stat-group">
@@ -341,7 +343,7 @@ def render_plan_html(result: Any) -> str:
341
  if not training_html and gaps_list:
342
  training_html = "<div style='font-size:12px; color:var(--arc-red); padding: 4px 0;'>PENDING: CURRICULUM GENERATION REQUIRED</div>"
343
  elif not training_html:
344
- training_html = "<div style='font-size:12px; color:var(--text-dim); padding: 4px 0;'>NO ACTION REQUIRED</div>"
345
 
346
  # Assemble Card
347
  # Create a faux Employee ID
 
59
  .sec-role-badge {
60
  font-size: 12px;
61
  font-weight: 700;
62
+ color: var(--text-main);
63
  letter-spacing: 1px;
64
  text-transform: uppercase;
65
+ opacity: 0.9;
66
  }
67
 
68
  /* BODY */
 
84
  font-size: 12px;
85
  color: var(--arc-orange);
86
  margin-bottom: 20px;
87
+ opacity: 1.0;
88
  }
89
 
90
  /* SKILL GAPS (Binary State) */
 
154
 
155
  .sec-item-details { flex-grow: 1; }
156
  .sec-item-title { font-size: 14px; color: #fff; font-weight: 600; margin-bottom: 4px; line-height: 1.4;}
157
+ .sec-item-meta { font-size: 12px; color: var(--text-main); font-family: var(--font-mono); opacity: 0.9; }
158
 
159
  /* STATS BAR */
160
  .sec-stats-bar {
 
172
  }
173
  .sec-stat-label {
174
  font-size: 11px;
175
+ color: var(--text-main);
176
  font-weight: 600;
177
  letter-spacing: 0.5px;
178
  text-transform: uppercase;
179
+ opacity: 0.9;
180
  }
181
  .sec-stat-value {
182
  font-size: 18px;
 
258
  <h2 style="margin:0; color:white; font-size:24px; letter-spacing: 0.5px;">CAPABILITY ANALYSIS REPORT</h2>
259
  <div style="color:var(--arc-yellow); font-family:var(--font-mono); font-size: 14px; margin-top:6px;">PROJECT: {project_name}</div>
260
  </div>
261
+ <div style="font-family: var(--font-mono); font-size: 12px; color: var(--text-main); text-align: right;">
262
  <div>STATUS: <span style="color:var(--arc-green)">ACTIVE</span></div>
263
  <div>HEADCOUNT: {len(team)}</div>
264
  </div>
 
275
  <div class="sec-divider"></div>
276
  <div class="sec-stat-group">
277
  <div class="sec-stat-label">Est. Timeline</div>
278
+ <div class="sec-stat-value" style="color: var(--arc-orange);">{max_duration:.0f} HRS <span style="font-size:11px; color:var(--text-main); opacity:0.8;">(CONCURRENT)</span></div>
279
  </div>
280
  <div class="sec-divider"></div>
281
  <div class="sec-stat-group">
 
343
  if not training_html and gaps_list:
344
  training_html = "<div style='font-size:12px; color:var(--arc-red); padding: 4px 0;'>PENDING: CURRICULUM GENERATION REQUIRED</div>"
345
  elif not training_html:
346
+ training_html = "<div style='font-size:12px; color:var(--text-main); opacity:0.9; padding: 4px 0;'>NO ACTION REQUIRED</div>"
347
 
348
  # Assemble Card
349
  # Create a faux Employee ID
agents/orchestrator_agent/get_agent_name_for_logs.py CHANGED
@@ -1,5 +1,5 @@
1
  agent_names_map = {
2
- 'supabase_agent': '🟦 Supabase Agent',
3
  'web_search_agent': '🌐 Web Search Agent',
4
  'orchestrator_agent': ' Orchestrator Agent',
5
  }
 
1
  agent_names_map = {
2
+ 'supabase_agent': 'Supabase Agent',
3
  'web_search_agent': '🌐 Web Search Agent',
4
  'orchestrator_agent': ' Orchestrator Agent',
5
  }
agents/supabase_agent/supabase_agent.py CHANGED
@@ -1,10 +1,12 @@
1
  # agents/supabase_agent.py
2
 
3
  import os
 
4
  from smolagents import CodeAgent, MCPClient
 
5
  from LLM.llm_models import supabase_model
6
- from .get_supabase_agent_prompt import get_supabase_agent_prompt
7
 
 
8
 
9
  mcp_client_config = {
10
  "url": "https://mcp.supabase.com/mcp?project_ref=kzrdiydhjgsaxomkuijq",
@@ -61,7 +63,7 @@ class SupabaseAgent:
61
  def patched_run(prompt: str, *args, **kwargs):
62
  if self._agent_start_callback:
63
  self._agent_start_callback(
64
- "🟦 Supabase Agent starting with database tasks",
65
  )
66
  return original_run(prompt, *args, **kwargs)
67
 
 
1
  # agents/supabase_agent.py
2
 
3
  import os
4
+
5
  from smolagents import CodeAgent, MCPClient
6
+
7
  from LLM.llm_models import supabase_model
 
8
 
9
+ from .get_supabase_agent_prompt import get_supabase_agent_prompt
10
 
11
  mcp_client_config = {
12
  "url": "https://mcp.supabase.com/mcp?project_ref=kzrdiydhjgsaxomkuijq",
 
63
  def patched_run(prompt: str, *args, **kwargs):
64
  if self._agent_start_callback:
65
  self._agent_start_callback(
66
+ "Supabase Agent starting with database tasks",
67
  )
68
  return original_run(prompt, *args, **kwargs)
69
 
app.py CHANGED
@@ -51,8 +51,7 @@ def analyze_and_plan_interface(user_prompt: str):
51
  if orchestrator_agent is None:
52
  message = orchestrator_error or "Agent unavailable"
53
  ui.register_agent_action("System Offline", {"reason": message, "prompt": user_prompt})
54
- yield ui.render_error_state(message)
55
- return
56
 
57
  # Cancel any existing run, if any
58
  if current_thread and current_thread.is_alive() and current_thread != threading.current_thread():
@@ -60,21 +59,28 @@ def analyze_and_plan_interface(user_prompt: str):
60
 
61
  current_thread = threading.current_thread()
62
 
63
- yield "⏳ Running...", None
64
-
65
- try:
66
- result: Any = orchestrator_agent.analyze_and_plan(
67
- user_prompt,
68
- ui.register_agent_action,
69
- get_is_run_cancelled_flag=lambda: is_run_cancelled
70
- )
71
- if result is not None:
72
- yield ui.render_analysis_result(result)
73
- except Exception as exc:
74
- yield ui.render_error_state(str(exc))
75
- finally:
76
- if current_thread == threading.current_thread():
77
- current_thread = None
 
 
 
 
 
 
 
78
 
79
 
80
  def main():
 
51
  if orchestrator_agent is None:
52
  message = orchestrator_error or "Agent unavailable"
53
  ui.register_agent_action("System Offline", {"reason": message, "prompt": user_prompt})
54
+ return ui.render_error_state(message)
 
55
 
56
  # Cancel any existing run, if any
57
  if current_thread and current_thread.is_alive() and current_thread != threading.current_thread():
 
59
 
60
  current_thread = threading.current_thread()
61
 
62
+ def run_agent():
63
+ try:
64
+ result: Any = orchestrator_agent.analyze_and_plan(
65
+ user_prompt,
66
+ ui.register_agent_action,
67
+ get_is_run_cancelled_flag=lambda: is_run_cancelled
68
+ )
69
+ if result is not None: # agent didn't get cancelled, keep running
70
+ ui.set_analysis_result(result)
71
+ except Exception as exc:
72
+ ui.set_analysis_error(str(exc))
73
+ finally:
74
+ # Stop processing when agent thread completes
75
+ ui.stop_processing()
76
+
77
+ # Start a brand new thread for the new request
78
+ # Note: start_processing() was already called by the button handler
79
+ current_thread = threading.Thread(target=run_agent)
80
+ current_thread.start()
81
+
82
+ # Return processing state - timer will poll for updates
83
+ return ui.render_project_processing_state()
84
 
85
 
86
  def main():
pyproject.toml CHANGED
@@ -16,6 +16,10 @@ dependencies = [
16
  "ddgs>=9.9.1",
17
  "repr>=0.3.1",
18
  "typer>=0.20.0",
 
 
 
 
19
  "elevenlabs>=2.24.0",
20
  ]
21
 
 
16
  "ddgs>=9.9.1",
17
  "repr>=0.3.1",
18
  "typer>=0.20.0",
19
+ "pypdf>=4.0.0",
20
+ "python-docx>=1.0.0",
21
+ "pdfplumber>=0.11.0",
22
+ "python-multipart>=0.0.6",
23
  "elevenlabs>=2.24.0",
24
  ]
25
 
utils/cv_parser.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CV Parser utility for extracting text from PDF and DOCX files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+
10
+ def _print_terminal_log(action: str, details: str = ""):
11
+ """Print formatted log to terminal."""
12
+ timestamp = __import__('datetime').datetime.now().strftime("%H:%M:%S")
13
+ if details:
14
+ print(f"[{timestamp}] [CV PARSER] {action} :: {details}")
15
+ else:
16
+ print(f"[{timestamp}] [CV PARSER] {action}")
17
+
18
+
19
+ def extract_text_from_pdf(file_content: bytes) -> str:
20
+ """Extract text from PDF file content."""
21
+ try:
22
+ import pdfplumber
23
+
24
+ text_content = []
25
+ with pdfplumber.open(io.BytesIO(file_content)) as pdf:
26
+ for page in pdf.pages:
27
+ page_text = page.extract_text()
28
+ if page_text:
29
+ text_content.append(page_text)
30
+
31
+ return "\n\n".join(text_content)
32
+ except Exception as e:
33
+ raise ValueError(f"Failed to extract text from PDF: {str(e)}")
34
+
35
+
36
+ def extract_text_from_docx(file_content: bytes) -> str:
37
+ """Extract text from DOCX file content."""
38
+ try:
39
+ from docx import Document
40
+
41
+ doc = Document(io.BytesIO(file_content))
42
+ text_content = []
43
+
44
+ for paragraph in doc.paragraphs:
45
+ if paragraph.text.strip():
46
+ text_content.append(paragraph.text)
47
+
48
+ # Also extract text from tables
49
+ for table in doc.tables:
50
+ for row in table.rows:
51
+ for cell in row.cells:
52
+ if cell.text.strip():
53
+ text_content.append(cell.text)
54
+
55
+ return "\n".join(text_content)
56
+ except Exception as e:
57
+ raise ValueError(f"Failed to extract text from DOCX: {str(e)}")
58
+
59
+
60
+ def parse_cv(file_path: str | None = None, file_content: bytes | None = None, log_callback=None) -> str:
61
+ """
62
+ Parse CV file and extract text content.
63
+
64
+ Args:
65
+ file_path: Path to the CV file (optional if file_content is provided)
66
+ file_content: Binary content of the file (optional if file_path is provided)
67
+
68
+ Returns:
69
+ Extracted text from the CV
70
+
71
+ Raises:
72
+ ValueError: If file format is not supported or parsing fails
73
+ """
74
+ if file_content is None and file_path is None:
75
+ raise ValueError("Either file_path or file_content must be provided")
76
+
77
+ # Read file if path is provided
78
+ if file_content is None and file_path:
79
+ with open(file_path, "rb") as f:
80
+ file_content = f.read()
81
+
82
+ # Determine file type
83
+ if file_path:
84
+ file_extension = Path(file_path).suffix.lower()
85
+ if log_callback:
86
+ log_callback("File Type Detection", {"method": "filename", "extension": file_extension})
87
+ else:
88
+ # Try to detect from content
89
+ if file_content and file_content[:4] == b'%PDF':
90
+ file_extension = '.pdf'
91
+ elif file_content and file_content[:2] == b'PK': # ZIP-based format (DOCX)
92
+ file_extension = '.docx'
93
+ else:
94
+ if log_callback:
95
+ log_callback("⚠️ File Type Detection Failed", {"reason": "Unknown file signature"})
96
+ raise ValueError("Could not determine file type. Please provide a PDF or DOCX file.")
97
+
98
+ if log_callback:
99
+ log_callback("File Type Detection", {"method": "content signature", "extension": file_extension})
100
+
101
+ # Extract text based on file type
102
+ if file_extension == '.pdf':
103
+ _print_terminal_log("PDF Parsing Started", f"Extracting text from PDF file...")
104
+
105
+ if log_callback:
106
+ log_callback("PDF Parser", {"status": "Starting PDF text extraction..."})
107
+
108
+ text = extract_text_from_pdf(file_content)
109
+ pages = text.count('\n\n') + 1
110
+
111
+ _print_terminal_log("PDF Extraction Complete", f"Extracted {pages} pages, {len(text)} characters")
112
+
113
+ if log_callback:
114
+ log_callback("PDF Extraction Complete", {"pages_extracted": pages})
115
+ return text
116
+ elif file_extension in ['.docx', '.doc']:
117
+ _print_terminal_log("DOCX Parsing Started", f"Extracting text from DOCX file...")
118
+
119
+ if log_callback:
120
+ log_callback("DOCX Parser", {"status": "Starting DOCX text extraction..."})
121
+
122
+ text = extract_text_from_docx(file_content)
123
+ paragraphs = len([p for p in text.split('\n') if p.strip()])
124
+
125
+ _print_terminal_log("DOCX Extraction Complete", f"Extracted {paragraphs} paragraphs, {len(text)} characters")
126
+
127
+ if log_callback:
128
+ log_callback("DOCX Extraction Complete", {"paragraphs": paragraphs})
129
+ return text
130
+ else:
131
+ if log_callback:
132
+ log_callback("⚠️ Unsupported Format", {"extension": file_extension})
133
+ raise ValueError(f"Unsupported file format: {file_extension}. Please upload a PDF or DOCX file.")
134
+
utils/cv_project_matcher.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CV to Project Matching - finds best project fits based on skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Tuple
6
+ from agents.supabase_agent import SupabaseAgent
7
+
8
+
9
+ def _print_terminal_log(action: str, details: str = ""):
10
+ """Print formatted log to terminal."""
11
+ timestamp = __import__('datetime').datetime.now().strftime("%H:%M:%S")
12
+ if details:
13
+ print(f"[{timestamp}] [CV MATCHER] {action} :: {details}")
14
+ else:
15
+ print(f"[{timestamp}] [CV MATCHER] {action}")
16
+
17
+
18
+ def get_all_projects_with_skills(log_callback=None) -> List[Dict[str, Any]]:
19
+ """
20
+ Fetch all projects with their required skills from Supabase.
21
+ Creates a fresh agent with MCP tools to call the edge function.
22
+
23
+ Returns:
24
+ List of projects with their required skills
25
+ """
26
+ _print_terminal_log("Fetching Projects", "Creating independent agent for edge function call...")
27
+
28
+ if log_callback:
29
+ log_callback("📊 Fetching Projects", {"status": "Calling Supabase edge function..."})
30
+
31
+ # Use the fallback method directly (it's proven to work)
32
+ return _call_edge_function_via_agent(log_callback)
33
+
34
+
35
+ def _call_edge_function_via_agent(log_callback=None) -> List[Dict[str, Any]]:
36
+ """
37
+ Fallback: Call edge function via agent's underlying model directly.
38
+ This bypasses the patched run() method that has orchestrator callbacks.
39
+ """
40
+ _print_terminal_log("Using Fallback", "Calling via agent's model directly...")
41
+
42
+ try:
43
+ from LLM.llm_models import supabase_model
44
+ import os
45
+ from smolagents import MCPClient
46
+
47
+ # Create our own MCP client (fresh, no callbacks)
48
+ mcp_client = MCPClient(
49
+ {
50
+ "url": "https://mcp.supabase.com/mcp?project_ref=kzrdiydhjgsaxomkuijq",
51
+ "transport": "streamable-http",
52
+ "headers": {"Authorization": f"Bearer {os.getenv('SUPABASE_API_KEY')}"},
53
+ },
54
+ structured_output=True,
55
+ )
56
+
57
+ _print_terminal_log("Fresh MCP Client", "Created independent MCP connection")
58
+
59
+ # Get tools
60
+ tools = mcp_client.get_tools()
61
+
62
+ # Create a minimal agent just for this call (no callbacks)
63
+ from smolagents import CodeAgent
64
+
65
+ temp_agent = CodeAgent(
66
+ model=supabase_model,
67
+ tools=tools,
68
+ max_steps=10, # Give it more steps
69
+ planning_interval=2,
70
+ additional_authorized_imports=['json', 'requests'], # Add needed imports
71
+ instructions="Use the MCP tools available to call edge functions. Do NOT write code to make HTTP requests - use the provided tools."
72
+ )
73
+
74
+ _print_terminal_log("Temp Agent Created", "Calling edge function...")
75
+
76
+ # Call the agent with a very specific prompt focusing on SQL query
77
+ prompt = """
78
+ Query the Supabase database to get all projects with their required skills.
79
+
80
+ Execute a SQL query that:
81
+ 1. Selects from the 'projects' table
82
+ 2. Joins with 'project_required_skills' table
83
+ 3. Joins with 'skills' table
84
+ 4. Returns all projects with their id, name, description, status, budget, deadline
85
+ 5. Includes an array of required skills for each project
86
+
87
+ Use execute_sql with the appropriate SELECT query.
88
+
89
+ The query should look like:
90
+ SELECT
91
+ p.id, p.name, p.description, p.status, p.budget, p.deadline,
92
+ json_agg(json_build_object('skill_id', s.id, 'skill_name', s.name)) as required_skills
93
+ FROM projects p
94
+ LEFT JOIN project_required_skills prs ON p.id = prs.project_id
95
+ LEFT JOIN skills s ON prs.skill_id = s.id
96
+ GROUP BY p.id, p.name, p.description, p.status, p.budget, p.deadline
97
+
98
+ Return the complete result.
99
+ """
100
+ result = temp_agent.run(prompt)
101
+
102
+ _print_terminal_log("Agent Completed", "Parsing response...")
103
+
104
+ projects = _parse_projects_from_result(result)
105
+
106
+ _print_terminal_log("Fallback Success", f"Retrieved {len(projects)} projects")
107
+
108
+ if log_callback:
109
+ log_callback("✅ Projects Retrieved (Fallback)", {"total": len(projects)})
110
+
111
+ return projects
112
+
113
+ except Exception as e:
114
+ _print_terminal_log(f"❌ Fallback ERROR: {type(e).__name__}", str(e))
115
+ if log_callback:
116
+ log_callback("❌ All Methods Failed", {"error": str(e)[:100]})
117
+ return []
118
+
119
+
120
+ def _parse_projects_from_result(result: Any) -> List[Dict[str, Any]]:
121
+ """Parse projects from various result formats, including untrusted-data wrapped responses."""
122
+ import json
123
+ import re
124
+
125
+ projects = []
126
+
127
+ if isinstance(result, dict):
128
+ if 'projects' in result:
129
+ projects = result['projects']
130
+ elif 'data' in result:
131
+ projects = result['data']
132
+ else:
133
+ projects = [result] if result.get('id') else []
134
+ elif isinstance(result, list):
135
+ projects = result
136
+ elif isinstance(result, str):
137
+ # First, try to extract data from untrusted-data tags
138
+ untrusted_match = re.search(r'<untrusted-data[^>]*>(.*?)</untrusted-data', result, re.DOTALL)
139
+ if untrusted_match:
140
+ data_str = untrusted_match.group(1).strip()
141
+ _print_terminal_log("Extracted from untrusted-data tags", f"Length: {len(data_str)}")
142
+
143
+ # The extracted content might have extra text before the JSON array
144
+ # Find the JSON array within it
145
+ json_array_match = re.search(r'(\[{.*}\])', data_str, re.DOTALL)
146
+ if json_array_match:
147
+ json_str = json_array_match.group(1)
148
+ _print_terminal_log("Found JSON array", f"Length: {len(json_str)}")
149
+ try:
150
+ parsed = json.loads(json_str)
151
+ if isinstance(parsed, list):
152
+ projects = parsed
153
+ elif isinstance(parsed, dict) and 'projects' in parsed:
154
+ projects = parsed['projects']
155
+ except json.JSONDecodeError as e:
156
+ _print_terminal_log("⚠️ JSON Parse Error", f"Could not parse JSON array: {str(e)[:100]}")
157
+ else:
158
+ # Try parsing the whole extracted content
159
+ try:
160
+ parsed = json.loads(data_str)
161
+ if isinstance(parsed, list):
162
+ projects = parsed
163
+ elif isinstance(parsed, dict) and 'projects' in parsed:
164
+ projects = parsed['projects']
165
+ except json.JSONDecodeError as e:
166
+ _print_terminal_log("⚠️ JSON Parse Error", f"Could not parse untrusted-data content: {str(e)[:100]}")
167
+ else:
168
+ # No untrusted-data tags, try parsing the whole string as JSON
169
+ try:
170
+ parsed = json.loads(result)
171
+ if isinstance(parsed, dict) and 'projects' in parsed:
172
+ projects = parsed['projects']
173
+ elif isinstance(parsed, list):
174
+ projects = parsed
175
+ except json.JSONDecodeError:
176
+ # Last resort: try to find JSON array in the string
177
+ json_match = re.search(r'\[{.*}\]', result, re.DOTALL)
178
+ if json_match:
179
+ try:
180
+ projects = json.loads(json_match.group(0))
181
+ except json.JSONDecodeError:
182
+ pass
183
+
184
+ # Transform the data structure to match expected format
185
+ if projects:
186
+ formatted_projects = []
187
+ for proj in projects:
188
+ # Convert required_skills format if needed
189
+ required_skills = proj.get('required_skills', [])
190
+ if required_skills and isinstance(required_skills, list):
191
+ # Transform {skill_id, skill_name} to expected format
192
+ formatted_skills = []
193
+ for skill in required_skills:
194
+ if isinstance(skill, dict):
195
+ formatted_skills.append({
196
+ 'skill_id': skill.get('skill_id'),
197
+ 'skill': {
198
+ 'id': skill.get('skill_id'),
199
+ 'name': skill.get('skill_name', ''),
200
+ 'description': ''
201
+ }
202
+ })
203
+ proj['required_skills'] = formatted_skills
204
+
205
+ formatted_projects.append(proj)
206
+
207
+ projects = formatted_projects
208
+
209
+ return projects
210
+
211
+
212
+ def calculate_skill_match(cv_skills: List[str], required_skills: List[Dict[str, Any]]) -> Tuple[float, List[str], List[str]]:
213
+ """
214
+ Calculate match percentage between CV skills and required skills.
215
+
216
+ Args:
217
+ cv_skills: List of skill names from CV
218
+ required_skills: List of required skill objects from project
219
+
220
+ Returns:
221
+ Tuple of (match_percentage, matched_skills, missing_skills)
222
+ """
223
+ # Normalize CV skills to lowercase for comparison
224
+ cv_skills_lower = [skill.lower().strip() for skill in cv_skills]
225
+
226
+ # Extract required skill names
227
+ required_skill_names = []
228
+ for req_skill in required_skills:
229
+ if isinstance(req_skill, dict) and 'skill' in req_skill:
230
+ skill_name = req_skill['skill'].get('name', '')
231
+ elif isinstance(req_skill, dict) and 'name' in req_skill:
232
+ skill_name = req_skill['name']
233
+ else:
234
+ skill_name = str(req_skill)
235
+
236
+ if skill_name:
237
+ required_skill_names.append(skill_name)
238
+
239
+ if not required_skill_names:
240
+ return (100.0, [], []) # No requirements means perfect match
241
+
242
+ required_skills_lower = [skill.lower().strip() for skill in required_skill_names]
243
+
244
+ # Find matches
245
+ matched_skills = []
246
+ missing_skills = []
247
+
248
+ for req_skill, req_skill_lower in zip(required_skill_names, required_skills_lower):
249
+ # Check for exact or partial matches
250
+ is_match = False
251
+ for cv_skill, cv_skill_lower in zip(cv_skills, cv_skills_lower):
252
+ if (cv_skill_lower == req_skill_lower or
253
+ cv_skill_lower in req_skill_lower or
254
+ req_skill_lower in cv_skill_lower):
255
+ is_match = True
256
+ if req_skill not in matched_skills:
257
+ matched_skills.append(req_skill)
258
+ break
259
+
260
+ if not is_match:
261
+ missing_skills.append(req_skill)
262
+
263
+ # Calculate percentage
264
+ match_percentage = (len(matched_skills) / len(required_skill_names)) * 100
265
+
266
+ return (match_percentage, matched_skills, missing_skills)
267
+
268
+
269
+ def match_cv_to_projects(cv_skills_data: Dict[str, Any], log_callback=None) -> List[Dict[str, Any]]:
270
+ """
271
+ Match CV skills against all projects and rank them.
272
+
273
+ Args:
274
+ cv_skills_data: Extracted skills from CV
275
+ log_callback: Optional callback for logging
276
+
277
+ Returns:
278
+ List of projects with match data, sorted by match percentage
279
+ """
280
+ _print_terminal_log("Starting CV Matching", "Analyzing candidate fit across all projects...")
281
+
282
+ if log_callback:
283
+ log_callback("🎯 Starting Project Matching", {"status": "Comparing CV skills with project requirements"})
284
+
285
+ # Get all CV skills (technical + soft skills)
286
+ technical_skills = cv_skills_data.get("technical_skills", [])
287
+ soft_skills = cv_skills_data.get("soft_skills", [])
288
+ all_cv_skills = technical_skills + soft_skills
289
+
290
+ _print_terminal_log("CV Skills Loaded", f"Technical: {len(technical_skills)}, Soft: {len(soft_skills)}, Total: {len(all_cv_skills)}")
291
+
292
+ # Fetch all projects
293
+ projects = get_all_projects_with_skills(log_callback)
294
+
295
+ if not projects:
296
+ _print_terminal_log("⚠️ No Projects Found", "No projects available for matching")
297
+ if log_callback:
298
+ log_callback("⚠️ No Projects", {"status": "No projects found in database"})
299
+ return []
300
+
301
+ # Match each project
302
+ matched_projects = []
303
+
304
+ for project in projects:
305
+ project_name = project.get('name', 'Unknown Project')
306
+ required_skills = project.get('required_skills', [])
307
+
308
+ _print_terminal_log(f"Matching: {project_name}", f"Checking {len(required_skills)} required skills...")
309
+
310
+ # Calculate match
311
+ match_percentage, matched_skills, missing_skills = calculate_skill_match(
312
+ all_cv_skills,
313
+ required_skills
314
+ )
315
+
316
+ matched_project = {
317
+ 'project_id': project.get('id'),
318
+ 'project_name': project_name,
319
+ 'description': project.get('description', ''),
320
+ 'budget': project.get('budget', 0),
321
+ 'deadline': project.get('deadline', ''),
322
+ 'status': project.get('status', ''),
323
+ 'match_percentage': round(match_percentage, 1),
324
+ 'matched_skills': matched_skills,
325
+ 'missing_skills': missing_skills,
326
+ 'required_skills_count': len(required_skills) if required_skills else 0,
327
+ 'matched_skills_count': len(matched_skills),
328
+ 'missing_skills_count': len(missing_skills),
329
+ }
330
+
331
+ matched_projects.append(matched_project)
332
+
333
+ _print_terminal_log(
334
+ f"Match Result: {project_name}",
335
+ f"{match_percentage:.1f}% match ({len(matched_skills)}/{len(required_skills) if required_skills else 0} skills)"
336
+ )
337
+
338
+ # Sort by match percentage (highest first)
339
+ matched_projects.sort(key=lambda x: x['match_percentage'], reverse=True)
340
+
341
+ _print_terminal_log("✅ Matching Complete", f"Analyzed {len(matched_projects)} projects, ranked by fit")
342
+
343
+ if log_callback:
344
+ log_callback("✅ Matching Complete", {
345
+ "total_projects": len(matched_projects),
346
+ "best_match": f"{matched_projects[0]['project_name']} ({matched_projects[0]['match_percentage']}%)" if matched_projects else "None"
347
+ })
348
+
349
+ return matched_projects
350
+
utils/cv_training_cost.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training cost estimation for CV project matching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List
6
+ from smolagents import CodeAgent
7
+ from LLM.llm_models import websearch_model
8
+ from agents.websearch_agent import WebSearchAgent
9
+
10
+
11
+ def _print_terminal_log(action: str, details: str = ""):
12
+ """Print formatted log to terminal."""
13
+ timestamp = __import__('datetime').datetime.now().strftime("%H:%M:%S")
14
+ if details:
15
+ print(f"[{timestamp}] [TRAINING COST] {action} :: {details}")
16
+ else:
17
+ print(f"[{timestamp}] [TRAINING COST] {action}")
18
+
19
+
20
+ def estimate_training_costs_for_skills(
21
+ missing_skills: List[str],
22
+ project_name: str,
23
+ log_callback=None
24
+ ) -> List[Dict[str, Any]]:
25
+ """
26
+ Estimate training costs for missing skills using websearch.
27
+ Uses the same logic as project analysis training plan generation.
28
+
29
+ Args:
30
+ missing_skills: List of skill names that candidate is missing
31
+ project_name: Name of the project (for context)
32
+ log_callback: Optional callback for logging
33
+
34
+ Returns:
35
+ List of training plans with costs
36
+ """
37
+ if not missing_skills:
38
+ return []
39
+
40
+ _print_terminal_log(
41
+ f"Estimating Training Costs",
42
+ f"Searching for {len(missing_skills)} missing skills for {project_name}"
43
+ )
44
+
45
+ if log_callback:
46
+ log_callback("💰 Training Cost Analysis", {
47
+ "project": project_name,
48
+ "missing_skills": len(missing_skills)
49
+ })
50
+
51
+ training_plans = []
52
+
53
+ try:
54
+ # Don't use WebSearchAgent - it has the same singleton callback issues
55
+ # Instead, use default estimates (fast and reliable)
56
+ _print_terminal_log("Using Default Estimates", "Skipping websearch to avoid callback issues")
57
+
58
+ # Use default estimates for all skills (reliable and fast)
59
+ for skill in missing_skills[:10]: # Limit to 10 skills
60
+ # Extract skill name from dict if needed
61
+ skill_name = skill.get('skill_name', skill) if isinstance(skill, dict) else skill
62
+
63
+ _print_terminal_log(f"Estimating: {skill_name}", "Using default estimates")
64
+
65
+ if log_callback:
66
+ log_callback(f"💰 Estimating: {skill_name}", {"status": "Calculating training cost"})
67
+
68
+ training_plans.append({
69
+ "skill": skill_name,
70
+ "title": f"{skill_name} - Professional Training",
71
+ "cost": estimate_default_cost(skill_name),
72
+ "duration_hours": estimate_default_duration(skill_name),
73
+ "provider": "Estimated",
74
+ "source": "default"
75
+ })
76
+
77
+ _print_terminal_log("✅ Cost Estimation Complete", f"Generated {len(training_plans)} training plans")
78
+
79
+ if log_callback:
80
+ total_cost = sum(plan['cost'] for plan in training_plans)
81
+ total_hours = sum(plan['duration_hours'] for plan in training_plans)
82
+ log_callback("✅ Training Costs Calculated", {
83
+ "total_plans": len(training_plans),
84
+ "total_cost": f"${total_cost:,.2f}",
85
+ "total_hours": total_hours
86
+ })
87
+
88
+ return training_plans
89
+
90
+ except Exception as e:
91
+ _print_terminal_log(f"❌ ERROR: {type(e).__name__}", str(e))
92
+ if log_callback:
93
+ log_callback("❌ Training Cost Error", {"error": str(e)})
94
+
95
+ # Return default estimates for all skills
96
+ # Return default estimates
97
+ result = []
98
+ for skill in missing_skills[:10]:
99
+ skill_name = skill.get('skill_name', skill) if isinstance(skill, dict) else skill
100
+ result.append({
101
+ "skill": skill_name,
102
+ "title": f"{skill_name} - Professional Training",
103
+ "cost": estimate_default_cost(skill_name),
104
+ "duration_hours": estimate_default_duration(skill_name),
105
+ "provider": "Estimated",
106
+ "source": "default"
107
+ })
108
+ return result
109
+
110
+
111
+ def estimate_default_cost(skill: str) -> float:
112
+ """Estimate default training cost based on skill complexity."""
113
+ skill_lower = skill.lower()
114
+
115
+ # High-complexity skills
116
+ if any(term in skill_lower for term in ['architect', 'senior', 'lead', 'advanced', 'expert']):
117
+ return 500.0
118
+
119
+ # Medium-complexity technical skills
120
+ elif any(term in skill_lower for term in ['programming', 'development', 'engineering', 'framework', 'platform']):
121
+ return 200.0
122
+
123
+ # Soft skills and basic technical skills
124
+ elif any(term in skill_lower for term in ['communication', 'management', 'leadership', 'teamwork', 'basic']):
125
+ return 100.0
126
+
127
+ # Default for other skills
128
+ else:
129
+ return 150.0
130
+
131
+
132
+ def estimate_default_duration(skill: str) -> int:
133
+ """Estimate default training duration based on skill complexity."""
134
+ skill_lower = skill.lower()
135
+
136
+ # High-complexity skills
137
+ if any(term in skill_lower for term in ['architect', 'senior', 'lead', 'advanced', 'expert']):
138
+ return 40
139
+
140
+ # Medium-complexity technical skills
141
+ elif any(term in skill_lower for term in ['programming', 'development', 'engineering', 'framework', 'platform']):
142
+ return 20
143
+
144
+ # Soft skills and basic technical skills
145
+ elif any(term in skill_lower for term in ['communication', 'management', 'leadership', 'teamwork', 'basic']):
146
+ return 8
147
+
148
+ # Default for other skills
149
+ else:
150
+ return 16
151
+
152
+
153
+ def calculate_total_training_cost(matched_projects: List[Dict[str, Any]]) -> Dict[str, Any]:
154
+ """
155
+ Calculate total training costs across all matched projects.
156
+
157
+ Args:
158
+ matched_projects: List of matched projects with training plans
159
+
160
+ Returns:
161
+ Dictionary with cost statistics
162
+ """
163
+ if not matched_projects:
164
+ return {
165
+ "total_projects": 0,
166
+ "total_cost": 0.0,
167
+ "total_hours": 0,
168
+ "average_cost_per_project": 0.0
169
+ }
170
+
171
+ total_cost = 0.0
172
+ total_hours = 0
173
+
174
+ for project in matched_projects:
175
+ training_plans = project.get('training_plans', [])
176
+ for plan in training_plans:
177
+ total_cost += plan.get('cost', 0)
178
+ total_hours += plan.get('duration_hours', 0)
179
+
180
+ return {
181
+ "total_projects": len(matched_projects),
182
+ "total_cost": total_cost,
183
+ "total_hours": total_hours,
184
+ "average_cost_per_project": total_cost / len(matched_projects) if matched_projects else 0.0
185
+ }
186
+
utils/skill_extractor.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Skill extraction from CV text using LLM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List
6
+
7
+ from LLM.llm_models import cv_analyzer_model # Using CV-specific model (same as orchestrator)
8
+ import json
9
+ import re
10
+
11
+
12
+ def _print_terminal_log(action: str, details: str = ""):
13
+ """Print formatted log to terminal."""
14
+ timestamp = __import__('datetime').datetime.now().strftime("%H:%M:%S")
15
+ if details:
16
+ print(f"[{timestamp}] [CV ANALYZER] {action} :: {details}")
17
+ else:
18
+ print(f"[{timestamp}] [CV ANALYZER] {action}")
19
+
20
+
21
+ def extract_skills_from_cv_text(cv_text: str, log_callback=None) -> Dict[str, Any]:
22
+ """
23
+ Extract skills and relevant information from CV text using LLM.
24
+
25
+ Args:
26
+ cv_text: The extracted text content from a CV
27
+
28
+ Returns:
29
+ Dictionary containing extracted skills and candidate information
30
+ """
31
+
32
+ prompt = f"""Analyze the following CV/Resume text and extract ALL relevant information in a structured format.
33
+
34
+ CV TEXT:
35
+ {cv_text}
36
+
37
+ Please extract and organize the following information:
38
+
39
+ 1. TECHNICAL SKILLS: Programming languages, frameworks, tools, technologies
40
+ 2. SOFT SKILLS: Communication, leadership, teamwork, problem-solving, etc.
41
+ 3. PROFESSIONAL EXPERIENCE: Years of experience, job titles, companies
42
+ 4. EDUCATION: Degrees, certifications, institutions
43
+ 5. DOMAIN EXPERTISE: Industries, specific domains (e.g., Finance, Healthcare, AI/ML)
44
+
45
+ Return your analysis in the following JSON-like structure:
46
+ {{
47
+ "technical_skills": ["skill1", "skill2", ...],
48
+ "soft_skills": ["skill1", "skill2", ...],
49
+ "experience_years": <number or "unknown">,
50
+ "recent_roles": ["role1", "role2", ...],
51
+ "education": ["degree1", "degree2", ...],
52
+ "certifications": ["cert1", "cert2", ...],
53
+ "domain_expertise": ["domain1", "domain2", ...],
54
+ "summary": "A brief 2-3 sentence summary of the candidate's profile"
55
+ }}
56
+
57
+ Be thorough and extract as many relevant skills as possible. If information is not available, use empty arrays or "unknown"."""
58
+
59
+ try:
60
+ _print_terminal_log("Starting AI skill extraction from CV text")
61
+
62
+ if log_callback:
63
+ log_callback("AI Skill Extraction", {"status": "Initializing LLM model..."})
64
+
65
+ # Use the CV analyzer model (same provider as orchestrator - HF/Gemini)
66
+ model = cv_analyzer_model
67
+
68
+ _print_terminal_log("LLM Initialized", f"Model ready, CV length: {len(cv_text)} chars")
69
+
70
+ if log_callback:
71
+ log_callback("AI Analysis", {"status": "Sending CV to AI for analysis", "cv_length": len(cv_text)})
72
+
73
+ messages = [
74
+ {
75
+ "role": "system",
76
+ "content": "You are an expert HR analyst specializing in CV/Resume analysis and skill extraction. Extract information accurately and comprehensively."
77
+ },
78
+ {
79
+ "role": "user",
80
+ "content": prompt
81
+ }
82
+ ]
83
+
84
+ if log_callback:
85
+ log_callback("LLM Request", {"message_count": len(messages), "model": "cv_analyzer_model"})
86
+
87
+ _print_terminal_log("Sending request to AI", "Waiting for skill extraction...")
88
+
89
+ response = model.generate(messages=messages)
90
+
91
+ # Handle ChatMessage object - convert to string
92
+ if hasattr(response, 'content'):
93
+ response_text = response.content
94
+ else:
95
+ response_text = str(response)
96
+
97
+ _print_terminal_log("AI Response Received", f"Response length: {len(response_text) if response_text else 0} chars")
98
+
99
+ if log_callback:
100
+ log_callback("AI Response Received", {"response_length": len(response_text) if response_text else 0})
101
+
102
+ # Extract JSON from response (handle markdown code blocks)
103
+
104
+ if log_callback:
105
+ log_callback("Parsing AI Response", {"status": "Extracting structured data from AI response"})
106
+
107
+ json_match = re.search(r'\{[\s\S]*\}', response_text)
108
+
109
+ if json_match:
110
+ if log_callback:
111
+ log_callback("JSON Extraction", {"status": "Found JSON in response, parsing..."})
112
+
113
+ _print_terminal_log("Parsing JSON response", "Extracting structured skill data...")
114
+
115
+ skills_data = json.loads(json_match.group())
116
+
117
+ tech_count = len(skills_data.get("technical_skills", []))
118
+ soft_count = len(skills_data.get("soft_skills", []))
119
+
120
+ _print_terminal_log("Skills Extracted Successfully",
121
+ f"Technical: {tech_count}, Soft: {soft_count}, Total: {tech_count + soft_count}")
122
+
123
+ if log_callback:
124
+ log_callback("Skills Parsed Successfully", {
125
+ "technical_skills": tech_count,
126
+ "soft_skills": soft_count,
127
+ "total_skills": tech_count + soft_count
128
+ })
129
+ else:
130
+ if log_callback:
131
+ log_callback("JSON Extraction Failed", {"status": "No JSON found, using fallback structure"})
132
+ # Fallback: return a basic structure with the raw response
133
+ skills_data = {
134
+ "technical_skills": [],
135
+ "soft_skills": [],
136
+ "experience_years": "unknown",
137
+ "recent_roles": [],
138
+ "education": [],
139
+ "certifications": [],
140
+ "domain_expertise": [],
141
+ "summary": response_text[:500] # First 500 chars
142
+ }
143
+
144
+ _print_terminal_log("✅ CV Analysis Complete", "All skills successfully extracted and structured")
145
+
146
+ if log_callback:
147
+ log_callback("✅ Extraction Complete", {"status": "CV processing finished successfully"})
148
+
149
+ return skills_data
150
+
151
+ except Exception as e:
152
+ error_msg = str(e)
153
+
154
+ _print_terminal_log(f"❌ ERROR: {type(e).__name__}", error_msg)
155
+
156
+ if log_callback:
157
+ log_callback("❌ AI Extraction Error", {"error": error_msg, "type": type(e).__name__})
158
+
159
+ # Return error information
160
+ return {
161
+ "error": error_msg,
162
+ "technical_skills": [],
163
+ "soft_skills": [],
164
+ "experience_years": "unknown",
165
+ "recent_roles": [],
166
+ "education": [],
167
+ "certifications": [],
168
+ "domain_expertise": [],
169
+ "summary": f"Failed to extract skills: {error_msg}"
170
+ }
171
+
172
+
173
+ def format_skills_for_display(skills_data: Dict[str, Any]) -> str:
174
+ """
175
+ Format extracted skills data into HTML for display in Gradio.
176
+
177
+ Args:
178
+ skills_data: Dictionary containing extracted skills
179
+
180
+ Returns:
181
+ HTML string for display
182
+ """
183
+
184
+ if "error" in skills_data:
185
+ return f"""
186
+ <div style="padding: 20px; background: var(--bg-card); border: 1px solid var(--arc-red); border-radius: 4px;">
187
+ <h3 style="color: var(--arc-red); margin-top: 0;">⚠️ Error Extracting Skills</h3>
188
+ <p style="color: var(--text-dim);">{skills_data.get('summary', 'Unknown error')}</p>
189
+ </div>
190
+ """
191
+
192
+ html_parts = [
193
+ '<div style="padding: 24px; background: var(--bg-card); border: 1px solid var(--border-bright); border-radius: 4px; margin-top: 20px;">',
194
+ '<h2 style="color: var(--arc-orange); margin-top: 0; display: flex; align-items: center; gap: 12px;">',
195
+ '<span style="font-size: 32px;">📄</span> CV ANALYSIS COMPLETE',
196
+ '</h2>',
197
+ ]
198
+
199
+ # Summary
200
+ if skills_data.get("summary"):
201
+ html_parts.append(f'<div style="background: var(--bg-panel); padding: 16px; border-left: 4px solid var(--arc-cyan); margin-bottom: 24px;">')
202
+ html_parts.append(f'<p style="color: var(--text-main); margin: 0; line-height: 1.6;">{skills_data["summary"]}</p>')
203
+ html_parts.append('</div>')
204
+
205
+ # Technical Skills
206
+ if skills_data.get("technical_skills"):
207
+ html_parts.append('<div style="margin-bottom: 20px;">')
208
+ html_parts.append('<h3 style="color: var(--arc-yellow); margin-bottom: 12px;">💻 TECHNICAL SKILLS</h3>')
209
+ html_parts.append('<div style="display: flex; flex-wrap: wrap; gap: 8px;">')
210
+ for skill in skills_data["technical_skills"]:
211
+ html_parts.append(
212
+ f'<span style="background: var(--bg-panel); border: 1px solid var(--border-dim); '
213
+ f'padding: 6px 12px; border-radius: 4px; color: var(--arc-green); font-weight: 600; '
214
+ f'font-size: 13px;">{skill}</span>'
215
+ )
216
+ html_parts.append('</div></div>')
217
+
218
+ # Soft Skills
219
+ if skills_data.get("soft_skills"):
220
+ html_parts.append('<div style="margin-bottom: 20px;">')
221
+ html_parts.append('<h3 style="color: var(--arc-yellow); margin-bottom: 12px;">🤝 SOFT SKILLS</h3>')
222
+ html_parts.append('<div style="display: flex; flex-wrap: wrap; gap: 8px;">')
223
+ for skill in skills_data["soft_skills"]:
224
+ html_parts.append(
225
+ f'<span style="background: var(--bg-panel); border: 1px solid var(--border-dim); '
226
+ f'padding: 6px 12px; border-radius: 4px; color: var(--arc-cyan); font-weight: 600; '
227
+ f'font-size: 13px;">{skill}</span>'
228
+ )
229
+ html_parts.append('</div></div>')
230
+
231
+ # Experience & Roles
232
+ if skills_data.get("experience_years") or skills_data.get("recent_roles"):
233
+ html_parts.append('<div style="margin-bottom: 20px;">')
234
+ html_parts.append('<h3 style="color: var(--arc-yellow); margin-bottom: 12px;">💼 EXPERIENCE</h3>')
235
+ if skills_data.get("experience_years"):
236
+ html_parts.append(f'<p style="color: var(--text-main); margin: 8px 0;"><strong>Years:</strong> {skills_data["experience_years"]}</p>')
237
+ if skills_data.get("recent_roles"):
238
+ html_parts.append('<p style="color: var(--text-main); margin: 8px 0;"><strong>Recent Roles:</strong></p>')
239
+ html_parts.append('<ul style="color: var(--text-dim); margin-top: 4px;">')
240
+ for role in skills_data["recent_roles"]:
241
+ html_parts.append(f'<li>{role}</li>')
242
+ html_parts.append('</ul>')
243
+ html_parts.append('</div>')
244
+
245
+ # Education
246
+ if skills_data.get("education") or skills_data.get("certifications"):
247
+ html_parts.append('<div style="margin-bottom: 20px;">')
248
+ html_parts.append('<h3 style="color: var(--arc-yellow); margin-bottom: 12px;">🎓 EDUCATION & CERTIFICATIONS</h3>')
249
+ if skills_data.get("education"):
250
+ html_parts.append('<p style="color: var(--text-main); margin: 8px 0;"><strong>Education:</strong></p>')
251
+ html_parts.append('<ul style="color: var(--text-dim); margin-top: 4px;">')
252
+ for edu in skills_data["education"]:
253
+ html_parts.append(f'<li>{edu}</li>')
254
+ html_parts.append('</ul>')
255
+ if skills_data.get("certifications"):
256
+ html_parts.append('<p style="color: var(--text-main); margin: 8px 0;"><strong>Certifications:</strong></p>')
257
+ html_parts.append('<ul style="color: var(--text-dim); margin-top: 4px;">')
258
+ for cert in skills_data["certifications"]:
259
+ html_parts.append(f'<li>{cert}</li>')
260
+ html_parts.append('</ul>')
261
+ html_parts.append('</div>')
262
+
263
+ # Domain Expertise
264
+ if skills_data.get("domain_expertise"):
265
+ html_parts.append('<div style="margin-bottom: 20px;">')
266
+ html_parts.append('<h3 style="color: var(--arc-yellow); margin-bottom: 12px;">🎯 DOMAIN EXPERTISE</h3>')
267
+ html_parts.append('<div style="display: flex; flex-wrap: wrap; gap: 8px;">')
268
+ for domain in skills_data["domain_expertise"]:
269
+ html_parts.append(
270
+ f'<span style="background: var(--bg-panel); border: 1px solid var(--border-dim); '
271
+ f'padding: 6px 12px; border-radius: 4px; color: var(--arc-orange); font-weight: 600; '
272
+ f'font-size: 13px;">{domain}</span>'
273
+ )
274
+ html_parts.append('</div></div>')
275
+
276
+ html_parts.append('</div>')
277
+
278
+ return ''.join(html_parts)
279
+
uv.lock CHANGED
@@ -441,7 +441,11 @@ dependencies = [
441
  { name = "elevenlabs" },
442
  { name = "fastmcp" },
443
  { name = "gradio" },
 
444
  { name = "pydantic" },
 
 
 
445
  { name = "repr" },
446
  { name = "smolagents", extra = ["mcp", "openai"] },
447
  { name = "supabase" },
@@ -454,7 +458,11 @@ requires-dist = [
454
  { name = "elevenlabs", specifier = ">=2.24.0" },
455
  { name = "fastmcp", specifier = ">=2.13.1" },
456
  { name = "gradio", specifier = "==6.0.0.dev1" },
 
457
  { name = "pydantic", specifier = ">=2.0.0" },
 
 
 
458
  { name = "repr", specifier = ">=0.3.1" },
459
  { name = "smolagents", extras = ["mcp", "openai"] },
460
  { name = "supabase", specifier = ">=2.0.0" },
@@ -1421,6 +1429,33 @@ wheels = [
1421
  { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" },
1422
  ]
1423
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1424
  [[package]]
1425
  name = "pillow"
1426
  version = "11.3.0"
@@ -1726,6 +1761,35 @@ crypto = [
1726
  { name = "cryptography" },
1727
  ]
1728
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1729
  [[package]]
1730
  name = "pyperclip"
1731
  version = "1.11.0"
@@ -1756,6 +1820,19 @@ wheels = [
1756
  { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
1757
  ]
1758
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1759
  [[package]]
1760
  name = "python-dotenv"
1761
  version = "1.2.1"
 
441
  { name = "elevenlabs" },
442
  { name = "fastmcp" },
443
  { name = "gradio" },
444
+ { name = "pdfplumber" },
445
  { name = "pydantic" },
446
+ { name = "pypdf" },
447
+ { name = "python-docx" },
448
+ { name = "python-multipart" },
449
  { name = "repr" },
450
  { name = "smolagents", extra = ["mcp", "openai"] },
451
  { name = "supabase" },
 
458
  { name = "elevenlabs", specifier = ">=2.24.0" },
459
  { name = "fastmcp", specifier = ">=2.13.1" },
460
  { name = "gradio", specifier = "==6.0.0.dev1" },
461
+ { name = "pdfplumber", specifier = ">=0.11.0" },
462
  { name = "pydantic", specifier = ">=2.0.0" },
463
+ { name = "pypdf", specifier = ">=4.0.0" },
464
+ { name = "python-docx", specifier = ">=1.0.0" },
465
+ { name = "python-multipart", specifier = ">=0.0.6" },
466
  { name = "repr", specifier = ">=0.3.1" },
467
  { name = "smolagents", extras = ["mcp", "openai"] },
468
  { name = "supabase", specifier = ">=2.0.0" },
 
1429
  { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" },
1430
  ]
1431
 
1432
+ [[package]]
1433
+ name = "pdfminer-six"
1434
+ version = "20251107"
1435
+ source = { registry = "https://pypi.org/simple" }
1436
+ dependencies = [
1437
+ { name = "charset-normalizer" },
1438
+ { name = "cryptography" },
1439
+ ]
1440
+ sdist = { url = "https://files.pythonhosted.org/packages/1d/50/5315f381a25dc80a8d2ea7c62d9a28c0137f10ccc263623a0db8b49fcced/pdfminer_six-20251107.tar.gz", hash = "sha256:5fb0c553799c591777f22c0c72b77fc2522d7d10c70654e25f4c5f1fd996e008", size = 7387104, upload-time = "2025-11-07T20:01:10.286Z" }
1441
+ wheels = [
1442
+ { url = "https://files.pythonhosted.org/packages/64/29/d1d9f6b900191288b77613ddefb73ed35b48fb35e44aaf8b01b0422b759d/pdfminer_six-20251107-py3-none-any.whl", hash = "sha256:c09df33e4cbe6b26b2a79248a4ffcccafaa5c5d39c9fff0e6e81567f165b5401", size = 5620299, upload-time = "2025-11-07T20:01:08.722Z" },
1443
+ ]
1444
+
1445
+ [[package]]
1446
+ name = "pdfplumber"
1447
+ version = "0.11.8"
1448
+ source = { registry = "https://pypi.org/simple" }
1449
+ dependencies = [
1450
+ { name = "pdfminer-six" },
1451
+ { name = "pillow" },
1452
+ { name = "pypdfium2" },
1453
+ ]
1454
+ sdist = { url = "https://files.pythonhosted.org/packages/09/d8/cb9fda4261ce389656bec0bb0bdde905df109ad97f7ae387747ded070e8c/pdfplumber-0.11.8.tar.gz", hash = "sha256:db29b04bc8bb62f39dd444533bcf2e0ba33584bd24f5a54644f3ba30f4f22d31", size = 102724, upload-time = "2025-11-08T20:52:01.955Z" }
1455
+ wheels = [
1456
+ { url = "https://files.pythonhosted.org/packages/12/28/3958ed81a9be317610ab73df32f1968076751d651c84dff1bcb45b7c6c0e/pdfplumber-0.11.8-py3-none-any.whl", hash = "sha256:7dda117b8ed21bca9c8e7d7808fee2439f93c8bd6ea45989bfb1aead6dc3cad3", size = 60043, upload-time = "2025-11-08T20:52:00.652Z" },
1457
+ ]
1458
+
1459
  [[package]]
1460
  name = "pillow"
1461
  version = "11.3.0"
 
1761
  { name = "cryptography" },
1762
  ]
1763
 
1764
+ [[package]]
1765
+ name = "pypdf"
1766
+ version = "6.3.0"
1767
+ source = { registry = "https://pypi.org/simple" }
1768
+ sdist = { url = "https://files.pythonhosted.org/packages/42/fd/6b5ff827a5b751a85c1a3a778a0636d1ddbe153fc03954071c431271405c/pypdf-6.3.0.tar.gz", hash = "sha256:d066a2fdf8195e1811ae5a9d5a2f97f5bed0e1e7954297295eadee6357e76c5d", size = 5275038, upload-time = "2025-11-16T14:05:16.37Z" }
1769
+ wheels = [
1770
+ { url = "https://files.pythonhosted.org/packages/d1/26/4ae62da67941784913606da037172d0f14b7ba120442e63a37b257110b2c/pypdf-6.3.0-py3-none-any.whl", hash = "sha256:2d5f9741e851e378908692d571374b3cbd94582fdd1c740fcf7c029ec35ac0e6", size = 328891, upload-time = "2025-11-16T14:05:14.574Z" },
1771
+ ]
1772
+
1773
+ [[package]]
1774
+ name = "pypdfium2"
1775
+ version = "5.0.0"
1776
+ source = { registry = "https://pypi.org/simple" }
1777
+ sdist = { url = "https://files.pythonhosted.org/packages/fc/a1/34ebc27160533f4f11c4f2e36e4d0c3bc6fbef24b63b4582a376bbf26646/pypdfium2-5.0.0.tar.gz", hash = "sha256:666f66e8170f5502feac3b31c5c05a3697989c10e65e1a8503bf8dff8936b125", size = 243319, upload-time = "2025-10-26T13:31:41.987Z" }
1778
+ wheels = [
1779
+ { url = "https://files.pythonhosted.org/packages/13/bf/4259b23a88b92bec8199e1a08a0821dbfbb465629c203bdbc49e2f993940/pypdfium2-5.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c477d68a0f32a22d6477d9aa9c5c2afae6512af1d5455a9ea561a224908f16ae", size = 2813187, upload-time = "2025-10-26T13:31:19.499Z" },
1780
+ { url = "https://files.pythonhosted.org/packages/48/5b/358ae0340300564b7d878cde62a40c01535ff1568393bdd5a8250278cfa9/pypdfium2-5.0.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:753954aeb8e130507cb3b408da68f66a25c4b7e510bdfaf5458975ab8c8285c4", size = 2935797, upload-time = "2025-10-26T13:31:22.112Z" },
1781
+ { url = "https://files.pythonhosted.org/packages/af/74/94a4dc2f6891008111a9666214b5ef53a8390e3a324e957fdd93a8f18957/pypdfium2-5.0.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1e50b08bde1c6c93685022ac72746ff099a7543f178d35e0a834f7e36bf401d", size = 2975686, upload-time = "2025-10-26T13:31:23.896Z" },
1782
+ { url = "https://files.pythonhosted.org/packages/b7/82/ce53918809fdc65d16b054e4d6e4f825b4e6513bcd67cfe89c13061c5ac5/pypdfium2-5.0.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:baf715937b3bc78312c2d07ab2b06684f57156adadc8e849f5892724f892648e", size = 2761052, upload-time = "2025-10-26T13:31:25.739Z" },
1783
+ { url = "https://files.pythonhosted.org/packages/6e/7b/b22dccb7ebd62b20bff1e8c3b06900bd1e529527326ddd9ee3c5157fbc6c/pypdfium2-5.0.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f216423de641187c4e322992f3a97afc5ffa63b72d4ad30f8189cfa783c9d781", size = 3061679, upload-time = "2025-10-26T13:31:27.625Z" },
1784
+ { url = "https://files.pythonhosted.org/packages/01/ed/e0cbbf7430d908108e135bd9fff8195876b3ed7402fbe2893b09e9f53b88/pypdfium2-5.0.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4445d83ae3c6688667feba568b7b390b948c4a06ab94e576ad3b029b5567b44c", size = 2990851, upload-time = "2025-10-26T13:31:29.09Z" },
1785
+ { url = "https://files.pythonhosted.org/packages/48/5c/41595b3051b43d270fa249c7c0dec5cd52aa633ec64e5f9e1526692eef9d/pypdfium2-5.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3b1cbc217a6accfab005806b53e467044f83fe61133df01a4fde94334e4655ac", size = 6320499, upload-time = "2025-10-26T13:31:31.335Z" },
1786
+ { url = "https://files.pythonhosted.org/packages/35/e2/7bfcdfd446fc3b086faca38621dc98dd6feafa9c1b2102a59b3a68862e03/pypdfium2-5.0.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2f050cca56c4d85c24dcb572344cf5e54ebcd0a0dd351fcf6b5117e72474382c", size = 6329280, upload-time = "2025-10-26T13:31:33.421Z" },
1787
+ { url = "https://files.pythonhosted.org/packages/3d/6a/626f358ecd363afd3306bd15e98a040d6b2f4db9482ca7827dcf34677994/pypdfium2-5.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4ee80e08a5c93a8e0f9e26a1978d4e0a31f0122a33351c260a6e436300d95075", size = 6408895, upload-time = "2025-10-26T13:31:35.055Z" },
1788
+ { url = "https://files.pythonhosted.org/packages/cc/87/79b9aa6d7f58959c821fb3d6e679ad288d17773c5ef59c69889bb1d3af53/pypdfium2-5.0.0-py3-none-win32.whl", hash = "sha256:aafb55d57f03c8cf482557ed421d40aed943cd563628a3df8515f301725f8e49", size = 2986180, upload-time = "2025-10-26T13:31:36.633Z" },
1789
+ { url = "https://files.pythonhosted.org/packages/21/46/21de463f575a85dc8973fdf89f7a103d09da553e896161536d7cc73950fd/pypdfium2-5.0.0-py3-none-win_amd64.whl", hash = "sha256:de2201d4e9e423779d2e3b2c2368591d6826153a009146eaa105b501a213b299", size = 3094011, upload-time = "2025-10-26T13:31:38.341Z" },
1790
+ { url = "https://files.pythonhosted.org/packages/ae/43/2b0607ef7f16d63fbe00de728151a090397ef5b3b9147b4aefe975d17106/pypdfium2-5.0.0-py3-none-win_arm64.whl", hash = "sha256:0a2a473fe95802e7a5f4140f25e5cd036cf17f060f27ee2d28c3977206add763", size = 2939015, upload-time = "2025-10-26T13:31:40.531Z" },
1791
+ ]
1792
+
1793
  [[package]]
1794
  name = "pyperclip"
1795
  version = "1.11.0"
 
1820
  { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
1821
  ]
1822
 
1823
+ [[package]]
1824
+ name = "python-docx"
1825
+ version = "1.2.0"
1826
+ source = { registry = "https://pypi.org/simple" }
1827
+ dependencies = [
1828
+ { name = "lxml" },
1829
+ { name = "typing-extensions" },
1830
+ ]
1831
+ sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
1832
+ wheels = [
1833
+ { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
1834
+ ]
1835
+
1836
  [[package]]
1837
  name = "python-dotenv"
1838
  version = "1.2.1"