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

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +232 -83
app.py CHANGED
@@ -6,7 +6,7 @@ import requests
6
  import markdown as md_lib
7
  from datasets import load_dataset
8
 
9
- # --- Chargement des données ---
10
  ds = load_dataset("opt-nc/avps", split="train")
11
  df = ds.to_pandas()
12
 
@@ -16,8 +16,8 @@ embeddings_norm = embeddings_matrix / (norms + 1e-10)
16
 
17
  EMBED_API = "https://api-inference.huggingface.co/models/BAAI/bge-m3"
18
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
19
-
20
  job_index = {str(row["id"]): dict(row) for _, row in df.iterrows()}
 
21
 
22
 
23
  def encode_query(text: str):
@@ -26,8 +26,7 @@ def encode_query(text: str):
26
  headers["Authorization"] = f"Bearer {HF_TOKEN}"
27
  try:
28
  r = requests.post(
29
- EMBED_API,
30
- headers=headers,
31
  json={"inputs": text, "options": {"wait_for_model": True}},
32
  timeout=30,
33
  )
@@ -42,96 +41,153 @@ def encode_query(text: str):
42
  return None
43
 
44
 
45
- def score_badge(score):
46
- if score is None:
47
  return ""
 
 
 
 
 
 
 
 
 
 
 
48
  pct = int(round(score * 100))
49
- bg = "#d1fae5" if pct >= 70 else ("#fef3c7" if pct >= 50 else "#f3f4f6")
50
- color = "#065f46" if pct >= 70 else ("#92400e" if pct >= 50 else "#6b7280")
 
 
 
 
51
  return (
52
- f'<span style="background:{bg};color:{color};font-size:12px;font-weight:600;'
53
- f'padding:3px 10px;border-radius:12px;white-space:nowrap">Score {pct}%</span>'
54
  )
55
 
56
 
