rastadidi commited on
Commit
cd75985
·
verified ·
1 Parent(s): f15569e

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +122 -125
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
- EMBED_API, headers=headers,
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}% match"
 
61
  elif pct >= 50:
62
- bg, color, label = "#fef3c7", "#92400e", f"◈ {pct}% match"
 
63
  else:
64
- bg, color, label = "#f3f4f6", "#6b7280", f"○ {pct}% match"
 
 
 
 
 
 
65
  return (
66
- f'<span style="background:{bg};color:{color};font-size:11px;font-weight:600;'
67
- f'padding:3px 9px;border-radius:12px;white-space:nowrap">{label}</span>'
 
 
68
  )
69
 
70
 
71
- def highlight_matches(text, qwords):
72
- """Extrait un snippet du texte autour des mots matchés."""
73
- if not qwords or not text:
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
- start = max(0, best_pos - 40)
85
- end = min(len(text), best_pos + 120)
86
- snippet = ("…" if start > 0 else "") + text[start:end].strip() + "…"
87
- # Surligner les mots
88
- for w in qwords:
89
- import re
90
- snippet = re.sub(f"({re.escape(w)})", r'<mark style="background:#fef08a;border-radius:3px;padding:0 2px">\1</mark>', snippet, flags=re.IGNORECASE)
91
- return snippet
 
 
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;font-weight:600;padding:3px 9px;border-radius:12px">⚡ Immédiat</span>'
 
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:#c4b5fd">#{numero}</span>'
123
-
124
- # Snippet surligné pour expliquer le match
125
- snippet_html = ""
126
- if qwords:
127
- snippet = highlight_matches(str(row.get("text") or ""), qwords)
128
- if snippet:
129
- snippet_html = f'<p style="font-size:12px;color:#6b7280;margin:6px 0 0;line-height:1.5;font-style:italic">{snippet}</p>'
 
 
 
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:6px">
152
  {cloture_html}{ref_html}
153
  </div>
154
- {snippet_html}
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 = row.get("titre") or "Poste sans titre"
169
- direction = row.get("direction_interne") or ""
170
- service = row.get("service") or ""
171
- grade = row.get("corps_grade") or ""
172
- lieu = row.get("lieu_travail") or ""
173
- immed = row.get("disponible_immediatement", False)
174
- cloture = fmt_date(row.get("date_cloture"))
175
- url = row.get("url") or ""
176
- url_pdf = row.get("url_pdf") or ""
177
- texte_md = row.get("text") or ""
178
- numero = row.get("numero") or job_id
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
- share_encoded = requests.utils.quote(share_text)
204
- share_url_encoded = requests.utils.quote(share_url)
205
 
206
  share_html = f"""
207
- <div style="margin-top:16px;padding:12px;background:#f8fafc;border-radius:10px;
208
- border:1px solid #e2e8f0">
209
- <p style="font-size:12px;color:#6b7280;margin:0 0 8px;font-weight:500">📤 Partager cette annonce</p>
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={share_encoded}&body=Bonjour%2C%0A%0AJe%20vous%20partage%20cette%20offre%20OPT-NC%20%3A%0A{share_url_encoded}"
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={share_encoded}%20{share_url_encoded}" target="_blank"
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
- mark { background: #fef08a; border-radius: 3px; padding: 0 2px; }
278
- @media (max-width: 640px) {
279
- #detail-panel { display: none !important; }
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 des nouvelles AVPs"
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
- minimum=0, maximum=100, value=50, step=5,
318
- label="Score minimum (%)",
319
- scale=2,
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
- # State pour l'id sélectionné — plus fiable que le textbox caché
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.getElementById('search-btn');
340
  if (btn) btn.click();
341
  }
342
  });
343
 
344
  function selectJob(jobId, el) {
345
- // Highlight la carte sélectionnée
346
- document.querySelectorAll('[id^="card-"]').forEach(c => {
347
  c.style.borderColor = '#e5e7eb';
348
  c.style.background = '#fff';
349
  });
 
350
  el.style.borderColor = '#3b82f6';
351
  el.style.background = '#eff6ff';
352
 
353
- // Mettre à jour le State Gradio via le bouton caché
354
- const hiddenBtn = document.getElementById('detail-trigger');
355
- if (hiddenBtn) {
356
- window._selectedJobId = jobId;
357
- hiddenBtn.click();
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
- search_btn.click(fn=search, inputs=inputs, outputs=[results_html, detail_html])
392
- threshold_slider.release(fn=search, inputs=inputs, outputs=[results_html, detail_html])
393
- demo.load(fn=search, inputs=inputs, outputs=[results_html, detail_html])
 
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&#39;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()