rastadidi commited on
Commit
79dbe96
·
verified ·
1 Parent(s): a215f4b

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +20 -8
  2. app.py +168 -0
  3. requirements.txt +4 -0
README.md CHANGED
@@ -1,15 +1,27 @@
1
  ---
2
- title: Avps
3
- emoji: 🌖
4
- colorFrom: red
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  license: cc-by-4.0
12
- short_description: Application de recherche augmentée pour les AVPs de l'OPT-NC
 
 
 
 
 
 
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
1
  ---
2
+ title: AVPS OPT-NC
3
+ emoji: 📋
4
+ colorFrom: blue
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: "4.44.0"
 
8
  app_file: app.py
9
  pinned: false
10
  license: cc-by-4.0
11
+ datasets:
12
+ - opt-nc/avps
13
+ tags:
14
+ - nouvelle-calédonie
15
+ - emploi
16
+ - job-posting
17
+ - opt-nc
18
  ---
19
 
20
+ # AVPS OPT-NC Avis de Vacances de Poste
21
+
22
+ Application de recherche et d'exploration des offres d'emploi de l'OPT-NC.
23
+
24
+ - **Recherche** : recherche libre par mots-clés avec filtres (direction, grade, disponibilité)
25
+ - **Exploration** : statistiques et répartition des offres
26
+
27
+ Source : [opt-nc/avps](https://huggingface.co/datasets/opt-nc/avps) · Licence CC-BY-4.0
app.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import gradio as gr
3
+ import pandas as pd
4
+ import numpy as np
5
+ import requests
6
+ from datasets import load_dataset
7
+
8
+ # --- Chargement des données ---
9
+ ds = load_dataset("opt-nc/avps", split="train")
10
+ df = ds.to_pandas()
11
+
12
+ embeddings_matrix = np.array(df["embedding"].tolist(), dtype=np.float32)
13
+ norms = np.linalg.norm(embeddings_matrix, axis=1, keepdims=True)
14
+ embeddings_norm = embeddings_matrix / (norms + 1e-10)
15
+
16
+ EMBED_API = "https://api-inference.huggingface.co/models/BAAI/bge-m3"
17
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
18
+
19
+
20
+ def encode_query(text: str):
21
+ headers = {"Content-Type": "application/json"}
22
+ if HF_TOKEN:
23
+ headers["Authorization"] = f"Bearer {HF_TOKEN}"
24
+ try:
25
+ r = requests.post(
26
+ EMBED_API,
27
+ headers=headers,
28
+ json={"inputs": text, "options": {"wait_for_model": True}},
29
+ timeout=30,
30
+ )
31
+ if r.status_code in (429, 503):
32
+ return None
33
+ r.raise_for_status()
34
+ vec = np.array(r.json(), dtype=np.float32)
35
+ if vec.ndim == 2:
36
+ vec = vec[0]
37
+ return vec / (np.linalg.norm(vec) + 1e-10)
38
+ except Exception:
39
+ return None
40
+
41
+
42
+ def render_cards(results_df, scores=None):
43
+ if results_df.empty:
44
+ return "<p style='color:#888;text-align:center;padding:2rem'>Aucun résultat trouvé.</p>"
45
+
46
+ cards = []
47
+ for i, (_, row) in enumerate(results_df.iterrows()):
48
+ titre = row.get("titre") or "Poste sans titre"
49
+ direction = row.get("direction_interne") or ""
50
+ grade = row.get("corps_grade") or ""
51
+ lieu = row.get("lieu_travail") or ""
52
+ immed = row.get("disponible_immediatement", False)
53
+ url = row.get("url") or ""
54
+ texte = (row.get("text") or "")[:200].strip()
55
+ score = scores[i] if scores is not None else None
56
+
57
+ immed_badge = (
58
+ '<span style="background:#d1fae5;color:#065f46;font-size:11px;'
59
+ 'padding:2px 8px;border-radius:12px;font-weight:500">⚡ Immédiat</span>'
60
+ if immed else ""
61
+ )
62
+ score_badge = (
63
+ f'<span style="background:#ede9fe;color:#5b21b6;font-size:11px;'
64
+ f'padding:2px 8px;border-radius:12px">{score:.0%}</span>'
65
+ if score is not None else ""
66
+ )
67
+ meta_parts = [p for p in [direction, grade, lieu] if p]
68
+ meta = " · ".join(meta_parts)
69
+
70
+ link_btn = (
71
+ f'<a href="{url}" target="_blank" style="display:inline-block;margin-top:10px;'
72
+ f'padding:8px 16px;background:#2563eb;color:#fff;border-radius:8px;'
73
+ f'text-decoration:none;font-size:13px;font-weight:500">Voir l\'annonce →</a>'
74
+ if url else ""
75
+ )
76
+
77
+ card = f"""
78
+ <div style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;
79
+ padding:14px 16px;margin-bottom:12px">
80
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;
81
+ flex-wrap:wrap;gap:6px;margin-bottom:6px">
82
+ <span style="font-size:15px;font-weight:600;color:#111;line-height:1.3;
83
+ flex:1;min-width:0">{titre}</span>
84
+ <div style="display:flex;gap:4px;flex-shrink:0">{score_badge}{immed_badge}</div>
85
+ </div>
86
+ <p style="font-size:12px;color:#6b7280;margin:0 0 6px">{meta}</p>
87
+ <p style="font-size:13px;color:#374151;margin:0;line-height:1.5">{texte}…</p>
88
+ {link_btn}
89
+ </div>"""
90
+ cards.append(card)
91
+
92
+ header = f'<p style="font-size:13px;color:#6b7280;margin-bottom:12px">{len(results_df)} offre(s) trouvée(s)</p>'
93
+ return header + "\n".join(cards)
94
+
95
+
96
+ def search(query):
97
+ if not query.strip():
98
+ # Afficher toutes les offres par défaut
99
+ return render_cards(df)
100
+
101
+ q_vec = encode_query(query)
102
+
103
+ if q_vec is not None:
104
+ sims = embeddings_norm @ q_vec
105
+ order = np.argsort(sims)[::-1]
106
+ results = df.iloc[order].copy()
107
+ scores = sims[order].tolist()
108
+ # Garder seulement les résultats pertinents (similarité > 0.3)
109
+ mask = [s > 0.3 for s in scores]
110
+ results = results[mask]
111
+ scores = [s for s, m in zip(scores, mask) if m]
112
+ return render_cards(results, scores)
113
+ else:
114
+ # Fallback mots-clés
115
+ qwords = query.lower().split()
116
+ def score_row(row):
117
+ text = f"{row.get('titre','')} {row.get('text','')} {row.get('corps_grade','')}".lower()
118
+ return sum(1 for w in qwords if w in text)
119
+ df2 = df.copy()
120
+ df2["_s"] = df2.apply(score_row, axis=1)
121
+ df2 = df2[df2["_s"] > 0].sort_values("_s", ascending=False)
122
+ return render_cards(df2)
123
+
124
+
125
+ # --- CSS global pour mobile ---
126
+ css = """
127
+ body { max-width: 600px; margin: 0 auto; }
128
+ .gradio-container { padding: 0 !important; }
129
+ footer { display: none !important; }
130
+ #query textarea {
131
+ font-size: 16px !important;
132
+ line-height: 1.5 !important;
133
+ }
134
+ #search-btn {
135
+ font-size: 16px !important;
136
+ height: 48px !important;
137
+ border-radius: 10px !important;
138
+ }
139
+ """
140
+
141
+ with gr.Blocks(css=css, title="AVPS OPT-NC") as demo:
142
+ gr.Markdown(
143
+ "## 📋 Offres OPT-NC\nDécrivez votre profil ou saisissez des mots-clés.",
144
+ )
145
+
146
+ query_input = gr.Textbox(
147
+ elem_id="query",
148
+ label="",
149
+ placeholder=(
150
+ "Ex : \"Cadre avec expérience en gestion de projets SI et management d'équipe\"\n"
151
+ "ou : chef de service, télécoms, RH, marketing..."
152
+ ),
153
+ lines=4,
154
+ max_lines=8,
155
+ show_label=False,
156
+ )
157
+
158
+ search_btn = gr.Button("🔍 Rechercher", variant="primary", elem_id="search-btn")
159
+
160
+ results_html = gr.HTML()
161
+
162
+ # Lancement au démarrage
163
+ demo.load(fn=search, inputs=query_input, outputs=results_html)
164
+ search_btn.click(fn=search, inputs=query_input, outputs=results_html)
165
+ query_input.submit(fn=search, inputs=query_input, outputs=results_html)
166
+
167
+ if __name__ == "__main__":
168
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ datasets
3
+ pandas
4
+ numpy