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>
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 | ||