Upload 2 files
Browse files- app.py +161 -13
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 |
-
|
| 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
|