Upload app.py
Browse files
app.py
CHANGED
|
@@ -6,7 +6,7 @@ import requests
|
|
| 6 |
import markdown as md_lib
|
| 7 |
from datasets import load_dataset
|
| 8 |
|
| 9 |
-
# ---
|
| 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
|
| 46 |
-
if
|
| 47 |
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
pct = int(round(score * 100))
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
return (
|
| 52 |
-
f'<span style="background:{bg};color:{color};font-size:
|
| 53 |
-
f'padding:3px
|
| 54 |
)
|
| 55 |
|
| 56 |
|
| 57 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
if results_df.empty:
|
| 59 |
-
return "<p style='color:#888;text-align:center;padding:2rem'>Aucun résultat
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
annonce_btn = (
|
| 86 |
f'<a href="{url}" target="_blank" '
|
| 87 |
-
f'style="padding:
|
| 88 |
-
f'text-decoration:none;font-size:13px;font-weight:500">Voir
|
| 89 |
if url else ""
|
| 90 |
)
|
| 91 |
|
| 92 |
cards.append(f"""
|
| 93 |
-
<div
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
| 102 |
</div>
|
| 103 |
-
|
| 104 |
-
<
|
| 105 |
-
<div style="display:flex;gap:8px;flex-wrap:wrap">{detail_btn}{annonce_btn}</div>
|
| 106 |
</div>""")
|
| 107 |
|
| 108 |
-
|
|
|
|
| 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 "
|
| 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 |
-
'
|
| 132 |
if immed else ""
|
| 133 |
)
|
| 134 |
-
|
|
|
|
| 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
|
| 149 |
-
|
| 150 |
-
|
| 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 >
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
return render_cards(
|
| 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
|
| 184 |
-
|
|
|
|
|
|
|
| 185 |
|
| 186 |
|
| 187 |
-
with gr.Blocks(title="
|
| 188 |
gr.HTML("""<style>
|
| 189 |
.gradio-container { max-width: 1100px !important; }
|
| 190 |
footer { display: none !important; }
|
| 191 |
-
#query textarea { font-size:
|
| 192 |
-
#search-btn { font-size:
|
|
|
|
| 193 |
@media (max-width: 640px) {
|
| 194 |
#detail-panel { display: none !important; }
|
| 195 |
-
#results-col { width: 100% !important; }
|
| 196 |
}
|
| 197 |
</style>""")
|
| 198 |
|
| 199 |
-
gr.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 209 |
-
max_lines=
|
| 210 |
show_label=False,
|
| 211 |
)
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 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 |
-
'
|
| 222 |
)
|
| 223 |
|
| 224 |
-
#
|
| 225 |
-
selected_id = gr.
|
| 226 |
|
| 227 |
-
#
|
| 228 |
gr.HTML("""<script>
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
}
|
| 236 |
}
|
| 237 |
</script>""")
|
| 238 |
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
-
inputs = [query_input]
|
| 242 |
search_btn.click(fn=search, inputs=inputs, outputs=[results_html, detail_html])
|
| 243 |
-
|
| 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__":
|