Upload app.py
Browse files
app.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import os
|
|
|
|
| 2 |
import gradio as gr
|
| 3 |
import pandas as pd
|
| 4 |
import numpy as np
|
|
@@ -6,7 +7,6 @@ import requests
|
|
| 6 |
import markdown as md_lib
|
| 7 |
from datasets import load_dataset
|
| 8 |
|
| 9 |
-
# --- Données ---
|
| 10 |
ds = load_dataset("opt-nc/avps", split="train")
|
| 11 |
df = ds.to_pandas()
|
| 12 |
|
|
@@ -25,11 +25,8 @@ def encode_query(text: str):
|
|
| 25 |
if HF_TOKEN:
|
| 26 |
headers["Authorization"] = f"Bearer {HF_TOKEN}"
|
| 27 |
try:
|
| 28 |
-
r = requests.post(
|
| 29 |
-
|
| 30 |
-
json={"inputs": text, "options": {"wait_for_model": True}},
|
| 31 |
-
timeout=30,
|
| 32 |
-
)
|
| 33 |
if r.status_code in (429, 503):
|
| 34 |
return None
|
| 35 |
r.raise_for_status()
|
|
@@ -54,41 +51,43 @@ def fmt_date(d):
|
|
| 54 |
|
| 55 |
|
| 56 |
def score_widget(score):
|
| 57 |
-
"""Badge visuel score style 'immédiat'."""
|
| 58 |
pct = int(round(score * 100))
|
| 59 |
if pct >= 70:
|
| 60 |
-
bg, color, label = "#d1fae5", "#065f46", f"✦ {pct}%
|
|
|
|
| 61 |
elif pct >= 50:
|
| 62 |
-
bg, color, label = "#fef3c7", "#92400e", f"◈ {pct}%
|
|
|
|
| 63 |
else:
|
| 64 |
-
bg, color, label = "#f3f4f6", "#6b7280", f"○ {pct}%
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
return (
|
| 66 |
-
f'<span
|
| 67 |
-
f'
|
|
|
|
|
|
|
| 68 |
)
|
| 69 |
|
| 70 |
|
| 71 |
-
def
|
| 72 |
-
"""
|
| 73 |
-
if not
|
| 74 |
-
return ""
|
| 75 |
-
tl = text.lower()
|
| 76 |
-
best_pos = -1
|
| 77 |
-
for w in qwords:
|
| 78 |
-
pos = tl.find(w)
|
| 79 |
-
if pos != -1:
|
| 80 |
-
best_pos = pos
|
| 81 |
-
break
|
| 82 |
-
if best_pos == -1:
|
| 83 |
return ""
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
def render_cards(results_df, scores=None, qwords=None):
|
|
@@ -107,6 +106,7 @@ def render_cards(results_df, scores=None, qwords=None):
|
|
| 107 |
immed = row.get("disponible_immediatement", False)
|
| 108 |
cloture = fmt_date(row.get("date_cloture"))
|
| 109 |
url = row.get("url") or ""
|
|
|
|
| 110 |
score = scores[i] if scores is not None else None
|
| 111 |
|
| 112 |
dir_str = direction + (f" › {service}" if service else "")
|
|
@@ -116,20 +116,24 @@ def render_cards(results_df, scores=None, qwords=None):
|
|
| 116 |
if score is not None:
|
| 117 |
badges += score_widget(score) + " "
|
| 118 |
if immed:
|
| 119 |
-
badges += '<span style="background:#d1fae5;color:#065f46;font-size:11px;
|
|
|
|
| 120 |
|
| 121 |
cloture_html = f'<span style="font-size:11px;color:#6b7280">🗓 {cloture}</span>' if cloture else ""
|
| 122 |
-
ref_html = f'<span style="font-size:11px;color:#
|
| 123 |
-
|
| 124 |
-
#
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
annonce_btn = (
|
| 132 |
-
f'<a href="{url}" target="_blank" '
|
| 133 |
f'style="padding:6px 14px;background:#2563eb;color:#fff;border-radius:8px;'
|
| 134 |
f'text-decoration:none;font-size:13px;font-weight:500">Voir →</a>'
|
| 135 |
if url else ""
|
|
@@ -138,9 +142,9 @@ def render_cards(results_df, scores=None, qwords=None):
|
|
| 138 |
cards.append(f"""
|
| 139 |
<div id="card-{job_id}"
|
| 140 |
style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;
|
| 141 |
-
padding:14px 16px;margin-bottom:10px;cursor:pointer;transition:border-color 0.15s"
|
| 142 |
onmouseenter="this.style.borderColor='#93c5fd'"
|
| 143 |
-
onmouseleave="this.style.borderColor='#e5e7eb'"
|
| 144 |
onclick="selectJob('{job_id}', this)">
|
| 145 |
<div style="display:flex;justify-content:space-between;align-items:flex-start;
|
| 146 |
flex-wrap:wrap;gap:6px;margin-bottom:4px">
|
|
@@ -148,10 +152,10 @@ def render_cards(results_df, scores=None, qwords=None):
|
|
| 148 |
<div style="display:flex;gap:4px;align-items:center;flex-shrink:0;flex-wrap:wrap">{badges}</div>
|
| 149 |
</div>
|
| 150 |
<p style="font-size:12px;color:#6b7280;margin:0 0 4px">{meta}</p>
|
| 151 |
-
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:
|
| 152 |
{cloture_html}{ref_html}
|
| 153 |
</div>
|
| 154 |
-
{
|
| 155 |
<div style="margin-top:10px">{annonce_btn}</div>
|
| 156 |
</div>""")
|
| 157 |
|
|
@@ -165,17 +169,17 @@ def render_detail(job_id: str):
|
|
| 165 |
if not row:
|
| 166 |
return ""
|
| 167 |
|
| 168 |
-
titre
|
| 169 |
-
direction
|
| 170 |
-
service
|
| 171 |
-
grade
|
| 172 |
-
lieu
|
| 173 |
-
immed
|
| 174 |
-
cloture
|
| 175 |
-
url
|
| 176 |
-
url_pdf
|
| 177 |
-
texte_md
|
| 178 |
-
numero
|
| 179 |
|
| 180 |
body_html = md_lib.markdown(texte_md, extensions=["nl2br", "sane_lists"])
|
| 181 |
dir_str = direction + (f" › {service}" if service else "")
|
|
@@ -200,21 +204,20 @@ def render_detail(job_id: str):
|
|
| 200 |
|
| 201 |
share_url = f"https://opt-nc.github.io/avps/{numero}/"
|
| 202 |
share_text = f"AVP OPT-NC — {titre} ({numero})"
|
| 203 |
-
|
| 204 |
-
|
| 205 |
|
| 206 |
share_html = f"""
|
| 207 |
-
<div style="margin-top:16px;padding:12px;background:#f8fafc;border-radius:10px;
|
| 208 |
-
|
| 209 |
-
<
|
| 210 |
-
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
| 211 |
<button onclick="navigator.clipboard.writeText('{share_url}').then(()=>alert('Lien copié !'))"
|
| 212 |
style="padding:5px 12px;background:#f1f5f9;color:#374151;border:1px solid #e2e8f0;
|
| 213 |
border-radius:8px;font-size:12px;cursor:pointer">📋 Copier le lien</button>
|
| 214 |
-
<a href="mailto:?subject={
|
| 215 |
style="padding:5px 12px;background:#f1f5f9;color:#374151;border:1px solid #e2e8f0;
|
| 216 |
border-radius:8px;font-size:12px;text-decoration:none">✉️ Email</a>
|
| 217 |
-
<a href="https://wa.me/?text={
|
| 218 |
style="padding:5px 12px;background:#f1f5f9;color:#374151;border:1px solid #e2e8f0;
|
| 219 |
border-radius:8px;font-size:12px;text-decoration:none">💬 WhatsApp</a>
|
| 220 |
</div>
|
|
@@ -231,16 +234,36 @@ def render_detail(job_id: str):
|
|
| 231 |
<div style="margin-bottom:16px">{btns}</div>
|
| 232 |
{share_html}
|
| 233 |
<hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0">
|
| 234 |
-
<div style="font-size:14px;color:#374151;line-height:1.7">{body_html}</div>
|
| 235 |
</div>"""
|
| 236 |
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
def search(query, threshold):
|
| 239 |
min_score = threshold / 100.0
|
| 240 |
qwords = [w for w in query.lower().split() if len(w) > 2] if query.strip() else []
|
| 241 |
|
| 242 |
if not query.strip():
|
| 243 |
-
return render_cards(df), ""
|
| 244 |
|
| 245 |
q_vec = encode_query(query)
|
| 246 |
if q_vec is not None:
|
|
@@ -251,7 +274,7 @@ def search(query, threshold):
|
|
| 251 |
mask = [s >= min_score for s in scores]
|
| 252 |
filtered = results[mask]
|
| 253 |
filtered_scores = [s for s, m in zip(scores, mask) if m]
|
| 254 |
-
return render_cards(filtered, filtered_scores, qwords), ""
|
| 255 |
else:
|
| 256 |
def score_row(row):
|
| 257 |
text = f"{row.get('titre','')} {row.get('text','')} {row.get('corps_grade','')}".lower()
|
|
@@ -259,7 +282,7 @@ def search(query, threshold):
|
|
| 259 |
df2 = df.copy()
|
| 260 |
df2["_s"] = df2.apply(score_row, axis=1)
|
| 261 |
df2 = df2[df2["_s"] > 0].sort_values("_s", ascending=False)
|
| 262 |
-
return render_cards(df2, qwords=qwords), ""
|
| 263 |
|
| 264 |
|
| 265 |
def on_card_click(job_id):
|
|
@@ -273,11 +296,14 @@ with gr.Blocks(title="AVPs OPT-NC") as demo:
|
|
| 273 |
.gradio-container { max-width: 1100px !important; }
|
| 274 |
footer { display: none !important; }
|
| 275 |
#query textarea { font-size: 15px !important; line-height: 1.5 !important; }
|
| 276 |
-
#search-btn { font-size: 15px !important; height: 44px !important; border-radius: 10px !important; }
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
}
|
|
|
|
|
|
|
|
|
|
| 281 |
</style>""")
|
| 282 |
|
| 283 |
gr.HTML(f"""
|
|
@@ -287,7 +313,7 @@ mark { background: #fef08a; border-radius: 3px; padding: 0 2px; }
|
|
| 287 |
<h1 style="font-size:20px;font-weight:700;margin:0;color:#111">📋 AVPs OPT-NC</h1>
|
| 288 |
<p style="font-size:13px;color:#6b7280;margin:2px 0 0">Décrivez votre profil ou saisissez des mots-clés</p>
|
| 289 |
</div>
|
| 290 |
-
<a href="{RSS_URL}" title="S'abonner au flux RSS
|
| 291 |
style="display:flex;align-items:center;gap:6px;padding:7px 14px;
|
| 292 |
background:#fff7ed;color:#c2410c;border:1px solid #fed7aa;
|
| 293 |
border-radius:10px;text-decoration:none;font-size:13px;font-weight:500">
|
|
@@ -299,25 +325,21 @@ mark { background: #fef08a; border-radius: 3px; padding: 0 2px; }
|
|
| 299 |
</div>""")
|
| 300 |
|
| 301 |
query_input = gr.Textbox(
|
| 302 |
-
elem_id="query",
|
| 303 |
-
label="",
|
| 304 |
placeholder=(
|
| 305 |
"Ex : \"Cadre avec expérience en gestion de projets SI et management d'équipe\"\n"
|
| 306 |
"ou : chef de service, télécoms, RH, marketing...\n"
|
| 307 |
"Ctrl+Entrée pour lancer la recherche"
|
| 308 |
),
|
| 309 |
-
lines=3,
|
| 310 |
-
max_lines=4,
|
| 311 |
-
show_label=False,
|
| 312 |
)
|
| 313 |
|
| 314 |
with gr.Row():
|
| 315 |
search_btn = gr.Button("🔍 Rechercher", variant="primary", elem_id="search-btn", scale=1)
|
| 316 |
-
threshold_slider = gr.Slider(
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
)
|
| 321 |
|
| 322 |
with gr.Row():
|
| 323 |
with gr.Column(scale=1, elem_id="results-col"):
|
|
@@ -329,68 +351,43 @@ mark { background: #fef08a; border-radius: 3px; padding: 0 2px; }
|
|
| 329 |
'Cliquez sur une vignette pour afficher le détail</div>'
|
| 330 |
)
|
| 331 |
|
| 332 |
-
|
| 333 |
-
selected_id = gr.State("")
|
| 334 |
|
| 335 |
-
# Ctrl+Entrée → recherche
|
| 336 |
gr.HTML("""<script>
|
|
|
|
| 337 |
document.addEventListener('keydown', function(e) {
|
| 338 |
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
| 339 |
-
const btn = document.
|
| 340 |
if (btn) btn.click();
|
| 341 |
}
|
| 342 |
});
|
| 343 |
|
| 344 |
function selectJob(jobId, el) {
|
| 345 |
-
|
| 346 |
-
|
| 347 |
c.style.borderColor = '#e5e7eb';
|
| 348 |
c.style.background = '#fff';
|
| 349 |
});
|
|
|
|
| 350 |
el.style.borderColor = '#3b82f6';
|
| 351 |
el.style.background = '#eff6ff';
|
| 352 |
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
if (
|
| 356 |
-
|
| 357 |
-
|
| 358 |
}
|
| 359 |
}
|
| 360 |
-
</script>""")
|
| 361 |
-
|
| 362 |
-
# Bouton invisible déclenché par le JS
|
| 363 |
-
detail_trigger = gr.Button("trigger", visible=False, elem_id="detail-trigger")
|
| 364 |
-
|
| 365 |
-
def trigger_detail(current_id):
|
| 366 |
-
# Appelé via JS — on retourne le même id pour déclencher on_card_click
|
| 367 |
-
return current_id
|
| 368 |
-
|
| 369 |
-
# On utilise un textbox caché pour passer l'id depuis JS
|
| 370 |
-
hidden_id_input = gr.Textbox(visible=False, elem_id="hidden-job-id")
|
| 371 |
-
|
| 372 |
-
gr.HTML("""<script>
|
| 373 |
-
// Override : quand le bouton detail-trigger est cliqué, on injecte l'id dans le textbox caché
|
| 374 |
-
document.addEventListener('click', function(e) {
|
| 375 |
-
if (e.target && e.target.id === 'detail-trigger') {
|
| 376 |
-
setTimeout(function() {
|
| 377 |
-
const ta = document.querySelector('#hidden-job-id textarea') ||
|
| 378 |
-
document.querySelector('#hidden-job-id input');
|
| 379 |
-
if (ta && window._selectedJobId) {
|
| 380 |
-
ta.value = window._selectedJobId;
|
| 381 |
-
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
| 382 |
-
}
|
| 383 |
-
}, 50);
|
| 384 |
-
}
|
| 385 |
-
}, true);
|
| 386 |
</script>""")
|
| 387 |
|
| 388 |
hidden_id_input.change(fn=on_card_click, inputs=hidden_id_input, outputs=detail_html)
|
| 389 |
|
| 390 |
inputs = [query_input, threshold_slider]
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
|
|
|
| 394 |
|
| 395 |
if __name__ == "__main__":
|
| 396 |
demo.launch()
|
|
|
|
| 1 |
import os
|
| 2 |
+
import re
|
| 3 |
import gradio as gr
|
| 4 |
import pandas as pd
|
| 5 |
import numpy as np
|
|
|
|
| 7 |
import markdown as md_lib
|
| 8 |
from datasets import load_dataset
|
| 9 |
|
|
|
|
| 10 |
ds = load_dataset("opt-nc/avps", split="train")
|
| 11 |
df = ds.to_pandas()
|
| 12 |
|
|
|
|
| 25 |
if HF_TOKEN:
|
| 26 |
headers["Authorization"] = f"Bearer {HF_TOKEN}"
|
| 27 |
try:
|
| 28 |
+
r = requests.post(EMBED_API, headers=headers,
|
| 29 |
+
json={"inputs": text, "options": {"wait_for_model": True}}, timeout=8)
|
|
|
|
|
|
|
|
|
|
| 30 |
if r.status_code in (429, 503):
|
| 31 |
return None
|
| 32 |
r.raise_for_status()
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
def score_widget(score):
|
|
|
|
| 54 |
pct = int(round(score * 100))
|
| 55 |
if pct >= 70:
|
| 56 |
+
bg, color, label = "#d1fae5", "#065f46", f"✦ {pct}%"
|
| 57 |
+
tip = f"{pct}% — très bonne correspondance"
|
| 58 |
elif pct >= 50:
|
| 59 |
+
bg, color, label = "#fef3c7", "#92400e", f"◈ {pct}%"
|
| 60 |
+
tip = f"{pct}% — correspondance correcte"
|
| 61 |
else:
|
| 62 |
+
bg, color, label = "#f3f4f6", "#6b7280", f"○ {pct}%"
|
| 63 |
+
tip = f"{pct}% — correspondance partielle"
|
| 64 |
+
tooltip = (
|
| 65 |
+
f"{tip}. Score de similarité sémantique entre votre profil "
|
| 66 |
+
"et le texte de l'annonce (missions, compétences), "
|
| 67 |
+
"calculé par le modèle BAAI/bge-m3. 100% = correspondance parfaite."
|
| 68 |
+
)
|
| 69 |
return (
|
| 70 |
+
f'<span title="{tooltip}" '
|
| 71 |
+
f'style="background:{bg};color:{color};font-size:11px;font-weight:600;'
|
| 72 |
+
f'padding:3px 9px;border-radius:12px;white-space:nowrap;cursor:help;'
|
| 73 |
+
f'border-bottom:1px dotted {color}">{label} match</span>'
|
| 74 |
)
|
| 75 |
|
| 76 |
|
| 77 |
+
def md_to_html_highlighted(text_md, qwords):
|
| 78 |
+
"""Convertit le markdown en HTML et surligne les mots-clés avec contraste correct."""
|
| 79 |
+
if not text_md:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
return ""
|
| 81 |
+
html = md_lib.markdown(text_md, extensions=["nl2br", "sane_lists"])
|
| 82 |
+
if qwords:
|
| 83 |
+
for w in qwords:
|
| 84 |
+
# Surlignage sombre (fond foncé + texte blanc) pour lisibilité
|
| 85 |
+
html = re.sub(
|
| 86 |
+
f"(?<![<\w])({re.escape(w)})(?![>\w])",
|
| 87 |
+
r'<mark>\1</mark>',
|
| 88 |
+
html, flags=re.IGNORECASE
|
| 89 |
+
)
|
| 90 |
+
return html
|
| 91 |
|
| 92 |
|
| 93 |
def render_cards(results_df, scores=None, qwords=None):
|
|
|
|
| 106 |
immed = row.get("disponible_immediatement", False)
|
| 107 |
cloture = fmt_date(row.get("date_cloture"))
|
| 108 |
url = row.get("url") or ""
|
| 109 |
+
texte_md = row.get("text") or ""
|
| 110 |
score = scores[i] if scores is not None else None
|
| 111 |
|
| 112 |
dir_str = direction + (f" › {service}" if service else "")
|
|
|
|
| 116 |
if score is not None:
|
| 117 |
badges += score_widget(score) + " "
|
| 118 |
if immed:
|
| 119 |
+
badges += ('<span style="background:#d1fae5;color:#065f46;font-size:11px;'
|
| 120 |
+
'font-weight:600;padding:3px 9px;border-radius:12px">⚡ Immédiat</span>')
|
| 121 |
|
| 122 |
cloture_html = f'<span style="font-size:11px;color:#6b7280">🗓 {cloture}</span>' if cloture else ""
|
| 123 |
+
ref_html = f'<span style="font-size:11px;color:#a78bfa">#{numero}</span>'
|
| 124 |
+
|
| 125 |
+
# Aperçu HTML du markdown avec surlignage
|
| 126 |
+
preview_html = md_to_html_highlighted(texte_md, qwords)
|
| 127 |
+
preview_block = f"""
|
| 128 |
+
<div class="md-preview" style="font-size:12px;color:#374151;line-height:1.6;
|
| 129 |
+
margin-top:10px;max-height:160px;overflow:hidden;position:relative;">
|
| 130 |
+
{preview_html}
|
| 131 |
+
<div style="position:absolute;bottom:0;left:0;right:0;height:40px;
|
| 132 |
+
background:linear-gradient(transparent,#fff)"></div>
|
| 133 |
+
</div>"""
|
| 134 |
|
| 135 |
annonce_btn = (
|
| 136 |
+
f'<a href="{url}" target="_blank" onclick="event.stopPropagation()" '
|
| 137 |
f'style="padding:6px 14px;background:#2563eb;color:#fff;border-radius:8px;'
|
| 138 |
f'text-decoration:none;font-size:13px;font-weight:500">Voir →</a>'
|
| 139 |
if url else ""
|
|
|
|
| 142 |
cards.append(f"""
|
| 143 |
<div id="card-{job_id}"
|
| 144 |
style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;
|
| 145 |
+
padding:14px 16px;margin-bottom:10px;cursor:pointer;transition:border-color 0.15s,background 0.15s"
|
| 146 |
onmouseenter="this.style.borderColor='#93c5fd'"
|
| 147 |
+
onmouseleave="if(!this.classList.contains('selected'))this.style.borderColor='#e5e7eb'"
|
| 148 |
onclick="selectJob('{job_id}', this)">
|
| 149 |
<div style="display:flex;justify-content:space-between;align-items:flex-start;
|
| 150 |
flex-wrap:wrap;gap:6px;margin-bottom:4px">
|
|
|
|
| 152 |
<div style="display:flex;gap:4px;align-items:center;flex-shrink:0;flex-wrap:wrap">{badges}</div>
|
| 153 |
</div>
|
| 154 |
<p style="font-size:12px;color:#6b7280;margin:0 0 4px">{meta}</p>
|
| 155 |
+
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:4px">
|
| 156 |
{cloture_html}{ref_html}
|
| 157 |
</div>
|
| 158 |
+
{preview_block}
|
| 159 |
<div style="margin-top:10px">{annonce_btn}</div>
|
| 160 |
</div>""")
|
| 161 |
|
|
|
|
| 169 |
if not row:
|
| 170 |
return ""
|
| 171 |
|
| 172 |
+
titre = row.get("titre") or "Poste sans titre"
|
| 173 |
+
direction= row.get("direction_interne") or ""
|
| 174 |
+
service = row.get("service") or ""
|
| 175 |
+
grade = row.get("corps_grade") or ""
|
| 176 |
+
lieu = row.get("lieu_travail") or ""
|
| 177 |
+
immed = row.get("disponible_immediatement", False)
|
| 178 |
+
cloture = fmt_date(row.get("date_cloture"))
|
| 179 |
+
url = row.get("url") or ""
|
| 180 |
+
url_pdf = row.get("url_pdf") or ""
|
| 181 |
+
texte_md = row.get("text") or ""
|
| 182 |
+
numero = row.get("numero") or job_id
|
| 183 |
|
| 184 |
body_html = md_lib.markdown(texte_md, extensions=["nl2br", "sane_lists"])
|
| 185 |
dir_str = direction + (f" › {service}" if service else "")
|
|
|
|
| 204 |
|
| 205 |
share_url = f"https://opt-nc.github.io/avps/{numero}/"
|
| 206 |
share_text = f"AVP OPT-NC — {titre} ({numero})"
|
| 207 |
+
se = requests.utils.quote(share_text)
|
| 208 |
+
ue = requests.utils.quote(share_url)
|
| 209 |
|
| 210 |
share_html = f"""
|
| 211 |
+
<div style="margin-top:16px;padding:12px;background:#f8fafc;border-radius:10px;border:1px solid #e2e8f0">
|
| 212 |
+
<p style="font-size:12px;color:#6b7280;margin:0 0 8px;font-weight:500">📤 Partager</p>
|
| 213 |
+
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
|
|
|
| 214 |
<button onclick="navigator.clipboard.writeText('{share_url}').then(()=>alert('Lien copié !'))"
|
| 215 |
style="padding:5px 12px;background:#f1f5f9;color:#374151;border:1px solid #e2e8f0;
|
| 216 |
border-radius:8px;font-size:12px;cursor:pointer">📋 Copier le lien</button>
|
| 217 |
+
<a href="mailto:?subject={se}&body=Bonjour%2C%0A%0AVoici%20une%20AVP%20OPT-NC%20%3A%0A{ue}"
|
| 218 |
style="padding:5px 12px;background:#f1f5f9;color:#374151;border:1px solid #e2e8f0;
|
| 219 |
border-radius:8px;font-size:12px;text-decoration:none">✉️ Email</a>
|
| 220 |
+
<a href="https://wa.me/?text={se}%20{ue}" target="_blank"
|
| 221 |
style="padding:5px 12px;background:#f1f5f9;color:#374151;border:1px solid #e2e8f0;
|
| 222 |
border-radius:8px;font-size:12px;text-decoration:none">💬 WhatsApp</a>
|
| 223 |
</div>
|
|
|
|
| 234 |
<div style="margin-bottom:16px">{btns}</div>
|
| 235 |
{share_html}
|
| 236 |
<hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0">
|
| 237 |
+
<div class="md-body" style="font-size:14px;color:#374151;line-height:1.7">{body_html}</div>
|
| 238 |
</div>"""
|
| 239 |
|
| 240 |
|
| 241 |
+
BANNER_SEMANTIC = """
|
| 242 |
+
<div style="display:flex;align-items:center;gap:8px;padding:8px 14px;margin-bottom:10px;
|
| 243 |
+
background:#eff6ff;border:1px solid #bfdbfe;border-radius:10px;font-size:13px;color:#1e40af">
|
| 244 |
+
<span style="font-size:16px">🧠</span>
|
| 245 |
+
<span><strong>Recherche sémantique active</strong> — les résultats sont classés par similarité avec votre profil
|
| 246 |
+
(modèle <a href="https://huggingface.co/BAAI/bge-m3" target="_blank"
|
| 247 |
+
style="color:#1e40af;text-decoration:underline">BAAI/bge-m3</a>)</span>
|
| 248 |
+
</div>"""
|
| 249 |
+
|
| 250 |
+
BANNER_KEYWORDS = """
|
| 251 |
+
<div style="display:flex;align-items:center;gap:8px;padding:8px 14px;margin-bottom:10px;
|
| 252 |
+
background:#fefce8;border:1px solid #fde68a;border-radius:10px;font-size:13px;color:#854d0e">
|
| 253 |
+
<span style="font-size:16px">⚠️</span>
|
| 254 |
+
<span><strong>Mode mots-clés</strong> — l'API sémantique est indisponible,
|
| 255 |
+
les résultats sont filtrés par présence de mots dans le texte (pas de classement par pertinence)</span>
|
| 256 |
+
</div>"""
|
| 257 |
+
|
| 258 |
+
BANNER_EMPTY = ""
|
| 259 |
+
|
| 260 |
+
|
| 261 |
def search(query, threshold):
|
| 262 |
min_score = threshold / 100.0
|
| 263 |
qwords = [w for w in query.lower().split() if len(w) > 2] if query.strip() else []
|
| 264 |
|
| 265 |
if not query.strip():
|
| 266 |
+
return render_cards(df), "", BANNER_EMPTY
|
| 267 |
|
| 268 |
q_vec = encode_query(query)
|
| 269 |
if q_vec is not None:
|
|
|
|
| 274 |
mask = [s >= min_score for s in scores]
|
| 275 |
filtered = results[mask]
|
| 276 |
filtered_scores = [s for s, m in zip(scores, mask) if m]
|
| 277 |
+
return render_cards(filtered, filtered_scores, qwords), "", BANNER_SEMANTIC
|
| 278 |
else:
|
| 279 |
def score_row(row):
|
| 280 |
text = f"{row.get('titre','')} {row.get('text','')} {row.get('corps_grade','')}".lower()
|
|
|
|
| 282 |
df2 = df.copy()
|
| 283 |
df2["_s"] = df2.apply(score_row, axis=1)
|
| 284 |
df2 = df2[df2["_s"] > 0].sort_values("_s", ascending=False)
|
| 285 |
+
return render_cards(df2, qwords=qwords), "", BANNER_KEYWORDS
|
| 286 |
|
| 287 |
|
| 288 |
def on_card_click(job_id):
|
|
|
|
| 296 |
.gradio-container { max-width: 1100px !important; }
|
| 297 |
footer { display: none !important; }
|
| 298 |
#query textarea { font-size: 15px !important; line-height: 1.5 !important; }
|
| 299 |
+
#search-btn button { font-size: 15px !important; height: 44px !important; border-radius: 10px !important; }
|
| 300 |
+
/* Surlignage lisible : fond orange foncé, texte blanc */
|
| 301 |
+
mark { background: #b45309; color: #fff !important; border-radius: 3px; padding: 0 3px; font-style: normal; }
|
| 302 |
+
.md-preview p, .md-preview li { margin: 0 0 4px; }
|
| 303 |
+
.md-body h1,.md-body h2,.md-body h3 { font-weight:600; margin:12px 0 6px; }
|
| 304 |
+
.md-body ul,.md-body ol { padding-left:18px; margin:6px 0; }
|
| 305 |
+
.md-body p { margin:0 0 8px; }
|
| 306 |
+
@media (max-width: 640px) { #detail-panel { display: none !important; } }
|
| 307 |
</style>""")
|
| 308 |
|
| 309 |
gr.HTML(f"""
|
|
|
|
| 313 |
<h1 style="font-size:20px;font-weight:700;margin:0;color:#111">📋 AVPs OPT-NC</h1>
|
| 314 |
<p style="font-size:13px;color:#6b7280;margin:2px 0 0">Décrivez votre profil ou saisissez des mots-clés</p>
|
| 315 |
</div>
|
| 316 |
+
<a href="{RSS_URL}" title="S'abonner au flux RSS"
|
| 317 |
style="display:flex;align-items:center;gap:6px;padding:7px 14px;
|
| 318 |
background:#fff7ed;color:#c2410c;border:1px solid #fed7aa;
|
| 319 |
border-radius:10px;text-decoration:none;font-size:13px;font-weight:500">
|
|
|
|
| 325 |
</div>""")
|
| 326 |
|
| 327 |
query_input = gr.Textbox(
|
| 328 |
+
elem_id="query", label="",
|
|
|
|
| 329 |
placeholder=(
|
| 330 |
"Ex : \"Cadre avec expérience en gestion de projets SI et management d'équipe\"\n"
|
| 331 |
"ou : chef de service, télécoms, RH, marketing...\n"
|
| 332 |
"Ctrl+Entrée pour lancer la recherche"
|
| 333 |
),
|
| 334 |
+
lines=3, max_lines=4, show_label=False,
|
|
|
|
|
|
|
| 335 |
)
|
| 336 |
|
| 337 |
with gr.Row():
|
| 338 |
search_btn = gr.Button("🔍 Rechercher", variant="primary", elem_id="search-btn", scale=1)
|
| 339 |
+
threshold_slider = gr.Slider(minimum=0, maximum=100, value=50, step=5,
|
| 340 |
+
label="Score minimum (%)", scale=2)
|
| 341 |
+
|
| 342 |
+
mode_banner = gr.HTML()
|
|
|
|
| 343 |
|
| 344 |
with gr.Row():
|
| 345 |
with gr.Column(scale=1, elem_id="results-col"):
|
|
|
|
| 351 |
'Cliquez sur une vignette pour afficher le détail</div>'
|
| 352 |
)
|
| 353 |
|
| 354 |
+
hidden_id_input = gr.Textbox(visible=False, elem_id="hidden-job-id")
|
|
|
|
| 355 |
|
|
|
|
| 356 |
gr.HTML("""<script>
|
| 357 |
+
// Ctrl+Entrée — cible le <button> à l'intérieur du wrapper Gradio
|
| 358 |
document.addEventListener('keydown', function(e) {
|
| 359 |
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
| 360 |
+
const btn = document.querySelector('#search-btn button');
|
| 361 |
if (btn) btn.click();
|
| 362 |
}
|
| 363 |
});
|
| 364 |
|
| 365 |
function selectJob(jobId, el) {
|
| 366 |
+
document.querySelectorAll('[id^="card-"]').forEach(function(c) {
|
| 367 |
+
c.classList.remove('selected');
|
| 368 |
c.style.borderColor = '#e5e7eb';
|
| 369 |
c.style.background = '#fff';
|
| 370 |
});
|
| 371 |
+
el.classList.add('selected');
|
| 372 |
el.style.borderColor = '#3b82f6';
|
| 373 |
el.style.background = '#eff6ff';
|
| 374 |
|
| 375 |
+
const ta = document.querySelector('#hidden-job-id textarea') ||
|
| 376 |
+
document.querySelector('#hidden-job-id input');
|
| 377 |
+
if (ta) {
|
| 378 |
+
ta.value = jobId;
|
| 379 |
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
| 380 |
}
|
| 381 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
</script>""")
|
| 383 |
|
| 384 |
hidden_id_input.change(fn=on_card_click, inputs=hidden_id_input, outputs=detail_html)
|
| 385 |
|
| 386 |
inputs = [query_input, threshold_slider]
|
| 387 |
+
outputs = [results_html, detail_html, mode_banner]
|
| 388 |
+
search_btn.click(fn=search, inputs=inputs, outputs=outputs)
|
| 389 |
+
threshold_slider.release(fn=search, inputs=inputs, outputs=outputs)
|
| 390 |
+
demo.load(fn=search, inputs=inputs, outputs=outputs)
|
| 391 |
|
| 392 |
if __name__ == "__main__":
|
| 393 |
demo.launch()
|