EastSync-AI / UI /app_interface.py
StanSava's picture
Add voice narrator (#16)
6317d4b
from __future__ import annotations
import html
from collections import deque
from threading import Lock
from typing import Any, Callable, Deque, Dict, Optional, Tuple
from queue import Queue, LifoQueue
import numpy as np
import os
import gradio as gr # type: ignore
from third_party_tools.text_to_audio_file import text_to_audio_file
from .cv_interface import CVInterface
from .render_plan_html import render_plan_html
should_narrate_events = os.getenv("SHOULD_NARRATE_EVENTS", "False").lower() == "true"
new_events_check_interval_seconds = 3
class EastSyncInterface:
"""
EASTSYNC ENTERPRISE INTERFACE
Aesthetic: High Contrast / Dark Mode / Professional Analytics.
"""
SAMPLE_PROMPT = (
"PROJECT: Data Analytics Dashboard\n"
"SCOPE: Develop a real-time visualization layer for regional sales data.\n"
"TEAM: Data Science Team Alpha (2 Juniors, 1 Senior).\n"
"OBJECTIVE: Analyze current team capabilities and generate a training roadmap to close skill gaps."
)
def __init__(self):
self._action_log: Deque[str] = deque(maxlen=200)
self._action_log_lock = Lock()
# Queues for live audio narration
self.audio_queue: Queue[Tuple[int, np.ndarray]] = Queue()
self.event_queue: Queue[str] = LifoQueue()
self.init_message = (
'<div class="console-line">>> SYSTEM INITIALIZED. WAITING FOR PROJECT INPUT...</div>'
)
self._app_css = self._compose_css()
self._cv_interface = CVInterface(self)
self._analysis_result: Optional[Any] = None # Store analysis result for async updates
self._analysis_error: Optional[str] = None # Store analysis error if any
self._analysis_running: bool = False # Track if analysis is currently running
self._cached_processing_state: Optional[str] = None # Cache processing state HTML
# Dynamic processing steps tracking
self._processing_steps: list[str] = [] # Current processing steps
self._processing_steps_lock = Lock()
self._processing_mode: Optional[str] = None # "project", "extract", or "match"
# ---------------------- HELPER METHODS ----------------------
def start_processing(self, mode: str):
"""Start processing mode and reset steps. Mode: 'project', 'extract', or 'match'."""
with self._processing_steps_lock:
self._processing_steps = []
self._processing_mode = mode
self._analysis_running = True
self._analysis_result = None
self._analysis_error = None
self._cached_processing_state = None
def stop_processing(self):
"""Stop processing mode and clear steps."""
with self._processing_steps_lock:
self._processing_mode = None
self._analysis_running = False
def add_processing_step(self, step: str):
"""Add a new processing step to the dynamic list."""
with self._processing_steps_lock:
# Avoid duplicates
if step not in self._processing_steps:
self._processing_steps.append(step)
# Invalidate cache so next render picks up new steps
self._cached_processing_state = None
def get_processing_steps(self) -> list[str]:
"""Get current processing steps."""
with self._processing_steps_lock:
return list(self._processing_steps)
def register_agent_action(self, action: str, args: Optional[Dict[str, Any]] = None):
import datetime
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
with self._action_log_lock:
# Keep the high-contrast aesthetic but clean up the formatting
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>'
if args:
args_str = str(args)
if len(args_str) > 80:
args_str = args_str[:80] + "..."
msg += f' <span style="color:var(--text-main); opacity:0.7;">:: {html.escape(args_str)}</span>'
self._action_log.appendleft(f'<div class="console-line">{msg}</div>')
# Add to processing steps if we're in processing mode (check INSIDE lock to avoid race condition)
with self._processing_steps_lock:
if self._processing_mode is not None:
action_str = str(action)
if action_str not in self._processing_steps:
self._processing_steps.append(action_str)
# Push to event queue for narrator
self.event_queue.put_nowait(f"{action_str} {args if args else ''}")
self._cached_processing_state = None
def get_action_log_text(self) -> str:
with self._action_log_lock:
body = "".join(self._action_log) if self._action_log else self.init_message
return f'<div class="console-wrapper">{body}</div>'
def clear_action_log(self) -> str:
with self._action_log_lock:
self._action_log.clear()
return self.get_action_log_text()
def render_analysis_result(self, result: Any) -> str:
"""Render the analysis result as HTML only. Audio is handled by narrator."""
html_out = render_plan_html(result)
return html_out
def set_analysis_result(self, result: Any):
"""Store analysis result for async display."""
self._analysis_result = result
self._analysis_error = None
def set_analysis_error(self, error: str):
"""Store analysis error for async display."""
self._analysis_error = error
self._analysis_result = None
def get_analysis_output(self) -> Optional[str]:
"""Get the current analysis output (result, error, or processing state).
Returns None if no update is needed."""
# Check result/error first (these are set by the agent thread)
if self._analysis_result is not None:
self.stop_processing() # Mark as complete
return self.render_analysis_result(self._analysis_result)
elif self._analysis_error is not None:
self.stop_processing() # Mark as complete
return self.render_error_state(self._analysis_error)
# Check processing mode inside the lock for thread safety
with self._processing_steps_lock:
mode = self._processing_mode
is_running = self._analysis_running
# If not running and no result/error, don't update
if not is_running:
return None
# Render dynamic processing state based on mode
if mode == "project":
return self.render_project_processing_state()
elif mode in ("extract", "match"):
return self.render_processing_state(mode)
elif mode is not None:
return self.render_project_processing_state() # fallback
else:
return None # No processing mode set
summary_text = result.get('corny_summary', '')
audio_path = text_to_audio_file(summary_text)
is_audio = audio_path is not None
audio_out = gr.update(value=audio_path, visible=is_audio)
html_out = render_plan_html(result)
return html_out, audio_out
def render_idle_state(self) -> str:
# The style is handled inside render_plan_html.py CSS for consistency.
return "<div class='sec-status-offline'>// ENTER PROJECT DETAILS TO GENERATE ROADMAP</div>"
def render_error_state(self, reason: str) -> str:
safe_reason = html.escape(reason)
return f"""
<div class='error-container'>
<div class='error-header'>
<span>⚠️</span> PROCESSING ERROR
</div>
<div class='error-body'>
{safe_reason}
</div>
</div>
""", None
def reset_prompt_value(self) -> str:
return self.SAMPLE_PROMPT
def render_project_processing_state(self) -> str:
"""Render animated processing state for project analysis with dynamic steps."""
# Get current processing steps (dynamic)
current_steps = self.get_processing_steps()
# Build steps HTML - show only actual steps that have been added
if current_steps:
steps_html = ""
# Current step (in progress) - with animation - ON TOP
current_step = current_steps[-1]
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>'
# Previous steps (completed) - WHITE text - REVERSED (Newest first)
steps_html += "".join([
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>'
for step in reversed(current_steps[:-1])
])
else:
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>'
step_count = len(current_steps) if current_steps else 0
return f"""
<style>
@keyframes pulse {{
0%, 100% {{ opacity: 1; }}
50% {{ opacity: 0.5; }}
}}
@keyframes spin {{
from {{ transform: rotate(0deg); }}
to {{ transform: rotate(360deg); }}
}}
@keyframes progress {{
0% {{ width: 0%; }}
100% {{ width: 100%; }}
}}
</style>
<div style="display: flex; align-items: center; justify-content: center; min-height: 70vh; padding: 40px;">
<div style="max-width: 800px; width: 100%;">
<div style="background: var(--bg-panel); border: 2px solid var(--arc-orange); border-radius: 4px; padding: 48px; text-align: center;">
<!-- Animated Icon -->
<div style="margin-bottom: 32px;">
<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>
</div>
<!-- Title -->
<h2 style="color: var(--arc-orange); margin: 0 0 16px 0; font-size: 24px; font-weight: 700; animation: pulse 2s ease-in-out infinite;">
🚀 INITIATING ANALYSIS
</h2>
<!-- Subtitle -->
<p style="color: var(--text-main); font-size: 15px; margin-bottom: 32px; opacity: 0.9;">
Analyzing project requirements and generating deployment plan... Monitor system logs for real-time updates.
</p>
<!-- Progress Bar -->
<div style="width: 100%; height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; margin-bottom: 32px;">
<div style="height: 100%; background: linear-gradient(90deg, var(--arc-red), var(--arc-orange), var(--arc-yellow), var(--arc-green)); animation: progress {new_events_check_interval_seconds}s ease-in-out infinite;"></div>
</div>
<!-- Processing Steps (DYNAMIC) -->
<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;">
<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;">
<span>ANALYSIS PIPELINE:</span>
<span style="color: var(--arc-cyan);">{step_count} step{"s" if step_count != 1 else ""}</span>
</div>
<div style="max-height: 300px; overflow-y: auto;">
{steps_html}
</div>
</div>
<!-- Info Note -->
<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;">
<div style="color: var(--arc-orange); font-size: 13px;">
⏱️ <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>
</div>
</div>
</div>
</div>
</div>
""", None
def render_processing_state(self, mode: str = "extract") -> str:
"""Render animated processing state for CV analysis with dynamic steps."""
title = "📊 EXTRACTING SKILLS" if mode == "extract" else "🎯 ANALYZING CV + MATCHING PROJECTS"
# Get current processing steps (dynamic)
current_steps = self.get_processing_steps()
# Build steps HTML - show only actual steps that have been added
if current_steps:
steps_html = ""
# Current step (in progress) - with animation - ON TOP
current_step = current_steps[-1]
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>'
# Previous steps (completed) - WHITE text - REVERSED (Newest first)
steps_html += "".join([
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>'
for step in reversed(current_steps[:-1])
])
else:
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>'
step_count = len(current_steps) if current_steps else 0
return f"""
<style>
@keyframes pulse {{
0%, 100% {{ opacity: 1; }}
50% {{ opacity: 0.5; }}
}}
@keyframes spin {{
from {{ transform: rotate(0deg); }}
to {{ transform: rotate(360deg); }}
}}
@keyframes progress {{
0% {{ width: 0%; }}
100% {{ width: 100%; }}
}}
</style>
<div style="display: flex; align-items: center; justify-content: center; min-height: 70vh; padding: 40px;">
<div style="max-width: 800px; width: 100%;">
<div style="background: var(--bg-panel); border: 2px solid var(--arc-orange); border-radius: 4px; padding: 48px; text-align: center;">
<!-- Animated Icon -->
<div style="margin-bottom: 32px;">
<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>
</div>
<!-- Title -->
<h2 style="color: var(--arc-orange); margin: 0 0 16px 0; font-size: 24px; font-weight: 700; animation: pulse 2s ease-in-out infinite;">
{title}
</h2>
<!-- Subtitle -->
<p style="color: var(--text-main); font-size: 15px; margin-bottom: 32px; opacity: 0.9;">
Processing document... Please monitor the system logs for real-time updates.
</p>
<!-- Progress Bar -->
<div style="width: 100%; height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; margin-bottom: 32px;">
<div style="height: 100%; background: linear-gradient(90deg, var(--arc-orange), var(--arc-yellow), var(--arc-green)); animation: progress {new_events_check_interval_seconds}s ease-in-out infinite;"></div>
</div>
<!-- Processing Steps (DYNAMIC) -->
<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;">
<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;">
<span>PROCESSING PIPELINE:</span>
<span style="color: var(--arc-orange);">{step_count} step{"s" if step_count != 1 else ""}</span>
</div>
<div style="max-height: 300px; overflow-y: auto;">
{steps_html}
</div>
</div>
<!-- Info Note -->
<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;">
<div style="color: var(--arc-cyan); font-size: 13px;">
⏱️ <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>
</div>
</div>
</div>
</div>
</div>
"""
# ---------------------- TACTICAL CSS ----------------------
def _token_css(self) -> str:
return """
:root {
/* ARC RAIDERS SPECTRUM PALETTE */
--arc-red: #FF2A2A;
--arc-orange: #FF7F00;
--arc-yellow: #FFD400;
--arc-green: #55FF00;
--arc-cyan: #00FFFF;
/* SURFACES */
--bg-void: #090B10; /* Deep Black/Navy */
--bg-panel: #12141A; /* Slightly lighter panel */
--bg-card: #181B24; /* Card background */
/* TEXT - IMPROVED CONTRAST */
--text-main: #FFFFFF; /* Pure White for readability */
--text-dim: #AABBC9; /* Lighter grey for secondary text */
/* BORDERS */
--border-dim: #2A303C;
--border-bright: #FF7F00;
/* FONTS */
--font-header: "Inter", "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", "Consolas", monospace;
}
"""
def _base_typography_css(self) -> str:
return """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
body {
background-color: var(--bg-void);
color: var(--text-main);
font-family: var(--font-header);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
margin: 0;
padding: 0;
min-height: 100vh;
}
/* FORCE FULL HEIGHT ON GRADIO CONTAINERS */
.gradio-container {
max-width: 1920px !important;
padding: 0 !important;
min-height: 100vh !important;
background-color: var(--bg-void); /* Ensure bg extends even if content is short */
}
/* Fix for the prose/markdown wrapper messing up heights */
.prose {
max-width: none !important;
}
h1, h2, h3, h4 {
font-family: var(--font-header);
letter-spacing: -0.02em;
font-weight: 800;
color: white;
}
/* COMPONENT OVERRIDES */
.gr-button {
border-radius: 2px !important;
font-family: var(--font-header) !important;
font-weight: 700 !important;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px !important;
padding: 10px 16px !important;
}
.gr-box, .gr-panel, .gr-group {
border-radius: 2px !important;
border: 1px solid var(--border-dim) !important;
background: var(--bg-panel) !important;
}
.gr-input, textarea {
background: #0D1017 !important;
border: 1px solid var(--border-dim) !important;
color: var(--text-main) !important;
font-family: var(--font-mono) !important;
font-size: 15px !important;
line-height: 1.6 !important;
}
.gr-form { background: transparent !important; }
.gr-block { background: transparent !important; border: none !important; }
span.svelte-1gfkn6j { font-size: 13px !important; font-weight: 600 !important; color: var(--arc-yellow) !important; }
"""
def _components_css(self) -> str:
return """
/* --- TOP SPECTRUM STRIPE --- */
.status-bar-spectrum {
height: 6px;
width: 100%;
background: linear-gradient(90deg,
var(--arc-red) 0%,
var(--arc-orange) 25%,
var(--arc-yellow) 50%,
var(--arc-green) 75%,
var(--arc-cyan) 100%);
box-shadow: 0 2px 15px rgba(255, 127, 0, 0.3);
}
/* --- BUTTON VARIANTS --- */
.btn-tac-primary {
background: var(--arc-orange) !important;
color: #000 !important;
border: 1px solid var(--arc-orange) !important;
}
.btn-tac-primary:hover {
background: #FF9500 !important;
box-shadow: 0 0 15px rgba(255, 127, 0, 0.5);
}
.btn-tac-secondary {
background: transparent !important;
border: 1px solid var(--border-dim) !important;
color: var(--text-dim) !important;
}
.btn-tac-secondary:hover {
border-color: var(--text-main) !important;
color: var(--text-main) !important;
background: rgba(255,255,255,0.05) !important;
}
/* --- LAYOUT PANELS --- */
/* Use fill_height=True in Gradio Blocks, but CSS reinforces it */
.main-container {
min-height: calc(100vh - 80px); /* Account for header height approx */
display: flex;
align-items: stretch;
}
.input-panel {
padding: 32px;
border-right: 1px solid var(--border-dim);
background: var(--bg-panel);
height: auto !important; /* Let it grow */
min-height: 100%;
}
.output-panel {
padding: 32px;
background: #0C0E14; /* Darker background for content */
height: auto !important;
min-height: 100%;
flex-grow: 1;
}
.ent-header-label {
font-size: 13px;
color: var(--arc-yellow);
text-transform: uppercase;
letter-spacing: 1.5px;
margin-bottom: 12px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.ent-header-label::before {
content: "";
display: block;
width: 4px; height: 16px;
background: var(--arc-yellow);
box-shadow: 0 0 8px var(--arc-yellow);
}
/* --- ERROR STATE --- */
.error-container {
border: 1px solid var(--arc-red);
background: rgba(255, 42, 42, 0.05);
border-left: 4px solid var(--arc-red);
padding: 0;
margin-top: 20px;
font-family: var(--font-mono);
}
.error-header {
background: rgba(255, 42, 42, 0.1);
padding: 12px 20px;
color: var(--arc-red);
font-weight: 700;
border-bottom: 1px solid rgba(255, 42, 42, 0.2);
display: flex;
align-items: center;
gap: 10px;
letter-spacing: 1px;
}
.error-body {
padding: 20px;
color: var(--text-main);
font-size: 14px;
line-height: 1.6;
}
"""
def _console_css(self) -> str:
return """
.console-wrapper {
background: #08090D;
border: 1px solid var(--border-dim);
padding: 16px;
font-family: var(--font-mono);
font-size: 13px;
min-height: 300px;
max-height: 40vh;
overflow-y: auto;
color: var(--text-main);
}
.console-line {
margin-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.05);
padding-bottom: 4px;
line-height: 1.4;
}
.console-timestamp { color: var(--arc-cyan); margin-right: 8px; font-weight:600; }
.console-wrapper::-webkit-scrollbar { width: 8px; }
.console-wrapper::-webkit-scrollbar-track { background: #08090D; }
.console-wrapper::-webkit-scrollbar-thumb { background: var(--border-dim); border-radius: 4px; }
/* DISABLE GRADIO DEFAULT LOADING OVERLAY */
.generating {
display: none !important;
}
.pending {
opacity: 1 !important;
}
.eta-bar {
display: none !important;
}
/* LIVE NARRATION STYLING */
/* Target the audio component's container */
audio {
width: 100% !important;
background: #1A1D24 !important;
border-radius: 4px !important;
border: 1px solid rgba(255, 127, 0, 0.4) !important;
}
/* Style the audio player controls */
audio::-webkit-media-controls-panel {
background: linear-gradient(to bottom, rgba(30, 30, 40, 0.9), rgba(20, 20, 30, 0.95)) !important;
border-radius: 4px !important;
}
audio::-webkit-media-controls-play-button,
audio::-webkit-media-controls-mute-button {
border-radius: 50% !important;
}
audio::-webkit-media-controls-timeline {
border-radius: 2px !important;
height: 6px !important;
}
audio::-webkit-media-controls-current-time-display,
audio::-webkit-media-controls-time-remaining-display {
color: #FFA94D !important;
font-family: var(--font-mono) !important;
font-size: 12px !important;
font-weight: 600 !important;
text-shadow: 0 0 3px rgba(255, 127, 0, 0.4) !important;
}
/* Add glow effect to live narration container */
.live-narration-wrapper {
padding: 16px;
background: linear-gradient(135deg, rgba(26, 29, 36, 0.8), rgba(20, 23, 30, 0.9));
border: 2px solid var(--arc-orange);
border-radius: 4px;
box-shadow: 0 0 20px rgba(255, 127, 0, 0.3);
margin-bottom: 20px;
}
.live-narration-label {
color: var(--arc-orange);
background: rgb(18, 20, 26);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
padding-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.live-narration-label::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
background: var(--arc-red);
border-radius: 50%;
animation: pulse-red 1.5s ease-in-out infinite;
}
@keyframes pulse-red {
0%, 100% { opacity: 1; box-shadow: 0 0 8px var(--arc-red); }
50% { opacity: 0.5; box-shadow: 0 0 4px var(--arc-red); }
}
"""
def _compose_css(self) -> str:
return "\n".join([
self._token_css(),
self._base_typography_css(),
self._components_css(),
self._console_css(),
])
# --- UI Builders ---
def _build_hero(self) -> str:
return """
<div style="margin-bottom: 0px;">
<div class="status-bar-spectrum"></div>
<div style="background: var(--bg-panel); padding: 24px 32px; display:flex; justify-content:space-between; align-items:center; border-bottom: 1px solid var(--border-dim); flex-wrap: wrap; gap: 20px;">
<div style="display:flex; align-items:center; gap: 16px;">
<div style="width:40px; height:40px; background: var(--arc-orange); border-radius: 2px; display:flex; align-items:center; justify-content:center; font-weight:800; color:black; font-size: 20px;">E</div>
<div>
<h1 style="margin:0; font-size: 28px; line-height:1; color:white;">EASTSYNC <span style="font-weight:400; color:var(--text-dim);">ENTERPRISE</span></h1>
<div style="color:var(--text-dim); font-size:13px; letter-spacing:1px; margin-top:4px;">CAPABILITY INTELLIGENCE PLATFORM</div>
</div>
</div>
<div style="text-align:right; font-family:var(--font-mono); color:var(--text-dim); font-size: 12px;">
<div><span style="color:var(--arc-green)">●</span> SYSTEM ONLINE</div>
<div style="color:var(--arc-cyan)">VER: 4.2.0-ENT</div>
</div>
</div>
</div>
"""
def build_interface(self, analyze_callback: Callable[[str], str], cancel_run_callback: Callable[[], None], start_audio_stream_callback: Callable[[], Any]) -> gr.Blocks:
theme = gr.themes.Base(
primary_hue="orange",
neutral_hue="slate",
)
# Use fill_height=True on Blocks to encourage full-screen layout
with gr.Blocks(theme=theme, css=self._app_css, title="EastSync Enterprise", fill_height=True) as demo:
gr.HTML(self._build_hero())
# Live Narration with custom wrapper
with gr.Group(elem_classes=["live-narration-wrapper"]) as live_narration_group:
gr.HTML('<div class="live-narration-label">🔴 LIVE AI NARRATION</div>')
live_audio = gr.Audio(
label="",
streaming=True,
autoplay=True,
buttons=None,
visible=should_narrate_events,
show_label=False,
elem_id="live-narrator-audio"
)
# Hide the wrapper if narration is disabled
if not should_narrate_events:
live_narration_group.visible = False
with gr.Row(equal_height=True, elem_classes=["main-container"]):
# --- LEFT COLUMN: INPUTS ---
with gr.Column(scale=3, elem_classes=["input-panel"]) as mission_panel:
gr.HTML("<div class='ent-header-label'>PROJECT PARAMETERS</div>")
input_box = gr.TextArea(
label="PROJECT REQUIREMENTS",
show_label=False,
value=self.SAMPLE_PROMPT,
lines=12,
placeholder="Define project scope, technical requirements, and current team composition..."
)
with gr.Row():
btn_run = gr.Button("GENERATE ROADMAP", elem_classes=["btn-tac-primary"])
with gr.Row():
btn_reset = gr.Button("RESET FORM", elem_classes=["btn-tac-secondary"])
with gr.Row():
btn_cancel = gr.Button("STOP ANALYSIS", elem_classes=["btn-tac-secondary"])
with gr.Row():
btn_cv = gr.Button("📄 CV ANALYSIS", elem_classes=["btn-tac-primary"])
gr.HTML("<div style='height:30px'></div>") # Flexible Spacer
gr.HTML("<div class='ent-header-label'>ACTIVITY LOG</div>")
console = gr.HTML(self.get_action_log_text())
# --- LEFT COLUMN: CV UPLOAD (hidden by default) ---
with gr.Column(scale=3, elem_classes=["input-panel"], visible=False) as cv_upload_panel:
gr.HTML("<div class='ent-header-label'>📄 CV ANALYSIS</div>")
gr.HTML("""
<div style="padding: 16px; background: rgba(255,127,0,0.1); border: 1px solid var(--arc-orange); border-radius: 4px; margin-bottom: 16px;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div style="padding: 10px; background: rgba(255,255,255,0.05); border-radius: 4px; border-left: 3px solid var(--arc-cyan);">
<div style="color: var(--arc-cyan); font-weight: 600; margin-bottom: 6px; font-size: 12px;">📊 EXTRACT SKILLS</div>
<div style="color: var(--text-dim); font-size: 10px;">
Parse CV to identify technical skills, experience, certifications.
</div>
</div>
<div style="padding: 10px; background: rgba(255,255,255,0.05); border-radius: 4px; border-left: 3px solid var(--arc-orange);">
<div style="color: var(--arc-orange); font-weight: 600; margin-bottom: 6px; font-size: 12px;">🎯 EXTRACT + MATCH</div>
<div style="color: var(--text-dim); font-size: 10px;">
Parse CV, rank projects, identify skill gaps.
</div>
</div>
</div>
</div>
""")
cv_file_input = gr.File(
label="SELECT CV FILE",
file_types=[".pdf", ".docx", ".doc"],
type="filepath"
)
with gr.Row():
btn_process_cv = gr.Button("📊 EXTRACT SKILLS", elem_classes=["btn-tac-secondary"])
btn_process_cv_match = gr.Button("🎯 EXTRACT + MATCH PROJECTS", elem_classes=["btn-tac-primary"])
with gr.Row():
btn_close_cv = gr.Button("← BACK", elem_classes=["btn-tac-secondary"])
gr.HTML("<div style='height:30px'></div>")
gr.HTML("<div class='ent-header-label'>SYSTEM LOGS</div>")
console_cv = gr.HTML(self.get_action_log_text())
# --- RIGHT COLUMN: OUTPUT ---
with gr.Column(scale=7, elem_classes=["output-panel"]):
output_display = gr.HTML(self.render_idle_state())
# --- Event Bindings ---
# Project Analysis
def start_project_analysis():
self.start_processing("project")
return self.render_project_processing_state()
# Trigger audio stream independently so it doesn't block analysis
btn_run.click(start_audio_stream_callback, outputs=live_audio)
btn_run.click(
start_project_analysis,
outputs=output_display,
queue=False
).then(
self.clear_action_log, outputs=console, queue=False
).then(
lambda: self.get_action_log_text(), outputs=console
).then(
analyze_callback, inputs=input_box, outputs=output_display
).then(
self.get_action_log_text, outputs=console
)
btn_reset.click(self.reset_prompt_value, outputs=input_box)
# CV Button - Toggle panels
def show_cv_interface():
return (
gr.update(visible=False), # Hide mission_panel
gr.update(visible=True), # Show cv_upload_panel
self._cv_interface.render_cv_upload_interface() # Update output_display
)
btn_cv.click(
show_cv_interface,
outputs=[mission_panel, cv_upload_panel, output_display]
)
# Close CV Section - Back to mission panel
def close_cv_interface():
return (
gr.update(visible=True), # Show mission_panel
gr.update(visible=False), # Hide cv_upload_panel
self.render_idle_state() # Reset output_display
)
btn_close_cv.click(
close_cv_interface,
outputs=[mission_panel, cv_upload_panel, output_display]
)
# Standard CV analysis (no project matching)
def start_cv_extract():
self.start_processing("extract")
return self.render_processing_state("extract")
def finish_cv_processing():
self.stop_processing()
return self.get_action_log_text()
btn_process_cv.click(
start_cv_extract,
outputs=output_display,
queue=False
).then(
self.clear_action_log, outputs=console_cv, queue=False
).then(
self._cv_interface.process_cv_upload,
inputs=cv_file_input,
outputs=output_display
).then(
finish_cv_processing, outputs=console_cv
)
# CV + Project Matching
def start_cv_match():
self.start_processing("match")
return self.render_processing_state("match")
btn_process_cv_match.click(
start_cv_match,
outputs=output_display,
queue=False
).then(
self.clear_action_log, outputs=console_cv, queue=False
).then(
self._cv_interface.process_cv_with_matching,
inputs=cv_file_input,
outputs=output_display
).then(
finish_cv_processing, outputs=console_cv
)
btn_cancel.click(cancel_run_callback)
# Live log updates (both consoles)
def update_both_consoles():
log_text = self.get_action_log_text()
return (log_text, log_text) # Return same log for both consoles
gr.Timer(new_events_check_interval_seconds).tick(update_both_consoles, outputs=[console, console_cv])
# Check for analysis result updates (poll every 1 second)
def check_analysis_result():
"""Check if analysis is complete and update output display."""
output = self.get_analysis_output()
# Only return if there's an update (None means no update needed)
if output is not None:
return output
# Return None to skip update (Gradio will handle this)
return None
# Poll for results - Gradio will skip None returns
def poll_with_skip():
result = check_analysis_result()
# Return the result if not None, otherwise skip update
return result if result is not None else gr.update()
gr.Timer(new_events_check_interval_seconds).tick(poll_with_skip, outputs=output_display)
return demo