rastadidi commited on
Commit
d560bfe
·
verified ·
1 Parent(s): 5c932db

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +161 -13
  2. requirements.txt +1 -1
app.py CHANGED
@@ -20,7 +20,42 @@ norms = np.linalg.norm(embeddings_matrix, axis=1, keepdims=True)
20
  embeddings_norm = embeddings_matrix / (norms + 1e-10)
21
 
22
  job_index = {str(row["id"]): dict(row) for _, row in df.iterrows()}
23
- RSS_URL = "https://opt-nc.github.io/avps/feed.xml"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
 
26
  def encode_query(text: str):
@@ -119,6 +154,17 @@ def render_cards(results_df, scores=None, qwords=None):
119
  cloture_html = f'<span style="font-size:11px;color:#6b7280">🗓 {cloture}</span>' if cloture else ""
120
  ref_html = f'<span style="font-size:11px;color:#a78bfa">#{numero}</span>'
121
 
 
 
 
 
 
 
 
 
 
 
 
122
  # Aperçu HTML du markdown avec surlignage
123
  preview_html = md_to_html_highlighted(texte_md, qwords)
124
  preview_block = f"""
@@ -150,9 +196,10 @@ def render_cards(results_df, scores=None, qwords=None):
150
  <div style="display:flex;gap:4px;align-items:center;flex-shrink:0;flex-wrap:wrap">{badges}</div>
151
  </div>
152
  <p style="font-size:12px;color:#6b7280;margin:0 0 4px">{meta}</p>
153
- <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:4px">
154
  {cloture_html}{ref_html}
155
  </div>
 
156
  {preview_block}
157
  <div style="margin-top:10px">{annonce_btn}</div>
158
  </div>""")
@@ -256,7 +303,21 @@ BANNER_KEYWORDS = """
256
  BANNER_EMPTY = ""
257
 
258
 
259
- def search(query, threshold):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  min_score = threshold / 100.0
261
  qwords = [w for w in query.lower().split() if len(w) > 2] if query.strip() else []
262
 
@@ -312,15 +373,7 @@ mark { background: #92400e; color: #fff !important; border-radius: 3px; padding:
312
  <h1 style="font-size:20px;font-weight:700;margin:0;color:#111">📋 AVPs OPT-NC</h1>
313
  <p style="font-size:13px;color:#6b7280;margin:2px 0 0">Décrivez votre profil ou saisissez des mots-clés</p>
314
  </div>
315
- <a href="{RSS_URL}" title="S'abonner au flux RSS"
316
- style="display:flex;align-items:center;gap:6px;padding:7px 14px;
317
- background:#fff7ed;color:#c2410c;border:1px solid #fed7aa;
318
- border-radius:10px;text-decoration:none;font-size:13px;font-weight:500">
319
- <svg width="14" height="14" viewBox="0 0 24 24" fill="#c2410c">
320
- <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"/>
321
- </svg>
322
- Flux RSS
323
- </a>
324
  </div>""")
325
 
326
  query_input = gr.Textbox(
@@ -377,5 +430,100 @@ function selectJob(jobId, el) {
377
  threshold_slider.release(fn=search, inputs=inputs, outputs=outputs)
378
  demo.load(fn=search, inputs=inputs, outputs=outputs)
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  if __name__ == "__main__":
381
- demo.launch()
 
20
  embeddings_norm = embeddings_matrix / (norms + 1e-10)
21
 
22
  job_index = {str(row["id"]): dict(row) for _, row in df.iterrows()}
23
+
24
+ # --- TF-IDF léger pour extraire les mots-clés saillants de chaque annonce ---
25
+ import math
26
+ from collections import Counter
27
+
28
+ STOPWORDS = set("""
29
+ le la les un une des de du en et à au aux ce qui que qu est par sur dans pour avec
30
+ ou si bien plus mais aussi car dont où ses son sa leur leurs nos vos
31
+ il elle ils elles je tu nous vous me te se lui y
32
+ être avoir faire pouvoir devoir vouloir aller savoir
33
+ tout tous toute toutes cette cet ces même très peu
34
+ """.split())
35
+
36
+ def tokenize(text):
37
+ words = re.sub(r'[^a-zàâçéèêëîïôûùüÿæœ]', ' ', text.lower()).split()
38
+ return [w for w in words if len(w) > 3 and w not in STOPWORDS]
39
+
40
+ # Calcul IDF sur le corpus
41
+ corpus_tokens = [tokenize(str(row.get("text", ""))) for _, row in df.iterrows()]
42
+ N = len(corpus_tokens)
43
+ df_counts = Counter()
44
+ for tokens in corpus_tokens:
45
+ df_counts.update(set(tokens))
46
+ idf = {w: math.log(N / (1 + c)) for w, c in df_counts.items()}
47
+
48
+ # Calcul TF-IDF par annonce → top 3 mots
49
+ def top_keywords(tokens, n=3):
50
+ tf = Counter(tokens)
51
+ total = len(tokens) or 1
52
+ scores = {w: (tf[w]/total) * idf.get(w, 0) for w in tf}
53
+ return [w for w, _ in sorted(scores.items(), key=lambda x: -x[1])[:n]]
54
+
55
+ # Pré-calcul pour toutes les annonces
56
+ keywords_by_id = {}
57
+ for (_, row), tokens in zip(df.iterrows(), corpus_tokens):
58
+ keywords_by_id[str(row["id"])] = top_keywords(tokens)
59
 
60
 
61
  def encode_query(text: str):
 
154
  cloture_html = f'<span style="font-size:11px;color:#6b7280">🗓 {cloture}</span>' if cloture else ""
155
  ref_html = f'<span style="font-size:11px;color:#a78bfa">#{numero}</span>'
156
 
157
+ # Tags TF-IDF
158
+ kw = keywords_by_id.get(job_id, [])
159
+ tags_html = ""
160
+ if kw:
161
+ tags = " ".join(
162
+ f'<span style="font-size:11px;padding:2px 8px;border-radius:10px;'
163
+ f'background:#1e293b;color:#94a3b8;border:1px solid #334155">{w}</span>'
164
+ for w in kw
165
+ )
166
+ tags_html = f'<div style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px">{tags}</div>'
167
+
168
  # Aperçu HTML du markdown avec surlignage
169
  preview_html = md_to_html_highlighted(texte_md, qwords)
170
  preview_block = f"""
 
196
  <div style="display:flex;gap:4px;align-items:center;flex-shrink:0;flex-wrap:wrap">{badges}</div>
197
  </div>
198
  <p style="font-size:12px;color:#6b7280;margin:0 0 4px">{meta}</p>
199
+ <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
200
  {cloture_html}{ref_html}
201
  </div>
202
+ {tags_html}
203
  {preview_block}
204
  <div style="margin-top:10px">{annonce_btn}</div>
205
  </div>""")
 
303
  BANNER_EMPTY = ""
304
 
305
 
306
+ def search(query: str, threshold: float = 50) -> str:
307
+ """
308
+ Recherche des Avis de Vacances de Poste (AVP) de l'OPT-NC par similarité sémantique.
309
+
310
+ Décrivez librement votre profil, vos compétences ou un intitulé de poste.
311
+ Les résultats sont classés par similarité avec le modèle BAAI/bge-m3.
312
+
313
+ Args:
314
+ query: Profil ou mots-clés à rechercher (ex: "ingénieur réseau télécoms management")
315
+ threshold: Score minimum de similarité en % (0-100, défaut 50)
316
+
317
+ Returns:
318
+ Liste des annonces correspondantes au format JSON (titre, direction, lieu, score, url)
319
+ """
320
+ import json
321
  min_score = threshold / 100.0
322
  qwords = [w for w in query.lower().split() if len(w) > 2] if query.strip() else []
323
 
 
373
  <h1 style="font-size:20px;font-weight:700;margin:0;color:#111">📋 AVPs OPT-NC</h1>
374
  <p style="font-size:13px;color:#6b7280;margin:2px 0 0">Décrivez votre profil ou saisissez des mots-clés</p>
375
  </div>
376
+
 
 
 
 
 
 
 
 
377
  </div>""")
378
 
379
  query_input = gr.Textbox(
 
430
  threshold_slider.release(fn=search, inputs=inputs, outputs=outputs)
431
  demo.load(fn=search, inputs=inputs, outputs=outputs)
432
 
433
+ def search_avps(query: str, threshold: float = 50) -> str:
434
+ pass
435
+
436
+ # Docstring dynamique — reflète le vrai nombre d'AVPs au chargement
437
+ search_avps.__doc__ = """
438
+ Recherche les Avis de Vacances de Poste (AVP) de l'OPT-NC
439
+ (Office des Postes et Télécommunications de Nouvelle-Calédonie).
440
+
441
+ Utilisation optimale :
442
+ - Décrire un profil complet plutôt que des mots-clés isolés améliore la précision
443
+ - Mentionner le domaine métier, le niveau hiérarchique et les compétences clés
444
+ - Baisser threshold (ex: 30) pour élargir les résultats, monter (ex: 70) pour affiner
445
+
446
+ Exemples de requêtes efficaces :
447
+ - "ingénieur réseau senior expérience cybersécurité SOC et management d'équipe"
448
+ - "cadre administratif pilotage budgétaire ressources humaines et conduite du changement"
449
+ - "technicien de maintenance réseaux télécoms fibre optique intervention terrain"
450
+ - "chef de projet SI transformation digitale MOA"
451
+
452
+ Chaque résultat contient :
453
+ - titre, numero, direction, service, grade, lieu
454
+ - disponible_immediatement (bool), date_cloture (YYYY-MM-DD)
455
+ - score : similarité cosinus [0.0–1.0] — au-dessus de 0.7 = très pertinent
456
+ - url : page web de l'annonce (https://opt-nc.github.io/avps/{numero}/)
457
+ - url_markdown : texte brut Markdown de l'annonce, lisible directement par un LLM
458
+ (https://raw.githubusercontent.com/opt-nc/avps/refs/heads/main/data/{numero}.md)
459
+ - keywords : 3 mots-clés TF-IDF caractérisant l'annonce parmi le corpus
460
+
461
+ Args:
462
+ query: Description libre du profil ou intitulé de poste recherché
463
+ threshold: Score minimum de similarité en % entre 0 et 100 (défaut: 50)
464
+
465
+ Returns:
466
+ JSON array, 10 résultats max, triés par score décroissant
467
+ """
468
+
469
+ def search_avps(query: str, threshold: float = 50) -> str:
470
+ import json
471
+ min_score = threshold / 100.0
472
+
473
+ if not query.strip():
474
+ return json.dumps([], ensure_ascii=False)
475
+
476
+ q_vec = encode_query(query)
477
+ if q_vec is not None:
478
+ sims = embeddings_norm @ q_vec
479
+ order = np.argsort(sims)[::-1]
480
+ results = df.iloc[order].copy()
481
+ scores = sims[order].tolist()
482
+ mask = [s >= min_score for s in scores]
483
+ filtered = results[mask].copy()
484
+ filtered_scores = [s for s, m in zip(scores, mask) if m]
485
+ else:
486
+ qwords = [w for w in query.lower().split() if len(w) > 2]
487
+ def score_row(row):
488
+ text = f"{row.get('titre','')} {row.get('text','')}".lower()
489
+ return sum(1 for w in qwords if w in text) / (len(qwords) + 1)
490
+ filtered = df.copy()
491
+ filtered["_s"] = filtered.apply(score_row, axis=1)
492
+ filtered = filtered[filtered["_s"] > 0].sort_values("_s", ascending=False)
493
+ filtered_scores = filtered["_s"].tolist()
494
+
495
+ out = []
496
+ for i, (_, row) in enumerate(filtered.iterrows()):
497
+ job_id = str(row.get("id") or "")
498
+ numero = row.get("numero") or job_id
499
+ out.append({
500
+ "titre": row.get("titre") or "",
501
+ "numero": numero,
502
+ "direction": row.get("direction_interne_acronyme") or "",
503
+ "service": row.get("service_acronyme") or "",
504
+ "grade": row.get("corps_grade") or "",
505
+ "lieu": row.get("lieu_travail") or "",
506
+ "disponible_immediatement": bool(row.get("disponible_immediatement")),
507
+ "date_cloture": str(row.get("date_cloture") or ""),
508
+ "score": round(float(filtered_scores[i]), 3),
509
+ "url": row.get("url") or "",
510
+ "url_markdown": f"https://raw.githubusercontent.com/opt-nc/avps/refs/heads/main/data/{numero}.md",
511
+ "keywords": keywords_by_id.get(job_id, []),
512
+ })
513
+ return json.dumps(out[:10], ensure_ascii=False, indent=2)
514
+
515
+
516
+ # --- Interface MCP séparée (exposée comme outil MCP) ---
517
+ mcp_interface = gr.Interface(
518
+ fn=search_avps,
519
+ inputs=[
520
+ gr.Textbox(label="Profil ou mots-clés", placeholder="Ex: ingénieur réseau télécoms management"),
521
+ gr.Slider(minimum=0, maximum=100, value=50, step=5, label="Score minimum (%)"),
522
+ ],
523
+ outputs=gr.Textbox(label="Résultats JSON"),
524
+ title="AVPs OPT-NC — API MCP",
525
+ api_name="search_avps",
526
+ )
527
+
528
  if __name__ == "__main__":
529
+ demo.launch(mcp_server=True)
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
- gradio>=6.0.0
2
  datasets
3
  pandas
4
  numpy
 
1
+ gradio[mcp]>=6.0.0
2
  datasets
3
  pandas
4
  numpy