Spaces:
Runtime error
Runtime error
| import gradio as gr | |
| import pandas as pd | |
| from sentence_transformers import SentenceTransformer | |
| import faiss | |
| import numpy as np | |
| from datasets import load_dataset | |
| from huggingface_hub import InferenceClient | |
| import os | |
| import re | |
| from datetime import datetime | |
| from markdown_it import MarkdownIt | |
| from pypdf import PdfReader | |
| # --- CONFIGURATION --- | |
| MODEL_ID = "BAAI/bge-m3" | |
| DATASET_REPO = "opt-nc/metiers" | |
| LLM_MODEL = "Qwen/Qwen2.5-7B-Instruct" | |
| APP_URL = "https://huggingface.co/spaces/opt-nc/metiers" | |
| # Initialisation du convertisseur Markdown | |
| md = MarkdownIt() | |
| # Vérification du Token (Logs) | |
| HF_TOKEN = os.getenv("HF_TOKEN") | |
| if HF_TOKEN: | |
| print(f"✅ HF_TOKEN détecté (longueur : {len(HF_TOKEN)})") | |
| else: | |
| print("⚠️ Aucun HF_TOKEN détecté. L'usage sera limité ou échouera.") | |
| # Initialisation du client LLM | |
| client = InferenceClient(model=LLM_MODEL, token=HF_TOKEN) | |
| print(f"🚀 Chargement des données depuis le Hub HF ({DATASET_REPO})...") | |
| try: | |
| ds = load_dataset(DATASET_REPO, split="train") | |
| df = ds.to_pandas() | |
| print(f"✅ {len(df)} fiches chargées avec succès.") | |
| except Exception as e: | |
| print(f"❌ Erreur lors du chargement du dataset : {e}") | |
| df = pd.DataFrame(columns=["id", "title", "text", "famille"]) | |
| print(f"🧠 Chargement du modèle d'embedding {MODEL_ID}...") | |
| model = SentenceTransformer(MODEL_ID) | |
| # Indexation FAISS — IndexFlatIP + normalisation L2 => score cosinus borné [0,1] | |
| print("⚡ Indexation des fiches métiers...") | |
| if not df.empty: | |
| embeddings = model.encode(df['text'].tolist(), show_progress_bar=True, normalize_embeddings=True) | |
| embeddings = np.array(embeddings).astype('float32') | |
| dimension = embeddings.shape[1] | |
| index = faiss.IndexFlatIP(dimension) | |
| index.add(embeddings) | |
| else: | |
| index = None | |
| # --------------------------------------------------------------------------- | |
| # UTILITAIRES | |
| # --------------------------------------------------------------------------- | |
| def strip_markdown(text): | |
| """Supprime les balises Markdown pour l'affichage en texte brut (PDF).""" | |
| text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) | |
| text = re.sub(r'\*(.*?)\*', r'\1', text) | |
| text = re.sub(r'#{1,6}\s', '', text) | |
| text = re.sub(r'`{1,3}', '', text) | |
| text = re.sub(r'\n{3,}', '\n\n', text) | |
| return text.strip() | |
| def extract_text_from_pdf(file_path): | |
| try: | |
| reader = PdfReader(file_path) | |
| text = "" | |
| for page in reader.pages: | |
| text += page.extract_text() + "\n" | |
| return text.strip() | |
| except Exception as e: | |
| return f"Erreur lors de l'extraction du PDF : {e}" | |
| def expand_query(raw_query): | |
| """Reformule la query en mots-clés métier via le LLM (query expansion). | |
| Retourne (query enrichie, mots-clés) — fallback sur query originale si erreur.""" | |
| try: | |
| messages = [ | |
| { | |
| "role": "system", | |
| "content": ( | |
| "Tu es un expert RH en référentiels métiers (GPEC). " | |
| "Transforme la description d'un candidat en mots-clés professionnels " | |
| "utilisés dans les fiches de poste et référentiels de compétences. " | |
| "Réponds UNIQUEMENT avec les mots-clés séparés par des virgules. " | |
| "15 mots maximum. Pas de phrases, pas d'explication." | |
| ) | |
| }, | |
| {"role": "user", "content": raw_query[:500]} | |
| ] | |
| completion = client.chat_completion(messages, max_tokens=60) | |
| keywords = completion.choices[0].message.content.strip() | |
| print(f"🔍 Query expansion : {keywords}") | |
| # Stratégie B : query originale + mots-clés enrichis | |
| return f"{raw_query} {keywords}", keywords | |
| except Exception as e: | |
| print(f"⚠️ Query expansion échouée, query originale utilisée : {e}") | |
| return raw_query, "" | |
| # --------------------------------------------------------------------------- | |
| # RAG | |
| # --------------------------------------------------------------------------- | |
| def get_rag_context(query, top_k, score_min): | |
| if df.empty or index is None: | |
| return [], "" | |
| search_k = min(len(df), 50) | |
| query_vector = model.encode([query], normalize_embeddings=True).astype('float32') | |
| distances, indices = index.search(query_vector, search_k) | |
| unique_results = [] | |
| seen_metiers = set() | |
| for i in range(search_k): | |
| idx = indices[0][i] | |
| score = distances[0][i] | |
| metier = df.iloc[idx] | |
| # Filtre score minimum | |
| if score < score_min: | |
| continue | |
| base_id = metier['id'].split('_')[0] | |
| if base_id not in seen_metiers: | |
| seen_metiers.add(base_id) | |
| unique_results.append({"metier": metier, "score": score}) | |
| if len(unique_results) >= int(top_k): | |
| break | |
| context_text = "" | |
| for i, res in enumerate(unique_results): | |
| m = res["metier"] | |
| context_text += f"--- MÉTIER {i+1}: {m['title']} ---\n{m['text']}\n\n" | |
| return unique_results, context_text | |
| # --------------------------------------------------------------------------- | |
| # HTML | |
| # --------------------------------------------------------------------------- | |
| def build_results_html(results): | |
| """Génère le HTML des fiches métiers.""" | |
| results_html = """ | |
| <style> | |
| .metier-card { border: 1px solid #bbb !important; padding: 20px !important; margin-bottom: 20px !important; border-radius: 10px !important; background-color: #ffffff !important; box-shadow: 0 4px 6px rgba(0,0,0,0.1) !important; color: #000000 !important; } | |
| .metier-header { display: flex !important; justify-content: space-between !important; align-items: center !important; margin-bottom: 12px !important; border-bottom: 2px solid #eee !important; padding-bottom: 8px !important; } | |
| .metier-title { margin: 0 !important; color: #1e4620 !important; font-size: 1.4em !important; font-weight: bold !important; display: block !important; } | |
| .match-badge { background: #1e4620 !important; color: #ffffff !important; padding: 5px 15px !important; border-radius: 25px !important; font-size: 0.95em !important; font-weight: bold !important; } | |
| .metier-info { margin: 8px 0 !important; font-size: 1em !important; color: #1a1a1a !important; display: block !important; } | |
| .metier-info strong { color: #000000 !important; } | |
| .metier-details { margin-top: 15px !important; background-color: #f0f0f0 !important; border-radius: 6px !important; } | |
| summary { font-weight: bold !important; color: #004a99 !important; padding: 10px !important; cursor: pointer !important; list-style: none !important; } | |
| .metier-content { margin-top: 10px !important; font-size: 1em !important; line-height: 1.6 !important; color: #000000 !important; padding: 15px !important; background-color: #ffffff !important; border: 1px solid #ddd !important; border-radius: 4px !important; } | |
| .metier-content h1, .metier-content h2, .metier-content h3 { color: #1e4620 !important; font-weight: bold !important; } | |
| .metier-content p, .metier-content li, .metier-content span { color: #000000 !important; } | |
| .metier-content ul { padding-left: 25px !important; } | |
| .metier-content li { list-style-type: disc !important; margin-bottom: 8px !important; } | |
| .metier-content strong { color: #000000 !important; font-weight: bold !important; } | |
| </style> | |
| """ | |
| for res in results: | |
| metier = res["metier"] | |
| score = res["score"] | |
| similarity = score * 100 | |
| html_content = md.render(str(metier['text'])) | |
| results_html += f""" | |
| <div class="metier-card"> | |
| <div class="metier-header"> | |
| <h3 class="metier-title">{metier['title']}</h3> | |
| <span class="match-badge">Match: {similarity:.1f}%</span> | |
| </div> | |
| <p class="metier-info"> | |
| <strong style="color: #333;">ID:</strong> {str(metier['id']).split('_')[0]} | | |
| <strong style="color: #333;">Famille:</strong> {metier['famille']} | |
| </p> | |
| <details class="metier-details"> | |
| <summary style="cursor: pointer; color: #007bff; font-weight: 500;">📋 Voir la fiche de compétences complète</summary> | |
| <div class="metier-content"> | |
| {html_content} | |
| </div> | |
| </details> | |
| </div> | |
| """ | |
| return results_html | |
| # --------------------------------------------------------------------------- | |
| # RAPPORT HTML (remplace PDF — imprimable via Ctrl+P) | |
| # --------------------------------------------------------------------------- | |
| def generate_html_report(combined_query, keywords, results, llm_context, synthesis): | |
| """ | |
| Appelle le LLM avec un prompt dédié 'rapport RH' puis génère un HTML | |
| formaté, imprimable via Ctrl+P ou Enregistrer en PDF depuis le navigateur. | |
| """ | |
| now = datetime.now().strftime("%d/%m/%Y à %Hh%M") | |
| llm_query = combined_query[:2000] | |
| # Appel LLM dédié rapport RH (synchrone, sans streaming) | |
| print("📝 Génération de la note de synthèse RH...") | |
| try: | |
| rh_messages = [ | |
| { | |
| "role": "system", | |
| "content": ( | |
| "Tu es un consultant RH senior à l'OPT-NC. " | |
| "Tu rédiges des notes de synthèse officielles destinées " | |
| "à être archivées dans le dossier d'un candidat. " | |
| "Ton style est professionnel, structuré et factuel. " | |
| "Réponds en HTML avec des balises <h3>, <p>, <ul>, <li>, <strong>. " | |
| "N'inclus pas de balises <html>, <head> ou <body>." | |
| ) | |
| }, | |
| { | |
| "role": "user", | |
| "content": f"""Rédige une note de synthèse RH structurée pour le candidat suivant. | |
| PROFIL CANDIDAT : | |
| {llm_query} | |
| MOTS-CLÉS MÉTIER IDENTIFIÉS (query expansion) : | |
| {keywords if keywords else "Non disponibles"} | |
| MÉTIERS SUGGÉRÉS PAR LE SYSTÈME RAG : | |
| {llm_context} | |
| La note doit comporter exactement ces 4 sections avec des balises <h3> : | |
| 1. Profil synthétique — résumé du candidat en 3-4 lignes | |
| 2. Adéquation aux métiers identifiés — pour chaque métier, les points de correspondance | |
| 3. Points de vigilance — compétences manquantes ou écarts à combler | |
| 4. Recommandations — formations ou étapes suggérées | |
| Ne dépasse pas 400 mots. Réponds en français.""" | |
| } | |
| ] | |
| rh_completion = client.chat_completion(rh_messages, max_tokens=800) | |
| rh_html = rh_completion.choices[0].message.content | |
| # Nettoyer les éventuels blocs ```html | |
| rh_html = re.sub(r'```html?', '', rh_html) | |
| rh_html = re.sub(r'```', '', rh_html).strip() | |
| except Exception as e: | |
| rh_html = f"<p><em>Erreur lors de la génération de la note : {e}</em></p>" | |
| # Construction des métiers suggérés en HTML | |
| metiers_html = "" | |
| for i, res in enumerate(results): | |
| m = res["metier"] | |
| score = res["score"] * 100 | |
| metiers_html += f""" | |
| <div style="margin-bottom:10px; padding:10px; background:#f9f9f9; border-left:4px solid #1e4620;"> | |
| <strong>{i+1}. {m['title']}</strong> | |
| <span style="float:right; background:#1e4620; color:white; padding:2px 10px; border-radius:12px; font-size:0.9em;"> | |
| Match : {score:.1f}% | |
| </span><br> | |
| <small style="color:#555;">Famille : {m['famille']} | ID : {str(m['id']).split('_')[0]}</small> | |
| </div>""" | |
| # Synthèse grand public (déjà streamée) en HTML | |
| synthesis_html = md.render(synthesis) if synthesis else "<p><em>Non disponible</em></p>" | |
| # Assemblage du HTML interne (dans l'iframe) | |
| inner_html = f"""<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <style> | |
| * {{ box-sizing: border-box; margin: 0; padding: 0; }} | |
| body {{ | |
| font-family: Arial, sans-serif; | |
| font-size: 14px; | |
| line-height: 1.7; | |
| color: #111111; | |
| background: #ffffff; | |
| padding: 30px; | |
| }} | |
| h1 {{ font-size: 1.4em; color: #ffffff; margin: 0; }} | |
| h2 {{ font-size: 1.1em; color: #1e4620; border-bottom: 2px solid #1e4620; | |
| padding-bottom: 6px; margin: 0 0 12px 0; }} | |
| h3 {{ font-size: 1em; color: #1e4620; margin: 10px 0 6px; }} | |
| p, li {{ color: #111111; }} | |
| ul {{ padding-left: 20px; }} | |
| li {{ margin-bottom: 4px; }} | |
| strong {{ color: #111111; }} | |
| a {{ color: #1e4620; }} | |
| .header {{ | |
| background: #1e4620; | |
| color: #ffffff; | |
| padding: 20px 24px; | |
| border-radius: 8px; | |
| margin-bottom: 24px; | |
| }} | |
| .header p {{ color: #ffffffcc; font-size: 0.85em; margin-top: 6px; }} | |
| .section {{ margin-bottom: 24px; }} | |
| .box {{ | |
| background: #f5f5f5; | |
| color: #111111; | |
| padding: 12px; | |
| border-radius: 6px; | |
| white-space: pre-wrap; | |
| }} | |
| .metier-card {{ | |
| padding: 10px 12px; | |
| background: #f9f9f9; | |
| border-left: 4px solid #1e4620; | |
| margin-bottom: 8px; | |
| border-radius: 0 6px 6px 0; | |
| }} | |
| .metier-card strong {{ color: #1e4620; }} | |
| .metier-card small {{ color: #555555; }} | |
| .badge {{ | |
| float: right; | |
| background: #1e4620; | |
| color: #ffffff; | |
| padding: 2px 10px; | |
| border-radius: 12px; | |
| font-size: 0.85em; | |
| }} | |
| .analyse-box {{ | |
| background: #fafafa; | |
| border: 1px solid #e0e0e0; | |
| border-radius: 6px; | |
| padding: 16px; | |
| }} | |
| .transparence {{ | |
| background: #eef2ee; | |
| border-top: 3px solid #1e4620; | |
| border-radius: 6px; | |
| padding: 16px; | |
| font-size: 0.85em; | |
| color: #333333; | |
| margin-top: 24px; | |
| }} | |
| .transparence strong {{ color: #1e4620; }} | |
| .transparence td {{ padding: 3px 12px 3px 0; color: #333333; vertical-align: top; }} | |
| .transparence td:first-child {{ color: #555555; white-space: nowrap; }} | |
| .warning {{ color: #555555; font-style: italic; margin-top: 10px; }} | |
| .buttons {{ | |
| display: flex; gap: 12px; justify-content: center; margin-top: 24px; | |
| }} | |
| button {{ | |
| padding: 10px 28px; border: none; border-radius: 6px; | |
| font-size: 0.95em; cursor: pointer; | |
| }} | |
| .btn-print {{ background: #1e4620; color: #ffffff; }} | |
| .btn-copy {{ background: #555555; color: #ffffff; }} | |
| @media print {{ | |
| .buttons {{ display: none; }} | |
| body {{ padding: 10px; }} | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <h1>OPT-NC — Note de synthèse RH</h1> | |
| <p>Générée le {now}</p> | |
| </div> | |
| <div id="rapport-texte"> | |
| <div class="section"> | |
| <h2>Profil candidat</h2> | |
| <div class="box">{llm_query}</div> | |
| </div> | |
| {"" if not keywords else f''' | |
| <div class="section"> | |
| <h2>Mots-clés métier identifiés</h2> | |
| <div class="box">{keywords}</div> | |
| </div>'''} | |
| <div class="section"> | |
| <h2>Métiers suggérés par le système RAG</h2> | |
| {metiers_html} | |
| </div> | |
| <div class="section"> | |
| <h2>Analyse RH <small style="font-weight:normal; font-size:0.8em; color:#555;">(générée par IA — usage interne)</small></h2> | |
| <div class="analyse-box">{rh_html}</div> | |
| </div> | |
| <div class="section"> | |
| <h2>Synthèse présentée au candidat</h2> | |
| <div>{synthesis_html}</div> | |
| </div> | |
| <div class="transparence"> | |
| <strong>Transparence & mentions</strong> | |
| <p style="margin-top:10px; color:#333333;"> | |
| Ce document a été produit automatiquement par un <strong>prototype opérationnel</strong> | |
| développé par l'OPT-NC, à partir de <strong>données ouvertes</strong> et de | |
| <strong>modèles open source</strong>. | |
| </p> | |
| <table style="margin-top:10px; border-collapse:collapse; width:100%;"> | |
| <tr><td>Données</td><td>Référentiel GPEC OPT-NC (open data)</td></tr> | |
| <tr><td>Recherche sémantique</td><td>{MODEL_ID} (open source)</td></tr> | |
| <tr><td>Modèle d'analyse</td><td>{LLM_MODEL} (open source)</td></tr> | |
| <tr><td>Fiches indexées</td><td>{len(df)}</td></tr> | |
| </table> | |
| <p class="warning">⚠️ Les suggestions sont indicatives et ne constituent pas une décision RH officielle.</p> | |
| <p style="margin-top:8px;">Application : <a href="{APP_URL}">{APP_URL}</a></p> | |
| </div> | |
| </div><!-- fin rapport-texte --> | |
| <div class="buttons"> | |
| <button class="btn-print" onclick="window.print()">🖨️ Imprimer / Enregistrer en PDF</button> | |
| <button class="btn-copy" onclick=" | |
| const el = document.getElementById('rapport-texte'); | |
| navigator.clipboard.writeText(el.innerText).then(() => {{ | |
| this.textContent = '✅ Copié !'; | |
| setTimeout(() => this.textContent = '📋 Copier le texte', 2000); | |
| }});">📋 Copier le texte</button> | |
| </div> | |
| </body> | |
| </html>""" | |
| import base64 | |
| encoded = base64.b64encode(inner_html.encode('utf-8')).decode('utf-8') | |
| html = f'<iframe src="data:text/html;base64,{encoded}" style="width:100%; height:900px; border:none; border-radius:8px;"></iframe>' | |
| return html | |
| # --------------------------------------------------------------------------- | |
| # RECHERCHE + CHAT | |
| # --------------------------------------------------------------------------- | |
| def search_and_chat(query, file_obj, top_k, score_min): | |
| """Générateur : yield (results_html, llm_text, results_state, query_state, keywords_state, context_state, synthesis_state).""" | |
| combined_query = query | |
| if file_obj is not None: | |
| pdf_text = extract_text_from_pdf(file_obj.name) | |
| if pdf_text.startswith("Erreur"): | |
| yield results_html_error(pdf_text), "Erreur lors de la lecture du CV.", [], "", "", "", "" | |
| return | |
| combined_query = f"{query}\n\n[Contenu du CV extrait] :\n{pdf_text}" if query else pdf_text | |
| if not combined_query: | |
| yield "Veuillez entrer une description ou uploader un CV.", "En attente de votre profil...", [], "", "", "", "" | |
| return | |
| # Query expansion | |
| expanded_query, keywords = expand_query(combined_query) | |
| results, context = get_rag_context(expanded_query, top_k, score_min / 100) | |
| if not results: | |
| seuil = int(score_min) | |
| yield ( | |
| f"<div style='padding:20px; background:#fff8e1; border:1px solid #f0c040; border-radius:8px;'>" | |
| f"⚠️ Aucun métier trouvé avec un score ≥ {seuil}%. " | |
| f"Essayez de baisser le seuil de score minimum.</div>", | |
| "Aucun résultat suffisamment pertinent trouvé.", [], "", "", "", "" | |
| ) | |
| return | |
| # Fiches HTML immédiates | |
| results_html = build_results_html(results) | |
| yield results_html, "⏳ Analyse en cours...", results, combined_query, keywords, context, "" | |
| # Streaming LLM — synthèse grand public | |
| llm_query = combined_query[:2000] | |
| llm_context = context[:4000] | |
| messages = [ | |
| { | |
| "role": "system", | |
| "content": "Tu es un expert en ressources humaines à l'OPT-NC (Nouvelle-Calédonie). Ton rôle est d'analyser le profil d'un candidat par rapport à des fiches métiers du référentiel GPEC." | |
| }, | |
| { | |
| "role": "user", | |
| "content": f"""Un candidat se présente avec le profil suivant : "{llm_query}" | |
| Voici les fiches métiers les plus pertinentes trouvées dans notre référentiel : | |
| {llm_context} | |
| En te basant UNIQUEMENT sur ces fiches, explique au candidat pourquoi ces métiers lui correspondent. | |
| Sois encourageant, professionnel et cite des compétences spécifiques mentionnées dans les fiches. | |
| Réponds en français. Ne dépasse pas 300 mots.""" | |
| } | |
| ] | |
| try: | |
| response_text = "" | |
| for chunk in client.chat_completion(messages, max_tokens=1000, stream=True): | |
| token = chunk.choices[0].delta.content or "" | |
| response_text += token | |
| # synthesis_state mis à jour à chaque token — valeur finale disponible en fin de stream | |
| yield results_html, response_text, results, combined_query, keywords, context, response_text | |
| except Exception as e: | |
| error_msg = str(e) | |
| print(f"❌ Erreur LLM : {error_msg}") | |
| if "401" in error_msg or "token" in error_msg.lower(): | |
| yield results_html, "🔑 Erreur d'authentification : Veuillez configurer un `HF_TOKEN` valide dans les Secrets du Space.", results, combined_query, keywords, context, "" | |
| else: | |
| yield results_html, f"⚠️ Erreur lors de la génération : {error_msg}", results, combined_query, keywords, context, "" | |
| def on_generate_report(results_state, query_state, keywords_state, context_state, synthesis_state): | |
| """Génère le rapport HTML et le rend visible.""" | |
| if not results_state: | |
| return gr.update(visible=False) | |
| html = generate_html_report( | |
| combined_query=query_state, | |
| keywords=keywords_state, | |
| results=results_state, | |
| llm_context=context_state[:4000], | |
| synthesis=synthesis_state | |
| ) | |
| return gr.update(value=html, visible=True) | |
| def results_html_error(msg): | |
| return f"<div style='color: red; padding: 20px; border: 1px solid red; border-radius: 8px;'>{msg}</div>" | |
| # --------------------------------------------------------------------------- | |
| # INTERFACE GRADIO | |
| # --------------------------------------------------------------------------- | |
| with gr.Blocks(theme=gr.themes.Soft()) as demo: | |
| gr.Markdown( | |
| """ | |
| # Assistant Métiers OPT-NC (Expert RAG) | |
| ### Trouvez votre voie au sein de l'Office avec l'aide de notre IA. | |
| """ | |
| ) | |
| # States internes (non visibles par l'utilisateur) | |
| results_state = gr.State([]) | |
| query_state = gr.State("") | |
| keywords_state = gr.State("") | |
| context_state = gr.State("") | |
| synthesis_state = gr.State("") | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| query_input = gr.Textbox( | |
| label="Décrivez votre profil ou vos envies", | |
| placeholder="Ex: J'aime l'informatique, le contact client...", | |
| lines=3, | |
| max_lines=10, | |
| max_length=2000 | |
| ) | |
| query_input.submit( | |
| fn=search_and_chat, | |
| inputs=[query_input, file_input, top_k_slider, score_slider], | |
| outputs=[results_output, llm_output, results_state, query_state, keywords_state, context_state, synthesis_state], | |
| stream_every=0.05 | |
| ).then( | |
| fn=lambda r: gr.update(visible=bool(r)), | |
| inputs=[results_state], | |
| outputs=[export_button] | |
| ) | |
| file_input = gr.File(label="Ou uploadez votre CV (PDF)", file_types=[".pdf"]) | |
| with gr.Column(scale=2): | |
| top_k_slider = gr.Slider(minimum=1, maximum=10, value=3, step=1, label="Nombre de résultats") | |
| score_slider = gr.Slider(minimum=0, maximum=100, value=40, step=5, | |
| label="Score minimum (%)", | |
| info="Baisser si aucun résultat ne s'affiche") | |
| search_button = gr.Button("🚀 Analyser mon profil", variant="primary") | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| gr.Markdown("### 🤖 Analyse de l'Assistant IA") | |
| llm_output = gr.Markdown("L'IA analysera votre profil après votre recherche...") | |
| export_button = gr.Button("📄 Générer la note de synthèse RH", variant="secondary", visible=False) | |
| with gr.Column(scale=3): | |
| gr.Markdown("### 📋 Fiches Métiers suggérées") | |
| results_output = gr.HTML() | |
| # Rapport HTML (pleine largeur, sous les deux colonnes) | |
| report_output = gr.HTML(visible=False) | |
| # 1. Recherche + streaming LLM | |
| search_button.click( | |
| fn=search_and_chat, | |
| inputs=[query_input, file_input, top_k_slider, score_slider], | |
| outputs=[results_output, llm_output, results_state, query_state, keywords_state, context_state, synthesis_state], | |
| stream_every=0.05 | |
| ).then( | |
| fn=lambda r: gr.update(visible=bool(r)), | |
| inputs=[results_state], | |
| outputs=[export_button] | |
| ) | |
| # 2. Génération du rapport HTML | |
| export_button.click( | |
| fn=on_generate_report, | |
| inputs=[results_state, query_state, keywords_state, context_state, synthesis_state], | |
| outputs=[report_output] | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |