Spaces:
Running
Running
| from __future__ import annotations | |
| import html | |
| import json | |
| from typing import Any | |
| # ========================================== | |
| # HELPER: ENTERPRISE REPORT RENDERER | |
| # ========================================== | |
| def render_plan_html(result: Any) -> str: | |
| """ | |
| Renders the analysis payload into a High-Contrast Enterprise Grid. | |
| """ | |
| # --- INTERNAL CSS FOR THE REPORT --- | |
| # (CSS remains unchanged to preserve the requested theme) | |
| css = """ | |
| <style> | |
| /* FORCE ROBUST CONTAINER HEIGHT */ | |
| .sec-report-wrapper { | |
| width: 100%; | |
| min-height: 70vh; /* Force container to take up significant screen space */ | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .sec-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); /* Wider cards for better readability */ | |
| gap: 20px; | |
| padding: 10px 0; | |
| flex-grow: 1; /* Allow grid to expand */ | |
| } | |
| /* CARD STYLING */ | |
| .sec-card { | |
| background-color: var(--bg-card); | |
| border: 1px solid var(--border-dim); | |
| border-left: 4px solid var(--arc-orange); /* Default tactical color */ | |
| display: flex; | |
| flex-direction: column; | |
| position: relative; | |
| transition: border-color 0.2s; | |
| } | |
| .sec-card:hover { | |
| border-color: var(--arc-yellow); | |
| box-shadow: 0 4px 24px rgba(0,0,0,0.4); | |
| } | |
| /* HEADER */ | |
| .sec-card-header { | |
| background: rgba(255,255,255,0.03); | |
| padding: 16px 20px; | |
| border-bottom: 1px solid var(--border-dim); | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .sec-role-badge { | |
| font-size: 12px; | |
| font-weight: 700; | |
| color: var(--text-main); | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| opacity: 0.9; | |
| } | |
| /* BODY */ | |
| .sec-card-body { | |
| padding: 20px; | |
| flex-grow: 1; | |
| } | |
| .sec-name { | |
| font-family: var(--font-header); | |
| font-size: 20px; /* Larger Name */ | |
| font-weight: 700; | |
| color: var(--text-main); | |
| margin-bottom: 4px; | |
| letter-spacing: 0.5px; | |
| } | |
| .sec-id-tag { | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| color: var(--arc-orange); | |
| margin-bottom: 20px; | |
| opacity: 1.0; | |
| } | |
| /* SKILL GAPS (Binary State) */ | |
| .sec-skill-container { | |
| background: rgba(255, 42, 42, 0.08); /* Red tint */ | |
| border: 1px dashed var(--arc-red); | |
| padding: 12px 16px; | |
| margin-bottom: 20px; | |
| border-radius: 4px; | |
| } | |
| .sec-skill-label { | |
| font-size: 12px; | |
| color: var(--arc-red); | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| margin-bottom: 10px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .sec-skill-tags { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| } | |
| .sec-skill-tag { | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| background: var(--bg-void); | |
| border: 1px solid var(--arc-red); | |
| color: var(--text-main); | |
| padding: 6px 10px; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .sec-skill-icon { | |
| width: 8px; height: 8px; background: var(--arc-red); border-radius: 50%; | |
| } | |
| /* TRAINING PLAN */ | |
| .sec-plan-section { | |
| border-top: 1px solid var(--border-dim); | |
| padding-top: 16px; | |
| } | |
| .sec-section-title { | |
| font-size: 12px; | |
| color: var(--arc-yellow); | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| margin-bottom: 12px; | |
| font-weight: 700; | |
| } | |
| .sec-item { | |
| display: flex; | |
| background: var(--bg-panel); | |
| border-left: 3px solid var(--text-dim); | |
| margin-bottom: 10px; | |
| padding: 12px; | |
| align-items: flex-start; | |
| } | |
| /* Cost Indicators */ | |
| .inv-low { border-left-color: var(--arc-green); } | |
| .inv-med { border-left-color: var(--arc-yellow); } | |
| .inv-high { border-left-color: var(--arc-red); } | |
| .sec-item-details { flex-grow: 1; } | |
| .sec-item-title { font-size: 14px; color: #fff; font-weight: 600; margin-bottom: 4px; line-height: 1.4;} | |
| .sec-item-meta { font-size: 12px; color: var(--text-main); font-family: var(--font-mono); opacity: 0.9; } | |
| /* STATS BAR */ | |
| .sec-stats-bar { | |
| display: flex; | |
| gap: 20px; | |
| background: rgba(255,255,255,0.05); | |
| border: 1px solid var(--border-dim); | |
| padding: 12px 20px; | |
| margin-bottom: 20px; | |
| align-items: center; | |
| } | |
| .sec-stat-group { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .sec-stat-label { | |
| font-size: 11px; | |
| color: var(--text-main); | |
| font-weight: 600; | |
| letter-spacing: 0.5px; | |
| text-transform: uppercase; | |
| opacity: 0.9; | |
| } | |
| .sec-stat-value { | |
| font-size: 18px; | |
| color: var(--text-main); | |
| font-weight: 700; | |
| font-family: var(--font-mono); | |
| } | |
| .sec-divider { | |
| width: 1px; | |
| height: 24px; | |
| background: var(--border-dim); | |
| } | |
| /* LOADING / ERROR STATES */ | |
| .sec-status-offline { | |
| color: var(--text-dim); | |
| font-family: var(--font-mono); | |
| font-size: 14px; | |
| padding: 20px; | |
| min-height: 70vh; /* Use VH to ensure it fills screen vertical space */ | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: rgba(255,255,255,0.01); | |
| border: 1px dashed var(--border-dim); | |
| flex-grow: 1; | |
| } | |
| .sec-terminal-text { | |
| color: var(--arc-red); | |
| font-family: var(--font-mono); | |
| font-size: 14px; | |
| padding: 20px; | |
| min-height: 70vh; /* Consistent height for error states */ | |
| } | |
| </style> | |
| """ | |
| if result is None: | |
| return f"{css}<div class='sec-report-wrapper'><div class='sec-status-offline'>// WAITING FOR ANALYSIS DATA</div></div>" | |
| # Ensure data is dict; handle string inputs gracefully | |
| data = result | |
| if not isinstance(data, dict): | |
| try: | |
| data = json.loads(str(result)) | |
| except (json.JSONDecodeError, TypeError): | |
| # Fallback for non-JSON strings | |
| return f"{css}<div class='sec-report-wrapper'><div class='sec-terminal-text'>{html.escape(str(result))}</div></div>" | |
| project_name = html.escape(str(data.get("project_name", "UNDEFINED PROJECT")).upper()) | |
| team = data.get("team", []) | |
| # --- CALCULATE AGGREGATE STATS --- | |
| total_cost = 0.0 | |
| max_duration = 0.0 | |
| for member in team: | |
| member_cost = 0.0 | |
| member_duration = 0.0 | |
| for plan in member.get("training_plan", []): | |
| try: | |
| member_cost += float(plan.get("cost", 0)) | |
| member_duration += float(plan.get("duration_hours", 0)) | |
| except (ValueError, TypeError): | |
| continue | |
| total_cost += member_cost | |
| # Parallel training assumption: Project duration is limited by the person with the longest plan | |
| if member_duration > max_duration: | |
| max_duration = member_duration | |
| # --- BUILD HTML --- | |
| html_parts = [css, "<div class='sec-report-wrapper'>"] | |
| # --- MAIN HEADER --- | |
| html_parts.append(f""" | |
| <div style="margin-bottom: 16px; padding: 0 4px; display:flex; flex-wrap: wrap; justify-content:space-between; align-items:flex-end; border-bottom: 1px solid var(--border-dim); padding-bottom: 12px; gap: 10px;"> | |
| <div> | |
| <h2 style="margin:0; color:white; font-size:24px; letter-spacing: 0.5px;">CAPABILITY ANALYSIS REPORT</h2> | |
| <div style="color:var(--arc-yellow); font-family:var(--font-mono); font-size: 14px; margin-top:6px;">PROJECT: {project_name}</div> | |
| </div> | |
| <div style="font-family: var(--font-mono); font-size: 12px; color: var(--text-main); text-align: right;"> | |
| <div>STATUS: <span style="color:var(--arc-green)">ACTIVE</span></div> | |
| <div>HEADCOUNT: {len(team)}</div> | |
| </div> | |
| </div> | |
| """) | |
| # --- OVERALL STATS BAR --- | |
| html_parts.append(f""" | |
| <div class="sec-stats-bar"> | |
| <div class="sec-stat-group"> | |
| <div class="sec-stat-label">Total Budget</div> | |
| <div class="sec-stat-value" style="color: var(--arc-cyan);">${total_cost:,.2f}</div> | |
| </div> | |
| <div class="sec-divider"></div> | |
| <div class="sec-stat-group"> | |
| <div class="sec-stat-label">Est. Timeline</div> | |
| <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> | |
| </div> | |
| <div class="sec-divider"></div> | |
| <div class="sec-stat-group"> | |
| <div class="sec-stat-label">Team Size</div> | |
| <div class="sec-stat-value">{len(team)}</div> | |
| </div> | |
| </div> | |
| """) | |
| html_parts.append("<div class='sec-grid'>") | |
| for member in team: | |
| name = html.escape(str(member.get("employee_name", "UNKNOWN")).upper()) | |
| role = html.escape(str(member.get("role", "UNASSIGNED")).upper()) | |
| # --- SKILL GAPS --- | |
| skills_html = "" | |
| gaps_list = member.get("skills_gaps", []) | |
| if gaps_list: | |
| skill_tags = "" | |
| for gap_data in gaps_list: | |
| skill_name = html.escape(gap_data.get("skill", "GENERIC")) | |
| skill_tags += f""" | |
| <div class="sec-skill-tag"> | |
| <div class="sec-skill-icon"></div> | |
| {skill_name} | |
| </div> | |
| """ | |
| skills_html = f""" | |
| <div class="sec-skill-container"> | |
| <div class="sec-skill-label">⚠ SKILL GAP IDENTIFIED</div> | |
| <div class="sec-skill-tags">{skill_tags}</div> | |
| </div> | |
| """ | |
| else: | |
| skills_html = """ | |
| <div style="padding: 12px; border: 1px solid var(--arc-green); color: var(--arc-green); font-size: 12px; display:flex; align-items:center; gap:8px; margin-bottom:20px; border-radius:4px;"> | |
| <span>✔</span> SKILLS VERIFIED. NO GAPS. | |
| </div> | |
| """ | |
| # --- TRAINING PLAN --- | |
| training_html = "" | |
| for plan in member.get("training_plan", []): | |
| title = html.escape(plan.get("title", "Training Module")) | |
| cost = float(plan.get("cost", 0)) | |
| hours = float(plan.get("duration_hours", 0)) | |
| # Investment Level Logic | |
| inv_class = "inv-low" | |
| if cost > 50: inv_class = "inv-med" | |
| if cost > 200: inv_class = "inv-high" | |
| training_html += f""" | |
| <div class="sec-item {inv_class}"> | |
| <div class="sec-item-details"> | |
| <div class="sec-item-title">{title}</div> | |
| <div class="sec-item-meta">COST: ${cost} | DURATION: {hours} HRS</div> | |
| </div> | |
| </div> | |
| """ | |
| if not training_html and gaps_list: | |
| training_html = "<div style='font-size:12px; color:var(--arc-red); padding: 4px 0;'>PENDING: CURRICULUM GENERATION REQUIRED</div>" | |
| elif not training_html: | |
| training_html = "<div style='font-size:12px; color:var(--text-main); opacity:0.9; padding: 4px 0;'>NO ACTION REQUIRED</div>" | |
| # Assemble Card | |
| # Create a faux Employee ID | |
| emp_hash = f"EMP-{hash(name) % 99999:05d}" | |
| card_html = f""" | |
| <div class="sec-card"> | |
| <div class="sec-card-header"> | |
| <div style="display:flex; align-items:center; gap:8px;"> | |
| <div style="width:8px; height:8px; background:var(--arc-orange);"></div> | |
| <div class="sec-role-badge">{role}</div> | |
| </div> | |
| </div> | |
| <div class="sec-card-body"> | |
| <div class="sec-name">{name}</div> | |
| <div class="sec-id-tag">{emp_hash}</div> | |
| {skills_html} | |
| <div class="sec-plan-section"> | |
| <div class="sec-section-title">RECOMMENDED TRAINING</div> | |
| {training_html} | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| html_parts.append(card_html) | |
| html_parts.append("</div>") | |
| html_parts.append("</div>") # Close sec-report-wrapper | |
| return "".join(html_parts) |