CVGFSI / app.py
MMOON's picture
Update app.py
f0e478f verified
import streamlit as st
from PyPDF2 import PdfReader
import pandas as pd
from groq import Groq
from referentials import REFERENTIALS
import json
import re
# Configuration avancée de la page
st.set_page_config(
layout="wide",
page_title="Analyse de CV - GFSI (Version 25.12)",
page_icon="📄",
initial_sidebar_state="expanded"
)
# Fonction pour configurer le client Groq avec validation
def get_groq_client(api_key):
"""
Initialise et valide un client Groq avec la clé API fournie.
Args:
api_key (str): Clé API Groq
Returns:
Groq: Instance de client Groq ou None en cas d'erreur
"""
if not api_key or not api_key.startswith("gsk_"):
st.error("La clé API semble invalide. Les clés Groq commencent généralement par 'gsk_'.")
return None
try:
client = Groq(api_key=api_key)
# Test de la connexion avec une requête minimale
test_response = client.chat.completions.create(
messages=[{"role": "user", "content": "Test de connexion"}],
model="llama-3.1-8b-instant",
max_tokens=10
)
if test_response:
return client
except Exception as e:
st.error(f"Erreur de connexion à l'API Groq : {str(e)}")
return None
# Extraction améliorée de texte depuis un PDF
def extract_text_from_pdf(file):
"""
Extrait le texte d'un fichier PDF avec gestion optimisée des erreurs.
Args:
file (UploadedFile): Fichier PDF téléchargé
Returns:
str: Texte extrait du PDF ou None en cas d'erreur
"""
try:
with st.spinner("Extraction du texte en cours..."):
reader = PdfReader(file)
# Vérification des pages vides
if len(reader.pages) == 0:
st.warning("Le PDF ne contient aucune page.")
return None
# Extraction avec nettoyage des caractères spéciaux
text_parts = []
for page in reader.pages:
page_text = page.extract_text()
if page_text:
# Nettoyage des caractères problématiques
page_text = re.sub(r'\s+', ' ', page_text)
text_parts.append(page_text)
if not text_parts:
st.warning("Aucun texte n'a pu être extrait du PDF. Vérifiez qu'il ne s'agit pas d'un PDF scanné.")
return None
return " ".join(text_parts)
except Exception as e:
st.error(f"Erreur lors de l'extraction du texte : {str(e)}")
st.info("Conseils: Vérifiez que le PDF n'est pas protégé ou qu'il ne s'agit pas d'un document scanné sans OCR.")
return None
# Analyse améliorée du CV avec l'API Groq et référentiel
def analyze_cv_with_groq(cv_text, referential, groq_client, model="openai/gpt-oss-120b"):
"""
Analyse approfondie du CV en fonction des référentiels GFSI avec références systématiques.
Args:
cv_text (str): Texte du CV à analyser
referential (str): Nom du référentiel GFSI sélectionné
groq_client (Groq): Instance de client Groq
model (str): Modèle LLM à utiliser
Returns:
dict: Résultats structurés de l'analyse ou None en cas d'erreur
"""
referential_data = REFERENTIALS.get(referential, {})
# Structuration des exigences pour analyse systématique
requirements_categories = {
"General_Requirements": referential_data.get("General_Requirements", {}),
"Qualifications": referential_data.get("Qualifications", {}),
"Audit_Experience": referential_data.get("Audit_Experience", {}),
"Advanced_Requirements": referential_data.get("Advanced_Requirements", {})
}
# Construction d'un prompt structuré pour obtenir une réponse analysable
prompt = f"""
Vous êtes un expert en évaluation des compétences selon les référentiels GFSI.
TÂCHE: Analysez ce CV pour vérifier sa conformité avec le référentiel {referential} de manière systématique.
INSTRUCTIONS:
1. Ne jamais mentionner le nom du candidat - utilisez toujours "le candidat"
2. Pour chaque exigence, fournissez:
- La référence exacte de l'exigence (code/numéro)
- Votre évaluation avec STATUT: CONFORME, NON CONFORME, ou PARTIELLEMENT CONFORME
- Les éléments du CV justifiant votre évaluation
- Des recommandations spécifiques si nécessaire
3. Structurez votre réponse exactement selon le format JSON demandé à la fin
RÉFÉRENTIEL {referential} - EXIGENCES:
SECTION 1: EXIGENCES GÉNÉRALES
{json.dumps(requirements_categories["General_Requirements"], ensure_ascii=False, indent=2)}
SECTION 2: QUALIFICATIONS
{json.dumps(requirements_categories["Qualifications"], ensure_ascii=False, indent=2)}
SECTION 3: EXPÉRIENCE EN AUDIT
{json.dumps(requirements_categories["Audit_Experience"], ensure_ascii=False, indent=2)}
SECTION 4: EXIGENCES AVANCÉES
{json.dumps(requirements_categories["Advanced_Requirements"], ensure_ascii=False, indent=2)}
CV DU CANDIDAT:
{cv_text}
INSTRUCTIONS SUPPLÉMENTAIRES:
- Vérifiez chaque formation et expérience plusieurs fois, car les terminologies peuvent varier
- Évaluez si l'équivalence des formations est acceptable selon le référentiel
- Identifiez précisément chaque lacune avec référence à l'exigence spécifique
FORMAT DE RÉPONSE: Fournissez votre analyse au format JSON avec la structure suivante:
{{
"analysis": {{
"general_requirements": [
{{
"reference": "REF-CODE-1",
"requirement": "Description de l'exigence",
"status": "CONFORME/NON CONFORME/PARTIELLEMENT CONFORME",
"evidence": "Éléments du CV justifiant l'évaluation",
"recommendations": "Recommandations si nécessaire"
}}
],
"qualifications": [...],
"audit_experience": [...],
"advanced_requirements": [...]
}},
"summary": {{
"conformant_count": 12,
"non_conformant_count": 3,
"partially_conformant_count": 2,
"overall_assessment": "CONFORME/NON CONFORME/PARTIELLEMENT CONFORME",
"key_strengths": ["Force 1", "Force 2"],
"key_gaps": ["Lacune 1", "Lacune 2"],
"conclusion": "Conclusion générale sur l'adéquation du candidat"
}}
}}
IMPORTANT: Assurez-vous que votre réponse soit un JSON valide et bien structuré.
"""
try:
with st.spinner("Analyse approfondie du CV en cours..."):
messages = [
{"role": "system", "content": "Vous êtes un expert en conformité GFSI qui fournit des analyses structurées avec références systématiques aux exigences."},
{"role": "user", "content": prompt}
]
response = groq_client.chat.completions.create(
messages=messages,
model=model,
max_tokens=4000,
temperature=0.1,
response_format={"type": "json_object"}
)
# Parsing de la réponse en JSON
try:
result = json.loads(response.choices[0].message.content)
return result
except json.JSONDecodeError:
# Si le parsing JSON échoue, tenter de récupérer le contenu brut
content = response.choices[0].message.content
st.warning("L'analyse n'a pas pu être structurée correctement. Affichage des résultats bruts.")
return {"raw_content": content}
except Exception as e:
st.error(f"Erreur lors de l'analyse du CV : {str(e)}")
return None
# Affichage amélioré des résultats d'analyse
def display_analysis_results(analysis_result, referential):
"""
Affiche les résultats de l'analyse de manière structurée et visuelle.
Args:
analysis_result (dict): Résultats de l'analyse
referential (str): Référentiel GFSI utilisé pour l'analyse
"""
if not analysis_result:
return
# Cas où le résultat est brut (non JSON)
if "raw_content" in analysis_result:
st.markdown(
f"""
<div style='font-size:18px;line-height:1.6;margin-top:20px;'>
{analysis_result["raw_content"]}
</div>
""",
unsafe_allow_html=True
)
return
# Affichage du résumé global
st.markdown(f"## Résumé de l'analyse selon le référentiel {referential}")
summary = analysis_result.get("summary", {})
conformant = summary.get("conformant_count", 0)
non_conformant = summary.get("non_conformant_count", 0)
partially = summary.get("partially_conformant_count", 0)
total = conformant + non_conformant + partially
# Affichage des statistiques
col1, col2, col3, col4 = st.columns(4)
with col1:
st.metric("Total des exigences", total)
with col2:
st.metric("Conformes", conformant, f"{int(conformant/total*100 if total else 0)}%")
with col3:
st.metric("Non conformes", non_conformant, f"{int(non_conformant/total*100 if total else 0)}%")
with col4:
st.metric("Partiellement conformes", partially, f"{int(partially/total*100 if total else 0)}%")
# Conclusion globale
conclusion_color = {
"CONFORME": "#28a745",
"NON CONFORME": "#dc3545",
"PARTIELLEMENT CONFORME": "#ffc107"
}.get(summary.get("overall_assessment", ""), "#6c757d")
st.markdown(
f"""
<div style="background-color:{conclusion_color};color:white;padding:20px;border-radius:10px;margin-top:20px;font-size:22px;line-height:1.8;">
<strong>Évaluation générale :</strong> {summary.get("overall_assessment", "Non déterminée")}
</div>
""",
unsafe_allow_html=True
)
# Forces et lacunes
col1, col2 = st.columns(2)
with col1:
st.markdown("### Forces principales")
for strength in summary.get("key_strengths", []):
st.markdown(f"✅ {strength}")
with col2:
st.markdown("### Points d'amélioration")
for gap in summary.get("key_gaps", []):
st.markdown(f"❌ {gap}")
# Conclusion détaillée
st.markdown("### Conclusion")
st.markdown(summary.get("conclusion", "Aucune conclusion disponible."))
# Affichage détaillé des sections d'analyse
analysis = analysis_result.get("analysis", {})
# Liste des sections pour affichage uniforme
sections = [
("Exigences générales", analysis.get("general_requirements", [])),
("Qualifications", analysis.get("qualifications", [])),
("Expérience en audit", analysis.get("audit_experience", [])),
("Exigences avancées", analysis.get("advanced_requirements", []))
]
# Création d'onglets pour chaque section
tabs = st.tabs([section[0] for section in sections])
# Affichage du contenu de chaque section dans son onglet
for i, (section_name, section_data) in enumerate(sections):
with tabs[i]:
if not section_data:
st.info(f"Aucune donnée disponible pour la section {section_name}")
continue
for item in section_data:
# Couleur en fonction du statut
status_color = {
"CONFORME": "#28a745",
"NON CONFORME": "#dc3545",
"PARTIELLEMENT CONFORME": "#ffc107"
}.get(item.get("status", ""), "#6c757d")
# Création d'un expander pour chaque exigence
with st.expander(f"{item.get('reference', 'REF')} - {item.get('requirement', 'Exigence')} ({item.get('status', 'Non évalué')})"):
st.markdown(
f"""
<div style="border-left: 5px solid {status_color}; padding-left: 10px;">
<p><strong>Référence:</strong> {item.get('reference', 'Non spécifiée')}</p>
<p><strong>Exigence:</strong> {item.get('requirement', 'Non spécifiée')}</p>
<p><strong>Statut:</strong> <span style="background-color:{status_color};color:white;padding:3px 6px;border-radius:3px;">{item.get('status', 'Non évalué')}</span></p>
<p><strong>Éléments justificatifs:</strong> {item.get('evidence', 'Aucun')}</p>
<p><strong>Recommandations:</strong> {item.get('recommendations', 'Aucune')}</p>
</div>
""",
unsafe_allow_html=True
)
# Génération d'un rapport exportable
def generate_exportable_report(analysis_result, referential, cv_text):
"""
Génère un rapport exportable basé sur l'analyse du CV.
Args:
analysis_result (dict): Résultats de l'analyse
referential (str): Référentiel utilisé
cv_text (str): Texte du CV analysé
Returns:
str: HTML du rapport
"""
if not analysis_result or "raw_content" in analysis_result:
return None
summary = analysis_result.get("summary", {})
analysis = analysis_result.get("analysis", {})
html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Rapport d'analyse CV - {referential}</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
.header {{ background-color: #f8f9fa; padding: 20px; border-radius: 5px; margin-bottom: 20px; }}
.section {{ margin-bottom: 30px; }}
.item {{ border-left: 5px solid #ccc; padding-left: 15px; margin-bottom: 15px; }}
.conforme {{ border-color: #28a745; }}
.non-conforme {{ border-color: #dc3545; }}
.partiel {{ border-color: #ffc107; }}
.status-badge {{ padding: 3px 6px; border-radius: 3px; color: white; font-size: 12px; }}
.summary {{ background-color: #f8f9fa; padding: 15px; border-radius: 5px; }}
table {{ width: 100%; border-collapse: collapse; margin-bottom: 20px; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background-color: #f2f2f2; }}
</style>
</head>
<body>
<div class="header">
<h1>Rapport d'analyse de CV</h1>
<p>Référentiel: <strong>{referential}</strong></p>
<p>Date: <strong>{pd.Timestamp.now().strftime('%d/%m/%Y')}</strong></p>
</div>
<div class="section summary">
<h2>Résumé de l'analyse</h2>
<table>
<tr>
<th>Exigences conformes</th>
<th>Exigences non conformes</th>
<th>Exigences partiellement conformes</th>
<th>Évaluation générale</th>
</tr>
<tr>
<td>{summary.get('conformant_count', 0)}</td>
<td>{summary.get('non_conformant_count', 0)}</td>
<td>{summary.get('partially_conformant_count', 0)}</td>
<td><strong>{summary.get('overall_assessment', 'Non déterminée')}</strong></td>
</tr>
</table>
<h3>Forces principales</h3>
<ul>
{' '.join(f'<li>{strength}</li>' for strength in summary.get('key_strengths', []))}
</ul>
<h3>Points d'amélioration</h3>
<ul>
{' '.join(f'<li>{gap}</li>' for gap in summary.get('key_gaps', []))}
</ul>
<h3>Conclusion</h3>
<p>{summary.get('conclusion', 'Aucune conclusion disponible.')}</p>
</div>
"""
# Ajout des sections détaillées
sections = [
("Exigences générales", analysis.get("general_requirements", [])),
("Qualifications", analysis.get("qualifications", [])),
("Expérience en audit", analysis.get("audit_experience", [])),
("Exigences avancées", analysis.get("advanced_requirements", []))
]
for section_name, section_data in sections:
html += f"""
<div class="section">
<h2>{section_name}</h2>
"""
if not section_data:
html += "<p>Aucune donnée disponible pour cette section</p>"
else:
for item in section_data:
status = item.get('status', '')
status_class = ""
if "CONFORME" in status and "NON" not in status:
status_class = "conforme"
elif "NON CONFORME" in status:
status_class = "non-conforme"
elif "PARTIELLEMENT" in status:
status_class = "partiel"
html += f"""
<div class="item {status_class}">
<h3>{item.get('reference', 'REF')} - {item.get('requirement', 'Exigence')}</h3>
<p><strong>Statut:</strong> <span class="status-badge" style="background-color: {'#28a745' if status_class == 'conforme' else '#dc3545' if status_class == 'non-conforme' else '#ffc107'};">{status}</span></p>
<p><strong>Éléments justificatifs:</strong> {item.get('evidence', 'Aucun')}</p>
<p><strong>Recommandations:</strong> {item.get('recommendations', 'Aucune')}</p>
</div>
"""
html += "</div>"
html += """
</body>
</html>
"""
return html
# Fonction principale améliorée
def main():
"""
Interface principale améliorée avec fonctionnalités d'exportation et statistiques.
"""
# Barre latérale pour configuration
with st.sidebar:
st.image("https://via.placeholder.com/150x60?text=GFSI+Analyzer", width=150)
st.title("Configuration")
# Gestion de la clé API avec sauvegarde dans session_state
if "api_key" not in st.session_state:
st.session_state.api_key = ""
api_key = st.text_input(
"Clé API Groq :",
value=st.session_state.api_key,
type="password",
help="La clé API commence par 'gsk_'"
)
if api_key != st.session_state.api_key:
st.session_state.api_key = api_key
st.session_state.groq_client = None if not api_key else get_groq_client(api_key)
if "groq_client" not in st.session_state:
st.session_state.groq_client = None if not api_key else get_groq_client(api_key)
# Sélection du modèle
model_options = {
"openai/gpt-oss-120b": "openai/gpt-oss-120b (Haute précision)",
"llama-3.2-11b-versatile": "Llama 3.2 11B (Équilibré)",
"llama-3.1-8b-instant": "Llama 3.1 8B (Rapide)"
}
selected_model = st.selectbox(
"Modèle d'IA:",
options=list(model_options.keys()),
format_func=lambda x: model_options[x],
help="Balance entre précision d'analyse et vitesse"
)
st.markdown("---")
st.markdown("### Options d'analyse")
# Options de debug pour développement
show_debug = st.checkbox("Afficher les données brutes (Debug)", False)
# Informations supplémentaires
st.markdown("---")
st.markdown("### À propos")
st.info("""
**Outil d'analyse CV - GFSI**
Version 25.12
Référentiels supportés : BRCGS, FSSC 22000, IFS
""")
# Contenu principal
st.title("Analyse de CV selon les Référentiels GFSI")
st.markdown("---")
# Panneau d'information avec des instructions complètes
with st.expander("ℹ️ Guide d'utilisation", expanded=True):
st.markdown("""
### Comment utiliser cet outil?
1. **Configuration**:
- Saisissez votre clé API Groq dans le panneau latéral
- Sélectionnez le modèle d'IA à utiliser
2. **Analyse**:
- Téléchargez un CV au format PDF
- Sélectionnez le référentiel GFSI applicable
- Lancez l'analyse
3. **Résultats**:
- Consultez le rapport détaillé avec références aux exigences
- Explorez les détails par section (Général, Qualifications, etc.)
- Exportez les résultats au format HTML
**Astuce**: Plus le document est clairement formaté, meilleure sera l'analyse.
""")
# Contenu principal - Interface d'analyse
st.markdown("## Analyse de CV")
# Vérification de la présence du client Groq
if not st.session_state.groq_client:
st.warning("⚠️ Veuillez configurer votre clé API Groq dans le panneau latéral pour continuer.")
return
# Chargement du fichier PDF
uploaded_file = st.file_uploader(
"📄 Téléchargez un fichier PDF (CV)",
type="pdf",
help="Formats supportés : PDF. Taille maximale: 10MB"
)
if not uploaded_file:
st.info("Veuillez télécharger un fichier PDF pour commencer l'analyse.")
# Affichage d'exemples des référentiels
st.markdown("### Aperçu des référentiels supportés")
for ref_name, ref_data in REFERENTIALS.items():
with st.expander(f"Référentiel {ref_name}"):
st.json(ref_data)
return
# Sélection du référentiel
referential = st.selectbox(
"📋 Sélectionnez un référentiel",
list(REFERENTIALS.keys()),
help="Choisissez le standard GFSI applicable pour ce candidat"
)
# Vérification que tous les éléments nécessaires sont présents
if uploaded_file and referential and st.session_state.groq_client:
# Bouton pour lancer l'analyse
if st.button("🔍 Analyser le CV", type="primary"):
# Extraction du texte du CV
cv_text = extract_text_from_pdf(uploaded_file)
# Afficher un extrait du texte extrait en mode debug
if show_debug and cv_text:
with st.expander("Aperçu du texte extrait", expanded=False):
st.text(cv_text[:1000] + "..." if len(cv_text) > 1000 else cv_text)
if cv_text:
# Analyse du CV avec références systématiques
analysis_result = analyze_cv_with_groq(
cv_text,
referential,
st.session_state.groq_client,
model=selected_model
)
# Affichage des données brutes en mode debug
if show_debug and analysis_result:
with st.expander("Données brutes de l'analyse (Debug)", expanded=False):
st.json(analysis_result)
# Affichage des résultats
if analysis_result:
# Affichage structuré des résultats
display_analysis_results(analysis_result, referential)
# Génération et téléchargement du rapport
report_html = generate_exportable_report(analysis_result, referential, cv_text)
if report_html:
st.download_button(
label="📥 Télécharger le rapport complet",
data=report_html,
file_name=f"analyse_cv_{referential}_{pd.Timestamp.now().strftime('%Y%m%d_%H%M')}.html",
mime="text/html",
)
if __name__ == "__main__":
main()