Spaces:
Running
Running
Commit
·
07273d8
1
Parent(s):
c398d3e
Cv reader + matching project (#13)
Browse filesCo-authored-by: Stanislav Sava <[email protected]>
Co-authored-by: Dragos Dit <[email protected]>
- LLM/llm_models.py +3 -0
- UI/app_interface.py +433 -13
- UI/cv_interface.py +263 -0
- UI/render_cv_html.py +384 -0
- UI/render_cv_matching.py +387 -0
- UI/render_plan_html.py +9 -7
- agents/orchestrator_agent/get_agent_name_for_logs.py +1 -1
- agents/supabase_agent/supabase_agent.py +4 -2
- app.py +23 -17
- pyproject.toml +4 -0
- utils/cv_parser.py +134 -0
- utils/cv_project_matcher.py +350 -0
- utils/cv_training_cost.py +186 -0
- utils/skill_extractor.py +279 -0
- uv.lock +77 -0
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-
|
| 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
|
| 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-
|
| 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 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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
|
| 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:
|
| 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-
|
| 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-
|
| 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-
|
| 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-
|
| 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': '
|
| 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 |
-
"
|
| 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 |
-
|
| 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 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|