| import re |
| import gradio as gr |
| import pandas as pd |
| import numpy as np |
| import markdown as md_lib |
| from datasets import load_dataset |
| from sentence_transformers import SentenceTransformer |
|
|
| |
| print("Chargement du modèle BAAI/bge-m3...") |
| model = SentenceTransformer("BAAI/bge-m3") |
| print("Modèle prêt.") |
|
|
| |
| ds = load_dataset("opt-nc/avps", split="train") |
| df = ds.to_pandas() |
|
|
| embeddings_matrix = np.array(df["embedding"].tolist(), dtype=np.float32) |
| norms = np.linalg.norm(embeddings_matrix, axis=1, keepdims=True) |
| embeddings_norm = embeddings_matrix / (norms + 1e-10) |
|
|
| job_index = {str(row["id"]): dict(row) for _, row in df.iterrows()} |
|
|
| |
| import math |
| from collections import Counter |
|
|
| STOPWORDS = set(""" |
| le la les un une des de du en et à au aux ce qui que qu est par sur dans pour avec |
| ou si bien plus mais aussi car dont où ses son sa leur leurs nos vos |
| il elle ils elles je tu nous vous me te se lui y |
| être avoir faire pouvoir devoir vouloir aller savoir |
| tout tous toute toutes cette cet ces même très peu |
| """.split()) |
|
|
| def tokenize(text): |
| words = re.sub(r'[^a-zàâçéèêëîïôûùüÿæœ]', ' ', text.lower()).split() |
| return [w for w in words if len(w) > 3 and w not in STOPWORDS] |
|
|
| |
| corpus_tokens = [tokenize(str(row.get("text", ""))) for _, row in df.iterrows()] |
| N = len(corpus_tokens) |
| df_counts = Counter() |
| for tokens in corpus_tokens: |
| df_counts.update(set(tokens)) |
| idf = {w: math.log(N / (1 + c)) for w, c in df_counts.items()} |
|
|
| |
| def top_keywords(tokens, n=3): |
| tf = Counter(tokens) |
| total = len(tokens) or 1 |
| scores = {w: (tf[w]/total) * idf.get(w, 0) for w in tf} |
| return [w for w, _ in sorted(scores.items(), key=lambda x: -x[1])[:n]] |
|
|
| |
| keywords_by_id = {} |
| for (_, row), tokens in zip(df.iterrows(), corpus_tokens): |
| keywords_by_id[str(row["id"])] = top_keywords(tokens) |
|
|
|
|
| _model_ready = False |
|
|
| def encode_query(text: str): |
| global _model_ready |
| try: |
| vec = model.encode(text, normalize_embeddings=True) |
| _model_ready = True |
| return np.array(vec, dtype=np.float32) |
| except Exception as e: |
| print(f"encode_query error: {e}") |
| return None |
|
|
|
|
| def fmt_date(d): |
| if not d or str(d) in ("NaT", "None", "nan"): |
| return "" |
| try: |
| dt = pd.to_datetime(d) |
| delta = (dt - pd.Timestamp.now()).days |
| urgence = " 🔴" if delta <= 7 else (" 🟡" if delta <= 14 else "") |
| return dt.strftime("%d/%m/%Y") + urgence |
| except Exception: |
| return str(d)[:10] |
|
|
|
|
| def score_widget(score): |
| pct = int(round(score * 100)) |
| if pct >= 70: |
| bg, color, label = "#d1fae5", "#065f46", f"✦ {pct}%" |
| tip = f"{pct}% — très bonne correspondance" |
| elif pct >= 50: |
| bg, color, label = "#fef3c7", "#92400e", f"◈ {pct}%" |
| tip = f"{pct}% — correspondance correcte" |
| else: |
| bg, color, label = "#f3f4f6", "#6b7280", f"○ {pct}%" |
| tip = f"{pct}% — correspondance partielle" |
| tooltip = ( |
| f"{tip}. Score de similarité sémantique entre votre profil " |
| "et le texte de l'annonce (missions, compétences), " |
| "calculé par le modèle BAAI/bge-m3. 100% = correspondance parfaite." |
| ) |
| return ( |
| f'<span title="{tooltip}" ' |
| f'style="background:{bg};color:{color};font-size:11px;font-weight:600;' |
| f'padding:3px 9px;border-radius:12px;white-space:nowrap;cursor:help;' |
| f'border-bottom:1px dotted {color}">{label} match</span>' |
| ) |
|
|
|
|
| GRAY = "color:#6b7280 !important" |
|
|
| def md_to_html_highlighted(text_md, qwords): |
| """Convertit le markdown en HTML, force les couleurs inline, surligne les mots-clés.""" |
| if not text_md: |
| return "" |
| html = md_lib.markdown(text_md, extensions=["nl2br", "sane_lists"]) |
| for tag in ("p", "li", "ul", "ol", "h1", "h2", "h3", "h4", "strong", "em"): |
| html = html.replace(f"<{tag}>", f'<{tag} style="{GRAY}">') |
| html = re.sub(f'<{tag} ((?!style=)[^>]*)>', f'<{tag} style="{GRAY}" \\1>', html) |
| if qwords: |
| for w in qwords: |
| html = re.sub( |
| r"(?<![<\w])(" + re.escape(w) + r")(?![>\w])", |
| r'<mark>\1</mark>', |
| html, flags=re.IGNORECASE |
| ) |
| return html |
|
|
|
|
| def render_cards(results_df, scores=None, qwords=None): |
| if results_df.empty: |
| return "<p style='color:#888;text-align:center;padding:2rem'>Aucun résultat.</p>" |
|
|
| cards = [] |
| for i, (_, row) in enumerate(results_df.iterrows()): |
| job_id = str(row.get("id") or "") |
| titre = row.get("titre") or "Poste sans titre" |
| numero = row.get("numero") or job_id |
| direction = row.get("direction_interne_acronyme") or "" |
| service = row.get("service_acronyme") or "" |
| grade = row.get("corps_grade") or "" |
| lieu = row.get("lieu_travail") or "" |
| immed = row.get("disponible_immediatement", False) |
| cloture = fmt_date(row.get("date_cloture")) |
| url = row.get("url") or "" |
| texte_md = row.get("text") or "" |
| score = scores[i] if scores is not None else None |
|
|
| dir_str = direction + (f" › {service}" if service else "") |
| meta = " · ".join(p for p in [dir_str, grade, lieu] if p) |
|
|
| badges = "" |
| if score is not None: |
| badges += score_widget(score) + " " |
| if immed: |
| badges += ('<span style="background:#d1fae5;color:#065f46;font-size:11px;' |
| 'font-weight:600;padding:3px 9px;border-radius:12px">⚡ Immédiat</span>') |
|
|
| cloture_html = f'<span style="font-size:11px;color:#6b7280">🗓 {cloture}</span>' if cloture else "" |
| ref_html = f'<span style="font-size:11px;color:#a78bfa">#{numero}</span>' |
|
|
| |
| kw = keywords_by_id.get(job_id, []) |
| tags_html = "" |
| if kw: |
| tags = " ".join( |
| f'<span style="font-size:11px;padding:3px 9px;border-radius:10px;' |
| f'background:#4338ca;color:#ffffff;font-weight:500;letter-spacing:0.01em">' |
| f'#{w}</span>' |
| for w in kw |
| ) |
| tags_html = f'<div style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px">{tags}</div>' |
|
|
| |
| preview_html = md_to_html_highlighted(texte_md, qwords) |
| preview_block = f""" |
| <div style="font-size:12px;line-height:1.5;margin-top:10px;max-height:150px; |
| overflow:hidden;position:relative; |
| color:#6b7280 !important"> |
| <div style="color:#6b7280 !important">{preview_html}</div> |
| <div style="position:absolute;bottom:0;left:0;right:0;height:36px; |
| background:linear-gradient(rgba(255,255,255,0),rgba(30,30,30,0.01))"></div> |
| </div>""" |
|
|
| annonce_btn = ( |
| f'<a href="{url}" target="_blank" onclick="event.stopPropagation()" ' |
| f'style="padding:6px 14px;background:#2563eb;color:#fff;border-radius:8px;' |
| f'text-decoration:none;font-size:13px;font-weight:500">Voir →</a>' |
| if url else "" |
| ) |
|
|
| cards.append(f""" |
| <div id="card-{job_id}" |
| style="background:#fff;border:1px solid #e5e7eb;border-radius:12px; |
| padding:14px 16px;margin-bottom:10px;cursor:pointer;transition:border-color 0.15s,background 0.15s" |
| onmouseenter="this.style.borderColor='#93c5fd'" |
| onmouseleave="if(!this.classList.contains('selected'))this.style.borderColor='#e5e7eb'" |
| onclick="selectJob('{job_id}', this)"> |
| <div style="display:flex;justify-content:space-between;align-items:flex-start; |
| flex-wrap:wrap;gap:6px;margin-bottom:4px"> |
| <span style="font-size:15px;font-weight:600;color:#111;line-height:1.3;flex:1;min-width:0">{titre}</span> |
| <div style="display:flex;gap:4px;align-items:center;flex-shrink:0;flex-wrap:wrap">{badges}</div> |
| </div> |
| <p style="font-size:12px;color:#6b7280;margin:0 0 4px">{meta}</p> |
| <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px"> |
| {cloture_html}{ref_html} |
| </div> |
| {tags_html} |
| {preview_block} |
| <div style="margin-top:10px">{annonce_btn}</div> |
| </div>""") |
|
|
| n = len(results_df) |
| 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>' |
| return header + "\n".join(cards) |
|
|
|
|
| def render_detail(job_id: str): |
| row = job_index.get(str(job_id)) |
| if not row: |
| return "" |
|
|
| titre = row.get("titre") or "Poste sans titre" |
| direction= row.get("direction_interne") or "" |
| service = row.get("service") or "" |
| grade = row.get("corps_grade") or "" |
| lieu = row.get("lieu_travail") or "" |
| immed = row.get("disponible_immediatement", False) |
| cloture = fmt_date(row.get("date_cloture")) |
| url = row.get("url") or "" |
| url_pdf = row.get("url_pdf") or "" |
| texte_md = row.get("text") or "" |
| numero = row.get("numero") or job_id |
|
|
| body_html = md_lib.markdown(texte_md, extensions=["nl2br", "sane_lists"]) |
| dir_str = direction + (f" › {service}" if service else "") |
| meta = " · ".join(p for p in [dir_str, grade, lieu] if p) |
|
|
| immed_html = ( |
| '<span style="background:#d1fae5;color:#065f46;font-size:12px;padding:3px 10px;' |
| 'border-radius:12px;font-weight:600">⚡ Disponible immédiatement</span> ' |
| if immed else "" |
| ) |
| cloture_html = f'<span style="font-size:12px;color:#6b7280">🗓 Clôture : {cloture}</span>' if cloture else "" |
|
|
| btns = "" |
| if url: |
| btns += (f'<a href="{url}" target="_blank" style="padding:8px 16px;background:#2563eb;' |
| f'color:#fff;border-radius:8px;text-decoration:none;font-size:13px;' |
| f'font-weight:500;margin-right:8px">🌐 Voir l\'annonce</a>') |
| if url_pdf: |
| btns += (f'<a href="{url_pdf}" target="_blank" style="padding:8px 16px;' |
| f'background:#f3f4f6;color:#374151;border-radius:8px;text-decoration:none;' |
| f'font-size:13px;font-weight:500">📎 PDF</a>') |
|
|
| share_url = f"https://opt-nc.github.io/avps/{numero}/" |
| share_text = f"AVP OPT-NC — {titre} ({numero})" |
| from urllib.parse import quote |
| se = quote(share_text) |
| ue = quote(share_url) |
|
|
| share_html = f""" |
| <div style="margin-top:16px;padding:12px;background:#f8fafc;border-radius:10px;border:1px solid #e2e8f0"> |
| <p style="font-size:12px;color:#6b7280;margin:0 0 8px;font-weight:500">📤 Partager</p> |
| <div style="display:flex;gap:8px;flex-wrap:wrap"> |
| <button onclick="navigator.clipboard.writeText('{share_url}').then(()=>alert('Lien copié !'))" |
| style="padding:5px 12px;background:#f1f5f9;color:#374151;border:1px solid #e2e8f0; |
| border-radius:8px;font-size:12px;cursor:pointer">📋 Copier le lien</button> |
| <a href="mailto:?subject={se}&body=Bonjour%2C%0A%0AVoici%20une%20AVP%20OPT-NC%20%3A%0A{ue}" |
| style="padding:5px 12px;background:#f1f5f9;color:#374151;border:1px solid #e2e8f0; |
| border-radius:8px;font-size:12px;text-decoration:none">✉️ Email</a> |
| <a href="https://wa.me/?text={se}%20{ue}" target="_blank" |
| style="padding:5px 12px;background:#f1f5f9;color:#374151;border:1px solid #e2e8f0; |
| border-radius:8px;font-size:12px;text-decoration:none">💬 WhatsApp</a> |
| </div> |
| </div>""" |
|
|
| return f""" |
| <div style="padding:4px 0"> |
| <p style="font-size:11px;color:#9ca3af;margin:0 0 4px">#{numero}</p> |
| <h2 style="font-size:17px;font-weight:700;color:#111;margin:0 0 6px;line-height:1.3">{titre}</h2> |
| <p style="font-size:12px;color:#6b7280;margin:0 0 10px">{meta}</p> |
| <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px"> |
| {immed_html}{cloture_html} |
| </div> |
| <div style="margin-bottom:16px">{btns}</div> |
| {share_html} |
| <hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0"> |
| <div class="md-body" style="font-size:14px;color:#374151;line-height:1.7">{body_html}</div> |
| </div>""" |
|
|
|
|
| BANNER_SEMANTIC = """ |
| <div style="display:flex;align-items:center;gap:8px;padding:10px 14px;margin-bottom:10px; |
| background:#1e3a5f;border:1px solid #3b82f6;border-radius:10px;font-size:13px"> |
| <span style="font-size:16px">🧠</span> |
| <span style="color:#e0f2fe"><strong style="color:#fff">Recherche sémantique</strong> — résultats classés par similarité avec votre profil |
| (modèle local <a href="https://huggingface.co/BAAI/bge-m3" target="_blank" |
| style="color:#93c5fd;text-decoration:underline">BAAI/bge-m3</a>)</span> |
| </div>""" |
|
|
| BANNER_KEYWORDS = """ |
| <div style="display:flex;align-items:center;gap:8px;padding:10px 14px;margin-bottom:10px; |
| background:#431407;border:1px solid #f97316;border-radius:10px;font-size:13px"> |
| <span style="font-size:16px">⚠️</span> |
| <span style="color:#fed7aa"><strong style="color:#fff">Mode mots-clés</strong> — le modèle sémantique est en cours de chargement ou indisponible. |
| Résultats filtrés par mots-clés, relancez la recherche dans quelques secondes pour activer la recherche sémantique.</span> |
| </div>""" |
|
|
| BANNER_EMPTY = "" |
|
|
|
|
| def _search(query: str, threshold: float = 50) -> str: |
| """ |
| Recherche des Avis de Vacances de Poste (AVP) de l'OPT-NC par similarité sémantique. |
| |
| Décrivez librement votre profil, vos compétences ou un intitulé de poste. |
| Les résultats sont classés par similarité avec le modèle BAAI/bge-m3. |
| |
| Args: |
| query: Profil ou mots-clés à rechercher (ex: "ingénieur réseau télécoms management") |
| threshold: Score minimum de similarité en % (0-100, défaut 50) |
| |
| Returns: |
| Liste des annonces correspondantes au format JSON (titre, direction, lieu, score, url) |
| """ |
| import json |
| min_score = threshold / 100.0 |
| qwords = [w for w in query.lower().split() if len(w) > 2] if query.strip() else [] |
|
|
| if not query.strip(): |
| return render_cards(df), "", BANNER_EMPTY |
|
|
| q_vec = encode_query(query) |
| if q_vec is not None: |
| sims = embeddings_norm @ q_vec |
| order = np.argsort(sims)[::-1] |
| results = df.iloc[order].copy() |
| scores = sims[order].tolist() |
| mask = [s >= min_score for s in scores] |
| filtered = results[mask] |
| filtered_scores = [s for s, m in zip(scores, mask) if m] |
| return render_cards(filtered, filtered_scores, qwords), "", BANNER_SEMANTIC |
| else: |
| def score_row(row): |
| text = f"{row.get('titre','')} {row.get('text','')} {row.get('corps_grade','')}".lower() |
| return sum(1 for w in qwords if w in text) |
| df2 = df.copy() |
| df2["_s"] = df2.apply(score_row, axis=1) |
| df2 = df2[df2["_s"] > 0].sort_values("_s", ascending=False) |
| return render_cards(df2, scores=df2["_s"].tolist(), qwords=qwords), "", BANNER_KEYWORDS |
|
|
|
|
| def on_card_click(job_id): |
| if not job_id or not job_id.strip(): |
| return "" |
| return render_detail(job_id.strip()) |
|
|
|
|
| with gr.Blocks(title="AVPs OPT-NC") as demo: |
| gr.HTML("""<style> |
| .gradio-container { max-width: 1100px !important; } |
| footer { display: none !important; } |
| #query textarea { font-size: 15px !important; line-height: 1.5 !important; } |
| mark { background: #92400e; color: #fff !important; border-radius: 3px; padding: 0 3px; font-style: normal; } |
| .md-preview { color: #9ca3af !important; font-size: 12px !important; line-height: 1.5 !important; } |
| .md-preview p,.md-preview li { margin: 0 0 2px !important; color: #9ca3af !important; } |
| .md-preview strong { font-weight: 500 !important; color: #6b7280 !important; } |
| .md-preview h1,.md-preview h2,.md-preview h3 { font-size: 12px !important; font-weight: 600 !important; color: #6b7280 !important; margin: 3px 0 1px !important; } |
| .md-body h1,.md-body h2,.md-body h3 { font-weight:600; margin:12px 0 6px; } |
| .md-body ul,.md-body ol { padding-left:18px; margin:6px 0; } |
| .md-body p { margin:0 0 8px; } |
| @media (max-width: 640px) { #detail-panel { display: none !important; } } |
| </style>""") |
|
|
| gr.HTML(f""" |
| <div style="display:flex;justify-content:space-between;align-items:center; |
| flex-wrap:wrap;gap:8px;margin-bottom:1rem"> |
| <div> |
| <h1 style="font-size:20px;font-weight:700;margin:0;color:#111">📋 AVPs OPT-NC</h1> |
| <p style="font-size:13px;color:#6b7280;margin:2px 0 0">Décrivez votre profil ou saisissez des mots-clés</p> |
| </div> |
| |
| </div>""") |
|
|
| query_input = gr.Textbox( |
| elem_id="query", label="", |
| placeholder='Ex: "Cadre expérimenté en gestion de projets SI et management" — Entrée pour rechercher', |
| lines=1, max_lines=6, show_label=False, |
| ) |
|
|
| with gr.Row(): |
| search_btn = gr.Button("🔍 Rechercher", variant="primary", elem_id="search-btn", scale=1) |
| threshold_slider = gr.Slider(minimum=0, maximum=100, value=50, step=5, |
| label="Score minimum (%)", scale=2) |
|
|
| mode_banner = gr.HTML() |
|
|
| with gr.Row(): |
| with gr.Column(scale=1, elem_id="results-col"): |
| results_html = gr.HTML() |
| with gr.Column(scale=1, elem_id="detail-panel"): |
| detail_html = gr.HTML( |
| '<div style="padding:2rem;color:#9ca3af;text-align:center;' |
| 'border:1px dashed #e5e7eb;border-radius:12px;margin-top:4px">' |
| 'Cliquez sur une vignette pour afficher le détail</div>' |
| ) |
|
|
| hidden_id_input = gr.Textbox(visible=False, elem_id="hidden-job-id") |
|
|
| gr.HTML("""<script> |
| function selectJob(jobId, el) { |
| document.querySelectorAll('[id^="card-"]').forEach(function(c) { |
| c.classList.remove('selected'); |
| c.style.borderColor = '#e5e7eb'; |
| c.style.background = '#fff'; |
| }); |
| el.classList.add('selected'); |
| el.style.borderColor = '#3b82f6'; |
| el.style.background = '#eff6ff'; |
| |
| const ta = document.querySelector('#hidden-job-id textarea') || |
| document.querySelector('#hidden-job-id input'); |
| if (ta) { |
| ta.value = jobId; |
| ta.dispatchEvent(new Event('input', { bubbles: true })); |
| } |
| } |
| </script>""") |
|
|
| hidden_id_input.change(fn=on_card_click, inputs=hidden_id_input, outputs=detail_html) |
|
|
| inputs = [query_input, threshold_slider] |
| outputs = [results_html, detail_html, mode_banner] |
| search_btn.click(fn=_search, inputs=inputs, outputs=outputs, api_name=False) |
| query_input.submit(fn=_search, inputs=inputs, outputs=outputs, api_name=False) |
| threshold_slider.release(fn=_search, inputs=inputs, outputs=outputs, api_name=False) |
| demo.load(fn=_search, inputs=inputs, outputs=outputs, api_name=False) |
|
|
| def search_avps(query: str, threshold: float = 50) -> str: |
| """ |
| Recherche les Avis de Vacances de Poste (AVP) de l'OPT-NC |
| (Office des Postes et Télécommunications de Nouvelle-Calédonie). |
| |
| Utilisation optimale : |
| - Décrire un profil complet plutôt que des mots-clés isolés améliore la précision |
| - Mentionner le domaine métier, le niveau hiérarchique et les compétences clés |
| - Baisser threshold (ex: 30) pour élargir les résultats, monter (ex: 70) pour affiner |
| |
| Exemples de requêtes efficaces : |
| - "ingénieur réseau senior expérience cybersécurité SOC et management d'équipe" |
| - "cadre administratif pilotage budgétaire ressources humaines et conduite du changement" |
| - "technicien de maintenance réseaux télécoms fibre optique intervention terrain" |
| - "chef de projet SI transformation digitale MOA" |
| |
| Chaque résultat contient : |
| - titre, numero, direction, service, grade, lieu |
| - disponible_immediatement (bool), date_cloture (YYYY-MM-DD) |
| - score : similarité cosinus [0.0-1.0] — au-dessus de 0.7 = très pertinent |
| - url : page web de l'annonce |
| - url_markdown : texte brut Markdown lisible directement par un LLM |
| - keywords : 3 mots-clés TF-IDF caractérisant l'annonce parmi le corpus |
| |
| Args: |
| query: Description libre du profil ou intitulé de poste recherché |
| threshold: Score minimum de similarité en % entre 0 et 100 (défaut: 50) |
| |
| Returns: |
| JSON array, 10 résultats max, triés par score décroissant |
| """ |
| import json |
| min_score = threshold / 100.0 |
|
|
| if not query.strip(): |
| return json.dumps([], ensure_ascii=False) |
|
|
| q_vec = encode_query(query) |
| if q_vec is not None: |
| sims = embeddings_norm @ q_vec |
| order = np.argsort(sims)[::-1] |
| results = df.iloc[order].copy() |
| scores = sims[order].tolist() |
| mask = [s >= min_score for s in scores] |
| filtered = results[mask].copy() |
| filtered_scores = [s for s, m in zip(scores, mask) if m] |
| else: |
| qwords = [w for w in query.lower().split() if len(w) > 2] |
| def score_row(row): |
| text = f"{row.get('titre','')} {row.get('text','')}".lower() |
| return sum(1 for w in qwords if w in text) / (len(qwords) + 1) |
| filtered = df.copy() |
| filtered["_s"] = filtered.apply(score_row, axis=1) |
| filtered = filtered[filtered["_s"] > 0].sort_values("_s", ascending=False) |
| filtered_scores = filtered["_s"].tolist() |
|
|
| def safe_str(v): |
| """Convertit toute valeur en string, gère NaN, None, bool, numpy types.""" |
| if v is None: |
| return "" |
| try: |
| if pd.isna(v): |
| return "" |
| except (TypeError, ValueError): |
| pass |
| return str(v) |
|
|
| out = [] |
| for i, (_, row) in enumerate(filtered.iterrows()): |
| job_id = safe_str(row.get("id")) |
| numero = safe_str(row.get("numero")) or job_id |
| out.append({ |
| "titre": safe_str(row.get("titre")), |
| "numero": numero, |
| "direction": safe_str(row.get("direction_interne_acronyme")), |
| "service": safe_str(row.get("service_acronyme")), |
| "grade": safe_str(row.get("corps_grade")), |
| "lieu": safe_str(row.get("lieu_travail")), |
| "disponible_immediatement": bool(row.get("disponible_immediatement")), |
| "date_cloture": safe_str(row.get("date_cloture")), |
| "score": round(float(filtered_scores[i]), 3), |
| "url": safe_str(row.get("url")), |
| "url_markdown": "https://raw.githubusercontent.com/opt-nc/avps/refs/heads/main/data/" + numero + ".md" if numero else "", |
| "keywords": keywords_by_id.get(job_id, []), |
| }) |
| return json.dumps(out[:10], ensure_ascii=False, indent=2) |
|
|
|
|
|
|
|
|
| |
| with demo: |
| gr.api(search_avps, api_name="search_avps") |
|
|
| if __name__ == "__main__": |
| demo.launch(mcp_server=True) |