',
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(
'
""",
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()