Spaces:
Sleeping
Sleeping
| """ | |
| CV Evaluator - Multi-Agent Streamlit Application | |
| Main entry point for the CV evaluation system. | |
| """ | |
| import json | |
| import logging | |
| import os | |
| import sys | |
| import warnings | |
| from datetime import datetime | |
| import streamlit as st | |
| from dotenv import load_dotenv | |
| # ── Setup ── | |
| load_dotenv(override=False) # Ne pas écraser les variables d'environnement existantes | |
| logging.basicConfig( | |
| level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s" | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # Suppress urllib3/requests compatibility warnings | |
| warnings.filterwarnings("ignore", category=ImportWarning, module="requests") | |
| # Add project root to path | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| from models.schemas import FinalReport | |
| from orchestrator import CVEvaluationOrchestrator | |
| from utils.pdf_parser import extract_text_from_uploaded_file | |
| # ── Page config ── | |
| st.set_page_config( | |
| page_title="CV Evaluator - JEMS Group", | |
| page_icon="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAAulBMVEUAAAD9sxr+YXP/bmP6eFj/Tob/vBFHcEz/iUf+YXL/UoL/tRn/VX7+shv/ToX/pSn/uBb/vRD/sB7/XnT/ljr+mjX+vRH/////TYf/vRD/U4H/WHz/jj//XXX/oiz/qyL/gkn/mzT/fVP/aGj/Ym//tBn/cGP/ljn/d1v/TXb/4+H/hYj/cUz/V2j/qgj/4sH/0tT/qrT/xr//vqn/z5r/maX/j23/rJn/Xkr/8/D/jiP/w2X+fBr/wEkOWfK9AAAAF3RSTlMBL17fFObgAP42wLzzVoXlfPH5f9SEgb0o5TAAAAGFSURBVCiRZdLpkoIwDADgckbAa92j4IkIrhyiiPfq+7/WJi0o6/KD6eSbtJkkjIkPAKx322i3DbtrAbDmB6BqYTgZjYZD13UNtcEAurZcLMJJra6hPxSsYLX8q64FtfmB0PC/gj5u+cFqRanhE11xM3wQBtnx5WIDETrzb1S/5DwVGsexVBUY9KZCOee7c5qu18X2IBVTlRnh+Ii4PXFepHgYVTWx/kxoSZkJ58kBD4VM7TInmk2n8+sGY+WF80uBhyQWRdnMjEivGOIn+lEmj0XJBnvzIrqYQvQuT8UPERWR9IdCVDE/i3SZykwPNd9T6IZvJudEVES9MJjzwMtth0VlO6p7TZ2yWR/Ry6nYzXVcnrKMytoK7DKF0CPcYy98HIFEVIuBWaNsY5AlhNjkNva2Q3jHxLzSY5KkND2VxiJSvXsum0y5GU1Po0UCXaDsBT0byMlXa6TUOq0upqWpl6jSqKnWczl1s3lxy9fkAtWb2zGf2lJfdp6B8uWYg0HP+VSgtl/wnEmGER38dAAAAABJRU5ErkJggg==", | |
| layout="wide", | |
| initial_sidebar_state="expanded", | |
| ) | |
| # ── Custom CSS ── | |
| st.markdown( | |
| """ | |
| <style> | |
| /* ── Global fonts & base ── */ | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); | |
| html, body, [class*="css"] { | |
| font-family: 'Inter', sans-serif; | |
| } | |
| /* ── Header banner ── */ | |
| .main-header { | |
| text-align: center; | |
| padding: 2rem 1.5rem; | |
| background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); | |
| color: white; | |
| border-radius: 16px; | |
| margin-bottom: 2rem; | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); | |
| border: 1px solid rgba(255,255,255,0.08); | |
| } | |
| .main-header h1 { | |
| font-size: 2.2rem; | |
| font-weight: 700; | |
| margin: 0 0 0.4rem 0; | |
| letter-spacing: -0.5px; | |
| } | |
| .main-header .subtitle { | |
| font-size: 1rem; | |
| opacity: 0.75; | |
| margin: 0; | |
| } | |
| .main-header .badge-row { | |
| display: flex; | |
| justify-content: center; | |
| gap: 0.6rem; | |
| margin-top: 0.8rem; | |
| flex-wrap: wrap; | |
| } | |
| .main-header .badge { | |
| background: rgba(255,255,255,0.12); | |
| border: 1px solid rgba(255,255,255,0.2); | |
| border-radius: 20px; | |
| padding: 0.2rem 0.75rem; | |
| font-size: 0.78rem; | |
| font-weight: 500; | |
| backdrop-filter: blur(4px); | |
| } | |
| /* ── Upload zone ── */ | |
| .upload-zone { | |
| border: 2px dashed #4f6ef7; | |
| border-radius: 12px; | |
| padding: 2rem; | |
| text-align: center; | |
| background: linear-gradient(135deg, rgba(79,110,247,0.05), rgba(118,75,162,0.05)); | |
| margin-bottom: 1rem; | |
| } | |
| .upload-zone h3 { color: #4f6ef7; margin-bottom: 0.3rem; } | |
| .upload-zone p { color: #888; font-size: 0.9rem; margin: 0; } | |
| /* ── File info banner ── */ | |
| .file-info-banner { | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| padding: 1rem 1.25rem; | |
| background: linear-gradient(135deg, #d4edda, #c3e6cb); | |
| border: 1px solid #b1dfbb; | |
| border-radius: 10px; | |
| margin: 0.75rem 0 1.25rem 0; | |
| } | |
| .file-info-banner .icon { font-size: 1.8rem; } | |
| .file-info-banner .name { font-weight: 600; font-size: 1rem; color: #155724; } | |
| .file-info-banner .size { font-size: 0.82rem; color: #2d6a4f; } | |
| /* ── Score cards ── */ | |
| .score-card { | |
| text-align: center; | |
| padding: 1.5rem 1rem; | |
| border-radius: 14px; | |
| margin: 0.4rem 0; | |
| box-shadow: 0 4px 16px rgba(0,0,0,0.12); | |
| transition: transform 0.2s; | |
| } | |
| .score-card:hover { transform: translateY(-2px); } | |
| .score-excellent { background: linear-gradient(135deg, #11998e, #38ef7d); color: white; } | |
| .score-good { background: linear-gradient(135deg, #36d1dc, #5b86e5); color: white; } | |
| .score-average { background: linear-gradient(135deg, #f2994a, #f2c94c); color: white; } | |
| .score-low { background: linear-gradient(135deg, #eb3349, #f45c43); color: white; } | |
| /* ── Verdict box ── */ | |
| .verdict-box { | |
| padding: 1.25rem 1.5rem; | |
| border-radius: 10px; | |
| border-left: 5px solid; | |
| margin: 1rem 0; | |
| } | |
| .verdict-oui { background: #d4edda; border-color: #28a745; } | |
| .verdict-non { background: #f8d7da; border-color: #dc3545; } | |
| .verdict-maybe { background: #fff3cd; border-color: #ffc107; } | |
| /* ── Agent section (sidebar) ── */ | |
| .agent-section { | |
| background: rgba(79,110,247,0.06); | |
| padding: 0.85rem 1rem; | |
| border-radius: 8px; | |
| margin: 0.4rem 0; | |
| border-left: 3px solid #4f6ef7; | |
| } | |
| /* ── Progress bar color ── */ | |
| .stProgress > div > div > div > div { | |
| background: linear-gradient(90deg, #4f6ef7, #764ba2); | |
| } | |
| /* ── Action buttons row ── */ | |
| .action-row { | |
| display: flex; | |
| gap: 0.75rem; | |
| margin: 1rem 0; | |
| flex-wrap: wrap; | |
| } | |
| /* ── Section divider ── */ | |
| .section-title { | |
| font-size: 1.2rem; | |
| font-weight: 600; | |
| color: #1a1a2e; | |
| padding-bottom: 0.4rem; | |
| border-bottom: 2px solid #4f6ef7; | |
| margin: 1.5rem 0 1rem 0; | |
| } | |
| /* ── Info chip ── */ | |
| .chip { | |
| display: inline-block; | |
| background: #eef0ff; | |
| color: #4f6ef7; | |
| border-radius: 20px; | |
| padding: 0.15rem 0.65rem; | |
| font-size: 0.78rem; | |
| font-weight: 500; | |
| margin: 0.15rem; | |
| } | |
| /* ── Tabs style override ── */ | |
| .stTabs [data-baseweb="tab-list"] { | |
| gap: 4px; | |
| background: #f3f4f8; | |
| padding: 4px; | |
| border-radius: 10px; | |
| } | |
| .stTabs [data-baseweb="tab"] { | |
| border-radius: 8px; | |
| padding: 6px 14px; | |
| font-size: 0.88rem; | |
| } | |
| .stTabs [aria-selected="true"] { | |
| background: white !important; | |
| box-shadow: 0 1px 4px rgba(0,0,0,0.12); | |
| } | |
| /* ── Barème card ── */ | |
| .bareme-card { | |
| padding: 1.4rem 1.6rem; | |
| border-radius: 16px; | |
| color: white; | |
| box-shadow: 0 6px 24px rgba(0,0,0,0.22); | |
| margin-bottom: 1.2rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 1.2rem; | |
| border: 1px solid rgba(255,255,255,0.15); | |
| } | |
| .bareme-card .bc-icon { font-size: 3rem; line-height: 1; flex-shrink: 0; } | |
| .bareme-card .bc-body { flex: 1; } | |
| .bareme-card .bc-label { font-size: 1.55rem; font-weight: 800; letter-spacing: -.5px; margin: 0; line-height: 1.15; } | |
| .bareme-card .bc-desc { font-size: .95rem; opacity: .85; margin: .3rem 0 0; } | |
| .bareme-card .bc-score { font-size: 2.6rem; font-weight: 900; line-height: 1; flex-shrink: 0; text-align:right; } | |
| .bareme-card .bc-score span { font-size: 1.1rem; font-weight: 500; opacity: .8; } | |
| /* ── Barème scale strip ── */ | |
| .bareme-scale { | |
| display: flex; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| height: 36px; | |
| margin: .6rem 0 1.2rem; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.12); | |
| } | |
| .bareme-scale-seg { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: .72rem; | |
| font-weight: 600; | |
| color: white; | |
| transition: opacity .2s; | |
| cursor: default; | |
| } | |
| .bareme-scale-seg.active { | |
| outline: 3px solid white; | |
| outline-offset: -2px; | |
| z-index: 1; | |
| border-radius: 4px; | |
| } | |
| .bareme-scale-seg.inactive { opacity: .38; } | |
| /* ── Reset banner ── */ | |
| .reset-banner { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 0.9rem 1.25rem; | |
| background: linear-gradient(135deg, #fff8e1, #fff3cd); | |
| border: 1px solid #ffe082; | |
| border-radius: 10px; | |
| margin-bottom: 1.2rem; | |
| } | |
| .reset-banner .label { font-size: 0.9rem; font-weight: 500; color: #795548; } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # ══════════════════════════════════════════════ | |
| # HELPERS | |
| # ══════════════════════════════════════════════ | |
| def get_score_class(score_100: float) -> str: | |
| if score_100 >= 75: | |
| return "score-excellent" | |
| if score_100 >= 55: | |
| return "score-good" | |
| if score_100 >= 35: | |
| return "score-average" | |
| return "score-low" | |
| # ── Barème d'appréciation officiel ── | |
| BAREME = [ | |
| { | |
| "range": (0, 10), | |
| "label": "Inexploitable", | |
| "short": "DC inutilisable, décrédibilisant.", | |
| "emoji": "🚫", | |
| "gradient": "linear-gradient(135deg,#3a0000,#8b0000)", | |
| "text": "#ffcdd2", | |
| "bar_color": "#c62828", | |
| }, | |
| { | |
| "range": (11, 12), | |
| "label": "Très insuffisant", | |
| "short": "DC incomplet, brouillon, donne une mauvaise image.", | |
| "emoji": "❌", | |
| "gradient": "linear-gradient(135deg,#7f0000,#d32f2f)", | |
| "text": "#ffcdd2", | |
| "bar_color": "#e53935", | |
| }, | |
| { | |
| "range": (13, 14), | |
| "label": "Insuffisant", | |
| "short": "DC exploitable mais faible, non vendeur.", | |
| "emoji": "⚠️", | |
| "gradient": "linear-gradient(135deg,#bf360c,#f4511e)", | |
| "text": "#ffe0b2", | |
| "bar_color": "#f4511e", | |
| }, | |
| { | |
| "range": (15, 16), | |
| "label": "Correct", | |
| "short": "DC utilisable mais perfectible, profil crédible mais banal.", | |
| "emoji": "📋", | |
| "gradient": "linear-gradient(135deg,#e65100,#fb8c00)", | |
| "text": "#fff3e0", | |
| "bar_color": "#fb8c00", | |
| }, | |
| { | |
| "range": (17, 17), | |
| "label": "Bon", | |
| "short": "DC solide, clair, cohérent, peut être transmis.", | |
| "emoji": "👍", | |
| "gradient": "linear-gradient(135deg,#1565c0,#1e88e5)", | |
| "text": "#e3f2fd", | |
| "bar_color": "#1e88e5", | |
| }, | |
| { | |
| "range": (18, 19), | |
| "label": "Très bon", | |
| "short": "DC percutant, vendeur, bien rédigé.", | |
| "emoji": "🌟", | |
| "gradient": "linear-gradient(135deg,#1b5e20,#2e7d32)", | |
| "text": "#e8f5e9", | |
| "bar_color": "#43a047", | |
| }, | |
| { | |
| "range": (20, 20), | |
| "label": "Excellent", | |
| "short": "DC exemplaire, parfaitement aligné, riche en résultats et démonstrations.", | |
| "emoji": "🏆", | |
| "gradient": "linear-gradient(135deg,#4a148c,#7b1fa2)", | |
| "text": "#f3e5f5", | |
| "bar_color": "#8e24aa", | |
| }, | |
| ] | |
| ALL_LEVELS = [ | |
| (0, 10, "Inexploitable", "🚫", "#c62828"), | |
| (11, 12, "Très insuffisant", "❌", "#e53935"), | |
| (13, 14, "Insuffisant", "⚠️", "#f4511e"), | |
| (15, 16, "Correct", "📋", "#fb8c00"), | |
| (17, 17, "Bon", "👍", "#1e88e5"), | |
| (18, 19, "Très bon", "🌟", "#43a047"), | |
| (20, 20, "Excellent", "🏆", "#8e24aa"), | |
| ] | |
| def get_bareme(note_sur_20: float) -> dict: | |
| """Return the matching barème entry for a /20 score.""" | |
| n = round(note_sur_20) | |
| for entry in BAREME: | |
| lo, hi = entry["range"] | |
| if lo <= n <= hi: | |
| return entry | |
| # Fallback: clamp to extremes | |
| return BAREME[0] if n < 10 else BAREME[-1] | |
| def reset_evaluation(): | |
| """ | |
| Clear all evaluation-related session state keys. | |
| Called when user wants to start a new evaluation. | |
| """ | |
| keys_to_clear = [ | |
| "report", | |
| "cv_text", | |
| "evaluated_filename", | |
| "evaluation_started", | |
| "evaluation_complete", | |
| ] | |
| for key in keys_to_clear: | |
| st.session_state.pop(key, None) | |
| # ══════════════════════════════════════════════ | |
| # LAYOUT COMPONENTS | |
| # ══════════════════════════════════════════════ | |
| def render_header(): | |
| st.markdown( | |
| """ | |
| <style> | |
| /* Animation de clignotement */ | |
| @keyframes blinker { | |
| 50% { | |
| opacity: 0; /* Devient invisible au milieu du cycle */ | |
| } | |
| } | |
| /* Classe pour le logo qui clignote */ | |
| .blinking-logo { | |
| animation: blinker 4.0s linear infinite; | |
| height: 50px; | |
| } | |
| .header-container { | |
| display: flex; | |
| flex-direction: row; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 10px; | |
| margin-bottom: 10px; | |
| } | |
| .main-header { | |
| text-align: center; | |
| } | |
| </style> | |
| <div class="main-header"> | |
| <div class="header-container"> | |
| <img src="https://www.jems-group.com/wp-content/uploads/2021/12/Logo.svg" | |
| alt="JEMS Group Logo" | |
| class="blinking-logo"> | |
| <img src="https://readme-typing-svg.demolab.com?font=Bungee+Spice&size=40&duration=3000&pause=800&color=FFFFFF&vCenter=true&width=350&lines=CV+Evaluator" | |
| alt="CV Evaluator"> | |
| </div> | |
| <p class="subtitle">Système Multi-Agents d'Évaluation de CV propulsé par IA GEN</p> | |
| <div class="badge-row"> | |
| <span class="badge">⚡ 6 agents spécialisés</span> | |
| <span class="badge">🧠 Analyse déterministe</span> | |
| <span class="badge">📋 Rapport structuré</span> | |
| <span class="badge">🔗 LangChainc</span> | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def render_sidebar(): | |
| with st.sidebar: | |
| st.markdown( | |
| """ | |
| <div style="text-align:center;padding:1rem 0 .5rem;"> | |
| <img src="https://img.icons8.com/fluency/96/artificial-intelligence.png" width="56"> | |
| <div style="font-size:1.1rem;font-weight:700;color:#1a1a2e;margin-top:.4rem;">Configuration</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.divider() | |
| # ── Mode Gratuit Ollama Cloud ── | |
| use_ollama = st.toggle( | |
| "🆓 Utiliser le mode gratuit (Ollama Cloud)", | |
| value=False, | |
| help="Aucune clé API requise · Modèles open-source · Totalement gratuit", | |
| ) | |
| if use_ollama: | |
| # Ollama Cloud models available | |
| model = st.selectbox( | |
| "🤖 Modèle Ollama Cloud", | |
| ["gpt-oss:20b-cloud", "gpt-oss:120b-cloud", "gemma4:31b-cloud"], | |
| index=0, | |
| help="Modèles open-source accessibles via l'API Ollama Cloud", | |
| ) | |
| api_key = ( | |
| "" # No API key needed for Ollama Cloud (uses default embedded key) | |
| ) | |
| st.info("🔑 Clé API Ollama incluse automatiquement") | |
| else: | |
| # ── Mode Premium (Gemini / OpenAI) ── | |
| api_key = st.text_input( | |
| "🔑 Clé API Google Gemini Ou OpenAI", | |
| type="password", | |
| value=os.getenv("GOOGLE_API_KEY", ""), | |
| help="Obtenez votre clé sur https://makersuite.google.com/app/apikey", | |
| ) | |
| model = st.selectbox( | |
| "🤖 Modèle Gemini & OpenAI", | |
| [ | |
| "gemini-2.5-flash-lite", | |
| "gemini-2.5-flash", | |
| "gemini-2.5-pro", | |
| "gpt-5", | |
| "gpt-5-mini", | |
| "gpt-5-nano", | |
| "gpt-4o", | |
| "gpt-4o-mini", | |
| "gpt-4.1", | |
| "gpt-4.1-mini", | |
| "gpt-4.1-nano", | |
| "gpt-4-turbo", | |
| "gpt-4", | |
| ], | |
| index=0, | |
| help="Flash = rapide & économique · Pro = plus précis", | |
| ) | |
| st.divider() | |
| st.caption("v1.0.0 · CV-Evaluator © JEMS GROUP") | |
| return api_key, model, use_ollama | |
| # ══════════════════════════════════════════════ | |
| # RESULT RENDERERS | |
| # ══════════════════════════════════════════════ | |
| def _bareme_color(note_20: float) -> str: | |
| """Return the exact hex color for a /20 score per the barème.""" | |
| n = round(note_20) | |
| if n <= 10: | |
| return "#c62828" # Rouge — Inexploitable | |
| if n <= 12: | |
| return "#e53935" # Rouge-orange — Très insuffisant | |
| if n <= 14: | |
| return "#f4511e" # Orange — Insuffisant | |
| if n <= 16: | |
| return "#7cb342" # Vert clair — Correct | |
| if n == 17: | |
| return "#388e3c" # Vert moyen — Bon | |
| if n <= 19: | |
| return "#2e7d32" # Vert foncé — Très bon | |
| return "#1b5e20" # Vert très foncé — Excellent | |
| def _progress_ring_svg( | |
| value: float, max_val: float, label: str, sublabel: str, color: str, size: int = 160 | |
| ) -> str: | |
| """ | |
| Generate an SVG animated progress ring. | |
| value : raw score value | |
| max_val : maximum possible value | |
| label : big centred text (the score string) | |
| sublabel: small text below (e.g. '/ 20') | |
| color : stroke colour hex | |
| """ | |
| pct = min(value / max_val, 1.0) | |
| radius = (size - 24) / 2 | |
| circ = 2 * 3.14159 * radius | |
| dash_val = pct * circ | |
| track_color = "#e8eaf0" | |
| cx = cy = size / 2 | |
| anim_id = f"anim_{label.replace('/', '').replace(' ', '')}" | |
| return f""" | |
| <svg width="{size}" height="{size}" viewBox="0 0 {size} {size}" xmlns="http://www.w3.org/2000/svg"> | |
| <defs> | |
| <style> | |
| @keyframes {anim_id} {{ | |
| from {{ stroke-dashoffset: {circ:.2f}; }} | |
| to {{ stroke-dashoffset: {circ - dash_val:.2f}; }} | |
| }} | |
| </style> | |
| <filter id="glow_{anim_id}"> | |
| <feGaussianBlur stdDeviation="3" result="blur"/> | |
| <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge> | |
| </filter> | |
| </defs> | |
| <!-- Track --> | |
| <circle cx="{cx}" cy="{cy}" r="{radius}" fill="none" | |
| stroke="{track_color}" stroke-width="12"/> | |
| <!-- Progress arc --> | |
| <circle cx="{cx}" cy="{cy}" r="{radius}" fill="none" | |
| stroke="{color}" stroke-width="12" | |
| stroke-linecap="round" | |
| stroke-dasharray="{circ:.2f}" | |
| stroke-dashoffset="{circ:.2f}" | |
| transform="rotate(-90 {cx} {cy})" | |
| filter="url(#glow_{anim_id})" | |
| style="animation:{anim_id} 1.2s ease-out forwards;"> | |
| <animate attributeName="stroke-dashoffset" | |
| from="{circ:.2f}" to="{circ - dash_val:.2f}" | |
| dur="1.2s" fill="freeze" calcMode="spline" | |
| keyTimes="0;1" keySplines="0.4 0 0.2 1"/> | |
| </circle> | |
| <!-- Centre label --> | |
| <text x="{cx}" y="{cy - 8}" text-anchor="middle" dominant-baseline="middle" | |
| font-family="Inter,sans-serif" font-size="28" font-weight="800" fill="{color}">{label}</text> | |
| <text x="{cx}" y="{cy + 20}" text-anchor="middle" | |
| font-family="Inter,sans-serif" font-size="13" font-weight="500" fill="#9ea3b0">{sublabel}</text> | |
| </svg>""" | |
| def render_scores(report: FinalReport): | |
| scoring = report.scoring | |
| score_20 = scoring.note_finale_sur_20 | |
| score_10 = scoring.note_finale_sur_10 | |
| score_100 = scoring.note_finale_sur_100 | |
| bareme = get_bareme(score_20) | |
| ring_color = _bareme_color(score_20) | |
| # ── Section title ── | |
| st.markdown('<div class="section-title">📊 Scores</div>', unsafe_allow_html=True) | |
| # ── 3 progress rings ── | |
| c10, c20, c100 = st.columns(3) | |
| ring_css = """ | |
| <style> | |
| .ring-wrapper { | |
| display:flex; flex-direction:column; align-items:center; | |
| padding:1.4rem 1rem 1rem; | |
| background:#fff; | |
| border-radius:18px; | |
| box-shadow:0 2px 16px rgba(0,0,0,.07); | |
| border:1px solid #f0f1f5; | |
| transition:transform .2s; | |
| } | |
| .ring-wrapper:hover { transform:translateY(-3px); box-shadow:0 6px 24px rgba(0,0,0,.11); } | |
| .ring-title { | |
| font-family:'Inter',sans-serif; | |
| font-size:.78rem; font-weight:600; letter-spacing:.06em; | |
| text-transform:uppercase; color:#9ea3b0; margin-bottom:.6rem; | |
| } | |
| .ring-badge { | |
| margin-top:.8rem; | |
| display:inline-block; | |
| padding:.28rem .85rem; | |
| border-radius:20px; | |
| font-size:.82rem; font-weight:700; | |
| color:white; | |
| } | |
| </style> | |
| """ | |
| st.markdown(ring_css, unsafe_allow_html=True) | |
| with c10: | |
| svg = _progress_ring_svg(score_10, 10, f"{score_10:.1f}", "/ 10", ring_color) | |
| st.markdown( | |
| f'<div class="ring-wrapper">' | |
| f'<div class="ring-title">Score sur 10</div>' | |
| f"{svg}" | |
| f'<div class="ring-badge" style="background:{ring_color};">{bareme["emoji"]} {bareme["label"]}</div>' | |
| f"</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| with c20: | |
| svg = _progress_ring_svg( | |
| score_20, 20, f"{score_20:.1f}", "/ 20", ring_color, size=190 | |
| ) | |
| st.markdown( | |
| f'<div class="ring-wrapper" style="border:2px solid {ring_color}30;">' | |
| f'<div class="ring-title" style="color:{ring_color};">⭐ Score sur 20</div>' | |
| f"{svg}" | |
| f'<div class="ring-badge" style="background:{ring_color};font-size:.9rem;padding:.35rem 1.1rem;">' | |
| f"{bareme['emoji']} {bareme['label']}</div>" | |
| f"</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| with c100: | |
| svg = _progress_ring_svg( | |
| score_100, 100, f"{score_100:.0f}", "/ 100", ring_color | |
| ) | |
| st.markdown( | |
| f'<div class="ring-wrapper">' | |
| f'<div class="ring-title">Score sur 100</div>' | |
| f"{svg}" | |
| f'<div class="ring-badge" style="background:{ring_color};">{bareme["emoji"]} {bareme["label"]}</div>' | |
| f"</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| # ── Recommandation + Verdict row ── | |
| col_rec, col_ver = st.columns(2) | |
| with col_rec: | |
| rec = report.quality_control.recommandation | |
| rec_emoji = {"Oui": "✅", "Non": "❌", "Peut-être": "⚠️"}.get(rec, "❓") | |
| rec_color = {"Oui": "#155724", "Non": "#721c24", "Peut-être": "#856404"}.get( | |
| rec, "#6c757d" | |
| ) | |
| rec_bg = {"Oui": "#d4edda", "Non": "#f8d7da", "Peut-être": "#fff3cd"}.get( | |
| rec, "#f0f2f8" | |
| ) | |
| st.markdown( | |
| f""" | |
| <div style="display:flex;align-items:center;gap:1rem;padding:1.1rem 1.4rem; | |
| background:{rec_bg};border-radius:12px;border:1px solid {rec_color}30;"> | |
| <span style="font-size:2rem;">{rec_emoji}</span> | |
| <div> | |
| <div style="font-size:.72rem;font-weight:600;text-transform:uppercase; | |
| letter-spacing:.06em;color:{rec_color};opacity:.7;">Recommandation</div> | |
| <div style="font-size:1.2rem;font-weight:800;color:{rec_color};">{rec}</div> | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| with col_ver: | |
| verdict_label = report.quality_control.verdict.replace("_", " ").title() | |
| verdict_emoji = { | |
| "profil vendeur": "🌟", | |
| "profil banal": "😐", | |
| "profil intermediaire": "🤔", | |
| }.get(report.quality_control.verdict.replace("_", " "), "❓") | |
| st.markdown( | |
| f""" | |
| <div style="display:flex;align-items:center;gap:1rem;padding:1.1rem 1.4rem; | |
| background:#f5f6fa;border-radius:12px;border:1px solid #e0e2ea;"> | |
| <span style="font-size:2rem;">{verdict_emoji}</span> | |
| <div> | |
| <div style="font-size:.72rem;font-weight:600;text-transform:uppercase; | |
| letter-spacing:.06em;color:#888;">Verdict</div> | |
| <div style="font-size:1.2rem;font-weight:800;color:#1a1a2e;">{verdict_label}</div> | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| # ── Detail by criterion ── | |
| st.markdown( | |
| '<div class="section-title">📈 Détail par critère</div>', unsafe_allow_html=True | |
| ) | |
| cols = st.columns(4) | |
| for i, detail in enumerate(scoring.details): | |
| with cols[i]: | |
| pct = detail.score_brut * 10 | |
| bar_color = _bareme_color( | |
| detail.score_brut * 2 | |
| ) # map /10 → /20 scale for colour | |
| st.metric( | |
| label=f"{detail.critere}", | |
| value=f"{detail.score_brut}/10", | |
| delta=f"Pondéré : {detail.score_pondere:.2f} (×{detail.poids})", | |
| ) | |
| st.markdown( | |
| f'<div style="height:6px;border-radius:4px;background:#e8eaf0;overflow:hidden;">' | |
| f'<div style="width:{pct}%;height:100%;background:{bar_color};' | |
| f'border-radius:4px;transition:width 1s ease;"></div></div><br>', | |
| unsafe_allow_html=True, | |
| ) | |
| with st.expander("🔢 Détail du calcul mathématique"): | |
| st.code(scoring.calcul_intermediaire) | |
| if scoring.validation_mathematique: | |
| st.success("✅ Validation mathématique OK") | |
| else: | |
| st.error("❌ Erreur de calcul détectée") | |
| if scoring.erreur_calcul: | |
| st.warning(scoring.erreur_calcul) | |
| def render_evaluation_table(report: FinalReport): | |
| st.markdown( | |
| '<div class="section-title">📋 Tableau d\'Évaluation Détaillé</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| table = report.evaluation_table | |
| if not table.lignes: | |
| st.warning("Aucune donnée dans le tableau d'évaluation.") | |
| return | |
| headers = [ | |
| "Élément", | |
| "Clarté", | |
| "Cohérence", | |
| "Qualité réd.", | |
| "Pertinence", | |
| "Respect règles", | |
| "Erreurs naïves", | |
| ] | |
| header_row = "| " + " | ".join(headers) + " |" | |
| separator = "| " + " | ".join(["---"] * len(headers)) + " |" | |
| rows = [] | |
| for row in table.lignes: | |
| cells = [ | |
| f"**{row.element}**", | |
| f"{row.clarte.emoji} {row.clarte.justification[:50]}", | |
| f"{row.coherence.emoji} {row.coherence.justification[:50]}", | |
| f"{row.qualite_redactionnelle.emoji} {row.qualite_redactionnelle.justification[:50]}", | |
| f"{row.pertinence.emoji} {row.pertinence.justification[:50]}", | |
| f"{row.respect_regles.emoji} {row.respect_regles.justification[:50]}", | |
| f"{row.erreurs_naives.emoji} {row.erreurs_naives.justification[:50]}", | |
| ] | |
| rows.append("| " + " | ".join(cells) + " |") | |
| st.markdown("\n".join([header_row, separator] + rows), unsafe_allow_html=True) | |
| with st.expander("🔎 Voir les justifications complètes"): | |
| for row in table.lignes: | |
| st.markdown(f"#### {row.element}") | |
| detail_cols = st.columns(6) | |
| for j, (label, cell) in enumerate( | |
| [ | |
| ("Clarté", row.clarte), | |
| ("Cohérence", row.coherence), | |
| ("Qualité réd.", row.qualite_redactionnelle), | |
| ("Pertinence", row.pertinence), | |
| ("Respect règles", row.respect_regles), | |
| ("Erreurs naïves", row.erreurs_naives), | |
| ] | |
| ): | |
| with detail_cols[j]: | |
| st.markdown(f"**{label}** {cell.emoji}") | |
| st.caption(cell.justification) | |
| st.divider() | |
| st.info(f"📝 {table.resume_tableau}") | |
| def render_experience_analysis(report: FinalReport): | |
| st.markdown( | |
| '<div class="section-title">🔍 Analyse des Expériences</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| exp = report.experience_analysis | |
| c1, c2 = st.columns([1, 3]) | |
| with c1: | |
| st.metric("Score global", f"{exp.score_global_experiences}/10") | |
| with c2: | |
| st.info(f"💬 **Synthèse :** {exp.synthese}") | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| st.markdown("#### ✅ Points forts") | |
| for p in exp.points_forts: | |
| st.markdown(f"- 🟢 {p}") | |
| with col2: | |
| st.markdown("#### ⚠️ Points faibles") | |
| for p in exp.points_faibles: | |
| st.markdown(f"- 🔴 {p}") | |
| if exp.donnees_manquantes: | |
| st.warning("**Données manquantes :** " + ", ".join(exp.donnees_manquantes)) | |
| st.markdown(f"#### 💼 Expériences détaillées ({len(exp.experiences)})") | |
| for e in exp.experiences: | |
| with st.expander(f"💼 {e.poste} @ {e.entreprise} — {e.score}/10"): | |
| cols = st.columns([1, 1, 1]) | |
| with cols[0]: | |
| st.markdown(f"📅 **Période :** {e.periode}") | |
| with cols[1]: | |
| st.markdown(f"⏱️ **Durée :** {e.duree_estimee or 'non précisée'}") | |
| with cols[2]: | |
| st.metric("Score", f"{e.score}/10") | |
| st.markdown(f"**Contexte métier :** {e.contexte_metier}") | |
| st.markdown(f"**Cohérence technique :** {e.coherence_technique}") | |
| if e.missions: | |
| st.markdown("**Missions :**") | |
| for m in e.missions: | |
| st.markdown(f" - {m}") | |
| if e.missions_differenciantes: | |
| st.markdown("**🌟 Missions différenciantes :**") | |
| for m in e.missions_differenciantes: | |
| st.markdown(f" - ⭐ {m}") | |
| if e.resultats_mesurables: | |
| st.markdown("**📊 Résultats mesurables :**") | |
| for r in e.resultats_mesurables: | |
| st.markdown(f" - 📈 {r}") | |
| if e.erreurs_naives: | |
| st.error("**❌ Erreurs naïves détectées :**") | |
| for err in e.erreurs_naives: | |
| st.markdown(f" - ⚠️ {err}") | |
| st.caption(f"**Justification du score :** {e.justification_score}") | |
| def render_skills_education(report: FinalReport): | |
| st.markdown( | |
| '<div class="section-title">🎯 Compétences & Formations</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| se = report.skills_education | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| st.metric("Score Compétences", f"{se.score_competences}/10") | |
| with col2: | |
| st.metric("Score Formations", f"{se.score_formations}/10") | |
| st.markdown("#### 🛠️ Compétences") | |
| demonstrated = [c for c in se.competences if c.demontree_dans_experience] | |
| not_demonstrated = [c for c in se.competences if not c.demontree_dans_experience] | |
| if demonstrated: | |
| st.markdown("**✅ Compétences démontrées**") | |
| for c in demonstrated: | |
| level = f" ({c.niveau_estime})" if c.niveau_estime else "" | |
| assoc = f" → _{c.experience_associee}_" if c.experience_associee else "" | |
| st.markdown(f"- ✅ **{c.nom}** `{c.categorie}`{level}{assoc}") | |
| if not_demonstrated: | |
| st.markdown("**❌ Compétences non démontrées**") | |
| for c in not_demonstrated: | |
| st.markdown(f"- ❌ **{c.nom}** `{c.categorie}` — Déclarée mais non prouvée") | |
| st.markdown("#### 🎓 Formations") | |
| for f in se.formations: | |
| year = f" ({f.annee})" if f.annee else "" | |
| st.markdown(f"- 📚 **{f.diplome}** — {f.etablissement}{year}") | |
| st.caption(f" Cohérence parcours : {f.coherence_parcours}") | |
| st.info(f"**Cohérence formation ↔ parcours :** {se.coherence_formation_parcours}") | |
| def render_summary_validation(report: FinalReport): | |
| st.markdown( | |
| '<div class="section-title">✅ Validation du Résumé / Profil</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| sv = report.summary_validation | |
| col1, col2, col3 = st.columns(3) | |
| with col1: | |
| st.metric("Score Résumé", f"{sv.score_resume}/10") | |
| with col2: | |
| st.metric("Affirmations prouvées", f"{sv.taux_affirmations_prouvees:.0f}%") | |
| with col3: | |
| total = len(sv.affirmations_analysees) | |
| proven = sum(1 for a in sv.affirmations_analysees if a.prouvee) | |
| st.metric("Ratio", f"{proven}/{total}") | |
| col_pos = st.columns(2) | |
| with col_pos[0]: | |
| st.info(f"📌 **Positionnement déclaré :** {sv.positionnement_declare}") | |
| with col_pos[1]: | |
| st.info(f"🔎 **Positionnement réel :** {sv.positionnement_reel}") | |
| if sv.ecarts_alignement: | |
| st.warning("**Écarts d'alignement :**") | |
| for e in sv.ecarts_alignement: | |
| st.markdown(f"- ⚠️ {e}") | |
| st.markdown("#### 📝 Analyse des affirmations") | |
| for a in sv.affirmations_analysees: | |
| icon = "✅" if a.prouvee else "❌" | |
| label = a.affirmation[:80] + "…" if len(a.affirmation) > 80 else a.affirmation | |
| with st.expander(f"{icon} « {label} »"): | |
| st.markdown(f"**Prouvée :** {'Oui ✅' if a.prouvee else 'Non ❌'}") | |
| if a.preuve: | |
| st.markdown(f"**Preuve :** {a.preuve}") | |
| st.markdown(f"**Commentaire :** {a.commentaire}") | |
| def render_quality_control(report: FinalReport): | |
| st.markdown( | |
| '<div class="section-title">🏁 Contrôle Qualité Final</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| qc = report.quality_control | |
| verdict_class = { | |
| "Oui": "verdict-oui", | |
| "Non": "verdict-non", | |
| "Peut-être": "verdict-maybe", | |
| }.get(qc.recommandation, "verdict-maybe") | |
| st.markdown( | |
| f""" | |
| <div class="verdict-box {verdict_class}"> | |
| <h3 style="margin:0 0 .4rem 0;">Recommandation : {qc.recommandation}</h3> | |
| <p style="margin:0;">{qc.justification_recommandation}</p> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| c1, c2, c3 = st.columns(3) | |
| with c1: | |
| st.markdown(f"**Verdict :** {qc.verdict.replace('_', ' ').title()}") | |
| with c2: | |
| st.markdown(f"**Alignement global :** {qc.alignement_global}") | |
| with c3: | |
| st.metric("Score Alignement", f"{qc.score_alignement}/10") | |
| st.markdown(f"**Justification :** {qc.justification_verdict}") | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| st.markdown("#### 💪 Forces") | |
| for f in qc.forces: | |
| st.markdown(f"- 🟢 {f}") | |
| with col2: | |
| st.markdown("#### 📉 Faiblesses") | |
| for f in qc.faiblesses: | |
| st.markdown(f"- 🔴 {f}") | |
| with st.expander("📋 Éléments vérifiés"): | |
| quality_colors = { | |
| "excellent": "🟢", | |
| "bon": "🔵", | |
| "moyen": "🟡", | |
| "faible": "🟠", | |
| "absent": "🔴", | |
| } | |
| for item in qc.elements_verifies: | |
| icon = "✅" if item.present else "❌" | |
| q_emoji = quality_colors.get(item.qualite, "⚪") | |
| st.markdown( | |
| f"{icon} **{item.element}** — {q_emoji} {item.qualite.title()} : {item.commentaire}" | |
| ) | |
| def render_export_section(report: FinalReport): | |
| """Render the Export tab with JSON download and CV text download.""" | |
| st.markdown( | |
| '<div class="section-title">📥 Export & Téléchargements</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| report_json = report.model_dump_json(indent=2) | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| col_json, col_txt, col_preview = st.columns([1, 1, 1]) | |
| # ── JSON download ── | |
| with col_json: | |
| st.markdown("**📄 Rapport complet**") | |
| st.download_button( | |
| label="⬇️ Télécharger JSON", | |
| data=report_json, | |
| file_name=f"cv_evaluation_{timestamp}.json", | |
| mime="application/json", | |
| use_container_width=True, | |
| ) | |
| # ── CV text download ── | |
| with col_txt: | |
| st.markdown("**📝 Texte extrait du CV**") | |
| cv_text = st.session_state.get("cv_text", "") | |
| if cv_text: | |
| st.download_button( | |
| label="⬇️ Télécharger CV (.txt)", | |
| data=cv_text, | |
| file_name=f"cv_extrait_{timestamp}.txt", | |
| mime="text/plain", | |
| use_container_width=True, | |
| help="Télécharger le texte brut extrait du PDF", | |
| ) | |
| else: | |
| st.info("Texte du CV non disponible.") | |
| # ── Copy preview ── | |
| with col_preview: | |
| st.markdown("**🔍 Aperçu JSON**") | |
| if st.button("👁️ Afficher aperçu", use_container_width=True): | |
| st.code(report_json[:600] + "\n…", language="json") | |
| with st.expander("🔎 Voir le JSON complet"): | |
| st.json(json.loads(report_json)) | |
| # ══════════════════════════════════════════════ | |
| # MAIN APPLICATION | |
| # ══════════════════════════════════════════════ | |
| def main(): | |
| render_header() | |
| api_key, model, use_ollama = render_sidebar() | |
| # ── Upload section ── | |
| st.markdown( | |
| '<div class="section-title">📤 Importer un CV</div>', unsafe_allow_html=True | |
| ) | |
| # If a previous result exists, show a reset banner at the top | |
| if "report" in st.session_state: | |
| fname = st.session_state.get("evaluated_filename", "CV précédent") | |
| reset_col1, reset_col2 = st.columns([5, 1]) | |
| with reset_col1: | |
| st.markdown( | |
| f'<div class="reset-banner">' | |
| f'<span class="label">📌 Résultat actuel : <strong>{fname}</strong> — ' | |
| f"Pour analyser un nouveau CV, réinitialisez d'abord.</span>" | |
| f"</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| with reset_col2: | |
| if st.button( | |
| "🔄 Réinitialiser", | |
| type="secondary", | |
| use_container_width=True, | |
| help="Efface les résultats et permet de déposer un nouveau CV", | |
| ): | |
| reset_evaluation() | |
| st.rerun() | |
| uploaded_file = st.file_uploader( | |
| "Glissez votre CV au format PDF ici", | |
| type=["pdf"], | |
| help="Format accepté : PDF · Taille max : 10 Mo · 2 pages max recommandées", | |
| disabled="report" in st.session_state, # lock uploader once evaluated | |
| ) | |
| # Validate file size early | |
| if uploaded_file and uploaded_file.size > 10 * 1024 * 1024: | |
| st.error("❌ Le fichier dépasse 10 Mo. Veuillez compresser votre PDF.") | |
| return | |
| if uploaded_file: | |
| # File info card | |
| st.markdown( | |
| f'<div class="file-info-banner">' | |
| f'<span class="icon">📄</span>' | |
| f'<div><div class="name">{uploaded_file.name}</div>' | |
| f'<div class="size">{uploaded_file.size / 1024:.1f} Ko · PDF</div></div>' | |
| f"</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| # API key validation: required for Gemini/OpenAI, not for Ollama Cloud | |
| if not use_ollama and not api_key: | |
| st.error( | |
| "⚠️ Veuillez entrer votre clé API Gemini/OpenAI dans la barre latérale." | |
| ) | |
| return | |
| # ── Evaluate button ── | |
| if "report" not in st.session_state: | |
| if st.button( | |
| "🚀 Lancer l'évaluation", type="primary", use_container_width=True | |
| ): | |
| progress_bar = st.progress(0) | |
| status_box = st.empty() | |
| def progress_callback(message: str, percentage: float): | |
| progress_bar.progress(min(percentage, 1.0)) | |
| status_box.info(f"⏳ {message}") | |
| try: | |
| # Step 1 – extract text | |
| with st.spinner("📄 Extraction du texte du PDF…"): | |
| try: | |
| cv_text = extract_text_from_uploaded_file(uploaded_file) | |
| except Exception as e: | |
| # Handle custom PDFExtractionError with user-friendly message | |
| error_msg = str(e) | |
| if "vide" in error_msg.lower(): | |
| st.error( | |
| "❌ Le PDF est vide. Veuillez vérifier le fichier." | |
| ) | |
| elif ( | |
| "scanné" in error_msg.lower() | |
| or "image" in error_msg.lower() | |
| ): | |
| st.error( | |
| "❌ Le PDF semble être une image scannée. " | |
| "Le texte ne peut pas être extrait. " | |
| "Utilisez un PDF avec du texte sélectionnable." | |
| ) | |
| else: | |
| st.error( | |
| f"❌ Erreur lors de la lecture du PDF : {error_msg}" | |
| ) | |
| return | |
| if len(cv_text.strip()) < 100: | |
| st.error( | |
| "❌ Le PDF contient très peu de texte (< 100 caractères). " | |
| "Veuillez vérifier le fichier." | |
| ) | |
| return | |
| # Persist extracted text for download later | |
| st.session_state["cv_text"] = cv_text | |
| with st.expander("📄 Texte extrait du CV (aperçu)", expanded=False): | |
| st.text(cv_text[:3000] + ("…" if len(cv_text) > 3000 else "")) | |
| # Step 2 – run evaluation | |
| # Force ollama provider when using Ollama Cloud mode | |
| if use_ollama: | |
| # Use API key from environment variable (required for Ollama Cloud) | |
| ollama_api_key = "d3416cecd2bd4e81a52dde8ba54bbd9c.uT8ag03jpMcxjOm5we3zKGYK" | |
| if not ollama_api_key: | |
| st.error( | |
| "⚠️ Clé API Ollama manquante. " | |
| "Ajoutez OLLAMA_API_KEY dans votre fichier .env ou en variable d'environnement." | |
| ) | |
| return | |
| orchestrator = CVEvaluationOrchestrator( | |
| api_key=ollama_api_key, | |
| model_name=model, | |
| cache_dir=None, | |
| progress_callback=progress_callback, | |
| ) | |
| else: | |
| orchestrator = CVEvaluationOrchestrator( | |
| api_key=api_key, | |
| model_name=model, | |
| cache_dir=None, | |
| progress_callback=progress_callback, | |
| ) | |
| report = orchestrator.evaluate(cv_text) | |
| # Persist results | |
| st.session_state["report"] = report | |
| st.session_state["evaluated_filename"] = uploaded_file.name | |
| progress_bar.progress(1.0) | |
| status_box.success("✅ Évaluation terminée avec succès !") | |
| except Exception as e: | |
| error_msg = str(e) | |
| # User-friendly error messages | |
| if "API" in error_msg or "api" in error_msg.lower(): | |
| st.error( | |
| "❌ Erreur de connexion à l'API. Vérifiez votre clé API et votre connexion internet." | |
| ) | |
| elif "timeout" in error_msg.lower(): | |
| st.error( | |
| "⏱️ La requête a expiré. Le modèle est peut-être surchargé. Réessayez dans quelques instants." | |
| ) | |
| elif "JSON" in error_msg or "parsing" in error_msg.lower(): | |
| st.error( | |
| "🔧 Erreur d'analyse de la réponse IA. Le modèle a renvoyé un format invalide. Réessayez." | |
| ) | |
| else: | |
| st.error(f"❌ Erreur lors de l'évaluation : {error_msg}") | |
| logger.error(f"[App] Evaluation error: {error_msg}", exc_info=True) | |
| return | |
| # ── Results display ── | |
| if "report" in st.session_state: | |
| report = st.session_state["report"] | |
| # ══════════════════════════════════════════════ | |
| # NEW EVALUATION SECTION - Prominent CTA | |
| # ══════════════════════════════════════════════ | |
| st.markdown( | |
| """ | |
| <style> | |
| .new-eval-section { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 1.5rem 2rem; | |
| background: linear-gradient(135deg, rgba(79,110,247,0.08), rgba(118,75,162,0.08)); | |
| border: 2px solid #4f6ef7; | |
| border-radius: 16px; | |
| margin: 1.5rem 0; | |
| box-shadow: 0 4px 20px rgba(79,110,247,0.15); | |
| } | |
| .new-eval-text h3 { | |
| color: #1a1a2e; | |
| margin: 0 0 0.3rem 0; | |
| font-size: 1.3rem; | |
| } | |
| .new-eval-text p { | |
| color: #666; | |
| margin: 0; | |
| font-size: 0.95rem; | |
| } | |
| .new-eval-btn { | |
| background: linear-gradient(135deg, #4f6ef7, #764ba2); | |
| color: white; | |
| border: none; | |
| padding: 0.85rem 2rem; | |
| border-radius: 12px; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| box-shadow: 0 4px 15px rgba(79,110,247,0.3); | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| } | |
| .new-eval-btn:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 6px 20px rgba(79,110,247,0.4); | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown( | |
| """ | |
| <div class="new-eval-section"> | |
| <div class="new-eval-text"> | |
| <h3>✨ Évaluation terminée !</h3> | |
| <p>Souhaitez-vous analyser un nouveau CV ?</p> | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # Full-width button for new evaluation | |
| if st.button( | |
| "🔄 Nouvelle évaluation", | |
| type="primary", | |
| use_container_width=True, | |
| help="Réinitialiser tous les résultats et commencer une nouvelle évaluation", | |
| key="new_evaluation_btn", | |
| ): | |
| reset_evaluation() | |
| st.rerun() | |
| st.divider() | |
| # Sidebar metadata | |
| with st.sidebar: | |
| st.divider() | |
| st.markdown("### 📊 Métadonnées") | |
| meta = report.metadata | |
| st.caption(f"📅 {meta.get('date_evaluation', 'N/A')}") | |
| # Display provider badge | |
| model_name = meta.get("modele_llm", "N/A") | |
| provider_badge = "" | |
| if model_name.endswith("-cloud") or "ollama" in model_name.lower(): | |
| provider_badge = "🆓 Ollama Cloud" | |
| elif model_name.startswith("gemini"): | |
| provider_badge = "💎 Google Gemini" | |
| else: | |
| provider_badge = "🔵 OpenAI" | |
| st.caption(f"🤖 {model_name}") | |
| st.markdown( | |
| f"<span class='chip'>{provider_badge}</span>", unsafe_allow_html=True | |
| ) | |
| st.caption(f"⏱️ {meta.get('duree_evaluation_secondes', 'N/A')} s") | |
| st.caption(f"📂 {', '.join(meta.get('sections_detectees', []))}") | |
| # Also add a reset button in sidebar for convenience | |
| st.divider() | |
| if st.button( | |
| "🗑️ Effacer les résultats", | |
| type="secondary", | |
| use_container_width=True, | |
| help="Supprimer les résultats actuels", | |
| key="sidebar_reset_btn", | |
| ): | |
| reset_evaluation() | |
| st.rerun() | |
| tabs = st.tabs( | |
| [ | |
| "📊 Scores", | |
| "📋 Tableau", | |
| "🔍 Expériences", | |
| "🎯 Compétences", | |
| "✅ Résumé", | |
| "🏁 Qualité", | |
| "📥 Export", | |
| ] | |
| ) | |
| with tabs[0]: | |
| render_scores(report) | |
| with tabs[1]: | |
| render_evaluation_table(report) | |
| with tabs[2]: | |
| render_experience_analysis(report) | |
| with tabs[3]: | |
| render_skills_education(report) | |
| with tabs[4]: | |
| render_summary_validation(report) | |
| with tabs[5]: | |
| render_quality_control(report) | |
| with tabs[6]: | |
| render_export_section(report) | |
| if __name__ == "__main__": | |
| main() | |