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"""
{analysis_result["raw_content"]}
""",
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"""
Évaluation générale : {summary.get("overall_assessment", "Non déterminée")}
""",
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"""
Référence: {item.get('reference', 'Non spécifiée')}
Exigence: {item.get('requirement', 'Non spécifiée')}
Statut: {item.get('status', 'Non évalué')}
Éléments justificatifs: {item.get('evidence', 'Aucun')}
Recommandations: {item.get('recommendations', 'Aucune')}
""",
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"""
Rapport d'analyse CV - {referential}
Résumé de l'analyse
| Exigences conformes |
Exigences non conformes |
Exigences partiellement conformes |
Évaluation générale |
| {summary.get('conformant_count', 0)} |
{summary.get('non_conformant_count', 0)} |
{summary.get('partially_conformant_count', 0)} |
{summary.get('overall_assessment', 'Non déterminée')} |
Forces principales
{' '.join(f'- {strength}
' for strength in summary.get('key_strengths', []))}
Points d'amélioration
{' '.join(f'- {gap}
' for gap in summary.get('key_gaps', []))}
Conclusion
{summary.get('conclusion', 'Aucune conclusion disponible.')}
"""
# 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"""
{section_name}
"""
if not section_data:
html += "
Aucune donnée disponible pour cette section
"
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"""
{item.get('reference', 'REF')} - {item.get('requirement', 'Exigence')}
Statut: {status}
Éléments justificatifs: {item.get('evidence', 'Aucun')}
Recommandations: {item.get('recommendations', 'Aucune')}
"""
html += "
"
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()