| import streamlit as st |
| from PyPDF2 import PdfReader |
| import pandas as pd |
| from groq import Groq |
| from referentials import REFERENTIALS |
| import json |
| import re |
|
|
| |
| st.set_page_config( |
| layout="wide", |
| page_title="Analyse de CV - GFSI (Version 25.12)", |
| page_icon="📄", |
| initial_sidebar_state="expanded" |
| ) |
|
|
| |
| 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_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 |
|
|
| |
| 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) |
| |
| |
| if len(reader.pages) == 0: |
| st.warning("Le PDF ne contient aucune page.") |
| return None |
| |
| |
| text_parts = [] |
| for page in reader.pages: |
| page_text = page.extract_text() |
| if page_text: |
| |
| 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 |
|
|
| |
| 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, {}) |
| |
| |
| 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", {}) |
| } |
| |
| |
| 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"} |
| ) |
| |
| |
| try: |
| result = json.loads(response.choices[0].message.content) |
| return result |
| except json.JSONDecodeError: |
| |
| 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 |
|
|
| |
| 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 |
| |
| |
| 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 |
| |
| |
| 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 |
| |
| |
| 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_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 |
| ) |
| |
| |
| 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}") |
| |
| |
| st.markdown("### Conclusion") |
| st.markdown(summary.get("conclusion", "Aucune conclusion disponible.")) |
| |
| |
| analysis = analysis_result.get("analysis", {}) |
| |
| |
| 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", [])) |
| ] |
| |
| |
| tabs = st.tabs([section[0] for section in sections]) |
| |
| |
| 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: |
| |
| status_color = { |
| "CONFORME": "#28a745", |
| "NON CONFORME": "#dc3545", |
| "PARTIELLEMENT CONFORME": "#ffc107" |
| }.get(item.get("status", ""), "#6c757d") |
| |
| |
| 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 |
| ) |
|
|
| |
| 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> |
| """ |
| |
| |
| 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 |
|
|
| |
| def main(): |
| """ |
| Interface principale améliorée avec fonctionnalités d'exportation et statistiques. |
| """ |
| |
| with st.sidebar: |
| st.image("https://via.placeholder.com/150x60?text=GFSI+Analyzer", width=150) |
| st.title("Configuration") |
| |
| |
| 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) |
| |
| |
| 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") |
| |
| |
| show_debug = st.checkbox("Afficher les données brutes (Debug)", False) |
| |
| |
| st.markdown("---") |
| st.markdown("### À propos") |
| st.info(""" |
| **Outil d'analyse CV - GFSI** |
| Version 25.12 |
| |
| Référentiels supportés : BRCGS, FSSC 22000, IFS |
| """) |
|
|
| |
| st.title("Analyse de CV selon les Référentiels GFSI") |
| st.markdown("---") |
|
|
| |
| 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. |
| """) |
| |
| |
| st.markdown("## Analyse de CV") |
| |
| |
| if not st.session_state.groq_client: |
| st.warning("⚠️ Veuillez configurer votre clé API Groq dans le panneau latéral pour continuer.") |
| return |
| |
| |
| 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.") |
| |
| 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 |
| |
| |
| referential = st.selectbox( |
| "📋 Sélectionnez un référentiel", |
| list(REFERENTIALS.keys()), |
| help="Choisissez le standard GFSI applicable pour ce candidat" |
| ) |
| |
| |
| if uploaded_file and referential and st.session_state.groq_client: |
| |
| if st.button("🔍 Analyser le CV", type="primary"): |
| |
| cv_text = extract_text_from_pdf(uploaded_file) |
| |
| |
| 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: |
| |
| analysis_result = analyze_cv_with_groq( |
| cv_text, |
| referential, |
| st.session_state.groq_client, |
| model=selected_model |
| ) |
| |
| |
| if show_debug and analysis_result: |
| with st.expander("Données brutes de l'analyse (Debug)", expanded=False): |
| st.json(analysis_result) |
| |
| |
| if analysis_result: |
| |
| display_analysis_results(analysis_result, referential) |
| |
| |
| 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() |