EastSync-AI / UI /render_cv_matching.py
Daniel Tatar
Cv reader + matching project (#13)
07273d8
"""Render CV project matching results in 3-panel tactical layout."""
from __future__ import annotations
import html
from typing import Any, Dict, List
def render_cv_matching_html(
cv_skills_data: Dict[str, Any],
matched_projects: List[Dict[str, Any]],
filename: str = "Unknown"
) -> str:
"""
Renders CV matching results in a 3-panel tactical layout.
Panel 1: CV Analysis
Panel 2: Project Matches (ranked)
Panel 3: Training Costs per Project
Args:
cv_skills_data: Extracted CV skills
matched_projects: Projects with match data and training plans
filename: CV filename
Returns:
HTML string for display
"""
# CSS for 3-panel layout
css = """
<style>
.cv-matching-wrapper {
width: 100%;
min-height: 70vh;
display: flex;
flex-direction: column;
}
.cv-matching-header {
margin-bottom: 24px;
padding: 0 4px;
border-bottom: 1px solid var(--border-dim);
padding-bottom: 16px;
}
.cv-matching-panels {
display: grid;
grid-template-columns: 1fr 1.5fr 1fr;
gap: 20px;
min-height: 60vh;
}
@media (max-width: 1400px) {
.cv-matching-panels {
grid-template-columns: 1fr;
}
}
.cv-panel {
background: var(--bg-card);
border: 1px solid var(--border-dim);
padding: 20px;
display: flex;
flex-direction: column;
max-height: 80vh;
overflow-y: auto;
}
.cv-panel::-webkit-scrollbar { width: 8px; }
.cv-panel::-webkit-scrollbar-track { background: var(--bg-void); }
.cv-panel::-webkit-scrollbar-thumb { background: var(--border-dim); border-radius: 4px; }
.panel-header {
font-size: 14px;
color: var(--arc-yellow);
text-transform: uppercase;
letter-spacing: 1.5px;
margin-bottom: 20px;
font-weight: 700;
padding-bottom: 12px;
border-bottom: 2px solid var(--arc-yellow);
display: flex;
align-items: center;
gap: 8px;
}
.cv-summary-compact {
background: rgba(255, 127, 0, 0.08);
border: 1px solid var(--arc-orange);
border-radius: 4px;
padding: 12px;
margin-bottom: 16px;
font-size: 13px;
color: var(--text-main);
line-height: 1.5;
opacity: 0.95;
}
.skill-badge-small {
display: inline-block;
background: var(--bg-panel);
border: 1px solid var(--border-dim);
padding: 4px 8px;
margin: 2px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
font-family: var(--font-mono);
}
.skill-badge-small.technical { color: var(--arc-green); border-color: var(--arc-green); }
.skill-badge-small.soft { color: var(--arc-cyan); border-color: var(--arc-cyan); }
.project-match-card {
background: var(--bg-panel);
border: 1px solid var(--border-dim);
border-left: 4px solid var(--arc-orange);
padding: 16px;
margin-bottom: 12px;
transition: all 0.2s;
cursor: pointer;
}
.project-match-card:hover {
border-left-color: var(--arc-yellow);
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.project-match-card.perfect { border-left-color: var(--arc-green); }
.project-match-card.good { border-left-color: var(--arc-cyan); }
.project-match-card.partial { border-left-color: var(--arc-yellow); }
.project-match-card.low { border-left-color: var(--arc-red); }
.match-percentage {
font-size: 32px;
font-weight: 800;
font-family: var(--font-mono);
color: var(--text-main);
margin-bottom: 8px;
}
.match-percentage.perfect { color: var(--arc-green); }
.match-percentage.good { color: var(--arc-cyan); }
.match-percentage.partial { color: var(--arc-yellow); }
.match-percentage.low { color: var(--arc-red); }
.project-name {
font-size: 16px;
font-weight: 700;
color: var(--text-main);
margin-bottom: 8px;
}
.project-meta {
font-size: 11px;
color: var(--text-main);
font-family: var(--font-mono);
margin-bottom: 12px;
opacity: 0.9;
}
.skill-match-bar {
display: flex;
gap: 8px;
align-items: center;
font-size: 12px;
color: var(--text-main);
}
.training-cost-card {
background: var(--bg-panel);
border: 1px solid var(--border-dim);
padding: 12px;
margin-bottom: 10px;
}
.training-cost-card.low { border-left: 3px solid var(--arc-green); }
.training-cost-card.medium { border-left: 3px solid var(--arc-yellow); }
.training-cost-card.high { border-left: 3px solid var(--arc-red); }
.cost-project-name {
font-size: 13px;
font-weight: 700;
color: var(--arc-orange);
margin-bottom: 8px;
}
.cost-summary {
font-size: 12px;
color: var(--text-main);
margin-bottom: 10px;
opacity: 0.9;
}
.cost-value {
font-size: 20px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--text-main);
}
.cost-breakdown {
font-size: 11px;
color: var(--text-main);
font-family: var(--font-mono);
opacity: 0.9;
}
.stat-inline {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255,255,255,0.05);
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
margin: 2px;
}
.no-data-message {
text-align: center;
padding: 40px 20px;
color: var(--text-main);
font-family: var(--font-mono);
font-size: 13px;
opacity: 0.9;
}
</style>
"""
# Extract data
tech_skills = cv_skills_data.get("technical_skills", [])
soft_skills = cv_skills_data.get("soft_skills", [])
experience_years = cv_skills_data.get("experience_years", "unknown")
summary = cv_skills_data.get("summary", "")
total_skills = len(tech_skills) + len(soft_skills)
# Build HTML
html_parts = [css, '<div class="cv-matching-wrapper">']
# Header
safe_filename = html.escape(filename)
html_parts.append(f"""
<div class="cv-matching-header">
<div style="display: flex; justify-content: space-between; align-items: flex-end; flex-wrap: wrap; gap: 16px;">
<div>
<h2 style="margin:0; color:white; font-size:24px; letter-spacing: 0.5px;">CV + PROJECT MATCHING REPORT</h2>
<div style="color:var(--arc-yellow); font-family:var(--font-mono); font-size: 14px; margin-top:6px;">SOURCE: {safe_filename}</div>
</div>
<div style="font-family: var(--font-mono); font-size: 12px; color: var(--text-main); text-align: right;">
<div>PROJECTS ANALYZED: <span style="color:var(--arc-cyan); font-weight:700;">{len(matched_projects)}</span></div>
<div>CANDIDATE SKILLS: <span style="color:var(--arc-green); font-weight:700;">{total_skills}</span></div>
</div>
</div>
</div>
""")
# 3-Panel Grid
html_parts.append('<div class="cv-matching-panels">')
# ============== PANEL 1: CV ANALYSIS ==============
html_parts.append('<div class="cv-panel">')
html_parts.append('<div class="panel-header">πŸ“„ CANDIDATE PROFILE</div>')
# Summary
if summary:
safe_summary = html.escape(summary)
html_parts.append(f'<div class="cv-summary-compact">{safe_summary}</div>')
# Stats
html_parts.append(f"""
<div style="margin-bottom: 20px;">
<div class="stat-inline"><strong>Experience:</strong> {html.escape(str(experience_years))}</div>
<div class="stat-inline"><strong>Tech Skills:</strong> {len(tech_skills)}</div>
<div class="stat-inline"><strong>Soft Skills:</strong> {len(soft_skills)}</div>
</div>
""")
# Skills
if tech_skills:
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>')
for skill in tech_skills[:15]: # Limit display
html_parts.append(f'<span class="skill-badge-small technical">{html.escape(skill)}</span>')
if len(tech_skills) > 15:
html_parts.append(f'<span class="skill-badge-small technical">+{len(tech_skills) - 15} more</span>')
html_parts.append('</div>')
if soft_skills:
html_parts.append('<div><div style="font-size:12px; color:var(--arc-cyan); margin-bottom:8px; font-weight:600;">🀝 SOFT SKILLS</div>')
for skill in soft_skills[:10]:
html_parts.append(f'<span class="skill-badge-small soft">{html.escape(skill)}</span>')
if len(soft_skills) > 10:
html_parts.append(f'<span class="skill-badge-small soft">+{len(soft_skills) - 10} more</span>')
html_parts.append('</div>')
html_parts.append('</div>') # Close Panel 1
# ============== PANEL 2: PROJECT MATCHES ==============
html_parts.append('<div class="cv-panel">')
html_parts.append('<div class="panel-header">🎯 PROJECT MATCHES (RANKED)</div>')
if matched_projects:
for project in matched_projects:
match_pct = project['match_percentage']
# Determine match level
if match_pct >= 90:
match_class = "perfect"
elif match_pct >= 70:
match_class = "good"
elif match_pct >= 50:
match_class = "partial"
else:
match_class = "low"
project_name = html.escape(project['project_name'])
matched_count = project['matched_skills_count']
required_count = project['required_skills_count']
missing_count = project['missing_skills_count']
status = html.escape(project.get('status', 'Unknown'))
# Convert budget to int/float for formatting
budget = project.get("budget", 0)
try:
budget_num = float(budget) if budget else 0
except (ValueError, TypeError):
budget_num = 0
html_parts.append(f'<div class="project-match-card {match_class}">')
html_parts.append(f'<div class="match-percentage {match_class}">{match_pct}%</div>')
html_parts.append(f'<div class="project-name">{project_name}</div>')
html_parts.append(f'<div class="project-meta">STATUS: {status} | BUDGET: ${budget_num:,.0f}</div>')
html_parts.append(f'<div class="skill-match-bar">')
html_parts.append(f'<span style="color:var(--arc-green);">βœ“ {matched_count} Match</span>')
html_parts.append(f'<span style="color:var(--text-main); opacity:0.6;">|</span>')
html_parts.append(f'<span style="color:var(--arc-red);">βœ— {missing_count} Gap</span>')
html_parts.append(f'<span style="color:var(--text-main); opacity:0.6;">|</span>')
html_parts.append(f'<span style="color:var(--text-main);">{required_count} Required</span>')
html_parts.append('</div></div>')
else:
html_parts.append('<div class="no-data-message">No projects found for matching.</div>')
html_parts.append('</div>') # Close Panel 2
# ============== PANEL 3: SKILL GAPS SUMMARY ==============
html_parts.append('<div class="cv-panel">')
html_parts.append('<div class="panel-header">⚠️ SKILL GAPS</div>')
if matched_projects:
# Show missing skills for each project
has_gaps = False
for project in matched_projects:
if project['missing_skills_count'] == 0:
continue # Skip projects with 100% match
has_gaps = True
project_name = html.escape(project['project_name'])
missing_skills = project.get('missing_skills', [])
html_parts.append('<div class="training-cost-card">')
html_parts.append(f'<div class="cost-project-name">{project_name}</div>')
html_parts.append(f'<div class="cost-summary">{project["missing_skills_count"]} skill gap(s)</div>')
# List missing skills
html_parts.append('<div style="margin-top: 10px;">')
for skill in missing_skills[:5]: # Show max 5
skill_name = html.escape(skill)
html_parts.append(f'<div style="font-size:12px; color:var(--arc-red); padding:4px 0;">β€’ {skill_name}</div>')
if len(missing_skills) > 5:
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>')
html_parts.append('</div>')
html_parts.append('</div>')
if not has_gaps:
html_parts.append('<div class="no-data-message">βœ… Candidate is fully qualified for all projects!<br>No skill gaps detected.</div>')
else:
html_parts.append('<div class="no-data-message">No data available.</div>')
html_parts.append('</div>') # Close Panel 3
html_parts.append('</div>') # Close panels grid
html_parts.append('</div>') # Close wrapper
return ''.join(html_parts)