""" 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( """ """, 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( """
CV Evaluator

Système Multi-Agents d'Évaluation de CV propulsé par IA GEN

⚡ 6 agents spécialisés 🧠 Analyse déterministe 📋 Rapport structuré 🔗 LangChainc
""", unsafe_allow_html=True, ) def render_sidebar(): with st.sidebar: st.markdown( """
Configuration
""", 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""" {label} {sublabel} """ 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('
📊 Scores
', unsafe_allow_html=True) # ── 3 progress rings ── c10, c20, c100 = st.columns(3) ring_css = """ """ 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'
' f'
Score sur 10
' f"{svg}" f'
{bareme["emoji"]} {bareme["label"]}
' f"
", 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'
' f'
⭐ Score sur 20
' f"{svg}" f'
' f"{bareme['emoji']} {bareme['label']}
" f"
", unsafe_allow_html=True, ) with c100: svg = _progress_ring_svg( score_100, 100, f"{score_100:.0f}", "/ 100", ring_color ) st.markdown( f'
' f'
Score sur 100
' f"{svg}" f'
{bareme["emoji"]} {bareme["label"]}
' f"
", unsafe_allow_html=True, ) st.markdown("
", 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"""
{rec_emoji}
Recommandation
{rec}
""", 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"""
{verdict_emoji}
Verdict
{verdict_label}
""", unsafe_allow_html=True, ) st.markdown("
", unsafe_allow_html=True) # ── Detail by criterion ── st.markdown( '
📈 Détail par critère
', 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'
' f'

', 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( '
📋 Tableau d\'Évaluation Détaillé
', 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( '
🔍 Analyse des Expériences
', 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( '
🎯 Compétences & Formations
', 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( '
✅ Validation du Résumé / Profil
', 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( '
🏁 Contrôle Qualité Final
', 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"""

Recommandation : {qc.recommandation}

{qc.justification_recommandation}

""", 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( '
📥 Export & Téléchargements
', 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( '
📤 Importer un CV
', 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'
' f'📌 Résultat actuel : {fname} — ' f"Pour analyser un nouveau CV, réinitialisez d'abord." f"
", 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'
' f'📄' f'
{uploaded_file.name}
' f'
{uploaded_file.size / 1024:.1f} Ko · PDF
' f"
", 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( """ """, unsafe_allow_html=True, ) st.markdown( """

✨ Évaluation terminée !

Souhaitez-vous analyser un nouveau CV ?

""", 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"{provider_badge}", 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()