SPB Git forge

spb/job-ka

Public
226commits 1branches 0releases
37.5 MBsize
maindefault branch
10 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

Phase 3 — enrichissement des 1050+ connecteurs (exigences, séniorité) + refonte du display des offres avec métriques claires

Backend (enrichissement central via finalize() -> tous les connecteurs) :
- normalize.py : extract_requirements() (années d'expérience, scolarité,
  langues exigées, FR/EN, bornes de plausibilité), parse_seniority()
  (stage/junior/intermédiaire/senior/direction : titre, libellés ATS,
  années d'expérience), en-têtes d'avantages élargis
- schema.py : champ seniority + branchement des extracteurs (setdefault,
  jamais d'écrasement des valeurs ATS)
- db.py : colonne seniority (migration auto) dans insert/update
- web.py : filtre + facette séniorité, salary_context (médiane/quartiles
  de la catégorie + écart % de l'offre, cache 10 min), employer_jobs,
  median_salary_year dans /api/stats
- scripts/backfill_enrich.py : backfill exécuté — 23 680 offres scannées,
  16 606 exigences ajoutées (26 % -> 77 %), 10 966 séniorités (0 -> 46 %)
- tests/test_enrich.py : 27 nouveaux tests (101 au total, tous verts)

Frontend (display + métriques claires) :
- JobCard : badge Nouveau (≤3 j), salaire avec équivalent annuel, badges
  séniorité/exigences/bilingue, pied fraîcheur · avantages · plateforme
- Fiche : panneau « En un coup d'œil » (salaire vs marché avec barre
  interquartile P25-P75, expérience, scolarité, langues, fraîcheur, date
  limite), volume d'offres de l'employeur, historique du salaire affiché
- Accueil : filtre séniorité ; api.ts : types + helpers (compactMoney,
  yearlyEquiv, daysSince)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent df3faf0

11 changed files +819 −37

modified frontend/src/api.ts +74 −2
@@ -5,7 +5,7 @@
5 5 * Contact : contact@spboucher.ai
6 6 * Fichier : frontend/src/api.ts
7 7 * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py
8 − * Créé : 2026-08-17 Modifié : 2026-08-17
8 + * Créé : 2026-08-17 Modifié : 2026-08-25
9 9 * =============================================================================
10 10 */
11 11
@@ -33,7 +33,7 @@ export interface Job {
33 33 salary_hour_min: number | null;
34 34 salary_hour_max: number | null;
35 35 benefits: string[];
36 − requirements: Record<string, unknown>;
36 + requirements: JobRequirements;
37 37 date_posted: string | null;
38 38 date_deadline: string | null;
39 39 category: string;
@@ -48,6 +48,38 @@ export interface Job {
48 48 language?: string | null; // fr | en | bilingue
49 49 apply_url?: string | null; // candidature directe (≠ url de la fiche)
50 50 title_clean?: string | null; // titre d'affichage normalisé
51 + seniority?: string | null; // stage | junior | intermediaire | senior | direction
52 + first_seen?: number | null; // epoch : première collecte par Job·Ka
53 + // fiche seulement (GET /api/jobs/{uid}) :
54 + salary_context?: SalaryContext | null;
55 + employer_jobs?: number; // offres actives du même employeur
56 + salary_history?: SalaryPoint[];
57 +}
58 +
59 +/** Exigences structurées extraites de l'offre (clés présentes si explicites). */
60 +export interface JobRequirements {
61 + experience_years?: number; // années d'expérience minimales exigées
62 + education?: string; // plus bas diplôme exigé (DEC, baccalauréat…)
63 + languages?: string[]; // langues exigées
64 + [k: string]: unknown; // champs bruts hérités des ATS
65 +}
66 +
67 +/** Salaire de l'offre vs marché de sa catégorie (médiane/quartiles, $/an). */
68 +export interface SalaryContext {
69 + category: string;
70 + n: number; // offres avec salaire dans la catégorie
71 + p25: number;
72 + median: number;
73 + p75: number;
74 + job_mid?: number; // point médian de la fourchette de l'offre
75 + delta_pct?: number; // écart % vs médiane de la catégorie
76 +}
77 +
78 +export interface SalaryPoint {
79 + ts: number;
80 + s_min: number | null;
81 + s_max: number | null;
82 + unit: string | null;
51 83 }
52 84
53 85 export interface JobList {
@@ -64,6 +96,7 @@ export interface Facets {
64 96 employers: { employer: string; n: number }[];
65 97 work_modes: string[];
66 98 employment_types: string[];
99 + seniorities: { seniority: string; n: number }[];
67 100 sources: { source: string; n: number }[];
68 101 }
69 102
@@ -126,6 +159,7 @@ export interface JobFilters {
126 159 category?: string;
127 160 work_mode?: string;
128 161 employment_type?: string;
162 + seniority?: string;
129 163 language?: string;
130 164 salary_min?: number;
131 165 with_salary?: number;
@@ -182,6 +216,44 @@ export const LANG_FR: Record<string, string> = {
182 216 fr: "Français", en: "Anglais", bilingue: "Bilingue",
183 217 };
184 218
219 +export const SENIORITY_FR: Record<string, string> = {
220 + stage: "Stage", junior: "Junior", intermediaire: "Intermédiaire",
221 + senior: "Senior", direction: "Direction",
222 +};
223 +
224 +/** « 62 500 $ » compacté en « 62,5 k$ » (métriques et badges). */
225 +export function compactMoney(n: number): string {
226 + if (n >= 1000) {
227 + const k = n / 1000;
228 + return `${k.toLocaleString("fr-CA", { maximumFractionDigits: k < 100 ? 1 : 0 })} k$`;
229 + }
230 + return `${n.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`;
231 +}
232 +
233 +/** Équivalent annuel « ≈ 52 k$ – 60 k$/an » pour un salaire non annuel. */
234 +export function yearlyEquiv(job: Pick<Job, "salary_year_min" | "salary_year_max" | "salary_unit">): string | null {
235 + if (job.salary_year_min == null || job.salary_unit === "year") return null;
236 + const lo = compactMoney(job.salary_year_min);
237 + const hi = job.salary_year_max != null && job.salary_year_max > job.salary_year_min
238 + ? ` – ${compactMoney(job.salary_year_max)}` : "";
239 + return `≈ ${lo}${hi}/an`;
240 +}
241 +
242 +/** Jours écoulés depuis une date ISO (null si absente ou invalide). */
243 +export function daysSince(iso: string | null | undefined): number | null {
244 + if (!iso) return null;
245 + const d = new Date(`${iso}T12:00:00`).getTime();
246 + if (Number.isNaN(d)) return null;
247 + return Math.max(0, Math.round((Date.now() - d) / 86400000));
248 +}
249 +
250 +/** « aujourd'hui », « hier », « il y a N jours ». */
251 +export function agoLabel(days: number): string {
252 + if (days === 0) return "aujourd'hui";
253 + if (days === 1) return "hier";
254 + return `il y a ${days} jours`;
255 +}
256 +
185 257 /** Titre d'affichage : version normalisée si disponible, sinon le titre source. */
186 258 export const displayTitle = (job: Pick<Job, "title" | "title_clean">) =>
187 259 job.title_clean || job.title;
modified frontend/src/components/JobCard.tsx +35 −6
@@ -4,17 +4,28 @@
4 4 * Auteur : Simon-Pierre Boucher
5 5 * Contact : contact@spboucher.ai
6 6 * Fichier : frontend/src/components/JobCard.tsx
7 − * Rôle : Carte d'offre dans la liste (titre, employeur, badges, meta,
8 − * cœur ♥ favoris « Mon univers Ka »)
9 − * Créé : 2026-08-17 Modifié : 2026-08-23
7 + * Rôle : Carte d'offre dans la liste — salaire avec équivalent annuel,
8 + * badges (nouveau, séniorité, exigences), fraîcheur, cœur ♥ favoris
9 + * Créé : 2026-08-17 Modifié : 2026-08-25
10 10 * =============================================================================
11 11 */
12 12 import { Link } from "react-router-dom";
13 −import { displayTitle, formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from "../api";
13 +import {
14 + agoLabel, daysSince, displayTitle, formatSalary, Job,
15 + MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,
16 +} from "../api";
14 17 import { FavButton } from "../favorites";
15 18
19 +const NEW_DAYS = 3; // « Nouveau » : publiée il y a 3 jours ou moins
20 +
16 21 export default function JobCard({ job }: { job: Job }) {
17 22 const salary = formatSalary(job);
23 + const yearly = yearlyEquiv(job);
24 + const days = daysSince(job.date_posted);
25 + const isNew = days !== null && days <= NEW_DAYS;
26 + const exp = job.requirements?.experience_years;
27 + const langs = job.requirements?.languages;
28 +
18 29 return (
19 30 <Link to={`/emploi/${job.uid}`} className="job-card">
20 31 <FavButton job={job} />
@@ -22,6 +33,7 @@ export default function JobCard({ job }: { job: Job }) {
22 33 <span className="employer">{job.employer}</span>
23 34 {job.city ? <span className="muted"> — {job.city}</span> : null}
24 35 <div className="meta">
36 + {isNew && <span className="badge new">Nouveau</span>}
25 37 {job.is_direct !== false && (
26 38 <span
27 39 className="badge direct"
@@ -30,11 +42,28 @@ export default function JobCard({ job }: { job: Job }) {
30 42 Offre directe
31 43 </span>
32 44 )}
33 − {salary && <span className="badge salary">{salary}</span>}
45 + {salary && (
46 + <span className="badge salary">
47 + {salary}{yearly ? <em className="approx">{yearly}</em> : null}
48 + </span>
49 + )}
50 + {job.seniority && <span className="badge seniority">{SENIORITY_FR[job.seniority] ?? job.seniority}</span>}
34 51 {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>}
35 52 {job.employment_type && <span className="badge">{TYPE_FR[job.employment_type] ?? job.employment_type}</span>}
53 + {typeof exp === "number" && (
54 + <span className="badge" title="Années d'expérience minimales exigées dans l'offre">
55 + {exp} an{exp > 1 ? "s" : ""} d'exp. min.
56 + </span>
57 + )}
58 + {langs && langs.length === 2 && <span className="badge">Bilingue exigé</span>}
36 59 {job.category && <span className="badge">{job.category}</span>}
37 − {job.date_posted && <span>Publiée le {formatDate(job.date_posted)}</span>}
60 + </div>
61 + <div className="jc-foot muted">
62 + {days !== null && <span>Publiée {agoLabel(days)}</span>}
63 + {job.benefits && job.benefits.length > 0 && (
64 + <span>{job.benefits.length} avantage{job.benefits.length > 1 ? "s" : ""} listé{job.benefits.length > 1 ? "s" : ""}</span>
65 + )}
66 + {job.ats && <span className="ats">{job.ats}</span>}
38 67 </div>
39 68 </Link>
40 69 );
modified frontend/src/pages/Home.tsx +11 −2
@@ -5,12 +5,12 @@
5 5 * Contact : contact@spboucher.ai
6 6 * Fichier : frontend/src/pages/Home.tsx
7 7 * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres
8 − * Créé : 2026-08-17 Modifié : 2026-08-22
8 + * Créé : 2026-08-17 Modifié : 2026-08-25
9 9 * =============================================================================
10 10 */
11 11 import { useEffect, useMemo, useState } from "react";
12 12 import { useSearchParams } from "react-router-dom";
13 −import { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, TYPE_FR } from "../api";
13 +import { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR } from "../api";
14 14 import JobCard from "../components/JobCard";
15 15
16 16 const PAGE = 30;
@@ -30,6 +30,7 @@ export default function Home() {
30 30 category: params.get("categorie") ?? "",
31 31 work_mode: params.get("mode") ?? "",
32 32 employment_type: params.get("type") ?? "",
33 + seniority: params.get("seniorite") ?? "",
33 34 language: params.get("langue") ?? "",
34 35 with_salary: params.get("salaire") === "1" ? 1 : undefined,
35 36 sort: params.get("tri") ?? "recent",
@@ -100,6 +101,14 @@ export default function Home() {
100 101 <option value="">Tous les types</option>
101 102 {facets?.employment_types.map((t) => <option key={t} value={t}>{TYPE_FR[t] ?? t}</option>)}
102 103 </select>
104 + <select value={filters.seniority} onChange={(e) => setParam("seniorite", e.target.value)}>
105 + <option value="">Toutes les séniorités</option>
106 + {facets?.seniorities?.map((s) => (
107 + <option key={s.seniority} value={s.seniority}>
108 + {SENIORITY_FR[s.seniority] ?? s.seniority} ({s.n})
109 + </option>
110 + ))}
111 + </select>
103 112 <select value={filters.language} onChange={(e) => setParam("langue", e.target.value)}>
104 113 <option value="">Toutes les langues</option>
105 114 {facets?.languages?.map((l) => (
modified frontend/src/pages/Job.tsx +133 −11
@@ -4,16 +4,47 @@
4 4 * Auteur : Simon-Pierre Boucher
5 5 * Contact : contact@spboucher.ai
6 6 * Fichier : frontend/src/pages/Job.tsx
7 − * Rôle : Fiche d'une offre — faits, description, lien vers l'offre
8 − * originale, cœur ♥ favoris « Mon univers Ka »
9 − * Créé : 2026-08-17 Modifié : 2026-08-23
7 + * Rôle : Fiche d'une offre — panneau de métriques « En un coup d'œil »
8 + * (salaire vs marché, exigences, fraîcheur), description, lien vers
9 + * l'offre originale, cœur ♥ favoris « Mon univers Ka »
10 + * Créé : 2026-08-17 Modifié : 2026-08-25
10 11 * =============================================================================
11 12 */
12 −import { useEffect, useState } from "react";
13 +import { ReactNode, useEffect, useState } from "react";
13 14 import { Link, useParams } from "react-router-dom";
14 −import { displayTitle, fetchJob, formatDate, formatSalary, Job, LANG_FR, MODE_FR, TYPE_FR } from "../api";
15 +import {
16 + agoLabel, compactMoney, daysSince, displayTitle, fetchJob, formatDate,
17 + formatSalary, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,
18 +} from "../api";
15 19 import { FavButton } from "../favorites";
16 20
21 +/** Tuile métrique : valeur mise en avant + libellé mono + précision optionnelle. */
22 +function Metric({ label, value, sub, title }: {
23 + label: string; value: ReactNode; sub?: ReactNode; title?: string;
24 +}) {
25 + return (
26 + <div className="metric" title={title}>
27 + <div className="m-value">{value}</div>
28 + <div className="m-label">{label}</div>
29 + {sub && <div className="m-sub">{sub}</div>}
30 + </div>
31 + );
32 +}
33 +
34 +/** Barre de position du salaire de l'offre dans la fourchette P25–P75 de sa
35 + * catégorie (fenêtre interquartile = zone « normale » du marché). */
36 +function MarketBar({ p25, p75, mid }: { p25: number; p75: number; mid: number }) {
37 + const lo = p25 * 0.75, hi = p75 * 1.25; // marges autour des quartiles
38 + const pct = (v: number) => Math.min(100, Math.max(0, ((v - lo) / (hi - lo)) * 100));
39 + return (
40 + <div className="market-bar" aria-hidden="true">
41 + <div className="mb-track" />
42 + <div className="mb-window" style={{ left: `${pct(p25)}%`, width: `${pct(p75) - pct(p25)}%` }} />
43 + <div className="mb-marker" style={{ left: `${pct(mid)}%` }} />
44 + </div>
45 + );
46 +}
47 +
17 48 export default function JobPage() {
18 49 const { uid = "" } = useParams();
19 50 const [job, setJob] = useState<Job | null>(null);
@@ -29,9 +60,16 @@ export default function JobPage() {
29 60 if (!job) return <div className="container empty">Chargement…</div>;
30 61
31 62 const salary = formatSalary(job);
32 − const yearly = job.salary_year_min != null && job.salary_unit !== "year"
33 − ? `≈ ${Math.round(job.salary_year_min!).toLocaleString("fr-CA")} $${job.salary_year_max && job.salary_year_max > job.salary_year_min! ? ` à ${Math.round(job.salary_year_max).toLocaleString("fr-CA")} $` : ""} / an`
63 + const yearly = yearlyEquiv(job);
64 + const days = daysSince(job.date_posted);
65 + const deadlineDays = job.date_deadline ? daysSince(job.date_deadline) : null;
66 + const deadlineIn = job.date_deadline
67 + ? Math.round((new Date(`${job.date_deadline}T12:00:00`).getTime() - Date.now()) / 86400000)
34 68 : null;
69 + const ctx = job.salary_context;
70 + const reqs = job.requirements ?? {};
71 + const langsReq = reqs.languages;
72 + const history = (job.salary_history ?? []).filter((h) => h.s_min != null);
35 73
36 74 return (
37 75 <main className="container">
@@ -51,20 +89,88 @@ export default function JobPage() {
51 89 </div>
52 90 <p style={{ margin: "6px 0 0" }}>
53 91 <span className="employer" style={{ color: "var(--green)", fontWeight: 600 }}>{job.employer}</span>
92 + {typeof job.employer_jobs === "number" && job.employer_jobs > 1 && (
93 + <span className="muted"> · {job.employer_jobs} offres actives</span>
94 + )}
54 95 {job.location_label && <span className="muted"> — {job.location_label}</span>}
55 96 {job.region && job.region !== "Québec" && <span className="muted"> ({job.region})</span>}
56 97 </p>
98 +
57 99 <div className="facts">
58 − {salary && <span className="badge salary">{salary}{yearly ? ` (${yearly})` : ""}</span>}
59 − {!salary && <span className="badge">Salaire non affiché par l'employeur</span>}
100 + {days !== null && days <= 3 && <span className="badge new">Nouveau</span>}
101 + {job.is_direct !== false && <span className="badge direct">Offre directe</span>}
102 + {job.seniority && <span className="badge seniority">{SENIORITY_FR[job.seniority] ?? job.seniority}</span>}
60 103 {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>}
61 104 {job.employment_type && <span className="badge">{TYPE_FR[job.employment_type] ?? job.employment_type}</span>}
62 105 {job.language && <span className="badge">{LANG_FR[job.language] ?? job.language}</span>}
63 106 {job.category && <span className="badge">{job.category}</span>}
64 − {job.date_posted && <span className="badge">Publiée le {formatDate(job.date_posted)}</span>}
65 − {job.date_deadline && <span className="badge">Date limite : {formatDate(job.date_deadline)}</span>}
66 107 {job.active === 0 && <span className="badge" style={{ background: "#fde8e8", color: "#a02222" }}>Offre retirée</span>}
67 108 </div>
109 +
110 + {/* --- En un coup d'œil : métriques claires, jamais inventées --------- */}
111 + <h2 className="section-title" style={{ marginTop: 18 }}>En un coup d'œil</h2>
112 + <div className="metrics">
113 + <Metric
114 + label="Salaire affiché"
115 + value={salary ?? "Non affiché"}
116 + sub={salary
117 + ? (yearly ?? (job.salary_hour_min != null && job.salary_unit !== "hour"
118 + ? `≈ ${job.salary_hour_min.toLocaleString("fr-CA", { maximumFractionDigits: 2 })} $/h`
119 + : null))
120 + : "L'employeur n'a pas publié de salaire"}
121 + title="Salaire tel que publié par l'employeur — jamais estimé par Job·Ka"
122 + />
123 + {ctx && ctx.job_mid != null && ctx.delta_pct != null && (
124 + <Metric
125 + label={`Vs marché ${ctx.category || "toutes catégories"}`}
126 + value={
127 + <span className={ctx.delta_pct >= 0 ? "delta-up" : "delta-down"}>
128 + {ctx.delta_pct > 0 ? "+" : ""}{ctx.delta_pct.toLocaleString("fr-CA")} %
129 + </span>
130 + }
131 + sub={
132 + <>
133 + <MarketBar p25={ctx.p25} p75={ctx.p75} mid={ctx.job_mid} />
134 + médiane {compactMoney(ctx.median)} · P25 {compactMoney(ctx.p25)} – P75 {compactMoney(ctx.p75)} · {ctx.n.toLocaleString("fr-CA")} offres comparées
135 + </>
136 + }
137 + title="Écart entre le point médian de la fourchette de l'offre et la médiane des salaires affichés de la même catégorie sur Job·Ka"
138 + />
139 + )}
140 + {typeof reqs.experience_years === "number" && (
141 + <Metric
142 + label="Expérience exigée"
143 + value={`${reqs.experience_years} an${reqs.experience_years > 1 ? "s" : ""} min.`}
144 + sub="Extrait du texte de l'offre"
145 + />
146 + )}
147 + {typeof reqs.education === "string" && (
148 + <Metric label="Scolarité exigée" value={reqs.education} sub="Plus bas diplôme accepté" />
149 + )}
150 + {Array.isArray(langsReq) && langsReq.length > 0 && (
151 + <Metric
152 + label="Langues exigées"
153 + value={langsReq.length === 2 ? "Bilingue" : langsReq.join(", ")}
154 + sub={langsReq.length === 2 ? "Français et anglais" : undefined}
155 + />
156 + )}
157 + {days !== null && (
158 + <Metric
159 + label="Fraîcheur"
160 + value={agoLabel(days)}
161 + sub={`Publiée le ${formatDate(job.date_posted)}`}
162 + />
163 + )}
164 + {deadlineIn !== null && deadlineDays !== null && (
165 + <Metric
166 + label="Date limite"
167 + value={deadlineIn >= 0 ? `dans ${deadlineIn} jour${deadlineIn > 1 ? "s" : ""}` : "dépassée"}
168 + sub={formatDate(job.date_deadline)}
169 + title="Date limite pour postuler, publiée par l'employeur"
170 + />
171 + )}
172 + </div>
173 +
68 174 <p>
69 175 <a className="btn" href={job.apply_url || job.url} target="_blank" rel="noopener noreferrer">
70 176 Postuler chez {job.employer} ↗
@@ -74,6 +180,22 @@ export default function JobPage() {
74 180 Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka
75 181 n'est pas un intermédiaire, la candidature se fait chez l'employeur.
76 182 </p>
183 +
184 + {history.length > 1 && (
185 + <>
186 + <h2 className="section-title">Évolution du salaire affiché</h2>
187 + <ul className="salary-history">
188 + {history.map((h, i) => (
189 + <li key={i}>
190 + <span className="muted">{new Date(h.ts * 1000).toLocaleDateString("fr-CA")}</span>
191 + {" — "}
192 + {formatSalary({ salary_min: h.s_min, salary_max: h.s_max, salary_unit: h.unit })}
193 + </li>
194 + ))}
195 + </ul>
196 + </>
197 + )}
198 +
77 199 {job.benefits && job.benefits.length > 0 && (
78 200 <>
79 201 <h2 className="section-title">Avantages</h2>
modified frontend/src/styles.css +33 −1
@@ -11,7 +11,7 @@ Rôle : CSS local Job·Ka — s'appuie sur le design system commun Groupe KA
11 11 encre 1,5 px + ombres décalées dures, étiquettes mono). Le socle
12 12 (body, grain, sélection, .container, .btn, .card, .chip,
13 13 ka-footer…) vit dans tokens.css.
14 −Créé : 2026-08-17 Modifié : 2026-08-22
14 +Créé : 2026-08-17 Modifié : 2026-08-25
15 15 =============================================================================
16 16 */
17 17
@@ -214,6 +214,38 @@ nav.main a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10%
214 214 .badge.salary { background: var(--green-soft); color: var(--green); border-color: var(--green); }
215 215 .badge.mode { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--line); }
216 216 .badge.direct { background: transparent; border-color: var(--accent); color: var(--accent-deep); }
217 +.badge.new { background: var(--accent); color: var(--on-accent); border-color: var(--accent-deep); }
218 +.badge.seniority { background: var(--surface-2); color: var(--ink); border-color: var(--ink); }
219 +.badge.salary .approx { font-style: normal; font-weight: 600; opacity: 0.75; margin-left: 5px; text-transform: none; letter-spacing: 0; }
220 +
221 +/* pied de carte : fraîcheur · avantages · plateforme source */
222 +.jc-foot { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12.5px; }
223 +.jc-foot .ats { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.8; }
224 +
225 +/* --- métriques « En un coup d'œil » (fiche d'offre) -------------------------- */
226 +.metrics {
227 + display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
228 + gap: 12px; margin: 12px 0 22px;
229 +}
230 +.metric {
231 + background: var(--surface-2); border: 1.5px solid var(--line);
232 + border-radius: var(--r-card); padding: 13px 15px;
233 +}
234 +.metric .m-value { font-family: var(--font-display); font-size: 19px; font-weight: 700; letter-spacing: -0.02em; }
235 +.metric .m-label { color: var(--ink-3); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 3px; }
236 +.metric .m-sub { color: var(--ink-3); font-size: 12px; margin-top: 6px; line-height: 1.45; }
237 +.delta-up { color: var(--green); }
238 +.delta-down { color: #a05a22; }
239 +
240 +/* position du salaire dans la fenêtre interquartile de sa catégorie */
241 +.market-bar { position: relative; height: 12px; margin: 2px 0 7px; }
242 +.market-bar .mb-track { position: absolute; inset: 4px 0; background: rgb(20 24 20 / 8%); border-radius: 4px; }
243 +.market-bar .mb-window { position: absolute; top: 4px; bottom: 4px; background: var(--accent-soft); border: 1px solid var(--accent); border-radius: 4px; }
244 +.market-bar .mb-marker { position: absolute; top: 0; bottom: 0; width: 3px; margin-left: -1.5px; background: var(--ink); border-radius: 2px; }
245 +
246 +/* historique du salaire affiché */
247 +.salary-history { margin: 8px 0 0; padding-left: 18px; }
248 +.salary-history li { margin: 3px 0; font-size: 14px; }
217 249
218 250 /* --- fiche ------------------------------------------------------------------------- */
219 251 .sheet {
modified jobka/db.py +7 −5
@@ -5,7 +5,7 @@
5 5 # Fichier : jobka/db.py
6 6 # Rôle : Persistance SQLite — upsert avec détection de changements, cycle de
7 7 # vie avec délai de grâce, anti-dérive, expiration sur date limite
8 −# Créé : 2026-08-17 Modifié : 2026-08-17
8 +# Créé : 2026-08-17 Modifié : 2026-08-25
9 9 # =============================================================================
10 10 from __future__ import annotations
11 11
@@ -141,6 +141,8 @@ _MIGRATIONS: dict[str, dict[str, str]] = {
141 141 "apply_url": "TEXT", # lien de candidature directe
142 142 "title_clean": "TEXT", # titre d'affichage normalisé (source intacte)
143 143 "quarantine": "TEXT", # JSON des motifs qualité (NULL = publiable)
144 + # Phase 3 (2026-08-25) — enrichissement : séniorité du poste
145 + "seniority": "TEXT", # stage | junior | intermediaire | senior | direction
144 146 },
145 147 "sync_log": {},
146 148 }
@@ -245,7 +247,7 @@ def sync_source(con: sqlite3.Connection, source: str,
245 247 details=json.dumps(job.details, ensure_ascii=False),
246 248 lat=job.lat, lng=job.lng, content_hash=h, now=now,
247 249 company_logo=job.company_logo, language=job.language,
248 − apply_url=job.apply_url,
250 + apply_url=job.apply_url, seniority=job.seniority,
249 251 title_clean=clean_title(job.title, job.city),
250 252 quarantine=json.dumps(defects, ensure_ascii=False)
251 253 if defects else None,
@@ -260,7 +262,7 @@ def sync_source(con: sqlite3.Connection, source: str,
260 262 requirements, date_posted, date_deadline, category, ats,
261 263 details, lat, lng, content_hash, first_seen, last_seen,
262 264 updated_at, miss_count, active, company_logo, language,
263 − apply_url, title_clean, quarantine)
265 + apply_url, title_clean, quarantine, seniority)
264 266 VALUES (:uid,:source,:external_id,:url,:employer,:title,
265 267 :description,:address,:city,:region,:postal_code,
266 268 :location_label,:work_mode,:employment_type,:salary_min,
@@ -269,7 +271,7 @@ def sync_source(con: sqlite3.Connection, source: str,
269 271 :benefits,:requirements,:date_posted,:date_deadline,
270 272 :category,:ats,:details,:lat,:lng,:content_hash,
271 273 :now,:now,:now,0,1,:company_logo,:language,:apply_url,
272 − :title_clean,:quarantine)""", params)
274 + :title_clean,:quarantine,:seniority)""", params)
273 275 if job.salary_min is not None: # point de départ de l'historique
274 276 con.execute(
275 277 "INSERT INTO salary_log (uid, ts, s_min, s_max, unit)"
@@ -300,7 +302,7 @@ def sync_source(con: sqlite3.Connection, source: str,
300 302 lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),
301 303 company_logo=:company_logo, language=:language,
302 304 apply_url=:apply_url, title_clean=:title_clean,
303 − quarantine=:quarantine,
305 + quarantine=:quarantine, seniority=:seniority,
304 306 content_hash=:content_hash, last_seen=:now,
305 307 updated_at=:now, miss_count=0, active=1
306 308 WHERE uid=:uid""", params)
modified jobka/normalize.py +187 −7
@@ -4,8 +4,9 @@
4 4 # Contact : contact@spboucher.ai
5 5 # Fichier : jobka/normalize.py
6 6 # Rôle : Normalisation commune des offres (salaires, dates, mode/type
7 −# d'emploi, lieu, catégorie) — jamais de valeur inventée
8 −# Créé : 2026-08-17 Modifié : 2026-08-18
7 +# d'emploi, lieu, catégorie, exigences, séniorité) — jamais de
8 +# valeur inventée
9 +# Créé : 2026-08-17 Modifié : 2026-08-25
9 10 # =============================================================================
10 11 """Couche de normalisation commune appelée par JobPosting.finalize().
11 12
@@ -25,7 +26,8 @@ __all__ = [
25 26 "salary_to_hourly", "parse_date", "parse_work_mode",
26 27 "parse_employment_type", "parse_location", "canonical_city",
27 28 "is_quebec_location", "categorize", "detect_language",
28 − "extract_benefits", "clean_title",
29 + "extract_benefits", "clean_title", "extract_requirements",
30 + "parse_seniority",
29 31 ]
30 32
31 33
@@ -618,10 +620,15 @@ def detect_language(title: str, description: str = "") -> str:
618 620 # ---------------------------------------------------------------------------
619 621
620 622 _BENEFITS_HEADING = re.compile(
621 − r"^\s*(?:les\s+|nos\s+|our\s+|tes\s+|vos\s+)?(?:avantages(?:\s+sociaux)?|"
622 − r"benefits(?:\s+&\s+perks)?|perks(?:\s+(?:and|&)\s+benefits)?|"
623 − r"ce que nous (?:t')?offrons|what we offer|why join us|"
624 − r"pourquoi nous rejoindre|nous offrons|we offer)\s*:?\s*$", re.I)
623 + r"^\s*(?:les\s+|nos\s+|our\s+|tes\s+|vos\s+)?(?:avantages(?:\s+sociaux|"
624 + r"\s+offerts|\s+et\s+conditions)?|"
625 + r"benefits(?:\s+&\s+perks|\s+offered)?|perks(?:\s+(?:and|&)\s+benefits)?|"
626 + r"ce que (?:nous|l'on|on) (?:t'|te\s+|vous\s+)?offr(?:ons|e)|"
627 + r"nous (?:t'|te\s+|vous\s+)?offrons|on (?:t'|te\s+|vous\s+)offre|"
628 + r"what we offer|what'?s in it for you|why join us|why work (?:with|for) us|"
629 + r"pourquoi (?:nous rejoindre|nous choisir|te joindre a nous|"
630 + r"vous joindre a nous|travailler (?:chez|avec) nous)|we offer)"
631 + r"\s*:?\s*$", re.I)
625 632 _BENEFIT_LINE_MAX = 120
626 633
627 634
@@ -661,6 +668,179 @@ def extract_benefits(description: str) -> list[str]:
661 668 return []
662 669
663 670
671 +# ---------------------------------------------------------------------------
672 +# Exigences — expérience, scolarité, langues (depuis la description)
673 +# ---------------------------------------------------------------------------
674 +
675 +# Années d'expérience : « 3 à 5 ans d'expérience », « minimum de 5 ans »,
676 +# « 5+ years of experience », « two (2) years of relevant experience »…
677 +_EXP_PATTERNS = [
678 + # « X à Y ans/years … expérience » (fourchette) — on garde le MIN
679 + re.compile(r"(\d{1,2})\s*(?:a|-|to)\s*\d{1,2}\s*(?:ans?|years?)"
680 + r"[^.\n]{0,40}?(?:d )?experience", re.I),
681 + # « X ans/années/years (+) d'expérience »
682 + re.compile(r"(\d{1,2})\s*\+?\s*(?:ans?|annees?|years?)"
683 + r"[^.\n]{0,40}?(?:d )?experience", re.I),
684 + # « expérience … de X ans » / « experience of X+ years »
685 + re.compile(r"experience[^.\n]{0,50}?(?:de|of|d au moins|minimum(?: de)?|"
686 + r"at least)\s*(\d{1,2})\s*\+?\s*(?:ans?|annees?|years?)", re.I),
687 + # « minimum de X ans » / « at least X years » (sans le mot expérience)
688 + re.compile(r"(?:minimum(?: de)?|au moins|at least)\s*(\d{1,2})\s*"
689 + r"(?:ans?|annees?|years?)\b", re.I),
690 +]
691 +
692 +# Scolarité : du plus élevé au moins élevé — on retourne le PLUS BAS exigé
693 +# quand plusieurs sont acceptés (« bac ou maîtrise » -> baccalauréat).
694 +# Les sigles QC (DEP/DEC/AEC/DES) ne comptent que dans un contexte scolaire
695 +# (« dec. » est aussi l'abréviation de décembre).
696 +_EDU_SCHOOL_CONTEXT = re.compile(
697 + r"diplome|dipl\.|etudes|formation|scolarite|degree|diploma|education|"
698 + r"detenir|detention|obtenu|completed?\b|graduate", re.I)
699 +_EDU_LEVELS = [ # (niveau canonique, motif, sigle nécessitant le contexte ?)
700 + ("secondaire", r"diplome d etudes secondaires|\bdes\b|secondaire (?:5|v)\b|"
701 + r"high school diploma|secondary school diploma", True),
702 + ("DEP", r"diplome d etudes professionnelles|\bdep\b|"
703 + r"vocational diploma", True),
704 + ("AEC", r"attestation d etudes collegiales|\baec\b", True),
705 + ("DEC", r"diplome d etudes collegiales|\bdec\b|techniques? collegiales?|"
706 + r"college diploma|cegep", True),
707 + ("certificat", r"certificat universitaire|university certificate", False),
708 + ("baccalauréat", r"baccalaureat|bachelor|\bbacc\b|bac universitaire|"
709 + r"\bbac\b (?:en|in)\b|undergraduate degree|"
710 + r"b\.?\s?(?:sc|ing|a\.?a|com)\b", False),
711 + ("maîtrise", r"maitrise|master s degree|masters degree|\bmba\b|"
712 + r"m\.?\s?sc\.?\b|graduate degree", False),
713 + ("doctorat", r"doctorat|\bphd\b|ph\.?\s?d\b|doctoral", False),
714 +]
715 +
716 +# Langues exigées : mention explicite d'exigence (pas la langue du TEXTE —
717 +# ça, c'est detect_language). « bilingue », « anglais requis », « fluent in
718 +# English », « maîtrise du français »…
719 +_LANG_BILINGUAL = re.compile(r"bilingu", re.I)
720 +_LANG_REQ_FR = re.compile(
721 + r"francais[^.\n]{0,30}?(?:requis|exige|obligatoire|essentiel|"
722 + r"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|"
723 + r"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |du )?"
724 + r"francais|french (?:is )?(?:required|mandatory|essential)|"
725 + r"fluent in french|fluency in french|french proficiency", re.I)
726 +_LANG_REQ_EN = re.compile(
727 + r"anglais[^.\n]{0,30}?(?:requis|exige|obligatoire|essentiel|"
728 + r"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|"
729 + r"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |de l )?"
730 + r"anglais|english (?:is )?(?:required|mandatory|essential)|"
731 + r"fluent in english|fluency in english|english proficiency", re.I)
732 +
733 +
734 +def extract_requirements(title: str, description: str) -> dict:
735 + """Exigences structurées depuis le texte de l'offre — clés présentes
736 + seulement quand l'information est explicite (jamais de valeur inventée) :
737 +
738 + - ``experience_years`` : années d'expérience minimales exigées (int)
739 + - ``education`` : plus bas diplôme exigé (secondaire, DEP, AEC,
740 + DEC, certificat, baccalauréat, maîtrise, doctorat)
741 + - ``languages`` : langues exigées (["français"], ["anglais"], …)
742 + """
743 + out: dict = {}
744 + text = _key(f"{title or ''}\n{description or ''}"[:8000])
745 + text = re.sub(r"[’']", " ", text) # « d'expérience » -> « d experience »
746 + if len(text) < 20:
747 + return out
748 +
749 + years: list[int] = []
750 + for pat in _EXP_PATTERNS:
751 + for m in pat.finditer(text):
752 + try:
753 + n = int(m.group(1))
754 + except (TypeError, ValueError):
755 + continue
756 + if 1 <= n <= 30: # bornes de plausibilité
757 + years.append(n)
758 + if years:
759 + out["experience_years"] = min(years) # l'exigence MINIMALE
760 +
761 + for level, pat, needs_ctx in _EDU_LEVELS:
762 + for m in re.finditer(pat, text):
763 + if needs_ctx:
764 + window = text[max(0, m.start() - 120):m.end() + 120]
765 + if not _EDU_SCHOOL_CONTEXT.search(window):
766 + continue
767 + out["education"] = level
768 + break
769 + if "education" in out:
770 + break # plus bas niveau exigé trouvé
771 +
772 + langs: list[str] = []
773 + if _LANG_BILINGUAL.search(text):
774 + langs = ["français", "anglais"]
775 + else:
776 + if _LANG_REQ_FR.search(text):
777 + langs.append("français")
778 + if _LANG_REQ_EN.search(text):
779 + langs.append("anglais")
780 + if langs:
781 + out["languages"] = langs
782 + return out
783 +
784 +
785 +# ---------------------------------------------------------------------------
786 +# Séniorité — stage | junior | intermediaire | senior | direction
787 +# ---------------------------------------------------------------------------
788 +
789 +_SENIORITY_TITLE = [
790 + ("stage", r"\bstagiaire\b|\bstage\b|\bintern(?:ship)?\b|\bco-?op\b|"
791 + r"alternance|etudiant"),
792 + ("direction", r"directeur|directrice|\bdirector\b|vice[- ]?president|"
793 + r"\bvp\b|\bpdg\b|\bceo\b|\bcfo\b|\bcto\b|\bcoo\b|"
794 + r"chef de (?:service|departement|division|la direction)|"
795 + r"head of|\bdg\b|gestionnaire de (?:service|departement)"),
796 + ("senior", r"\bsenior\b|\bsr\.?\b|\bprincipal(?:e)?\b|\blead\b|"
797 + r"\bexpert(?:e)?\b|chef d equipe|team lead|superviseur|"
798 + r"\bniveau (?:3|iii)\b|\biii\b"),
799 + ("junior", r"\bjunior\b|\bjr\.?\b|debutant|entry[- ]level|"
800 + r"\bniveau (?:1|i)\b|premier emploi|releve"),
801 + ("intermediaire", r"intermediaire|intermediate|\bniveau (?:2|ii)\b|\bii\b"),
802 +]
803 +
804 +# libellés d'expérience des ATS (SmartRecruiters/LinkedIn-style) -> séniorité
805 +_SENIORITY_ATS = [
806 + ("stage", r"internship|stage"),
807 + ("direction", r"director|executive|direction"),
808 + ("senior", r"mid[- ]?senior|senior"),
809 + ("intermediaire", r"associate|intermediaire|intermediate|mid[- ]?level"),
810 + ("junior", r"entry|junior|debutant"),
811 +]
812 +
813 +
814 +def parse_seniority(title: str, requirements: dict | None = None,
815 + employment_type: str | None = None) -> str | None:
816 + """Niveau de séniorité du poste, ou None si aucun signal explicite.
817 +
818 + Ordre des signaux : type d'emploi « stage », titre du poste, libellé
819 + d'expérience fourni par l'ATS, puis années d'expérience exigées.
820 + """
821 + if employment_type == "stage":
822 + return "stage"
823 + key = _key(title or "")
824 + if key:
825 + for level, pat in _SENIORITY_TITLE:
826 + if re.search(pat, key):
827 + return level
828 + req = requirements or {}
829 + label = _key(str(req.get("experience", "")))
830 + if label and "not applicable" not in label:
831 + for level, pat in _SENIORITY_ATS:
832 + if re.search(pat, label):
833 + return level
834 + years = req.get("experience_years")
835 + if isinstance(years, (int, float)):
836 + if years <= 2:
837 + return "junior"
838 + if years <= 4:
839 + return "intermediaire"
840 + return "senior"
841 + return None
842 +
843 +
664 844 # ---------------------------------------------------------------------------
665 845 # Titre d'affichage — casse propre, codes de réquisition et suffixes retirés
666 846 # ---------------------------------------------------------------------------
modified jobka/schema.py +16 −1
@@ -4,7 +4,7 @@
4 4 # Contact : contact@spboucher.ai
5 5 # Fichier : jobka/schema.py
6 6 # Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale
7 −# Créé : 2026-08-17 Modifié : 2026-08-17
7 +# Créé : 2026-08-17 Modifié : 2026-08-25
8 8 # =============================================================================
9 9 """Schéma standard d'une offre d'emploi (JobPosting) et normalisation.
10 10
@@ -27,11 +27,13 @@ from .normalize import ( # ré-exportés pour les connecteurs
27 27 clean_text,
28 28 detect_language,
29 29 extract_benefits,
30 + extract_requirements,
30 31 is_quebec_location,
31 32 parse_date,
32 33 parse_employment_type,
33 34 parse_location,
34 35 parse_salary,
36 + parse_seniority,
35 37 parse_work_mode,
36 38 salary_from_text,
37 39 salary_to_hourly,
@@ -81,6 +83,8 @@ class JobPosting:
81 83 company_logo: str = "" # URL du logo employeur (si exposé)
82 84 language: str = "" # fr | en | bilingue ("" = indéterminée)
83 85 apply_url: str = "" # lien de candidature directe (≠ url fiche)
86 + seniority: str | None = None # stage | junior | intermediaire |
87 + # senior | direction (None = indéterminée)
84 88
85 89 @property
86 90 def uid(self) -> str:
@@ -185,6 +189,17 @@ class JobPosting:
185 189 if not self.benefits and self.description:
186 190 self.benefits = extract_benefits(self.description)
187 191
192 + # exigences structurées (expérience, scolarité, langues) : extraites de
193 + # la description, sans jamais écraser une valeur fournie par l'ATS
194 + if self.description:
195 + for k, v in extract_requirements(self.title, self.description).items():
196 + self.requirements.setdefault(k, v)
197 +
198 + # séniorité du poste : titre, libellé ATS, puis années d'expérience
199 + if self.seniority is None:
200 + self.seniority = parse_seniority(
201 + self.title, self.requirements, self.employment_type)
202 +
188 203 # coordonnées fournies par la source : rejeter tout point hors de la
189 204 # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra
190 205 # le relais sur l'adresse/la ville
modified jobka/web.py +61 −2
@@ -4,7 +4,7 @@
4 4 # Contact : contact@spboucher.ai
5 5 # Fichier : jobka/web.py
6 6 # Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)
7 −# Créé : 2026-08-17 Modifié : 2026-08-17
7 +# Créé : 2026-08-17 Modifié : 2026-08-25
8 8 # =============================================================================
9 9 from __future__ import annotations
10 10
@@ -83,6 +83,7 @@ def list_jobs(
83 83 source: str | None = None,
84 84 work_mode: str | None = None,
85 85 employment_type: str | None = None,
86 + seniority: str | None = None, # stage | junior | intermediaire | senior | direction
86 87 salary_min: float | None = None, # $/an (converti côté serveur)
87 88 with_salary: int | None = None, # 1 = transparence salariale seulement
88 89 posted_after: str | None = None, # ISO : offres publiées depuis…
@@ -118,6 +119,8 @@ def list_jobs(
118 119 sql += " AND work_mode=?"; args.append(work_mode)
119 120 if employment_type:
120 121 sql += " AND employment_type=?"; args.append(employment_type)
122 + if seniority:
123 + sql += " AND seniority=?"; args.append(seniority)
121 124 if salary_min is not None:
122 125 sql += (" AND salary_year_max IS NOT NULL AND salary_year_max>=?")
123 126 args.append(salary_min)
@@ -235,6 +238,37 @@ def jobs_geojson(
235 238 "totalGeocoded": total_geo, "totalMatching": total_all}
236 239
237 240
241 +# --- contexte salarial de marché (métrique de la fiche d'offre) ---------------
242 +# Distribution des salaires annuels affichés par catégorie (point médian de la
243 +# fourchette), recalculée au plus toutes les 10 minutes — assez frais pour une
244 +# base resynchronisée à l'heure, et jamais de requête lourde par visite.
245 +_SALARY_CTX_TTL = 600.0
246 +_salary_ctx_cache: dict[str, tuple[float, dict | None]] = {}
247 +
248 +
249 +def _salary_context(con, category: str) -> dict | None:
250 + """Quartiles des salaires annuels affichés dans la catégorie (n >= 20)."""
251 + key = category or "__toutes__"
252 + hit = _salary_ctx_cache.get(key)
253 + if hit and time.time() - hit[0] < _SALARY_CTX_TTL:
254 + return hit[1]
255 + sql = ("SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))"
256 + " / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL"
257 + " AND quarantine IS NULL AND salary_year_min IS NOT NULL")
258 + args: list = []
259 + if category:
260 + sql += " AND category=?"; args.append(category)
261 + vals = sorted(r["v"] for r in con.execute(sql, args))
262 + ctx = None
263 + if len(vals) >= 20: # trop peu d'offres = pas de médiane représentative
264 + def pct(p: float) -> float:
265 + return round(vals[min(len(vals) - 1, int(p * len(vals)))], 0)
266 + ctx = {"category": category or "", "n": len(vals),
267 + "p25": pct(0.25), "median": pct(0.50), "p75": pct(0.75)}
268 + _salary_ctx_cache[key] = (time.time(), ctx)
269 + return ctx
270 +
271 +
238 272 @app.get("/api/jobs/{uid}")
239 273 def get_job(uid: str):
240 274 con = db.connect()
@@ -245,6 +279,20 @@ def get_job(uid: str):
245 279 d["salary_history"] = [dict(r) for r in con.execute(
246 280 "SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?"
247 281 " ORDER BY ts DESC LIMIT 6", (uid,)).fetchall()]
282 + # métriques de contexte : salaire vs marché de la catégorie + volume
283 + # d'offres actives de l'employeur (comparables, jamais inventées)
284 + ctx = _salary_context(con, d.get("category") or "")
285 + if ctx and d.get("salary_year_min") is not None:
286 + mid = (d["salary_year_min"]
287 + + (d.get("salary_year_max") or d["salary_year_min"])) / 2.0
288 + ctx = {**ctx, "job_mid": round(mid, 0),
289 + "delta_pct": round((mid - ctx["median"]) / ctx["median"] * 100, 1)}
290 + d["salary_context"] = ctx
291 + if d.get("employer"):
292 + d["employer_jobs"] = con.execute(
293 + "SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL"
294 + " AND quarantine IS NULL AND employer=?",
295 + (d["employer"],)).fetchone()["n"]
248 296 con.close()
249 297 if d is None:
250 298 raise HTTPException(404, "Offre introuvable")
@@ -279,6 +327,9 @@ def facets(city: str | None = None):
279 327 "employment_types": [r["employment_type"] for r in con.execute(
280 328 f"SELECT DISTINCT employment_type{base}"
281 329 " AND employment_type IS NOT NULL ORDER BY employment_type")],
330 + "seniorities": [dict(r) for r in con.execute(
331 + f"SELECT seniority, COUNT(*) n{base} AND seniority IS NOT NULL"
332 + " GROUP BY seniority ORDER BY n DESC")],
282 333 "sources": [dict(r) for r in con.execute(
283 334 f"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC")],
284 335 }
@@ -317,6 +368,13 @@ def stats(syncs_since_h: float | None = None):
317 368 quarantined = con.execute(
318 369 "SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL"
319 370 ).fetchone()["n"]
371 + # médiane des salaires annuels affichés (point médian des fourchettes) —
372 + # plus robuste que la moyenne face aux salaires de direction
373 + sal = sorted(r["v"] for r in con.execute(
374 + """SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))
375 + / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL
376 + AND quarantine IS NULL AND salary_year_min IS NOT NULL"""))
377 + median_salary = round(sal[len(sal) // 2], 0) if sal else None
320 378 cities = [dict(r) for r in con.execute(
321 379 """SELECT city, COUNT(*) n FROM jobs
322 380 WHERE active=1 AND dup_of IS NULL AND city<>''
@@ -355,7 +413,8 @@ def stats(syncs_since_h: float | None = None):
355 413 "SELECT COUNT(*) n FROM jobs WHERE active=1"
356 414 " AND dup_of IS NOT NULL").fetchone()
357 415 con.close()
358 − return {**dict(row), "top_cities": cities, "categories": categories,
416 + return {**dict(row), "median_salary_year": median_salary,
417 + "top_cities": cities, "categories": categories,
359 418 "direct_jobs": direct["n"], "direct_employers": direct["e"],
360 419 "direct_sources": direct["s"], "portal_jobs": portal["n"],
361 420 "duplicates_hidden": dups["n"], "quarantined": quarantined,
added scripts/backfill_enrich.py +90 −0
@@ -0,0 +1,90 @@
1 +# =============================================================================
2 +# Job·Ka — Groupe KA
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : scripts/backfill_enrich.py
6 +# Rôle : Backfill de l'enrichissement Phase 3 (exigences structurées,
7 +# séniorité, avantages) sur les offres déjà en base — sans toucher
8 +# content_hash (la prochaine synchro réalignera naturellement)
9 +# Créé : 2026-08-25 Modifié : 2026-08-25
10 +# =============================================================================
11 +"""Réapplique les nouveaux extracteurs (extract_requirements, parse_seniority,
12 +extract_benefits élargi) aux offres stockées, à partir du titre et de la
13 +description déjà en base. Idempotent : ne remplit que les champs manquants,
14 +n'écrase jamais une valeur fournie par un ATS.
15 +
16 +Usage : python -m scripts.backfill_enrich [--all] (défaut : offres actives)
17 +"""
18 +from __future__ import annotations
19 +
20 +import json
21 +import sys
22 +import time
23 +from pathlib import Path
24 +
25 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
26 +
27 +from jobka import db # noqa: E402
28 +from jobka.normalize import ( # noqa: E402
29 + extract_benefits, extract_requirements, parse_seniority)
30 +
31 +
32 +def run(only_active: bool = True) -> dict:
33 + con = db.connect() # applique aussi la migration (colonne seniority)
34 + where = "WHERE active=1" if only_active else ""
35 + rows = con.execute(
36 + f"SELECT uid, title, description, employment_type, requirements,"
37 + f" benefits, seniority FROM jobs {where}").fetchall()
38 +
39 + stats = {"scanned": len(rows), "requirements": 0, "seniority": 0,
40 + "benefits": 0, "updated": 0}
41 + t0 = time.time()
42 + for r in rows:
43 + try:
44 + reqs = json.loads(r["requirements"] or "{}")
45 + except ValueError:
46 + reqs = {}
47 + try:
48 + bens = json.loads(r["benefits"] or "[]")
49 + except ValueError:
50 + bens = []
51 +
52 + changed = False
53 + extracted = extract_requirements(r["title"] or "", r["description"] or "")
54 + added_req = {k: v for k, v in extracted.items() if k not in reqs}
55 + if added_req:
56 + reqs.update(added_req)
57 + stats["requirements"] += 1
58 + changed = True
59 +
60 + seniority = r["seniority"]
61 + if not seniority:
62 + seniority = parse_seniority(r["title"] or "", reqs,
63 + r["employment_type"])
64 + if seniority:
65 + stats["seniority"] += 1
66 + changed = True
67 +
68 + if not bens and r["description"]:
69 + bens = extract_benefits(r["description"])
70 + if bens:
71 + stats["benefits"] += 1
72 + changed = True
73 +
74 + if changed:
75 + con.execute(
76 + "UPDATE jobs SET requirements=?, seniority=?, benefits=?"
77 + " WHERE uid=?",
78 + (json.dumps(reqs, ensure_ascii=False), seniority,
79 + json.dumps(bens, ensure_ascii=False), r["uid"]))
80 + stats["updated"] += 1
81 +
82 + con.commit()
83 + con.close()
84 + stats["seconds"] = round(time.time() - t0, 1)
85 + return stats
86 +
87 +
88 +if __name__ == "__main__":
89 + out = run(only_active="--all" not in sys.argv)
90 + print(f"[backfill-enrich] {out}")
added tests/test_enrich.py +172 −0
@@ -0,0 +1,172 @@
1 +# =============================================================================
2 +# Job·Ka — Groupe KA
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : tests/test_enrich.py
6 +# Rôle : Tests unitaires de l'enrichissement Phase 3 — exigences
7 +# structurées (expérience, scolarité, langues) et séniorité
8 +# Créé : 2026-08-25 Modifié : 2026-08-25
9 +# =============================================================================
10 +from jobka.normalize import extract_benefits, extract_requirements, parse_seniority
11 +from jobka.schema import JobPosting
12 +
13 +
14 +# --- exigences : années d'expérience ------------------------------------------
15 +
16 +def test_experience_range_fr():
17 + r = extract_requirements("Comptable", "Détenir 3 à 5 ans d'expérience en comptabilité.")
18 + assert r["experience_years"] == 3
19 +
20 +
21 +def test_experience_plus_en():
22 + r = extract_requirements("Developer", "5+ years of experience with Python required.")
23 + assert r["experience_years"] == 5
24 +
25 +
26 +def test_experience_minimum_de():
27 + r = extract_requirements("Technicien", "Expérience minimum de 2 ans dans un poste similaire.")
28 + assert r["experience_years"] == 2
29 +
30 +
31 +def test_experience_absente():
32 + r = extract_requirements("Cuisinier", "Préparer les plats du menu. Horaire de 35 heures.")
33 + assert "experience_years" not in r
34 +
35 +
36 +def test_experience_bornes():
37 + # 45 ans d'expérience = non plausible, on ne retient rien
38 + r = extract_requirements("Poste", "45 ans d'expérience exigée")
39 + assert "experience_years" not in r
40 +
41 +
42 +# --- exigences : scolarité ------------------------------------------------------
43 +
44 +def test_education_dec_avec_contexte():
45 + r = extract_requirements("Technicien", "Diplôme d'études collégiales (DEC) en informatique requis.")
46 + assert r["education"] == "DEC"
47 +
48 +
49 +def test_education_dec_sans_contexte_ignore():
50 + # « déc. » = décembre, pas un diplôme : sans contexte scolaire on ignore
51 + r = extract_requirements("Poste", "Entrée en fonction : déc. 2026. Aucun prérequis.")
52 + assert "education" not in r
53 +
54 +
55 +def test_education_bac():
56 + r = extract_requirements("Analyste", "Baccalauréat en administration des affaires.")
57 + assert r["education"] == "baccalauréat"
58 +
59 +
60 +def test_education_bachelor_en():
61 + r = extract_requirements("Analyst", "Bachelor's degree in Computer Science or equivalent.")
62 + assert r["education"] == "baccalauréat"
63 +
64 +
65 +def test_education_plus_bas_exige():
66 + # « bac ou maîtrise » -> l'exigence minimale est le bac
67 + r = extract_requirements("Poste", "Baccalauréat ou maîtrise en génie exigé.")
68 + assert r["education"] == "baccalauréat"
69 +
70 +
71 +def test_education_dep():
72 + r = extract_requirements("Mécanicien", "Détenir un DEP en mécanique automobile.")
73 + assert r["education"] == "DEP"
74 +
75 +
76 +# --- exigences : langues --------------------------------------------------------
77 +
78 +def test_langues_bilingue():
79 + r = extract_requirements("Poste", "Le candidat doit être bilingue (français/anglais).")
80 + assert r["languages"] == ["français", "anglais"]
81 +
82 +
83 +def test_langues_anglais_requis():
84 + r = extract_requirements("Poste", "Anglais fonctionnel requis pour ce poste.")
85 + assert r["languages"] == ["anglais"]
86 +
87 +
88 +def test_langues_fluent_english():
89 + r = extract_requirements("Role", "Fluent in English; French is an asset.")
90 + assert "anglais" in r["languages"]
91 +
92 +
93 +def test_langues_aucune_mention():
94 + r = extract_requirements("Poste", "Assembler des pièces sur la chaîne de production.")
95 + assert "languages" not in r
96 +
97 +
98 +# --- séniorité -------------------------------------------------------------------
99 +
100 +def test_seniority_titre_senior():
101 + assert parse_seniority("Développeur senior") == "senior"
102 +
103 +
104 +def test_seniority_titre_junior():
105 + assert parse_seniority("Analyste junior") == "junior"
106 +
107 +
108 +def test_seniority_titre_direction():
109 + assert parse_seniority("Directeur des finances") == "direction"
110 +
111 +
112 +def test_seniority_stage_par_type():
113 + assert parse_seniority("Analyste TI", None, "stage") == "stage"
114 +
115 +
116 +def test_seniority_label_ats():
117 + assert parse_seniority("Ingénieur", {"experience": "Mid-Senior Level"}) == "senior"
118 + assert parse_seniority("Ingénieur", {"experience": "Entry level"}) == "junior"
119 +
120 +
121 +def test_seniority_annees_experience():
122 + assert parse_seniority("Comptable", {"experience_years": 1}) == "junior"
123 + assert parse_seniority("Comptable", {"experience_years": 4}) == "intermediaire"
124 + assert parse_seniority("Comptable", {"experience_years": 8}) == "senior"
125 +
126 +
127 +def test_seniority_aucun_signal():
128 + assert parse_seniority("Préposé à l'entretien") is None
129 +
130 +
131 +def test_seniority_not_applicable_ignore():
132 + assert parse_seniority("Poste", {"experience": "Not Applicable"}) is None
133 +
134 +
135 +# --- avantages : en-têtes élargis -------------------------------------------------
136 +
137 +def test_benefits_ce_quon_offre():
138 + desc = ("Ce que nous vous offrons\n• Assurances collectives\n"
139 + "• REER avec cotisation de l'employeur\n• 4 semaines de vacances\n")
140 + assert len(extract_benefits(desc)) == 3
141 +
142 +
143 +def test_benefits_whats_in_it_for_you():
144 + desc = ("What's in it for you\n- Health insurance\n- RRSP matching\n\nApply now.")
145 + assert extract_benefits(desc) == ["Health insurance", "RRSP matching"]
146 +
147 +
148 +# --- intégration finalize() --------------------------------------------------------
149 +
150 +def test_finalize_remplit_exigences_et_seniorite():
151 + job = JobPosting(
152 + source="test", external_id="1", url="https://example.com/1",
153 + employer="Exemple inc.", title="Développeur senior",
154 + description=("Baccalauréat en informatique et 5 ans d'expérience "
155 + "exigés. Le candidat doit être bilingue."),
156 + ).finalize()
157 + assert job.requirements["experience_years"] == 5
158 + assert job.requirements["education"] == "baccalauréat"
159 + assert job.requirements["languages"] == ["français", "anglais"]
160 + assert job.seniority == "senior"
161 +
162 +
163 +def test_finalize_ne_touche_pas_valeurs_ats():
164 + job = JobPosting(
165 + source="test", external_id="2", url="https://example.com/2",
166 + employer="Exemple inc.", title="Analyste",
167 + description="2 ans d'expérience demandés.",
168 + requirements={"experience_years": 7},
169 + seniority="junior",
170 + ).finalize()
171 + assert job.requirements["experience_years"] == 7 # valeur ATS conservée
172 + assert job.seniority == "junior"
173