| """ |
| 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 |
|
|
| |
| load_dotenv(override=False) |
| logging.basicConfig( |
| level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s" |
| ) |
| logger = logging.getLogger(__name__) |
|
|
| |
| warnings.filterwarnings("ignore", category=ImportWarning, module="requests") |
|
|
| |
| 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 |
|
|
| |
| 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", |
| ) |
|
|
| |
| 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, |
| ) |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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" |
|
|
|
|
| |
| 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 |
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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() |
|
|
| |
| 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: |
| |
|
|
| 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 = ( |
| "" |
| ) |
| st.info("🔑 Clé API Ollama incluse automatiquement") |
| else: |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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" |
| if n <= 12: |
| return "#e53935" |
| if n <= 14: |
| return "#f4511e" |
| if n <= 16: |
| return "#7cb342" |
| if n == 17: |
| return "#388e3c" |
| if n <= 19: |
| return "#2e7d32" |
| return "#1b5e20" |
|
|
|
|
| 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) |
|
|
| |
| st.markdown('<div class="section-title">📊 Scores</div>', unsafe_allow_html=True) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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 |
| ) |
| 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]) |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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.") |
|
|
| |
| 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)) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def main(): |
| render_header() |
| api_key, model, use_ollama = render_sidebar() |
|
|
| |
| st.markdown( |
| '<div class="section-title">📤 Importer un CV</div>', unsafe_allow_html=True |
| ) |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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: |
| |
| 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, |
| ) |
|
|
| |
| if not use_ollama and not api_key: |
| st.error( |
| "⚠️ Veuillez entrer votre clé API Gemini/OpenAI dans la barre latérale." |
| ) |
| return |
|
|
| |
| 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: |
| |
| with st.spinner("📄 Extraction du texte du PDF…"): |
| try: |
| cv_text = extract_text_from_uploaded_file(uploaded_file) |
| except Exception as e: |
| |
| 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 |
|
|
| |
| 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 "")) |
|
|
| |
| |
| if use_ollama: |
| |
| 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) |
|
|
| |
| 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) |
| |
| 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 |
|
|
| |
| if "report" in st.session_state: |
| report = st.session_state["report"] |
|
|
| |
| |
| |
| 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, |
| ) |
|
|
| |
| 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() |
|
|
| |
| with st.sidebar: |
| st.divider() |
| st.markdown("### 📊 Métadonnées") |
| meta = report.metadata |
| st.caption(f"📅 {meta.get('date_evaluation', 'N/A')}") |
|
|
| |
| 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', []))}") |
|
|
| |
| 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() |
|
|