Delete agents/semantic_analyzer.py
Browse files- agents/semantic_analyzer.py +0 -901
agents/semantic_analyzer.py
DELETED
|
@@ -1,901 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Agent 1C: Semantic Color Analyzer
|
| 3 |
-
Design System Extractor v2
|
| 4 |
-
|
| 5 |
-
Persona: Design System Semanticist
|
| 6 |
-
|
| 7 |
-
Responsibilities:
|
| 8 |
-
- Analyze colors based on their actual CSS usage
|
| 9 |
-
- Categorize into semantic roles (brand, text, background, border, feedback)
|
| 10 |
-
- Use LLM to understand color relationships and hierarchy
|
| 11 |
-
- Provide structured output for Stage 1 UI and Stage 2 analysis
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
import json
|
| 15 |
-
import re
|
| 16 |
-
from typing import Optional, Callable
|
| 17 |
-
from datetime import datetime
|
| 18 |
-
|
| 19 |
-
from core.color_utils import (
|
| 20 |
-
parse_color,
|
| 21 |
-
get_contrast_with_white,
|
| 22 |
-
get_contrast_with_black,
|
| 23 |
-
check_wcag_compliance,
|
| 24 |
-
)
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
class SemanticColorAnalyzer:
|
| 28 |
-
"""
|
| 29 |
-
Analyzes extracted colors and categorizes them by semantic role.
|
| 30 |
-
|
| 31 |
-
Uses LLM to understand:
|
| 32 |
-
- Which colors are brand/primary colors (used on buttons, CTAs)
|
| 33 |
-
- Which colors are for text (used with 'color' property)
|
| 34 |
-
- Which colors are backgrounds (used with 'background-color')
|
| 35 |
-
- Which colors are borders (used with 'border-color')
|
| 36 |
-
- Which colors are feedback states (error, success, warning)
|
| 37 |
-
"""
|
| 38 |
-
|
| 39 |
-
def __init__(self, llm_provider=None):
|
| 40 |
-
"""
|
| 41 |
-
Initialize the semantic analyzer.
|
| 42 |
-
|
| 43 |
-
Args:
|
| 44 |
-
llm_provider: Optional LLM provider for AI analysis.
|
| 45 |
-
If None, uses rule-based fallback.
|
| 46 |
-
"""
|
| 47 |
-
self.llm_provider = llm_provider
|
| 48 |
-
self.analysis_result = {}
|
| 49 |
-
self.logs = []
|
| 50 |
-
|
| 51 |
-
def log(self, message: str):
|
| 52 |
-
"""Add timestamped log message."""
|
| 53 |
-
timestamp = datetime.now().strftime("%H:%M:%S")
|
| 54 |
-
self.logs.append(f"[{timestamp}] {message}")
|
| 55 |
-
|
| 56 |
-
def get_logs(self) -> str:
|
| 57 |
-
"""Get all logs as string."""
|
| 58 |
-
return "\n".join(self.logs)
|
| 59 |
-
|
| 60 |
-
def _prepare_color_data_for_llm(self, colors: dict) -> str:
|
| 61 |
-
"""
|
| 62 |
-
Prepare color data in a format optimized for LLM analysis.
|
| 63 |
-
|
| 64 |
-
Args:
|
| 65 |
-
colors: Dict of color tokens with metadata
|
| 66 |
-
|
| 67 |
-
Returns:
|
| 68 |
-
Formatted string for LLM prompt
|
| 69 |
-
"""
|
| 70 |
-
color_entries = []
|
| 71 |
-
|
| 72 |
-
for name, token in colors.items():
|
| 73 |
-
# Handle both dict and object formats
|
| 74 |
-
if hasattr(token, 'value'):
|
| 75 |
-
hex_val = token.value
|
| 76 |
-
frequency = token.frequency
|
| 77 |
-
contexts = token.contexts if hasattr(token, 'contexts') else []
|
| 78 |
-
elements = token.elements if hasattr(token, 'elements') else []
|
| 79 |
-
css_props = token.css_properties if hasattr(token, 'css_properties') else []
|
| 80 |
-
else:
|
| 81 |
-
hex_val = token.get('value', '#000000')
|
| 82 |
-
frequency = token.get('frequency', 0)
|
| 83 |
-
contexts = token.get('contexts', [])
|
| 84 |
-
elements = token.get('elements', [])
|
| 85 |
-
css_props = token.get('css_properties', [])
|
| 86 |
-
|
| 87 |
-
# Calculate color properties
|
| 88 |
-
contrast_white = get_contrast_with_white(hex_val)
|
| 89 |
-
contrast_black = get_contrast_with_black(hex_val)
|
| 90 |
-
|
| 91 |
-
# Determine luminance
|
| 92 |
-
try:
|
| 93 |
-
r = int(hex_val[1:3], 16)
|
| 94 |
-
g = int(hex_val[3:5], 16)
|
| 95 |
-
b = int(hex_val[5:7], 16)
|
| 96 |
-
luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
| 97 |
-
|
| 98 |
-
# Calculate saturation
|
| 99 |
-
max_c = max(r, g, b)
|
| 100 |
-
min_c = min(r, g, b)
|
| 101 |
-
saturation = (max_c - min_c) / 255 if max_c > 0 else 0
|
| 102 |
-
except:
|
| 103 |
-
luminance = 0.5
|
| 104 |
-
saturation = 0
|
| 105 |
-
|
| 106 |
-
entry = {
|
| 107 |
-
"hex": hex_val,
|
| 108 |
-
"name": name,
|
| 109 |
-
"frequency": frequency,
|
| 110 |
-
"css_properties": css_props[:5], # Limit for prompt size
|
| 111 |
-
"elements": elements[:5],
|
| 112 |
-
"contexts": contexts[:3],
|
| 113 |
-
"luminance": round(luminance, 2),
|
| 114 |
-
"saturation": round(saturation, 2),
|
| 115 |
-
"contrast_on_white": round(contrast_white, 2),
|
| 116 |
-
"contrast_on_black": round(contrast_black, 2),
|
| 117 |
-
"aa_compliant_on_white": contrast_white >= 4.5,
|
| 118 |
-
}
|
| 119 |
-
color_entries.append(entry)
|
| 120 |
-
|
| 121 |
-
# Sort by frequency for LLM to see most important first
|
| 122 |
-
color_entries.sort(key=lambda x: -x['frequency'])
|
| 123 |
-
|
| 124 |
-
# Limit to top 50 colors for LLM (avoid token limits)
|
| 125 |
-
return json.dumps(color_entries[:50], indent=2)
|
| 126 |
-
|
| 127 |
-
def _build_llm_prompt(self, color_data: str) -> str:
|
| 128 |
-
"""Build the prompt for LLM semantic analysis."""
|
| 129 |
-
|
| 130 |
-
return f"""You are a Design System Analyst specializing in color semantics.
|
| 131 |
-
|
| 132 |
-
TASK: Analyze these extracted colors and categorize them by their semantic role in the UI.
|
| 133 |
-
|
| 134 |
-
EXTRACTED COLORS (sorted by frequency):
|
| 135 |
-
{color_data}
|
| 136 |
-
|
| 137 |
-
ANALYSIS RULES:
|
| 138 |
-
1. BRAND/PRIMARY colors are typically:
|
| 139 |
-
- Used on buttons, links, CTAs (elements: button, a, input[type=submit])
|
| 140 |
-
- Applied via background-color on interactive elements
|
| 141 |
-
- Saturated (saturation > 0.3) and not gray
|
| 142 |
-
- High frequency on interactive elements
|
| 143 |
-
|
| 144 |
-
2. TEXT colors are typically:
|
| 145 |
-
- Applied via "color" CSS property (not background-color)
|
| 146 |
-
- Used on text elements (p, span, h1-h6, label)
|
| 147 |
-
- Form a hierarchy: primary (darkest), secondary (medium), muted (lightest)
|
| 148 |
-
- Low saturation (grays)
|
| 149 |
-
|
| 150 |
-
3. BACKGROUND colors are typically:
|
| 151 |
-
- Applied via "background-color" on containers
|
| 152 |
-
- Used on div, section, main, body, card elements
|
| 153 |
-
- Light colors (luminance > 0.8) for light themes
|
| 154 |
-
- May include dark backgrounds for inverse sections
|
| 155 |
-
|
| 156 |
-
4. BORDER colors are typically:
|
| 157 |
-
- Applied via border-color properties
|
| 158 |
-
- Often gray/neutral
|
| 159 |
-
- Lower frequency than text/background
|
| 160 |
-
|
| 161 |
-
5. FEEDBACK colors are:
|
| 162 |
-
- Red variants = error
|
| 163 |
-
- Green variants = success
|
| 164 |
-
- Yellow/orange = warning
|
| 165 |
-
- Blue variants = info
|
| 166 |
-
- Often used with specific class contexts
|
| 167 |
-
|
| 168 |
-
OUTPUT FORMAT (JSON):
|
| 169 |
-
{{
|
| 170 |
-
"brand": {{
|
| 171 |
-
"primary": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}},
|
| 172 |
-
"secondary": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}},
|
| 173 |
-
"accent": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}}
|
| 174 |
-
}},
|
| 175 |
-
"text": {{
|
| 176 |
-
"primary": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}},
|
| 177 |
-
"secondary": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}},
|
| 178 |
-
"muted": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}},
|
| 179 |
-
"inverse": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}}
|
| 180 |
-
}},
|
| 181 |
-
"background": {{
|
| 182 |
-
"primary": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}},
|
| 183 |
-
"secondary": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}},
|
| 184 |
-
"tertiary": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}},
|
| 185 |
-
"inverse": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}}
|
| 186 |
-
}},
|
| 187 |
-
"border": {{
|
| 188 |
-
"default": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}},
|
| 189 |
-
"strong": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}}
|
| 190 |
-
}},
|
| 191 |
-
"feedback": {{
|
| 192 |
-
"error": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}},
|
| 193 |
-
"success": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}},
|
| 194 |
-
"warning": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}},
|
| 195 |
-
"info": {{"hex": "#xxx", "confidence": "high|medium|low", "reason": "..."}}
|
| 196 |
-
}},
|
| 197 |
-
"summary": {{
|
| 198 |
-
"total_colors_analyzed": 50,
|
| 199 |
-
"brand_colors_found": 2,
|
| 200 |
-
"has_clear_hierarchy": true,
|
| 201 |
-
"accessibility_notes": "..."
|
| 202 |
-
}}
|
| 203 |
-
}}
|
| 204 |
-
|
| 205 |
-
IMPORTANT:
|
| 206 |
-
- Only include roles where you found a matching color
|
| 207 |
-
- Set confidence based on how certain you are
|
| 208 |
-
- Provide brief reasoning for each categorization
|
| 209 |
-
- If no color fits a role, omit that key
|
| 210 |
-
|
| 211 |
-
Return ONLY valid JSON, no other text."""
|
| 212 |
-
|
| 213 |
-
def _rule_based_analysis(self, colors: dict) -> dict:
|
| 214 |
-
"""
|
| 215 |
-
Fallback rule-based analysis when LLM is not available.
|
| 216 |
-
|
| 217 |
-
Uses heuristics based on:
|
| 218 |
-
- CSS properties (color vs background-color vs border-color)
|
| 219 |
-
- Element types (button, a, p, div, etc.)
|
| 220 |
-
- Color characteristics (saturation, luminance)
|
| 221 |
-
- Frequency
|
| 222 |
-
"""
|
| 223 |
-
self.log(" Using rule-based analysis (no LLM)")
|
| 224 |
-
|
| 225 |
-
result = {
|
| 226 |
-
"brand": {},
|
| 227 |
-
"text": {},
|
| 228 |
-
"background": {},
|
| 229 |
-
"border": {},
|
| 230 |
-
"feedback": {},
|
| 231 |
-
"summary": {
|
| 232 |
-
"total_colors_analyzed": len(colors),
|
| 233 |
-
"brand_colors_found": 0,
|
| 234 |
-
"has_clear_hierarchy": False,
|
| 235 |
-
"accessibility_notes": "",
|
| 236 |
-
"method": "rule-based"
|
| 237 |
-
}
|
| 238 |
-
}
|
| 239 |
-
|
| 240 |
-
# Categorize colors
|
| 241 |
-
brand_candidates = []
|
| 242 |
-
text_candidates = []
|
| 243 |
-
background_candidates = []
|
| 244 |
-
border_candidates = []
|
| 245 |
-
feedback_candidates = {"error": [], "success": [], "warning": [], "info": []}
|
| 246 |
-
|
| 247 |
-
for name, token in colors.items():
|
| 248 |
-
# Extract data
|
| 249 |
-
if hasattr(token, 'value'):
|
| 250 |
-
hex_val = token.value
|
| 251 |
-
frequency = token.frequency
|
| 252 |
-
contexts = token.contexts if hasattr(token, 'contexts') else []
|
| 253 |
-
elements = token.elements if hasattr(token, 'elements') else []
|
| 254 |
-
css_props = token.css_properties if hasattr(token, 'css_properties') else []
|
| 255 |
-
else:
|
| 256 |
-
hex_val = token.get('value', '#000000')
|
| 257 |
-
frequency = token.get('frequency', 0)
|
| 258 |
-
contexts = token.get('contexts', [])
|
| 259 |
-
elements = token.get('elements', [])
|
| 260 |
-
css_props = token.get('css_properties', [])
|
| 261 |
-
|
| 262 |
-
# Calculate color properties
|
| 263 |
-
try:
|
| 264 |
-
r = int(hex_val[1:3], 16)
|
| 265 |
-
g = int(hex_val[3:5], 16)
|
| 266 |
-
b = int(hex_val[5:7], 16)
|
| 267 |
-
luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
| 268 |
-
max_c = max(r, g, b)
|
| 269 |
-
min_c = min(r, g, b)
|
| 270 |
-
saturation = (max_c - min_c) / 255 if max_c > 0 else 0
|
| 271 |
-
|
| 272 |
-
# Determine hue for feedback colors
|
| 273 |
-
if max_c == min_c:
|
| 274 |
-
hue = 0
|
| 275 |
-
elif max_c == r:
|
| 276 |
-
hue = 60 * ((g - b) / (max_c - min_c) % 6)
|
| 277 |
-
elif max_c == g:
|
| 278 |
-
hue = 60 * ((b - r) / (max_c - min_c) + 2)
|
| 279 |
-
else:
|
| 280 |
-
hue = 60 * ((r - g) / (max_c - min_c) + 4)
|
| 281 |
-
except:
|
| 282 |
-
luminance = 0.5
|
| 283 |
-
saturation = 0
|
| 284 |
-
hue = 0
|
| 285 |
-
|
| 286 |
-
color_info = {
|
| 287 |
-
"hex": hex_val,
|
| 288 |
-
"name": name,
|
| 289 |
-
"frequency": frequency,
|
| 290 |
-
"luminance": luminance,
|
| 291 |
-
"saturation": saturation,
|
| 292 |
-
"hue": hue,
|
| 293 |
-
"css_props": css_props,
|
| 294 |
-
"elements": elements,
|
| 295 |
-
"contexts": contexts,
|
| 296 |
-
}
|
| 297 |
-
|
| 298 |
-
# --- CATEGORIZATION RULES ---
|
| 299 |
-
|
| 300 |
-
# BRAND: Saturated colors - multiple detection methods
|
| 301 |
-
interactive_elements = ['button', 'a', 'input', 'select', 'submit', 'btn', 'cta']
|
| 302 |
-
is_interactive = any(el in str(elements).lower() for el in interactive_elements)
|
| 303 |
-
has_bg_prop = any('background' in str(p).lower() for p in css_props)
|
| 304 |
-
|
| 305 |
-
# Method 1: Interactive elements with background
|
| 306 |
-
if saturation > 0.25 and is_interactive and has_bg_prop:
|
| 307 |
-
brand_candidates.append(color_info)
|
| 308 |
-
# Method 2: Highly saturated + high frequency (works for Firecrawl)
|
| 309 |
-
elif saturation > 0.35 and frequency > 15:
|
| 310 |
-
brand_candidates.append(color_info)
|
| 311 |
-
# Method 3: Very saturated colors regardless of frequency
|
| 312 |
-
elif saturation > 0.5 and frequency > 5:
|
| 313 |
-
brand_candidates.append(color_info)
|
| 314 |
-
# Method 4: Cyan/Teal range (common brand colors)
|
| 315 |
-
elif 160 <= hue <= 200 and saturation > 0.4 and frequency > 10:
|
| 316 |
-
brand_candidates.append(color_info)
|
| 317 |
-
# Method 5: Lime/Green-Yellow (secondary brand colors)
|
| 318 |
-
elif 60 <= hue <= 90 and saturation > 0.5 and frequency > 5:
|
| 319 |
-
brand_candidates.append(color_info)
|
| 320 |
-
|
| 321 |
-
# TEXT: Low saturation, used with 'color' property
|
| 322 |
-
has_color_prop = any(p == 'color' or (p.endswith('-color') and 'background' not in p and 'border' not in p)
|
| 323 |
-
for p in css_props)
|
| 324 |
-
text_elements = ['p', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label', 'div', 'text']
|
| 325 |
-
is_text_element = any(el in str(elements).lower() for el in text_elements)
|
| 326 |
-
|
| 327 |
-
# Text detection - low saturation grays
|
| 328 |
-
if saturation < 0.15 and (has_color_prop or 'text' in str(contexts).lower()):
|
| 329 |
-
text_candidates.append(color_info)
|
| 330 |
-
elif saturation < 0.1 and 0.1 < luminance < 0.8: # Gray range
|
| 331 |
-
text_candidates.append(color_info)
|
| 332 |
-
elif saturation < 0.1 and luminance < 0.5 and frequency > 50: # Dark grays used a lot
|
| 333 |
-
text_candidates.append(color_info)
|
| 334 |
-
|
| 335 |
-
if saturation < 0.15 and (has_color_prop or 'text' in str(contexts).lower()):
|
| 336 |
-
text_candidates.append(color_info)
|
| 337 |
-
elif saturation < 0.1 and luminance < 0.7 and is_text_element:
|
| 338 |
-
text_candidates.append(color_info)
|
| 339 |
-
|
| 340 |
-
# BACKGROUND: Used with background-color on containers
|
| 341 |
-
container_elements = ['div', 'section', 'main', 'body', 'article', 'header', 'footer', 'card']
|
| 342 |
-
is_container = any(el in str(elements).lower() for el in container_elements)
|
| 343 |
-
|
| 344 |
-
if has_bg_prop and (is_container or 'background' in str(contexts).lower()):
|
| 345 |
-
if saturation < 0.15: # Mostly neutral backgrounds
|
| 346 |
-
background_candidates.append(color_info)
|
| 347 |
-
|
| 348 |
-
# BORDER: Used with border-color properties
|
| 349 |
-
has_border_prop = any('border' in str(p).lower() for p in css_props)
|
| 350 |
-
|
| 351 |
-
if has_border_prop or 'border' in str(contexts).lower():
|
| 352 |
-
border_candidates.append(color_info)
|
| 353 |
-
|
| 354 |
-
# FEEDBACK: Based on hue
|
| 355 |
-
if saturation > 0.3:
|
| 356 |
-
if 0 <= hue <= 30 or 330 <= hue <= 360: # Red
|
| 357 |
-
feedback_candidates["error"].append(color_info)
|
| 358 |
-
elif 90 <= hue <= 150: # Green
|
| 359 |
-
feedback_candidates["success"].append(color_info)
|
| 360 |
-
elif 30 <= hue <= 60: # Yellow/Orange
|
| 361 |
-
feedback_candidates["warning"].append(color_info)
|
| 362 |
-
elif 180 <= hue <= 250: # Blue
|
| 363 |
-
feedback_candidates["info"].append(color_info)
|
| 364 |
-
|
| 365 |
-
# --- SELECT BEST CANDIDATES ---
|
| 366 |
-
|
| 367 |
-
# Brand: Sort by frequency * saturation
|
| 368 |
-
brand_candidates.sort(key=lambda x: -(x['frequency'] * x['saturation']))
|
| 369 |
-
if brand_candidates:
|
| 370 |
-
result["brand"]["primary"] = {
|
| 371 |
-
"hex": brand_candidates[0]["hex"],
|
| 372 |
-
"confidence": "high" if brand_candidates[0]["frequency"] > 20 else "medium",
|
| 373 |
-
"reason": f"Most frequent saturated color on interactive elements (freq: {brand_candidates[0]['frequency']})"
|
| 374 |
-
}
|
| 375 |
-
result["summary"]["brand_colors_found"] += 1
|
| 376 |
-
if len(brand_candidates) > 1:
|
| 377 |
-
result["brand"]["secondary"] = {
|
| 378 |
-
"hex": brand_candidates[1]["hex"],
|
| 379 |
-
"confidence": "medium",
|
| 380 |
-
"reason": f"Second most frequent brand color (freq: {brand_candidates[1]['frequency']})"
|
| 381 |
-
}
|
| 382 |
-
result["summary"]["brand_colors_found"] += 1
|
| 383 |
-
|
| 384 |
-
# Text: Sort by luminance (darkest first for primary)
|
| 385 |
-
text_candidates.sort(key=lambda x: x['luminance'])
|
| 386 |
-
if text_candidates:
|
| 387 |
-
result["text"]["primary"] = {
|
| 388 |
-
"hex": text_candidates[0]["hex"],
|
| 389 |
-
"confidence": "high" if text_candidates[0]["luminance"] < 0.3 else "medium",
|
| 390 |
-
"reason": f"Darkest text color (luminance: {text_candidates[0]['luminance']:.2f})"
|
| 391 |
-
}
|
| 392 |
-
if len(text_candidates) > 1:
|
| 393 |
-
# Find secondary (mid-luminance)
|
| 394 |
-
mid_idx = len(text_candidates) // 2
|
| 395 |
-
result["text"]["secondary"] = {
|
| 396 |
-
"hex": text_candidates[mid_idx]["hex"],
|
| 397 |
-
"confidence": "medium",
|
| 398 |
-
"reason": f"Mid-tone text color (luminance: {text_candidates[mid_idx]['luminance']:.2f})"
|
| 399 |
-
}
|
| 400 |
-
if len(text_candidates) > 2:
|
| 401 |
-
result["text"]["muted"] = {
|
| 402 |
-
"hex": text_candidates[-1]["hex"],
|
| 403 |
-
"confidence": "medium",
|
| 404 |
-
"reason": f"Lightest text color (luminance: {text_candidates[-1]['luminance']:.2f})"
|
| 405 |
-
}
|
| 406 |
-
|
| 407 |
-
# Check for text hierarchy
|
| 408 |
-
if len(text_candidates) >= 3:
|
| 409 |
-
result["summary"]["has_clear_hierarchy"] = True
|
| 410 |
-
|
| 411 |
-
# Background: Sort by luminance (lightest first for primary)
|
| 412 |
-
background_candidates.sort(key=lambda x: -x['luminance'])
|
| 413 |
-
if background_candidates:
|
| 414 |
-
result["background"]["primary"] = {
|
| 415 |
-
"hex": background_candidates[0]["hex"],
|
| 416 |
-
"confidence": "high" if background_candidates[0]["luminance"] > 0.9 else "medium",
|
| 417 |
-
"reason": f"Lightest background (luminance: {background_candidates[0]['luminance']:.2f})"
|
| 418 |
-
}
|
| 419 |
-
if len(background_candidates) > 1:
|
| 420 |
-
result["background"]["secondary"] = {
|
| 421 |
-
"hex": background_candidates[1]["hex"],
|
| 422 |
-
"confidence": "medium",
|
| 423 |
-
"reason": f"Secondary background (luminance: {background_candidates[1]['luminance']:.2f})"
|
| 424 |
-
}
|
| 425 |
-
# Find dark background for inverse
|
| 426 |
-
dark_bgs = [c for c in background_candidates if c['luminance'] < 0.2]
|
| 427 |
-
if dark_bgs:
|
| 428 |
-
result["background"]["inverse"] = {
|
| 429 |
-
"hex": dark_bgs[0]["hex"],
|
| 430 |
-
"confidence": "medium",
|
| 431 |
-
"reason": f"Dark background for inverse sections (luminance: {dark_bgs[0]['luminance']:.2f})"
|
| 432 |
-
}
|
| 433 |
-
|
| 434 |
-
# Border: Sort by frequency
|
| 435 |
-
border_candidates.sort(key=lambda x: -x['frequency'])
|
| 436 |
-
if border_candidates:
|
| 437 |
-
result["border"]["default"] = {
|
| 438 |
-
"hex": border_candidates[0]["hex"],
|
| 439 |
-
"confidence": "medium",
|
| 440 |
-
"reason": f"Most common border color (freq: {border_candidates[0]['frequency']})"
|
| 441 |
-
}
|
| 442 |
-
|
| 443 |
-
# Feedback: Pick highest frequency for each
|
| 444 |
-
for feedback_type, candidates in feedback_candidates.items():
|
| 445 |
-
if candidates:
|
| 446 |
-
candidates.sort(key=lambda x: -x['frequency'])
|
| 447 |
-
result["feedback"][feedback_type] = {
|
| 448 |
-
"hex": candidates[0]["hex"],
|
| 449 |
-
"confidence": "medium",
|
| 450 |
-
"reason": f"Detected {feedback_type} color by hue analysis"
|
| 451 |
-
}
|
| 452 |
-
|
| 453 |
-
return result
|
| 454 |
-
|
| 455 |
-
async def analyze_with_llm(self, colors: dict, log_callback: Optional[Callable] = None) -> dict:
|
| 456 |
-
"""
|
| 457 |
-
Analyze colors using LLM for semantic categorization.
|
| 458 |
-
|
| 459 |
-
Args:
|
| 460 |
-
colors: Dict of color tokens
|
| 461 |
-
log_callback: Optional callback for logging
|
| 462 |
-
|
| 463 |
-
Returns:
|
| 464 |
-
Semantic analysis result
|
| 465 |
-
"""
|
| 466 |
-
def log(msg):
|
| 467 |
-
self.log(msg)
|
| 468 |
-
if log_callback:
|
| 469 |
-
log_callback(msg)
|
| 470 |
-
|
| 471 |
-
log("")
|
| 472 |
-
log("=" * 60)
|
| 473 |
-
log("🧠 SEMANTIC COLOR ANALYSIS (LLM)")
|
| 474 |
-
log("=" * 60)
|
| 475 |
-
log("")
|
| 476 |
-
|
| 477 |
-
# Prepare data for LLM
|
| 478 |
-
log(" 📊 Preparing color data for analysis...")
|
| 479 |
-
color_data = self._prepare_color_data_for_llm(colors)
|
| 480 |
-
log(f" ✅ Prepared {min(50, len(colors))} colors for analysis")
|
| 481 |
-
|
| 482 |
-
# Check if LLM provider is available
|
| 483 |
-
if self.llm_provider is None:
|
| 484 |
-
log(" ⚠️ No LLM provider configured, using rule-based analysis")
|
| 485 |
-
self.analysis_result = self._rule_based_analysis(colors)
|
| 486 |
-
else:
|
| 487 |
-
try:
|
| 488 |
-
log(" 🤖 Calling LLM for semantic analysis...")
|
| 489 |
-
|
| 490 |
-
prompt = self._build_llm_prompt(color_data)
|
| 491 |
-
|
| 492 |
-
# Call LLM
|
| 493 |
-
response = await self.llm_provider.generate(
|
| 494 |
-
prompt=prompt,
|
| 495 |
-
max_tokens=2000,
|
| 496 |
-
temperature=0.3, # Low temperature for consistent categorization
|
| 497 |
-
)
|
| 498 |
-
|
| 499 |
-
log(" ✅ LLM response received")
|
| 500 |
-
|
| 501 |
-
# Parse JSON response
|
| 502 |
-
try:
|
| 503 |
-
# Extract JSON from response
|
| 504 |
-
json_match = re.search(r'\{[\s\S]*\}', response)
|
| 505 |
-
if json_match:
|
| 506 |
-
self.analysis_result = json.loads(json_match.group())
|
| 507 |
-
self.analysis_result["summary"]["method"] = "llm"
|
| 508 |
-
log(" ✅ Successfully parsed LLM analysis")
|
| 509 |
-
else:
|
| 510 |
-
raise ValueError("No JSON found in response")
|
| 511 |
-
|
| 512 |
-
except json.JSONDecodeError as e:
|
| 513 |
-
log(f" ⚠️ Failed to parse LLM response: {e}")
|
| 514 |
-
log(" 🔄 Falling back to rule-based analysis")
|
| 515 |
-
self.analysis_result = self._rule_based_analysis(colors)
|
| 516 |
-
|
| 517 |
-
except Exception as e:
|
| 518 |
-
log(f" ❌ LLM analysis failed: {str(e)}")
|
| 519 |
-
log(" 🔄 Falling back to rule-based analysis")
|
| 520 |
-
self.analysis_result = self._rule_based_analysis(colors)
|
| 521 |
-
|
| 522 |
-
# Log results
|
| 523 |
-
self._log_analysis_results(log)
|
| 524 |
-
|
| 525 |
-
return self.analysis_result
|
| 526 |
-
|
| 527 |
-
def analyze_sync(self, colors: dict, log_callback: Optional[Callable] = None) -> dict:
|
| 528 |
-
"""
|
| 529 |
-
Synchronous analysis using rule-based approach.
|
| 530 |
-
|
| 531 |
-
Args:
|
| 532 |
-
colors: Dict of color tokens
|
| 533 |
-
log_callback: Optional callback for logging
|
| 534 |
-
|
| 535 |
-
Returns:
|
| 536 |
-
Semantic analysis result
|
| 537 |
-
"""
|
| 538 |
-
def log(msg):
|
| 539 |
-
self.log(msg)
|
| 540 |
-
if log_callback:
|
| 541 |
-
log_callback(msg)
|
| 542 |
-
|
| 543 |
-
log("")
|
| 544 |
-
log("=" * 60)
|
| 545 |
-
log("🧠 SEMANTIC COLOR ANALYSIS")
|
| 546 |
-
log("=" * 60)
|
| 547 |
-
log("")
|
| 548 |
-
|
| 549 |
-
log(f" 📊 Analyzing {len(colors)} colors...")
|
| 550 |
-
|
| 551 |
-
self.analysis_result = self._rule_based_analysis(colors)
|
| 552 |
-
|
| 553 |
-
# Log results
|
| 554 |
-
self._log_analysis_results(log)
|
| 555 |
-
|
| 556 |
-
return self.analysis_result
|
| 557 |
-
|
| 558 |
-
def _log_analysis_results(self, log: Callable):
|
| 559 |
-
"""Log the analysis results in a formatted way."""
|
| 560 |
-
|
| 561 |
-
result = self.analysis_result
|
| 562 |
-
|
| 563 |
-
log("")
|
| 564 |
-
log("📊 SEMANTIC ANALYSIS RESULTS:")
|
| 565 |
-
log("")
|
| 566 |
-
|
| 567 |
-
# Brand colors
|
| 568 |
-
if result.get("brand"):
|
| 569 |
-
log(" 🎨 BRAND COLORS:")
|
| 570 |
-
for role, data in result["brand"].items():
|
| 571 |
-
if data:
|
| 572 |
-
log(f" {role}: {data['hex']} ({data['confidence']})")
|
| 573 |
-
log(f" └─ {data['reason']}")
|
| 574 |
-
|
| 575 |
-
# Text colors
|
| 576 |
-
if result.get("text"):
|
| 577 |
-
log("")
|
| 578 |
-
log(" 📝 TEXT COLORS:")
|
| 579 |
-
for role, data in result["text"].items():
|
| 580 |
-
if data:
|
| 581 |
-
log(f" {role}: {data['hex']} ({data['confidence']})")
|
| 582 |
-
|
| 583 |
-
# Background colors
|
| 584 |
-
if result.get("background"):
|
| 585 |
-
log("")
|
| 586 |
-
log(" 🖼️ BACKGROUND COLORS:")
|
| 587 |
-
for role, data in result["background"].items():
|
| 588 |
-
if data:
|
| 589 |
-
log(f" {role}: {data['hex']} ({data['confidence']})")
|
| 590 |
-
|
| 591 |
-
# Border colors
|
| 592 |
-
if result.get("border"):
|
| 593 |
-
log("")
|
| 594 |
-
log(" 📏 BORDER COLORS:")
|
| 595 |
-
for role, data in result["border"].items():
|
| 596 |
-
if data:
|
| 597 |
-
log(f" {role}: {data['hex']} ({data['confidence']})")
|
| 598 |
-
|
| 599 |
-
# Feedback colors
|
| 600 |
-
if result.get("feedback"):
|
| 601 |
-
log("")
|
| 602 |
-
log(" 🚨 FEEDBACK COLORS:")
|
| 603 |
-
for role, data in result["feedback"].items():
|
| 604 |
-
if data:
|
| 605 |
-
log(f" {role}: {data['hex']} ({data['confidence']})")
|
| 606 |
-
|
| 607 |
-
# Summary
|
| 608 |
-
summary = result.get("summary", {})
|
| 609 |
-
log("")
|
| 610 |
-
log(" 📈 SUMMARY:")
|
| 611 |
-
log(f" Total colors analyzed: {summary.get('total_colors_analyzed', 0)}")
|
| 612 |
-
log(f" Brand colors found: {summary.get('brand_colors_found', 0)}")
|
| 613 |
-
log(f" Clear hierarchy: {'Yes' if summary.get('has_clear_hierarchy') else 'No'}")
|
| 614 |
-
log(f" Analysis method: {summary.get('method', 'unknown')}")
|
| 615 |
-
log("")
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
def generate_semantic_preview_html(analysis_result: dict) -> str:
|
| 619 |
-
"""
|
| 620 |
-
Generate HTML preview showing colors organized by semantic role.
|
| 621 |
-
|
| 622 |
-
Args:
|
| 623 |
-
analysis_result: Output from SemanticColorAnalyzer
|
| 624 |
-
|
| 625 |
-
Returns:
|
| 626 |
-
HTML string for Gradio HTML component
|
| 627 |
-
"""
|
| 628 |
-
|
| 629 |
-
# Handle empty or invalid result
|
| 630 |
-
if not analysis_result:
|
| 631 |
-
return '''
|
| 632 |
-
<div class="sem-warning-box" style="padding: 40px; text-align: center; background: #fff3cd; border-radius: 8px; border: 1px solid #ffc107;">
|
| 633 |
-
<p style="color: #856404; font-size: 14px; margin: 0;">
|
| 634 |
-
⚠️ Semantic analysis did not produce results. Check the logs for errors.
|
| 635 |
-
</p>
|
| 636 |
-
</div>
|
| 637 |
-
<style>
|
| 638 |
-
.dark .sem-warning-box { background: #422006 !important; border-color: #b45309 !important; }
|
| 639 |
-
.dark .sem-warning-box p { color: #fde68a !important; }
|
| 640 |
-
</style>
|
| 641 |
-
'''
|
| 642 |
-
|
| 643 |
-
def color_card(hex_val: str, role: str, confidence: str, reason: str = "") -> str:
|
| 644 |
-
"""Generate HTML for a single color card."""
|
| 645 |
-
# Determine text color based on luminance
|
| 646 |
-
try:
|
| 647 |
-
r = int(hex_val[1:3], 16)
|
| 648 |
-
g = int(hex_val[3:5], 16)
|
| 649 |
-
b = int(hex_val[5:7], 16)
|
| 650 |
-
luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
| 651 |
-
text_color = "#1a1a1a" if luminance > 0.5 else "#ffffff"
|
| 652 |
-
except:
|
| 653 |
-
text_color = "#1a1a1a"
|
| 654 |
-
|
| 655 |
-
confidence_badge = {
|
| 656 |
-
"high": '<span class="confidence high">High</span>',
|
| 657 |
-
"medium": '<span class="confidence medium">Medium</span>',
|
| 658 |
-
"low": '<span class="confidence low">Low</span>',
|
| 659 |
-
}.get(confidence, "")
|
| 660 |
-
|
| 661 |
-
return f'''
|
| 662 |
-
<div class="sem-color-card">
|
| 663 |
-
<div class="sem-color-swatch" style="background-color: {hex_val};">
|
| 664 |
-
<span class="sem-hex-label" style="color: {text_color};">{hex_val}</span>
|
| 665 |
-
</div>
|
| 666 |
-
<div class="sem-color-details">
|
| 667 |
-
<div class="sem-role-name">{role.replace("_", " ").title()}</div>
|
| 668 |
-
{confidence_badge}
|
| 669 |
-
</div>
|
| 670 |
-
</div>
|
| 671 |
-
'''
|
| 672 |
-
|
| 673 |
-
def category_section(title: str, icon: str, colors: dict) -> str:
|
| 674 |
-
"""Generate HTML for a category section."""
|
| 675 |
-
if not colors:
|
| 676 |
-
return ""
|
| 677 |
-
|
| 678 |
-
cards_html = ""
|
| 679 |
-
for role, data in colors.items():
|
| 680 |
-
if data and isinstance(data, dict) and "hex" in data:
|
| 681 |
-
cards_html += color_card(
|
| 682 |
-
data["hex"],
|
| 683 |
-
role,
|
| 684 |
-
data.get("confidence", "medium"),
|
| 685 |
-
data.get("reason", "")
|
| 686 |
-
)
|
| 687 |
-
|
| 688 |
-
if not cards_html:
|
| 689 |
-
return ""
|
| 690 |
-
|
| 691 |
-
return f'''
|
| 692 |
-
<div class="sem-category-section">
|
| 693 |
-
<h3 class="sem-category-title">{icon} {title}</h3>
|
| 694 |
-
<div class="sem-color-grid">
|
| 695 |
-
{cards_html}
|
| 696 |
-
</div>
|
| 697 |
-
</div>
|
| 698 |
-
'''
|
| 699 |
-
|
| 700 |
-
# Build sections
|
| 701 |
-
sections_html = ""
|
| 702 |
-
sections_html += category_section("Brand Colors", "🎨", analysis_result.get("brand", {}))
|
| 703 |
-
sections_html += category_section("Text Colors", "📝", analysis_result.get("text", {}))
|
| 704 |
-
sections_html += category_section("Background Colors", "🖼️", analysis_result.get("background", {}))
|
| 705 |
-
sections_html += category_section("Border Colors", "📏", analysis_result.get("border", {}))
|
| 706 |
-
sections_html += category_section("Feedback Colors", "🚨", analysis_result.get("feedback", {}))
|
| 707 |
-
|
| 708 |
-
# Check if any sections were created
|
| 709 |
-
if not sections_html.strip():
|
| 710 |
-
return '''
|
| 711 |
-
<div class="sem-warning-box" style="padding: 40px; text-align: center; background: #fff3cd; border-radius: 8px; border: 1px solid #ffc107;">
|
| 712 |
-
<p style="color: #856404; font-size: 14px; margin: 0;">
|
| 713 |
-
⚠️ No semantic color categories were detected. The colors may not have enough context data (elements, CSS properties) for classification.
|
| 714 |
-
</p>
|
| 715 |
-
</div>
|
| 716 |
-
<style>
|
| 717 |
-
.dark .sem-warning-box { background: #422006 !important; border-color: #b45309 !important; }
|
| 718 |
-
.dark .sem-warning-box p { color: #fde68a !important; }
|
| 719 |
-
</style>
|
| 720 |
-
'''
|
| 721 |
-
|
| 722 |
-
# Summary
|
| 723 |
-
summary = analysis_result.get("summary", {})
|
| 724 |
-
summary_html = f'''
|
| 725 |
-
<div class="sem-summary-section">
|
| 726 |
-
<h3 class="sem-summary-title">📈 Analysis Summary</h3>
|
| 727 |
-
<div class="sem-summary-stats">
|
| 728 |
-
<div class="sem-stat">
|
| 729 |
-
<span class="sem-stat-value">{summary.get("total_colors_analyzed", 0)}</span>
|
| 730 |
-
<span class="sem-stat-label">Colors Analyzed</span>
|
| 731 |
-
</div>
|
| 732 |
-
<div class="sem-stat">
|
| 733 |
-
<span class="sem-stat-value">{summary.get("brand_colors_found", 0)}</span>
|
| 734 |
-
<span class="sem-stat-label">Brand Colors</span>
|
| 735 |
-
</div>
|
| 736 |
-
<div class="sem-stat">
|
| 737 |
-
<span class="sem-stat-value">{"✓" if summary.get("has_clear_hierarchy") else "✗"}</span>
|
| 738 |
-
<span class="sem-stat-label">Clear Hierarchy</span>
|
| 739 |
-
</div>
|
| 740 |
-
<div class="sem-stat">
|
| 741 |
-
<span class="sem-stat-value">{summary.get("method", "rule-based").upper()}</span>
|
| 742 |
-
<span class="sem-stat-label">Analysis Method</span>
|
| 743 |
-
</div>
|
| 744 |
-
</div>
|
| 745 |
-
</div>
|
| 746 |
-
'''
|
| 747 |
-
|
| 748 |
-
html = f'''
|
| 749 |
-
<style>
|
| 750 |
-
.sem-preview {{
|
| 751 |
-
font-family: system-ui, -apple-system, sans-serif;
|
| 752 |
-
padding: 20px;
|
| 753 |
-
background: #f5f5f5 !important;
|
| 754 |
-
border-radius: 12px;
|
| 755 |
-
}}
|
| 756 |
-
|
| 757 |
-
.sem-category-section {{
|
| 758 |
-
margin-bottom: 24px;
|
| 759 |
-
background: #ffffff !important;
|
| 760 |
-
border-radius: 8px;
|
| 761 |
-
padding: 16px;
|
| 762 |
-
border: 1px solid #d0d0d0 !important;
|
| 763 |
-
}}
|
| 764 |
-
|
| 765 |
-
.sem-category-title {{
|
| 766 |
-
font-size: 16px;
|
| 767 |
-
font-weight: 700;
|
| 768 |
-
color: #1a1a1a !important;
|
| 769 |
-
margin: 0 0 16px 0;
|
| 770 |
-
padding-bottom: 8px;
|
| 771 |
-
border-bottom: 2px solid #e0e0e0 !important;
|
| 772 |
-
}}
|
| 773 |
-
|
| 774 |
-
.sem-color-grid {{
|
| 775 |
-
display: grid;
|
| 776 |
-
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
| 777 |
-
gap: 12px;
|
| 778 |
-
}}
|
| 779 |
-
|
| 780 |
-
.sem-color-card {{
|
| 781 |
-
background: #f0f0f0 !important;
|
| 782 |
-
border-radius: 8px;
|
| 783 |
-
overflow: hidden;
|
| 784 |
-
border: 1px solid #d0d0d0 !important;
|
| 785 |
-
}}
|
| 786 |
-
|
| 787 |
-
.sem-color-swatch {{
|
| 788 |
-
height: 80px;
|
| 789 |
-
display: flex;
|
| 790 |
-
align-items: center;
|
| 791 |
-
justify-content: center;
|
| 792 |
-
}}
|
| 793 |
-
|
| 794 |
-
.sem-hex-label {{
|
| 795 |
-
font-family: 'SF Mono', Monaco, monospace;
|
| 796 |
-
font-size: 12px;
|
| 797 |
-
font-weight: 600;
|
| 798 |
-
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
|
| 799 |
-
}}
|
| 800 |
-
|
| 801 |
-
.sem-color-details {{
|
| 802 |
-
padding: 10px;
|
| 803 |
-
text-align: center;
|
| 804 |
-
background: #ffffff !important;
|
| 805 |
-
}}
|
| 806 |
-
|
| 807 |
-
.sem-role-name {{
|
| 808 |
-
font-size: 12px;
|
| 809 |
-
font-weight: 600;
|
| 810 |
-
color: #1a1a1a !important;
|
| 811 |
-
margin-bottom: 4px;
|
| 812 |
-
}}
|
| 813 |
-
|
| 814 |
-
.sem-preview .confidence {{
|
| 815 |
-
font-size: 10px;
|
| 816 |
-
padding: 2px 8px;
|
| 817 |
-
border-radius: 10px;
|
| 818 |
-
font-weight: 500;
|
| 819 |
-
display: inline-block;
|
| 820 |
-
}}
|
| 821 |
-
|
| 822 |
-
.sem-preview .confidence.high {{
|
| 823 |
-
background: #dcfce7 !important;
|
| 824 |
-
color: #166534 !important;
|
| 825 |
-
}}
|
| 826 |
-
|
| 827 |
-
.sem-preview .confidence.medium {{
|
| 828 |
-
background: #fef9c3 !important;
|
| 829 |
-
color: #854d0e !important;
|
| 830 |
-
}}
|
| 831 |
-
|
| 832 |
-
.sem-preview .confidence.low {{
|
| 833 |
-
background: #fee2e2 !important;
|
| 834 |
-
color: #991b1b !important;
|
| 835 |
-
}}
|
| 836 |
-
|
| 837 |
-
.sem-summary-section {{
|
| 838 |
-
background: #ffffff !important;
|
| 839 |
-
border-radius: 8px;
|
| 840 |
-
padding: 16px;
|
| 841 |
-
border: 1px solid #d0d0d0 !important;
|
| 842 |
-
}}
|
| 843 |
-
|
| 844 |
-
.sem-summary-title {{
|
| 845 |
-
font-size: 16px;
|
| 846 |
-
font-weight: 700;
|
| 847 |
-
color: #1a1a1a !important;
|
| 848 |
-
margin: 0 0 16px 0;
|
| 849 |
-
}}
|
| 850 |
-
|
| 851 |
-
.sem-summary-stats {{
|
| 852 |
-
display: grid;
|
| 853 |
-
grid-template-columns: repeat(4, 1fr);
|
| 854 |
-
gap: 16px;
|
| 855 |
-
}}
|
| 856 |
-
|
| 857 |
-
.sem-stat {{
|
| 858 |
-
text-align: center;
|
| 859 |
-
padding: 12px;
|
| 860 |
-
background: #f0f0f0 !important;
|
| 861 |
-
border-radius: 8px;
|
| 862 |
-
}}
|
| 863 |
-
|
| 864 |
-
.sem-stat-value {{
|
| 865 |
-
display: block;
|
| 866 |
-
font-size: 24px;
|
| 867 |
-
font-weight: 700;
|
| 868 |
-
color: #1a1a1a !important;
|
| 869 |
-
}}
|
| 870 |
-
|
| 871 |
-
.sem-stat-label {{
|
| 872 |
-
display: block;
|
| 873 |
-
font-size: 11px;
|
| 874 |
-
color: #555 !important;
|
| 875 |
-
margin-top: 4px;
|
| 876 |
-
}}
|
| 877 |
-
|
| 878 |
-
/* Dark mode */
|
| 879 |
-
.dark .sem-preview {{ background: #0f172a !important; }}
|
| 880 |
-
.dark .sem-category-section {{ background: #1e293b !important; border-color: #475569 !important; }}
|
| 881 |
-
.dark .sem-category-title {{ color: #f1f5f9 !important; border-bottom-color: #475569 !important; }}
|
| 882 |
-
.dark .sem-color-card {{ background: #334155 !important; border-color: #475569 !important; }}
|
| 883 |
-
.dark .sem-color-details {{ background: #1e293b !important; }}
|
| 884 |
-
.dark .sem-role-name {{ color: #f1f5f9 !important; }}
|
| 885 |
-
.dark .sem-preview .confidence.high {{ background: #14532d !important; color: #86efac !important; }}
|
| 886 |
-
.dark .sem-preview .confidence.medium {{ background: #422006 !important; color: #fde68a !important; }}
|
| 887 |
-
.dark .sem-preview .confidence.low {{ background: #450a0a !important; color: #fca5a5 !important; }}
|
| 888 |
-
.dark .sem-summary-section {{ background: #1e293b !important; border-color: #475569 !important; }}
|
| 889 |
-
.dark .sem-summary-title {{ color: #f1f5f9 !important; }}
|
| 890 |
-
.dark .sem-stat {{ background: #334155 !important; }}
|
| 891 |
-
.dark .sem-stat-value {{ color: #f1f5f9 !important; }}
|
| 892 |
-
.dark .sem-stat-label {{ color: #94a3b8 !important; }}
|
| 893 |
-
</style>
|
| 894 |
-
|
| 895 |
-
<div class="sem-preview">
|
| 896 |
-
{sections_html}
|
| 897 |
-
{summary_html}
|
| 898 |
-
</div>
|
| 899 |
-
'''
|
| 900 |
-
|
| 901 |
-
return html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|