metiers / app.py
rastadidi's picture
Upload app.py
11f36d0 verified
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']} &nbsp;|&nbsp; 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 &amp; 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()