SPB Git

spb/job-ka

Public

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 (Aug 25, 2026) parent df3faf0 Browse files

11 changed files +819 −37

modified frontend/src/api.ts +74 −2
@@ -5,7 +5,7 @@
5 * Contact : contact@spboucher.ai 5 * Contact : contact@spboucher.ai
6 * Fichier : frontend/src/api.ts 6 * Fichier : frontend/src/api.ts
7 * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py 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 salary_hour_min: number | null; 33 salary_hour_min: number | null;
34 salary_hour_max: number | null; 34 salary_hour_max: number | null;
35 benefits: string[]; 35 benefits: string[];
36− requirements: Record<string, unknown>; 36+ requirements: JobRequirements;
37 date_posted: string | null; 37 date_posted: string | null;
38 date_deadline: string | null; 38 date_deadline: string | null;
39 category: string; 39 category: string;
@@ -48,6 +48,38 @@ export interface Job {
48 language?: string | null; // fr | en | bilingue 48 language?: string | null; // fr | en | bilingue
49 apply_url?: string | null; // candidature directe (≠ url de la fiche) 49 apply_url?: string | null; // candidature directe (≠ url de la fiche)
50 title_clean?: string | null; // titre d'affichage normalisé 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 export interface JobList { 85 export interface JobList {
@@ -64,6 +96,7 @@ export interface Facets {
64 employers: { employer: string; n: number }[]; 96 employers: { employer: string; n: number }[];
65 work_modes: string[]; 97 work_modes: string[];
66 employment_types: string[]; 98 employment_types: string[];
99+ seniorities: { seniority: string; n: number }[];
67 sources: { source: string; n: number }[]; 100 sources: { source: string; n: number }[];
68 } 101 }
69 102
@@ -126,6 +159,7 @@ export interface JobFilters {
126 category?: string; 159 category?: string;
127 work_mode?: string; 160 work_mode?: string;
128 employment_type?: string; 161 employment_type?: string;
162+ seniority?: string;
129 language?: string; 163 language?: string;
130 salary_min?: number; 164 salary_min?: number;
131 with_salary?: number; 165 with_salary?: number;
@@ -182,6 +216,44 @@ export const LANG_FR: Record<string, string> = {
182 fr: "Français", en: "Anglais", bilingue: "Bilingue", 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 /** Titre d'affichage : version normalisée si disponible, sinon le titre source. */ 257 /** Titre d'affichage : version normalisée si disponible, sinon le titre source. */
186 export const displayTitle = (job: Pick<Job, "title" | "title_clean">) => 258 export const displayTitle = (job: Pick<Job, "title" | "title_clean">) =>
187 job.title_clean || job.title; 259 job.title_clean || job.title;
modified frontend/src/components/JobCard.tsx +35 −6
@@ -4,17 +4,28 @@
4 * Auteur : Simon-Pierre Boucher 4 * Auteur : Simon-Pierre Boucher
5 * Contact : contact@spboucher.ai 5 * Contact : contact@spboucher.ai
6 * Fichier : frontend/src/components/JobCard.tsx 6 * Fichier : frontend/src/components/JobCard.tsx
7− * Rôle : Carte d'offre dans la liste (titre, employeur, badges, meta, 7+ * Rôle : Carte d'offre dans la liste — salaire avec équivalent annuel,
8− * cœur ♥ favoris « Mon univers Ka ») 8+ * badges (nouveau, séniorité, exigences), fraîcheur, cœur ♥ favoris
9− * Créé : 2026-08-17 Modifié : 2026-08-23 9+ * Créé : 2026-08-17 Modifié : 2026-08-25
10 * ============================================================================= 10 * =============================================================================
11 */ 11 */
12 import { Link } from "react-router-dom"; 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 import { FavButton } from "../favorites"; 17 import { FavButton } from "../favorites";
15 18
19+const NEW_DAYS = 3; // « Nouveau » : publiée il y a 3 jours ou moins
20+
16 export default function JobCard({ job }: { job: Job }) { 21 export default function JobCard({ job }: { job: Job }) {
17 const salary = formatSalary(job); 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 return ( 29 return (
19 <Link to={`/emploi/${job.uid}`} className="job-card"> 30 <Link to={`/emploi/${job.uid}`} className="job-card">
20 <FavButton job={job} /> 31 <FavButton job={job} />
@@ -22,6 +33,7 @@ export default function JobCard({ job }: { job: Job }) {
22 <span className="employer">{job.employer}</span> 33 <span className="employer">{job.employer}</span>
23 {job.city ? <span className="muted"> — {job.city}</span> : null} 34 {job.city ? <span className="muted"> — {job.city}</span> : null}
24 <div className="meta"> 35 <div className="meta">
36+ {isNew && <span className="badge new">Nouveau</span>}
25 {job.is_direct !== false && ( 37 {job.is_direct !== false && (
26 <span 38 <span
27 className="badge direct" 39 className="badge direct"
@@ -30,11 +42,28 @@ export default function JobCard({ job }: { job: Job }) {
30 Offre directe 42 Offre directe
31 </span> 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 {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>} 51 {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>}
35 {job.employment_type && <span className="badge">{TYPE_FR[job.employment_type] ?? job.employment_type}</span>} 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 {job.category && <span className="badge">{job.category}</span>} 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 </div> 67 </div>
39 </Link> 68 </Link>
40 ); 69 );
modified frontend/src/pages/Home.tsx +11 −2
@@ -5,12 +5,12 @@
5 * Contact : contact@spboucher.ai 5 * Contact : contact@spboucher.ai
6 * Fichier : frontend/src/pages/Home.tsx 6 * Fichier : frontend/src/pages/Home.tsx
7 * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres 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 import { useEffect, useMemo, useState } from "react"; 11 import { useEffect, useMemo, useState } from "react";
12 import { useSearchParams } from "react-router-dom"; 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 import JobCard from "../components/JobCard"; 14 import JobCard from "../components/JobCard";
15 15
16 const PAGE = 30; 16 const PAGE = 30;
@@ -30,6 +30,7 @@ export default function Home() {
30 category: params.get("categorie") ?? "", 30 category: params.get("categorie") ?? "",
31 work_mode: params.get("mode") ?? "", 31 work_mode: params.get("mode") ?? "",
32 employment_type: params.get("type") ?? "", 32 employment_type: params.get("type") ?? "",
33+ seniority: params.get("seniorite") ?? "",
33 language: params.get("langue") ?? "", 34 language: params.get("langue") ?? "",
34 with_salary: params.get("salaire") === "1" ? 1 : undefined, 35 with_salary: params.get("salaire") === "1" ? 1 : undefined,
35 sort: params.get("tri") ?? "recent", 36 sort: params.get("tri") ?? "recent",
@@ -100,6 +101,14 @@ export default function Home() {
100 <option value="">Tous les types</option> 101 <option value="">Tous les types</option>
101 {facets?.employment_types.map((t) => <option key={t} value={t}>{TYPE_FR[t] ?? t}</option>)} 102 {facets?.employment_types.map((t) => <option key={t} value={t}>{TYPE_FR[t] ?? t}</option>)}
102 </select> 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 <select value={filters.language} onChange={(e) => setParam("langue", e.target.value)}> 112 <select value={filters.language} onChange={(e) => setParam("langue", e.target.value)}>
104 <option value="">Toutes les langues</option> 113 <option value="">Toutes les langues</option>
105 {facets?.languages?.map((l) => ( 114 {facets?.languages?.map((l) => (
modified frontend/src/pages/Job.tsx +133 −11
@@ -4,16 +4,47 @@
4 * Auteur : Simon-Pierre Boucher 4 * Auteur : Simon-Pierre Boucher
5 * Contact : contact@spboucher.ai 5 * Contact : contact@spboucher.ai
6 * Fichier : frontend/src/pages/Job.tsx 6 * Fichier : frontend/src/pages/Job.tsx
7− * Rôle : Fiche d'une offre — faits, description, lien vers l'offre 7+ * Rôle : Fiche d'une offre — panneau de métriques « En un coup d'œil »
8− * originale, cœur ♥ favoris « Mon univers Ka » 8+ * (salaire vs marché, exigences, fraîcheur), description, lien vers
9− * Créé : 2026-08-17 Modifié : 2026-08-23 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 import { Link, useParams } from "react-router-dom"; 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 import { FavButton } from "../favorites"; 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 export default function JobPage() { 48 export default function JobPage() {
18 const { uid = "" } = useParams(); 49 const { uid = "" } = useParams();
19 const [job, setJob] = useState<Job | null>(null); 50 const [job, setJob] = useState<Job | null>(null);
@@ -29,9 +60,16 @@ export default function JobPage() {
29 if (!job) return <div className="container empty">Chargement…</div>; 60 if (!job) return <div className="container empty">Chargement…</div>;
30 61
31 const salary = formatSalary(job); 62 const salary = formatSalary(job);
32− const yearly = job.salary_year_min != null && job.salary_unit !== "year" 63+ const yearly = yearlyEquiv(job);
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` 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 : null; 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 return ( 74 return (
37 <main className="container"> 75 <main className="container">
@@ -51,20 +89,88 @@ export default function JobPage() {
51 </div> 89 </div>
52 <p style={{ margin: "6px 0 0" }}> 90 <p style={{ margin: "6px 0 0" }}>
53 <span className="employer" style={{ color: "var(--green)", fontWeight: 600 }}>{job.employer}</span> 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 {job.location_label && <span className="muted"> — {job.location_label}</span>} 95 {job.location_label && <span className="muted"> — {job.location_label}</span>}
55 {job.region && job.region !== "Québec" && <span className="muted"> ({job.region})</span>} 96 {job.region && job.region !== "Québec" && <span className="muted"> ({job.region})</span>}
56 </p> 97 </p>
98+
57 <div className="facts"> 99 <div className="facts">
58− {salary && <span className="badge salary">{salary}{yearly ? ` (${yearly})` : ""}</span>} 100+ {days !== null && days <= 3 && <span className="badge new">Nouveau</span>}
59− {!salary && <span className="badge">Salaire non affiché par l'employeur</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 {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>} 103 {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>}
61 {job.employment_type && <span className="badge">{TYPE_FR[job.employment_type] ?? job.employment_type}</span>} 104 {job.employment_type && <span className="badge">{TYPE_FR[job.employment_type] ?? job.employment_type}</span>}
62 {job.language && <span className="badge">{LANG_FR[job.language] ?? job.language}</span>} 105 {job.language && <span className="badge">{LANG_FR[job.language] ?? job.language}</span>}
63 {job.category && <span className="badge">{job.category}</span>} 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 {job.active === 0 && <span className="badge" style={{ background: "#fde8e8", color: "#a02222" }}>Offre retirée</span>} 107 {job.active === 0 && <span className="badge" style={{ background: "#fde8e8", color: "#a02222" }}>Offre retirée</span>}
67 </div> 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 <p> 174 <p>
69 <a className="btn" href={job.apply_url || job.url} target="_blank" rel="noopener noreferrer"> 175 <a className="btn" href={job.apply_url || job.url} target="_blank" rel="noopener noreferrer">
70 Postuler chez {job.employer} ↗ 176 Postuler chez {job.employer} ↗
@@ -74,6 +180,22 @@ export default function JobPage() {
74 Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka 180 Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka
75 n'est pas un intermédiaire, la candidature se fait chez l'employeur. 181 n'est pas un intermédiaire, la candidature se fait chez l'employeur.
76 </p> 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 {job.benefits && job.benefits.length > 0 && ( 199 {job.benefits && job.benefits.length > 0 && (
78 <> 200 <>
79 <h2 className="section-title">Avantages</h2> 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 encre 1,5 px + ombres décalées dures, étiquettes mono). Le socle 11 encre 1,5 px + ombres décalées dures, étiquettes mono). Le socle
12 (body, grain, sélection, .container, .btn, .card, .chip, 12 (body, grain, sélection, .container, .btn, .card, .chip,
13 ka-footer…) vit dans tokens.css. 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 .badge.salary { background: var(--green-soft); color: var(--green); border-color: var(--green); } 214 .badge.salary { background: var(--green-soft); color: var(--green); border-color: var(--green); }
215 .badge.mode { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--line); } 215 .badge.mode { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--line); }
216 .badge.direct { background: transparent; border-color: var(--accent); color: var(--accent-deep); } 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 /* --- fiche ------------------------------------------------------------------------- */ 250 /* --- fiche ------------------------------------------------------------------------- */
219 .sheet { 251 .sheet {
modified jobka/db.py +7 −5
@@ -5,7 +5,7 @@
5 # Fichier : jobka/db.py 5 # Fichier : jobka/db.py
6 # Rôle : Persistance SQLite — upsert avec détection de changements, cycle de 6 # Rôle : Persistance SQLite — upsert avec détection de changements, cycle de
7 # vie avec délai de grâce, anti-dérive, expiration sur date limite 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 from __future__ import annotations 10 from __future__ import annotations
11 11
@@ -141,6 +141,8 @@ _MIGRATIONS: dict[str, dict[str, str]] = {
141 "apply_url": "TEXT", # lien de candidature directe 141 "apply_url": "TEXT", # lien de candidature directe
142 "title_clean": "TEXT", # titre d'affichage normalisé (source intacte) 142 "title_clean": "TEXT", # titre d'affichage normalisé (source intacte)
143 "quarantine": "TEXT", # JSON des motifs qualité (NULL = publiable) 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 "sync_log": {}, 147 "sync_log": {},
146 } 148 }
@@ -245,7 +247,7 @@ def sync_source(con: sqlite3.Connection, source: str,
245 details=json.dumps(job.details, ensure_ascii=False), 247 details=json.dumps(job.details, ensure_ascii=False),
246 lat=job.lat, lng=job.lng, content_hash=h, now=now, 248 lat=job.lat, lng=job.lng, content_hash=h, now=now,
247 company_logo=job.company_logo, language=job.language, 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 title_clean=clean_title(job.title, job.city), 251 title_clean=clean_title(job.title, job.city),
250 quarantine=json.dumps(defects, ensure_ascii=False) 252 quarantine=json.dumps(defects, ensure_ascii=False)
251 if defects else None, 253 if defects else None,
@@ -260,7 +262,7 @@ def sync_source(con: sqlite3.Connection, source: str,
260 requirements, date_posted, date_deadline, category, ats, 262 requirements, date_posted, date_deadline, category, ats,
261 details, lat, lng, content_hash, first_seen, last_seen, 263 details, lat, lng, content_hash, first_seen, last_seen,
262 updated_at, miss_count, active, company_logo, language, 264 updated_at, miss_count, active, company_logo, language,
263− apply_url, title_clean, quarantine) 265+ apply_url, title_clean, quarantine, seniority)
264 VALUES (:uid,:source,:external_id,:url,:employer,:title, 266 VALUES (:uid,:source,:external_id,:url,:employer,:title,
265 :description,:address,:city,:region,:postal_code, 267 :description,:address,:city,:region,:postal_code,
266 :location_label,:work_mode,:employment_type,:salary_min, 268 :location_label,:work_mode,:employment_type,:salary_min,
@@ -269,7 +271,7 @@ def sync_source(con: sqlite3.Connection, source: str,
269 :benefits,:requirements,:date_posted,:date_deadline, 271 :benefits,:requirements,:date_posted,:date_deadline,
270 :category,:ats,:details,:lat,:lng,:content_hash, 272 :category,:ats,:details,:lat,:lng,:content_hash,
271 :now,:now,:now,0,1,:company_logo,:language,:apply_url, 273 :now,:now,:now,0,1,:company_logo,:language,:apply_url,
272− :title_clean,:quarantine)""", params) 274+ :title_clean,:quarantine,:seniority)""", params)
273 if job.salary_min is not None: # point de départ de l'historique 275 if job.salary_min is not None: # point de départ de l'historique
274 con.execute( 276 con.execute(
275 "INSERT INTO salary_log (uid, ts, s_min, s_max, unit)" 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 lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng), 302 lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),
301 company_logo=:company_logo, language=:language, 303 company_logo=:company_logo, language=:language,
302 apply_url=:apply_url, title_clean=:title_clean, 304 apply_url=:apply_url, title_clean=:title_clean,
303− quarantine=:quarantine, 305+ quarantine=:quarantine, seniority=:seniority,
304 content_hash=:content_hash, last_seen=:now, 306 content_hash=:content_hash, last_seen=:now,
305 updated_at=:now, miss_count=0, active=1 307 updated_at=:now, miss_count=0, active=1
306 WHERE uid=:uid""", params) 308 WHERE uid=:uid""", params)
modified jobka/normalize.py +187 −7
@@ -4,8 +4,9 @@
4 # Contact : contact@spboucher.ai 4 # Contact : contact@spboucher.ai
5 # Fichier : jobka/normalize.py 5 # Fichier : jobka/normalize.py
6 # Rôle : Normalisation commune des offres (salaires, dates, mode/type 6 # Rôle : Normalisation commune des offres (salaires, dates, mode/type
7−# d'emploi, lieu, catégorie) — jamais de valeur inventée 7+# d'emploi, lieu, catégorie, exigences, séniorité) — jamais de
8−# Créé : 2026-08-17 Modifié : 2026-08-18 8+# valeur inventée
9+# Créé : 2026-08-17 Modifié : 2026-08-25
9 # ============================================================================= 10 # =============================================================================
10 """Couche de normalisation commune appelée par JobPosting.finalize(). 11 """Couche de normalisation commune appelée par JobPosting.finalize().
11 12
@@ -25,7 +26,8 @@ __all__ = [
25 "salary_to_hourly", "parse_date", "parse_work_mode", 26 "salary_to_hourly", "parse_date", "parse_work_mode",
26 "parse_employment_type", "parse_location", "canonical_city", 27 "parse_employment_type", "parse_location", "canonical_city",
27 "is_quebec_location", "categorize", "detect_language", 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 _BENEFITS_HEADING = re.compile( 622 _BENEFITS_HEADING = re.compile(
621− r"^\s*(?:les\s+|nos\s+|our\s+|tes\s+|vos\s+)?(?:avantages(?:\s+sociaux)?|" 623+ 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)?|" 624+ r"\s+offerts|\s+et\s+conditions)?|"
623− r"ce que nous (?:t')?offrons|what we offer|why join us|" 625+ r"benefits(?:\s+&\s+perks|\s+offered)?|perks(?:\s+(?:and|&)\s+benefits)?|"
624− r"pourquoi nous rejoindre|nous offrons|we offer)\s*:?\s*$", re.I) 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 _BENEFIT_LINE_MAX = 120 632 _BENEFIT_LINE_MAX = 120
626 633
627 634
@@ -661,6 +668,179 @@ def extract_benefits(description: str) -> list[str]:
661 return [] 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 # Titre d'affichage — casse propre, codes de réquisition et suffixes retirés 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 # Contact : contact@spboucher.ai 4 # Contact : contact@spboucher.ai
5 # Fichier : jobka/schema.py 5 # Fichier : jobka/schema.py
6 # Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale 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 """Schéma standard d'une offre d'emploi (JobPosting) et normalisation. 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 clean_text, 27 clean_text,
28 detect_language, 28 detect_language,
29 extract_benefits, 29 extract_benefits,
30+ extract_requirements,
30 is_quebec_location, 31 is_quebec_location,
31 parse_date, 32 parse_date,
32 parse_employment_type, 33 parse_employment_type,
33 parse_location, 34 parse_location,
34 parse_salary, 35 parse_salary,
36+ parse_seniority,
35 parse_work_mode, 37 parse_work_mode,
36 salary_from_text, 38 salary_from_text,
37 salary_to_hourly, 39 salary_to_hourly,
@@ -81,6 +83,8 @@ class JobPosting:
81 company_logo: str = "" # URL du logo employeur (si exposé) 83 company_logo: str = "" # URL du logo employeur (si exposé)
82 language: str = "" # fr | en | bilingue ("" = indéterminée) 84 language: str = "" # fr | en | bilingue ("" = indéterminée)
83 apply_url: str = "" # lien de candidature directe (≠ url fiche) 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 @property 89 @property
86 def uid(self) -> str: 90 def uid(self) -> str:
@@ -185,6 +189,17 @@ class JobPosting:
185 if not self.benefits and self.description: 189 if not self.benefits and self.description:
186 self.benefits = extract_benefits(self.description) 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 # coordonnées fournies par la source : rejeter tout point hors de la 203 # coordonnées fournies par la source : rejeter tout point hors de la
189 # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra 204 # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra
190 # le relais sur l'adresse/la ville 205 # le relais sur l'adresse/la ville
modified jobka/web.py +61 −2
@@ -4,7 +4,7 @@
4 # Contact : contact@spboucher.ai 4 # Contact : contact@spboucher.ai
5 # Fichier : jobka/web.py 5 # Fichier : jobka/web.py
6 # Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist) 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 from __future__ import annotations 9 from __future__ import annotations
10 10
@@ -83,6 +83,7 @@ def list_jobs(
83 source: str | None = None, 83 source: str | None = None,
84 work_mode: str | None = None, 84 work_mode: str | None = None,
85 employment_type: str | None = None, 85 employment_type: str | None = None,
86+ seniority: str | None = None, # stage | junior | intermediaire | senior | direction
86 salary_min: float | None = None, # $/an (converti côté serveur) 87 salary_min: float | None = None, # $/an (converti côté serveur)
87 with_salary: int | None = None, # 1 = transparence salariale seulement 88 with_salary: int | None = None, # 1 = transparence salariale seulement
88 posted_after: str | None = None, # ISO : offres publiées depuis… 89 posted_after: str | None = None, # ISO : offres publiées depuis…
@@ -118,6 +119,8 @@ def list_jobs(
118 sql += " AND work_mode=?"; args.append(work_mode) 119 sql += " AND work_mode=?"; args.append(work_mode)
119 if employment_type: 120 if employment_type:
120 sql += " AND employment_type=?"; args.append(employment_type) 121 sql += " AND employment_type=?"; args.append(employment_type)
122+ if seniority:
123+ sql += " AND seniority=?"; args.append(seniority)
121 if salary_min is not None: 124 if salary_min is not None:
122 sql += (" AND salary_year_max IS NOT NULL AND salary_year_max>=?") 125 sql += (" AND salary_year_max IS NOT NULL AND salary_year_max>=?")
123 args.append(salary_min) 126 args.append(salary_min)
@@ -235,6 +238,37 @@ def jobs_geojson(
235 "totalGeocoded": total_geo, "totalMatching": total_all} 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 @app.get("/api/jobs/{uid}") 272 @app.get("/api/jobs/{uid}")
239 def get_job(uid: str): 273 def get_job(uid: str):
240 con = db.connect() 274 con = db.connect()
@@ -245,6 +279,20 @@ def get_job(uid: str):
245 d["salary_history"] = [dict(r) for r in con.execute( 279 d["salary_history"] = [dict(r) for r in con.execute(
246 "SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?" 280 "SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?"
247 " ORDER BY ts DESC LIMIT 6", (uid,)).fetchall()] 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 con.close() 296 con.close()
249 if d is None: 297 if d is None:
250 raise HTTPException(404, "Offre introuvable") 298 raise HTTPException(404, "Offre introuvable")
@@ -279,6 +327,9 @@ def facets(city: str | None = None):
279 "employment_types": [r["employment_type"] for r in con.execute( 327 "employment_types": [r["employment_type"] for r in con.execute(
280 f"SELECT DISTINCT employment_type{base}" 328 f"SELECT DISTINCT employment_type{base}"
281 " AND employment_type IS NOT NULL ORDER BY employment_type")], 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 "sources": [dict(r) for r in con.execute( 333 "sources": [dict(r) for r in con.execute(
283 f"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC")], 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 quarantined = con.execute( 368 quarantined = con.execute(
318 "SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL" 369 "SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL"
319 ).fetchone()["n"] 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 cities = [dict(r) for r in con.execute( 378 cities = [dict(r) for r in con.execute(
321 """SELECT city, COUNT(*) n FROM jobs 379 """SELECT city, COUNT(*) n FROM jobs
322 WHERE active=1 AND dup_of IS NULL AND city<>'' 380 WHERE active=1 AND dup_of IS NULL AND city<>''
@@ -355,7 +413,8 @@ def stats(syncs_since_h: float | None = None):
355 "SELECT COUNT(*) n FROM jobs WHERE active=1" 413 "SELECT COUNT(*) n FROM jobs WHERE active=1"
356 " AND dup_of IS NOT NULL").fetchone() 414 " AND dup_of IS NOT NULL").fetchone()
357 con.close() 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 "direct_jobs": direct["n"], "direct_employers": direct["e"], 418 "direct_jobs": direct["n"], "direct_employers": direct["e"],
360 "direct_sources": direct["s"], "portal_jobs": portal["n"], 419 "direct_sources": direct["s"], "portal_jobs": portal["n"],
361 "duplicates_hidden": dups["n"], "quarantined": quarantined, 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
Menu

Theme