57
- def render_cards(results_df, scores=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  if results_df.empty:
59
- return "<p style='color:#888;text-align:center;padding:2rem'>Aucun résultat trouvé.</p>"
60
 
61
  cards = []
62
  for i, (_, row) in enumerate(results_df.iterrows()):
63
  job_id = str(row.get("id") or "")
64
  titre = row.get("titre") or "Poste sans titre"
65
- direction = row.get("direction_interne") or ""
 
 
66
  grade = row.get("corps_grade") or ""
67
  lieu = row.get("lieu_travail") or ""
68
  immed = row.get("disponible_immediatement", False)
 
69
  url = row.get("url") or ""
70
- apercu = (row.get("text") or "")[:200].strip()
71
  score = scores[i] if scores is not None else None
72
 
73
- immed_html = (
74
- '<span style="background:#d1fae5;color:#065f46;font-size:11px;'
75
- 'padding:2px 8px;border-radius:12px;font-weight:500">⚡ Immédiat</span>'
76
- if immed else ""
77
- )
78
- meta = " · ".join(p for p in [direction, grade, lieu] if p)
79
- detail_btn = (
80
- f'<button onclick="showDetail(\'{job_id}\')" '
81
- f'style="padding:7px 14px;background:#f3f4f6;color:#374151;border:none;'
82
- f'border-radius:8px;font-size:13px;cursor:pointer;font-weight:500">'
83
- f'📄 Détail</button>'
84
- )
 
 
 
 
 
 
 
85
  annonce_btn = (
86
  f'<a href="{url}" target="_blank" '
87
- f'style="padding:7px 14px;background:#2563eb;color:#fff;border-radius:8px;'
88
- f'text-decoration:none;font-size:13px;font-weight:500">Voir l\'annonce →</a>'
89
  if url else ""
90
  )
91
 
92
  cards.append(f"""
93
- <div style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;
94
- padding:14px 16px;margin-bottom:10px">
 
 
 
 
95
  <div style="display:flex;justify-content:space-between;align-items:flex-start;
96
  flex-wrap:wrap;gap:6px;margin-bottom:4px">
97
- <span style="font-size:15px;font-weight:600;color:#111;line-height:1.3;
98
- flex:1;min-width:0">{titre}</span>
99
- <div style="display:flex;gap:4px;align-items:center;flex-shrink:0">
100
- {score_badge(score)}{immed_html}
101
- </div>
 
102
  </div>
103
- <p style="font-size:12px;color:#6b7280;margin:0 0 6px">{meta}</p>
104
- <p style="font-size:13px;color:#374151;margin:0 0 10px;line-height:1.5">{apercu}</p>
105
- <div style="display:flex;gap:8px;flex-wrap:wrap">{detail_btn}{annonce_btn}</div>
106
  </div>""")
107
 
108
- header = f'<p style="font-size:13px;color:#6b7280;margin-bottom:10px">{len(results_df)} offre(s) trouvée(s)</p>'
 
109
  return header + "\n".join(cards)
110
 
111
 
112
  def render_detail(job_id: str):
113
- row = job_index.get(job_id)
114
  if not row:
115
- return "<p>Annonce introuvable.</p>"
116
 
117
  titre = row.get("titre") or "Poste sans titre"
118
  direction = row.get("direction_interne") or ""
 
119
  grade = row.get("corps_grade") or ""
120
  lieu = row.get("lieu_travail") or ""
121
  immed = row.get("disponible_immediatement", False)
 
122
  url = row.get("url") or ""
123
  url_pdf = row.get("url_pdf") or ""
124
  texte_md = row.get("text") or ""
 
125
 
126
- # Markdown → HTML
127
  body_html = md_lib.markdown(texte_md, extensions=["nl2br", "sane_lists"])
 
 
128
 
129
  immed_html = (
130
- '<span style="background:#d1fae5;color:#065f46;font-size:12px;'
131
- 'padding:3px 10px;border-radius:12px;font-weight:500">⚡ Disponible immédiatement</span>'
132
  if immed else ""
133
  )
134
- meta = " · ".join(p for p in [direction, grade, lieu] if p)
 
135
  btns = ""
136
  if url:
137
  btns += (f'<a href="{url}" target="_blank" style="padding:8px 16px;background:#2563eb;'
@@ -142,20 +198,47 @@ def render_detail(job_id: str):
142
  f'background:#f3f4f6;color:#374151;border-radius:8px;text-decoration:none;'
143
  f'font-size:13px;font-weight:500">📎 PDF</a>')
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  return f"""
146
  <div style="padding:4px 0">
 
147
  <h2 style="font-size:17px;font-weight:700;color:#111;margin:0 0 6px;line-height:1.3">{titre}</h2>
148
- <p style="font-size:12px;color:#6b7280;margin:0 0 8px">{meta}</p>
149
- {immed_html}
150
- <div style="margin:16px 0">{btns}</div>
151
- <hr style="border:none;border-top:1px solid #e5e7eb;margin:12px 0">
152
- <div style="font-size:14px;color:#374151;line-height:1.7">
153
- {body_html}
154
  </div>
 
 
 
 
155
  </div>"""
156
 
157
 
158
- def search(query):
 
 
 
159
  if not query.strip():
160
  return render_cards(df), ""
161
 
@@ -165,82 +248,148 @@ def search(query):
165
  order = np.argsort(sims)[::-1]
166
  results = df.iloc[order].copy()
167
  scores = sims[order].tolist()
168
- mask = [s > 0.3 for s in scores]
169
- results = results[mask]
170
- scores = [s for s, m in zip(scores, mask) if m]
171
- return render_cards(results, scores), ""
172
  else:
173
- qwords = query.lower().split()
174
  def score_row(row):
175
  text = f"{row.get('titre','')} {row.get('text','')} {row.get('corps_grade','')}".lower()
176
  return sum(1 for w in qwords if w in text)
177
  df2 = df.copy()
178
  df2["_s"] = df2.apply(score_row, axis=1)
179
  df2 = df2[df2["_s"] > 0].sort_values("_s", ascending=False)
180
- return render_cards(df2), ""
181
 
182
 
183
- def show_detail(job_id: str):
184
- return render_detail(job_id)
 
 
185
 
186
 
187
- with gr.Blocks(title="AVPS OPT-NC") as demo:
188
  gr.HTML("""<style>
189
  .gradio-container { max-width: 1100px !important; }
190
  footer { display: none !important; }
191
- #query textarea { font-size: 16px !important; line-height: 1.5 !important; }
192
- #search-btn { font-size: 16px !important; height: 48px !important; border-radius: 10px !important; }
 
193
  @media (max-width: 640px) {
194
  #detail-panel { display: none !important; }
195
- #results-col { width: 100% !important; }
196
  }
197
  </style>""")
198
 
199
- gr.Markdown("## 📋 Offres OPT-NC\nDécrivez votre profil ou saisissez des mots-clés.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  query_input = gr.Textbox(
202
  elem_id="query",
203
  label="",
204
  placeholder=(
205
  "Ex : \"Cadre avec expérience en gestion de projets SI et management d'équipe\"\n"
206
- "ou : chef de service, télécoms, RH, marketing..."
 
207
  ),
208
- lines=4,
209
- max_lines=8,
210
  show_label=False,
211
  )
212
- search_btn = gr.Button("🔍 Rechercher", variant="primary", elem_id="search-btn")
 
 
 
 
 
 
 
213
 
214
  with gr.Row():
215
  with gr.Column(scale=1, elem_id="results-col"):
216
  results_html = gr.HTML()
217
- with gr.Column(scale=1, elem_id="detail-panel", visible=True):
218
  detail_html = gr.HTML(
219
  '<div style="padding:2rem;color:#9ca3af;text-align:center;'
220
  'border:1px dashed #e5e7eb;border-radius:12px;margin-top:4px">'
221
- 'Cliquez sur "Détail" pour afficher une annonce</div>'
222
  )
223
 
224
- # Bouton Détail : appel via gr.Text caché qui transmet l'id
225
- selected_id = gr.Textbox(visible=False)
226
 
227
- # JS : le bouton "Détail" dans le HTML met à jour selected_id
228
  gr.HTML("""<script>
229
- function showDetail(jobId) {
230
- const input = document.querySelector('#selected-id textarea') ||
231
- document.querySelector('#selected-id input');
232
- if (input) {
233
- input.value = jobId;
234
- input.dispatchEvent(new Event('input', { bubbles: true }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  }
236
  }
237
  </script>""")
238
 
239
- selected_id.change(fn=show_detail, inputs=selected_id, outputs=detail_html)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
- inputs = [query_input]
242
  search_btn.click(fn=search, inputs=inputs, outputs=[results_html, detail_html])
243
- query_input.submit(fn=search, inputs=inputs, outputs=[results_html, detail_html])
244
  demo.load(fn=search, inputs=inputs, outputs=[results_html, detail_html])
245
 
246
  if __name__ == "__main__":
 
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
 
 
16
 
17
  EMBED_API = "https://api-inference.huggingface.co/models/BAAI/bge-m3"
18
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
 
19
  job_index = {str(row["id"]): dict(row) for _, row in df.iterrows()}
20
+ RSS_URL = "https://opt-nc.github.io/avps/feed.xml"
21
 
22
 
23
  def encode_query(text: str):
 
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
  )
 
41
  return None
42
 
43
 
44
+ def fmt_date(d):
45
+ if not d or str(d) in ("NaT", "None", "nan"):
46
  return ""
47
+ try:
48
+ dt = pd.to_datetime(d)
49
+ delta = (dt - pd.Timestamp.now()).days
50
+ urgence = " 🔴" if delta <= 7 else (" 🟡" if delta <= 14 else "")
51
+ return dt.strftime("%d/%m/%Y") + urgence
52
+ except Exception:
53
+ return str(d)[:10]
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):
95
  if results_df.empty:
96
+ return "<p style='color:#888;text-align:center;padding:2rem'>Aucun résultat.</p>"
97
 
98
  cards = []
99
  for i, (_, row) in enumerate(results_df.iterrows()):
100
  job_id = str(row.get("id") or "")
101
  titre = row.get("titre") or "Poste sans titre"
102
+ numero = row.get("numero") or job_id
103
+ direction = row.get("direction_interne_acronyme") or ""
104
+ service = row.get("service_acronyme") or ""
105
  grade = row.get("corps_grade") or ""
106
  lieu = row.get("lieu_travail") or ""
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 "")
113
+ meta = " · ".join(p for p in [dir_str, grade, lieu] if p)
114
+
115
+ badges = ""
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 ""
136
  )
137
 
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">
147
+ <span style="font-size:15px;font-weight:600;color:#111;line-height:1.3;flex:1;min-width:0">{titre}</span>
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
 
158
+ n = len(results_df)
159
+ header = f'<p style="font-size:13px;color:#6b7280;margin-bottom:10px">{n} offre{"s" if n > 1 else ""} trouvée{"s" if n > 1 else ""}</p>'
160
  return header + "\n".join(cards)
161
 
162
 
163
  def render_detail(job_id: str):
164
+ row = job_index.get(str(job_id))
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 "")
182
+ meta = " · ".join(p for p in [dir_str, grade, lieu] if p)
183
 
184
  immed_html = (
185
+ '<span style="background:#d1fae5;color:#065f46;font-size:12px;padding:3px 10px;'
186
+ 'border-radius:12px;font-weight:600">⚡ Disponible immédiatement</span> '
187
  if immed else ""
188
  )
189
+ cloture_html = f'<span style="font-size:12px;color:#6b7280">🗓 Clôture : {cloture}</span>' if cloture else ""
190
+
191
  btns = ""
192
  if url:
193
  btns += (f'<a href="{url}" target="_blank" style="padding:8px 16px;background:#2563eb;'
 
198
  f'background:#f3f4f6;color:#374151;border-radius:8px;text-decoration:none;'
199
  f'font-size:13px;font-weight:500">📎 PDF</a>')
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>
221
+ </div>"""
222
+
223
  return f"""
224
  <div style="padding:4px 0">
225
+ <p style="font-size:11px;color:#9ca3af;margin:0 0 4px">#{numero}</p>
226
  <h2 style="font-size:17px;font-weight:700;color:#111;margin:0 0 6px;line-height:1.3">{titre}</h2>
227
+ <p style="font-size:12px;color:#6b7280;margin:0 0 10px">{meta}</p>
228
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px">
229
+ {immed_html}{cloture_html}
 
 
 
230
  </div>
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
 
 
248
  order = np.argsort(sims)[::-1]
249
  results = df.iloc[order].copy()
250
  scores = sims[order].tolist()
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()
258
  return sum(1 for w in qwords if w in text)
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):
266
+ if not job_id or not job_id.strip():
267
+ return ""
268
+ return render_detail(job_id.strip())
269
 
270
 
271
+ with gr.Blocks(title="AVPs OPT-NC") as demo:
272
  gr.HTML("""<style>
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"""
284
+ <div style="display:flex;justify-content:space-between;align-items:center;
285
+ flex-wrap:wrap;gap:8px;margin-bottom:1rem">
286
+ <div>
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">
294
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="#c2410c">
295
+ <path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19.01 7.38 20 6.18 20C4.98 20 4 19.01 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1z"/>
296
+ </svg>
297
+ Flux RSS
298
+ </a>
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"):
324
  results_html = gr.HTML()
325
+ with gr.Column(scale=1, elem_id="detail-panel"):
326
  detail_html = gr.HTML(
327
  '<div style="padding:2rem;color:#9ca3af;text-align:center;'
328
  'border:1px dashed #e5e7eb;border-radius:12px;margin-top:4px">'
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__":