{"type":"user_prompt","text":"Peux tu faire un major upgrade en effectuant un enrichissement de tous les connecteur de job ka et un amélioration du display des annonce job avec meilleur display et metric clair","ts":1787667031135} {"type":"status","state":"running"} {"type":"claude","event":{"type":"system","subtype":"init","cwd":"/Users/simon-pierreboucher/apps/job-ka","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","tools":["Task","Bash","CronCreate","CronDelete","CronList","DesignSync","Edit","EnterWorktree","ExitWorktree","Monitor","NotebookEdit","PushNotification","Read","ReportFindings","ScheduleWakeup","SendMessage","Skill","TaskCreate","TaskGet","TaskList","TaskOutput","TaskStop","TaskUpdate","ToolSearch","WebFetch","WebSearch","Workflow","Write"],"mcp_servers":[{"name":"maclustr-cluster","status":"pending"},{"name":"drive","status":"pending"}],"model":"claude-fable-5","permissionMode":"bypassPermissions","slash_commands":["cluster","cluster-compute","cluster-deploy","cluster-deploy-app","cluster-run","cluster-status","deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","loop","claude-api","run","run-skill-generator","agents","clear","compact","config","context","heapdump","init","__remote-workflow","reload-skills","review","security-review","usage","insights","recap","goal","design","design-consent","design-revoke","team-onboarding"],"apiKeySource":"ANTHROPIC_API_KEY","claude_code_version":"2.1.204","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","loop","claude-api","run","run-skill-generator"],"plugins":[],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"23bbf564-9cdd-4a79-b86c-286b45ced2da","memory_paths":{"auto":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-job-ka/memory/"},"fast_mode_state":"off"}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"0afe10e9-2979-4ee2-817a-cd80219c040f","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":100,"estimated_tokens_delta":100,"uuid":"d89ccc52-6ff0-4466-8636-4eb49f714db6","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":131,"estimated_tokens_delta":31,"uuid":"dfff4315-a5cf-43cd-bcfe-662d5f6bec0e","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbp2tN36SYWP1GxTGH3","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISgwQKpwEIERgCKkD4g+DSYtQHs24g/yp4BbprR4MTUku53dN0LdvdQ3/2sXeg2WlVL6YUPtEKZorgBcspx/fCiLWfsuZAaoWrrKlwMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAHdxLbUBrABAhIMfKdnJ6nEBXumB2ImGgy7bs5NEwST4TxhQTgiMHgobziU5aGqS36bcDPdE8+0se5ieZvK2haNqG7jqMNOZVV4bwZiDjKvLlPDML1e6CqIAoyShsQ9YlyCJGQyBn6Er4duTyvGHO8UdC0ajAjed6IAKkhWKFuh2hJA+smYhQrE24y/ZdvdLEwaA6U4S+5qNrQSE4lv8baljm/rgmJ5G+MdWMXFFn70qP+MQFUaoSUflZRfc+Db4Qw2o0qQW5oBBxGhKuON/RGXUXKqVwOs5Qw4xsbcenMBnJj0rOt6Y4KA0uEhbsJ1/bOhSPoOJK4s1QQvwPManB32Uoe0/JBGZtw+po+quNjztyc1UFUUGpxWedRXrDrG1AtIg5X832ye62TUSx34ygh6Z2GLeV4byM4SatAY6mO6S5cg0zWfAbz0cp5bPT6SEvSPXuK2wCkwV4nR9VulFgshyxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3157,"cache_creation_input_tokens":18121,"cache_read_input_tokens":15199,"cache_creation":{"ephemeral_5m_input_tokens":18121,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"b598df93-6b6d-4587-820b-5593a1b87c33","request_id":"req_011CePbp1NaZmZVrQ2Kk2MQu"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbp2tN36SYWP1GxTGH3","type":"message","role":"assistant","content":[{"type":"text","text":"Je commence par explorer la structure du projet Job·Ka pour comprendre l'architecture actuelle des connecteurs et du frontend avant de planifier le major upgrade."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3157,"cache_creation_input_tokens":18121,"cache_read_input_tokens":15199,"cache_creation":{"ephemeral_5m_input_tokens":18121,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"95d6cb60-c267-4a0c-a390-2d54d1f25795","request_id":"req_011CePbp1NaZmZVrQ2Kk2MQu"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbp2tN36SYWP1GxTGH3","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CDQvCWyMioJS5JAEnYL6By","name":"Bash","input":{"command":"ls /Users/simon-pierreboucher/apps/job-ka && echo \"---\" && cat /Users/simon-pierreboucher/apps/job-ka/README.md 2>/dev/null | head -80","description":"List job-ka repo structure and README head"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3157,"cache_creation_input_tokens":18121,"cache_read_input_tokens":15199,"cache_creation":{"ephemeral_5m_input_tokens":18121,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"a9cf39c1-6d0c-4024-a822-a2b3ca91e6f2","request_id":"req_011CePbp1NaZmZVrQ2Kk2MQu"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CDQvCWyMioJS5JAEnYL6By","type":"tool_result","content":"CLAUDE.md\nREADME.md\ndata\ndocs\nfrontend\njobka\nrequirements.txt\nrun.py\nscripts\ntests\n---\n\n\n

\n \"Job·Ka\n

\n\n

Job·Ka

\n\n

Tous les emplois des employeurs québécois

\n\n
\n\n[![Site](https://img.shields.io/website?url=https%3A%2F%2Fwww.job-ka.com&style=flat-square&label=www.job-ka.com&up_color=0c8599)](https://www.job-ka.com)\n[![Documentation](https://img.shields.io/badge/📖_documentation-%2Fdoc-0c8599?style=flat-square)](https://www.job-ka.com/doc/)\n[![PDF](https://img.shields.io/badge/guide-PDF-0c8599?style=flat-square)](https://www.job-ka.com/doc/job-ka-documentation.pdf)\n![Nœud](https://img.shields.io/badge/n%C5%93ud-M3U96a-1f6feb?style=flat-square)\n![Port](https://img.shields.io/badge/port-8096-0c8599?style=flat-square)\n![PM2](https://img.shields.io/badge/process-PM2-2b037a?style=flat-square)\n\n[![Offres actives](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.total&label=offres%20actives&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n[![Offres directes](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.direct_jobs&label=offres%20directes&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n[![Employeurs](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.employers&label=employeurs&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n[![Sources](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.sources&label=sources&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n[![Avec salaire](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.with_salary&label=avec%20salaire%20affich%C3%A9&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n[![Télétravail](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.remote&label=t%C3%A9l%C3%A9travail&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n[![Géocodées](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.geocoded&label=g%C3%A9ocod%C3%A9es&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n\n![Python](https://img.shields.io/badge/Python-FastAPI-3776ab?style=flat-square&logo=python&logoColor=white)\n![FastAPI](https://img.shields.io/badge/FastAPI-API%20%2B%20SSR-009688?style=flat-square&logo=fastapi&logoColor=white)\n![React](https://img.shields.io/badge/React-18%20%2B%20Vite-61dafb?style=flat-square&logo=react&logoColor=black)\n![TypeScript](https://img.shields.io/badge/TypeScript-frontend-141814?style=flat-square&logo=typescript)\n![SQLite](https://img.shields.io/badge/SQLite-diff%20engine-141814?style=flat-square&logo=sqlite)\n![MapLibre](https://img.shields.io/badge/carte-MapLibre-396cb2?style=flat-square)\n![pytest](https://img.shields.io/badge/pytest-74%20tests%20hors%20ligne-141814?style=flat-square&logo=pytest)\n![ngrok](https://img.shields.io/badge/tunnel-ngrok-140a3c?style=flat-square&logo=ngrok)\n![Groupe KA](https://img.shields.io/badge/Groupe-KA-b7f000?style=flat-square)\n![remote-first](https://img.shields.io/badge/remote--first-spbgit-24292f?style=flat-square&logo=git&logoColor=white)\n\n
\n\nLes grands sites d'emploi mélangent annonces payantes, doublons repostés et offres mortes. L'offre originale — celle de la page carrière de l'employeur — est la seule source fiable. **Job·Ka** se branche **directement sur les systèmes de recrutement (ATS)** des employeurs québécois : **969 connecteurs employeurs directs** sur **21 plateformes ATS** (Workday, SmartRecruiters, Dayforce, Workable, Workland/réseau Atlas, SuccessFactors, Njoyn, Greenhouse, WP Job Manager, Lever, Breezy, Ashby, Recruitee, BambooHR, Taleo Business Edition, Taleo, Digital Recruiters, iCIMS, UltiPro/UKG, ADP WFN + sites carrières maison), complétés par **3 portails publics** (Guichet-Emplois, Jobillico, Espresso-Jobs) — l'offre directe gagne toujours.\n\nRésultat en base (au 2026-08-24) : **21 492 offres actives** de **1 653 employeurs** (963 sources), dont **19 856 offres directes** et **6 856 avec salaire affiché** (moyenne 62 283 $/an) — salaires **normalisés en $/h et $/an**, jamais inventés. Chaque offre est traçable à sa source et la candidature se fait chez l'employeur. Pour qui ? Les chercheurs d'emploi québécois qui veulent l'inventaire réel, à jour et transparent, des postes affichés dans la province — resynchronisé jour et nuit.\n\n## Chiffres clés (live sur [/api/stats](https://www.job-ka.com/api/stats), vérifiés au 2026-08-24)\n\n| Métrique | Valeur |\n|---|---|\n| Offres actives | **21 492** (98 doublons inter-sources masqués, 227 en quarantaine qualité) |\n| Offres directes (pages carrières / ATS) | **19 856** — de 1 048 employeurs directs |\n| Offres portails (complément) | 1 863 |\n| Employeurs | **1 653** |\n| Sources actives | **963** (registre : 973 entrées ; 969 employeurs directs connectés via 21 plateformes ATS + 3 portails + dépôt direct) |\n| Avec salaire affiché | **6 856** — salaire moyen affiché ≈ **62 283 $/an** |\n| Télétravail | **1 013** |\n| Offres géocodées (carte) | **16 303** |\n| Top villes | Montréal 7 645 · Québec 3 637 · Dorval 734 · Laval 733 |\n| Top catégories | TI 2 699 · Santé 2 302 · Service à la clientèle 1 380 · Ventes 1 297 |\n\n## Visite guidée\n\n\n \n \n \n \n
\"Accueil
Accueil — recherche d'offres (desktop)
\"Accueil
Accueil — mobile
\n\n### Guide pas à pas (« Utiliser le site en 4 étapes », depuis [/doc](https://www.job-ka.com/doc/))\n\n","is_error":false}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"8e90ef38-351a-4184-a37a-95932e700273","timestamp":"2026-08-25T14:10:40.135Z","tool_use_result":{"stdout":"CLAUDE.md\nREADME.md\ndata\ndocs\nfrontend\njobka\nrequirements.txt\nrun.py\nscripts\ntests\n---\n\n\n

\n \"Job·Ka\n

\n\n

Job·Ka

\n\n

Tous les emplois des employeurs québécois

\n\n
\n\n[![Site](https://img.shields.io/website?url=https%3A%2F%2Fwww.job-ka.com&style=flat-square&label=www.job-ka.com&up_color=0c8599)](https://www.job-ka.com)\n[![Documentation](https://img.shields.io/badge/📖_documentation-%2Fdoc-0c8599?style=flat-square)](https://www.job-ka.com/doc/)\n[![PDF](https://img.shields.io/badge/guide-PDF-0c8599?style=flat-square)](https://www.job-ka.com/doc/job-ka-documentation.pdf)\n![Nœud](https://img.shields.io/badge/n%C5%93ud-M3U96a-1f6feb?style=flat-square)\n![Port](https://img.shields.io/badge/port-8096-0c8599?style=flat-square)\n![PM2](https://img.shields.io/badge/process-PM2-2b037a?style=flat-square)\n\n[![Offres actives](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.total&label=offres%20actives&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n[![Offres directes](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.direct_jobs&label=offres%20directes&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n[![Employeurs](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.employers&label=employeurs&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n[![Sources](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.sources&label=sources&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n[![Avec salaire](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.with_salary&label=avec%20salaire%20affich%C3%A9&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n[![Télétravail](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.remote&label=t%C3%A9l%C3%A9travail&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n[![Géocodées](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.job-ka.com%2Fapi%2Fstats&query=%24.geocoded&label=g%C3%A9ocod%C3%A9es&color=0c8599&style=flat-square)](https://www.job-ka.com/api/stats)\n\n![Python](https://img.shields.io/badge/Python-FastAPI-3776ab?style=flat-square&logo=python&logoColor=white)\n![FastAPI](https://img.shields.io/badge/FastAPI-API%20%2B%20SSR-009688?style=flat-square&logo=fastapi&logoColor=white)\n![React](https://img.shields.io/badge/React-18%20%2B%20Vite-61dafb?style=flat-square&logo=react&logoColor=black)\n![TypeScript](https://img.shields.io/badge/TypeScript-frontend-141814?style=flat-square&logo=typescript)\n![SQLite](https://img.shields.io/badge/SQLite-diff%20engine-141814?style=flat-square&logo=sqlite)\n![MapLibre](https://img.shields.io/badge/carte-MapLibre-396cb2?style=flat-square)\n![pytest](https://img.shields.io/badge/pytest-74%20tests%20hors%20ligne-141814?style=flat-square&logo=pytest)\n![ngrok](https://img.shields.io/badge/tunnel-ngrok-140a3c?style=flat-square&logo=ngrok)\n![Groupe KA](https://img.shields.io/badge/Groupe-KA-b7f000?style=flat-square)\n![remote-first](https://img.shields.io/badge/remote--first-spbgit-24292f?style=flat-square&logo=git&logoColor=white)\n\n
\n\nLes grands sites d'emploi mélangent annonces payantes, doublons repostés et offres mortes. L'offre originale — celle de la page carrière de l'employeur — est la seule source fiable. **Job·Ka** se branche **directement sur les systèmes de recrutement (ATS)** des employeurs québécois : **969 connecteurs employeurs directs** sur **21 plateformes ATS** (Workday, SmartRecruiters, Dayforce, Workable, Workland/réseau Atlas, SuccessFactors, Njoyn, Greenhouse, WP Job Manager, Lever, Breezy, Ashby, Recruitee, BambooHR, Taleo Business Edition, Taleo, Digital Recruiters, iCIMS, UltiPro/UKG, ADP WFN + sites carrières maison), complétés par **3 portails publics** (Guichet-Emplois, Jobillico, Espresso-Jobs) — l'offre directe gagne toujours.\n\nRésultat en base (au 2026-08-24) : **21 492 offres actives** de **1 653 employeurs** (963 sources), dont **19 856 offres directes** et **6 856 avec salaire affiché** (moyenne 62 283 $/an) — salaires **normalisés en $/h et $/an**, jamais inventés. Chaque offre est traçable à sa source et la candidature se fait chez l'employeur. Pour qui ? Les chercheurs d'emploi québécois qui veulent l'inventaire réel, à jour et transparent, des postes affichés dans la province — resynchronisé jour et nuit.\n\n## Chiffres clés (live sur [/api/stats](https://www.job-ka.com/api/stats), vérifiés au 2026-08-24)\n\n| Métrique | Valeur |\n|---|---|\n| Offres actives | **21 492** (98 doublons inter-sources masqués, 227 en quarantaine qualité) |\n| Offres directes (pages carrières / ATS) | **19 856** — de 1 048 employeurs directs |\n| Offres portails (complément) | 1 863 |\n| Employeurs | **1 653** |\n| Sources actives | **963** (registre : 973 entrées ; 969 employeurs directs connectés via 21 plateformes ATS + 3 portails + dépôt direct) |\n| Avec salaire affiché | **6 856** — salaire moyen affiché ≈ **62 283 $/an** |\n| Télétravail | **1 013** |\n| Offres géocodées (carte) | **16 303** |\n| Top villes | Montréal 7 645 · Québec 3 637 · Dorval 734 · Laval 733 |\n| Top catégories | TI 2 699 · Santé 2 302 · Service à la clientèle 1 380 · Ventes 1 297 |\n\n## Visite guidée\n\n
\n \n \n \n \n
\"Accueil
Accueil — recherche d'offres (desktop)
\"Accueil
Accueil — mobile
\n\n### Guide pas à pas (« Utiliser le site en 4 étapes », depuis [/doc](https://www.job-ka.com/doc/))\n\n","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbp2tN36SYWP1GxTGH3","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PcjzS5cduFwKSMvjsNFmp1","name":"Bash","input":{"command":"find /Users/simon-pierreboucher/apps/job-ka -maxdepth 2 -type d | grep -v node_modules | grep -v .git | head -40","description":"Show directory tree 2 levels deep"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3157,"cache_creation_input_tokens":18121,"cache_read_input_tokens":15199,"cache_creation":{"ephemeral_5m_input_tokens":18121,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"8f0cb2e1-020a-4a1c-94b5-1529139c435c","request_id":"req_011CePbp1NaZmZVrQ2Kk2MQu"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PcjzS5cduFwKSMvjsNFmp1","type":"tool_result","content":"/Users/simon-pierreboucher/apps/job-ka\n/Users/simon-pierreboucher/apps/job-ka/frontend\n/Users/simon-pierreboucher/apps/job-ka/.pytest_cache\n/Users/simon-pierreboucher/apps/job-ka/tests\n/Users/simon-pierreboucher/apps/job-ka/.claude\n/Users/simon-pierreboucher/apps/job-ka/docs\n/Users/simon-pierreboucher/apps/job-ka/.venv\n/Users/simon-pierreboucher/apps/job-ka/scripts\n/Users/simon-pierreboucher/apps/job-ka/data\n/Users/simon-pierreboucher/apps/job-ka/jobka\n/Users/simon-pierreboucher/apps/job-ka/frontend/dist\n/Users/simon-pierreboucher/apps/job-ka/frontend/public\n/Users/simon-pierreboucher/apps/job-ka/frontend/src\n/Users/simon-pierreboucher/apps/job-ka/.pytest_cache/v\n/Users/simon-pierreboucher/apps/job-ka/tests/__pycache__\n/Users/simon-pierreboucher/apps/job-ka/tests/fixtures\n/Users/simon-pierreboucher/apps/job-ka/docs/screenshots\n/Users/simon-pierreboucher/apps/job-ka/docs/connecteurs\n/Users/simon-pierreboucher/apps/job-ka/.venv/bin\n/Users/simon-pierreboucher/apps/job-ka/.venv/include\n/Users/simon-pierreboucher/apps/job-ka/.venv/lib\n/Users/simon-pierreboucher/apps/job-ka/.venv/share\n/Users/simon-pierreboucher/apps/job-ka/scripts/__pycache__\n/Users/simon-pierreboucher/apps/job-ka/jobka/connectors\n/Users/simon-pierreboucher/apps/job-ka/jobka/__pycache__","is_error":false}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"3b37b249-33d5-4ee4-a238-9ba967f97b75","timestamp":"2026-08-25T14:10:41.375Z","tool_use_result":{"stdout":"/Users/simon-pierreboucher/apps/job-ka\n/Users/simon-pierreboucher/apps/job-ka/frontend\n/Users/simon-pierreboucher/apps/job-ka/.pytest_cache\n/Users/simon-pierreboucher/apps/job-ka/tests\n/Users/simon-pierreboucher/apps/job-ka/.claude\n/Users/simon-pierreboucher/apps/job-ka/docs\n/Users/simon-pierreboucher/apps/job-ka/.venv\n/Users/simon-pierreboucher/apps/job-ka/scripts\n/Users/simon-pierreboucher/apps/job-ka/data\n/Users/simon-pierreboucher/apps/job-ka/jobka\n/Users/simon-pierreboucher/apps/job-ka/frontend/dist\n/Users/simon-pierreboucher/apps/job-ka/frontend/public\n/Users/simon-pierreboucher/apps/job-ka/frontend/src\n/Users/simon-pierreboucher/apps/job-ka/.pytest_cache/v\n/Users/simon-pierreboucher/apps/job-ka/tests/__pycache__\n/Users/simon-pierreboucher/apps/job-ka/tests/fixtures\n/Users/simon-pierreboucher/apps/job-ka/docs/screenshots\n/Users/simon-pierreboucher/apps/job-ka/docs/connecteurs\n/Users/simon-pierreboucher/apps/job-ka/.venv/bin\n/Users/simon-pierreboucher/apps/job-ka/.venv/include\n/Users/simon-pierreboucher/apps/job-ka/.venv/lib\n/Users/simon-pierreboucher/apps/job-ka/.venv/share\n/Users/simon-pierreboucher/apps/job-ka/scripts/__pycache__\n/Users/simon-pierreboucher/apps/job-ka/jobka/connectors\n/Users/simon-pierreboucher/apps/job-ka/jobka/__pycache__","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"f3443907-a8d3-4a5f-b8b6-00c95bd7e6c1","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"c893cd01-e297-4d0a-b79f-913f97be9bbc","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":137,"estimated_tokens_delta":87,"uuid":"721c462c-96bd-45fe-a429-12268f4537de","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbpmK1f86P1B6JL276o","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISmQQKpwEIERgCKkDSMgw08LxIAAk9O+Pd/CbFKQZJwvNwPSUurQU5ZBssVhuzClI+WlGRlWAIOXSUFWF1JFic/dm06OVRAw82ug5SMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAHoxLbUBrABAhIM4pq2HyIOhGgalOKkGgx2o+cPwQFrnq7d/LYiMJIJGrY6BVMMw5/RBKyuNLM6I2TfBdUNvPrnWyWxdl3H1Zbfa5S3X4utVneR7NrxLyqeAo5N+btPM09JLzlpQdSk8sBEOflVlBIA+OzwR9bt8PnKMWFjfBFoTsxaoXgTP13LfJxTiyNLh0qefX5zHwRQcOn9xdn0rp8UY1J3Bicruqcsui+XekkC6bz/0/HDsoz7idi8nvCDGJXC63FLxL/e6lPFGhHvHOh5x1sFZ9kJLRZ5szZSrz8mAVlcGbBciA/GYwdFh59ejAcdTxBGriP+fxmPqWiCyoCNKRkDf7N0enN0cOccbETy5NJ88S7+98VvMj/z30s+dNy2Ws0A6UsavWTSrBgmzcrjTY7/vU+lI/6Y/umKf8P2TAE2xoydUjSDYTbQcKoRDSpstgna106zA8/VmC//WJArprsSXq+MKVa3ODlklw9+DkLjEJbxquIYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7448,"cache_read_input_tokens":33320,"cache_creation":{"ephemeral_5m_input_tokens":7448,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"e9753a84-4da0-45c0-9c24-e03d4a299df3","request_id":"req_011CePbpjca5n1x7ed8YkzY8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbpmK1f86P1B6JL276o","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Bb1N3enEGoPcaEusaNoTBc","name":"Bash","input":{"command":"ls /Users/simon-pierreboucher/apps/job-ka/jobka /Users/simon-pierreboucher/apps/job-ka/jobka/connectors | head -80 && echo \"---FRONTEND---\" && ls -R /Users/simon-pierreboucher/apps/job-ka/frontend/src | head -60","description":"List jobka package and frontend src"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7448,"cache_read_input_tokens":33320,"cache_creation":{"ephemeral_5m_input_tokens":7448,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"4d5b1bcc-b780-4680-bbd0-71b1e13a45ed","request_id":"req_011CePbpjca5n1x7ed8YkzY8"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Bb1N3enEGoPcaEusaNoTBc","type":"tool_result","content":"/Users/simon-pierreboucher/apps/job-ka/jobka:\n__init__.py\n__pycache__\nauth.py\nconnectors\ndb.py\ndedup.py\nfixtures.py\ngeocode.py\nhubfav.py\ningest.py\nkapdf.py\nnormalize.py\nquality.py\nregions.py\nschema.py\nseo.py\nstatsdash.py\nweb.py\n\n/Users/simon-pierreboucher/apps/job-ka/jobka/connectors:\n__init__.py\n__pycache__\n_jsonld.py\n_resilient.py\na2design.py\nabbott.py\nabbvie.py\nabinbev.py\nabove_flights.py\nabritek.py\nac780.py\nacadierouge.py\naccediannetworks.py\naccenture.py\naccorhotel.py\nacuity_insights.py\nacumatica.py\nadaptivefinancialconsulting.py\nadeleexpressfr.py\nadfdiesel.py\nadgagroupconsultantsinc1.py\nadi_albert_davidson_industries.py\nadoc_tm.py\nadp.py\nadvanceauto.py\nadvens.py\naecom2.py\nafg.py\nafrica_moko.py\nagapyo.py\nagence_carrieres.py\nagencemaplrinc.py\nagglobeservices.py\nagiledss.py\nagnico_eagle.py\nagropur.py\nairbus.py\nairlabinc.py\nairliquidehr.py\najholdingsltd.py\najwalter.py\nalan.py\nalayacare.py\nalcoa.py\naleanzarecrutementdurable.py\naliaxis.py\nalimentiv_2.py\nallegion.py\nalliedreit.py\nallredi.py\nallstate.py\nallucent.py\naloyoga.py\nalphanumeric_systems.py\nalsglobal.py\nalsorecrutement.py\nalstom.py\naltasciences.py\nalterra.py\n---FRONTEND---\nApp.tsx\napi.ts\ncomponents\nfavorites.tsx\nka\nmain.tsx\npages\nstyles.css\n\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/components:\nJobCard.tsx\n\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka:\nGroupeKaBadge.tsx\nKaFooter.tsx\necosystem.json\nstats\ntokens.css\n\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/stats:\nSPEC.md\nkacharts.tsx\nkapdf.py\n\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages:\nContact.tsx\nEmployeurs.tsx\nFavoris.tsx\nHome.tsx\nJob.tsx\nMapPage.tsx\nSources.tsx\nStats.tsx\nVille.tsx","is_error":false}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"4848c8aa-ece0-46bd-ac53-810fe8a9a14e","timestamp":"2026-08-25T14:10:50.143Z","tool_use_result":{"stdout":"/Users/simon-pierreboucher/apps/job-ka/jobka:\n__init__.py\n__pycache__\nauth.py\nconnectors\ndb.py\ndedup.py\nfixtures.py\ngeocode.py\nhubfav.py\ningest.py\nkapdf.py\nnormalize.py\nquality.py\nregions.py\nschema.py\nseo.py\nstatsdash.py\nweb.py\n\n/Users/simon-pierreboucher/apps/job-ka/jobka/connectors:\n__init__.py\n__pycache__\n_jsonld.py\n_resilient.py\na2design.py\nabbott.py\nabbvie.py\nabinbev.py\nabove_flights.py\nabritek.py\nac780.py\nacadierouge.py\naccediannetworks.py\naccenture.py\naccorhotel.py\nacuity_insights.py\nacumatica.py\nadaptivefinancialconsulting.py\nadeleexpressfr.py\nadfdiesel.py\nadgagroupconsultantsinc1.py\nadi_albert_davidson_industries.py\nadoc_tm.py\nadp.py\nadvanceauto.py\nadvens.py\naecom2.py\nafg.py\nafrica_moko.py\nagapyo.py\nagence_carrieres.py\nagencemaplrinc.py\nagglobeservices.py\nagiledss.py\nagnico_eagle.py\nagropur.py\nairbus.py\nairlabinc.py\nairliquidehr.py\najholdingsltd.py\najwalter.py\nalan.py\nalayacare.py\nalcoa.py\naleanzarecrutementdurable.py\naliaxis.py\nalimentiv_2.py\nallegion.py\nalliedreit.py\nallredi.py\nallstate.py\nallucent.py\naloyoga.py\nalphanumeric_systems.py\nalsglobal.py\nalsorecrutement.py\nalstom.py\naltasciences.py\nalterra.py\n---FRONTEND---\nApp.tsx\napi.ts\ncomponents\nfavorites.tsx\nka\nmain.tsx\npages\nstyles.css\n\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/components:\nJobCard.tsx\n\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka:\nGroupeKaBadge.tsx\nKaFooter.tsx\necosystem.json\nstats\ntokens.css\n\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/ka/stats:\nSPEC.md\nkacharts.tsx\nkapdf.py\n\n/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages:\nContact.tsx\nEmployeurs.tsx\nFavoris.tsx\nHome.tsx\nJob.tsx\nMapPage.tsx\nSources.tsx\nStats.tsx\nVille.tsx","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbpmK1f86P1B6JL276o","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01M8rw6koRMg8Amw5n4XUEoV","name":"Bash","input":{"command":"wc -l /Users/simon-pierreboucher/apps/job-ka/jobka/*.py /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/*.py 2>/dev/null | tail -50","description":"Line counts of python modules"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7448,"cache_read_input_tokens":33320,"cache_creation":{"ephemeral_5m_input_tokens":7448,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"d864063d-9ee9-4960-9842-6d6e599cf1ff","request_id":"req_011CePbpjca5n1x7ed8YkzY8"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01M8rw6koRMg8Amw5n4XUEoV","type":"tool_result","content":" 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/voxdata.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wabtec.py\n 17 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wafna.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/walmart.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/warnerbros.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/waterwellirrigation.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/waveshaperai.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wcginternationalconsultantsltd.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wealthy_recruiting.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/webprops.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/weir.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/weloglobal.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/westcliff.py\n 15 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/westgroupe.py\n 17 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/westjet.py\n 17 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wikoff.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wingtra_2.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wisemotion.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wolseley.py\n 84 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/workable.py\n 239 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/workday.py\n 15 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/workinquebec.py\n 158 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/workland.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/workwear.py\n 125 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wpjobmanager.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wppmedia.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wundermanthompson.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wyze_labs.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x1pertinent.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x1pertinentinc.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x2k.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x2kvegas.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x3m.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x49_solutions_1.py\n 17 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x4refuel.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x4th_day_trucking.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/xplornet.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/xsolla.py\n 15 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/xtltransportlogistics.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/yaksa.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/youset.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zayataroma.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zebra.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zepinc.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/ziptrekeco.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zoetis.py\n 95 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zoho_recruit.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zoll.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zscaler.py\n 28882 total","is_error":false}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"8d06c04f-66e1-4377-98cd-04efdd844523","timestamp":"2026-08-25T14:10:51.293Z","tool_use_result":{"stdout":" 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/voxdata.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wabtec.py\n 17 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wafna.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/walmart.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/warnerbros.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/waterwellirrigation.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/waveshaperai.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wcginternationalconsultantsltd.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wealthy_recruiting.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/webprops.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/weir.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/weloglobal.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/westcliff.py\n 15 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/westgroupe.py\n 17 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/westjet.py\n 17 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wikoff.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wingtra_2.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wisemotion.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wolseley.py\n 84 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/workable.py\n 239 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/workday.py\n 15 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/workinquebec.py\n 158 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/workland.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/workwear.py\n 125 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wpjobmanager.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wppmedia.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wundermanthompson.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/wyze_labs.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x1pertinent.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x1pertinentinc.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x2k.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x2kvegas.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x3m.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x49_solutions_1.py\n 17 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x4refuel.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/x4th_day_trucking.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/xplornet.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/xsolla.py\n 15 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/xtltransportlogistics.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/yaksa.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/youset.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zayataroma.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zebra.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zepinc.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/ziptrekeco.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zoetis.py\n 95 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zoho_recruit.py\n 18 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zoll.py\n 16 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/zscaler.py\n 28882 total","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"ff0f3d6d-e845-4ad1-908e-a565de90afe4","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"da463962-1f23-4da0-a1f0-451fbf9b2d97","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":150,"estimated_tokens_delta":100,"uuid":"1d5b05be-d63b-40f1-bb5d-8b6b8e319385","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":250,"estimated_tokens_delta":100,"uuid":"33ed5bc2-e025-45c9-88a1-6d83fe8faaab","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbqVRpUyDFgkTpwwhzx","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS9AYKpwEIERgCKkACgyx/f/X0IZNV28MZF2z6dBX2ILFaUCn9P2PZrvqjIjrQLygvAPubXntttVzlcJN9T/XBfRYTPnVmCfxU8wt9Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAH2xLbUBrABAhIM2HF9g5fi59d7D/YLGgwM/Ji2gFUHmzZxIlYiMN/f0aamG/RBvu1J96l049Tsy70jZZpquOfYEQtsF5DrKU31YpV6aQjUl27JgtFMEyr5BN2YXzNrfuVJIt7Y/5emzxaLEvUXSbcNuauOIjg+x5UsepQeiSctpBZ+ozGA+8CElHVYAOECkEvSNxl/alCbI5BHFF1nrezt5df5bSYn+lu+QVV8bkPN2CFeAnHg2NP/7J8PS0sI5LXLuUCb/6g7TsSyoYEPjim1UVAr0lF1cFkDYRy+XeP0qzYm2gXLeqGqMkinODv29h6JmeSxYeWzuu9JT4Q1BMNydaAy3/yzaQ2FdZkx5Jck56CFfyJngaJevNAZHX5XkFgwBTPFyBGOv6dHXC6q97sQKvnaO+7eA9vn/pP+XZiBzQNwiPhF75uZHprjW1ic5HWsRlQPA/1IXudo+hR6143q7ZuVDJMQxsrmJUgRAPP3XlqHFvbLEMyd2aUp1Uq7pVJAQB0gdgCMQePxZ48/DAReu1x+1a/tNqEES5SPLI79oRzpIU4AqIzoxNYyQoAyhYF+DOYC/SFe7CwoF6u3qnzA8rhKUlVd1eDsKKEIX9eunDxI5X6hTv5BDNAhKtZ56HuYn+sOucmXnnvb9GG37VsjFCdiXjfmNGQ9RVfGsE/b/MqwuRDrIvwPqRhbr/YKE8Y+vO6EYLK5aD1DHv+JEDJuXXhIX2d8RzkXpseUHIYGebm148Da87RskX7di7Rg10z4kRO9h7Q01cgZiHC6G7nvrug64d9/qYo+YudnagA32fyuaJVAlWSwOPZYyz49TEBxXLWjzYih2bUG6vVSl9feMK1TYEqUP3P/8N2g/C8CH24lus2EEBWP55uaNZJzLIwXeBx2bni1KDpWhO2dn07oc2i2XYn8NTz4v/Ij7eF7vAf2f2hBCETuU5kR7dTk4YL+OhgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3445,"cache_read_input_tokens":40768,"cache_creation":{"ephemeral_5m_input_tokens":3445,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"9591feba-db67-4a02-b3fd-e1ca74249225","request_id":"req_011CePbqTvH6Y3eJsg4nPi8i"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbqVRpUyDFgkTpwwhzx","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS8wMKqAEIERgCKkAXZ2/HxMeL7uPNOHJMjAPqHc4tgZVMI84WwfnSJDou4y69K7JtekcSyiRkxHgseEIlRIjGj5L3X1PDTn29WhQoMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQHSFahiYOz86M3z97Uqk2VogBAagB9sS21AawAQISDIZ0OuTFRkrF91GbMxoM/FrxKDn/toehsgt1IjD4RaXCccDIyJRiDlzaBTIiiQa/kjkzcoRQRJfb6sWEkB6oSOep+VT2ZSwYXVJGv2Uq9wGlWOYA7CqyQeiX1lZp8d2XrliRHzQiWDGXiU6Jhu+huBMJ7VEqhXGwhDGWFJS/IuY8+S7BlAjB8If4aIzfUBoRTV6gdqwufnlaSjGhUGoz5RKFfZE0u0BJL4wluBelgBqHUjuFD9OUgrA4wErgVXxRETr9DW2gmWyOVbthbk5hyAeGCjqCFiVxFZm0tNYgjoHrbD2Hfxu1oR30TW5gGOcD2zBQzuIjk4u2L2BBP1K65tZuZMZew6IAU2G3UeTFGJJEukdjxYsuZgqrAc2FTZ/NFIQllkXe5oSmS4Zs1vH2emUyX0tYmzAYiUtu0OaEtuLC6bYHT1YlGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3445,"cache_read_input_tokens":40768,"cache_creation":{"ephemeral_5m_input_tokens":3445,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"50287e3f-8a6a-473d-9993-a238e56e8c68","request_id":"req_011CePbqTvH6Y3eJsg4nPi8i"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbqVRpUyDFgkTpwwhzx","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018Gnzj6BkibMbTLwWn5UJ6a","name":"Bash","input":{"command":"wc -l /Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py /Users/simon-pierreboucher/apps/job-ka/jobka/schema.py /Users/simon-pierreboucher/apps/job-ka/jobka/ingest.py /Users/simon-pierreboucher/apps/job-ka/jobka/web.py /Users/simon-pierreboucher/apps/job-ka/jobka/db.py /Users/simon-pierreboucher/apps/job-ka/jobka/quality.py /Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx /Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/__init__.py","description":"Line counts of core files"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3445,"cache_read_input_tokens":40768,"cache_creation":{"ephemeral_5m_input_tokens":3445,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"14ba794b-5ba9-4997-9cab-dc76ccb6f71d","request_id":"req_011CePbqTvH6Y3eJsg4nPi8i"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbqVRpUyDFgkTpwwhzx","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VtCxsYWRg2c48QgsvoXT8M","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/schema.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3445,"cache_read_input_tokens":40768,"cache_creation":{"ephemeral_5m_input_tokens":3445,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"47817634-d43b-41ee-b46a-e5528e6bb111","request_id":"req_011CePbqTvH6Y3eJsg4nPi8i"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VtCxsYWRg2c48QgsvoXT8M","type":"tool_result","content":"1\t# =============================================================================\n2\t# Job·Ka — Groupe KA\n3\t# Auteur : Simon-Pierre Boucher\n4\t# Contact : contact@spboucher.ai\n5\t# Fichier : jobka/schema.py\n6\t# Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale\n7\t# Créé : 2026-08-17 Modifié : 2026-08-17\n8\t# =============================================================================\n9\t\"\"\"Schéma standard d'une offre d'emploi (JobPosting) et normalisation.\n10\t\n11\tChaque connecteur, peu importe l'employeur ou l'ATS source, doit produire des\n12\tobjets `JobPosting` conformes à ce schéma. La méthode `finalize()` applique\n13\tensuite la couche de normalisation commune (jobka/normalize.py) : dates ISO,\n14\tsalaires numériques + conversions $/h et $/an, mode de travail, type d'emploi,\n15\tcatégorie… — les connecteurs restent simples et remplissent les champs bruts.\n16\t\"\"\"\n17\tfrom __future__ import annotations\n18\t\n19\timport hashlib\n20\timport json\n21\tfrom dataclasses import dataclass, field, asdict\n22\t\n23\tfrom .normalize import ( # ré-exportés pour les connecteurs\n24\t canonical_city,\n25\t categorize,\n26\t clean_html,\n27\t clean_text,\n28\t detect_language,\n29\t extract_benefits,\n30\t is_quebec_location,\n31\t parse_date,\n32\t parse_employment_type,\n33\t parse_location,\n34\t parse_salary,\n35\t parse_work_mode,\n36\t salary_from_text,\n37\t salary_to_hourly,\n38\t salary_to_yearly,\n39\t sanitize_salary,\n40\t strip_accents,\n41\t)\n42\t\n43\t__all__ = [\n44\t \"JobPosting\", \"categorize\", \"clean_html\", \"clean_text\",\n45\t \"is_quebec_location\", \"parse_date\", \"parse_employment_type\",\n46\t \"parse_location\", \"parse_salary\", \"parse_work_mode\", \"strip_accents\",\n47\t]\n48\t\n49\t\n50\t@dataclass\n51\tclass JobPosting:\n52\t \"\"\"Offre d'emploi standardisée Job·Ka.\"\"\"\n53\t\n54\t source: str # id du connecteur (voir data/sources.json)\n55\t external_id: str # identifiant chez l'employeur/ATS\n56\t url: str # lien direct vers l'offre — zéro boîte noire\n57\t employer: str = \"\" # nom de l'entreprise\n58\t title: str = \"\" # titre du poste\n59\t description: str = \"\" # texte complet (HTML nettoyé)\n60\t address: str = \"\" # adresse du lieu de travail si disponible\n61\t city: str = \"\" # Montréal, Québec, Sherbrooke…\n62\t region: str = \"\" # région administrative / « Québec »\n63\t postal_code: str = \"\"\n64\t location_label: str = \"\" # libellé de lieu original (brut)\n65\t work_mode: str | None = None # presentiel | hybride | teletravail\n66\t employment_type: str | None = None # temps_plein | temps_partiel |\n67\t # contractuel | stage | saisonnier\n68\t salary_min: float | None = None # nombres, jamais des strings\n69\t salary_max: float | None = None\n70\t salary_unit: str | None = None # hour | day | week | biweek | month | year\n71\t salary_label: str = \"\" # texte original (ex. « 25 $ à 30 $/h »)\n72\t benefits: list[str] = field(default_factory=list)\n73\t requirements: dict = field(default_factory=dict) # scolarité, expérience, langues\n74\t date_posted: str | None = None # ISO 8601\n75\t date_deadline: str | None = None # ISO 8601 (date limite pour postuler)\n76\t category: str = \"\" # taxonomie interne (TI, Santé, …)\n77\t ats: str = \"\" # workday | lever | greenhouse | custom…\n78\t details: dict = field(default_factory=dict) # champs structurés (sparse)\n79\t lat: float | None = None\n80\t lng: float | None = None\n81\t company_logo: str = \"\" # URL du logo employeur (si exposé)\n82\t language: str = \"\" # fr | en | bilingue (\"\" = indéterminée)\n83\t apply_url: str = \"\" # lien de candidature directe (≠ url fiche)\n84\t\n85\t @property\n86\t def uid(self) -> str:\n87\t return f\"{self.source}:{self.external_id}\"\n88\t\n89\t def content_hash(self) -> str:\n90\t \"\"\"Hash du contenu pour la détection de changements (pseudo-webhook).\"\"\"\n91\t payload = asdict(self)\n92\t blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)\n93\t return hashlib.sha256(blob.encode(\"utf-8\")).hexdigest()\n94\t\n95\t # Conversions dérivées — calculées à la volée, PAS dans content_hash\n96\t # (elles découlent de salary_min/max/unit, les stocker dans le hash ne\n97\t # ferait que dupliquer l'information).\n98\t def salary_year_min(self) -> float | None:\n99\t return salary_to_yearly(self.salary_min, self.salary_unit)\n100\t\n101\t def salary_year_max(self) -> float | None:\n102\t return salary_to_yearly(self.salary_max, self.salary_unit)\n103\t\n104\t def salary_hour_min(self) -> float | None:\n105\t return salary_to_hourly(self.salary_min, self.salary_unit)\n106\t\n107\t def salary_hour_max(self) -> float | None:\n108\t return salary_to_hourly(self.salary_max, self.salary_unit)\n109\t\n110\t def finalize(self) -> \"JobPosting\":\n111\t \"\"\"Applique la normalisation commune. Appelé par le pipeline d'ingestion.\n112\t\n113\t Idempotent ; ne remplace jamais une valeur explicite du connecteur.\n114\t \"\"\"\n115\t self.title = clean_text(self.title)\n116\t self.employer = clean_text(self.employer)\n117\t self.description = clean_text(self.description) if \"\\n\" not in self.description \\\n118\t else self.description.strip()\n119\t\n120\t # lieu : compléter ville/région/code postal depuis le libellé brut,\n121\t # puis canonicaliser la ville (« Montreal » -> « Montréal »)\n122\t if self.location_label and not (self.city and self.region):\n123\t loc = parse_location(self.location_label)\n124\t self.city = self.city or loc[\"city\"]\n125\t self.region = self.region or loc[\"region\"]\n126\t self.postal_code = self.postal_code or loc[\"postal_code\"]\n127\t self.city = canonical_city(self.city)\n128\t # région administrative (17 régions, répertoire MAMH embarqué) ;\n129\t # repli : « Québec » (province) quand seule l'appartenance QC est sûre\n130\t if self.city and (not self.region or self.region == \"Québec\"):\n131\t from .regions import region_for_city\n132\t self.region = region_for_city(self.city) or self.region\n133\t if self.city and not self.region and is_quebec_location(self.city):\n134\t self.region = \"Québec\"\n135\t\n136\t # salaire : parser le libellé si le connecteur n'a pas de valeurs numériques\n137\t if self.salary_min is None and self.salary_label:\n138\t lo, hi, unit = parse_salary(self.salary_label)\n139\t self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit\n140\t if self.salary_min is None and self.description:\n141\t # mention salariale dans la description (fréquent au QC) — avec\n142\t # garde-fou de contexte (financement, remboursements ≠ salaire)\n143\t lo, hi, unit, label = salary_from_text(self.description)\n144\t if lo is not None:\n145\t self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit\n146\t self.salary_label = self.salary_label or label\n147\t # bornes de plausibilité aussi sur les valeurs STRUCTURÉES des ATS\n148\t # (unités mal étiquetées : « 75 000 $/mois » qui est un annuel…)\n149\t self.salary_min, self.salary_max, self.salary_unit = sanitize_salary(\n150\t self.salary_min, self.salary_max, self.salary_unit)\n151\t if self.salary_min is None:\n152\t self.salary_unit = None\n153\t\n154\t # dates : accepter epoch/textuel/relatif -> ISO\n155\t if self.date_posted is not None:\n156\t self.date_posted = parse_date(self.date_posted)\n157\t if self.date_deadline is not None:\n158\t self.date_deadline = parse_date(self.date_deadline)\n159\t\n160\t # mode de travail / type d'emploi : depuis le libellé de lieu, le titre\n161\t # puis la description — première réponse trouvée, jamais de défaut\n162\t if self.work_mode is None:\n163\t for texte in (self.location_label, self.title, self.description[:500]):\n164\t self.work_mode = parse_work_mode(texte)\n165\t if self.work_mode:\n166\t break\n167\t if self.employment_type is None:\n168\t for texte in (self.details.get(\"employment_label\", \"\"), self.title,\n169\t self.description[:500]):\n170\t self.employment_type = parse_employment_type(str(texte))\n171\t if self.employment_type:\n172\t break\n173\t\n174\t if not self.category:\n175\t self.category = categorize(self.title, self.description)\n176\t\n177\t # langue de l'offre : valeur source si fournie, sinon détection légère\n178\t if not self.language:\n179\t self.language = detect_language(self.title, self.description)\n180\t elif self.language not in (\"fr\", \"en\", \"bilingue\"):\n181\t lang = str(self.language).lower()[:2]\n182\t self.language = {\"fr\": \"fr\", \"en\": \"en\"}.get(lang, \"\")\n183\t\n184\t # avantages : blocs structurés « Avantages / Benefits » des descriptions\n185\t if not self.benefits and self.description:\n186\t self.benefits = extract_benefits(self.description)\n187\t\n188\t # coordonnées fournies par la source : rejeter tout point hors de la\n189\t # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra\n190\t # le relais sur l'adresse/la ville\n191\t if self.lat is not None and self.lng is not None:\n192\t if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):\n193\t self.lat = self.lng = None\n194\t return self\n195\t"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"586a8b52-512f-4b21-8b47-b474ffabe944","timestamp":"2026-08-25T14:11:05.350Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/schema.py","content":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/schema.py\n# Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale\n# Créé : 2026-08-17 Modifié : 2026-08-17\n# =============================================================================\n\"\"\"Schéma standard d'une offre d'emploi (JobPosting) et normalisation.\n\nChaque connecteur, peu importe l'employeur ou l'ATS source, doit produire des\nobjets `JobPosting` conformes à ce schéma. La méthode `finalize()` applique\nensuite la couche de normalisation commune (jobka/normalize.py) : dates ISO,\nsalaires numériques + conversions $/h et $/an, mode de travail, type d'emploi,\ncatégorie… — les connecteurs restent simples et remplissent les champs bruts.\n\"\"\"\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nfrom dataclasses import dataclass, field, asdict\n\nfrom .normalize import ( # ré-exportés pour les connecteurs\n canonical_city,\n categorize,\n clean_html,\n clean_text,\n detect_language,\n extract_benefits,\n is_quebec_location,\n parse_date,\n parse_employment_type,\n parse_location,\n parse_salary,\n parse_work_mode,\n salary_from_text,\n salary_to_hourly,\n salary_to_yearly,\n sanitize_salary,\n strip_accents,\n)\n\n__all__ = [\n \"JobPosting\", \"categorize\", \"clean_html\", \"clean_text\",\n \"is_quebec_location\", \"parse_date\", \"parse_employment_type\",\n \"parse_location\", \"parse_salary\", \"parse_work_mode\", \"strip_accents\",\n]\n\n\n@dataclass\nclass JobPosting:\n \"\"\"Offre d'emploi standardisée Job·Ka.\"\"\"\n\n source: str # id du connecteur (voir data/sources.json)\n external_id: str # identifiant chez l'employeur/ATS\n url: str # lien direct vers l'offre — zéro boîte noire\n employer: str = \"\" # nom de l'entreprise\n title: str = \"\" # titre du poste\n description: str = \"\" # texte complet (HTML nettoyé)\n address: str = \"\" # adresse du lieu de travail si disponible\n city: str = \"\" # Montréal, Québec, Sherbrooke…\n region: str = \"\" # région administrative / « Québec »\n postal_code: str = \"\"\n location_label: str = \"\" # libellé de lieu original (brut)\n work_mode: str | None = None # presentiel | hybride | teletravail\n employment_type: str | None = None # temps_plein | temps_partiel |\n # contractuel | stage | saisonnier\n salary_min: float | None = None # nombres, jamais des strings\n salary_max: float | None = None\n salary_unit: str | None = None # hour | day | week | biweek | month | year\n salary_label: str = \"\" # texte original (ex. « 25 $ à 30 $/h »)\n benefits: list[str] = field(default_factory=list)\n requirements: dict = field(default_factory=dict) # scolarité, expérience, langues\n date_posted: str | None = None # ISO 8601\n date_deadline: str | None = None # ISO 8601 (date limite pour postuler)\n category: str = \"\" # taxonomie interne (TI, Santé, …)\n ats: str = \"\" # workday | lever | greenhouse | custom…\n details: dict = field(default_factory=dict) # champs structurés (sparse)\n lat: float | None = None\n lng: float | None = None\n company_logo: str = \"\" # URL du logo employeur (si exposé)\n language: str = \"\" # fr | en | bilingue (\"\" = indéterminée)\n apply_url: str = \"\" # lien de candidature directe (≠ url fiche)\n\n @property\n def uid(self) -> str:\n return f\"{self.source}:{self.external_id}\"\n\n def content_hash(self) -> str:\n \"\"\"Hash du contenu pour la détection de changements (pseudo-webhook).\"\"\"\n payload = asdict(self)\n blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)\n return hashlib.sha256(blob.encode(\"utf-8\")).hexdigest()\n\n # Conversions dérivées — calculées à la volée, PAS dans content_hash\n # (elles découlent de salary_min/max/unit, les stocker dans le hash ne\n # ferait que dupliquer l'information).\n def salary_year_min(self) -> float | None:\n return salary_to_yearly(self.salary_min, self.salary_unit)\n\n def salary_year_max(self) -> float | None:\n return salary_to_yearly(self.salary_max, self.salary_unit)\n\n def salary_hour_min(self) -> float | None:\n return salary_to_hourly(self.salary_min, self.salary_unit)\n\n def salary_hour_max(self) -> float | None:\n return salary_to_hourly(self.salary_max, self.salary_unit)\n\n def finalize(self) -> \"JobPosting\":\n \"\"\"Applique la normalisation commune. Appelé par le pipeline d'ingestion.\n\n Idempotent ; ne remplace jamais une valeur explicite du connecteur.\n \"\"\"\n self.title = clean_text(self.title)\n self.employer = clean_text(self.employer)\n self.description = clean_text(self.description) if \"\\n\" not in self.description \\\n else self.description.strip()\n\n # lieu : compléter ville/région/code postal depuis le libellé brut,\n # puis canonicaliser la ville (« Montreal » -> « Montréal »)\n if self.location_label and not (self.city and self.region):\n loc = parse_location(self.location_label)\n self.city = self.city or loc[\"city\"]\n self.region = self.region or loc[\"region\"]\n self.postal_code = self.postal_code or loc[\"postal_code\"]\n self.city = canonical_city(self.city)\n # région administrative (17 régions, répertoire MAMH embarqué) ;\n # repli : « Québec » (province) quand seule l'appartenance QC est sûre\n if self.city and (not self.region or self.region == \"Québec\"):\n from .regions import region_for_city\n self.region = region_for_city(self.city) or self.region\n if self.city and not self.region and is_quebec_location(self.city):\n self.region = \"Québec\"\n\n # salaire : parser le libellé si le connecteur n'a pas de valeurs numériques\n if self.salary_min is None and self.salary_label:\n lo, hi, unit = parse_salary(self.salary_label)\n self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit\n if self.salary_min is None and self.description:\n # mention salariale dans la description (fréquent au QC) — avec\n # garde-fou de contexte (financement, remboursements ≠ salaire)\n lo, hi, unit, label = salary_from_text(self.description)\n if lo is not None:\n self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit\n self.salary_label = self.salary_label or label\n # bornes de plausibilité aussi sur les valeurs STRUCTURÉES des ATS\n # (unités mal étiquetées : « 75 000 $/mois » qui est un annuel…)\n self.salary_min, self.salary_max, self.salary_unit = sanitize_salary(\n self.salary_min, self.salary_max, self.salary_unit)\n if self.salary_min is None:\n self.salary_unit = None\n\n # dates : accepter epoch/textuel/relatif -> ISO\n if self.date_posted is not None:\n self.date_posted = parse_date(self.date_posted)\n if self.date_deadline is not None:\n self.date_deadline = parse_date(self.date_deadline)\n\n # mode de travail / type d'emploi : depuis le libellé de lieu, le titre\n # puis la description — première réponse trouvée, jamais de défaut\n if self.work_mode is None:\n for texte in (self.location_label, self.title, self.description[:500]):\n self.work_mode = parse_work_mode(texte)\n if self.work_mode:\n break\n if self.employment_type is None:\n for texte in (self.details.get(\"employment_label\", \"\"), self.title,\n self.description[:500]):\n self.employment_type = parse_employment_type(str(texte))\n if self.employment_type:\n break\n\n if not self.category:\n self.category = categorize(self.title, self.description)\n\n # langue de l'offre : valeur source si fournie, sinon détection légère\n if not self.language:\n self.language = detect_language(self.title, self.description)\n elif self.language not in (\"fr\", \"en\", \"bilingue\"):\n lang = str(self.language).lower()[:2]\n self.language = {\"fr\": \"fr\", \"en\": \"en\"}.get(lang, \"\")\n\n # avantages : blocs structurés « Avantages / Benefits » des descriptions\n if not self.benefits and self.description:\n self.benefits = extract_benefits(self.description)\n\n # coordonnées fournies par la source : rejeter tout point hors de la\n # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra\n # le relais sur l'adresse/la ville\n if self.lat is not None and self.lng is not None:\n if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):\n self.lat = self.lng = None\n return self\n","numLines":195,"startLine":1,"totalLines":195}}}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018Gnzj6BkibMbTLwWn5UJ6a","type":"tool_result","content":" 744 /Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py\n 194 /Users/simon-pierreboucher/apps/job-ka/jobka/schema.py\n 105 /Users/simon-pierreboucher/apps/job-ka/jobka/ingest.py\n 605 /Users/simon-pierreboucher/apps/job-ka/jobka/web.py\n 439 /Users/simon-pierreboucher/apps/job-ka/jobka/db.py\n 82 /Users/simon-pierreboucher/apps/job-ka/jobka/quality.py\n 41 /Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx\n 151 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx\n 94 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx\n 193 /Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts\n 32 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/__init__.py\n 2680 total","is_error":false}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"4b3f67a5-cae7-4a28-846a-f1c92a99db42","timestamp":"2026-08-25T14:11:05.395Z","tool_use_result":{"stdout":" 744 /Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py\n 194 /Users/simon-pierreboucher/apps/job-ka/jobka/schema.py\n 105 /Users/simon-pierreboucher/apps/job-ka/jobka/ingest.py\n 605 /Users/simon-pierreboucher/apps/job-ka/jobka/web.py\n 439 /Users/simon-pierreboucher/apps/job-ka/jobka/db.py\n 82 /Users/simon-pierreboucher/apps/job-ka/jobka/quality.py\n 41 /Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx\n 151 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx\n 94 /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx\n 193 /Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts\n 32 /Users/simon-pierreboucher/apps/job-ka/jobka/connectors/__init__.py\n 2680 total","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"45aaa546-7634-490e-86ed-fe0a1104c963","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"15519221-7b2e-4580-b158-2f8b2a3c353a","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":135,"estimated_tokens_delta":85,"uuid":"00e3de4f-d997-4a83-a8aa-b10f9cc1766b","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbrZbNe14xhqPQqLzHZ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISlAQKpwEIERgCKkBl7skdgDvivmOQ8S1hPakIr+vvfqY5d42vKmyQlSxVenKQIiEkaYbceeHy/CfDGugQUZ8JHWDCoVL4tBbCavGBMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAGExbbUBrABAhIMCKYm3KpxytPeZ8bCGgzPILUrS77V3o8FJ14iMAeoj6m1ko8Xckg7UvZqy0zoMA25W9KLGSgjm02pFtjLQxfLBfWJqpiy32N4fB0QbiqZAvLWmp6CLLa0cBQgPBwQcZylk8+lcZUTwxDW+sNQ/1bYCweXS4/ZirJyKb66rsS0b5k1CvkrHR9REggnY+RCEfrdRgnQih+MPY2i/rpHsaDFfY/hnU5n8lEtQa5YZZm1mm1nNmSVxJVrwlHpMHwXMHR6auIMrJ9re4KE6eO4IZr1v5nRx6SdtRXfp7Tx4K78/5qnfayuCE+8Paj5bBYT2+dkJIg5wCIi1gvhRRAeHLmIciCR3aFsqQXJPk28tYmFI37nEDDNx03azSmFAtim9AOMc3RE3yVZaTLM0m4wwEOBTCsC9g7mfIyD30cL/XQJxs8ouQV2pVGoIC1anS3xq6vyvU9MaFCsrKFKyvrHogUeyY4VJQe2F/ohGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3775,"cache_creation_input_tokens":1263,"cache_read_input_tokens":44213,"cache_creation":{"ephemeral_5m_input_tokens":1263,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"93c9d36c-6ee5-4db2-8450-e4624b41fb5b","request_id":"req_011CePbrWb3kjAG4GWFhquXy"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbrZbNe14xhqPQqLzHZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013x4PzonwDpYHScZnD4U32t","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3775,"cache_creation_input_tokens":1263,"cache_read_input_tokens":44213,"cache_creation":{"ephemeral_5m_input_tokens":1263,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"a67a9192-b13d-4484-8191-b2de38acd5e9","request_id":"req_011CePbrWb3kjAG4GWFhquXy"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013x4PzonwDpYHScZnD4U32t","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/components/JobCard.tsx\n7\t * Rôle : Carte d'offre dans la liste (titre, employeur, badges, meta,\n8\t * cœur ♥ favoris « Mon univers Ka »)\n9\t * Créé : 2026-08-17 Modifié : 2026-08-23\n10\t * =============================================================================\n11\t */\n12\timport { Link } from \"react-router-dom\";\n13\timport { displayTitle, formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from \"../api\";\n14\timport { FavButton } from \"../favorites\";\n15\t\n16\texport default function JobCard({ job }: { job: Job }) {\n17\t const salary = formatSalary(job);\n18\t return (\n19\t \n20\t \n21\t

{displayTitle(job)}

\n22\t {job.employer}\n23\t {job.city ? — {job.city} : null}\n24\t
\n25\t {job.is_direct !== false && (\n26\t \n30\t Offre directe\n31\t \n32\t )}\n33\t {salary && {salary}}\n34\t {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n35\t {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n36\t {job.category && {job.category}}\n37\t {job.date_posted && Publiée le {formatDate(job.date_posted)}}\n38\t
\n39\t \n40\t );\n41\t}\n42\t"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"e5411c54-b1f2-430a-9533-d65f16df4248","timestamp":"2026-08-25T14:11:17.471Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/components/JobCard.tsx\n * Rôle : Carte d'offre dans la liste (titre, employeur, badges, meta,\n * cœur ♥ favoris « Mon univers Ka »)\n * Créé : 2026-08-17 Modifié : 2026-08-23\n * =============================================================================\n */\nimport { Link } from \"react-router-dom\";\nimport { displayTitle, formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from \"../api\";\nimport { FavButton } from \"../favorites\";\n\nexport default function JobCard({ job }: { job: Job }) {\n const salary = formatSalary(job);\n return (\n \n \n

{displayTitle(job)}

\n {job.employer}\n {job.city ? — {job.city} : null}\n
\n {job.is_direct !== false && (\n \n Offre directe\n \n )}\n {salary && {salary}}\n {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n {job.category && {job.category}}\n {job.date_posted && Publiée le {formatDate(job.date_posted)}}\n
\n \n );\n}\n","numLines":42,"startLine":1,"totalLines":42}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbrZbNe14xhqPQqLzHZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HcQqRZfBtUAgzeAxCTMz7k","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3775,"cache_creation_input_tokens":1263,"cache_read_input_tokens":44213,"cache_creation":{"ephemeral_5m_input_tokens":1263,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"1fbe1b5a-b13c-4593-b598-03d82262aaca","request_id":"req_011CePbrWb3kjAG4GWFhquXy"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbrZbNe14xhqPQqLzHZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HuLGpoHU3qwuVUZEiVaVek","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3775,"cache_creation_input_tokens":1263,"cache_read_input_tokens":44213,"cache_creation":{"ephemeral_5m_input_tokens":1263,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"a6f5e0cc-56ff-412f-8a43-9d49a3695ea0","request_id":"req_011CePbrWb3kjAG4GWFhquXy"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HcQqRZfBtUAgzeAxCTMz7k","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/pages/Job.tsx\n7\t * Rôle : Fiche d'une offre — faits, description, lien vers l'offre\n8\t * originale, cœur ♥ favoris « Mon univers Ka »\n9\t * Créé : 2026-08-17 Modifié : 2026-08-23\n10\t * =============================================================================\n11\t */\n12\timport { useEffect, useState } from \"react\";\n13\timport { Link, useParams } from \"react-router-dom\";\n14\timport { displayTitle, fetchJob, formatDate, formatSalary, Job, LANG_FR, MODE_FR, TYPE_FR } from \"../api\";\n15\timport { FavButton } from \"../favorites\";\n16\t\n17\texport default function JobPage() {\n18\t const { uid = \"\" } = useParams();\n19\t const [job, setJob] = useState(null);\n20\t const [error, setError] = useState(\"\");\n21\t\n22\t useEffect(() => {\n23\t setJob(null);\n24\t fetchJob(uid).then(setJob).catch((e) => setError(String(e)));\n25\t window.scrollTo(0, 0);\n26\t }, [uid]);\n27\t\n28\t if (error) return

Offre introuvable

← Retour aux offres
;\n29\t if (!job) return
Chargement…
;\n30\t\n31\t const salary = formatSalary(job);\n32\t const yearly = job.salary_year_min != null && job.salary_unit !== \"year\"\n33\t ? `≈ ${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`\n34\t : null;\n35\t\n36\t return (\n37\t
\n38\t
\n39\t

← Toutes les offres

\n40\t
\n41\t {job.company_logo && (\n42\t { (e.target as HTMLImageElement).style.display = \"none\"; }}\n47\t />\n48\t )}\n49\t

{displayTitle(job)}

\n50\t \n51\t
\n52\t

\n53\t {job.employer}\n54\t {job.location_label && — {job.location_label}}\n55\t {job.region && job.region !== \"Québec\" && ({job.region})}\n56\t

\n57\t
\n58\t {salary && {salary}{yearly ? ` (${yearly})` : \"\"}}\n59\t {!salary && Salaire non affiché par l'employeur}\n60\t {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n61\t {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n62\t {job.language && {LANG_FR[job.language] ?? job.language}}\n63\t {job.category && {job.category}}\n64\t {job.date_posted && Publiée le {formatDate(job.date_posted)}}\n65\t {job.date_deadline && Date limite : {formatDate(job.date_deadline)}}\n66\t {job.active === 0 && Offre retirée}\n67\t
\n68\t

\n69\t \n70\t Postuler chez {job.employer} ↗\n71\t \n72\t

\n73\t

\n74\t Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka\n75\t n'est pas un intermédiaire, la candidature se fait chez l'employeur.\n76\t

\n77\t {job.benefits && job.benefits.length > 0 && (\n78\t <>\n79\t

Avantages

\n80\t
    \n81\t {job.benefits.map((b, i) =>
  • {b}
  • )}\n82\t
\n83\t \n84\t )}\n85\t {job.description && (\n86\t <>\n87\t

Description du poste

\n88\t
{job.description}
\n89\t \n90\t )}\n91\t
\n92\t
\n93\t );\n94\t}\n95\t"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"7b3c6f22-5ce8-47b5-8645-2af25cf02e8c","timestamp":"2026-08-25T14:11:18.140Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Job.tsx\n * Rôle : Fiche d'une offre — faits, description, lien vers l'offre\n * originale, cœur ♥ favoris « Mon univers Ka »\n * Créé : 2026-08-17 Modifié : 2026-08-23\n * =============================================================================\n */\nimport { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport { displayTitle, fetchJob, formatDate, formatSalary, Job, LANG_FR, MODE_FR, TYPE_FR } from \"../api\";\nimport { FavButton } from \"../favorites\";\n\nexport default function JobPage() {\n const { uid = \"\" } = useParams();\n const [job, setJob] = useState(null);\n const [error, setError] = useState(\"\");\n\n useEffect(() => {\n setJob(null);\n fetchJob(uid).then(setJob).catch((e) => setError(String(e)));\n window.scrollTo(0, 0);\n }, [uid]);\n\n if (error) return

Offre introuvable

← Retour aux offres
;\n if (!job) return
Chargement…
;\n\n const salary = formatSalary(job);\n const yearly = job.salary_year_min != null && job.salary_unit !== \"year\"\n ? `≈ ${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`\n : null;\n\n return (\n
\n
\n

← Toutes les offres

\n
\n {job.company_logo && (\n { (e.target as HTMLImageElement).style.display = \"none\"; }}\n />\n )}\n

{displayTitle(job)}

\n \n
\n

\n {job.employer}\n {job.location_label && — {job.location_label}}\n {job.region && job.region !== \"Québec\" && ({job.region})}\n

\n
\n {salary && {salary}{yearly ? ` (${yearly})` : \"\"}}\n {!salary && Salaire non affiché par l'employeur}\n {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n {job.language && {LANG_FR[job.language] ?? job.language}}\n {job.category && {job.category}}\n {job.date_posted && Publiée le {formatDate(job.date_posted)}}\n {job.date_deadline && Date limite : {formatDate(job.date_deadline)}}\n {job.active === 0 && Offre retirée}\n
\n

\n \n Postuler chez {job.employer} ↗\n \n

\n

\n Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka\n n'est pas un intermédiaire, la candidature se fait chez l'employeur.\n

\n {job.benefits && job.benefits.length > 0 && (\n <>\n

Avantages

\n
    \n {job.benefits.map((b, i) =>
  • {b}
  • )}\n
\n \n )}\n {job.description && (\n <>\n

Description du poste

\n
{job.description}
\n \n )}\n
\n
\n );\n}\n","numLines":95,"startLine":1,"totalLines":95}}}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HuLGpoHU3qwuVUZEiVaVek","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/api.ts\n7\t * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n8\t * Créé : 2026-08-17 Modifié : 2026-08-17\n9\t * =============================================================================\n10\t */\n11\t\n12\texport interface Job {\n13\t uid: string;\n14\t source: string;\n15\t external_id: string;\n16\t url: string;\n17\t employer: string;\n18\t title: string;\n19\t description?: string;\n20\t address: string;\n21\t city: string;\n22\t region: string;\n23\t postal_code: string;\n24\t location_label: string;\n25\t work_mode: string | null;\n26\t employment_type: string | null;\n27\t salary_min: number | null;\n28\t salary_max: number | null;\n29\t salary_unit: string | null;\n30\t salary_label: string;\n31\t salary_year_min: number | null;\n32\t salary_year_max: number | null;\n33\t salary_hour_min: number | null;\n34\t salary_hour_max: number | null;\n35\t benefits: string[];\n36\t requirements: Record;\n37\t date_posted: string | null;\n38\t date_deadline: string | null;\n39\t category: string;\n40\t ats: string;\n41\t details: Record;\n42\t lat: number | null;\n43\t lng: number | null;\n44\t active: number;\n45\t dup_sources?: string[];\n46\t is_direct?: boolean;\n47\t company_logo?: string | null;\n48\t language?: string | null; // fr | en | bilingue\n49\t apply_url?: string | null; // candidature directe (≠ url de la fiche)\n50\t title_clean?: string | null; // titre d'affichage normalisé\n51\t}\n52\t\n53\texport interface JobList {\n54\t total: number;\n55\t count: number;\n56\t jobs: Job[];\n57\t}\n58\t\n59\texport interface Facets {\n60\t cities: string[];\n61\t regions: { region: string; n: number }[];\n62\t languages: { language: string; n: number }[];\n63\t categories: { category: string; n: number }[];\n64\t employers: { employer: string; n: number }[];\n65\t work_modes: string[];\n66\t employment_types: string[];\n67\t sources: { source: string; n: number }[];\n68\t}\n69\t\n70\texport interface Source {\n71\t id: string;\n72\t name: string;\n73\t url: string;\n74\t careers_url: string;\n75\t sectors: string[];\n76\t connector: string;\n77\t status: string;\n78\t region: string;\n79\t active_jobs: number;\n80\t last_sync: number | null;\n81\t}\n82\t\n83\texport interface Stats {\n84\t total: number;\n85\t employers: number;\n86\t sources: number;\n87\t with_salary: number;\n88\t avg_salary_year: number | null;\n89\t remote: number;\n90\t geocoded: number;\n91\t direct_jobs?: number;\n92\t direct_employers?: number;\n93\t direct_sources?: number;\n94\t portal_jobs?: number;\n95\t duplicates_hidden?: number;\n96\t top_cities: { city: string; n: number }[];\n97\t categories: { category: string; n: number }[];\n98\t recent_syncs: {\n99\t source: string; ts: number; found: number; added: number;\n100\t updated: number; removed: number; ok: number; message: string;\n101\t }[];\n102\t}\n103\t\n104\tasync function get(path: string, params?: Record): Promise {\n105\t const url = new URL(path, window.location.origin);\n106\t if (params) {\n107\t for (const [k, v] of Object.entries(params)) {\n108\t if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n109\t }\n110\t }\n111\t const ctl = new AbortController();\n112\t const timer = setTimeout(() => ctl.abort(), 20000);\n113\t try {\n114\t const resp = await fetch(url.toString(), { signal: ctl.signal });\n115\t if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);\n116\t return (await resp.json()) as T;\n117\t } finally {\n118\t clearTimeout(timer);\n119\t }\n120\t}\n121\t\n122\texport interface JobFilters {\n123\t q?: string;\n124\t city?: string;\n125\t region?: string;\n126\t category?: string;\n127\t work_mode?: string;\n128\t employment_type?: string;\n129\t language?: string;\n130\t salary_min?: number;\n131\t with_salary?: number;\n132\t sort?: string;\n133\t}\n134\t\n135\texport const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>\n136\t get(\"/api/jobs\", { ...filters, limit, offset });\n137\t\n138\texport const fetchJob = (uid: string) => get(`/api/jobs/${uid}`);\n139\t\n140\texport const fetchFacets = () => get(\"/api/facets\");\n141\t\n142\texport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n143\t\n144\texport const fetchStats = () => get(\"/api/stats\");\n145\t\n146\texport const geojsonUrl = (filters: JobFilters) => {\n147\t const url = new URL(\"/api/jobs.geojson\", window.location.origin);\n148\t for (const [k, v] of Object.entries(filters)) {\n149\t if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n150\t }\n151\t return url.toString();\n152\t};\n153\t\n154\t// --- présentation ------------------------------------------------------------\n155\t\n156\tconst UNIT_FR: Record = {\n157\t hour: \"h\", day: \"jour\", week: \"sem.\", biweek: \"2 sem.\", month: \"mois\", year: \"an\",\n158\t};\n159\t\n160\texport function formatSalary(job: Pick): string | null {\n161\t if (job.salary_min == null || !job.salary_unit) return null;\n162\t const fmt = (n: number) =>\n163\t n >= 1000 ? n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })\n164\t : n.toLocaleString(\"fr-CA\", { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n165\t const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;\n166\t if (job.salary_max != null && job.salary_max > job.salary_min) {\n167\t return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;\n168\t }\n169\t return `${fmt(job.salary_min)} $ / ${unit}`;\n170\t}\n171\t\n172\texport const MODE_FR: Record = {\n173\t presentiel: \"Présentiel\", hybride: \"Hybride\", teletravail: \"Télétravail\",\n174\t};\n175\t\n176\texport const TYPE_FR: Record = {\n177\t temps_plein: \"Temps plein\", temps_partiel: \"Temps partiel\",\n178\t contractuel: \"Contractuel\", stage: \"Stage\", saisonnier: \"Saisonnier\",\n179\t};\n180\t\n181\texport const LANG_FR: Record = {\n182\t fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n183\t};\n184\t\n185\t/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */\n186\texport const displayTitle = (job: Pick) =>\n187\t job.title_clean || job.title;\n188\t\n189\texport function formatDate(iso: string | null): string {\n190\t if (!iso) return \"\";\n191\t const d = new Date(`${iso}T12:00:00`);\n192\t return d.toLocaleDateString(\"fr-CA\", { day: \"numeric\", month: \"long\", year: \"numeric\" });\n193\t}\n194\t"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"36198e24-9395-4cec-98d7-dbf527c8af9a","timestamp":"2026-08-25T14:11:18.142Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/api.ts\n * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n * Créé : 2026-08-17 Modifié : 2026-08-17\n * =============================================================================\n */\n\nexport interface Job {\n uid: string;\n source: string;\n external_id: string;\n url: string;\n employer: string;\n title: string;\n description?: string;\n address: string;\n city: string;\n region: string;\n postal_code: string;\n location_label: string;\n work_mode: string | null;\n employment_type: string | null;\n salary_min: number | null;\n salary_max: number | null;\n salary_unit: string | null;\n salary_label: string;\n salary_year_min: number | null;\n salary_year_max: number | null;\n salary_hour_min: number | null;\n salary_hour_max: number | null;\n benefits: string[];\n requirements: Record;\n date_posted: string | null;\n date_deadline: string | null;\n category: string;\n ats: string;\n details: Record;\n lat: number | null;\n lng: number | null;\n active: number;\n dup_sources?: string[];\n is_direct?: boolean;\n company_logo?: string | null;\n language?: string | null; // fr | en | bilingue\n apply_url?: string | null; // candidature directe (≠ url de la fiche)\n title_clean?: string | null; // titre d'affichage normalisé\n}\n\nexport interface JobList {\n total: number;\n count: number;\n jobs: Job[];\n}\n\nexport interface Facets {\n cities: string[];\n regions: { region: string; n: number }[];\n languages: { language: string; n: number }[];\n categories: { category: string; n: number }[];\n employers: { employer: string; n: number }[];\n work_modes: string[];\n employment_types: string[];\n sources: { source: string; n: number }[];\n}\n\nexport interface Source {\n id: string;\n name: string;\n url: string;\n careers_url: string;\n sectors: string[];\n connector: string;\n status: string;\n region: string;\n active_jobs: number;\n last_sync: number | null;\n}\n\nexport interface Stats {\n total: number;\n employers: number;\n sources: number;\n with_salary: number;\n avg_salary_year: number | null;\n remote: number;\n geocoded: number;\n direct_jobs?: number;\n direct_employers?: number;\n direct_sources?: number;\n portal_jobs?: number;\n duplicates_hidden?: number;\n top_cities: { city: string; n: number }[];\n categories: { category: string; n: number }[];\n recent_syncs: {\n source: string; ts: number; found: number; added: number;\n updated: number; removed: number; ok: number; message: string;\n }[];\n}\n\nasync function get(path: string, params?: Record): Promise {\n const url = new URL(path, window.location.origin);\n if (params) {\n for (const [k, v] of Object.entries(params)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n }\n const ctl = new AbortController();\n const timer = setTimeout(() => ctl.abort(), 20000);\n try {\n const resp = await fetch(url.toString(), { signal: ctl.signal });\n if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);\n return (await resp.json()) as T;\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport interface JobFilters {\n q?: string;\n city?: string;\n region?: string;\n category?: string;\n work_mode?: string;\n employment_type?: string;\n language?: string;\n salary_min?: number;\n with_salary?: number;\n sort?: string;\n}\n\nexport const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>\n get(\"/api/jobs\", { ...filters, limit, offset });\n\nexport const fetchJob = (uid: string) => get(`/api/jobs/${uid}`);\n\nexport const fetchFacets = () => get(\"/api/facets\");\n\nexport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n\nexport const fetchStats = () => get(\"/api/stats\");\n\nexport const geojsonUrl = (filters: JobFilters) => {\n const url = new URL(\"/api/jobs.geojson\", window.location.origin);\n for (const [k, v] of Object.entries(filters)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n return url.toString();\n};\n\n// --- présentation ------------------------------------------------------------\n\nconst UNIT_FR: Record = {\n hour: \"h\", day: \"jour\", week: \"sem.\", biweek: \"2 sem.\", month: \"mois\", year: \"an\",\n};\n\nexport function formatSalary(job: Pick): string | null {\n if (job.salary_min == null || !job.salary_unit) return null;\n const fmt = (n: number) =>\n n >= 1000 ? n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })\n : n.toLocaleString(\"fr-CA\", { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;\n if (job.salary_max != null && job.salary_max > job.salary_min) {\n return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;\n }\n return `${fmt(job.salary_min)} $ / ${unit}`;\n}\n\nexport const MODE_FR: Record = {\n presentiel: \"Présentiel\", hybride: \"Hybride\", teletravail: \"Télétravail\",\n};\n\nexport const TYPE_FR: Record = {\n temps_plein: \"Temps plein\", temps_partiel: \"Temps partiel\",\n contractuel: \"Contractuel\", stage: \"Stage\", saisonnier: \"Saisonnier\",\n};\n\nexport const LANG_FR: Record = {\n fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n};\n\n/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */\nexport const displayTitle = (job: Pick) =>\n job.title_clean || job.title;\n\nexport function formatDate(iso: string | null): string {\n if (!iso) return \"\";\n const d = new Date(`${iso}T12:00:00`);\n return d.toLocaleDateString(\"fr-CA\", { day: \"numeric\", month: \"long\", year: \"numeric\" });\n}\n","numLines":194,"startLine":1,"totalLines":194}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"0f1b30c4-6d3f-4227-b307-eed45f72d14d","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"576e9a86-9812-4824-b881-1dfd05984fa8","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":150,"estimated_tokens_delta":100,"uuid":"770fdb9d-6ed1-4440-a8d3-aa8bcc342477","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":250,"estimated_tokens_delta":100,"uuid":"1362ebe3-9fee-490d-92c1-02f3c9503170","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":350,"estimated_tokens_delta":100,"uuid":"dc0c44ed-070f-4379-b0a2-0b658055c929","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":450,"estimated_tokens_delta":100,"uuid":"700e1264-7997-4371-94a0-e843cd580275","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":550,"estimated_tokens_delta":100,"uuid":"1ecb4ecf-5ab6-4429-9fb3-544706d063b3","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":650,"estimated_tokens_delta":100,"uuid":"7e489587-3756-4453-8193-21897b67e07d","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":750,"estimated_tokens_delta":100,"uuid":"0564f0de-d8df-4cb3-acf8-4ee70bb6a2a9","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":900,"estimated_tokens_delta":150,"uuid":"1ebcb4ad-2283-43c7-8791-2cc4c547d634","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":950,"estimated_tokens_delta":50,"uuid":"9e8a8615-c999-4e82-94e2-b20e7c1d8a16","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbsV4o3kDUzR44ajVDk","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISlhcKpwEIERgCKkBRkQd3BZXkqW2OioYBujLZsyUynpf8QCUaS3TDpVFn5g3ABSxbZR0RBc9R1ijAuCQKKijZG/SsbmnN6U1Cc/HmMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAGbxbbUBrABAhIMaU+tV/itN9/DbnE6GgwPEUgNLoshaBF9dG8iMFoXE/Z3/W8Os7G9lTwqGPoEBvYGQ+GnrWWPSSFZufz0rQwpKzfW3E2tdbXKlqnNqyqbFVildM4kh6LK0WdN/P4qy0w2Qe/UvZ/gvf3CbKT5e8jnFHqJPpBt4r+2TtjkUgvB+2OkRrH/leHqDYOosb31Y7M27qXVq9d/wmCtTRw/VpV4BycRO5I3PhWuCAiHeyYkU50HLs/8KN/UGzwtZqyV+EzdxNkV7s4HipaZs42EoIPB3GmsAkrRgoG3YdOC4o9t/cvPdLw5wnKn50P7VcjmgGl5sOMYuYX283jZOmVPxz5W2yl5U1jQaZJNudfkOcZjw4fZ/oCnu5knfhxQ80V3Ikhlijkg/htRHlgR7yNE50wmkoIsm3Xub/DlS21CXqXKyC+MR0DdZXp+mJB4F8jVjPl2gXzt70+xNoMKRSbkDCwPjIjJ8bBeO8kDPEzTJn611yPsLZAQpbTpO5M45C49tuuqn7hSqDTxAIrWUAj2tmff1xFZ55JDTTWgzPOg1hWRgZgf6O47WWNLjCqWLj4i8UWDdDT5WtjdlXqvWWk8y3h9Gu6Xb051LNYOBgpnzxbhQZcVU+srAbuY/ewDGOx9UeBSJi6suZxDzBBqzW7oBkICgqqgBVMnoPMUAxpprzuf8pzizMdcpX3Lqv3h8MHqNr7dbJKcpEL0QG0ubJ0U2vhuCeauSkhgWAW1q5Lu8jY2awcpqbdnWCT7qwZvF/2F6VrXjO3gEeno6+43fCQDhjCo82EJuggd/e/yM1+/m0q/2qV20VtE75gr0wBiCN7LjAdtmKGP4TLbbvIbb9tlFbXI5FGm9g0LCLK+qn/RiIJNzRRAhtRLzeYgzRJBhqHqQg3ZbkBr1jDaTlzAbvh2CWC/KI7QiP/8XjjwpSrTjiS9vXNeNj5QKQe4qDLxqA6ld8uyaTWnip7vqWo5kVyPitIZPnGYLsTJDDb3EWX8Ox6hM0CBzZAv3K1fT8rVxfDeQaMjZHgOWRoN8s2LJnwxjslMbD91RRbMlxEIZDxlmdSYU30RiA2T7Cehmgl89IwGW8Ud19X0iLC7poI+7TWSq393k89BnTZmZsT01YNm8Kz13lvEdjiuVwbke/Gwc5mCaVKp4js8T3WX9c/yLs/7nlW+1tPv5tDjbDQvDSW1KZQaURIc34OWSFeNEKpGmfanySuRv8pTVE19hDGypVoClnmVZsQaJAHErzt+0k/k9OLmS2ROc2UqvWqWm0sxt7Zb71e2W8vNlhYAlnY63NSF4rTm/v4/HYZt7u0KP+zUjKkNC+nxNztPAjGDIgoeYK1b51R8bd0pcgRYiEtMjzY+mkL1IvjEtw0f9Mu9XpZ6C8ILBvczS/aTpnuMdRoYcMlHiNZDIvdv1EtSc2SQFufBRy3CqF5nJ6QJiUx8bMs0gfFwMkp+QDciK+gA87B8Mrj6Aek3lL8q4P8eXqaterlcvM3KxRyT/h0ziDicr/Q3ybOVin91PLF+dSygGkvFMVUYpFqDuUwXEEeYV1t/hJzYsPHNRoYYZSK57Naj77Q0QlL4Qr2DlDffeEWsvGSV7mX93vKiVWy4lftC+UJtieXByE2k49rR0/Tj/bGeV8chf/nr/tlRcWU9xc4aW+s4kWe3YZ7viKjojTxuSLsxu+69WwiLKFqq9DDqxOW/Najxro1sZW+HqXAl93NNNpbBbPUsHP9j7u5Dk6eFRZkID6sPfG12DDWcPAbTvI39/nC+n9xs541eQLIXAxgUEm18lEl7wXTbMeBrp/UoQTw6K9bO0BlC7UAHpNL6+s+ir6dzv3+ocgoFGSC5/4tdzEfb0JBVbhfBAOGEjSxNgG1rD7x+H7fBBKyaBuTd2zGyJ6lmD6SJYiyR8pvhKxPfxeawQHcY4ttjY5GM2YJzAARfjLyteWFUngZMboaqOiGNpgfhqC+84FTiTvGZWI0JqIAMCIi3d6nAEykbwblB06aO6MUtI8gEsEHOkD5DLQRGkm0Rw5HYLdG0jnGFp5CA0c+1q24ywrMT+5+PHVWX3CCnT/PblDTQIVB21QqGtUec0m0Qzsmou1Twcic6AECdjUBvBoV+GmdCE8Ig9chIcGswBo0fNauPKghpjuVjPkKVVjA5NWARuKCzJiH34lIHTU66iZrRI6IGq0M0L3/IC+rdNc3jh5uBCjcQyDQ4BKQilnijvuZxXCdWKw1hnbCLNJEEUGUpMvKx2RUdJVu7hAl2eDqXq9iqOYibVQZf58V/Hke/iRFWeuh1kgk5GIzP51o5hMUh80N/eme3t6jvrDob4bpg91uW4gjwmQBXmuIGPizLZEoTJKZRxFPtH2nXE3+CvdNsMfufPoWDLHzcraXulCtcwfuMM8s1W6VxfXrcyuPRrgfjXEROshElXhSN04Ag7JeNEq1DBgdOO/7sU98sp8HD26eGMhg0NtpMWUjj6B91ZH3WbNJRhJ7S7CtlNlUaZ0F+MJsAMfsaWJhox6TQGTMQiDVmx5VQaFwO1SAsAiU3SpnVtTF6pFBp1PpB2W5ue19UY4Jg3p6Sq+or6n3+nNhTL25KZIPJVkEzASOPNXXJLhiebGmb3MzO0T2hzMXwMznv+Yxy3Zf2Z9DfsIH3mo8zNJ3SNGDFlv9irsl6nLucIStm5F9BFu8AQZsl2BVFMSX0wB14v4+Qq6tpAhXnrQZ9EeS3VpP1s8yZjg95f1NpfluQc/iuYrqYqEwXt2T/x8/9bQCo3vjXTCCquDC9sNgHJLlSNhFCOZ7R74Y/4d9jOqTb6tGjY8bsfVg6NovkKl2TpiOS3878meoWb5wLUHbLj1vivVLwBeMXRlx2N9gMy6uQMHlOM++Q9caZ2a0q9/9tptn8EVfec6Noy9mKnvoyX37Fmnf+1OAZOtHqcHKBfqb14tHMFqJdGx/KJh1d6WPp5QRyEOT5rxqwhNb+w42kqqYjcsRkT0fdTJWPj1wV4nWccbwx+BDRNznTIlXJjKmnH0O9paX8BVMA7tAFsGGdApzWabLzKeq3+nyoTKK7t3fB1rBwXvK/AOcs5v77cDdEQCkgh7iUldjX2oo9ePw0h1DPqpuGvgZiFevCxkuNAmnojbwBeU/+hYzmutxFFB7DfSNKg47OzPAA5x3g79WQr6/XHWXv00xL4QRbD/vXdIQiOT29xLTgZba8uk+TVcKeF2NQITSzi0NYvcSBex5rcBx93uvtZhXQHnhgT6ER2Ju+JHsP0YF50P42Ewbp8mvY45lG1GgjI+jpoG7lZmRQGoyH0Un7JNo4PUIu4rpW4hsxxWqsiAIPVANlcajBqnG+kI+Bsav2NxibajogFc/W0mql/5W09G23FQEYyubloyDje8ayxStXQuIMZr1/i1qBPsK8EwQ0/H1VdFA+SISsbyTIYsgKzm/Jw5VXZDUaV0/woBw3QPhlhV4+QwAMDsIeqoamFSGbmTjxgRIWjOgMf2VnnogCYMzaGpWsrjL6QCIDkXeSRFjcGem/HXSQRRg8Iztd6s/wlfa7YdGMOBSng52kHc12swZTTxN7vN85KKcIDmy8bqww++PNIuL7+8Ls4z5JFxCH/Hq5XaZZ3FT4F84IhCi3yABMVZ1QIDIYngCAEaP1ISF2WhmOIheJ1uKqz3dVy3KCAjQ+WT2JY0LIPuDwQSj6/GkCnwuIumV5E0oJrjNXnzHiP4mU3qouS3CgkTcqnmxTz+n1W2KlIxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":9805,"cache_read_input_tokens":45476,"cache_creation":{"ephemeral_5m_input_tokens":9805,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"3cb0ab2e-5e2b-4677-ba6d-e25990688934","request_id":"req_011CePbsSuZaXyYRu4LGpiot"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbsV4o3kDUzR44ajVDk","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISkgQKqAEIERgCKkBu5IhpqT83j6hYKBujnxFDCpxdWMJ3CI+k4vsuXoBMuBOFQrBwS3qhtAAijL+vAVeqlRxoVOdiu1sXYbjXR6ABMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQHSFahiYOz86M3z97Uqk2VogBAagBm8W21AawAQISDFS7IhRA558lQ8C5HhoMh1qQ0acryui00+JxIjCXPW/ze93Suoa3CMo4VERV/74zZCZJNSfpZi5XGAkHodHqIZSvY1mWqe3H3xeznpUqlgLaP4b+EOjrFxar9UiM+uWqouL6cLIGGcTcBMes5VaQWENs3LLsvzUS16z9JFC8RcM5trOjiF+Fb9Lha7XTr8KUXlPOT4WfTO1uB2UuEJRNkpl6L/DSRnw+P8SCVnzbRaP4bbdnsBP/WftjXrbWB5/O33+aeVquamCs1UVFw7aoF8A18bpZT9wFFF5qNOO1Uhv5h+ODL6Ix5B2pv8ad57UeRdpGr/kIn5w2dzGda7iOeAOCUbMxy5MO6dnE3kaHgqTph2rqKvLIhrA9JWfNZLAFksUJ4vPBM5rmWRD/fsNsf8eMebtLi+6kjdt8PsgAizWvvljsIjou9gbczXc0L+L7Jqievg5O/Ygo5pVZUYwNDpe9pkXVPRgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":9805,"cache_read_input_tokens":45476,"cache_creation":{"ephemeral_5m_input_tokens":9805,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"5cb634a2-a161-4ee3-b657-ddd1205c09bb","request_id":"req_011CePbsSuZaXyYRu4LGpiot"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbsV4o3kDUzR44ajVDk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01E7YYrVEiWdXY5z4KajtdP8","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":9805,"cache_read_input_tokens":45476,"cache_creation":{"ephemeral_5m_input_tokens":9805,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"03d9b40d-756d-4419-af98-0a5445df8d6e","request_id":"req_011CePbsSuZaXyYRu4LGpiot"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01E7YYrVEiWdXY5z4KajtdP8","type":"tool_result","content":"1\t# =============================================================================\n2\t# Job·Ka — Groupe KA\n3\t# Auteur : Simon-Pierre Boucher\n4\t# Contact : contact@spboucher.ai\n5\t# Fichier : jobka/web.py\n6\t# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)\n7\t# Créé : 2026-08-17 Modifié : 2026-08-17\n8\t# =============================================================================\n9\tfrom __future__ import annotations\n10\t\n11\timport json\n12\timport re\n13\timport secrets\n14\timport threading\n15\timport time\n16\tfrom pathlib import Path\n17\t\n18\tfrom fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request\n19\tfrom fastapi.middleware.cors import CORSMiddleware\n20\tfrom fastapi.middleware.gzip import GZipMiddleware\n21\tfrom fastapi.responses import FileResponse, Response\n22\tfrom fastapi.staticfiles import StaticFiles\n23\tfrom pydantic import BaseModel, Field\n24\t\n25\tfrom . import auth, db, hubfav, ingest\n26\tfrom .dedup import AGGREGATORS\n27\tfrom .schema import JobPosting\n28\t\n29\tROOT = Path(__file__).resolve().parent.parent\n30\tSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\n31\tFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n32\t\n33\tapp = FastAPI(title=\"Job-Ka API\", version=\"1.0\",\n34\t description=\"Agrégateur d'offres d'emploi — employeurs québécois, \"\n35\t \"directement à la source\")\n36\tapp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n37\t allow_methods=[\"*\"], allow_headers=[\"*\"])\n38\tapp.add_middleware(GZipMiddleware, minimum_size=1000)\n39\t\n40\t# connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py\n41\tapp.include_router(auth.router)\n42\t\n43\t_sync_lock = threading.Lock()\n44\t\n45\t\n46\tdef _row_to_dict(row) -> dict:\n47\t d = dict(row)\n48\t d[\"benefits\"] = json.loads(d.get(\"benefits\") or \"[]\")\n49\t d[\"requirements\"] = json.loads(d.get(\"requirements\") or \"{}\")\n50\t d[\"details\"] = json.loads(d.get(\"details\") or \"{}\")\n51\t if d.get(\"dup_sources\"):\n52\t try:\n53\t d[\"dup_sources\"] = json.loads(d[\"dup_sources\"])\n54\t except (ValueError, TypeError):\n55\t d[\"dup_sources\"] = []\n56\t # offre directe = page carrière de l'employeur ou dépôt direct (≠ portail)\n57\t d[\"is_direct\"] = d.get(\"source\") not in AGGREGATORS\n58\t return d\n59\t\n60\t\n61\t@app.get(\"/health\")\n62\tdef health():\n63\t \"\"\"Healthcheck de déploiement : BD accessible + fraîcheur des syncs.\"\"\"\n64\t try:\n65\t con = db.connect()\n66\t total = con.execute(\n67\t \"SELECT COUNT(*) c FROM jobs WHERE active=1\").fetchone()[\"c\"]\n68\t last = con.execute(\n69\t \"SELECT MAX(ts) ts FROM sync_log WHERE ok=1\").fetchone()[\"ts\"]\n70\t con.close()\n71\t except Exception as exc:\n72\t raise HTTPException(500, f\"BD inaccessible : {exc}\")\n73\t return {\"status\": \"ok\", \"active_jobs\": total,\n74\t \"last_sync_age_s\": round(time.time() - last) if last else None}\n75\t\n76\t\n77\t@app.get(\"/api/jobs\")\n78\tdef list_jobs(\n79\t city: str | None = None,\n80\t region: str | None = None,\n81\t category: str | None = None,\n82\t employer: str | None = None,\n83\t source: str | None = None,\n84\t work_mode: str | None = None,\n85\t employment_type: str | None = None,\n86\t salary_min: float | None = None, # $/an (converti côté serveur)\n87\t with_salary: int | None = None, # 1 = transparence salariale seulement\n88\t posted_after: str | None = None, # ISO : offres publiées depuis…\n89\t language: str | None = None, # fr | en | bilingue\n90\t q: str | None = None,\n91\t active: int = 1,\n92\t quarantined: int | None = None, # 1 = quarantaine seulement (supervision)\n93\t sort: str = \"recent\", # recent | salary\n94\t limit: int = Query(100, le=2000),\n95\t offset: int = 0,\n96\t):\n97\t con = db.connect()\n98\t sql = \"SELECT * FROM jobs WHERE dup_of IS NULL\" # doublons masqués\n99\t args: list = []\n100\t # quarantaine qualité : jamais publiée par défaut (voir jobka/quality.py)\n101\t sql += \" AND quarantine IS NOT NULL\" if quarantined == 1 \\\n102\t else \" AND quarantine IS NULL\"\n103\t if active in (0, 1):\n104\t sql += \" AND active=?\"; args.append(active)\n105\t if city:\n106\t sql += \" AND city=?\"; args.append(city)\n107\t if region:\n108\t sql += \" AND region=?\"; args.append(region)\n109\t if language:\n110\t sql += \" AND language=?\"; args.append(language)\n111\t if category:\n112\t sql += \" AND category=?\"; args.append(category)\n113\t if employer:\n114\t sql += \" AND employer LIKE ?\"; args.append(f\"%{employer}%\")\n115\t if source:\n116\t sql += \" AND source=?\"; args.append(source)\n117\t if work_mode:\n118\t sql += \" AND work_mode=?\"; args.append(work_mode)\n119\t if employment_type:\n120\t sql += \" AND employment_type=?\"; args.append(employment_type)\n121\t if salary_min is not None:\n122\t sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")\n123\t args.append(salary_min)\n124\t if with_salary == 1:\n125\t sql += \" AND salary_min IS NOT NULL\"\n126\t if posted_after:\n127\t sql += \" AND date_posted IS NOT NULL AND date_posted>=?\"\n128\t args.append(posted_after)\n129\t if q:\n130\t sql += \" AND (title LIKE ? OR employer LIKE ? OR description LIKE ?)\"\n131\t args += [f\"%{q}%\"] * 3\n132\t total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n133\t if sort == \"salary\":\n134\t sql += \" ORDER BY salary_year_max IS NULL, salary_year_max DESC\"\n135\t else:\n136\t sql += \" ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC\"\n137\t sql += \" LIMIT ? OFFSET ?\"\n138\t args += [limit, offset]\n139\t rows = []\n140\t for r in con.execute(sql, args).fetchall():\n141\t d = _row_to_dict(r)\n142\t d.pop(\"description\", None) # liste allégée : la fiche a le texte complet\n143\t rows.append(d)\n144\t con.close()\n145\t return {\"total\": total, \"count\": len(rows), \"jobs\": rows}\n146\t\n147\t\n148\t@app.get(\"/api/jobs.geojson\")\n149\tdef jobs_geojson(\n150\t city: str | None = None,\n151\t region: str | None = None,\n152\t category: str | None = None,\n153\t work_mode: str | None = None,\n154\t employment_type: str | None = None,\n155\t salary_min: float | None = None,\n156\t with_salary: int | None = None,\n157\t language: str | None = None,\n158\t q: str | None = None,\n159\t bbox: str | None = None,\n160\t limit: int = Query(3000, le=8000),\n161\t):\n162\t \"\"\"Offres géolocalisées au format GeoJSON (mêmes filtres que /api/jobs).\n163\t\n164\t `bbox=ouest,sud,est,nord` restreint au rectangle visible (idx_jobs_geo).\n165\t Membres additionnels : `totalGeocoded` / `totalMatching` pour afficher\n166\t « N résultats sans position sur la carte ».\n167\t \"\"\"\n168\t con = db.connect()\n169\t sql = (\"SELECT uid, title, title_clean, employer, city, category, work_mode,\"\n170\t \" employment_type, salary_min, salary_max, salary_unit,\"\n171\t \" salary_year_min, salary_year_max, date_posted, source, lat, lng\"\n172\t \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n173\t \" AND lat IS NOT NULL AND lng IS NOT NULL\")\n174\t args: list = []\n175\t if bbox:\n176\t try:\n177\t west, south, east, north = (float(v) for v in bbox.split(\",\"))\n178\t except ValueError:\n179\t raise HTTPException(400, \"bbox attendu : ouest,sud,est,nord\")\n180\t sql += \" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\"\n181\t args += [south, north, west, east]\n182\t if city:\n183\t sql += \" AND city=?\"; args.append(city)\n184\t if region:\n185\t sql += \" AND region=?\"; args.append(region)\n186\t if category:\n187\t sql += \" AND category=?\"; args.append(category)\n188\t if work_mode:\n189\t sql += \" AND work_mode=?\"; args.append(work_mode)\n190\t if employment_type:\n191\t sql += \" AND employment_type=?\"; args.append(employment_type)\n192\t if salary_min is not None:\n193\t sql += \" AND salary_year_max IS NOT NULL AND salary_year_max>=?\"\n194\t args.append(salary_min)\n195\t if with_salary == 1:\n196\t sql += \" AND salary_min IS NOT NULL\"\n197\t if language:\n198\t sql += \" AND language=?\"; args.append(language)\n199\t if q:\n200\t sql += \" AND (title LIKE ? OR employer LIKE ?)\"\n201\t args += [f\"%{q}%\"] * 2\n202\t\n203\t total_geo = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n204\t sql_all = sql.replace(\" AND lat IS NOT NULL AND lng IS NOT NULL\", \"\")\n205\t args_all = list(args)\n206\t if bbox:\n207\t sql_all = sql_all.replace(\" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\", \"\")\n208\t del args_all[0:4]\n209\t total_all = con.execute(f\"SELECT COUNT(*) c FROM ({sql_all})\", args_all).fetchone()[\"c\"]\n210\t\n211\t sql += \" ORDER BY date_posted IS NULL, date_posted DESC LIMIT ?\"\n212\t args.append(limit)\n213\t\n214\t features = []\n215\t for r in con.execute(sql, args).fetchall():\n216\t features.append({\n217\t \"type\": \"Feature\",\n218\t \"geometry\": {\"type\": \"Point\", \"coordinates\": [r[\"lng\"], r[\"lat\"]]},\n219\t \"properties\": {\n220\t \"uid\": r[\"uid\"], \"title\": r[\"title_clean\"] or r[\"title\"],\n221\t \"employer\": r[\"employer\"],\n222\t \"city\": r[\"city\"], \"category\": r[\"category\"],\n223\t \"work_mode\": r[\"work_mode\"],\n224\t \"employment_type\": r[\"employment_type\"],\n225\t \"salary_min\": r[\"salary_min\"], \"salary_max\": r[\"salary_max\"],\n226\t \"salary_unit\": r[\"salary_unit\"],\n227\t \"salary_year_min\": r[\"salary_year_min\"],\n228\t \"salary_year_max\": r[\"salary_year_max\"],\n229\t \"date_posted\": r[\"date_posted\"], \"source\": r[\"source\"],\n230\t \"is_direct\": r[\"source\"] not in AGGREGATORS,\n231\t },\n232\t })\n233\t con.close()\n234\t return {\"type\": \"FeatureCollection\", \"features\": features,\n235\t \"totalGeocoded\": total_geo, \"totalMatching\": total_all}\n236\t\n237\t\n238\t@app.get(\"/api/jobs/{uid}\")\n239\tdef get_job(uid: str):\n240\t con = db.connect()\n241\t row = con.execute(\"SELECT * FROM jobs WHERE uid=?\", (uid,)).fetchone()\n242\t d = None\n243\t if row is not None:\n244\t d = _row_to_dict(row)\n245\t d[\"salary_history\"] = [dict(r) for r in con.execute(\n246\t \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\"\n247\t \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]\n248\t con.close()\n249\t if d is None:\n250\t raise HTTPException(404, \"Offre introuvable\")\n251\t return d\n252\t\n253\t\n254\t@app.get(\"/api/facets\")\n255\tdef facets(city: str | None = None):\n256\t \"\"\"Valeurs distinctes pour construire les filtres du frontend.\"\"\"\n257\t con = db.connect()\n258\t base = \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n259\t out = {\n260\t \"cities\": [r[\"city\"] for r in con.execute(\n261\t f\"SELECT DISTINCT city{base} AND city<>'' ORDER BY city\")],\n262\t # régions administratives (17) — le repli générique « Québec »\n263\t # (appartenance provinciale sans région précise) n'est pas une facette\n264\t \"regions\": [dict(r) for r in con.execute(\n265\t f\"SELECT region, COUNT(*) n{base} AND region<>''\"\n266\t \" AND region<>'Québec' GROUP BY region ORDER BY region\")],\n267\t \"languages\": [dict(r) for r in con.execute(\n268\t f\"SELECT language, COUNT(*) n{base} AND language IS NOT NULL\"\n269\t \" AND language<>'' GROUP BY language ORDER BY n DESC\")],\n270\t \"categories\": [dict(r) for r in con.execute(\n271\t f\"SELECT category, COUNT(*) n{base} AND category<>''\"\n272\t \" GROUP BY category ORDER BY n DESC\")],\n273\t \"employers\": [dict(r) for r in con.execute(\n274\t f\"SELECT employer, COUNT(*) n{base} AND employer<>''\"\n275\t \" GROUP BY employer ORDER BY n DESC LIMIT 100\")],\n276\t \"work_modes\": [r[\"work_mode\"] for r in con.execute(\n277\t f\"SELECT DISTINCT work_mode{base} AND work_mode IS NOT NULL\"\n278\t \" ORDER BY work_mode\")],\n279\t \"employment_types\": [r[\"employment_type\"] for r in con.execute(\n280\t f\"SELECT DISTINCT employment_type{base}\"\n281\t \" AND employment_type IS NOT NULL ORDER BY employment_type\")],\n282\t \"sources\": [dict(r) for r in con.execute(\n283\t f\"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC\")],\n284\t }\n285\t con.close()\n286\t return out\n287\t\n288\t\n289\t@app.get(\"/api/sources\")\n290\tdef sources():\n291\t registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n292\t con = db.connect()\n293\t counts = {r[\"source\"]: r[\"n\"] for r in con.execute(\n294\t \"SELECT source, COUNT(*) n FROM jobs WHERE active=1 GROUP BY source\")}\n295\t last = {r[\"source\"]: r[\"ts\"] for r in con.execute(\n296\t \"SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source\")}\n297\t con.close()\n298\t for s in registry:\n299\t s[\"active_jobs\"] = counts.get(s[\"id\"], 0)\n300\t s[\"last_sync\"] = last.get(s[\"id\"])\n301\t return {\"sources\": registry}\n302\t\n303\t\n304\t@app.get(\"/api/stats\")\n305\tdef stats(syncs_since_h: float | None = None):\n306\t con = db.connect()\n307\t row = con.execute(\n308\t \"\"\"SELECT COUNT(*) total,\n309\t COUNT(DISTINCT employer) employers,\n310\t COUNT(DISTINCT source) sources,\n311\t SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary,\n312\t AVG(salary_year_min) avg_salary_year,\n313\t SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote,\n314\t SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded\n315\t FROM jobs WHERE active=1 AND dup_of IS NULL\n316\t AND quarantine IS NULL\"\"\").fetchone()\n317\t quarantined = con.execute(\n318\t \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\"\n319\t ).fetchone()[\"n\"]\n320\t cities = [dict(r) for r in con.execute(\n321\t \"\"\"SELECT city, COUNT(*) n FROM jobs\n322\t WHERE active=1 AND dup_of IS NULL AND city<>''\n323\t GROUP BY city ORDER BY n DESC LIMIT 15\"\"\")]\n324\t categories = [dict(r) for r in con.execute(\n325\t \"\"\"SELECT category, COUNT(*) n FROM jobs\n326\t WHERE active=1 AND dup_of IS NULL AND category<>''\n327\t GROUP BY category ORDER BY n DESC\"\"\")]\n328\t # Fenêtre élargie pour la supervision (api-ka) : sans le paramètre, on\n329\t # garde les 20 dernières entrées ; avec, toute l'activité de la fenêtre\n330\t # (sinon le moniteur bi-horaire ne voit qu'une fraction de la rotation\n331\t # des sources → fausses alertes « stale »).\n332\t if syncs_since_h and syncs_since_h > 0:\n333\t cutoff = time.time() - syncs_since_h * 3600\n334\t log = [dict(r) for r in con.execute(\n335\t \"SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000\",\n336\t (cutoff,))]\n337\t if len(log) < 20:\n338\t log = [dict(r) for r in con.execute(\n339\t \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n340\t else:\n341\t log = [dict(r) for r in con.execute(\n342\t \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n343\t # offres directes (pages carrières + dépôt direct) c. portails agrégateurs\n344\t agg = sorted(AGGREGATORS) or [\"__aucun__\"]\n345\t ph = \",\".join(\"?\" * len(agg))\n346\t direct = con.execute(\n347\t f\"\"\"SELECT COUNT(*) n, COUNT(DISTINCT employer) e,\n348\t COUNT(DISTINCT source) s\n349\t FROM jobs WHERE active=1 AND dup_of IS NULL\n350\t AND source NOT IN ({ph})\"\"\", agg).fetchone()\n351\t portal = con.execute(\n352\t f\"\"\"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\n353\t AND source IN ({ph})\"\"\", agg).fetchone()\n354\t dups = con.execute(\n355\t \"SELECT COUNT(*) n FROM jobs WHERE active=1\"\n356\t \" AND dup_of IS NOT NULL\").fetchone()\n357\t con.close()\n358\t return {**dict(row), \"top_cities\": cities, \"categories\": categories,\n359\t \"direct_jobs\": direct[\"n\"], \"direct_employers\": direct[\"e\"],\n360\t \"direct_sources\": direct[\"s\"], \"portal_jobs\": portal[\"n\"],\n361\t \"duplicates_hidden\": dups[\"n\"], \"quarantined\": quarantined,\n362\t \"recent_syncs\": log}\n363\t\n364\t\n365\t@app.get(\"/api/stats/dashboard\")\n366\tdef stats_dashboard(period: str = \"30j\",\n367\t from_: str | None = Query(None, alias=\"from\"),\n368\t to: str | None = None):\n369\t \"\"\"Tableau de bord analytique — contrat commun Groupe KA (SPEC.md).\n370\t Cache mémoire 5 min par clé de période (voir jobka/statsdash.py).\"\"\"\n371\t from . import statsdash\n372\t if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n373\t period = \"30j\"\n374\t return statsdash.compute(period, from_, to)\n375\t\n376\t\n377\t@app.get(\"/api/stats/report\")\n378\tdef stats_report(period: str = \"30j\",\n379\t from_: str | None = Query(None, alias=\"from\"),\n380\t to: str | None = None,\n381\t mode: str = \"complet\"):\n382\t \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes : complet | synthese |\n383\t tendances | repartitions | donnees (mode inconnu -> complet).\"\"\"\n384\t from . import kapdf, statsdash\n385\t if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n386\t period = \"30j\"\n387\t if mode not in kapdf.REPORT_MODES:\n388\t mode = \"complet\"\n389\t dash = statsdash.compute(period, from_, to)\n390\t data = kapdf.GroupeKAReport(\n391\t site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n392\t \"domain\": \"www.job-ka.com\",\n393\t \"tagline\": \"Tous les emplois des employeurs québécois\"},\n394\t dashboard=dash,\n395\t mode=mode,\n396\t ).build()\n397\t fname = kapdf.filename(\"job-ka\", period, mode)\n398\t return Response(\n399\t content=data, media_type=\"application/pdf\",\n400\t headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n401\t\n402\t\n403\t@app.get(\"/api/stats/catalog\")\n404\tdef stats_catalog(period: str = \"30j\",\n405\t from_: str | None = Query(None, alias=\"from\"),\n406\t to: str | None = None):\n407\t \"\"\"v3 — blocs composables pour le constructeur de rapports personnalisés.\"\"\"\n408\t from . import kapdf, statsdash\n409\t if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n410\t period = \"30j\"\n411\t dash = statsdash.compute(period, from_, to)\n412\t return {\"updated\": dash.get(\"updated\"), \"period\": dash.get(\"period\"),\n413\t \"blocks\": kapdf.catalog(dash)}\n414\t\n415\t\n416\t@app.post(\"/api/stats/report/custom\")\n417\tdef stats_report_custom(spec: dict = Body(...)):\n418\t \"\"\"v3 — rapport PDF personnalisé : {\"title\", \"period\", \"from\", \"to\",\n419\t \"blocks\": [{\"key\": \"series:…\", \"render\": \"bar\"}, …]} (SPEC.md §3bis).\"\"\"\n420\t from . import kapdf, statsdash\n421\t period = str(spec.get(\"period\") or \"30j\")\n422\t if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n423\t period = \"30j\"\n424\t dash = statsdash.compute(period, str(spec.get(\"from\") or \"\") or None,\n425\t str(spec.get(\"to\") or \"\") or None)\n426\t known = {b[\"key\"] for b in kapdf.catalog(dash)}\n427\t blocks = [b for b in (spec.get(\"blocks\") or [])\n428\t if isinstance(b, dict) and b.get(\"key\") in known][:40]\n429\t if not blocks:\n430\t raise HTTPException(400, \"Aucun bloc valide dans la composition\")\n431\t data = kapdf.GroupeKAReport(\n432\t site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n433\t \"domain\": \"www.job-ka.com\",\n434\t \"tagline\": \"Tous les emplois des employeurs québécois\"},\n435\t dashboard=dash, mode=kapdf.CUSTOM_MODE,\n436\t spec={\"title\": str(spec.get(\"title\") or \"\")[:80], \"blocks\": blocks},\n437\t ).build()\n438\t fname = kapdf.filename(\"job-ka\", period, kapdf.CUSTOM_MODE)\n439\t return Response(\n440\t content=data, media_type=\"application/pdf\",\n441\t headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n442\t\n443\t\n444\t# --- Dépôt direct d'offres par les employeurs ---------------------------------\n445\tclass OffreDeposee(BaseModel):\n446\t \"\"\"Offre soumise par un employeur (validation minimale, modérée ensuite).\"\"\"\n447\t\n448\t employer: str = Field(min_length=2, max_length=120)\n449\t title: str = Field(min_length=3, max_length=160)\n450\t description: str = Field(min_length=30, max_length=20000)\n451\t contact_email: str = Field(min_length=5, max_length=160)\n452\t city: str = Field(\"\", max_length=80)\n453\t url: str = Field(\"\", max_length=400)\n454\t salary_label: str = Field(\"\", max_length=120)\n455\t employment_type: str | None = None\n456\t work_mode: str | None = None\n457\t website: str = \"\" # pot de miel anti-robots : doit rester vide\n458\t\n459\t\n460\t@app.post(\"/api/employeurs/offres\")\n461\tdef deposer_offre(offre: OffreDeposee):\n462\t \"\"\"Canal de dépôt direct : l'offre est stockée INACTIVE (source\n463\t « depot-direct ») en attente de modération manuelle. Voir /employeurs.\"\"\"\n464\t if offre.website: # robot pris au pot de miel\n465\t return {\"status\": \"reçu\"}\n466\t if not re.match(r\"^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$\", offre.contact_email):\n467\t raise HTTPException(422, \"Courriel de contact invalide\")\n468\t if offre.url and not re.match(r\"^https?://\", offre.url):\n469\t raise HTTPException(422, \"Lien invalide : http(s):// requis\")\n470\t if offre.employment_type and offre.employment_type not in (\n471\t \"temps_plein\", \"temps_partiel\", \"contractuel\", \"stage\", \"saisonnier\"):\n472\t raise HTTPException(422, \"Type d'emploi inconnu\")\n473\t if offre.work_mode and offre.work_mode not in (\n474\t \"presentiel\", \"hybride\", \"teletravail\"):\n475\t raise HTTPException(422, \"Mode de travail inconnu\")\n476\t\n477\t job = JobPosting(\n478\t source=\"depot-direct\", external_id=secrets.token_hex(6),\n479\t url=offre.url, employer=offre.employer, title=offre.title,\n480\t description=offre.description, city=offre.city,\n481\t salary_label=offre.salary_label,\n482\t employment_type=offre.employment_type, work_mode=offre.work_mode,\n483\t ats=\"depot-direct\",\n484\t )\n485\t job.details[\"contact_email\"] = offre.contact_email\n486\t job.details[\"moderation\"] = \"en_attente\"\n487\t job.finalize()\n488\t\n489\t now = time.time()\n490\t cols = {\n491\t \"uid\": job.uid, \"source\": job.source, \"external_id\": job.external_id,\n492\t \"url\": job.url, \"employer\": job.employer, \"title\": job.title,\n493\t \"description\": job.description, \"city\": job.city,\n494\t \"region\": job.region, \"postal_code\": job.postal_code,\n495\t \"location_label\": job.location_label, \"work_mode\": job.work_mode,\n496\t \"employment_type\": job.employment_type,\n497\t \"salary_min\": job.salary_min, \"salary_max\": job.salary_max,\n498\t \"salary_unit\": job.salary_unit, \"salary_label\": job.salary_label,\n499\t \"salary_year_min\": job.salary_year_min(),\n500\t \"salary_year_max\": job.salary_year_max(),\n501\t \"salary_hour_min\": job.salary_hour_min(),\n502\t \"salary_hour_max\": job.salary_hour_max(),\n503\t \"benefits\": json.dumps(job.benefits, ensure_ascii=False),\n504\t \"requirements\": json.dumps(job.requirements, ensure_ascii=False),\n505\t \"category\": job.category, \"ats\": job.ats,\n506\t \"details\": json.dumps(job.details, ensure_ascii=False),\n507\t \"content_hash\": job.content_hash(),\n508\t \"first_seen\": now, \"last_seen\": now, \"updated_at\": now,\n509\t \"miss_count\": 0, \"active\": 0, # inactive tant que non modérée\n510\t }\n511\t con = db.connect()\n512\t con.execute(\n513\t f\"INSERT INTO jobs ({', '.join(cols)}) VALUES \"\n514\t f\"({', '.join(':' + k for k in cols)})\", cols)\n515\t con.commit()\n516\t con.close()\n517\t return {\"status\": \"reçu\", \"uid\": job.uid,\n518\t \"message\": \"Merci ! Votre offre a bien été reçue. Elle sera \"\n519\t \"vérifiée par notre équipe avant publication.\"}\n520\t\n521\t\n522\t@app.post(\"/api/sync\")\n523\tdef trigger_sync(background: BackgroundTasks, source: str | None = None):\n524\t \"\"\"Déclenche une synchronisation (équivalent d'un webhook entrant).\"\"\"\n525\t def _job():\n526\t with _sync_lock:\n527\t ingest.run([source] if source else None)\n528\t background.add_task(_job)\n529\t return {\"status\": \"démarré\", \"source\": source or \"toutes\"}\n530\t\n531\t\n532\t# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ------\n533\t@app.get(\"/api/favorites\")\n534\tdef favorites(request: Request):\n535\t \"\"\"Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).\"\"\"\n536\t user = auth.current_user(request)\n537\t if not user:\n538\t raise HTTPException(401, \"Connexion KA ID requise\")\n539\t items = hubfav.hub_list(user.get(\"ka_id\") or \"\")\n540\t if items is None:\n541\t raise HTTPException(502, \"Hub Groupe KA injoignable — réessayez\")\n542\t return {\"ids\": [i[\"item_id\"] for i in items if i.get(\"item_id\")],\n543\t \"items\": items}\n544\t\n545\t\n546\t@app.post(\"/api/favorites/toggle\")\n547\tdef toggle_favorite(request: Request, body: dict = Body(...)):\n548\t \"\"\"Ajoute (on=true) ou retire (on=false) un favori — poussé au hub\n549\t Groupe KA de façon synchrone : le hub est la seule source de vérité.\"\"\"\n550\t user = auth.current_user(request)\n551\t if not user:\n552\t raise HTTPException(401, \"Connexion KA ID requise\")\n553\t ka_id = user.get(\"ka_id\") or \"\"\n554\t if not hubfav.linked(ka_id):\n555\t raise HTTPException(403, \"Compte non relié au hub Groupe KA\")\n556\t on = bool(body.get(\"on\"))\n557\t item = hubfav.clean_item(body.get(\"item\") or {})\n558\t if not item.get(\"item_id\"):\n559\t raise HTTPException(422, \"item.item_id requis\")\n560\t if not hubfav.hub_toggle(ka_id, \"add\" if on else \"remove\", item):\n561\t raise HTTPException(502, \"Hub Groupe KA injoignable — favori non enregistré\")\n562\t return {\"ok\": True, \"on\": on}\n563\t\n564\t\n565\t# --- Frontend (build Vite) ---------------------------------------------------\n566\tif FRONTEND_DIST.exists():\n567\t app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIST / \"assets\"), name=\"assets\")\n568\t\n569\t # SEO : SSR léger des routes publiques (accueil, fiches emploi, pages\n570\t # statiques), robots.txt et sitemaps — DOIT être inclus AVANT le\n571\t # rattrape-tout SPA ci-dessous (l'ordre d'enregistrement fait foi).\n572\t from . import seo # noqa: E402\n573\t app.include_router(seo.router)\n574\t\n575\t @app.middleware(\"http\")\n576\t async def _cache_headers(request, call_next):\n577\t \"\"\"Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé —\n578\t sinon les navigateurs gardent une vieille version après un déploiement.\"\"\"\n579\t resp = await call_next(request)\n580\t path = request.url.path\n581\t if path.startswith(\"/assets/\"):\n582\t resp.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n583\t elif \"text/html\" in (resp.headers.get(\"content-type\") or \"\"):\n584\t resp.headers[\"Cache-Control\"] = \"no-cache\"\n585\t return resp\n586\t\n587\t # routes rendues uniquement côté client — tout autre chemin inconnu renvoie\n588\t # index.html avec un statut 404 (pas de soft-404 pour les moteurs)\n589\t _CLIENT_ROUTES = {\"carte\", \"sources\", \"stats\", \"contact\", \"employeurs\",\n590\t \"favoris\", \"confidentialite\", \"conditions\"}\n591\t _CLIENT_PREFIXES = (\"emploi/\",)\n592\t\n593\t @app.get(\"/{full_path:path}\")\n594\t def spa(full_path: str):\n595\t target = FRONTEND_DIST / full_path\n596\t if full_path and target.is_file():\n597\t return FileResponse(target)\n598\t if full_path and target.is_dir() and (target / \"index.html\").is_file():\n599\t # sous-répertoire statique avec index (ex. /doc/) — servi tel quel\n600\t return FileResponse(target / \"index.html\")\n601\t known = (full_path == \"\" or full_path in _CLIENT_ROUTES\n602\t or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))\n603\t return FileResponse(FRONTEND_DIST / \"index.html\",\n604\t status_code=200 if known else 404,\n605\t headers={\"Cache-Control\": \"no-cache\"})\n606\t"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"750d5030-5d70-40c8-ba86-d4b498b1c61b","timestamp":"2026-08-25T14:11:40.799Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","content":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/web.py\n# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)\n# Créé : 2026-08-17 Modifié : 2026-08-17\n# =============================================================================\nfrom __future__ import annotations\n\nimport json\nimport re\nimport secrets\nimport threading\nimport time\nfrom pathlib import Path\n\nfrom fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom fastapi.staticfiles import StaticFiles\nfrom pydantic import BaseModel, Field\n\nfrom . import auth, db, hubfav, ingest\nfrom .dedup import AGGREGATORS\nfrom .schema import JobPosting\n\nROOT = Path(__file__).resolve().parent.parent\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n\napp = FastAPI(title=\"Job-Ka API\", version=\"1.0\",\n description=\"Agrégateur d'offres d'emploi — employeurs québécois, \"\n \"directement à la source\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n# connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py\napp.include_router(auth.router)\n\n_sync_lock = threading.Lock()\n\n\ndef _row_to_dict(row) -> dict:\n d = dict(row)\n d[\"benefits\"] = json.loads(d.get(\"benefits\") or \"[]\")\n d[\"requirements\"] = json.loads(d.get(\"requirements\") or \"{}\")\n d[\"details\"] = json.loads(d.get(\"details\") or \"{}\")\n if d.get(\"dup_sources\"):\n try:\n d[\"dup_sources\"] = json.loads(d[\"dup_sources\"])\n except (ValueError, TypeError):\n d[\"dup_sources\"] = []\n # offre directe = page carrière de l'employeur ou dépôt direct (≠ portail)\n d[\"is_direct\"] = d.get(\"source\") not in AGGREGATORS\n return d\n\n\n@app.get(\"/health\")\ndef health():\n \"\"\"Healthcheck de déploiement : BD accessible + fraîcheur des syncs.\"\"\"\n try:\n con = db.connect()\n total = con.execute(\n \"SELECT COUNT(*) c FROM jobs WHERE active=1\").fetchone()[\"c\"]\n last = con.execute(\n \"SELECT MAX(ts) ts FROM sync_log WHERE ok=1\").fetchone()[\"ts\"]\n con.close()\n except Exception as exc:\n raise HTTPException(500, f\"BD inaccessible : {exc}\")\n return {\"status\": \"ok\", \"active_jobs\": total,\n \"last_sync_age_s\": round(time.time() - last) if last else None}\n\n\n@app.get(\"/api/jobs\")\ndef list_jobs(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n employer: str | None = None,\n source: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n salary_min: float | None = None, # $/an (converti côté serveur)\n with_salary: int | None = None, # 1 = transparence salariale seulement\n posted_after: str | None = None, # ISO : offres publiées depuis…\n language: str | None = None, # fr | en | bilingue\n q: str | None = None,\n active: int = 1,\n quarantined: int | None = None, # 1 = quarantaine seulement (supervision)\n sort: str = \"recent\", # recent | salary\n limit: int = Query(100, le=2000),\n offset: int = 0,\n):\n con = db.connect()\n sql = \"SELECT * FROM jobs WHERE dup_of IS NULL\" # doublons masqués\n args: list = []\n # quarantaine qualité : jamais publiée par défaut (voir jobka/quality.py)\n sql += \" AND quarantine IS NOT NULL\" if quarantined == 1 \\\n else \" AND quarantine IS NULL\"\n if active in (0, 1):\n sql += \" AND active=?\"; args.append(active)\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if language:\n sql += \" AND language=?\"; args.append(language)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if employer:\n sql += \" AND employer LIKE ?\"; args.append(f\"%{employer}%\")\n if source:\n sql += \" AND source=?\"; args.append(source)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if posted_after:\n sql += \" AND date_posted IS NOT NULL AND date_posted>=?\"\n args.append(posted_after)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ? OR description LIKE ?)\"\n args += [f\"%{q}%\"] * 3\n total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n if sort == \"salary\":\n sql += \" ORDER BY salary_year_max IS NULL, salary_year_max DESC\"\n else:\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC\"\n sql += \" LIMIT ? OFFSET ?\"\n args += [limit, offset]\n rows = []\n for r in con.execute(sql, args).fetchall():\n d = _row_to_dict(r)\n d.pop(\"description\", None) # liste allégée : la fiche a le texte complet\n rows.append(d)\n con.close()\n return {\"total\": total, \"count\": len(rows), \"jobs\": rows}\n\n\n@app.get(\"/api/jobs.geojson\")\ndef jobs_geojson(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n salary_min: float | None = None,\n with_salary: int | None = None,\n language: str | None = None,\n q: str | None = None,\n bbox: str | None = None,\n limit: int = Query(3000, le=8000),\n):\n \"\"\"Offres géolocalisées au format GeoJSON (mêmes filtres que /api/jobs).\n\n `bbox=ouest,sud,est,nord` restreint au rectangle visible (idx_jobs_geo).\n Membres additionnels : `totalGeocoded` / `totalMatching` pour afficher\n « N résultats sans position sur la carte ».\n \"\"\"\n con = db.connect()\n sql = (\"SELECT uid, title, title_clean, employer, city, category, work_mode,\"\n \" employment_type, salary_min, salary_max, salary_unit,\"\n \" salary_year_min, salary_year_max, date_posted, source, lat, lng\"\n \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n \" AND lat IS NOT NULL AND lng IS NOT NULL\")\n args: list = []\n if bbox:\n try:\n west, south, east, north = (float(v) for v in bbox.split(\",\"))\n except ValueError:\n raise HTTPException(400, \"bbox attendu : ouest,sud,est,nord\")\n sql += \" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\"\n args += [south, north, west, east]\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += \" AND salary_year_max IS NOT NULL AND salary_year_max>=?\"\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if language:\n sql += \" AND language=?\"; args.append(language)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ?)\"\n args += [f\"%{q}%\"] * 2\n\n total_geo = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n sql_all = sql.replace(\" AND lat IS NOT NULL AND lng IS NOT NULL\", \"\")\n args_all = list(args)\n if bbox:\n sql_all = sql_all.replace(\" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\", \"\")\n del args_all[0:4]\n total_all = con.execute(f\"SELECT COUNT(*) c FROM ({sql_all})\", args_all).fetchone()[\"c\"]\n\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC LIMIT ?\"\n args.append(limit)\n\n features = []\n for r in con.execute(sql, args).fetchall():\n features.append({\n \"type\": \"Feature\",\n \"geometry\": {\"type\": \"Point\", \"coordinates\": [r[\"lng\"], r[\"lat\"]]},\n \"properties\": {\n \"uid\": r[\"uid\"], \"title\": r[\"title_clean\"] or r[\"title\"],\n \"employer\": r[\"employer\"],\n \"city\": r[\"city\"], \"category\": r[\"category\"],\n \"work_mode\": r[\"work_mode\"],\n \"employment_type\": r[\"employment_type\"],\n \"salary_min\": r[\"salary_min\"], \"salary_max\": r[\"salary_max\"],\n \"salary_unit\": r[\"salary_unit\"],\n \"salary_year_min\": r[\"salary_year_min\"],\n \"salary_year_max\": r[\"salary_year_max\"],\n \"date_posted\": r[\"date_posted\"], \"source\": r[\"source\"],\n \"is_direct\": r[\"source\"] not in AGGREGATORS,\n },\n })\n con.close()\n return {\"type\": \"FeatureCollection\", \"features\": features,\n \"totalGeocoded\": total_geo, \"totalMatching\": total_all}\n\n\n@app.get(\"/api/jobs/{uid}\")\ndef get_job(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM jobs WHERE uid=?\", (uid,)).fetchone()\n d = None\n if row is not None:\n d = _row_to_dict(row)\n d[\"salary_history\"] = [dict(r) for r in con.execute(\n \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\"\n \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]\n con.close()\n if d is None:\n raise HTTPException(404, \"Offre introuvable\")\n return d\n\n\n@app.get(\"/api/facets\")\ndef facets(city: str | None = None):\n \"\"\"Valeurs distinctes pour construire les filtres du frontend.\"\"\"\n con = db.connect()\n base = \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n out = {\n \"cities\": [r[\"city\"] for r in con.execute(\n f\"SELECT DISTINCT city{base} AND city<>'' ORDER BY city\")],\n # régions administratives (17) — le repli générique « Québec »\n # (appartenance provinciale sans région précise) n'est pas une facette\n \"regions\": [dict(r) for r in con.execute(\n f\"SELECT region, COUNT(*) n{base} AND region<>''\"\n \" AND region<>'Québec' GROUP BY region ORDER BY region\")],\n \"languages\": [dict(r) for r in con.execute(\n f\"SELECT language, COUNT(*) n{base} AND language IS NOT NULL\"\n \" AND language<>'' GROUP BY language ORDER BY n DESC\")],\n \"categories\": [dict(r) for r in con.execute(\n f\"SELECT category, COUNT(*) n{base} AND category<>''\"\n \" GROUP BY category ORDER BY n DESC\")],\n \"employers\": [dict(r) for r in con.execute(\n f\"SELECT employer, COUNT(*) n{base} AND employer<>''\"\n \" GROUP BY employer ORDER BY n DESC LIMIT 100\")],\n \"work_modes\": [r[\"work_mode\"] for r in con.execute(\n f\"SELECT DISTINCT work_mode{base} AND work_mode IS NOT NULL\"\n \" ORDER BY work_mode\")],\n \"employment_types\": [r[\"employment_type\"] for r in con.execute(\n f\"SELECT DISTINCT employment_type{base}\"\n \" AND employment_type IS NOT NULL ORDER BY employment_type\")],\n \"sources\": [dict(r) for r in con.execute(\n f\"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC\")],\n }\n con.close()\n return out\n\n\n@app.get(\"/api/sources\")\ndef sources():\n registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n con = db.connect()\n counts = {r[\"source\"]: r[\"n\"] for r in con.execute(\n \"SELECT source, COUNT(*) n FROM jobs WHERE active=1 GROUP BY source\")}\n last = {r[\"source\"]: r[\"ts\"] for r in con.execute(\n \"SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source\")}\n con.close()\n for s in registry:\n s[\"active_jobs\"] = counts.get(s[\"id\"], 0)\n s[\"last_sync\"] = last.get(s[\"id\"])\n return {\"sources\": registry}\n\n\n@app.get(\"/api/stats\")\ndef stats(syncs_since_h: float | None = None):\n con = db.connect()\n row = con.execute(\n \"\"\"SELECT COUNT(*) total,\n COUNT(DISTINCT employer) employers,\n COUNT(DISTINCT source) sources,\n SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary,\n AVG(salary_year_min) avg_salary_year,\n SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote,\n SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND quarantine IS NULL\"\"\").fetchone()\n quarantined = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\"\n ).fetchone()[\"n\"]\n cities = [dict(r) for r in con.execute(\n \"\"\"SELECT city, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND city<>''\n GROUP BY city ORDER BY n DESC LIMIT 15\"\"\")]\n categories = [dict(r) for r in con.execute(\n \"\"\"SELECT category, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND category<>''\n GROUP BY category ORDER BY n DESC\"\"\")]\n # Fenêtre élargie pour la supervision (api-ka) : sans le paramètre, on\n # garde les 20 dernières entrées ; avec, toute l'activité de la fenêtre\n # (sinon le moniteur bi-horaire ne voit qu'une fraction de la rotation\n # des sources → fausses alertes « stale »).\n if syncs_since_h and syncs_since_h > 0:\n cutoff = time.time() - syncs_since_h * 3600\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000\",\n (cutoff,))]\n if len(log) < 20:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n else:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n # offres directes (pages carrières + dépôt direct) c. portails agrégateurs\n agg = sorted(AGGREGATORS) or [\"__aucun__\"]\n ph = \",\".join(\"?\" * len(agg))\n direct = con.execute(\n f\"\"\"SELECT COUNT(*) n, COUNT(DISTINCT employer) e,\n COUNT(DISTINCT source) s\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source NOT IN ({ph})\"\"\", agg).fetchone()\n portal = con.execute(\n f\"\"\"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source IN ({ph})\"\"\", agg).fetchone()\n dups = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1\"\n \" AND dup_of IS NOT NULL\").fetchone()\n con.close()\n return {**dict(row), \"top_cities\": cities, \"categories\": categories,\n \"direct_jobs\": direct[\"n\"], \"direct_employers\": direct[\"e\"],\n \"direct_sources\": direct[\"s\"], \"portal_jobs\": portal[\"n\"],\n \"duplicates_hidden\": dups[\"n\"], \"quarantined\": quarantined,\n \"recent_syncs\": log}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"Tableau de bord analytique — contrat commun Groupe KA (SPEC.md).\n Cache mémoire 5 min par clé de période (voir jobka/statsdash.py).\"\"\"\n from . import statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n return statsdash.compute(period, from_, to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None,\n mode: str = \"complet\"):\n \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes : complet | synthese |\n tendances | repartitions | donnees (mode inconnu -> complet).\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n if mode not in kapdf.REPORT_MODES:\n mode = \"complet\"\n dash = statsdash.compute(period, from_, to)\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash,\n mode=mode,\n ).build()\n fname = kapdf.filename(\"job-ka\", period, mode)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/stats/catalog\")\ndef stats_catalog(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"v3 — blocs composables pour le constructeur de rapports personnalisés.\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, from_, to)\n return {\"updated\": dash.get(\"updated\"), \"period\": dash.get(\"period\"),\n \"blocks\": kapdf.catalog(dash)}\n\n\n@app.post(\"/api/stats/report/custom\")\ndef stats_report_custom(spec: dict = Body(...)):\n \"\"\"v3 — rapport PDF personnalisé : {\"title\", \"period\", \"from\", \"to\",\n \"blocks\": [{\"key\": \"series:…\", \"render\": \"bar\"}, …]} (SPEC.md §3bis).\"\"\"\n from . import kapdf, statsdash\n period = str(spec.get(\"period\") or \"30j\")\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, str(spec.get(\"from\") or \"\") or None,\n str(spec.get(\"to\") or \"\") or None)\n known = {b[\"key\"] for b in kapdf.catalog(dash)}\n blocks = [b for b in (spec.get(\"blocks\") or [])\n if isinstance(b, dict) and b.get(\"key\") in known][:40]\n if not blocks:\n raise HTTPException(400, \"Aucun bloc valide dans la composition\")\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash, mode=kapdf.CUSTOM_MODE,\n spec={\"title\": str(spec.get(\"title\") or \"\")[:80], \"blocks\": blocks},\n ).build()\n fname = kapdf.filename(\"job-ka\", period, kapdf.CUSTOM_MODE)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n# --- Dépôt direct d'offres par les employeurs ---------------------------------\nclass OffreDeposee(BaseModel):\n \"\"\"Offre soumise par un employeur (validation minimale, modérée ensuite).\"\"\"\n\n employer: str = Field(min_length=2, max_length=120)\n title: str = Field(min_length=3, max_length=160)\n description: str = Field(min_length=30, max_length=20000)\n contact_email: str = Field(min_length=5, max_length=160)\n city: str = Field(\"\", max_length=80)\n url: str = Field(\"\", max_length=400)\n salary_label: str = Field(\"\", max_length=120)\n employment_type: str | None = None\n work_mode: str | None = None\n website: str = \"\" # pot de miel anti-robots : doit rester vide\n\n\n@app.post(\"/api/employeurs/offres\")\ndef deposer_offre(offre: OffreDeposee):\n \"\"\"Canal de dépôt direct : l'offre est stockée INACTIVE (source\n « depot-direct ») en attente de modération manuelle. Voir /employeurs.\"\"\"\n if offre.website: # robot pris au pot de miel\n return {\"status\": \"reçu\"}\n if not re.match(r\"^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$\", offre.contact_email):\n raise HTTPException(422, \"Courriel de contact invalide\")\n if offre.url and not re.match(r\"^https?://\", offre.url):\n raise HTTPException(422, \"Lien invalide : http(s):// requis\")\n if offre.employment_type and offre.employment_type not in (\n \"temps_plein\", \"temps_partiel\", \"contractuel\", \"stage\", \"saisonnier\"):\n raise HTTPException(422, \"Type d'emploi inconnu\")\n if offre.work_mode and offre.work_mode not in (\n \"presentiel\", \"hybride\", \"teletravail\"):\n raise HTTPException(422, \"Mode de travail inconnu\")\n\n job = JobPosting(\n source=\"depot-direct\", external_id=secrets.token_hex(6),\n url=offre.url, employer=offre.employer, title=offre.title,\n description=offre.description, city=offre.city,\n salary_label=offre.salary_label,\n employment_type=offre.employment_type, work_mode=offre.work_mode,\n ats=\"depot-direct\",\n )\n job.details[\"contact_email\"] = offre.contact_email\n job.details[\"moderation\"] = \"en_attente\"\n job.finalize()\n\n now = time.time()\n cols = {\n \"uid\": job.uid, \"source\": job.source, \"external_id\": job.external_id,\n \"url\": job.url, \"employer\": job.employer, \"title\": job.title,\n \"description\": job.description, \"city\": job.city,\n \"region\": job.region, \"postal_code\": job.postal_code,\n \"location_label\": job.location_label, \"work_mode\": job.work_mode,\n \"employment_type\": job.employment_type,\n \"salary_min\": job.salary_min, \"salary_max\": job.salary_max,\n \"salary_unit\": job.salary_unit, \"salary_label\": job.salary_label,\n \"salary_year_min\": job.salary_year_min(),\n \"salary_year_max\": job.salary_year_max(),\n \"salary_hour_min\": job.salary_hour_min(),\n \"salary_hour_max\": job.salary_hour_max(),\n \"benefits\": json.dumps(job.benefits, ensure_ascii=False),\n \"requirements\": json.dumps(job.requirements, ensure_ascii=False),\n \"category\": job.category, \"ats\": job.ats,\n \"details\": json.dumps(job.details, ensure_ascii=False),\n \"content_hash\": job.content_hash(),\n \"first_seen\": now, \"last_seen\": now, \"updated_at\": now,\n \"miss_count\": 0, \"active\": 0, # inactive tant que non modérée\n }\n con = db.connect()\n con.execute(\n f\"INSERT INTO jobs ({', '.join(cols)}) VALUES \"\n f\"({', '.join(':' + k for k in cols)})\", cols)\n con.commit()\n con.close()\n return {\"status\": \"reçu\", \"uid\": job.uid,\n \"message\": \"Merci ! Votre offre a bien été reçue. Elle sera \"\n \"vérifiée par notre équipe avant publication.\"}\n\n\n@app.post(\"/api/sync\")\ndef trigger_sync(background: BackgroundTasks, source: str | None = None):\n \"\"\"Déclenche une synchronisation (équivalent d'un webhook entrant).\"\"\"\n def _job():\n with _sync_lock:\n ingest.run([source] if source else None)\n background.add_task(_job)\n return {\"status\": \"démarré\", \"source\": source or \"toutes\"}\n\n\n# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ------\n@app.get(\"/api/favorites\")\ndef favorites(request: Request):\n \"\"\"Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n items = hubfav.hub_list(user.get(\"ka_id\") or \"\")\n if items is None:\n raise HTTPException(502, \"Hub Groupe KA injoignable — réessayez\")\n return {\"ids\": [i[\"item_id\"] for i in items if i.get(\"item_id\")],\n \"items\": items}\n\n\n@app.post(\"/api/favorites/toggle\")\ndef toggle_favorite(request: Request, body: dict = Body(...)):\n \"\"\"Ajoute (on=true) ou retire (on=false) un favori — poussé au hub\n Groupe KA de façon synchrone : le hub est la seule source de vérité.\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n ka_id = user.get(\"ka_id\") or \"\"\n if not hubfav.linked(ka_id):\n raise HTTPException(403, \"Compte non relié au hub Groupe KA\")\n on = bool(body.get(\"on\"))\n item = hubfav.clean_item(body.get(\"item\") or {})\n if not item.get(\"item_id\"):\n raise HTTPException(422, \"item.item_id requis\")\n if not hubfav.hub_toggle(ka_id, \"add\" if on else \"remove\", item):\n raise HTTPException(502, \"Hub Groupe KA injoignable — favori non enregistré\")\n return {\"ok\": True, \"on\": on}\n\n\n# --- Frontend (build Vite) ---------------------------------------------------\nif FRONTEND_DIST.exists():\n app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIST / \"assets\"), name=\"assets\")\n\n # SEO : SSR léger des routes publiques (accueil, fiches emploi, pages\n # statiques), robots.txt et sitemaps — DOIT être inclus AVANT le\n # rattrape-tout SPA ci-dessous (l'ordre d'enregistrement fait foi).\n from . import seo # noqa: E402\n app.include_router(seo.router)\n\n @app.middleware(\"http\")\n async def _cache_headers(request, call_next):\n \"\"\"Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé —\n sinon les navigateurs gardent une vieille version après un déploiement.\"\"\"\n resp = await call_next(request)\n path = request.url.path\n if path.startswith(\"/assets/\"):\n resp.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n elif \"text/html\" in (resp.headers.get(\"content-type\") or \"\"):\n resp.headers[\"Cache-Control\"] = \"no-cache\"\n return resp\n\n # routes rendues uniquement côté client — tout autre chemin inconnu renvoie\n # index.html avec un statut 404 (pas de soft-404 pour les moteurs)\n _CLIENT_ROUTES = {\"carte\", \"sources\", \"stats\", \"contact\", \"employeurs\",\n \"favoris\", \"confidentialite\", \"conditions\"}\n _CLIENT_PREFIXES = (\"emploi/\",)\n\n @app.get(\"/{full_path:path}\")\n def spa(full_path: str):\n target = FRONTEND_DIST / full_path\n if full_path and target.is_file():\n return FileResponse(target)\n if full_path and target.is_dir() and (target / \"index.html\").is_file():\n # sous-répertoire statique avec index (ex. /doc/) — servi tel quel\n return FileResponse(target / \"index.html\")\n known = (full_path == \"\" or full_path in _CLIENT_ROUTES\n or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))\n return FileResponse(FRONTEND_DIST / \"index.html\",\n status_code=200 if known else 404,\n headers={\"Cache-Control\": \"no-cache\"})\n","numLines":606,"startLine":1,"totalLines":606}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbsV4o3kDUzR44ajVDk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Uqs2CZdr2C9cSAZ7FhjgCe","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/db.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":9805,"cache_read_input_tokens":45476,"cache_creation":{"ephemeral_5m_input_tokens":9805,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"86485496-1c90-47d4-8079-5e0a17c36714","request_id":"req_011CePbsSuZaXyYRu4LGpiot"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Uqs2CZdr2C9cSAZ7FhjgCe","type":"tool_result","content":"1\t# =============================================================================\n2\t# Job·Ka — Groupe KA\n3\t# Auteur : Simon-Pierre Boucher\n4\t# Contact : contact@spboucher.ai\n5\t# Fichier : jobka/db.py\n6\t# Rôle : Persistance SQLite — upsert avec détection de changements, cycle de\n7\t# vie avec délai de grâce, anti-dérive, expiration sur date limite\n8\t# Créé : 2026-08-17 Modifié : 2026-08-17\n9\t# =============================================================================\n10\tfrom __future__ import annotations\n11\t\n12\timport datetime as _dt\n13\timport json\n14\timport sqlite3\n15\timport statistics\n16\timport time\n17\tfrom pathlib import Path\n18\t\n19\tfrom .schema import JobPosting\n20\t\n21\tDB_PATH = Path(__file__).resolve().parent.parent / \"data\" / \"jobka.db\"\n22\t\n23\t# Nombre d'exécutions consécutives où une offre doit être absente de la\n24\t# source avant d'être désactivée (délai de grâce contre les ratés ponctuels).\n25\tMISS_GRACE = 2\n26\t\n27\t# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique\n28\t# (médiane >= DRIFT_MIN_BASE offres), on alerte et on suspend les retraits.\n29\tDRIFT_RATIO = 0.25\n30\tDRIFT_MIN_BASE = 8\n31\tDRIFT_HISTORY = 5\n32\t\n33\t_SCHEMA = \"\"\"\n34\tCREATE TABLE IF NOT EXISTS jobs (\n35\t uid TEXT PRIMARY KEY,\n36\t source TEXT NOT NULL,\n37\t external_id TEXT NOT NULL,\n38\t url TEXT,\n39\t employer TEXT,\n40\t title TEXT,\n41\t description TEXT,\n42\t address TEXT,\n43\t city TEXT,\n44\t region TEXT,\n45\t postal_code TEXT,\n46\t location_label TEXT,\n47\t work_mode TEXT,\n48\t employment_type TEXT,\n49\t salary_min REAL,\n50\t salary_max REAL,\n51\t salary_unit TEXT,\n52\t salary_label TEXT,\n53\t salary_year_min REAL, -- conversions dérivées ($/an, $/h) pour filtres/tri\n54\t salary_year_max REAL,\n55\t salary_hour_min REAL,\n56\t salary_hour_max REAL,\n57\t benefits TEXT, -- JSON (liste de textes source)\n58\t requirements TEXT, -- JSON (scolarité, expérience, langues)\n59\t date_posted TEXT,\n60\t date_deadline TEXT,\n61\t category TEXT,\n62\t ats TEXT,\n63\t details TEXT, -- JSON (champs structurés, sparse)\n64\t lat REAL,\n65\t lng REAL,\n66\t geocode_failed INTEGER DEFAULT 0,\n67\t content_hash TEXT,\n68\t first_seen REAL,\n69\t last_seen REAL,\n70\t updated_at REAL,\n71\t miss_count INTEGER DEFAULT 0,\n72\t active INTEGER DEFAULT 1,\n73\t dup_of TEXT, -- uid de l'offre canonique si doublon inter-sources\n74\t dup_sources TEXT -- JSON : autres sources où l'offre est publiée\n75\t);\n76\tCREATE INDEX IF NOT EXISTS idx_jobs_source ON jobs(source);\n77\tCREATE INDEX IF NOT EXISTS idx_jobs_city ON jobs(city);\n78\tCREATE INDEX IF NOT EXISTS idx_jobs_active ON jobs(active);\n79\tCREATE INDEX IF NOT EXISTS idx_jobs_category ON jobs(category);\n80\tCREATE INDEX IF NOT EXISTS idx_jobs_geo ON jobs(lat, lng);\n81\t\n82\tCREATE TABLE IF NOT EXISTS sync_log (\n83\t id INTEGER PRIMARY KEY AUTOINCREMENT,\n84\t source TEXT,\n85\t ts REAL,\n86\t found INTEGER,\n87\t added INTEGER,\n88\t updated INTEGER,\n89\t removed INTEGER,\n90\t ok INTEGER,\n91\t message TEXT,\n92\t stats TEXT -- JSON : taux de champs null, missed, alerte…\n93\t);\n94\t\n95\tCREATE TABLE IF NOT EXISTS detail_cache (\n96\t source TEXT NOT NULL,\n97\t external_id TEXT NOT NULL,\n98\t key TEXT, -- hash du contenu « liste » de l'offre\n99\t payload TEXT, -- JSON opaque propre au connecteur\n100\t fetched_at REAL,\n101\t PRIMARY KEY (source, external_id)\n102\t);\n103\t\n104\tCREATE TABLE IF NOT EXISTS salary_log (\n105\t uid TEXT NOT NULL,\n106\t ts REAL NOT NULL,\n107\t s_min REAL,\n108\t s_max REAL,\n109\t unit TEXT\n110\t);\n111\tCREATE INDEX IF NOT EXISTS idx_salary_log_uid ON salary_log(uid);\n112\t\n113\tCREATE TABLE IF NOT EXISTS users (\n114\t id INTEGER PRIMARY KEY AUTOINCREMENT,\n115\t ka_sub TEXT UNIQUE, -- « ka:{sub} » identifiant stable du hub KA\n116\t ka_id TEXT, -- identifiant membre « ka-0123456789 » (hub)\n117\t email TEXT,\n118\t name TEXT,\n119\t picture TEXT, -- URL de l'avatar (hub / Google)\n120\t created_at REAL,\n121\t last_login REAL\n122\t);\n123\tCREATE UNIQUE INDEX IF NOT EXISTS users_ka_id ON users(ka_id);\n124\t\n125\tCREATE TABLE IF NOT EXISTS geocode_cache (\n126\t address TEXT PRIMARY KEY, -- adresse/ville normalisée (clé de cache)\n127\t lat REAL,\n128\t lng REAL,\n129\t provider TEXT,\n130\t failed INTEGER DEFAULT 0,\n131\t ts REAL\n132\t);\n133\t\"\"\"\n134\t\n135\t# Colonnes ajoutées après la v1 — migration automatique des bases existantes.\n136\t_MIGRATIONS: dict[str, dict[str, str]] = {\n137\t \"jobs\": {\n138\t # Phase 2 (2026-08-19) — enrichissement des connecteurs\n139\t \"company_logo\": \"TEXT\", # URL du logo employeur (breezy, JSON-LD…)\n140\t \"language\": \"TEXT\", # fr | en | bilingue (source ou heuristique)\n141\t \"apply_url\": \"TEXT\", # lien de candidature directe\n142\t \"title_clean\": \"TEXT\", # titre d'affichage normalisé (source intacte)\n143\t \"quarantine\": \"TEXT\", # JSON des motifs qualité (NULL = publiable)\n144\t },\n145\t \"sync_log\": {},\n146\t}\n147\t\n148\t\n149\tdef connect() -> sqlite3.Connection:\n150\t DB_PATH.parent.mkdir(parents=True, exist_ok=True)\n151\t con = sqlite3.connect(DB_PATH, timeout=30.0)\n152\t con.row_factory = sqlite3.Row\n153\t con.execute(\"PRAGMA busy_timeout=30000\") # BD vivante (sync horaire)\n154\t con.executescript(_SCHEMA)\n155\t for table, cols in _MIGRATIONS.items():\n156\t existing = {r[\"name\"] for r in con.execute(f\"PRAGMA table_info({table})\")}\n157\t for col, decl in cols.items():\n158\t if col not in existing:\n159\t con.execute(f\"ALTER TABLE {table} ADD COLUMN {col} {decl}\")\n160\t con.commit()\n161\t return con\n162\t\n163\t\n164\t# ---------------------------------------------------------------------------\n165\t# Synchronisation d'une source\n166\t# ---------------------------------------------------------------------------\n167\t\n168\tdef _drift_alert(con: sqlite3.Connection, source: str, found: int,\n169\t null_salary_rate: float) -> str | None:\n170\t \"\"\"Détecte une dérive du connecteur (chute du volume ou des salaires extraits).\n171\t\n172\t Retourne un message d'alerte, ou None si tout est normal.\n173\t \"\"\"\n174\t hist = con.execute(\n175\t \"SELECT found, stats FROM sync_log WHERE source=? AND ok=1\"\n176\t \" ORDER BY ts DESC LIMIT ?\", (source, DRIFT_HISTORY)).fetchall()\n177\t if len(hist) < 3:\n178\t return None\n179\t med_found = statistics.median(r[\"found\"] for r in hist)\n180\t if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:\n181\t return (f\"dérive: {found} offre(s) trouvée(s) contre une médiane de \"\n182\t f\"{med_found:.0f} — retraits suspendus, vérifier le connecteur\")\n183\t if found >= DRIFT_MIN_BASE and null_salary_rate >= 0.95:\n184\t rates = []\n185\t for r in hist:\n186\t try:\n187\t rates.append(json.loads(r[\"stats\"] or \"{}\")[\"null_salary_rate\"])\n188\t except (KeyError, ValueError, TypeError):\n189\t continue\n190\t if rates and statistics.median(rates) <= 0.5:\n191\t return (f\"dérive: {null_salary_rate:.0%} des offres sans salaire \"\n192\t f\"(habituellement {statistics.median(rates):.0%}) — \"\n193\t \"le format de la source a probablement changé\")\n194\t return None\n195\t\n196\t\n197\tdef sync_source(con: sqlite3.Connection, source: str,\n198\t postings: list[JobPosting]) -> dict:\n199\t \"\"\"Synchronise les offres d'une source.\n200\t\n201\t - nouvelle offre -> insertion\n202\t - offre modifiée -> mise à jour (comparaison de content_hash)\n203\t - offre disparue -> miss_count += 1, puis active=0 après MISS_GRACE\n204\t exécutions consécutives (délai de grâce)\n205\t - dérive détectée -> alerte consignée, retraits suspendus\n206\t \"\"\"\n207\t now = time.time()\n208\t added = updated = 0\n209\t seen_uids = set()\n210\t\n211\t n = len(postings)\n212\t null_salary = sum(1 for p in postings if p.salary_min is None)\n213\t null_city = sum(1 for p in postings if not p.city)\n214\t null_salary_rate = round(null_salary / n, 3) if n else 0.0\n215\t\n216\t alert = _drift_alert(con, source, n, null_salary_rate)\n217\t\n218\t from .normalize import clean_title\n219\t from .quality import issues_for_posting\n220\t\n221\t for job in postings:\n222\t seen_uids.add(job.uid)\n223\t h = job.content_hash()\n224\t row = con.execute(\n225\t \"SELECT content_hash, salary_min, salary_max FROM jobs WHERE uid=?\",\n226\t (job.uid,)).fetchone()\n227\t defects = issues_for_posting(job)\n228\t params = dict(\n229\t uid=job.uid, source=job.source, external_id=job.external_id,\n230\t url=job.url, employer=job.employer, title=job.title,\n231\t description=job.description, address=job.address, city=job.city,\n232\t region=job.region, postal_code=job.postal_code,\n233\t location_label=job.location_label, work_mode=job.work_mode,\n234\t employment_type=job.employment_type,\n235\t salary_min=job.salary_min, salary_max=job.salary_max,\n236\t salary_unit=job.salary_unit, salary_label=job.salary_label,\n237\t salary_year_min=job.salary_year_min(),\n238\t salary_year_max=job.salary_year_max(),\n239\t salary_hour_min=job.salary_hour_min(),\n240\t salary_hour_max=job.salary_hour_max(),\n241\t benefits=json.dumps(job.benefits, ensure_ascii=False),\n242\t requirements=json.dumps(job.requirements, ensure_ascii=False),\n243\t date_posted=job.date_posted, date_deadline=job.date_deadline,\n244\t category=job.category, ats=job.ats,\n245\t details=json.dumps(job.details, ensure_ascii=False),\n246\t lat=job.lat, lng=job.lng, content_hash=h, now=now,\n247\t company_logo=job.company_logo, language=job.language,\n248\t apply_url=job.apply_url,\n249\t title_clean=clean_title(job.title, job.city),\n250\t quarantine=json.dumps(defects, ensure_ascii=False)\n251\t if defects else None,\n252\t )\n253\t if row is None:\n254\t con.execute(\n255\t \"\"\"INSERT INTO jobs (uid, source, external_id, url, employer,\n256\t title, description, address, city, region, postal_code,\n257\t location_label, work_mode, employment_type, salary_min,\n258\t salary_max, salary_unit, salary_label, salary_year_min,\n259\t salary_year_max, salary_hour_min, salary_hour_max, benefits,\n260\t requirements, date_posted, date_deadline, category, ats,\n261\t details, lat, lng, content_hash, first_seen, last_seen,\n262\t updated_at, miss_count, active, company_logo, language,\n263\t apply_url, title_clean, quarantine)\n264\t VALUES (:uid,:source,:external_id,:url,:employer,:title,\n265\t :description,:address,:city,:region,:postal_code,\n266\t :location_label,:work_mode,:employment_type,:salary_min,\n267\t :salary_max,:salary_unit,:salary_label,:salary_year_min,\n268\t :salary_year_max,:salary_hour_min,:salary_hour_max,\n269\t :benefits,:requirements,:date_posted,:date_deadline,\n270\t :category,:ats,:details,:lat,:lng,:content_hash,\n271\t :now,:now,:now,0,1,:company_logo,:language,:apply_url,\n272\t :title_clean,:quarantine)\"\"\", params)\n273\t if job.salary_min is not None: # point de départ de l'historique\n274\t con.execute(\n275\t \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n276\t \" VALUES (?,?,?,?,?)\",\n277\t (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n278\t added += 1\n279\t elif row[\"content_hash\"] != h:\n280\t # COALESCE : ne jamais écraser des coordonnées géocodées par null.\n281\t # Si l'adresse change, geocode_failed est remis à 0 (correction du\n282\t # bogue relevé chez Lou·Ka) pour permettre un re-géocodage.\n283\t con.execute(\n284\t \"\"\"UPDATE jobs SET url=:url, employer=:employer, title=:title,\n285\t description=:description,\n286\t geocode_failed=CASE WHEN address<>:address OR city<>:city\n287\t THEN 0 ELSE geocode_failed END,\n288\t address=:address, city=:city, region=:region,\n289\t postal_code=:postal_code, location_label=:location_label,\n290\t work_mode=:work_mode, employment_type=:employment_type,\n291\t salary_min=:salary_min, salary_max=:salary_max,\n292\t salary_unit=:salary_unit, salary_label=:salary_label,\n293\t salary_year_min=:salary_year_min,\n294\t salary_year_max=:salary_year_max,\n295\t salary_hour_min=:salary_hour_min,\n296\t salary_hour_max=:salary_hour_max,\n297\t benefits=:benefits, requirements=:requirements,\n298\t date_posted=:date_posted, date_deadline=:date_deadline,\n299\t category=:category, ats=:ats, details=:details,\n300\t lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),\n301\t company_logo=:company_logo, language=:language,\n302\t apply_url=:apply_url, title_clean=:title_clean,\n303\t quarantine=:quarantine,\n304\t content_hash=:content_hash, last_seen=:now,\n305\t updated_at=:now, miss_count=0, active=1\n306\t WHERE uid=:uid\"\"\", params)\n307\t if (job.salary_min, job.salary_max) != (row[\"salary_min\"], row[\"salary_max\"]):\n308\t con.execute(\n309\t \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n310\t \" VALUES (?,?,?,?,?)\",\n311\t (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n312\t updated += 1\n313\t else:\n314\t con.execute(\n315\t \"UPDATE jobs SET last_seen=?, miss_count=0, active=1 WHERE uid=?\",\n316\t (now, job.uid))\n317\t\n318\t # Offres de cette source qui n'apparaissent plus : délai de grâce,\n319\t # puis désactivation. Suspendu si une dérive est détectée.\n320\t removed = missed = 0\n321\t if not alert:\n322\t for r in con.execute(\n323\t \"SELECT uid, miss_count FROM jobs WHERE source=? AND active=1\",\n324\t (source,)).fetchall():\n325\t if r[\"uid\"] in seen_uids:\n326\t continue\n327\t missed += 1\n328\t if r[\"miss_count\"] + 1 >= MISS_GRACE:\n329\t con.execute(\n330\t \"UPDATE jobs SET active=0, miss_count=?, updated_at=?\"\n331\t \" WHERE uid=?\", (r[\"miss_count\"] + 1, now, r[\"uid\"]))\n332\t removed += 1\n333\t else:\n334\t con.execute(\"UPDATE jobs SET miss_count=miss_count+1 WHERE uid=?\",\n335\t (r[\"uid\"],))\n336\t\n337\t stats = {\n338\t \"null_salary_rate\": null_salary_rate,\n339\t \"null_city_rate\": round(null_city / n, 3) if n else 0.0,\n340\t \"missed\": missed,\n341\t }\n342\t if alert:\n343\t stats[\"alert\"] = alert\n344\t con.execute(\n345\t \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,\"\n346\t \" message, stats) VALUES (?,?,?,?,?,?,1,?,?)\",\n347\t (source, now, n, added, updated, removed, alert or \"ok\",\n348\t json.dumps(stats, ensure_ascii=False)))\n349\t con.commit()\n350\t out = {\"source\": source, \"found\": n, \"added\": added,\n351\t \"updated\": updated, \"removed\": removed}\n352\t if alert:\n353\t out[\"alert\"] = alert\n354\t return out\n355\t\n356\t\n357\tdef expire_past_deadline(con: sqlite3.Connection) -> int:\n358\t \"\"\"Désactive les offres dont la date limite est dépassée (offres zombies :\n359\t encore affichées sur la page carrière mais plus ouvertes aux candidatures).\"\"\"\n360\t today = _dt.date.today().isoformat()\n361\t cur = con.execute(\n362\t \"UPDATE jobs SET active=0, updated_at=? WHERE active=1\"\n363\t \" AND date_deadline IS NOT NULL AND date_deadline < ?\",\n364\t (time.time(), today))\n365\t con.commit()\n366\t return cur.rowcount\n367\t\n368\t\n369\tSTALE_DAYS = 7 # offres plus jamais revues (connecteur retiré/muet) : archivées\n370\t\n371\t\n372\tdef expire_stale(con: sqlite3.Connection, days: int = STALE_DAYS) -> int:\n373\t \"\"\"Archive (active=0) les offres actives non revues depuis `days` jours.\n374\t\n375\t Filet de sécurité complémentaire au délai de grâce MISS_GRACE : couvre le\n376\t cas d'un connecteur SUPPRIMÉ du code (ses offres ne passent plus jamais\n377\t par sync_source et resteraient actives pour toujours — cas « valero »).\n378\t Rien n'est supprimé : les offres sont archivées, réactivables si revues.\n379\t \"\"\"\n380\t cutoff = time.time() - days * 86400\n381\t cur = con.execute(\n382\t \"UPDATE jobs SET active=0, updated_at=? WHERE active=1 AND last_seen'depot-direct'\", # le dépôt direct n'est jamais resynchronisé\n384\t (time.time(), cutoff))\n385\t con.commit()\n386\t return cur.rowcount\n387\t\n388\t\n389\tdef log_failure(con: sqlite3.Connection, source: str, message: str) -> None:\n390\t con.execute(\n391\t \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)\"\n392\t \" VALUES (?,?,0,0,0,0,0,?)\", (source, time.time(), message))\n393\t con.commit()\n394\t\n395\t\n396\t# ---------------------------------------------------------------------------\n397\t# Cache des pages détail (« détail si nouveau/modifié »)\n398\t# ---------------------------------------------------------------------------\n399\t\n400\tdef get_cached_detail(con: sqlite3.Connection, source: str,\n401\t external_id: str, key: str) -> dict | None:\n402\t \"\"\"Payload détail mis en cache si la clé (hash liste) n'a pas changé.\"\"\"\n403\t row = con.execute(\n404\t \"SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?\",\n405\t (source, external_id)).fetchone()\n406\t if row and row[\"key\"] == key and row[\"payload\"]:\n407\t try:\n408\t return json.loads(row[\"payload\"])\n409\t except ValueError:\n410\t return None\n411\t return None\n412\t\n413\t\n414\tdef get_stale_detail(con: sqlite3.Connection, source: str,\n415\t external_id: str) -> dict | None:\n416\t \"\"\"Payload détail en cache MÊME si la clé a changé — secours quand le\n417\t budget de re-visites est épuisé : un détail périmé vaut mieux qu'une\n418\t description vide (la re-visite aura lieu à un prochain cycle).\"\"\"\n419\t row = con.execute(\n420\t \"SELECT payload FROM detail_cache WHERE source=? AND external_id=?\",\n421\t (source, external_id)).fetchone()\n422\t if row and row[\"payload\"]:\n423\t try:\n424\t return json.loads(row[\"payload\"])\n425\t except ValueError:\n426\t return None\n427\t return None\n428\t\n429\t\n430\tdef put_cached_detail(con: sqlite3.Connection, source: str,\n431\t external_id: str, key: str, payload: dict) -> None:\n432\t con.execute(\n433\t \"INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)\"\n434\t \" VALUES (?,?,?,?,?)\"\n435\t \" ON CONFLICT(source, external_id) DO UPDATE SET\"\n436\t \" key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at\",\n437\t (source, external_id, key, json.dumps(payload, ensure_ascii=False),\n438\t time.time()))\n439\t con.commit()\n440\t"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"205a6405-74b7-4d5b-8322-0845de6cb52e","timestamp":"2026-08-25T14:11:41.126Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/db.py","content":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/db.py\n# Rôle : Persistance SQLite — upsert avec détection de changements, cycle de\n# vie avec délai de grâce, anti-dérive, expiration sur date limite\n# Créé : 2026-08-17 Modifié : 2026-08-17\n# =============================================================================\nfrom __future__ import annotations\n\nimport datetime as _dt\nimport json\nimport sqlite3\nimport statistics\nimport time\nfrom pathlib import Path\n\nfrom .schema import JobPosting\n\nDB_PATH = Path(__file__).resolve().parent.parent / \"data\" / \"jobka.db\"\n\n# Nombre d'exécutions consécutives où une offre doit être absente de la\n# source avant d'être désactivée (délai de grâce contre les ratés ponctuels).\nMISS_GRACE = 2\n\n# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique\n# (médiane >= DRIFT_MIN_BASE offres), on alerte et on suspend les retraits.\nDRIFT_RATIO = 0.25\nDRIFT_MIN_BASE = 8\nDRIFT_HISTORY = 5\n\n_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS jobs (\n uid TEXT PRIMARY KEY,\n source TEXT NOT NULL,\n external_id TEXT NOT NULL,\n url TEXT,\n employer TEXT,\n title TEXT,\n description TEXT,\n address TEXT,\n city TEXT,\n region TEXT,\n postal_code TEXT,\n location_label TEXT,\n work_mode TEXT,\n employment_type TEXT,\n salary_min REAL,\n salary_max REAL,\n salary_unit TEXT,\n salary_label TEXT,\n salary_year_min REAL, -- conversions dérivées ($/an, $/h) pour filtres/tri\n salary_year_max REAL,\n salary_hour_min REAL,\n salary_hour_max REAL,\n benefits TEXT, -- JSON (liste de textes source)\n requirements TEXT, -- JSON (scolarité, expérience, langues)\n date_posted TEXT,\n date_deadline TEXT,\n category TEXT,\n ats TEXT,\n details TEXT, -- JSON (champs structurés, sparse)\n lat REAL,\n lng REAL,\n geocode_failed INTEGER DEFAULT 0,\n content_hash TEXT,\n first_seen REAL,\n last_seen REAL,\n updated_at REAL,\n miss_count INTEGER DEFAULT 0,\n active INTEGER DEFAULT 1,\n dup_of TEXT, -- uid de l'offre canonique si doublon inter-sources\n dup_sources TEXT -- JSON : autres sources où l'offre est publiée\n);\nCREATE INDEX IF NOT EXISTS idx_jobs_source ON jobs(source);\nCREATE INDEX IF NOT EXISTS idx_jobs_city ON jobs(city);\nCREATE INDEX IF NOT EXISTS idx_jobs_active ON jobs(active);\nCREATE INDEX IF NOT EXISTS idx_jobs_category ON jobs(category);\nCREATE INDEX IF NOT EXISTS idx_jobs_geo ON jobs(lat, lng);\n\nCREATE TABLE IF NOT EXISTS sync_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n source TEXT,\n ts REAL,\n found INTEGER,\n added INTEGER,\n updated INTEGER,\n removed INTEGER,\n ok INTEGER,\n message TEXT,\n stats TEXT -- JSON : taux de champs null, missed, alerte…\n);\n\nCREATE TABLE IF NOT EXISTS detail_cache (\n source TEXT NOT NULL,\n external_id TEXT NOT NULL,\n key TEXT, -- hash du contenu « liste » de l'offre\n payload TEXT, -- JSON opaque propre au connecteur\n fetched_at REAL,\n PRIMARY KEY (source, external_id)\n);\n\nCREATE TABLE IF NOT EXISTS salary_log (\n uid TEXT NOT NULL,\n ts REAL NOT NULL,\n s_min REAL,\n s_max REAL,\n unit TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_salary_log_uid ON salary_log(uid);\n\nCREATE TABLE IF NOT EXISTS users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ka_sub TEXT UNIQUE, -- « ka:{sub} » identifiant stable du hub KA\n ka_id TEXT, -- identifiant membre « ka-0123456789 » (hub)\n email TEXT,\n name TEXT,\n picture TEXT, -- URL de l'avatar (hub / Google)\n created_at REAL,\n last_login REAL\n);\nCREATE UNIQUE INDEX IF NOT EXISTS users_ka_id ON users(ka_id);\n\nCREATE TABLE IF NOT EXISTS geocode_cache (\n address TEXT PRIMARY KEY, -- adresse/ville normalisée (clé de cache)\n lat REAL,\n lng REAL,\n provider TEXT,\n failed INTEGER DEFAULT 0,\n ts REAL\n);\n\"\"\"\n\n# Colonnes ajoutées après la v1 — migration automatique des bases existantes.\n_MIGRATIONS: dict[str, dict[str, str]] = {\n \"jobs\": {\n # Phase 2 (2026-08-19) — enrichissement des connecteurs\n \"company_logo\": \"TEXT\", # URL du logo employeur (breezy, JSON-LD…)\n \"language\": \"TEXT\", # fr | en | bilingue (source ou heuristique)\n \"apply_url\": \"TEXT\", # lien de candidature directe\n \"title_clean\": \"TEXT\", # titre d'affichage normalisé (source intacte)\n \"quarantine\": \"TEXT\", # JSON des motifs qualité (NULL = publiable)\n },\n \"sync_log\": {},\n}\n\n\ndef connect() -> sqlite3.Connection:\n DB_PATH.parent.mkdir(parents=True, exist_ok=True)\n con = sqlite3.connect(DB_PATH, timeout=30.0)\n con.row_factory = sqlite3.Row\n con.execute(\"PRAGMA busy_timeout=30000\") # BD vivante (sync horaire)\n con.executescript(_SCHEMA)\n for table, cols in _MIGRATIONS.items():\n existing = {r[\"name\"] for r in con.execute(f\"PRAGMA table_info({table})\")}\n for col, decl in cols.items():\n if col not in existing:\n con.execute(f\"ALTER TABLE {table} ADD COLUMN {col} {decl}\")\n con.commit()\n return con\n\n\n# ---------------------------------------------------------------------------\n# Synchronisation d'une source\n# ---------------------------------------------------------------------------\n\ndef _drift_alert(con: sqlite3.Connection, source: str, found: int,\n null_salary_rate: float) -> str | None:\n \"\"\"Détecte une dérive du connecteur (chute du volume ou des salaires extraits).\n\n Retourne un message d'alerte, ou None si tout est normal.\n \"\"\"\n hist = con.execute(\n \"SELECT found, stats FROM sync_log WHERE source=? AND ok=1\"\n \" ORDER BY ts DESC LIMIT ?\", (source, DRIFT_HISTORY)).fetchall()\n if len(hist) < 3:\n return None\n med_found = statistics.median(r[\"found\"] for r in hist)\n if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:\n return (f\"dérive: {found} offre(s) trouvée(s) contre une médiane de \"\n f\"{med_found:.0f} — retraits suspendus, vérifier le connecteur\")\n if found >= DRIFT_MIN_BASE and null_salary_rate >= 0.95:\n rates = []\n for r in hist:\n try:\n rates.append(json.loads(r[\"stats\"] or \"{}\")[\"null_salary_rate\"])\n except (KeyError, ValueError, TypeError):\n continue\n if rates and statistics.median(rates) <= 0.5:\n return (f\"dérive: {null_salary_rate:.0%} des offres sans salaire \"\n f\"(habituellement {statistics.median(rates):.0%}) — \"\n \"le format de la source a probablement changé\")\n return None\n\n\ndef sync_source(con: sqlite3.Connection, source: str,\n postings: list[JobPosting]) -> dict:\n \"\"\"Synchronise les offres d'une source.\n\n - nouvelle offre -> insertion\n - offre modifiée -> mise à jour (comparaison de content_hash)\n - offre disparue -> miss_count += 1, puis active=0 après MISS_GRACE\n exécutions consécutives (délai de grâce)\n - dérive détectée -> alerte consignée, retraits suspendus\n \"\"\"\n now = time.time()\n added = updated = 0\n seen_uids = set()\n\n n = len(postings)\n null_salary = sum(1 for p in postings if p.salary_min is None)\n null_city = sum(1 for p in postings if not p.city)\n null_salary_rate = round(null_salary / n, 3) if n else 0.0\n\n alert = _drift_alert(con, source, n, null_salary_rate)\n\n from .normalize import clean_title\n from .quality import issues_for_posting\n\n for job in postings:\n seen_uids.add(job.uid)\n h = job.content_hash()\n row = con.execute(\n \"SELECT content_hash, salary_min, salary_max FROM jobs WHERE uid=?\",\n (job.uid,)).fetchone()\n defects = issues_for_posting(job)\n params = dict(\n uid=job.uid, source=job.source, external_id=job.external_id,\n url=job.url, employer=job.employer, title=job.title,\n description=job.description, address=job.address, city=job.city,\n region=job.region, postal_code=job.postal_code,\n location_label=job.location_label, work_mode=job.work_mode,\n employment_type=job.employment_type,\n salary_min=job.salary_min, salary_max=job.salary_max,\n salary_unit=job.salary_unit, salary_label=job.salary_label,\n salary_year_min=job.salary_year_min(),\n salary_year_max=job.salary_year_max(),\n salary_hour_min=job.salary_hour_min(),\n salary_hour_max=job.salary_hour_max(),\n benefits=json.dumps(job.benefits, ensure_ascii=False),\n requirements=json.dumps(job.requirements, ensure_ascii=False),\n date_posted=job.date_posted, date_deadline=job.date_deadline,\n category=job.category, ats=job.ats,\n details=json.dumps(job.details, ensure_ascii=False),\n lat=job.lat, lng=job.lng, content_hash=h, now=now,\n company_logo=job.company_logo, language=job.language,\n apply_url=job.apply_url,\n title_clean=clean_title(job.title, job.city),\n quarantine=json.dumps(defects, ensure_ascii=False)\n if defects else None,\n )\n if row is None:\n con.execute(\n \"\"\"INSERT INTO jobs (uid, source, external_id, url, employer,\n title, description, address, city, region, postal_code,\n location_label, work_mode, employment_type, salary_min,\n salary_max, salary_unit, salary_label, salary_year_min,\n salary_year_max, salary_hour_min, salary_hour_max, benefits,\n requirements, date_posted, date_deadline, category, ats,\n details, lat, lng, content_hash, first_seen, last_seen,\n updated_at, miss_count, active, company_logo, language,\n apply_url, title_clean, quarantine)\n VALUES (:uid,:source,:external_id,:url,:employer,:title,\n :description,:address,:city,:region,:postal_code,\n :location_label,:work_mode,:employment_type,:salary_min,\n :salary_max,:salary_unit,:salary_label,:salary_year_min,\n :salary_year_max,:salary_hour_min,:salary_hour_max,\n :benefits,:requirements,:date_posted,:date_deadline,\n :category,:ats,:details,:lat,:lng,:content_hash,\n :now,:now,:now,0,1,:company_logo,:language,:apply_url,\n :title_clean,:quarantine)\"\"\", params)\n if job.salary_min is not None: # point de départ de l'historique\n con.execute(\n \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n \" VALUES (?,?,?,?,?)\",\n (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n added += 1\n elif row[\"content_hash\"] != h:\n # COALESCE : ne jamais écraser des coordonnées géocodées par null.\n # Si l'adresse change, geocode_failed est remis à 0 (correction du\n # bogue relevé chez Lou·Ka) pour permettre un re-géocodage.\n con.execute(\n \"\"\"UPDATE jobs SET url=:url, employer=:employer, title=:title,\n description=:description,\n geocode_failed=CASE WHEN address<>:address OR city<>:city\n THEN 0 ELSE geocode_failed END,\n address=:address, city=:city, region=:region,\n postal_code=:postal_code, location_label=:location_label,\n work_mode=:work_mode, employment_type=:employment_type,\n salary_min=:salary_min, salary_max=:salary_max,\n salary_unit=:salary_unit, salary_label=:salary_label,\n salary_year_min=:salary_year_min,\n salary_year_max=:salary_year_max,\n salary_hour_min=:salary_hour_min,\n salary_hour_max=:salary_hour_max,\n benefits=:benefits, requirements=:requirements,\n date_posted=:date_posted, date_deadline=:date_deadline,\n category=:category, ats=:ats, details=:details,\n lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),\n company_logo=:company_logo, language=:language,\n apply_url=:apply_url, title_clean=:title_clean,\n quarantine=:quarantine,\n content_hash=:content_hash, last_seen=:now,\n updated_at=:now, miss_count=0, active=1\n WHERE uid=:uid\"\"\", params)\n if (job.salary_min, job.salary_max) != (row[\"salary_min\"], row[\"salary_max\"]):\n con.execute(\n \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n \" VALUES (?,?,?,?,?)\",\n (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n updated += 1\n else:\n con.execute(\n \"UPDATE jobs SET last_seen=?, miss_count=0, active=1 WHERE uid=?\",\n (now, job.uid))\n\n # Offres de cette source qui n'apparaissent plus : délai de grâce,\n # puis désactivation. Suspendu si une dérive est détectée.\n removed = missed = 0\n if not alert:\n for r in con.execute(\n \"SELECT uid, miss_count FROM jobs WHERE source=? AND active=1\",\n (source,)).fetchall():\n if r[\"uid\"] in seen_uids:\n continue\n missed += 1\n if r[\"miss_count\"] + 1 >= MISS_GRACE:\n con.execute(\n \"UPDATE jobs SET active=0, miss_count=?, updated_at=?\"\n \" WHERE uid=?\", (r[\"miss_count\"] + 1, now, r[\"uid\"]))\n removed += 1\n else:\n con.execute(\"UPDATE jobs SET miss_count=miss_count+1 WHERE uid=?\",\n (r[\"uid\"],))\n\n stats = {\n \"null_salary_rate\": null_salary_rate,\n \"null_city_rate\": round(null_city / n, 3) if n else 0.0,\n \"missed\": missed,\n }\n if alert:\n stats[\"alert\"] = alert\n con.execute(\n \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,\"\n \" message, stats) VALUES (?,?,?,?,?,?,1,?,?)\",\n (source, now, n, added, updated, removed, alert or \"ok\",\n json.dumps(stats, ensure_ascii=False)))\n con.commit()\n out = {\"source\": source, \"found\": n, \"added\": added,\n \"updated\": updated, \"removed\": removed}\n if alert:\n out[\"alert\"] = alert\n return out\n\n\ndef expire_past_deadline(con: sqlite3.Connection) -> int:\n \"\"\"Désactive les offres dont la date limite est dépassée (offres zombies :\n encore affichées sur la page carrière mais plus ouvertes aux candidatures).\"\"\"\n today = _dt.date.today().isoformat()\n cur = con.execute(\n \"UPDATE jobs SET active=0, updated_at=? WHERE active=1\"\n \" AND date_deadline IS NOT NULL AND date_deadline < ?\",\n (time.time(), today))\n con.commit()\n return cur.rowcount\n\n\nSTALE_DAYS = 7 # offres plus jamais revues (connecteur retiré/muet) : archivées\n\n\ndef expire_stale(con: sqlite3.Connection, days: int = STALE_DAYS) -> int:\n \"\"\"Archive (active=0) les offres actives non revues depuis `days` jours.\n\n Filet de sécurité complémentaire au délai de grâce MISS_GRACE : couvre le\n cas d'un connecteur SUPPRIMÉ du code (ses offres ne passent plus jamais\n par sync_source et resteraient actives pour toujours — cas « valero »).\n Rien n'est supprimé : les offres sont archivées, réactivables si revues.\n \"\"\"\n cutoff = time.time() - days * 86400\n cur = con.execute(\n \"UPDATE jobs SET active=0, updated_at=? WHERE active=1 AND last_seen'depot-direct'\", # le dépôt direct n'est jamais resynchronisé\n (time.time(), cutoff))\n con.commit()\n return cur.rowcount\n\n\ndef log_failure(con: sqlite3.Connection, source: str, message: str) -> None:\n con.execute(\n \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)\"\n \" VALUES (?,?,0,0,0,0,0,?)\", (source, time.time(), message))\n con.commit()\n\n\n# ---------------------------------------------------------------------------\n# Cache des pages détail (« détail si nouveau/modifié »)\n# ---------------------------------------------------------------------------\n\ndef get_cached_detail(con: sqlite3.Connection, source: str,\n external_id: str, key: str) -> dict | None:\n \"\"\"Payload détail mis en cache si la clé (hash liste) n'a pas changé.\"\"\"\n row = con.execute(\n \"SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?\",\n (source, external_id)).fetchone()\n if row and row[\"key\"] == key and row[\"payload\"]:\n try:\n return json.loads(row[\"payload\"])\n except ValueError:\n return None\n return None\n\n\ndef get_stale_detail(con: sqlite3.Connection, source: str,\n external_id: str) -> dict | None:\n \"\"\"Payload détail en cache MÊME si la clé a changé — secours quand le\n budget de re-visites est épuisé : un détail périmé vaut mieux qu'une\n description vide (la re-visite aura lieu à un prochain cycle).\"\"\"\n row = con.execute(\n \"SELECT payload FROM detail_cache WHERE source=? AND external_id=?\",\n (source, external_id)).fetchone()\n if row and row[\"payload\"]:\n try:\n return json.loads(row[\"payload\"])\n except ValueError:\n return None\n return None\n\n\ndef put_cached_detail(con: sqlite3.Connection, source: str,\n external_id: str, key: str, payload: dict) -> None:\n con.execute(\n \"INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)\"\n \" VALUES (?,?,?,?,?)\"\n \" ON CONFLICT(source, external_id) DO UPDATE SET\"\n \" key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at\",\n (source, external_id, key, json.dumps(payload, ensure_ascii=False),\n time.time()))\n con.commit()\n","numLines":440,"startLine":1,"totalLines":440}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4986af1e-82aa-4cc1-8526-84f61291fbd7","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"abc45465-f629-4d8c-b686-6cb40e2caf3f","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":126,"estimated_tokens_delta":76,"uuid":"d707c2a7-6b78-4990-8e8c-1fdbf883ddcd","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbuAozaq4qGm24vVm5e","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS7wMKpwEIERgCKkCLmqQV+e65ZXuSfEoC9xVZoo4eEmn59RgLZqbSkGdyeUF7lODhsuFCnp12u6v//CmXqY5cVX5SEg1hcRd4zttkMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAGkxbbUBrABAhIMh01Vw7Qab4wuxrPvGgzUGNxV0wqBfT3KNygiMK8Xu92hZFXvpR7BMOyilKHvurj4X5itDVunm8IJP3nhxq3Si5Q5eaNwIm49sAM2fCr0AQTE/OxAGS4T3JR9dRpbV70k67f41YSh22U0HpMsJufTreTw4gOU884OealrKyRsSkbtr5hCZOavsdCGW3WDlDbqqXqDdvmFy0vbxAHAEwswrWUh4p59xZG6L9vH5yG2Ee2P2H0fCr4wppdeatiNPX5hEpJzCoT0wtDX8NnzEw0PUW08ojwvX+dvxvVRcKcFjNR0R9ohkK5FBAIB3XHKiGLfJgnbzavj74SsMDsgyusS4jkd2QZmTuFy7i2ovN68PD2lTId1SkZaynqgPUdvH6+2qrD6g2AuXK/h3j3XaozLtgIvFZiDf2PnoouiOfR/UtoOA+gYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":22054,"cache_read_input_tokens":55281,"cache_creation":{"ephemeral_5m_input_tokens":22054,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"dfe1936c-b560-4315-8eea-62ccc2d31ad3","request_id":"req_011CePbu97JaFi4XZayhEykp"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbuAozaq4qGm24vVm5e","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HRDw75u1tNrReQ7G9yjEy9","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":22054,"cache_read_input_tokens":55281,"cache_creation":{"ephemeral_5m_input_tokens":22054,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"f061eeb2-724d-4782-b550-0d56d18b74b4","request_id":"req_011CePbu97JaFi4XZayhEykp"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HRDw75u1tNrReQ7G9yjEy9","type":"tool_result","content":"1\t# =============================================================================\n2\t# Job·Ka — Groupe KA\n3\t# Auteur : Simon-Pierre Boucher\n4\t# Contact : contact@spboucher.ai\n5\t# Fichier : jobka/normalize.py\n6\t# Rôle : Normalisation commune des offres (salaires, dates, mode/type\n7\t# d'emploi, lieu, catégorie) — jamais de valeur inventée\n8\t# Créé : 2026-08-17 Modifié : 2026-08-18\n9\t# =============================================================================\n10\t\"\"\"Couche de normalisation commune appelée par JobPosting.finalize().\n11\t\n12\tPhilosophie héritée de Lou·Ka : parseurs déterministes (regex, tables de\n13\tmots-clés FR/EN), bornes de plausibilité, et surtout — une valeur absente\n14\treste absente (None / chaîne vide), on ne devine jamais.\n15\t\"\"\"\n16\tfrom __future__ import annotations\n17\t\n18\timport datetime as _dt\n19\timport re\n20\timport unicodedata\n21\t\n22\t__all__ = [\n23\t \"strip_accents\", \"clean_text\", \"clean_html\", \"parse_salary\",\n24\t \"sanitize_salary\", \"salary_from_text\", \"salary_to_yearly\",\n25\t \"salary_to_hourly\", \"parse_date\", \"parse_work_mode\",\n26\t \"parse_employment_type\", \"parse_location\", \"canonical_city\",\n27\t \"is_quebec_location\", \"categorize\", \"detect_language\",\n28\t \"extract_benefits\", \"clean_title\",\n29\t]\n30\t\n31\t\n32\t# ---------------------------------------------------------------------------\n33\t# Texte\n34\t# ---------------------------------------------------------------------------\n35\t\n36\tdef strip_accents(s: str) -> str:\n37\t return \"\".join(c for c in unicodedata.normalize(\"NFD\", s)\n38\t if unicodedata.category(c) != \"Mn\")\n39\t\n40\t\n41\tdef _key(s: str) -> str:\n42\t \"\"\"Minuscules sans accents — pour le matching de mots-clés.\"\"\"\n43\t return strip_accents((s or \"\").lower())\n44\t\n45\t\n46\tdef clean_text(s: str) -> str:\n47\t \"\"\"Espaces insécables -> espace, blancs réduits.\"\"\"\n48\t s = (s or \"\").replace(\"\\xa0\", \" \").replace(\" \", \" \").replace(\" \", \" \")\n49\t return re.sub(r\"[ \\t]+\", \" \", s).strip()\n50\t\n51\t\n52\t_BLOCK_TAGS = re.compile(\n53\t r\"]*>\", re.I)\n54\t\n55\t# Résidu HTML après une passe : balise ouvrante plausible ou entité encore\n56\t# encodée — signe d'un contenu doublement échappé.\n57\t_HTML_LEFTOVER = re.compile(\n58\t r\"<(?:p|br|li|ul|ol|div|span|strong|em|b|i|h[1-6]|a)\\b|&(?:amp|lt|gt|nbsp|#\\d+);\",\n59\t re.I)\n60\t\n61\t\n62\tdef clean_html(html: str) -> str:\n63\t \"\"\"HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré.\n64\t\n65\t Suffisant pour les descriptions d'offres (les connecteurs qui ont besoin\n66\t d'un vrai parseur utilisent BeautifulSoup eux-mêmes).\n67\t \"\"\"\n68\t if not html:\n69\t return \"\"\n70\t import html as _html\n71\t s = html\n72\t # Contenus sur-échappés (entités dans les entités — Greenhouse/Lever) :\n73\t # nettoyer en boucle tant qu'il reste des balises/entités (max 3 passes).\n74\t for _ in range(3):\n75\t s = _html.unescape(s)\n76\t s = re.sub(r\"<(script|style)[^>]*>.*?\", \" \", s, flags=re.I | re.S)\n77\t s = _BLOCK_TAGS.sub(\"\\n\", s)\n78\t s = re.sub(r\"<[^>]+>\", \" \", s)\n79\t if not _HTML_LEFTOVER.search(s):\n80\t break\n81\t s = s.replace(\"\\xa0\", \" \").replace(\" \", \" \")\n82\t s = re.sub(r\"[ \\t]+\", \" \", s)\n83\t s = re.sub(r\" ?\\n ?\", \"\\n\", s)\n84\t return re.sub(r\"\\n{3,}\", \"\\n\\n\", s).strip()\n85\t\n86\t\n87\t# ---------------------------------------------------------------------------\n88\t# Salaires — transparence salariale = différenciateur clé de Job·Ka\n89\t# ---------------------------------------------------------------------------\n90\t\n91\t# Bornes de plausibilité par unité (CAD). Hors bornes = on jette (pas de\n92\t# salaire inventé, pas de « 1 $/h » venu d'un parsing raté).\n93\t_SALARY_BOUNDS = {\n94\t \"hour\": (14.0, 250.0),\n95\t \"day\": (100.0, 2000.0),\n96\t \"week\": (500.0, 10000.0),\n97\t \"biweek\": (1000.0, 20000.0),\n98\t \"month\": (1800.0, 45000.0),\n99\t \"year\": (20000.0, 600000.0),\n100\t}\n101\t\n102\t# Heures/an de référence pour les conversions (semaine de 40 h).\n103\t_HOURS_PER_YEAR = 2080.0\n104\t\n105\t_UNIT_PATTERNS = [\n106\t (\"hour\", r\"(?:/|par|per|an?)\\s*(?:h\\b|hr\\b|hre\\b|heure|hour)|horaire|hourly\"),\n107\t (\"year\", r\"(?:/|par|per|an?)\\s*(?:an\\b|année|annee|year|yr\\b)|annuel|annual|yearly|per\\s+annum\"),\n108\t (\"month\", r\"(?:/|par|per)\\s*(?:mois|month)|mensuel|monthly\"),\n109\t (\"biweek\", r\"(?:aux|par|per)\\s*(?:2|deux|two)\\s*semaines|bi-?weekly|quinzaine\"),\n110\t (\"week\", r\"(?:/|par|per)\\s*(?:sem\\b|semaine|week|wk\\b)|hebdomadaire|weekly\"),\n111\t (\"day\", r\"(?:/|par|per)\\s*(?:jour|day)|journalier|daily\"),\n112\t]\n113\t\n114\t_NUM_RE = re.compile(r\"\\d{1,3}(?:[   ,]\\d{3})+(?:[.,]\\d{1,2})?|\\d+(?:[.,]\\d{1,2})?[kK]?\")\n115\t\n116\t\n117\tdef _parse_number(tok: str) -> float | None:\n118\t tok = tok.strip()\n119\t k = tok.lower().endswith(\"k\")\n120\t if k:\n121\t tok = tok[:-1]\n122\t # « 52,000 » (milliers anglais) vs « 52000,00 » / « 25,50 » (décimales)\n123\t tok = tok.replace(\" \", \" \").replace(\" \", \" \")\n124\t if re.fullmatch(r\"\\d{1,3}(?:[ ,]\\d{3})+(?:[.,]\\d{1,2})?\", tok):\n125\t # séparateur de milliers : retirer espaces/virgules de groupe\n126\t tok = re.sub(r\"[ ,](?=\\d{3}\\b)\", \"\", tok)\n127\t tok = tok.replace(\",\", \".\").replace(\" \", \"\")\n128\t try:\n129\t v = float(tok)\n130\t except ValueError:\n131\t return None\n132\t return v * 1000 if k else v\n133\t\n134\t\n135\tdef parse_salary(raw: str) -> tuple[float | None, float | None, str | None]:\n136\t \"\"\"Texte de salaire -> (min, max, unité) ou (None, None, None).\n137\t\n138\t Exige un signe monétaire ($, CAD, dollars) OU une unité explicite proche\n139\t des nombres — sans quoi « 35 heures/semaine » deviendrait un salaire.\n140\t \"\"\"\n141\t s = clean_text(raw)\n142\t if not s:\n143\t return (None, None, None)\n144\t key = _key(s)\n145\t\n146\t unit = None\n147\t for u, pat in _UNIT_PATTERNS:\n148\t if re.search(pat, key):\n149\t unit = u\n150\t break\n151\t\n152\t has_money = bool(re.search(r\"\\$|cad\\b|dollar\", key))\n153\t if not has_money and unit is None:\n154\t return (None, None, None)\n155\t\n156\t nums = [_parse_number(m.group(0)) for m in _NUM_RE.finditer(s)]\n157\t nums = [n for n in nums if n is not None]\n158\t if not nums:\n159\t return (None, None, None)\n160\t\n161\t # Sans unité explicite : inférer par l'ordre de grandeur (avec $ obligatoire)\n162\t if unit is None:\n163\t probe = max(nums)\n164\t if probe < 300:\n165\t unit = \"hour\"\n166\t elif probe >= 18000:\n167\t unit = \"year\"\n168\t else:\n169\t return (None, None, None) # zone ambiguë : ne pas deviner\n170\t\n171\t lo, hi = _SALARY_BOUNDS[unit]\n172\t vals = sorted(n for n in nums if lo <= n <= hi)\n173\t if not vals:\n174\t return (None, None, None)\n175\t return (vals[0], vals[-1] if len(vals) > 1 else vals[0], unit)\n176\t\n177\t\n178\t_TO_YEAR = {\"hour\": _HOURS_PER_YEAR, \"day\": 260.0, \"week\": 52.0,\n179\t \"biweek\": 26.0, \"month\": 12.0, \"year\": 1.0}\n180\t\n181\t\n182\tdef salary_to_yearly(value: float | None, unit: str | None) -> float | None:\n183\t if value is None or unit not in _TO_YEAR:\n184\t return None\n185\t return round(value * _TO_YEAR[unit], 0)\n186\t\n187\t\n188\tdef salary_to_hourly(value: float | None, unit: str | None) -> float | None:\n189\t y = salary_to_yearly(value, unit)\n190\t return round(y / _HOURS_PER_YEAR, 2) if y is not None else None\n191\t\n192\t\n193\tdef sanitize_salary(lo: float | None, hi: float | None, unit: str | None\n194\t ) -> tuple[float | None, float | None, str | None]:\n195\t \"\"\"Bornes de plausibilité sur les salaires STRUCTURÉS fournis par les ATS.\n196\t\n197\t Certaines sources étiquettent mal l'unité (75 000 « /mois » ou « /h » pour\n198\t un salaire annuel — vu chez Lever et dans les JSON-LD de portails) : si la\n199\t valeur sort des bornes de son unité, on tente une réinterprétation vers\n200\t l'unité dont les bornes la contiennent (année d'abord, puis heure). Aucune\n201\t unité plausible -> on jette (pas de salaire aberrant publié).\n202\t \"\"\"\n203\t if lo is None or unit not in _SALARY_BOUNDS:\n204\t return (lo, hi, unit)\n205\t b = _SALARY_BOUNDS[unit]\n206\t if not (b[0] <= lo <= b[1]):\n207\t for candidate in (\"year\", \"hour\"):\n208\t cb = _SALARY_BOUNDS[candidate]\n209\t if candidate != unit and cb[0] <= lo <= cb[1]:\n210\t unit, b = candidate, cb\n211\t break\n212\t else:\n213\t return (None, None, None)\n214\t if hi is not None and not (b[0] <= hi <= b[1]):\n215\t hi = None # max aberrant : on garde le min plausible seul\n216\t if hi is not None and hi < lo:\n217\t lo, hi = hi, lo\n218\t return (lo, hi, unit)\n219\t\n220\t\n221\t# Contextes où un montant en $ n'est PAS un salaire (financement, dépenses,\n222\t# allocations…) — garde-fou de l'extraction depuis la description.\n223\t_NOT_SALARY_CONTEXT = re.compile(\n224\t r\"financement|funding|amorcage|seed|investisse|invest(?:ment|or)|\"\n225\t r\"revenu[s]?\\b|revenue|chiffre d affaires|budget|remboursement|\"\n226\t r\"reimburse|expense|allocation|allowance|subvention|credit d impot|\"\n227\t r\"cotisation|rabais|discount|gift|carte[- ]cadeau|referral (?:bonus|fee)|\"\n228\t r\"signing bonus|prime (?:a l embauche|de demarrage|de reference)|\"\n229\t r\"million|milliard\")\n230\t\n231\t\n232\tdef salary_from_text(text: str) -> tuple[float | None, float | None,\n233\t str | None, str]:\n234\t \"\"\"Cherche une mention salariale dans un texte libre (description).\n235\t\n236\t Découpe en phrases (les points décimaux « 182,500.00 » ne coupent pas),\n237\t ignore les contextes non salariaux (financement, remboursements…), puis\n238\t parse la première phrase plausible. Retourne (min, max, unité, libellé).\n239\t \"\"\"\n240\t if not text or \"$\" not in text:\n241\t return (None, None, None, \"\")\n242\t for sentence in re.split(r\"(? str | None:\n281\t \"\"\"Valeur de date hétéroclite -> ISO « YYYY-MM-DD », ou None.\n282\t\n283\t Accepte : ISO (+ horodatages), epoch secondes/millisecondes, formats\n284\t textuels FR/EN (« 1er juillet 2026 », « July 1st, 2026 »), et les dates\n285\t relatives (« il y a 3 jours », « Posted 30+ Days Ago »).\n286\t \"\"\"\n287\t if raw is None:\n288\t return None\n289\t today = today or _dt.date.today()\n290\t\n291\t if isinstance(raw, (int, float)): # epoch (Lever : millisecondes)\n292\t ts = float(raw)\n293\t if ts > 1e12:\n294\t ts /= 1000.0\n295\t if 1e9 <= ts <= 4e9:\n296\t return _dt.date.fromtimestamp(ts).isoformat()\n297\t return None\n298\t\n299\t s = clean_text(str(raw))\n300\t if not s:\n301\t return None\n302\t key = _key(s)\n303\t\n304\t m = re.search(r\"(\\d{4})-(\\d{2})-(\\d{2})\", s) # ISO (avec ou sans heure)\n305\t if m:\n306\t try:\n307\t return _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3))).isoformat()\n308\t except ValueError:\n309\t return None\n310\t\n311\t if _TODAY_RE.search(key):\n312\t return today.isoformat()\n313\t if _YESTERDAY_RE.search(key):\n314\t return (today - _dt.timedelta(days=1)).isoformat()\n315\t\n316\t m = _REL_RE.search(key)\n317\t if m:\n318\t n = int(m.group(1) or m.group(3))\n319\t unit = (m.group(2) or m.group(4) or \"\").lower()\n320\t days = n * (7 if unit.startswith((\"sem\", \"week\"))\n321\t else 30 if unit.startswith((\"moi\", \"month\")) else 1)\n322\t return (today - _dt.timedelta(days=days)).isoformat()\n323\t\n324\t # « 1er juillet 2026 » / « July 1st, 2026 » / « déc. 2026 »\n325\t m = re.search(r\"(?:(\\d{1,2})(?:er|e|st|nd|rd|th)?\\s+)?([a-z]{3,9})\\.?,?\\s+(?:(\\d{1,2})(?:er|st|nd|rd|th)?,?\\s+)?(\\d{4})\", key)\n326\t if m:\n327\t mois = _MONTHS.get(m.group(2))\n328\t if mois:\n329\t jour = int(m.group(1) or m.group(3) or 1)\n330\t try:\n331\t return _dt.date(int(m.group(4)), mois, jour).isoformat()\n332\t except ValueError:\n333\t return None\n334\t\n335\t m = re.fullmatch(r\"(\\d{1,2})/(\\d{1,2})/(\\d{4})\", s) # jj/mm/aaaa (usage QC)\n336\t if m:\n337\t d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))\n338\t if mo > 12 and d <= 12: # en fait mm/jj/aaaa\n339\t d, mo = mo, d\n340\t try:\n341\t return _dt.date(y, mo, d).isoformat()\n342\t except ValueError:\n343\t return None\n344\t return None\n345\t\n346\t\n347\t# ---------------------------------------------------------------------------\n348\t# Mode de travail / type d'emploi\n349\t# ---------------------------------------------------------------------------\n350\t\n351\t_MODE_RULES = [\n352\t (\"teletravail\", r\"100\\s*%\\s*(?:teletravail|remote)|full(?:y)?[ -]remote|\"\n353\t r\"teletravail|travail a distance|remote(?:\\b|-first)|a distance\"),\n354\t (\"hybride\", r\"hybrid|hybride|flexible?\\s*(?:work|travail)\"),\n355\t (\"presentiel\", r\"presentiel|sur (?:place|site)|on[- ]?site|in[- ]?(?:office|person)\"),\n356\t]\n357\t\n358\t\n359\tdef parse_work_mode(raw: str) -> str | None:\n360\t key = _key(raw)\n361\t if not key:\n362\t return None\n363\t # « hybride » prime sur les mentions accessoires de télétravail\n364\t if re.search(_MODE_RULES[1][1], key):\n365\t return \"hybride\"\n366\t for mode, pat in _MODE_RULES:\n367\t if re.search(pat, key):\n368\t return mode\n369\t return None\n370\t\n371\t\n372\t_TYPE_RULES = [\n373\t (\"stage\", r\"\\bstage\\b|stagiaire|intern(?:ship)?\\b|co-?op\\b|alternance\"),\n374\t (\"saisonnier\", r\"saisonn|seasonal\"),\n375\t (\"contractuel\", r\"contrat\\b|contract(?:uel|or)?\\b|temporaire|temporary|terme|\"\n376\t r\"fixed[- ]term|duree determinee|pigiste|freelance|consultant\"),\n377\t (\"temps_partiel\", r\"temps[- ]partiel|part[- ]?time|partiel\\b\"),\n378\t (\"temps_plein\", r\"temps[- ]plein|full[- ]?time|permanent|regulier|\"\n379\t r\"regular\\b|duree indeterminee\"),\n380\t]\n381\t\n382\t\n383\tdef parse_employment_type(raw: str) -> str | None:\n384\t key = _key(raw)\n385\t if not key:\n386\t return None\n387\t for typ, pat in _TYPE_RULES:\n388\t if re.search(pat, key):\n389\t return typ\n390\t return None\n391\t\n392\t\n393\t# ---------------------------------------------------------------------------\n394\t# Lieu — extraction ville/région + garde-fou « offre au Québec »\n395\t# ---------------------------------------------------------------------------\n396\t\n397\t# Villes québécoises fréquentes dans les libellés d'offres (clé sans accents).\n398\t# Sert à la fois au parsing et au filtre is_quebec_location.\n399\t_QC_CITIES = {\n400\t \"montreal\": \"Montréal\", \"quebec\": \"Québec\", \"ville de quebec\": \"Québec\",\n401\t \"quebec city\": \"Québec\", \"laval\": \"Laval\", \"gatineau\": \"Gatineau\",\n402\t \"longueuil\": \"Longueuil\", \"sherbrooke\": \"Sherbrooke\",\n403\t \"saguenay\": \"Saguenay\", \"chicoutimi\": \"Saguenay\",\n404\t \"levis\": \"Lévis\", \"trois-rivieres\": \"Trois-Rivières\",\n405\t \"trois rivieres\": \"Trois-Rivières\", \"terrebonne\": \"Terrebonne\",\n406\t \"brossard\": \"Brossard\", \"repentigny\": \"Repentigny\",\n407\t \"boucherville\": \"Boucherville\", \"saint-jerome\": \"Saint-Jérôme\",\n408\t \"drummondville\": \"Drummondville\", \"saint-jean-sur-richelieu\":\n409\t \"Saint-Jean-sur-Richelieu\", \"granby\": \"Granby\", \"shawinigan\": \"Shawinigan\",\n410\t \"joliette\": \"Joliette\", \"victoriaville\": \"Victoriaville\",\n411\t \"rimouski\": \"Rimouski\", \"rouyn-noranda\": \"Rouyn-Noranda\",\n412\t \"baie-comeau\": \"Baie-Comeau\", \"sept-iles\": \"Sept-Îles\",\n413\t \"val-d'or\": \"Val-d'Or\", \"val d'or\": \"Val-d'Or\", \"alma\": \"Alma\",\n414\t \"sainte-foy\": \"Québec\", \"anjou\": \"Montréal\", \"dorval\": \"Dorval\",\n415\t \"boisbriand\": \"Boisbriand\", \"blainville\": \"Blainville\",\n416\t \"mirabel\": \"Mirabel\", \"saint-hyacinthe\": \"Saint-Hyacinthe\",\n417\t \"beloeil\": \"Belœil\", \"chateauguay\": \"Châteauguay\", \"magog\": \"Magog\",\n418\t \"thetford mines\": \"Thetford Mines\", \"saint-georges\": \"Saint-Georges\",\n419\t \"riviere-du-loup\": \"Rivière-du-Loup\", \"matane\": \"Matane\",\n420\t \"sorel-tracy\": \"Sorel-Tracy\", \"varennes\": \"Varennes\",\n421\t \"vaudreuil-dorion\": \"Vaudreuil-Dorion\", \"salaberry-de-valleyfield\":\n422\t \"Salaberry-de-Valleyfield\", \"kirkland\": \"Kirkland\",\n423\t \"pointe-claire\": \"Pointe-Claire\", \"saint-laurent\": \"Montréal\",\n424\t \"ville saint-laurent\": \"Montréal\", \"verdun\": \"Montréal\",\n425\t \"westmount\": \"Westmount\", \"candiac\": \"Candiac\", \"la prairie\": \"La Prairie\",\n426\t \"mont-tremblant\": \"Mont-Tremblant\", \"saint-bruno\": \"Saint-Bruno-de-Montarville\",\n427\t \"bromont\": \"Bromont\", \"cowansville\": \"Cowansville\",\n428\t \"saint-augustin-de-desmaures\": \"Saint-Augustin-de-Desmaures\",\n429\t \"l'ancienne-lorette\": \"L'Ancienne-Lorette\",\n430\t}\n431\t\n432\t_QC_MARKERS = re.compile(\n433\t r\"\\bqc\\b|\\bquebec\\b|\\bqu[e]bec, canada\\b|province de quebec\", re.I)\n434\t_POSTAL_RE = re.compile(r\"\\b([GHJ]\\d[A-Z])\\s*(\\d[A-Z]\\d)\\b\", re.I) # G/H/J = Québec\n435\t\n436\t# Villes canadiennes hors Québec (et grandes villes étrangères) fréquentes\n437\t# dans les flux pancanadiens/mondiaux — jamais des villes d'offres\n438\t# québécoises : à rejeter lors du parsing de lieu (clés sans accents).\n439\t_NON_QC_CITIES = frozenset({\n440\t \"toronto\", \"ottawa\", \"vancouver\", \"calgary\", \"edmonton\", \"winnipeg\",\n441\t \"mississauga\", \"brampton\", \"hamilton\", \"kitchener\", \"waterloo\", \"halifax\",\n442\t \"dartmouth\", \"victoria\", \"saskatoon\", \"regina\", \"st. john's\", \"st johns\",\n443\t \"saint john\", \"fredericton\", \"moncton\", \"charlottetown\", \"london\",\n444\t \"markham\", \"vaughan\", \"richmond hill\", \"oakville\", \"burlington\", \"oshawa\",\n445\t \"whitby\", \"ajax\", \"pickering\", \"guelph\", \"kingston\", \"barrie\",\n446\t \"kelowna\", \"kamloops\", \"nanaimo\", \"abbotsford\", \"surrey\", \"burnaby\",\n447\t \"coquitlam\", \"langley\", \"north york\", \"scarborough\", \"etobicoke\",\n448\t \"north vancouver\", \"milton\", \"cambridge\", \"st. catharines\",\n449\t \"niagara falls\", \"thunder bay\", \"sudbury\", \"greater sudbury\",\n450\t \"sault ste. marie\", \"red deer\", \"lethbridge\", \"medicine hat\",\n451\t \"grande prairie\", \"fort mcmurray\", \"yellowknife\", \"whitehorse\", \"iqaluit\",\n452\t \"can\", \"canada\", \"ontario\", \"alberta\", \"british columbia\", \"manitoba\",\n453\t \"saskatchewan\", \"nova scotia\", \"new brunswick\",\n454\t \"new york\", \"new york city\", \"san francisco\", \"boston\", \"chicago\",\n455\t \"seattle\", \"austin\", \"denver\", \"los angeles\", \"miami\", \"atlanta\",\n456\t \"dallas\", \"houston\", \"philadelphia\", \"washington\", \"phoenix\", \"portland\",\n457\t \"paris\", \"londres\", \"amsterdam\", \"berlin\", \"munich\", \"dublin\", \"madrid\",\n458\t \"barcelona\", \"barcelone\", \"lisbon\", \"lisbonne\", \"sydney\", \"melbourne\",\n459\t \"singapore\", \"singapour\", \"tokyo\", \"bangalore\", \"bengaluru\", \"mumbai\",\n460\t \"mexico city\", \"sao paulo\", \"bogota\", \"buenos aires\", \"las vegas\",\n461\t \"orlando\", \"nashville\", \"macau\",\n462\t})\n463\t\n464\t\n465\tdef canonical_city(raw: str) -> str:\n466\t \"\"\"Canonicalise un nom de ville (« Montreal » -> « Montréal »,\n467\t « Quebec City » -> « Québec »). Inconnu : retourne le texte nettoyé.\"\"\"\n468\t s = clean_text(raw)\n469\t if not s:\n470\t return \"\"\n471\t key = _key(s)\n472\t exact = _QC_CITIES.get(key)\n473\t if exact:\n474\t return exact\n475\t for ck, city in _QC_CITIES.items():\n476\t if re.fullmatch(re.escape(ck), key):\n477\t return city\n478\t return s\n479\t\n480\t\n481\tdef parse_location(raw: str) -> dict:\n482\t \"\"\"Libellé de lieu -> {city, region, postal_code} (champs possiblement vides).\n483\t\n484\t Ex. « Montréal, QC, Canada », « Ville de Québec, Québec », « Remote - QC ».\n485\t \"\"\"\n486\t s = clean_text(raw)\n487\t out = {\"city\": \"\", \"region\": \"\", \"postal_code\": \"\"}\n488\t if not s:\n489\t return out\n490\t m = _POSTAL_RE.search(s)\n491\t if m:\n492\t out[\"postal_code\"] = f\"{m.group(1).upper()} {m.group(2).upper()}\"\n493\t key = _key(s)\n494\t for ck, city in _QC_CITIES.items():\n495\t if re.search(rf\"(? bool:\n519\t \"\"\"Vrai si le libellé pointe vers le Québec (filtre des ATS pancanadiens).\"\"\"\n520\t key = _key(clean_text(raw))\n521\t if not key:\n522\t return False\n523\t if key in _NON_QC_CITIES: # libellé = ville canadienne/monde hors QC\n524\t return False\n525\t if _QC_MARKERS.search(key):\n526\t return True\n527\t m = _POSTAL_RE.search(raw)\n528\t if m and m.group(1)[0].upper() in \"GHJ\":\n529\t return True\n530\t return any(re.search(rf\"(? str:\n593\t \"\"\"Langue du texte de l'offre : « fr », « en », « bilingue », ou \"\".\n594\t\n595\t Comptage de mots-outils sur le début du texte — déterministe, jamais de\n596\t valeur inventée : texte trop court ou ambigu -> \"\".\n597\t \"\"\"\n598\t text = _key(f\"{title}\\n{description or ''}\"[:4000])\n599\t if len(text) < 30:\n600\t return \"\"\n601\t fr = len(_FR_STOPWORDS.findall(text))\n602\t en = len(_EN_STOPWORDS.findall(text))\n603\t total = fr + en\n604\t if total < 5:\n605\t return \"\"\n606\t # les deux langues nettement présentes -> offre bilingue\n607\t if fr >= 8 and en >= 8 and min(fr, en) / max(fr, en) >= 0.35:\n608\t return \"bilingue\"\n609\t if fr > en:\n610\t return \"fr\"\n611\t if en > fr:\n612\t return \"en\"\n613\t return \"\"\n614\t\n615\t\n616\t# ---------------------------------------------------------------------------\n617\t# Avantages — extraction des blocs « Avantages / Benefits » des descriptions\n618\t# ---------------------------------------------------------------------------\n619\t\n620\t_BENEFITS_HEADING = re.compile(\n621\t r\"^\\s*(?:les\\s+|nos\\s+|our\\s+|tes\\s+|vos\\s+)?(?:avantages(?:\\s+sociaux)?|\"\n622\t r\"benefits(?:\\s+&\\s+perks)?|perks(?:\\s+(?:and|&)\\s+benefits)?|\"\n623\t r\"ce que nous (?:t')?offrons|what we offer|why join us|\"\n624\t r\"pourquoi nous rejoindre|nous offrons|we offer)\\s*:?\\s*$\", re.I)\n625\t_BENEFIT_LINE_MAX = 120\n626\t\n627\t\n628\tdef extract_benefits(description: str) -> list[str]:\n629\t \"\"\"Liste d'avantages depuis un bloc structuré de la description.\n630\t\n631\t Cherche un titre de section « Avantages/Benefits/… » seul sur sa ligne,\n632\t puis prend les lignes courtes qui suivent (puces), en s'arrêtant à la\n633\t première ligne longue ou au prochain titre. Max 12 items — si le bloc ne\n634\t ressemble pas à une liste, on ne retourne rien (pas d'à-peu-près).\n635\t \"\"\"\n636\t if not description:\n637\t return []\n638\t lines = description.split(\"\\n\")\n639\t for i, line in enumerate(lines):\n640\t if not _BENEFITS_HEADING.match(strip_accents(line)):\n641\t continue\n642\t items: list[str] = []\n643\t for nxt in lines[i + 1:]:\n644\t t = clean_text(nxt).lstrip(\"•-–—*·◦▪ \").strip()\n645\t if not t:\n646\t if items:\n647\t break # fin du bloc (ligne vide après les puces)\n648\t continue\n649\t if len(t) > _BENEFIT_LINE_MAX or _BENEFITS_HEADING.match(\n650\t strip_accents(t)):\n651\t break\n652\t # un « titre » de section suivant (courte ligne sans ponctuation\n653\t # finale, capitalisée) termine aussi le bloc\n654\t if items and len(t.split()) <= 4 and t.endswith(\":\"):\n655\t break\n656\t items.append(t.rstrip(\" ;,.\").strip())\n657\t if len(items) >= 12:\n658\t break\n659\t if len(items) >= 2:\n660\t return items\n661\t return []\n662\t\n663\t\n664\t# ---------------------------------------------------------------------------\n665\t# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés\n666\t# ---------------------------------------------------------------------------\n667\t\n668\t# codes de réquisition : « [R-12345] », « (REQ 2026-42) », « - JR0035678 »…\n669\t_REQ_CODE = re.compile(\n670\t # en tête : « 123456 - Titre », « [REQ-1234] Titre » (≥5 chiffres nus pour\n671\t # ne pas manger une année « 2026 - Programme d'été »)\n672\t r\"^\\s*[\\[(]?(?:req(?:uisition)?|jr|job)[\\s#:-]*\\d{3,10}[\\])]?\\s*[-–—:|]\\s*|\"\n673\t r\"^\\s*[\\[(]?\\d{5,10}[\\])]?\\s*[-–—:|]\\s*|\"\n674\t # en queue : « Titre - R-12345 », « Titre (JOB 004521) »\n675\t r\"\\s*[-–—|(]\\s*(?:req(?:uisition)?|job|poste)?\\s*#?\\s*(?:[A-Z]{1,4}-?)?\\d{4,10}\\s*[)\\]]?\\s*$|\"\n676\t # au milieu : « Titre [R-12345] suite »\n677\t r\"\\s*[\\[(](?:R|REQ|JR|JOB)[-_ ]?\\d{3,10}[\\])]\\s*\", re.I)\n678\t\n679\t# petits mots gardés en minuscules lors de la remise en casse d'un titre\n680\t# tout-majuscules ; sigles usuels gardés en majuscules\n681\t_TITLE_SMALL = {\"de\", \"du\", \"des\", \"la\", \"le\", \"les\", \"et\", \"en\", \"à\", \"a\",\n682\t \"au\", \"aux\", \"d'\", \"l'\", \"of\", \"the\", \"and\", \"in\", \"for\",\n683\t \"to\", \"or\", \"ou\", \"un\", \"une\", \"sur\", \"pour\"}\n684\t_TITLE_ACRONYMS = {\"rh\", \"ti\", \"it\", \"qa\", \"qc\", \"pab\", \"cpa\", \"cfa\", \"erp\",\n685\t \"sap\", \"crm\", \"sst\", \"cnc\", \"hvac\", \"rpa\", \"bi\", \"ia\",\n686\t \"ai\", \"vp\", \"pdg\", \"dg\"}\n687\t\n688\t\n689\tdef _smart_case(title: str) -> str:\n690\t \"\"\"Titre TOUT EN MAJUSCULES -> casse de phrase lisible (sigles préservés).\"\"\"\n691\t words = title.lower().split()\n692\t out = []\n693\t for i, w in enumerate(words):\n694\t base = w.strip(\"()[],.;:/\")\n695\t if base in _TITLE_ACRONYMS:\n696\t out.append(w.replace(base, base.upper()))\n697\t elif i > 0 and base in _TITLE_SMALL:\n698\t out.append(w)\n699\t else:\n700\t out.append(w[:1].upper() + w[1:])\n701\t return \" \".join(out)\n702\t\n703\t\n704\tdef clean_title(title: str, city: str = \"\") -> str:\n705\t \"\"\"Titre d'affichage : codes de réquisition retirés, suffixe « - »\n706\t redondant retiré, casse corrigée si tout-majuscules. Le titre SOURCE n'est\n707\t jamais modifié — ceci alimente une colonne d'affichage distincte.\"\"\"\n708\t t = clean_text(title)\n709\t if not t:\n710\t return \"\"\n711\t prev = None\n712\t while prev != t: # les codes peuvent être empilés\n713\t prev = t\n714\t t = _REQ_CODE.sub(\" \", t).strip(\" -–—|:\")\n715\t t = clean_text(t)\n716\t # suffixe « - Montréal » / « (Montréal, QC) » identique à la ville de\n717\t # l'offre : repéré sans accents, coupé sur le titre original\n718\t if city:\n719\t ck = re.escape(strip_accents(city.lower()))\n720\t m = re.search(\n721\t rf\"\\s*[-–—|,(]\\s*(?:ville de\\s+)?{ck}(?:\\s*,\\s*(?:qc|quebec))?\\s*\\)?\\s*$\",\n722\t strip_accents(t.lower()))\n723\t if m and m.start() > 0:\n724\t t = clean_text(t[:m.start()]).strip(\" -–—|,(\")\n725\t if len(t) > 8 and t == t.upper() and t != t.lower():\n726\t t = _smart_case(t)\n727\t return t or clean_text(title)\n728\t\n729\t\n730\tdef categorize(title: str, description: str = \"\") -> str:\n731\t \"\"\"Titre -> catégorie interne (repli : début de description), ou \"\".\n732\t\n733\t Le titre est essayé SEUL d'abord : la description commence souvent par le\n734\t boilerplate de l'entreprise (« leader du manufacturier… ») qui fausserait\n735\t la catégorie du poste.\n736\t \"\"\"\n737\t for texte in (title, description[:300]):\n738\t key = _key(texte or \"\")\n739\t if not key.strip():\n740\t continue\n741\t for cat, pat in _CATEGORY_RULES:\n742\t if re.search(pat, key):\n743\t return cat\n744\t return \"\"\n745\t"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"2d7d2780-84b6-4ccb-a3ad-33d0036a6082","timestamp":"2026-08-25T14:11:49.017Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py","content":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/normalize.py\n# Rôle : Normalisation commune des offres (salaires, dates, mode/type\n# d'emploi, lieu, catégorie) — jamais de valeur inventée\n# Créé : 2026-08-17 Modifié : 2026-08-18\n# =============================================================================\n\"\"\"Couche de normalisation commune appelée par JobPosting.finalize().\n\nPhilosophie héritée de Lou·Ka : parseurs déterministes (regex, tables de\nmots-clés FR/EN), bornes de plausibilité, et surtout — une valeur absente\nreste absente (None / chaîne vide), on ne devine jamais.\n\"\"\"\nfrom __future__ import annotations\n\nimport datetime as _dt\nimport re\nimport unicodedata\n\n__all__ = [\n \"strip_accents\", \"clean_text\", \"clean_html\", \"parse_salary\",\n \"sanitize_salary\", \"salary_from_text\", \"salary_to_yearly\",\n \"salary_to_hourly\", \"parse_date\", \"parse_work_mode\",\n \"parse_employment_type\", \"parse_location\", \"canonical_city\",\n \"is_quebec_location\", \"categorize\", \"detect_language\",\n \"extract_benefits\", \"clean_title\",\n]\n\n\n# ---------------------------------------------------------------------------\n# Texte\n# ---------------------------------------------------------------------------\n\ndef strip_accents(s: str) -> str:\n return \"\".join(c for c in unicodedata.normalize(\"NFD\", s)\n if unicodedata.category(c) != \"Mn\")\n\n\ndef _key(s: str) -> str:\n \"\"\"Minuscules sans accents — pour le matching de mots-clés.\"\"\"\n return strip_accents((s or \"\").lower())\n\n\ndef clean_text(s: str) -> str:\n \"\"\"Espaces insécables -> espace, blancs réduits.\"\"\"\n s = (s or \"\").replace(\"\\xa0\", \" \").replace(\" \", \" \").replace(\" \", \" \")\n return re.sub(r\"[ \\t]+\", \" \", s).strip()\n\n\n_BLOCK_TAGS = re.compile(\n r\"]*>\", re.I)\n\n# Résidu HTML après une passe : balise ouvrante plausible ou entité encore\n# encodée — signe d'un contenu doublement échappé.\n_HTML_LEFTOVER = re.compile(\n r\"<(?:p|br|li|ul|ol|div|span|strong|em|b|i|h[1-6]|a)\\b|&(?:amp|lt|gt|nbsp|#\\d+);\",\n re.I)\n\n\ndef clean_html(html: str) -> str:\n \"\"\"HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré.\n\n Suffisant pour les descriptions d'offres (les connecteurs qui ont besoin\n d'un vrai parseur utilisent BeautifulSoup eux-mêmes).\n \"\"\"\n if not html:\n return \"\"\n import html as _html\n s = html\n # Contenus sur-échappés (entités dans les entités — Greenhouse/Lever) :\n # nettoyer en boucle tant qu'il reste des balises/entités (max 3 passes).\n for _ in range(3):\n s = _html.unescape(s)\n s = re.sub(r\"<(script|style)[^>]*>.*?\", \" \", s, flags=re.I | re.S)\n s = _BLOCK_TAGS.sub(\"\\n\", s)\n s = re.sub(r\"<[^>]+>\", \" \", s)\n if not _HTML_LEFTOVER.search(s):\n break\n s = s.replace(\"\\xa0\", \" \").replace(\" \", \" \")\n s = re.sub(r\"[ \\t]+\", \" \", s)\n s = re.sub(r\" ?\\n ?\", \"\\n\", s)\n return re.sub(r\"\\n{3,}\", \"\\n\\n\", s).strip()\n\n\n# ---------------------------------------------------------------------------\n# Salaires — transparence salariale = différenciateur clé de Job·Ka\n# ---------------------------------------------------------------------------\n\n# Bornes de plausibilité par unité (CAD). Hors bornes = on jette (pas de\n# salaire inventé, pas de « 1 $/h » venu d'un parsing raté).\n_SALARY_BOUNDS = {\n \"hour\": (14.0, 250.0),\n \"day\": (100.0, 2000.0),\n \"week\": (500.0, 10000.0),\n \"biweek\": (1000.0, 20000.0),\n \"month\": (1800.0, 45000.0),\n \"year\": (20000.0, 600000.0),\n}\n\n# Heures/an de référence pour les conversions (semaine de 40 h).\n_HOURS_PER_YEAR = 2080.0\n\n_UNIT_PATTERNS = [\n (\"hour\", r\"(?:/|par|per|an?)\\s*(?:h\\b|hr\\b|hre\\b|heure|hour)|horaire|hourly\"),\n (\"year\", r\"(?:/|par|per|an?)\\s*(?:an\\b|année|annee|year|yr\\b)|annuel|annual|yearly|per\\s+annum\"),\n (\"month\", r\"(?:/|par|per)\\s*(?:mois|month)|mensuel|monthly\"),\n (\"biweek\", r\"(?:aux|par|per)\\s*(?:2|deux|two)\\s*semaines|bi-?weekly|quinzaine\"),\n (\"week\", r\"(?:/|par|per)\\s*(?:sem\\b|semaine|week|wk\\b)|hebdomadaire|weekly\"),\n (\"day\", r\"(?:/|par|per)\\s*(?:jour|day)|journalier|daily\"),\n]\n\n_NUM_RE = re.compile(r\"\\d{1,3}(?:[   ,]\\d{3})+(?:[.,]\\d{1,2})?|\\d+(?:[.,]\\d{1,2})?[kK]?\")\n\n\ndef _parse_number(tok: str) -> float | None:\n tok = tok.strip()\n k = tok.lower().endswith(\"k\")\n if k:\n tok = tok[:-1]\n # « 52,000 » (milliers anglais) vs « 52000,00 » / « 25,50 » (décimales)\n tok = tok.replace(\" \", \" \").replace(\" \", \" \")\n if re.fullmatch(r\"\\d{1,3}(?:[ ,]\\d{3})+(?:[.,]\\d{1,2})?\", tok):\n # séparateur de milliers : retirer espaces/virgules de groupe\n tok = re.sub(r\"[ ,](?=\\d{3}\\b)\", \"\", tok)\n tok = tok.replace(\",\", \".\").replace(\" \", \"\")\n try:\n v = float(tok)\n except ValueError:\n return None\n return v * 1000 if k else v\n\n\ndef parse_salary(raw: str) -> tuple[float | None, float | None, str | None]:\n \"\"\"Texte de salaire -> (min, max, unité) ou (None, None, None).\n\n Exige un signe monétaire ($, CAD, dollars) OU une unité explicite proche\n des nombres — sans quoi « 35 heures/semaine » deviendrait un salaire.\n \"\"\"\n s = clean_text(raw)\n if not s:\n return (None, None, None)\n key = _key(s)\n\n unit = None\n for u, pat in _UNIT_PATTERNS:\n if re.search(pat, key):\n unit = u\n break\n\n has_money = bool(re.search(r\"\\$|cad\\b|dollar\", key))\n if not has_money and unit is None:\n return (None, None, None)\n\n nums = [_parse_number(m.group(0)) for m in _NUM_RE.finditer(s)]\n nums = [n for n in nums if n is not None]\n if not nums:\n return (None, None, None)\n\n # Sans unité explicite : inférer par l'ordre de grandeur (avec $ obligatoire)\n if unit is None:\n probe = max(nums)\n if probe < 300:\n unit = \"hour\"\n elif probe >= 18000:\n unit = \"year\"\n else:\n return (None, None, None) # zone ambiguë : ne pas deviner\n\n lo, hi = _SALARY_BOUNDS[unit]\n vals = sorted(n for n in nums if lo <= n <= hi)\n if not vals:\n return (None, None, None)\n return (vals[0], vals[-1] if len(vals) > 1 else vals[0], unit)\n\n\n_TO_YEAR = {\"hour\": _HOURS_PER_YEAR, \"day\": 260.0, \"week\": 52.0,\n \"biweek\": 26.0, \"month\": 12.0, \"year\": 1.0}\n\n\ndef salary_to_yearly(value: float | None, unit: str | None) -> float | None:\n if value is None or unit not in _TO_YEAR:\n return None\n return round(value * _TO_YEAR[unit], 0)\n\n\ndef salary_to_hourly(value: float | None, unit: str | None) -> float | None:\n y = salary_to_yearly(value, unit)\n return round(y / _HOURS_PER_YEAR, 2) if y is not None else None\n\n\ndef sanitize_salary(lo: float | None, hi: float | None, unit: str | None\n ) -> tuple[float | None, float | None, str | None]:\n \"\"\"Bornes de plausibilité sur les salaires STRUCTURÉS fournis par les ATS.\n\n Certaines sources étiquettent mal l'unité (75 000 « /mois » ou « /h » pour\n un salaire annuel — vu chez Lever et dans les JSON-LD de portails) : si la\n valeur sort des bornes de son unité, on tente une réinterprétation vers\n l'unité dont les bornes la contiennent (année d'abord, puis heure). Aucune\n unité plausible -> on jette (pas de salaire aberrant publié).\n \"\"\"\n if lo is None or unit not in _SALARY_BOUNDS:\n return (lo, hi, unit)\n b = _SALARY_BOUNDS[unit]\n if not (b[0] <= lo <= b[1]):\n for candidate in (\"year\", \"hour\"):\n cb = _SALARY_BOUNDS[candidate]\n if candidate != unit and cb[0] <= lo <= cb[1]:\n unit, b = candidate, cb\n break\n else:\n return (None, None, None)\n if hi is not None and not (b[0] <= hi <= b[1]):\n hi = None # max aberrant : on garde le min plausible seul\n if hi is not None and hi < lo:\n lo, hi = hi, lo\n return (lo, hi, unit)\n\n\n# Contextes où un montant en $ n'est PAS un salaire (financement, dépenses,\n# allocations…) — garde-fou de l'extraction depuis la description.\n_NOT_SALARY_CONTEXT = re.compile(\n r\"financement|funding|amorcage|seed|investisse|invest(?:ment|or)|\"\n r\"revenu[s]?\\b|revenue|chiffre d affaires|budget|remboursement|\"\n r\"reimburse|expense|allocation|allowance|subvention|credit d impot|\"\n r\"cotisation|rabais|discount|gift|carte[- ]cadeau|referral (?:bonus|fee)|\"\n r\"signing bonus|prime (?:a l embauche|de demarrage|de reference)|\"\n r\"million|milliard\")\n\n\ndef salary_from_text(text: str) -> tuple[float | None, float | None,\n str | None, str]:\n \"\"\"Cherche une mention salariale dans un texte libre (description).\n\n Découpe en phrases (les points décimaux « 182,500.00 » ne coupent pas),\n ignore les contextes non salariaux (financement, remboursements…), puis\n parse la première phrase plausible. Retourne (min, max, unité, libellé).\n \"\"\"\n if not text or \"$\" not in text:\n return (None, None, None, \"\")\n for sentence in re.split(r\"(? str | None:\n \"\"\"Valeur de date hétéroclite -> ISO « YYYY-MM-DD », ou None.\n\n Accepte : ISO (+ horodatages), epoch secondes/millisecondes, formats\n textuels FR/EN (« 1er juillet 2026 », « July 1st, 2026 »), et les dates\n relatives (« il y a 3 jours », « Posted 30+ Days Ago »).\n \"\"\"\n if raw is None:\n return None\n today = today or _dt.date.today()\n\n if isinstance(raw, (int, float)): # epoch (Lever : millisecondes)\n ts = float(raw)\n if ts > 1e12:\n ts /= 1000.0\n if 1e9 <= ts <= 4e9:\n return _dt.date.fromtimestamp(ts).isoformat()\n return None\n\n s = clean_text(str(raw))\n if not s:\n return None\n key = _key(s)\n\n m = re.search(r\"(\\d{4})-(\\d{2})-(\\d{2})\", s) # ISO (avec ou sans heure)\n if m:\n try:\n return _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3))).isoformat()\n except ValueError:\n return None\n\n if _TODAY_RE.search(key):\n return today.isoformat()\n if _YESTERDAY_RE.search(key):\n return (today - _dt.timedelta(days=1)).isoformat()\n\n m = _REL_RE.search(key)\n if m:\n n = int(m.group(1) or m.group(3))\n unit = (m.group(2) or m.group(4) or \"\").lower()\n days = n * (7 if unit.startswith((\"sem\", \"week\"))\n else 30 if unit.startswith((\"moi\", \"month\")) else 1)\n return (today - _dt.timedelta(days=days)).isoformat()\n\n # « 1er juillet 2026 » / « July 1st, 2026 » / « déc. 2026 »\n m = re.search(r\"(?:(\\d{1,2})(?:er|e|st|nd|rd|th)?\\s+)?([a-z]{3,9})\\.?,?\\s+(?:(\\d{1,2})(?:er|st|nd|rd|th)?,?\\s+)?(\\d{4})\", key)\n if m:\n mois = _MONTHS.get(m.group(2))\n if mois:\n jour = int(m.group(1) or m.group(3) or 1)\n try:\n return _dt.date(int(m.group(4)), mois, jour).isoformat()\n except ValueError:\n return None\n\n m = re.fullmatch(r\"(\\d{1,2})/(\\d{1,2})/(\\d{4})\", s) # jj/mm/aaaa (usage QC)\n if m:\n d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))\n if mo > 12 and d <= 12: # en fait mm/jj/aaaa\n d, mo = mo, d\n try:\n return _dt.date(y, mo, d).isoformat()\n except ValueError:\n return None\n return None\n\n\n# ---------------------------------------------------------------------------\n# Mode de travail / type d'emploi\n# ---------------------------------------------------------------------------\n\n_MODE_RULES = [\n (\"teletravail\", r\"100\\s*%\\s*(?:teletravail|remote)|full(?:y)?[ -]remote|\"\n r\"teletravail|travail a distance|remote(?:\\b|-first)|a distance\"),\n (\"hybride\", r\"hybrid|hybride|flexible?\\s*(?:work|travail)\"),\n (\"presentiel\", r\"presentiel|sur (?:place|site)|on[- ]?site|in[- ]?(?:office|person)\"),\n]\n\n\ndef parse_work_mode(raw: str) -> str | None:\n key = _key(raw)\n if not key:\n return None\n # « hybride » prime sur les mentions accessoires de télétravail\n if re.search(_MODE_RULES[1][1], key):\n return \"hybride\"\n for mode, pat in _MODE_RULES:\n if re.search(pat, key):\n return mode\n return None\n\n\n_TYPE_RULES = [\n (\"stage\", r\"\\bstage\\b|stagiaire|intern(?:ship)?\\b|co-?op\\b|alternance\"),\n (\"saisonnier\", r\"saisonn|seasonal\"),\n (\"contractuel\", r\"contrat\\b|contract(?:uel|or)?\\b|temporaire|temporary|terme|\"\n r\"fixed[- ]term|duree determinee|pigiste|freelance|consultant\"),\n (\"temps_partiel\", r\"temps[- ]partiel|part[- ]?time|partiel\\b\"),\n (\"temps_plein\", r\"temps[- ]plein|full[- ]?time|permanent|regulier|\"\n r\"regular\\b|duree indeterminee\"),\n]\n\n\ndef parse_employment_type(raw: str) -> str | None:\n key = _key(raw)\n if not key:\n return None\n for typ, pat in _TYPE_RULES:\n if re.search(pat, key):\n return typ\n return None\n\n\n# ---------------------------------------------------------------------------\n# Lieu — extraction ville/région + garde-fou « offre au Québec »\n# ---------------------------------------------------------------------------\n\n# Villes québécoises fréquentes dans les libellés d'offres (clé sans accents).\n# Sert à la fois au parsing et au filtre is_quebec_location.\n_QC_CITIES = {\n \"montreal\": \"Montréal\", \"quebec\": \"Québec\", \"ville de quebec\": \"Québec\",\n \"quebec city\": \"Québec\", \"laval\": \"Laval\", \"gatineau\": \"Gatineau\",\n \"longueuil\": \"Longueuil\", \"sherbrooke\": \"Sherbrooke\",\n \"saguenay\": \"Saguenay\", \"chicoutimi\": \"Saguenay\",\n \"levis\": \"Lévis\", \"trois-rivieres\": \"Trois-Rivières\",\n \"trois rivieres\": \"Trois-Rivières\", \"terrebonne\": \"Terrebonne\",\n \"brossard\": \"Brossard\", \"repentigny\": \"Repentigny\",\n \"boucherville\": \"Boucherville\", \"saint-jerome\": \"Saint-Jérôme\",\n \"drummondville\": \"Drummondville\", \"saint-jean-sur-richelieu\":\n \"Saint-Jean-sur-Richelieu\", \"granby\": \"Granby\", \"shawinigan\": \"Shawinigan\",\n \"joliette\": \"Joliette\", \"victoriaville\": \"Victoriaville\",\n \"rimouski\": \"Rimouski\", \"rouyn-noranda\": \"Rouyn-Noranda\",\n \"baie-comeau\": \"Baie-Comeau\", \"sept-iles\": \"Sept-Îles\",\n \"val-d'or\": \"Val-d'Or\", \"val d'or\": \"Val-d'Or\", \"alma\": \"Alma\",\n \"sainte-foy\": \"Québec\", \"anjou\": \"Montréal\", \"dorval\": \"Dorval\",\n \"boisbriand\": \"Boisbriand\", \"blainville\": \"Blainville\",\n \"mirabel\": \"Mirabel\", \"saint-hyacinthe\": \"Saint-Hyacinthe\",\n \"beloeil\": \"Belœil\", \"chateauguay\": \"Châteauguay\", \"magog\": \"Magog\",\n \"thetford mines\": \"Thetford Mines\", \"saint-georges\": \"Saint-Georges\",\n \"riviere-du-loup\": \"Rivière-du-Loup\", \"matane\": \"Matane\",\n \"sorel-tracy\": \"Sorel-Tracy\", \"varennes\": \"Varennes\",\n \"vaudreuil-dorion\": \"Vaudreuil-Dorion\", \"salaberry-de-valleyfield\":\n \"Salaberry-de-Valleyfield\", \"kirkland\": \"Kirkland\",\n \"pointe-claire\": \"Pointe-Claire\", \"saint-laurent\": \"Montréal\",\n \"ville saint-laurent\": \"Montréal\", \"verdun\": \"Montréal\",\n \"westmount\": \"Westmount\", \"candiac\": \"Candiac\", \"la prairie\": \"La Prairie\",\n \"mont-tremblant\": \"Mont-Tremblant\", \"saint-bruno\": \"Saint-Bruno-de-Montarville\",\n \"bromont\": \"Bromont\", \"cowansville\": \"Cowansville\",\n \"saint-augustin-de-desmaures\": \"Saint-Augustin-de-Desmaures\",\n \"l'ancienne-lorette\": \"L'Ancienne-Lorette\",\n}\n\n_QC_MARKERS = re.compile(\n r\"\\bqc\\b|\\bquebec\\b|\\bqu[e]bec, canada\\b|province de quebec\", re.I)\n_POSTAL_RE = re.compile(r\"\\b([GHJ]\\d[A-Z])\\s*(\\d[A-Z]\\d)\\b\", re.I) # G/H/J = Québec\n\n# Villes canadiennes hors Québec (et grandes villes étrangères) fréquentes\n# dans les flux pancanadiens/mondiaux — jamais des villes d'offres\n# québécoises : à rejeter lors du parsing de lieu (clés sans accents).\n_NON_QC_CITIES = frozenset({\n \"toronto\", \"ottawa\", \"vancouver\", \"calgary\", \"edmonton\", \"winnipeg\",\n \"mississauga\", \"brampton\", \"hamilton\", \"kitchener\", \"waterloo\", \"halifax\",\n \"dartmouth\", \"victoria\", \"saskatoon\", \"regina\", \"st. john's\", \"st johns\",\n \"saint john\", \"fredericton\", \"moncton\", \"charlottetown\", \"london\",\n \"markham\", \"vaughan\", \"richmond hill\", \"oakville\", \"burlington\", \"oshawa\",\n \"whitby\", \"ajax\", \"pickering\", \"guelph\", \"kingston\", \"barrie\",\n \"kelowna\", \"kamloops\", \"nanaimo\", \"abbotsford\", \"surrey\", \"burnaby\",\n \"coquitlam\", \"langley\", \"north york\", \"scarborough\", \"etobicoke\",\n \"north vancouver\", \"milton\", \"cambridge\", \"st. catharines\",\n \"niagara falls\", \"thunder bay\", \"sudbury\", \"greater sudbury\",\n \"sault ste. marie\", \"red deer\", \"lethbridge\", \"medicine hat\",\n \"grande prairie\", \"fort mcmurray\", \"yellowknife\", \"whitehorse\", \"iqaluit\",\n \"can\", \"canada\", \"ontario\", \"alberta\", \"british columbia\", \"manitoba\",\n \"saskatchewan\", \"nova scotia\", \"new brunswick\",\n \"new york\", \"new york city\", \"san francisco\", \"boston\", \"chicago\",\n \"seattle\", \"austin\", \"denver\", \"los angeles\", \"miami\", \"atlanta\",\n \"dallas\", \"houston\", \"philadelphia\", \"washington\", \"phoenix\", \"portland\",\n \"paris\", \"londres\", \"amsterdam\", \"berlin\", \"munich\", \"dublin\", \"madrid\",\n \"barcelona\", \"barcelone\", \"lisbon\", \"lisbonne\", \"sydney\", \"melbourne\",\n \"singapore\", \"singapour\", \"tokyo\", \"bangalore\", \"bengaluru\", \"mumbai\",\n \"mexico city\", \"sao paulo\", \"bogota\", \"buenos aires\", \"las vegas\",\n \"orlando\", \"nashville\", \"macau\",\n})\n\n\ndef canonical_city(raw: str) -> str:\n \"\"\"Canonicalise un nom de ville (« Montreal » -> « Montréal »,\n « Quebec City » -> « Québec »). Inconnu : retourne le texte nettoyé.\"\"\"\n s = clean_text(raw)\n if not s:\n return \"\"\n key = _key(s)\n exact = _QC_CITIES.get(key)\n if exact:\n return exact\n for ck, city in _QC_CITIES.items():\n if re.fullmatch(re.escape(ck), key):\n return city\n return s\n\n\ndef parse_location(raw: str) -> dict:\n \"\"\"Libellé de lieu -> {city, region, postal_code} (champs possiblement vides).\n\n Ex. « Montréal, QC, Canada », « Ville de Québec, Québec », « Remote - QC ».\n \"\"\"\n s = clean_text(raw)\n out = {\"city\": \"\", \"region\": \"\", \"postal_code\": \"\"}\n if not s:\n return out\n m = _POSTAL_RE.search(s)\n if m:\n out[\"postal_code\"] = f\"{m.group(1).upper()} {m.group(2).upper()}\"\n key = _key(s)\n for ck, city in _QC_CITIES.items():\n if re.search(rf\"(? bool:\n \"\"\"Vrai si le libellé pointe vers le Québec (filtre des ATS pancanadiens).\"\"\"\n key = _key(clean_text(raw))\n if not key:\n return False\n if key in _NON_QC_CITIES: # libellé = ville canadienne/monde hors QC\n return False\n if _QC_MARKERS.search(key):\n return True\n m = _POSTAL_RE.search(raw)\n if m and m.group(1)[0].upper() in \"GHJ\":\n return True\n return any(re.search(rf\"(? str:\n \"\"\"Langue du texte de l'offre : « fr », « en », « bilingue », ou \"\".\n\n Comptage de mots-outils sur le début du texte — déterministe, jamais de\n valeur inventée : texte trop court ou ambigu -> \"\".\n \"\"\"\n text = _key(f\"{title}\\n{description or ''}\"[:4000])\n if len(text) < 30:\n return \"\"\n fr = len(_FR_STOPWORDS.findall(text))\n en = len(_EN_STOPWORDS.findall(text))\n total = fr + en\n if total < 5:\n return \"\"\n # les deux langues nettement présentes -> offre bilingue\n if fr >= 8 and en >= 8 and min(fr, en) / max(fr, en) >= 0.35:\n return \"bilingue\"\n if fr > en:\n return \"fr\"\n if en > fr:\n return \"en\"\n return \"\"\n\n\n# ---------------------------------------------------------------------------\n# Avantages — extraction des blocs « Avantages / Benefits » des descriptions\n# ---------------------------------------------------------------------------\n\n_BENEFITS_HEADING = re.compile(\n r\"^\\s*(?:les\\s+|nos\\s+|our\\s+|tes\\s+|vos\\s+)?(?:avantages(?:\\s+sociaux)?|\"\n r\"benefits(?:\\s+&\\s+perks)?|perks(?:\\s+(?:and|&)\\s+benefits)?|\"\n r\"ce que nous (?:t')?offrons|what we offer|why join us|\"\n r\"pourquoi nous rejoindre|nous offrons|we offer)\\s*:?\\s*$\", re.I)\n_BENEFIT_LINE_MAX = 120\n\n\ndef extract_benefits(description: str) -> list[str]:\n \"\"\"Liste d'avantages depuis un bloc structuré de la description.\n\n Cherche un titre de section « Avantages/Benefits/… » seul sur sa ligne,\n puis prend les lignes courtes qui suivent (puces), en s'arrêtant à la\n première ligne longue ou au prochain titre. Max 12 items — si le bloc ne\n ressemble pas à une liste, on ne retourne rien (pas d'à-peu-près).\n \"\"\"\n if not description:\n return []\n lines = description.split(\"\\n\")\n for i, line in enumerate(lines):\n if not _BENEFITS_HEADING.match(strip_accents(line)):\n continue\n items: list[str] = []\n for nxt in lines[i + 1:]:\n t = clean_text(nxt).lstrip(\"•-–—*·◦▪ \").strip()\n if not t:\n if items:\n break # fin du bloc (ligne vide après les puces)\n continue\n if len(t) > _BENEFIT_LINE_MAX or _BENEFITS_HEADING.match(\n strip_accents(t)):\n break\n # un « titre » de section suivant (courte ligne sans ponctuation\n # finale, capitalisée) termine aussi le bloc\n if items and len(t.split()) <= 4 and t.endswith(\":\"):\n break\n items.append(t.rstrip(\" ;,.\").strip())\n if len(items) >= 12:\n break\n if len(items) >= 2:\n return items\n return []\n\n\n# ---------------------------------------------------------------------------\n# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés\n# ---------------------------------------------------------------------------\n\n# codes de réquisition : « [R-12345] », « (REQ 2026-42) », « - JR0035678 »…\n_REQ_CODE = re.compile(\n # en tête : « 123456 - Titre », « [REQ-1234] Titre » (≥5 chiffres nus pour\n # ne pas manger une année « 2026 - Programme d'été »)\n r\"^\\s*[\\[(]?(?:req(?:uisition)?|jr|job)[\\s#:-]*\\d{3,10}[\\])]?\\s*[-–—:|]\\s*|\"\n r\"^\\s*[\\[(]?\\d{5,10}[\\])]?\\s*[-–—:|]\\s*|\"\n # en queue : « Titre - R-12345 », « Titre (JOB 004521) »\n r\"\\s*[-–—|(]\\s*(?:req(?:uisition)?|job|poste)?\\s*#?\\s*(?:[A-Z]{1,4}-?)?\\d{4,10}\\s*[)\\]]?\\s*$|\"\n # au milieu : « Titre [R-12345] suite »\n r\"\\s*[\\[(](?:R|REQ|JR|JOB)[-_ ]?\\d{3,10}[\\])]\\s*\", re.I)\n\n# petits mots gardés en minuscules lors de la remise en casse d'un titre\n# tout-majuscules ; sigles usuels gardés en majuscules\n_TITLE_SMALL = {\"de\", \"du\", \"des\", \"la\", \"le\", \"les\", \"et\", \"en\", \"à\", \"a\",\n \"au\", \"aux\", \"d'\", \"l'\", \"of\", \"the\", \"and\", \"in\", \"for\",\n \"to\", \"or\", \"ou\", \"un\", \"une\", \"sur\", \"pour\"}\n_TITLE_ACRONYMS = {\"rh\", \"ti\", \"it\", \"qa\", \"qc\", \"pab\", \"cpa\", \"cfa\", \"erp\",\n \"sap\", \"crm\", \"sst\", \"cnc\", \"hvac\", \"rpa\", \"bi\", \"ia\",\n \"ai\", \"vp\", \"pdg\", \"dg\"}\n\n\ndef _smart_case(title: str) -> str:\n \"\"\"Titre TOUT EN MAJUSCULES -> casse de phrase lisible (sigles préservés).\"\"\"\n words = title.lower().split()\n out = []\n for i, w in enumerate(words):\n base = w.strip(\"()[],.;:/\")\n if base in _TITLE_ACRONYMS:\n out.append(w.replace(base, base.upper()))\n elif i > 0 and base in _TITLE_SMALL:\n out.append(w)\n else:\n out.append(w[:1].upper() + w[1:])\n return \" \".join(out)\n\n\ndef clean_title(title: str, city: str = \"\") -> str:\n \"\"\"Titre d'affichage : codes de réquisition retirés, suffixe « - »\n redondant retiré, casse corrigée si tout-majuscules. Le titre SOURCE n'est\n jamais modifié — ceci alimente une colonne d'affichage distincte.\"\"\"\n t = clean_text(title)\n if not t:\n return \"\"\n prev = None\n while prev != t: # les codes peuvent être empilés\n prev = t\n t = _REQ_CODE.sub(\" \", t).strip(\" -–—|:\")\n t = clean_text(t)\n # suffixe « - Montréal » / « (Montréal, QC) » identique à la ville de\n # l'offre : repéré sans accents, coupé sur le titre original\n if city:\n ck = re.escape(strip_accents(city.lower()))\n m = re.search(\n rf\"\\s*[-–—|,(]\\s*(?:ville de\\s+)?{ck}(?:\\s*,\\s*(?:qc|quebec))?\\s*\\)?\\s*$\",\n strip_accents(t.lower()))\n if m and m.start() > 0:\n t = clean_text(t[:m.start()]).strip(\" -–—|,(\")\n if len(t) > 8 and t == t.upper() and t != t.lower():\n t = _smart_case(t)\n return t or clean_text(title)\n\n\ndef categorize(title: str, description: str = \"\") -> str:\n \"\"\"Titre -> catégorie interne (repli : début de description), ou \"\".\n\n Le titre est essayé SEUL d'abord : la description commence souvent par le\n boilerplate de l'entreprise (« leader du manufacturier… ») qui fausserait\n la catégorie du poste.\n \"\"\"\n for texte in (title, description[:300]):\n key = _key(texte or \"\")\n if not key.strip():\n continue\n for cat, pat in _CATEGORY_RULES:\n if re.search(pat, key):\n return cat\n return \"\"\n","numLines":745,"startLine":1,"totalLines":745}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"715371a4-92fb-4da5-a1ce-7c2e22baa883","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"a98a8660-a420-4039-8a26-62c61854b8fe","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":200,"estimated_tokens_delta":150,"uuid":"b9ab0f45-8eac-4ab8-9afd-f4637324d533","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":250,"estimated_tokens_delta":50,"uuid":"a2bcea1f-2812-4564-9c69-e84aeef27c78","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":350,"estimated_tokens_delta":100,"uuid":"5c6eacf7-fa50-45b2-a0f7-801a46e1ae70","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":500,"estimated_tokens_delta":150,"uuid":"58553d35-2be5-41fd-ba71-168a45e4b8a3","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":600,"estimated_tokens_delta":100,"uuid":"452f3790-5792-480f-955e-d4eb4ecec2cb","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":700,"estimated_tokens_delta":100,"uuid":"445372da-15c0-47fa-8488-a47c5443eba3","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":800,"estimated_tokens_delta":100,"uuid":"6008d194-731a-4c5d-8ee3-ba0d5984de02","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":850,"estimated_tokens_delta":50,"uuid":"93c98f93-f3c9-4a7e-951b-4e2024b14786","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":950,"estimated_tokens_delta":100,"uuid":"e2ae38aa-3f4c-4a28-aef3-30edd8acd885","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1050,"estimated_tokens_delta":100,"uuid":"8ddfd1e1-0215-4ed5-98e5-07a315fec845","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1150,"estimated_tokens_delta":100,"uuid":"df56e6ca-54ce-48ac-8dca-ae5aea6b3eec","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1250,"estimated_tokens_delta":100,"uuid":"cc91fb00-0199-4050-a83d-50d1f28cf590","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1350,"estimated_tokens_delta":100,"uuid":"64116150-247b-4b03-b4dc-e37a792916fa","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbumngUAS76kXvBhdL1","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISyCAKpwEIERgCKkC04bi99PoU1QqZA0b7XR6QeCVebpf2WMeqVu19XDW/8gownAFJHXaUa2uA00I6Vmp1YJQXiPOGRifApgQJa2ByMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAHDxbbUBrABAhIMiUub/LyAYCQ4Mp82GgxflHXYexLEzXo/tEsiMNwZgYjAg/rmyNJcuE+fnqPpBom+C1Eb6j6Gcp0AqHtGyxR1XBLv+ghcLa7YGZsPXCrNHkGSgDTNh9Tc2W1wzeDFWnkYBMCQRMeAjOLbzmvmqTKloNv7DnntzeMU3P2sV17/izjivqGQuJJ1SSLBawrW1Q+RyWtQcMOo8N2uOuG1pXaIciGOanQ1iNFwggJuassRfMqeIEoo3R00DN4hW0g6YVxeaWuZP3hYe3MxkkHOifcuARXJP4bGCS9E19P273W+hA5H1tA9s56AAd/CYLyOJEOPRjq6+je/RRU+6pn6XyBW7cRNaE//9gTtiI3Errfg+ttvc8+pnox7tNmV/doZZGOynLL7+FQq3jM3YQ6jLrQgZcoerO7Vw1me0XU0SWqEK8GU1Pl0wBRVjy7Dpe6FPpOCrJxaWWD6vM/910uGNF47jdC98dyUATTxqwFoF+SPpUp8YrETAHd8Qh/UjGm23NDs7cM/oFEfAMkIkn9zVDIg+TdPOv2Kib2jRoHntonHHArLIRpiquCqzoQPa+wzytfzQvHIo94353OlM42wr2XcIcPrB1Bo9cUoIFmVLBNnXTz9AGrzkDxYFz7Ee7xTwnebWsSy08bQsnU7iciUo+y1x4+GTmqTWLnuQRcgYgt+GpM/zqZ5wt3Ln4runx3qqKUKSJiqLa3lqwkQ1Q5mFXMOxZQhJQfrf82K+ZFqY6U2RNmqb5x4S2YJZBZkvJUHjNSaaJm7gW5Yfd+Os8xQ7148oZkLzsO9+70rNpl99XOCC2yxyYihJ974yQNRIYNNpNNnInD9RW62bqIb9G2aVep4y5aoXFOW+1W/0YJCT8EKiWDv9cjQG3FkgbHMrUYKa+IF6/8LesdFes2vOQpSTZ+bPfepDfTyZKbv3vFu45EI78cpdWNEb6t12e5X24nM85PSUJ6/foAoMSo+Bs1i4etjwNVzKheYaPkKNrDRm/bFxHE+eE2SnCVdvcEcI6X9ZNjmYCpItapv8TLG456aXOGYONByBcscUgEclrQf+z5mQ/649ERLoPQ596rJeIr+F90FTFmYucEXgQqPXiIBuTsCCO+/ysD1z+bKsmE2wqE7Ka9DbLXmB6+ko3MCSD3uBXflTHK3zR5mZo+5mOkhp9B1VK3xuyhIH+ErXoT1fVRr1HpU324KXLKtUeI9XgYAAs33FvGdWfFCxhNri1BUlSnF0xtCIGxrNfo5mafEgZMF5wd5g5zoA8IPh4Z/52utTE0GQ9w0EKmpvJThLzolcXTQ+gZBRH3PStIDf/wsHGgzOjmi4TP0hcaU6EEFq/db3f/xJ+BZMK7gFPzTUnFjuFPntbb6B6AJRpTVoZkwpKEJVBTbGVkUyGjp7vKCr1WyXf5uIShMVnmu2O3bXCZ/o27ju975bjEFXKKkw9zt88b8uFDtDC226FAz6oAlpvNCjnfti3+bKGlGoP2UFekP8FUwGMg+Uek1SGj0z6fCE+sj54B8k4qWpgwsJZ/dkXH/5u3v3YkFL6obTVu0XhPa+2SqjZc80kalVCyxJxnH57MrdqP0YlgNf+3Fr15E1WXb4N3muVbL4b20P2aikwUw653gBzMHgcbgbRbesKXeGCeaKU/C9090EXXuV52rnPfGlpOkNhkAXwJQe7h2HXAnZs2O5m0hT0HLhz1AGUHI9IRJv74iz/f0Qj/g+8+4Pp3sKJuLsVz9YqdLj9+6omCOi01jLn8vQ1YIQboeVVdmJnJaEPgB+iaXVf3HI13oM+Nt87O8lNMFvWMtIDtTm0WDvX3U2Nx7o2diXte3wwTQvRQ3uVjiTR8dzU9wXaooh0RZgzOFn+BwALLhMe9gQ97UePJYBROdyx18rjpWEgQMy6FpiRxxBnHu/zuOSSsIfuAI34xW/p/DISMncIWRna9ooUFadxqFiJQS4RUkTsQuVASDxrOgCqJqfayGGJP7a68r7pXWsgHwfXJYzjlZvLlXOlGcSy8SkZRC9QYBYKm4jWHyqTJxSGH9wvGDd8BSEEOnTawcpLUPwkAPvu2QdCpzUhyZ6ztR6vSz5ITL2lT5YYltwFbfQXhReduCa1rE+tlE6ir8zn8mZRPbKfRthztrX8YmbDrEWve0qIvMQBKgQkOIDTCi5p87PJn2pycmNCS+xgixO3uQtHF7IBwF8n179AIqguS3ZTHNZv3ewz3Bp9c0ggiKtSbCTxr8DLMEHxpZ5drjmc6XWMwqJDlJg+NrCJq77p829hwK6YcXek1i5T6RYMAqQQNHBXwiQt3y4NJ2SLarLvQnofGsfnRS5W5cM3xbKtXKjhejjwXpRcUrNRa0nbA9L/ywXdvZkroCGPBPzljTR/JA2vmg9Dwx/6O6jfC2pzyiPx0l1kl4s/0z/rjVfpIFejw/v/ISM4iih8f/sHEHCo2bEIJt/nUscApKUTFdmtibxB6718lj9RcRP2kAdqQl6sBj+wCtgJqvS063seWt6qpoZtPW4SkxmaZav1KusXTHjVH1ks0xs9FSAeUfat8l03UFYPUv72hWFYC/IkU+AexkzxvD0xyBwkIxYlTVPc0wmUnDlemqaZQP7/CVVfkmvVZnZPEohrklipXACIsC2TOQFlG3lHUZt2Y+jIZmjcZmd8OiUqS93427N+l6QT4oflNhZNc+DGPUIRWC7y4oJ7n8gADMZLBl68AYk8uHzd42OLS7I5V0QGyVrhV3QCRJgM2ODXSIg7cne2BZ0/y/Rc58eKtddWuDvccfiXHxVml6V/ok1H2t+XBOJ97njwqfEKIE8rXqsr7CiBo/P1SwaX36qY7Xii+XlMLCWHwRxHJuT6wEgli6mP//L4dix75JJsdylSyZxrs2gg0fteIAmlJdPZarQv7U/2vgFxJbf6HTccv8AJLtmM16P0mT1zh6zcdhazmEb4SCzYTdRUMNkQrRG9rdyuks88WfumR5AIS4uBz1aAtJLeYrS3lfKPAYHeUcpERMZjyU7HbciQbTx8oWvMlEwfF0XE8fY75ZBUQokIP4cyJQr3w5+qcVKfcwbbauWp+YMERMURr6GfdTNHREv7XpEFbBFpLUoPlT28vmm6hepc05IXxX3quxy++6OeRQyFL80FBmZbINl9HBEHn2bXMOoX+tEvB0kT+jr4RRSuomJdsSD6+0Ch6vKvOx8sV+hmyLnrIeESyqclpYDsUOFqi5/nfPVCunwGeIg7Iu/k6kRNrl3w1r/ti1BTYTJ8E6OJ6akV3y+1JLn30dGDYs/R7XAnjWVqMmHNAQDfsFvYuCt7FAXMBglkLtvrCPl2cx11soYMb+N9Lz10FKy4Mj4jMhFwmMLq/opnK7fBMkKq20QGHWZZvG+nmK3w1p7MLI5TiIsqy//X7nInMRJtqhEq1k+N2KyrvzPLciZubAvUCiZH9UBYKqhlqLegK8FiDkgc9HMp5Ez6A0aaI1flHJotJtGpiz528Ds43CgB69iYH2qI6xR1NqIxELP/Fo4kO7mBV7qL/VOxvJSVF7TvQAur3uuOlV737N5Fwz+n6c20ys/DWrXhYrxgNs1hOxLXzHB79W4FZKyXjXJeKMXCNw4wjfFl3ST7o/kIgFAiuA1cjtj1pudmtLDy1mBvGJZHc/IX4mZaTMNLRa0tgC/9Nz5v/aMx0mEuGSWeATzP64jW43W/NutKhXtg2R5amvc+hQBlJ415RIU9p4PlgTXUfOu1T5HB5tfvFXyp8oNSLLFAJJbU7lQW67OqoSi4kBvMv2+7Omz363FIp6+gGbbWFX5CevB+3p3JAezWxwS+QI8Kj9f+kwt80U06Bfk1bPlp7qNCrDI04KNBidyqOyfHso5KIccr2UBb7dgjmvPYnPYRbC1N5jMTemvxLDSHYed9Xl5WyM3qHG93ooPtWNw8hMljau6pi9JYWNljq2nA5tGAxlaPek9Icg0c+8Qc4fHyIXw0vkuqY4OrrS+dgFsFP721KQfusuK5yh8TQvMPLyhNkkzIoCuaAAAHEIVqm1OXnUv84I2Fu3/VhlVaCJbVCywJDmB+4DWRIt6BLjEPLXE+MB6tbh/WMt+A94rtq2sdVUGDK7MTMcC1AvCMwqb4g6xB85c+OQkLuxeKu6ctK0pAUO2YKMiLyIcvfBXH4XtVuoZQJphZX1l50KVmGEXRsxhL5oCucuSXHcLatreTOapdsiS6mQqO8a76s4M4vyMl/x7ckwnbVtt7i5Klodbqz3mxVcUvAMm+IN7REyvHr9S5wIn/wqfudOVQvuar02+TnVpyO9XcLxlBQj8wEXXbtnVV36BY2P9ibo/S0nH616jE/wtsnXV/ZGdl4WA8VK3oLoxlWYuL43SrOifViDWLAKeDuCDPspmdDjNqL9Z/E5PzFctOp2kl/pqZ0yd55vSLQpancwysqNJR/GPbhh0P53YO9PzSHthXistjYJMDJDsbuV9iEWDX71FumqRipmkoR5DNeC1X6v3QfNS5n8imO2sRfrtJ9hhuIEdcSIBRddIdue2q3+WVglHd0DY5ukPdx6U0f1YNP6udnLEhVRHhDTj1dgJkjcECSJT7fsMk+ik3pjEULk94WUdauX23RDCsFYteuBLP2AzjpvRhw/itA76nLQQro9+HwBRbGFNr1avupSeSUGmbF95YvAzQLX9DfSvW6b5fz70h9axefIQirVey8Dw34y7e1hBqJLLsfynCd3yUWPpGGZIoqu2Z3KlQxsnF53pbPStWLA983oD+NByI0tMFxf6Nbn9jkIMmmypkiQ5NDBIrE8XTcibUa5x0eAofZ4JdEK8bdsc9qbvZnWi5hCE/rD85uEFr+Zxxw/XdEcly0hiwju7IWd8FQVNCJcTJ57uWs75uyp9Q/FJakIRx5RdGmqDFk2gC/KDc4FDVaE0Y90Eyb5elNL4D4oEyuLlvgCThdU98j/zEHvT3qMOFw+V+i8/EUJ3Y7Pe+bqWzHlQZNeDZqSqgZp9f5j1+pffi7mY5vUlE6SlhCayYeHiAkEJ8vKrB7ykRl76ta3u4VP99mRYOltNL7lmTk5kmQqZ79Ln94a1rftis43NLAJsT+/wMuVoClkMHeDIw5K0R3mMfbdWqQar5W5eqJmGnliTB+Y1A3isFxNHkbVoocP+xY+3Li7IthrAO4dHprvEreVcLSj8vDevtKDXJvj0EDeUsx3cczI2IzkdcBmMl9Ndij8Egw450zPCugZNhKicwOxLSGOLuK6fr0zDY2QGkyR5Q7wLa0JEkN4XKEx0seDwClOoj9oOTwFdGdGXYBjpFiiMau7fm4AiaL7Fgbi9JzEuuFcVgOuI8SMC/H81cRND0MkKq8LGspWGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":15886,"cache_read_input_tokens":77335,"cache_creation":{"ephemeral_5m_input_tokens":15886,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"f82b89b8-3f0b-4605-98ce-4c304ce66713","request_id":"req_011CePbuiobWuf4QaSj5Argi"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbumngUAS76kXvBhdL1","type":"message","role":"assistant","content":[{"type":"text","text":"Vue d'ensemble acquise. Je vérifie maintenant la couverture actuelle en base (champs `requirements`, avantages, etc.), et je lis Home.tsx + le CSS des cartes pour préparer le nouveau display."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":15886,"cache_read_input_tokens":77335,"cache_creation":{"ephemeral_5m_input_tokens":15886,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"0e7ea925-1d20-4946-b854-33a4d3108df2","request_id":"req_011CePbuiobWuf4QaSj5Argi"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbumngUAS76kXvBhdL1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XyzkrJCBjuBc64hjpLk7f5","name":"Bash","input":{"command":"sqlite3 data/jobka.db \"SELECT COUNT(*) total, SUM(CASE WHEN requirements IS NOT NULL AND requirements<>'{}' THEN 1 ELSE 0 END) with_req, SUM(CASE WHEN benefits IS NOT NULL AND benefits<>'[]' THEN 1 ELSE 0 END) with_benefits, SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary, SUM(CASE WHEN description IS NOT NULL AND LENGTH(description)>200 THEN 1 ELSE 0 END) with_desc, SUM(CASE WHEN date_posted IS NOT NULL THEN 1 ELSE 0 END) with_date, SUM(CASE WHEN company_logo IS NOT NULL AND company_logo<>'' THEN 1 ELSE 0 END) with_logo, SUM(CASE WHEN language IS NOT NULL AND language<>'' THEN 1 ELSE 0 END) with_lang FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL;\"","description":"Check field coverage in jobs DB"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":15886,"cache_read_input_tokens":77335,"cache_creation":{"ephemeral_5m_input_tokens":15886,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"e12fd8c0-f554-48b2-a7f5-7ef4d970522b","request_id":"req_011CePbuiobWuf4QaSj5Argi"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XyzkrJCBjuBc64hjpLk7f5","type":"tool_result","content":"23063|5995|276|7283|22709|21898|2257|22723","is_error":false}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"d0107dc0-1b8a-4db4-928f-f322d10aa6c3","timestamp":"2026-08-25T14:12:24.358Z","tool_use_result":{"stdout":"23063|5995|276|7283|22709|21898|2257|22723","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbumngUAS76kXvBhdL1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NPrbniAUefe4XSSWTpd9mv","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":15886,"cache_read_input_tokens":77335,"cache_creation":{"ephemeral_5m_input_tokens":15886,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"52f6b945-04c5-455c-b2e3-e7b661e843c7","request_id":"req_011CePbuiobWuf4QaSj5Argi"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NPrbniAUefe4XSSWTpd9mv","type":"tool_result","content":"1\t/**\n2\t * =============================================================================\n3\t * Job·Ka — Groupe KA\n4\t * Auteur : Simon-Pierre Boucher\n5\t * Contact : contact@spboucher.ai\n6\t * Fichier : frontend/src/pages/Home.tsx\n7\t * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres\n8\t * Créé : 2026-08-17 Modifié : 2026-08-22\n9\t * =============================================================================\n10\t */\n11\timport { useEffect, useMemo, useState } from \"react\";\n12\timport { useSearchParams } from \"react-router-dom\";\n13\timport { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, TYPE_FR } from \"../api\";\n14\timport JobCard from \"../components/JobCard\";\n15\t\n16\tconst PAGE = 30;\n17\t\n18\texport default function Home() {\n19\t const [params, setParams] = useSearchParams();\n20\t const [facets, setFacets] = useState(null);\n21\t const [jobs, setJobs] = useState([]);\n22\t const [total, setTotal] = useState(0);\n23\t const [loading, setLoading] = useState(true);\n24\t const [error, setError] = useState(\"\");\n25\t\n26\t const filters = useMemo(() => ({\n27\t q: params.get(\"q\") ?? \"\",\n28\t city: params.get(\"ville\") ?? \"\",\n29\t region: params.get(\"region\") ?? \"\",\n30\t category: params.get(\"categorie\") ?? \"\",\n31\t work_mode: params.get(\"mode\") ?? \"\",\n32\t employment_type: params.get(\"type\") ?? \"\",\n33\t language: params.get(\"langue\") ?? \"\",\n34\t with_salary: params.get(\"salaire\") === \"1\" ? 1 : undefined,\n35\t sort: params.get(\"tri\") ?? \"recent\",\n36\t }), [params]);\n37\t const page = Math.max(1, parseInt(params.get(\"page\") ?? \"1\", 10));\n38\t\n39\t useEffect(() => { fetchFacets().then(setFacets).catch(() => {}); }, []);\n40\t\n41\t useEffect(() => {\n42\t setLoading(true);\n43\t setError(\"\");\n44\t fetchJobs(filters, PAGE, (page - 1) * PAGE)\n45\t .then((r) => { setJobs(r.jobs); setTotal(r.total); })\n46\t .catch((e) => setError(String(e)))\n47\t .finally(() => setLoading(false));\n48\t }, [filters, page]);\n49\t\n50\t const setParam = (key: string, value: string) => {\n51\t const next = new URLSearchParams(params);\n52\t if (value) next.set(key, value); else next.delete(key);\n53\t next.delete(\"page\");\n54\t setParams(next, { replace: true });\n55\t };\n56\t\n57\t const pages = Math.max(1, Math.ceil(total / PAGE));\n58\t\n59\t return (\n60\t <>\n61\t
\n62\t
\n63\t Pages carrières · Zéro intermédiaire\n64\t

L'emploi au Québec, à la source.

\n65\t

\n66\t Job·Ka agrège les offres directement depuis les pages carrières des\n67\t employeurs québécois. Salaires transparents, lien vers l'offre\n68\t originale, aucun intermédiaire.\n69\t

\n70\t
\n71\t {\n76\t if (e.key === \"Enter\") setParam(\"q\", (e.target as HTMLInputElement).value);\n77\t }}\n78\t />\n79\t \n83\t \n89\t \n95\t \n99\t \n103\t \n109\t \n117\t
\n118\t
\n119\t
\n120\t\n121\t
\n122\t

\n123\t {loading ? \"Chargement…\" : `${total.toLocaleString(\"fr-CA\")} offre${total > 1 ? \"s\" : \"\"} trouvée${total > 1 ? \"s\" : \"\"}`}\n124\t {\" · \"}\n125\t setParam(\"tri\", e.target.value)}\n128\t style={{ border: \"none\", background: \"none\", color: \"inherit\", font: \"inherit\", cursor: \"pointer\" }}\n129\t >\n130\t \n131\t \n132\t \n133\t

\n134\t {error &&
Erreur de chargement : {error}
}\n135\t
\n136\t {jobs.map((j) => )}\n137\t
\n138\t {!loading && jobs.length === 0 && !error && (\n139\t
Aucune offre ne correspond à ces filtres.
\n140\t )}\n141\t {pages > 1 && (\n142\t
\n143\t \n144\t \n145\t \n146\t
\n147\t )}\n148\t
\n149\t \n150\t );\n151\t}\n152\t"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"4d724a87-157b-48aa-9bf9-b7a01fed40e4","timestamp":"2026-08-25T14:12:24.912Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Home.tsx\n * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres\n * Créé : 2026-08-17 Modifié : 2026-08-22\n * =============================================================================\n */\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useSearchParams } from \"react-router-dom\";\nimport { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, TYPE_FR } from \"../api\";\nimport JobCard from \"../components/JobCard\";\n\nconst PAGE = 30;\n\nexport default function Home() {\n const [params, setParams] = useSearchParams();\n const [facets, setFacets] = useState(null);\n const [jobs, setJobs] = useState([]);\n const [total, setTotal] = useState(0);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(\"\");\n\n const filters = useMemo(() => ({\n q: params.get(\"q\") ?? \"\",\n city: params.get(\"ville\") ?? \"\",\n region: params.get(\"region\") ?? \"\",\n category: params.get(\"categorie\") ?? \"\",\n work_mode: params.get(\"mode\") ?? \"\",\n employment_type: params.get(\"type\") ?? \"\",\n language: params.get(\"langue\") ?? \"\",\n with_salary: params.get(\"salaire\") === \"1\" ? 1 : undefined,\n sort: params.get(\"tri\") ?? \"recent\",\n }), [params]);\n const page = Math.max(1, parseInt(params.get(\"page\") ?? \"1\", 10));\n\n useEffect(() => { fetchFacets().then(setFacets).catch(() => {}); }, []);\n\n useEffect(() => {\n setLoading(true);\n setError(\"\");\n fetchJobs(filters, PAGE, (page - 1) * PAGE)\n .then((r) => { setJobs(r.jobs); setTotal(r.total); })\n .catch((e) => setError(String(e)))\n .finally(() => setLoading(false));\n }, [filters, page]);\n\n const setParam = (key: string, value: string) => {\n const next = new URLSearchParams(params);\n if (value) next.set(key, value); else next.delete(key);\n next.delete(\"page\");\n setParams(next, { replace: true });\n };\n\n const pages = Math.max(1, Math.ceil(total / PAGE));\n\n return (\n <>\n
\n
\n Pages carrières · Zéro intermédiaire\n

L'emploi au Québec, à la source.

\n

\n Job·Ka agrège les offres directement depuis les pages carrières des\n employeurs québécois. Salaires transparents, lien vers l'offre\n originale, aucun intermédiaire.\n

\n
\n {\n if (e.key === \"Enter\") setParam(\"q\", (e.target as HTMLInputElement).value);\n }}\n />\n \n \n \n \n \n \n \n
\n
\n
\n\n
\n

\n {loading ? \"Chargement…\" : `${total.toLocaleString(\"fr-CA\")} offre${total > 1 ? \"s\" : \"\"} trouvée${total > 1 ? \"s\" : \"\"}`}\n {\" · \"}\n setParam(\"tri\", e.target.value)}\n style={{ border: \"none\", background: \"none\", color: \"inherit\", font: \"inherit\", cursor: \"pointer\" }}\n >\n \n \n \n

\n {error &&
Erreur de chargement : {error}
}\n
\n {jobs.map((j) => )}\n
\n {!loading && jobs.length === 0 && !error && (\n
Aucune offre ne correspond à ces filtres.
\n )}\n {pages > 1 && (\n
\n \n \n \n
\n )}\n
\n \n );\n}\n","numLines":152,"startLine":1,"totalLines":152}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbumngUAS76kXvBhdL1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Vj9sx54T5BmDKvNhGNmoFr","name":"Bash","input":{"command":"wc -l frontend/src/styles.css && grep -n \"job-card\\|\\.badge\\|\\.meta\\|\\.facts\" frontend/src/styles.css | head -40","description":"Inspect styles.css structure"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":15886,"cache_read_input_tokens":77335,"cache_creation":{"ephemeral_5m_input_tokens":15886,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"7ba4baf1-fbfe-4152-b200-c5e50c8a716c","request_id":"req_011CePbuiobWuf4QaSj5Argi"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Vj9sx54T5BmDKvNhGNmoFr","type":"tool_result","content":" 375 frontend/src/styles.css\n193:.job-card {\n199:.job-card:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); text-decoration: none; }\n200:.job-card h3 { margin: 0 0 2px; font-family: var(--font-display); font-size: 17.5px; font-weight: 700; color: var(--ink); letter-spacing: -0.02em; }\n201:.job-card:hover h3 { text-decoration: underline; text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: 3px; }\n202:.job-card .employer { color: var(--accent-deep); font-weight: 600; }\n203:.job-card .meta { color: var(--ink-3); font-size: 13px; margin-top: 8px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }\n206:.badge {\n214:.badge.salary { background: var(--green-soft); color: var(--green); border-color: var(--green); }\n215:.badge.mode { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--line); }\n216:.badge.direct { background: transparent; border-color: var(--accent); color: var(--accent-deep); }\n225:.sheet .facts { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 20px; }\n357:.job-card { position: relative; }\n358:.job-card h3 { padding-right: 48px; }","is_error":false}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"92ac850f-0058-4f8d-aabc-dea868f8eca1","timestamp":"2026-08-25T14:12:25.739Z","tool_use_result":{"stdout":" 375 frontend/src/styles.css\n193:.job-card {\n199:.job-card:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); text-decoration: none; }\n200:.job-card h3 { margin: 0 0 2px; font-family: var(--font-display); font-size: 17.5px; font-weight: 700; color: var(--ink); letter-spacing: -0.02em; }\n201:.job-card:hover h3 { text-decoration: underline; text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: 3px; }\n202:.job-card .employer { color: var(--accent-deep); font-weight: 600; }\n203:.job-card .meta { color: var(--ink-3); font-size: 13px; margin-top: 8px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }\n206:.badge {\n214:.badge.salary { background: var(--green-soft); color: var(--green); border-color: var(--green); }\n215:.badge.mode { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--line); }\n216:.badge.direct { background: transparent; border-color: var(--accent); color: var(--accent-deep); }\n225:.sheet .facts { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 20px; }\n357:.job-card { position: relative; }\n358:.job-card h3 { padding-right: 48px; }","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"95d15a63-125d-42cf-89b3-8a2fd8dc8d2f","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"a0af3585-426a-44dd-9663-6b0c42486524","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":100,"estimated_tokens_delta":50,"uuid":"eee00cb6-32af-4041-887c-ed1f0b3d2c9c","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":200,"estimated_tokens_delta":100,"uuid":"77bacc45-99b8-4164-9ad9-c6d914cfacc3","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":300,"estimated_tokens_delta":100,"uuid":"b8f6bad0-cce5-4ed4-b5ee-91ef462ff59e","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":400,"estimated_tokens_delta":100,"uuid":"8e25861f-74bd-4845-b577-f16441fc7185","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":450,"estimated_tokens_delta":50,"uuid":"37cae9d8-5c88-49af-beb2-e08bde45c461","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":600,"estimated_tokens_delta":150,"uuid":"74e2323a-e127-48f6-a09e-60e01f56a7d8","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":700,"estimated_tokens_delta":100,"uuid":"df9535a5-220d-4d2f-8244-f3f9a94bbb64","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":850,"estimated_tokens_delta":150,"uuid":"f24e9f13-1d1f-44bb-8f7e-93acb0032ecb","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":900,"estimated_tokens_delta":50,"uuid":"52be6898-6681-43cd-8a2f-432f9b53823f","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1000,"estimated_tokens_delta":100,"uuid":"dec80242-3905-4f7a-9f3a-0f4069291251","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1100,"estimated_tokens_delta":100,"uuid":"f310e447-8806-4b16-8561-8404cefda7c0","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1200,"estimated_tokens_delta":100,"uuid":"45ced90b-1eb0-40a0-9e13-6743fc62a576","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1250,"estimated_tokens_delta":50,"uuid":"2c977262-757c-4f62-bbb4-c01f185ba670","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1350,"estimated_tokens_delta":100,"uuid":"5a1a0784-6b3b-4af2-bc14-3058c4cdd105","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbxUooa9aWn5wsN1HnT","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS1x0KpwEIERgCKkDz3gC2VRPHRsgcZGdoKFY2IGlC1qgIh5aVRLpjI6NnUMX93bL2f+728OCVfqPU4GY74jlkrZbNd500xKVbuF6rMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAHoxbbUBrABAhIMcCHzKdqLacgItSs3GgzzkhOQXNZeP6Bp2uQiMAsLiHmnSVmzU9KZwL/a9EEXTrINfnORJayn0iXx7br5D32lNlPlb09PROy9sm5ItircG2TKaiCX0kqeDK1gnACskpzivlgHSM0V5hn0bTkpddXzUW/rg1f8EWVx2FuGJok7wyET2CtNqAxrRHROSf9Ju63G49X7E5iLlI+YbpxHXQldT2dO8VcK9qmUIRLuOWbAwRrmjLN/6egdZcRpft0Enitd85l+7DrYmcJWo25QdZzndrzX118H0i8kT90eopR5VDVcEPD/ApnKWzL3vZ7hh/sjNDnPk/5EcY8LxrDqzgjHStDhOGSVLQeSNTmTs+LuvBqsCdx2rGksQVNCBoYX95wQjzc1dTXP2eucfL/e2+VGZSsl7Bo3v2RNdnBXMHvPjyTjfYyyZYwx+esXvsCTS8a+IgVmZEP9jq93yKCbHgqIr3OZRt9m2fuhGFY11olpi4j9U49hvz0E0sYqEFlyrghG2yjh4ampmNrkG34ptZY0/26/NSaGsNXvUxkL8kyzMXu4T3elqsVDEQSELKP/aKOyaxIXeDQkPbwDaaAlCkpBGnKb00YkuQsnS4nY4jArGjXKjfBxGLhniTt884ci4Wjj198bn9Ou2sWy3rc1BxVh5Kwx6dFQ+6h9mkuk3M24wf5nZ8aDekjsuYkAK/oKZqnL/uIaFOzv5E0rxR9T/M3cAaOE1cnK0VXNmFKPFmw1h4QV/wxLp/nAsJQ7lrJzfSoLdaMSqVO+F8VWfu1W8EPFVzqVYCz7iIeDZquJn/f6HBSMrXKswTBVL9QesLy4/EKghC+s8ZxrqtzJO6imCKSC4zpqI6Y3wRUBATBt402HjbX/grsaAccQxSDgqg2ep6NzBmhfGB8gmOy8r1xb69f0Eo+POJzfzeFxeWvNBa07yD1Ko+m2FvKACruIdhHd4BIDQ1bj712OVxUxyZ1+0ZDiA4eITTZ8rUc6g7aSnzcR6U1Fhdr21dMoP8ZLLL3jI9acMt0+ommOPG6+Vxxb88kDwDevBJZ2KYUKBrC1sjgeaFthKtGGGk/HwKbFlMPvIO+GC6f4qzq1UmshzWQPIw8IdPlmlN3Jxr8V3rAjkTedO9iITPBX9R0d8zWQP+68lGXzgja2rKvWba1sdS8TlsV3pJhfrrTo+0C4l9w76mkggs4IBUxQAYWvQva4Bb1jmUoVykQkA+UdgBxWPqYmBwUXyE+cw4N63EV13nNi7oQC0UjNmNrB6iM5uP33dJ5fFLD03zp3aGr4RMGBSceeWP+YpN7BfeazsL+UIcDojw+86pn/72eZQ+12y98Zq3cYlXSCC781wzmDPRLfq8flLtiE9fFT+tUIsDkgbJlR8rvr0VMKehPwVAGidH2rGieJ9uhbCYz1FplaZslgQYgjWwhoArp1VTxnZXPpRuBsXXEg89Ww4IZYmPYflNzNc1W4YSiMifbu8hFhfrNTcnGi0Mu3UWUI3FpzTL7L2ny2X3KM9FYFgBmTKLoD+6E6/QSuDmDKaT7qGWJc/i/tusrR6Wd3vFMTjNPJwDQproyEftcGkonjjqG94X8A6vNCibOu9oHd3nDLprK1XIJfZOyCuspOx2+u+rrkFaps33g7t5d82qNM0nhkqVrsKK9I0RPih0rSBJUxjGwHoYgpe9oFMNlgv47yezIrGqsEW8kI3+o6f085FKFOi+9Q/i+4lvnp+VjluujY+77C3JMEwwdAYFLkyBhyUEjAIKEMSkqHsbTRTZeCyxVogPS91hwNBgeomaWWOwY67pTKS8BzZ7ccbHlzStUNjG4TMWd/d5FD/6IlKmZY795/wSwjptoVnwlOcq9RP2+4BcIgOCZawmbbPPTZU3qtXESV8fRLwtsdwVDbwR3fJlzIcdhFZbkAHuOJie5E6QftUqUwDvNz3GorvIx3kyRahJarntcdFiYrarsn2rUbdwSlQfPrJEERedpCbMoK39BgufI5QHBHr/+TQFq9gz+EKZ1uNAW9S4ZFhebm3lWrO+d3zQNCgNgnIYcTh2H9Gj5nK7JxzSjkAVLqiHD8is/uQVGLFQ4wzjd5PwCb6njkNpZKI/+sBG7zWL1L7zr9KpjV3BLOZk3PVxSQAtfwfqYL4PVeZXWCM/oKLJyrdHQYfBdWEHWSp+MPeag1+i1JXlqzKHhnaE4cKz3C9hwS+t9EeVFzvgyQqtc4ZmKXT49zF4x8gGpjEcCQcg9fvNCzNUr1v55/o7JXxEgYvrxF2NQwURk1X9246JsR2Oe/Jcnin7sKlqFQu5TiN2eiY87+BLn2LwVpoQ8fskMKmFtDM2uLLpneKzDe70C/9dpbzCrjQrAgzuxkdYF44wuRPb6jprL2M7giJTPETaQVmkx9baKBakZup2ZYDInbE/w5TyEvmxoXgNVC0PZf3LcOx2z3LUHTKVg3pWV3CiqY5aIX/eYY3d/uNd0/Sr859p6VKmmzPO2oAJN1YcW3RBs/fNCRy55KziiGVqdkcv6CM2EzJWkeL/DxS9fUdG21+1DQNRmPFnyGX1qR74XlwswcWByRlI6hOqpHZ58l/mS8PvQbD/6llkqFm3I+S0IH6rGlqCCdOOqWvE/RO8s1tyHxJrEkScYO2eGMXtGnfOq5e1gxa77tE3bDujnQv4qs8fXWWfrjiff+NYAXjFiDw2XAqb2nlbJCjtlsG3mZvg+KEsxOT4FpeDBvYfio8Lg2sjOSt8wREXVr2ob0N9B+LkZhjnvKVj0by3hTYG/shTsPwBstzx0TrS9fB69CH74XL0lVlIHmwKRH2yBRQelDo0+W0GijlDrVYDJ5gRC/sW9MxuzM3vc3wuIPMgXR6dHIOdjLUcIDwW81weeN6qksddC0vTOWrKBMMNJqgERdtLOh4t5/Pu8JyPoWIh2rXmHF20SmTKcnK4GtkrykYwasTWWKjTgG0gb83XlOlRzE/oyevqFd9nSyNxnAS8G5XBRnTsXfWbwlXlEbP+d95qmCu8AtnISla/r1T917z3LqpcWQNiLM93+SBRRLe3EN7umE2W9VFQ1jG/SBK57hyOtn17imZXfjetrcCW5G4ZmYzmOS4hvy8KvYJ3RBhZerBYNanJhqIRmV8pnfS3iBai8FUwqvN4o5tRAcWhEnlgzSnEgZR1ErnkiOuryrqb+mwH7+Iy4et+JODng9xoxRyY3o02gd29iS32cuuKLfggsFc9hHQqeuv/ES/wcF0MxIE6U/ks62vTdUwGa3SSnn7+2glFP5zaxJSsIkmWOiqLGQHjecMFzAPVHVPxxsdEwRZYigp/zWEdXkrkBwWANeIjdCKEHw/xZBr7EpAO8f4O5liZetjjZfQ9XYdp/csBNnSesKUWhVr47kJkFr5nW7vlO+bdNFU0IxxrQuSoxZSElmzJP/b7ZpjUmhUUk5bbNRF2NDoB/y32Rt+6YQPX+npTUBob4DsQBfFa/Wqtz7FenUPFYw5Ca/s3LMqLWsIgCuHZKnmWLoyONXW0bIjGixzkxmu1ah0AJbVtRGTSF1AO51tYfVoXXct4ifYURJz1G6g4HD/3uzekfnwxYT8aEMB/pFLBFg7EhMJ+yvwLr/fJk2HGZQDtNOAXxN4TzuC9/nXkajkZPe7D9TpA884qlBTdZsG6zH3R2FqBa+aG1E9XpbLi7JoJxIeVsDNqST2+3gzvR9aiVszEsycsTMPpkE3p+I3SMFPv1txZwX00H0wFv6Ohf7RcGKRXy68qzfKhfck+eUOhxLcLQ1BAPXYfD4JvqbzGRWJIoGSVCxf2O6fxM311LnTBzaLVAlF+6kzXlso/lJt+o8fXrr5nU79PQpuV2beUS9p0WdD79yr1TG1yEj9BLZeh3PmgPanvxPVG9kusZXPTaDbnhU2jdwGIbngyhuP83ISDNgSAZQcR7R8Sr+nC87vMksoaD6MLuI/ToVrcy+4Y1fenZh4FmTXkUCmwyCWLWie9JclDCm0+unV082oxkoxzxZoL9zQvPxEl4Imfc8MWzOTNrRk3NhUaEm0IlfJmXN6wUZe/0wwhq5fadRswFEIi/+PWiUPukWzvnHHK265ZpcpGqKZZKX+XSUyPOBugkJzt8s3GKV3YcXpQmxMTSJ6tXzgVdf88uyu1Q7SqsRTCGl5Bc9h2Yi0dA//5V8/JrGwwPM+qjpeCiJiEmZwbIQM+XKycmgcPYjOe4pUl9+PICXE1cLT1N4TM7RE9+xA+LGcEYV8Ew2FvWDw3gzLN4tuAxG0SlYNrNNHAnjiX0xTLxb/xK9gQrg0lNDF6ymz7AD2WCsBRv++1+fo4ExbJZTDG2BTa8OJ9MqJNQ9MDxVRjxMZRA4fRteIAUfcDA6QWd07dkoeDvq4Or2Xb/vWr0xG/l9SwhWJeFQVMSoO0k//LM/ubxPbDOrP+X6LZ0BKBghh3ZcmdhSExF/NpchKrjUT20xsHaHvK6NVaejMDFFyDGCM8d9M4k8SWuKiAh87omm2Eme/5rWnk2+1KOEp235ORQVp9VuaTltP0u4vv8oJOY1jlgFk+dww/bFHeZfbcP5AOddGT4EH8byjtP0qYvvayqrZNPbPzwiNuo6p9ijwTivnRY+WhlmfxvzXu1YEJ7IJKoJQfJT7dMhQ8TKI7HKKqIrg20gLJIBW/cFgKL3Vy5YSsofMFU4gOHt05tDCTOP3aSw1j5loMrspVqwWShVVjKnxdiOEp3O3TAfCl/C8/BOQX477On1uOpXYe3QDuJWS4lu//bd38VhLeWvZ28mkH9X6GyGPMw3fKZtKNT3TB8jo1k2H4NFIURFlrqYbjVEQ7JkhWhDR3bEe9Q/fDjkMO0DCICx6mLa2e1yMjYAGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5575,"cache_read_input_tokens":93221,"cache_creation":{"ephemeral_5m_input_tokens":5575,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"78f77d12-58d9-45af-925a-cd6f8867b881","request_id":"req_011CePbxRvAzfZDeaKWE3sbg"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbxUooa9aWn5wsN1HnT","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISmwUKqAEIERgCKkB6VRAOiAJxz/yurLy0AT3RApZZtNLDllnqY0PlNuWc/Bh4qKa6xE/EaeF90BUdOog6iL5fg8idi9Bk6tQz9QI2Mg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQHSFahiYOz86M3z97Uqk2VogBAagB6MW21AawAQISDIFGCY1URNaFhNlvfRoM1h+d7x+cAn4jFIrHIjA1eQT7PRE6AvheW3HaJVW4fUK1b07KkoKzppWbYfhwYASjzQKO1JOy4ySI+tUAjXIqnwMAn2kMKyD4n9yGpUhHwjw+Yp7rGbXmPENlFv8BibqIbXmKauW86mDuwIhzfCObJTlt67VmOL82Ef/xKIS6mD+p/etTwRo1bh69pIsFUdXxx/NuBYsAtvpN7NlK5Oo3b0FmKpDFms+SQE6fboyLwZi21RrmkeFTaHvCkHvYIVPStKd7lsuL6c5oG6oK8iqV9jJHFSsLrxsoTnjp6lU5wiNNZSyxM+/bVtEKNxAdkVxHGgG3bOwrloA7I1EwGZOBZiocqkK7BVm6tc/g7djv6FZsmB8l1T7k3+2NpZt94Ei49rkSSTk/VWZy/TbEOaw652YjZGzVmEvNcZC5zqg7aDOTV2T0SBrXlWRSXfeoFff5siLWp7yZVDSz6t4Fr/cW5d9fq0n9dv/zNXm936ZD1mFaXJLGJXqCFg/QKikkvEh7mJQ+fEQfvOrJKmOrYAPTcBy+VnBm/lUrwomhKAV+EHI74d4Vpn8jsl8jn9Q/rH13ohvXvzzDRWXZmPHn3wMXUOXVYlBMurXcaank5XucU9KNRtRb3orWRt5fZO2TQqHfGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5575,"cache_read_input_tokens":93221,"cache_creation":{"ephemeral_5m_input_tokens":5575,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"7b080023-d98a-4a8a-885f-27fd7d03988a","request_id":"req_011CePbxRvAzfZDeaKWE3sbg"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbxUooa9aWn5wsN1HnT","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JJr6ZgcxwArYDF4wUzpZMy","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/ingest.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5575,"cache_read_input_tokens":93221,"cache_creation":{"ephemeral_5m_input_tokens":5575,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"879b54ce-3914-425b-b3d6-a9dd0d5eb1d7","request_id":"req_011CePbxRvAzfZDeaKWE3sbg"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JJr6ZgcxwArYDF4wUzpZMy","type":"tool_result","content":"1\t# =============================================================================\n2\t# Job·Ka — Groupe KA\n3\t# Auteur : Simon-Pierre Boucher\n4\t# Contact : contact@spboucher.ai\n5\t# Fichier : jobka/ingest.py\n6\t# Rôle : Pipeline d'ingestion — exécute les connecteurs et synchronise la\n7\t# base (ajouts / mises à jour / retraits) = contenu toujours à jour\n8\t# Créé : 2026-08-17 Modifié : 2026-08-17\n9\t# =============================================================================\n10\tfrom __future__ import annotations\n11\t\n12\timport sys\n13\timport time\n14\timport traceback\n15\t\n16\tfrom . import db\n17\tfrom .connectors import CONNECTORS\n18\t\n19\t\n20\tdef run(sources: list[str] | None = None) -> list[dict]:\n21\t \"\"\"Exécute l'ingestion pour toutes les sources (ou celles demandées).\"\"\"\n22\t con = db.connect()\n23\t results = []\n24\t targets = sources or list(CONNECTORS.keys())\n25\t for sid in targets:\n26\t cls = CONNECTORS.get(sid)\n27\t if cls is None:\n28\t print(f\"[job-ka] connecteur inconnu : {sid}\", file=sys.stderr)\n29\t continue\n30\t t0 = time.time()\n31\t print(f\"[job-ka] sync {sid} ...\")\n32\t try:\n33\t postings = cls().fetch()\n34\t finalized, dropped = [], 0\n35\t for job in postings:\n36\t try:\n37\t finalized.append(job.finalize())\n38\t except Exception: # une offre malformée ne bloque pas la source\n39\t dropped += 1\n40\t stats = db.sync_source(con, sid, finalized)\n41\t stats[\"seconds\"] = round(time.time() - t0, 1)\n42\t if dropped:\n43\t stats[\"dropped\"] = dropped\n44\t if stats.get(\"alert\"):\n45\t print(f\"[job-ka] ⚠ ALERTE {sid} : {stats['alert']}\")\n46\t print(f\"[job-ka] {stats}\")\n47\t results.append(stats)\n48\t except Exception as exc: # robustesse : une source ne bloque pas les autres\n49\t db.log_failure(con, sid, f\"{exc}\")\n50\t traceback.print_exc()\n51\t results.append({\"source\": sid, \"error\": str(exc)})\n52\t # offres dont la date limite est dépassée : retirées même si encore en ligne\n53\t try:\n54\t expired = db.expire_past_deadline(con)\n55\t if expired:\n56\t print(f\"[job-ka] date limite dépassée : {expired} offre(s) retirée(s)\")\n57\t except Exception:\n58\t pass\n59\t # offres orphelines (connecteur retiré/muet) : archivées après STALE_DAYS\n60\t # — seulement lors d'un passage complet (un sync ciblé ne juge pas les autres)\n61\t if not sources:\n62\t try:\n63\t stale = db.expire_stale(con)\n64\t if stale:\n65\t print(f\"[job-ka] offres orphelines archivées : {stale}\")\n66\t except Exception:\n67\t pass\n68\t # statistiques du planificateur SQLite (requêtes bbox de la carte)\n69\t try:\n70\t con.execute(\"PRAGMA optimize\")\n71\t except Exception:\n72\t pass\n73\t con.close()\n74\t # déduplication inter-sources : aussi après un sync manuel (chez Lou·Ka\n75\t # elle ne tournait que dans watch(), ce qui laissait dup_of périmé)\n76\t try:\n77\t from . import dedup\n78\t stats = dedup.run()\n79\t print(f\"[job-ka] dedup: {stats}\")\n80\t except Exception as exc:\n81\t print(f\"[job-ka] dedup: erreur non bloquante: {exc}\", file=sys.stderr)\n82\t return results\n83\t\n84\t\n85\tdef watch(interval_seconds: int = 3600) -> None:\n86\t \"\"\"Boucle de rafraîchissement périodique (pseudo-webhook par sondage).\"\"\"\n87\t while True:\n88\t run()\n89\t try: # géocoder les nouveaux lieux de travail EN LOT (Adresses Québec)\n90\t from . import geocode\n91\t geocode.run_batch()\n92\t except Exception as exc:\n93\t print(f\"[job-ka] geocode: erreur non bloquante: {exc}\", file=sys.stderr)\n94\t try: # re-dédupliquer APRÈS géocodage (coordonnées = signal additionnel)\n95\t from . import dedup\n96\t stats = dedup.run()\n97\t print(f\"[job-ka] dedup: {stats}\")\n98\t except Exception as exc:\n99\t print(f\"[job-ka] dedup: erreur non bloquante: {exc}\", file=sys.stderr)\n100\t print(f\"[job-ka] prochaine synchronisation dans {interval_seconds}s\")\n101\t time.sleep(interval_seconds)\n102\t\n103\t\n104\tif __name__ == \"__main__\":\n105\t run(sys.argv[1:] or None)\n106\t"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"b4f8d46e-5a10-448b-9ed8-9c4ac44325ff","timestamp":"2026-08-25T14:12:57.623Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/ingest.py","content":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/ingest.py\n# Rôle : Pipeline d'ingestion — exécute les connecteurs et synchronise la\n# base (ajouts / mises à jour / retraits) = contenu toujours à jour\n# Créé : 2026-08-17 Modifié : 2026-08-17\n# =============================================================================\nfrom __future__ import annotations\n\nimport sys\nimport time\nimport traceback\n\nfrom . import db\nfrom .connectors import CONNECTORS\n\n\ndef run(sources: list[str] | None = None) -> list[dict]:\n \"\"\"Exécute l'ingestion pour toutes les sources (ou celles demandées).\"\"\"\n con = db.connect()\n results = []\n targets = sources or list(CONNECTORS.keys())\n for sid in targets:\n cls = CONNECTORS.get(sid)\n if cls is None:\n print(f\"[job-ka] connecteur inconnu : {sid}\", file=sys.stderr)\n continue\n t0 = time.time()\n print(f\"[job-ka] sync {sid} ...\")\n try:\n postings = cls().fetch()\n finalized, dropped = [], 0\n for job in postings:\n try:\n finalized.append(job.finalize())\n except Exception: # une offre malformée ne bloque pas la source\n dropped += 1\n stats = db.sync_source(con, sid, finalized)\n stats[\"seconds\"] = round(time.time() - t0, 1)\n if dropped:\n stats[\"dropped\"] = dropped\n if stats.get(\"alert\"):\n print(f\"[job-ka] ⚠ ALERTE {sid} : {stats['alert']}\")\n print(f\"[job-ka] {stats}\")\n results.append(stats)\n except Exception as exc: # robustesse : une source ne bloque pas les autres\n db.log_failure(con, sid, f\"{exc}\")\n traceback.print_exc()\n results.append({\"source\": sid, \"error\": str(exc)})\n # offres dont la date limite est dépassée : retirées même si encore en ligne\n try:\n expired = db.expire_past_deadline(con)\n if expired:\n print(f\"[job-ka] date limite dépassée : {expired} offre(s) retirée(s)\")\n except Exception:\n pass\n # offres orphelines (connecteur retiré/muet) : archivées après STALE_DAYS\n # — seulement lors d'un passage complet (un sync ciblé ne juge pas les autres)\n if not sources:\n try:\n stale = db.expire_stale(con)\n if stale:\n print(f\"[job-ka] offres orphelines archivées : {stale}\")\n except Exception:\n pass\n # statistiques du planificateur SQLite (requêtes bbox de la carte)\n try:\n con.execute(\"PRAGMA optimize\")\n except Exception:\n pass\n con.close()\n # déduplication inter-sources : aussi après un sync manuel (chez Lou·Ka\n # elle ne tournait que dans watch(), ce qui laissait dup_of périmé)\n try:\n from . import dedup\n stats = dedup.run()\n print(f\"[job-ka] dedup: {stats}\")\n except Exception as exc:\n print(f\"[job-ka] dedup: erreur non bloquante: {exc}\", file=sys.stderr)\n return results\n\n\ndef watch(interval_seconds: int = 3600) -> None:\n \"\"\"Boucle de rafraîchissement périodique (pseudo-webhook par sondage).\"\"\"\n while True:\n run()\n try: # géocoder les nouveaux lieux de travail EN LOT (Adresses Québec)\n from . import geocode\n geocode.run_batch()\n except Exception as exc:\n print(f\"[job-ka] geocode: erreur non bloquante: {exc}\", file=sys.stderr)\n try: # re-dédupliquer APRÈS géocodage (coordonnées = signal additionnel)\n from . import dedup\n stats = dedup.run()\n print(f\"[job-ka] dedup: {stats}\")\n except Exception as exc:\n print(f\"[job-ka] dedup: erreur non bloquante: {exc}\", file=sys.stderr)\n print(f\"[job-ka] prochaine synchronisation dans {interval_seconds}s\")\n time.sleep(interval_seconds)\n\n\nif __name__ == \"__main__\":\n run(sys.argv[1:] or None)\n","numLines":106,"startLine":1,"totalLines":106}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbxUooa9aWn5wsN1HnT","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DZdX8Av8sDHDdTMCKrwgv5","name":"Bash","input":{"command":"ls tests/ && head -30 tests/test_normalize.py 2>/dev/null; grep -rn \"requirements\" jobka/connectors/*.py | grep -v \"\\.pyc\" | head; echo \"---seniority check---\"; sqlite3 data/jobka.db \"SELECT requirements FROM jobs WHERE requirements<>'{}' AND requirements IS NOT NULL LIMIT 5;\"","description":"Check tests layout and existing requirements content"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5575,"cache_read_input_tokens":93221,"cache_creation":{"ephemeral_5m_input_tokens":5575,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"210db598-8935-497c-a751-fd09c74820cd","request_id":"req_011CePbxRvAzfZDeaKWE3sbg"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DZdX8Av8sDHDdTMCKrwgv5","type":"tool_result","content":"__pycache__\nconftest.py\nfixtures\ntest_connectors.py\ntest_normalize.py\n# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : tests/test_normalize.py\n# Rôle : Tests unitaires de la couche de normalisation (salaires, dates,\n# mode/type d'emploi, lieux, catégories)\n# Créé : 2026-08-17 Modifié : 2026-08-17\n# =============================================================================\nimport datetime as dt\n\nfrom jobka.normalize import (\n categorize,\n clean_html,\n is_quebec_location,\n parse_date,\n parse_employment_type,\n parse_location,\n parse_salary,\n parse_work_mode,\n salary_to_hourly,\n salary_to_yearly,\n)\n\nTODAY = dt.date(2026, 8, 17)\n\n\n# --- salaires ----------------------------------------------------------------\n\ndef test_salary_hourly_range():\njobka/connectors/_jsonld.py:187: job.requirements.setdefault(\"education\", fields[\"education\"])\njobka/connectors/_jsonld.py:189: job.requirements.setdefault(\"skills\", fields[\"skills\"])\njobka/connectors/bamboohr.py:120: job.requirements[\"experience\"] = d[\"experience\"]\njobka/connectors/njoyn.py:290: job.requirements[\"scolarite\"] = f[\"niveau de scolarité\"]\njobka/connectors/recruitee.py:15: description(HTML, dans la liste), requirements(HTML),\njobka/connectors/recruitee.py:76: reqs_html = item.get(\"requirements\") or \"\"\njobka/connectors/recruitee.py:80: reqs_html = fr.get(\"requirements\") or reqs_html\njobka/connectors/recruitee.py:126: job.requirements.setdefault(\njobka/connectors/recruitee.py:129: job.requirements.setdefault(\njobka/connectors/smartrecruiters.py:113: job.requirements[\"experience\"] = exp\n---seniority check---\n{\"experience\": \"Not Applicable\"}\n{\"experience\": \"Mid-Senior Level\"}\n{\"experience\": \"Mid-Senior Level\"}\n{\"experience\": \"Mid-Senior Level\"}\n{\"experience\": \"Mid-Senior Level\"}","is_error":false}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"360580d0-9a0c-407a-b915-e0f86598500b","timestamp":"2026-08-25T14:12:59.437Z","tool_use_result":{"stdout":"__pycache__\nconftest.py\nfixtures\ntest_connectors.py\ntest_normalize.py\n# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : tests/test_normalize.py\n# Rôle : Tests unitaires de la couche de normalisation (salaires, dates,\n# mode/type d'emploi, lieux, catégories)\n# Créé : 2026-08-17 Modifié : 2026-08-17\n# =============================================================================\nimport datetime as dt\n\nfrom jobka.normalize import (\n categorize,\n clean_html,\n is_quebec_location,\n parse_date,\n parse_employment_type,\n parse_location,\n parse_salary,\n parse_work_mode,\n salary_to_hourly,\n salary_to_yearly,\n)\n\nTODAY = dt.date(2026, 8, 17)\n\n\n# --- salaires ----------------------------------------------------------------\n\ndef test_salary_hourly_range():\njobka/connectors/_jsonld.py:187: job.requirements.setdefault(\"education\", fields[\"education\"])\njobka/connectors/_jsonld.py:189: job.requirements.setdefault(\"skills\", fields[\"skills\"])\njobka/connectors/bamboohr.py:120: job.requirements[\"experience\"] = d[\"experience\"]\njobka/connectors/njoyn.py:290: job.requirements[\"scolarite\"] = f[\"niveau de scolarité\"]\njobka/connectors/recruitee.py:15: description(HTML, dans la liste), requirements(HTML),\njobka/connectors/recruitee.py:76: reqs_html = item.get(\"requirements\") or \"\"\njobka/connectors/recruitee.py:80: reqs_html = fr.get(\"requirements\") or reqs_html\njobka/connectors/recruitee.py:126: job.requirements.setdefault(\njobka/connectors/recruitee.py:129: job.requirements.setdefault(\njobka/connectors/smartrecruiters.py:113: job.requirements[\"experience\"] = exp\n---seniority check---\n{\"experience\": \"Not Applicable\"}\n{\"experience\": \"Mid-Senior Level\"}\n{\"experience\": \"Mid-Senior Level\"}\n{\"experience\": \"Mid-Senior Level\"}\n{\"experience\": \"Mid-Senior Level\"}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"06c2bab5-126a-442d-8d41-c69ad89aca07","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"a6aff410-3afd-4def-a7e2-703c30b39b8a","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":200,"estimated_tokens_delta":150,"uuid":"023fc46e-f49f-4a3c-aa21-62ab68c104a9","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":350,"estimated_tokens_delta":150,"uuid":"f1e9e1e4-17a9-4bcc-bd5e-11597832b1c3","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":450,"estimated_tokens_delta":100,"uuid":"8df92608-5371-4346-8941-4f693e34a8f0","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":550,"estimated_tokens_delta":100,"uuid":"f33e7fe4-9568-4732-be67-5efaffd0dd0e","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":700,"estimated_tokens_delta":150,"uuid":"57576995-401f-4e30-b11e-5e37c782e1aa","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":800,"estimated_tokens_delta":100,"uuid":"0a3c0ab5-1233-4f2d-9d6a-c9626561c782","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":900,"estimated_tokens_delta":100,"uuid":"9f9a79d2-372d-4596-b58b-965a97ceae9e","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1050,"estimated_tokens_delta":150,"uuid":"e9bfc92a-0448-4711-bdf9-57dc8d75bf63","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1250,"estimated_tokens_delta":200,"uuid":"42d7de7c-d685-44c3-8b3b-38fcd043105a","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1350,"estimated_tokens_delta":100,"uuid":"072f954e-72ca-4e65-9639-515117fcdbf5","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbzweeavGoZqBEuz9PQ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS/RsKpwEIERgCKkDufjkAAHY3aLtCaRxh2284PhpnIVgZQVCHo/YBOEwNnTCuMaJ9WZDjrvpfYkcT9HqNDiKdZn3xG17671sk+yTLMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAGDxrbUBrABAhIMtBWjRQkuOa3AjfqaGgxOmWWsjF9ABPGeXPwiMKmR1yQvhBb4Uv/TuqqjacoazNaIr1R7g4MJ9KA0Yz3vxjIxFff+e754VtFC74kRxyqCGmRAQe4lx62A3a7jjZs51h0O4F2kzzJUtC8WdEhNgK6oSgfly/acnALkSEOcDOc6BT3FmhN4Bfbkw/U0SFa3PnBpJCbh4bxz3YzOMsvEzMCkKeTuvRMBXZJbT+NSX5jNinBr291gIFdVnrt3T28L7MZYOahG+vu3E/GTHGT+cjQgrG3Ti4hh+nSwxG/i4F24b210SEdTs0XfR5yPrwTg+CstMn1x+vTR6fSYr0gxox44nqqA2lsFagDJADYsMtdYAbHVIl8PmxV51Ze+zOYuwFzp/inEbnEhvRVIgtQNoGnstfMd3HLzBixbZ/AgpIUhRRHDGTSrh1ykmsTMCV4+ZZy2x8+QjK5c8OAh1BWMta0iLs0FicOjy5zYdmBmcOpfW0VjCtZ4zsPxjsKp0r/6Hx48UdcAso73a6ol3h/edHk4OQdIuselRsZh1D1pYGQrBJY/StVXQOtc3maOD29I3M3xbd9mWivQ8k2Gz+FeExTT8XssdOIPU73LXtQ/cU4zmOvwPy1HMj0LxdD+TkCgCIAm992sF1vHfdOy2ICzvUADk7fgVZGLBmp+spSddrEZyP6Y0gw1KSAgNz7HKRQKLxPYj5bK8iZEtllM869ueXCnz/QHuuFnzFaDGxg+KGDgO6szNxcNBKxDcC+HsVU8V0lUjqdxDUBrd8tdjOEiWo2FhvjYAReEeXwmj1PW4IFf/lumYsNZGNu21DhUMPkjhP7bCvXwokKi9K152nv3byOxwpjy7wlYN882NR2Q6iD3SFDEJMjNJbXGKWkKzxpb8kkP5nYLvxyn73LbWFjbK2adZKgEWPf70ek0YY6R4t//gAJBvlmzJCjyGfZhm4R0aLpndrvp+ZpjGW8J7hVtclpNnTM1GvDh3pMu7BNEG4kPCyRtqEb+OY34O8h2VTOGJ5KBNzBAuBDspD2KnidMSNLWl6QNuorl0JSYcsj61TGwLtKmKkvf2eLK2+JjBZ4tvGuR/WJFl0VtYDUtDp6XYZ74lB2vw+G3WUIPsZUaAozd+kbouvFNTjicVLNtk/rrb6VK2h4utiHQI2T1PCcgorXSDN6Cg80lL47AfHs1efW63m7Xk9IqerkcxEppIZy+uKh1bxD4OvKL/OqstM+A3FtcsJeMx/d1eKU5CsaiJjYH857fWBLYVMLWqpVfk85F1Fp/aHIe1jwDQZ4WxfaDrlWIBM5mFY0sL0WFwEIPHfB99IuznEf5wotCgjyDpbw8PL0TXUloCZdIasPRqoSfl1XAQr4sMm0DBcKv/fF5gXvBdtKGJcHkDXo4qyNd7cCdsW9SCveoZIS/RNUM7UZL1obJAk+oO9rA7f03CVtu3O95iPKRvGqpx3K23t/8s68NbVIMxkOV8Xdo+RC/mZtLGHmgZsWFsykoQdCslM863xYg6r7XrR2Luz2MyZCKvkWtvd+QWpe+CnxYIgB0yAXeIl5+BJ5nvr/JYniOopaGF56872xFHd7W4hzEDrMBKxif1nAop/iVK+RZUkLJ7WxyZq0UBKC8oZKFbeR0sdQ71Ff9hMwGGctk6ZncU90Gx7do2Saf9j39gQvHxUihDIi1Oa3hi3CdsTfhesZZfdONFS89kfCSZJKbvxAhKrZ1J13RjAz4+BcQTY3qhX/rKNG0KUngxvov/xUExYPx/5mAg4f53nGPfJXhEsK17ZN4sA0ajwvWMSv5MnjQWlU3+ZLy/8y7Tx+EocfTrF8V0zvV2YBKXI/mN8+kS/xkRKjJDB6poxNYcouls0VPuMGNwa5zsLHZFp6Cz6WrJ/QOsh5RGoDKs2G9bpZULNBjShzwdbnGXgpi4uSDdvWWYy58Qf6Iu6fJjDWl/OhgqfU9DIyy2NmACten45SUfQOPZuK5Wt9Se0E+CpIOHlcslVOPDVF2OKNYpVYFInUuZ2IHhamGGfxAoC13SsiEHdm08pDwkynMiSIidsdbJL0T22aR0XNZSkqQviy5AUveUgmx6mzI3rCIH8jMPwcuuazE22OiHQJej0qiGRJt8ut+KDkDhfrs/yj/AffMmenzwV5tlgwsHMF+5Hj1BDmAwcoBemO+LNTP+pKYmd4qm3M6gyEhLd8Q99HWmy9ReyKshm2CMS42IGZZrrLj3nRRuThrFA0QusJqi6SCIuyCIn4cSv+62IpNqs89L0C3S6XuSUPhzz1iT4eMC0HzNx1MXzjKrdWAlgI43QQOWWr8sZlK7LlzMWEFwJMOIGF+k0imdE7VMLDl8deLtCNMk9BAThsSGndoRRJmecJu8MPDrh/VobVTNN262n5wkTrDsT5/WXE9JDqgVNpjVY8aNZEuk7yMg+DCjjDcjS5+poyRHWfWVy5f23yIDBlYszzzEoG6ECC8YQDGLYklqrFoieUHUYUsC0KHgWccHto9QzB94qpRwArBDmf/6gE/6AsDBtVYm6Nni9VZiUJpoPB9X+CAfKk4bGtdAN+GclhLq2DTZ4UbgRDbJJr5LId4mV28fwbY5xERJGaBnIYG8+TU7ctbAWejI1fNzC7BBf0/KjlN0KaF7mJVNfkjlx/RnRg9jfgczMeELeQX5Iehu5H//8fHmGS8aJuFm4JnngOkT/pGM/I5KsWgrjb3Qu1OAeIXDnlIQtJj9ip6w6xGbViqJUL5KccjTHmSvkKeWSsJ45sqHmfZYNoA7B3CbblbLm0FQlPdj7aQgG78LF6SShKiaEFit0ihEYe0snhztlGqJp2FwHFH2YjpnsjUtFQ35YaH6wO3fgNyvQ+8tdAxRrhr3bZrthP6ccvRQWvLc/Yv+FMjSj68Xupm64BynRFvCOMugGt14I9WoEI1LgBnG+h1udW7WzelW0jfwlJNPVbEDZjM/+rhnzGRkxIE9f21O3GMUpzdddJo2SVeEvNkKbX4czH1AnX4fJCBkPfjGO8ODoAvtwzgsMlQRaizU0bcA18fxFFtMpG3VIkXJpL3VCiVmNjsqyJ7vzgAtJYGcKlmRL11cU5eQyS4ulnVWLSn8rrzXLB+RyjdLkcOHDJU5kgAc6Js6T8aa/oXr2jXZrsgpP9Uhg3vtXlNI8lrx4Na3uiMYnnCZhdpeR+JS7EyrxkuDLAbOES6vrgVtUJmL95ATFUMywvkGe+eLGTD4AkAryE6yCmtR4IT4o/4s2fWEWJJag0IxYzh6mgogXT5mJoRfvg4n4Rm7ykbufP0AG3JhPtiS/PcARt6Gzs2AVbW1DXAo8pI41db5DB/KWMabUMKCrc/PSWQwFMcakeB9VBcL+NMBYbN9L90ef1CNu/Y59nmMr5ZDYuNUcf56BHEdQmg2pl0XaUREGZbczbJ486Oekv4Mv0LneNBgPqLGLJviNBteEZ9XPKJgkXX7NOUvGFdhw1p7kutZfvWZWOKcCB9eXsR+9YWk3bXC9EQBCTColoFYLSHo1U1wYGyj5FuS0YVNQuiXwW5F2zWCuGn/UE5VfmTPbBDseDzi3kCLCLrWKddKpDRwl9A/t5B/8tx6sVLAjUOZOegPGmS+GhNP7MoBKWVQr8SWEdsItWacC5ZB97ewp9BbOihkz/dt2Q42RULZxTf2oX6rAVXPSnJ82DLam6xdUdighqHF1aDT3d5+7/XVq5+iL3G8s/rQ8YDBjI2XG4wJxfWeMNKqgzd0T7QN+V5ERHcGP3xut2/pAZDgxJG1no1g43T4rH2j8aD5KsRHontZVXYcsgokD/Hmhk8U9Gn6FEqthljzbtdg2EWChbz3if0GYl1SQhnEOseId0jFCjJDn/PL/VqU81dH7MI2eS6tFiGKALF2R23fncpfy5k9gLhoT1GChbySNz4WXgbepiS0gEAv5oBRzHXZ75f4SJw0osIlC8/h9CrLZ8zWsP6Vwwmk3BA+X5dEvTDueWeVbO2gYwRLUVKTYCE3XRUJEVtbnrBPTmvMP85Wy7y2YjrSsRx5BSZv6QUyoWZHGCjzUgUiQOT/R2gpDykj80ww0RI844rKHhCuFQD0TcvVGTHUTukzUiHid80bgmSmLxDLTkoGxoFb/L1O8C7nhpeWuP+Mn3ccMLhae00UNSob0ifdFj/SYaJFRZkSLr0armlN5O7QYcnuRG+VhlZ/71uQmCmr0f8qxDKY+8l/exf9046I0xspDvC/rJ7rHMgGOSH8motaK0gEuTosC9LxqCQ9+Kyjd3WS0mfNSQzSShewSJiliwcm55CaCY1G6MpKXgV1lnyF9DGh6nOFSLz0EgrAk0hbSE1/xtqbNuwENrWn9dc+HWptAn68FmGrog4GCvZO2e1x2xYvxMiKy2QveiGQ6rJXgvYecy/E76xokzZlvdSlzxMToLJMXIS1E7xjtErNoNdtjtcN1TpkkUU5fisq0kLwF8YMYhmQmKNaf2fbviRK2IYV4P+yCcGnsRkgBZa+U77xOFMPteCfIM5DNoJoqdgyasO4/23frstMSyBT3pnkBaryh488459dmFQKJ0CUxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4475,"cache_read_input_tokens":98796,"cache_creation":{"ephemeral_5m_input_tokens":4475,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"786bbefd-1538-4409-9b99-18fcf4933d6f","request_id":"req_011CePbzurzWYdj1HXqVZBmd"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbzweeavGoZqBEuz9PQ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS3gMKqAEIERgCKkCIurKVR/7loNtljm5u6rAiOfjuHEGhM8A2TpYTNJZlCHeDF4jUpRxCaxcw2oDUdpvkYXpbDKHI0NFRXcDqzGdXMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQHSFahiYOz86M3z97Uqk2VogBAagBg8a21AawAQISDB2iIg8AL7II6j5eeBoMhS07jGK052pzINKoIjBY9q7RzsfohHsWklXr2oSCr2+FtDZXiEee52caeCESujnFy3AV7x/39s9wRj6stX8q4gG4bCpauJZlOgI8XGKeIcZlSVi6jC8jVM46bMhvGBmKVogj2CsAnICc7MHq+KeV8Z7YI6/CbrKSl0cBt2fUqWdSfqWwYaX1ExPNZGwdpw9tf4eonblzGOxAhK4ECKrxvWAokaEXb3ZLDibnFw3IkT/ejJHSwDLG6ys+ctZNzNLr8GVqZ+ewnq5n/XWoiKSo3/U3GVUdA7+Oc9Housd8OZr0uNtV1bKtN6PAi/m5MNjweDHpSgYBKaqjtkYAspiccPOfbmqyb3+6ehF6hzmUlqmZ/EMIceSfL+gnoQqM0qXK5W3rGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4475,"cache_read_input_tokens":98796,"cache_creation":{"ephemeral_5m_input_tokens":4475,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"0802ab9f-dfc6-4926-9e41-bf8df01bee0e","request_id":"req_011CePbzurzWYdj1HXqVZBmd"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePbzweeavGoZqBEuz9PQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015d9CVUSqPwgMZ8iMvgk7Ab","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/styles.css"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4475,"cache_read_input_tokens":98796,"cache_creation":{"ephemeral_5m_input_tokens":4475,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"d459a6aa-ba97-4b4a-be80-c3fb83edfbd2","request_id":"req_011CePbzurzWYdj1HXqVZBmd"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015d9CVUSqPwgMZ8iMvgk7Ab","type":"tool_result","content":"1\t/*\n2\t=============================================================================\n3\tJob·Ka — Groupe KA\n4\tAuteur : Simon-Pierre Boucher\n5\tContact : contact@spboucher.ai\n6\tFichier : frontend/src/styles.css\n7\tRôle : CSS local Job·Ka — s'appuie sur le design system commun Groupe KA\n8\t (ka/tokens.css, importé AVANT ce fichier dans main.tsx). Ici :\n9\t seulement l'accent de la marque + les classes métier (offres,\n10\t filtres, carte) au style « éditorial sharp » du groupe (bordures\n11\t encre 1,5 px + ombres décalées dures, étiquettes mono). Le socle\n12\t (body, grain, sélection, .container, .btn, .card, .chip,\n13\t ka-footer…) vit dans tokens.css.\n14\tCréé : 2026-08-17 Modifié : 2026-08-22\n15\t=============================================================================\n16\t*/\n17\t\n18\t:root {\n19\t /* ---- ACCENT Job·Ka (cyan officiel — voir ka/ecosystem.json) ---- */\n20\t --accent: #0c8599;\n21\t --accent-soft: #def0f4;\n22\t --accent-deep: #095c6b;\n23\t --on-accent: #ffffff;\n24\t --lime: var(--accent);\n25\t --lime-soft: var(--accent-soft);\n26\t\n27\t /* ---- alias hérités du CSS v1 (classes métier ci-dessous) ---- */\n28\t --brand: var(--ink);\n29\t --border: var(--line);\n30\t --green-soft: #e7f2ec;\n31\t --radius: var(--r-card);\n32\t --shadow: var(--shadow-flat), 0 4px 14px rgb(20 24 20 / 6%);\n33\t}\n34\t\n35\ta { color: var(--green); text-decoration: none; }\n36\ta:hover { text-decoration: underline; }\n37\t\n38\t/* --- en-tête ---------------------------------------------------------------- */\n39\theader.site {\n40\t background: var(--brand);\n41\t color: #fff;\n42\t position: sticky; top: 0; z-index: var(--z-header, 500);\n43\t}\n44\t/* menu mobile ouvert : le header (et son panneau) passe au-dessus du backdrop */\n45\theader.site.menu-open { z-index: var(--z-modal, 900); }\n46\theader.site .container {\n47\t display: flex; align-items: center; flex-wrap: wrap;\n48\t gap: 4px 14px; min-height: 58px; padding-top: 8px; padding-bottom: 8px;\n49\t}\n50\t.logo { font-family: var(--font-display); font-weight: 700; font-size: 20px; color: #fff; letter-spacing: -0.3px; white-space: nowrap; position: relative; }\n51\t/* extension tactile : le logo (63×31) atteint >=44px de cible sans changer le rendu */\n52\t.logo::after { content: \"\"; position: absolute; inset: -8px; }\n53\t.logo em { color: var(--accent-soft); font-style: normal; }\n54\theader.site .gk-badge { flex-shrink: 0; }\n55\tnav.main { display: flex; gap: 4px; margin-left: auto; }\n56\tnav.main a {\n57\t /* pilules font-display 700 (standard header/menu KA 2026-08-22), thème encre conservé */\n58\t color: #d7ddda; padding: 7px 12px; border-radius: 999px; font-weight: 700;\n59\t font-family: var(--font-display, \"Space Grotesk\", system-ui, sans-serif); font-size: 13.5px;\n60\t letter-spacing: -0.01em; white-space: nowrap;\n61\t}\n62\tnav.main a:hover { color: #fff; background: rgb(255 255 255 / 8%); text-decoration: none; }\n63\tnav.main a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10%); }\n64\t\n65\t/* connexion KA ID (droite du header) */\n66\t.auth-box { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n67\t.btn-ka {\n68\t display: inline-flex; align-items: center; gap: 7px; min-height: 34px;\n69\t padding: 4px 13px; border: 1.5px solid rgb(245 243 238 / 45%);\n70\t border-radius: var(--r-pill); background: transparent; color: #f5f3ee;\n71\t font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n72\t letter-spacing: 0.07em; text-transform: uppercase; cursor: pointer;\n73\t white-space: nowrap; text-decoration: none;\n74\t}\n75\t.btn-ka b {\n76\t font-family: var(--font-display); font-size: 12px; text-transform: none;\n77\t letter-spacing: -0.02em; background: var(--accent); color: var(--on-accent);\n78\t border-radius: 5px; padding: 0 6px 1px;\n79\t}\n80\t.btn-ka:hover { border-color: var(--accent-soft); text-decoration: none; }\n81\t.auth-user { display: inline-flex; align-items: center; gap: 8px; color: #d7ddda; font-size: 13px; white-space: nowrap; }\n82\t.auth-user img { width: 26px; height: 26px; border-radius: 50%; border: 1.5px solid rgb(245 243 238 / 45%); }\n83\t.auth-user button {\n84\t border: 0; background: none; color: #9aa5a0; font: inherit; font-size: 12px;\n85\t cursor: pointer; padding: 6px 0; min-height: 34px;\n86\t}\n87\t.auth-user button:hover { color: #fff; text-decoration: underline; }\n88\t\n89\t/* bascule mobile du header : sous 980 px la nav passe sur sa propre rangée\n90\t (défilement horizontal contenu — jamais de débordement de page) */\n91\t@media (max-width: 979px) {\n92\t nav.main {\n93\t order: 10; flex-basis: 100%; margin-left: 0;\n94\t overflow-x: auto; -webkit-overflow-scrolling: touch;\n95\t scrollbar-width: none; padding-bottom: 2px;\n96\t }\n97\t nav.main::-webkit-scrollbar { display: none; }\n98\t nav.main a { padding: 7px 9px; font-size: 13.5px; }\n99\t .auth-box { margin-left: auto; }\n100\t}\n101\t@media (max-width: 560px) {\n102\t header.site .gk-badge { padding: 2px 8px 3px; font-size: 9px; }\n103\t header.site .gk-badge b { font-size: 11px; }\n104\t .auth-user span.name { display: none; }\n105\t}\n106\t\n107\t/* --- menu mobile (hamburger) -------------------------------------------------\n108\t Sous 700 px, la nav en flex-wrap (2-3 lignes) est remplacée par un bouton\n109\t 44×44 + panneau déroulant sous le header ; backdrop fixe rendu HORS du\n110\t header (frère dans App.tsx), verrou de scroll géré côté React. */\n111\t.menu-btn {\n112\t display: none; width: 44px; height: 44px; margin-left: 4px;\n113\t flex-direction: column; align-items: center; justify-content: center; gap: 5px;\n114\t flex-shrink: 0; background: none; border: 0; border-radius: 8px;\n115\t cursor: pointer; padding: 0;\n116\t}\n117\t.menu-btn:hover { background: rgb(255 255 255 / 8%); }\n118\t.menu-btn span {\n119\t display: block; width: 22px; height: 2px; background: #f5f3ee; border-radius: 2px;\n120\t transition: transform 0.2s ease, opacity 0.15s ease;\n121\t}\n122\t.menu-btn[aria-expanded=\"true\"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }\n123\t.menu-btn[aria-expanded=\"true\"] span:nth-child(2) { opacity: 0; }\n124\t.menu-btn[aria-expanded=\"true\"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }\n125\t.mobile-menu {\n126\t display: none; position: absolute; top: 100%; left: 0; right: 0;\n127\t background: var(--brand); overflow: hidden;\n128\t max-height: 0; opacity: 0;\n129\t transition: max-height 0.25s ease, opacity 0.2s ease;\n130\t}\n131\t.mobile-menu.open {\n132\t max-height: 480px; opacity: 1;\n133\t border-bottom: 1.5px solid var(--ink);\n134\t padding-bottom: calc(16px + env(safe-area-inset-bottom));\n135\t}\n136\t.mobile-menu a {\n137\t display: flex; align-items: center; min-height: 44px;\n138\t padding: 10px 20px; color: #d7ddda; font-weight: 500;\n139\t}\n140\t.mobile-menu a:hover { color: #fff; text-decoration: none; background: rgb(255 255 255 / 8%); }\n141\t.mobile-menu a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10%); }\n142\t.mm-backdrop {\n143\t position: fixed; inset: 0; background: rgb(16 18 16 / 45%);\n144\t z-index: var(--z-overlay, 800);\n145\t}\n146\t@media (max-width: 700px) {\n147\t nav.main { display: none; }\n148\t .menu-btn { display: flex; }\n149\t .mobile-menu { display: block; }\n150\t}\n151\t\n152\t/* --- héros (encre — identité Job·Ka) ---------------------------------------- */\n153\t.hero { background: var(--brand); color: #fff; padding: 42px 0 36px; }\n154\t.hero .kicker { color: var(--accent-soft); }\n155\t.hero .kicker::before { background: var(--accent); }\n156\t.hero h1 { margin: 6px 0 8px; font-size: clamp(28px, 2.6vw + 16px, 42px); font-weight: 700; letter-spacing: -0.03em; color: #fff; line-height: 1.08; }\n157\t.hero h1 em {\n158\t /* surligné d'accent incliné — signature du groupe (.hl), déclinaison encre */\n159\t display: inline-block; font-style: normal; background: var(--accent);\n160\t color: var(--on-accent); border-radius: 8px; padding: 0 10px 2px;\n161\t transform: rotate(-1deg);\n162\t}\n163\t.hero p { margin: 0 0 22px; color: #b9c2be; max-width: 640px; }\n164\t\n165\t/* --- filtres (champs sharp : bordure encre + focus accent décalé) ------------ */\n166\t.filters { display: flex; flex-wrap: wrap; gap: 10px; }\n167\t.filters input[type=\"search\"], .filters select {\n168\t height: 44px; padding: 0 12px; border-radius: var(--r-ctl);\n169\t border: 1.5px solid var(--ink);\n170\t background: var(--surface); color: var(--ink); font: inherit; min-width: 130px;\n171\t}\n172\t.filters input[type=\"search\"]:focus, .filters select:focus {\n173\t outline: none; box-shadow: 3px 3px 0 var(--accent);\n174\t}\n175\t.filters input[type=\"search\"] { flex: 1 1 220px; }\n176\t.filters input[type=\"search\"]::placeholder { color: var(--ink-3); }\n177\t.filters label.check {\n178\t display: inline-flex; align-items: center; gap: 7px; background: var(--surface);\n179\t color: var(--ink); /* le héros est blanc sur encre — ne pas hériter */\n180\t border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 0 14px;\n181\t height: 44px; cursor: pointer; user-select: none; white-space: nowrap;\n182\t}\n183\t.filters label.check:has(input:checked) {\n184\t background: var(--accent-soft); box-shadow: 3px 3px 0 var(--accent);\n185\t}\n186\t/* champs >=16px sur mobile : évite le zoom automatique d'iOS au focus */\n187\t@media (max-width: 700px) {\n188\t .filters input[type=\"search\"], .filters select, .filters label.check { font-size: 16px; }\n189\t}\n190\t\n191\t/* --- cartes d'offres (sharp : bordure encre + ombre décalée) --------------------- */\n192\t.job-list { display: grid; gap: 14px; margin: 22px 0; }\n193\t.job-card {\n194\t display: block; background: var(--surface); border: 1.5px solid var(--ink);\n195\t border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n196\t padding: 18px 20px; color: inherit;\n197\t transition: transform 0.15s, box-shadow 0.15s;\n198\t}\n199\t.job-card:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); text-decoration: none; }\n200\t.job-card h3 { margin: 0 0 2px; font-family: var(--font-display); font-size: 17.5px; font-weight: 700; color: var(--ink); letter-spacing: -0.02em; }\n201\t.job-card:hover h3 { text-decoration: underline; text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: 3px; }\n202\t.job-card .employer { color: var(--accent-deep); font-weight: 600; }\n203\t.job-card .meta { color: var(--ink-3); font-size: 13px; margin-top: 8px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }\n204\t\n205\t/* étiquettes façon .chip du groupe : mono, uppercase, pilule bordée */\n206\t.badge {\n207\t display: inline-flex; align-items: center; gap: 5px;\n208\t padding: 3px 10px; border-radius: var(--r-pill);\n209\t font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n210\t text-transform: uppercase; letter-spacing: 0.05em;\n211\t background: var(--surface-2); color: var(--ink-2);\n212\t border: 1.5px solid var(--line);\n213\t}\n214\t.badge.salary { background: var(--green-soft); color: var(--green); border-color: var(--green); }\n215\t.badge.mode { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--line); }\n216\t.badge.direct { background: transparent; border-color: var(--accent); color: var(--accent-deep); }\n217\t\n218\t/* --- fiche ------------------------------------------------------------------------- */\n219\t.sheet {\n220\t background: var(--surface); border: 1.5px solid var(--ink);\n221\t border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n222\t padding: 28px 32px; margin: 24px 0;\n223\t}\n224\t.sheet h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.3px; }\n225\t.sheet .facts { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 20px; }\n226\t.sheet .description { white-space: pre-line; color: var(--ink-2); }\n227\t\n228\t/* --- tuiles de statistiques (hero numbers) --------------------------------------- */\n229\t.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin: 22px 0; }\n230\t.tile {\n231\t background: var(--surface); border: 1.5px solid var(--ink);\n232\t border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n233\t padding: 18px 20px; position: relative; overflow: hidden;\n234\t}\n235\t.tile::before { content: \"\"; position: absolute; top: 0; left: 0; right: 0; height: 4px; background: var(--accent); }\n236\t.tile .value { font-family: var(--font-display); font-size: 27px; font-weight: 700; letter-spacing: -0.5px; }\n237\t.tile .label { color: var(--ink-3); font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 4px; }\n238\t\n239\t/* --- barres horizontales (magnitude, teinte unique) --------------------------------- */\n240\t.bars { display: grid; gap: 8px; margin: 10px 0 26px; }\n241\t.bar-row { display: grid; grid-template-columns: 190px 1fr 52px; align-items: center; gap: 10px; }\n242\t.bar-row .name { font-size: 13.5px; color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n243\t.bar-row .track { background: rgb(20 24 20 / 6%); border-radius: 4px; height: 14px; }\n244\t.bar-row .fill { background: var(--accent); border-radius: 4px; height: 14px; min-width: 3px; }\n245\t.bar-row .n { font-size: 13px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }\n246\t\n247\t/* --- tableaux ----------------------------------------------------------------------- */\n248\ttable.data { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); overflow: hidden; box-shadow: var(--shadow-off-soft); }\n249\ttable.data th, table.data td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 14px; }\n250\ttable.data th { background: var(--surface-2); color: var(--ink-2); font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1.5px solid var(--ink); }\n251\ttable.data tr:last-child td { border-bottom: none; }\n252\t\n253\t/* --- carte -------------------------------------------------------------------------- */\n254\t/* hauteur = viewport - hauteur RÉELLE du header (--header-h posée par App.tsx\n255\t via ResizeObserver) ; dvh évite le saut quand la barre du navigateur se replie */\n256\t.map-wrap {\n257\t height: calc(100vh - var(--header-h, 58px));\n258\t height: calc(100dvh - var(--header-h, 58px));\n259\t position: relative;\n260\t}\n261\t.map-wrap .maplibregl-map { height: 100%; }\n262\t.map-count {\n263\t position: absolute; top: 14px; left: 14px; z-index: 5;\n264\t background: var(--brand); color: #fff; border-radius: var(--r-card);\n265\t border: 1.5px solid var(--ink); box-shadow: var(--shadow-off-mid);\n266\t padding: 8px 14px; font-size: 13px; font-family: var(--font-display); font-weight: 700;\n267\t}\n268\t.map-popup h4 { margin: 0 0 2px; font-size: 14px; }\n269\t.map-popup { font: 13px \"Inter\", sans-serif; }\n270\t\n271\t/* --- pagination / divers ------------------------------------------------------------- */\n272\t.pager { display: flex; gap: 10px; justify-content: center; margin: 26px 0; }\n273\t.pager button {\n274\t background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n275\t padding: 8px 16px; cursor: pointer; font: inherit;\n276\t font-family: var(--font-display); font-weight: 700; font-size: 14px;\n277\t min-height: var(--touch);\n278\t transition: transform 0.15s, box-shadow 0.15s;\n279\t}\n280\t.pager button:hover:not(:disabled) { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }\n281\t.pager button:active { transform: none; box-shadow: none; }\n282\t.pager button:disabled { opacity: 0.4; cursor: default; }\n283\t.empty { text-align: center; color: var(--ink-3); padding: 60px 0; }\n284\t.section-title { font-family: var(--font-display); font-size: 19px; font-weight: 700; letter-spacing: -0.02em; margin: 30px 0 4px; }\n285\t.muted { color: var(--ink-3); font-size: 13px; }\n286\t\n287\t/* --- formulaire de dépôt direct (page Publier) ------------------------------------------ */\n288\t.depot-grid { display: grid; gap: 14px; grid-template-columns: 1fr 1fr; }\n289\t@media (max-width: 700px) { .depot-grid { grid-template-columns: 1fr; } }\n290\t.depot-form label {\n291\t display: flex; flex-direction: column; gap: 6px;\n292\t font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n293\t text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-2);\n294\t}\n295\t.depot-form input:not([name=\"website\"]), .depot-form select, .depot-form textarea {\n296\t font: 15px/1.4 var(--font-body); color: var(--ink);\n297\t border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n298\t background: var(--surface); padding: 10px 12px; min-height: var(--touch);\n299\t}\n300\t.depot-form textarea { resize: vertical; }\n301\t.depot-form input:focus, .depot-form select:focus, .depot-form textarea:focus {\n302\t outline: none; box-shadow: 3px 3px 0 var(--accent);\n303\t}\n304\t.depot-form input::placeholder, .depot-form textarea::placeholder { color: var(--ink-3); }\n305\t\n306\t/* --- page Contact (commune Groupe KA) -------------------------------------------------- */\n307\t.contact-page { padding: 44px 0 90px; }\n308\t.contact-page h1 { margin: 10px 0 8px; }\n309\t.contact-page .lede { color: var(--ink-2); max-width: 640px; margin: 0 0 26px; }\n310\t.contact-grid { display: grid; gap: 16px; grid-template-columns: 1fr; margin: 26px 0; }\n311\t@media (min-width: 768px) { .contact-grid { grid-template-columns: repeat(3, 1fr); } }\n312\t.contact-card { padding: 18px 20px; }\n313\t.contact-card .klabel { display: block; margin-bottom: 6px; }\n314\t.contact-card a.mail {\n315\t font-family: var(--font-mono); font-weight: 700; font-size: 13.5px;\n316\t color: var(--accent-deep); word-break: break-all;\n317\t text-decoration: underline; text-underline-offset: 3px;\n318\t}\n319\t.contact-note {\n320\t border-left: 3px solid var(--accent); background: var(--surface);\n321\t border-top: 1.5px solid var(--ink); border-right: 1.5px solid var(--ink);\n322\t border-bottom: 1.5px solid var(--ink); border-radius: 0 var(--r-card) var(--r-card) 0;\n323\t padding: 14px 18px; max-width: 720px; color: var(--ink-2); font-size: 14px;\n324\t}\n325\t.contact-note b { color: var(--ink); }\n326\t.contact-sites { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }\n327\t.contact-sites a {\n328\t display: inline-flex; align-items: center; min-height: 34px;\n329\t padding: 4px 12px; border: 1.5px solid var(--ink); border-radius: var(--r-pill);\n330\t background: var(--surface); font-family: var(--font-mono); font-size: 11px;\n331\t font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;\n332\t color: var(--ink); transition: transform 0.12s, box-shadow 0.12s;\n333\t}\n334\t.contact-sites a:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); text-decoration: none; }\n335\t.contact-hub { margin-top: 26px; }\n336\t\n337\t/* --- rangée de navigation locale au-dessus du footer commun ----------------------------- */\n338\t.prefooter { border-top: 1.5px solid var(--ink); background: var(--surface-2); padding: 18px 0; margin-top: 60px; }\n339\t.prefooter .container {\n340\t display: flex; flex-wrap: wrap; align-items: center; gap: 6px 20px;\n341\t}\n342\t.prefooter a {\n343\t color: var(--ink-2); text-decoration: none; text-transform: uppercase;\n344\t letter-spacing: 0.08em; font-family: var(--font-mono); font-weight: 700; font-size: 11.5px;\n345\t min-height: var(--touch); display: inline-flex; align-items: center;\n346\t}\n347\t.prefooter a:hover { color: var(--accent-deep); text-decoration: underline; text-underline-offset: 4px; }\n348\t.prefooter .baseline { width: 100%; font-size: 13px; color: var(--ink-2); }\n349\t.ka-footer { margin-top: 0; }\n350\t\n351\t@media (max-width: 640px) {\n352\t .bar-row { grid-template-columns: 120px 1fr 44px; }\n353\t .sheet { padding: 20px; }\n354\t}\n355\t\n356\t/* --- favoris ♥ « Mon univers Ka » (hub Groupe KA) ---------------------------------- */\n357\t.job-card { position: relative; }\n358\t.job-card h3 { padding-right: 48px; }\n359\t.fav-btn {\n360\t position: absolute; right: 14px; top: 14px; z-index: 2;\n361\t width: 40px; height: 40px; padding: 0;\n362\t display: inline-flex; align-items: center; justify-content: center;\n363\t border: 1.5px solid var(--ink); border-radius: 999px; cursor: pointer;\n364\t background: rgba(255, 255, 255, 0.95); color: var(--ink);\n365\t transition: transform 0.12s ease, background 0.12s ease;\n366\t}\n367\t.fav-btn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; }\n368\t.fav-btn:hover { transform: scale(1.08); background: var(--accent-soft); }\n369\t.fav-btn.on { background: var(--accent); border-color: var(--accent-deep); color: var(--on-accent); }\n370\t.fav-btn.on svg { fill: currentColor; }\n371\t/* fiche d'offre : le cœur à droite du titre */\n372\t.fav-btn.big { position: static; flex-shrink: 0; width: 44px; height: 44px; }\n373\t.fav-btn.big svg { width: 21px; height: 21px; }\n374\t/* page /favoris */\n375\t.fav-card { padding-right: 64px; }\n376\t"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"06c5fdc3-3fea-4768-ad2a-c983f7fd886d","timestamp":"2026-08-25T14:13:24.032Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/styles.css","content":"/*\n=============================================================================\nJob·Ka — Groupe KA\nAuteur : Simon-Pierre Boucher\nContact : contact@spboucher.ai\nFichier : frontend/src/styles.css\nRôle : CSS local Job·Ka — s'appuie sur le design system commun Groupe KA\n (ka/tokens.css, importé AVANT ce fichier dans main.tsx). Ici :\n seulement l'accent de la marque + les classes métier (offres,\n filtres, carte) au style « éditorial sharp » du groupe (bordures\n encre 1,5 px + ombres décalées dures, étiquettes mono). Le socle\n (body, grain, sélection, .container, .btn, .card, .chip,\n ka-footer…) vit dans tokens.css.\nCréé : 2026-08-17 Modifié : 2026-08-22\n=============================================================================\n*/\n\n:root {\n /* ---- ACCENT Job·Ka (cyan officiel — voir ka/ecosystem.json) ---- */\n --accent: #0c8599;\n --accent-soft: #def0f4;\n --accent-deep: #095c6b;\n --on-accent: #ffffff;\n --lime: var(--accent);\n --lime-soft: var(--accent-soft);\n\n /* ---- alias hérités du CSS v1 (classes métier ci-dessous) ---- */\n --brand: var(--ink);\n --border: var(--line);\n --green-soft: #e7f2ec;\n --radius: var(--r-card);\n --shadow: var(--shadow-flat), 0 4px 14px rgb(20 24 20 / 6%);\n}\n\na { color: var(--green); text-decoration: none; }\na:hover { text-decoration: underline; }\n\n/* --- en-tête ---------------------------------------------------------------- */\nheader.site {\n background: var(--brand);\n color: #fff;\n position: sticky; top: 0; z-index: var(--z-header, 500);\n}\n/* menu mobile ouvert : le header (et son panneau) passe au-dessus du backdrop */\nheader.site.menu-open { z-index: var(--z-modal, 900); }\nheader.site .container {\n display: flex; align-items: center; flex-wrap: wrap;\n gap: 4px 14px; min-height: 58px; padding-top: 8px; padding-bottom: 8px;\n}\n.logo { font-family: var(--font-display); font-weight: 700; font-size: 20px; color: #fff; letter-spacing: -0.3px; white-space: nowrap; position: relative; }\n/* extension tactile : le logo (63×31) atteint >=44px de cible sans changer le rendu */\n.logo::after { content: \"\"; position: absolute; inset: -8px; }\n.logo em { color: var(--accent-soft); font-style: normal; }\nheader.site .gk-badge { flex-shrink: 0; }\nnav.main { display: flex; gap: 4px; margin-left: auto; }\nnav.main a {\n /* pilules font-display 700 (standard header/menu KA 2026-08-22), thème encre conservé */\n color: #d7ddda; padding: 7px 12px; border-radius: 999px; font-weight: 700;\n font-family: var(--font-display, \"Space Grotesk\", system-ui, sans-serif); font-size: 13.5px;\n letter-spacing: -0.01em; white-space: nowrap;\n}\nnav.main a:hover { color: #fff; background: rgb(255 255 255 / 8%); text-decoration: none; }\nnav.main a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10%); }\n\n/* connexion KA ID (droite du header) */\n.auth-box { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n.btn-ka {\n display: inline-flex; align-items: center; gap: 7px; min-height: 34px;\n padding: 4px 13px; border: 1.5px solid rgb(245 243 238 / 45%);\n border-radius: var(--r-pill); background: transparent; color: #f5f3ee;\n font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n letter-spacing: 0.07em; text-transform: uppercase; cursor: pointer;\n white-space: nowrap; text-decoration: none;\n}\n.btn-ka b {\n font-family: var(--font-display); font-size: 12px; text-transform: none;\n letter-spacing: -0.02em; background: var(--accent); color: var(--on-accent);\n border-radius: 5px; padding: 0 6px 1px;\n}\n.btn-ka:hover { border-color: var(--accent-soft); text-decoration: none; }\n.auth-user { display: inline-flex; align-items: center; gap: 8px; color: #d7ddda; font-size: 13px; white-space: nowrap; }\n.auth-user img { width: 26px; height: 26px; border-radius: 50%; border: 1.5px solid rgb(245 243 238 / 45%); }\n.auth-user button {\n border: 0; background: none; color: #9aa5a0; font: inherit; font-size: 12px;\n cursor: pointer; padding: 6px 0; min-height: 34px;\n}\n.auth-user button:hover { color: #fff; text-decoration: underline; }\n\n/* bascule mobile du header : sous 980 px la nav passe sur sa propre rangée\n (défilement horizontal contenu — jamais de débordement de page) */\n@media (max-width: 979px) {\n nav.main {\n order: 10; flex-basis: 100%; margin-left: 0;\n overflow-x: auto; -webkit-overflow-scrolling: touch;\n scrollbar-width: none; padding-bottom: 2px;\n }\n nav.main::-webkit-scrollbar { display: none; }\n nav.main a { padding: 7px 9px; font-size: 13.5px; }\n .auth-box { margin-left: auto; }\n}\n@media (max-width: 560px) {\n header.site .gk-badge { padding: 2px 8px 3px; font-size: 9px; }\n header.site .gk-badge b { font-size: 11px; }\n .auth-user span.name { display: none; }\n}\n\n/* --- menu mobile (hamburger) -------------------------------------------------\n Sous 700 px, la nav en flex-wrap (2-3 lignes) est remplacée par un bouton\n 44×44 + panneau déroulant sous le header ; backdrop fixe rendu HORS du\n header (frère dans App.tsx), verrou de scroll géré côté React. */\n.menu-btn {\n display: none; width: 44px; height: 44px; margin-left: 4px;\n flex-direction: column; align-items: center; justify-content: center; gap: 5px;\n flex-shrink: 0; background: none; border: 0; border-radius: 8px;\n cursor: pointer; padding: 0;\n}\n.menu-btn:hover { background: rgb(255 255 255 / 8%); }\n.menu-btn span {\n display: block; width: 22px; height: 2px; background: #f5f3ee; border-radius: 2px;\n transition: transform 0.2s ease, opacity 0.15s ease;\n}\n.menu-btn[aria-expanded=\"true\"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }\n.menu-btn[aria-expanded=\"true\"] span:nth-child(2) { opacity: 0; }\n.menu-btn[aria-expanded=\"true\"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }\n.mobile-menu {\n display: none; position: absolute; top: 100%; left: 0; right: 0;\n background: var(--brand); overflow: hidden;\n max-height: 0; opacity: 0;\n transition: max-height 0.25s ease, opacity 0.2s ease;\n}\n.mobile-menu.open {\n max-height: 480px; opacity: 1;\n border-bottom: 1.5px solid var(--ink);\n padding-bottom: calc(16px + env(safe-area-inset-bottom));\n}\n.mobile-menu a {\n display: flex; align-items: center; min-height: 44px;\n padding: 10px 20px; color: #d7ddda; font-weight: 500;\n}\n.mobile-menu a:hover { color: #fff; text-decoration: none; background: rgb(255 255 255 / 8%); }\n.mobile-menu a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10%); }\n.mm-backdrop {\n position: fixed; inset: 0; background: rgb(16 18 16 / 45%);\n z-index: var(--z-overlay, 800);\n}\n@media (max-width: 700px) {\n nav.main { display: none; }\n .menu-btn { display: flex; }\n .mobile-menu { display: block; }\n}\n\n/* --- héros (encre — identité Job·Ka) ---------------------------------------- */\n.hero { background: var(--brand); color: #fff; padding: 42px 0 36px; }\n.hero .kicker { color: var(--accent-soft); }\n.hero .kicker::before { background: var(--accent); }\n.hero h1 { margin: 6px 0 8px; font-size: clamp(28px, 2.6vw + 16px, 42px); font-weight: 700; letter-spacing: -0.03em; color: #fff; line-height: 1.08; }\n.hero h1 em {\n /* surligné d'accent incliné — signature du groupe (.hl), déclinaison encre */\n display: inline-block; font-style: normal; background: var(--accent);\n color: var(--on-accent); border-radius: 8px; padding: 0 10px 2px;\n transform: rotate(-1deg);\n}\n.hero p { margin: 0 0 22px; color: #b9c2be; max-width: 640px; }\n\n/* --- filtres (champs sharp : bordure encre + focus accent décalé) ------------ */\n.filters { display: flex; flex-wrap: wrap; gap: 10px; }\n.filters input[type=\"search\"], .filters select {\n height: 44px; padding: 0 12px; border-radius: var(--r-ctl);\n border: 1.5px solid var(--ink);\n background: var(--surface); color: var(--ink); font: inherit; min-width: 130px;\n}\n.filters input[type=\"search\"]:focus, .filters select:focus {\n outline: none; box-shadow: 3px 3px 0 var(--accent);\n}\n.filters input[type=\"search\"] { flex: 1 1 220px; }\n.filters input[type=\"search\"]::placeholder { color: var(--ink-3); }\n.filters label.check {\n display: inline-flex; align-items: center; gap: 7px; background: var(--surface);\n color: var(--ink); /* le héros est blanc sur encre — ne pas hériter */\n border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 0 14px;\n height: 44px; cursor: pointer; user-select: none; white-space: nowrap;\n}\n.filters label.check:has(input:checked) {\n background: var(--accent-soft); box-shadow: 3px 3px 0 var(--accent);\n}\n/* champs >=16px sur mobile : évite le zoom automatique d'iOS au focus */\n@media (max-width: 700px) {\n .filters input[type=\"search\"], .filters select, .filters label.check { font-size: 16px; }\n}\n\n/* --- cartes d'offres (sharp : bordure encre + ombre décalée) --------------------- */\n.job-list { display: grid; gap: 14px; margin: 22px 0; }\n.job-card {\n display: block; background: var(--surface); border: 1.5px solid var(--ink);\n border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n padding: 18px 20px; color: inherit;\n transition: transform 0.15s, box-shadow 0.15s;\n}\n.job-card:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); text-decoration: none; }\n.job-card h3 { margin: 0 0 2px; font-family: var(--font-display); font-size: 17.5px; font-weight: 700; color: var(--ink); letter-spacing: -0.02em; }\n.job-card:hover h3 { text-decoration: underline; text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: 3px; }\n.job-card .employer { color: var(--accent-deep); font-weight: 600; }\n.job-card .meta { color: var(--ink-3); font-size: 13px; margin-top: 8px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }\n\n/* étiquettes façon .chip du groupe : mono, uppercase, pilule bordée */\n.badge {\n display: inline-flex; align-items: center; gap: 5px;\n padding: 3px 10px; border-radius: var(--r-pill);\n font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.05em;\n background: var(--surface-2); color: var(--ink-2);\n border: 1.5px solid var(--line);\n}\n.badge.salary { background: var(--green-soft); color: var(--green); border-color: var(--green); }\n.badge.mode { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--line); }\n.badge.direct { background: transparent; border-color: var(--accent); color: var(--accent-deep); }\n\n/* --- fiche ------------------------------------------------------------------------- */\n.sheet {\n background: var(--surface); border: 1.5px solid var(--ink);\n border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n padding: 28px 32px; margin: 24px 0;\n}\n.sheet h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.3px; }\n.sheet .facts { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 20px; }\n.sheet .description { white-space: pre-line; color: var(--ink-2); }\n\n/* --- tuiles de statistiques (hero numbers) --------------------------------------- */\n.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin: 22px 0; }\n.tile {\n background: var(--surface); border: 1.5px solid var(--ink);\n border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);\n padding: 18px 20px; position: relative; overflow: hidden;\n}\n.tile::before { content: \"\"; position: absolute; top: 0; left: 0; right: 0; height: 4px; background: var(--accent); }\n.tile .value { font-family: var(--font-display); font-size: 27px; font-weight: 700; letter-spacing: -0.5px; }\n.tile .label { color: var(--ink-3); font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 4px; }\n\n/* --- barres horizontales (magnitude, teinte unique) --------------------------------- */\n.bars { display: grid; gap: 8px; margin: 10px 0 26px; }\n.bar-row { display: grid; grid-template-columns: 190px 1fr 52px; align-items: center; gap: 10px; }\n.bar-row .name { font-size: 13.5px; color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.bar-row .track { background: rgb(20 24 20 / 6%); border-radius: 4px; height: 14px; }\n.bar-row .fill { background: var(--accent); border-radius: 4px; height: 14px; min-width: 3px; }\n.bar-row .n { font-size: 13px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }\n\n/* --- tableaux ----------------------------------------------------------------------- */\ntable.data { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); overflow: hidden; box-shadow: var(--shadow-off-soft); }\ntable.data th, table.data td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 14px; }\ntable.data th { background: var(--surface-2); color: var(--ink-2); font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1.5px solid var(--ink); }\ntable.data tr:last-child td { border-bottom: none; }\n\n/* --- carte -------------------------------------------------------------------------- */\n/* hauteur = viewport - hauteur RÉELLE du header (--header-h posée par App.tsx\n via ResizeObserver) ; dvh évite le saut quand la barre du navigateur se replie */\n.map-wrap {\n height: calc(100vh - var(--header-h, 58px));\n height: calc(100dvh - var(--header-h, 58px));\n position: relative;\n}\n.map-wrap .maplibregl-map { height: 100%; }\n.map-count {\n position: absolute; top: 14px; left: 14px; z-index: 5;\n background: var(--brand); color: #fff; border-radius: var(--r-card);\n border: 1.5px solid var(--ink); box-shadow: var(--shadow-off-mid);\n padding: 8px 14px; font-size: 13px; font-family: var(--font-display); font-weight: 700;\n}\n.map-popup h4 { margin: 0 0 2px; font-size: 14px; }\n.map-popup { font: 13px \"Inter\", sans-serif; }\n\n/* --- pagination / divers ------------------------------------------------------------- */\n.pager { display: flex; gap: 10px; justify-content: center; margin: 26px 0; }\n.pager button {\n background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n padding: 8px 16px; cursor: pointer; font: inherit;\n font-family: var(--font-display); font-weight: 700; font-size: 14px;\n min-height: var(--touch);\n transition: transform 0.15s, box-shadow 0.15s;\n}\n.pager button:hover:not(:disabled) { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }\n.pager button:active { transform: none; box-shadow: none; }\n.pager button:disabled { opacity: 0.4; cursor: default; }\n.empty { text-align: center; color: var(--ink-3); padding: 60px 0; }\n.section-title { font-family: var(--font-display); font-size: 19px; font-weight: 700; letter-spacing: -0.02em; margin: 30px 0 4px; }\n.muted { color: var(--ink-3); font-size: 13px; }\n\n/* --- formulaire de dépôt direct (page Publier) ------------------------------------------ */\n.depot-grid { display: grid; gap: 14px; grid-template-columns: 1fr 1fr; }\n@media (max-width: 700px) { .depot-grid { grid-template-columns: 1fr; } }\n.depot-form label {\n display: flex; flex-direction: column; gap: 6px;\n font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-2);\n}\n.depot-form input:not([name=\"website\"]), .depot-form select, .depot-form textarea {\n font: 15px/1.4 var(--font-body); color: var(--ink);\n border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n background: var(--surface); padding: 10px 12px; min-height: var(--touch);\n}\n.depot-form textarea { resize: vertical; }\n.depot-form input:focus, .depot-form select:focus, .depot-form textarea:focus {\n outline: none; box-shadow: 3px 3px 0 var(--accent);\n}\n.depot-form input::placeholder, .depot-form textarea::placeholder { color: var(--ink-3); }\n\n/* --- page Contact (commune Groupe KA) -------------------------------------------------- */\n.contact-page { padding: 44px 0 90px; }\n.contact-page h1 { margin: 10px 0 8px; }\n.contact-page .lede { color: var(--ink-2); max-width: 640px; margin: 0 0 26px; }\n.contact-grid { display: grid; gap: 16px; grid-template-columns: 1fr; margin: 26px 0; }\n@media (min-width: 768px) { .contact-grid { grid-template-columns: repeat(3, 1fr); } }\n.contact-card { padding: 18px 20px; }\n.contact-card .klabel { display: block; margin-bottom: 6px; }\n.contact-card a.mail {\n font-family: var(--font-mono); font-weight: 700; font-size: 13.5px;\n color: var(--accent-deep); word-break: break-all;\n text-decoration: underline; text-underline-offset: 3px;\n}\n.contact-note {\n border-left: 3px solid var(--accent); background: var(--surface);\n border-top: 1.5px solid var(--ink); border-right: 1.5px solid var(--ink);\n border-bottom: 1.5px solid var(--ink); border-radius: 0 var(--r-card) var(--r-card) 0;\n padding: 14px 18px; max-width: 720px; color: var(--ink-2); font-size: 14px;\n}\n.contact-note b { color: var(--ink); }\n.contact-sites { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }\n.contact-sites a {\n display: inline-flex; align-items: center; min-height: 34px;\n padding: 4px 12px; border: 1.5px solid var(--ink); border-radius: var(--r-pill);\n background: var(--surface); font-family: var(--font-mono); font-size: 11px;\n font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;\n color: var(--ink); transition: transform 0.12s, box-shadow 0.12s;\n}\n.contact-sites a:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); text-decoration: none; }\n.contact-hub { margin-top: 26px; }\n\n/* --- rangée de navigation locale au-dessus du footer commun ----------------------------- */\n.prefooter { border-top: 1.5px solid var(--ink); background: var(--surface-2); padding: 18px 0; margin-top: 60px; }\n.prefooter .container {\n display: flex; flex-wrap: wrap; align-items: center; gap: 6px 20px;\n}\n.prefooter a {\n color: var(--ink-2); text-decoration: none; text-transform: uppercase;\n letter-spacing: 0.08em; font-family: var(--font-mono); font-weight: 700; font-size: 11.5px;\n min-height: var(--touch); display: inline-flex; align-items: center;\n}\n.prefooter a:hover { color: var(--accent-deep); text-decoration: underline; text-underline-offset: 4px; }\n.prefooter .baseline { width: 100%; font-size: 13px; color: var(--ink-2); }\n.ka-footer { margin-top: 0; }\n\n@media (max-width: 640px) {\n .bar-row { grid-template-columns: 120px 1fr 44px; }\n .sheet { padding: 20px; }\n}\n\n/* --- favoris ♥ « Mon univers Ka » (hub Groupe KA) ---------------------------------- */\n.job-card { position: relative; }\n.job-card h3 { padding-right: 48px; }\n.fav-btn {\n position: absolute; right: 14px; top: 14px; z-index: 2;\n width: 40px; height: 40px; padding: 0;\n display: inline-flex; align-items: center; justify-content: center;\n border: 1.5px solid var(--ink); border-radius: 999px; cursor: pointer;\n background: rgba(255, 255, 255, 0.95); color: var(--ink);\n transition: transform 0.12s ease, background 0.12s ease;\n}\n.fav-btn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; }\n.fav-btn:hover { transform: scale(1.08); background: var(--accent-soft); }\n.fav-btn.on { background: var(--accent); border-color: var(--accent-deep); color: var(--on-accent); }\n.fav-btn.on svg { fill: currentColor; }\n/* fiche d'offre : le cœur à droite du titre */\n.fav-btn.big { position: static; flex-shrink: 0; width: 44px; height: 44px; }\n.fav-btn.big svg { width: 21px; height: 21px; }\n/* page /favoris */\n.fav-card { padding-right: 64px; }\n","numLines":376,"startLine":1,"totalLines":376}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"e2a917c5-b64e-42ef-a4f2-2771bb83bfd0","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"d36cec05-dad2-4c44-99ed-12e171137f26","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":150,"estimated_tokens_delta":100,"uuid":"3aa7de28-ba07-4e6d-9579-67003d189003","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":300,"estimated_tokens_delta":150,"uuid":"e03c0cc9-c9bb-4681-9f29-a0aed1a7c483","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":400,"estimated_tokens_delta":100,"uuid":"3850ea16-0674-4a2e-b966-4c009fc49aba","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":550,"estimated_tokens_delta":150,"uuid":"434c1fce-0c10-4dbb-b723-b7e62810b6cc","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":600,"estimated_tokens_delta":50,"uuid":"185fdeec-2393-4aeb-841f-0a3e4b97b178","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":700,"estimated_tokens_delta":100,"uuid":"0f5ba30d-889d-4a38-9f96-09ab994c8def","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":800,"estimated_tokens_delta":100,"uuid":"9093c744-5b8d-45ae-8d23-593d91565457","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":900,"estimated_tokens_delta":100,"uuid":"0f15fde6-e8d2-4f34-99dd-eddcd88812a6","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1000,"estimated_tokens_delta":100,"uuid":"12d06491-9775-480c-bde9-97cabf89bce4","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1050,"estimated_tokens_delta":50,"uuid":"f4787702-f81e-41d5-91a6-0c541d150adb","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1150,"estimated_tokens_delta":100,"uuid":"b6d8375c-e0be-49fe-92a1-a908bc6d84a8","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1200,"estimated_tokens_delta":50,"uuid":"6c49da07-9292-4fa6-bc62-cd5cdaee2d8d","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1300,"estimated_tokens_delta":100,"uuid":"47ddadeb-6c9e-4102-8ddf-7ccbaa779110","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1400,"estimated_tokens_delta":100,"uuid":"bcab3149-ceb5-467a-86b0-81f71a166580","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1550,"estimated_tokens_delta":150,"uuid":"599f4bf8-4026-4e4a-aed0-20e473dab6d3","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1600,"estimated_tokens_delta":50,"uuid":"709850d1-17fc-4334-8a70-b4134da1e0ed","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1750,"estimated_tokens_delta":150,"uuid":"793f1ba7-52e9-4451-b77f-12e39fa88c5d","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1850,"estimated_tokens_delta":100,"uuid":"f641cb4e-9590-4858-96ae-ebae234d61d3","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1950,"estimated_tokens_delta":100,"uuid":"ee4016b4-7fe5-4ebf-8415-491b9718e516","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":2050,"estimated_tokens_delta":100,"uuid":"2156b99a-5904-4640-8483-4164f627b034","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":2150,"estimated_tokens_delta":100,"uuid":"4bcc3989-5aee-4e72-af88-78397d468982","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":2250,"estimated_tokens_delta":100,"uuid":"53de30ae-9df5-492d-a04e-c192a44c0308","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":2300,"estimated_tokens_delta":50,"uuid":"d6aec07a-a6fd-49ea-bbd0-474b1967d24c","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":2450,"estimated_tokens_delta":150,"uuid":"f8ecfc54-06ed-4ae6-9093-00cccd81b0b2","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":2500,"estimated_tokens_delta":50,"uuid":"350cb462-ae40-47b6-a22f-8e24318b2ffa","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":2600,"estimated_tokens_delta":100,"uuid":"93461e70-d0b0-49f8-8c6e-7e6b0f86e313","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":2650,"estimated_tokens_delta":50,"uuid":"00124520-a280-4b37-8272-3b88fdf0dfc2","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":2750,"estimated_tokens_delta":100,"uuid":"ef7b395f-3bec-482f-be5a-a7653f03daa2","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":2850,"estimated_tokens_delta":100,"uuid":"c244448f-13ec-4f9b-a4fd-a910f75e1141","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":2900,"estimated_tokens_delta":50,"uuid":"19fe28b3-798b-448e-a726-e83873af27c4","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":3000,"estimated_tokens_delta":100,"uuid":"f0830d20-df13-4979-b110-543f08af0326","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":3050,"estimated_tokens_delta":50,"uuid":"41faa005-5800-44e8-afda-e91a9823a42a","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":3200,"estimated_tokens_delta":150,"uuid":"53c7e40f-1547-447d-abb0-54c30a08f227","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":3350,"estimated_tokens_delta":150,"uuid":"47a1f39a-f0a9-4f32-a870-5a730d6ebf72","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":3400,"estimated_tokens_delta":50,"uuid":"f699099b-735b-4f1c-b857-d0fa0a1c81b0","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":3500,"estimated_tokens_delta":100,"uuid":"d34292cf-4e3d-4ee6-a4bd-2d31d315aa3e","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":3600,"estimated_tokens_delta":100,"uuid":"1c42a179-29e5-45db-b699-b33e3e325241","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":3750,"estimated_tokens_delta":150,"uuid":"51c5892f-7107-4ed2-a6bf-d27f377f2d1f","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":3850,"estimated_tokens_delta":100,"uuid":"b7855227-c851-4121-be14-169e56f70760","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":3950,"estimated_tokens_delta":100,"uuid":"c4e3cc30-2d08-4e94-86da-6a0aff495ac3","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":4050,"estimated_tokens_delta":100,"uuid":"a1fed6ba-ca01-4cb2-b67e-66ac5cc71674","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":4150,"estimated_tokens_delta":100,"uuid":"f67e5580-fa90-45a3-8929-a9196190c170","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":4250,"estimated_tokens_delta":100,"uuid":"9865796d-b1e0-4667-bbc3-8c66138b3e3d","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePc2mE8qzcb2U6ouDR6Q","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISzFIKpwEIERgCKkDDP5gVqQggTve8fC8NoUmb8x/A0nlLnezyp8botCwB4ULdcVrKjY/6Ea54Y0zgVjO5mZOXO2fr+XmCsDXi6lqRMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAHPxrbUBrABAhIMCPOlVLnU3fvg27+EGgx9qLIvq9WgyWrs6N0iMChaMavxwfUsTmaCp4E+YG2mow2MBZKEv+GBRcdKo+FkmBP7jjSltMiwcSfW9tBYLyrRUH1TqCYrkRbQKVPYN86CBYzUrzoPB90VNdi1GUQouWJ+TivQTRrqXYbPmL/OTTPe/Tsu918ohk3Px0E83mhloahWKpXnpoclpEpc4wLZSiaUpPprd5A5Tfnc8K2WDhxSSeNHenW43Q+P60RT90pRuo2V+fpgdMMdiZaOUa/LhT9ciBkBXQMPUv361LbGaA0ejx6NQozlaAFgCxMyNqi2WftBX9N5TOHdNiUm1VkdyIpbHE6FpHP6HJtmHF7UHbXwdeQcU8GbY3QaigMJSqE+nL4wuBdT4on0WKr0xC7Xd585cxdZ0W2F5/y20XreDtyiz7D/YhbTJ6c7J5Hz5wUr490QZeL2BZ2sTFbps4dsAUIkOuhPdVH8wI15P2EOWY9v045S2Ayl8jpd8bGX7yCTxLf/b8BxqZwtqEvB683qkmjSWTxa8t7X/gWbNpjj2tdHOvAPev/aSo/G2Dk28Y9OetciBJ+yYVFYiQyKcRjfBlPRXbNkgBEkHvv0jPDeZsbNX+PA2a/3hVuksHb8xGh1SB4BQ65MZBsRMnVfvXIgcpCSfhpCd6WIjk/caGsx90+lhP4Z/dNZA+fzwg3Ni83bub4qJk7tTlmJSQl1dSUgBfuEH8/asW5czw7Uutl2a3xAlYLaHNkE4bS1Y18+l0jEvQlPseWgC2ZOqir3QrtxOmeQjMqAuJuydvR9ceBWBJeoAgW1FMPzuO4DOCDt/NzwYReu0OUT/wMo8Grwn0xRsQL178wkJMfrF9vlazmChpfaRY6myNcS8FaDciKgzw9gTzkDizNdWUDq8/4+M8wwl50AapvrnIDyIi2q8+aqPJ/EVrC0LgwNM/5COLRcyiYiB3c+lUYeWEAo6SAtVxZOpNhFzR2L6IZWEQy/+KA1vDIISyQstzGJNZFM8nweAG+84zgNnWjXX9hZOjorLyjgoEHWJCf5quA8QIxNsxb31W1Ywd+GgPVBF8HS3WfEFo6r+Zm9ommufwApEQqhCgiqBIrJrARsOF4VUSLMH7gUNjKebyspLwzACSKjHSCbs2wV3/JIQJgbleXJw45oGYWU5tYqDYAxOqhKvUsm8d4l2j3QjrHmgHLQvAE7ui8HvH9zDpDC6NSj70qCqNGKNU+tQ2ThyW/XE3Qxn9VKWUKx3xxr/J92s2UnTzKdjUXOAcS/Tx7aO7tPtY+9oAmGV45PrLpRCW6o80qM/dhyVIuG9k1M0/az+uHKJJDLEaAf+qebkp9/WOgo4j+d/4wepO9O/jcVFBi53PDwSkInHw2mCg61jiiPmXkgdEzNmA3FsoVx7qzPcnGoczY65B6HfLDaKLdfJSwBcXMfRHwCXUN+qneQfxTPFbXYtVkIgSTm4CvlLuNrE7CNmz66YqoycONBegjZdR+AC+Wq0l5q7KpQxlwIO1r4bhaqZ2TKE1zoprft4j3LRN/lX5nyq66VJb4MRUIx+bDwRqG0FsxvMk9EkyqNf0/6JDgpwUoWLHcl0+Cg0VETaZy/P2Vu7WxRY/mpEQmCHG0MrrHD2jyeCL1349bB0QrLQRJJFYTH0JxBv0oJuaRMSswhukXXCZol0CDe7x0vla/MSTd8i5LwrPgjbRd5XbFKLQKXDiWEzpjmeusIqpEbSdtxk68z4OsAwapgxju0ZidNv6XihC3s5IU0rFL6O0rZ6bwkLXUdr3nKiKE+pLbw4b9xGqQ+CRQW/znO+0YR6eDUMVFUR7+egjyX1i5Q9abZWybkn8E8rOdRa4zd88Jj9TshNDRAV+T/Eg/IegF4CeCZiBUAffB42v8ifpV14JsxLvbo/V10ViJzVyvHifhvsTPvK2JgGOvZReSSMOzWUsPq2Kr19yA0pT8/ZVEnK/WmObFDr+cUBQemCaBq3DWDyQaplKCzIZYOSDeEnANmTJT0OuZqG8xeqaRzRAxZXTbRrLexRAV97kzCDe9klHk4Sv2VIpCile3Wx/jbP9JUw7bsx+KA8gt8DZE9B1qCU9f6OylpxRayPsj4wT7Nyn96jBZf4UmGKY1L2FYG74GBKyPKg88MOGFfUWVWofSKodhQB/9HGm7sPGwRyuZXBZB/11vzwEheIIqWm2F9VMnO9doTIl59e9wHLYw0BlJTYSxQYHHbYgb0jPhVBK0ZkxuzJS95KeUttHstRmdIQUJAQl8866otfTSCdV0U4vg2H/I4r7OwbM2OR6tHrSv2rCokHKVF1YVD3FJEtCSqNlutVLfBXF+NzeX1cHUQ8BpSCokIiZPd9xiUKuB4dLlGGND/wTXQp9gcc88o0oUzPSpjR7rd2JpFto7esOOZ1YzSwbaB5vDyvqXLFxmlcJ/jZQV8mHfPt5dqC/iwuBzolMFJ1PkosazzOcI3vmW/qJlY0t6aQSmZQe7ukz29AplAITj6W+zA4Fak+1t20QvRn8xvAVGwCPB60g2FrjWt8ojcwvCfnEPV4uaJ0K7EwCZZqquRGT787KIb6Lrmu3gkt5EcLn2Jj3gEMvmpTzoIvzi2Z+JGmeMB12u4Pzy/KAgD/RfTY0cR8x4BLNqyrva1MCRSb58YntPjDEdWUAxApoC8QgPS0OfHpbNzoeRlVdZYMwD5eTqfWREtb2xS9KNbGsp6OpwwgRYlzUn3d1PRF38ThskCSeSpJo0RUJVJHbmsKMD2YdCrsNXguU7g1A+hSKOuKMJXspPNHXgEKdEUADrVQG3jzDXZmX+BwM7Un6RjsyzelGKNlRZfmAM5N+w3DVNqgxGHqZ35OTP0RNdKGwcEkm7vFCvATVeoH/fjbkBLPusKk2XINovhBFP/dCnNUifIF7wHEEpbyl3xlJIBY0oiymBWf+9TwwZcEmvlw+ZUH4Hvt/teOoDKJyLfZOyl32ifGWTo8JYtlpM9QADATKMsrGOsSGiehM49oShcJ+WydjcvjQGgQmna/f9M9onStUw1NKaFMvlpGhsiNrSSGZnOgabtT0EWWtCwLJazRQSKlU2y7z5NrALYpLJ9dGkCSvdCQXqXrl9PA+Ouamdbp/GqTD8FfnB0ZWNJ+5N/Fnkwk4P47zviL2ZQBBZF106bjhybSQOsRoIzt5mD3cOP5qTh/px4G0ReOxVrVuFr3hulm6YEYRvo8WCPKxkQvXJ4rQefrhtVME8krDjH4jKIdLGiegT6/KMUnCkgvEgh21CNO/HCnhpn5qo4OllOaRbO7aQ6YC1WItRND6IzCd7/aPdG60SZPIbZrci+PinNsFUZGZU8QBCBTa6oHW9wqbVlpUFV3SfmKr6SdnshI7aRJ/STKNNkl0EAm2I/3o5tjmlN0yaYDhAFW/95wM2VATHFlXYb/FJmR656H98f0QvRjNOMuri0YYtMeDkBYNrSalZzYG1g+M8KhgAM3H95aPfzNhcVE1te1UjCS15zM46mkPbTo/XQ1e248LgwLPTcT7SJni6Ygc8bdzMrjnbhyhammBSXF+QguACOqMZhvOWIl55IrVtl7QYkHz0W62yyRjYuZizlARHYL1maxr2TU8RWDT+rj+BudsQN/+1yMoGzyRbOH6Jw8/QkHpEOuvJLTpcGgGV+Ihz7npkwHm+u+jy6ZMwpwZ9IP+aPahjLOR6uD8BG2k1LCfwf/znumxxVuHjGrdXj+4POBjpsJmQr72bOL4IMTwTxSmZytLCQvQk6PKunOohpJQCIc/MtHCyAoWt13yxDImrpme4+9cmiwVPV0ct+yM6h6VmhrDw7YWPpTY6YFYYPL0IfbzM69Nfc9Ci09PXkGQJJf3BXmH8kPn7aq7NzDl6koTpy51nwXvB1BvIwbSZBZpqTT5H0iz8JcT4b35FfGmkf661QewGo0QPywnfTe8l/sZ/hdWBKhwwq8ZzJH0TLPkmnLs63FakuC0tYa7K5f7Hra9ncUjWAnq4cdzSWXA2j6hsFhW4+tNI1YHsRxlqLuultxVGhEjKpLxXdZ9QFvA+pfIrRLF0g7GFh41rp9sDGXRIWnjcEIYZFGw07LSjP4uerSaNKhTSe2wvB+F1Pn5d01tqBRy9iF52eYxNTtsiA75dxJrPnV1ZEkLfKdEVn4us/UXqEXz3PLGmDZUkpY2F+/jKjeYW1hn8peE3NUP2osEAK0VytLitOgk70WgWBRzrGG9m+SZG1aS2sIniKr7w82aa/N/ji/wYvW9CpcNGIMeufZCsU9CgCEfwt4SVXlqeiLvyEgWaodOzlLfHkMmGC6nlWfxlU844waXux3+TL1gGoyGH3TM+VSzNOgzSbTRcU7/3Pfv1qenkK83g38Wu3g+qsmZRGQrJ5bkOZgxOzGAmHRtvutFooO5BLn2K1R+nO+iYbbnmhILpLuJSYjH7MWqG45Hi9jNhJ/kRXlQjyQncQoE1y98cydp19M4YkzU9K0YBgbZYop+9aPs/ZWkqjzVpEGteYzjKsy3oJ7Qk9nO1gANuMsq12zK9hObglDRTn3xYEG7OLshw+/tN9umzroEx6V67Ar5EvblpBF45uIA2/fCbdkGP5q+NjDYHYEJ2Im3Im21Wxsu2kjBwcWsTviPz0goM6YOfXgpTrEEnb5Q2rWqbDK1LqmgGsp9Rkmfn5APxQ+sufllKn40abcO13rm+DEld3/kq8FUQpbrSxCcJorj1b6IA1liBxUq9fzOjhbVhFBpjwjSQJNe5eWvJ4GKtmgFLNaKccJqaDrf9bUPgc+oZdf2zDasWQxJpHDQYqfCBTgGIqc3XlI49niwTrjmUlUQEg/6nUO5KSR61RnRFfk+ILjNcN559ykRkcMrQO8Gyb4FqK0RNWcHLq2ZXtWFW/YekyX+zhfwelfDV6CEPnQDFr1zi3BSj5+wP+6V55PX2Dm1YdNh2ZQBSfL19G4pkYTTyBbOOq5BrwaUU1QbJqun97uB8xRYWkCi/uELDGxOb7dPY+UQxWcxu+4av31+E3S4WcXhpaO1EmotWEYY0HcLvgoh0EP9NIUGxzd1pVxNNAzQTsREUoJwG2bMn8Wm+hN8VZ9f+0yw50APV8pN5o2m8luq5I/Yxe9S8zq81bCkVGkTUnogXzEcYVVMpZeGzV1oxesdRtmNbrZams5i/F8KN7JU11g1+cuK6d3GgOrUa02n7lT63P9cqSuuqSk1C3vgN+zugWfIdVfsTK8AO4ZD7T9YYSXlEVvNqdVezBGsiViOp4njRCLQm+5RoLma/FATAMnYCHkd7JabH6wkHRqDXTAmRLL715T43RvFLFXQn4CGBs496QD9Vd9UevaHtDczin6OeUvh/FQE2KrmdCy55Z2k9OExt76Jm9uq9DE3gGolmhaZlINlEq+lSeO1U7tPyTgZbfWWDgppUtLwzHOxQf/11ioj1M/WZAPo4qknzOlQkpvUC4WUSjBCV5yqDWlRICc3L4QAVRq/dfqBmHc16mY0eMnEkU+uVvOImxI8jI60GhUIfj6hjAKnfsdmKa0kNT/jWMLmxH1NSiIkJgkp7jZVSxxg8cG/IJ80MD+IhXA3Lh6dqA7zzi3gPkF5KGBYE5g1LudwuTP9cfYUxq5cMYayrEOA2lvPZjlnvwjiJNE7isX3xa95ga4n9Pr+ExJgbS4nc2+CMfa/G515tcxMJxUjCsb0dJPV5p1kEtKUbQcwNgwVCLZDFlKJp0TP8PGd1gjQpdailf7WoRlDz7xX2DGK+lpztiOic7I6PPxrCRFAoXzlXUleORC2p52LLir34YXP+IoKHWQTbFVjy9HVLhYpxRaA9Hosh5pSucYAcu6CDKOPp/NPuWpNUUeebzsTCxAZHN1h6dF6PBWQ4Y2Jrk0aMJnR3XRZFvD0Vae+RSvAzmzoFdW/pPvFzQ0Boiblm8kSO9bvcFuxup7MvNc3gHlTGZOV4wtcRjD6HKnKUe3QqsulniEM9lZZ8/PvjlMHe56wXk6fYiM9nU4ZjeeyZ42kSKm5oi3+KGw7YscLk6agbM/PhuQrCH3pTu22bo0CFKcJV7eW3cF+bx9xdIrv5M0CZIAJBb5Jk2WOt89SFjc3VMlbEhV3ytrBdQ9xdqPLWxtgNlnROWAbQ8BchAQvZXtfWmEwORlAsYZImipgUvE8CNmK8TJZUfp0d91Xc/v5JMD4QloBC/2GW9kcHqSmXlB/Q6eomblSPHMwekaJKJZOchcDCK+eKHtSGxrMYCPyfFje1ZApuvGxdMM8MdtF57rZY4Q8neq9WDlIRMC3ckiQEQVjvZDEdn4w9th2O9wzaWtT1yqKUgrYa8j7kNDGtAgVFQe/9AKIwnCxp5HhKp4aMroMKIC/ETVqB8ATet9tvnEHqydvXFqInhLBiaKW0ahYKMHtIc6R11Prna9S5KUdw57ZYY2NKXdo+hoxFjKOKZXoIHwnqAT55iWHr1Xm/F2BI0uBP+2ZlMCp8X9yFn5ofbhDtUwVT2ZGC9eSxLcu6Lc1FMcLwAuHBmz9eYAs/2FxbsY3YP3Z5nV/7+pcE6j8RsNeP4MPSZ8NfdZSKz+S1VGSEZs8f8fb9IVupArvHEJFM4N+Wk0cxoGtzt//hOfKBmv3+8zl7rmzM5QpN75yuiB9YyHwwEONlURBvUcO3Ewy1zKcpwbUJuwz8P2WsOJxEgVgJC2aUdQiOeuUSVWuOXg4Uostg6t/9qRcZhpPwoK21WpEBZ5m8NFR7QrgJYNfP16ewh8L0J8NTIvwEqyH1aR+NN7zUVvhAVlCvtQAJHLwrwVk4ZAUYT5Ec6vM1/F7DkBFZbnvT0hFl2eRUPiicIyruT5B2ssL2raZ1qwLQ6mjDWjUXnMH/lzuIF/IAGa3h8Vir78H8YejQ4XbQB9XxTZkB992UhXsVRX0DuHuGM4IPV0O5SDD7ZZr6cHy1JcN0MGtAUWthx9iCuECEroJ4dgARORNtMhbKrw/GdpBlQOTt78ru787QGUkYmXucUzIbMypX/RHTieW0bYUHAFrP99lcAn6THSsnUATdTNm2+2brmZCsbL4Kh4cLLJ3DSDPRJLnovxidNeHffccZL0nLnUoxkv1EH9t4IksWdWfx8Thaquu/56Y8KTWMTY6RLFs00H490ZgBkrP5tcVB1nDO3Or6K0hAxjTbZPrK9eYjzG3CuNq0s0LUe3HAapt+yBdnmRWVGBlzy+5Z584tuMizXpym2ol9X7joE+rtZ1bIPokosRzaeDsaKKsZjXDBs2POnkaWmlTKvEDHMuN+pA+uaVBcoPfwPKPbdaSr6Pepijtmzs2vnU/9FaXtaOtTry7PaeMuR3IBVPtsigBKV9cLzLHDLqqlsH8fXCvKOM+YCcuWueBHe4hYg8C9ppLU4bISDi4bL1wrhqoBhzdeKpTqlnJhhxmv5Xp2ORkBLN/Bv9mNuSph0YPTRWF0qdMNtbt2XtfHRgM2/zr+nlUROXdjbdEEZdNSMMTZRcGGohjScVSuH5zz7kMv7rvgfMu8z0uqD7kCQ6Py3BMPCVkwxi4ZjoPf/BsaTFT7xrPTW3FET/X2UxmG9VsGhifKq5hpUbXNlkArk5jzOOvdKfzrPtLJ0I32VucRf74fPWZ86T5M0TUHO9VR/o8FwnZq8hFXR0zYZxacyZZ0/y/tI55ub5MH4Ph2uku4TYtyxs0ZULrX5u+oxi5xcvdvf88Vti5HRXkalmm54Neck+rvLB/R8LAD8O7WQ8+ai8Gv15F/Ov4hs32uOY/0rV0hskMjN2N6cgMzlsfZv6wuWGwjiaAdLkBU/1FoBGMgp4vG0GYL7cPlhwT8DO6mLHWxGm5ySXTo1VkV3W1j47II5IVI0T6QVh1PnxR5dWDvV2KEN+/X8+xidG45LGbAFkgXr34DAuljKmEX4DBm0Rb8WB3x8R874ZW3VVGGeyp02Je5HBsue0Qstqd0VJUS/LX3xJ9K8BkR9b1n7AUBA05Bc9VfG30vjKOGGacsvcDWoSu1MEOSBq08h+1kexdVUQtZvVWtUWTXmG4rsn3I8SUoYPuYdtAaF1InvZ97cufNkAYvuYD7uDlEkUpOqXnNaFhaJ6n4zcjzIPV2bNpIQVN/GWmB+AJq182igMmmB738wH4EmIIU2SC+lissz5EuJttT/ABsZlnY711CN+SVQeW7KeSlBkDPK3psS3sOzXsxhjEWpj8aND4SC9QjWp0NRfeftRfdPoZEMBAccLgegKcBQl4WyzO+zSsdOCfGkE2oH/l2ipGLXmBma70h5uC6T6X00O4TA8qcFt30K5tSsTR0RTl8UcQ8N/5yCC9JLCNTqXgcZAyG9tcm55WOVqmLcRUF6jsCnWOQZmxYAhxYTR0ZV+S8kBNUPbOU461q0cmuPtiggw8FwK7sTKURp2+6gNdD7zJe+aIAn7WVk86CawVTf/C8UVn/T6boWJPEc9DFUd2NCMiBT9Bw+jG9j0Byo3rQ4DE8ltO7de29mpJyNocjoTtiuWWxKL4Vjpnlp0ul9Gp41Yk3/MpXphxFFtNAvNhwzjr53WOtmt/eQ1CF9v8YwcmePzZxOnleNCx6f2NskcclTESY832FPz7/4BvzpXGBKn1znqw8BtYlNO4u8XLlkDoQYF10WrDiS3l8JS5lx86LNxvy0Qcjb/XDRAQP3s+K9By4+6hxcu2FCDUS5d0K/giwEzzdejTXeTreCsYW0SUXF6jmaA0mjJgZ1uPxnuMNphRlEmfdTHScKEY50QQVs0eR4GUz1UCgTwluHHfUpqDk6xbZPbk96wHJauTwx0OhTlwuChEny1yCQDn6gZn0ZhMTx0wU/Xfxi/Dtj3et0Lk7Zorzg0gYcUdtzINCHwsp+YcGpBDQr/S7rOYUy++arQafF6s3Hc3/HLz6EK1YyO+dtV4m+kaX2U8HFDD4dMKJ16Qief9x52GRyMXAc3yc1MWSru6lgSrC7Nvd8cojuKFq8S3z68RgjPLrCf8jtZR4X1E8K9/QoAX/p2dDYEAvpayXgLKFAI9c6u8n/e8jqLNB87YHUxm8ExK2kM0easH1XQiFkfFXydMTiPQjVFTiyH/MTYYLwv2Yj4vn6GR7oV371rs0DxQ26hLPxCUSdNJziEQ2J4gjL27LIWV7piICo1M1XTLfAQd3NjqRnpO20U2iXtcRs2nkFh1bVzGzDi6ALbQ3CaPv9a4ltkN1YD7UqeR8xszXxDBxWg4wunSUA5gMaF99hHCRRVzZ4qTF7qa5mqVOrcAsTthsOcHXb3GxJBfMG4gkyaaYfK+EMU8wuaZ0GN0p3FFzy6MZvOpDjGv33w7Pmf0njKwmqMLUkIYGiAd2UGrtQsxlw177I9siV+PUl6dWMF/gengEZ3mKHJVbXm9kNjLKSj6CdbvnN33fDtfPoXHUuPzAr8YDcCQ7tGJY0QTIWwrTNe/XxRqip4v2xwXbwYLO8GEv6cb+Z/chfaXgLvFJ3brNxnb49+Qe6c63PaWj3sabp1aISpaMPqBdKksKLquCuGBRCRuHXvUe2w+skj8BZsRYDmoIZiSbr/HUfLjzktgcmY45Dd7PS/8n1OVteRjXxqC/sbdTJlSpf6Ptsmb/URFLqxMbB1jwcLX9E/CBYm7z8Neyu7qlSI1bxVtW9hGjk/ndE4xitjGAWW3i+e/O7ct9O4C8Qwk6yr17Krts2z/UFna/K1hI1mEs8M4ciKPSbA5K5ewBL/FqEilnzTA46ulkJH9Fmv9YnNrwOzbAnTjOMnppeatVylEWSWOPZoRAkMjk/pIIXXowv4sF56k6HpSAq7d6KiibKQg1IQ2bV2a9KagbaPCoeLAg0f4Pxda3fAbFINoRlnY1GaiRG/8c/dmxo3w/G0h3WFGWI5bTHzChdvbPeVZiFyHizNvaCCeHS7iDRIUHizwwt4iUOMOIBKn775HsapuIkyH+D8PWqc822Sc5GCvW05lC5+NkUZXkX1b2dAAck4NkQtXu7tZwrafaRKnqGOcs3zKNxQIPba6riqusfXb8wQem3v5zECmQ2sMSad8234d5BRMe3QYL1sh34S4iXoc9o42n8mjane1Nm7OGqCgj/sSncSme7h0jlvFnxDe0J75qYNwZ+PVxxKAU1NwMWdJjI7Jml0ecQKQcYVa9PJ2WNjQViv2uKDjcaXwisHtliJiDMD2nwzZh6M3XLFsoT9g7+eD57dP879dUooiACAi1NMkoea61r5EExRqqx0Tdf/avMtVwjINY6noAyYHBwUELtHd+RP+w4OPhquppzuEjJ+vGAYHf/stcn/yILxzBZw0tZUCggHK54pYpiHQpXC0+RnetfrJ7x7/GsPa76c1xrTZ+Wl/+FVxvap0pggG3NYQHAOd9F8ZNqErcfRylfJze4OjI9/s1VFZO1vfQGGHb1awXQYgHeWywb4Xkah/jkXyy7lKBkZ0engcbxvC+u+GJZv2weCUdUlrccxixVGlXVInAL0jsXIcm09z44j+5Xlawz1kswMuW/GpjkvjdKaeDRk+/gu7qm0l9SjGNZ7ON6b1ArccatD/T1aVK9WOpVWntIfGxG8BkKyPGkBXaR8gD8lNXGaqZ661dCvOgNMIYnE6HJX594K021oCByQYfUt1ndN6X6PJ6IBAtCVJipHk1Xa8p97JWGZPlVrxR6qGYgsxybRsXWZNEKry76TKaNX1CgxzHWr4CfmCILZqyrHy7146D5ZtjmBMPZvoREeUtk0CfrRSr7glXOBt0+g4AMFP0FuqoEX3fBcxZIyww0X08FNXGMgGDCA2DIGiWnCVWVvSER6Y+SGe5Q+PL9aOVf0EiuzbuMTreqGVMXVPFtq/Q1TjMJ6So5cnRlwjf9Rb+ji/gcubFKq9KuenUT1YXQ/A+XEp3PTktyOIl217j8tJRNT8eK4q7/JN3j0cFjLs7FVqyaqBIM/ie8FG+HlVatXPmfzLHmj2Lvbk0LxZuTKhMXGqdu+UrKKf4vCmAcDA+SLvtZDw5RvEhE2/4+CPDxEogEX75TvFsbgOpFuOGzch3pf3VkVLUdV9CNQnDJDnZ3h6APBR7/9X0yIVXcVFT3MsevUWmU/1ZXWU/Pv7wor5SQ+nXOiHkC6gzPeQAFoI3RMsGAY+eGt34JS/L1SRBYtEMp+c6w8r5m2/69wEHas9dVslXHghIl8GNEDyxqEQv19f+xrwvaoDtk4Rqoqc9cRussGAsSba4hSvHIzy3fdkt3jP5Ce72IiXBB6B+6EmIXuvEMjG17bGttsXTKiVLc41DvTCdkBSnwJqGzBrSmGlLXQePsnstkeUq0fx0Z21TMWMg48FoHxGiEvAaQbZJniPagJSyRyEJRba2WEJrF6EaH282kd/7f6D7qSb8wXm2fBdfIGpFOWB5DnwSV2+x5xeycY3Mojf1JuyZ3+FH7WaXEJ0Z20N5WhLEEXylAH6z5cCfCYHl/1G8MnsRcDeSROgQd6BnWSI/NRvqNjpiRnAeCYOtK5JT1feRGjKSMrjSeRuNaMXjH02Tg7f30xCHcFBAUq8llIlO7KBBVgRhpw3AURMenidzNspnXQu/2ODvHPoF2NdiNhiKSf519RRemlgdzTioVOoE97IAjb3iKnY5dTZtALT4uPSqXGISU4wnybhoNYcdEfNZ8SD0B55UNmyVMrlghM4+Ka4YvBNIX5LblpSkVOGGKlqPJdMv3RbW0qDQhMCwnfYPA3/CblMhiFnNUxeEBhBYMF39Z1f1w4Yvtujy0n6JeuzJ9/MZMiEW4VLaIqWX6/OWNUvXswrT9Qt107jWFakXJNVqPeXxpHLjKS7p1g8E9rzhI2HjnGHshxJ60MsRToQUAo20SS3lkCFHs1gWVnA7QnAN56rI7BDgWrAlqYrYLZ9pDAalRKD06ZwA2lcWdjkdvMvOLLkVqMfMBWuHGkstpg8A1VReSbqkXMybFMg6HamjUN6i1naDROkyogk/Sul5Oe+WOvr6EN6ISCeR7QaoAziX6doVS+EBADsUFZ7riCeTkR/D+CGhtDuENv8knMnf3PrfupBM/gLteeGM+F3XQkgfz4dOxSNSMXDxvq4yIaUAUVh7i2owcMm2vzjL6s4s0oLVEzM6bDzmiUTfrFsXHU78aNzzeozyOkMSBhdi8A1wjizROdpF8Ktbeic49lyb5fINCZsSTqgTL5UUgI/MXerMVr9xTvcKHJVNzyUU1AMat2ItgUiA489Tg5mHVkO1nigCOkzaIpgZJ7UnhyOFifZ4lhNqvAOA99k0wMqWDaVcp9AwZIbtBkS2D4lKfA08UKYajScW/e8spgVPuM4Ast51/EC25oYbFBZKDXNpyavUSPXm9/JPfd3HoHMsUgGprN1X4mTa7Zgp0hEOyADbM/vHAovPqgLEyIEvwa3/eRiRKOw8oXfyy4oCZgte48AndFxOjYznbPffcwuaBmpa7MzaY8mPt19n1l5QQBUV/JfZYwippTifPeuBemGqKb7VrV6mBQdxoTRi9e1x+yzOZ0YigkNu4BuGTXBrtHW12j97G8DWvRwSnJKldtrJeGO+nQvw/u3M/VX0FYeRa3jGJ7GHmYAF6KI/W2vg39m5jgrZ5J6CGAKkMc8k35UofnNjCXq+lDF4hSThUQGfjuLYl/osAiPUYpFbXDttT6WUg4105uRIgkpst48sOQQKjJ3KhwXPWwxgRNlo6j13HVZLH+ZXy5YJ7DhQQu1DQ02BLJN41pAvZsj4A4FGVx0T9LZcv60RvK8l1RySge0U0LmWFuidvzetfyaL//of+ZzKrKdIL27fMShtRlC0Wy0grHhClPjGIJdXuzpJEWbUAgtg3KgwkClHZgwQmRkw9A2lTvqa9tO4XRxgNyVVBIW1tEJKLEGcPZE1USz2NhFByqcCaectbvHg9jHdpOggZ77RKw5691HG2Dq4gTAFEkqoozUwvi3cWkOtktLL2gzB5sxS92OJ+hgP4OhK8QnooNRtP5wqN2W/jLJ92WNRPDEij++RCL51z5R+FQwSyQed2BUXLcQE9E/26Yb3IKPmm1oCudOjkZptx70CZkgWap7OL4YXwAptmOZK2kr2qZ8sEhNw5FiONkeuYGk82tMfuNyxT7hxAsUPcTb/qIEJxZNc+DY9DimS4/+l6ffKpdl4B7CFxok0aXua9Yvr6J7aynJlG5c1mq2H+k4NJjphY+bHK9Em9Mw/5PX/6dJn0OzMUCnHOx7xSq332UykUA5+AbkYd3+gS8WJW0ilFBElEzTYPDYjLAM3dIi3SlMHYi/aMp0iHx9JzZH/O5oh5Q1urPxbibeS0KqgS9q4oBsGJgWp3TQvQ4jWc82WDPWeJKrjr2TWgVIZrrWhFJzPnAHYhFRW6au/D/cGXZdJlk90su11RvenZUj/eDX/Pfw3dfqxg7l5LV1tqd9nhwe0zuOyw2SyGdyBOrS8/ACEqe7zr2GHe7/KqGKSwECsKnCGPBaGMeM4M8GRwCJ22E/wsOucaIfGPg+PwdCntHCHxjU4a7AnkjyGYvDp7p95qHL4HDvQc8wyKDYNpSjq6nbDHlK4ISrsW1ydLkNdcPbQyJFX2MD2dx5wI3BAykFWKx928vO7pJR8bcopFfC/11uCXFsrUK5n7jMiSg705ukXBfIKvfni/lxk8SObAHo0ilI6w1ABWaOiRHuYhlmx4lSvw1pz6/SeA7iw8gM1nZKf/4PFSG1fY61+8DuvydXcGKp1bQPeA4Rrw/uSP3oHU0k8y88G647WlM7E/MtGRAS+9vxi4VZGP7CSHdL/0iJoCi/vw0kKKejwPOMtLQvPLgFybzjWlSn0d4ZqBjbp3KoJIaQ5WRI/p3NVLb/brreg0XXnWAEhbV8Bt6RespAoSsV0+lNsao+02hHnui/U1LRlkb5SAB8f/kzRArUE+GH7F6JHf4tVgesq7aqHXm9dIj4eyBcqENIQjh3PIomM0H+4JqzcYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":11057,"cache_read_input_tokens":103271,"cache_creation":{"ephemeral_5m_input_tokens":11057,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"19ed4ba9-b591-40dd-b63c-a7fc69cc944d","request_id":"req_011CePc2jNX7HiyuwRd4x854"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePc2mE8qzcb2U6ouDR6Q","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISngoKqAEIERgCKkA1bRiPSLAMZ4czxLjC/3gvoKJJzinqx5Dby8Fd+s/SCCs6Pxa8ULeBd5o+f3a5Gp976VqdqmKH6ps+BNweraI4Mg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQHSFahiYOz86M3z97Uqk2VogBAagBz8a21AawAQISDKa8HMxcZ2osGgttFxoMRfTt3Ckayw8CFao9IjC6h1Pu7QijD4g6GPVDKllKKazvl/YeWXGpNo8MvlpmXG72QgE27y/W2dZywbaEQ5cqogg1awZzf+Vpvxx7MlmympMLLwECSUj8oQZFouqIOEy9eQYF0I4nW1zjVshSL0HnG04ap/+DplLO23ukr3/E2iL8TZ0OtwSMqDxN2loMkvV3qKmMkcs4u7NO6eRtnaAKUYpOKqZOoDzOanVASiI2+y04aO8oJwRwYWmjBsGPYrD0VKj205fqYSZWOVfiPjZm+yBV6YVp6I0DcQI8IUbuOPOqxJpQhKzuKbgoLZeYSJoslbHBU16wBVcIQyyLvsTmRfNmJoQtaOlm9CkEeSFDvGtQTSxe13Ip7h42sCulhPDKFH4xzwoeZyo8vxYG+j2Jfe/Bzu85qmXIbAkQ0Lbp9VNfpdi7ee3Acn2Ff9iFCmEpk+Hpf1WdHvab6gnBMYAr/sqOD+Hg/7LY53mjOFG98Cm9dNyfFGpbXARNjdtlEC1Bd7eD01iytJPyYkvXlGNzAPpRlFxsuMyM3aemEPfP80MxFHtKV5UNLnxGyOrjUl8jLRbhBfAu/V3e/8okyk67WnFSB5fOjl7RTGj5hsna4uN+HrS5BxzsDKkx2A4TT3eTrYpAxxx3NrGMLC3mOryYNS029DfFeGD2rcL8cUYEkv1YThjs7YRtzrHXlO0Chubj29GcZ8trUSMxk5HGgejctmZhwB30L6oIjvyw1n8pzbPZiGK8fgTa+wQJHr5SIGrLT0xJMx+9VKxMr8Haw7/40Srr0oEG1dWmKx/h1w8alp94MYnWC0lLDS65/+Otli3SFKrJfccQZH9DotC7ZLgf+JiNPIbTDhhiaK/EHoNmC65JoIBeCq1O0RIEiUOKVR6+9OfQq1SluNAs5tcyOl+FMbi7ouI3xJ1frtGINC28OeZrhY2HHZaW2OWd7lEvTC4ACySm1BOMs+D//M6sPxLQ0bos2/LzbiKv64dEZn3yB8IzkZJMkAreiwbURbe/+4xOIzT7K2a7vFXhHDWj033tzCIhywYPtN0Fkmcdy42b32D4/QCKsg5e59EqJkaRrLTsAmO7qvh0mhPgsuNe8PrJnJlyfGzHN3sZ9fPomZNxUHyz06P61mZ4eAWnU1Rc7/7fO1f5oKoYRZB5CEzH6BSlSeKZfzRnO+AprhaZxHCWTJeZ/iaOCvRjdoDFNClpB/YC/mhKW+FFy06z157z43meNiMH6ZEBcnkvhBQUH9jmBZbKXPvRb8lVwIJnZ4vXNVSqBZQsNZUlr5Y5MbZ86+35/Aabxii/505dlPv7iwjgr++Xio+qq5Iebuk5YRObQ2oMDuANOCqpEPqysYhJzA2sQYOnLZIImm+87pUwdW5WCG4ryimEHRxy/p4ApeZpUALIT4/meT/5calgW2aMXkR2hjZYw/Wd1bgoJgvNG5/TVQ0Rk5GutlWV+VDjhsXuAek1c8iny8ahAnRuU9RMzeCzSWvp8BgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":11057,"cache_read_input_tokens":103271,"cache_creation":{"ephemeral_5m_input_tokens":11057,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"fc867596-df95-4857-b0b2-f833799017ef","request_id":"req_011CePc2jNX7HiyuwRd4x854"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePc2mE8qzcb2U6ouDR6Q","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NJXyy2U9QVQCKxh34L1Z3D","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py","old_string":"# Rôle : Normalisation commune des offres (salaires, dates, mode/type\n# d'emploi, lieu, catégorie) — jamais de valeur inventée\n# Créé : 2026-08-17 Modifié : 2026-08-18","new_string":"# Rôle : Normalisation commune des offres (salaires, dates, mode/type\n# d'emploi, lieu, catégorie, exigences, séniorité) — jamais de\n# valeur inventée\n# Créé : 2026-08-17 Modifié : 2026-08-25"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":11057,"cache_read_input_tokens":103271,"cache_creation":{"ephemeral_5m_input_tokens":11057,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"53834f05-ae9c-4d6a-ad65-0d00f8f47587","request_id":"req_011CePc2jNX7HiyuwRd4x854"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NJXyy2U9QVQCKxh34L1Z3D","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"9c9809c2-32b8-4e7b-b663-e94791e0c007","timestamp":"2026-08-25T14:14:42.114Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py","oldString":"# Rôle : Normalisation commune des offres (salaires, dates, mode/type\n# d'emploi, lieu, catégorie) — jamais de valeur inventée\n# Créé : 2026-08-17 Modifié : 2026-08-18","newString":"# Rôle : Normalisation commune des offres (salaires, dates, mode/type\n# d'emploi, lieu, catégorie, exigences, séniorité) — jamais de\n# valeur inventée\n# Créé : 2026-08-17 Modifié : 2026-08-25","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/normalize.py\n# Rôle : Normalisation commune des offres (salaires, dates, mode/type\n# d'emploi, lieu, catégorie) — jamais de valeur inventée\n# Créé : 2026-08-17 Modifié : 2026-08-18\n# =============================================================================\n\"\"\"Couche de normalisation commune appelée par JobPosting.finalize().\n\nPhilosophie héritée de Lou·Ka : parseurs déterministes (regex, tables de\nmots-clés FR/EN), bornes de plausibilité, et surtout — une valeur absente\nreste absente (None / chaîne vide), on ne devine jamais.\n\"\"\"\nfrom __future__ import annotations\n\nimport datetime as _dt\nimport re\nimport unicodedata\n\n__all__ = [\n \"strip_accents\", \"clean_text\", \"clean_html\", \"parse_salary\",\n \"sanitize_salary\", \"salary_from_text\", \"salary_to_yearly\",\n \"salary_to_hourly\", \"parse_date\", \"parse_work_mode\",\n \"parse_employment_type\", \"parse_location\", \"canonical_city\",\n \"is_quebec_location\", \"categorize\", \"detect_language\",\n \"extract_benefits\", \"clean_title\",\n]\n\n\n# ---------------------------------------------------------------------------\n# Texte\n# ---------------------------------------------------------------------------\n\ndef strip_accents(s: str) -> str:\n return \"\".join(c for c in unicodedata.normalize(\"NFD\", s)\n if unicodedata.category(c) != \"Mn\")\n\n\ndef _key(s: str) -> str:\n \"\"\"Minuscules sans accents — pour le matching de mots-clés.\"\"\"\n return strip_accents((s or \"\").lower())\n\n\ndef clean_text(s: str) -> str:\n \"\"\"Espaces insécables -> espace, blancs réduits.\"\"\"\n s = (s or \"\").replace(\"\\xa0\", \" \").replace(\" \", \" \").replace(\" \", \" \")\n return re.sub(r\"[ \\t]+\", \" \", s).strip()\n\n\n_BLOCK_TAGS = re.compile(\n r\"]*>\", re.I)\n\n# Résidu HTML après une passe : balise ouvrante plausible ou entité encore\n# encodée — signe d'un contenu doublement échappé.\n_HTML_LEFTOVER = re.compile(\n r\"<(?:p|br|li|ul|ol|div|span|strong|em|b|i|h[1-6]|a)\\b|&(?:amp|lt|gt|nbsp|#\\d+);\",\n re.I)\n\n\ndef clean_html(html: str) -> str:\n \"\"\"HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré.\n\n Suffisant pour les descriptions d'offres (les connecteurs qui ont besoin\n d'un vrai parseur utilisent BeautifulSoup eux-mêmes).\n \"\"\"\n if not html:\n return \"\"\n import html as _html\n s = html\n # Contenus sur-échappés (entités dans les entités — Greenhouse/Lever) :\n # nettoyer en boucle tant qu'il reste des balises/entités (max 3 passes).\n for _ in range(3):\n s = _html.unescape(s)\n s = re.sub(r\"<(script|style)[^>]*>.*?\", \" \", s, flags=re.I | re.S)\n s = _BLOCK_TAGS.sub(\"\\n\", s)\n s = re.sub(r\"<[^>]+>\", \" \", s)\n if not _HTML_LEFTOVER.search(s):\n break\n s = s.replace(\"\\xa0\", \" \").replace(\" \", \" \")\n s = re.sub(r\"[ \\t]+\", \" \", s)\n s = re.sub(r\" ?\\n ?\", \"\\n\", s)\n return re.sub(r\"\\n{3,}\", \"\\n\\n\", s).strip()\n\n\n# ---------------------------------------------------------------------------\n# Salaires — transparence salariale = différenciateur clé de Job·Ka\n# ---------------------------------------------------------------------------\n\n# Bornes de plausibilité par unité (CAD). Hors bornes = on jette (pas de\n# salaire inventé, pas de « 1 $/h » venu d'un parsing raté).\n_SALARY_BOUNDS = {\n \"hour\": (14.0, 250.0),\n \"day\": (100.0, 2000.0),\n \"week\": (500.0, 10000.0),\n \"biweek\": (1000.0, 20000.0),\n \"month\": (1800.0, 45000.0),\n \"year\": (20000.0, 600000.0),\n}\n\n# Heures/an de référence pour les conversions (semaine de 40 h).\n_HOURS_PER_YEAR = 2080.0\n\n_UNIT_PATTERNS = [\n (\"hour\", r\"(?:/|par|per|an?)\\s*(?:h\\b|hr\\b|hre\\b|heure|hour)|horaire|hourly\"),\n (\"year\", r\"(?:/|par|per|an?)\\s*(?:an\\b|année|annee|year|yr\\b)|annuel|annual|yearly|per\\s+annum\"),\n (\"month\", r\"(?:/|par|per)\\s*(?:mois|month)|mensuel|monthly\"),\n (\"biweek\", r\"(?:aux|par|per)\\s*(?:2|deux|two)\\s*semaines|bi-?weekly|quinzaine\"),\n (\"week\", r\"(?:/|par|per)\\s*(?:sem\\b|semaine|week|wk\\b)|hebdomadaire|weekly\"),\n (\"day\", r\"(?:/|par|per)\\s*(?:jour|day)|journalier|daily\"),\n]\n\n_NUM_RE = re.compile(r\"\\d{1,3}(?:[   ,]\\d{3})+(?:[.,]\\d{1,2})?|\\d+(?:[.,]\\d{1,2})?[kK]?\")\n\n\ndef _parse_number(tok: str) -> float | None:\n tok = tok.strip()\n k = tok.lower().endswith(\"k\")\n if k:\n tok = tok[:-1]\n # « 52,000 » (milliers anglais) vs « 52000,00 » / « 25,50 » (décimales)\n tok = tok.replace(\" \", \" \").replace(\" \", \" \")\n if re.fullmatch(r\"\\d{1,3}(?:[ ,]\\d{3})+(?:[.,]\\d{1,2})?\", tok):\n # séparateur de milliers : retirer espaces/virgules de groupe\n tok = re.sub(r\"[ ,](?=\\d{3}\\b)\", \"\", tok)\n tok = tok.replace(\",\", \".\").replace(\" \", \"\")\n try:\n v = float(tok)\n except ValueError:\n return None\n return v * 1000 if k else v\n\n\ndef parse_salary(raw: str) -> tuple[float | None, float | None, str | None]:\n \"\"\"Texte de salaire -> (min, max, unité) ou (None, None, None).\n\n Exige un signe monétaire ($, CAD, dollars) OU une unité explicite proche\n des nombres — sans quoi « 35 heures/semaine » deviendrait un salaire.\n \"\"\"\n s = clean_text(raw)\n if not s:\n return (None, None, None)\n key = _key(s)\n\n unit = None\n for u, pat in _UNIT_PATTERNS:\n if re.search(pat, key):\n unit = u\n break\n\n has_money = bool(re.search(r\"\\$|cad\\b|dollar\", key))\n if not has_money and unit is None:\n return (None, None, None)\n\n nums = [_parse_number(m.group(0)) for m in _NUM_RE.finditer(s)]\n nums = [n for n in nums if n is not None]\n if not nums:\n return (None, None, None)\n\n # Sans unité explicite : inférer par l'ordre de grandeur (avec $ obligatoire)\n if unit is None:\n probe = max(nums)\n if probe < 300:\n unit = \"hour\"\n elif probe >= 18000:\n unit = \"year\"\n else:\n return (None, None, None) # zone ambiguë : ne pas deviner\n\n lo, hi = _SALARY_BOUNDS[unit]\n vals = sorted(n for n in nums if lo <= n <= hi)\n if not vals:\n return (None, None, None)\n return (vals[0], vals[-1] if len(vals) > 1 else vals[0], unit)\n\n\n_TO_YEAR = {\"hour\": _HOURS_PER_YEAR, \"day\": 260.0, \"week\": 52.0,\n \"biweek\": 26.0, \"month\": 12.0, \"year\": 1.0}\n\n\ndef salary_to_yearly(value: float | None, unit: str | None) -> float | None:\n if value is None or unit not in _TO_YEAR:\n return None\n return round(value * _TO_YEAR[unit], 0)\n\n\ndef salary_to_hourly(value: float | None, unit: str | None) -> float | None:\n y = salary_to_yearly(value, unit)\n return round(y / _HOURS_PER_YEAR, 2) if y is not None else None\n\n\ndef sanitize_salary(lo: float | None, hi: float | None, unit: str | None\n ) -> tuple[float | None, float | None, str | None]:\n \"\"\"Bornes de plausibilité sur les salaires STRUCTURÉS fournis par les ATS.\n\n Certaines sources étiquettent mal l'unité (75 000 « /mois » ou « /h » pour\n un salaire annuel — vu chez Lever et dans les JSON-LD de portails) : si la\n valeur sort des bornes de son unité, on tente une réinterprétation vers\n l'unité dont les bornes la contiennent (année d'abord, puis heure). Aucune\n unité plausible -> on jette (pas de salaire aberrant publié).\n \"\"\"\n if lo is None or unit not in _SALARY_BOUNDS:\n return (lo, hi, unit)\n b = _SALARY_BOUNDS[unit]\n if not (b[0] <= lo <= b[1]):\n for candidate in (\"year\", \"hour\"):\n cb = _SALARY_BOUNDS[candidate]\n if candidate != unit and cb[0] <= lo <= cb[1]:\n unit, b = candidate, cb\n break\n else:\n return (None, None, None)\n if hi is not None and not (b[0] <= hi <= b[1]):\n hi = None # max aberrant : on garde le min plausible seul\n if hi is not None and hi < lo:\n lo, hi = hi, lo\n return (lo, hi, unit)\n\n\n# Contextes où un montant en $ n'est PAS un salaire (financement, dépenses,\n# allocations…) — garde-fou de l'extraction depuis la description.\n_NOT_SALARY_CONTEXT = re.compile(\n r\"financement|funding|amorcage|seed|investisse|invest(?:ment|or)|\"\n r\"revenu[s]?\\b|revenue|chiffre d affaires|budget|remboursement|\"\n r\"reimburse|expense|allocation|allowance|subvention|credit d impot|\"\n r\"cotisation|rabais|discount|gift|carte[- ]cadeau|referral (?:bonus|fee)|\"\n r\"signing bonus|prime (?:a l embauche|de demarrage|de reference)|\"\n r\"million|milliard\")\n\n\ndef salary_from_text(text: str) -> tuple[float | None, float | None,\n str | None, str]:\n \"\"\"Cherche une mention salariale dans un texte libre (description).\n\n Découpe en phrases (les points décimaux « 182,500.00 » ne coupent pas),\n ignore les contextes non salariaux (financement, remboursements…), puis\n parse la première phrase plausible. Retourne (min, max, unité, libellé).\n \"\"\"\n if not text or \"$\" not in text:\n return (None, None, None, \"\")\n for sentence in re.split(r\"(? str | None:\n \"\"\"Valeur de date hétéroclite -> ISO « YYYY-MM-DD », ou None.\n\n Accepte : ISO (+ horodatages), epoch secondes/millisecondes, formats\n textuels FR/EN (« 1er juillet 2026 », « July 1st, 2026 »), et les dates\n relatives (« il y a 3 jours », « Posted 30+ Days Ago »).\n \"\"\"\n if raw is None:\n return None\n today = today or _dt.date.today()\n\n if isinstance(raw, (int, float)): # epoch (Lever : millisecondes)\n ts = float(raw)\n if ts > 1e12:\n ts /= 1000.0\n if 1e9 <= ts <= 4e9:\n return _dt.date.fromtimestamp(ts).isoformat()\n return None\n\n s = clean_text(str(raw))\n if not s:\n return None\n key = _key(s)\n\n m = re.search(r\"(\\d{4})-(\\d{2})-(\\d{2})\", s) # ISO (avec ou sans heure)\n if m:\n try:\n return _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3))).isoformat()\n except ValueError:\n return None\n\n if _TODAY_RE.search(key):\n return today.isoformat()\n if _YESTERDAY_RE.search(key):\n return (today - _dt.timedelta(days=1)).isoformat()\n\n m = _REL_RE.search(key)\n if m:\n n = int(m.group(1) or m.group(3))\n unit = (m.group(2) or m.group(4) or \"\").lower()\n days = n * (7 if unit.startswith((\"sem\", \"week\"))\n else 30 if unit.startswith((\"moi\", \"month\")) else 1)\n return (today - _dt.timedelta(days=days)).isoformat()\n\n # « 1er juillet 2026 » / « July 1st, 2026 » / « déc. 2026 »\n m = re.search(r\"(?:(\\d{1,2})(?:er|e|st|nd|rd|th)?\\s+)?([a-z]{3,9})\\.?,?\\s+(?:(\\d{1,2})(?:er|st|nd|rd|th)?,?\\s+)?(\\d{4})\", key)\n if m:\n mois = _MONTHS.get(m.group(2))\n if mois:\n jour = int(m.group(1) or m.group(3) or 1)\n try:\n return _dt.date(int(m.group(4)), mois, jour).isoformat()\n except ValueError:\n return None\n\n m = re.fullmatch(r\"(\\d{1,2})/(\\d{1,2})/(\\d{4})\", s) # jj/mm/aaaa (usage QC)\n if m:\n d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))\n if mo > 12 and d <= 12: # en fait mm/jj/aaaa\n d, mo = mo, d\n try:\n return _dt.date(y, mo, d).isoformat()\n except ValueError:\n return None\n return None\n\n\n# ---------------------------------------------------------------------------\n# Mode de travail / type d'emploi\n# ---------------------------------------------------------------------------\n\n_MODE_RULES = [\n (\"teletravail\", r\"100\\s*%\\s*(?:teletravail|remote)|full(?:y)?[ -]remote|\"\n r\"teletravail|travail a distance|remote(?:\\b|-first)|a distance\"),\n (\"hybride\", r\"hybrid|hybride|flexible?\\s*(?:work|travail)\"),\n (\"presentiel\", r\"presentiel|sur (?:place|site)|on[- ]?site|in[- ]?(?:office|person)\"),\n]\n\n\ndef parse_work_mode(raw: str) -> str | None:\n key = _key(raw)\n if not key:\n return None\n # « hybride » prime sur les mentions accessoires de télétravail\n if re.search(_MODE_RULES[1][1], key):\n return \"hybride\"\n for mode, pat in _MODE_RULES:\n if re.search(pat, key):\n return mode\n return None\n\n\n_TYPE_RULES = [\n (\"stage\", r\"\\bstage\\b|stagiaire|intern(?:ship)?\\b|co-?op\\b|alternance\"),\n (\"saisonnier\", r\"saisonn|seasonal\"),\n (\"contractuel\", r\"contrat\\b|contract(?:uel|or)?\\b|temporaire|temporary|terme|\"\n r\"fixed[- ]term|duree determinee|pigiste|freelance|consultant\"),\n (\"temps_partiel\", r\"temps[- ]partiel|part[- ]?time|partiel\\b\"),\n (\"temps_plein\", r\"temps[- ]plein|full[- ]?time|permanent|regulier|\"\n r\"regular\\b|duree indeterminee\"),\n]\n\n\ndef parse_employment_type(raw: str) -> str | None:\n key = _key(raw)\n if not key:\n return None\n for typ, pat in _TYPE_RULES:\n if re.search(pat, key):\n return typ\n return None\n\n\n# ---------------------------------------------------------------------------\n# Lieu — extraction ville/région + garde-fou « offre au Québec »\n# ---------------------------------------------------------------------------\n\n# Villes québécoises fréquentes dans les libellés d'offres (clé sans accents).\n# Sert à la fois au parsing et au filtre is_quebec_location.\n_QC_CITIES = {\n \"montreal\": \"Montréal\", \"quebec\": \"Québec\", \"ville de quebec\": \"Québec\",\n \"quebec city\": \"Québec\", \"laval\": \"Laval\", \"gatineau\": \"Gatineau\",\n \"longueuil\": \"Longueuil\", \"sherbrooke\": \"Sherbrooke\",\n \"saguenay\": \"Saguenay\", \"chicoutimi\": \"Saguenay\",\n \"levis\": \"Lévis\", \"trois-rivieres\": \"Trois-Rivières\",\n \"trois rivieres\": \"Trois-Rivières\", \"terrebonne\": \"Terrebonne\",\n \"brossard\": \"Brossard\", \"repentigny\": \"Repentigny\",\n \"boucherville\": \"Boucherville\", \"saint-jerome\": \"Saint-Jérôme\",\n \"drummondville\": \"Drummondville\", \"saint-jean-sur-richelieu\":\n \"Saint-Jean-sur-Richelieu\", \"granby\": \"Granby\", \"shawinigan\": \"Shawinigan\",\n \"joliette\": \"Joliette\", \"victoriaville\": \"Victoriaville\",\n \"rimouski\": \"Rimouski\", \"rouyn-noranda\": \"Rouyn-Noranda\",\n \"baie-comeau\": \"Baie-Comeau\", \"sept-iles\": \"Sept-Îles\",\n \"val-d'or\": \"Val-d'Or\", \"val d'or\": \"Val-d'Or\", \"alma\": \"Alma\",\n \"sainte-foy\": \"Québec\", \"anjou\": \"Montréal\", \"dorval\": \"Dorval\",\n \"boisbriand\": \"Boisbriand\", \"blainville\": \"Blainville\",\n \"mirabel\": \"Mirabel\", \"saint-hyacinthe\": \"Saint-Hyacinthe\",\n \"beloeil\": \"Belœil\", \"chateauguay\": \"Châteauguay\", \"magog\": \"Magog\",\n \"thetford mines\": \"Thetford Mines\", \"saint-georges\": \"Saint-Georges\",\n \"riviere-du-loup\": \"Rivière-du-Loup\", \"matane\": \"Matane\",\n \"sorel-tracy\": \"Sorel-Tracy\", \"varennes\": \"Varennes\",\n \"vaudreuil-dorion\": \"Vaudreuil-Dorion\", \"salaberry-de-valleyfield\":\n \"Salaberry-de-Valleyfield\", \"kirkland\": \"Kirkland\",\n \"pointe-claire\": \"Pointe-Claire\", \"saint-laurent\": \"Montréal\",\n \"ville saint-laurent\": \"Montréal\", \"verdun\": \"Montréal\",\n \"westmount\": \"Westmount\", \"candiac\": \"Candiac\", \"la prairie\": \"La Prairie\",\n \"mont-tremblant\": \"Mont-Tremblant\", \"saint-bruno\": \"Saint-Bruno-de-Montarville\",\n \"bromont\": \"Bromont\", \"cowansville\": \"Cowansville\",\n \"saint-augustin-de-desmaures\": \"Saint-Augustin-de-Desmaures\",\n \"l'ancienne-lorette\": \"L'Ancienne-Lorette\",\n}\n\n_QC_MARKERS = re.compile(\n r\"\\bqc\\b|\\bquebec\\b|\\bqu[e]bec, canada\\b|province de quebec\", re.I)\n_POSTAL_RE = re.compile(r\"\\b([GHJ]\\d[A-Z])\\s*(\\d[A-Z]\\d)\\b\", re.I) # G/H/J = Québec\n\n# Villes canadiennes hors Québec (et grandes villes étrangères) fréquentes\n# dans les flux pancanadiens/mondiaux — jamais des villes d'offres\n# québécoises : à rejeter lors du parsing de lieu (clés sans accents).\n_NON_QC_CITIES = frozenset({\n \"toronto\", \"ottawa\", \"vancouver\", \"calgary\", \"edmonton\", \"winnipeg\",\n \"mississauga\", \"brampton\", \"hamilton\", \"kitchener\", \"waterloo\", \"halifax\",\n \"dartmouth\", \"victoria\", \"saskatoon\", \"regina\", \"st. john's\", \"st johns\",\n \"saint john\", \"fredericton\", \"moncton\", \"charlottetown\", \"london\",\n \"markham\", \"vaughan\", \"richmond hill\", \"oakville\", \"burlington\", \"oshawa\",\n \"whitby\", \"ajax\", \"pickering\", \"guelph\", \"kingston\", \"barrie\",\n \"kelowna\", \"kamloops\", \"nanaimo\", \"abbotsford\", \"surrey\", \"burnaby\",\n \"coquitlam\", \"langley\", \"north york\", \"scarborough\", \"etobicoke\",\n \"north vancouver\", \"milton\", \"cambridge\", \"st. catharines\",\n \"niagara falls\", \"thunder bay\", \"sudbury\", \"greater sudbury\",\n \"sault ste. marie\", \"red deer\", \"lethbridge\", \"medicine hat\",\n \"grande prairie\", \"fort mcmurray\", \"yellowknife\", \"whitehorse\", \"iqaluit\",\n \"can\", \"canada\", \"ontario\", \"alberta\", \"british columbia\", \"manitoba\",\n \"saskatchewan\", \"nova scotia\", \"new brunswick\",\n \"new york\", \"new york city\", \"san francisco\", \"boston\", \"chicago\",\n \"seattle\", \"austin\", \"denver\", \"los angeles\", \"miami\", \"atlanta\",\n \"dallas\", \"houston\", \"philadelphia\", \"washington\", \"phoenix\", \"portland\",\n \"paris\", \"londres\", \"amsterdam\", \"berlin\", \"munich\", \"dublin\", \"madrid\",\n \"barcelona\", \"barcelone\", \"lisbon\", \"lisbonne\", \"sydney\", \"melbourne\",\n \"singapore\", \"singapour\", \"tokyo\", \"bangalore\", \"bengaluru\", \"mumbai\",\n \"mexico city\", \"sao paulo\", \"bogota\", \"buenos aires\", \"las vegas\",\n \"orlando\", \"nashville\", \"macau\",\n})\n\n\ndef canonical_city(raw: str) -> str:\n \"\"\"Canonicalise un nom de ville (« Montreal » -> « Montréal »,\n « Quebec City » -> « Québec »). Inconnu : retourne le texte nettoyé.\"\"\"\n s = clean_text(raw)\n if not s:\n return \"\"\n key = _key(s)\n exact = _QC_CITIES.get(key)\n if exact:\n return exact\n for ck, city in _QC_CITIES.items():\n if re.fullmatch(re.escape(ck), key):\n return city\n return s\n\n\ndef parse_location(raw: str) -> dict:\n \"\"\"Libellé de lieu -> {city, region, postal_code} (champs possiblement vides).\n\n Ex. « Montréal, QC, Canada », « Ville de Québec, Québec », « Remote - QC ».\n \"\"\"\n s = clean_text(raw)\n out = {\"city\": \"\", \"region\": \"\", \"postal_code\": \"\"}\n if not s:\n return out\n m = _POSTAL_RE.search(s)\n if m:\n out[\"postal_code\"] = f\"{m.group(1).upper()} {m.group(2).upper()}\"\n key = _key(s)\n for ck, city in _QC_CITIES.items():\n if re.search(rf\"(? bool:\n \"\"\"Vrai si le libellé pointe vers le Québec (filtre des ATS pancanadiens).\"\"\"\n key = _key(clean_text(raw))\n if not key:\n return False\n if key in _NON_QC_CITIES: # libellé = ville canadienne/monde hors QC\n return False\n if _QC_MARKERS.search(key):\n return True\n m = _POSTAL_RE.search(raw)\n if m and m.group(1)[0].upper() in \"GHJ\":\n return True\n return any(re.search(rf\"(? str:\n \"\"\"Langue du texte de l'offre : « fr », « en », « bilingue », ou \"\".\n\n Comptage de mots-outils sur le début du texte — déterministe, jamais de\n valeur inventée : texte trop court ou ambigu -> \"\".\n \"\"\"\n text = _key(f\"{title}\\n{description or ''}\"[:4000])\n if len(text) < 30:\n return \"\"\n fr = len(_FR_STOPWORDS.findall(text))\n en = len(_EN_STOPWORDS.findall(text))\n total = fr + en\n if total < 5:\n return \"\"\n # les deux langues nettement présentes -> offre bilingue\n if fr >= 8 and en >= 8 and min(fr, en) / max(fr, en) >= 0.35:\n return \"bilingue\"\n if fr > en:\n return \"fr\"\n if en > fr:\n return \"en\"\n return \"\"\n\n\n# ---------------------------------------------------------------------------\n# Avantages — extraction des blocs « Avantages / Benefits » des descriptions\n# ---------------------------------------------------------------------------\n\n_BENEFITS_HEADING = re.compile(\n r\"^\\s*(?:les\\s+|nos\\s+|our\\s+|tes\\s+|vos\\s+)?(?:avantages(?:\\s+sociaux)?|\"\n r\"benefits(?:\\s+&\\s+perks)?|perks(?:\\s+(?:and|&)\\s+benefits)?|\"\n r\"ce que nous (?:t')?offrons|what we offer|why join us|\"\n r\"pourquoi nous rejoindre|nous offrons|we offer)\\s*:?\\s*$\", re.I)\n_BENEFIT_LINE_MAX = 120\n\n\ndef extract_benefits(description: str) -> list[str]:\n \"\"\"Liste d'avantages depuis un bloc structuré de la description.\n\n Cherche un titre de section « Avantages/Benefits/… » seul sur sa ligne,\n puis prend les lignes courtes qui suivent (puces), en s'arrêtant à la\n première ligne longue ou au prochain titre. Max 12 items — si le bloc ne\n ressemble pas à une liste, on ne retourne rien (pas d'à-peu-près).\n \"\"\"\n if not description:\n return []\n lines = description.split(\"\\n\")\n for i, line in enumerate(lines):\n if not _BENEFITS_HEADING.match(strip_accents(line)):\n continue\n items: list[str] = []\n for nxt in lines[i + 1:]:\n t = clean_text(nxt).lstrip(\"•-–—*·◦▪ \").strip()\n if not t:\n if items:\n break # fin du bloc (ligne vide après les puces)\n continue\n if len(t) > _BENEFIT_LINE_MAX or _BENEFITS_HEADING.match(\n strip_accents(t)):\n break\n # un « titre » de section suivant (courte ligne sans ponctuation\n # finale, capitalisée) termine aussi le bloc\n if items and len(t.split()) <= 4 and t.endswith(\":\"):\n break\n items.append(t.rstrip(\" ;,.\").strip())\n if len(items) >= 12:\n break\n if len(items) >= 2:\n return items\n return []\n\n\n# ---------------------------------------------------------------------------\n# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés\n# ---------------------------------------------------------------------------\n\n# codes de réquisition : « [R-12345] », « (REQ 2026-42) », « - JR0035678 »…\n_REQ_CODE = re.compile(\n # en tête : « 123456 - Titre », « [REQ-1234] Titre » (≥5 chiffres nus pour\n # ne pas manger une année « 2026 - Programme d'été »)\n r\"^\\s*[\\[(]?(?:req(?:uisition)?|jr|job)[\\s#:-]*\\d{3,10}[\\])]?\\s*[-–—:|]\\s*|\"\n r\"^\\s*[\\[(]?\\d{5,10}[\\])]?\\s*[-–—:|]\\s*|\"\n # en queue : « Titre - R-12345 », « Titre (JOB 004521) »\n r\"\\s*[-–—|(]\\s*(?:req(?:uisition)?|job|poste)?\\s*#?\\s*(?:[A-Z]{1,4}-?)?\\d{4,10}\\s*[)\\]]?\\s*$|\"\n # au milieu : « Titre [R-12345] suite »\n r\"\\s*[\\[(](?:R|REQ|JR|JOB)[-_ ]?\\d{3,10}[\\])]\\s*\", re.I)\n\n# petits mots gardés en minuscules lors de la remise en casse d'un titre\n# tout-majuscules ; sigles usuels gardés en majuscules\n_TITLE_SMALL = {\"de\", \"du\", \"des\", \"la\", \"le\", \"les\", \"et\", \"en\", \"à\", \"a\",\n \"au\", \"aux\", \"d'\", \"l'\", \"of\", \"the\", \"and\", \"in\", \"for\",\n \"to\", \"or\", \"ou\", \"un\", \"une\", \"sur\", \"pour\"}\n_TITLE_ACRONYMS = {\"rh\", \"ti\", \"it\", \"qa\", \"qc\", \"pab\", \"cpa\", \"cfa\", \"erp\",\n \"sap\", \"crm\", \"sst\", \"cnc\", \"hvac\", \"rpa\", \"bi\", \"ia\",\n \"ai\", \"vp\", \"pdg\", \"dg\"}\n\n\ndef _smart_case(title: str) -> str:\n \"\"\"Titre TOUT EN MAJUSCULES -> casse de phrase lisible (sigles préservés).\"\"\"\n words = title.lower().split()\n out = []\n for i, w in enumerate(words):\n base = w.strip(\"()[],.;:/\")\n if base in _TITLE_ACRONYMS:\n out.append(w.replace(base, base.upper()))\n elif i > 0 and base in _TITLE_SMALL:\n out.append(w)\n else:\n out.append(w[:1].upper() + w[1:])\n return \" \".join(out)\n\n\ndef clean_title(title: str, city: str = \"\") -> str:\n \"\"\"Titre d'affichage : codes de réquisition retirés, suffixe « - »\n redondant retiré, casse corrigée si tout-majuscules. Le titre SOURCE n'est\n jamais modifié — ceci alimente une colonne d'affichage distincte.\"\"\"\n t = clean_text(title)\n if not t:\n return \"\"\n prev = None\n while prev != t: # les codes peuvent être empilés\n prev = t\n t = _REQ_CODE.sub(\" \", t).strip(\" -–—|:\")\n t = clean_text(t)\n # suffixe « - Montréal » / « (Montréal, QC) » identique à la ville de\n # l'offre : repéré sans accents, coupé sur le titre original\n if city:\n ck = re.escape(strip_accents(city.lower()))\n m = re.search(\n rf\"\\s*[-–—|,(]\\s*(?:ville de\\s+)?{ck}(?:\\s*,\\s*(?:qc|quebec))?\\s*\\)?\\s*$\",\n strip_accents(t.lower()))\n if m and m.start() > 0:\n t = clean_text(t[:m.start()]).strip(\" -–—|,(\")\n if len(t) > 8 and t == t.upper() and t != t.lower():\n t = _smart_case(t)\n return t or clean_text(title)\n\n\ndef categorize(title: str, description: str = \"\") -> str:\n \"\"\"Titre -> catégorie interne (repli : début de description), ou \"\".\n\n Le titre est essayé SEUL d'abord : la description commence souvent par le\n boilerplate de l'entreprise (« leader du manufacturier… ») qui fausserait\n la catégorie du poste.\n \"\"\"\n for texte in (title, description[:300]):\n key = _key(texte or \"\")\n if not key.strip():\n continue\n for cat, pat in _CATEGORY_RULES:\n if re.search(pat, key):\n return cat\n return \"\"\n","structuredPatch":[{"oldStart":4,"oldLines":8,"newStart":4,"newLines":9,"lines":[" # Contact : contact@spboucher.ai"," # Fichier : jobka/normalize.py"," # Rôle : Normalisation commune des offres (salaires, dates, mode/type","-# d'emploi, lieu, catégorie) — jamais de valeur inventée","-# Créé : 2026-08-17 Modifié : 2026-08-18","+# d'emploi, lieu, catégorie, exigences, séniorité) — jamais de","+# valeur inventée","+# Créé : 2026-08-17 Modifié : 2026-08-25"," # ============================================================================="," \"\"\"Couche de normalisation commune appelée par JobPosting.finalize()."," "]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePc2mE8qzcb2U6ouDR6Q","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01H3XPthSHwUP2RpDMhaz4vk","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py","old_string":" \"is_quebec_location\", \"categorize\", \"detect_language\",\n \"extract_benefits\", \"clean_title\",\n]","new_string":" \"is_quebec_location\", \"categorize\", \"detect_language\",\n \"extract_benefits\", \"clean_title\", \"extract_requirements\",\n \"parse_seniority\",\n]"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":11057,"cache_read_input_tokens":103271,"cache_creation":{"ephemeral_5m_input_tokens":11057,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"01e1f566-6d17-4cb0-8861-e69e99bc3d2f","request_id":"req_011CePc2jNX7HiyuwRd4x854"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01H3XPthSHwUP2RpDMhaz4vk","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"db5cea30-5d94-4e8a-a7b6-69a30b7c790f","timestamp":"2026-08-25T14:14:43.513Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py","oldString":" \"is_quebec_location\", \"categorize\", \"detect_language\",\n \"extract_benefits\", \"clean_title\",\n]","newString":" \"is_quebec_location\", \"categorize\", \"detect_language\",\n \"extract_benefits\", \"clean_title\", \"extract_requirements\",\n \"parse_seniority\",\n]","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/normalize.py\n# Rôle : Normalisation commune des offres (salaires, dates, mode/type\n# d'emploi, lieu, catégorie, exigences, séniorité) — jamais de\n# valeur inventée\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\n\"\"\"Couche de normalisation commune appelée par JobPosting.finalize().\n\nPhilosophie héritée de Lou·Ka : parseurs déterministes (regex, tables de\nmots-clés FR/EN), bornes de plausibilité, et surtout — une valeur absente\nreste absente (None / chaîne vide), on ne devine jamais.\n\"\"\"\nfrom __future__ import annotations\n\nimport datetime as _dt\nimport re\nimport unicodedata\n\n__all__ = [\n \"strip_accents\", \"clean_text\", \"clean_html\", \"parse_salary\",\n \"sanitize_salary\", \"salary_from_text\", \"salary_to_yearly\",\n \"salary_to_hourly\", \"parse_date\", \"parse_work_mode\",\n \"parse_employment_type\", \"parse_location\", \"canonical_city\",\n \"is_quebec_location\", \"categorize\", \"detect_language\",\n \"extract_benefits\", \"clean_title\",\n]\n\n\n# ---------------------------------------------------------------------------\n# Texte\n# ---------------------------------------------------------------------------\n\ndef strip_accents(s: str) -> str:\n return \"\".join(c for c in unicodedata.normalize(\"NFD\", s)\n if unicodedata.category(c) != \"Mn\")\n\n\ndef _key(s: str) -> str:\n \"\"\"Minuscules sans accents — pour le matching de mots-clés.\"\"\"\n return strip_accents((s or \"\").lower())\n\n\ndef clean_text(s: str) -> str:\n \"\"\"Espaces insécables -> espace, blancs réduits.\"\"\"\n s = (s or \"\").replace(\"\\xa0\", \" \").replace(\" \", \" \").replace(\" \", \" \")\n return re.sub(r\"[ \\t]+\", \" \", s).strip()\n\n\n_BLOCK_TAGS = re.compile(\n r\"]*>\", re.I)\n\n# Résidu HTML après une passe : balise ouvrante plausible ou entité encore\n# encodée — signe d'un contenu doublement échappé.\n_HTML_LEFTOVER = re.compile(\n r\"<(?:p|br|li|ul|ol|div|span|strong|em|b|i|h[1-6]|a)\\b|&(?:amp|lt|gt|nbsp|#\\d+);\",\n re.I)\n\n\ndef clean_html(html: str) -> str:\n \"\"\"HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré.\n\n Suffisant pour les descriptions d'offres (les connecteurs qui ont besoin\n d'un vrai parseur utilisent BeautifulSoup eux-mêmes).\n \"\"\"\n if not html:\n return \"\"\n import html as _html\n s = html\n # Contenus sur-échappés (entités dans les entités — Greenhouse/Lever) :\n # nettoyer en boucle tant qu'il reste des balises/entités (max 3 passes).\n for _ in range(3):\n s = _html.unescape(s)\n s = re.sub(r\"<(script|style)[^>]*>.*?\", \" \", s, flags=re.I | re.S)\n s = _BLOCK_TAGS.sub(\"\\n\", s)\n s = re.sub(r\"<[^>]+>\", \" \", s)\n if not _HTML_LEFTOVER.search(s):\n break\n s = s.replace(\"\\xa0\", \" \").replace(\" \", \" \")\n s = re.sub(r\"[ \\t]+\", \" \", s)\n s = re.sub(r\" ?\\n ?\", \"\\n\", s)\n return re.sub(r\"\\n{3,}\", \"\\n\\n\", s).strip()\n\n\n# ---------------------------------------------------------------------------\n# Salaires — transparence salariale = différenciateur clé de Job·Ka\n# ---------------------------------------------------------------------------\n\n# Bornes de plausibilité par unité (CAD). Hors bornes = on jette (pas de\n# salaire inventé, pas de « 1 $/h » venu d'un parsing raté).\n_SALARY_BOUNDS = {\n \"hour\": (14.0, 250.0),\n \"day\": (100.0, 2000.0),\n \"week\": (500.0, 10000.0),\n \"biweek\": (1000.0, 20000.0),\n \"month\": (1800.0, 45000.0),\n \"year\": (20000.0, 600000.0),\n}\n\n# Heures/an de référence pour les conversions (semaine de 40 h).\n_HOURS_PER_YEAR = 2080.0\n\n_UNIT_PATTERNS = [\n (\"hour\", r\"(?:/|par|per|an?)\\s*(?:h\\b|hr\\b|hre\\b|heure|hour)|horaire|hourly\"),\n (\"year\", r\"(?:/|par|per|an?)\\s*(?:an\\b|année|annee|year|yr\\b)|annuel|annual|yearly|per\\s+annum\"),\n (\"month\", r\"(?:/|par|per)\\s*(?:mois|month)|mensuel|monthly\"),\n (\"biweek\", r\"(?:aux|par|per)\\s*(?:2|deux|two)\\s*semaines|bi-?weekly|quinzaine\"),\n (\"week\", r\"(?:/|par|per)\\s*(?:sem\\b|semaine|week|wk\\b)|hebdomadaire|weekly\"),\n (\"day\", r\"(?:/|par|per)\\s*(?:jour|day)|journalier|daily\"),\n]\n\n_NUM_RE = re.compile(r\"\\d{1,3}(?:[   ,]\\d{3})+(?:[.,]\\d{1,2})?|\\d+(?:[.,]\\d{1,2})?[kK]?\")\n\n\ndef _parse_number(tok: str) -> float | None:\n tok = tok.strip()\n k = tok.lower().endswith(\"k\")\n if k:\n tok = tok[:-1]\n # « 52,000 » (milliers anglais) vs « 52000,00 » / « 25,50 » (décimales)\n tok = tok.replace(\" \", \" \").replace(\" \", \" \")\n if re.fullmatch(r\"\\d{1,3}(?:[ ,]\\d{3})+(?:[.,]\\d{1,2})?\", tok):\n # séparateur de milliers : retirer espaces/virgules de groupe\n tok = re.sub(r\"[ ,](?=\\d{3}\\b)\", \"\", tok)\n tok = tok.replace(\",\", \".\").replace(\" \", \"\")\n try:\n v = float(tok)\n except ValueError:\n return None\n return v * 1000 if k else v\n\n\ndef parse_salary(raw: str) -> tuple[float | None, float | None, str | None]:\n \"\"\"Texte de salaire -> (min, max, unité) ou (None, None, None).\n\n Exige un signe monétaire ($, CAD, dollars) OU une unité explicite proche\n des nombres — sans quoi « 35 heures/semaine » deviendrait un salaire.\n \"\"\"\n s = clean_text(raw)\n if not s:\n return (None, None, None)\n key = _key(s)\n\n unit = None\n for u, pat in _UNIT_PATTERNS:\n if re.search(pat, key):\n unit = u\n break\n\n has_money = bool(re.search(r\"\\$|cad\\b|dollar\", key))\n if not has_money and unit is None:\n return (None, None, None)\n\n nums = [_parse_number(m.group(0)) for m in _NUM_RE.finditer(s)]\n nums = [n for n in nums if n is not None]\n if not nums:\n return (None, None, None)\n\n # Sans unité explicite : inférer par l'ordre de grandeur (avec $ obligatoire)\n if unit is None:\n probe = max(nums)\n if probe < 300:\n unit = \"hour\"\n elif probe >= 18000:\n unit = \"year\"\n else:\n return (None, None, None) # zone ambiguë : ne pas deviner\n\n lo, hi = _SALARY_BOUNDS[unit]\n vals = sorted(n for n in nums if lo <= n <= hi)\n if not vals:\n return (None, None, None)\n return (vals[0], vals[-1] if len(vals) > 1 else vals[0], unit)\n\n\n_TO_YEAR = {\"hour\": _HOURS_PER_YEAR, \"day\": 260.0, \"week\": 52.0,\n \"biweek\": 26.0, \"month\": 12.0, \"year\": 1.0}\n\n\ndef salary_to_yearly(value: float | None, unit: str | None) -> float | None:\n if value is None or unit not in _TO_YEAR:\n return None\n return round(value * _TO_YEAR[unit], 0)\n\n\ndef salary_to_hourly(value: float | None, unit: str | None) -> float | None:\n y = salary_to_yearly(value, unit)\n return round(y / _HOURS_PER_YEAR, 2) if y is not None else None\n\n\ndef sanitize_salary(lo: float | None, hi: float | None, unit: str | None\n ) -> tuple[float | None, float | None, str | None]:\n \"\"\"Bornes de plausibilité sur les salaires STRUCTURÉS fournis par les ATS.\n\n Certaines sources étiquettent mal l'unité (75 000 « /mois » ou « /h » pour\n un salaire annuel — vu chez Lever et dans les JSON-LD de portails) : si la\n valeur sort des bornes de son unité, on tente une réinterprétation vers\n l'unité dont les bornes la contiennent (année d'abord, puis heure). Aucune\n unité plausible -> on jette (pas de salaire aberrant publié).\n \"\"\"\n if lo is None or unit not in _SALARY_BOUNDS:\n return (lo, hi, unit)\n b = _SALARY_BOUNDS[unit]\n if not (b[0] <= lo <= b[1]):\n for candidate in (\"year\", \"hour\"):\n cb = _SALARY_BOUNDS[candidate]\n if candidate != unit and cb[0] <= lo <= cb[1]:\n unit, b = candidate, cb\n break\n else:\n return (None, None, None)\n if hi is not None and not (b[0] <= hi <= b[1]):\n hi = None # max aberrant : on garde le min plausible seul\n if hi is not None and hi < lo:\n lo, hi = hi, lo\n return (lo, hi, unit)\n\n\n# Contextes où un montant en $ n'est PAS un salaire (financement, dépenses,\n# allocations…) — garde-fou de l'extraction depuis la description.\n_NOT_SALARY_CONTEXT = re.compile(\n r\"financement|funding|amorcage|seed|investisse|invest(?:ment|or)|\"\n r\"revenu[s]?\\b|revenue|chiffre d affaires|budget|remboursement|\"\n r\"reimburse|expense|allocation|allowance|subvention|credit d impot|\"\n r\"cotisation|rabais|discount|gift|carte[- ]cadeau|referral (?:bonus|fee)|\"\n r\"signing bonus|prime (?:a l embauche|de demarrage|de reference)|\"\n r\"million|milliard\")\n\n\ndef salary_from_text(text: str) -> tuple[float | None, float | None,\n str | None, str]:\n \"\"\"Cherche une mention salariale dans un texte libre (description).\n\n Découpe en phrases (les points décimaux « 182,500.00 » ne coupent pas),\n ignore les contextes non salariaux (financement, remboursements…), puis\n parse la première phrase plausible. Retourne (min, max, unité, libellé).\n \"\"\"\n if not text or \"$\" not in text:\n return (None, None, None, \"\")\n for sentence in re.split(r\"(? str | None:\n \"\"\"Valeur de date hétéroclite -> ISO « YYYY-MM-DD », ou None.\n\n Accepte : ISO (+ horodatages), epoch secondes/millisecondes, formats\n textuels FR/EN (« 1er juillet 2026 », « July 1st, 2026 »), et les dates\n relatives (« il y a 3 jours », « Posted 30+ Days Ago »).\n \"\"\"\n if raw is None:\n return None\n today = today or _dt.date.today()\n\n if isinstance(raw, (int, float)): # epoch (Lever : millisecondes)\n ts = float(raw)\n if ts > 1e12:\n ts /= 1000.0\n if 1e9 <= ts <= 4e9:\n return _dt.date.fromtimestamp(ts).isoformat()\n return None\n\n s = clean_text(str(raw))\n if not s:\n return None\n key = _key(s)\n\n m = re.search(r\"(\\d{4})-(\\d{2})-(\\d{2})\", s) # ISO (avec ou sans heure)\n if m:\n try:\n return _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3))).isoformat()\n except ValueError:\n return None\n\n if _TODAY_RE.search(key):\n return today.isoformat()\n if _YESTERDAY_RE.search(key):\n return (today - _dt.timedelta(days=1)).isoformat()\n\n m = _REL_RE.search(key)\n if m:\n n = int(m.group(1) or m.group(3))\n unit = (m.group(2) or m.group(4) or \"\").lower()\n days = n * (7 if unit.startswith((\"sem\", \"week\"))\n else 30 if unit.startswith((\"moi\", \"month\")) else 1)\n return (today - _dt.timedelta(days=days)).isoformat()\n\n # « 1er juillet 2026 » / « July 1st, 2026 » / « déc. 2026 »\n m = re.search(r\"(?:(\\d{1,2})(?:er|e|st|nd|rd|th)?\\s+)?([a-z]{3,9})\\.?,?\\s+(?:(\\d{1,2})(?:er|st|nd|rd|th)?,?\\s+)?(\\d{4})\", key)\n if m:\n mois = _MONTHS.get(m.group(2))\n if mois:\n jour = int(m.group(1) or m.group(3) or 1)\n try:\n return _dt.date(int(m.group(4)), mois, jour).isoformat()\n except ValueError:\n return None\n\n m = re.fullmatch(r\"(\\d{1,2})/(\\d{1,2})/(\\d{4})\", s) # jj/mm/aaaa (usage QC)\n if m:\n d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))\n if mo > 12 and d <= 12: # en fait mm/jj/aaaa\n d, mo = mo, d\n try:\n return _dt.date(y, mo, d).isoformat()\n except ValueError:\n return None\n return None\n\n\n# ---------------------------------------------------------------------------\n# Mode de travail / type d'emploi\n# ---------------------------------------------------------------------------\n\n_MODE_RULES = [\n (\"teletravail\", r\"100\\s*%\\s*(?:teletravail|remote)|full(?:y)?[ -]remote|\"\n r\"teletravail|travail a distance|remote(?:\\b|-first)|a distance\"),\n (\"hybride\", r\"hybrid|hybride|flexible?\\s*(?:work|travail)\"),\n (\"presentiel\", r\"presentiel|sur (?:place|site)|on[- ]?site|in[- ]?(?:office|person)\"),\n]\n\n\ndef parse_work_mode(raw: str) -> str | None:\n key = _key(raw)\n if not key:\n return None\n # « hybride » prime sur les mentions accessoires de télétravail\n if re.search(_MODE_RULES[1][1], key):\n return \"hybride\"\n for mode, pat in _MODE_RULES:\n if re.search(pat, key):\n return mode\n return None\n\n\n_TYPE_RULES = [\n (\"stage\", r\"\\bstage\\b|stagiaire|intern(?:ship)?\\b|co-?op\\b|alternance\"),\n (\"saisonnier\", r\"saisonn|seasonal\"),\n (\"contractuel\", r\"contrat\\b|contract(?:uel|or)?\\b|temporaire|temporary|terme|\"\n r\"fixed[- ]term|duree determinee|pigiste|freelance|consultant\"),\n (\"temps_partiel\", r\"temps[- ]partiel|part[- ]?time|partiel\\b\"),\n (\"temps_plein\", r\"temps[- ]plein|full[- ]?time|permanent|regulier|\"\n r\"regular\\b|duree indeterminee\"),\n]\n\n\ndef parse_employment_type(raw: str) -> str | None:\n key = _key(raw)\n if not key:\n return None\n for typ, pat in _TYPE_RULES:\n if re.search(pat, key):\n return typ\n return None\n\n\n# ---------------------------------------------------------------------------\n# Lieu — extraction ville/région + garde-fou « offre au Québec »\n# ---------------------------------------------------------------------------\n\n# Villes québécoises fréquentes dans les libellés d'offres (clé sans accents).\n# Sert à la fois au parsing et au filtre is_quebec_location.\n_QC_CITIES = {\n \"montreal\": \"Montréal\", \"quebec\": \"Québec\", \"ville de quebec\": \"Québec\",\n \"quebec city\": \"Québec\", \"laval\": \"Laval\", \"gatineau\": \"Gatineau\",\n \"longueuil\": \"Longueuil\", \"sherbrooke\": \"Sherbrooke\",\n \"saguenay\": \"Saguenay\", \"chicoutimi\": \"Saguenay\",\n \"levis\": \"Lévis\", \"trois-rivieres\": \"Trois-Rivières\",\n \"trois rivieres\": \"Trois-Rivières\", \"terrebonne\": \"Terrebonne\",\n \"brossard\": \"Brossard\", \"repentigny\": \"Repentigny\",\n \"boucherville\": \"Boucherville\", \"saint-jerome\": \"Saint-Jérôme\",\n \"drummondville\": \"Drummondville\", \"saint-jean-sur-richelieu\":\n \"Saint-Jean-sur-Richelieu\", \"granby\": \"Granby\", \"shawinigan\": \"Shawinigan\",\n \"joliette\": \"Joliette\", \"victoriaville\": \"Victoriaville\",\n \"rimouski\": \"Rimouski\", \"rouyn-noranda\": \"Rouyn-Noranda\",\n \"baie-comeau\": \"Baie-Comeau\", \"sept-iles\": \"Sept-Îles\",\n \"val-d'or\": \"Val-d'Or\", \"val d'or\": \"Val-d'Or\", \"alma\": \"Alma\",\n \"sainte-foy\": \"Québec\", \"anjou\": \"Montréal\", \"dorval\": \"Dorval\",\n \"boisbriand\": \"Boisbriand\", \"blainville\": \"Blainville\",\n \"mirabel\": \"Mirabel\", \"saint-hyacinthe\": \"Saint-Hyacinthe\",\n \"beloeil\": \"Belœil\", \"chateauguay\": \"Châteauguay\", \"magog\": \"Magog\",\n \"thetford mines\": \"Thetford Mines\", \"saint-georges\": \"Saint-Georges\",\n \"riviere-du-loup\": \"Rivière-du-Loup\", \"matane\": \"Matane\",\n \"sorel-tracy\": \"Sorel-Tracy\", \"varennes\": \"Varennes\",\n \"vaudreuil-dorion\": \"Vaudreuil-Dorion\", \"salaberry-de-valleyfield\":\n \"Salaberry-de-Valleyfield\", \"kirkland\": \"Kirkland\",\n \"pointe-claire\": \"Pointe-Claire\", \"saint-laurent\": \"Montréal\",\n \"ville saint-laurent\": \"Montréal\", \"verdun\": \"Montréal\",\n \"westmount\": \"Westmount\", \"candiac\": \"Candiac\", \"la prairie\": \"La Prairie\",\n \"mont-tremblant\": \"Mont-Tremblant\", \"saint-bruno\": \"Saint-Bruno-de-Montarville\",\n \"bromont\": \"Bromont\", \"cowansville\": \"Cowansville\",\n \"saint-augustin-de-desmaures\": \"Saint-Augustin-de-Desmaures\",\n \"l'ancienne-lorette\": \"L'Ancienne-Lorette\",\n}\n\n_QC_MARKERS = re.compile(\n r\"\\bqc\\b|\\bquebec\\b|\\bqu[e]bec, canada\\b|province de quebec\", re.I)\n_POSTAL_RE = re.compile(r\"\\b([GHJ]\\d[A-Z])\\s*(\\d[A-Z]\\d)\\b\", re.I) # G/H/J = Québec\n\n# Villes canadiennes hors Québec (et grandes villes étrangères) fréquentes\n# dans les flux pancanadiens/mondiaux — jamais des villes d'offres\n# québécoises : à rejeter lors du parsing de lieu (clés sans accents).\n_NON_QC_CITIES = frozenset({\n \"toronto\", \"ottawa\", \"vancouver\", \"calgary\", \"edmonton\", \"winnipeg\",\n \"mississauga\", \"brampton\", \"hamilton\", \"kitchener\", \"waterloo\", \"halifax\",\n \"dartmouth\", \"victoria\", \"saskatoon\", \"regina\", \"st. john's\", \"st johns\",\n \"saint john\", \"fredericton\", \"moncton\", \"charlottetown\", \"london\",\n \"markham\", \"vaughan\", \"richmond hill\", \"oakville\", \"burlington\", \"oshawa\",\n \"whitby\", \"ajax\", \"pickering\", \"guelph\", \"kingston\", \"barrie\",\n \"kelowna\", \"kamloops\", \"nanaimo\", \"abbotsford\", \"surrey\", \"burnaby\",\n \"coquitlam\", \"langley\", \"north york\", \"scarborough\", \"etobicoke\",\n \"north vancouver\", \"milton\", \"cambridge\", \"st. catharines\",\n \"niagara falls\", \"thunder bay\", \"sudbury\", \"greater sudbury\",\n \"sault ste. marie\", \"red deer\", \"lethbridge\", \"medicine hat\",\n \"grande prairie\", \"fort mcmurray\", \"yellowknife\", \"whitehorse\", \"iqaluit\",\n \"can\", \"canada\", \"ontario\", \"alberta\", \"british columbia\", \"manitoba\",\n \"saskatchewan\", \"nova scotia\", \"new brunswick\",\n \"new york\", \"new york city\", \"san francisco\", \"boston\", \"chicago\",\n \"seattle\", \"austin\", \"denver\", \"los angeles\", \"miami\", \"atlanta\",\n \"dallas\", \"houston\", \"philadelphia\", \"washington\", \"phoenix\", \"portland\",\n \"paris\", \"londres\", \"amsterdam\", \"berlin\", \"munich\", \"dublin\", \"madrid\",\n \"barcelona\", \"barcelone\", \"lisbon\", \"lisbonne\", \"sydney\", \"melbourne\",\n \"singapore\", \"singapour\", \"tokyo\", \"bangalore\", \"bengaluru\", \"mumbai\",\n \"mexico city\", \"sao paulo\", \"bogota\", \"buenos aires\", \"las vegas\",\n \"orlando\", \"nashville\", \"macau\",\n})\n\n\ndef canonical_city(raw: str) -> str:\n \"\"\"Canonicalise un nom de ville (« Montreal » -> « Montréal »,\n « Quebec City » -> « Québec »). Inconnu : retourne le texte nettoyé.\"\"\"\n s = clean_text(raw)\n if not s:\n return \"\"\n key = _key(s)\n exact = _QC_CITIES.get(key)\n if exact:\n return exact\n for ck, city in _QC_CITIES.items():\n if re.fullmatch(re.escape(ck), key):\n return city\n return s\n\n\ndef parse_location(raw: str) -> dict:\n \"\"\"Libellé de lieu -> {city, region, postal_code} (champs possiblement vides).\n\n Ex. « Montréal, QC, Canada », « Ville de Québec, Québec », « Remote - QC ».\n \"\"\"\n s = clean_text(raw)\n out = {\"city\": \"\", \"region\": \"\", \"postal_code\": \"\"}\n if not s:\n return out\n m = _POSTAL_RE.search(s)\n if m:\n out[\"postal_code\"] = f\"{m.group(1).upper()} {m.group(2).upper()}\"\n key = _key(s)\n for ck, city in _QC_CITIES.items():\n if re.search(rf\"(? bool:\n \"\"\"Vrai si le libellé pointe vers le Québec (filtre des ATS pancanadiens).\"\"\"\n key = _key(clean_text(raw))\n if not key:\n return False\n if key in _NON_QC_CITIES: # libellé = ville canadienne/monde hors QC\n return False\n if _QC_MARKERS.search(key):\n return True\n m = _POSTAL_RE.search(raw)\n if m and m.group(1)[0].upper() in \"GHJ\":\n return True\n return any(re.search(rf\"(? str:\n \"\"\"Langue du texte de l'offre : « fr », « en », « bilingue », ou \"\".\n\n Comptage de mots-outils sur le début du texte — déterministe, jamais de\n valeur inventée : texte trop court ou ambigu -> \"\".\n \"\"\"\n text = _key(f\"{title}\\n{description or ''}\"[:4000])\n if len(text) < 30:\n return \"\"\n fr = len(_FR_STOPWORDS.findall(text))\n en = len(_EN_STOPWORDS.findall(text))\n total = fr + en\n if total < 5:\n return \"\"\n # les deux langues nettement présentes -> offre bilingue\n if fr >= 8 and en >= 8 and min(fr, en) / max(fr, en) >= 0.35:\n return \"bilingue\"\n if fr > en:\n return \"fr\"\n if en > fr:\n return \"en\"\n return \"\"\n\n\n# ---------------------------------------------------------------------------\n# Avantages — extraction des blocs « Avantages / Benefits » des descriptions\n# ---------------------------------------------------------------------------\n\n_BENEFITS_HEADING = re.compile(\n r\"^\\s*(?:les\\s+|nos\\s+|our\\s+|tes\\s+|vos\\s+)?(?:avantages(?:\\s+sociaux)?|\"\n r\"benefits(?:\\s+&\\s+perks)?|perks(?:\\s+(?:and|&)\\s+benefits)?|\"\n r\"ce que nous (?:t')?offrons|what we offer|why join us|\"\n r\"pourquoi nous rejoindre|nous offrons|we offer)\\s*:?\\s*$\", re.I)\n_BENEFIT_LINE_MAX = 120\n\n\ndef extract_benefits(description: str) -> list[str]:\n \"\"\"Liste d'avantages depuis un bloc structuré de la description.\n\n Cherche un titre de section « Avantages/Benefits/… » seul sur sa ligne,\n puis prend les lignes courtes qui suivent (puces), en s'arrêtant à la\n première ligne longue ou au prochain titre. Max 12 items — si le bloc ne\n ressemble pas à une liste, on ne retourne rien (pas d'à-peu-près).\n \"\"\"\n if not description:\n return []\n lines = description.split(\"\\n\")\n for i, line in enumerate(lines):\n if not _BENEFITS_HEADING.match(strip_accents(line)):\n continue\n items: list[str] = []\n for nxt in lines[i + 1:]:\n t = clean_text(nxt).lstrip(\"•-–—*·◦▪ \").strip()\n if not t:\n if items:\n break # fin du bloc (ligne vide après les puces)\n continue\n if len(t) > _BENEFIT_LINE_MAX or _BENEFITS_HEADING.match(\n strip_accents(t)):\n break\n # un « titre » de section suivant (courte ligne sans ponctuation\n # finale, capitalisée) termine aussi le bloc\n if items and len(t.split()) <= 4 and t.endswith(\":\"):\n break\n items.append(t.rstrip(\" ;,.\").strip())\n if len(items) >= 12:\n break\n if len(items) >= 2:\n return items\n return []\n\n\n# ---------------------------------------------------------------------------\n# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés\n# ---------------------------------------------------------------------------\n\n# codes de réquisition : « [R-12345] », « (REQ 2026-42) », « - JR0035678 »…\n_REQ_CODE = re.compile(\n # en tête : « 123456 - Titre », « [REQ-1234] Titre » (≥5 chiffres nus pour\n # ne pas manger une année « 2026 - Programme d'été »)\n r\"^\\s*[\\[(]?(?:req(?:uisition)?|jr|job)[\\s#:-]*\\d{3,10}[\\])]?\\s*[-–—:|]\\s*|\"\n r\"^\\s*[\\[(]?\\d{5,10}[\\])]?\\s*[-–—:|]\\s*|\"\n # en queue : « Titre - R-12345 », « Titre (JOB 004521) »\n r\"\\s*[-–—|(]\\s*(?:req(?:uisition)?|job|poste)?\\s*#?\\s*(?:[A-Z]{1,4}-?)?\\d{4,10}\\s*[)\\]]?\\s*$|\"\n # au milieu : « Titre [R-12345] suite »\n r\"\\s*[\\[(](?:R|REQ|JR|JOB)[-_ ]?\\d{3,10}[\\])]\\s*\", re.I)\n\n# petits mots gardés en minuscules lors de la remise en casse d'un titre\n# tout-majuscules ; sigles usuels gardés en majuscules\n_TITLE_SMALL = {\"de\", \"du\", \"des\", \"la\", \"le\", \"les\", \"et\", \"en\", \"à\", \"a\",\n \"au\", \"aux\", \"d'\", \"l'\", \"of\", \"the\", \"and\", \"in\", \"for\",\n \"to\", \"or\", \"ou\", \"un\", \"une\", \"sur\", \"pour\"}\n_TITLE_ACRONYMS = {\"rh\", \"ti\", \"it\", \"qa\", \"qc\", \"pab\", \"cpa\", \"cfa\", \"erp\",\n \"sap\", \"crm\", \"sst\", \"cnc\", \"hvac\", \"rpa\", \"bi\", \"ia\",\n \"ai\", \"vp\", \"pdg\", \"dg\"}\n\n\ndef _smart_case(title: str) -> str:\n \"\"\"Titre TOUT EN MAJUSCULES -> casse de phrase lisible (sigles préservés).\"\"\"\n words = title.lower().split()\n out = []\n for i, w in enumerate(words):\n base = w.strip(\"()[],.;:/\")\n if base in _TITLE_ACRONYMS:\n out.append(w.replace(base, base.upper()))\n elif i > 0 and base in _TITLE_SMALL:\n out.append(w)\n else:\n out.append(w[:1].upper() + w[1:])\n return \" \".join(out)\n\n\ndef clean_title(title: str, city: str = \"\") -> str:\n \"\"\"Titre d'affichage : codes de réquisition retirés, suffixe « - »\n redondant retiré, casse corrigée si tout-majuscules. Le titre SOURCE n'est\n jamais modifié — ceci alimente une colonne d'affichage distincte.\"\"\"\n t = clean_text(title)\n if not t:\n return \"\"\n prev = None\n while prev != t: # les codes peuvent être empilés\n prev = t\n t = _REQ_CODE.sub(\" \", t).strip(\" -–—|:\")\n t = clean_text(t)\n # suffixe « - Montréal » / « (Montréal, QC) » identique à la ville de\n # l'offre : repéré sans accents, coupé sur le titre original\n if city:\n ck = re.escape(strip_accents(city.lower()))\n m = re.search(\n rf\"\\s*[-–—|,(]\\s*(?:ville de\\s+)?{ck}(?:\\s*,\\s*(?:qc|quebec))?\\s*\\)?\\s*$\",\n strip_accents(t.lower()))\n if m and m.start() > 0:\n t = clean_text(t[:m.start()]).strip(\" -–—|,(\")\n if len(t) > 8 and t == t.upper() and t != t.lower():\n t = _smart_case(t)\n return t or clean_text(title)\n\n\ndef categorize(title: str, description: str = \"\") -> str:\n \"\"\"Titre -> catégorie interne (repli : début de description), ou \"\".\n\n Le titre est essayé SEUL d'abord : la description commence souvent par le\n boilerplate de l'entreprise (« leader du manufacturier… ») qui fausserait\n la catégorie du poste.\n \"\"\"\n for texte in (title, description[:300]):\n key = _key(texte or \"\")\n if not key.strip():\n continue\n for cat, pat in _CATEGORY_RULES:\n if re.search(pat, key):\n return cat\n return \"\"\n","structuredPatch":[{"oldStart":26,"oldLines":7,"newStart":26,"newLines":8,"lines":[" \"salary_to_hourly\", \"parse_date\", \"parse_work_mode\","," \"parse_employment_type\", \"parse_location\", \"canonical_city\","," \"is_quebec_location\", \"categorize\", \"detect_language\",","- \"extract_benefits\", \"clean_title\",","+ \"extract_benefits\", \"clean_title\", \"extract_requirements\",","+ \"parse_seniority\","," ]"," "," "]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d63f58b0-2af9-4c1f-9571-d1092f4677a2","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePc8jW8GJ3AF7KyaSQEx","type":"message","role":"assistant","content":[{"type":"text","text":"Maintenant l'extension des en-têtes d'avantages et les deux nouveaux extracteurs :"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5558,"cache_read_input_tokens":114328,"cache_creation":{"ephemeral_5m_input_tokens":5558,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"18667026-bf26-40c0-9d13-9a88ce9fbc8b","request_id":"req_011CePc8b1eR5JyPke87tEaw"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePc8jW8GJ3AF7KyaSQEx","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018pj3eKjqjuWVXaEArdgdwq","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py","old_string":"_BENEFITS_HEADING = re.compile(\n r\"^\\s*(?:les\\s+|nos\\s+|our\\s+|tes\\s+|vos\\s+)?(?:avantages(?:\\s+sociaux)?|\"\n r\"benefits(?:\\s+&\\s+perks)?|perks(?:\\s+(?:and|&)\\s+benefits)?|\"\n r\"ce que nous (?:t')?offrons|what we offer|why join us|\"\n r\"pourquoi nous rejoindre|nous offrons|we offer)\\s*:?\\s*$\", re.I)\n_BENEFIT_LINE_MAX = 120","new_string":"_BENEFITS_HEADING = re.compile(\n r\"^\\s*(?:les\\s+|nos\\s+|our\\s+|tes\\s+|vos\\s+)?(?:avantages(?:\\s+sociaux|\"\n r\"\\s+offerts|\\s+et\\s+conditions)?|\"\n r\"benefits(?:\\s+&\\s+perks|\\s+offered)?|perks(?:\\s+(?:and|&)\\s+benefits)?|\"\n r\"ce que (?:nous|l'on|on) (?:t'|te\\s+|vous\\s+)?offr(?:ons|e)|\"\n r\"nous (?:t'|te\\s+|vous\\s+)?offrons|on (?:t'|te\\s+|vous\\s+)offre|\"\n r\"what we offer|what'?s in it for you|why join us|why work (?:with|for) us|\"\n r\"pourquoi (?:nous rejoindre|nous choisir|te joindre a nous|\"\n r\"vous joindre a nous|travailler (?:chez|avec) nous)|we offer)\"\n r\"\\s*:?\\s*$\", re.I)\n_BENEFIT_LINE_MAX = 120"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5558,"cache_read_input_tokens":114328,"cache_creation":{"ephemeral_5m_input_tokens":5558,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"2878c88d-dbec-4d3f-b83f-05a10cdb84b0","request_id":"req_011CePc8b1eR5JyPke87tEaw"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018pj3eKjqjuWVXaEArdgdwq","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"b164080c-1f30-4d7b-af7a-e36cd17f9b96","timestamp":"2026-08-25T14:14:59.247Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py","oldString":"_BENEFITS_HEADING = re.compile(\n r\"^\\s*(?:les\\s+|nos\\s+|our\\s+|tes\\s+|vos\\s+)?(?:avantages(?:\\s+sociaux)?|\"\n r\"benefits(?:\\s+&\\s+perks)?|perks(?:\\s+(?:and|&)\\s+benefits)?|\"\n r\"ce que nous (?:t')?offrons|what we offer|why join us|\"\n r\"pourquoi nous rejoindre|nous offrons|we offer)\\s*:?\\s*$\", re.I)\n_BENEFIT_LINE_MAX = 120","newString":"_BENEFITS_HEADING = re.compile(\n r\"^\\s*(?:les\\s+|nos\\s+|our\\s+|tes\\s+|vos\\s+)?(?:avantages(?:\\s+sociaux|\"\n r\"\\s+offerts|\\s+et\\s+conditions)?|\"\n r\"benefits(?:\\s+&\\s+perks|\\s+offered)?|perks(?:\\s+(?:and|&)\\s+benefits)?|\"\n r\"ce que (?:nous|l'on|on) (?:t'|te\\s+|vous\\s+)?offr(?:ons|e)|\"\n r\"nous (?:t'|te\\s+|vous\\s+)?offrons|on (?:t'|te\\s+|vous\\s+)offre|\"\n r\"what we offer|what'?s in it for you|why join us|why work (?:with|for) us|\"\n r\"pourquoi (?:nous rejoindre|nous choisir|te joindre a nous|\"\n r\"vous joindre a nous|travailler (?:chez|avec) nous)|we offer)\"\n r\"\\s*:?\\s*$\", re.I)\n_BENEFIT_LINE_MAX = 120","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/normalize.py\n# Rôle : Normalisation commune des offres (salaires, dates, mode/type\n# d'emploi, lieu, catégorie, exigences, séniorité) — jamais de\n# valeur inventée\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\n\"\"\"Couche de normalisation commune appelée par JobPosting.finalize().\n\nPhilosophie héritée de Lou·Ka : parseurs déterministes (regex, tables de\nmots-clés FR/EN), bornes de plausibilité, et surtout — une valeur absente\nreste absente (None / chaîne vide), on ne devine jamais.\n\"\"\"\nfrom __future__ import annotations\n\nimport datetime as _dt\nimport re\nimport unicodedata\n\n__all__ = [\n \"strip_accents\", \"clean_text\", \"clean_html\", \"parse_salary\",\n \"sanitize_salary\", \"salary_from_text\", \"salary_to_yearly\",\n \"salary_to_hourly\", \"parse_date\", \"parse_work_mode\",\n \"parse_employment_type\", \"parse_location\", \"canonical_city\",\n \"is_quebec_location\", \"categorize\", \"detect_language\",\n \"extract_benefits\", \"clean_title\", \"extract_requirements\",\n \"parse_seniority\",\n]\n\n\n# ---------------------------------------------------------------------------\n# Texte\n# ---------------------------------------------------------------------------\n\ndef strip_accents(s: str) -> str:\n return \"\".join(c for c in unicodedata.normalize(\"NFD\", s)\n if unicodedata.category(c) != \"Mn\")\n\n\ndef _key(s: str) -> str:\n \"\"\"Minuscules sans accents — pour le matching de mots-clés.\"\"\"\n return strip_accents((s or \"\").lower())\n\n\ndef clean_text(s: str) -> str:\n \"\"\"Espaces insécables -> espace, blancs réduits.\"\"\"\n s = (s or \"\").replace(\"\\xa0\", \" \").replace(\" \", \" \").replace(\" \", \" \")\n return re.sub(r\"[ \\t]+\", \" \", s).strip()\n\n\n_BLOCK_TAGS = re.compile(\n r\"]*>\", re.I)\n\n# Résidu HTML après une passe : balise ouvrante plausible ou entité encore\n# encodée — signe d'un contenu doublement échappé.\n_HTML_LEFTOVER = re.compile(\n r\"<(?:p|br|li|ul|ol|div|span|strong|em|b|i|h[1-6]|a)\\b|&(?:amp|lt|gt|nbsp|#\\d+);\",\n re.I)\n\n\ndef clean_html(html: str) -> str:\n \"\"\"HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré.\n\n Suffisant pour les descriptions d'offres (les connecteurs qui ont besoin\n d'un vrai parseur utilisent BeautifulSoup eux-mêmes).\n \"\"\"\n if not html:\n return \"\"\n import html as _html\n s = html\n # Contenus sur-échappés (entités dans les entités — Greenhouse/Lever) :\n # nettoyer en boucle tant qu'il reste des balises/entités (max 3 passes).\n for _ in range(3):\n s = _html.unescape(s)\n s = re.sub(r\"<(script|style)[^>]*>.*?\", \" \", s, flags=re.I | re.S)\n s = _BLOCK_TAGS.sub(\"\\n\", s)\n s = re.sub(r\"<[^>]+>\", \" \", s)\n if not _HTML_LEFTOVER.search(s):\n break\n s = s.replace(\"\\xa0\", \" \").replace(\" \", \" \")\n s = re.sub(r\"[ \\t]+\", \" \", s)\n s = re.sub(r\" ?\\n ?\", \"\\n\", s)\n return re.sub(r\"\\n{3,}\", \"\\n\\n\", s).strip()\n\n\n# ---------------------------------------------------------------------------\n# Salaires — transparence salariale = différenciateur clé de Job·Ka\n# ---------------------------------------------------------------------------\n\n# Bornes de plausibilité par unité (CAD). Hors bornes = on jette (pas de\n# salaire inventé, pas de « 1 $/h » venu d'un parsing raté).\n_SALARY_BOUNDS = {\n \"hour\": (14.0, 250.0),\n \"day\": (100.0, 2000.0),\n \"week\": (500.0, 10000.0),\n \"biweek\": (1000.0, 20000.0),\n \"month\": (1800.0, 45000.0),\n \"year\": (20000.0, 600000.0),\n}\n\n# Heures/an de référence pour les conversions (semaine de 40 h).\n_HOURS_PER_YEAR = 2080.0\n\n_UNIT_PATTERNS = [\n (\"hour\", r\"(?:/|par|per|an?)\\s*(?:h\\b|hr\\b|hre\\b|heure|hour)|horaire|hourly\"),\n (\"year\", r\"(?:/|par|per|an?)\\s*(?:an\\b|année|annee|year|yr\\b)|annuel|annual|yearly|per\\s+annum\"),\n (\"month\", r\"(?:/|par|per)\\s*(?:mois|month)|mensuel|monthly\"),\n (\"biweek\", r\"(?:aux|par|per)\\s*(?:2|deux|two)\\s*semaines|bi-?weekly|quinzaine\"),\n (\"week\", r\"(?:/|par|per)\\s*(?:sem\\b|semaine|week|wk\\b)|hebdomadaire|weekly\"),\n (\"day\", r\"(?:/|par|per)\\s*(?:jour|day)|journalier|daily\"),\n]\n\n_NUM_RE = re.compile(r\"\\d{1,3}(?:[   ,]\\d{3})+(?:[.,]\\d{1,2})?|\\d+(?:[.,]\\d{1,2})?[kK]?\")\n\n\ndef _parse_number(tok: str) -> float | None:\n tok = tok.strip()\n k = tok.lower().endswith(\"k\")\n if k:\n tok = tok[:-1]\n # « 52,000 » (milliers anglais) vs « 52000,00 » / « 25,50 » (décimales)\n tok = tok.replace(\" \", \" \").replace(\" \", \" \")\n if re.fullmatch(r\"\\d{1,3}(?:[ ,]\\d{3})+(?:[.,]\\d{1,2})?\", tok):\n # séparateur de milliers : retirer espaces/virgules de groupe\n tok = re.sub(r\"[ ,](?=\\d{3}\\b)\", \"\", tok)\n tok = tok.replace(\",\", \".\").replace(\" \", \"\")\n try:\n v = float(tok)\n except ValueError:\n return None\n return v * 1000 if k else v\n\n\ndef parse_salary(raw: str) -> tuple[float | None, float | None, str | None]:\n \"\"\"Texte de salaire -> (min, max, unité) ou (None, None, None).\n\n Exige un signe monétaire ($, CAD, dollars) OU une unité explicite proche\n des nombres — sans quoi « 35 heures/semaine » deviendrait un salaire.\n \"\"\"\n s = clean_text(raw)\n if not s:\n return (None, None, None)\n key = _key(s)\n\n unit = None\n for u, pat in _UNIT_PATTERNS:\n if re.search(pat, key):\n unit = u\n break\n\n has_money = bool(re.search(r\"\\$|cad\\b|dollar\", key))\n if not has_money and unit is None:\n return (None, None, None)\n\n nums = [_parse_number(m.group(0)) for m in _NUM_RE.finditer(s)]\n nums = [n for n in nums if n is not None]\n if not nums:\n return (None, None, None)\n\n # Sans unité explicite : inférer par l'ordre de grandeur (avec $ obligatoire)\n if unit is None:\n probe = max(nums)\n if probe < 300:\n unit = \"hour\"\n elif probe >= 18000:\n unit = \"year\"\n else:\n return (None, None, None) # zone ambiguë : ne pas deviner\n\n lo, hi = _SALARY_BOUNDS[unit]\n vals = sorted(n for n in nums if lo <= n <= hi)\n if not vals:\n return (None, None, None)\n return (vals[0], vals[-1] if len(vals) > 1 else vals[0], unit)\n\n\n_TO_YEAR = {\"hour\": _HOURS_PER_YEAR, \"day\": 260.0, \"week\": 52.0,\n \"biweek\": 26.0, \"month\": 12.0, \"year\": 1.0}\n\n\ndef salary_to_yearly(value: float | None, unit: str | None) -> float | None:\n if value is None or unit not in _TO_YEAR:\n return None\n return round(value * _TO_YEAR[unit], 0)\n\n\ndef salary_to_hourly(value: float | None, unit: str | None) -> float | None:\n y = salary_to_yearly(value, unit)\n return round(y / _HOURS_PER_YEAR, 2) if y is not None else None\n\n\ndef sanitize_salary(lo: float | None, hi: float | None, unit: str | None\n ) -> tuple[float | None, float | None, str | None]:\n \"\"\"Bornes de plausibilité sur les salaires STRUCTURÉS fournis par les ATS.\n\n Certaines sources étiquettent mal l'unité (75 000 « /mois » ou « /h » pour\n un salaire annuel — vu chez Lever et dans les JSON-LD de portails) : si la\n valeur sort des bornes de son unité, on tente une réinterprétation vers\n l'unité dont les bornes la contiennent (année d'abord, puis heure). Aucune\n unité plausible -> on jette (pas de salaire aberrant publié).\n \"\"\"\n if lo is None or unit not in _SALARY_BOUNDS:\n return (lo, hi, unit)\n b = _SALARY_BOUNDS[unit]\n if not (b[0] <= lo <= b[1]):\n for candidate in (\"year\", \"hour\"):\n cb = _SALARY_BOUNDS[candidate]\n if candidate != unit and cb[0] <= lo <= cb[1]:\n unit, b = candidate, cb\n break\n else:\n return (None, None, None)\n if hi is not None and not (b[0] <= hi <= b[1]):\n hi = None # max aberrant : on garde le min plausible seul\n if hi is not None and hi < lo:\n lo, hi = hi, lo\n return (lo, hi, unit)\n\n\n# Contextes où un montant en $ n'est PAS un salaire (financement, dépenses,\n# allocations…) — garde-fou de l'extraction depuis la description.\n_NOT_SALARY_CONTEXT = re.compile(\n r\"financement|funding|amorcage|seed|investisse|invest(?:ment|or)|\"\n r\"revenu[s]?\\b|revenue|chiffre d affaires|budget|remboursement|\"\n r\"reimburse|expense|allocation|allowance|subvention|credit d impot|\"\n r\"cotisation|rabais|discount|gift|carte[- ]cadeau|referral (?:bonus|fee)|\"\n r\"signing bonus|prime (?:a l embauche|de demarrage|de reference)|\"\n r\"million|milliard\")\n\n\ndef salary_from_text(text: str) -> tuple[float | None, float | None,\n str | None, str]:\n \"\"\"Cherche une mention salariale dans un texte libre (description).\n\n Découpe en phrases (les points décimaux « 182,500.00 » ne coupent pas),\n ignore les contextes non salariaux (financement, remboursements…), puis\n parse la première phrase plausible. Retourne (min, max, unité, libellé).\n \"\"\"\n if not text or \"$\" not in text:\n return (None, None, None, \"\")\n for sentence in re.split(r\"(? str | None:\n \"\"\"Valeur de date hétéroclite -> ISO « YYYY-MM-DD », ou None.\n\n Accepte : ISO (+ horodatages), epoch secondes/millisecondes, formats\n textuels FR/EN (« 1er juillet 2026 », « July 1st, 2026 »), et les dates\n relatives (« il y a 3 jours », « Posted 30+ Days Ago »).\n \"\"\"\n if raw is None:\n return None\n today = today or _dt.date.today()\n\n if isinstance(raw, (int, float)): # epoch (Lever : millisecondes)\n ts = float(raw)\n if ts > 1e12:\n ts /= 1000.0\n if 1e9 <= ts <= 4e9:\n return _dt.date.fromtimestamp(ts).isoformat()\n return None\n\n s = clean_text(str(raw))\n if not s:\n return None\n key = _key(s)\n\n m = re.search(r\"(\\d{4})-(\\d{2})-(\\d{2})\", s) # ISO (avec ou sans heure)\n if m:\n try:\n return _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3))).isoformat()\n except ValueError:\n return None\n\n if _TODAY_RE.search(key):\n return today.isoformat()\n if _YESTERDAY_RE.search(key):\n return (today - _dt.timedelta(days=1)).isoformat()\n\n m = _REL_RE.search(key)\n if m:\n n = int(m.group(1) or m.group(3))\n unit = (m.group(2) or m.group(4) or \"\").lower()\n days = n * (7 if unit.startswith((\"sem\", \"week\"))\n else 30 if unit.startswith((\"moi\", \"month\")) else 1)\n return (today - _dt.timedelta(days=days)).isoformat()\n\n # « 1er juillet 2026 » / « July 1st, 2026 » / « déc. 2026 »\n m = re.search(r\"(?:(\\d{1,2})(?:er|e|st|nd|rd|th)?\\s+)?([a-z]{3,9})\\.?,?\\s+(?:(\\d{1,2})(?:er|st|nd|rd|th)?,?\\s+)?(\\d{4})\", key)\n if m:\n mois = _MONTHS.get(m.group(2))\n if mois:\n jour = int(m.group(1) or m.group(3) or 1)\n try:\n return _dt.date(int(m.group(4)), mois, jour).isoformat()\n except ValueError:\n return None\n\n m = re.fullmatch(r\"(\\d{1,2})/(\\d{1,2})/(\\d{4})\", s) # jj/mm/aaaa (usage QC)\n if m:\n d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))\n if mo > 12 and d <= 12: # en fait mm/jj/aaaa\n d, mo = mo, d\n try:\n return _dt.date(y, mo, d).isoformat()\n except ValueError:\n return None\n return None\n\n\n# ---------------------------------------------------------------------------\n# Mode de travail / type d'emploi\n# ---------------------------------------------------------------------------\n\n_MODE_RULES = [\n (\"teletravail\", r\"100\\s*%\\s*(?:teletravail|remote)|full(?:y)?[ -]remote|\"\n r\"teletravail|travail a distance|remote(?:\\b|-first)|a distance\"),\n (\"hybride\", r\"hybrid|hybride|flexible?\\s*(?:work|travail)\"),\n (\"presentiel\", r\"presentiel|sur (?:place|site)|on[- ]?site|in[- ]?(?:office|person)\"),\n]\n\n\ndef parse_work_mode(raw: str) -> str | None:\n key = _key(raw)\n if not key:\n return None\n # « hybride » prime sur les mentions accessoires de télétravail\n if re.search(_MODE_RULES[1][1], key):\n return \"hybride\"\n for mode, pat in _MODE_RULES:\n if re.search(pat, key):\n return mode\n return None\n\n\n_TYPE_RULES = [\n (\"stage\", r\"\\bstage\\b|stagiaire|intern(?:ship)?\\b|co-?op\\b|alternance\"),\n (\"saisonnier\", r\"saisonn|seasonal\"),\n (\"contractuel\", r\"contrat\\b|contract(?:uel|or)?\\b|temporaire|temporary|terme|\"\n r\"fixed[- ]term|duree determinee|pigiste|freelance|consultant\"),\n (\"temps_partiel\", r\"temps[- ]partiel|part[- ]?time|partiel\\b\"),\n (\"temps_plein\", r\"temps[- ]plein|full[- ]?time|permanent|regulier|\"\n r\"regular\\b|duree indeterminee\"),\n]\n\n\ndef parse_employment_type(raw: str) -> str | None:\n key = _key(raw)\n if not key:\n return None\n for typ, pat in _TYPE_RULES:\n if re.search(pat, key):\n return typ\n return None\n\n\n# ---------------------------------------------------------------------------\n# Lieu — extraction ville/région + garde-fou « offre au Québec »\n# ---------------------------------------------------------------------------\n\n# Villes québécoises fréquentes dans les libellés d'offres (clé sans accents).\n# Sert à la fois au parsing et au filtre is_quebec_location.\n_QC_CITIES = {\n \"montreal\": \"Montréal\", \"quebec\": \"Québec\", \"ville de quebec\": \"Québec\",\n \"quebec city\": \"Québec\", \"laval\": \"Laval\", \"gatineau\": \"Gatineau\",\n \"longueuil\": \"Longueuil\", \"sherbrooke\": \"Sherbrooke\",\n \"saguenay\": \"Saguenay\", \"chicoutimi\": \"Saguenay\",\n \"levis\": \"Lévis\", \"trois-rivieres\": \"Trois-Rivières\",\n \"trois rivieres\": \"Trois-Rivières\", \"terrebonne\": \"Terrebonne\",\n \"brossard\": \"Brossard\", \"repentigny\": \"Repentigny\",\n \"boucherville\": \"Boucherville\", \"saint-jerome\": \"Saint-Jérôme\",\n \"drummondville\": \"Drummondville\", \"saint-jean-sur-richelieu\":\n \"Saint-Jean-sur-Richelieu\", \"granby\": \"Granby\", \"shawinigan\": \"Shawinigan\",\n \"joliette\": \"Joliette\", \"victoriaville\": \"Victoriaville\",\n \"rimouski\": \"Rimouski\", \"rouyn-noranda\": \"Rouyn-Noranda\",\n \"baie-comeau\": \"Baie-Comeau\", \"sept-iles\": \"Sept-Îles\",\n \"val-d'or\": \"Val-d'Or\", \"val d'or\": \"Val-d'Or\", \"alma\": \"Alma\",\n \"sainte-foy\": \"Québec\", \"anjou\": \"Montréal\", \"dorval\": \"Dorval\",\n \"boisbriand\": \"Boisbriand\", \"blainville\": \"Blainville\",\n \"mirabel\": \"Mirabel\", \"saint-hyacinthe\": \"Saint-Hyacinthe\",\n \"beloeil\": \"Belœil\", \"chateauguay\": \"Châteauguay\", \"magog\": \"Magog\",\n \"thetford mines\": \"Thetford Mines\", \"saint-georges\": \"Saint-Georges\",\n \"riviere-du-loup\": \"Rivière-du-Loup\", \"matane\": \"Matane\",\n \"sorel-tracy\": \"Sorel-Tracy\", \"varennes\": \"Varennes\",\n \"vaudreuil-dorion\": \"Vaudreuil-Dorion\", \"salaberry-de-valleyfield\":\n \"Salaberry-de-Valleyfield\", \"kirkland\": \"Kirkland\",\n \"pointe-claire\": \"Pointe-Claire\", \"saint-laurent\": \"Montréal\",\n \"ville saint-laurent\": \"Montréal\", \"verdun\": \"Montréal\",\n \"westmount\": \"Westmount\", \"candiac\": \"Candiac\", \"la prairie\": \"La Prairie\",\n \"mont-tremblant\": \"Mont-Tremblant\", \"saint-bruno\": \"Saint-Bruno-de-Montarville\",\n \"bromont\": \"Bromont\", \"cowansville\": \"Cowansville\",\n \"saint-augustin-de-desmaures\": \"Saint-Augustin-de-Desmaures\",\n \"l'ancienne-lorette\": \"L'Ancienne-Lorette\",\n}\n\n_QC_MARKERS = re.compile(\n r\"\\bqc\\b|\\bquebec\\b|\\bqu[e]bec, canada\\b|province de quebec\", re.I)\n_POSTAL_RE = re.compile(r\"\\b([GHJ]\\d[A-Z])\\s*(\\d[A-Z]\\d)\\b\", re.I) # G/H/J = Québec\n\n# Villes canadiennes hors Québec (et grandes villes étrangères) fréquentes\n# dans les flux pancanadiens/mondiaux — jamais des villes d'offres\n# québécoises : à rejeter lors du parsing de lieu (clés sans accents).\n_NON_QC_CITIES = frozenset({\n \"toronto\", \"ottawa\", \"vancouver\", \"calgary\", \"edmonton\", \"winnipeg\",\n \"mississauga\", \"brampton\", \"hamilton\", \"kitchener\", \"waterloo\", \"halifax\",\n \"dartmouth\", \"victoria\", \"saskatoon\", \"regina\", \"st. john's\", \"st johns\",\n \"saint john\", \"fredericton\", \"moncton\", \"charlottetown\", \"london\",\n \"markham\", \"vaughan\", \"richmond hill\", \"oakville\", \"burlington\", \"oshawa\",\n \"whitby\", \"ajax\", \"pickering\", \"guelph\", \"kingston\", \"barrie\",\n \"kelowna\", \"kamloops\", \"nanaimo\", \"abbotsford\", \"surrey\", \"burnaby\",\n \"coquitlam\", \"langley\", \"north york\", \"scarborough\", \"etobicoke\",\n \"north vancouver\", \"milton\", \"cambridge\", \"st. catharines\",\n \"niagara falls\", \"thunder bay\", \"sudbury\", \"greater sudbury\",\n \"sault ste. marie\", \"red deer\", \"lethbridge\", \"medicine hat\",\n \"grande prairie\", \"fort mcmurray\", \"yellowknife\", \"whitehorse\", \"iqaluit\",\n \"can\", \"canada\", \"ontario\", \"alberta\", \"british columbia\", \"manitoba\",\n \"saskatchewan\", \"nova scotia\", \"new brunswick\",\n \"new york\", \"new york city\", \"san francisco\", \"boston\", \"chicago\",\n \"seattle\", \"austin\", \"denver\", \"los angeles\", \"miami\", \"atlanta\",\n \"dallas\", \"houston\", \"philadelphia\", \"washington\", \"phoenix\", \"portland\",\n \"paris\", \"londres\", \"amsterdam\", \"berlin\", \"munich\", \"dublin\", \"madrid\",\n \"barcelona\", \"barcelone\", \"lisbon\", \"lisbonne\", \"sydney\", \"melbourne\",\n \"singapore\", \"singapour\", \"tokyo\", \"bangalore\", \"bengaluru\", \"mumbai\",\n \"mexico city\", \"sao paulo\", \"bogota\", \"buenos aires\", \"las vegas\",\n \"orlando\", \"nashville\", \"macau\",\n})\n\n\ndef canonical_city(raw: str) -> str:\n \"\"\"Canonicalise un nom de ville (« Montreal » -> « Montréal »,\n « Quebec City » -> « Québec »). Inconnu : retourne le texte nettoyé.\"\"\"\n s = clean_text(raw)\n if not s:\n return \"\"\n key = _key(s)\n exact = _QC_CITIES.get(key)\n if exact:\n return exact\n for ck, city in _QC_CITIES.items():\n if re.fullmatch(re.escape(ck), key):\n return city\n return s\n\n\ndef parse_location(raw: str) -> dict:\n \"\"\"Libellé de lieu -> {city, region, postal_code} (champs possiblement vides).\n\n Ex. « Montréal, QC, Canada », « Ville de Québec, Québec », « Remote - QC ».\n \"\"\"\n s = clean_text(raw)\n out = {\"city\": \"\", \"region\": \"\", \"postal_code\": \"\"}\n if not s:\n return out\n m = _POSTAL_RE.search(s)\n if m:\n out[\"postal_code\"] = f\"{m.group(1).upper()} {m.group(2).upper()}\"\n key = _key(s)\n for ck, city in _QC_CITIES.items():\n if re.search(rf\"(? bool:\n \"\"\"Vrai si le libellé pointe vers le Québec (filtre des ATS pancanadiens).\"\"\"\n key = _key(clean_text(raw))\n if not key:\n return False\n if key in _NON_QC_CITIES: # libellé = ville canadienne/monde hors QC\n return False\n if _QC_MARKERS.search(key):\n return True\n m = _POSTAL_RE.search(raw)\n if m and m.group(1)[0].upper() in \"GHJ\":\n return True\n return any(re.search(rf\"(? str:\n \"\"\"Langue du texte de l'offre : « fr », « en », « bilingue », ou \"\".\n\n Comptage de mots-outils sur le début du texte — déterministe, jamais de\n valeur inventée : texte trop court ou ambigu -> \"\".\n \"\"\"\n text = _key(f\"{title}\\n{description or ''}\"[:4000])\n if len(text) < 30:\n return \"\"\n fr = len(_FR_STOPWORDS.findall(text))\n en = len(_EN_STOPWORDS.findall(text))\n total = fr + en\n if total < 5:\n return \"\"\n # les deux langues nettement présentes -> offre bilingue\n if fr >= 8 and en >= 8 and min(fr, en) / max(fr, en) >= 0.35:\n return \"bilingue\"\n if fr > en:\n return \"fr\"\n if en > fr:\n return \"en\"\n return \"\"\n\n\n# ---------------------------------------------------------------------------\n# Avantages — extraction des blocs « Avantages / Benefits » des descriptions\n# ---------------------------------------------------------------------------\n\n_BENEFITS_HEADING = re.compile(\n r\"^\\s*(?:les\\s+|nos\\s+|our\\s+|tes\\s+|vos\\s+)?(?:avantages(?:\\s+sociaux)?|\"\n r\"benefits(?:\\s+&\\s+perks)?|perks(?:\\s+(?:and|&)\\s+benefits)?|\"\n r\"ce que nous (?:t')?offrons|what we offer|why join us|\"\n r\"pourquoi nous rejoindre|nous offrons|we offer)\\s*:?\\s*$\", re.I)\n_BENEFIT_LINE_MAX = 120\n\n\ndef extract_benefits(description: str) -> list[str]:\n \"\"\"Liste d'avantages depuis un bloc structuré de la description.\n\n Cherche un titre de section « Avantages/Benefits/… » seul sur sa ligne,\n puis prend les lignes courtes qui suivent (puces), en s'arrêtant à la\n première ligne longue ou au prochain titre. Max 12 items — si le bloc ne\n ressemble pas à une liste, on ne retourne rien (pas d'à-peu-près).\n \"\"\"\n if not description:\n return []\n lines = description.split(\"\\n\")\n for i, line in enumerate(lines):\n if not _BENEFITS_HEADING.match(strip_accents(line)):\n continue\n items: list[str] = []\n for nxt in lines[i + 1:]:\n t = clean_text(nxt).lstrip(\"•-–—*·◦▪ \").strip()\n if not t:\n if items:\n break # fin du bloc (ligne vide après les puces)\n continue\n if len(t) > _BENEFIT_LINE_MAX or _BENEFITS_HEADING.match(\n strip_accents(t)):\n break\n # un « titre » de section suivant (courte ligne sans ponctuation\n # finale, capitalisée) termine aussi le bloc\n if items and len(t.split()) <= 4 and t.endswith(\":\"):\n break\n items.append(t.rstrip(\" ;,.\").strip())\n if len(items) >= 12:\n break\n if len(items) >= 2:\n return items\n return []\n\n\n# ---------------------------------------------------------------------------\n# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés\n# ---------------------------------------------------------------------------\n\n# codes de réquisition : « [R-12345] », « (REQ 2026-42) », « - JR0035678 »…\n_REQ_CODE = re.compile(\n # en tête : « 123456 - Titre », « [REQ-1234] Titre » (≥5 chiffres nus pour\n # ne pas manger une année « 2026 - Programme d'été »)\n r\"^\\s*[\\[(]?(?:req(?:uisition)?|jr|job)[\\s#:-]*\\d{3,10}[\\])]?\\s*[-–—:|]\\s*|\"\n r\"^\\s*[\\[(]?\\d{5,10}[\\])]?\\s*[-–—:|]\\s*|\"\n # en queue : « Titre - R-12345 », « Titre (JOB 004521) »\n r\"\\s*[-–—|(]\\s*(?:req(?:uisition)?|job|poste)?\\s*#?\\s*(?:[A-Z]{1,4}-?)?\\d{4,10}\\s*[)\\]]?\\s*$|\"\n # au milieu : « Titre [R-12345] suite »\n r\"\\s*[\\[(](?:R|REQ|JR|JOB)[-_ ]?\\d{3,10}[\\])]\\s*\", re.I)\n\n# petits mots gardés en minuscules lors de la remise en casse d'un titre\n# tout-majuscules ; sigles usuels gardés en majuscules\n_TITLE_SMALL = {\"de\", \"du\", \"des\", \"la\", \"le\", \"les\", \"et\", \"en\", \"à\", \"a\",\n \"au\", \"aux\", \"d'\", \"l'\", \"of\", \"the\", \"and\", \"in\", \"for\",\n \"to\", \"or\", \"ou\", \"un\", \"une\", \"sur\", \"pour\"}\n_TITLE_ACRONYMS = {\"rh\", \"ti\", \"it\", \"qa\", \"qc\", \"pab\", \"cpa\", \"cfa\", \"erp\",\n \"sap\", \"crm\", \"sst\", \"cnc\", \"hvac\", \"rpa\", \"bi\", \"ia\",\n \"ai\", \"vp\", \"pdg\", \"dg\"}\n\n\ndef _smart_case(title: str) -> str:\n \"\"\"Titre TOUT EN MAJUSCULES -> casse de phrase lisible (sigles préservés).\"\"\"\n words = title.lower().split()\n out = []\n for i, w in enumerate(words):\n base = w.strip(\"()[],.;:/\")\n if base in _TITLE_ACRONYMS:\n out.append(w.replace(base, base.upper()))\n elif i > 0 and base in _TITLE_SMALL:\n out.append(w)\n else:\n out.append(w[:1].upper() + w[1:])\n return \" \".join(out)\n\n\ndef clean_title(title: str, city: str = \"\") -> str:\n \"\"\"Titre d'affichage : codes de réquisition retirés, suffixe « - »\n redondant retiré, casse corrigée si tout-majuscules. Le titre SOURCE n'est\n jamais modifié — ceci alimente une colonne d'affichage distincte.\"\"\"\n t = clean_text(title)\n if not t:\n return \"\"\n prev = None\n while prev != t: # les codes peuvent être empilés\n prev = t\n t = _REQ_CODE.sub(\" \", t).strip(\" -–—|:\")\n t = clean_text(t)\n # suffixe « - Montréal » / « (Montréal, QC) » identique à la ville de\n # l'offre : repéré sans accents, coupé sur le titre original\n if city:\n ck = re.escape(strip_accents(city.lower()))\n m = re.search(\n rf\"\\s*[-–—|,(]\\s*(?:ville de\\s+)?{ck}(?:\\s*,\\s*(?:qc|quebec))?\\s*\\)?\\s*$\",\n strip_accents(t.lower()))\n if m and m.start() > 0:\n t = clean_text(t[:m.start()]).strip(\" -–—|,(\")\n if len(t) > 8 and t == t.upper() and t != t.lower():\n t = _smart_case(t)\n return t or clean_text(title)\n\n\ndef categorize(title: str, description: str = \"\") -> str:\n \"\"\"Titre -> catégorie interne (repli : début de description), ou \"\".\n\n Le titre est essayé SEUL d'abord : la description commence souvent par le\n boilerplate de l'entreprise (« leader du manufacturier… ») qui fausserait\n la catégorie du poste.\n \"\"\"\n for texte in (title, description[:300]):\n key = _key(texte or \"\")\n if not key.strip():\n continue\n for cat, pat in _CATEGORY_RULES:\n if re.search(pat, key):\n return cat\n return \"\"\n","structuredPatch":[{"oldStart":620,"oldLines":10,"newStart":620,"newLines":15,"lines":[" # ---------------------------------------------------------------------------"," "," _BENEFITS_HEADING = re.compile(","- r\"^\\s*(?:les\\s+|nos\\s+|our\\s+|tes\\s+|vos\\s+)?(?:avantages(?:\\s+sociaux)?|\"","- r\"benefits(?:\\s+&\\s+perks)?|perks(?:\\s+(?:and|&)\\s+benefits)?|\"","- r\"ce que nous (?:t')?offrons|what we offer|why join us|\"","- r\"pourquoi nous rejoindre|nous offrons|we offer)\\s*:?\\s*$\", re.I)","+ r\"^\\s*(?:les\\s+|nos\\s+|our\\s+|tes\\s+|vos\\s+)?(?:avantages(?:\\s+sociaux|\"","+ r\"\\s+offerts|\\s+et\\s+conditions)?|\"","+ r\"benefits(?:\\s+&\\s+perks|\\s+offered)?|perks(?:\\s+(?:and|&)\\s+benefits)?|\"","+ r\"ce que (?:nous|l'on|on) (?:t'|te\\s+|vous\\s+)?offr(?:ons|e)|\"","+ r\"nous (?:t'|te\\s+|vous\\s+)?offrons|on (?:t'|te\\s+|vous\\s+)offre|\"","+ r\"what we offer|what'?s in it for you|why join us|why work (?:with|for) us|\"","+ r\"pourquoi (?:nous rejoindre|nous choisir|te joindre a nous|\"","+ r\"vous joindre a nous|travailler (?:chez|avec) nous)|we offer)\"","+ r\"\\s*:?\\s*$\", re.I)"," _BENEFIT_LINE_MAX = 120"," "," "]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d3b5a360-ba96-4cec-950c-efd75c35ab45","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"d544aceb-a725-497b-80f8-ebc91a1e0625","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":123,"estimated_tokens_delta":73,"uuid":"58887d6c-4115-4e4b-a4e9-d883ecf7b265","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePc9nPHVYvTZVQsbzwrR","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS4AMKpwEIERgCKkDS7t4Ao72F6+Ele1FpvJ+G0XmbOCXMNL7TmoC9/g4s1uB3jb8yHqyuUbbNAGQvDjF/1YOx1dTvZlsUXQBOKH+zMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAHsxrbUBrABAhIMlmQtYLPZ6q/HLTzNGgynb6XxqnDv5vqyW/8iMK2hNXWAUWojZIaWAnfd9oTU5YNyp/te9XKYph4iW8Wuir+BEwLUoB2iYsMgNBTvairlAVP2u/vbgQbFwDgw6dYOjdgNVC1mpvmXdEd3pHKBdONNuimd/c+/VWRa9kYLzZv9Gg6+ikkYjkzjzT2F4J/YPEWaJ5/QWDelrDC51WHD8mMfum3VLUah1TEYh83Jn8nIVvS+StZgNBOTdgCVVRHUjvuzSy49c09GpvW+Z2pOIXFm33OeTQ6gvFn+NRMNHR1AQ3uWAs9ewhI3d1XwhHRjxPwyk5kQEBrt2cGf+YZ5AhPV57BR9LlnSwocBM6htePJ+LcNkpcPBoM7cwCSqz8dxtgCS6gj4LHFTO174IjzvsLd2NfHsjMYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":853,"cache_read_input_tokens":119886,"cache_creation":{"ephemeral_5m_input_tokens":853,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"97d58fa4-a3e2-46cf-84ef-45db6c7cfa3b","request_id":"req_011CePc9kQidH8dno12akMSh"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePc9nPHVYvTZVQsbzwrR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01URvgnpCBgqqJUVBXMgVj6K","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py","old_string":"# ---------------------------------------------------------------------------\n# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés\n# ---------------------------------------------------------------------------","new_string":"# ---------------------------------------------------------------------------\n# Exigences — expérience, scolarité, langues (depuis la description)\n# ---------------------------------------------------------------------------\n\n# Années d'expérience : « 3 à 5 ans d'expérience », « minimum de 5 ans »,\n# « 5+ years of experience », « two (2) years of relevant experience »…\n_EXP_PATTERNS = [\n # « X à Y ans/years … expérience » (fourchette) — on garde le MIN\n re.compile(r\"(\\d{1,2})\\s*(?:a|-|to)\\s*\\d{1,2}\\s*(?:ans?|years?)\"\n r\"[^.\\n]{0,40}?(?:d )?experience\", re.I),\n # « X ans/années/years (+) d'expérience »\n re.compile(r\"(\\d{1,2})\\s*\\+?\\s*(?:ans?|annees?|years?)\"\n r\"[^.\\n]{0,40}?(?:d )?experience\", re.I),\n # « expérience … de X ans » / « experience of X+ years »\n re.compile(r\"experience[^.\\n]{0,50}?(?:de|of|d au moins|minimum(?: de)?|\"\n r\"at least)\\s*(\\d{1,2})\\s*\\+?\\s*(?:ans?|annees?|years?)\", re.I),\n # « minimum de X ans » / « at least X years » (sans le mot expérience)\n re.compile(r\"(?:minimum(?: de)?|au moins|at least)\\s*(\\d{1,2})\\s*\"\n r\"(?:ans?|annees?|years?)\\b\", re.I),\n]\n\n# Scolarité : du plus élevé au moins élevé — on retourne le PLUS BAS exigé\n# quand plusieurs sont acceptés (« bac ou maîtrise » -> baccalauréat).\n# Les sigles QC (DEP/DEC/AEC/DES) ne comptent que dans un contexte scolaire\n# (« dec. » est aussi l'abréviation de décembre).\n_EDU_SCHOOL_CONTEXT = re.compile(\n r\"diplome|dipl\\.|etudes|formation|scolarite|degree|diploma|education|\"\n r\"detenir|detention|obtenu|completed?\\b|graduate\", re.I)\n_EDU_LEVELS = [ # (niveau canonique, motif, sigle nécessitant le contexte ?)\n (\"secondaire\", r\"diplome d etudes secondaires|\\bdes\\b|secondaire (?:5|v)\\b|\"\n r\"high school diploma|secondary school diploma\", True),\n (\"DEP\", r\"diplome d etudes professionnelles|\\bdep\\b|\"\n r\"vocational diploma\", True),\n (\"AEC\", r\"attestation d etudes collegiales|\\baec\\b\", True),\n (\"DEC\", r\"diplome d etudes collegiales|\\bdec\\b|techniques? collegiales?|\"\n r\"college diploma|cegep\", True),\n (\"certificat\", r\"certificat universitaire|university certificate\", False),\n (\"baccalauréat\", r\"baccalaureat|bachelor|\\bbacc\\b|bac universitaire|\"\n r\"\\bbac\\b (?:en|in)\\b|undergraduate degree|\"\n r\"b\\.?\\s?(?:sc|ing|a\\.?a|com)\\b\", False),\n (\"maîtrise\", r\"maitrise|master s degree|masters degree|\\bmba\\b|\"\n r\"m\\.?\\s?sc\\.?\\b|graduate degree\", False),\n (\"doctorat\", r\"doctorat|\\bphd\\b|ph\\.?\\s?d\\b|doctoral\", False),\n]\n\n# Langues exigées : mention explicite d'exigence (pas la langue du TEXTE —\n# ça, c'est detect_language). « bilingue », « anglais requis », « fluent in\n# English », « maîtrise du français »…\n_LANG_BILINGUAL = re.compile(r\"bilingu\", re.I)\n_LANG_REQ_FR = re.compile(\n r\"francais[^.\\n]{0,30}?(?:requis|exige|obligatoire|essentiel|\"\n r\"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|\"\n r\"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |du )?\"\n r\"francais|french (?:is )?(?:required|mandatory|essential)|\"\n r\"fluent in french|fluency in french|french proficiency\", re.I)\n_LANG_REQ_EN = re.compile(\n r\"anglais[^.\\n]{0,30}?(?:requis|exige|obligatoire|essentiel|\"\n r\"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|\"\n r\"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |de l )?\"\n r\"anglais|english (?:is )?(?:required|mandatory|essential)|\"\n r\"fluent in english|fluency in english|english proficiency\", re.I)\n\n\ndef extract_requirements(title: str, description: str) -> dict:\n \"\"\"Exigences structurées depuis le texte de l'offre — clés présentes\n seulement quand l'information est explicite (jamais de valeur inventée) :\n\n - ``experience_years`` : années d'expérience minimales exigées (int)\n - ``education`` : plus bas diplôme exigé (secondaire, DEP, AEC,\n DEC, certificat, baccalauréat, maîtrise, doctorat)\n - ``languages`` : langues exigées ([\"français\"], [\"anglais\"], …)\n \"\"\"\n out: dict = {}\n text = _key(f\"{title or ''}\\n{description or ''}\"[:8000])\n text = re.sub(r\"[’']\", \" \", text) # « d'expérience » -> « d experience »\n if len(text) < 20:\n return out\n\n years: list[int] = []\n for pat in _EXP_PATTERNS:\n for m in pat.finditer(text):\n try:\n n = int(m.group(1))\n except (TypeError, ValueError):\n continue\n if 1 <= n <= 30: # bornes de plausibilité\n years.append(n)\n if years:\n out[\"experience_years\"] = min(years) # l'exigence MINIMALE\n\n for level, pat, needs_ctx in _EDU_LEVELS:\n for m in re.finditer(pat, text):\n if needs_ctx:\n window = text[max(0, m.start() - 120):m.end() + 120]\n if not _EDU_SCHOOL_CONTEXT.search(window):\n continue\n out[\"education\"] = level\n break\n if \"education\" in out:\n break # plus bas niveau exigé trouvé\n\n langs: list[str] = []\n if _LANG_BILINGUAL.search(text):\n langs = [\"français\", \"anglais\"]\n else:\n if _LANG_REQ_FR.search(text):\n langs.append(\"français\")\n if _LANG_REQ_EN.search(text):\n langs.append(\"anglais\")\n if langs:\n out[\"languages\"] = langs\n return out\n\n\n# ---------------------------------------------------------------------------\n# Séniorité — stage | junior | intermediaire | senior | direction\n# ---------------------------------------------------------------------------\n\n_SENIORITY_TITLE = [\n (\"stage\", r\"\\bstagiaire\\b|\\bstage\\b|\\bintern(?:ship)?\\b|\\bco-?op\\b|\"\n r\"alternance|etudiant\"),\n (\"direction\", r\"directeur|directrice|\\bdirector\\b|vice[- ]?president|\"\n r\"\\bvp\\b|\\bpdg\\b|\\bceo\\b|\\bcfo\\b|\\bcto\\b|\\bcoo\\b|\"\n r\"chef de (?:service|departement|division|la direction)|\"\n r\"head of|\\bdg\\b|gestionnaire de (?:service|departement)\"),\n (\"senior\", r\"\\bsenior\\b|\\bsr\\.?\\b|\\bprincipal(?:e)?\\b|\\blead\\b|\"\n r\"\\bexpert(?:e)?\\b|chef d equipe|team lead|superviseur|\"\n r\"\\bniveau (?:3|iii)\\b|\\biii\\b\"),\n (\"junior\", r\"\\bjunior\\b|\\bjr\\.?\\b|debutant|entry[- ]level|\"\n r\"\\bniveau (?:1|i)\\b|premier emploi|releve\"),\n (\"intermediaire\", r\"intermediaire|intermediate|\\bniveau (?:2|ii)\\b|\\bii\\b\"),\n]\n\n# libellés d'expérience des ATS (SmartRecruiters/LinkedIn-style) -> séniorité\n_SENIORITY_ATS = [\n (\"stage\", r\"internship|stage\"),\n (\"direction\", r\"director|executive|direction\"),\n (\"senior\", r\"mid[- ]?senior|senior\"),\n (\"intermediaire\", r\"associate|intermediaire|intermediate|mid[- ]?level\"),\n (\"junior\", r\"entry|junior|debutant\"),\n]\n\n\ndef parse_seniority(title: str, requirements: dict | None = None,\n employment_type: str | None = None,\n description: str = \"\") -> str | None:\n \"\"\"Niveau de séniorité du poste, ou None si aucun signal explicite.\n\n Ordre des signaux : type d'emploi « stage », titre du poste, libellé\n d'expérience fourni par l'ATS, puis années d'expérience exigées.\n \"\"\"\n if employment_type == \"stage\":\n return \"stage\"\n key = _key(title or \"\")\n if key:\n for level, pat in _SENIORITY_TITLE:\n if re.search(pat, key):\n return level\n req = requirements or {}\n label = _key(str(req.get(\"experience\", \"\")))\n if label and \"not applicable\" not in label:\n for level, pat in _SENIORITY_ATS:\n if re.search(pat, label):\n return level\n years = req.get(\"experience_years\")\n if isinstance(years, (int, float)):\n if years <= 2:\n return \"junior\"\n if years <= 4:\n return \"intermediaire\"\n return \"senior\"\n return None\n\n\n# ---------------------------------------------------------------------------\n# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés\n# ---------------------------------------------------------------------------"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":853,"cache_read_input_tokens":119886,"cache_creation":{"ephemeral_5m_input_tokens":853,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"7723baad-bedc-4734-a3d2-efe80ad1cccc","request_id":"req_011CePc9kQidH8dno12akMSh"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01URvgnpCBgqqJUVBXMgVj6K","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"2ba4275b-3fc3-4ad3-bdc4-e7d232430fd3","timestamp":"2026-08-25T14:15:47.622Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py","oldString":"# ---------------------------------------------------------------------------\n# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés\n# ---------------------------------------------------------------------------","newString":"# ---------------------------------------------------------------------------\n# Exigences — expérience, scolarité, langues (depuis la description)\n# ---------------------------------------------------------------------------\n\n# Années d'expérience : « 3 à 5 ans d'expérience », « minimum de 5 ans »,\n# « 5+ years of experience », « two (2) years of relevant experience »…\n_EXP_PATTERNS = [\n # « X à Y ans/years … expérience » (fourchette) — on garde le MIN\n re.compile(r\"(\\d{1,2})\\s*(?:a|-|to)\\s*\\d{1,2}\\s*(?:ans?|years?)\"\n r\"[^.\\n]{0,40}?(?:d )?experience\", re.I),\n # « X ans/années/years (+) d'expérience »\n re.compile(r\"(\\d{1,2})\\s*\\+?\\s*(?:ans?|annees?|years?)\"\n r\"[^.\\n]{0,40}?(?:d )?experience\", re.I),\n # « expérience … de X ans » / « experience of X+ years »\n re.compile(r\"experience[^.\\n]{0,50}?(?:de|of|d au moins|minimum(?: de)?|\"\n r\"at least)\\s*(\\d{1,2})\\s*\\+?\\s*(?:ans?|annees?|years?)\", re.I),\n # « minimum de X ans » / « at least X years » (sans le mot expérience)\n re.compile(r\"(?:minimum(?: de)?|au moins|at least)\\s*(\\d{1,2})\\s*\"\n r\"(?:ans?|annees?|years?)\\b\", re.I),\n]\n\n# Scolarité : du plus élevé au moins élevé — on retourne le PLUS BAS exigé\n# quand plusieurs sont acceptés (« bac ou maîtrise » -> baccalauréat).\n# Les sigles QC (DEP/DEC/AEC/DES) ne comptent que dans un contexte scolaire\n# (« dec. » est aussi l'abréviation de décembre).\n_EDU_SCHOOL_CONTEXT = re.compile(\n r\"diplome|dipl\\.|etudes|formation|scolarite|degree|diploma|education|\"\n r\"detenir|detention|obtenu|completed?\\b|graduate\", re.I)\n_EDU_LEVELS = [ # (niveau canonique, motif, sigle nécessitant le contexte ?)\n (\"secondaire\", r\"diplome d etudes secondaires|\\bdes\\b|secondaire (?:5|v)\\b|\"\n r\"high school diploma|secondary school diploma\", True),\n (\"DEP\", r\"diplome d etudes professionnelles|\\bdep\\b|\"\n r\"vocational diploma\", True),\n (\"AEC\", r\"attestation d etudes collegiales|\\baec\\b\", True),\n (\"DEC\", r\"diplome d etudes collegiales|\\bdec\\b|techniques? collegiales?|\"\n r\"college diploma|cegep\", True),\n (\"certificat\", r\"certificat universitaire|university certificate\", False),\n (\"baccalauréat\", r\"baccalaureat|bachelor|\\bbacc\\b|bac universitaire|\"\n r\"\\bbac\\b (?:en|in)\\b|undergraduate degree|\"\n r\"b\\.?\\s?(?:sc|ing|a\\.?a|com)\\b\", False),\n (\"maîtrise\", r\"maitrise|master s degree|masters degree|\\bmba\\b|\"\n r\"m\\.?\\s?sc\\.?\\b|graduate degree\", False),\n (\"doctorat\", r\"doctorat|\\bphd\\b|ph\\.?\\s?d\\b|doctoral\", False),\n]\n\n# Langues exigées : mention explicite d'exigence (pas la langue du TEXTE —\n# ça, c'est detect_language). « bilingue », « anglais requis », « fluent in\n# English », « maîtrise du français »…\n_LANG_BILINGUAL = re.compile(r\"bilingu\", re.I)\n_LANG_REQ_FR = re.compile(\n r\"francais[^.\\n]{0,30}?(?:requis|exige|obligatoire|essentiel|\"\n r\"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|\"\n r\"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |du )?\"\n r\"francais|french (?:is )?(?:required|mandatory|essential)|\"\n r\"fluent in french|fluency in french|french proficiency\", re.I)\n_LANG_REQ_EN = re.compile(\n r\"anglais[^.\\n]{0,30}?(?:requis|exige|obligatoire|essentiel|\"\n r\"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|\"\n r\"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |de l )?\"\n r\"anglais|english (?:is )?(?:required|mandatory|essential)|\"\n r\"fluent in english|fluency in english|english proficiency\", re.I)\n\n\ndef extract_requirements(title: str, description: str) -> dict:\n \"\"\"Exigences structurées depuis le texte de l'offre — clés présentes\n seulement quand l'information est explicite (jamais de valeur inventée) :\n\n - ``experience_years`` : années d'expérience minimales exigées (int)\n - ``education`` : plus bas diplôme exigé (secondaire, DEP, AEC,\n DEC, certificat, baccalauréat, maîtrise, doctorat)\n - ``languages`` : langues exigées ([\"français\"], [\"anglais\"], …)\n \"\"\"\n out: dict = {}\n text = _key(f\"{title or ''}\\n{description or ''}\"[:8000])\n text = re.sub(r\"[’']\", \" \", text) # « d'expérience » -> « d experience »\n if len(text) < 20:\n return out\n\n years: list[int] = []\n for pat in _EXP_PATTERNS:\n for m in pat.finditer(text):\n try:\n n = int(m.group(1))\n except (TypeError, ValueError):\n continue\n if 1 <= n <= 30: # bornes de plausibilité\n years.append(n)\n if years:\n out[\"experience_years\"] = min(years) # l'exigence MINIMALE\n\n for level, pat, needs_ctx in _EDU_LEVELS:\n for m in re.finditer(pat, text):\n if needs_ctx:\n window = text[max(0, m.start() - 120):m.end() + 120]\n if not _EDU_SCHOOL_CONTEXT.search(window):\n continue\n out[\"education\"] = level\n break\n if \"education\" in out:\n break # plus bas niveau exigé trouvé\n\n langs: list[str] = []\n if _LANG_BILINGUAL.search(text):\n langs = [\"français\", \"anglais\"]\n else:\n if _LANG_REQ_FR.search(text):\n langs.append(\"français\")\n if _LANG_REQ_EN.search(text):\n langs.append(\"anglais\")\n if langs:\n out[\"languages\"] = langs\n return out\n\n\n# ---------------------------------------------------------------------------\n# Séniorité — stage | junior | intermediaire | senior | direction\n# ---------------------------------------------------------------------------\n\n_SENIORITY_TITLE = [\n (\"stage\", r\"\\bstagiaire\\b|\\bstage\\b|\\bintern(?:ship)?\\b|\\bco-?op\\b|\"\n r\"alternance|etudiant\"),\n (\"direction\", r\"directeur|directrice|\\bdirector\\b|vice[- ]?president|\"\n r\"\\bvp\\b|\\bpdg\\b|\\bceo\\b|\\bcfo\\b|\\bcto\\b|\\bcoo\\b|\"\n r\"chef de (?:service|departement|division|la direction)|\"\n r\"head of|\\bdg\\b|gestionnaire de (?:service|departement)\"),\n (\"senior\", r\"\\bsenior\\b|\\bsr\\.?\\b|\\bprincipal(?:e)?\\b|\\blead\\b|\"\n r\"\\bexpert(?:e)?\\b|chef d equipe|team lead|superviseur|\"\n r\"\\bniveau (?:3|iii)\\b|\\biii\\b\"),\n (\"junior\", r\"\\bjunior\\b|\\bjr\\.?\\b|debutant|entry[- ]level|\"\n r\"\\bniveau (?:1|i)\\b|premier emploi|releve\"),\n (\"intermediaire\", r\"intermediaire|intermediate|\\bniveau (?:2|ii)\\b|\\bii\\b\"),\n]\n\n# libellés d'expérience des ATS (SmartRecruiters/LinkedIn-style) -> séniorité\n_SENIORITY_ATS = [\n (\"stage\", r\"internship|stage\"),\n (\"direction\", r\"director|executive|direction\"),\n (\"senior\", r\"mid[- ]?senior|senior\"),\n (\"intermediaire\", r\"associate|intermediaire|intermediate|mid[- ]?level\"),\n (\"junior\", r\"entry|junior|debutant\"),\n]\n\n\ndef parse_seniority(title: str, requirements: dict | None = None,\n employment_type: str | None = None,\n description: str = \"\") -> str | None:\n \"\"\"Niveau de séniorité du poste, ou None si aucun signal explicite.\n\n Ordre des signaux : type d'emploi « stage », titre du poste, libellé\n d'expérience fourni par l'ATS, puis années d'expérience exigées.\n \"\"\"\n if employment_type == \"stage\":\n return \"stage\"\n key = _key(title or \"\")\n if key:\n for level, pat in _SENIORITY_TITLE:\n if re.search(pat, key):\n return level\n req = requirements or {}\n label = _key(str(req.get(\"experience\", \"\")))\n if label and \"not applicable\" not in label:\n for level, pat in _SENIORITY_ATS:\n if re.search(pat, label):\n return level\n years = req.get(\"experience_years\")\n if isinstance(years, (int, float)):\n if years <= 2:\n return \"junior\"\n if years <= 4:\n return \"intermediaire\"\n return \"senior\"\n return None\n\n\n# ---------------------------------------------------------------------------\n# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés\n# ---------------------------------------------------------------------------","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/normalize.py\n# Rôle : Normalisation commune des offres (salaires, dates, mode/type\n# d'emploi, lieu, catégorie, exigences, séniorité) — jamais de\n# valeur inventée\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\n\"\"\"Couche de normalisation commune appelée par JobPosting.finalize().\n\nPhilosophie héritée de Lou·Ka : parseurs déterministes (regex, tables de\nmots-clés FR/EN), bornes de plausibilité, et surtout — une valeur absente\nreste absente (None / chaîne vide), on ne devine jamais.\n\"\"\"\nfrom __future__ import annotations\n\nimport datetime as _dt\nimport re\nimport unicodedata\n\n__all__ = [\n \"strip_accents\", \"clean_text\", \"clean_html\", \"parse_salary\",\n \"sanitize_salary\", \"salary_from_text\", \"salary_to_yearly\",\n \"salary_to_hourly\", \"parse_date\", \"parse_work_mode\",\n \"parse_employment_type\", \"parse_location\", \"canonical_city\",\n \"is_quebec_location\", \"categorize\", \"detect_language\",\n \"extract_benefits\", \"clean_title\", \"extract_requirements\",\n \"parse_seniority\",\n]\n\n\n# ---------------------------------------------------------------------------\n# Texte\n# ---------------------------------------------------------------------------\n\ndef strip_accents(s: str) -> str:\n return \"\".join(c for c in unicodedata.normalize(\"NFD\", s)\n if unicodedata.category(c) != \"Mn\")\n\n\ndef _key(s: str) -> str:\n \"\"\"Minuscules sans accents — pour le matching de mots-clés.\"\"\"\n return strip_accents((s or \"\").lower())\n\n\ndef clean_text(s: str) -> str:\n \"\"\"Espaces insécables -> espace, blancs réduits.\"\"\"\n s = (s or \"\").replace(\"\\xa0\", \" \").replace(\" \", \" \").replace(\" \", \" \")\n return re.sub(r\"[ \\t]+\", \" \", s).strip()\n\n\n_BLOCK_TAGS = re.compile(\n r\"]*>\", re.I)\n\n# Résidu HTML après une passe : balise ouvrante plausible ou entité encore\n# encodée — signe d'un contenu doublement échappé.\n_HTML_LEFTOVER = re.compile(\n r\"<(?:p|br|li|ul|ol|div|span|strong|em|b|i|h[1-6]|a)\\b|&(?:amp|lt|gt|nbsp|#\\d+);\",\n re.I)\n\n\ndef clean_html(html: str) -> str:\n \"\"\"HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré.\n\n Suffisant pour les descriptions d'offres (les connecteurs qui ont besoin\n d'un vrai parseur utilisent BeautifulSoup eux-mêmes).\n \"\"\"\n if not html:\n return \"\"\n import html as _html\n s = html\n # Contenus sur-échappés (entités dans les entités — Greenhouse/Lever) :\n # nettoyer en boucle tant qu'il reste des balises/entités (max 3 passes).\n for _ in range(3):\n s = _html.unescape(s)\n s = re.sub(r\"<(script|style)[^>]*>.*?\", \" \", s, flags=re.I | re.S)\n s = _BLOCK_TAGS.sub(\"\\n\", s)\n s = re.sub(r\"<[^>]+>\", \" \", s)\n if not _HTML_LEFTOVER.search(s):\n break\n s = s.replace(\"\\xa0\", \" \").replace(\" \", \" \")\n s = re.sub(r\"[ \\t]+\", \" \", s)\n s = re.sub(r\" ?\\n ?\", \"\\n\", s)\n return re.sub(r\"\\n{3,}\", \"\\n\\n\", s).strip()\n\n\n# ---------------------------------------------------------------------------\n# Salaires — transparence salariale = différenciateur clé de Job·Ka\n# ---------------------------------------------------------------------------\n\n# Bornes de plausibilité par unité (CAD). Hors bornes = on jette (pas de\n# salaire inventé, pas de « 1 $/h » venu d'un parsing raté).\n_SALARY_BOUNDS = {\n \"hour\": (14.0, 250.0),\n \"day\": (100.0, 2000.0),\n \"week\": (500.0, 10000.0),\n \"biweek\": (1000.0, 20000.0),\n \"month\": (1800.0, 45000.0),\n \"year\": (20000.0, 600000.0),\n}\n\n# Heures/an de référence pour les conversions (semaine de 40 h).\n_HOURS_PER_YEAR = 2080.0\n\n_UNIT_PATTERNS = [\n (\"hour\", r\"(?:/|par|per|an?)\\s*(?:h\\b|hr\\b|hre\\b|heure|hour)|horaire|hourly\"),\n (\"year\", r\"(?:/|par|per|an?)\\s*(?:an\\b|année|annee|year|yr\\b)|annuel|annual|yearly|per\\s+annum\"),\n (\"month\", r\"(?:/|par|per)\\s*(?:mois|month)|mensuel|monthly\"),\n (\"biweek\", r\"(?:aux|par|per)\\s*(?:2|deux|two)\\s*semaines|bi-?weekly|quinzaine\"),\n (\"week\", r\"(?:/|par|per)\\s*(?:sem\\b|semaine|week|wk\\b)|hebdomadaire|weekly\"),\n (\"day\", r\"(?:/|par|per)\\s*(?:jour|day)|journalier|daily\"),\n]\n\n_NUM_RE = re.compile(r\"\\d{1,3}(?:[   ,]\\d{3})+(?:[.,]\\d{1,2})?|\\d+(?:[.,]\\d{1,2})?[kK]?\")\n\n\ndef _parse_number(tok: str) -> float | None:\n tok = tok.strip()\n k = tok.lower().endswith(\"k\")\n if k:\n tok = tok[:-1]\n # « 52,000 » (milliers anglais) vs « 52000,00 » / « 25,50 » (décimales)\n tok = tok.replace(\" \", \" \").replace(\" \", \" \")\n if re.fullmatch(r\"\\d{1,3}(?:[ ,]\\d{3})+(?:[.,]\\d{1,2})?\", tok):\n # séparateur de milliers : retirer espaces/virgules de groupe\n tok = re.sub(r\"[ ,](?=\\d{3}\\b)\", \"\", tok)\n tok = tok.replace(\",\", \".\").replace(\" \", \"\")\n try:\n v = float(tok)\n except ValueError:\n return None\n return v * 1000 if k else v\n\n\ndef parse_salary(raw: str) -> tuple[float | None, float | None, str | None]:\n \"\"\"Texte de salaire -> (min, max, unité) ou (None, None, None).\n\n Exige un signe monétaire ($, CAD, dollars) OU une unité explicite proche\n des nombres — sans quoi « 35 heures/semaine » deviendrait un salaire.\n \"\"\"\n s = clean_text(raw)\n if not s:\n return (None, None, None)\n key = _key(s)\n\n unit = None\n for u, pat in _UNIT_PATTERNS:\n if re.search(pat, key):\n unit = u\n break\n\n has_money = bool(re.search(r\"\\$|cad\\b|dollar\", key))\n if not has_money and unit is None:\n return (None, None, None)\n\n nums = [_parse_number(m.group(0)) for m in _NUM_RE.finditer(s)]\n nums = [n for n in nums if n is not None]\n if not nums:\n return (None, None, None)\n\n # Sans unité explicite : inférer par l'ordre de grandeur (avec $ obligatoire)\n if unit is None:\n probe = max(nums)\n if probe < 300:\n unit = \"hour\"\n elif probe >= 18000:\n unit = \"year\"\n else:\n return (None, None, None) # zone ambiguë : ne pas deviner\n\n lo, hi = _SALARY_BOUNDS[unit]\n vals = sorted(n for n in nums if lo <= n <= hi)\n if not vals:\n return (None, None, None)\n return (vals[0], vals[-1] if len(vals) > 1 else vals[0], unit)\n\n\n_TO_YEAR = {\"hour\": _HOURS_PER_YEAR, \"day\": 260.0, \"week\": 52.0,\n \"biweek\": 26.0, \"month\": 12.0, \"year\": 1.0}\n\n\ndef salary_to_yearly(value: float | None, unit: str | None) -> float | None:\n if value is None or unit not in _TO_YEAR:\n return None\n return round(value * _TO_YEAR[unit], 0)\n\n\ndef salary_to_hourly(value: float | None, unit: str | None) -> float | None:\n y = salary_to_yearly(value, unit)\n return round(y / _HOURS_PER_YEAR, 2) if y is not None else None\n\n\ndef sanitize_salary(lo: float | None, hi: float | None, unit: str | None\n ) -> tuple[float | None, float | None, str | None]:\n \"\"\"Bornes de plausibilité sur les salaires STRUCTURÉS fournis par les ATS.\n\n Certaines sources étiquettent mal l'unité (75 000 « /mois » ou « /h » pour\n un salaire annuel — vu chez Lever et dans les JSON-LD de portails) : si la\n valeur sort des bornes de son unité, on tente une réinterprétation vers\n l'unité dont les bornes la contiennent (année d'abord, puis heure). Aucune\n unité plausible -> on jette (pas de salaire aberrant publié).\n \"\"\"\n if lo is None or unit not in _SALARY_BOUNDS:\n return (lo, hi, unit)\n b = _SALARY_BOUNDS[unit]\n if not (b[0] <= lo <= b[1]):\n for candidate in (\"year\", \"hour\"):\n cb = _SALARY_BOUNDS[candidate]\n if candidate != unit and cb[0] <= lo <= cb[1]:\n unit, b = candidate, cb\n break\n else:\n return (None, None, None)\n if hi is not None and not (b[0] <= hi <= b[1]):\n hi = None # max aberrant : on garde le min plausible seul\n if hi is not None and hi < lo:\n lo, hi = hi, lo\n return (lo, hi, unit)\n\n\n# Contextes où un montant en $ n'est PAS un salaire (financement, dépenses,\n# allocations…) — garde-fou de l'extraction depuis la description.\n_NOT_SALARY_CONTEXT = re.compile(\n r\"financement|funding|amorcage|seed|investisse|invest(?:ment|or)|\"\n r\"revenu[s]?\\b|revenue|chiffre d affaires|budget|remboursement|\"\n r\"reimburse|expense|allocation|allowance|subvention|credit d impot|\"\n r\"cotisation|rabais|discount|gift|carte[- ]cadeau|referral (?:bonus|fee)|\"\n r\"signing bonus|prime (?:a l embauche|de demarrage|de reference)|\"\n r\"million|milliard\")\n\n\ndef salary_from_text(text: str) -> tuple[float | None, float | None,\n str | None, str]:\n \"\"\"Cherche une mention salariale dans un texte libre (description).\n\n Découpe en phrases (les points décimaux « 182,500.00 » ne coupent pas),\n ignore les contextes non salariaux (financement, remboursements…), puis\n parse la première phrase plausible. Retourne (min, max, unité, libellé).\n \"\"\"\n if not text or \"$\" not in text:\n return (None, None, None, \"\")\n for sentence in re.split(r\"(? str | None:\n \"\"\"Valeur de date hétéroclite -> ISO « YYYY-MM-DD », ou None.\n\n Accepte : ISO (+ horodatages), epoch secondes/millisecondes, formats\n textuels FR/EN (« 1er juillet 2026 », « July 1st, 2026 »), et les dates\n relatives (« il y a 3 jours », « Posted 30+ Days Ago »).\n \"\"\"\n if raw is None:\n return None\n today = today or _dt.date.today()\n\n if isinstance(raw, (int, float)): # epoch (Lever : millisecondes)\n ts = float(raw)\n if ts > 1e12:\n ts /= 1000.0\n if 1e9 <= ts <= 4e9:\n return _dt.date.fromtimestamp(ts).isoformat()\n return None\n\n s = clean_text(str(raw))\n if not s:\n return None\n key = _key(s)\n\n m = re.search(r\"(\\d{4})-(\\d{2})-(\\d{2})\", s) # ISO (avec ou sans heure)\n if m:\n try:\n return _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3))).isoformat()\n except ValueError:\n return None\n\n if _TODAY_RE.search(key):\n return today.isoformat()\n if _YESTERDAY_RE.search(key):\n return (today - _dt.timedelta(days=1)).isoformat()\n\n m = _REL_RE.search(key)\n if m:\n n = int(m.group(1) or m.group(3))\n unit = (m.group(2) or m.group(4) or \"\").lower()\n days = n * (7 if unit.startswith((\"sem\", \"week\"))\n else 30 if unit.startswith((\"moi\", \"month\")) else 1)\n return (today - _dt.timedelta(days=days)).isoformat()\n\n # « 1er juillet 2026 » / « July 1st, 2026 » / « déc. 2026 »\n m = re.search(r\"(?:(\\d{1,2})(?:er|e|st|nd|rd|th)?\\s+)?([a-z]{3,9})\\.?,?\\s+(?:(\\d{1,2})(?:er|st|nd|rd|th)?,?\\s+)?(\\d{4})\", key)\n if m:\n mois = _MONTHS.get(m.group(2))\n if mois:\n jour = int(m.group(1) or m.group(3) or 1)\n try:\n return _dt.date(int(m.group(4)), mois, jour).isoformat()\n except ValueError:\n return None\n\n m = re.fullmatch(r\"(\\d{1,2})/(\\d{1,2})/(\\d{4})\", s) # jj/mm/aaaa (usage QC)\n if m:\n d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))\n if mo > 12 and d <= 12: # en fait mm/jj/aaaa\n d, mo = mo, d\n try:\n return _dt.date(y, mo, d).isoformat()\n except ValueError:\n return None\n return None\n\n\n# ---------------------------------------------------------------------------\n# Mode de travail / type d'emploi\n# ---------------------------------------------------------------------------\n\n_MODE_RULES = [\n (\"teletravail\", r\"100\\s*%\\s*(?:teletravail|remote)|full(?:y)?[ -]remote|\"\n r\"teletravail|travail a distance|remote(?:\\b|-first)|a distance\"),\n (\"hybride\", r\"hybrid|hybride|flexible?\\s*(?:work|travail)\"),\n (\"presentiel\", r\"presentiel|sur (?:place|site)|on[- ]?site|in[- ]?(?:office|person)\"),\n]\n\n\ndef parse_work_mode(raw: str) -> str | None:\n key = _key(raw)\n if not key:\n return None\n # « hybride » prime sur les mentions accessoires de télétravail\n if re.search(_MODE_RULES[1][1], key):\n return \"hybride\"\n for mode, pat in _MODE_RULES:\n if re.search(pat, key):\n return mode\n return None\n\n\n_TYPE_RULES = [\n (\"stage\", r\"\\bstage\\b|stagiaire|intern(?:ship)?\\b|co-?op\\b|alternance\"),\n (\"saisonnier\", r\"saisonn|seasonal\"),\n (\"contractuel\", r\"contrat\\b|contract(?:uel|or)?\\b|temporaire|temporary|terme|\"\n r\"fixed[- ]term|duree determinee|pigiste|freelance|consultant\"),\n (\"temps_partiel\", r\"temps[- ]partiel|part[- ]?time|partiel\\b\"),\n (\"temps_plein\", r\"temps[- ]plein|full[- ]?time|permanent|regulier|\"\n r\"regular\\b|duree indeterminee\"),\n]\n\n\ndef parse_employment_type(raw: str) -> str | None:\n key = _key(raw)\n if not key:\n return None\n for typ, pat in _TYPE_RULES:\n if re.search(pat, key):\n return typ\n return None\n\n\n# ---------------------------------------------------------------------------\n# Lieu — extraction ville/région + garde-fou « offre au Québec »\n# ---------------------------------------------------------------------------\n\n# Villes québécoises fréquentes dans les libellés d'offres (clé sans accents).\n# Sert à la fois au parsing et au filtre is_quebec_location.\n_QC_CITIES = {\n \"montreal\": \"Montréal\", \"quebec\": \"Québec\", \"ville de quebec\": \"Québec\",\n \"quebec city\": \"Québec\", \"laval\": \"Laval\", \"gatineau\": \"Gatineau\",\n \"longueuil\": \"Longueuil\", \"sherbrooke\": \"Sherbrooke\",\n \"saguenay\": \"Saguenay\", \"chicoutimi\": \"Saguenay\",\n \"levis\": \"Lévis\", \"trois-rivieres\": \"Trois-Rivières\",\n \"trois rivieres\": \"Trois-Rivières\", \"terrebonne\": \"Terrebonne\",\n \"brossard\": \"Brossard\", \"repentigny\": \"Repentigny\",\n \"boucherville\": \"Boucherville\", \"saint-jerome\": \"Saint-Jérôme\",\n \"drummondville\": \"Drummondville\", \"saint-jean-sur-richelieu\":\n \"Saint-Jean-sur-Richelieu\", \"granby\": \"Granby\", \"shawinigan\": \"Shawinigan\",\n \"joliette\": \"Joliette\", \"victoriaville\": \"Victoriaville\",\n \"rimouski\": \"Rimouski\", \"rouyn-noranda\": \"Rouyn-Noranda\",\n \"baie-comeau\": \"Baie-Comeau\", \"sept-iles\": \"Sept-Îles\",\n \"val-d'or\": \"Val-d'Or\", \"val d'or\": \"Val-d'Or\", \"alma\": \"Alma\",\n \"sainte-foy\": \"Québec\", \"anjou\": \"Montréal\", \"dorval\": \"Dorval\",\n \"boisbriand\": \"Boisbriand\", \"blainville\": \"Blainville\",\n \"mirabel\": \"Mirabel\", \"saint-hyacinthe\": \"Saint-Hyacinthe\",\n \"beloeil\": \"Belœil\", \"chateauguay\": \"Châteauguay\", \"magog\": \"Magog\",\n \"thetford mines\": \"Thetford Mines\", \"saint-georges\": \"Saint-Georges\",\n \"riviere-du-loup\": \"Rivière-du-Loup\", \"matane\": \"Matane\",\n \"sorel-tracy\": \"Sorel-Tracy\", \"varennes\": \"Varennes\",\n \"vaudreuil-dorion\": \"Vaudreuil-Dorion\", \"salaberry-de-valleyfield\":\n \"Salaberry-de-Valleyfield\", \"kirkland\": \"Kirkland\",\n \"pointe-claire\": \"Pointe-Claire\", \"saint-laurent\": \"Montréal\",\n \"ville saint-laurent\": \"Montréal\", \"verdun\": \"Montréal\",\n \"westmount\": \"Westmount\", \"candiac\": \"Candiac\", \"la prairie\": \"La Prairie\",\n \"mont-tremblant\": \"Mont-Tremblant\", \"saint-bruno\": \"Saint-Bruno-de-Montarville\",\n \"bromont\": \"Bromont\", \"cowansville\": \"Cowansville\",\n \"saint-augustin-de-desmaures\": \"Saint-Augustin-de-Desmaures\",\n \"l'ancienne-lorette\": \"L'Ancienne-Lorette\",\n}\n\n_QC_MARKERS = re.compile(\n r\"\\bqc\\b|\\bquebec\\b|\\bqu[e]bec, canada\\b|province de quebec\", re.I)\n_POSTAL_RE = re.compile(r\"\\b([GHJ]\\d[A-Z])\\s*(\\d[A-Z]\\d)\\b\", re.I) # G/H/J = Québec\n\n# Villes canadiennes hors Québec (et grandes villes étrangères) fréquentes\n# dans les flux pancanadiens/mondiaux — jamais des villes d'offres\n# québécoises : à rejeter lors du parsing de lieu (clés sans accents).\n_NON_QC_CITIES = frozenset({\n \"toronto\", \"ottawa\", \"vancouver\", \"calgary\", \"edmonton\", \"winnipeg\",\n \"mississauga\", \"brampton\", \"hamilton\", \"kitchener\", \"waterloo\", \"halifax\",\n \"dartmouth\", \"victoria\", \"saskatoon\", \"regina\", \"st. john's\", \"st johns\",\n \"saint john\", \"fredericton\", \"moncton\", \"charlottetown\", \"london\",\n \"markham\", \"vaughan\", \"richmond hill\", \"oakville\", \"burlington\", \"oshawa\",\n \"whitby\", \"ajax\", \"pickering\", \"guelph\", \"kingston\", \"barrie\",\n \"kelowna\", \"kamloops\", \"nanaimo\", \"abbotsford\", \"surrey\", \"burnaby\",\n \"coquitlam\", \"langley\", \"north york\", \"scarborough\", \"etobicoke\",\n \"north vancouver\", \"milton\", \"cambridge\", \"st. catharines\",\n \"niagara falls\", \"thunder bay\", \"sudbury\", \"greater sudbury\",\n \"sault ste. marie\", \"red deer\", \"lethbridge\", \"medicine hat\",\n \"grande prairie\", \"fort mcmurray\", \"yellowknife\", \"whitehorse\", \"iqaluit\",\n \"can\", \"canada\", \"ontario\", \"alberta\", \"british columbia\", \"manitoba\",\n \"saskatchewan\", \"nova scotia\", \"new brunswick\",\n \"new york\", \"new york city\", \"san francisco\", \"boston\", \"chicago\",\n \"seattle\", \"austin\", \"denver\", \"los angeles\", \"miami\", \"atlanta\",\n \"dallas\", \"houston\", \"philadelphia\", \"washington\", \"phoenix\", \"portland\",\n \"paris\", \"londres\", \"amsterdam\", \"berlin\", \"munich\", \"dublin\", \"madrid\",\n \"barcelona\", \"barcelone\", \"lisbon\", \"lisbonne\", \"sydney\", \"melbourne\",\n \"singapore\", \"singapour\", \"tokyo\", \"bangalore\", \"bengaluru\", \"mumbai\",\n \"mexico city\", \"sao paulo\", \"bogota\", \"buenos aires\", \"las vegas\",\n \"orlando\", \"nashville\", \"macau\",\n})\n\n\ndef canonical_city(raw: str) -> str:\n \"\"\"Canonicalise un nom de ville (« Montreal » -> « Montréal »,\n « Quebec City » -> « Québec »). Inconnu : retourne le texte nettoyé.\"\"\"\n s = clean_text(raw)\n if not s:\n return \"\"\n key = _key(s)\n exact = _QC_CITIES.get(key)\n if exact:\n return exact\n for ck, city in _QC_CITIES.items():\n if re.fullmatch(re.escape(ck), key):\n return city\n return s\n\n\ndef parse_location(raw: str) -> dict:\n \"\"\"Libellé de lieu -> {city, region, postal_code} (champs possiblement vides).\n\n Ex. « Montréal, QC, Canada », « Ville de Québec, Québec », « Remote - QC ».\n \"\"\"\n s = clean_text(raw)\n out = {\"city\": \"\", \"region\": \"\", \"postal_code\": \"\"}\n if not s:\n return out\n m = _POSTAL_RE.search(s)\n if m:\n out[\"postal_code\"] = f\"{m.group(1).upper()} {m.group(2).upper()}\"\n key = _key(s)\n for ck, city in _QC_CITIES.items():\n if re.search(rf\"(? bool:\n \"\"\"Vrai si le libellé pointe vers le Québec (filtre des ATS pancanadiens).\"\"\"\n key = _key(clean_text(raw))\n if not key:\n return False\n if key in _NON_QC_CITIES: # libellé = ville canadienne/monde hors QC\n return False\n if _QC_MARKERS.search(key):\n return True\n m = _POSTAL_RE.search(raw)\n if m and m.group(1)[0].upper() in \"GHJ\":\n return True\n return any(re.search(rf\"(? str:\n \"\"\"Langue du texte de l'offre : « fr », « en », « bilingue », ou \"\".\n\n Comptage de mots-outils sur le début du texte — déterministe, jamais de\n valeur inventée : texte trop court ou ambigu -> \"\".\n \"\"\"\n text = _key(f\"{title}\\n{description or ''}\"[:4000])\n if len(text) < 30:\n return \"\"\n fr = len(_FR_STOPWORDS.findall(text))\n en = len(_EN_STOPWORDS.findall(text))\n total = fr + en\n if total < 5:\n return \"\"\n # les deux langues nettement présentes -> offre bilingue\n if fr >= 8 and en >= 8 and min(fr, en) / max(fr, en) >= 0.35:\n return \"bilingue\"\n if fr > en:\n return \"fr\"\n if en > fr:\n return \"en\"\n return \"\"\n\n\n# ---------------------------------------------------------------------------\n# Avantages — extraction des blocs « Avantages / Benefits » des descriptions\n# ---------------------------------------------------------------------------\n\n_BENEFITS_HEADING = re.compile(\n r\"^\\s*(?:les\\s+|nos\\s+|our\\s+|tes\\s+|vos\\s+)?(?:avantages(?:\\s+sociaux|\"\n r\"\\s+offerts|\\s+et\\s+conditions)?|\"\n r\"benefits(?:\\s+&\\s+perks|\\s+offered)?|perks(?:\\s+(?:and|&)\\s+benefits)?|\"\n r\"ce que (?:nous|l'on|on) (?:t'|te\\s+|vous\\s+)?offr(?:ons|e)|\"\n r\"nous (?:t'|te\\s+|vous\\s+)?offrons|on (?:t'|te\\s+|vous\\s+)offre|\"\n r\"what we offer|what'?s in it for you|why join us|why work (?:with|for) us|\"\n r\"pourquoi (?:nous rejoindre|nous choisir|te joindre a nous|\"\n r\"vous joindre a nous|travailler (?:chez|avec) nous)|we offer)\"\n r\"\\s*:?\\s*$\", re.I)\n_BENEFIT_LINE_MAX = 120\n\n\ndef extract_benefits(description: str) -> list[str]:\n \"\"\"Liste d'avantages depuis un bloc structuré de la description.\n\n Cherche un titre de section « Avantages/Benefits/… » seul sur sa ligne,\n puis prend les lignes courtes qui suivent (puces), en s'arrêtant à la\n première ligne longue ou au prochain titre. Max 12 items — si le bloc ne\n ressemble pas à une liste, on ne retourne rien (pas d'à-peu-près).\n \"\"\"\n if not description:\n return []\n lines = description.split(\"\\n\")\n for i, line in enumerate(lines):\n if not _BENEFITS_HEADING.match(strip_accents(line)):\n continue\n items: list[str] = []\n for nxt in lines[i + 1:]:\n t = clean_text(nxt).lstrip(\"•-–—*·◦▪ \").strip()\n if not t:\n if items:\n break # fin du bloc (ligne vide après les puces)\n continue\n if len(t) > _BENEFIT_LINE_MAX or _BENEFITS_HEADING.match(\n strip_accents(t)):\n break\n # un « titre » de section suivant (courte ligne sans ponctuation\n # finale, capitalisée) termine aussi le bloc\n if items and len(t.split()) <= 4 and t.endswith(\":\"):\n break\n items.append(t.rstrip(\" ;,.\").strip())\n if len(items) >= 12:\n break\n if len(items) >= 2:\n return items\n return []\n\n\n# ---------------------------------------------------------------------------\n# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés\n# ---------------------------------------------------------------------------\n\n# codes de réquisition : « [R-12345] », « (REQ 2026-42) », « - JR0035678 »…\n_REQ_CODE = re.compile(\n # en tête : « 123456 - Titre », « [REQ-1234] Titre » (≥5 chiffres nus pour\n # ne pas manger une année « 2026 - Programme d'été »)\n r\"^\\s*[\\[(]?(?:req(?:uisition)?|jr|job)[\\s#:-]*\\d{3,10}[\\])]?\\s*[-–—:|]\\s*|\"\n r\"^\\s*[\\[(]?\\d{5,10}[\\])]?\\s*[-–—:|]\\s*|\"\n # en queue : « Titre - R-12345 », « Titre (JOB 004521) »\n r\"\\s*[-–—|(]\\s*(?:req(?:uisition)?|job|poste)?\\s*#?\\s*(?:[A-Z]{1,4}-?)?\\d{4,10}\\s*[)\\]]?\\s*$|\"\n # au milieu : « Titre [R-12345] suite »\n r\"\\s*[\\[(](?:R|REQ|JR|JOB)[-_ ]?\\d{3,10}[\\])]\\s*\", re.I)\n\n# petits mots gardés en minuscules lors de la remise en casse d'un titre\n# tout-majuscules ; sigles usuels gardés en majuscules\n_TITLE_SMALL = {\"de\", \"du\", \"des\", \"la\", \"le\", \"les\", \"et\", \"en\", \"à\", \"a\",\n \"au\", \"aux\", \"d'\", \"l'\", \"of\", \"the\", \"and\", \"in\", \"for\",\n \"to\", \"or\", \"ou\", \"un\", \"une\", \"sur\", \"pour\"}\n_TITLE_ACRONYMS = {\"rh\", \"ti\", \"it\", \"qa\", \"qc\", \"pab\", \"cpa\", \"cfa\", \"erp\",\n \"sap\", \"crm\", \"sst\", \"cnc\", \"hvac\", \"rpa\", \"bi\", \"ia\",\n \"ai\", \"vp\", \"pdg\", \"dg\"}\n\n\ndef _smart_case(title: str) -> str:\n \"\"\"Titre TOUT EN MAJUSCULES -> casse de phrase lisible (sigles préservés).\"\"\"\n words = title.lower().split()\n out = []\n for i, w in enumerate(words):\n base = w.strip(\"()[],.;:/\")\n if base in _TITLE_ACRONYMS:\n out.append(w.replace(base, base.upper()))\n elif i > 0 and base in _TITLE_SMALL:\n out.append(w)\n else:\n out.append(w[:1].upper() + w[1:])\n return \" \".join(out)\n\n\ndef clean_title(title: str, city: str = \"\") -> str:\n \"\"\"Titre d'affichage : codes de réquisition retirés, suffixe « - »\n redondant retiré, casse corrigée si tout-majuscules. Le titre SOURCE n'est\n jamais modifié — ceci alimente une colonne d'affichage distincte.\"\"\"\n t = clean_text(title)\n if not t:\n return \"\"\n prev = None\n while prev != t: # les codes peuvent être empilés\n prev = t\n t = _REQ_CODE.sub(\" \", t).strip(\" -–—|:\")\n t = clean_text(t)\n # suffixe « - Montréal » / « (Montréal, QC) » identique à la ville de\n # l'offre : repéré sans accents, coupé sur le titre original\n if city:\n ck = re.escape(strip_accents(city.lower()))\n m = re.search(\n rf\"\\s*[-–—|,(]\\s*(?:ville de\\s+)?{ck}(?:\\s*,\\s*(?:qc|quebec))?\\s*\\)?\\s*$\",\n strip_accents(t.lower()))\n if m and m.start() > 0:\n t = clean_text(t[:m.start()]).strip(\" -–—|,(\")\n if len(t) > 8 and t == t.upper() and t != t.lower():\n t = _smart_case(t)\n return t or clean_text(title)\n\n\ndef categorize(title: str, description: str = \"\") -> str:\n \"\"\"Titre -> catégorie interne (repli : début de description), ou \"\".\n\n Le titre est essayé SEUL d'abord : la description commence souvent par le\n boilerplate de l'entreprise (« leader du manufacturier… ») qui fausserait\n la catégorie du poste.\n \"\"\"\n for texte in (title, description[:300]):\n key = _key(texte or \"\")\n if not key.strip():\n continue\n for cat, pat in _CATEGORY_RULES:\n if re.search(pat, key):\n return cat\n return \"\"\n","structuredPatch":[{"oldStart":669,"oldLines":6,"newStart":669,"newLines":180,"lines":[" "," "," # ---------------------------------------------------------------------------","+# Exigences — expérience, scolarité, langues (depuis la description)","+# ---------------------------------------------------------------------------","+","+# Années d'expérience : « 3 à 5 ans d'expérience », « minimum de 5 ans »,","+# « 5+ years of experience », « two (2) years of relevant experience »…","+_EXP_PATTERNS = [","+ # « X à Y ans/years … expérience » (fourchette) — on garde le MIN","+ re.compile(r\"(\\d{1,2})\\s*(?:a|-|to)\\s*\\d{1,2}\\s*(?:ans?|years?)\"","+ r\"[^.\\n]{0,40}?(?:d )?experience\", re.I),","+ # « X ans/années/years (+) d'expérience »","+ re.compile(r\"(\\d{1,2})\\s*\\+?\\s*(?:ans?|annees?|years?)\"","+ r\"[^.\\n]{0,40}?(?:d )?experience\", re.I),","+ # « expérience … de X ans » / « experience of X+ years »","+ re.compile(r\"experience[^.\\n]{0,50}?(?:de|of|d au moins|minimum(?: de)?|\"","+ r\"at least)\\s*(\\d{1,2})\\s*\\+?\\s*(?:ans?|annees?|years?)\", re.I),","+ # « minimum de X ans » / « at least X years » (sans le mot expérience)","+ re.compile(r\"(?:minimum(?: de)?|au moins|at least)\\s*(\\d{1,2})\\s*\"","+ r\"(?:ans?|annees?|years?)\\b\", re.I),","+]","+","+# Scolarité : du plus élevé au moins élevé — on retourne le PLUS BAS exigé","+# quand plusieurs sont acceptés (« bac ou maîtrise » -> baccalauréat).","+# Les sigles QC (DEP/DEC/AEC/DES) ne comptent que dans un contexte scolaire","+# (« dec. » est aussi l'abréviation de décembre).","+_EDU_SCHOOL_CONTEXT = re.compile(","+ r\"diplome|dipl\\.|etudes|formation|scolarite|degree|diploma|education|\"","+ r\"detenir|detention|obtenu|completed?\\b|graduate\", re.I)","+_EDU_LEVELS = [ # (niveau canonique, motif, sigle nécessitant le contexte ?)","+ (\"secondaire\", r\"diplome d etudes secondaires|\\bdes\\b|secondaire (?:5|v)\\b|\"","+ r\"high school diploma|secondary school diploma\", True),","+ (\"DEP\", r\"diplome d etudes professionnelles|\\bdep\\b|\"","+ r\"vocational diploma\", True),","+ (\"AEC\", r\"attestation d etudes collegiales|\\baec\\b\", True),","+ (\"DEC\", r\"diplome d etudes collegiales|\\bdec\\b|techniques? collegiales?|\"","+ r\"college diploma|cegep\", True),","+ (\"certificat\", r\"certificat universitaire|university certificate\", False),","+ (\"baccalauréat\", r\"baccalaureat|bachelor|\\bbacc\\b|bac universitaire|\"","+ r\"\\bbac\\b (?:en|in)\\b|undergraduate degree|\"","+ r\"b\\.?\\s?(?:sc|ing|a\\.?a|com)\\b\", False),","+ (\"maîtrise\", r\"maitrise|master s degree|masters degree|\\bmba\\b|\"","+ r\"m\\.?\\s?sc\\.?\\b|graduate degree\", False),","+ (\"doctorat\", r\"doctorat|\\bphd\\b|ph\\.?\\s?d\\b|doctoral\", False),","+]","+","+# Langues exigées : mention explicite d'exigence (pas la langue du TEXTE —","+# ça, c'est detect_language). « bilingue », « anglais requis », « fluent in","+# English », « maîtrise du français »…","+_LANG_BILINGUAL = re.compile(r\"bilingu\", re.I)","+_LANG_REQ_FR = re.compile(","+ r\"francais[^.\\n]{0,30}?(?:requis|exige|obligatoire|essentiel|\"","+ r\"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|\"","+ r\"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |du )?\"","+ r\"francais|french (?:is )?(?:required|mandatory|essential)|\"","+ r\"fluent in french|fluency in french|french proficiency\", re.I)","+_LANG_REQ_EN = re.compile(","+ r\"anglais[^.\\n]{0,30}?(?:requis|exige|obligatoire|essentiel|\"","+ r\"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|\"","+ r\"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |de l )?\"","+ r\"anglais|english (?:is )?(?:required|mandatory|essential)|\"","+ r\"fluent in english|fluency in english|english proficiency\", re.I)","+","+","+def extract_requirements(title: str, description: str) -> dict:","+ \"\"\"Exigences structurées depuis le texte de l'offre — clés présentes","+ seulement quand l'information est explicite (jamais de valeur inventée) :","+","+ - ``experience_years`` : années d'expérience minimales exigées (int)","+ - ``education`` : plus bas diplôme exigé (secondaire, DEP, AEC,","+ DEC, certificat, baccalauréat, maîtrise, doctorat)","+ - ``languages`` : langues exigées ([\"français\"], [\"anglais\"], …)","+ \"\"\"","+ out: dict = {}","+ text = _key(f\"{title or ''}\\n{description or ''}\"[:8000])","+ text = re.sub(r\"[’']\", \" \", text) # « d'expérience » -> « d experience »","+ if len(text) < 20:","+ return out","+","+ years: list[int] = []","+ for pat in _EXP_PATTERNS:","+ for m in pat.finditer(text):","+ try:","+ n = int(m.group(1))","+ except (TypeError, ValueError):","+ continue","+ if 1 <= n <= 30: # bornes de plausibilité","+ years.append(n)","+ if years:","+ out[\"experience_years\"] = min(years) # l'exigence MINIMALE","+","+ for level, pat, needs_ctx in _EDU_LEVELS:","+ for m in re.finditer(pat, text):","+ if needs_ctx:","+ window = text[max(0, m.start() - 120):m.end() + 120]","+ if not _EDU_SCHOOL_CONTEXT.search(window):","+ continue","+ out[\"education\"] = level","+ break","+ if \"education\" in out:","+ break # plus bas niveau exigé trouvé","+","+ langs: list[str] = []","+ if _LANG_BILINGUAL.search(text):","+ langs = [\"français\", \"anglais\"]","+ else:","+ if _LANG_REQ_FR.search(text):","+ langs.append(\"français\")","+ if _LANG_REQ_EN.search(text):","+ langs.append(\"anglais\")","+ if langs:","+ out[\"languages\"] = langs","+ return out","+","+","+# ---------------------------------------------------------------------------","+# Séniorité — stage | junior | intermediaire | senior | direction","+# ---------------------------------------------------------------------------","+","+_SENIORITY_TITLE = [","+ (\"stage\", r\"\\bstagiaire\\b|\\bstage\\b|\\bintern(?:ship)?\\b|\\bco-?op\\b|\"","+ r\"alternance|etudiant\"),","+ (\"direction\", r\"directeur|directrice|\\bdirector\\b|vice[- ]?president|\"","+ r\"\\bvp\\b|\\bpdg\\b|\\bceo\\b|\\bcfo\\b|\\bcto\\b|\\bcoo\\b|\"","+ r\"chef de (?:service|departement|division|la direction)|\"","+ r\"head of|\\bdg\\b|gestionnaire de (?:service|departement)\"),","+ (\"senior\", r\"\\bsenior\\b|\\bsr\\.?\\b|\\bprincipal(?:e)?\\b|\\blead\\b|\"","+ r\"\\bexpert(?:e)?\\b|chef d equipe|team lead|superviseur|\"","+ r\"\\bniveau (?:3|iii)\\b|\\biii\\b\"),","+ (\"junior\", r\"\\bjunior\\b|\\bjr\\.?\\b|debutant|entry[- ]level|\"","+ r\"\\bniveau (?:1|i)\\b|premier emploi|releve\"),","+ (\"intermediaire\", r\"intermediaire|intermediate|\\bniveau (?:2|ii)\\b|\\bii\\b\"),","+]","+","+# libellés d'expérience des ATS (SmartRecruiters/LinkedIn-style) -> séniorité","+_SENIORITY_ATS = [","+ (\"stage\", r\"internship|stage\"),","+ (\"direction\", r\"director|executive|direction\"),","+ (\"senior\", r\"mid[- ]?senior|senior\"),","+ (\"intermediaire\", r\"associate|intermediaire|intermediate|mid[- ]?level\"),","+ (\"junior\", r\"entry|junior|debutant\"),","+]","+","+","+def parse_seniority(title: str, requirements: dict | None = None,","+ employment_type: str | None = None,","+ description: str = \"\") -> str | None:","+ \"\"\"Niveau de séniorité du poste, ou None si aucun signal explicite.","+","+ Ordre des signaux : type d'emploi « stage », titre du poste, libellé","+ d'expérience fourni par l'ATS, puis années d'expérience exigées.","+ \"\"\"","+ if employment_type == \"stage\":","+ return \"stage\"","+ key = _key(title or \"\")","+ if key:","+ for level, pat in _SENIORITY_TITLE:","+ if re.search(pat, key):","+ return level","+ req = requirements or {}","+ label = _key(str(req.get(\"experience\", \"\")))","+ if label and \"not applicable\" not in label:","+ for level, pat in _SENIORITY_ATS:","+ if re.search(pat, label):","+ return level","+ years = req.get(\"experience_years\")","+ if isinstance(years, (int, float)):","+ if years <= 2:","+ return \"junior\"","+ if years <= 4:","+ return \"intermediaire\"","+ return \"senior\"","+ return None","+","+","+# ---------------------------------------------------------------------------"," # Titre d'affichage — casse propre, codes de réquisition et suffixes retirés"," # ---------------------------------------------------------------------------"," "]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"c08778b2-fe3e-41f8-91da-cec64b4785de","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"9e1069c1-946b-4a36-a9b8-dbcb2de76177","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":100,"estimated_tokens_delta":50,"uuid":"4797ac45-60b4-4a78-b750-9751fa7734fe","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":200,"estimated_tokens_delta":100,"uuid":"171ff811-bfbb-425e-91ae-5b0b09d3eeb0","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":350,"estimated_tokens_delta":150,"uuid":"3bbb16db-4cc7-4d70-b07f-f77a45970c3a","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":450,"estimated_tokens_delta":100,"uuid":"4ee37d1a-f68e-41b8-aa0b-c5b9684dc09a","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":600,"estimated_tokens_delta":150,"uuid":"917949fd-608e-4857-9307-2653c7737252","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":650,"estimated_tokens_delta":50,"uuid":"e9fbebf5-290c-4e1b-a428-d7043b62847a","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcDMD5MS9gnTCZHRAKF","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISkBAKpwEIERgCKkCfr2wLNEMoG0JRH4wbWdKLzlvbBN895EgzheFY+GegLQDAInxJPvhzqvHUJa7neT5kdRWzKmcCW6JIZRVIdkgHMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAGkx7bUBrABAhIMC3GeCGYGL9csT87iGgx2N1RXc6MbERw2HWQiMPwRf7oYUJ65S2d+TtBDpM936KQFZVKk137UrSUQw8ZNic3bbVgkMiNmq356L+0XNyqVDnt6apJ2e5ShobK/69Emn64ZUYFCHiUPelVVHiMNu0yOdDiluNZJllkw+EBmkMt7pQWYxorMv8Fw/KF6FJfBSnsfxx7dsyIl5Lw8zxRsaL3EIm7wp2AWZHs8mEeA1b4ey0gS+Vif2xXorr7x7cNu36yvYixB22xwEFjj6Hs8ZHjSHPIFYoZcI18MbuVb+eqEO4TL+XwKuykuZC/08+ewgiQfX65aClDx8NJGEXYU0gBv+NygEJTRUcC+QUpIS60j+RPyr282ilA/9qhZaaI6ixz2O9H1T8JQMglHxir2y/DfsdIYX4EG68jwqjVTg6t29g4JeeV3Iz9cC8mK8iqu9MBTzx9kjgrGCLag3RjYPfIa5SCJfyayrUO7DiFwriXPTRFnEFSiCqtrMHoNxzGs3+fCZp95TunxsaUSNxAWHw7JamtszokiSsLr5VjfvBZVc2oxATKasVx6nnjLOYlyaMrX1z1KJAtI4rzO26o9/3p5v5wxwnSwGt7N+ysXqEfW2RmDDG3G1CLU8GDgS3qNazjy0P9Fe/AJhPSI0+Q5384zD+ptZN5FX1h3P2wzQlqwf9dsOPbgdb40ek1LQ7w66uxoCe5LVpgJgd8m8QWrzOFelFelnaRAl76sE/GA/kGhP8X5ZvD9sh103TxPwDJtpvtCiqucDJB2tApQsFp35lwzi2DbB8v4t0ixYpCnMQ1obkYLx+TM+MVZKxtJCrisL0xS5dYY4zVWTwzSa6+SBEKoGvMMYnYStjG19npv1U9Y3PG/d7fsfqR5NaSrdEQvEt6yx85iTdn5gebvce66YZhlMfAxiHuJKtX9KGEJ8yENwK1LNkAe3ecaGLe5R25C9Bj9SY/9tRUrSvSO0h7yKFuMfSnIImtQ4Ow3mvawuw1IxT8+6oqj2nrbdrkyWdXa3LhW0CU7X2s8XLlRZwwoeXsgVKvDRWH4F1dm7AX9XrpQKpwfU/4Lmt2l83XYbAlDgTTguHSp1mCdli1TR0r5D3ApxM3iOESNGv1O0QUnz9Lc1iY6CekDvsDBgq6//FlprOF0o3Aq1WAYNEC37EYznJkl5HwpfAvbMZ2OOG5C/zj+PV4OPGXIK3jUVw4ek74AD0xR2KXh0sKfdmRigEKu3kFSZs9EplzIU5t8UB+NsWcuT/p1G70k1s449Oepiz9j4gSS7LFD4BTmw11CEjk4pUu+J0Rp9mYsxR03f/tzaWdq0r8rMQiWEz1Hl9xb0tCsYFf0OXDMrWsXJByQFn45o2YsAFJHrQhaWXqGpfay47byRN41FVajzoYyG4ZqnSYgcHTwpZzVaCMp04/DQRuNlJVyIscDxNMsk0VPW0lIfrRAnmmBuZpxqPPFdO/WQGrgirsB9jypKvMYu3RvpZHy9azN1XBeuZiMZ9ZYzCuN0TUo1sfUOVc0mGu9Z7N2SoTNEnF5rE9WtNRWHngi6gW22dgi4UZRg56Y8+3q7ZSA5VxkrblRs2F9C/CW6XJaK7y7U+WmSH/kubKNkGLJpfnLKdlDhyGXs4lGPwjv1XKlsiSPpK2szUuu2+G8mIUNy6LlDX6wZk70Vb9xsrdU5668FhSwvRHQMJlO6uJVWlhP2Z8Q/PhuMy0VT4bLgIkXf7q61U+dXGAWP8Z6h1TqbrK1wbWlKBEK6ZCPd7zh+qwuJDhBnsU0ExQCwCX6RcMbeJxQkUx9i7SfQpBTHg2VXloZMi9JPQWHmfBnNdPu9+v8HeMDAuyCK4dVwuKNVOa4Q1v7X/3xO5TjsGDWF/vkxGLbnV42pMkhSl5/60snzkgt3gnahR+tqQzPQ8vJDrJ5u7tWYbYm42DEqvmU6CmQG6CLhcj5sUqQFdlnXlRQWZeBFDTsPynRNlkhdW0prZ/Ky+6SDVyRISz8MK7y1eUNxX7xRTA4pOiBkIksH9fbrqS0IRRzHKDh+E/NhOh58woDutByT3siKs3y/Wdoc9c1LOwn6+Wfwkt3iT646JMqgszYvOzv21kGz8+Tb/Ln0MQYHAPJwrv1IlFTxFdm5pEviUz8h+NB5ylCDI4Z6q1sGZ5MyBcXZbKNhwuQ3JUpAfoJSZbCVVvyN/05utl25mq3k+Eh7nMi7rBkwaePtxFetd5+wu2OqwoBQZVrw78DMfiOR9Xz3aKtcfZh0yhCWT56+wdHNQSclu4hGEnctJ0tfDukYgyyZyfXsU7gnD7nwYXz1t4bH5hyGHtk/Eu8opJXVRSF8h0GhGD4JNNDEhr3QrDN0AabxGJ1h4s6jhH+JjGShYLgptWrAMyOOFG1e5vuc/Uw9flkfSBBkBuMwC/T3hAvD84IEXJPzOJu3gSeTWafMQ+p8VY/TS5lRv4r++cfUf2KBe3jehHj6fVM1VyGFb+OA4zc6kar9RLSsL+ctL10A9J0S8G5RjddWSEt008RHrEegZ+7i5c01IUYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3908,"cache_read_input_tokens":120739,"cache_creation":{"ephemeral_5m_input_tokens":3908,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"0f9298b4-2657-411d-ba44-9fcfee0ac6d2","request_id":"req_011CePcDJxt7sLtUJ49qSpEi"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcDMD5MS9gnTCZHRAKF","type":"message","role":"assistant","content":[{"type":"text","text":"Je retire un paramètre inutilisé, puis je branche le tout dans `schema.py` :"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3908,"cache_read_input_tokens":120739,"cache_creation":{"ephemeral_5m_input_tokens":3908,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"964383fe-40d0-42d6-8486-071cd9019ea9","request_id":"req_011CePcDJxt7sLtUJ49qSpEi"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcDMD5MS9gnTCZHRAKF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DZATzAuFawvNih3Zvq2A6Y","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py","old_string":"def parse_seniority(title: str, requirements: dict | None = None,\n employment_type: str | None = None,\n description: str = \"\") -> str | None:","new_string":"def parse_seniority(title: str, requirements: dict | None = None,\n employment_type: str | None = None) -> str | None:"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3908,"cache_read_input_tokens":120739,"cache_creation":{"ephemeral_5m_input_tokens":3908,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"65b24a30-d14c-4bac-b038-fad489b0363a","request_id":"req_011CePcDJxt7sLtUJ49qSpEi"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DZATzAuFawvNih3Zvq2A6Y","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"039259cf-eb36-4c60-8616-95421ac62901","timestamp":"2026-08-25T14:16:06.338Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/normalize.py","oldString":"def parse_seniority(title: str, requirements: dict | None = None,\n employment_type: str | None = None,\n description: str = \"\") -> str | None:","newString":"def parse_seniority(title: str, requirements: dict | None = None,\n employment_type: str | None = None) -> str | None:","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/normalize.py\n# Rôle : Normalisation commune des offres (salaires, dates, mode/type\n# d'emploi, lieu, catégorie, exigences, séniorité) — jamais de\n# valeur inventée\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\n\"\"\"Couche de normalisation commune appelée par JobPosting.finalize().\n\nPhilosophie héritée de Lou·Ka : parseurs déterministes (regex, tables de\nmots-clés FR/EN), bornes de plausibilité, et surtout — une valeur absente\nreste absente (None / chaîne vide), on ne devine jamais.\n\"\"\"\nfrom __future__ import annotations\n\nimport datetime as _dt\nimport re\nimport unicodedata\n\n__all__ = [\n \"strip_accents\", \"clean_text\", \"clean_html\", \"parse_salary\",\n \"sanitize_salary\", \"salary_from_text\", \"salary_to_yearly\",\n \"salary_to_hourly\", \"parse_date\", \"parse_work_mode\",\n \"parse_employment_type\", \"parse_location\", \"canonical_city\",\n \"is_quebec_location\", \"categorize\", \"detect_language\",\n \"extract_benefits\", \"clean_title\", \"extract_requirements\",\n \"parse_seniority\",\n]\n\n\n# ---------------------------------------------------------------------------\n# Texte\n# ---------------------------------------------------------------------------\n\ndef strip_accents(s: str) -> str:\n return \"\".join(c for c in unicodedata.normalize(\"NFD\", s)\n if unicodedata.category(c) != \"Mn\")\n\n\ndef _key(s: str) -> str:\n \"\"\"Minuscules sans accents — pour le matching de mots-clés.\"\"\"\n return strip_accents((s or \"\").lower())\n\n\ndef clean_text(s: str) -> str:\n \"\"\"Espaces insécables -> espace, blancs réduits.\"\"\"\n s = (s or \"\").replace(\"\\xa0\", \" \").replace(\" \", \" \").replace(\" \", \" \")\n return re.sub(r\"[ \\t]+\", \" \", s).strip()\n\n\n_BLOCK_TAGS = re.compile(\n r\"]*>\", re.I)\n\n# Résidu HTML après une passe : balise ouvrante plausible ou entité encore\n# encodée — signe d'un contenu doublement échappé.\n_HTML_LEFTOVER = re.compile(\n r\"<(?:p|br|li|ul|ol|div|span|strong|em|b|i|h[1-6]|a)\\b|&(?:amp|lt|gt|nbsp|#\\d+);\",\n re.I)\n\n\ndef clean_html(html: str) -> str:\n \"\"\"HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré.\n\n Suffisant pour les descriptions d'offres (les connecteurs qui ont besoin\n d'un vrai parseur utilisent BeautifulSoup eux-mêmes).\n \"\"\"\n if not html:\n return \"\"\n import html as _html\n s = html\n # Contenus sur-échappés (entités dans les entités — Greenhouse/Lever) :\n # nettoyer en boucle tant qu'il reste des balises/entités (max 3 passes).\n for _ in range(3):\n s = _html.unescape(s)\n s = re.sub(r\"<(script|style)[^>]*>.*?\", \" \", s, flags=re.I | re.S)\n s = _BLOCK_TAGS.sub(\"\\n\", s)\n s = re.sub(r\"<[^>]+>\", \" \", s)\n if not _HTML_LEFTOVER.search(s):\n break\n s = s.replace(\"\\xa0\", \" \").replace(\" \", \" \")\n s = re.sub(r\"[ \\t]+\", \" \", s)\n s = re.sub(r\" ?\\n ?\", \"\\n\", s)\n return re.sub(r\"\\n{3,}\", \"\\n\\n\", s).strip()\n\n\n# ---------------------------------------------------------------------------\n# Salaires — transparence salariale = différenciateur clé de Job·Ka\n# ---------------------------------------------------------------------------\n\n# Bornes de plausibilité par unité (CAD). Hors bornes = on jette (pas de\n# salaire inventé, pas de « 1 $/h » venu d'un parsing raté).\n_SALARY_BOUNDS = {\n \"hour\": (14.0, 250.0),\n \"day\": (100.0, 2000.0),\n \"week\": (500.0, 10000.0),\n \"biweek\": (1000.0, 20000.0),\n \"month\": (1800.0, 45000.0),\n \"year\": (20000.0, 600000.0),\n}\n\n# Heures/an de référence pour les conversions (semaine de 40 h).\n_HOURS_PER_YEAR = 2080.0\n\n_UNIT_PATTERNS = [\n (\"hour\", r\"(?:/|par|per|an?)\\s*(?:h\\b|hr\\b|hre\\b|heure|hour)|horaire|hourly\"),\n (\"year\", r\"(?:/|par|per|an?)\\s*(?:an\\b|année|annee|year|yr\\b)|annuel|annual|yearly|per\\s+annum\"),\n (\"month\", r\"(?:/|par|per)\\s*(?:mois|month)|mensuel|monthly\"),\n (\"biweek\", r\"(?:aux|par|per)\\s*(?:2|deux|two)\\s*semaines|bi-?weekly|quinzaine\"),\n (\"week\", r\"(?:/|par|per)\\s*(?:sem\\b|semaine|week|wk\\b)|hebdomadaire|weekly\"),\n (\"day\", r\"(?:/|par|per)\\s*(?:jour|day)|journalier|daily\"),\n]\n\n_NUM_RE = re.compile(r\"\\d{1,3}(?:[   ,]\\d{3})+(?:[.,]\\d{1,2})?|\\d+(?:[.,]\\d{1,2})?[kK]?\")\n\n\ndef _parse_number(tok: str) -> float | None:\n tok = tok.strip()\n k = tok.lower().endswith(\"k\")\n if k:\n tok = tok[:-1]\n # « 52,000 » (milliers anglais) vs « 52000,00 » / « 25,50 » (décimales)\n tok = tok.replace(\" \", \" \").replace(\" \", \" \")\n if re.fullmatch(r\"\\d{1,3}(?:[ ,]\\d{3})+(?:[.,]\\d{1,2})?\", tok):\n # séparateur de milliers : retirer espaces/virgules de groupe\n tok = re.sub(r\"[ ,](?=\\d{3}\\b)\", \"\", tok)\n tok = tok.replace(\",\", \".\").replace(\" \", \"\")\n try:\n v = float(tok)\n except ValueError:\n return None\n return v * 1000 if k else v\n\n\ndef parse_salary(raw: str) -> tuple[float | None, float | None, str | None]:\n \"\"\"Texte de salaire -> (min, max, unité) ou (None, None, None).\n\n Exige un signe monétaire ($, CAD, dollars) OU une unité explicite proche\n des nombres — sans quoi « 35 heures/semaine » deviendrait un salaire.\n \"\"\"\n s = clean_text(raw)\n if not s:\n return (None, None, None)\n key = _key(s)\n\n unit = None\n for u, pat in _UNIT_PATTERNS:\n if re.search(pat, key):\n unit = u\n break\n\n has_money = bool(re.search(r\"\\$|cad\\b|dollar\", key))\n if not has_money and unit is None:\n return (None, None, None)\n\n nums = [_parse_number(m.group(0)) for m in _NUM_RE.finditer(s)]\n nums = [n for n in nums if n is not None]\n if not nums:\n return (None, None, None)\n\n # Sans unité explicite : inférer par l'ordre de grandeur (avec $ obligatoire)\n if unit is None:\n probe = max(nums)\n if probe < 300:\n unit = \"hour\"\n elif probe >= 18000:\n unit = \"year\"\n else:\n return (None, None, None) # zone ambiguë : ne pas deviner\n\n lo, hi = _SALARY_BOUNDS[unit]\n vals = sorted(n for n in nums if lo <= n <= hi)\n if not vals:\n return (None, None, None)\n return (vals[0], vals[-1] if len(vals) > 1 else vals[0], unit)\n\n\n_TO_YEAR = {\"hour\": _HOURS_PER_YEAR, \"day\": 260.0, \"week\": 52.0,\n \"biweek\": 26.0, \"month\": 12.0, \"year\": 1.0}\n\n\ndef salary_to_yearly(value: float | None, unit: str | None) -> float | None:\n if value is None or unit not in _TO_YEAR:\n return None\n return round(value * _TO_YEAR[unit], 0)\n\n\ndef salary_to_hourly(value: float | None, unit: str | None) -> float | None:\n y = salary_to_yearly(value, unit)\n return round(y / _HOURS_PER_YEAR, 2) if y is not None else None\n\n\ndef sanitize_salary(lo: float | None, hi: float | None, unit: str | None\n ) -> tuple[float | None, float | None, str | None]:\n \"\"\"Bornes de plausibilité sur les salaires STRUCTURÉS fournis par les ATS.\n\n Certaines sources étiquettent mal l'unité (75 000 « /mois » ou « /h » pour\n un salaire annuel — vu chez Lever et dans les JSON-LD de portails) : si la\n valeur sort des bornes de son unité, on tente une réinterprétation vers\n l'unité dont les bornes la contiennent (année d'abord, puis heure). Aucune\n unité plausible -> on jette (pas de salaire aberrant publié).\n \"\"\"\n if lo is None or unit not in _SALARY_BOUNDS:\n return (lo, hi, unit)\n b = _SALARY_BOUNDS[unit]\n if not (b[0] <= lo <= b[1]):\n for candidate in (\"year\", \"hour\"):\n cb = _SALARY_BOUNDS[candidate]\n if candidate != unit and cb[0] <= lo <= cb[1]:\n unit, b = candidate, cb\n break\n else:\n return (None, None, None)\n if hi is not None and not (b[0] <= hi <= b[1]):\n hi = None # max aberrant : on garde le min plausible seul\n if hi is not None and hi < lo:\n lo, hi = hi, lo\n return (lo, hi, unit)\n\n\n# Contextes où un montant en $ n'est PAS un salaire (financement, dépenses,\n# allocations…) — garde-fou de l'extraction depuis la description.\n_NOT_SALARY_CONTEXT = re.compile(\n r\"financement|funding|amorcage|seed|investisse|invest(?:ment|or)|\"\n r\"revenu[s]?\\b|revenue|chiffre d affaires|budget|remboursement|\"\n r\"reimburse|expense|allocation|allowance|subvention|credit d impot|\"\n r\"cotisation|rabais|discount|gift|carte[- ]cadeau|referral (?:bonus|fee)|\"\n r\"signing bonus|prime (?:a l embauche|de demarrage|de reference)|\"\n r\"million|milliard\")\n\n\ndef salary_from_text(text: str) -> tuple[float | None, float | None,\n str | None, str]:\n \"\"\"Cherche une mention salariale dans un texte libre (description).\n\n Découpe en phrases (les points décimaux « 182,500.00 » ne coupent pas),\n ignore les contextes non salariaux (financement, remboursements…), puis\n parse la première phrase plausible. Retourne (min, max, unité, libellé).\n \"\"\"\n if not text or \"$\" not in text:\n return (None, None, None, \"\")\n for sentence in re.split(r\"(? str | None:\n \"\"\"Valeur de date hétéroclite -> ISO « YYYY-MM-DD », ou None.\n\n Accepte : ISO (+ horodatages), epoch secondes/millisecondes, formats\n textuels FR/EN (« 1er juillet 2026 », « July 1st, 2026 »), et les dates\n relatives (« il y a 3 jours », « Posted 30+ Days Ago »).\n \"\"\"\n if raw is None:\n return None\n today = today or _dt.date.today()\n\n if isinstance(raw, (int, float)): # epoch (Lever : millisecondes)\n ts = float(raw)\n if ts > 1e12:\n ts /= 1000.0\n if 1e9 <= ts <= 4e9:\n return _dt.date.fromtimestamp(ts).isoformat()\n return None\n\n s = clean_text(str(raw))\n if not s:\n return None\n key = _key(s)\n\n m = re.search(r\"(\\d{4})-(\\d{2})-(\\d{2})\", s) # ISO (avec ou sans heure)\n if m:\n try:\n return _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3))).isoformat()\n except ValueError:\n return None\n\n if _TODAY_RE.search(key):\n return today.isoformat()\n if _YESTERDAY_RE.search(key):\n return (today - _dt.timedelta(days=1)).isoformat()\n\n m = _REL_RE.search(key)\n if m:\n n = int(m.group(1) or m.group(3))\n unit = (m.group(2) or m.group(4) or \"\").lower()\n days = n * (7 if unit.startswith((\"sem\", \"week\"))\n else 30 if unit.startswith((\"moi\", \"month\")) else 1)\n return (today - _dt.timedelta(days=days)).isoformat()\n\n # « 1er juillet 2026 » / « July 1st, 2026 » / « déc. 2026 »\n m = re.search(r\"(?:(\\d{1,2})(?:er|e|st|nd|rd|th)?\\s+)?([a-z]{3,9})\\.?,?\\s+(?:(\\d{1,2})(?:er|st|nd|rd|th)?,?\\s+)?(\\d{4})\", key)\n if m:\n mois = _MONTHS.get(m.group(2))\n if mois:\n jour = int(m.group(1) or m.group(3) or 1)\n try:\n return _dt.date(int(m.group(4)), mois, jour).isoformat()\n except ValueError:\n return None\n\n m = re.fullmatch(r\"(\\d{1,2})/(\\d{1,2})/(\\d{4})\", s) # jj/mm/aaaa (usage QC)\n if m:\n d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))\n if mo > 12 and d <= 12: # en fait mm/jj/aaaa\n d, mo = mo, d\n try:\n return _dt.date(y, mo, d).isoformat()\n except ValueError:\n return None\n return None\n\n\n# ---------------------------------------------------------------------------\n# Mode de travail / type d'emploi\n# ---------------------------------------------------------------------------\n\n_MODE_RULES = [\n (\"teletravail\", r\"100\\s*%\\s*(?:teletravail|remote)|full(?:y)?[ -]remote|\"\n r\"teletravail|travail a distance|remote(?:\\b|-first)|a distance\"),\n (\"hybride\", r\"hybrid|hybride|flexible?\\s*(?:work|travail)\"),\n (\"presentiel\", r\"presentiel|sur (?:place|site)|on[- ]?site|in[- ]?(?:office|person)\"),\n]\n\n\ndef parse_work_mode(raw: str) -> str | None:\n key = _key(raw)\n if not key:\n return None\n # « hybride » prime sur les mentions accessoires de télétravail\n if re.search(_MODE_RULES[1][1], key):\n return \"hybride\"\n for mode, pat in _MODE_RULES:\n if re.search(pat, key):\n return mode\n return None\n\n\n_TYPE_RULES = [\n (\"stage\", r\"\\bstage\\b|stagiaire|intern(?:ship)?\\b|co-?op\\b|alternance\"),\n (\"saisonnier\", r\"saisonn|seasonal\"),\n (\"contractuel\", r\"contrat\\b|contract(?:uel|or)?\\b|temporaire|temporary|terme|\"\n r\"fixed[- ]term|duree determinee|pigiste|freelance|consultant\"),\n (\"temps_partiel\", r\"temps[- ]partiel|part[- ]?time|partiel\\b\"),\n (\"temps_plein\", r\"temps[- ]plein|full[- ]?time|permanent|regulier|\"\n r\"regular\\b|duree indeterminee\"),\n]\n\n\ndef parse_employment_type(raw: str) -> str | None:\n key = _key(raw)\n if not key:\n return None\n for typ, pat in _TYPE_RULES:\n if re.search(pat, key):\n return typ\n return None\n\n\n# ---------------------------------------------------------------------------\n# Lieu — extraction ville/région + garde-fou « offre au Québec »\n# ---------------------------------------------------------------------------\n\n# Villes québécoises fréquentes dans les libellés d'offres (clé sans accents).\n# Sert à la fois au parsing et au filtre is_quebec_location.\n_QC_CITIES = {\n \"montreal\": \"Montréal\", \"quebec\": \"Québec\", \"ville de quebec\": \"Québec\",\n \"quebec city\": \"Québec\", \"laval\": \"Laval\", \"gatineau\": \"Gatineau\",\n \"longueuil\": \"Longueuil\", \"sherbrooke\": \"Sherbrooke\",\n \"saguenay\": \"Saguenay\", \"chicoutimi\": \"Saguenay\",\n \"levis\": \"Lévis\", \"trois-rivieres\": \"Trois-Rivières\",\n \"trois rivieres\": \"Trois-Rivières\", \"terrebonne\": \"Terrebonne\",\n \"brossard\": \"Brossard\", \"repentigny\": \"Repentigny\",\n \"boucherville\": \"Boucherville\", \"saint-jerome\": \"Saint-Jérôme\",\n \"drummondville\": \"Drummondville\", \"saint-jean-sur-richelieu\":\n \"Saint-Jean-sur-Richelieu\", \"granby\": \"Granby\", \"shawinigan\": \"Shawinigan\",\n \"joliette\": \"Joliette\", \"victoriaville\": \"Victoriaville\",\n \"rimouski\": \"Rimouski\", \"rouyn-noranda\": \"Rouyn-Noranda\",\n \"baie-comeau\": \"Baie-Comeau\", \"sept-iles\": \"Sept-Îles\",\n \"val-d'or\": \"Val-d'Or\", \"val d'or\": \"Val-d'Or\", \"alma\": \"Alma\",\n \"sainte-foy\": \"Québec\", \"anjou\": \"Montréal\", \"dorval\": \"Dorval\",\n \"boisbriand\": \"Boisbriand\", \"blainville\": \"Blainville\",\n \"mirabel\": \"Mirabel\", \"saint-hyacinthe\": \"Saint-Hyacinthe\",\n \"beloeil\": \"Belœil\", \"chateauguay\": \"Châteauguay\", \"magog\": \"Magog\",\n \"thetford mines\": \"Thetford Mines\", \"saint-georges\": \"Saint-Georges\",\n \"riviere-du-loup\": \"Rivière-du-Loup\", \"matane\": \"Matane\",\n \"sorel-tracy\": \"Sorel-Tracy\", \"varennes\": \"Varennes\",\n \"vaudreuil-dorion\": \"Vaudreuil-Dorion\", \"salaberry-de-valleyfield\":\n \"Salaberry-de-Valleyfield\", \"kirkland\": \"Kirkland\",\n \"pointe-claire\": \"Pointe-Claire\", \"saint-laurent\": \"Montréal\",\n \"ville saint-laurent\": \"Montréal\", \"verdun\": \"Montréal\",\n \"westmount\": \"Westmount\", \"candiac\": \"Candiac\", \"la prairie\": \"La Prairie\",\n \"mont-tremblant\": \"Mont-Tremblant\", \"saint-bruno\": \"Saint-Bruno-de-Montarville\",\n \"bromont\": \"Bromont\", \"cowansville\": \"Cowansville\",\n \"saint-augustin-de-desmaures\": \"Saint-Augustin-de-Desmaures\",\n \"l'ancienne-lorette\": \"L'Ancienne-Lorette\",\n}\n\n_QC_MARKERS = re.compile(\n r\"\\bqc\\b|\\bquebec\\b|\\bqu[e]bec, canada\\b|province de quebec\", re.I)\n_POSTAL_RE = re.compile(r\"\\b([GHJ]\\d[A-Z])\\s*(\\d[A-Z]\\d)\\b\", re.I) # G/H/J = Québec\n\n# Villes canadiennes hors Québec (et grandes villes étrangères) fréquentes\n# dans les flux pancanadiens/mondiaux — jamais des villes d'offres\n# québécoises : à rejeter lors du parsing de lieu (clés sans accents).\n_NON_QC_CITIES = frozenset({\n \"toronto\", \"ottawa\", \"vancouver\", \"calgary\", \"edmonton\", \"winnipeg\",\n \"mississauga\", \"brampton\", \"hamilton\", \"kitchener\", \"waterloo\", \"halifax\",\n \"dartmouth\", \"victoria\", \"saskatoon\", \"regina\", \"st. john's\", \"st johns\",\n \"saint john\", \"fredericton\", \"moncton\", \"charlottetown\", \"london\",\n \"markham\", \"vaughan\", \"richmond hill\", \"oakville\", \"burlington\", \"oshawa\",\n \"whitby\", \"ajax\", \"pickering\", \"guelph\", \"kingston\", \"barrie\",\n \"kelowna\", \"kamloops\", \"nanaimo\", \"abbotsford\", \"surrey\", \"burnaby\",\n \"coquitlam\", \"langley\", \"north york\", \"scarborough\", \"etobicoke\",\n \"north vancouver\", \"milton\", \"cambridge\", \"st. catharines\",\n \"niagara falls\", \"thunder bay\", \"sudbury\", \"greater sudbury\",\n \"sault ste. marie\", \"red deer\", \"lethbridge\", \"medicine hat\",\n \"grande prairie\", \"fort mcmurray\", \"yellowknife\", \"whitehorse\", \"iqaluit\",\n \"can\", \"canada\", \"ontario\", \"alberta\", \"british columbia\", \"manitoba\",\n \"saskatchewan\", \"nova scotia\", \"new brunswick\",\n \"new york\", \"new york city\", \"san francisco\", \"boston\", \"chicago\",\n \"seattle\", \"austin\", \"denver\", \"los angeles\", \"miami\", \"atlanta\",\n \"dallas\", \"houston\", \"philadelphia\", \"washington\", \"phoenix\", \"portland\",\n \"paris\", \"londres\", \"amsterdam\", \"berlin\", \"munich\", \"dublin\", \"madrid\",\n \"barcelona\", \"barcelone\", \"lisbon\", \"lisbonne\", \"sydney\", \"melbourne\",\n \"singapore\", \"singapour\", \"tokyo\", \"bangalore\", \"bengaluru\", \"mumbai\",\n \"mexico city\", \"sao paulo\", \"bogota\", \"buenos aires\", \"las vegas\",\n \"orlando\", \"nashville\", \"macau\",\n})\n\n\ndef canonical_city(raw: str) -> str:\n \"\"\"Canonicalise un nom de ville (« Montreal » -> « Montréal »,\n « Quebec City » -> « Québec »). Inconnu : retourne le texte nettoyé.\"\"\"\n s = clean_text(raw)\n if not s:\n return \"\"\n key = _key(s)\n exact = _QC_CITIES.get(key)\n if exact:\n return exact\n for ck, city in _QC_CITIES.items():\n if re.fullmatch(re.escape(ck), key):\n return city\n return s\n\n\ndef parse_location(raw: str) -> dict:\n \"\"\"Libellé de lieu -> {city, region, postal_code} (champs possiblement vides).\n\n Ex. « Montréal, QC, Canada », « Ville de Québec, Québec », « Remote - QC ».\n \"\"\"\n s = clean_text(raw)\n out = {\"city\": \"\", \"region\": \"\", \"postal_code\": \"\"}\n if not s:\n return out\n m = _POSTAL_RE.search(s)\n if m:\n out[\"postal_code\"] = f\"{m.group(1).upper()} {m.group(2).upper()}\"\n key = _key(s)\n for ck, city in _QC_CITIES.items():\n if re.search(rf\"(? bool:\n \"\"\"Vrai si le libellé pointe vers le Québec (filtre des ATS pancanadiens).\"\"\"\n key = _key(clean_text(raw))\n if not key:\n return False\n if key in _NON_QC_CITIES: # libellé = ville canadienne/monde hors QC\n return False\n if _QC_MARKERS.search(key):\n return True\n m = _POSTAL_RE.search(raw)\n if m and m.group(1)[0].upper() in \"GHJ\":\n return True\n return any(re.search(rf\"(? str:\n \"\"\"Langue du texte de l'offre : « fr », « en », « bilingue », ou \"\".\n\n Comptage de mots-outils sur le début du texte — déterministe, jamais de\n valeur inventée : texte trop court ou ambigu -> \"\".\n \"\"\"\n text = _key(f\"{title}\\n{description or ''}\"[:4000])\n if len(text) < 30:\n return \"\"\n fr = len(_FR_STOPWORDS.findall(text))\n en = len(_EN_STOPWORDS.findall(text))\n total = fr + en\n if total < 5:\n return \"\"\n # les deux langues nettement présentes -> offre bilingue\n if fr >= 8 and en >= 8 and min(fr, en) / max(fr, en) >= 0.35:\n return \"bilingue\"\n if fr > en:\n return \"fr\"\n if en > fr:\n return \"en\"\n return \"\"\n\n\n# ---------------------------------------------------------------------------\n# Avantages — extraction des blocs « Avantages / Benefits » des descriptions\n# ---------------------------------------------------------------------------\n\n_BENEFITS_HEADING = re.compile(\n r\"^\\s*(?:les\\s+|nos\\s+|our\\s+|tes\\s+|vos\\s+)?(?:avantages(?:\\s+sociaux|\"\n r\"\\s+offerts|\\s+et\\s+conditions)?|\"\n r\"benefits(?:\\s+&\\s+perks|\\s+offered)?|perks(?:\\s+(?:and|&)\\s+benefits)?|\"\n r\"ce que (?:nous|l'on|on) (?:t'|te\\s+|vous\\s+)?offr(?:ons|e)|\"\n r\"nous (?:t'|te\\s+|vous\\s+)?offrons|on (?:t'|te\\s+|vous\\s+)offre|\"\n r\"what we offer|what'?s in it for you|why join us|why work (?:with|for) us|\"\n r\"pourquoi (?:nous rejoindre|nous choisir|te joindre a nous|\"\n r\"vous joindre a nous|travailler (?:chez|avec) nous)|we offer)\"\n r\"\\s*:?\\s*$\", re.I)\n_BENEFIT_LINE_MAX = 120\n\n\ndef extract_benefits(description: str) -> list[str]:\n \"\"\"Liste d'avantages depuis un bloc structuré de la description.\n\n Cherche un titre de section « Avantages/Benefits/… » seul sur sa ligne,\n puis prend les lignes courtes qui suivent (puces), en s'arrêtant à la\n première ligne longue ou au prochain titre. Max 12 items — si le bloc ne\n ressemble pas à une liste, on ne retourne rien (pas d'à-peu-près).\n \"\"\"\n if not description:\n return []\n lines = description.split(\"\\n\")\n for i, line in enumerate(lines):\n if not _BENEFITS_HEADING.match(strip_accents(line)):\n continue\n items: list[str] = []\n for nxt in lines[i + 1:]:\n t = clean_text(nxt).lstrip(\"•-–—*·◦▪ \").strip()\n if not t:\n if items:\n break # fin du bloc (ligne vide après les puces)\n continue\n if len(t) > _BENEFIT_LINE_MAX or _BENEFITS_HEADING.match(\n strip_accents(t)):\n break\n # un « titre » de section suivant (courte ligne sans ponctuation\n # finale, capitalisée) termine aussi le bloc\n if items and len(t.split()) <= 4 and t.endswith(\":\"):\n break\n items.append(t.rstrip(\" ;,.\").strip())\n if len(items) >= 12:\n break\n if len(items) >= 2:\n return items\n return []\n\n\n# ---------------------------------------------------------------------------\n# Exigences — expérience, scolarité, langues (depuis la description)\n# ---------------------------------------------------------------------------\n\n# Années d'expérience : « 3 à 5 ans d'expérience », « minimum de 5 ans »,\n# « 5+ years of experience », « two (2) years of relevant experience »…\n_EXP_PATTERNS = [\n # « X à Y ans/years … expérience » (fourchette) — on garde le MIN\n re.compile(r\"(\\d{1,2})\\s*(?:a|-|to)\\s*\\d{1,2}\\s*(?:ans?|years?)\"\n r\"[^.\\n]{0,40}?(?:d )?experience\", re.I),\n # « X ans/années/years (+) d'expérience »\n re.compile(r\"(\\d{1,2})\\s*\\+?\\s*(?:ans?|annees?|years?)\"\n r\"[^.\\n]{0,40}?(?:d )?experience\", re.I),\n # « expérience … de X ans » / « experience of X+ years »\n re.compile(r\"experience[^.\\n]{0,50}?(?:de|of|d au moins|minimum(?: de)?|\"\n r\"at least)\\s*(\\d{1,2})\\s*\\+?\\s*(?:ans?|annees?|years?)\", re.I),\n # « minimum de X ans » / « at least X years » (sans le mot expérience)\n re.compile(r\"(?:minimum(?: de)?|au moins|at least)\\s*(\\d{1,2})\\s*\"\n r\"(?:ans?|annees?|years?)\\b\", re.I),\n]\n\n# Scolarité : du plus élevé au moins élevé — on retourne le PLUS BAS exigé\n# quand plusieurs sont acceptés (« bac ou maîtrise » -> baccalauréat).\n# Les sigles QC (DEP/DEC/AEC/DES) ne comptent que dans un contexte scolaire\n# (« dec. » est aussi l'abréviation de décembre).\n_EDU_SCHOOL_CONTEXT = re.compile(\n r\"diplome|dipl\\.|etudes|formation|scolarite|degree|diploma|education|\"\n r\"detenir|detention|obtenu|completed?\\b|graduate\", re.I)\n_EDU_LEVELS = [ # (niveau canonique, motif, sigle nécessitant le contexte ?)\n (\"secondaire\", r\"diplome d etudes secondaires|\\bdes\\b|secondaire (?:5|v)\\b|\"\n r\"high school diploma|secondary school diploma\", True),\n (\"DEP\", r\"diplome d etudes professionnelles|\\bdep\\b|\"\n r\"vocational diploma\", True),\n (\"AEC\", r\"attestation d etudes collegiales|\\baec\\b\", True),\n (\"DEC\", r\"diplome d etudes collegiales|\\bdec\\b|techniques? collegiales?|\"\n r\"college diploma|cegep\", True),\n (\"certificat\", r\"certificat universitaire|university certificate\", False),\n (\"baccalauréat\", r\"baccalaureat|bachelor|\\bbacc\\b|bac universitaire|\"\n r\"\\bbac\\b (?:en|in)\\b|undergraduate degree|\"\n r\"b\\.?\\s?(?:sc|ing|a\\.?a|com)\\b\", False),\n (\"maîtrise\", r\"maitrise|master s degree|masters degree|\\bmba\\b|\"\n r\"m\\.?\\s?sc\\.?\\b|graduate degree\", False),\n (\"doctorat\", r\"doctorat|\\bphd\\b|ph\\.?\\s?d\\b|doctoral\", False),\n]\n\n# Langues exigées : mention explicite d'exigence (pas la langue du TEXTE —\n# ça, c'est detect_language). « bilingue », « anglais requis », « fluent in\n# English », « maîtrise du français »…\n_LANG_BILINGUAL = re.compile(r\"bilingu\", re.I)\n_LANG_REQ_FR = re.compile(\n r\"francais[^.\\n]{0,30}?(?:requis|exige|obligatoire|essentiel|\"\n r\"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|\"\n r\"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |du )?\"\n r\"francais|french (?:is )?(?:required|mandatory|essential)|\"\n r\"fluent in french|fluency in french|french proficiency\", re.I)\n_LANG_REQ_EN = re.compile(\n r\"anglais[^.\\n]{0,30}?(?:requis|exige|obligatoire|essentiel|\"\n r\"fonctionnel|avance|courant|parle et ecrit|oral et ecrit)|\"\n r\"(?:maitrise|connaissance|excellente maitrise) (?:de la langue |de l )?\"\n r\"anglais|english (?:is )?(?:required|mandatory|essential)|\"\n r\"fluent in english|fluency in english|english proficiency\", re.I)\n\n\ndef extract_requirements(title: str, description: str) -> dict:\n \"\"\"Exigences structurées depuis le texte de l'offre — clés présentes\n seulement quand l'information est explicite (jamais de valeur inventée) :\n\n - ``experience_years`` : années d'expérience minimales exigées (int)\n - ``education`` : plus bas diplôme exigé (secondaire, DEP, AEC,\n DEC, certificat, baccalauréat, maîtrise, doctorat)\n - ``languages`` : langues exigées ([\"français\"], [\"anglais\"], …)\n \"\"\"\n out: dict = {}\n text = _key(f\"{title or ''}\\n{description or ''}\"[:8000])\n text = re.sub(r\"[’']\", \" \", text) # « d'expérience » -> « d experience »\n if len(text) < 20:\n return out\n\n years: list[int] = []\n for pat in _EXP_PATTERNS:\n for m in pat.finditer(text):\n try:\n n = int(m.group(1))\n except (TypeError, ValueError):\n continue\n if 1 <= n <= 30: # bornes de plausibilité\n years.append(n)\n if years:\n out[\"experience_years\"] = min(years) # l'exigence MINIMALE\n\n for level, pat, needs_ctx in _EDU_LEVELS:\n for m in re.finditer(pat, text):\n if needs_ctx:\n window = text[max(0, m.start() - 120):m.end() + 120]\n if not _EDU_SCHOOL_CONTEXT.search(window):\n continue\n out[\"education\"] = level\n break\n if \"education\" in out:\n break # plus bas niveau exigé trouvé\n\n langs: list[str] = []\n if _LANG_BILINGUAL.search(text):\n langs = [\"français\", \"anglais\"]\n else:\n if _LANG_REQ_FR.search(text):\n langs.append(\"français\")\n if _LANG_REQ_EN.search(text):\n langs.append(\"anglais\")\n if langs:\n out[\"languages\"] = langs\n return out\n\n\n# ---------------------------------------------------------------------------\n# Séniorité — stage | junior | intermediaire | senior | direction\n# ---------------------------------------------------------------------------\n\n_SENIORITY_TITLE = [\n (\"stage\", r\"\\bstagiaire\\b|\\bstage\\b|\\bintern(?:ship)?\\b|\\bco-?op\\b|\"\n r\"alternance|etudiant\"),\n (\"direction\", r\"directeur|directrice|\\bdirector\\b|vice[- ]?president|\"\n r\"\\bvp\\b|\\bpdg\\b|\\bceo\\b|\\bcfo\\b|\\bcto\\b|\\bcoo\\b|\"\n r\"chef de (?:service|departement|division|la direction)|\"\n r\"head of|\\bdg\\b|gestionnaire de (?:service|departement)\"),\n (\"senior\", r\"\\bsenior\\b|\\bsr\\.?\\b|\\bprincipal(?:e)?\\b|\\blead\\b|\"\n r\"\\bexpert(?:e)?\\b|chef d equipe|team lead|superviseur|\"\n r\"\\bniveau (?:3|iii)\\b|\\biii\\b\"),\n (\"junior\", r\"\\bjunior\\b|\\bjr\\.?\\b|debutant|entry[- ]level|\"\n r\"\\bniveau (?:1|i)\\b|premier emploi|releve\"),\n (\"intermediaire\", r\"intermediaire|intermediate|\\bniveau (?:2|ii)\\b|\\bii\\b\"),\n]\n\n# libellés d'expérience des ATS (SmartRecruiters/LinkedIn-style) -> séniorité\n_SENIORITY_ATS = [\n (\"stage\", r\"internship|stage\"),\n (\"direction\", r\"director|executive|direction\"),\n (\"senior\", r\"mid[- ]?senior|senior\"),\n (\"intermediaire\", r\"associate|intermediaire|intermediate|mid[- ]?level\"),\n (\"junior\", r\"entry|junior|debutant\"),\n]\n\n\ndef parse_seniority(title: str, requirements: dict | None = None,\n employment_type: str | None = None,\n description: str = \"\") -> str | None:\n \"\"\"Niveau de séniorité du poste, ou None si aucun signal explicite.\n\n Ordre des signaux : type d'emploi « stage », titre du poste, libellé\n d'expérience fourni par l'ATS, puis années d'expérience exigées.\n \"\"\"\n if employment_type == \"stage\":\n return \"stage\"\n key = _key(title or \"\")\n if key:\n for level, pat in _SENIORITY_TITLE:\n if re.search(pat, key):\n return level\n req = requirements or {}\n label = _key(str(req.get(\"experience\", \"\")))\n if label and \"not applicable\" not in label:\n for level, pat in _SENIORITY_ATS:\n if re.search(pat, label):\n return level\n years = req.get(\"experience_years\")\n if isinstance(years, (int, float)):\n if years <= 2:\n return \"junior\"\n if years <= 4:\n return \"intermediaire\"\n return \"senior\"\n return None\n\n\n# ---------------------------------------------------------------------------\n# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés\n# ---------------------------------------------------------------------------\n\n# codes de réquisition : « [R-12345] », « (REQ 2026-42) », « - JR0035678 »…\n_REQ_CODE = re.compile(\n # en tête : « 123456 - Titre », « [REQ-1234] Titre » (≥5 chiffres nus pour\n # ne pas manger une année « 2026 - Programme d'été »)\n r\"^\\s*[\\[(]?(?:req(?:uisition)?|jr|job)[\\s#:-]*\\d{3,10}[\\])]?\\s*[-–—:|]\\s*|\"\n r\"^\\s*[\\[(]?\\d{5,10}[\\])]?\\s*[-–—:|]\\s*|\"\n # en queue : « Titre - R-12345 », « Titre (JOB 004521) »\n r\"\\s*[-–—|(]\\s*(?:req(?:uisition)?|job|poste)?\\s*#?\\s*(?:[A-Z]{1,4}-?)?\\d{4,10}\\s*[)\\]]?\\s*$|\"\n # au milieu : « Titre [R-12345] suite »\n r\"\\s*[\\[(](?:R|REQ|JR|JOB)[-_ ]?\\d{3,10}[\\])]\\s*\", re.I)\n\n# petits mots gardés en minuscules lors de la remise en casse d'un titre\n# tout-majuscules ; sigles usuels gardés en majuscules\n_TITLE_SMALL = {\"de\", \"du\", \"des\", \"la\", \"le\", \"les\", \"et\", \"en\", \"à\", \"a\",\n \"au\", \"aux\", \"d'\", \"l'\", \"of\", \"the\", \"and\", \"in\", \"for\",\n \"to\", \"or\", \"ou\", \"un\", \"une\", \"sur\", \"pour\"}\n_TITLE_ACRONYMS = {\"rh\", \"ti\", \"it\", \"qa\", \"qc\", \"pab\", \"cpa\", \"cfa\", \"erp\",\n \"sap\", \"crm\", \"sst\", \"cnc\", \"hvac\", \"rpa\", \"bi\", \"ia\",\n \"ai\", \"vp\", \"pdg\", \"dg\"}\n\n\ndef _smart_case(title: str) -> str:\n \"\"\"Titre TOUT EN MAJUSCULES -> casse de phrase lisible (sigles préservés).\"\"\"\n words = title.lower().split()\n out = []\n for i, w in enumerate(words):\n base = w.strip(\"()[],.;:/\")\n if base in _TITLE_ACRONYMS:\n out.append(w.replace(base, base.upper()))\n elif i > 0 and base in _TITLE_SMALL:\n out.append(w)\n else:\n out.append(w[:1].upper() + w[1:])\n return \" \".join(out)\n\n\ndef clean_title(title: str, city: str = \"\") -> str:\n \"\"\"Titre d'affichage : codes de réquisition retirés, suffixe « - »\n redondant retiré, casse corrigée si tout-majuscules. Le titre SOURCE n'est\n jamais modifié — ceci alimente une colonne d'affichage distincte.\"\"\"\n t = clean_text(title)\n if not t:\n return \"\"\n prev = None\n while prev != t: # les codes peuvent être empilés\n prev = t\n t = _REQ_CODE.sub(\" \", t).strip(\" -–—|:\")\n t = clean_text(t)\n # suffixe « - Montréal » / « (Montréal, QC) » identique à la ville de\n # l'offre : repéré sans accents, coupé sur le titre original\n if city:\n ck = re.escape(strip_accents(city.lower()))\n m = re.search(\n rf\"\\s*[-–—|,(]\\s*(?:ville de\\s+)?{ck}(?:\\s*,\\s*(?:qc|quebec))?\\s*\\)?\\s*$\",\n strip_accents(t.lower()))\n if m and m.start() > 0:\n t = clean_text(t[:m.start()]).strip(\" -–—|,(\")\n if len(t) > 8 and t == t.upper() and t != t.lower():\n t = _smart_case(t)\n return t or clean_text(title)\n\n\ndef categorize(title: str, description: str = \"\") -> str:\n \"\"\"Titre -> catégorie interne (repli : début de description), ou \"\".\n\n Le titre est essayé SEUL d'abord : la description commence souvent par le\n boilerplate de l'entreprise (« leader du manufacturier… ») qui fausserait\n la catégorie du poste.\n \"\"\"\n for texte in (title, description[:300]):\n key = _key(texte or \"\")\n if not key.strip():\n continue\n for cat, pat in _CATEGORY_RULES:\n if re.search(pat, key):\n return cat\n return \"\"\n","structuredPatch":[{"oldStart":812,"oldLines":8,"newStart":812,"newLines":7,"lines":[" "," "," def parse_seniority(title: str, requirements: dict | None = None,","- employment_type: str | None = None,","- description: str = \"\") -> str | None:","+ employment_type: str | None = None) -> str | None:"," \"\"\"Niveau de séniorité du poste, ou None si aucun signal explicite."," "," Ordre des signaux : type d'emploi « stage », titre du poste, libellé"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcDMD5MS9gnTCZHRAKF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CATUb2ygCzetjHhJxJ7FZ8","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/schema.py","old_string":"# Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale\n# Créé : 2026-08-17 Modifié : 2026-08-17","new_string":"# Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale\n# Créé : 2026-08-17 Modifié : 2026-08-25"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3908,"cache_read_input_tokens":120739,"cache_creation":{"ephemeral_5m_input_tokens":3908,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"9c24368e-abb2-43c2-9170-18bcd30441df","request_id":"req_011CePcDJxt7sLtUJ49qSpEi"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CATUb2ygCzetjHhJxJ7FZ8","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/schema.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"28d6f365-7855-402c-a726-82f5b1e1f8ce","timestamp":"2026-08-25T14:16:08.453Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/schema.py","oldString":"# Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale\n# Créé : 2026-08-17 Modifié : 2026-08-17","newString":"# Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale\n# Créé : 2026-08-17 Modifié : 2026-08-25","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/schema.py\n# Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale\n# Créé : 2026-08-17 Modifié : 2026-08-17\n# =============================================================================\n\"\"\"Schéma standard d'une offre d'emploi (JobPosting) et normalisation.\n\nChaque connecteur, peu importe l'employeur ou l'ATS source, doit produire des\nobjets `JobPosting` conformes à ce schéma. La méthode `finalize()` applique\nensuite la couche de normalisation commune (jobka/normalize.py) : dates ISO,\nsalaires numériques + conversions $/h et $/an, mode de travail, type d'emploi,\ncatégorie… — les connecteurs restent simples et remplissent les champs bruts.\n\"\"\"\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nfrom dataclasses import dataclass, field, asdict\n\nfrom .normalize import ( # ré-exportés pour les connecteurs\n canonical_city,\n categorize,\n clean_html,\n clean_text,\n detect_language,\n extract_benefits,\n is_quebec_location,\n parse_date,\n parse_employment_type,\n parse_location,\n parse_salary,\n parse_work_mode,\n salary_from_text,\n salary_to_hourly,\n salary_to_yearly,\n sanitize_salary,\n strip_accents,\n)\n\n__all__ = [\n \"JobPosting\", \"categorize\", \"clean_html\", \"clean_text\",\n \"is_quebec_location\", \"parse_date\", \"parse_employment_type\",\n \"parse_location\", \"parse_salary\", \"parse_work_mode\", \"strip_accents\",\n]\n\n\n@dataclass\nclass JobPosting:\n \"\"\"Offre d'emploi standardisée Job·Ka.\"\"\"\n\n source: str # id du connecteur (voir data/sources.json)\n external_id: str # identifiant chez l'employeur/ATS\n url: str # lien direct vers l'offre — zéro boîte noire\n employer: str = \"\" # nom de l'entreprise\n title: str = \"\" # titre du poste\n description: str = \"\" # texte complet (HTML nettoyé)\n address: str = \"\" # adresse du lieu de travail si disponible\n city: str = \"\" # Montréal, Québec, Sherbrooke…\n region: str = \"\" # région administrative / « Québec »\n postal_code: str = \"\"\n location_label: str = \"\" # libellé de lieu original (brut)\n work_mode: str | None = None # presentiel | hybride | teletravail\n employment_type: str | None = None # temps_plein | temps_partiel |\n # contractuel | stage | saisonnier\n salary_min: float | None = None # nombres, jamais des strings\n salary_max: float | None = None\n salary_unit: str | None = None # hour | day | week | biweek | month | year\n salary_label: str = \"\" # texte original (ex. « 25 $ à 30 $/h »)\n benefits: list[str] = field(default_factory=list)\n requirements: dict = field(default_factory=dict) # scolarité, expérience, langues\n date_posted: str | None = None # ISO 8601\n date_deadline: str | None = None # ISO 8601 (date limite pour postuler)\n category: str = \"\" # taxonomie interne (TI, Santé, …)\n ats: str = \"\" # workday | lever | greenhouse | custom…\n details: dict = field(default_factory=dict) # champs structurés (sparse)\n lat: float | None = None\n lng: float | None = None\n company_logo: str = \"\" # URL du logo employeur (si exposé)\n language: str = \"\" # fr | en | bilingue (\"\" = indéterminée)\n apply_url: str = \"\" # lien de candidature directe (≠ url fiche)\n\n @property\n def uid(self) -> str:\n return f\"{self.source}:{self.external_id}\"\n\n def content_hash(self) -> str:\n \"\"\"Hash du contenu pour la détection de changements (pseudo-webhook).\"\"\"\n payload = asdict(self)\n blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)\n return hashlib.sha256(blob.encode(\"utf-8\")).hexdigest()\n\n # Conversions dérivées — calculées à la volée, PAS dans content_hash\n # (elles découlent de salary_min/max/unit, les stocker dans le hash ne\n # ferait que dupliquer l'information).\n def salary_year_min(self) -> float | None:\n return salary_to_yearly(self.salary_min, self.salary_unit)\n\n def salary_year_max(self) -> float | None:\n return salary_to_yearly(self.salary_max, self.salary_unit)\n\n def salary_hour_min(self) -> float | None:\n return salary_to_hourly(self.salary_min, self.salary_unit)\n\n def salary_hour_max(self) -> float | None:\n return salary_to_hourly(self.salary_max, self.salary_unit)\n\n def finalize(self) -> \"JobPosting\":\n \"\"\"Applique la normalisation commune. Appelé par le pipeline d'ingestion.\n\n Idempotent ; ne remplace jamais une valeur explicite du connecteur.\n \"\"\"\n self.title = clean_text(self.title)\n self.employer = clean_text(self.employer)\n self.description = clean_text(self.description) if \"\\n\" not in self.description \\\n else self.description.strip()\n\n # lieu : compléter ville/région/code postal depuis le libellé brut,\n # puis canonicaliser la ville (« Montreal » -> « Montréal »)\n if self.location_label and not (self.city and self.region):\n loc = parse_location(self.location_label)\n self.city = self.city or loc[\"city\"]\n self.region = self.region or loc[\"region\"]\n self.postal_code = self.postal_code or loc[\"postal_code\"]\n self.city = canonical_city(self.city)\n # région administrative (17 régions, répertoire MAMH embarqué) ;\n # repli : « Québec » (province) quand seule l'appartenance QC est sûre\n if self.city and (not self.region or self.region == \"Québec\"):\n from .regions import region_for_city\n self.region = region_for_city(self.city) or self.region\n if self.city and not self.region and is_quebec_location(self.city):\n self.region = \"Québec\"\n\n # salaire : parser le libellé si le connecteur n'a pas de valeurs numériques\n if self.salary_min is None and self.salary_label:\n lo, hi, unit = parse_salary(self.salary_label)\n self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit\n if self.salary_min is None and self.description:\n # mention salariale dans la description (fréquent au QC) — avec\n # garde-fou de contexte (financement, remboursements ≠ salaire)\n lo, hi, unit, label = salary_from_text(self.description)\n if lo is not None:\n self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit\n self.salary_label = self.salary_label or label\n # bornes de plausibilité aussi sur les valeurs STRUCTURÉES des ATS\n # (unités mal étiquetées : « 75 000 $/mois » qui est un annuel…)\n self.salary_min, self.salary_max, self.salary_unit = sanitize_salary(\n self.salary_min, self.salary_max, self.salary_unit)\n if self.salary_min is None:\n self.salary_unit = None\n\n # dates : accepter epoch/textuel/relatif -> ISO\n if self.date_posted is not None:\n self.date_posted = parse_date(self.date_posted)\n if self.date_deadline is not None:\n self.date_deadline = parse_date(self.date_deadline)\n\n # mode de travail / type d'emploi : depuis le libellé de lieu, le titre\n # puis la description — première réponse trouvée, jamais de défaut\n if self.work_mode is None:\n for texte in (self.location_label, self.title, self.description[:500]):\n self.work_mode = parse_work_mode(texte)\n if self.work_mode:\n break\n if self.employment_type is None:\n for texte in (self.details.get(\"employment_label\", \"\"), self.title,\n self.description[:500]):\n self.employment_type = parse_employment_type(str(texte))\n if self.employment_type:\n break\n\n if not self.category:\n self.category = categorize(self.title, self.description)\n\n # langue de l'offre : valeur source si fournie, sinon détection légère\n if not self.language:\n self.language = detect_language(self.title, self.description)\n elif self.language not in (\"fr\", \"en\", \"bilingue\"):\n lang = str(self.language).lower()[:2]\n self.language = {\"fr\": \"fr\", \"en\": \"en\"}.get(lang, \"\")\n\n # avantages : blocs structurés « Avantages / Benefits » des descriptions\n if not self.benefits and self.description:\n self.benefits = extract_benefits(self.description)\n\n # coordonnées fournies par la source : rejeter tout point hors de la\n # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra\n # le relais sur l'adresse/la ville\n if self.lat is not None and self.lng is not None:\n if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):\n self.lat = self.lng = None\n return self\n","structuredPatch":[{"oldStart":4,"oldLines":7,"newStart":4,"newLines":7,"lines":[" # Contact : contact@spboucher.ai"," # Fichier : jobka/schema.py"," # Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale","-# Créé : 2026-08-17 Modifié : 2026-08-17","+# Créé : 2026-08-17 Modifié : 2026-08-25"," # ============================================================================="," \"\"\"Schéma standard d'une offre d'emploi (JobPosting) et normalisation."," "]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcDMD5MS9gnTCZHRAKF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VMSJ5NHRFL7SbAHyqLNYCd","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/schema.py","old_string":" detect_language,\n extract_benefits,\n is_quebec_location,\n parse_date,\n parse_employment_type,\n parse_location,\n parse_salary,\n parse_work_mode,","new_string":" detect_language,\n extract_benefits,\n extract_requirements,\n is_quebec_location,\n parse_date,\n parse_employment_type,\n parse_location,\n parse_salary,\n parse_seniority,\n parse_work_mode,"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3908,"cache_read_input_tokens":120739,"cache_creation":{"ephemeral_5m_input_tokens":3908,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"852f5157-af45-40b4-afd2-60b9510f6a66","request_id":"req_011CePcDJxt7sLtUJ49qSpEi"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VMSJ5NHRFL7SbAHyqLNYCd","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/schema.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"f82991f6-9368-46d5-8405-c6e4f29cf64b","timestamp":"2026-08-25T14:16:10.580Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/schema.py","oldString":" detect_language,\n extract_benefits,\n is_quebec_location,\n parse_date,\n parse_employment_type,\n parse_location,\n parse_salary,\n parse_work_mode,","newString":" detect_language,\n extract_benefits,\n extract_requirements,\n is_quebec_location,\n parse_date,\n parse_employment_type,\n parse_location,\n parse_salary,\n parse_seniority,\n parse_work_mode,","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/schema.py\n# Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\n\"\"\"Schéma standard d'une offre d'emploi (JobPosting) et normalisation.\n\nChaque connecteur, peu importe l'employeur ou l'ATS source, doit produire des\nobjets `JobPosting` conformes à ce schéma. La méthode `finalize()` applique\nensuite la couche de normalisation commune (jobka/normalize.py) : dates ISO,\nsalaires numériques + conversions $/h et $/an, mode de travail, type d'emploi,\ncatégorie… — les connecteurs restent simples et remplissent les champs bruts.\n\"\"\"\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nfrom dataclasses import dataclass, field, asdict\n\nfrom .normalize import ( # ré-exportés pour les connecteurs\n canonical_city,\n categorize,\n clean_html,\n clean_text,\n detect_language,\n extract_benefits,\n is_quebec_location,\n parse_date,\n parse_employment_type,\n parse_location,\n parse_salary,\n parse_work_mode,\n salary_from_text,\n salary_to_hourly,\n salary_to_yearly,\n sanitize_salary,\n strip_accents,\n)\n\n__all__ = [\n \"JobPosting\", \"categorize\", \"clean_html\", \"clean_text\",\n \"is_quebec_location\", \"parse_date\", \"parse_employment_type\",\n \"parse_location\", \"parse_salary\", \"parse_work_mode\", \"strip_accents\",\n]\n\n\n@dataclass\nclass JobPosting:\n \"\"\"Offre d'emploi standardisée Job·Ka.\"\"\"\n\n source: str # id du connecteur (voir data/sources.json)\n external_id: str # identifiant chez l'employeur/ATS\n url: str # lien direct vers l'offre — zéro boîte noire\n employer: str = \"\" # nom de l'entreprise\n title: str = \"\" # titre du poste\n description: str = \"\" # texte complet (HTML nettoyé)\n address: str = \"\" # adresse du lieu de travail si disponible\n city: str = \"\" # Montréal, Québec, Sherbrooke…\n region: str = \"\" # région administrative / « Québec »\n postal_code: str = \"\"\n location_label: str = \"\" # libellé de lieu original (brut)\n work_mode: str | None = None # presentiel | hybride | teletravail\n employment_type: str | None = None # temps_plein | temps_partiel |\n # contractuel | stage | saisonnier\n salary_min: float | None = None # nombres, jamais des strings\n salary_max: float | None = None\n salary_unit: str | None = None # hour | day | week | biweek | month | year\n salary_label: str = \"\" # texte original (ex. « 25 $ à 30 $/h »)\n benefits: list[str] = field(default_factory=list)\n requirements: dict = field(default_factory=dict) # scolarité, expérience, langues\n date_posted: str | None = None # ISO 8601\n date_deadline: str | None = None # ISO 8601 (date limite pour postuler)\n category: str = \"\" # taxonomie interne (TI, Santé, …)\n ats: str = \"\" # workday | lever | greenhouse | custom…\n details: dict = field(default_factory=dict) # champs structurés (sparse)\n lat: float | None = None\n lng: float | None = None\n company_logo: str = \"\" # URL du logo employeur (si exposé)\n language: str = \"\" # fr | en | bilingue (\"\" = indéterminée)\n apply_url: str = \"\" # lien de candidature directe (≠ url fiche)\n\n @property\n def uid(self) -> str:\n return f\"{self.source}:{self.external_id}\"\n\n def content_hash(self) -> str:\n \"\"\"Hash du contenu pour la détection de changements (pseudo-webhook).\"\"\"\n payload = asdict(self)\n blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)\n return hashlib.sha256(blob.encode(\"utf-8\")).hexdigest()\n\n # Conversions dérivées — calculées à la volée, PAS dans content_hash\n # (elles découlent de salary_min/max/unit, les stocker dans le hash ne\n # ferait que dupliquer l'information).\n def salary_year_min(self) -> float | None:\n return salary_to_yearly(self.salary_min, self.salary_unit)\n\n def salary_year_max(self) -> float | None:\n return salary_to_yearly(self.salary_max, self.salary_unit)\n\n def salary_hour_min(self) -> float | None:\n return salary_to_hourly(self.salary_min, self.salary_unit)\n\n def salary_hour_max(self) -> float | None:\n return salary_to_hourly(self.salary_max, self.salary_unit)\n\n def finalize(self) -> \"JobPosting\":\n \"\"\"Applique la normalisation commune. Appelé par le pipeline d'ingestion.\n\n Idempotent ; ne remplace jamais une valeur explicite du connecteur.\n \"\"\"\n self.title = clean_text(self.title)\n self.employer = clean_text(self.employer)\n self.description = clean_text(self.description) if \"\\n\" not in self.description \\\n else self.description.strip()\n\n # lieu : compléter ville/région/code postal depuis le libellé brut,\n # puis canonicaliser la ville (« Montreal » -> « Montréal »)\n if self.location_label and not (self.city and self.region):\n loc = parse_location(self.location_label)\n self.city = self.city or loc[\"city\"]\n self.region = self.region or loc[\"region\"]\n self.postal_code = self.postal_code or loc[\"postal_code\"]\n self.city = canonical_city(self.city)\n # région administrative (17 régions, répertoire MAMH embarqué) ;\n # repli : « Québec » (province) quand seule l'appartenance QC est sûre\n if self.city and (not self.region or self.region == \"Québec\"):\n from .regions import region_for_city\n self.region = region_for_city(self.city) or self.region\n if self.city and not self.region and is_quebec_location(self.city):\n self.region = \"Québec\"\n\n # salaire : parser le libellé si le connecteur n'a pas de valeurs numériques\n if self.salary_min is None and self.salary_label:\n lo, hi, unit = parse_salary(self.salary_label)\n self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit\n if self.salary_min is None and self.description:\n # mention salariale dans la description (fréquent au QC) — avec\n # garde-fou de contexte (financement, remboursements ≠ salaire)\n lo, hi, unit, label = salary_from_text(self.description)\n if lo is not None:\n self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit\n self.salary_label = self.salary_label or label\n # bornes de plausibilité aussi sur les valeurs STRUCTURÉES des ATS\n # (unités mal étiquetées : « 75 000 $/mois » qui est un annuel…)\n self.salary_min, self.salary_max, self.salary_unit = sanitize_salary(\n self.salary_min, self.salary_max, self.salary_unit)\n if self.salary_min is None:\n self.salary_unit = None\n\n # dates : accepter epoch/textuel/relatif -> ISO\n if self.date_posted is not None:\n self.date_posted = parse_date(self.date_posted)\n if self.date_deadline is not None:\n self.date_deadline = parse_date(self.date_deadline)\n\n # mode de travail / type d'emploi : depuis le libellé de lieu, le titre\n # puis la description — première réponse trouvée, jamais de défaut\n if self.work_mode is None:\n for texte in (self.location_label, self.title, self.description[:500]):\n self.work_mode = parse_work_mode(texte)\n if self.work_mode:\n break\n if self.employment_type is None:\n for texte in (self.details.get(\"employment_label\", \"\"), self.title,\n self.description[:500]):\n self.employment_type = parse_employment_type(str(texte))\n if self.employment_type:\n break\n\n if not self.category:\n self.category = categorize(self.title, self.description)\n\n # langue de l'offre : valeur source si fournie, sinon détection légère\n if not self.language:\n self.language = detect_language(self.title, self.description)\n elif self.language not in (\"fr\", \"en\", \"bilingue\"):\n lang = str(self.language).lower()[:2]\n self.language = {\"fr\": \"fr\", \"en\": \"en\"}.get(lang, \"\")\n\n # avantages : blocs structurés « Avantages / Benefits » des descriptions\n if not self.benefits and self.description:\n self.benefits = extract_benefits(self.description)\n\n # coordonnées fournies par la source : rejeter tout point hors de la\n # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra\n # le relais sur l'adresse/la ville\n if self.lat is not None and self.lng is not None:\n if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):\n self.lat = self.lng = None\n return self\n","structuredPatch":[{"oldStart":27,"oldLines":11,"newStart":27,"newLines":13,"lines":[" clean_text,"," detect_language,"," extract_benefits,","+ extract_requirements,"," is_quebec_location,"," parse_date,"," parse_employment_type,"," parse_location,"," parse_salary,","+ parse_seniority,"," parse_work_mode,"," salary_from_text,"," salary_to_hourly,"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcDMD5MS9gnTCZHRAKF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01D5aQdBJ8DstwVMx4gd2YEv","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/schema.py","old_string":" company_logo: str = \"\" # URL du logo employeur (si exposé)\n language: str = \"\" # fr | en | bilingue (\"\" = indéterminée)\n apply_url: str = \"\" # lien de candidature directe (≠ url fiche)","new_string":" company_logo: str = \"\" # URL du logo employeur (si exposé)\n language: str = \"\" # fr | en | bilingue (\"\" = indéterminée)\n apply_url: str = \"\" # lien de candidature directe (≠ url fiche)\n seniority: str | None = None # stage | junior | intermediaire |\n # senior | direction (None = indéterminée)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3908,"cache_read_input_tokens":120739,"cache_creation":{"ephemeral_5m_input_tokens":3908,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"df1ad32d-5622-4ccc-bf14-f80ad5fbc8e1","request_id":"req_011CePcDJxt7sLtUJ49qSpEi"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01D5aQdBJ8DstwVMx4gd2YEv","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/schema.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"e0739d1b-2835-4731-850b-c51ceaeb3d7f","timestamp":"2026-08-25T14:16:14.118Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/schema.py","oldString":" company_logo: str = \"\" # URL du logo employeur (si exposé)\n language: str = \"\" # fr | en | bilingue (\"\" = indéterminée)\n apply_url: str = \"\" # lien de candidature directe (≠ url fiche)","newString":" company_logo: str = \"\" # URL du logo employeur (si exposé)\n language: str = \"\" # fr | en | bilingue (\"\" = indéterminée)\n apply_url: str = \"\" # lien de candidature directe (≠ url fiche)\n seniority: str | None = None # stage | junior | intermediaire |\n # senior | direction (None = indéterminée)","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/schema.py\n# Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\n\"\"\"Schéma standard d'une offre d'emploi (JobPosting) et normalisation.\n\nChaque connecteur, peu importe l'employeur ou l'ATS source, doit produire des\nobjets `JobPosting` conformes à ce schéma. La méthode `finalize()` applique\nensuite la couche de normalisation commune (jobka/normalize.py) : dates ISO,\nsalaires numériques + conversions $/h et $/an, mode de travail, type d'emploi,\ncatégorie… — les connecteurs restent simples et remplissent les champs bruts.\n\"\"\"\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nfrom dataclasses import dataclass, field, asdict\n\nfrom .normalize import ( # ré-exportés pour les connecteurs\n canonical_city,\n categorize,\n clean_html,\n clean_text,\n detect_language,\n extract_benefits,\n extract_requirements,\n is_quebec_location,\n parse_date,\n parse_employment_type,\n parse_location,\n parse_salary,\n parse_seniority,\n parse_work_mode,\n salary_from_text,\n salary_to_hourly,\n salary_to_yearly,\n sanitize_salary,\n strip_accents,\n)\n\n__all__ = [\n \"JobPosting\", \"categorize\", \"clean_html\", \"clean_text\",\n \"is_quebec_location\", \"parse_date\", \"parse_employment_type\",\n \"parse_location\", \"parse_salary\", \"parse_work_mode\", \"strip_accents\",\n]\n\n\n@dataclass\nclass JobPosting:\n \"\"\"Offre d'emploi standardisée Job·Ka.\"\"\"\n\n source: str # id du connecteur (voir data/sources.json)\n external_id: str # identifiant chez l'employeur/ATS\n url: str # lien direct vers l'offre — zéro boîte noire\n employer: str = \"\" # nom de l'entreprise\n title: str = \"\" # titre du poste\n description: str = \"\" # texte complet (HTML nettoyé)\n address: str = \"\" # adresse du lieu de travail si disponible\n city: str = \"\" # Montréal, Québec, Sherbrooke…\n region: str = \"\" # région administrative / « Québec »\n postal_code: str = \"\"\n location_label: str = \"\" # libellé de lieu original (brut)\n work_mode: str | None = None # presentiel | hybride | teletravail\n employment_type: str | None = None # temps_plein | temps_partiel |\n # contractuel | stage | saisonnier\n salary_min: float | None = None # nombres, jamais des strings\n salary_max: float | None = None\n salary_unit: str | None = None # hour | day | week | biweek | month | year\n salary_label: str = \"\" # texte original (ex. « 25 $ à 30 $/h »)\n benefits: list[str] = field(default_factory=list)\n requirements: dict = field(default_factory=dict) # scolarité, expérience, langues\n date_posted: str | None = None # ISO 8601\n date_deadline: str | None = None # ISO 8601 (date limite pour postuler)\n category: str = \"\" # taxonomie interne (TI, Santé, …)\n ats: str = \"\" # workday | lever | greenhouse | custom…\n details: dict = field(default_factory=dict) # champs structurés (sparse)\n lat: float | None = None\n lng: float | None = None\n company_logo: str = \"\" # URL du logo employeur (si exposé)\n language: str = \"\" # fr | en | bilingue (\"\" = indéterminée)\n apply_url: str = \"\" # lien de candidature directe (≠ url fiche)\n\n @property\n def uid(self) -> str:\n return f\"{self.source}:{self.external_id}\"\n\n def content_hash(self) -> str:\n \"\"\"Hash du contenu pour la détection de changements (pseudo-webhook).\"\"\"\n payload = asdict(self)\n blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)\n return hashlib.sha256(blob.encode(\"utf-8\")).hexdigest()\n\n # Conversions dérivées — calculées à la volée, PAS dans content_hash\n # (elles découlent de salary_min/max/unit, les stocker dans le hash ne\n # ferait que dupliquer l'information).\n def salary_year_min(self) -> float | None:\n return salary_to_yearly(self.salary_min, self.salary_unit)\n\n def salary_year_max(self) -> float | None:\n return salary_to_yearly(self.salary_max, self.salary_unit)\n\n def salary_hour_min(self) -> float | None:\n return salary_to_hourly(self.salary_min, self.salary_unit)\n\n def salary_hour_max(self) -> float | None:\n return salary_to_hourly(self.salary_max, self.salary_unit)\n\n def finalize(self) -> \"JobPosting\":\n \"\"\"Applique la normalisation commune. Appelé par le pipeline d'ingestion.\n\n Idempotent ; ne remplace jamais une valeur explicite du connecteur.\n \"\"\"\n self.title = clean_text(self.title)\n self.employer = clean_text(self.employer)\n self.description = clean_text(self.description) if \"\\n\" not in self.description \\\n else self.description.strip()\n\n # lieu : compléter ville/région/code postal depuis le libellé brut,\n # puis canonicaliser la ville (« Montreal » -> « Montréal »)\n if self.location_label and not (self.city and self.region):\n loc = parse_location(self.location_label)\n self.city = self.city or loc[\"city\"]\n self.region = self.region or loc[\"region\"]\n self.postal_code = self.postal_code or loc[\"postal_code\"]\n self.city = canonical_city(self.city)\n # région administrative (17 régions, répertoire MAMH embarqué) ;\n # repli : « Québec » (province) quand seule l'appartenance QC est sûre\n if self.city and (not self.region or self.region == \"Québec\"):\n from .regions import region_for_city\n self.region = region_for_city(self.city) or self.region\n if self.city and not self.region and is_quebec_location(self.city):\n self.region = \"Québec\"\n\n # salaire : parser le libellé si le connecteur n'a pas de valeurs numériques\n if self.salary_min is None and self.salary_label:\n lo, hi, unit = parse_salary(self.salary_label)\n self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit\n if self.salary_min is None and self.description:\n # mention salariale dans la description (fréquent au QC) — avec\n # garde-fou de contexte (financement, remboursements ≠ salaire)\n lo, hi, unit, label = salary_from_text(self.description)\n if lo is not None:\n self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit\n self.salary_label = self.salary_label or label\n # bornes de plausibilité aussi sur les valeurs STRUCTURÉES des ATS\n # (unités mal étiquetées : « 75 000 $/mois » qui est un annuel…)\n self.salary_min, self.salary_max, self.salary_unit = sanitize_salary(\n self.salary_min, self.salary_max, self.salary_unit)\n if self.salary_min is None:\n self.salary_unit = None\n\n # dates : accepter epoch/textuel/relatif -> ISO\n if self.date_posted is not None:\n self.date_posted = parse_date(self.date_posted)\n if self.date_deadline is not None:\n self.date_deadline = parse_date(self.date_deadline)\n\n # mode de travail / type d'emploi : depuis le libellé de lieu, le titre\n # puis la description — première réponse trouvée, jamais de défaut\n if self.work_mode is None:\n for texte in (self.location_label, self.title, self.description[:500]):\n self.work_mode = parse_work_mode(texte)\n if self.work_mode:\n break\n if self.employment_type is None:\n for texte in (self.details.get(\"employment_label\", \"\"), self.title,\n self.description[:500]):\n self.employment_type = parse_employment_type(str(texte))\n if self.employment_type:\n break\n\n if not self.category:\n self.category = categorize(self.title, self.description)\n\n # langue de l'offre : valeur source si fournie, sinon détection légère\n if not self.language:\n self.language = detect_language(self.title, self.description)\n elif self.language not in (\"fr\", \"en\", \"bilingue\"):\n lang = str(self.language).lower()[:2]\n self.language = {\"fr\": \"fr\", \"en\": \"en\"}.get(lang, \"\")\n\n # avantages : blocs structurés « Avantages / Benefits » des descriptions\n if not self.benefits and self.description:\n self.benefits = extract_benefits(self.description)\n\n # coordonnées fournies par la source : rejeter tout point hors de la\n # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra\n # le relais sur l'adresse/la ville\n if self.lat is not None and self.lng is not None:\n if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):\n self.lat = self.lng = None\n return self\n","structuredPatch":[{"oldStart":83,"oldLines":6,"newStart":83,"newLines":8,"lines":[" company_logo: str = \"\" # URL du logo employeur (si exposé)"," language: str = \"\" # fr | en | bilingue (\"\" = indéterminée)"," apply_url: str = \"\" # lien de candidature directe (≠ url fiche)","+ seniority: str | None = None # stage | junior | intermediaire |","+ # senior | direction (None = indéterminée)"," "," @property"," def uid(self) -> str:"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcDMD5MS9gnTCZHRAKF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QNCJXqkxJjKSnEj5vqpoHW","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/schema.py","old_string":" # avantages : blocs structurés « Avantages / Benefits » des descriptions\n if not self.benefits and self.description:\n self.benefits = extract_benefits(self.description)","new_string":" # avantages : blocs structurés « Avantages / Benefits » des descriptions\n if not self.benefits and self.description:\n self.benefits = extract_benefits(self.description)\n\n # exigences structurées (expérience, scolarité, langues) : extraites de\n # la description, sans jamais écraser une valeur fournie par l'ATS\n if self.description:\n for k, v in extract_requirements(self.title, self.description).items():\n self.requirements.setdefault(k, v)\n\n # séniorité du poste : titre, libellé ATS, puis années d'expérience\n if self.seniority is None:\n self.seniority = parse_seniority(\n self.title, self.requirements, self.employment_type)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3908,"cache_read_input_tokens":120739,"cache_creation":{"ephemeral_5m_input_tokens":3908,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"e5736863-68b6-4dea-8aad-9b764352edb9","request_id":"req_011CePcDJxt7sLtUJ49qSpEi"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QNCJXqkxJjKSnEj5vqpoHW","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/schema.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"ab9a6650-8294-424a-b85f-7816b4b83b4e","timestamp":"2026-08-25T14:16:17.223Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/schema.py","oldString":" # avantages : blocs structurés « Avantages / Benefits » des descriptions\n if not self.benefits and self.description:\n self.benefits = extract_benefits(self.description)","newString":" # avantages : blocs structurés « Avantages / Benefits » des descriptions\n if not self.benefits and self.description:\n self.benefits = extract_benefits(self.description)\n\n # exigences structurées (expérience, scolarité, langues) : extraites de\n # la description, sans jamais écraser une valeur fournie par l'ATS\n if self.description:\n for k, v in extract_requirements(self.title, self.description).items():\n self.requirements.setdefault(k, v)\n\n # séniorité du poste : titre, libellé ATS, puis années d'expérience\n if self.seniority is None:\n self.seniority = parse_seniority(\n self.title, self.requirements, self.employment_type)","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/schema.py\n# Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\n\"\"\"Schéma standard d'une offre d'emploi (JobPosting) et normalisation.\n\nChaque connecteur, peu importe l'employeur ou l'ATS source, doit produire des\nobjets `JobPosting` conformes à ce schéma. La méthode `finalize()` applique\nensuite la couche de normalisation commune (jobka/normalize.py) : dates ISO,\nsalaires numériques + conversions $/h et $/an, mode de travail, type d'emploi,\ncatégorie… — les connecteurs restent simples et remplissent les champs bruts.\n\"\"\"\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nfrom dataclasses import dataclass, field, asdict\n\nfrom .normalize import ( # ré-exportés pour les connecteurs\n canonical_city,\n categorize,\n clean_html,\n clean_text,\n detect_language,\n extract_benefits,\n extract_requirements,\n is_quebec_location,\n parse_date,\n parse_employment_type,\n parse_location,\n parse_salary,\n parse_seniority,\n parse_work_mode,\n salary_from_text,\n salary_to_hourly,\n salary_to_yearly,\n sanitize_salary,\n strip_accents,\n)\n\n__all__ = [\n \"JobPosting\", \"categorize\", \"clean_html\", \"clean_text\",\n \"is_quebec_location\", \"parse_date\", \"parse_employment_type\",\n \"parse_location\", \"parse_salary\", \"parse_work_mode\", \"strip_accents\",\n]\n\n\n@dataclass\nclass JobPosting:\n \"\"\"Offre d'emploi standardisée Job·Ka.\"\"\"\n\n source: str # id du connecteur (voir data/sources.json)\n external_id: str # identifiant chez l'employeur/ATS\n url: str # lien direct vers l'offre — zéro boîte noire\n employer: str = \"\" # nom de l'entreprise\n title: str = \"\" # titre du poste\n description: str = \"\" # texte complet (HTML nettoyé)\n address: str = \"\" # adresse du lieu de travail si disponible\n city: str = \"\" # Montréal, Québec, Sherbrooke…\n region: str = \"\" # région administrative / « Québec »\n postal_code: str = \"\"\n location_label: str = \"\" # libellé de lieu original (brut)\n work_mode: str | None = None # presentiel | hybride | teletravail\n employment_type: str | None = None # temps_plein | temps_partiel |\n # contractuel | stage | saisonnier\n salary_min: float | None = None # nombres, jamais des strings\n salary_max: float | None = None\n salary_unit: str | None = None # hour | day | week | biweek | month | year\n salary_label: str = \"\" # texte original (ex. « 25 $ à 30 $/h »)\n benefits: list[str] = field(default_factory=list)\n requirements: dict = field(default_factory=dict) # scolarité, expérience, langues\n date_posted: str | None = None # ISO 8601\n date_deadline: str | None = None # ISO 8601 (date limite pour postuler)\n category: str = \"\" # taxonomie interne (TI, Santé, …)\n ats: str = \"\" # workday | lever | greenhouse | custom…\n details: dict = field(default_factory=dict) # champs structurés (sparse)\n lat: float | None = None\n lng: float | None = None\n company_logo: str = \"\" # URL du logo employeur (si exposé)\n language: str = \"\" # fr | en | bilingue (\"\" = indéterminée)\n apply_url: str = \"\" # lien de candidature directe (≠ url fiche)\n seniority: str | None = None # stage | junior | intermediaire |\n # senior | direction (None = indéterminée)\n\n @property\n def uid(self) -> str:\n return f\"{self.source}:{self.external_id}\"\n\n def content_hash(self) -> str:\n \"\"\"Hash du contenu pour la détection de changements (pseudo-webhook).\"\"\"\n payload = asdict(self)\n blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)\n return hashlib.sha256(blob.encode(\"utf-8\")).hexdigest()\n\n # Conversions dérivées — calculées à la volée, PAS dans content_hash\n # (elles découlent de salary_min/max/unit, les stocker dans le hash ne\n # ferait que dupliquer l'information).\n def salary_year_min(self) -> float | None:\n return salary_to_yearly(self.salary_min, self.salary_unit)\n\n def salary_year_max(self) -> float | None:\n return salary_to_yearly(self.salary_max, self.salary_unit)\n\n def salary_hour_min(self) -> float | None:\n return salary_to_hourly(self.salary_min, self.salary_unit)\n\n def salary_hour_max(self) -> float | None:\n return salary_to_hourly(self.salary_max, self.salary_unit)\n\n def finalize(self) -> \"JobPosting\":\n \"\"\"Applique la normalisation commune. Appelé par le pipeline d'ingestion.\n\n Idempotent ; ne remplace jamais une valeur explicite du connecteur.\n \"\"\"\n self.title = clean_text(self.title)\n self.employer = clean_text(self.employer)\n self.description = clean_text(self.description) if \"\\n\" not in self.description \\\n else self.description.strip()\n\n # lieu : compléter ville/région/code postal depuis le libellé brut,\n # puis canonicaliser la ville (« Montreal » -> « Montréal »)\n if self.location_label and not (self.city and self.region):\n loc = parse_location(self.location_label)\n self.city = self.city or loc[\"city\"]\n self.region = self.region or loc[\"region\"]\n self.postal_code = self.postal_code or loc[\"postal_code\"]\n self.city = canonical_city(self.city)\n # région administrative (17 régions, répertoire MAMH embarqué) ;\n # repli : « Québec » (province) quand seule l'appartenance QC est sûre\n if self.city and (not self.region or self.region == \"Québec\"):\n from .regions import region_for_city\n self.region = region_for_city(self.city) or self.region\n if self.city and not self.region and is_quebec_location(self.city):\n self.region = \"Québec\"\n\n # salaire : parser le libellé si le connecteur n'a pas de valeurs numériques\n if self.salary_min is None and self.salary_label:\n lo, hi, unit = parse_salary(self.salary_label)\n self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit\n if self.salary_min is None and self.description:\n # mention salariale dans la description (fréquent au QC) — avec\n # garde-fou de contexte (financement, remboursements ≠ salaire)\n lo, hi, unit, label = salary_from_text(self.description)\n if lo is not None:\n self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit\n self.salary_label = self.salary_label or label\n # bornes de plausibilité aussi sur les valeurs STRUCTURÉES des ATS\n # (unités mal étiquetées : « 75 000 $/mois » qui est un annuel…)\n self.salary_min, self.salary_max, self.salary_unit = sanitize_salary(\n self.salary_min, self.salary_max, self.salary_unit)\n if self.salary_min is None:\n self.salary_unit = None\n\n # dates : accepter epoch/textuel/relatif -> ISO\n if self.date_posted is not None:\n self.date_posted = parse_date(self.date_posted)\n if self.date_deadline is not None:\n self.date_deadline = parse_date(self.date_deadline)\n\n # mode de travail / type d'emploi : depuis le libellé de lieu, le titre\n # puis la description — première réponse trouvée, jamais de défaut\n if self.work_mode is None:\n for texte in (self.location_label, self.title, self.description[:500]):\n self.work_mode = parse_work_mode(texte)\n if self.work_mode:\n break\n if self.employment_type is None:\n for texte in (self.details.get(\"employment_label\", \"\"), self.title,\n self.description[:500]):\n self.employment_type = parse_employment_type(str(texte))\n if self.employment_type:\n break\n\n if not self.category:\n self.category = categorize(self.title, self.description)\n\n # langue de l'offre : valeur source si fournie, sinon détection légère\n if not self.language:\n self.language = detect_language(self.title, self.description)\n elif self.language not in (\"fr\", \"en\", \"bilingue\"):\n lang = str(self.language).lower()[:2]\n self.language = {\"fr\": \"fr\", \"en\": \"en\"}.get(lang, \"\")\n\n # avantages : blocs structurés « Avantages / Benefits » des descriptions\n if not self.benefits and self.description:\n self.benefits = extract_benefits(self.description)\n\n # coordonnées fournies par la source : rejeter tout point hors de la\n # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra\n # le relais sur l'adresse/la ville\n if self.lat is not None and self.lng is not None:\n if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):\n self.lat = self.lng = None\n return self\n","structuredPatch":[{"oldStart":189,"oldLines":6,"newStart":189,"newLines":17,"lines":[" if not self.benefits and self.description:"," self.benefits = extract_benefits(self.description)"," ","+ # exigences structurées (expérience, scolarité, langues) : extraites de","+ # la description, sans jamais écraser une valeur fournie par l'ATS","+ if self.description:","+ for k, v in extract_requirements(self.title, self.description).items():","+ self.requirements.setdefault(k, v)","+","+ # séniorité du poste : titre, libellé ATS, puis années d'expérience","+ if self.seniority is None:","+ self.seniority = parse_seniority(","+ self.title, self.requirements, self.employment_type)","+"," # coordonnées fournies par la source : rejeter tout point hors de la"," # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra"," # le relais sur l'adresse/la ville"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d739dbca-2154-44d3-9f88-ebfdad9c00d5","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcFXZj1otn83ix1iAeC","type":"message","role":"assistant","content":[{"type":"text","text":"Maintenant `db.py` (colonne + insert/update) :"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":2752,"cache_read_input_tokens":124647,"cache_creation":{"ephemeral_5m_input_tokens":2752,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"450e5479-5ecf-4aee-adc8-516a70779410","request_id":"req_011CePcFVYgJhHni6KFU6cqS"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcFXZj1otn83ix1iAeC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AGaVtMuncZbhjp8uqVUGhw","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/db.py","old_string":"# vie avec délai de grâce, anti-dérive, expiration sur date limite\n# Créé : 2026-08-17 Modifié : 2026-08-17","new_string":"# vie avec délai de grâce, anti-dérive, expiration sur date limite\n# Créé : 2026-08-17 Modifié : 2026-08-25"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":2752,"cache_read_input_tokens":124647,"cache_creation":{"ephemeral_5m_input_tokens":2752,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"8e7d50db-814f-4f99-a3ef-0bc049b2deca","request_id":"req_011CePcFVYgJhHni6KFU6cqS"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AGaVtMuncZbhjp8uqVUGhw","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/db.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"6f1395f4-238f-4426-8928-c6ec1745f4e5","timestamp":"2026-08-25T14:16:25.330Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/db.py","oldString":"# vie avec délai de grâce, anti-dérive, expiration sur date limite\n# Créé : 2026-08-17 Modifié : 2026-08-17","newString":"# vie avec délai de grâce, anti-dérive, expiration sur date limite\n# Créé : 2026-08-17 Modifié : 2026-08-25","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/db.py\n# Rôle : Persistance SQLite — upsert avec détection de changements, cycle de\n# vie avec délai de grâce, anti-dérive, expiration sur date limite\n# Créé : 2026-08-17 Modifié : 2026-08-17\n# =============================================================================\nfrom __future__ import annotations\n\nimport datetime as _dt\nimport json\nimport sqlite3\nimport statistics\nimport time\nfrom pathlib import Path\n\nfrom .schema import JobPosting\n\nDB_PATH = Path(__file__).resolve().parent.parent / \"data\" / \"jobka.db\"\n\n# Nombre d'exécutions consécutives où une offre doit être absente de la\n# source avant d'être désactivée (délai de grâce contre les ratés ponctuels).\nMISS_GRACE = 2\n\n# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique\n# (médiane >= DRIFT_MIN_BASE offres), on alerte et on suspend les retraits.\nDRIFT_RATIO = 0.25\nDRIFT_MIN_BASE = 8\nDRIFT_HISTORY = 5\n\n_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS jobs (\n uid TEXT PRIMARY KEY,\n source TEXT NOT NULL,\n external_id TEXT NOT NULL,\n url TEXT,\n employer TEXT,\n title TEXT,\n description TEXT,\n address TEXT,\n city TEXT,\n region TEXT,\n postal_code TEXT,\n location_label TEXT,\n work_mode TEXT,\n employment_type TEXT,\n salary_min REAL,\n salary_max REAL,\n salary_unit TEXT,\n salary_label TEXT,\n salary_year_min REAL, -- conversions dérivées ($/an, $/h) pour filtres/tri\n salary_year_max REAL,\n salary_hour_min REAL,\n salary_hour_max REAL,\n benefits TEXT, -- JSON (liste de textes source)\n requirements TEXT, -- JSON (scolarité, expérience, langues)\n date_posted TEXT,\n date_deadline TEXT,\n category TEXT,\n ats TEXT,\n details TEXT, -- JSON (champs structurés, sparse)\n lat REAL,\n lng REAL,\n geocode_failed INTEGER DEFAULT 0,\n content_hash TEXT,\n first_seen REAL,\n last_seen REAL,\n updated_at REAL,\n miss_count INTEGER DEFAULT 0,\n active INTEGER DEFAULT 1,\n dup_of TEXT, -- uid de l'offre canonique si doublon inter-sources\n dup_sources TEXT -- JSON : autres sources où l'offre est publiée\n);\nCREATE INDEX IF NOT EXISTS idx_jobs_source ON jobs(source);\nCREATE INDEX IF NOT EXISTS idx_jobs_city ON jobs(city);\nCREATE INDEX IF NOT EXISTS idx_jobs_active ON jobs(active);\nCREATE INDEX IF NOT EXISTS idx_jobs_category ON jobs(category);\nCREATE INDEX IF NOT EXISTS idx_jobs_geo ON jobs(lat, lng);\n\nCREATE TABLE IF NOT EXISTS sync_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n source TEXT,\n ts REAL,\n found INTEGER,\n added INTEGER,\n updated INTEGER,\n removed INTEGER,\n ok INTEGER,\n message TEXT,\n stats TEXT -- JSON : taux de champs null, missed, alerte…\n);\n\nCREATE TABLE IF NOT EXISTS detail_cache (\n source TEXT NOT NULL,\n external_id TEXT NOT NULL,\n key TEXT, -- hash du contenu « liste » de l'offre\n payload TEXT, -- JSON opaque propre au connecteur\n fetched_at REAL,\n PRIMARY KEY (source, external_id)\n);\n\nCREATE TABLE IF NOT EXISTS salary_log (\n uid TEXT NOT NULL,\n ts REAL NOT NULL,\n s_min REAL,\n s_max REAL,\n unit TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_salary_log_uid ON salary_log(uid);\n\nCREATE TABLE IF NOT EXISTS users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ka_sub TEXT UNIQUE, -- « ka:{sub} » identifiant stable du hub KA\n ka_id TEXT, -- identifiant membre « ka-0123456789 » (hub)\n email TEXT,\n name TEXT,\n picture TEXT, -- URL de l'avatar (hub / Google)\n created_at REAL,\n last_login REAL\n);\nCREATE UNIQUE INDEX IF NOT EXISTS users_ka_id ON users(ka_id);\n\nCREATE TABLE IF NOT EXISTS geocode_cache (\n address TEXT PRIMARY KEY, -- adresse/ville normalisée (clé de cache)\n lat REAL,\n lng REAL,\n provider TEXT,\n failed INTEGER DEFAULT 0,\n ts REAL\n);\n\"\"\"\n\n# Colonnes ajoutées après la v1 — migration automatique des bases existantes.\n_MIGRATIONS: dict[str, dict[str, str]] = {\n \"jobs\": {\n # Phase 2 (2026-08-19) — enrichissement des connecteurs\n \"company_logo\": \"TEXT\", # URL du logo employeur (breezy, JSON-LD…)\n \"language\": \"TEXT\", # fr | en | bilingue (source ou heuristique)\n \"apply_url\": \"TEXT\", # lien de candidature directe\n \"title_clean\": \"TEXT\", # titre d'affichage normalisé (source intacte)\n \"quarantine\": \"TEXT\", # JSON des motifs qualité (NULL = publiable)\n },\n \"sync_log\": {},\n}\n\n\ndef connect() -> sqlite3.Connection:\n DB_PATH.parent.mkdir(parents=True, exist_ok=True)\n con = sqlite3.connect(DB_PATH, timeout=30.0)\n con.row_factory = sqlite3.Row\n con.execute(\"PRAGMA busy_timeout=30000\") # BD vivante (sync horaire)\n con.executescript(_SCHEMA)\n for table, cols in _MIGRATIONS.items():\n existing = {r[\"name\"] for r in con.execute(f\"PRAGMA table_info({table})\")}\n for col, decl in cols.items():\n if col not in existing:\n con.execute(f\"ALTER TABLE {table} ADD COLUMN {col} {decl}\")\n con.commit()\n return con\n\n\n# ---------------------------------------------------------------------------\n# Synchronisation d'une source\n# ---------------------------------------------------------------------------\n\ndef _drift_alert(con: sqlite3.Connection, source: str, found: int,\n null_salary_rate: float) -> str | None:\n \"\"\"Détecte une dérive du connecteur (chute du volume ou des salaires extraits).\n\n Retourne un message d'alerte, ou None si tout est normal.\n \"\"\"\n hist = con.execute(\n \"SELECT found, stats FROM sync_log WHERE source=? AND ok=1\"\n \" ORDER BY ts DESC LIMIT ?\", (source, DRIFT_HISTORY)).fetchall()\n if len(hist) < 3:\n return None\n med_found = statistics.median(r[\"found\"] for r in hist)\n if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:\n return (f\"dérive: {found} offre(s) trouvée(s) contre une médiane de \"\n f\"{med_found:.0f} — retraits suspendus, vérifier le connecteur\")\n if found >= DRIFT_MIN_BASE and null_salary_rate >= 0.95:\n rates = []\n for r in hist:\n try:\n rates.append(json.loads(r[\"stats\"] or \"{}\")[\"null_salary_rate\"])\n except (KeyError, ValueError, TypeError):\n continue\n if rates and statistics.median(rates) <= 0.5:\n return (f\"dérive: {null_salary_rate:.0%} des offres sans salaire \"\n f\"(habituellement {statistics.median(rates):.0%}) — \"\n \"le format de la source a probablement changé\")\n return None\n\n\ndef sync_source(con: sqlite3.Connection, source: str,\n postings: list[JobPosting]) -> dict:\n \"\"\"Synchronise les offres d'une source.\n\n - nouvelle offre -> insertion\n - offre modifiée -> mise à jour (comparaison de content_hash)\n - offre disparue -> miss_count += 1, puis active=0 après MISS_GRACE\n exécutions consécutives (délai de grâce)\n - dérive détectée -> alerte consignée, retraits suspendus\n \"\"\"\n now = time.time()\n added = updated = 0\n seen_uids = set()\n\n n = len(postings)\n null_salary = sum(1 for p in postings if p.salary_min is None)\n null_city = sum(1 for p in postings if not p.city)\n null_salary_rate = round(null_salary / n, 3) if n else 0.0\n\n alert = _drift_alert(con, source, n, null_salary_rate)\n\n from .normalize import clean_title\n from .quality import issues_for_posting\n\n for job in postings:\n seen_uids.add(job.uid)\n h = job.content_hash()\n row = con.execute(\n \"SELECT content_hash, salary_min, salary_max FROM jobs WHERE uid=?\",\n (job.uid,)).fetchone()\n defects = issues_for_posting(job)\n params = dict(\n uid=job.uid, source=job.source, external_id=job.external_id,\n url=job.url, employer=job.employer, title=job.title,\n description=job.description, address=job.address, city=job.city,\n region=job.region, postal_code=job.postal_code,\n location_label=job.location_label, work_mode=job.work_mode,\n employment_type=job.employment_type,\n salary_min=job.salary_min, salary_max=job.salary_max,\n salary_unit=job.salary_unit, salary_label=job.salary_label,\n salary_year_min=job.salary_year_min(),\n salary_year_max=job.salary_year_max(),\n salary_hour_min=job.salary_hour_min(),\n salary_hour_max=job.salary_hour_max(),\n benefits=json.dumps(job.benefits, ensure_ascii=False),\n requirements=json.dumps(job.requirements, ensure_ascii=False),\n date_posted=job.date_posted, date_deadline=job.date_deadline,\n category=job.category, ats=job.ats,\n details=json.dumps(job.details, ensure_ascii=False),\n lat=job.lat, lng=job.lng, content_hash=h, now=now,\n company_logo=job.company_logo, language=job.language,\n apply_url=job.apply_url,\n title_clean=clean_title(job.title, job.city),\n quarantine=json.dumps(defects, ensure_ascii=False)\n if defects else None,\n )\n if row is None:\n con.execute(\n \"\"\"INSERT INTO jobs (uid, source, external_id, url, employer,\n title, description, address, city, region, postal_code,\n location_label, work_mode, employment_type, salary_min,\n salary_max, salary_unit, salary_label, salary_year_min,\n salary_year_max, salary_hour_min, salary_hour_max, benefits,\n requirements, date_posted, date_deadline, category, ats,\n details, lat, lng, content_hash, first_seen, last_seen,\n updated_at, miss_count, active, company_logo, language,\n apply_url, title_clean, quarantine)\n VALUES (:uid,:source,:external_id,:url,:employer,:title,\n :description,:address,:city,:region,:postal_code,\n :location_label,:work_mode,:employment_type,:salary_min,\n :salary_max,:salary_unit,:salary_label,:salary_year_min,\n :salary_year_max,:salary_hour_min,:salary_hour_max,\n :benefits,:requirements,:date_posted,:date_deadline,\n :category,:ats,:details,:lat,:lng,:content_hash,\n :now,:now,:now,0,1,:company_logo,:language,:apply_url,\n :title_clean,:quarantine)\"\"\", params)\n if job.salary_min is not None: # point de départ de l'historique\n con.execute(\n \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n \" VALUES (?,?,?,?,?)\",\n (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n added += 1\n elif row[\"content_hash\"] != h:\n # COALESCE : ne jamais écraser des coordonnées géocodées par null.\n # Si l'adresse change, geocode_failed est remis à 0 (correction du\n # bogue relevé chez Lou·Ka) pour permettre un re-géocodage.\n con.execute(\n \"\"\"UPDATE jobs SET url=:url, employer=:employer, title=:title,\n description=:description,\n geocode_failed=CASE WHEN address<>:address OR city<>:city\n THEN 0 ELSE geocode_failed END,\n address=:address, city=:city, region=:region,\n postal_code=:postal_code, location_label=:location_label,\n work_mode=:work_mode, employment_type=:employment_type,\n salary_min=:salary_min, salary_max=:salary_max,\n salary_unit=:salary_unit, salary_label=:salary_label,\n salary_year_min=:salary_year_min,\n salary_year_max=:salary_year_max,\n salary_hour_min=:salary_hour_min,\n salary_hour_max=:salary_hour_max,\n benefits=:benefits, requirements=:requirements,\n date_posted=:date_posted, date_deadline=:date_deadline,\n category=:category, ats=:ats, details=:details,\n lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),\n company_logo=:company_logo, language=:language,\n apply_url=:apply_url, title_clean=:title_clean,\n quarantine=:quarantine,\n content_hash=:content_hash, last_seen=:now,\n updated_at=:now, miss_count=0, active=1\n WHERE uid=:uid\"\"\", params)\n if (job.salary_min, job.salary_max) != (row[\"salary_min\"], row[\"salary_max\"]):\n con.execute(\n \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n \" VALUES (?,?,?,?,?)\",\n (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n updated += 1\n else:\n con.execute(\n \"UPDATE jobs SET last_seen=?, miss_count=0, active=1 WHERE uid=?\",\n (now, job.uid))\n\n # Offres de cette source qui n'apparaissent plus : délai de grâce,\n # puis désactivation. Suspendu si une dérive est détectée.\n removed = missed = 0\n if not alert:\n for r in con.execute(\n \"SELECT uid, miss_count FROM jobs WHERE source=? AND active=1\",\n (source,)).fetchall():\n if r[\"uid\"] in seen_uids:\n continue\n missed += 1\n if r[\"miss_count\"] + 1 >= MISS_GRACE:\n con.execute(\n \"UPDATE jobs SET active=0, miss_count=?, updated_at=?\"\n \" WHERE uid=?\", (r[\"miss_count\"] + 1, now, r[\"uid\"]))\n removed += 1\n else:\n con.execute(\"UPDATE jobs SET miss_count=miss_count+1 WHERE uid=?\",\n (r[\"uid\"],))\n\n stats = {\n \"null_salary_rate\": null_salary_rate,\n \"null_city_rate\": round(null_city / n, 3) if n else 0.0,\n \"missed\": missed,\n }\n if alert:\n stats[\"alert\"] = alert\n con.execute(\n \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,\"\n \" message, stats) VALUES (?,?,?,?,?,?,1,?,?)\",\n (source, now, n, added, updated, removed, alert or \"ok\",\n json.dumps(stats, ensure_ascii=False)))\n con.commit()\n out = {\"source\": source, \"found\": n, \"added\": added,\n \"updated\": updated, \"removed\": removed}\n if alert:\n out[\"alert\"] = alert\n return out\n\n\ndef expire_past_deadline(con: sqlite3.Connection) -> int:\n \"\"\"Désactive les offres dont la date limite est dépassée (offres zombies :\n encore affichées sur la page carrière mais plus ouvertes aux candidatures).\"\"\"\n today = _dt.date.today().isoformat()\n cur = con.execute(\n \"UPDATE jobs SET active=0, updated_at=? WHERE active=1\"\n \" AND date_deadline IS NOT NULL AND date_deadline < ?\",\n (time.time(), today))\n con.commit()\n return cur.rowcount\n\n\nSTALE_DAYS = 7 # offres plus jamais revues (connecteur retiré/muet) : archivées\n\n\ndef expire_stale(con: sqlite3.Connection, days: int = STALE_DAYS) -> int:\n \"\"\"Archive (active=0) les offres actives non revues depuis `days` jours.\n\n Filet de sécurité complémentaire au délai de grâce MISS_GRACE : couvre le\n cas d'un connecteur SUPPRIMÉ du code (ses offres ne passent plus jamais\n par sync_source et resteraient actives pour toujours — cas « valero »).\n Rien n'est supprimé : les offres sont archivées, réactivables si revues.\n \"\"\"\n cutoff = time.time() - days * 86400\n cur = con.execute(\n \"UPDATE jobs SET active=0, updated_at=? WHERE active=1 AND last_seen'depot-direct'\", # le dépôt direct n'est jamais resynchronisé\n (time.time(), cutoff))\n con.commit()\n return cur.rowcount\n\n\ndef log_failure(con: sqlite3.Connection, source: str, message: str) -> None:\n con.execute(\n \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)\"\n \" VALUES (?,?,0,0,0,0,0,?)\", (source, time.time(), message))\n con.commit()\n\n\n# ---------------------------------------------------------------------------\n# Cache des pages détail (« détail si nouveau/modifié »)\n# ---------------------------------------------------------------------------\n\ndef get_cached_detail(con: sqlite3.Connection, source: str,\n external_id: str, key: str) -> dict | None:\n \"\"\"Payload détail mis en cache si la clé (hash liste) n'a pas changé.\"\"\"\n row = con.execute(\n \"SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?\",\n (source, external_id)).fetchone()\n if row and row[\"key\"] == key and row[\"payload\"]:\n try:\n return json.loads(row[\"payload\"])\n except ValueError:\n return None\n return None\n\n\ndef get_stale_detail(con: sqlite3.Connection, source: str,\n external_id: str) -> dict | None:\n \"\"\"Payload détail en cache MÊME si la clé a changé — secours quand le\n budget de re-visites est épuisé : un détail périmé vaut mieux qu'une\n description vide (la re-visite aura lieu à un prochain cycle).\"\"\"\n row = con.execute(\n \"SELECT payload FROM detail_cache WHERE source=? AND external_id=?\",\n (source, external_id)).fetchone()\n if row and row[\"payload\"]:\n try:\n return json.loads(row[\"payload\"])\n except ValueError:\n return None\n return None\n\n\ndef put_cached_detail(con: sqlite3.Connection, source: str,\n external_id: str, key: str, payload: dict) -> None:\n con.execute(\n \"INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)\"\n \" VALUES (?,?,?,?,?)\"\n \" ON CONFLICT(source, external_id) DO UPDATE SET\"\n \" key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at\",\n (source, external_id, key, json.dumps(payload, ensure_ascii=False),\n time.time()))\n con.commit()\n","structuredPatch":[{"oldStart":5,"oldLines":7,"newStart":5,"newLines":7,"lines":[" # Fichier : jobka/db.py"," # Rôle : Persistance SQLite — upsert avec détection de changements, cycle de"," # vie avec délai de grâce, anti-dérive, expiration sur date limite","-# Créé : 2026-08-17 Modifié : 2026-08-17","+# Créé : 2026-08-17 Modifié : 2026-08-25"," # ============================================================================="," from __future__ import annotations"," "]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcFXZj1otn83ix1iAeC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KqdHfA6oKq43xmHvUKt6mR","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/db.py","old_string":" \"title_clean\": \"TEXT\", # titre d'affichage normalisé (source intacte)\n \"quarantine\": \"TEXT\", # JSON des motifs qualité (NULL = publiable)\n },","new_string":" \"title_clean\": \"TEXT\", # titre d'affichage normalisé (source intacte)\n \"quarantine\": \"TEXT\", # JSON des motifs qualité (NULL = publiable)\n # Phase 3 (2026-08-25) — enrichissement : séniorité du poste\n \"seniority\": \"TEXT\", # stage | junior | intermediaire | senior | direction\n },"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":2752,"cache_read_input_tokens":124647,"cache_creation":{"ephemeral_5m_input_tokens":2752,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"40d8eb3b-7662-40e1-b98b-33dcf56df874","request_id":"req_011CePcFVYgJhHni6KFU6cqS"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KqdHfA6oKq43xmHvUKt6mR","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/db.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"9501854c-54f7-4b04-a333-a96addce844e","timestamp":"2026-08-25T14:16:28.354Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/db.py","oldString":" \"title_clean\": \"TEXT\", # titre d'affichage normalisé (source intacte)\n \"quarantine\": \"TEXT\", # JSON des motifs qualité (NULL = publiable)\n },","newString":" \"title_clean\": \"TEXT\", # titre d'affichage normalisé (source intacte)\n \"quarantine\": \"TEXT\", # JSON des motifs qualité (NULL = publiable)\n # Phase 3 (2026-08-25) — enrichissement : séniorité du poste\n \"seniority\": \"TEXT\", # stage | junior | intermediaire | senior | direction\n },","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/db.py\n# Rôle : Persistance SQLite — upsert avec détection de changements, cycle de\n# vie avec délai de grâce, anti-dérive, expiration sur date limite\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\nfrom __future__ import annotations\n\nimport datetime as _dt\nimport json\nimport sqlite3\nimport statistics\nimport time\nfrom pathlib import Path\n\nfrom .schema import JobPosting\n\nDB_PATH = Path(__file__).resolve().parent.parent / \"data\" / \"jobka.db\"\n\n# Nombre d'exécutions consécutives où une offre doit être absente de la\n# source avant d'être désactivée (délai de grâce contre les ratés ponctuels).\nMISS_GRACE = 2\n\n# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique\n# (médiane >= DRIFT_MIN_BASE offres), on alerte et on suspend les retraits.\nDRIFT_RATIO = 0.25\nDRIFT_MIN_BASE = 8\nDRIFT_HISTORY = 5\n\n_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS jobs (\n uid TEXT PRIMARY KEY,\n source TEXT NOT NULL,\n external_id TEXT NOT NULL,\n url TEXT,\n employer TEXT,\n title TEXT,\n description TEXT,\n address TEXT,\n city TEXT,\n region TEXT,\n postal_code TEXT,\n location_label TEXT,\n work_mode TEXT,\n employment_type TEXT,\n salary_min REAL,\n salary_max REAL,\n salary_unit TEXT,\n salary_label TEXT,\n salary_year_min REAL, -- conversions dérivées ($/an, $/h) pour filtres/tri\n salary_year_max REAL,\n salary_hour_min REAL,\n salary_hour_max REAL,\n benefits TEXT, -- JSON (liste de textes source)\n requirements TEXT, -- JSON (scolarité, expérience, langues)\n date_posted TEXT,\n date_deadline TEXT,\n category TEXT,\n ats TEXT,\n details TEXT, -- JSON (champs structurés, sparse)\n lat REAL,\n lng REAL,\n geocode_failed INTEGER DEFAULT 0,\n content_hash TEXT,\n first_seen REAL,\n last_seen REAL,\n updated_at REAL,\n miss_count INTEGER DEFAULT 0,\n active INTEGER DEFAULT 1,\n dup_of TEXT, -- uid de l'offre canonique si doublon inter-sources\n dup_sources TEXT -- JSON : autres sources où l'offre est publiée\n);\nCREATE INDEX IF NOT EXISTS idx_jobs_source ON jobs(source);\nCREATE INDEX IF NOT EXISTS idx_jobs_city ON jobs(city);\nCREATE INDEX IF NOT EXISTS idx_jobs_active ON jobs(active);\nCREATE INDEX IF NOT EXISTS idx_jobs_category ON jobs(category);\nCREATE INDEX IF NOT EXISTS idx_jobs_geo ON jobs(lat, lng);\n\nCREATE TABLE IF NOT EXISTS sync_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n source TEXT,\n ts REAL,\n found INTEGER,\n added INTEGER,\n updated INTEGER,\n removed INTEGER,\n ok INTEGER,\n message TEXT,\n stats TEXT -- JSON : taux de champs null, missed, alerte…\n);\n\nCREATE TABLE IF NOT EXISTS detail_cache (\n source TEXT NOT NULL,\n external_id TEXT NOT NULL,\n key TEXT, -- hash du contenu « liste » de l'offre\n payload TEXT, -- JSON opaque propre au connecteur\n fetched_at REAL,\n PRIMARY KEY (source, external_id)\n);\n\nCREATE TABLE IF NOT EXISTS salary_log (\n uid TEXT NOT NULL,\n ts REAL NOT NULL,\n s_min REAL,\n s_max REAL,\n unit TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_salary_log_uid ON salary_log(uid);\n\nCREATE TABLE IF NOT EXISTS users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ka_sub TEXT UNIQUE, -- « ka:{sub} » identifiant stable du hub KA\n ka_id TEXT, -- identifiant membre « ka-0123456789 » (hub)\n email TEXT,\n name TEXT,\n picture TEXT, -- URL de l'avatar (hub / Google)\n created_at REAL,\n last_login REAL\n);\nCREATE UNIQUE INDEX IF NOT EXISTS users_ka_id ON users(ka_id);\n\nCREATE TABLE IF NOT EXISTS geocode_cache (\n address TEXT PRIMARY KEY, -- adresse/ville normalisée (clé de cache)\n lat REAL,\n lng REAL,\n provider TEXT,\n failed INTEGER DEFAULT 0,\n ts REAL\n);\n\"\"\"\n\n# Colonnes ajoutées après la v1 — migration automatique des bases existantes.\n_MIGRATIONS: dict[str, dict[str, str]] = {\n \"jobs\": {\n # Phase 2 (2026-08-19) — enrichissement des connecteurs\n \"company_logo\": \"TEXT\", # URL du logo employeur (breezy, JSON-LD…)\n \"language\": \"TEXT\", # fr | en | bilingue (source ou heuristique)\n \"apply_url\": \"TEXT\", # lien de candidature directe\n \"title_clean\": \"TEXT\", # titre d'affichage normalisé (source intacte)\n \"quarantine\": \"TEXT\", # JSON des motifs qualité (NULL = publiable)\n },\n \"sync_log\": {},\n}\n\n\ndef connect() -> sqlite3.Connection:\n DB_PATH.parent.mkdir(parents=True, exist_ok=True)\n con = sqlite3.connect(DB_PATH, timeout=30.0)\n con.row_factory = sqlite3.Row\n con.execute(\"PRAGMA busy_timeout=30000\") # BD vivante (sync horaire)\n con.executescript(_SCHEMA)\n for table, cols in _MIGRATIONS.items():\n existing = {r[\"name\"] for r in con.execute(f\"PRAGMA table_info({table})\")}\n for col, decl in cols.items():\n if col not in existing:\n con.execute(f\"ALTER TABLE {table} ADD COLUMN {col} {decl}\")\n con.commit()\n return con\n\n\n# ---------------------------------------------------------------------------\n# Synchronisation d'une source\n# ---------------------------------------------------------------------------\n\ndef _drift_alert(con: sqlite3.Connection, source: str, found: int,\n null_salary_rate: float) -> str | None:\n \"\"\"Détecte une dérive du connecteur (chute du volume ou des salaires extraits).\n\n Retourne un message d'alerte, ou None si tout est normal.\n \"\"\"\n hist = con.execute(\n \"SELECT found, stats FROM sync_log WHERE source=? AND ok=1\"\n \" ORDER BY ts DESC LIMIT ?\", (source, DRIFT_HISTORY)).fetchall()\n if len(hist) < 3:\n return None\n med_found = statistics.median(r[\"found\"] for r in hist)\n if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:\n return (f\"dérive: {found} offre(s) trouvée(s) contre une médiane de \"\n f\"{med_found:.0f} — retraits suspendus, vérifier le connecteur\")\n if found >= DRIFT_MIN_BASE and null_salary_rate >= 0.95:\n rates = []\n for r in hist:\n try:\n rates.append(json.loads(r[\"stats\"] or \"{}\")[\"null_salary_rate\"])\n except (KeyError, ValueError, TypeError):\n continue\n if rates and statistics.median(rates) <= 0.5:\n return (f\"dérive: {null_salary_rate:.0%} des offres sans salaire \"\n f\"(habituellement {statistics.median(rates):.0%}) — \"\n \"le format de la source a probablement changé\")\n return None\n\n\ndef sync_source(con: sqlite3.Connection, source: str,\n postings: list[JobPosting]) -> dict:\n \"\"\"Synchronise les offres d'une source.\n\n - nouvelle offre -> insertion\n - offre modifiée -> mise à jour (comparaison de content_hash)\n - offre disparue -> miss_count += 1, puis active=0 après MISS_GRACE\n exécutions consécutives (délai de grâce)\n - dérive détectée -> alerte consignée, retraits suspendus\n \"\"\"\n now = time.time()\n added = updated = 0\n seen_uids = set()\n\n n = len(postings)\n null_salary = sum(1 for p in postings if p.salary_min is None)\n null_city = sum(1 for p in postings if not p.city)\n null_salary_rate = round(null_salary / n, 3) if n else 0.0\n\n alert = _drift_alert(con, source, n, null_salary_rate)\n\n from .normalize import clean_title\n from .quality import issues_for_posting\n\n for job in postings:\n seen_uids.add(job.uid)\n h = job.content_hash()\n row = con.execute(\n \"SELECT content_hash, salary_min, salary_max FROM jobs WHERE uid=?\",\n (job.uid,)).fetchone()\n defects = issues_for_posting(job)\n params = dict(\n uid=job.uid, source=job.source, external_id=job.external_id,\n url=job.url, employer=job.employer, title=job.title,\n description=job.description, address=job.address, city=job.city,\n region=job.region, postal_code=job.postal_code,\n location_label=job.location_label, work_mode=job.work_mode,\n employment_type=job.employment_type,\n salary_min=job.salary_min, salary_max=job.salary_max,\n salary_unit=job.salary_unit, salary_label=job.salary_label,\n salary_year_min=job.salary_year_min(),\n salary_year_max=job.salary_year_max(),\n salary_hour_min=job.salary_hour_min(),\n salary_hour_max=job.salary_hour_max(),\n benefits=json.dumps(job.benefits, ensure_ascii=False),\n requirements=json.dumps(job.requirements, ensure_ascii=False),\n date_posted=job.date_posted, date_deadline=job.date_deadline,\n category=job.category, ats=job.ats,\n details=json.dumps(job.details, ensure_ascii=False),\n lat=job.lat, lng=job.lng, content_hash=h, now=now,\n company_logo=job.company_logo, language=job.language,\n apply_url=job.apply_url,\n title_clean=clean_title(job.title, job.city),\n quarantine=json.dumps(defects, ensure_ascii=False)\n if defects else None,\n )\n if row is None:\n con.execute(\n \"\"\"INSERT INTO jobs (uid, source, external_id, url, employer,\n title, description, address, city, region, postal_code,\n location_label, work_mode, employment_type, salary_min,\n salary_max, salary_unit, salary_label, salary_year_min,\n salary_year_max, salary_hour_min, salary_hour_max, benefits,\n requirements, date_posted, date_deadline, category, ats,\n details, lat, lng, content_hash, first_seen, last_seen,\n updated_at, miss_count, active, company_logo, language,\n apply_url, title_clean, quarantine)\n VALUES (:uid,:source,:external_id,:url,:employer,:title,\n :description,:address,:city,:region,:postal_code,\n :location_label,:work_mode,:employment_type,:salary_min,\n :salary_max,:salary_unit,:salary_label,:salary_year_min,\n :salary_year_max,:salary_hour_min,:salary_hour_max,\n :benefits,:requirements,:date_posted,:date_deadline,\n :category,:ats,:details,:lat,:lng,:content_hash,\n :now,:now,:now,0,1,:company_logo,:language,:apply_url,\n :title_clean,:quarantine)\"\"\", params)\n if job.salary_min is not None: # point de départ de l'historique\n con.execute(\n \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n \" VALUES (?,?,?,?,?)\",\n (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n added += 1\n elif row[\"content_hash\"] != h:\n # COALESCE : ne jamais écraser des coordonnées géocodées par null.\n # Si l'adresse change, geocode_failed est remis à 0 (correction du\n # bogue relevé chez Lou·Ka) pour permettre un re-géocodage.\n con.execute(\n \"\"\"UPDATE jobs SET url=:url, employer=:employer, title=:title,\n description=:description,\n geocode_failed=CASE WHEN address<>:address OR city<>:city\n THEN 0 ELSE geocode_failed END,\n address=:address, city=:city, region=:region,\n postal_code=:postal_code, location_label=:location_label,\n work_mode=:work_mode, employment_type=:employment_type,\n salary_min=:salary_min, salary_max=:salary_max,\n salary_unit=:salary_unit, salary_label=:salary_label,\n salary_year_min=:salary_year_min,\n salary_year_max=:salary_year_max,\n salary_hour_min=:salary_hour_min,\n salary_hour_max=:salary_hour_max,\n benefits=:benefits, requirements=:requirements,\n date_posted=:date_posted, date_deadline=:date_deadline,\n category=:category, ats=:ats, details=:details,\n lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),\n company_logo=:company_logo, language=:language,\n apply_url=:apply_url, title_clean=:title_clean,\n quarantine=:quarantine,\n content_hash=:content_hash, last_seen=:now,\n updated_at=:now, miss_count=0, active=1\n WHERE uid=:uid\"\"\", params)\n if (job.salary_min, job.salary_max) != (row[\"salary_min\"], row[\"salary_max\"]):\n con.execute(\n \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n \" VALUES (?,?,?,?,?)\",\n (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n updated += 1\n else:\n con.execute(\n \"UPDATE jobs SET last_seen=?, miss_count=0, active=1 WHERE uid=?\",\n (now, job.uid))\n\n # Offres de cette source qui n'apparaissent plus : délai de grâce,\n # puis désactivation. Suspendu si une dérive est détectée.\n removed = missed = 0\n if not alert:\n for r in con.execute(\n \"SELECT uid, miss_count FROM jobs WHERE source=? AND active=1\",\n (source,)).fetchall():\n if r[\"uid\"] in seen_uids:\n continue\n missed += 1\n if r[\"miss_count\"] + 1 >= MISS_GRACE:\n con.execute(\n \"UPDATE jobs SET active=0, miss_count=?, updated_at=?\"\n \" WHERE uid=?\", (r[\"miss_count\"] + 1, now, r[\"uid\"]))\n removed += 1\n else:\n con.execute(\"UPDATE jobs SET miss_count=miss_count+1 WHERE uid=?\",\n (r[\"uid\"],))\n\n stats = {\n \"null_salary_rate\": null_salary_rate,\n \"null_city_rate\": round(null_city / n, 3) if n else 0.0,\n \"missed\": missed,\n }\n if alert:\n stats[\"alert\"] = alert\n con.execute(\n \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,\"\n \" message, stats) VALUES (?,?,?,?,?,?,1,?,?)\",\n (source, now, n, added, updated, removed, alert or \"ok\",\n json.dumps(stats, ensure_ascii=False)))\n con.commit()\n out = {\"source\": source, \"found\": n, \"added\": added,\n \"updated\": updated, \"removed\": removed}\n if alert:\n out[\"alert\"] = alert\n return out\n\n\ndef expire_past_deadline(con: sqlite3.Connection) -> int:\n \"\"\"Désactive les offres dont la date limite est dépassée (offres zombies :\n encore affichées sur la page carrière mais plus ouvertes aux candidatures).\"\"\"\n today = _dt.date.today().isoformat()\n cur = con.execute(\n \"UPDATE jobs SET active=0, updated_at=? WHERE active=1\"\n \" AND date_deadline IS NOT NULL AND date_deadline < ?\",\n (time.time(), today))\n con.commit()\n return cur.rowcount\n\n\nSTALE_DAYS = 7 # offres plus jamais revues (connecteur retiré/muet) : archivées\n\n\ndef expire_stale(con: sqlite3.Connection, days: int = STALE_DAYS) -> int:\n \"\"\"Archive (active=0) les offres actives non revues depuis `days` jours.\n\n Filet de sécurité complémentaire au délai de grâce MISS_GRACE : couvre le\n cas d'un connecteur SUPPRIMÉ du code (ses offres ne passent plus jamais\n par sync_source et resteraient actives pour toujours — cas « valero »).\n Rien n'est supprimé : les offres sont archivées, réactivables si revues.\n \"\"\"\n cutoff = time.time() - days * 86400\n cur = con.execute(\n \"UPDATE jobs SET active=0, updated_at=? WHERE active=1 AND last_seen'depot-direct'\", # le dépôt direct n'est jamais resynchronisé\n (time.time(), cutoff))\n con.commit()\n return cur.rowcount\n\n\ndef log_failure(con: sqlite3.Connection, source: str, message: str) -> None:\n con.execute(\n \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)\"\n \" VALUES (?,?,0,0,0,0,0,?)\", (source, time.time(), message))\n con.commit()\n\n\n# ---------------------------------------------------------------------------\n# Cache des pages détail (« détail si nouveau/modifié »)\n# ---------------------------------------------------------------------------\n\ndef get_cached_detail(con: sqlite3.Connection, source: str,\n external_id: str, key: str) -> dict | None:\n \"\"\"Payload détail mis en cache si la clé (hash liste) n'a pas changé.\"\"\"\n row = con.execute(\n \"SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?\",\n (source, external_id)).fetchone()\n if row and row[\"key\"] == key and row[\"payload\"]:\n try:\n return json.loads(row[\"payload\"])\n except ValueError:\n return None\n return None\n\n\ndef get_stale_detail(con: sqlite3.Connection, source: str,\n external_id: str) -> dict | None:\n \"\"\"Payload détail en cache MÊME si la clé a changé — secours quand le\n budget de re-visites est épuisé : un détail périmé vaut mieux qu'une\n description vide (la re-visite aura lieu à un prochain cycle).\"\"\"\n row = con.execute(\n \"SELECT payload FROM detail_cache WHERE source=? AND external_id=?\",\n (source, external_id)).fetchone()\n if row and row[\"payload\"]:\n try:\n return json.loads(row[\"payload\"])\n except ValueError:\n return None\n return None\n\n\ndef put_cached_detail(con: sqlite3.Connection, source: str,\n external_id: str, key: str, payload: dict) -> None:\n con.execute(\n \"INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)\"\n \" VALUES (?,?,?,?,?)\"\n \" ON CONFLICT(source, external_id) DO UPDATE SET\"\n \" key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at\",\n (source, external_id, key, json.dumps(payload, ensure_ascii=False),\n time.time()))\n con.commit()\n","structuredPatch":[{"oldStart":141,"oldLines":6,"newStart":141,"newLines":8,"lines":[" \"apply_url\": \"TEXT\", # lien de candidature directe"," \"title_clean\": \"TEXT\", # titre d'affichage normalisé (source intacte)"," \"quarantine\": \"TEXT\", # JSON des motifs qualité (NULL = publiable)","+ # Phase 3 (2026-08-25) — enrichissement : séniorité du poste","+ \"seniority\": \"TEXT\", # stage | junior | intermediaire | senior | direction"," },"," \"sync_log\": {},"," }"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcFXZj1otn83ix1iAeC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018bXeN2FtNiZ5TWkSqJXJdZ","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/db.py","old_string":" company_logo=job.company_logo, language=job.language,\n apply_url=job.apply_url,\n title_clean=clean_title(job.title, job.city),","new_string":" company_logo=job.company_logo, language=job.language,\n apply_url=job.apply_url, seniority=job.seniority,\n title_clean=clean_title(job.title, job.city),"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":2752,"cache_read_input_tokens":124647,"cache_creation":{"ephemeral_5m_input_tokens":2752,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"5438f403-f09c-4445-9b50-461f0ad4062c","request_id":"req_011CePcFVYgJhHni6KFU6cqS"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018bXeN2FtNiZ5TWkSqJXJdZ","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/db.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"6feca762-61e7-447d-9aee-b162ad1245cf","timestamp":"2026-08-25T14:16:30.282Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/db.py","oldString":" company_logo=job.company_logo, language=job.language,\n apply_url=job.apply_url,\n title_clean=clean_title(job.title, job.city),","newString":" company_logo=job.company_logo, language=job.language,\n apply_url=job.apply_url, seniority=job.seniority,\n title_clean=clean_title(job.title, job.city),","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/db.py\n# Rôle : Persistance SQLite — upsert avec détection de changements, cycle de\n# vie avec délai de grâce, anti-dérive, expiration sur date limite\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\nfrom __future__ import annotations\n\nimport datetime as _dt\nimport json\nimport sqlite3\nimport statistics\nimport time\nfrom pathlib import Path\n\nfrom .schema import JobPosting\n\nDB_PATH = Path(__file__).resolve().parent.parent / \"data\" / \"jobka.db\"\n\n# Nombre d'exécutions consécutives où une offre doit être absente de la\n# source avant d'être désactivée (délai de grâce contre les ratés ponctuels).\nMISS_GRACE = 2\n\n# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique\n# (médiane >= DRIFT_MIN_BASE offres), on alerte et on suspend les retraits.\nDRIFT_RATIO = 0.25\nDRIFT_MIN_BASE = 8\nDRIFT_HISTORY = 5\n\n_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS jobs (\n uid TEXT PRIMARY KEY,\n source TEXT NOT NULL,\n external_id TEXT NOT NULL,\n url TEXT,\n employer TEXT,\n title TEXT,\n description TEXT,\n address TEXT,\n city TEXT,\n region TEXT,\n postal_code TEXT,\n location_label TEXT,\n work_mode TEXT,\n employment_type TEXT,\n salary_min REAL,\n salary_max REAL,\n salary_unit TEXT,\n salary_label TEXT,\n salary_year_min REAL, -- conversions dérivées ($/an, $/h) pour filtres/tri\n salary_year_max REAL,\n salary_hour_min REAL,\n salary_hour_max REAL,\n benefits TEXT, -- JSON (liste de textes source)\n requirements TEXT, -- JSON (scolarité, expérience, langues)\n date_posted TEXT,\n date_deadline TEXT,\n category TEXT,\n ats TEXT,\n details TEXT, -- JSON (champs structurés, sparse)\n lat REAL,\n lng REAL,\n geocode_failed INTEGER DEFAULT 0,\n content_hash TEXT,\n first_seen REAL,\n last_seen REAL,\n updated_at REAL,\n miss_count INTEGER DEFAULT 0,\n active INTEGER DEFAULT 1,\n dup_of TEXT, -- uid de l'offre canonique si doublon inter-sources\n dup_sources TEXT -- JSON : autres sources où l'offre est publiée\n);\nCREATE INDEX IF NOT EXISTS idx_jobs_source ON jobs(source);\nCREATE INDEX IF NOT EXISTS idx_jobs_city ON jobs(city);\nCREATE INDEX IF NOT EXISTS idx_jobs_active ON jobs(active);\nCREATE INDEX IF NOT EXISTS idx_jobs_category ON jobs(category);\nCREATE INDEX IF NOT EXISTS idx_jobs_geo ON jobs(lat, lng);\n\nCREATE TABLE IF NOT EXISTS sync_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n source TEXT,\n ts REAL,\n found INTEGER,\n added INTEGER,\n updated INTEGER,\n removed INTEGER,\n ok INTEGER,\n message TEXT,\n stats TEXT -- JSON : taux de champs null, missed, alerte…\n);\n\nCREATE TABLE IF NOT EXISTS detail_cache (\n source TEXT NOT NULL,\n external_id TEXT NOT NULL,\n key TEXT, -- hash du contenu « liste » de l'offre\n payload TEXT, -- JSON opaque propre au connecteur\n fetched_at REAL,\n PRIMARY KEY (source, external_id)\n);\n\nCREATE TABLE IF NOT EXISTS salary_log (\n uid TEXT NOT NULL,\n ts REAL NOT NULL,\n s_min REAL,\n s_max REAL,\n unit TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_salary_log_uid ON salary_log(uid);\n\nCREATE TABLE IF NOT EXISTS users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ka_sub TEXT UNIQUE, -- « ka:{sub} » identifiant stable du hub KA\n ka_id TEXT, -- identifiant membre « ka-0123456789 » (hub)\n email TEXT,\n name TEXT,\n picture TEXT, -- URL de l'avatar (hub / Google)\n created_at REAL,\n last_login REAL\n);\nCREATE UNIQUE INDEX IF NOT EXISTS users_ka_id ON users(ka_id);\n\nCREATE TABLE IF NOT EXISTS geocode_cache (\n address TEXT PRIMARY KEY, -- adresse/ville normalisée (clé de cache)\n lat REAL,\n lng REAL,\n provider TEXT,\n failed INTEGER DEFAULT 0,\n ts REAL\n);\n\"\"\"\n\n# Colonnes ajoutées après la v1 — migration automatique des bases existantes.\n_MIGRATIONS: dict[str, dict[str, str]] = {\n \"jobs\": {\n # Phase 2 (2026-08-19) — enrichissement des connecteurs\n \"company_logo\": \"TEXT\", # URL du logo employeur (breezy, JSON-LD…)\n \"language\": \"TEXT\", # fr | en | bilingue (source ou heuristique)\n \"apply_url\": \"TEXT\", # lien de candidature directe\n \"title_clean\": \"TEXT\", # titre d'affichage normalisé (source intacte)\n \"quarantine\": \"TEXT\", # JSON des motifs qualité (NULL = publiable)\n # Phase 3 (2026-08-25) — enrichissement : séniorité du poste\n \"seniority\": \"TEXT\", # stage | junior | intermediaire | senior | direction\n },\n \"sync_log\": {},\n}\n\n\ndef connect() -> sqlite3.Connection:\n DB_PATH.parent.mkdir(parents=True, exist_ok=True)\n con = sqlite3.connect(DB_PATH, timeout=30.0)\n con.row_factory = sqlite3.Row\n con.execute(\"PRAGMA busy_timeout=30000\") # BD vivante (sync horaire)\n con.executescript(_SCHEMA)\n for table, cols in _MIGRATIONS.items():\n existing = {r[\"name\"] for r in con.execute(f\"PRAGMA table_info({table})\")}\n for col, decl in cols.items():\n if col not in existing:\n con.execute(f\"ALTER TABLE {table} ADD COLUMN {col} {decl}\")\n con.commit()\n return con\n\n\n# ---------------------------------------------------------------------------\n# Synchronisation d'une source\n# ---------------------------------------------------------------------------\n\ndef _drift_alert(con: sqlite3.Connection, source: str, found: int,\n null_salary_rate: float) -> str | None:\n \"\"\"Détecte une dérive du connecteur (chute du volume ou des salaires extraits).\n\n Retourne un message d'alerte, ou None si tout est normal.\n \"\"\"\n hist = con.execute(\n \"SELECT found, stats FROM sync_log WHERE source=? AND ok=1\"\n \" ORDER BY ts DESC LIMIT ?\", (source, DRIFT_HISTORY)).fetchall()\n if len(hist) < 3:\n return None\n med_found = statistics.median(r[\"found\"] for r in hist)\n if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:\n return (f\"dérive: {found} offre(s) trouvée(s) contre une médiane de \"\n f\"{med_found:.0f} — retraits suspendus, vérifier le connecteur\")\n if found >= DRIFT_MIN_BASE and null_salary_rate >= 0.95:\n rates = []\n for r in hist:\n try:\n rates.append(json.loads(r[\"stats\"] or \"{}\")[\"null_salary_rate\"])\n except (KeyError, ValueError, TypeError):\n continue\n if rates and statistics.median(rates) <= 0.5:\n return (f\"dérive: {null_salary_rate:.0%} des offres sans salaire \"\n f\"(habituellement {statistics.median(rates):.0%}) — \"\n \"le format de la source a probablement changé\")\n return None\n\n\ndef sync_source(con: sqlite3.Connection, source: str,\n postings: list[JobPosting]) -> dict:\n \"\"\"Synchronise les offres d'une source.\n\n - nouvelle offre -> insertion\n - offre modifiée -> mise à jour (comparaison de content_hash)\n - offre disparue -> miss_count += 1, puis active=0 après MISS_GRACE\n exécutions consécutives (délai de grâce)\n - dérive détectée -> alerte consignée, retraits suspendus\n \"\"\"\n now = time.time()\n added = updated = 0\n seen_uids = set()\n\n n = len(postings)\n null_salary = sum(1 for p in postings if p.salary_min is None)\n null_city = sum(1 for p in postings if not p.city)\n null_salary_rate = round(null_salary / n, 3) if n else 0.0\n\n alert = _drift_alert(con, source, n, null_salary_rate)\n\n from .normalize import clean_title\n from .quality import issues_for_posting\n\n for job in postings:\n seen_uids.add(job.uid)\n h = job.content_hash()\n row = con.execute(\n \"SELECT content_hash, salary_min, salary_max FROM jobs WHERE uid=?\",\n (job.uid,)).fetchone()\n defects = issues_for_posting(job)\n params = dict(\n uid=job.uid, source=job.source, external_id=job.external_id,\n url=job.url, employer=job.employer, title=job.title,\n description=job.description, address=job.address, city=job.city,\n region=job.region, postal_code=job.postal_code,\n location_label=job.location_label, work_mode=job.work_mode,\n employment_type=job.employment_type,\n salary_min=job.salary_min, salary_max=job.salary_max,\n salary_unit=job.salary_unit, salary_label=job.salary_label,\n salary_year_min=job.salary_year_min(),\n salary_year_max=job.salary_year_max(),\n salary_hour_min=job.salary_hour_min(),\n salary_hour_max=job.salary_hour_max(),\n benefits=json.dumps(job.benefits, ensure_ascii=False),\n requirements=json.dumps(job.requirements, ensure_ascii=False),\n date_posted=job.date_posted, date_deadline=job.date_deadline,\n category=job.category, ats=job.ats,\n details=json.dumps(job.details, ensure_ascii=False),\n lat=job.lat, lng=job.lng, content_hash=h, now=now,\n company_logo=job.company_logo, language=job.language,\n apply_url=job.apply_url,\n title_clean=clean_title(job.title, job.city),\n quarantine=json.dumps(defects, ensure_ascii=False)\n if defects else None,\n )\n if row is None:\n con.execute(\n \"\"\"INSERT INTO jobs (uid, source, external_id, url, employer,\n title, description, address, city, region, postal_code,\n location_label, work_mode, employment_type, salary_min,\n salary_max, salary_unit, salary_label, salary_year_min,\n salary_year_max, salary_hour_min, salary_hour_max, benefits,\n requirements, date_posted, date_deadline, category, ats,\n details, lat, lng, content_hash, first_seen, last_seen,\n updated_at, miss_count, active, company_logo, language,\n apply_url, title_clean, quarantine)\n VALUES (:uid,:source,:external_id,:url,:employer,:title,\n :description,:address,:city,:region,:postal_code,\n :location_label,:work_mode,:employment_type,:salary_min,\n :salary_max,:salary_unit,:salary_label,:salary_year_min,\n :salary_year_max,:salary_hour_min,:salary_hour_max,\n :benefits,:requirements,:date_posted,:date_deadline,\n :category,:ats,:details,:lat,:lng,:content_hash,\n :now,:now,:now,0,1,:company_logo,:language,:apply_url,\n :title_clean,:quarantine)\"\"\", params)\n if job.salary_min is not None: # point de départ de l'historique\n con.execute(\n \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n \" VALUES (?,?,?,?,?)\",\n (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n added += 1\n elif row[\"content_hash\"] != h:\n # COALESCE : ne jamais écraser des coordonnées géocodées par null.\n # Si l'adresse change, geocode_failed est remis à 0 (correction du\n # bogue relevé chez Lou·Ka) pour permettre un re-géocodage.\n con.execute(\n \"\"\"UPDATE jobs SET url=:url, employer=:employer, title=:title,\n description=:description,\n geocode_failed=CASE WHEN address<>:address OR city<>:city\n THEN 0 ELSE geocode_failed END,\n address=:address, city=:city, region=:region,\n postal_code=:postal_code, location_label=:location_label,\n work_mode=:work_mode, employment_type=:employment_type,\n salary_min=:salary_min, salary_max=:salary_max,\n salary_unit=:salary_unit, salary_label=:salary_label,\n salary_year_min=:salary_year_min,\n salary_year_max=:salary_year_max,\n salary_hour_min=:salary_hour_min,\n salary_hour_max=:salary_hour_max,\n benefits=:benefits, requirements=:requirements,\n date_posted=:date_posted, date_deadline=:date_deadline,\n category=:category, ats=:ats, details=:details,\n lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),\n company_logo=:company_logo, language=:language,\n apply_url=:apply_url, title_clean=:title_clean,\n quarantine=:quarantine,\n content_hash=:content_hash, last_seen=:now,\n updated_at=:now, miss_count=0, active=1\n WHERE uid=:uid\"\"\", params)\n if (job.salary_min, job.salary_max) != (row[\"salary_min\"], row[\"salary_max\"]):\n con.execute(\n \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n \" VALUES (?,?,?,?,?)\",\n (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n updated += 1\n else:\n con.execute(\n \"UPDATE jobs SET last_seen=?, miss_count=0, active=1 WHERE uid=?\",\n (now, job.uid))\n\n # Offres de cette source qui n'apparaissent plus : délai de grâce,\n # puis désactivation. Suspendu si une dérive est détectée.\n removed = missed = 0\n if not alert:\n for r in con.execute(\n \"SELECT uid, miss_count FROM jobs WHERE source=? AND active=1\",\n (source,)).fetchall():\n if r[\"uid\"] in seen_uids:\n continue\n missed += 1\n if r[\"miss_count\"] + 1 >= MISS_GRACE:\n con.execute(\n \"UPDATE jobs SET active=0, miss_count=?, updated_at=?\"\n \" WHERE uid=?\", (r[\"miss_count\"] + 1, now, r[\"uid\"]))\n removed += 1\n else:\n con.execute(\"UPDATE jobs SET miss_count=miss_count+1 WHERE uid=?\",\n (r[\"uid\"],))\n\n stats = {\n \"null_salary_rate\": null_salary_rate,\n \"null_city_rate\": round(null_city / n, 3) if n else 0.0,\n \"missed\": missed,\n }\n if alert:\n stats[\"alert\"] = alert\n con.execute(\n \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,\"\n \" message, stats) VALUES (?,?,?,?,?,?,1,?,?)\",\n (source, now, n, added, updated, removed, alert or \"ok\",\n json.dumps(stats, ensure_ascii=False)))\n con.commit()\n out = {\"source\": source, \"found\": n, \"added\": added,\n \"updated\": updated, \"removed\": removed}\n if alert:\n out[\"alert\"] = alert\n return out\n\n\ndef expire_past_deadline(con: sqlite3.Connection) -> int:\n \"\"\"Désactive les offres dont la date limite est dépassée (offres zombies :\n encore affichées sur la page carrière mais plus ouvertes aux candidatures).\"\"\"\n today = _dt.date.today().isoformat()\n cur = con.execute(\n \"UPDATE jobs SET active=0, updated_at=? WHERE active=1\"\n \" AND date_deadline IS NOT NULL AND date_deadline < ?\",\n (time.time(), today))\n con.commit()\n return cur.rowcount\n\n\nSTALE_DAYS = 7 # offres plus jamais revues (connecteur retiré/muet) : archivées\n\n\ndef expire_stale(con: sqlite3.Connection, days: int = STALE_DAYS) -> int:\n \"\"\"Archive (active=0) les offres actives non revues depuis `days` jours.\n\n Filet de sécurité complémentaire au délai de grâce MISS_GRACE : couvre le\n cas d'un connecteur SUPPRIMÉ du code (ses offres ne passent plus jamais\n par sync_source et resteraient actives pour toujours — cas « valero »).\n Rien n'est supprimé : les offres sont archivées, réactivables si revues.\n \"\"\"\n cutoff = time.time() - days * 86400\n cur = con.execute(\n \"UPDATE jobs SET active=0, updated_at=? WHERE active=1 AND last_seen'depot-direct'\", # le dépôt direct n'est jamais resynchronisé\n (time.time(), cutoff))\n con.commit()\n return cur.rowcount\n\n\ndef log_failure(con: sqlite3.Connection, source: str, message: str) -> None:\n con.execute(\n \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)\"\n \" VALUES (?,?,0,0,0,0,0,?)\", (source, time.time(), message))\n con.commit()\n\n\n# ---------------------------------------------------------------------------\n# Cache des pages détail (« détail si nouveau/modifié »)\n# ---------------------------------------------------------------------------\n\ndef get_cached_detail(con: sqlite3.Connection, source: str,\n external_id: str, key: str) -> dict | None:\n \"\"\"Payload détail mis en cache si la clé (hash liste) n'a pas changé.\"\"\"\n row = con.execute(\n \"SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?\",\n (source, external_id)).fetchone()\n if row and row[\"key\"] == key and row[\"payload\"]:\n try:\n return json.loads(row[\"payload\"])\n except ValueError:\n return None\n return None\n\n\ndef get_stale_detail(con: sqlite3.Connection, source: str,\n external_id: str) -> dict | None:\n \"\"\"Payload détail en cache MÊME si la clé a changé — secours quand le\n budget de re-visites est épuisé : un détail périmé vaut mieux qu'une\n description vide (la re-visite aura lieu à un prochain cycle).\"\"\"\n row = con.execute(\n \"SELECT payload FROM detail_cache WHERE source=? AND external_id=?\",\n (source, external_id)).fetchone()\n if row and row[\"payload\"]:\n try:\n return json.loads(row[\"payload\"])\n except ValueError:\n return None\n return None\n\n\ndef put_cached_detail(con: sqlite3.Connection, source: str,\n external_id: str, key: str, payload: dict) -> None:\n con.execute(\n \"INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)\"\n \" VALUES (?,?,?,?,?)\"\n \" ON CONFLICT(source, external_id) DO UPDATE SET\"\n \" key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at\",\n (source, external_id, key, json.dumps(payload, ensure_ascii=False),\n time.time()))\n con.commit()\n","structuredPatch":[{"oldStart":247,"oldLines":7,"newStart":247,"newLines":7,"lines":[" details=json.dumps(job.details, ensure_ascii=False),"," lat=job.lat, lng=job.lng, content_hash=h, now=now,"," company_logo=job.company_logo, language=job.language,","- apply_url=job.apply_url,","+ apply_url=job.apply_url, seniority=job.seniority,"," title_clean=clean_title(job.title, job.city),"," quarantine=json.dumps(defects, ensure_ascii=False)"," if defects else None,"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcFXZj1otn83ix1iAeC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017WhMDqXGViiMm2nyzY8jNm","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/db.py","old_string":" details, lat, lng, content_hash, first_seen, last_seen,\n updated_at, miss_count, active, company_logo, language,\n apply_url, title_clean, quarantine)\n VALUES (:uid,:source,:external_id,:url,:employer,:title,\n :description,:address,:city,:region,:postal_code,\n :location_label,:work_mode,:employment_type,:salary_min,\n :salary_max,:salary_unit,:salary_label,:salary_year_min,\n :salary_year_max,:salary_hour_min,:salary_hour_max,\n :benefits,:requirements,:date_posted,:date_deadline,\n :category,:ats,:details,:lat,:lng,:content_hash,\n :now,:now,:now,0,1,:company_logo,:language,:apply_url,\n :title_clean,:quarantine)\"\"\", params)","new_string":" details, lat, lng, content_hash, first_seen, last_seen,\n updated_at, miss_count, active, company_logo, language,\n apply_url, title_clean, quarantine, seniority)\n VALUES (:uid,:source,:external_id,:url,:employer,:title,\n :description,:address,:city,:region,:postal_code,\n :location_label,:work_mode,:employment_type,:salary_min,\n :salary_max,:salary_unit,:salary_label,:salary_year_min,\n :salary_year_max,:salary_hour_min,:salary_hour_max,\n :benefits,:requirements,:date_posted,:date_deadline,\n :category,:ats,:details,:lat,:lng,:content_hash,\n :now,:now,:now,0,1,:company_logo,:language,:apply_url,\n :title_clean,:quarantine,:seniority)\"\"\", params)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":2752,"cache_read_input_tokens":124647,"cache_creation":{"ephemeral_5m_input_tokens":2752,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"7473ea24-cb0d-4f37-a27a-16d7ee030fea","request_id":"req_011CePcFVYgJhHni6KFU6cqS"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017WhMDqXGViiMm2nyzY8jNm","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/db.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"a51124f1-7665-4232-ba37-89c536863d15","timestamp":"2026-08-25T14:16:35.922Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/db.py","oldString":" details, lat, lng, content_hash, first_seen, last_seen,\n updated_at, miss_count, active, company_logo, language,\n apply_url, title_clean, quarantine)\n VALUES (:uid,:source,:external_id,:url,:employer,:title,\n :description,:address,:city,:region,:postal_code,\n :location_label,:work_mode,:employment_type,:salary_min,\n :salary_max,:salary_unit,:salary_label,:salary_year_min,\n :salary_year_max,:salary_hour_min,:salary_hour_max,\n :benefits,:requirements,:date_posted,:date_deadline,\n :category,:ats,:details,:lat,:lng,:content_hash,\n :now,:now,:now,0,1,:company_logo,:language,:apply_url,\n :title_clean,:quarantine)\"\"\", params)","newString":" details, lat, lng, content_hash, first_seen, last_seen,\n updated_at, miss_count, active, company_logo, language,\n apply_url, title_clean, quarantine, seniority)\n VALUES (:uid,:source,:external_id,:url,:employer,:title,\n :description,:address,:city,:region,:postal_code,\n :location_label,:work_mode,:employment_type,:salary_min,\n :salary_max,:salary_unit,:salary_label,:salary_year_min,\n :salary_year_max,:salary_hour_min,:salary_hour_max,\n :benefits,:requirements,:date_posted,:date_deadline,\n :category,:ats,:details,:lat,:lng,:content_hash,\n :now,:now,:now,0,1,:company_logo,:language,:apply_url,\n :title_clean,:quarantine,:seniority)\"\"\", params)","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/db.py\n# Rôle : Persistance SQLite — upsert avec détection de changements, cycle de\n# vie avec délai de grâce, anti-dérive, expiration sur date limite\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\nfrom __future__ import annotations\n\nimport datetime as _dt\nimport json\nimport sqlite3\nimport statistics\nimport time\nfrom pathlib import Path\n\nfrom .schema import JobPosting\n\nDB_PATH = Path(__file__).resolve().parent.parent / \"data\" / \"jobka.db\"\n\n# Nombre d'exécutions consécutives où une offre doit être absente de la\n# source avant d'être désactivée (délai de grâce contre les ratés ponctuels).\nMISS_GRACE = 2\n\n# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique\n# (médiane >= DRIFT_MIN_BASE offres), on alerte et on suspend les retraits.\nDRIFT_RATIO = 0.25\nDRIFT_MIN_BASE = 8\nDRIFT_HISTORY = 5\n\n_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS jobs (\n uid TEXT PRIMARY KEY,\n source TEXT NOT NULL,\n external_id TEXT NOT NULL,\n url TEXT,\n employer TEXT,\n title TEXT,\n description TEXT,\n address TEXT,\n city TEXT,\n region TEXT,\n postal_code TEXT,\n location_label TEXT,\n work_mode TEXT,\n employment_type TEXT,\n salary_min REAL,\n salary_max REAL,\n salary_unit TEXT,\n salary_label TEXT,\n salary_year_min REAL, -- conversions dérivées ($/an, $/h) pour filtres/tri\n salary_year_max REAL,\n salary_hour_min REAL,\n salary_hour_max REAL,\n benefits TEXT, -- JSON (liste de textes source)\n requirements TEXT, -- JSON (scolarité, expérience, langues)\n date_posted TEXT,\n date_deadline TEXT,\n category TEXT,\n ats TEXT,\n details TEXT, -- JSON (champs structurés, sparse)\n lat REAL,\n lng REAL,\n geocode_failed INTEGER DEFAULT 0,\n content_hash TEXT,\n first_seen REAL,\n last_seen REAL,\n updated_at REAL,\n miss_count INTEGER DEFAULT 0,\n active INTEGER DEFAULT 1,\n dup_of TEXT, -- uid de l'offre canonique si doublon inter-sources\n dup_sources TEXT -- JSON : autres sources où l'offre est publiée\n);\nCREATE INDEX IF NOT EXISTS idx_jobs_source ON jobs(source);\nCREATE INDEX IF NOT EXISTS idx_jobs_city ON jobs(city);\nCREATE INDEX IF NOT EXISTS idx_jobs_active ON jobs(active);\nCREATE INDEX IF NOT EXISTS idx_jobs_category ON jobs(category);\nCREATE INDEX IF NOT EXISTS idx_jobs_geo ON jobs(lat, lng);\n\nCREATE TABLE IF NOT EXISTS sync_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n source TEXT,\n ts REAL,\n found INTEGER,\n added INTEGER,\n updated INTEGER,\n removed INTEGER,\n ok INTEGER,\n message TEXT,\n stats TEXT -- JSON : taux de champs null, missed, alerte…\n);\n\nCREATE TABLE IF NOT EXISTS detail_cache (\n source TEXT NOT NULL,\n external_id TEXT NOT NULL,\n key TEXT, -- hash du contenu « liste » de l'offre\n payload TEXT, -- JSON opaque propre au connecteur\n fetched_at REAL,\n PRIMARY KEY (source, external_id)\n);\n\nCREATE TABLE IF NOT EXISTS salary_log (\n uid TEXT NOT NULL,\n ts REAL NOT NULL,\n s_min REAL,\n s_max REAL,\n unit TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_salary_log_uid ON salary_log(uid);\n\nCREATE TABLE IF NOT EXISTS users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ka_sub TEXT UNIQUE, -- « ka:{sub} » identifiant stable du hub KA\n ka_id TEXT, -- identifiant membre « ka-0123456789 » (hub)\n email TEXT,\n name TEXT,\n picture TEXT, -- URL de l'avatar (hub / Google)\n created_at REAL,\n last_login REAL\n);\nCREATE UNIQUE INDEX IF NOT EXISTS users_ka_id ON users(ka_id);\n\nCREATE TABLE IF NOT EXISTS geocode_cache (\n address TEXT PRIMARY KEY, -- adresse/ville normalisée (clé de cache)\n lat REAL,\n lng REAL,\n provider TEXT,\n failed INTEGER DEFAULT 0,\n ts REAL\n);\n\"\"\"\n\n# Colonnes ajoutées après la v1 — migration automatique des bases existantes.\n_MIGRATIONS: dict[str, dict[str, str]] = {\n \"jobs\": {\n # Phase 2 (2026-08-19) — enrichissement des connecteurs\n \"company_logo\": \"TEXT\", # URL du logo employeur (breezy, JSON-LD…)\n \"language\": \"TEXT\", # fr | en | bilingue (source ou heuristique)\n \"apply_url\": \"TEXT\", # lien de candidature directe\n \"title_clean\": \"TEXT\", # titre d'affichage normalisé (source intacte)\n \"quarantine\": \"TEXT\", # JSON des motifs qualité (NULL = publiable)\n # Phase 3 (2026-08-25) — enrichissement : séniorité du poste\n \"seniority\": \"TEXT\", # stage | junior | intermediaire | senior | direction\n },\n \"sync_log\": {},\n}\n\n\ndef connect() -> sqlite3.Connection:\n DB_PATH.parent.mkdir(parents=True, exist_ok=True)\n con = sqlite3.connect(DB_PATH, timeout=30.0)\n con.row_factory = sqlite3.Row\n con.execute(\"PRAGMA busy_timeout=30000\") # BD vivante (sync horaire)\n con.executescript(_SCHEMA)\n for table, cols in _MIGRATIONS.items():\n existing = {r[\"name\"] for r in con.execute(f\"PRAGMA table_info({table})\")}\n for col, decl in cols.items():\n if col not in existing:\n con.execute(f\"ALTER TABLE {table} ADD COLUMN {col} {decl}\")\n con.commit()\n return con\n\n\n# ---------------------------------------------------------------------------\n# Synchronisation d'une source\n# ---------------------------------------------------------------------------\n\ndef _drift_alert(con: sqlite3.Connection, source: str, found: int,\n null_salary_rate: float) -> str | None:\n \"\"\"Détecte une dérive du connecteur (chute du volume ou des salaires extraits).\n\n Retourne un message d'alerte, ou None si tout est normal.\n \"\"\"\n hist = con.execute(\n \"SELECT found, stats FROM sync_log WHERE source=? AND ok=1\"\n \" ORDER BY ts DESC LIMIT ?\", (source, DRIFT_HISTORY)).fetchall()\n if len(hist) < 3:\n return None\n med_found = statistics.median(r[\"found\"] for r in hist)\n if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:\n return (f\"dérive: {found} offre(s) trouvée(s) contre une médiane de \"\n f\"{med_found:.0f} — retraits suspendus, vérifier le connecteur\")\n if found >= DRIFT_MIN_BASE and null_salary_rate >= 0.95:\n rates = []\n for r in hist:\n try:\n rates.append(json.loads(r[\"stats\"] or \"{}\")[\"null_salary_rate\"])\n except (KeyError, ValueError, TypeError):\n continue\n if rates and statistics.median(rates) <= 0.5:\n return (f\"dérive: {null_salary_rate:.0%} des offres sans salaire \"\n f\"(habituellement {statistics.median(rates):.0%}) — \"\n \"le format de la source a probablement changé\")\n return None\n\n\ndef sync_source(con: sqlite3.Connection, source: str,\n postings: list[JobPosting]) -> dict:\n \"\"\"Synchronise les offres d'une source.\n\n - nouvelle offre -> insertion\n - offre modifiée -> mise à jour (comparaison de content_hash)\n - offre disparue -> miss_count += 1, puis active=0 après MISS_GRACE\n exécutions consécutives (délai de grâce)\n - dérive détectée -> alerte consignée, retraits suspendus\n \"\"\"\n now = time.time()\n added = updated = 0\n seen_uids = set()\n\n n = len(postings)\n null_salary = sum(1 for p in postings if p.salary_min is None)\n null_city = sum(1 for p in postings if not p.city)\n null_salary_rate = round(null_salary / n, 3) if n else 0.0\n\n alert = _drift_alert(con, source, n, null_salary_rate)\n\n from .normalize import clean_title\n from .quality import issues_for_posting\n\n for job in postings:\n seen_uids.add(job.uid)\n h = job.content_hash()\n row = con.execute(\n \"SELECT content_hash, salary_min, salary_max FROM jobs WHERE uid=?\",\n (job.uid,)).fetchone()\n defects = issues_for_posting(job)\n params = dict(\n uid=job.uid, source=job.source, external_id=job.external_id,\n url=job.url, employer=job.employer, title=job.title,\n description=job.description, address=job.address, city=job.city,\n region=job.region, postal_code=job.postal_code,\n location_label=job.location_label, work_mode=job.work_mode,\n employment_type=job.employment_type,\n salary_min=job.salary_min, salary_max=job.salary_max,\n salary_unit=job.salary_unit, salary_label=job.salary_label,\n salary_year_min=job.salary_year_min(),\n salary_year_max=job.salary_year_max(),\n salary_hour_min=job.salary_hour_min(),\n salary_hour_max=job.salary_hour_max(),\n benefits=json.dumps(job.benefits, ensure_ascii=False),\n requirements=json.dumps(job.requirements, ensure_ascii=False),\n date_posted=job.date_posted, date_deadline=job.date_deadline,\n category=job.category, ats=job.ats,\n details=json.dumps(job.details, ensure_ascii=False),\n lat=job.lat, lng=job.lng, content_hash=h, now=now,\n company_logo=job.company_logo, language=job.language,\n apply_url=job.apply_url, seniority=job.seniority,\n title_clean=clean_title(job.title, job.city),\n quarantine=json.dumps(defects, ensure_ascii=False)\n if defects else None,\n )\n if row is None:\n con.execute(\n \"\"\"INSERT INTO jobs (uid, source, external_id, url, employer,\n title, description, address, city, region, postal_code,\n location_label, work_mode, employment_type, salary_min,\n salary_max, salary_unit, salary_label, salary_year_min,\n salary_year_max, salary_hour_min, salary_hour_max, benefits,\n requirements, date_posted, date_deadline, category, ats,\n details, lat, lng, content_hash, first_seen, last_seen,\n updated_at, miss_count, active, company_logo, language,\n apply_url, title_clean, quarantine)\n VALUES (:uid,:source,:external_id,:url,:employer,:title,\n :description,:address,:city,:region,:postal_code,\n :location_label,:work_mode,:employment_type,:salary_min,\n :salary_max,:salary_unit,:salary_label,:salary_year_min,\n :salary_year_max,:salary_hour_min,:salary_hour_max,\n :benefits,:requirements,:date_posted,:date_deadline,\n :category,:ats,:details,:lat,:lng,:content_hash,\n :now,:now,:now,0,1,:company_logo,:language,:apply_url,\n :title_clean,:quarantine)\"\"\", params)\n if job.salary_min is not None: # point de départ de l'historique\n con.execute(\n \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n \" VALUES (?,?,?,?,?)\",\n (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n added += 1\n elif row[\"content_hash\"] != h:\n # COALESCE : ne jamais écraser des coordonnées géocodées par null.\n # Si l'adresse change, geocode_failed est remis à 0 (correction du\n # bogue relevé chez Lou·Ka) pour permettre un re-géocodage.\n con.execute(\n \"\"\"UPDATE jobs SET url=:url, employer=:employer, title=:title,\n description=:description,\n geocode_failed=CASE WHEN address<>:address OR city<>:city\n THEN 0 ELSE geocode_failed END,\n address=:address, city=:city, region=:region,\n postal_code=:postal_code, location_label=:location_label,\n work_mode=:work_mode, employment_type=:employment_type,\n salary_min=:salary_min, salary_max=:salary_max,\n salary_unit=:salary_unit, salary_label=:salary_label,\n salary_year_min=:salary_year_min,\n salary_year_max=:salary_year_max,\n salary_hour_min=:salary_hour_min,\n salary_hour_max=:salary_hour_max,\n benefits=:benefits, requirements=:requirements,\n date_posted=:date_posted, date_deadline=:date_deadline,\n category=:category, ats=:ats, details=:details,\n lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),\n company_logo=:company_logo, language=:language,\n apply_url=:apply_url, title_clean=:title_clean,\n quarantine=:quarantine,\n content_hash=:content_hash, last_seen=:now,\n updated_at=:now, miss_count=0, active=1\n WHERE uid=:uid\"\"\", params)\n if (job.salary_min, job.salary_max) != (row[\"salary_min\"], row[\"salary_max\"]):\n con.execute(\n \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n \" VALUES (?,?,?,?,?)\",\n (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n updated += 1\n else:\n con.execute(\n \"UPDATE jobs SET last_seen=?, miss_count=0, active=1 WHERE uid=?\",\n (now, job.uid))\n\n # Offres de cette source qui n'apparaissent plus : délai de grâce,\n # puis désactivation. Suspendu si une dérive est détectée.\n removed = missed = 0\n if not alert:\n for r in con.execute(\n \"SELECT uid, miss_count FROM jobs WHERE source=? AND active=1\",\n (source,)).fetchall():\n if r[\"uid\"] in seen_uids:\n continue\n missed += 1\n if r[\"miss_count\"] + 1 >= MISS_GRACE:\n con.execute(\n \"UPDATE jobs SET active=0, miss_count=?, updated_at=?\"\n \" WHERE uid=?\", (r[\"miss_count\"] + 1, now, r[\"uid\"]))\n removed += 1\n else:\n con.execute(\"UPDATE jobs SET miss_count=miss_count+1 WHERE uid=?\",\n (r[\"uid\"],))\n\n stats = {\n \"null_salary_rate\": null_salary_rate,\n \"null_city_rate\": round(null_city / n, 3) if n else 0.0,\n \"missed\": missed,\n }\n if alert:\n stats[\"alert\"] = alert\n con.execute(\n \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,\"\n \" message, stats) VALUES (?,?,?,?,?,?,1,?,?)\",\n (source, now, n, added, updated, removed, alert or \"ok\",\n json.dumps(stats, ensure_ascii=False)))\n con.commit()\n out = {\"source\": source, \"found\": n, \"added\": added,\n \"updated\": updated, \"removed\": removed}\n if alert:\n out[\"alert\"] = alert\n return out\n\n\ndef expire_past_deadline(con: sqlite3.Connection) -> int:\n \"\"\"Désactive les offres dont la date limite est dépassée (offres zombies :\n encore affichées sur la page carrière mais plus ouvertes aux candidatures).\"\"\"\n today = _dt.date.today().isoformat()\n cur = con.execute(\n \"UPDATE jobs SET active=0, updated_at=? WHERE active=1\"\n \" AND date_deadline IS NOT NULL AND date_deadline < ?\",\n (time.time(), today))\n con.commit()\n return cur.rowcount\n\n\nSTALE_DAYS = 7 # offres plus jamais revues (connecteur retiré/muet) : archivées\n\n\ndef expire_stale(con: sqlite3.Connection, days: int = STALE_DAYS) -> int:\n \"\"\"Archive (active=0) les offres actives non revues depuis `days` jours.\n\n Filet de sécurité complémentaire au délai de grâce MISS_GRACE : couvre le\n cas d'un connecteur SUPPRIMÉ du code (ses offres ne passent plus jamais\n par sync_source et resteraient actives pour toujours — cas « valero »).\n Rien n'est supprimé : les offres sont archivées, réactivables si revues.\n \"\"\"\n cutoff = time.time() - days * 86400\n cur = con.execute(\n \"UPDATE jobs SET active=0, updated_at=? WHERE active=1 AND last_seen'depot-direct'\", # le dépôt direct n'est jamais resynchronisé\n (time.time(), cutoff))\n con.commit()\n return cur.rowcount\n\n\ndef log_failure(con: sqlite3.Connection, source: str, message: str) -> None:\n con.execute(\n \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)\"\n \" VALUES (?,?,0,0,0,0,0,?)\", (source, time.time(), message))\n con.commit()\n\n\n# ---------------------------------------------------------------------------\n# Cache des pages détail (« détail si nouveau/modifié »)\n# ---------------------------------------------------------------------------\n\ndef get_cached_detail(con: sqlite3.Connection, source: str,\n external_id: str, key: str) -> dict | None:\n \"\"\"Payload détail mis en cache si la clé (hash liste) n'a pas changé.\"\"\"\n row = con.execute(\n \"SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?\",\n (source, external_id)).fetchone()\n if row and row[\"key\"] == key and row[\"payload\"]:\n try:\n return json.loads(row[\"payload\"])\n except ValueError:\n return None\n return None\n\n\ndef get_stale_detail(con: sqlite3.Connection, source: str,\n external_id: str) -> dict | None:\n \"\"\"Payload détail en cache MÊME si la clé a changé — secours quand le\n budget de re-visites est épuisé : un détail périmé vaut mieux qu'une\n description vide (la re-visite aura lieu à un prochain cycle).\"\"\"\n row = con.execute(\n \"SELECT payload FROM detail_cache WHERE source=? AND external_id=?\",\n (source, external_id)).fetchone()\n if row and row[\"payload\"]:\n try:\n return json.loads(row[\"payload\"])\n except ValueError:\n return None\n return None\n\n\ndef put_cached_detail(con: sqlite3.Connection, source: str,\n external_id: str, key: str, payload: dict) -> None:\n con.execute(\n \"INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)\"\n \" VALUES (?,?,?,?,?)\"\n \" ON CONFLICT(source, external_id) DO UPDATE SET\"\n \" key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at\",\n (source, external_id, key, json.dumps(payload, ensure_ascii=False),\n time.time()))\n con.commit()\n","structuredPatch":[{"oldStart":262,"oldLines":7,"newStart":262,"newLines":7,"lines":[" requirements, date_posted, date_deadline, category, ats,"," details, lat, lng, content_hash, first_seen, last_seen,"," updated_at, miss_count, active, company_logo, language,","- apply_url, title_clean, quarantine)","+ apply_url, title_clean, quarantine, seniority)"," VALUES (:uid,:source,:external_id,:url,:employer,:title,"," :description,:address,:city,:region,:postal_code,"," :location_label,:work_mode,:employment_type,:salary_min,"]},{"oldStart":271,"oldLines":7,"newStart":271,"newLines":7,"lines":[" :benefits,:requirements,:date_posted,:date_deadline,"," :category,:ats,:details,:lat,:lng,:content_hash,"," :now,:now,:now,0,1,:company_logo,:language,:apply_url,","- :title_clean,:quarantine)\"\"\", params)","+ :title_clean,:quarantine,:seniority)\"\"\", params)"," if job.salary_min is not None: # point de départ de l'historique"," con.execute("," \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\""]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcFXZj1otn83ix1iAeC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MbfKNt9jp5y845vDN6aE9Y","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/db.py","old_string":" company_logo=:company_logo, language=:language,\n apply_url=:apply_url, title_clean=:title_clean,\n quarantine=:quarantine,","new_string":" company_logo=:company_logo, language=:language,\n apply_url=:apply_url, title_clean=:title_clean,\n quarantine=:quarantine, seniority=:seniority,"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":2752,"cache_read_input_tokens":124647,"cache_creation":{"ephemeral_5m_input_tokens":2752,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"e6d13ebe-88e2-4d14-9c84-8855546138cb","request_id":"req_011CePcFVYgJhHni6KFU6cqS"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MbfKNt9jp5y845vDN6aE9Y","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/db.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"b05878a0-a367-42f4-b5e6-b7de6761f502","timestamp":"2026-08-25T14:16:37.953Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/db.py","oldString":" company_logo=:company_logo, language=:language,\n apply_url=:apply_url, title_clean=:title_clean,\n quarantine=:quarantine,","newString":" company_logo=:company_logo, language=:language,\n apply_url=:apply_url, title_clean=:title_clean,\n quarantine=:quarantine, seniority=:seniority,","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/db.py\n# Rôle : Persistance SQLite — upsert avec détection de changements, cycle de\n# vie avec délai de grâce, anti-dérive, expiration sur date limite\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\nfrom __future__ import annotations\n\nimport datetime as _dt\nimport json\nimport sqlite3\nimport statistics\nimport time\nfrom pathlib import Path\n\nfrom .schema import JobPosting\n\nDB_PATH = Path(__file__).resolve().parent.parent / \"data\" / \"jobka.db\"\n\n# Nombre d'exécutions consécutives où une offre doit être absente de la\n# source avant d'être désactivée (délai de grâce contre les ratés ponctuels).\nMISS_GRACE = 2\n\n# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique\n# (médiane >= DRIFT_MIN_BASE offres), on alerte et on suspend les retraits.\nDRIFT_RATIO = 0.25\nDRIFT_MIN_BASE = 8\nDRIFT_HISTORY = 5\n\n_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS jobs (\n uid TEXT PRIMARY KEY,\n source TEXT NOT NULL,\n external_id TEXT NOT NULL,\n url TEXT,\n employer TEXT,\n title TEXT,\n description TEXT,\n address TEXT,\n city TEXT,\n region TEXT,\n postal_code TEXT,\n location_label TEXT,\n work_mode TEXT,\n employment_type TEXT,\n salary_min REAL,\n salary_max REAL,\n salary_unit TEXT,\n salary_label TEXT,\n salary_year_min REAL, -- conversions dérivées ($/an, $/h) pour filtres/tri\n salary_year_max REAL,\n salary_hour_min REAL,\n salary_hour_max REAL,\n benefits TEXT, -- JSON (liste de textes source)\n requirements TEXT, -- JSON (scolarité, expérience, langues)\n date_posted TEXT,\n date_deadline TEXT,\n category TEXT,\n ats TEXT,\n details TEXT, -- JSON (champs structurés, sparse)\n lat REAL,\n lng REAL,\n geocode_failed INTEGER DEFAULT 0,\n content_hash TEXT,\n first_seen REAL,\n last_seen REAL,\n updated_at REAL,\n miss_count INTEGER DEFAULT 0,\n active INTEGER DEFAULT 1,\n dup_of TEXT, -- uid de l'offre canonique si doublon inter-sources\n dup_sources TEXT -- JSON : autres sources où l'offre est publiée\n);\nCREATE INDEX IF NOT EXISTS idx_jobs_source ON jobs(source);\nCREATE INDEX IF NOT EXISTS idx_jobs_city ON jobs(city);\nCREATE INDEX IF NOT EXISTS idx_jobs_active ON jobs(active);\nCREATE INDEX IF NOT EXISTS idx_jobs_category ON jobs(category);\nCREATE INDEX IF NOT EXISTS idx_jobs_geo ON jobs(lat, lng);\n\nCREATE TABLE IF NOT EXISTS sync_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n source TEXT,\n ts REAL,\n found INTEGER,\n added INTEGER,\n updated INTEGER,\n removed INTEGER,\n ok INTEGER,\n message TEXT,\n stats TEXT -- JSON : taux de champs null, missed, alerte…\n);\n\nCREATE TABLE IF NOT EXISTS detail_cache (\n source TEXT NOT NULL,\n external_id TEXT NOT NULL,\n key TEXT, -- hash du contenu « liste » de l'offre\n payload TEXT, -- JSON opaque propre au connecteur\n fetched_at REAL,\n PRIMARY KEY (source, external_id)\n);\n\nCREATE TABLE IF NOT EXISTS salary_log (\n uid TEXT NOT NULL,\n ts REAL NOT NULL,\n s_min REAL,\n s_max REAL,\n unit TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_salary_log_uid ON salary_log(uid);\n\nCREATE TABLE IF NOT EXISTS users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ka_sub TEXT UNIQUE, -- « ka:{sub} » identifiant stable du hub KA\n ka_id TEXT, -- identifiant membre « ka-0123456789 » (hub)\n email TEXT,\n name TEXT,\n picture TEXT, -- URL de l'avatar (hub / Google)\n created_at REAL,\n last_login REAL\n);\nCREATE UNIQUE INDEX IF NOT EXISTS users_ka_id ON users(ka_id);\n\nCREATE TABLE IF NOT EXISTS geocode_cache (\n address TEXT PRIMARY KEY, -- adresse/ville normalisée (clé de cache)\n lat REAL,\n lng REAL,\n provider TEXT,\n failed INTEGER DEFAULT 0,\n ts REAL\n);\n\"\"\"\n\n# Colonnes ajoutées après la v1 — migration automatique des bases existantes.\n_MIGRATIONS: dict[str, dict[str, str]] = {\n \"jobs\": {\n # Phase 2 (2026-08-19) — enrichissement des connecteurs\n \"company_logo\": \"TEXT\", # URL du logo employeur (breezy, JSON-LD…)\n \"language\": \"TEXT\", # fr | en | bilingue (source ou heuristique)\n \"apply_url\": \"TEXT\", # lien de candidature directe\n \"title_clean\": \"TEXT\", # titre d'affichage normalisé (source intacte)\n \"quarantine\": \"TEXT\", # JSON des motifs qualité (NULL = publiable)\n # Phase 3 (2026-08-25) — enrichissement : séniorité du poste\n \"seniority\": \"TEXT\", # stage | junior | intermediaire | senior | direction\n },\n \"sync_log\": {},\n}\n\n\ndef connect() -> sqlite3.Connection:\n DB_PATH.parent.mkdir(parents=True, exist_ok=True)\n con = sqlite3.connect(DB_PATH, timeout=30.0)\n con.row_factory = sqlite3.Row\n con.execute(\"PRAGMA busy_timeout=30000\") # BD vivante (sync horaire)\n con.executescript(_SCHEMA)\n for table, cols in _MIGRATIONS.items():\n existing = {r[\"name\"] for r in con.execute(f\"PRAGMA table_info({table})\")}\n for col, decl in cols.items():\n if col not in existing:\n con.execute(f\"ALTER TABLE {table} ADD COLUMN {col} {decl}\")\n con.commit()\n return con\n\n\n# ---------------------------------------------------------------------------\n# Synchronisation d'une source\n# ---------------------------------------------------------------------------\n\ndef _drift_alert(con: sqlite3.Connection, source: str, found: int,\n null_salary_rate: float) -> str | None:\n \"\"\"Détecte une dérive du connecteur (chute du volume ou des salaires extraits).\n\n Retourne un message d'alerte, ou None si tout est normal.\n \"\"\"\n hist = con.execute(\n \"SELECT found, stats FROM sync_log WHERE source=? AND ok=1\"\n \" ORDER BY ts DESC LIMIT ?\", (source, DRIFT_HISTORY)).fetchall()\n if len(hist) < 3:\n return None\n med_found = statistics.median(r[\"found\"] for r in hist)\n if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:\n return (f\"dérive: {found} offre(s) trouvée(s) contre une médiane de \"\n f\"{med_found:.0f} — retraits suspendus, vérifier le connecteur\")\n if found >= DRIFT_MIN_BASE and null_salary_rate >= 0.95:\n rates = []\n for r in hist:\n try:\n rates.append(json.loads(r[\"stats\"] or \"{}\")[\"null_salary_rate\"])\n except (KeyError, ValueError, TypeError):\n continue\n if rates and statistics.median(rates) <= 0.5:\n return (f\"dérive: {null_salary_rate:.0%} des offres sans salaire \"\n f\"(habituellement {statistics.median(rates):.0%}) — \"\n \"le format de la source a probablement changé\")\n return None\n\n\ndef sync_source(con: sqlite3.Connection, source: str,\n postings: list[JobPosting]) -> dict:\n \"\"\"Synchronise les offres d'une source.\n\n - nouvelle offre -> insertion\n - offre modifiée -> mise à jour (comparaison de content_hash)\n - offre disparue -> miss_count += 1, puis active=0 après MISS_GRACE\n exécutions consécutives (délai de grâce)\n - dérive détectée -> alerte consignée, retraits suspendus\n \"\"\"\n now = time.time()\n added = updated = 0\n seen_uids = set()\n\n n = len(postings)\n null_salary = sum(1 for p in postings if p.salary_min is None)\n null_city = sum(1 for p in postings if not p.city)\n null_salary_rate = round(null_salary / n, 3) if n else 0.0\n\n alert = _drift_alert(con, source, n, null_salary_rate)\n\n from .normalize import clean_title\n from .quality import issues_for_posting\n\n for job in postings:\n seen_uids.add(job.uid)\n h = job.content_hash()\n row = con.execute(\n \"SELECT content_hash, salary_min, salary_max FROM jobs WHERE uid=?\",\n (job.uid,)).fetchone()\n defects = issues_for_posting(job)\n params = dict(\n uid=job.uid, source=job.source, external_id=job.external_id,\n url=job.url, employer=job.employer, title=job.title,\n description=job.description, address=job.address, city=job.city,\n region=job.region, postal_code=job.postal_code,\n location_label=job.location_label, work_mode=job.work_mode,\n employment_type=job.employment_type,\n salary_min=job.salary_min, salary_max=job.salary_max,\n salary_unit=job.salary_unit, salary_label=job.salary_label,\n salary_year_min=job.salary_year_min(),\n salary_year_max=job.salary_year_max(),\n salary_hour_min=job.salary_hour_min(),\n salary_hour_max=job.salary_hour_max(),\n benefits=json.dumps(job.benefits, ensure_ascii=False),\n requirements=json.dumps(job.requirements, ensure_ascii=False),\n date_posted=job.date_posted, date_deadline=job.date_deadline,\n category=job.category, ats=job.ats,\n details=json.dumps(job.details, ensure_ascii=False),\n lat=job.lat, lng=job.lng, content_hash=h, now=now,\n company_logo=job.company_logo, language=job.language,\n apply_url=job.apply_url, seniority=job.seniority,\n title_clean=clean_title(job.title, job.city),\n quarantine=json.dumps(defects, ensure_ascii=False)\n if defects else None,\n )\n if row is None:\n con.execute(\n \"\"\"INSERT INTO jobs (uid, source, external_id, url, employer,\n title, description, address, city, region, postal_code,\n location_label, work_mode, employment_type, salary_min,\n salary_max, salary_unit, salary_label, salary_year_min,\n salary_year_max, salary_hour_min, salary_hour_max, benefits,\n requirements, date_posted, date_deadline, category, ats,\n details, lat, lng, content_hash, first_seen, last_seen,\n updated_at, miss_count, active, company_logo, language,\n apply_url, title_clean, quarantine, seniority)\n VALUES (:uid,:source,:external_id,:url,:employer,:title,\n :description,:address,:city,:region,:postal_code,\n :location_label,:work_mode,:employment_type,:salary_min,\n :salary_max,:salary_unit,:salary_label,:salary_year_min,\n :salary_year_max,:salary_hour_min,:salary_hour_max,\n :benefits,:requirements,:date_posted,:date_deadline,\n :category,:ats,:details,:lat,:lng,:content_hash,\n :now,:now,:now,0,1,:company_logo,:language,:apply_url,\n :title_clean,:quarantine,:seniority)\"\"\", params)\n if job.salary_min is not None: # point de départ de l'historique\n con.execute(\n \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n \" VALUES (?,?,?,?,?)\",\n (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n added += 1\n elif row[\"content_hash\"] != h:\n # COALESCE : ne jamais écraser des coordonnées géocodées par null.\n # Si l'adresse change, geocode_failed est remis à 0 (correction du\n # bogue relevé chez Lou·Ka) pour permettre un re-géocodage.\n con.execute(\n \"\"\"UPDATE jobs SET url=:url, employer=:employer, title=:title,\n description=:description,\n geocode_failed=CASE WHEN address<>:address OR city<>:city\n THEN 0 ELSE geocode_failed END,\n address=:address, city=:city, region=:region,\n postal_code=:postal_code, location_label=:location_label,\n work_mode=:work_mode, employment_type=:employment_type,\n salary_min=:salary_min, salary_max=:salary_max,\n salary_unit=:salary_unit, salary_label=:salary_label,\n salary_year_min=:salary_year_min,\n salary_year_max=:salary_year_max,\n salary_hour_min=:salary_hour_min,\n salary_hour_max=:salary_hour_max,\n benefits=:benefits, requirements=:requirements,\n date_posted=:date_posted, date_deadline=:date_deadline,\n category=:category, ats=:ats, details=:details,\n lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),\n company_logo=:company_logo, language=:language,\n apply_url=:apply_url, title_clean=:title_clean,\n quarantine=:quarantine,\n content_hash=:content_hash, last_seen=:now,\n updated_at=:now, miss_count=0, active=1\n WHERE uid=:uid\"\"\", params)\n if (job.salary_min, job.salary_max) != (row[\"salary_min\"], row[\"salary_max\"]):\n con.execute(\n \"INSERT INTO salary_log (uid, ts, s_min, s_max, unit)\"\n \" VALUES (?,?,?,?,?)\",\n (job.uid, now, job.salary_min, job.salary_max, job.salary_unit))\n updated += 1\n else:\n con.execute(\n \"UPDATE jobs SET last_seen=?, miss_count=0, active=1 WHERE uid=?\",\n (now, job.uid))\n\n # Offres de cette source qui n'apparaissent plus : délai de grâce,\n # puis désactivation. Suspendu si une dérive est détectée.\n removed = missed = 0\n if not alert:\n for r in con.execute(\n \"SELECT uid, miss_count FROM jobs WHERE source=? AND active=1\",\n (source,)).fetchall():\n if r[\"uid\"] in seen_uids:\n continue\n missed += 1\n if r[\"miss_count\"] + 1 >= MISS_GRACE:\n con.execute(\n \"UPDATE jobs SET active=0, miss_count=?, updated_at=?\"\n \" WHERE uid=?\", (r[\"miss_count\"] + 1, now, r[\"uid\"]))\n removed += 1\n else:\n con.execute(\"UPDATE jobs SET miss_count=miss_count+1 WHERE uid=?\",\n (r[\"uid\"],))\n\n stats = {\n \"null_salary_rate\": null_salary_rate,\n \"null_city_rate\": round(null_city / n, 3) if n else 0.0,\n \"missed\": missed,\n }\n if alert:\n stats[\"alert\"] = alert\n con.execute(\n \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,\"\n \" message, stats) VALUES (?,?,?,?,?,?,1,?,?)\",\n (source, now, n, added, updated, removed, alert or \"ok\",\n json.dumps(stats, ensure_ascii=False)))\n con.commit()\n out = {\"source\": source, \"found\": n, \"added\": added,\n \"updated\": updated, \"removed\": removed}\n if alert:\n out[\"alert\"] = alert\n return out\n\n\ndef expire_past_deadline(con: sqlite3.Connection) -> int:\n \"\"\"Désactive les offres dont la date limite est dépassée (offres zombies :\n encore affichées sur la page carrière mais plus ouvertes aux candidatures).\"\"\"\n today = _dt.date.today().isoformat()\n cur = con.execute(\n \"UPDATE jobs SET active=0, updated_at=? WHERE active=1\"\n \" AND date_deadline IS NOT NULL AND date_deadline < ?\",\n (time.time(), today))\n con.commit()\n return cur.rowcount\n\n\nSTALE_DAYS = 7 # offres plus jamais revues (connecteur retiré/muet) : archivées\n\n\ndef expire_stale(con: sqlite3.Connection, days: int = STALE_DAYS) -> int:\n \"\"\"Archive (active=0) les offres actives non revues depuis `days` jours.\n\n Filet de sécurité complémentaire au délai de grâce MISS_GRACE : couvre le\n cas d'un connecteur SUPPRIMÉ du code (ses offres ne passent plus jamais\n par sync_source et resteraient actives pour toujours — cas « valero »).\n Rien n'est supprimé : les offres sont archivées, réactivables si revues.\n \"\"\"\n cutoff = time.time() - days * 86400\n cur = con.execute(\n \"UPDATE jobs SET active=0, updated_at=? WHERE active=1 AND last_seen'depot-direct'\", # le dépôt direct n'est jamais resynchronisé\n (time.time(), cutoff))\n con.commit()\n return cur.rowcount\n\n\ndef log_failure(con: sqlite3.Connection, source: str, message: str) -> None:\n con.execute(\n \"INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)\"\n \" VALUES (?,?,0,0,0,0,0,?)\", (source, time.time(), message))\n con.commit()\n\n\n# ---------------------------------------------------------------------------\n# Cache des pages détail (« détail si nouveau/modifié »)\n# ---------------------------------------------------------------------------\n\ndef get_cached_detail(con: sqlite3.Connection, source: str,\n external_id: str, key: str) -> dict | None:\n \"\"\"Payload détail mis en cache si la clé (hash liste) n'a pas changé.\"\"\"\n row = con.execute(\n \"SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?\",\n (source, external_id)).fetchone()\n if row and row[\"key\"] == key and row[\"payload\"]:\n try:\n return json.loads(row[\"payload\"])\n except ValueError:\n return None\n return None\n\n\ndef get_stale_detail(con: sqlite3.Connection, source: str,\n external_id: str) -> dict | None:\n \"\"\"Payload détail en cache MÊME si la clé a changé — secours quand le\n budget de re-visites est épuisé : un détail périmé vaut mieux qu'une\n description vide (la re-visite aura lieu à un prochain cycle).\"\"\"\n row = con.execute(\n \"SELECT payload FROM detail_cache WHERE source=? AND external_id=?\",\n (source, external_id)).fetchone()\n if row and row[\"payload\"]:\n try:\n return json.loads(row[\"payload\"])\n except ValueError:\n return None\n return None\n\n\ndef put_cached_detail(con: sqlite3.Connection, source: str,\n external_id: str, key: str, payload: dict) -> None:\n con.execute(\n \"INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)\"\n \" VALUES (?,?,?,?,?)\"\n \" ON CONFLICT(source, external_id) DO UPDATE SET\"\n \" key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at\",\n (source, external_id, key, json.dumps(payload, ensure_ascii=False),\n time.time()))\n con.commit()\n","structuredPatch":[{"oldStart":302,"oldLines":7,"newStart":302,"newLines":7,"lines":[" lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),"," company_logo=:company_logo, language=:language,"," apply_url=:apply_url, title_clean=:title_clean,","- quarantine=:quarantine,","+ quarantine=:quarantine, seniority=:seniority,"," content_hash=:content_hash, last_seen=:now,"," updated_at=:now, miss_count=0, active=1"," WHERE uid=:uid\"\"\", params)"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"093be39b-8a1f-405e-a535-bed11063b26f","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"318ef306-6be2-450d-b856-f4974a1beb90","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":114,"estimated_tokens_delta":64,"uuid":"169c713c-4ea8-4edd-ae84-13d15ff70ffc","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcH6MxrTpLyXUTc2FeS","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISwAMKpwEIERgCKkCmqmwQI+xX21YqKHUErrpSWY9UEYosE7M+iO4fHVfmxlXetB2qtDhlBIQf2vp01Kzlm462jj8xlD9gbm43m4+ZMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAHNx7bUBrABAhIMMeDqSx1RNBJb2jMGGgxmihXJhMFebSn3MDwiMCY+ctcDxLiJxt7QzE8EFSfdNO5GsHAlwHvRot9TUnj1s1/slCJaGX086ub/U7IXXCrFAdIIe2/GUNb4a1KAl36UHH/n2eIAUkTf42G79K3MAaZxfWXsEhiaUE3CXxGURv+24ULl39f3LmcFDXOxIXZtCiYmwILdbOVVhzLJmQj9XQrE0SS0AsVMd+eZQRk6z3TlC1sTi8rLuO5b5GpVbNiv0hpCOOzM1Aayx20vld0NjPGjwCmOe1BLXX/5P3bp1b4rrs5tQ9aXnzFA4uKxjVnSOm58800vdM7qzVCZptfDqF/pQPb3eGCITujCUzEpX150eky38wIAGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2449,"cache_read_input_tokens":127399,"cache_creation":{"ephemeral_5m_input_tokens":2449,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"f4ad2355-8ecf-4980-81f1-44deb10bf00e","request_id":"req_011CePcH2Efvw7cXp4y9xYJg"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcH6MxrTpLyXUTc2FeS","type":"message","role":"assistant","content":[{"type":"text","text":"Maintenant `web.py` — filtre séniorité, facette, métriques de marché sur la fiche, médiane dans les stats :"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2449,"cache_read_input_tokens":127399,"cache_creation":{"ephemeral_5m_input_tokens":2449,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"ab4840f4-4387-4880-811c-b50c635072f9","request_id":"req_011CePcH2Efvw7cXp4y9xYJg"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcH6MxrTpLyXUTc2FeS","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WHshXMjMycN5K39zoVkvti","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","old_string":"# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)\n# Créé : 2026-08-17 Modifié : 2026-08-17","new_string":"# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)\n# Créé : 2026-08-17 Modifié : 2026-08-25"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2449,"cache_read_input_tokens":127399,"cache_creation":{"ephemeral_5m_input_tokens":2449,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"9821beb8-27a9-43b6-a61d-04062e9b305f","request_id":"req_011CePcH2Efvw7cXp4y9xYJg"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WHshXMjMycN5K39zoVkvti","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/web.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"99e0352b-f5c6-43b2-84d8-b4739b5fc95b","timestamp":"2026-08-25T14:16:47.667Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","oldString":"# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)\n# Créé : 2026-08-17 Modifié : 2026-08-17","newString":"# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)\n# Créé : 2026-08-17 Modifié : 2026-08-25","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/web.py\n# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)\n# Créé : 2026-08-17 Modifié : 2026-08-17\n# =============================================================================\nfrom __future__ import annotations\n\nimport json\nimport re\nimport secrets\nimport threading\nimport time\nfrom pathlib import Path\n\nfrom fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom fastapi.staticfiles import StaticFiles\nfrom pydantic import BaseModel, Field\n\nfrom . import auth, db, hubfav, ingest\nfrom .dedup import AGGREGATORS\nfrom .schema import JobPosting\n\nROOT = Path(__file__).resolve().parent.parent\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n\napp = FastAPI(title=\"Job-Ka API\", version=\"1.0\",\n description=\"Agrégateur d'offres d'emploi — employeurs québécois, \"\n \"directement à la source\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n# connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py\napp.include_router(auth.router)\n\n_sync_lock = threading.Lock()\n\n\ndef _row_to_dict(row) -> dict:\n d = dict(row)\n d[\"benefits\"] = json.loads(d.get(\"benefits\") or \"[]\")\n d[\"requirements\"] = json.loads(d.get(\"requirements\") or \"{}\")\n d[\"details\"] = json.loads(d.get(\"details\") or \"{}\")\n if d.get(\"dup_sources\"):\n try:\n d[\"dup_sources\"] = json.loads(d[\"dup_sources\"])\n except (ValueError, TypeError):\n d[\"dup_sources\"] = []\n # offre directe = page carrière de l'employeur ou dépôt direct (≠ portail)\n d[\"is_direct\"] = d.get(\"source\") not in AGGREGATORS\n return d\n\n\n@app.get(\"/health\")\ndef health():\n \"\"\"Healthcheck de déploiement : BD accessible + fraîcheur des syncs.\"\"\"\n try:\n con = db.connect()\n total = con.execute(\n \"SELECT COUNT(*) c FROM jobs WHERE active=1\").fetchone()[\"c\"]\n last = con.execute(\n \"SELECT MAX(ts) ts FROM sync_log WHERE ok=1\").fetchone()[\"ts\"]\n con.close()\n except Exception as exc:\n raise HTTPException(500, f\"BD inaccessible : {exc}\")\n return {\"status\": \"ok\", \"active_jobs\": total,\n \"last_sync_age_s\": round(time.time() - last) if last else None}\n\n\n@app.get(\"/api/jobs\")\ndef list_jobs(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n employer: str | None = None,\n source: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n salary_min: float | None = None, # $/an (converti côté serveur)\n with_salary: int | None = None, # 1 = transparence salariale seulement\n posted_after: str | None = None, # ISO : offres publiées depuis…\n language: str | None = None, # fr | en | bilingue\n q: str | None = None,\n active: int = 1,\n quarantined: int | None = None, # 1 = quarantaine seulement (supervision)\n sort: str = \"recent\", # recent | salary\n limit: int = Query(100, le=2000),\n offset: int = 0,\n):\n con = db.connect()\n sql = \"SELECT * FROM jobs WHERE dup_of IS NULL\" # doublons masqués\n args: list = []\n # quarantaine qualité : jamais publiée par défaut (voir jobka/quality.py)\n sql += \" AND quarantine IS NOT NULL\" if quarantined == 1 \\\n else \" AND quarantine IS NULL\"\n if active in (0, 1):\n sql += \" AND active=?\"; args.append(active)\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if language:\n sql += \" AND language=?\"; args.append(language)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if employer:\n sql += \" AND employer LIKE ?\"; args.append(f\"%{employer}%\")\n if source:\n sql += \" AND source=?\"; args.append(source)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if posted_after:\n sql += \" AND date_posted IS NOT NULL AND date_posted>=?\"\n args.append(posted_after)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ? OR description LIKE ?)\"\n args += [f\"%{q}%\"] * 3\n total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n if sort == \"salary\":\n sql += \" ORDER BY salary_year_max IS NULL, salary_year_max DESC\"\n else:\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC\"\n sql += \" LIMIT ? OFFSET ?\"\n args += [limit, offset]\n rows = []\n for r in con.execute(sql, args).fetchall():\n d = _row_to_dict(r)\n d.pop(\"description\", None) # liste allégée : la fiche a le texte complet\n rows.append(d)\n con.close()\n return {\"total\": total, \"count\": len(rows), \"jobs\": rows}\n\n\n@app.get(\"/api/jobs.geojson\")\ndef jobs_geojson(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n salary_min: float | None = None,\n with_salary: int | None = None,\n language: str | None = None,\n q: str | None = None,\n bbox: str | None = None,\n limit: int = Query(3000, le=8000),\n):\n \"\"\"Offres géolocalisées au format GeoJSON (mêmes filtres que /api/jobs).\n\n `bbox=ouest,sud,est,nord` restreint au rectangle visible (idx_jobs_geo).\n Membres additionnels : `totalGeocoded` / `totalMatching` pour afficher\n « N résultats sans position sur la carte ».\n \"\"\"\n con = db.connect()\n sql = (\"SELECT uid, title, title_clean, employer, city, category, work_mode,\"\n \" employment_type, salary_min, salary_max, salary_unit,\"\n \" salary_year_min, salary_year_max, date_posted, source, lat, lng\"\n \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n \" AND lat IS NOT NULL AND lng IS NOT NULL\")\n args: list = []\n if bbox:\n try:\n west, south, east, north = (float(v) for v in bbox.split(\",\"))\n except ValueError:\n raise HTTPException(400, \"bbox attendu : ouest,sud,est,nord\")\n sql += \" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\"\n args += [south, north, west, east]\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += \" AND salary_year_max IS NOT NULL AND salary_year_max>=?\"\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if language:\n sql += \" AND language=?\"; args.append(language)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ?)\"\n args += [f\"%{q}%\"] * 2\n\n total_geo = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n sql_all = sql.replace(\" AND lat IS NOT NULL AND lng IS NOT NULL\", \"\")\n args_all = list(args)\n if bbox:\n sql_all = sql_all.replace(\" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\", \"\")\n del args_all[0:4]\n total_all = con.execute(f\"SELECT COUNT(*) c FROM ({sql_all})\", args_all).fetchone()[\"c\"]\n\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC LIMIT ?\"\n args.append(limit)\n\n features = []\n for r in con.execute(sql, args).fetchall():\n features.append({\n \"type\": \"Feature\",\n \"geometry\": {\"type\": \"Point\", \"coordinates\": [r[\"lng\"], r[\"lat\"]]},\n \"properties\": {\n \"uid\": r[\"uid\"], \"title\": r[\"title_clean\"] or r[\"title\"],\n \"employer\": r[\"employer\"],\n \"city\": r[\"city\"], \"category\": r[\"category\"],\n \"work_mode\": r[\"work_mode\"],\n \"employment_type\": r[\"employment_type\"],\n \"salary_min\": r[\"salary_min\"], \"salary_max\": r[\"salary_max\"],\n \"salary_unit\": r[\"salary_unit\"],\n \"salary_year_min\": r[\"salary_year_min\"],\n \"salary_year_max\": r[\"salary_year_max\"],\n \"date_posted\": r[\"date_posted\"], \"source\": r[\"source\"],\n \"is_direct\": r[\"source\"] not in AGGREGATORS,\n },\n })\n con.close()\n return {\"type\": \"FeatureCollection\", \"features\": features,\n \"totalGeocoded\": total_geo, \"totalMatching\": total_all}\n\n\n@app.get(\"/api/jobs/{uid}\")\ndef get_job(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM jobs WHERE uid=?\", (uid,)).fetchone()\n d = None\n if row is not None:\n d = _row_to_dict(row)\n d[\"salary_history\"] = [dict(r) for r in con.execute(\n \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\"\n \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]\n con.close()\n if d is None:\n raise HTTPException(404, \"Offre introuvable\")\n return d\n\n\n@app.get(\"/api/facets\")\ndef facets(city: str | None = None):\n \"\"\"Valeurs distinctes pour construire les filtres du frontend.\"\"\"\n con = db.connect()\n base = \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n out = {\n \"cities\": [r[\"city\"] for r in con.execute(\n f\"SELECT DISTINCT city{base} AND city<>'' ORDER BY city\")],\n # régions administratives (17) — le repli générique « Québec »\n # (appartenance provinciale sans région précise) n'est pas une facette\n \"regions\": [dict(r) for r in con.execute(\n f\"SELECT region, COUNT(*) n{base} AND region<>''\"\n \" AND region<>'Québec' GROUP BY region ORDER BY region\")],\n \"languages\": [dict(r) for r in con.execute(\n f\"SELECT language, COUNT(*) n{base} AND language IS NOT NULL\"\n \" AND language<>'' GROUP BY language ORDER BY n DESC\")],\n \"categories\": [dict(r) for r in con.execute(\n f\"SELECT category, COUNT(*) n{base} AND category<>''\"\n \" GROUP BY category ORDER BY n DESC\")],\n \"employers\": [dict(r) for r in con.execute(\n f\"SELECT employer, COUNT(*) n{base} AND employer<>''\"\n \" GROUP BY employer ORDER BY n DESC LIMIT 100\")],\n \"work_modes\": [r[\"work_mode\"] for r in con.execute(\n f\"SELECT DISTINCT work_mode{base} AND work_mode IS NOT NULL\"\n \" ORDER BY work_mode\")],\n \"employment_types\": [r[\"employment_type\"] for r in con.execute(\n f\"SELECT DISTINCT employment_type{base}\"\n \" AND employment_type IS NOT NULL ORDER BY employment_type\")],\n \"sources\": [dict(r) for r in con.execute(\n f\"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC\")],\n }\n con.close()\n return out\n\n\n@app.get(\"/api/sources\")\ndef sources():\n registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n con = db.connect()\n counts = {r[\"source\"]: r[\"n\"] for r in con.execute(\n \"SELECT source, COUNT(*) n FROM jobs WHERE active=1 GROUP BY source\")}\n last = {r[\"source\"]: r[\"ts\"] for r in con.execute(\n \"SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source\")}\n con.close()\n for s in registry:\n s[\"active_jobs\"] = counts.get(s[\"id\"], 0)\n s[\"last_sync\"] = last.get(s[\"id\"])\n return {\"sources\": registry}\n\n\n@app.get(\"/api/stats\")\ndef stats(syncs_since_h: float | None = None):\n con = db.connect()\n row = con.execute(\n \"\"\"SELECT COUNT(*) total,\n COUNT(DISTINCT employer) employers,\n COUNT(DISTINCT source) sources,\n SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary,\n AVG(salary_year_min) avg_salary_year,\n SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote,\n SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND quarantine IS NULL\"\"\").fetchone()\n quarantined = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\"\n ).fetchone()[\"n\"]\n cities = [dict(r) for r in con.execute(\n \"\"\"SELECT city, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND city<>''\n GROUP BY city ORDER BY n DESC LIMIT 15\"\"\")]\n categories = [dict(r) for r in con.execute(\n \"\"\"SELECT category, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND category<>''\n GROUP BY category ORDER BY n DESC\"\"\")]\n # Fenêtre élargie pour la supervision (api-ka) : sans le paramètre, on\n # garde les 20 dernières entrées ; avec, toute l'activité de la fenêtre\n # (sinon le moniteur bi-horaire ne voit qu'une fraction de la rotation\n # des sources → fausses alertes « stale »).\n if syncs_since_h and syncs_since_h > 0:\n cutoff = time.time() - syncs_since_h * 3600\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000\",\n (cutoff,))]\n if len(log) < 20:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n else:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n # offres directes (pages carrières + dépôt direct) c. portails agrégateurs\n agg = sorted(AGGREGATORS) or [\"__aucun__\"]\n ph = \",\".join(\"?\" * len(agg))\n direct = con.execute(\n f\"\"\"SELECT COUNT(*) n, COUNT(DISTINCT employer) e,\n COUNT(DISTINCT source) s\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source NOT IN ({ph})\"\"\", agg).fetchone()\n portal = con.execute(\n f\"\"\"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source IN ({ph})\"\"\", agg).fetchone()\n dups = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1\"\n \" AND dup_of IS NOT NULL\").fetchone()\n con.close()\n return {**dict(row), \"top_cities\": cities, \"categories\": categories,\n \"direct_jobs\": direct[\"n\"], \"direct_employers\": direct[\"e\"],\n \"direct_sources\": direct[\"s\"], \"portal_jobs\": portal[\"n\"],\n \"duplicates_hidden\": dups[\"n\"], \"quarantined\": quarantined,\n \"recent_syncs\": log}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"Tableau de bord analytique — contrat commun Groupe KA (SPEC.md).\n Cache mémoire 5 min par clé de période (voir jobka/statsdash.py).\"\"\"\n from . import statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n return statsdash.compute(period, from_, to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None,\n mode: str = \"complet\"):\n \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes : complet | synthese |\n tendances | repartitions | donnees (mode inconnu -> complet).\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n if mode not in kapdf.REPORT_MODES:\n mode = \"complet\"\n dash = statsdash.compute(period, from_, to)\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash,\n mode=mode,\n ).build()\n fname = kapdf.filename(\"job-ka\", period, mode)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/stats/catalog\")\ndef stats_catalog(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"v3 — blocs composables pour le constructeur de rapports personnalisés.\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, from_, to)\n return {\"updated\": dash.get(\"updated\"), \"period\": dash.get(\"period\"),\n \"blocks\": kapdf.catalog(dash)}\n\n\n@app.post(\"/api/stats/report/custom\")\ndef stats_report_custom(spec: dict = Body(...)):\n \"\"\"v3 — rapport PDF personnalisé : {\"title\", \"period\", \"from\", \"to\",\n \"blocks\": [{\"key\": \"series:…\", \"render\": \"bar\"}, …]} (SPEC.md §3bis).\"\"\"\n from . import kapdf, statsdash\n period = str(spec.get(\"period\") or \"30j\")\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, str(spec.get(\"from\") or \"\") or None,\n str(spec.get(\"to\") or \"\") or None)\n known = {b[\"key\"] for b in kapdf.catalog(dash)}\n blocks = [b for b in (spec.get(\"blocks\") or [])\n if isinstance(b, dict) and b.get(\"key\") in known][:40]\n if not blocks:\n raise HTTPException(400, \"Aucun bloc valide dans la composition\")\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash, mode=kapdf.CUSTOM_MODE,\n spec={\"title\": str(spec.get(\"title\") or \"\")[:80], \"blocks\": blocks},\n ).build()\n fname = kapdf.filename(\"job-ka\", period, kapdf.CUSTOM_MODE)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n# --- Dépôt direct d'offres par les employeurs ---------------------------------\nclass OffreDeposee(BaseModel):\n \"\"\"Offre soumise par un employeur (validation minimale, modérée ensuite).\"\"\"\n\n employer: str = Field(min_length=2, max_length=120)\n title: str = Field(min_length=3, max_length=160)\n description: str = Field(min_length=30, max_length=20000)\n contact_email: str = Field(min_length=5, max_length=160)\n city: str = Field(\"\", max_length=80)\n url: str = Field(\"\", max_length=400)\n salary_label: str = Field(\"\", max_length=120)\n employment_type: str | None = None\n work_mode: str | None = None\n website: str = \"\" # pot de miel anti-robots : doit rester vide\n\n\n@app.post(\"/api/employeurs/offres\")\ndef deposer_offre(offre: OffreDeposee):\n \"\"\"Canal de dépôt direct : l'offre est stockée INACTIVE (source\n « depot-direct ») en attente de modération manuelle. Voir /employeurs.\"\"\"\n if offre.website: # robot pris au pot de miel\n return {\"status\": \"reçu\"}\n if not re.match(r\"^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$\", offre.contact_email):\n raise HTTPException(422, \"Courriel de contact invalide\")\n if offre.url and not re.match(r\"^https?://\", offre.url):\n raise HTTPException(422, \"Lien invalide : http(s):// requis\")\n if offre.employment_type and offre.employment_type not in (\n \"temps_plein\", \"temps_partiel\", \"contractuel\", \"stage\", \"saisonnier\"):\n raise HTTPException(422, \"Type d'emploi inconnu\")\n if offre.work_mode and offre.work_mode not in (\n \"presentiel\", \"hybride\", \"teletravail\"):\n raise HTTPException(422, \"Mode de travail inconnu\")\n\n job = JobPosting(\n source=\"depot-direct\", external_id=secrets.token_hex(6),\n url=offre.url, employer=offre.employer, title=offre.title,\n description=offre.description, city=offre.city,\n salary_label=offre.salary_label,\n employment_type=offre.employment_type, work_mode=offre.work_mode,\n ats=\"depot-direct\",\n )\n job.details[\"contact_email\"] = offre.contact_email\n job.details[\"moderation\"] = \"en_attente\"\n job.finalize()\n\n now = time.time()\n cols = {\n \"uid\": job.uid, \"source\": job.source, \"external_id\": job.external_id,\n \"url\": job.url, \"employer\": job.employer, \"title\": job.title,\n \"description\": job.description, \"city\": job.city,\n \"region\": job.region, \"postal_code\": job.postal_code,\n \"location_label\": job.location_label, \"work_mode\": job.work_mode,\n \"employment_type\": job.employment_type,\n \"salary_min\": job.salary_min, \"salary_max\": job.salary_max,\n \"salary_unit\": job.salary_unit, \"salary_label\": job.salary_label,\n \"salary_year_min\": job.salary_year_min(),\n \"salary_year_max\": job.salary_year_max(),\n \"salary_hour_min\": job.salary_hour_min(),\n \"salary_hour_max\": job.salary_hour_max(),\n \"benefits\": json.dumps(job.benefits, ensure_ascii=False),\n \"requirements\": json.dumps(job.requirements, ensure_ascii=False),\n \"category\": job.category, \"ats\": job.ats,\n \"details\": json.dumps(job.details, ensure_ascii=False),\n \"content_hash\": job.content_hash(),\n \"first_seen\": now, \"last_seen\": now, \"updated_at\": now,\n \"miss_count\": 0, \"active\": 0, # inactive tant que non modérée\n }\n con = db.connect()\n con.execute(\n f\"INSERT INTO jobs ({', '.join(cols)}) VALUES \"\n f\"({', '.join(':' + k for k in cols)})\", cols)\n con.commit()\n con.close()\n return {\"status\": \"reçu\", \"uid\": job.uid,\n \"message\": \"Merci ! Votre offre a bien été reçue. Elle sera \"\n \"vérifiée par notre équipe avant publication.\"}\n\n\n@app.post(\"/api/sync\")\ndef trigger_sync(background: BackgroundTasks, source: str | None = None):\n \"\"\"Déclenche une synchronisation (équivalent d'un webhook entrant).\"\"\"\n def _job():\n with _sync_lock:\n ingest.run([source] if source else None)\n background.add_task(_job)\n return {\"status\": \"démarré\", \"source\": source or \"toutes\"}\n\n\n# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ------\n@app.get(\"/api/favorites\")\ndef favorites(request: Request):\n \"\"\"Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n items = hubfav.hub_list(user.get(\"ka_id\") or \"\")\n if items is None:\n raise HTTPException(502, \"Hub Groupe KA injoignable — réessayez\")\n return {\"ids\": [i[\"item_id\"] for i in items if i.get(\"item_id\")],\n \"items\": items}\n\n\n@app.post(\"/api/favorites/toggle\")\ndef toggle_favorite(request: Request, body: dict = Body(...)):\n \"\"\"Ajoute (on=true) ou retire (on=false) un favori — poussé au hub\n Groupe KA de façon synchrone : le hub est la seule source de vérité.\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n ka_id = user.get(\"ka_id\") or \"\"\n if not hubfav.linked(ka_id):\n raise HTTPException(403, \"Compte non relié au hub Groupe KA\")\n on = bool(body.get(\"on\"))\n item = hubfav.clean_item(body.get(\"item\") or {})\n if not item.get(\"item_id\"):\n raise HTTPException(422, \"item.item_id requis\")\n if not hubfav.hub_toggle(ka_id, \"add\" if on else \"remove\", item):\n raise HTTPException(502, \"Hub Groupe KA injoignable — favori non enregistré\")\n return {\"ok\": True, \"on\": on}\n\n\n# --- Frontend (build Vite) ---------------------------------------------------\nif FRONTEND_DIST.exists():\n app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIST / \"assets\"), name=\"assets\")\n\n # SEO : SSR léger des routes publiques (accueil, fiches emploi, pages\n # statiques), robots.txt et sitemaps — DOIT être inclus AVANT le\n # rattrape-tout SPA ci-dessous (l'ordre d'enregistrement fait foi).\n from . import seo # noqa: E402\n app.include_router(seo.router)\n\n @app.middleware(\"http\")\n async def _cache_headers(request, call_next):\n \"\"\"Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé —\n sinon les navigateurs gardent une vieille version après un déploiement.\"\"\"\n resp = await call_next(request)\n path = request.url.path\n if path.startswith(\"/assets/\"):\n resp.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n elif \"text/html\" in (resp.headers.get(\"content-type\") or \"\"):\n resp.headers[\"Cache-Control\"] = \"no-cache\"\n return resp\n\n # routes rendues uniquement côté client — tout autre chemin inconnu renvoie\n # index.html avec un statut 404 (pas de soft-404 pour les moteurs)\n _CLIENT_ROUTES = {\"carte\", \"sources\", \"stats\", \"contact\", \"employeurs\",\n \"favoris\", \"confidentialite\", \"conditions\"}\n _CLIENT_PREFIXES = (\"emploi/\",)\n\n @app.get(\"/{full_path:path}\")\n def spa(full_path: str):\n target = FRONTEND_DIST / full_path\n if full_path and target.is_file():\n return FileResponse(target)\n if full_path and target.is_dir() and (target / \"index.html\").is_file():\n # sous-répertoire statique avec index (ex. /doc/) — servi tel quel\n return FileResponse(target / \"index.html\")\n known = (full_path == \"\" or full_path in _CLIENT_ROUTES\n or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))\n return FileResponse(FRONTEND_DIST / \"index.html\",\n status_code=200 if known else 404,\n headers={\"Cache-Control\": \"no-cache\"})\n","structuredPatch":[{"oldStart":4,"oldLines":7,"newStart":4,"newLines":7,"lines":[" # Contact : contact@spboucher.ai"," # Fichier : jobka/web.py"," # Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)","-# Créé : 2026-08-17 Modifié : 2026-08-17","+# Créé : 2026-08-17 Modifié : 2026-08-25"," # ============================================================================="," from __future__ import annotations"," "]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcH6MxrTpLyXUTc2FeS","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0129ZYHFxtJbajDEm8Sg3DQh","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","old_string":" work_mode: str | None = None,\n employment_type: str | None = None,\n salary_min: float | None = None, # $/an (converti côté serveur)\n with_salary: int | None = None, # 1 = transparence salariale seulement","new_string":" work_mode: str | None = None,\n employment_type: str | None = None,\n seniority: str | None = None, # stage | junior | intermediaire | senior | direction\n salary_min: float | None = None, # $/an (converti côté serveur)\n with_salary: int | None = None, # 1 = transparence salariale seulement"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2449,"cache_read_input_tokens":127399,"cache_creation":{"ephemeral_5m_input_tokens":2449,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"cffcd055-5681-4194-aa8b-75c8771d05f5","request_id":"req_011CePcH2Efvw7cXp4y9xYJg"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0129ZYHFxtJbajDEm8Sg3DQh","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/web.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"04d67f02-2440-46db-81ef-1b764750b54a","timestamp":"2026-08-25T14:16:51.020Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","oldString":" work_mode: str | None = None,\n employment_type: str | None = None,\n salary_min: float | None = None, # $/an (converti côté serveur)\n with_salary: int | None = None, # 1 = transparence salariale seulement","newString":" work_mode: str | None = None,\n employment_type: str | None = None,\n seniority: str | None = None, # stage | junior | intermediaire | senior | direction\n salary_min: float | None = None, # $/an (converti côté serveur)\n with_salary: int | None = None, # 1 = transparence salariale seulement","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/web.py\n# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\nfrom __future__ import annotations\n\nimport json\nimport re\nimport secrets\nimport threading\nimport time\nfrom pathlib import Path\n\nfrom fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom fastapi.staticfiles import StaticFiles\nfrom pydantic import BaseModel, Field\n\nfrom . import auth, db, hubfav, ingest\nfrom .dedup import AGGREGATORS\nfrom .schema import JobPosting\n\nROOT = Path(__file__).resolve().parent.parent\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n\napp = FastAPI(title=\"Job-Ka API\", version=\"1.0\",\n description=\"Agrégateur d'offres d'emploi — employeurs québécois, \"\n \"directement à la source\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n# connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py\napp.include_router(auth.router)\n\n_sync_lock = threading.Lock()\n\n\ndef _row_to_dict(row) -> dict:\n d = dict(row)\n d[\"benefits\"] = json.loads(d.get(\"benefits\") or \"[]\")\n d[\"requirements\"] = json.loads(d.get(\"requirements\") or \"{}\")\n d[\"details\"] = json.loads(d.get(\"details\") or \"{}\")\n if d.get(\"dup_sources\"):\n try:\n d[\"dup_sources\"] = json.loads(d[\"dup_sources\"])\n except (ValueError, TypeError):\n d[\"dup_sources\"] = []\n # offre directe = page carrière de l'employeur ou dépôt direct (≠ portail)\n d[\"is_direct\"] = d.get(\"source\") not in AGGREGATORS\n return d\n\n\n@app.get(\"/health\")\ndef health():\n \"\"\"Healthcheck de déploiement : BD accessible + fraîcheur des syncs.\"\"\"\n try:\n con = db.connect()\n total = con.execute(\n \"SELECT COUNT(*) c FROM jobs WHERE active=1\").fetchone()[\"c\"]\n last = con.execute(\n \"SELECT MAX(ts) ts FROM sync_log WHERE ok=1\").fetchone()[\"ts\"]\n con.close()\n except Exception as exc:\n raise HTTPException(500, f\"BD inaccessible : {exc}\")\n return {\"status\": \"ok\", \"active_jobs\": total,\n \"last_sync_age_s\": round(time.time() - last) if last else None}\n\n\n@app.get(\"/api/jobs\")\ndef list_jobs(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n employer: str | None = None,\n source: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n salary_min: float | None = None, # $/an (converti côté serveur)\n with_salary: int | None = None, # 1 = transparence salariale seulement\n posted_after: str | None = None, # ISO : offres publiées depuis…\n language: str | None = None, # fr | en | bilingue\n q: str | None = None,\n active: int = 1,\n quarantined: int | None = None, # 1 = quarantaine seulement (supervision)\n sort: str = \"recent\", # recent | salary\n limit: int = Query(100, le=2000),\n offset: int = 0,\n):\n con = db.connect()\n sql = \"SELECT * FROM jobs WHERE dup_of IS NULL\" # doublons masqués\n args: list = []\n # quarantaine qualité : jamais publiée par défaut (voir jobka/quality.py)\n sql += \" AND quarantine IS NOT NULL\" if quarantined == 1 \\\n else \" AND quarantine IS NULL\"\n if active in (0, 1):\n sql += \" AND active=?\"; args.append(active)\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if language:\n sql += \" AND language=?\"; args.append(language)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if employer:\n sql += \" AND employer LIKE ?\"; args.append(f\"%{employer}%\")\n if source:\n sql += \" AND source=?\"; args.append(source)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if posted_after:\n sql += \" AND date_posted IS NOT NULL AND date_posted>=?\"\n args.append(posted_after)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ? OR description LIKE ?)\"\n args += [f\"%{q}%\"] * 3\n total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n if sort == \"salary\":\n sql += \" ORDER BY salary_year_max IS NULL, salary_year_max DESC\"\n else:\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC\"\n sql += \" LIMIT ? OFFSET ?\"\n args += [limit, offset]\n rows = []\n for r in con.execute(sql, args).fetchall():\n d = _row_to_dict(r)\n d.pop(\"description\", None) # liste allégée : la fiche a le texte complet\n rows.append(d)\n con.close()\n return {\"total\": total, \"count\": len(rows), \"jobs\": rows}\n\n\n@app.get(\"/api/jobs.geojson\")\ndef jobs_geojson(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n salary_min: float | None = None,\n with_salary: int | None = None,\n language: str | None = None,\n q: str | None = None,\n bbox: str | None = None,\n limit: int = Query(3000, le=8000),\n):\n \"\"\"Offres géolocalisées au format GeoJSON (mêmes filtres que /api/jobs).\n\n `bbox=ouest,sud,est,nord` restreint au rectangle visible (idx_jobs_geo).\n Membres additionnels : `totalGeocoded` / `totalMatching` pour afficher\n « N résultats sans position sur la carte ».\n \"\"\"\n con = db.connect()\n sql = (\"SELECT uid, title, title_clean, employer, city, category, work_mode,\"\n \" employment_type, salary_min, salary_max, salary_unit,\"\n \" salary_year_min, salary_year_max, date_posted, source, lat, lng\"\n \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n \" AND lat IS NOT NULL AND lng IS NOT NULL\")\n args: list = []\n if bbox:\n try:\n west, south, east, north = (float(v) for v in bbox.split(\",\"))\n except ValueError:\n raise HTTPException(400, \"bbox attendu : ouest,sud,est,nord\")\n sql += \" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\"\n args += [south, north, west, east]\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += \" AND salary_year_max IS NOT NULL AND salary_year_max>=?\"\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if language:\n sql += \" AND language=?\"; args.append(language)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ?)\"\n args += [f\"%{q}%\"] * 2\n\n total_geo = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n sql_all = sql.replace(\" AND lat IS NOT NULL AND lng IS NOT NULL\", \"\")\n args_all = list(args)\n if bbox:\n sql_all = sql_all.replace(\" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\", \"\")\n del args_all[0:4]\n total_all = con.execute(f\"SELECT COUNT(*) c FROM ({sql_all})\", args_all).fetchone()[\"c\"]\n\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC LIMIT ?\"\n args.append(limit)\n\n features = []\n for r in con.execute(sql, args).fetchall():\n features.append({\n \"type\": \"Feature\",\n \"geometry\": {\"type\": \"Point\", \"coordinates\": [r[\"lng\"], r[\"lat\"]]},\n \"properties\": {\n \"uid\": r[\"uid\"], \"title\": r[\"title_clean\"] or r[\"title\"],\n \"employer\": r[\"employer\"],\n \"city\": r[\"city\"], \"category\": r[\"category\"],\n \"work_mode\": r[\"work_mode\"],\n \"employment_type\": r[\"employment_type\"],\n \"salary_min\": r[\"salary_min\"], \"salary_max\": r[\"salary_max\"],\n \"salary_unit\": r[\"salary_unit\"],\n \"salary_year_min\": r[\"salary_year_min\"],\n \"salary_year_max\": r[\"salary_year_max\"],\n \"date_posted\": r[\"date_posted\"], \"source\": r[\"source\"],\n \"is_direct\": r[\"source\"] not in AGGREGATORS,\n },\n })\n con.close()\n return {\"type\": \"FeatureCollection\", \"features\": features,\n \"totalGeocoded\": total_geo, \"totalMatching\": total_all}\n\n\n@app.get(\"/api/jobs/{uid}\")\ndef get_job(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM jobs WHERE uid=?\", (uid,)).fetchone()\n d = None\n if row is not None:\n d = _row_to_dict(row)\n d[\"salary_history\"] = [dict(r) for r in con.execute(\n \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\"\n \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]\n con.close()\n if d is None:\n raise HTTPException(404, \"Offre introuvable\")\n return d\n\n\n@app.get(\"/api/facets\")\ndef facets(city: str | None = None):\n \"\"\"Valeurs distinctes pour construire les filtres du frontend.\"\"\"\n con = db.connect()\n base = \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n out = {\n \"cities\": [r[\"city\"] for r in con.execute(\n f\"SELECT DISTINCT city{base} AND city<>'' ORDER BY city\")],\n # régions administratives (17) — le repli générique « Québec »\n # (appartenance provinciale sans région précise) n'est pas une facette\n \"regions\": [dict(r) for r in con.execute(\n f\"SELECT region, COUNT(*) n{base} AND region<>''\"\n \" AND region<>'Québec' GROUP BY region ORDER BY region\")],\n \"languages\": [dict(r) for r in con.execute(\n f\"SELECT language, COUNT(*) n{base} AND language IS NOT NULL\"\n \" AND language<>'' GROUP BY language ORDER BY n DESC\")],\n \"categories\": [dict(r) for r in con.execute(\n f\"SELECT category, COUNT(*) n{base} AND category<>''\"\n \" GROUP BY category ORDER BY n DESC\")],\n \"employers\": [dict(r) for r in con.execute(\n f\"SELECT employer, COUNT(*) n{base} AND employer<>''\"\n \" GROUP BY employer ORDER BY n DESC LIMIT 100\")],\n \"work_modes\": [r[\"work_mode\"] for r in con.execute(\n f\"SELECT DISTINCT work_mode{base} AND work_mode IS NOT NULL\"\n \" ORDER BY work_mode\")],\n \"employment_types\": [r[\"employment_type\"] for r in con.execute(\n f\"SELECT DISTINCT employment_type{base}\"\n \" AND employment_type IS NOT NULL ORDER BY employment_type\")],\n \"sources\": [dict(r) for r in con.execute(\n f\"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC\")],\n }\n con.close()\n return out\n\n\n@app.get(\"/api/sources\")\ndef sources():\n registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n con = db.connect()\n counts = {r[\"source\"]: r[\"n\"] for r in con.execute(\n \"SELECT source, COUNT(*) n FROM jobs WHERE active=1 GROUP BY source\")}\n last = {r[\"source\"]: r[\"ts\"] for r in con.execute(\n \"SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source\")}\n con.close()\n for s in registry:\n s[\"active_jobs\"] = counts.get(s[\"id\"], 0)\n s[\"last_sync\"] = last.get(s[\"id\"])\n return {\"sources\": registry}\n\n\n@app.get(\"/api/stats\")\ndef stats(syncs_since_h: float | None = None):\n con = db.connect()\n row = con.execute(\n \"\"\"SELECT COUNT(*) total,\n COUNT(DISTINCT employer) employers,\n COUNT(DISTINCT source) sources,\n SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary,\n AVG(salary_year_min) avg_salary_year,\n SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote,\n SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND quarantine IS NULL\"\"\").fetchone()\n quarantined = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\"\n ).fetchone()[\"n\"]\n cities = [dict(r) for r in con.execute(\n \"\"\"SELECT city, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND city<>''\n GROUP BY city ORDER BY n DESC LIMIT 15\"\"\")]\n categories = [dict(r) for r in con.execute(\n \"\"\"SELECT category, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND category<>''\n GROUP BY category ORDER BY n DESC\"\"\")]\n # Fenêtre élargie pour la supervision (api-ka) : sans le paramètre, on\n # garde les 20 dernières entrées ; avec, toute l'activité de la fenêtre\n # (sinon le moniteur bi-horaire ne voit qu'une fraction de la rotation\n # des sources → fausses alertes « stale »).\n if syncs_since_h and syncs_since_h > 0:\n cutoff = time.time() - syncs_since_h * 3600\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000\",\n (cutoff,))]\n if len(log) < 20:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n else:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n # offres directes (pages carrières + dépôt direct) c. portails agrégateurs\n agg = sorted(AGGREGATORS) or [\"__aucun__\"]\n ph = \",\".join(\"?\" * len(agg))\n direct = con.execute(\n f\"\"\"SELECT COUNT(*) n, COUNT(DISTINCT employer) e,\n COUNT(DISTINCT source) s\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source NOT IN ({ph})\"\"\", agg).fetchone()\n portal = con.execute(\n f\"\"\"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source IN ({ph})\"\"\", agg).fetchone()\n dups = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1\"\n \" AND dup_of IS NOT NULL\").fetchone()\n con.close()\n return {**dict(row), \"top_cities\": cities, \"categories\": categories,\n \"direct_jobs\": direct[\"n\"], \"direct_employers\": direct[\"e\"],\n \"direct_sources\": direct[\"s\"], \"portal_jobs\": portal[\"n\"],\n \"duplicates_hidden\": dups[\"n\"], \"quarantined\": quarantined,\n \"recent_syncs\": log}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"Tableau de bord analytique — contrat commun Groupe KA (SPEC.md).\n Cache mémoire 5 min par clé de période (voir jobka/statsdash.py).\"\"\"\n from . import statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n return statsdash.compute(period, from_, to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None,\n mode: str = \"complet\"):\n \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes : complet | synthese |\n tendances | repartitions | donnees (mode inconnu -> complet).\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n if mode not in kapdf.REPORT_MODES:\n mode = \"complet\"\n dash = statsdash.compute(period, from_, to)\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash,\n mode=mode,\n ).build()\n fname = kapdf.filename(\"job-ka\", period, mode)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/stats/catalog\")\ndef stats_catalog(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"v3 — blocs composables pour le constructeur de rapports personnalisés.\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, from_, to)\n return {\"updated\": dash.get(\"updated\"), \"period\": dash.get(\"period\"),\n \"blocks\": kapdf.catalog(dash)}\n\n\n@app.post(\"/api/stats/report/custom\")\ndef stats_report_custom(spec: dict = Body(...)):\n \"\"\"v3 — rapport PDF personnalisé : {\"title\", \"period\", \"from\", \"to\",\n \"blocks\": [{\"key\": \"series:…\", \"render\": \"bar\"}, …]} (SPEC.md §3bis).\"\"\"\n from . import kapdf, statsdash\n period = str(spec.get(\"period\") or \"30j\")\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, str(spec.get(\"from\") or \"\") or None,\n str(spec.get(\"to\") or \"\") or None)\n known = {b[\"key\"] for b in kapdf.catalog(dash)}\n blocks = [b for b in (spec.get(\"blocks\") or [])\n if isinstance(b, dict) and b.get(\"key\") in known][:40]\n if not blocks:\n raise HTTPException(400, \"Aucun bloc valide dans la composition\")\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash, mode=kapdf.CUSTOM_MODE,\n spec={\"title\": str(spec.get(\"title\") or \"\")[:80], \"blocks\": blocks},\n ).build()\n fname = kapdf.filename(\"job-ka\", period, kapdf.CUSTOM_MODE)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n# --- Dépôt direct d'offres par les employeurs ---------------------------------\nclass OffreDeposee(BaseModel):\n \"\"\"Offre soumise par un employeur (validation minimale, modérée ensuite).\"\"\"\n\n employer: str = Field(min_length=2, max_length=120)\n title: str = Field(min_length=3, max_length=160)\n description: str = Field(min_length=30, max_length=20000)\n contact_email: str = Field(min_length=5, max_length=160)\n city: str = Field(\"\", max_length=80)\n url: str = Field(\"\", max_length=400)\n salary_label: str = Field(\"\", max_length=120)\n employment_type: str | None = None\n work_mode: str | None = None\n website: str = \"\" # pot de miel anti-robots : doit rester vide\n\n\n@app.post(\"/api/employeurs/offres\")\ndef deposer_offre(offre: OffreDeposee):\n \"\"\"Canal de dépôt direct : l'offre est stockée INACTIVE (source\n « depot-direct ») en attente de modération manuelle. Voir /employeurs.\"\"\"\n if offre.website: # robot pris au pot de miel\n return {\"status\": \"reçu\"}\n if not re.match(r\"^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$\", offre.contact_email):\n raise HTTPException(422, \"Courriel de contact invalide\")\n if offre.url and not re.match(r\"^https?://\", offre.url):\n raise HTTPException(422, \"Lien invalide : http(s):// requis\")\n if offre.employment_type and offre.employment_type not in (\n \"temps_plein\", \"temps_partiel\", \"contractuel\", \"stage\", \"saisonnier\"):\n raise HTTPException(422, \"Type d'emploi inconnu\")\n if offre.work_mode and offre.work_mode not in (\n \"presentiel\", \"hybride\", \"teletravail\"):\n raise HTTPException(422, \"Mode de travail inconnu\")\n\n job = JobPosting(\n source=\"depot-direct\", external_id=secrets.token_hex(6),\n url=offre.url, employer=offre.employer, title=offre.title,\n description=offre.description, city=offre.city,\n salary_label=offre.salary_label,\n employment_type=offre.employment_type, work_mode=offre.work_mode,\n ats=\"depot-direct\",\n )\n job.details[\"contact_email\"] = offre.contact_email\n job.details[\"moderation\"] = \"en_attente\"\n job.finalize()\n\n now = time.time()\n cols = {\n \"uid\": job.uid, \"source\": job.source, \"external_id\": job.external_id,\n \"url\": job.url, \"employer\": job.employer, \"title\": job.title,\n \"description\": job.description, \"city\": job.city,\n \"region\": job.region, \"postal_code\": job.postal_code,\n \"location_label\": job.location_label, \"work_mode\": job.work_mode,\n \"employment_type\": job.employment_type,\n \"salary_min\": job.salary_min, \"salary_max\": job.salary_max,\n \"salary_unit\": job.salary_unit, \"salary_label\": job.salary_label,\n \"salary_year_min\": job.salary_year_min(),\n \"salary_year_max\": job.salary_year_max(),\n \"salary_hour_min\": job.salary_hour_min(),\n \"salary_hour_max\": job.salary_hour_max(),\n \"benefits\": json.dumps(job.benefits, ensure_ascii=False),\n \"requirements\": json.dumps(job.requirements, ensure_ascii=False),\n \"category\": job.category, \"ats\": job.ats,\n \"details\": json.dumps(job.details, ensure_ascii=False),\n \"content_hash\": job.content_hash(),\n \"first_seen\": now, \"last_seen\": now, \"updated_at\": now,\n \"miss_count\": 0, \"active\": 0, # inactive tant que non modérée\n }\n con = db.connect()\n con.execute(\n f\"INSERT INTO jobs ({', '.join(cols)}) VALUES \"\n f\"({', '.join(':' + k for k in cols)})\", cols)\n con.commit()\n con.close()\n return {\"status\": \"reçu\", \"uid\": job.uid,\n \"message\": \"Merci ! Votre offre a bien été reçue. Elle sera \"\n \"vérifiée par notre équipe avant publication.\"}\n\n\n@app.post(\"/api/sync\")\ndef trigger_sync(background: BackgroundTasks, source: str | None = None):\n \"\"\"Déclenche une synchronisation (équivalent d'un webhook entrant).\"\"\"\n def _job():\n with _sync_lock:\n ingest.run([source] if source else None)\n background.add_task(_job)\n return {\"status\": \"démarré\", \"source\": source or \"toutes\"}\n\n\n# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ------\n@app.get(\"/api/favorites\")\ndef favorites(request: Request):\n \"\"\"Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n items = hubfav.hub_list(user.get(\"ka_id\") or \"\")\n if items is None:\n raise HTTPException(502, \"Hub Groupe KA injoignable — réessayez\")\n return {\"ids\": [i[\"item_id\"] for i in items if i.get(\"item_id\")],\n \"items\": items}\n\n\n@app.post(\"/api/favorites/toggle\")\ndef toggle_favorite(request: Request, body: dict = Body(...)):\n \"\"\"Ajoute (on=true) ou retire (on=false) un favori — poussé au hub\n Groupe KA de façon synchrone : le hub est la seule source de vérité.\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n ka_id = user.get(\"ka_id\") or \"\"\n if not hubfav.linked(ka_id):\n raise HTTPException(403, \"Compte non relié au hub Groupe KA\")\n on = bool(body.get(\"on\"))\n item = hubfav.clean_item(body.get(\"item\") or {})\n if not item.get(\"item_id\"):\n raise HTTPException(422, \"item.item_id requis\")\n if not hubfav.hub_toggle(ka_id, \"add\" if on else \"remove\", item):\n raise HTTPException(502, \"Hub Groupe KA injoignable — favori non enregistré\")\n return {\"ok\": True, \"on\": on}\n\n\n# --- Frontend (build Vite) ---------------------------------------------------\nif FRONTEND_DIST.exists():\n app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIST / \"assets\"), name=\"assets\")\n\n # SEO : SSR léger des routes publiques (accueil, fiches emploi, pages\n # statiques), robots.txt et sitemaps — DOIT être inclus AVANT le\n # rattrape-tout SPA ci-dessous (l'ordre d'enregistrement fait foi).\n from . import seo # noqa: E402\n app.include_router(seo.router)\n\n @app.middleware(\"http\")\n async def _cache_headers(request, call_next):\n \"\"\"Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé —\n sinon les navigateurs gardent une vieille version après un déploiement.\"\"\"\n resp = await call_next(request)\n path = request.url.path\n if path.startswith(\"/assets/\"):\n resp.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n elif \"text/html\" in (resp.headers.get(\"content-type\") or \"\"):\n resp.headers[\"Cache-Control\"] = \"no-cache\"\n return resp\n\n # routes rendues uniquement côté client — tout autre chemin inconnu renvoie\n # index.html avec un statut 404 (pas de soft-404 pour les moteurs)\n _CLIENT_ROUTES = {\"carte\", \"sources\", \"stats\", \"contact\", \"employeurs\",\n \"favoris\", \"confidentialite\", \"conditions\"}\n _CLIENT_PREFIXES = (\"emploi/\",)\n\n @app.get(\"/{full_path:path}\")\n def spa(full_path: str):\n target = FRONTEND_DIST / full_path\n if full_path and target.is_file():\n return FileResponse(target)\n if full_path and target.is_dir() and (target / \"index.html\").is_file():\n # sous-répertoire statique avec index (ex. /doc/) — servi tel quel\n return FileResponse(target / \"index.html\")\n known = (full_path == \"\" or full_path in _CLIENT_ROUTES\n or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))\n return FileResponse(FRONTEND_DIST / \"index.html\",\n status_code=200 if known else 404,\n headers={\"Cache-Control\": \"no-cache\"})\n","structuredPatch":[{"oldStart":83,"oldLines":6,"newStart":83,"newLines":7,"lines":[" source: str | None = None,"," work_mode: str | None = None,"," employment_type: str | None = None,","+ seniority: str | None = None, # stage | junior | intermediaire | senior | direction"," salary_min: float | None = None, # $/an (converti côté serveur)"," with_salary: int | None = None, # 1 = transparence salariale seulement"," posted_after: str | None = None, # ISO : offres publiées depuis…"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcH6MxrTpLyXUTc2FeS","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PgjLRyMMXDebgp8tN5VrNy","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","old_string":" if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")\n args.append(salary_min)","new_string":" if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if seniority:\n sql += \" AND seniority=?\"; args.append(seniority)\n if salary_min is not None:\n sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")\n args.append(salary_min)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2449,"cache_read_input_tokens":127399,"cache_creation":{"ephemeral_5m_input_tokens":2449,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"f6f2d227-21b7-43db-8380-825f360847de","request_id":"req_011CePcH2Efvw7cXp4y9xYJg"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PgjLRyMMXDebgp8tN5VrNy","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/web.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"e51ccd32-c480-4150-8c2b-8235661c47d0","timestamp":"2026-08-25T14:16:53.061Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","oldString":" if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")\n args.append(salary_min)","newString":" if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if seniority:\n sql += \" AND seniority=?\"; args.append(seniority)\n if salary_min is not None:\n sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")\n args.append(salary_min)","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/web.py\n# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\nfrom __future__ import annotations\n\nimport json\nimport re\nimport secrets\nimport threading\nimport time\nfrom pathlib import Path\n\nfrom fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom fastapi.staticfiles import StaticFiles\nfrom pydantic import BaseModel, Field\n\nfrom . import auth, db, hubfav, ingest\nfrom .dedup import AGGREGATORS\nfrom .schema import JobPosting\n\nROOT = Path(__file__).resolve().parent.parent\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n\napp = FastAPI(title=\"Job-Ka API\", version=\"1.0\",\n description=\"Agrégateur d'offres d'emploi — employeurs québécois, \"\n \"directement à la source\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n# connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py\napp.include_router(auth.router)\n\n_sync_lock = threading.Lock()\n\n\ndef _row_to_dict(row) -> dict:\n d = dict(row)\n d[\"benefits\"] = json.loads(d.get(\"benefits\") or \"[]\")\n d[\"requirements\"] = json.loads(d.get(\"requirements\") or \"{}\")\n d[\"details\"] = json.loads(d.get(\"details\") or \"{}\")\n if d.get(\"dup_sources\"):\n try:\n d[\"dup_sources\"] = json.loads(d[\"dup_sources\"])\n except (ValueError, TypeError):\n d[\"dup_sources\"] = []\n # offre directe = page carrière de l'employeur ou dépôt direct (≠ portail)\n d[\"is_direct\"] = d.get(\"source\") not in AGGREGATORS\n return d\n\n\n@app.get(\"/health\")\ndef health():\n \"\"\"Healthcheck de déploiement : BD accessible + fraîcheur des syncs.\"\"\"\n try:\n con = db.connect()\n total = con.execute(\n \"SELECT COUNT(*) c FROM jobs WHERE active=1\").fetchone()[\"c\"]\n last = con.execute(\n \"SELECT MAX(ts) ts FROM sync_log WHERE ok=1\").fetchone()[\"ts\"]\n con.close()\n except Exception as exc:\n raise HTTPException(500, f\"BD inaccessible : {exc}\")\n return {\"status\": \"ok\", \"active_jobs\": total,\n \"last_sync_age_s\": round(time.time() - last) if last else None}\n\n\n@app.get(\"/api/jobs\")\ndef list_jobs(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n employer: str | None = None,\n source: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n seniority: str | None = None, # stage | junior | intermediaire | senior | direction\n salary_min: float | None = None, # $/an (converti côté serveur)\n with_salary: int | None = None, # 1 = transparence salariale seulement\n posted_after: str | None = None, # ISO : offres publiées depuis…\n language: str | None = None, # fr | en | bilingue\n q: str | None = None,\n active: int = 1,\n quarantined: int | None = None, # 1 = quarantaine seulement (supervision)\n sort: str = \"recent\", # recent | salary\n limit: int = Query(100, le=2000),\n offset: int = 0,\n):\n con = db.connect()\n sql = \"SELECT * FROM jobs WHERE dup_of IS NULL\" # doublons masqués\n args: list = []\n # quarantaine qualité : jamais publiée par défaut (voir jobka/quality.py)\n sql += \" AND quarantine IS NOT NULL\" if quarantined == 1 \\\n else \" AND quarantine IS NULL\"\n if active in (0, 1):\n sql += \" AND active=?\"; args.append(active)\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if language:\n sql += \" AND language=?\"; args.append(language)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if employer:\n sql += \" AND employer LIKE ?\"; args.append(f\"%{employer}%\")\n if source:\n sql += \" AND source=?\"; args.append(source)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if posted_after:\n sql += \" AND date_posted IS NOT NULL AND date_posted>=?\"\n args.append(posted_after)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ? OR description LIKE ?)\"\n args += [f\"%{q}%\"] * 3\n total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n if sort == \"salary\":\n sql += \" ORDER BY salary_year_max IS NULL, salary_year_max DESC\"\n else:\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC\"\n sql += \" LIMIT ? OFFSET ?\"\n args += [limit, offset]\n rows = []\n for r in con.execute(sql, args).fetchall():\n d = _row_to_dict(r)\n d.pop(\"description\", None) # liste allégée : la fiche a le texte complet\n rows.append(d)\n con.close()\n return {\"total\": total, \"count\": len(rows), \"jobs\": rows}\n\n\n@app.get(\"/api/jobs.geojson\")\ndef jobs_geojson(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n salary_min: float | None = None,\n with_salary: int | None = None,\n language: str | None = None,\n q: str | None = None,\n bbox: str | None = None,\n limit: int = Query(3000, le=8000),\n):\n \"\"\"Offres géolocalisées au format GeoJSON (mêmes filtres que /api/jobs).\n\n `bbox=ouest,sud,est,nord` restreint au rectangle visible (idx_jobs_geo).\n Membres additionnels : `totalGeocoded` / `totalMatching` pour afficher\n « N résultats sans position sur la carte ».\n \"\"\"\n con = db.connect()\n sql = (\"SELECT uid, title, title_clean, employer, city, category, work_mode,\"\n \" employment_type, salary_min, salary_max, salary_unit,\"\n \" salary_year_min, salary_year_max, date_posted, source, lat, lng\"\n \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n \" AND lat IS NOT NULL AND lng IS NOT NULL\")\n args: list = []\n if bbox:\n try:\n west, south, east, north = (float(v) for v in bbox.split(\",\"))\n except ValueError:\n raise HTTPException(400, \"bbox attendu : ouest,sud,est,nord\")\n sql += \" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\"\n args += [south, north, west, east]\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += \" AND salary_year_max IS NOT NULL AND salary_year_max>=?\"\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if language:\n sql += \" AND language=?\"; args.append(language)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ?)\"\n args += [f\"%{q}%\"] * 2\n\n total_geo = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n sql_all = sql.replace(\" AND lat IS NOT NULL AND lng IS NOT NULL\", \"\")\n args_all = list(args)\n if bbox:\n sql_all = sql_all.replace(\" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\", \"\")\n del args_all[0:4]\n total_all = con.execute(f\"SELECT COUNT(*) c FROM ({sql_all})\", args_all).fetchone()[\"c\"]\n\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC LIMIT ?\"\n args.append(limit)\n\n features = []\n for r in con.execute(sql, args).fetchall():\n features.append({\n \"type\": \"Feature\",\n \"geometry\": {\"type\": \"Point\", \"coordinates\": [r[\"lng\"], r[\"lat\"]]},\n \"properties\": {\n \"uid\": r[\"uid\"], \"title\": r[\"title_clean\"] or r[\"title\"],\n \"employer\": r[\"employer\"],\n \"city\": r[\"city\"], \"category\": r[\"category\"],\n \"work_mode\": r[\"work_mode\"],\n \"employment_type\": r[\"employment_type\"],\n \"salary_min\": r[\"salary_min\"], \"salary_max\": r[\"salary_max\"],\n \"salary_unit\": r[\"salary_unit\"],\n \"salary_year_min\": r[\"salary_year_min\"],\n \"salary_year_max\": r[\"salary_year_max\"],\n \"date_posted\": r[\"date_posted\"], \"source\": r[\"source\"],\n \"is_direct\": r[\"source\"] not in AGGREGATORS,\n },\n })\n con.close()\n return {\"type\": \"FeatureCollection\", \"features\": features,\n \"totalGeocoded\": total_geo, \"totalMatching\": total_all}\n\n\n@app.get(\"/api/jobs/{uid}\")\ndef get_job(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM jobs WHERE uid=?\", (uid,)).fetchone()\n d = None\n if row is not None:\n d = _row_to_dict(row)\n d[\"salary_history\"] = [dict(r) for r in con.execute(\n \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\"\n \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]\n con.close()\n if d is None:\n raise HTTPException(404, \"Offre introuvable\")\n return d\n\n\n@app.get(\"/api/facets\")\ndef facets(city: str | None = None):\n \"\"\"Valeurs distinctes pour construire les filtres du frontend.\"\"\"\n con = db.connect()\n base = \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n out = {\n \"cities\": [r[\"city\"] for r in con.execute(\n f\"SELECT DISTINCT city{base} AND city<>'' ORDER BY city\")],\n # régions administratives (17) — le repli générique « Québec »\n # (appartenance provinciale sans région précise) n'est pas une facette\n \"regions\": [dict(r) for r in con.execute(\n f\"SELECT region, COUNT(*) n{base} AND region<>''\"\n \" AND region<>'Québec' GROUP BY region ORDER BY region\")],\n \"languages\": [dict(r) for r in con.execute(\n f\"SELECT language, COUNT(*) n{base} AND language IS NOT NULL\"\n \" AND language<>'' GROUP BY language ORDER BY n DESC\")],\n \"categories\": [dict(r) for r in con.execute(\n f\"SELECT category, COUNT(*) n{base} AND category<>''\"\n \" GROUP BY category ORDER BY n DESC\")],\n \"employers\": [dict(r) for r in con.execute(\n f\"SELECT employer, COUNT(*) n{base} AND employer<>''\"\n \" GROUP BY employer ORDER BY n DESC LIMIT 100\")],\n \"work_modes\": [r[\"work_mode\"] for r in con.execute(\n f\"SELECT DISTINCT work_mode{base} AND work_mode IS NOT NULL\"\n \" ORDER BY work_mode\")],\n \"employment_types\": [r[\"employment_type\"] for r in con.execute(\n f\"SELECT DISTINCT employment_type{base}\"\n \" AND employment_type IS NOT NULL ORDER BY employment_type\")],\n \"sources\": [dict(r) for r in con.execute(\n f\"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC\")],\n }\n con.close()\n return out\n\n\n@app.get(\"/api/sources\")\ndef sources():\n registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n con = db.connect()\n counts = {r[\"source\"]: r[\"n\"] for r in con.execute(\n \"SELECT source, COUNT(*) n FROM jobs WHERE active=1 GROUP BY source\")}\n last = {r[\"source\"]: r[\"ts\"] for r in con.execute(\n \"SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source\")}\n con.close()\n for s in registry:\n s[\"active_jobs\"] = counts.get(s[\"id\"], 0)\n s[\"last_sync\"] = last.get(s[\"id\"])\n return {\"sources\": registry}\n\n\n@app.get(\"/api/stats\")\ndef stats(syncs_since_h: float | None = None):\n con = db.connect()\n row = con.execute(\n \"\"\"SELECT COUNT(*) total,\n COUNT(DISTINCT employer) employers,\n COUNT(DISTINCT source) sources,\n SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary,\n AVG(salary_year_min) avg_salary_year,\n SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote,\n SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND quarantine IS NULL\"\"\").fetchone()\n quarantined = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\"\n ).fetchone()[\"n\"]\n cities = [dict(r) for r in con.execute(\n \"\"\"SELECT city, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND city<>''\n GROUP BY city ORDER BY n DESC LIMIT 15\"\"\")]\n categories = [dict(r) for r in con.execute(\n \"\"\"SELECT category, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND category<>''\n GROUP BY category ORDER BY n DESC\"\"\")]\n # Fenêtre élargie pour la supervision (api-ka) : sans le paramètre, on\n # garde les 20 dernières entrées ; avec, toute l'activité de la fenêtre\n # (sinon le moniteur bi-horaire ne voit qu'une fraction de la rotation\n # des sources → fausses alertes « stale »).\n if syncs_since_h and syncs_since_h > 0:\n cutoff = time.time() - syncs_since_h * 3600\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000\",\n (cutoff,))]\n if len(log) < 20:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n else:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n # offres directes (pages carrières + dépôt direct) c. portails agrégateurs\n agg = sorted(AGGREGATORS) or [\"__aucun__\"]\n ph = \",\".join(\"?\" * len(agg))\n direct = con.execute(\n f\"\"\"SELECT COUNT(*) n, COUNT(DISTINCT employer) e,\n COUNT(DISTINCT source) s\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source NOT IN ({ph})\"\"\", agg).fetchone()\n portal = con.execute(\n f\"\"\"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source IN ({ph})\"\"\", agg).fetchone()\n dups = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1\"\n \" AND dup_of IS NOT NULL\").fetchone()\n con.close()\n return {**dict(row), \"top_cities\": cities, \"categories\": categories,\n \"direct_jobs\": direct[\"n\"], \"direct_employers\": direct[\"e\"],\n \"direct_sources\": direct[\"s\"], \"portal_jobs\": portal[\"n\"],\n \"duplicates_hidden\": dups[\"n\"], \"quarantined\": quarantined,\n \"recent_syncs\": log}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"Tableau de bord analytique — contrat commun Groupe KA (SPEC.md).\n Cache mémoire 5 min par clé de période (voir jobka/statsdash.py).\"\"\"\n from . import statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n return statsdash.compute(period, from_, to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None,\n mode: str = \"complet\"):\n \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes : complet | synthese |\n tendances | repartitions | donnees (mode inconnu -> complet).\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n if mode not in kapdf.REPORT_MODES:\n mode = \"complet\"\n dash = statsdash.compute(period, from_, to)\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash,\n mode=mode,\n ).build()\n fname = kapdf.filename(\"job-ka\", period, mode)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/stats/catalog\")\ndef stats_catalog(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"v3 — blocs composables pour le constructeur de rapports personnalisés.\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, from_, to)\n return {\"updated\": dash.get(\"updated\"), \"period\": dash.get(\"period\"),\n \"blocks\": kapdf.catalog(dash)}\n\n\n@app.post(\"/api/stats/report/custom\")\ndef stats_report_custom(spec: dict = Body(...)):\n \"\"\"v3 — rapport PDF personnalisé : {\"title\", \"period\", \"from\", \"to\",\n \"blocks\": [{\"key\": \"series:…\", \"render\": \"bar\"}, …]} (SPEC.md §3bis).\"\"\"\n from . import kapdf, statsdash\n period = str(spec.get(\"period\") or \"30j\")\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, str(spec.get(\"from\") or \"\") or None,\n str(spec.get(\"to\") or \"\") or None)\n known = {b[\"key\"] for b in kapdf.catalog(dash)}\n blocks = [b for b in (spec.get(\"blocks\") or [])\n if isinstance(b, dict) and b.get(\"key\") in known][:40]\n if not blocks:\n raise HTTPException(400, \"Aucun bloc valide dans la composition\")\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash, mode=kapdf.CUSTOM_MODE,\n spec={\"title\": str(spec.get(\"title\") or \"\")[:80], \"blocks\": blocks},\n ).build()\n fname = kapdf.filename(\"job-ka\", period, kapdf.CUSTOM_MODE)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n# --- Dépôt direct d'offres par les employeurs ---------------------------------\nclass OffreDeposee(BaseModel):\n \"\"\"Offre soumise par un employeur (validation minimale, modérée ensuite).\"\"\"\n\n employer: str = Field(min_length=2, max_length=120)\n title: str = Field(min_length=3, max_length=160)\n description: str = Field(min_length=30, max_length=20000)\n contact_email: str = Field(min_length=5, max_length=160)\n city: str = Field(\"\", max_length=80)\n url: str = Field(\"\", max_length=400)\n salary_label: str = Field(\"\", max_length=120)\n employment_type: str | None = None\n work_mode: str | None = None\n website: str = \"\" # pot de miel anti-robots : doit rester vide\n\n\n@app.post(\"/api/employeurs/offres\")\ndef deposer_offre(offre: OffreDeposee):\n \"\"\"Canal de dépôt direct : l'offre est stockée INACTIVE (source\n « depot-direct ») en attente de modération manuelle. Voir /employeurs.\"\"\"\n if offre.website: # robot pris au pot de miel\n return {\"status\": \"reçu\"}\n if not re.match(r\"^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$\", offre.contact_email):\n raise HTTPException(422, \"Courriel de contact invalide\")\n if offre.url and not re.match(r\"^https?://\", offre.url):\n raise HTTPException(422, \"Lien invalide : http(s):// requis\")\n if offre.employment_type and offre.employment_type not in (\n \"temps_plein\", \"temps_partiel\", \"contractuel\", \"stage\", \"saisonnier\"):\n raise HTTPException(422, \"Type d'emploi inconnu\")\n if offre.work_mode and offre.work_mode not in (\n \"presentiel\", \"hybride\", \"teletravail\"):\n raise HTTPException(422, \"Mode de travail inconnu\")\n\n job = JobPosting(\n source=\"depot-direct\", external_id=secrets.token_hex(6),\n url=offre.url, employer=offre.employer, title=offre.title,\n description=offre.description, city=offre.city,\n salary_label=offre.salary_label,\n employment_type=offre.employment_type, work_mode=offre.work_mode,\n ats=\"depot-direct\",\n )\n job.details[\"contact_email\"] = offre.contact_email\n job.details[\"moderation\"] = \"en_attente\"\n job.finalize()\n\n now = time.time()\n cols = {\n \"uid\": job.uid, \"source\": job.source, \"external_id\": job.external_id,\n \"url\": job.url, \"employer\": job.employer, \"title\": job.title,\n \"description\": job.description, \"city\": job.city,\n \"region\": job.region, \"postal_code\": job.postal_code,\n \"location_label\": job.location_label, \"work_mode\": job.work_mode,\n \"employment_type\": job.employment_type,\n \"salary_min\": job.salary_min, \"salary_max\": job.salary_max,\n \"salary_unit\": job.salary_unit, \"salary_label\": job.salary_label,\n \"salary_year_min\": job.salary_year_min(),\n \"salary_year_max\": job.salary_year_max(),\n \"salary_hour_min\": job.salary_hour_min(),\n \"salary_hour_max\": job.salary_hour_max(),\n \"benefits\": json.dumps(job.benefits, ensure_ascii=False),\n \"requirements\": json.dumps(job.requirements, ensure_ascii=False),\n \"category\": job.category, \"ats\": job.ats,\n \"details\": json.dumps(job.details, ensure_ascii=False),\n \"content_hash\": job.content_hash(),\n \"first_seen\": now, \"last_seen\": now, \"updated_at\": now,\n \"miss_count\": 0, \"active\": 0, # inactive tant que non modérée\n }\n con = db.connect()\n con.execute(\n f\"INSERT INTO jobs ({', '.join(cols)}) VALUES \"\n f\"({', '.join(':' + k for k in cols)})\", cols)\n con.commit()\n con.close()\n return {\"status\": \"reçu\", \"uid\": job.uid,\n \"message\": \"Merci ! Votre offre a bien été reçue. Elle sera \"\n \"vérifiée par notre équipe avant publication.\"}\n\n\n@app.post(\"/api/sync\")\ndef trigger_sync(background: BackgroundTasks, source: str | None = None):\n \"\"\"Déclenche une synchronisation (équivalent d'un webhook entrant).\"\"\"\n def _job():\n with _sync_lock:\n ingest.run([source] if source else None)\n background.add_task(_job)\n return {\"status\": \"démarré\", \"source\": source or \"toutes\"}\n\n\n# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ------\n@app.get(\"/api/favorites\")\ndef favorites(request: Request):\n \"\"\"Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n items = hubfav.hub_list(user.get(\"ka_id\") or \"\")\n if items is None:\n raise HTTPException(502, \"Hub Groupe KA injoignable — réessayez\")\n return {\"ids\": [i[\"item_id\"] for i in items if i.get(\"item_id\")],\n \"items\": items}\n\n\n@app.post(\"/api/favorites/toggle\")\ndef toggle_favorite(request: Request, body: dict = Body(...)):\n \"\"\"Ajoute (on=true) ou retire (on=false) un favori — poussé au hub\n Groupe KA de façon synchrone : le hub est la seule source de vérité.\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n ka_id = user.get(\"ka_id\") or \"\"\n if not hubfav.linked(ka_id):\n raise HTTPException(403, \"Compte non relié au hub Groupe KA\")\n on = bool(body.get(\"on\"))\n item = hubfav.clean_item(body.get(\"item\") or {})\n if not item.get(\"item_id\"):\n raise HTTPException(422, \"item.item_id requis\")\n if not hubfav.hub_toggle(ka_id, \"add\" if on else \"remove\", item):\n raise HTTPException(502, \"Hub Groupe KA injoignable — favori non enregistré\")\n return {\"ok\": True, \"on\": on}\n\n\n# --- Frontend (build Vite) ---------------------------------------------------\nif FRONTEND_DIST.exists():\n app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIST / \"assets\"), name=\"assets\")\n\n # SEO : SSR léger des routes publiques (accueil, fiches emploi, pages\n # statiques), robots.txt et sitemaps — DOIT être inclus AVANT le\n # rattrape-tout SPA ci-dessous (l'ordre d'enregistrement fait foi).\n from . import seo # noqa: E402\n app.include_router(seo.router)\n\n @app.middleware(\"http\")\n async def _cache_headers(request, call_next):\n \"\"\"Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé —\n sinon les navigateurs gardent une vieille version après un déploiement.\"\"\"\n resp = await call_next(request)\n path = request.url.path\n if path.startswith(\"/assets/\"):\n resp.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n elif \"text/html\" in (resp.headers.get(\"content-type\") or \"\"):\n resp.headers[\"Cache-Control\"] = \"no-cache\"\n return resp\n\n # routes rendues uniquement côté client — tout autre chemin inconnu renvoie\n # index.html avec un statut 404 (pas de soft-404 pour les moteurs)\n _CLIENT_ROUTES = {\"carte\", \"sources\", \"stats\", \"contact\", \"employeurs\",\n \"favoris\", \"confidentialite\", \"conditions\"}\n _CLIENT_PREFIXES = (\"emploi/\",)\n\n @app.get(\"/{full_path:path}\")\n def spa(full_path: str):\n target = FRONTEND_DIST / full_path\n if full_path and target.is_file():\n return FileResponse(target)\n if full_path and target.is_dir() and (target / \"index.html\").is_file():\n # sous-répertoire statique avec index (ex. /doc/) — servi tel quel\n return FileResponse(target / \"index.html\")\n known = (full_path == \"\" or full_path in _CLIENT_ROUTES\n or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))\n return FileResponse(FRONTEND_DIST / \"index.html\",\n status_code=200 if known else 404,\n headers={\"Cache-Control\": \"no-cache\"})\n","structuredPatch":[{"oldStart":119,"oldLines":6,"newStart":119,"newLines":8,"lines":[" sql += \" AND work_mode=?\"; args.append(work_mode)"," if employment_type:"," sql += \" AND employment_type=?\"; args.append(employment_type)","+ if seniority:","+ sql += \" AND seniority=?\"; args.append(seniority)"," if salary_min is not None:"," sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")"," args.append(salary_min)"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"ec71561a-8258-4ead-a7b3-ec90b86a725b","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcJBCTCxFqUxhU8aF6C","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JyihRaUMkRsmfP5pBwmY5k","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","old_string":"@app.get(\"/api/jobs/{uid}\")\ndef get_job(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM jobs WHERE uid=?\", (uid,)).fetchone()\n d = None\n if row is not None:\n d = _row_to_dict(row)\n d[\"salary_history\"] = [dict(r) for r in con.execute(\n \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\"\n \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]\n con.close()\n if d is None:\n raise HTTPException(404, \"Offre introuvable\")\n return d","new_string":"# --- contexte salarial de marché (métrique de la fiche d'offre) ---------------\n# Distribution des salaires annuels affichés par catégorie (point médian de la\n# fourchette), recalculée au plus toutes les 10 minutes — assez frais pour une\n# base resynchronisée à l'heure, et jamais de requête lourde par visite.\n_SALARY_CTX_TTL = 600.0\n_salary_ctx_cache: dict[str, tuple[float, dict | None]] = {}\n\n\ndef _salary_context(con, category: str) -> dict | None:\n \"\"\"Quartiles des salaires annuels affichés dans la catégorie (n >= 20).\"\"\"\n key = category or \"__toutes__\"\n hit = _salary_ctx_cache.get(key)\n if hit and time.time() - hit[0] < _SALARY_CTX_TTL:\n return hit[1]\n sql = (\"SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))\"\n \" / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL\"\n \" AND quarantine IS NULL AND salary_year_min IS NOT NULL\")\n args: list = []\n if category:\n sql += \" AND category=?\"; args.append(category)\n vals = sorted(r[\"v\"] for r in con.execute(sql, args))\n ctx = None\n if len(vals) >= 20: # trop peu d'offres = pas de médiane représentative\n def pct(p: float) -> float:\n return round(vals[min(len(vals) - 1, int(p * len(vals)))], 0)\n ctx = {\"category\": category or \"\", \"n\": len(vals),\n \"p25\": pct(0.25), \"median\": pct(0.50), \"p75\": pct(0.75)}\n _salary_ctx_cache[key] = (time.time(), ctx)\n return ctx\n\n\n@app.get(\"/api/jobs/{uid}\")\ndef get_job(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM jobs WHERE uid=?\", (uid,)).fetchone()\n d = None\n if row is not None:\n d = _row_to_dict(row)\n d[\"salary_history\"] = [dict(r) for r in con.execute(\n \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\"\n \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]\n # métriques de contexte : salaire vs marché de la catégorie + volume\n # d'offres actives de l'employeur (comparables, jamais inventées)\n ctx = _salary_context(con, d.get(\"category\") or \"\")\n if ctx and d.get(\"salary_year_min\") is not None:\n mid = (d[\"salary_year_min\"]\n + (d.get(\"salary_year_max\") or d[\"salary_year_min\"])) / 2.0\n ctx = {**ctx, \"job_mid\": round(mid, 0),\n \"delta_pct\": round((mid - ctx[\"median\"]) / ctx[\"median\"] * 100, 1)}\n d[\"salary_context\"] = ctx\n if d.get(\"employer\"):\n d[\"employer_jobs\"] = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\"\n \" AND quarantine IS NULL AND employer=?\",\n (d[\"employer\"],)).fetchone()[\"n\"]\n con.close()\n if d is None:\n raise HTTPException(404, \"Offre introuvable\")\n return d"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":1318,"cache_read_input_tokens":129848,"cache_creation":{"ephemeral_5m_input_tokens":1318,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"64c2755f-632f-40b7-9f2d-4d0e9205af08","request_id":"req_011CePcJ8i8keujMRcAzag8m"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JyihRaUMkRsmfP5pBwmY5k","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/web.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"fa2eecdb-f36d-43a5-b89e-d1b25af3f7b9","timestamp":"2026-08-25T14:17:13.471Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","oldString":"@app.get(\"/api/jobs/{uid}\")\ndef get_job(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM jobs WHERE uid=?\", (uid,)).fetchone()\n d = None\n if row is not None:\n d = _row_to_dict(row)\n d[\"salary_history\"] = [dict(r) for r in con.execute(\n \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\"\n \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]\n con.close()\n if d is None:\n raise HTTPException(404, \"Offre introuvable\")\n return d","newString":"# --- contexte salarial de marché (métrique de la fiche d'offre) ---------------\n# Distribution des salaires annuels affichés par catégorie (point médian de la\n# fourchette), recalculée au plus toutes les 10 minutes — assez frais pour une\n# base resynchronisée à l'heure, et jamais de requête lourde par visite.\n_SALARY_CTX_TTL = 600.0\n_salary_ctx_cache: dict[str, tuple[float, dict | None]] = {}\n\n\ndef _salary_context(con, category: str) -> dict | None:\n \"\"\"Quartiles des salaires annuels affichés dans la catégorie (n >= 20).\"\"\"\n key = category or \"__toutes__\"\n hit = _salary_ctx_cache.get(key)\n if hit and time.time() - hit[0] < _SALARY_CTX_TTL:\n return hit[1]\n sql = (\"SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))\"\n \" / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL\"\n \" AND quarantine IS NULL AND salary_year_min IS NOT NULL\")\n args: list = []\n if category:\n sql += \" AND category=?\"; args.append(category)\n vals = sorted(r[\"v\"] for r in con.execute(sql, args))\n ctx = None\n if len(vals) >= 20: # trop peu d'offres = pas de médiane représentative\n def pct(p: float) -> float:\n return round(vals[min(len(vals) - 1, int(p * len(vals)))], 0)\n ctx = {\"category\": category or \"\", \"n\": len(vals),\n \"p25\": pct(0.25), \"median\": pct(0.50), \"p75\": pct(0.75)}\n _salary_ctx_cache[key] = (time.time(), ctx)\n return ctx\n\n\n@app.get(\"/api/jobs/{uid}\")\ndef get_job(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM jobs WHERE uid=?\", (uid,)).fetchone()\n d = None\n if row is not None:\n d = _row_to_dict(row)\n d[\"salary_history\"] = [dict(r) for r in con.execute(\n \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\"\n \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]\n # métriques de contexte : salaire vs marché de la catégorie + volume\n # d'offres actives de l'employeur (comparables, jamais inventées)\n ctx = _salary_context(con, d.get(\"category\") or \"\")\n if ctx and d.get(\"salary_year_min\") is not None:\n mid = (d[\"salary_year_min\"]\n + (d.get(\"salary_year_max\") or d[\"salary_year_min\"])) / 2.0\n ctx = {**ctx, \"job_mid\": round(mid, 0),\n \"delta_pct\": round((mid - ctx[\"median\"]) / ctx[\"median\"] * 100, 1)}\n d[\"salary_context\"] = ctx\n if d.get(\"employer\"):\n d[\"employer_jobs\"] = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\"\n \" AND quarantine IS NULL AND employer=?\",\n (d[\"employer\"],)).fetchone()[\"n\"]\n con.close()\n if d is None:\n raise HTTPException(404, \"Offre introuvable\")\n return d","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/web.py\n# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\nfrom __future__ import annotations\n\nimport json\nimport re\nimport secrets\nimport threading\nimport time\nfrom pathlib import Path\n\nfrom fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom fastapi.staticfiles import StaticFiles\nfrom pydantic import BaseModel, Field\n\nfrom . import auth, db, hubfav, ingest\nfrom .dedup import AGGREGATORS\nfrom .schema import JobPosting\n\nROOT = Path(__file__).resolve().parent.parent\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n\napp = FastAPI(title=\"Job-Ka API\", version=\"1.0\",\n description=\"Agrégateur d'offres d'emploi — employeurs québécois, \"\n \"directement à la source\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n# connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py\napp.include_router(auth.router)\n\n_sync_lock = threading.Lock()\n\n\ndef _row_to_dict(row) -> dict:\n d = dict(row)\n d[\"benefits\"] = json.loads(d.get(\"benefits\") or \"[]\")\n d[\"requirements\"] = json.loads(d.get(\"requirements\") or \"{}\")\n d[\"details\"] = json.loads(d.get(\"details\") or \"{}\")\n if d.get(\"dup_sources\"):\n try:\n d[\"dup_sources\"] = json.loads(d[\"dup_sources\"])\n except (ValueError, TypeError):\n d[\"dup_sources\"] = []\n # offre directe = page carrière de l'employeur ou dépôt direct (≠ portail)\n d[\"is_direct\"] = d.get(\"source\") not in AGGREGATORS\n return d\n\n\n@app.get(\"/health\")\ndef health():\n \"\"\"Healthcheck de déploiement : BD accessible + fraîcheur des syncs.\"\"\"\n try:\n con = db.connect()\n total = con.execute(\n \"SELECT COUNT(*) c FROM jobs WHERE active=1\").fetchone()[\"c\"]\n last = con.execute(\n \"SELECT MAX(ts) ts FROM sync_log WHERE ok=1\").fetchone()[\"ts\"]\n con.close()\n except Exception as exc:\n raise HTTPException(500, f\"BD inaccessible : {exc}\")\n return {\"status\": \"ok\", \"active_jobs\": total,\n \"last_sync_age_s\": round(time.time() - last) if last else None}\n\n\n@app.get(\"/api/jobs\")\ndef list_jobs(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n employer: str | None = None,\n source: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n seniority: str | None = None, # stage | junior | intermediaire | senior | direction\n salary_min: float | None = None, # $/an (converti côté serveur)\n with_salary: int | None = None, # 1 = transparence salariale seulement\n posted_after: str | None = None, # ISO : offres publiées depuis…\n language: str | None = None, # fr | en | bilingue\n q: str | None = None,\n active: int = 1,\n quarantined: int | None = None, # 1 = quarantaine seulement (supervision)\n sort: str = \"recent\", # recent | salary\n limit: int = Query(100, le=2000),\n offset: int = 0,\n):\n con = db.connect()\n sql = \"SELECT * FROM jobs WHERE dup_of IS NULL\" # doublons masqués\n args: list = []\n # quarantaine qualité : jamais publiée par défaut (voir jobka/quality.py)\n sql += \" AND quarantine IS NOT NULL\" if quarantined == 1 \\\n else \" AND quarantine IS NULL\"\n if active in (0, 1):\n sql += \" AND active=?\"; args.append(active)\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if language:\n sql += \" AND language=?\"; args.append(language)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if employer:\n sql += \" AND employer LIKE ?\"; args.append(f\"%{employer}%\")\n if source:\n sql += \" AND source=?\"; args.append(source)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if seniority:\n sql += \" AND seniority=?\"; args.append(seniority)\n if salary_min is not None:\n sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if posted_after:\n sql += \" AND date_posted IS NOT NULL AND date_posted>=?\"\n args.append(posted_after)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ? OR description LIKE ?)\"\n args += [f\"%{q}%\"] * 3\n total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n if sort == \"salary\":\n sql += \" ORDER BY salary_year_max IS NULL, salary_year_max DESC\"\n else:\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC\"\n sql += \" LIMIT ? OFFSET ?\"\n args += [limit, offset]\n rows = []\n for r in con.execute(sql, args).fetchall():\n d = _row_to_dict(r)\n d.pop(\"description\", None) # liste allégée : la fiche a le texte complet\n rows.append(d)\n con.close()\n return {\"total\": total, \"count\": len(rows), \"jobs\": rows}\n\n\n@app.get(\"/api/jobs.geojson\")\ndef jobs_geojson(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n salary_min: float | None = None,\n with_salary: int | None = None,\n language: str | None = None,\n q: str | None = None,\n bbox: str | None = None,\n limit: int = Query(3000, le=8000),\n):\n \"\"\"Offres géolocalisées au format GeoJSON (mêmes filtres que /api/jobs).\n\n `bbox=ouest,sud,est,nord` restreint au rectangle visible (idx_jobs_geo).\n Membres additionnels : `totalGeocoded` / `totalMatching` pour afficher\n « N résultats sans position sur la carte ».\n \"\"\"\n con = db.connect()\n sql = (\"SELECT uid, title, title_clean, employer, city, category, work_mode,\"\n \" employment_type, salary_min, salary_max, salary_unit,\"\n \" salary_year_min, salary_year_max, date_posted, source, lat, lng\"\n \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n \" AND lat IS NOT NULL AND lng IS NOT NULL\")\n args: list = []\n if bbox:\n try:\n west, south, east, north = (float(v) for v in bbox.split(\",\"))\n except ValueError:\n raise HTTPException(400, \"bbox attendu : ouest,sud,est,nord\")\n sql += \" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\"\n args += [south, north, west, east]\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += \" AND salary_year_max IS NOT NULL AND salary_year_max>=?\"\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if language:\n sql += \" AND language=?\"; args.append(language)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ?)\"\n args += [f\"%{q}%\"] * 2\n\n total_geo = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n sql_all = sql.replace(\" AND lat IS NOT NULL AND lng IS NOT NULL\", \"\")\n args_all = list(args)\n if bbox:\n sql_all = sql_all.replace(\" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\", \"\")\n del args_all[0:4]\n total_all = con.execute(f\"SELECT COUNT(*) c FROM ({sql_all})\", args_all).fetchone()[\"c\"]\n\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC LIMIT ?\"\n args.append(limit)\n\n features = []\n for r in con.execute(sql, args).fetchall():\n features.append({\n \"type\": \"Feature\",\n \"geometry\": {\"type\": \"Point\", \"coordinates\": [r[\"lng\"], r[\"lat\"]]},\n \"properties\": {\n \"uid\": r[\"uid\"], \"title\": r[\"title_clean\"] or r[\"title\"],\n \"employer\": r[\"employer\"],\n \"city\": r[\"city\"], \"category\": r[\"category\"],\n \"work_mode\": r[\"work_mode\"],\n \"employment_type\": r[\"employment_type\"],\n \"salary_min\": r[\"salary_min\"], \"salary_max\": r[\"salary_max\"],\n \"salary_unit\": r[\"salary_unit\"],\n \"salary_year_min\": r[\"salary_year_min\"],\n \"salary_year_max\": r[\"salary_year_max\"],\n \"date_posted\": r[\"date_posted\"], \"source\": r[\"source\"],\n \"is_direct\": r[\"source\"] not in AGGREGATORS,\n },\n })\n con.close()\n return {\"type\": \"FeatureCollection\", \"features\": features,\n \"totalGeocoded\": total_geo, \"totalMatching\": total_all}\n\n\n@app.get(\"/api/jobs/{uid}\")\ndef get_job(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM jobs WHERE uid=?\", (uid,)).fetchone()\n d = None\n if row is not None:\n d = _row_to_dict(row)\n d[\"salary_history\"] = [dict(r) for r in con.execute(\n \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\"\n \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]\n con.close()\n if d is None:\n raise HTTPException(404, \"Offre introuvable\")\n return d\n\n\n@app.get(\"/api/facets\")\ndef facets(city: str | None = None):\n \"\"\"Valeurs distinctes pour construire les filtres du frontend.\"\"\"\n con = db.connect()\n base = \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n out = {\n \"cities\": [r[\"city\"] for r in con.execute(\n f\"SELECT DISTINCT city{base} AND city<>'' ORDER BY city\")],\n # régions administratives (17) — le repli générique « Québec »\n # (appartenance provinciale sans région précise) n'est pas une facette\n \"regions\": [dict(r) for r in con.execute(\n f\"SELECT region, COUNT(*) n{base} AND region<>''\"\n \" AND region<>'Québec' GROUP BY region ORDER BY region\")],\n \"languages\": [dict(r) for r in con.execute(\n f\"SELECT language, COUNT(*) n{base} AND language IS NOT NULL\"\n \" AND language<>'' GROUP BY language ORDER BY n DESC\")],\n \"categories\": [dict(r) for r in con.execute(\n f\"SELECT category, COUNT(*) n{base} AND category<>''\"\n \" GROUP BY category ORDER BY n DESC\")],\n \"employers\": [dict(r) for r in con.execute(\n f\"SELECT employer, COUNT(*) n{base} AND employer<>''\"\n \" GROUP BY employer ORDER BY n DESC LIMIT 100\")],\n \"work_modes\": [r[\"work_mode\"] for r in con.execute(\n f\"SELECT DISTINCT work_mode{base} AND work_mode IS NOT NULL\"\n \" ORDER BY work_mode\")],\n \"employment_types\": [r[\"employment_type\"] for r in con.execute(\n f\"SELECT DISTINCT employment_type{base}\"\n \" AND employment_type IS NOT NULL ORDER BY employment_type\")],\n \"sources\": [dict(r) for r in con.execute(\n f\"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC\")],\n }\n con.close()\n return out\n\n\n@app.get(\"/api/sources\")\ndef sources():\n registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n con = db.connect()\n counts = {r[\"source\"]: r[\"n\"] for r in con.execute(\n \"SELECT source, COUNT(*) n FROM jobs WHERE active=1 GROUP BY source\")}\n last = {r[\"source\"]: r[\"ts\"] for r in con.execute(\n \"SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source\")}\n con.close()\n for s in registry:\n s[\"active_jobs\"] = counts.get(s[\"id\"], 0)\n s[\"last_sync\"] = last.get(s[\"id\"])\n return {\"sources\": registry}\n\n\n@app.get(\"/api/stats\")\ndef stats(syncs_since_h: float | None = None):\n con = db.connect()\n row = con.execute(\n \"\"\"SELECT COUNT(*) total,\n COUNT(DISTINCT employer) employers,\n COUNT(DISTINCT source) sources,\n SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary,\n AVG(salary_year_min) avg_salary_year,\n SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote,\n SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND quarantine IS NULL\"\"\").fetchone()\n quarantined = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\"\n ).fetchone()[\"n\"]\n cities = [dict(r) for r in con.execute(\n \"\"\"SELECT city, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND city<>''\n GROUP BY city ORDER BY n DESC LIMIT 15\"\"\")]\n categories = [dict(r) for r in con.execute(\n \"\"\"SELECT category, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND category<>''\n GROUP BY category ORDER BY n DESC\"\"\")]\n # Fenêtre élargie pour la supervision (api-ka) : sans le paramètre, on\n # garde les 20 dernières entrées ; avec, toute l'activité de la fenêtre\n # (sinon le moniteur bi-horaire ne voit qu'une fraction de la rotation\n # des sources → fausses alertes « stale »).\n if syncs_since_h and syncs_since_h > 0:\n cutoff = time.time() - syncs_since_h * 3600\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000\",\n (cutoff,))]\n if len(log) < 20:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n else:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n # offres directes (pages carrières + dépôt direct) c. portails agrégateurs\n agg = sorted(AGGREGATORS) or [\"__aucun__\"]\n ph = \",\".join(\"?\" * len(agg))\n direct = con.execute(\n f\"\"\"SELECT COUNT(*) n, COUNT(DISTINCT employer) e,\n COUNT(DISTINCT source) s\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source NOT IN ({ph})\"\"\", agg).fetchone()\n portal = con.execute(\n f\"\"\"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source IN ({ph})\"\"\", agg).fetchone()\n dups = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1\"\n \" AND dup_of IS NOT NULL\").fetchone()\n con.close()\n return {**dict(row), \"top_cities\": cities, \"categories\": categories,\n \"direct_jobs\": direct[\"n\"], \"direct_employers\": direct[\"e\"],\n \"direct_sources\": direct[\"s\"], \"portal_jobs\": portal[\"n\"],\n \"duplicates_hidden\": dups[\"n\"], \"quarantined\": quarantined,\n \"recent_syncs\": log}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"Tableau de bord analytique — contrat commun Groupe KA (SPEC.md).\n Cache mémoire 5 min par clé de période (voir jobka/statsdash.py).\"\"\"\n from . import statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n return statsdash.compute(period, from_, to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None,\n mode: str = \"complet\"):\n \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes : complet | synthese |\n tendances | repartitions | donnees (mode inconnu -> complet).\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n if mode not in kapdf.REPORT_MODES:\n mode = \"complet\"\n dash = statsdash.compute(period, from_, to)\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash,\n mode=mode,\n ).build()\n fname = kapdf.filename(\"job-ka\", period, mode)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/stats/catalog\")\ndef stats_catalog(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"v3 — blocs composables pour le constructeur de rapports personnalisés.\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, from_, to)\n return {\"updated\": dash.get(\"updated\"), \"period\": dash.get(\"period\"),\n \"blocks\": kapdf.catalog(dash)}\n\n\n@app.post(\"/api/stats/report/custom\")\ndef stats_report_custom(spec: dict = Body(...)):\n \"\"\"v3 — rapport PDF personnalisé : {\"title\", \"period\", \"from\", \"to\",\n \"blocks\": [{\"key\": \"series:…\", \"render\": \"bar\"}, …]} (SPEC.md §3bis).\"\"\"\n from . import kapdf, statsdash\n period = str(spec.get(\"period\") or \"30j\")\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, str(spec.get(\"from\") or \"\") or None,\n str(spec.get(\"to\") or \"\") or None)\n known = {b[\"key\"] for b in kapdf.catalog(dash)}\n blocks = [b for b in (spec.get(\"blocks\") or [])\n if isinstance(b, dict) and b.get(\"key\") in known][:40]\n if not blocks:\n raise HTTPException(400, \"Aucun bloc valide dans la composition\")\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash, mode=kapdf.CUSTOM_MODE,\n spec={\"title\": str(spec.get(\"title\") or \"\")[:80], \"blocks\": blocks},\n ).build()\n fname = kapdf.filename(\"job-ka\", period, kapdf.CUSTOM_MODE)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n# --- Dépôt direct d'offres par les employeurs ---------------------------------\nclass OffreDeposee(BaseModel):\n \"\"\"Offre soumise par un employeur (validation minimale, modérée ensuite).\"\"\"\n\n employer: str = Field(min_length=2, max_length=120)\n title: str = Field(min_length=3, max_length=160)\n description: str = Field(min_length=30, max_length=20000)\n contact_email: str = Field(min_length=5, max_length=160)\n city: str = Field(\"\", max_length=80)\n url: str = Field(\"\", max_length=400)\n salary_label: str = Field(\"\", max_length=120)\n employment_type: str | None = None\n work_mode: str | None = None\n website: str = \"\" # pot de miel anti-robots : doit rester vide\n\n\n@app.post(\"/api/employeurs/offres\")\ndef deposer_offre(offre: OffreDeposee):\n \"\"\"Canal de dépôt direct : l'offre est stockée INACTIVE (source\n « depot-direct ») en attente de modération manuelle. Voir /employeurs.\"\"\"\n if offre.website: # robot pris au pot de miel\n return {\"status\": \"reçu\"}\n if not re.match(r\"^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$\", offre.contact_email):\n raise HTTPException(422, \"Courriel de contact invalide\")\n if offre.url and not re.match(r\"^https?://\", offre.url):\n raise HTTPException(422, \"Lien invalide : http(s):// requis\")\n if offre.employment_type and offre.employment_type not in (\n \"temps_plein\", \"temps_partiel\", \"contractuel\", \"stage\", \"saisonnier\"):\n raise HTTPException(422, \"Type d'emploi inconnu\")\n if offre.work_mode and offre.work_mode not in (\n \"presentiel\", \"hybride\", \"teletravail\"):\n raise HTTPException(422, \"Mode de travail inconnu\")\n\n job = JobPosting(\n source=\"depot-direct\", external_id=secrets.token_hex(6),\n url=offre.url, employer=offre.employer, title=offre.title,\n description=offre.description, city=offre.city,\n salary_label=offre.salary_label,\n employment_type=offre.employment_type, work_mode=offre.work_mode,\n ats=\"depot-direct\",\n )\n job.details[\"contact_email\"] = offre.contact_email\n job.details[\"moderation\"] = \"en_attente\"\n job.finalize()\n\n now = time.time()\n cols = {\n \"uid\": job.uid, \"source\": job.source, \"external_id\": job.external_id,\n \"url\": job.url, \"employer\": job.employer, \"title\": job.title,\n \"description\": job.description, \"city\": job.city,\n \"region\": job.region, \"postal_code\": job.postal_code,\n \"location_label\": job.location_label, \"work_mode\": job.work_mode,\n \"employment_type\": job.employment_type,\n \"salary_min\": job.salary_min, \"salary_max\": job.salary_max,\n \"salary_unit\": job.salary_unit, \"salary_label\": job.salary_label,\n \"salary_year_min\": job.salary_year_min(),\n \"salary_year_max\": job.salary_year_max(),\n \"salary_hour_min\": job.salary_hour_min(),\n \"salary_hour_max\": job.salary_hour_max(),\n \"benefits\": json.dumps(job.benefits, ensure_ascii=False),\n \"requirements\": json.dumps(job.requirements, ensure_ascii=False),\n \"category\": job.category, \"ats\": job.ats,\n \"details\": json.dumps(job.details, ensure_ascii=False),\n \"content_hash\": job.content_hash(),\n \"first_seen\": now, \"last_seen\": now, \"updated_at\": now,\n \"miss_count\": 0, \"active\": 0, # inactive tant que non modérée\n }\n con = db.connect()\n con.execute(\n f\"INSERT INTO jobs ({', '.join(cols)}) VALUES \"\n f\"({', '.join(':' + k for k in cols)})\", cols)\n con.commit()\n con.close()\n return {\"status\": \"reçu\", \"uid\": job.uid,\n \"message\": \"Merci ! Votre offre a bien été reçue. Elle sera \"\n \"vérifiée par notre équipe avant publication.\"}\n\n\n@app.post(\"/api/sync\")\ndef trigger_sync(background: BackgroundTasks, source: str | None = None):\n \"\"\"Déclenche une synchronisation (équivalent d'un webhook entrant).\"\"\"\n def _job():\n with _sync_lock:\n ingest.run([source] if source else None)\n background.add_task(_job)\n return {\"status\": \"démarré\", \"source\": source or \"toutes\"}\n\n\n# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ------\n@app.get(\"/api/favorites\")\ndef favorites(request: Request):\n \"\"\"Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n items = hubfav.hub_list(user.get(\"ka_id\") or \"\")\n if items is None:\n raise HTTPException(502, \"Hub Groupe KA injoignable — réessayez\")\n return {\"ids\": [i[\"item_id\"] for i in items if i.get(\"item_id\")],\n \"items\": items}\n\n\n@app.post(\"/api/favorites/toggle\")\ndef toggle_favorite(request: Request, body: dict = Body(...)):\n \"\"\"Ajoute (on=true) ou retire (on=false) un favori — poussé au hub\n Groupe KA de façon synchrone : le hub est la seule source de vérité.\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n ka_id = user.get(\"ka_id\") or \"\"\n if not hubfav.linked(ka_id):\n raise HTTPException(403, \"Compte non relié au hub Groupe KA\")\n on = bool(body.get(\"on\"))\n item = hubfav.clean_item(body.get(\"item\") or {})\n if not item.get(\"item_id\"):\n raise HTTPException(422, \"item.item_id requis\")\n if not hubfav.hub_toggle(ka_id, \"add\" if on else \"remove\", item):\n raise HTTPException(502, \"Hub Groupe KA injoignable — favori non enregistré\")\n return {\"ok\": True, \"on\": on}\n\n\n# --- Frontend (build Vite) ---------------------------------------------------\nif FRONTEND_DIST.exists():\n app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIST / \"assets\"), name=\"assets\")\n\n # SEO : SSR léger des routes publiques (accueil, fiches emploi, pages\n # statiques), robots.txt et sitemaps — DOIT être inclus AVANT le\n # rattrape-tout SPA ci-dessous (l'ordre d'enregistrement fait foi).\n from . import seo # noqa: E402\n app.include_router(seo.router)\n\n @app.middleware(\"http\")\n async def _cache_headers(request, call_next):\n \"\"\"Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé —\n sinon les navigateurs gardent une vieille version après un déploiement.\"\"\"\n resp = await call_next(request)\n path = request.url.path\n if path.startswith(\"/assets/\"):\n resp.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n elif \"text/html\" in (resp.headers.get(\"content-type\") or \"\"):\n resp.headers[\"Cache-Control\"] = \"no-cache\"\n return resp\n\n # routes rendues uniquement côté client — tout autre chemin inconnu renvoie\n # index.html avec un statut 404 (pas de soft-404 pour les moteurs)\n _CLIENT_ROUTES = {\"carte\", \"sources\", \"stats\", \"contact\", \"employeurs\",\n \"favoris\", \"confidentialite\", \"conditions\"}\n _CLIENT_PREFIXES = (\"emploi/\",)\n\n @app.get(\"/{full_path:path}\")\n def spa(full_path: str):\n target = FRONTEND_DIST / full_path\n if full_path and target.is_file():\n return FileResponse(target)\n if full_path and target.is_dir() and (target / \"index.html\").is_file():\n # sous-répertoire statique avec index (ex. /doc/) — servi tel quel\n return FileResponse(target / \"index.html\")\n known = (full_path == \"\" or full_path in _CLIENT_ROUTES\n or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))\n return FileResponse(FRONTEND_DIST / \"index.html\",\n status_code=200 if known else 404,\n headers={\"Cache-Control\": \"no-cache\"})\n","structuredPatch":[{"oldStart":238,"oldLines":6,"newStart":238,"newLines":37,"lines":[" \"totalGeocoded\": total_geo, \"totalMatching\": total_all}"," "," ","+# --- contexte salarial de marché (métrique de la fiche d'offre) ---------------","+# Distribution des salaires annuels affichés par catégorie (point médian de la","+# fourchette), recalculée au plus toutes les 10 minutes — assez frais pour une","+# base resynchronisée à l'heure, et jamais de requête lourde par visite.","+_SALARY_CTX_TTL = 600.0","+_salary_ctx_cache: dict[str, tuple[float, dict | None]] = {}","+","+","+def _salary_context(con, category: str) -> dict | None:","+ \"\"\"Quartiles des salaires annuels affichés dans la catégorie (n >= 20).\"\"\"","+ key = category or \"__toutes__\"","+ hit = _salary_ctx_cache.get(key)","+ if hit and time.time() - hit[0] < _SALARY_CTX_TTL:","+ return hit[1]","+ sql = (\"SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))\"","+ \" / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL\"","+ \" AND quarantine IS NULL AND salary_year_min IS NOT NULL\")","+ args: list = []","+ if category:","+ sql += \" AND category=?\"; args.append(category)","+ vals = sorted(r[\"v\"] for r in con.execute(sql, args))","+ ctx = None","+ if len(vals) >= 20: # trop peu d'offres = pas de médiane représentative","+ def pct(p: float) -> float:","+ return round(vals[min(len(vals) - 1, int(p * len(vals)))], 0)","+ ctx = {\"category\": category or \"\", \"n\": len(vals),","+ \"p25\": pct(0.25), \"median\": pct(0.50), \"p75\": pct(0.75)}","+ _salary_ctx_cache[key] = (time.time(), ctx)","+ return ctx","+","+"," @app.get(\"/api/jobs/{uid}\")"," def get_job(uid: str):"," con = db.connect()"]},{"oldStart":248,"oldLines":6,"newStart":279,"newLines":20,"lines":[" d[\"salary_history\"] = [dict(r) for r in con.execute("," \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\""," \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]","+ # métriques de contexte : salaire vs marché de la catégorie + volume","+ # d'offres actives de l'employeur (comparables, jamais inventées)","+ ctx = _salary_context(con, d.get(\"category\") or \"\")","+ if ctx and d.get(\"salary_year_min\") is not None:","+ mid = (d[\"salary_year_min\"]","+ + (d.get(\"salary_year_max\") or d[\"salary_year_min\"])) / 2.0","+ ctx = {**ctx, \"job_mid\": round(mid, 0),","+ \"delta_pct\": round((mid - ctx[\"median\"]) / ctx[\"median\"] * 100, 1)}","+ d[\"salary_context\"] = ctx","+ if d.get(\"employer\"):","+ d[\"employer_jobs\"] = con.execute(","+ \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\"","+ \" AND quarantine IS NULL AND employer=?\",","+ (d[\"employer\"],)).fetchone()[\"n\"]"," con.close()"," if d is None:"," raise HTTPException(404, \"Offre introuvable\")"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcJBCTCxFqUxhU8aF6C","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Qq9y6XvT5w7x1bPMsP5MRz","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","old_string":" \"employment_types\": [r[\"employment_type\"] for r in con.execute(\n f\"SELECT DISTINCT employment_type{base}\"\n \" AND employment_type IS NOT NULL ORDER BY employment_type\")],","new_string":" \"employment_types\": [r[\"employment_type\"] for r in con.execute(\n f\"SELECT DISTINCT employment_type{base}\"\n \" AND employment_type IS NOT NULL ORDER BY employment_type\")],\n \"seniorities\": [dict(r) for r in con.execute(\n f\"SELECT seniority, COUNT(*) n{base} AND seniority IS NOT NULL\"\n \" GROUP BY seniority ORDER BY n DESC\")],"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":1318,"cache_read_input_tokens":129848,"cache_creation":{"ephemeral_5m_input_tokens":1318,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"6e4e8544-4a74-4065-b359-52f09dc0b68e","request_id":"req_011CePcJ8i8keujMRcAzag8m"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Qq9y6XvT5w7x1bPMsP5MRz","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/web.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"f3f4f458-583a-4d36-8b76-2b6ac075b009","timestamp":"2026-08-25T14:17:16.307Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","oldString":" \"employment_types\": [r[\"employment_type\"] for r in con.execute(\n f\"SELECT DISTINCT employment_type{base}\"\n \" AND employment_type IS NOT NULL ORDER BY employment_type\")],","newString":" \"employment_types\": [r[\"employment_type\"] for r in con.execute(\n f\"SELECT DISTINCT employment_type{base}\"\n \" AND employment_type IS NOT NULL ORDER BY employment_type\")],\n \"seniorities\": [dict(r) for r in con.execute(\n f\"SELECT seniority, COUNT(*) n{base} AND seniority IS NOT NULL\"\n \" GROUP BY seniority ORDER BY n DESC\")],","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/web.py\n# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\nfrom __future__ import annotations\n\nimport json\nimport re\nimport secrets\nimport threading\nimport time\nfrom pathlib import Path\n\nfrom fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom fastapi.staticfiles import StaticFiles\nfrom pydantic import BaseModel, Field\n\nfrom . import auth, db, hubfav, ingest\nfrom .dedup import AGGREGATORS\nfrom .schema import JobPosting\n\nROOT = Path(__file__).resolve().parent.parent\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n\napp = FastAPI(title=\"Job-Ka API\", version=\"1.0\",\n description=\"Agrégateur d'offres d'emploi — employeurs québécois, \"\n \"directement à la source\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n# connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py\napp.include_router(auth.router)\n\n_sync_lock = threading.Lock()\n\n\ndef _row_to_dict(row) -> dict:\n d = dict(row)\n d[\"benefits\"] = json.loads(d.get(\"benefits\") or \"[]\")\n d[\"requirements\"] = json.loads(d.get(\"requirements\") or \"{}\")\n d[\"details\"] = json.loads(d.get(\"details\") or \"{}\")\n if d.get(\"dup_sources\"):\n try:\n d[\"dup_sources\"] = json.loads(d[\"dup_sources\"])\n except (ValueError, TypeError):\n d[\"dup_sources\"] = []\n # offre directe = page carrière de l'employeur ou dépôt direct (≠ portail)\n d[\"is_direct\"] = d.get(\"source\") not in AGGREGATORS\n return d\n\n\n@app.get(\"/health\")\ndef health():\n \"\"\"Healthcheck de déploiement : BD accessible + fraîcheur des syncs.\"\"\"\n try:\n con = db.connect()\n total = con.execute(\n \"SELECT COUNT(*) c FROM jobs WHERE active=1\").fetchone()[\"c\"]\n last = con.execute(\n \"SELECT MAX(ts) ts FROM sync_log WHERE ok=1\").fetchone()[\"ts\"]\n con.close()\n except Exception as exc:\n raise HTTPException(500, f\"BD inaccessible : {exc}\")\n return {\"status\": \"ok\", \"active_jobs\": total,\n \"last_sync_age_s\": round(time.time() - last) if last else None}\n\n\n@app.get(\"/api/jobs\")\ndef list_jobs(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n employer: str | None = None,\n source: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n seniority: str | None = None, # stage | junior | intermediaire | senior | direction\n salary_min: float | None = None, # $/an (converti côté serveur)\n with_salary: int | None = None, # 1 = transparence salariale seulement\n posted_after: str | None = None, # ISO : offres publiées depuis…\n language: str | None = None, # fr | en | bilingue\n q: str | None = None,\n active: int = 1,\n quarantined: int | None = None, # 1 = quarantaine seulement (supervision)\n sort: str = \"recent\", # recent | salary\n limit: int = Query(100, le=2000),\n offset: int = 0,\n):\n con = db.connect()\n sql = \"SELECT * FROM jobs WHERE dup_of IS NULL\" # doublons masqués\n args: list = []\n # quarantaine qualité : jamais publiée par défaut (voir jobka/quality.py)\n sql += \" AND quarantine IS NOT NULL\" if quarantined == 1 \\\n else \" AND quarantine IS NULL\"\n if active in (0, 1):\n sql += \" AND active=?\"; args.append(active)\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if language:\n sql += \" AND language=?\"; args.append(language)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if employer:\n sql += \" AND employer LIKE ?\"; args.append(f\"%{employer}%\")\n if source:\n sql += \" AND source=?\"; args.append(source)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if seniority:\n sql += \" AND seniority=?\"; args.append(seniority)\n if salary_min is not None:\n sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if posted_after:\n sql += \" AND date_posted IS NOT NULL AND date_posted>=?\"\n args.append(posted_after)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ? OR description LIKE ?)\"\n args += [f\"%{q}%\"] * 3\n total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n if sort == \"salary\":\n sql += \" ORDER BY salary_year_max IS NULL, salary_year_max DESC\"\n else:\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC\"\n sql += \" LIMIT ? OFFSET ?\"\n args += [limit, offset]\n rows = []\n for r in con.execute(sql, args).fetchall():\n d = _row_to_dict(r)\n d.pop(\"description\", None) # liste allégée : la fiche a le texte complet\n rows.append(d)\n con.close()\n return {\"total\": total, \"count\": len(rows), \"jobs\": rows}\n\n\n@app.get(\"/api/jobs.geojson\")\ndef jobs_geojson(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n salary_min: float | None = None,\n with_salary: int | None = None,\n language: str | None = None,\n q: str | None = None,\n bbox: str | None = None,\n limit: int = Query(3000, le=8000),\n):\n \"\"\"Offres géolocalisées au format GeoJSON (mêmes filtres que /api/jobs).\n\n `bbox=ouest,sud,est,nord` restreint au rectangle visible (idx_jobs_geo).\n Membres additionnels : `totalGeocoded` / `totalMatching` pour afficher\n « N résultats sans position sur la carte ».\n \"\"\"\n con = db.connect()\n sql = (\"SELECT uid, title, title_clean, employer, city, category, work_mode,\"\n \" employment_type, salary_min, salary_max, salary_unit,\"\n \" salary_year_min, salary_year_max, date_posted, source, lat, lng\"\n \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n \" AND lat IS NOT NULL AND lng IS NOT NULL\")\n args: list = []\n if bbox:\n try:\n west, south, east, north = (float(v) for v in bbox.split(\",\"))\n except ValueError:\n raise HTTPException(400, \"bbox attendu : ouest,sud,est,nord\")\n sql += \" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\"\n args += [south, north, west, east]\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += \" AND salary_year_max IS NOT NULL AND salary_year_max>=?\"\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if language:\n sql += \" AND language=?\"; args.append(language)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ?)\"\n args += [f\"%{q}%\"] * 2\n\n total_geo = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n sql_all = sql.replace(\" AND lat IS NOT NULL AND lng IS NOT NULL\", \"\")\n args_all = list(args)\n if bbox:\n sql_all = sql_all.replace(\" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\", \"\")\n del args_all[0:4]\n total_all = con.execute(f\"SELECT COUNT(*) c FROM ({sql_all})\", args_all).fetchone()[\"c\"]\n\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC LIMIT ?\"\n args.append(limit)\n\n features = []\n for r in con.execute(sql, args).fetchall():\n features.append({\n \"type\": \"Feature\",\n \"geometry\": {\"type\": \"Point\", \"coordinates\": [r[\"lng\"], r[\"lat\"]]},\n \"properties\": {\n \"uid\": r[\"uid\"], \"title\": r[\"title_clean\"] or r[\"title\"],\n \"employer\": r[\"employer\"],\n \"city\": r[\"city\"], \"category\": r[\"category\"],\n \"work_mode\": r[\"work_mode\"],\n \"employment_type\": r[\"employment_type\"],\n \"salary_min\": r[\"salary_min\"], \"salary_max\": r[\"salary_max\"],\n \"salary_unit\": r[\"salary_unit\"],\n \"salary_year_min\": r[\"salary_year_min\"],\n \"salary_year_max\": r[\"salary_year_max\"],\n \"date_posted\": r[\"date_posted\"], \"source\": r[\"source\"],\n \"is_direct\": r[\"source\"] not in AGGREGATORS,\n },\n })\n con.close()\n return {\"type\": \"FeatureCollection\", \"features\": features,\n \"totalGeocoded\": total_geo, \"totalMatching\": total_all}\n\n\n# --- contexte salarial de marché (métrique de la fiche d'offre) ---------------\n# Distribution des salaires annuels affichés par catégorie (point médian de la\n# fourchette), recalculée au plus toutes les 10 minutes — assez frais pour une\n# base resynchronisée à l'heure, et jamais de requête lourde par visite.\n_SALARY_CTX_TTL = 600.0\n_salary_ctx_cache: dict[str, tuple[float, dict | None]] = {}\n\n\ndef _salary_context(con, category: str) -> dict | None:\n \"\"\"Quartiles des salaires annuels affichés dans la catégorie (n >= 20).\"\"\"\n key = category or \"__toutes__\"\n hit = _salary_ctx_cache.get(key)\n if hit and time.time() - hit[0] < _SALARY_CTX_TTL:\n return hit[1]\n sql = (\"SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))\"\n \" / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL\"\n \" AND quarantine IS NULL AND salary_year_min IS NOT NULL\")\n args: list = []\n if category:\n sql += \" AND category=?\"; args.append(category)\n vals = sorted(r[\"v\"] for r in con.execute(sql, args))\n ctx = None\n if len(vals) >= 20: # trop peu d'offres = pas de médiane représentative\n def pct(p: float) -> float:\n return round(vals[min(len(vals) - 1, int(p * len(vals)))], 0)\n ctx = {\"category\": category or \"\", \"n\": len(vals),\n \"p25\": pct(0.25), \"median\": pct(0.50), \"p75\": pct(0.75)}\n _salary_ctx_cache[key] = (time.time(), ctx)\n return ctx\n\n\n@app.get(\"/api/jobs/{uid}\")\ndef get_job(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM jobs WHERE uid=?\", (uid,)).fetchone()\n d = None\n if row is not None:\n d = _row_to_dict(row)\n d[\"salary_history\"] = [dict(r) for r in con.execute(\n \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\"\n \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]\n # métriques de contexte : salaire vs marché de la catégorie + volume\n # d'offres actives de l'employeur (comparables, jamais inventées)\n ctx = _salary_context(con, d.get(\"category\") or \"\")\n if ctx and d.get(\"salary_year_min\") is not None:\n mid = (d[\"salary_year_min\"]\n + (d.get(\"salary_year_max\") or d[\"salary_year_min\"])) / 2.0\n ctx = {**ctx, \"job_mid\": round(mid, 0),\n \"delta_pct\": round((mid - ctx[\"median\"]) / ctx[\"median\"] * 100, 1)}\n d[\"salary_context\"] = ctx\n if d.get(\"employer\"):\n d[\"employer_jobs\"] = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\"\n \" AND quarantine IS NULL AND employer=?\",\n (d[\"employer\"],)).fetchone()[\"n\"]\n con.close()\n if d is None:\n raise HTTPException(404, \"Offre introuvable\")\n return d\n\n\n@app.get(\"/api/facets\")\ndef facets(city: str | None = None):\n \"\"\"Valeurs distinctes pour construire les filtres du frontend.\"\"\"\n con = db.connect()\n base = \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n out = {\n \"cities\": [r[\"city\"] for r in con.execute(\n f\"SELECT DISTINCT city{base} AND city<>'' ORDER BY city\")],\n # régions administratives (17) — le repli générique « Québec »\n # (appartenance provinciale sans région précise) n'est pas une facette\n \"regions\": [dict(r) for r in con.execute(\n f\"SELECT region, COUNT(*) n{base} AND region<>''\"\n \" AND region<>'Québec' GROUP BY region ORDER BY region\")],\n \"languages\": [dict(r) for r in con.execute(\n f\"SELECT language, COUNT(*) n{base} AND language IS NOT NULL\"\n \" AND language<>'' GROUP BY language ORDER BY n DESC\")],\n \"categories\": [dict(r) for r in con.execute(\n f\"SELECT category, COUNT(*) n{base} AND category<>''\"\n \" GROUP BY category ORDER BY n DESC\")],\n \"employers\": [dict(r) for r in con.execute(\n f\"SELECT employer, COUNT(*) n{base} AND employer<>''\"\n \" GROUP BY employer ORDER BY n DESC LIMIT 100\")],\n \"work_modes\": [r[\"work_mode\"] for r in con.execute(\n f\"SELECT DISTINCT work_mode{base} AND work_mode IS NOT NULL\"\n \" ORDER BY work_mode\")],\n \"employment_types\": [r[\"employment_type\"] for r in con.execute(\n f\"SELECT DISTINCT employment_type{base}\"\n \" AND employment_type IS NOT NULL ORDER BY employment_type\")],\n \"sources\": [dict(r) for r in con.execute(\n f\"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC\")],\n }\n con.close()\n return out\n\n\n@app.get(\"/api/sources\")\ndef sources():\n registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n con = db.connect()\n counts = {r[\"source\"]: r[\"n\"] for r in con.execute(\n \"SELECT source, COUNT(*) n FROM jobs WHERE active=1 GROUP BY source\")}\n last = {r[\"source\"]: r[\"ts\"] for r in con.execute(\n \"SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source\")}\n con.close()\n for s in registry:\n s[\"active_jobs\"] = counts.get(s[\"id\"], 0)\n s[\"last_sync\"] = last.get(s[\"id\"])\n return {\"sources\": registry}\n\n\n@app.get(\"/api/stats\")\ndef stats(syncs_since_h: float | None = None):\n con = db.connect()\n row = con.execute(\n \"\"\"SELECT COUNT(*) total,\n COUNT(DISTINCT employer) employers,\n COUNT(DISTINCT source) sources,\n SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary,\n AVG(salary_year_min) avg_salary_year,\n SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote,\n SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND quarantine IS NULL\"\"\").fetchone()\n quarantined = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\"\n ).fetchone()[\"n\"]\n cities = [dict(r) for r in con.execute(\n \"\"\"SELECT city, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND city<>''\n GROUP BY city ORDER BY n DESC LIMIT 15\"\"\")]\n categories = [dict(r) for r in con.execute(\n \"\"\"SELECT category, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND category<>''\n GROUP BY category ORDER BY n DESC\"\"\")]\n # Fenêtre élargie pour la supervision (api-ka) : sans le paramètre, on\n # garde les 20 dernières entrées ; avec, toute l'activité de la fenêtre\n # (sinon le moniteur bi-horaire ne voit qu'une fraction de la rotation\n # des sources → fausses alertes « stale »).\n if syncs_since_h and syncs_since_h > 0:\n cutoff = time.time() - syncs_since_h * 3600\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000\",\n (cutoff,))]\n if len(log) < 20:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n else:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n # offres directes (pages carrières + dépôt direct) c. portails agrégateurs\n agg = sorted(AGGREGATORS) or [\"__aucun__\"]\n ph = \",\".join(\"?\" * len(agg))\n direct = con.execute(\n f\"\"\"SELECT COUNT(*) n, COUNT(DISTINCT employer) e,\n COUNT(DISTINCT source) s\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source NOT IN ({ph})\"\"\", agg).fetchone()\n portal = con.execute(\n f\"\"\"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source IN ({ph})\"\"\", agg).fetchone()\n dups = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1\"\n \" AND dup_of IS NOT NULL\").fetchone()\n con.close()\n return {**dict(row), \"top_cities\": cities, \"categories\": categories,\n \"direct_jobs\": direct[\"n\"], \"direct_employers\": direct[\"e\"],\n \"direct_sources\": direct[\"s\"], \"portal_jobs\": portal[\"n\"],\n \"duplicates_hidden\": dups[\"n\"], \"quarantined\": quarantined,\n \"recent_syncs\": log}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"Tableau de bord analytique — contrat commun Groupe KA (SPEC.md).\n Cache mémoire 5 min par clé de période (voir jobka/statsdash.py).\"\"\"\n from . import statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n return statsdash.compute(period, from_, to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None,\n mode: str = \"complet\"):\n \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes : complet | synthese |\n tendances | repartitions | donnees (mode inconnu -> complet).\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n if mode not in kapdf.REPORT_MODES:\n mode = \"complet\"\n dash = statsdash.compute(period, from_, to)\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash,\n mode=mode,\n ).build()\n fname = kapdf.filename(\"job-ka\", period, mode)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/stats/catalog\")\ndef stats_catalog(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"v3 — blocs composables pour le constructeur de rapports personnalisés.\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, from_, to)\n return {\"updated\": dash.get(\"updated\"), \"period\": dash.get(\"period\"),\n \"blocks\": kapdf.catalog(dash)}\n\n\n@app.post(\"/api/stats/report/custom\")\ndef stats_report_custom(spec: dict = Body(...)):\n \"\"\"v3 — rapport PDF personnalisé : {\"title\", \"period\", \"from\", \"to\",\n \"blocks\": [{\"key\": \"series:…\", \"render\": \"bar\"}, …]} (SPEC.md §3bis).\"\"\"\n from . import kapdf, statsdash\n period = str(spec.get(\"period\") or \"30j\")\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, str(spec.get(\"from\") or \"\") or None,\n str(spec.get(\"to\") or \"\") or None)\n known = {b[\"key\"] for b in kapdf.catalog(dash)}\n blocks = [b for b in (spec.get(\"blocks\") or [])\n if isinstance(b, dict) and b.get(\"key\") in known][:40]\n if not blocks:\n raise HTTPException(400, \"Aucun bloc valide dans la composition\")\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash, mode=kapdf.CUSTOM_MODE,\n spec={\"title\": str(spec.get(\"title\") or \"\")[:80], \"blocks\": blocks},\n ).build()\n fname = kapdf.filename(\"job-ka\", period, kapdf.CUSTOM_MODE)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n# --- Dépôt direct d'offres par les employeurs ---------------------------------\nclass OffreDeposee(BaseModel):\n \"\"\"Offre soumise par un employeur (validation minimale, modérée ensuite).\"\"\"\n\n employer: str = Field(min_length=2, max_length=120)\n title: str = Field(min_length=3, max_length=160)\n description: str = Field(min_length=30, max_length=20000)\n contact_email: str = Field(min_length=5, max_length=160)\n city: str = Field(\"\", max_length=80)\n url: str = Field(\"\", max_length=400)\n salary_label: str = Field(\"\", max_length=120)\n employment_type: str | None = None\n work_mode: str | None = None\n website: str = \"\" # pot de miel anti-robots : doit rester vide\n\n\n@app.post(\"/api/employeurs/offres\")\ndef deposer_offre(offre: OffreDeposee):\n \"\"\"Canal de dépôt direct : l'offre est stockée INACTIVE (source\n « depot-direct ») en attente de modération manuelle. Voir /employeurs.\"\"\"\n if offre.website: # robot pris au pot de miel\n return {\"status\": \"reçu\"}\n if not re.match(r\"^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$\", offre.contact_email):\n raise HTTPException(422, \"Courriel de contact invalide\")\n if offre.url and not re.match(r\"^https?://\", offre.url):\n raise HTTPException(422, \"Lien invalide : http(s):// requis\")\n if offre.employment_type and offre.employment_type not in (\n \"temps_plein\", \"temps_partiel\", \"contractuel\", \"stage\", \"saisonnier\"):\n raise HTTPException(422, \"Type d'emploi inconnu\")\n if offre.work_mode and offre.work_mode not in (\n \"presentiel\", \"hybride\", \"teletravail\"):\n raise HTTPException(422, \"Mode de travail inconnu\")\n\n job = JobPosting(\n source=\"depot-direct\", external_id=secrets.token_hex(6),\n url=offre.url, employer=offre.employer, title=offre.title,\n description=offre.description, city=offre.city,\n salary_label=offre.salary_label,\n employment_type=offre.employment_type, work_mode=offre.work_mode,\n ats=\"depot-direct\",\n )\n job.details[\"contact_email\"] = offre.contact_email\n job.details[\"moderation\"] = \"en_attente\"\n job.finalize()\n\n now = time.time()\n cols = {\n \"uid\": job.uid, \"source\": job.source, \"external_id\": job.external_id,\n \"url\": job.url, \"employer\": job.employer, \"title\": job.title,\n \"description\": job.description, \"city\": job.city,\n \"region\": job.region, \"postal_code\": job.postal_code,\n \"location_label\": job.location_label, \"work_mode\": job.work_mode,\n \"employment_type\": job.employment_type,\n \"salary_min\": job.salary_min, \"salary_max\": job.salary_max,\n \"salary_unit\": job.salary_unit, \"salary_label\": job.salary_label,\n \"salary_year_min\": job.salary_year_min(),\n \"salary_year_max\": job.salary_year_max(),\n \"salary_hour_min\": job.salary_hour_min(),\n \"salary_hour_max\": job.salary_hour_max(),\n \"benefits\": json.dumps(job.benefits, ensure_ascii=False),\n \"requirements\": json.dumps(job.requirements, ensure_ascii=False),\n \"category\": job.category, \"ats\": job.ats,\n \"details\": json.dumps(job.details, ensure_ascii=False),\n \"content_hash\": job.content_hash(),\n \"first_seen\": now, \"last_seen\": now, \"updated_at\": now,\n \"miss_count\": 0, \"active\": 0, # inactive tant que non modérée\n }\n con = db.connect()\n con.execute(\n f\"INSERT INTO jobs ({', '.join(cols)}) VALUES \"\n f\"({', '.join(':' + k for k in cols)})\", cols)\n con.commit()\n con.close()\n return {\"status\": \"reçu\", \"uid\": job.uid,\n \"message\": \"Merci ! Votre offre a bien été reçue. Elle sera \"\n \"vérifiée par notre équipe avant publication.\"}\n\n\n@app.post(\"/api/sync\")\ndef trigger_sync(background: BackgroundTasks, source: str | None = None):\n \"\"\"Déclenche une synchronisation (équivalent d'un webhook entrant).\"\"\"\n def _job():\n with _sync_lock:\n ingest.run([source] if source else None)\n background.add_task(_job)\n return {\"status\": \"démarré\", \"source\": source or \"toutes\"}\n\n\n# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ------\n@app.get(\"/api/favorites\")\ndef favorites(request: Request):\n \"\"\"Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n items = hubfav.hub_list(user.get(\"ka_id\") or \"\")\n if items is None:\n raise HTTPException(502, \"Hub Groupe KA injoignable — réessayez\")\n return {\"ids\": [i[\"item_id\"] for i in items if i.get(\"item_id\")],\n \"items\": items}\n\n\n@app.post(\"/api/favorites/toggle\")\ndef toggle_favorite(request: Request, body: dict = Body(...)):\n \"\"\"Ajoute (on=true) ou retire (on=false) un favori — poussé au hub\n Groupe KA de façon synchrone : le hub est la seule source de vérité.\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n ka_id = user.get(\"ka_id\") or \"\"\n if not hubfav.linked(ka_id):\n raise HTTPException(403, \"Compte non relié au hub Groupe KA\")\n on = bool(body.get(\"on\"))\n item = hubfav.clean_item(body.get(\"item\") or {})\n if not item.get(\"item_id\"):\n raise HTTPException(422, \"item.item_id requis\")\n if not hubfav.hub_toggle(ka_id, \"add\" if on else \"remove\", item):\n raise HTTPException(502, \"Hub Groupe KA injoignable — favori non enregistré\")\n return {\"ok\": True, \"on\": on}\n\n\n# --- Frontend (build Vite) ---------------------------------------------------\nif FRONTEND_DIST.exists():\n app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIST / \"assets\"), name=\"assets\")\n\n # SEO : SSR léger des routes publiques (accueil, fiches emploi, pages\n # statiques), robots.txt et sitemaps — DOIT être inclus AVANT le\n # rattrape-tout SPA ci-dessous (l'ordre d'enregistrement fait foi).\n from . import seo # noqa: E402\n app.include_router(seo.router)\n\n @app.middleware(\"http\")\n async def _cache_headers(request, call_next):\n \"\"\"Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé —\n sinon les navigateurs gardent une vieille version après un déploiement.\"\"\"\n resp = await call_next(request)\n path = request.url.path\n if path.startswith(\"/assets/\"):\n resp.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n elif \"text/html\" in (resp.headers.get(\"content-type\") or \"\"):\n resp.headers[\"Cache-Control\"] = \"no-cache\"\n return resp\n\n # routes rendues uniquement côté client — tout autre chemin inconnu renvoie\n # index.html avec un statut 404 (pas de soft-404 pour les moteurs)\n _CLIENT_ROUTES = {\"carte\", \"sources\", \"stats\", \"contact\", \"employeurs\",\n \"favoris\", \"confidentialite\", \"conditions\"}\n _CLIENT_PREFIXES = (\"emploi/\",)\n\n @app.get(\"/{full_path:path}\")\n def spa(full_path: str):\n target = FRONTEND_DIST / full_path\n if full_path and target.is_file():\n return FileResponse(target)\n if full_path and target.is_dir() and (target / \"index.html\").is_file():\n # sous-répertoire statique avec index (ex. /doc/) — servi tel quel\n return FileResponse(target / \"index.html\")\n known = (full_path == \"\" or full_path in _CLIENT_ROUTES\n or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))\n return FileResponse(FRONTEND_DIST / \"index.html\",\n status_code=200 if known else 404,\n headers={\"Cache-Control\": \"no-cache\"})\n","structuredPatch":[{"oldStart":327,"oldLines":6,"newStart":327,"newLines":9,"lines":[" \"employment_types\": [r[\"employment_type\"] for r in con.execute("," f\"SELECT DISTINCT employment_type{base}\""," \" AND employment_type IS NOT NULL ORDER BY employment_type\")],","+ \"seniorities\": [dict(r) for r in con.execute(","+ f\"SELECT seniority, COUNT(*) n{base} AND seniority IS NOT NULL\"","+ \" GROUP BY seniority ORDER BY n DESC\")],"," \"sources\": [dict(r) for r in con.execute("," f\"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC\")],"," }"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcJBCTCxFqUxhU8aF6C","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01M3i9Fgy4rAbaSa6ndmAdwD","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","old_string":" quarantined = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\"\n ).fetchone()[\"n\"]","new_string":" quarantined = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\"\n ).fetchone()[\"n\"]\n # médiane des salaires annuels affichés (point médian des fourchettes) —\n # plus robuste que la moyenne face aux salaires de direction\n sal = sorted(r[\"v\"] for r in con.execute(\n \"\"\"SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))\n / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL\n AND quarantine IS NULL AND salary_year_min IS NOT NULL\"\"\"))\n median_salary = round(sal[len(sal) // 2], 0) if sal else None"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":1318,"cache_read_input_tokens":129848,"cache_creation":{"ephemeral_5m_input_tokens":1318,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"00f19cef-0e28-48ab-9ee2-4d5d32450fa3","request_id":"req_011CePcJ8i8keujMRcAzag8m"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01M3i9Fgy4rAbaSa6ndmAdwD","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/web.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"76cfa415-1563-4c05-a5c5-41e2d7a555b8","timestamp":"2026-08-25T14:17:20.551Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","oldString":" quarantined = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\"\n ).fetchone()[\"n\"]","newString":" quarantined = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\"\n ).fetchone()[\"n\"]\n # médiane des salaires annuels affichés (point médian des fourchettes) —\n # plus robuste que la moyenne face aux salaires de direction\n sal = sorted(r[\"v\"] for r in con.execute(\n \"\"\"SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))\n / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL\n AND quarantine IS NULL AND salary_year_min IS NOT NULL\"\"\"))\n median_salary = round(sal[len(sal) // 2], 0) if sal else None","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/web.py\n# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\nfrom __future__ import annotations\n\nimport json\nimport re\nimport secrets\nimport threading\nimport time\nfrom pathlib import Path\n\nfrom fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom fastapi.staticfiles import StaticFiles\nfrom pydantic import BaseModel, Field\n\nfrom . import auth, db, hubfav, ingest\nfrom .dedup import AGGREGATORS\nfrom .schema import JobPosting\n\nROOT = Path(__file__).resolve().parent.parent\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n\napp = FastAPI(title=\"Job-Ka API\", version=\"1.0\",\n description=\"Agrégateur d'offres d'emploi — employeurs québécois, \"\n \"directement à la source\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n# connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py\napp.include_router(auth.router)\n\n_sync_lock = threading.Lock()\n\n\ndef _row_to_dict(row) -> dict:\n d = dict(row)\n d[\"benefits\"] = json.loads(d.get(\"benefits\") or \"[]\")\n d[\"requirements\"] = json.loads(d.get(\"requirements\") or \"{}\")\n d[\"details\"] = json.loads(d.get(\"details\") or \"{}\")\n if d.get(\"dup_sources\"):\n try:\n d[\"dup_sources\"] = json.loads(d[\"dup_sources\"])\n except (ValueError, TypeError):\n d[\"dup_sources\"] = []\n # offre directe = page carrière de l'employeur ou dépôt direct (≠ portail)\n d[\"is_direct\"] = d.get(\"source\") not in AGGREGATORS\n return d\n\n\n@app.get(\"/health\")\ndef health():\n \"\"\"Healthcheck de déploiement : BD accessible + fraîcheur des syncs.\"\"\"\n try:\n con = db.connect()\n total = con.execute(\n \"SELECT COUNT(*) c FROM jobs WHERE active=1\").fetchone()[\"c\"]\n last = con.execute(\n \"SELECT MAX(ts) ts FROM sync_log WHERE ok=1\").fetchone()[\"ts\"]\n con.close()\n except Exception as exc:\n raise HTTPException(500, f\"BD inaccessible : {exc}\")\n return {\"status\": \"ok\", \"active_jobs\": total,\n \"last_sync_age_s\": round(time.time() - last) if last else None}\n\n\n@app.get(\"/api/jobs\")\ndef list_jobs(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n employer: str | None = None,\n source: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n seniority: str | None = None, # stage | junior | intermediaire | senior | direction\n salary_min: float | None = None, # $/an (converti côté serveur)\n with_salary: int | None = None, # 1 = transparence salariale seulement\n posted_after: str | None = None, # ISO : offres publiées depuis…\n language: str | None = None, # fr | en | bilingue\n q: str | None = None,\n active: int = 1,\n quarantined: int | None = None, # 1 = quarantaine seulement (supervision)\n sort: str = \"recent\", # recent | salary\n limit: int = Query(100, le=2000),\n offset: int = 0,\n):\n con = db.connect()\n sql = \"SELECT * FROM jobs WHERE dup_of IS NULL\" # doublons masqués\n args: list = []\n # quarantaine qualité : jamais publiée par défaut (voir jobka/quality.py)\n sql += \" AND quarantine IS NOT NULL\" if quarantined == 1 \\\n else \" AND quarantine IS NULL\"\n if active in (0, 1):\n sql += \" AND active=?\"; args.append(active)\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if language:\n sql += \" AND language=?\"; args.append(language)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if employer:\n sql += \" AND employer LIKE ?\"; args.append(f\"%{employer}%\")\n if source:\n sql += \" AND source=?\"; args.append(source)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if seniority:\n sql += \" AND seniority=?\"; args.append(seniority)\n if salary_min is not None:\n sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if posted_after:\n sql += \" AND date_posted IS NOT NULL AND date_posted>=?\"\n args.append(posted_after)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ? OR description LIKE ?)\"\n args += [f\"%{q}%\"] * 3\n total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n if sort == \"salary\":\n sql += \" ORDER BY salary_year_max IS NULL, salary_year_max DESC\"\n else:\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC\"\n sql += \" LIMIT ? OFFSET ?\"\n args += [limit, offset]\n rows = []\n for r in con.execute(sql, args).fetchall():\n d = _row_to_dict(r)\n d.pop(\"description\", None) # liste allégée : la fiche a le texte complet\n rows.append(d)\n con.close()\n return {\"total\": total, \"count\": len(rows), \"jobs\": rows}\n\n\n@app.get(\"/api/jobs.geojson\")\ndef jobs_geojson(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n salary_min: float | None = None,\n with_salary: int | None = None,\n language: str | None = None,\n q: str | None = None,\n bbox: str | None = None,\n limit: int = Query(3000, le=8000),\n):\n \"\"\"Offres géolocalisées au format GeoJSON (mêmes filtres que /api/jobs).\n\n `bbox=ouest,sud,est,nord` restreint au rectangle visible (idx_jobs_geo).\n Membres additionnels : `totalGeocoded` / `totalMatching` pour afficher\n « N résultats sans position sur la carte ».\n \"\"\"\n con = db.connect()\n sql = (\"SELECT uid, title, title_clean, employer, city, category, work_mode,\"\n \" employment_type, salary_min, salary_max, salary_unit,\"\n \" salary_year_min, salary_year_max, date_posted, source, lat, lng\"\n \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n \" AND lat IS NOT NULL AND lng IS NOT NULL\")\n args: list = []\n if bbox:\n try:\n west, south, east, north = (float(v) for v in bbox.split(\",\"))\n except ValueError:\n raise HTTPException(400, \"bbox attendu : ouest,sud,est,nord\")\n sql += \" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\"\n args += [south, north, west, east]\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += \" AND salary_year_max IS NOT NULL AND salary_year_max>=?\"\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if language:\n sql += \" AND language=?\"; args.append(language)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ?)\"\n args += [f\"%{q}%\"] * 2\n\n total_geo = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n sql_all = sql.replace(\" AND lat IS NOT NULL AND lng IS NOT NULL\", \"\")\n args_all = list(args)\n if bbox:\n sql_all = sql_all.replace(\" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\", \"\")\n del args_all[0:4]\n total_all = con.execute(f\"SELECT COUNT(*) c FROM ({sql_all})\", args_all).fetchone()[\"c\"]\n\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC LIMIT ?\"\n args.append(limit)\n\n features = []\n for r in con.execute(sql, args).fetchall():\n features.append({\n \"type\": \"Feature\",\n \"geometry\": {\"type\": \"Point\", \"coordinates\": [r[\"lng\"], r[\"lat\"]]},\n \"properties\": {\n \"uid\": r[\"uid\"], \"title\": r[\"title_clean\"] or r[\"title\"],\n \"employer\": r[\"employer\"],\n \"city\": r[\"city\"], \"category\": r[\"category\"],\n \"work_mode\": r[\"work_mode\"],\n \"employment_type\": r[\"employment_type\"],\n \"salary_min\": r[\"salary_min\"], \"salary_max\": r[\"salary_max\"],\n \"salary_unit\": r[\"salary_unit\"],\n \"salary_year_min\": r[\"salary_year_min\"],\n \"salary_year_max\": r[\"salary_year_max\"],\n \"date_posted\": r[\"date_posted\"], \"source\": r[\"source\"],\n \"is_direct\": r[\"source\"] not in AGGREGATORS,\n },\n })\n con.close()\n return {\"type\": \"FeatureCollection\", \"features\": features,\n \"totalGeocoded\": total_geo, \"totalMatching\": total_all}\n\n\n# --- contexte salarial de marché (métrique de la fiche d'offre) ---------------\n# Distribution des salaires annuels affichés par catégorie (point médian de la\n# fourchette), recalculée au plus toutes les 10 minutes — assez frais pour une\n# base resynchronisée à l'heure, et jamais de requête lourde par visite.\n_SALARY_CTX_TTL = 600.0\n_salary_ctx_cache: dict[str, tuple[float, dict | None]] = {}\n\n\ndef _salary_context(con, category: str) -> dict | None:\n \"\"\"Quartiles des salaires annuels affichés dans la catégorie (n >= 20).\"\"\"\n key = category or \"__toutes__\"\n hit = _salary_ctx_cache.get(key)\n if hit and time.time() - hit[0] < _SALARY_CTX_TTL:\n return hit[1]\n sql = (\"SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))\"\n \" / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL\"\n \" AND quarantine IS NULL AND salary_year_min IS NOT NULL\")\n args: list = []\n if category:\n sql += \" AND category=?\"; args.append(category)\n vals = sorted(r[\"v\"] for r in con.execute(sql, args))\n ctx = None\n if len(vals) >= 20: # trop peu d'offres = pas de médiane représentative\n def pct(p: float) -> float:\n return round(vals[min(len(vals) - 1, int(p * len(vals)))], 0)\n ctx = {\"category\": category or \"\", \"n\": len(vals),\n \"p25\": pct(0.25), \"median\": pct(0.50), \"p75\": pct(0.75)}\n _salary_ctx_cache[key] = (time.time(), ctx)\n return ctx\n\n\n@app.get(\"/api/jobs/{uid}\")\ndef get_job(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM jobs WHERE uid=?\", (uid,)).fetchone()\n d = None\n if row is not None:\n d = _row_to_dict(row)\n d[\"salary_history\"] = [dict(r) for r in con.execute(\n \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\"\n \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]\n # métriques de contexte : salaire vs marché de la catégorie + volume\n # d'offres actives de l'employeur (comparables, jamais inventées)\n ctx = _salary_context(con, d.get(\"category\") or \"\")\n if ctx and d.get(\"salary_year_min\") is not None:\n mid = (d[\"salary_year_min\"]\n + (d.get(\"salary_year_max\") or d[\"salary_year_min\"])) / 2.0\n ctx = {**ctx, \"job_mid\": round(mid, 0),\n \"delta_pct\": round((mid - ctx[\"median\"]) / ctx[\"median\"] * 100, 1)}\n d[\"salary_context\"] = ctx\n if d.get(\"employer\"):\n d[\"employer_jobs\"] = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\"\n \" AND quarantine IS NULL AND employer=?\",\n (d[\"employer\"],)).fetchone()[\"n\"]\n con.close()\n if d is None:\n raise HTTPException(404, \"Offre introuvable\")\n return d\n\n\n@app.get(\"/api/facets\")\ndef facets(city: str | None = None):\n \"\"\"Valeurs distinctes pour construire les filtres du frontend.\"\"\"\n con = db.connect()\n base = \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n out = {\n \"cities\": [r[\"city\"] for r in con.execute(\n f\"SELECT DISTINCT city{base} AND city<>'' ORDER BY city\")],\n # régions administratives (17) — le repli générique « Québec »\n # (appartenance provinciale sans région précise) n'est pas une facette\n \"regions\": [dict(r) for r in con.execute(\n f\"SELECT region, COUNT(*) n{base} AND region<>''\"\n \" AND region<>'Québec' GROUP BY region ORDER BY region\")],\n \"languages\": [dict(r) for r in con.execute(\n f\"SELECT language, COUNT(*) n{base} AND language IS NOT NULL\"\n \" AND language<>'' GROUP BY language ORDER BY n DESC\")],\n \"categories\": [dict(r) for r in con.execute(\n f\"SELECT category, COUNT(*) n{base} AND category<>''\"\n \" GROUP BY category ORDER BY n DESC\")],\n \"employers\": [dict(r) for r in con.execute(\n f\"SELECT employer, COUNT(*) n{base} AND employer<>''\"\n \" GROUP BY employer ORDER BY n DESC LIMIT 100\")],\n \"work_modes\": [r[\"work_mode\"] for r in con.execute(\n f\"SELECT DISTINCT work_mode{base} AND work_mode IS NOT NULL\"\n \" ORDER BY work_mode\")],\n \"employment_types\": [r[\"employment_type\"] for r in con.execute(\n f\"SELECT DISTINCT employment_type{base}\"\n \" AND employment_type IS NOT NULL ORDER BY employment_type\")],\n \"seniorities\": [dict(r) for r in con.execute(\n f\"SELECT seniority, COUNT(*) n{base} AND seniority IS NOT NULL\"\n \" GROUP BY seniority ORDER BY n DESC\")],\n \"sources\": [dict(r) for r in con.execute(\n f\"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC\")],\n }\n con.close()\n return out\n\n\n@app.get(\"/api/sources\")\ndef sources():\n registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n con = db.connect()\n counts = {r[\"source\"]: r[\"n\"] for r in con.execute(\n \"SELECT source, COUNT(*) n FROM jobs WHERE active=1 GROUP BY source\")}\n last = {r[\"source\"]: r[\"ts\"] for r in con.execute(\n \"SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source\")}\n con.close()\n for s in registry:\n s[\"active_jobs\"] = counts.get(s[\"id\"], 0)\n s[\"last_sync\"] = last.get(s[\"id\"])\n return {\"sources\": registry}\n\n\n@app.get(\"/api/stats\")\ndef stats(syncs_since_h: float | None = None):\n con = db.connect()\n row = con.execute(\n \"\"\"SELECT COUNT(*) total,\n COUNT(DISTINCT employer) employers,\n COUNT(DISTINCT source) sources,\n SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary,\n AVG(salary_year_min) avg_salary_year,\n SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote,\n SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND quarantine IS NULL\"\"\").fetchone()\n quarantined = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\"\n ).fetchone()[\"n\"]\n cities = [dict(r) for r in con.execute(\n \"\"\"SELECT city, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND city<>''\n GROUP BY city ORDER BY n DESC LIMIT 15\"\"\")]\n categories = [dict(r) for r in con.execute(\n \"\"\"SELECT category, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND category<>''\n GROUP BY category ORDER BY n DESC\"\"\")]\n # Fenêtre élargie pour la supervision (api-ka) : sans le paramètre, on\n # garde les 20 dernières entrées ; avec, toute l'activité de la fenêtre\n # (sinon le moniteur bi-horaire ne voit qu'une fraction de la rotation\n # des sources → fausses alertes « stale »).\n if syncs_since_h and syncs_since_h > 0:\n cutoff = time.time() - syncs_since_h * 3600\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000\",\n (cutoff,))]\n if len(log) < 20:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n else:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n # offres directes (pages carrières + dépôt direct) c. portails agrégateurs\n agg = sorted(AGGREGATORS) or [\"__aucun__\"]\n ph = \",\".join(\"?\" * len(agg))\n direct = con.execute(\n f\"\"\"SELECT COUNT(*) n, COUNT(DISTINCT employer) e,\n COUNT(DISTINCT source) s\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source NOT IN ({ph})\"\"\", agg).fetchone()\n portal = con.execute(\n f\"\"\"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source IN ({ph})\"\"\", agg).fetchone()\n dups = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1\"\n \" AND dup_of IS NOT NULL\").fetchone()\n con.close()\n return {**dict(row), \"top_cities\": cities, \"categories\": categories,\n \"direct_jobs\": direct[\"n\"], \"direct_employers\": direct[\"e\"],\n \"direct_sources\": direct[\"s\"], \"portal_jobs\": portal[\"n\"],\n \"duplicates_hidden\": dups[\"n\"], \"quarantined\": quarantined,\n \"recent_syncs\": log}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"Tableau de bord analytique — contrat commun Groupe KA (SPEC.md).\n Cache mémoire 5 min par clé de période (voir jobka/statsdash.py).\"\"\"\n from . import statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n return statsdash.compute(period, from_, to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None,\n mode: str = \"complet\"):\n \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes : complet | synthese |\n tendances | repartitions | donnees (mode inconnu -> complet).\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n if mode not in kapdf.REPORT_MODES:\n mode = \"complet\"\n dash = statsdash.compute(period, from_, to)\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash,\n mode=mode,\n ).build()\n fname = kapdf.filename(\"job-ka\", period, mode)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/stats/catalog\")\ndef stats_catalog(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"v3 — blocs composables pour le constructeur de rapports personnalisés.\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, from_, to)\n return {\"updated\": dash.get(\"updated\"), \"period\": dash.get(\"period\"),\n \"blocks\": kapdf.catalog(dash)}\n\n\n@app.post(\"/api/stats/report/custom\")\ndef stats_report_custom(spec: dict = Body(...)):\n \"\"\"v3 — rapport PDF personnalisé : {\"title\", \"period\", \"from\", \"to\",\n \"blocks\": [{\"key\": \"series:…\", \"render\": \"bar\"}, …]} (SPEC.md §3bis).\"\"\"\n from . import kapdf, statsdash\n period = str(spec.get(\"period\") or \"30j\")\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, str(spec.get(\"from\") or \"\") or None,\n str(spec.get(\"to\") or \"\") or None)\n known = {b[\"key\"] for b in kapdf.catalog(dash)}\n blocks = [b for b in (spec.get(\"blocks\") or [])\n if isinstance(b, dict) and b.get(\"key\") in known][:40]\n if not blocks:\n raise HTTPException(400, \"Aucun bloc valide dans la composition\")\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash, mode=kapdf.CUSTOM_MODE,\n spec={\"title\": str(spec.get(\"title\") or \"\")[:80], \"blocks\": blocks},\n ).build()\n fname = kapdf.filename(\"job-ka\", period, kapdf.CUSTOM_MODE)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n# --- Dépôt direct d'offres par les employeurs ---------------------------------\nclass OffreDeposee(BaseModel):\n \"\"\"Offre soumise par un employeur (validation minimale, modérée ensuite).\"\"\"\n\n employer: str = Field(min_length=2, max_length=120)\n title: str = Field(min_length=3, max_length=160)\n description: str = Field(min_length=30, max_length=20000)\n contact_email: str = Field(min_length=5, max_length=160)\n city: str = Field(\"\", max_length=80)\n url: str = Field(\"\", max_length=400)\n salary_label: str = Field(\"\", max_length=120)\n employment_type: str | None = None\n work_mode: str | None = None\n website: str = \"\" # pot de miel anti-robots : doit rester vide\n\n\n@app.post(\"/api/employeurs/offres\")\ndef deposer_offre(offre: OffreDeposee):\n \"\"\"Canal de dépôt direct : l'offre est stockée INACTIVE (source\n « depot-direct ») en attente de modération manuelle. Voir /employeurs.\"\"\"\n if offre.website: # robot pris au pot de miel\n return {\"status\": \"reçu\"}\n if not re.match(r\"^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$\", offre.contact_email):\n raise HTTPException(422, \"Courriel de contact invalide\")\n if offre.url and not re.match(r\"^https?://\", offre.url):\n raise HTTPException(422, \"Lien invalide : http(s):// requis\")\n if offre.employment_type and offre.employment_type not in (\n \"temps_plein\", \"temps_partiel\", \"contractuel\", \"stage\", \"saisonnier\"):\n raise HTTPException(422, \"Type d'emploi inconnu\")\n if offre.work_mode and offre.work_mode not in (\n \"presentiel\", \"hybride\", \"teletravail\"):\n raise HTTPException(422, \"Mode de travail inconnu\")\n\n job = JobPosting(\n source=\"depot-direct\", external_id=secrets.token_hex(6),\n url=offre.url, employer=offre.employer, title=offre.title,\n description=offre.description, city=offre.city,\n salary_label=offre.salary_label,\n employment_type=offre.employment_type, work_mode=offre.work_mode,\n ats=\"depot-direct\",\n )\n job.details[\"contact_email\"] = offre.contact_email\n job.details[\"moderation\"] = \"en_attente\"\n job.finalize()\n\n now = time.time()\n cols = {\n \"uid\": job.uid, \"source\": job.source, \"external_id\": job.external_id,\n \"url\": job.url, \"employer\": job.employer, \"title\": job.title,\n \"description\": job.description, \"city\": job.city,\n \"region\": job.region, \"postal_code\": job.postal_code,\n \"location_label\": job.location_label, \"work_mode\": job.work_mode,\n \"employment_type\": job.employment_type,\n \"salary_min\": job.salary_min, \"salary_max\": job.salary_max,\n \"salary_unit\": job.salary_unit, \"salary_label\": job.salary_label,\n \"salary_year_min\": job.salary_year_min(),\n \"salary_year_max\": job.salary_year_max(),\n \"salary_hour_min\": job.salary_hour_min(),\n \"salary_hour_max\": job.salary_hour_max(),\n \"benefits\": json.dumps(job.benefits, ensure_ascii=False),\n \"requirements\": json.dumps(job.requirements, ensure_ascii=False),\n \"category\": job.category, \"ats\": job.ats,\n \"details\": json.dumps(job.details, ensure_ascii=False),\n \"content_hash\": job.content_hash(),\n \"first_seen\": now, \"last_seen\": now, \"updated_at\": now,\n \"miss_count\": 0, \"active\": 0, # inactive tant que non modérée\n }\n con = db.connect()\n con.execute(\n f\"INSERT INTO jobs ({', '.join(cols)}) VALUES \"\n f\"({', '.join(':' + k for k in cols)})\", cols)\n con.commit()\n con.close()\n return {\"status\": \"reçu\", \"uid\": job.uid,\n \"message\": \"Merci ! Votre offre a bien été reçue. Elle sera \"\n \"vérifiée par notre équipe avant publication.\"}\n\n\n@app.post(\"/api/sync\")\ndef trigger_sync(background: BackgroundTasks, source: str | None = None):\n \"\"\"Déclenche une synchronisation (équivalent d'un webhook entrant).\"\"\"\n def _job():\n with _sync_lock:\n ingest.run([source] if source else None)\n background.add_task(_job)\n return {\"status\": \"démarré\", \"source\": source or \"toutes\"}\n\n\n# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ------\n@app.get(\"/api/favorites\")\ndef favorites(request: Request):\n \"\"\"Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n items = hubfav.hub_list(user.get(\"ka_id\") or \"\")\n if items is None:\n raise HTTPException(502, \"Hub Groupe KA injoignable — réessayez\")\n return {\"ids\": [i[\"item_id\"] for i in items if i.get(\"item_id\")],\n \"items\": items}\n\n\n@app.post(\"/api/favorites/toggle\")\ndef toggle_favorite(request: Request, body: dict = Body(...)):\n \"\"\"Ajoute (on=true) ou retire (on=false) un favori — poussé au hub\n Groupe KA de façon synchrone : le hub est la seule source de vérité.\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n ka_id = user.get(\"ka_id\") or \"\"\n if not hubfav.linked(ka_id):\n raise HTTPException(403, \"Compte non relié au hub Groupe KA\")\n on = bool(body.get(\"on\"))\n item = hubfav.clean_item(body.get(\"item\") or {})\n if not item.get(\"item_id\"):\n raise HTTPException(422, \"item.item_id requis\")\n if not hubfav.hub_toggle(ka_id, \"add\" if on else \"remove\", item):\n raise HTTPException(502, \"Hub Groupe KA injoignable — favori non enregistré\")\n return {\"ok\": True, \"on\": on}\n\n\n# --- Frontend (build Vite) ---------------------------------------------------\nif FRONTEND_DIST.exists():\n app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIST / \"assets\"), name=\"assets\")\n\n # SEO : SSR léger des routes publiques (accueil, fiches emploi, pages\n # statiques), robots.txt et sitemaps — DOIT être inclus AVANT le\n # rattrape-tout SPA ci-dessous (l'ordre d'enregistrement fait foi).\n from . import seo # noqa: E402\n app.include_router(seo.router)\n\n @app.middleware(\"http\")\n async def _cache_headers(request, call_next):\n \"\"\"Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé —\n sinon les navigateurs gardent une vieille version après un déploiement.\"\"\"\n resp = await call_next(request)\n path = request.url.path\n if path.startswith(\"/assets/\"):\n resp.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n elif \"text/html\" in (resp.headers.get(\"content-type\") or \"\"):\n resp.headers[\"Cache-Control\"] = \"no-cache\"\n return resp\n\n # routes rendues uniquement côté client — tout autre chemin inconnu renvoie\n # index.html avec un statut 404 (pas de soft-404 pour les moteurs)\n _CLIENT_ROUTES = {\"carte\", \"sources\", \"stats\", \"contact\", \"employeurs\",\n \"favoris\", \"confidentialite\", \"conditions\"}\n _CLIENT_PREFIXES = (\"emploi/\",)\n\n @app.get(\"/{full_path:path}\")\n def spa(full_path: str):\n target = FRONTEND_DIST / full_path\n if full_path and target.is_file():\n return FileResponse(target)\n if full_path and target.is_dir() and (target / \"index.html\").is_file():\n # sous-répertoire statique avec index (ex. /doc/) — servi tel quel\n return FileResponse(target / \"index.html\")\n known = (full_path == \"\" or full_path in _CLIENT_ROUTES\n or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))\n return FileResponse(FRONTEND_DIST / \"index.html\",\n status_code=200 if known else 404,\n headers={\"Cache-Control\": \"no-cache\"})\n","structuredPatch":[{"oldStart":368,"oldLines":6,"newStart":368,"newLines":13,"lines":[" quarantined = con.execute("," \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\""," ).fetchone()[\"n\"]","+ # médiane des salaires annuels affichés (point médian des fourchettes) —","+ # plus robuste que la moyenne face aux salaires de direction","+ sal = sorted(r[\"v\"] for r in con.execute(","+ \"\"\"SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))","+ / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL","+ AND quarantine IS NULL AND salary_year_min IS NOT NULL\"\"\"))","+ median_salary = round(sal[len(sal) // 2], 0) if sal else None"," cities = [dict(r) for r in con.execute("," \"\"\"SELECT city, COUNT(*) n FROM jobs"," WHERE active=1 AND dup_of IS NULL AND city<>''"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcJBCTCxFqUxhU8aF6C","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01R71utB4dfiSkn2oTxukErB","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","old_string":" return {**dict(row), \"top_cities\": cities, \"categories\": categories,","new_string":" return {**dict(row), \"median_salary_year\": median_salary,\n \"top_cities\": cities, \"categories\": categories,"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":1318,"cache_read_input_tokens":129848,"cache_creation":{"ephemeral_5m_input_tokens":1318,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"1aafd1e2-284c-475f-999e-31c66e2db646","request_id":"req_011CePcJ8i8keujMRcAzag8m"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01R71utB4dfiSkn2oTxukErB","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/jobka/web.py has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"ed328c7a-acea-4df0-acff-813da0c8cacf","timestamp":"2026-08-25T14:17:22.016Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/jobka/web.py","oldString":" return {**dict(row), \"top_cities\": cities, \"categories\": categories,","newString":" return {**dict(row), \"median_salary_year\": median_salary,\n \"top_cities\": cities, \"categories\": categories,","originalFile":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : jobka/web.py\n# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist)\n# Créé : 2026-08-17 Modifié : 2026-08-25\n# =============================================================================\nfrom __future__ import annotations\n\nimport json\nimport re\nimport secrets\nimport threading\nimport time\nfrom pathlib import Path\n\nfrom fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom fastapi.staticfiles import StaticFiles\nfrom pydantic import BaseModel, Field\n\nfrom . import auth, db, hubfav, ingest\nfrom .dedup import AGGREGATORS\nfrom .schema import JobPosting\n\nROOT = Path(__file__).resolve().parent.parent\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n\napp = FastAPI(title=\"Job-Ka API\", version=\"1.0\",\n description=\"Agrégateur d'offres d'emploi — employeurs québécois, \"\n \"directement à la source\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n# connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py\napp.include_router(auth.router)\n\n_sync_lock = threading.Lock()\n\n\ndef _row_to_dict(row) -> dict:\n d = dict(row)\n d[\"benefits\"] = json.loads(d.get(\"benefits\") or \"[]\")\n d[\"requirements\"] = json.loads(d.get(\"requirements\") or \"{}\")\n d[\"details\"] = json.loads(d.get(\"details\") or \"{}\")\n if d.get(\"dup_sources\"):\n try:\n d[\"dup_sources\"] = json.loads(d[\"dup_sources\"])\n except (ValueError, TypeError):\n d[\"dup_sources\"] = []\n # offre directe = page carrière de l'employeur ou dépôt direct (≠ portail)\n d[\"is_direct\"] = d.get(\"source\") not in AGGREGATORS\n return d\n\n\n@app.get(\"/health\")\ndef health():\n \"\"\"Healthcheck de déploiement : BD accessible + fraîcheur des syncs.\"\"\"\n try:\n con = db.connect()\n total = con.execute(\n \"SELECT COUNT(*) c FROM jobs WHERE active=1\").fetchone()[\"c\"]\n last = con.execute(\n \"SELECT MAX(ts) ts FROM sync_log WHERE ok=1\").fetchone()[\"ts\"]\n con.close()\n except Exception as exc:\n raise HTTPException(500, f\"BD inaccessible : {exc}\")\n return {\"status\": \"ok\", \"active_jobs\": total,\n \"last_sync_age_s\": round(time.time() - last) if last else None}\n\n\n@app.get(\"/api/jobs\")\ndef list_jobs(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n employer: str | None = None,\n source: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n seniority: str | None = None, # stage | junior | intermediaire | senior | direction\n salary_min: float | None = None, # $/an (converti côté serveur)\n with_salary: int | None = None, # 1 = transparence salariale seulement\n posted_after: str | None = None, # ISO : offres publiées depuis…\n language: str | None = None, # fr | en | bilingue\n q: str | None = None,\n active: int = 1,\n quarantined: int | None = None, # 1 = quarantaine seulement (supervision)\n sort: str = \"recent\", # recent | salary\n limit: int = Query(100, le=2000),\n offset: int = 0,\n):\n con = db.connect()\n sql = \"SELECT * FROM jobs WHERE dup_of IS NULL\" # doublons masqués\n args: list = []\n # quarantaine qualité : jamais publiée par défaut (voir jobka/quality.py)\n sql += \" AND quarantine IS NOT NULL\" if quarantined == 1 \\\n else \" AND quarantine IS NULL\"\n if active in (0, 1):\n sql += \" AND active=?\"; args.append(active)\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if language:\n sql += \" AND language=?\"; args.append(language)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if employer:\n sql += \" AND employer LIKE ?\"; args.append(f\"%{employer}%\")\n if source:\n sql += \" AND source=?\"; args.append(source)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if seniority:\n sql += \" AND seniority=?\"; args.append(seniority)\n if salary_min is not None:\n sql += (\" AND salary_year_max IS NOT NULL AND salary_year_max>=?\")\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if posted_after:\n sql += \" AND date_posted IS NOT NULL AND date_posted>=?\"\n args.append(posted_after)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ? OR description LIKE ?)\"\n args += [f\"%{q}%\"] * 3\n total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n if sort == \"salary\":\n sql += \" ORDER BY salary_year_max IS NULL, salary_year_max DESC\"\n else:\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC\"\n sql += \" LIMIT ? OFFSET ?\"\n args += [limit, offset]\n rows = []\n for r in con.execute(sql, args).fetchall():\n d = _row_to_dict(r)\n d.pop(\"description\", None) # liste allégée : la fiche a le texte complet\n rows.append(d)\n con.close()\n return {\"total\": total, \"count\": len(rows), \"jobs\": rows}\n\n\n@app.get(\"/api/jobs.geojson\")\ndef jobs_geojson(\n city: str | None = None,\n region: str | None = None,\n category: str | None = None,\n work_mode: str | None = None,\n employment_type: str | None = None,\n salary_min: float | None = None,\n with_salary: int | None = None,\n language: str | None = None,\n q: str | None = None,\n bbox: str | None = None,\n limit: int = Query(3000, le=8000),\n):\n \"\"\"Offres géolocalisées au format GeoJSON (mêmes filtres que /api/jobs).\n\n `bbox=ouest,sud,est,nord` restreint au rectangle visible (idx_jobs_geo).\n Membres additionnels : `totalGeocoded` / `totalMatching` pour afficher\n « N résultats sans position sur la carte ».\n \"\"\"\n con = db.connect()\n sql = (\"SELECT uid, title, title_clean, employer, city, category, work_mode,\"\n \" employment_type, salary_min, salary_max, salary_unit,\"\n \" salary_year_min, salary_year_max, date_posted, source, lat, lng\"\n \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n \" AND lat IS NOT NULL AND lng IS NOT NULL\")\n args: list = []\n if bbox:\n try:\n west, south, east, north = (float(v) for v in bbox.split(\",\"))\n except ValueError:\n raise HTTPException(400, \"bbox attendu : ouest,sud,est,nord\")\n sql += \" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\"\n args += [south, north, west, east]\n if city:\n sql += \" AND city=?\"; args.append(city)\n if region:\n sql += \" AND region=?\"; args.append(region)\n if category:\n sql += \" AND category=?\"; args.append(category)\n if work_mode:\n sql += \" AND work_mode=?\"; args.append(work_mode)\n if employment_type:\n sql += \" AND employment_type=?\"; args.append(employment_type)\n if salary_min is not None:\n sql += \" AND salary_year_max IS NOT NULL AND salary_year_max>=?\"\n args.append(salary_min)\n if with_salary == 1:\n sql += \" AND salary_min IS NOT NULL\"\n if language:\n sql += \" AND language=?\"; args.append(language)\n if q:\n sql += \" AND (title LIKE ? OR employer LIKE ?)\"\n args += [f\"%{q}%\"] * 2\n\n total_geo = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n sql_all = sql.replace(\" AND lat IS NOT NULL AND lng IS NOT NULL\", \"\")\n args_all = list(args)\n if bbox:\n sql_all = sql_all.replace(\" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?\", \"\")\n del args_all[0:4]\n total_all = con.execute(f\"SELECT COUNT(*) c FROM ({sql_all})\", args_all).fetchone()[\"c\"]\n\n sql += \" ORDER BY date_posted IS NULL, date_posted DESC LIMIT ?\"\n args.append(limit)\n\n features = []\n for r in con.execute(sql, args).fetchall():\n features.append({\n \"type\": \"Feature\",\n \"geometry\": {\"type\": \"Point\", \"coordinates\": [r[\"lng\"], r[\"lat\"]]},\n \"properties\": {\n \"uid\": r[\"uid\"], \"title\": r[\"title_clean\"] or r[\"title\"],\n \"employer\": r[\"employer\"],\n \"city\": r[\"city\"], \"category\": r[\"category\"],\n \"work_mode\": r[\"work_mode\"],\n \"employment_type\": r[\"employment_type\"],\n \"salary_min\": r[\"salary_min\"], \"salary_max\": r[\"salary_max\"],\n \"salary_unit\": r[\"salary_unit\"],\n \"salary_year_min\": r[\"salary_year_min\"],\n \"salary_year_max\": r[\"salary_year_max\"],\n \"date_posted\": r[\"date_posted\"], \"source\": r[\"source\"],\n \"is_direct\": r[\"source\"] not in AGGREGATORS,\n },\n })\n con.close()\n return {\"type\": \"FeatureCollection\", \"features\": features,\n \"totalGeocoded\": total_geo, \"totalMatching\": total_all}\n\n\n# --- contexte salarial de marché (métrique de la fiche d'offre) ---------------\n# Distribution des salaires annuels affichés par catégorie (point médian de la\n# fourchette), recalculée au plus toutes les 10 minutes — assez frais pour une\n# base resynchronisée à l'heure, et jamais de requête lourde par visite.\n_SALARY_CTX_TTL = 600.0\n_salary_ctx_cache: dict[str, tuple[float, dict | None]] = {}\n\n\ndef _salary_context(con, category: str) -> dict | None:\n \"\"\"Quartiles des salaires annuels affichés dans la catégorie (n >= 20).\"\"\"\n key = category or \"__toutes__\"\n hit = _salary_ctx_cache.get(key)\n if hit and time.time() - hit[0] < _SALARY_CTX_TTL:\n return hit[1]\n sql = (\"SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))\"\n \" / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL\"\n \" AND quarantine IS NULL AND salary_year_min IS NOT NULL\")\n args: list = []\n if category:\n sql += \" AND category=?\"; args.append(category)\n vals = sorted(r[\"v\"] for r in con.execute(sql, args))\n ctx = None\n if len(vals) >= 20: # trop peu d'offres = pas de médiane représentative\n def pct(p: float) -> float:\n return round(vals[min(len(vals) - 1, int(p * len(vals)))], 0)\n ctx = {\"category\": category or \"\", \"n\": len(vals),\n \"p25\": pct(0.25), \"median\": pct(0.50), \"p75\": pct(0.75)}\n _salary_ctx_cache[key] = (time.time(), ctx)\n return ctx\n\n\n@app.get(\"/api/jobs/{uid}\")\ndef get_job(uid: str):\n con = db.connect()\n row = con.execute(\"SELECT * FROM jobs WHERE uid=?\", (uid,)).fetchone()\n d = None\n if row is not None:\n d = _row_to_dict(row)\n d[\"salary_history\"] = [dict(r) for r in con.execute(\n \"SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?\"\n \" ORDER BY ts DESC LIMIT 6\", (uid,)).fetchall()]\n # métriques de contexte : salaire vs marché de la catégorie + volume\n # d'offres actives de l'employeur (comparables, jamais inventées)\n ctx = _salary_context(con, d.get(\"category\") or \"\")\n if ctx and d.get(\"salary_year_min\") is not None:\n mid = (d[\"salary_year_min\"]\n + (d.get(\"salary_year_max\") or d[\"salary_year_min\"])) / 2.0\n ctx = {**ctx, \"job_mid\": round(mid, 0),\n \"delta_pct\": round((mid - ctx[\"median\"]) / ctx[\"median\"] * 100, 1)}\n d[\"salary_context\"] = ctx\n if d.get(\"employer\"):\n d[\"employer_jobs\"] = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\"\n \" AND quarantine IS NULL AND employer=?\",\n (d[\"employer\"],)).fetchone()[\"n\"]\n con.close()\n if d is None:\n raise HTTPException(404, \"Offre introuvable\")\n return d\n\n\n@app.get(\"/api/facets\")\ndef facets(city: str | None = None):\n \"\"\"Valeurs distinctes pour construire les filtres du frontend.\"\"\"\n con = db.connect()\n base = \" FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL\"\n out = {\n \"cities\": [r[\"city\"] for r in con.execute(\n f\"SELECT DISTINCT city{base} AND city<>'' ORDER BY city\")],\n # régions administratives (17) — le repli générique « Québec »\n # (appartenance provinciale sans région précise) n'est pas une facette\n \"regions\": [dict(r) for r in con.execute(\n f\"SELECT region, COUNT(*) n{base} AND region<>''\"\n \" AND region<>'Québec' GROUP BY region ORDER BY region\")],\n \"languages\": [dict(r) for r in con.execute(\n f\"SELECT language, COUNT(*) n{base} AND language IS NOT NULL\"\n \" AND language<>'' GROUP BY language ORDER BY n DESC\")],\n \"categories\": [dict(r) for r in con.execute(\n f\"SELECT category, COUNT(*) n{base} AND category<>''\"\n \" GROUP BY category ORDER BY n DESC\")],\n \"employers\": [dict(r) for r in con.execute(\n f\"SELECT employer, COUNT(*) n{base} AND employer<>''\"\n \" GROUP BY employer ORDER BY n DESC LIMIT 100\")],\n \"work_modes\": [r[\"work_mode\"] for r in con.execute(\n f\"SELECT DISTINCT work_mode{base} AND work_mode IS NOT NULL\"\n \" ORDER BY work_mode\")],\n \"employment_types\": [r[\"employment_type\"] for r in con.execute(\n f\"SELECT DISTINCT employment_type{base}\"\n \" AND employment_type IS NOT NULL ORDER BY employment_type\")],\n \"seniorities\": [dict(r) for r in con.execute(\n f\"SELECT seniority, COUNT(*) n{base} AND seniority IS NOT NULL\"\n \" GROUP BY seniority ORDER BY n DESC\")],\n \"sources\": [dict(r) for r in con.execute(\n f\"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC\")],\n }\n con.close()\n return out\n\n\n@app.get(\"/api/sources\")\ndef sources():\n registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n con = db.connect()\n counts = {r[\"source\"]: r[\"n\"] for r in con.execute(\n \"SELECT source, COUNT(*) n FROM jobs WHERE active=1 GROUP BY source\")}\n last = {r[\"source\"]: r[\"ts\"] for r in con.execute(\n \"SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source\")}\n con.close()\n for s in registry:\n s[\"active_jobs\"] = counts.get(s[\"id\"], 0)\n s[\"last_sync\"] = last.get(s[\"id\"])\n return {\"sources\": registry}\n\n\n@app.get(\"/api/stats\")\ndef stats(syncs_since_h: float | None = None):\n con = db.connect()\n row = con.execute(\n \"\"\"SELECT COUNT(*) total,\n COUNT(DISTINCT employer) employers,\n COUNT(DISTINCT source) sources,\n SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary,\n AVG(salary_year_min) avg_salary_year,\n SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote,\n SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND quarantine IS NULL\"\"\").fetchone()\n quarantined = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1 AND quarantine IS NOT NULL\"\n ).fetchone()[\"n\"]\n # médiane des salaires annuels affichés (point médian des fourchettes) —\n # plus robuste que la moyenne face aux salaires de direction\n sal = sorted(r[\"v\"] for r in con.execute(\n \"\"\"SELECT (salary_year_min + COALESCE(salary_year_max, salary_year_min))\n / 2.0 v FROM jobs WHERE active=1 AND dup_of IS NULL\n AND quarantine IS NULL AND salary_year_min IS NOT NULL\"\"\"))\n median_salary = round(sal[len(sal) // 2], 0) if sal else None\n cities = [dict(r) for r in con.execute(\n \"\"\"SELECT city, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND city<>''\n GROUP BY city ORDER BY n DESC LIMIT 15\"\"\")]\n categories = [dict(r) for r in con.execute(\n \"\"\"SELECT category, COUNT(*) n FROM jobs\n WHERE active=1 AND dup_of IS NULL AND category<>''\n GROUP BY category ORDER BY n DESC\"\"\")]\n # Fenêtre élargie pour la supervision (api-ka) : sans le paramètre, on\n # garde les 20 dernières entrées ; avec, toute l'activité de la fenêtre\n # (sinon le moniteur bi-horaire ne voit qu'une fraction de la rotation\n # des sources → fausses alertes « stale »).\n if syncs_since_h and syncs_since_h > 0:\n cutoff = time.time() - syncs_since_h * 3600\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log WHERE ts >= ? ORDER BY ts DESC LIMIT 4000\",\n (cutoff,))]\n if len(log) < 20:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n else:\n log = [dict(r) for r in con.execute(\n \"SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n # offres directes (pages carrières + dépôt direct) c. portails agrégateurs\n agg = sorted(AGGREGATORS) or [\"__aucun__\"]\n ph = \",\".join(\"?\" * len(agg))\n direct = con.execute(\n f\"\"\"SELECT COUNT(*) n, COUNT(DISTINCT employer) e,\n COUNT(DISTINCT source) s\n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source NOT IN ({ph})\"\"\", agg).fetchone()\n portal = con.execute(\n f\"\"\"SELECT COUNT(*) n FROM jobs WHERE active=1 AND dup_of IS NULL\n AND source IN ({ph})\"\"\", agg).fetchone()\n dups = con.execute(\n \"SELECT COUNT(*) n FROM jobs WHERE active=1\"\n \" AND dup_of IS NOT NULL\").fetchone()\n con.close()\n return {**dict(row), \"top_cities\": cities, \"categories\": categories,\n \"direct_jobs\": direct[\"n\"], \"direct_employers\": direct[\"e\"],\n \"direct_sources\": direct[\"s\"], \"portal_jobs\": portal[\"n\"],\n \"duplicates_hidden\": dups[\"n\"], \"quarantined\": quarantined,\n \"recent_syncs\": log}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"Tableau de bord analytique — contrat commun Groupe KA (SPEC.md).\n Cache mémoire 5 min par clé de période (voir jobka/statsdash.py).\"\"\"\n from . import statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n return statsdash.compute(period, from_, to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None,\n mode: str = \"complet\"):\n \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes : complet | synthese |\n tendances | repartitions | donnees (mode inconnu -> complet).\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n if mode not in kapdf.REPORT_MODES:\n mode = \"complet\"\n dash = statsdash.compute(period, from_, to)\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash,\n mode=mode,\n ).build()\n fname = kapdf.filename(\"job-ka\", period, mode)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/stats/catalog\")\ndef stats_catalog(period: str = \"30j\",\n from_: str | None = Query(None, alias=\"from\"),\n to: str | None = None):\n \"\"\"v3 — blocs composables pour le constructeur de rapports personnalisés.\"\"\"\n from . import kapdf, statsdash\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, from_, to)\n return {\"updated\": dash.get(\"updated\"), \"period\": dash.get(\"period\"),\n \"blocks\": kapdf.catalog(dash)}\n\n\n@app.post(\"/api/stats/report/custom\")\ndef stats_report_custom(spec: dict = Body(...)):\n \"\"\"v3 — rapport PDF personnalisé : {\"title\", \"period\", \"from\", \"to\",\n \"blocks\": [{\"key\": \"series:…\", \"render\": \"bar\"}, …]} (SPEC.md §3bis).\"\"\"\n from . import kapdf, statsdash\n period = str(spec.get(\"period\") or \"30j\")\n if period not in (\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"):\n period = \"30j\"\n dash = statsdash.compute(period, str(spec.get(\"from\") or \"\") or None,\n str(spec.get(\"to\") or \"\") or None)\n known = {b[\"key\"] for b in kapdf.catalog(dash)}\n blocks = [b for b in (spec.get(\"blocks\") or [])\n if isinstance(b, dict) and b.get(\"key\") in known][:40]\n if not blocks:\n raise HTTPException(400, \"Aucun bloc valide dans la composition\")\n data = kapdf.GroupeKAReport(\n site={\"wordmark\": \"Job·Ka\", \"accent\": \"#0c8599\",\n \"domain\": \"www.job-ka.com\",\n \"tagline\": \"Tous les emplois des employeurs québécois\"},\n dashboard=dash, mode=kapdf.CUSTOM_MODE,\n spec={\"title\": str(spec.get(\"title\") or \"\")[:80], \"blocks\": blocks},\n ).build()\n fname = kapdf.filename(\"job-ka\", period, kapdf.CUSTOM_MODE)\n return Response(\n content=data, media_type=\"application/pdf\",\n headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'})\n\n\n# --- Dépôt direct d'offres par les employeurs ---------------------------------\nclass OffreDeposee(BaseModel):\n \"\"\"Offre soumise par un employeur (validation minimale, modérée ensuite).\"\"\"\n\n employer: str = Field(min_length=2, max_length=120)\n title: str = Field(min_length=3, max_length=160)\n description: str = Field(min_length=30, max_length=20000)\n contact_email: str = Field(min_length=5, max_length=160)\n city: str = Field(\"\", max_length=80)\n url: str = Field(\"\", max_length=400)\n salary_label: str = Field(\"\", max_length=120)\n employment_type: str | None = None\n work_mode: str | None = None\n website: str = \"\" # pot de miel anti-robots : doit rester vide\n\n\n@app.post(\"/api/employeurs/offres\")\ndef deposer_offre(offre: OffreDeposee):\n \"\"\"Canal de dépôt direct : l'offre est stockée INACTIVE (source\n « depot-direct ») en attente de modération manuelle. Voir /employeurs.\"\"\"\n if offre.website: # robot pris au pot de miel\n return {\"status\": \"reçu\"}\n if not re.match(r\"^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$\", offre.contact_email):\n raise HTTPException(422, \"Courriel de contact invalide\")\n if offre.url and not re.match(r\"^https?://\", offre.url):\n raise HTTPException(422, \"Lien invalide : http(s):// requis\")\n if offre.employment_type and offre.employment_type not in (\n \"temps_plein\", \"temps_partiel\", \"contractuel\", \"stage\", \"saisonnier\"):\n raise HTTPException(422, \"Type d'emploi inconnu\")\n if offre.work_mode and offre.work_mode not in (\n \"presentiel\", \"hybride\", \"teletravail\"):\n raise HTTPException(422, \"Mode de travail inconnu\")\n\n job = JobPosting(\n source=\"depot-direct\", external_id=secrets.token_hex(6),\n url=offre.url, employer=offre.employer, title=offre.title,\n description=offre.description, city=offre.city,\n salary_label=offre.salary_label,\n employment_type=offre.employment_type, work_mode=offre.work_mode,\n ats=\"depot-direct\",\n )\n job.details[\"contact_email\"] = offre.contact_email\n job.details[\"moderation\"] = \"en_attente\"\n job.finalize()\n\n now = time.time()\n cols = {\n \"uid\": job.uid, \"source\": job.source, \"external_id\": job.external_id,\n \"url\": job.url, \"employer\": job.employer, \"title\": job.title,\n \"description\": job.description, \"city\": job.city,\n \"region\": job.region, \"postal_code\": job.postal_code,\n \"location_label\": job.location_label, \"work_mode\": job.work_mode,\n \"employment_type\": job.employment_type,\n \"salary_min\": job.salary_min, \"salary_max\": job.salary_max,\n \"salary_unit\": job.salary_unit, \"salary_label\": job.salary_label,\n \"salary_year_min\": job.salary_year_min(),\n \"salary_year_max\": job.salary_year_max(),\n \"salary_hour_min\": job.salary_hour_min(),\n \"salary_hour_max\": job.salary_hour_max(),\n \"benefits\": json.dumps(job.benefits, ensure_ascii=False),\n \"requirements\": json.dumps(job.requirements, ensure_ascii=False),\n \"category\": job.category, \"ats\": job.ats,\n \"details\": json.dumps(job.details, ensure_ascii=False),\n \"content_hash\": job.content_hash(),\n \"first_seen\": now, \"last_seen\": now, \"updated_at\": now,\n \"miss_count\": 0, \"active\": 0, # inactive tant que non modérée\n }\n con = db.connect()\n con.execute(\n f\"INSERT INTO jobs ({', '.join(cols)}) VALUES \"\n f\"({', '.join(':' + k for k in cols)})\", cols)\n con.commit()\n con.close()\n return {\"status\": \"reçu\", \"uid\": job.uid,\n \"message\": \"Merci ! Votre offre a bien été reçue. Elle sera \"\n \"vérifiée par notre équipe avant publication.\"}\n\n\n@app.post(\"/api/sync\")\ndef trigger_sync(background: BackgroundTasks, source: str | None = None):\n \"\"\"Déclenche une synchronisation (équivalent d'un webhook entrant).\"\"\"\n def _job():\n with _sync_lock:\n ingest.run([source] if source else None)\n background.add_task(_job)\n return {\"status\": \"démarré\", \"source\": source or \"toutes\"}\n\n\n# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ------\n@app.get(\"/api/favorites\")\ndef favorites(request: Request):\n \"\"\"Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n items = hubfav.hub_list(user.get(\"ka_id\") or \"\")\n if items is None:\n raise HTTPException(502, \"Hub Groupe KA injoignable — réessayez\")\n return {\"ids\": [i[\"item_id\"] for i in items if i.get(\"item_id\")],\n \"items\": items}\n\n\n@app.post(\"/api/favorites/toggle\")\ndef toggle_favorite(request: Request, body: dict = Body(...)):\n \"\"\"Ajoute (on=true) ou retire (on=false) un favori — poussé au hub\n Groupe KA de façon synchrone : le hub est la seule source de vérité.\"\"\"\n user = auth.current_user(request)\n if not user:\n raise HTTPException(401, \"Connexion KA ID requise\")\n ka_id = user.get(\"ka_id\") or \"\"\n if not hubfav.linked(ka_id):\n raise HTTPException(403, \"Compte non relié au hub Groupe KA\")\n on = bool(body.get(\"on\"))\n item = hubfav.clean_item(body.get(\"item\") or {})\n if not item.get(\"item_id\"):\n raise HTTPException(422, \"item.item_id requis\")\n if not hubfav.hub_toggle(ka_id, \"add\" if on else \"remove\", item):\n raise HTTPException(502, \"Hub Groupe KA injoignable — favori non enregistré\")\n return {\"ok\": True, \"on\": on}\n\n\n# --- Frontend (build Vite) ---------------------------------------------------\nif FRONTEND_DIST.exists():\n app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIST / \"assets\"), name=\"assets\")\n\n # SEO : SSR léger des routes publiques (accueil, fiches emploi, pages\n # statiques), robots.txt et sitemaps — DOIT être inclus AVANT le\n # rattrape-tout SPA ci-dessous (l'ordre d'enregistrement fait foi).\n from . import seo # noqa: E402\n app.include_router(seo.router)\n\n @app.middleware(\"http\")\n async def _cache_headers(request, call_next):\n \"\"\"Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé —\n sinon les navigateurs gardent une vieille version après un déploiement.\"\"\"\n resp = await call_next(request)\n path = request.url.path\n if path.startswith(\"/assets/\"):\n resp.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n elif \"text/html\" in (resp.headers.get(\"content-type\") or \"\"):\n resp.headers[\"Cache-Control\"] = \"no-cache\"\n return resp\n\n # routes rendues uniquement côté client — tout autre chemin inconnu renvoie\n # index.html avec un statut 404 (pas de soft-404 pour les moteurs)\n _CLIENT_ROUTES = {\"carte\", \"sources\", \"stats\", \"contact\", \"employeurs\",\n \"favoris\", \"confidentialite\", \"conditions\"}\n _CLIENT_PREFIXES = (\"emploi/\",)\n\n @app.get(\"/{full_path:path}\")\n def spa(full_path: str):\n target = FRONTEND_DIST / full_path\n if full_path and target.is_file():\n return FileResponse(target)\n if full_path and target.is_dir() and (target / \"index.html\").is_file():\n # sous-répertoire statique avec index (ex. /doc/) — servi tel quel\n return FileResponse(target / \"index.html\")\n known = (full_path == \"\" or full_path in _CLIENT_ROUTES\n or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))\n return FileResponse(FRONTEND_DIST / \"index.html\",\n status_code=200 if known else 404,\n headers={\"Cache-Control\": \"no-cache\"})\n","structuredPatch":[{"oldStart":413,"oldLines":7,"newStart":413,"newLines":8,"lines":[" \"SELECT COUNT(*) n FROM jobs WHERE active=1\""," \" AND dup_of IS NOT NULL\").fetchone()"," con.close()","- return {**dict(row), \"top_cities\": cities, \"categories\": categories,","+ return {**dict(row), \"median_salary_year\": median_salary,","+ \"top_cities\": cities, \"categories\": categories,"," \"direct_jobs\": direct[\"n\"], \"direct_employers\": direct[\"e\"],"," \"direct_sources\": direct[\"s\"], \"portal_jobs\": portal[\"n\"],"," \"duplicates_hidden\": dups[\"n\"], \"quarantined\": quarantined,"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"7314be84-d993-4799-98ac-15810aaabda1","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcLPeMz3YTGZcGtE4o5","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS3wIKpwEIERgCKkDfSnd6ODvF/rsY2m7Zblt/0l2HscUoAfvuaFaC8U5SoODP0kYjoaJuMCbqjyN3PtxooQp31uemeqFQR/3GxgFCMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAH5x7bUBrABAhIMhGs5U5wgq0u4VVPJGgz+f8MaBT16cm+FCF0iMA276znCFIaVcN97kBnU7XgeL34EsTKmOSeRon2DOQD6yoQgcfsczafDUpwxz7WXKCplEcAK3G0mdPciShtwMaJGlaGDD+u1S/JBP5tq7GDwsW7ZvQjofZjW8B3slyU65/4eL3ZTUvTT0/eWIQ/V67zU3+cyaRaQr7TE8ZTCy9Ov9BAiniZOs1YcQr9FrQMjzF3HSwtEgbYYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3118,"cache_read_input_tokens":131166,"cache_creation":{"ephemeral_5m_input_tokens":3118,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"7d3161f8-4648-4919-b139-644c26e752ec","request_id":"req_011CePcLGahxvFSzFLPPnQb9"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcLPeMz3YTGZcGtE4o5","type":"message","role":"assistant","content":[{"type":"text","text":"Backend API prêt. J'écris le script de backfill (enrichir les 23 000+ offres déjà en base sans attendre la prochaine synchro) et les tests :"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3118,"cache_read_input_tokens":131166,"cache_creation":{"ephemeral_5m_input_tokens":3118,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"2c3ae97b-1991-40f7-b058-5ad7f249f12d","request_id":"req_011CePcLGahxvFSzFLPPnQb9"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcLPeMz3YTGZcGtE4o5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QiRAPUqbCeNfBkMGhG7vsB","name":"Write","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/scripts/backfill_enrich.py","content":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : scripts/backfill_enrich.py\n# Rôle : Backfill de l'enrichissement Phase 3 (exigences structurées,\n# séniorité, avantages) sur les offres déjà en base — sans toucher\n# content_hash (la prochaine synchro réalignera naturellement)\n# Créé : 2026-08-25 Modifié : 2026-08-25\n# =============================================================================\n\"\"\"Réapplique les nouveaux extracteurs (extract_requirements, parse_seniority,\nextract_benefits élargi) aux offres stockées, à partir du titre et de la\ndescription déjà en base. Idempotent : ne remplit que les champs manquants,\nn'écrase jamais une valeur fournie par un ATS.\n\nUsage : python -m scripts.backfill_enrich [--all] (défaut : offres actives)\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport sys\nimport time\nfrom pathlib import Path\n\nsys.path.insert(0, str(Path(__file__).resolve().parent.parent))\n\nfrom jobka import db # noqa: E402\nfrom jobka.normalize import ( # noqa: E402\n extract_benefits, extract_requirements, parse_seniority)\n\n\ndef run(only_active: bool = True) -> dict:\n con = db.connect() # applique aussi la migration (colonne seniority)\n where = \"WHERE active=1\" if only_active else \"\"\n rows = con.execute(\n f\"SELECT uid, title, description, employment_type, requirements,\"\n f\" benefits, seniority FROM jobs {where}\").fetchall()\n\n stats = {\"scanned\": len(rows), \"requirements\": 0, \"seniority\": 0,\n \"benefits\": 0, \"updated\": 0}\n t0 = time.time()\n for r in rows:\n try:\n reqs = json.loads(r[\"requirements\"] or \"{}\")\n except ValueError:\n reqs = {}\n try:\n bens = json.loads(r[\"benefits\"] or \"[]\")\n except ValueError:\n bens = []\n\n changed = False\n extracted = extract_requirements(r[\"title\"] or \"\", r[\"description\"] or \"\")\n added_req = {k: v for k, v in extracted.items() if k not in reqs}\n if added_req:\n reqs.update(added_req)\n stats[\"requirements\"] += 1\n changed = True\n\n seniority = r[\"seniority\"]\n if not seniority:\n seniority = parse_seniority(r[\"title\"] or \"\", reqs,\n r[\"employment_type\"])\n if seniority:\n stats[\"seniority\"] += 1\n changed = True\n\n if not bens and r[\"description\"]:\n bens = extract_benefits(r[\"description\"])\n if bens:\n stats[\"benefits\"] += 1\n changed = True\n\n if changed:\n con.execute(\n \"UPDATE jobs SET requirements=?, seniority=?, benefits=?\"\n \" WHERE uid=?\",\n (json.dumps(reqs, ensure_ascii=False), seniority,\n json.dumps(bens, ensure_ascii=False), r[\"uid\"]))\n stats[\"updated\"] += 1\n\n con.commit()\n con.close()\n stats[\"seconds\"] = round(time.time() - t0, 1)\n return stats\n\n\nif __name__ == \"__main__\":\n out = run(only_active=\"--all\" not in sys.argv)\n print(f\"[backfill-enrich] {out}\")\n"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3118,"cache_read_input_tokens":131166,"cache_creation":{"ephemeral_5m_input_tokens":3118,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"f0da6124-cc75-4c92-8b0f-871826a367b6","request_id":"req_011CePcLGahxvFSzFLPPnQb9"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QiRAPUqbCeNfBkMGhG7vsB","type":"tool_result","content":"File created successfully at: /Users/simon-pierreboucher/apps/job-ka/scripts/backfill_enrich.py (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"02508789-c165-40c7-a843-f267ab171bc6","timestamp":"2026-08-25T14:17:41.316Z","tool_use_result":{"type":"create","filePath":"/Users/simon-pierreboucher/apps/job-ka/scripts/backfill_enrich.py","content":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : scripts/backfill_enrich.py\n# Rôle : Backfill de l'enrichissement Phase 3 (exigences structurées,\n# séniorité, avantages) sur les offres déjà en base — sans toucher\n# content_hash (la prochaine synchro réalignera naturellement)\n# Créé : 2026-08-25 Modifié : 2026-08-25\n# =============================================================================\n\"\"\"Réapplique les nouveaux extracteurs (extract_requirements, parse_seniority,\nextract_benefits élargi) aux offres stockées, à partir du titre et de la\ndescription déjà en base. Idempotent : ne remplit que les champs manquants,\nn'écrase jamais une valeur fournie par un ATS.\n\nUsage : python -m scripts.backfill_enrich [--all] (défaut : offres actives)\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport sys\nimport time\nfrom pathlib import Path\n\nsys.path.insert(0, str(Path(__file__).resolve().parent.parent))\n\nfrom jobka import db # noqa: E402\nfrom jobka.normalize import ( # noqa: E402\n extract_benefits, extract_requirements, parse_seniority)\n\n\ndef run(only_active: bool = True) -> dict:\n con = db.connect() # applique aussi la migration (colonne seniority)\n where = \"WHERE active=1\" if only_active else \"\"\n rows = con.execute(\n f\"SELECT uid, title, description, employment_type, requirements,\"\n f\" benefits, seniority FROM jobs {where}\").fetchall()\n\n stats = {\"scanned\": len(rows), \"requirements\": 0, \"seniority\": 0,\n \"benefits\": 0, \"updated\": 0}\n t0 = time.time()\n for r in rows:\n try:\n reqs = json.loads(r[\"requirements\"] or \"{}\")\n except ValueError:\n reqs = {}\n try:\n bens = json.loads(r[\"benefits\"] or \"[]\")\n except ValueError:\n bens = []\n\n changed = False\n extracted = extract_requirements(r[\"title\"] or \"\", r[\"description\"] or \"\")\n added_req = {k: v for k, v in extracted.items() if k not in reqs}\n if added_req:\n reqs.update(added_req)\n stats[\"requirements\"] += 1\n changed = True\n\n seniority = r[\"seniority\"]\n if not seniority:\n seniority = parse_seniority(r[\"title\"] or \"\", reqs,\n r[\"employment_type\"])\n if seniority:\n stats[\"seniority\"] += 1\n changed = True\n\n if not bens and r[\"description\"]:\n bens = extract_benefits(r[\"description\"])\n if bens:\n stats[\"benefits\"] += 1\n changed = True\n\n if changed:\n con.execute(\n \"UPDATE jobs SET requirements=?, seniority=?, benefits=?\"\n \" WHERE uid=?\",\n (json.dumps(reqs, ensure_ascii=False), seniority,\n json.dumps(bens, ensure_ascii=False), r[\"uid\"]))\n stats[\"updated\"] += 1\n\n con.commit()\n con.close()\n stats[\"seconds\"] = round(time.time() - t0, 1)\n return stats\n\n\nif __name__ == \"__main__\":\n out = run(only_active=\"--all\" not in sys.argv)\n print(f\"[backfill-enrich] {out}\")\n","structuredPatch":[],"originalFile":null,"userModified":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcLPeMz3YTGZcGtE4o5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01P9wFu1Y1JS55DuKH32taa4","name":"Write","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/tests/test_enrich.py","content":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : tests/test_enrich.py\n# Rôle : Tests unitaires de l'enrichissement Phase 3 — exigences\n# structurées (expérience, scolarité, langues) et séniorité\n# Créé : 2026-08-25 Modifié : 2026-08-25\n# =============================================================================\nfrom jobka.normalize import extract_benefits, extract_requirements, parse_seniority\nfrom jobka.schema import JobPosting\n\n\n# --- exigences : années d'expérience ------------------------------------------\n\ndef test_experience_range_fr():\n r = extract_requirements(\"Comptable\", \"Détenir 3 à 5 ans d'expérience en comptabilité.\")\n assert r[\"experience_years\"] == 3\n\n\ndef test_experience_plus_en():\n r = extract_requirements(\"Developer\", \"5+ years of experience with Python required.\")\n assert r[\"experience_years\"] == 5\n\n\ndef test_experience_minimum_de():\n r = extract_requirements(\"Technicien\", \"Expérience minimum de 2 ans dans un poste similaire.\")\n assert r[\"experience_years\"] == 2\n\n\ndef test_experience_absente():\n r = extract_requirements(\"Cuisinier\", \"Préparer les plats du menu. Horaire de 35 heures.\")\n assert \"experience_years\" not in r\n\n\ndef test_experience_bornes():\n # 45 ans d'expérience = non plausible, on ne retient rien\n r = extract_requirements(\"Poste\", \"45 ans d'expérience exigée\")\n assert \"experience_years\" not in r\n\n\n# --- exigences : scolarité ------------------------------------------------------\n\ndef test_education_dec_avec_contexte():\n r = extract_requirements(\"Technicien\", \"Diplôme d'études collégiales (DEC) en informatique requis.\")\n assert r[\"education\"] == \"DEC\"\n\n\ndef test_education_dec_sans_contexte_ignore():\n # « déc. » = décembre, pas un diplôme : sans contexte scolaire on ignore\n r = extract_requirements(\"Poste\", \"Entrée en fonction : déc. 2026. Aucun prérequis.\")\n assert \"education\" not in r\n\n\ndef test_education_bac():\n r = extract_requirements(\"Analyste\", \"Baccalauréat en administration des affaires.\")\n assert r[\"education\"] == \"baccalauréat\"\n\n\ndef test_education_bachelor_en():\n r = extract_requirements(\"Analyst\", \"Bachelor's degree in Computer Science or equivalent.\")\n assert r[\"education\"] == \"baccalauréat\"\n\n\ndef test_education_plus_bas_exige():\n # « bac ou maîtrise » -> l'exigence minimale est le bac\n r = extract_requirements(\"Poste\", \"Baccalauréat ou maîtrise en génie exigé.\")\n assert r[\"education\"] == \"baccalauréat\"\n\n\ndef test_education_dep():\n r = extract_requirements(\"Mécanicien\", \"Détenir un DEP en mécanique automobile.\")\n assert r[\"education\"] == \"DEP\"\n\n\n# --- exigences : langues --------------------------------------------------------\n\ndef test_langues_bilingue():\n r = extract_requirements(\"Poste\", \"Le candidat doit être bilingue (français/anglais).\")\n assert r[\"languages\"] == [\"français\", \"anglais\"]\n\n\ndef test_langues_anglais_requis():\n r = extract_requirements(\"Poste\", \"Anglais fonctionnel requis pour ce poste.\")\n assert r[\"languages\"] == [\"anglais\"]\n\n\ndef test_langues_fluent_english():\n r = extract_requirements(\"Role\", \"Fluent in English; French is an asset.\")\n assert \"anglais\" in r[\"languages\"]\n\n\ndef test_langues_aucune_mention():\n r = extract_requirements(\"Poste\", \"Assembler des pièces sur la chaîne de production.\")\n assert \"languages\" not in r\n\n\n# --- séniorité -------------------------------------------------------------------\n\ndef test_seniority_titre_senior():\n assert parse_seniority(\"Développeur senior\") == \"senior\"\n\n\ndef test_seniority_titre_junior():\n assert parse_seniority(\"Analyste junior\") == \"junior\"\n\n\ndef test_seniority_titre_direction():\n assert parse_seniority(\"Directeur des finances\") == \"direction\"\n\n\ndef test_seniority_stage_par_type():\n assert parse_seniority(\"Analyste TI\", None, \"stage\") == \"stage\"\n\n\ndef test_seniority_label_ats():\n assert parse_seniority(\"Ingénieur\", {\"experience\": \"Mid-Senior Level\"}) == \"senior\"\n assert parse_seniority(\"Ingénieur\", {\"experience\": \"Entry level\"}) == \"junior\"\n\n\ndef test_seniority_annees_experience():\n assert parse_seniority(\"Comptable\", {\"experience_years\": 1}) == \"junior\"\n assert parse_seniority(\"Comptable\", {\"experience_years\": 4}) == \"intermediaire\"\n assert parse_seniority(\"Comptable\", {\"experience_years\": 8}) == \"senior\"\n\n\ndef test_seniority_aucun_signal():\n assert parse_seniority(\"Préposé à l'entretien\") is None\n\n\ndef test_seniority_not_applicable_ignore():\n assert parse_seniority(\"Poste\", {\"experience\": \"Not Applicable\"}) is None\n\n\n# --- avantages : en-têtes élargis -------------------------------------------------\n\ndef test_benefits_ce_quon_offre():\n desc = (\"Ce que nous vous offrons\\n• Assurances collectives\\n\"\n \"• REER avec cotisation de l'employeur\\n• 4 semaines de vacances\\n\")\n assert len(extract_benefits(desc)) == 3\n\n\ndef test_benefits_whats_in_it_for_you():\n desc = (\"What's in it for you\\n- Health insurance\\n- RRSP matching\\n\\nApply now.\")\n assert extract_benefits(desc) == [\"Health insurance\", \"RRSP matching\"]\n\n\n# --- intégration finalize() --------------------------------------------------------\n\ndef test_finalize_remplit_exigences_et_seniorite():\n job = JobPosting(\n source=\"test\", external_id=\"1\", url=\"https://example.com/1\",\n employer=\"Exemple inc.\", title=\"Développeur senior\",\n description=(\"Baccalauréat en informatique et 5 ans d'expérience \"\n \"exigés. Le candidat doit être bilingue.\"),\n ).finalize()\n assert job.requirements[\"experience_years\"] == 5\n assert job.requirements[\"education\"] == \"baccalauréat\"\n assert job.requirements[\"languages\"] == [\"français\", \"anglais\"]\n assert job.seniority == \"senior\"\n\n\ndef test_finalize_ne_touche_pas_valeurs_ats():\n job = JobPosting(\n source=\"test\", external_id=\"2\", url=\"https://example.com/2\",\n employer=\"Exemple inc.\", title=\"Analyste\",\n description=\"2 ans d'expérience demandés.\",\n requirements={\"experience_years\": 7},\n seniority=\"junior\",\n ).finalize()\n assert job.requirements[\"experience_years\"] == 7 # valeur ATS conservée\n assert job.seniority == \"junior\"\n"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3118,"cache_read_input_tokens":131166,"cache_creation":{"ephemeral_5m_input_tokens":3118,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"ddc663c9-bbb4-4e79-a800-d0a4502af645","request_id":"req_011CePcLGahxvFSzFLPPnQb9"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01P9wFu1Y1JS55DuKH32taa4","type":"tool_result","content":"File created successfully at: /Users/simon-pierreboucher/apps/job-ka/tests/test_enrich.py (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"edfb8582-0cda-4326-a869-384a6dfb8051","timestamp":"2026-08-25T14:18:05.194Z","tool_use_result":{"type":"create","filePath":"/Users/simon-pierreboucher/apps/job-ka/tests/test_enrich.py","content":"# =============================================================================\n# Job·Ka — Groupe KA\n# Auteur : Simon-Pierre Boucher\n# Contact : contact@spboucher.ai\n# Fichier : tests/test_enrich.py\n# Rôle : Tests unitaires de l'enrichissement Phase 3 — exigences\n# structurées (expérience, scolarité, langues) et séniorité\n# Créé : 2026-08-25 Modifié : 2026-08-25\n# =============================================================================\nfrom jobka.normalize import extract_benefits, extract_requirements, parse_seniority\nfrom jobka.schema import JobPosting\n\n\n# --- exigences : années d'expérience ------------------------------------------\n\ndef test_experience_range_fr():\n r = extract_requirements(\"Comptable\", \"Détenir 3 à 5 ans d'expérience en comptabilité.\")\n assert r[\"experience_years\"] == 3\n\n\ndef test_experience_plus_en():\n r = extract_requirements(\"Developer\", \"5+ years of experience with Python required.\")\n assert r[\"experience_years\"] == 5\n\n\ndef test_experience_minimum_de():\n r = extract_requirements(\"Technicien\", \"Expérience minimum de 2 ans dans un poste similaire.\")\n assert r[\"experience_years\"] == 2\n\n\ndef test_experience_absente():\n r = extract_requirements(\"Cuisinier\", \"Préparer les plats du menu. Horaire de 35 heures.\")\n assert \"experience_years\" not in r\n\n\ndef test_experience_bornes():\n # 45 ans d'expérience = non plausible, on ne retient rien\n r = extract_requirements(\"Poste\", \"45 ans d'expérience exigée\")\n assert \"experience_years\" not in r\n\n\n# --- exigences : scolarité ------------------------------------------------------\n\ndef test_education_dec_avec_contexte():\n r = extract_requirements(\"Technicien\", \"Diplôme d'études collégiales (DEC) en informatique requis.\")\n assert r[\"education\"] == \"DEC\"\n\n\ndef test_education_dec_sans_contexte_ignore():\n # « déc. » = décembre, pas un diplôme : sans contexte scolaire on ignore\n r = extract_requirements(\"Poste\", \"Entrée en fonction : déc. 2026. Aucun prérequis.\")\n assert \"education\" not in r\n\n\ndef test_education_bac():\n r = extract_requirements(\"Analyste\", \"Baccalauréat en administration des affaires.\")\n assert r[\"education\"] == \"baccalauréat\"\n\n\ndef test_education_bachelor_en():\n r = extract_requirements(\"Analyst\", \"Bachelor's degree in Computer Science or equivalent.\")\n assert r[\"education\"] == \"baccalauréat\"\n\n\ndef test_education_plus_bas_exige():\n # « bac ou maîtrise » -> l'exigence minimale est le bac\n r = extract_requirements(\"Poste\", \"Baccalauréat ou maîtrise en génie exigé.\")\n assert r[\"education\"] == \"baccalauréat\"\n\n\ndef test_education_dep():\n r = extract_requirements(\"Mécanicien\", \"Détenir un DEP en mécanique automobile.\")\n assert r[\"education\"] == \"DEP\"\n\n\n# --- exigences : langues --------------------------------------------------------\n\ndef test_langues_bilingue():\n r = extract_requirements(\"Poste\", \"Le candidat doit être bilingue (français/anglais).\")\n assert r[\"languages\"] == [\"français\", \"anglais\"]\n\n\ndef test_langues_anglais_requis():\n r = extract_requirements(\"Poste\", \"Anglais fonctionnel requis pour ce poste.\")\n assert r[\"languages\"] == [\"anglais\"]\n\n\ndef test_langues_fluent_english():\n r = extract_requirements(\"Role\", \"Fluent in English; French is an asset.\")\n assert \"anglais\" in r[\"languages\"]\n\n\ndef test_langues_aucune_mention():\n r = extract_requirements(\"Poste\", \"Assembler des pièces sur la chaîne de production.\")\n assert \"languages\" not in r\n\n\n# --- séniorité -------------------------------------------------------------------\n\ndef test_seniority_titre_senior():\n assert parse_seniority(\"Développeur senior\") == \"senior\"\n\n\ndef test_seniority_titre_junior():\n assert parse_seniority(\"Analyste junior\") == \"junior\"\n\n\ndef test_seniority_titre_direction():\n assert parse_seniority(\"Directeur des finances\") == \"direction\"\n\n\ndef test_seniority_stage_par_type():\n assert parse_seniority(\"Analyste TI\", None, \"stage\") == \"stage\"\n\n\ndef test_seniority_label_ats():\n assert parse_seniority(\"Ingénieur\", {\"experience\": \"Mid-Senior Level\"}) == \"senior\"\n assert parse_seniority(\"Ingénieur\", {\"experience\": \"Entry level\"}) == \"junior\"\n\n\ndef test_seniority_annees_experience():\n assert parse_seniority(\"Comptable\", {\"experience_years\": 1}) == \"junior\"\n assert parse_seniority(\"Comptable\", {\"experience_years\": 4}) == \"intermediaire\"\n assert parse_seniority(\"Comptable\", {\"experience_years\": 8}) == \"senior\"\n\n\ndef test_seniority_aucun_signal():\n assert parse_seniority(\"Préposé à l'entretien\") is None\n\n\ndef test_seniority_not_applicable_ignore():\n assert parse_seniority(\"Poste\", {\"experience\": \"Not Applicable\"}) is None\n\n\n# --- avantages : en-têtes élargis -------------------------------------------------\n\ndef test_benefits_ce_quon_offre():\n desc = (\"Ce que nous vous offrons\\n• Assurances collectives\\n\"\n \"• REER avec cotisation de l'employeur\\n• 4 semaines de vacances\\n\")\n assert len(extract_benefits(desc)) == 3\n\n\ndef test_benefits_whats_in_it_for_you():\n desc = (\"What's in it for you\\n- Health insurance\\n- RRSP matching\\n\\nApply now.\")\n assert extract_benefits(desc) == [\"Health insurance\", \"RRSP matching\"]\n\n\n# --- intégration finalize() --------------------------------------------------------\n\ndef test_finalize_remplit_exigences_et_seniorite():\n job = JobPosting(\n source=\"test\", external_id=\"1\", url=\"https://example.com/1\",\n employer=\"Exemple inc.\", title=\"Développeur senior\",\n description=(\"Baccalauréat en informatique et 5 ans d'expérience \"\n \"exigés. Le candidat doit être bilingue.\"),\n ).finalize()\n assert job.requirements[\"experience_years\"] == 5\n assert job.requirements[\"education\"] == \"baccalauréat\"\n assert job.requirements[\"languages\"] == [\"français\", \"anglais\"]\n assert job.seniority == \"senior\"\n\n\ndef test_finalize_ne_touche_pas_valeurs_ats():\n job = JobPosting(\n source=\"test\", external_id=\"2\", url=\"https://example.com/2\",\n employer=\"Exemple inc.\", title=\"Analyste\",\n description=\"2 ans d'expérience demandés.\",\n requirements={\"experience_years\": 7},\n seniority=\"junior\",\n ).finalize()\n assert job.requirements[\"experience_years\"] == 7 # valeur ATS conservée\n assert job.seniority == \"junior\"\n","structuredPatch":[],"originalFile":null,"userModified":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"2e925088-8edf-4d55-b81d-78cfe152f1e3","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcPUyRhgXwrVd2Rh31p","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DMQMCyWxdZPEH2yTivCcM6","name":"Bash","input":{"command":".venv/bin/python -m pytest tests/test_enrich.py -q 2>&1 | tail -30","description":"Run new enrichment tests"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4091,"cache_read_input_tokens":134284,"cache_creation":{"ephemeral_5m_input_tokens":4091,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"a15c2426-beba-4df2-aeaf-53a4e0a8d151","request_id":"req_011CePcPTD1dNHVFmz4dQqDh"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DMQMCyWxdZPEH2yTivCcM6","type":"tool_result","content":"........................... [100%]\n27 passed in 0.03s","is_error":false}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"b3cd8853-2995-4dd3-9228-f236060c7b8a","timestamp":"2026-08-25T14:18:13.413Z","tool_use_result":{"stdout":"........................... [100%]\n27 passed in 0.03s","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4523a562-43c1-476e-97c2-6fa269e9061e","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcQ8GWTYp7Fzy87ZihL","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HMAMoQPjFwJmy7iaNFRyQr","name":"Bash","input":{"command":".venv/bin/python -m pytest tests/ -q 2>&1 | tail -8","description":"Run full test suite"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":137,"cache_read_input_tokens":138375,"cache_creation":{"ephemeral_5m_input_tokens":137,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"ab0573dc-a62f-4239-9172-1191a2cd8acd","request_id":"req_011CePcQ4BhJPACGc6Fve1Fv"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HMAMoQPjFwJmy7iaNFRyQr","type":"tool_result","content":"........................................................................ [ 71%]\n............................. [100%]\n101 passed in 1.80s","is_error":false}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"1c0bf821-93ea-4921-ba7f-4ff6ba02dadb","timestamp":"2026-08-25T14:18:23.201Z","tool_use_result":{"stdout":"........................................................................ [ 71%]\n............................. [100%]\n101 passed in 1.80s","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"62f323ec-2c7c-4390-871f-005f3132541e","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"79dc400e-9e30-49e4-ba02-d3e50fa96760","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":88,"estimated_tokens_delta":38,"uuid":"a32fc040-242c-43bb-a85f-0ef30f047838","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcQoeMhcHmBvR5kyX88","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS2AIKpwEIERgCKkCdks92nYMEf8h6PNQdP3LwVsYIRrcK/u7dcPHZBiucDnMC+7gBZ7kHWfukiO/00AvaBnAo0h5ct75I5/F/C6DZMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAG2yLbUBrABAhIMj7ToJIN52U0iHheSGgw3EN9QpVa3H3+IpKciMMe9RM6+omIvlQaWJm4V4w291B154cGUasbltUQwkPAj3aVfWjwmgvg5TmojsIp/ESpe2W2TI1ru9xVf7zKQjQqNQmjANN1dkpOMkizj37TSsqq12KUp1iIpq9eJYEreYs93s6HTABdG/trNOd3eCylcqyDdtJHBDAr86i6jF5+yneW3UVjLtnY/Q4GNfODBqxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":141,"cache_read_input_tokens":138512,"cache_creation":{"ephemeral_5m_input_tokens":141,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"277fa506-c7ed-443e-ad81-ddbe205bca31","request_id":"req_011CePcQn6bQPXvZBe3PUbcE"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcQoeMhcHmBvR5kyX88","type":"message","role":"assistant","content":[{"type":"text","text":"101 tests verts (74 existants + 27 nouveaux). Je lance le backfill sur la base de production :"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":141,"cache_read_input_tokens":138512,"cache_creation":{"ephemeral_5m_input_tokens":141,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"3356365e-8ccb-4900-a612-e92e434b14e1","request_id":"req_011CePcQn6bQPXvZBe3PUbcE"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcQoeMhcHmBvR5kyX88","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01916dSQmmvXirX9xqmVeMgJ","name":"Bash","input":{"command":".venv/bin/python -m scripts.backfill_enrich 2>&1 | tail -3","description":"Backfill enrichment on active jobs","timeout":300000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":141,"cache_read_input_tokens":138512,"cache_creation":{"ephemeral_5m_input_tokens":141,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"c7436e1a-138e-4dcb-8569-37cd70679f17","request_id":"req_011CePcQn6bQPXvZBe3PUbcE"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bf9eed8ko","tool_use_id":"toolu_01916dSQmmvXirX9xqmVeMgJ","description":"Backfill enrichment on active jobs","task_type":"local_bash","uuid":"eeb7d32a-5087-4226-be68-873a19383cdb","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bf9eed8ko","tool_use_id":"toolu_01916dSQmmvXirX9xqmVeMgJ","status":"completed","output_file":"","summary":"Backfill enrichment on active jobs","uuid":"c069e004-22b4-4e1f-9063-d16e4072cf91","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01916dSQmmvXirX9xqmVeMgJ","type":"tool_result","content":"[backfill-enrich] {'scanned': 23680, 'requirements': 16606, 'seniority': 10966, 'benefits': 9, 'updated': 18018, 'seconds': 30.2}","is_error":false}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"0bd73b62-5535-440c-aea6-0d4123fac6ce","timestamp":"2026-08-25T14:19:01.402Z","tool_use_result":{"stdout":"[backfill-enrich] {'scanned': 23680, 'requirements': 16606, 'seniority': 10966, 'benefits': 9, 'updated': 18018, 'seconds': 30.2}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"c9b60e0c-2bfc-4aca-8791-93ab5c3948f7","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"2578ee69-809b-4421-9b73-bd17590130b0","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":150,"estimated_tokens_delta":100,"uuid":"df30a996-bd40-475a-8997-5610689458b4","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":200,"estimated_tokens_delta":50,"uuid":"45df2a06-c2a5-4ddb-8198-61ef13d4276c","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcTdchCuRbTADy57qXj","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISlgYKpwEIERgCKkCBJO+TYuPIW0hGKm/JUMczZgB+Q8f8GjNCpLZLpcwNuMLhbi/i/49dbeGfF8KAwLB1QQC6RVax6aK6D+o1mq5fMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAHfyLbUBrABAhIMRPGKhs83uCVYcz+kGgxZOQC3YT7sXP9lnZ4iMNzSPh+j4rQ8+CAVSesKp2m6Xlvf3WRn5KxOO4XNCnFj1A/KOPMVdDt08CDts4KGbCqbBBDOIqlm32BtD/VWMEXeGZhftblzDp2SaTKebLxyvhO8thnzvjkIybX0szff+Delk00E3g52fFQPovFiVss+/sXxhL7srspxfjM0hIRZQX1mLRum3DycpLJ8/YQv5s4+C/KWMvz2HS1/ZpCXT8Q9wkEy4gav7Xig2rNM76r/p/oxnKbi3M9srlwVd1cbuYgqVv6Qg1p2tW2UQWnYCzE+rIqWir6ui0BnlvZTGuVXjEFLax1BoXT1jvQsBtZgCKlR2i8Zqrmbmcf5Eq8uAu08sUy8kZi8szqmkEBKfjBsiSbwqp/8X8wdPKFCwJeb4zix7UOBjTzGJAbKBSVxBGIjOljD3QAi5txWpCqOAA12QdWaMETekmDcD4wiQP1kv3CodOeF7mXq945vudaANZd7Uz0kK+TWW/3Vd0uGqTST4yNrIjwQ0A5q1bCmwT06msayZnO0DrWxIs+Xvg7AK1XdCnsg9C6mc72QLvURRx0Wf6Y+XDl/7fqxt4obVu5Xv18yCNej1d6c8Ckjlwki99h/HI9+Y9GYehAvBPW7ZE4WzYBc21MpZH64dhTqDb16XBcWE6iEoCawX7sFN6jPke55WjrUM61UIua31+z9chxUgztuPnR0czOAakYC5/9U8fF0CakGcwJYmx+Y/2PJgidOI6Y8DZXy+GA+PqGuYohbJt9U24Tsr8U9YLLX76guo3FILC7rq4gVrVjiu7GSGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":279,"cache_read_input_tokens":138653,"cache_creation":{"ephemeral_5m_input_tokens":279,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"bbe6c550-5de3-4e13-88e8-ccf49a1420cb","request_id":"req_011CePcTbQyvatL5PqmX5SQc"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcTdchCuRbTADy57qXj","type":"message","role":"assistant","content":[{"type":"text","text":"Backfill spectaculaire : **16 606 offres** ont gagné des exigences structurées et **10 966** une séniorité en 30 s. Je vérifie la couverture, puis je passe au frontend :"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":279,"cache_read_input_tokens":138653,"cache_creation":{"ephemeral_5m_input_tokens":279,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"10d83eb3-7fae-4a07-a76f-a1e10432007a","request_id":"req_011CePcTbQyvatL5PqmX5SQc"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcTdchCuRbTADy57qXj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01StDVxUMz5AzJou55LRzMG4","name":"Bash","input":{"command":"sqlite3 data/jobka.db \"SELECT SUM(CASE WHEN requirements<>'{}' THEN 1 ELSE 0 END)*100/COUNT(*) pct_req, SUM(CASE WHEN seniority IS NOT NULL THEN 1 ELSE 0 END)*100/COUNT(*) pct_sen FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL; SELECT seniority, COUNT(*) FROM jobs WHERE active=1 AND dup_of IS NULL AND quarantine IS NULL AND seniority IS NOT NULL GROUP BY seniority ORDER BY 2 DESC;\"","description":"Check enrichment coverage after backfill"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":279,"cache_read_input_tokens":138653,"cache_creation":{"ephemeral_5m_input_tokens":279,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"5b9f0efd-f5c0-45d4-ac8e-43dc9b1a1aee","request_id":"req_011CePcTbQyvatL5PqmX5SQc"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01StDVxUMz5AzJou55LRzMG4","type":"tool_result","content":"77|46\nsenior|5266\njunior|2651\nintermediaire|1743\ndirection|846\nstage|332","is_error":false}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"1fb3c359-b36b-4142-9d2d-515c13732f2a","timestamp":"2026-08-25T14:19:13.662Z","tool_use_result":{"stdout":"77|46\nsenior|5266\njunior|2651\nintermediaire|1743\ndirection|846\nstage|332","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"fb2c389a-5fcf-4fc4-8626-2e19f0ac9f84","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"419292b5-5c9e-43de-a366-2d7baf4bd9e6","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":200,"estimated_tokens_delta":150,"uuid":"8bcaa466-0346-4197-804d-cba5ee4d12e2","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":300,"estimated_tokens_delta":100,"uuid":"59c7e2c5-6824-49db-82dc-fa2784f1f93d","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":400,"estimated_tokens_delta":100,"uuid":"220f164b-7a9f-4d96-8836-64a4f82a9c9a","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":450,"estimated_tokens_delta":50,"uuid":"029c2cbc-2ec1-4f8f-a515-90c3a4634e26","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":600,"estimated_tokens_delta":150,"uuid":"32bb8b97-2466-45c9-9fe8-189ce91e6340","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":650,"estimated_tokens_delta":50,"uuid":"8a19758d-1d2c-4b03-bd6d-d2b5be9f88e4","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":750,"estimated_tokens_delta":100,"uuid":"9de954a5-2836-4391-8399-d7a4ab1e2c60","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":850,"estimated_tokens_delta":100,"uuid":"d285ab37-5a66-4e0f-b492-ba0d6225c6cb","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":950,"estimated_tokens_delta":100,"uuid":"2ff68053-8429-4ae0-ba47-8c7de0ba3c64","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1050,"estimated_tokens_delta":100,"uuid":"436a237b-665c-4e12-90b8-cf0db28918be","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1150,"estimated_tokens_delta":100,"uuid":"4be54517-dd42-47ce-bf0f-1608c1c8ea86","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcUXQAXkpVUk97urMhd","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS9BYKpwEIERgCKkDzfDxJn7ULeX8hN9S0H3TCHIn9Z3jsaTaB4PJ761T5BepasBTRpQKzIn53Nk/vcwdZ2GyDlOeXqrWS+e2tNbt1Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAH4yLbUBrABAhIM0j2b0Nx5pTjL8BzgGgywL54KOsl8MI+4a9YiMETfXHo9CVxV6Pc2pjarN4l+z+IpDWtcWT/rINFM3I2OdWcGvREI1naGlc32C/wfXyr5FN5PVp0tkDVbxea/x3MOski2Zk56gztRh4u93X7aSxPAffDaGy1uQyHRnXbyON1npISZmB9cd4aJaIgqmNy0ueN3KS8PEFQflBJF8cd15Od8dUf3UBvBEcRmHM93ksu+5S3vDB3kfS9toYBTG1zALlHlTGbLBX66xJT4KjTg83KUQ0mIAxpk1I0pqhs6kpsWC6MERNqRVGAeRu2Bo6QFl4Zcz4ouCMqARPqCjOkpSkSH7Qa9cjeGVHC9O2sWAweutdnolS+SD/XQ+vmLhYQW5k2UKNHtENbkNvhvhW2xF9sWGKKrjF7lJ5aq5FhmnWb7HtnuwYSiMWJ8l2kikWysrQx8jkUQPToKANZcl9ItxhJqzIg1jK3gTHclxgzxD0dhugb0wFvg2aGJfz8id00Ahu5+GnT1apJ9sHf0Au7EoF/zsGNQNeyoON7g3kKIZCCrhZl8gMKDD8FYi/nsRT2NqSVbqhxx1viasDwAnFe6EIlL+joU0eOYORh0SkG8Emk96FSytvf6tFHnlSszV3iLr1fUcY4tg5MEAGXKJ7s7sVkILDYGKi5jaWbFAF/c5M6zwnzy/3HFsK5C8qwyiWe9MoWv25YLTxmlyNHxCKh3Fy+lDm05r/SM63rFFjyeoS0Dq2eocjtZokImONkbdrjSVQnvJWFDqae2VFJHAqW33uCZyEfzw4nv04Jx3TGR5cmC540yPbHkQxzgvk8CFAYEZmyVpFlVUL/RfqKM83jEOH2UMQfSes3VXX/GA/7/cmuOFeLIj2PWgWbmrWUUupjQ1Bn5FexuKgwWm94VHZeoh31KhYMrMr8Lp+di45F5o8zvVaShRoiq154fcvHvCC+sLHu7lpLZoSCYkQSgFKMRuvYgi1aM+QQyg+jh8pnfrj9u7KTjqPOu5k8+bUDk4iVudLl+Ikelc0lAvPw07jX0/AiVJXkzQU6qB7BjYzRSAj3e3fwCSPDMyneSusCXS6PPv2i6VgJPRG7pYv9xhAvKdAgOMlDn2n0WMCpkntJNJcQzVzEP+otpZK78xNQlFC6y9d2pPU5HLZPRpRbiHeBLI8XyzoowPdhqKUdCwsHhurKslPdxecjFMQnQFiRlwiAO/lxeR9bDEOTuWJau8vquX3p4JBHQ2T73KcXPVMJ6XQ3fdXluLhckLUTAItGOfpaIejjd8hxMUK0QjOeiiIlRJrMu7o0jPB9fBCM1BbLUaQZpk0w/JjVDE7LZpz//JkaYBz13Xz4CpTkl+SHngvb5Qq9wzC8zJtG2CduCRtguKIAxhC9ABvTjWMTQ7rsEWbl/MeGFHW62Deyefs1B+hbud+eOz1H+a5PW0kgtfyMTGunqLBafaADBNz3NS2ObGrGaQVtx+XIj5wV9J5EY0+IQ1N2BF0k9og1ynGNWJzVKfuTAyvnnv8GXR9U4RF4NJ+laRXanyS/3TeRovA9QMjvZqUaCc+eP7kmqcG2nxQgVEzeO1dVm79tJioKT4iQxd0XTw4LcgqEDmny5ygyTs/Y1yySzTkAVW4X+x4B3NszPGaYUDUSzJ2lZfdS+/m9/C7Z2RKIulguwzSID+ST0qjrfD/6WGGmz4r7ldXYfl7wRmdIArYmHZTEjh42kvlWtwciIuTon6Hti6kDlR9l6Wemv2U1UjjA+bGz0LECu6p3fvqbO92Rt/BJtwURDyFFBLvkWvKsuhTkzXUlo3Sy3qnAee4ZpQgKnntaxL67J7gvpC5d5WOMsZ2IGN1VSsKHZfiF23HzB8xvmAh6c/Pyd1IwmRzuabjl/eiyRdcCL4isy6zmm3wxIPMzJPYeSf/keY/9M3mRpwsVA6DBD2IdFWJyxccC2wey6bSabfIN5xihAOZmp9J46IsC3Pef4T5hjWF7Zrsxi35QPYOvinLrphHfwliCKJ832+sKqiuISEs0Fa4RXKpeEMODi3ZM7GynRXHI2gvsQ3x0LoMSwr4fp2JXKWtXocK4FC3KgGqCJoKpJz965+yR1dT61MysyIYg+gsVnZHKXTkxisi1G1VJZ8fyJnO1kJ9Y9FXEqPS0BD/G0YmprciLtwK6Xk8Phi24a/3zHoN5Bd3XLkdgdBCNE0Sp7SfgYkOd3CmlP67pukmr9OZgqFDRsOLwuE97q3Y1/Q+baja9h0hnNVnKy3hj4EcZ7xXpZ6gD8G4rf10L2+dppbOeZyI+mlK8VjY2OhCdYdUtl6ReGrmRKesmIot26ZDbQFiceZ8cYH4WGNzkB9c+iT5+8MN1TzCr4meszq1XsQv5UI6VVLRdMYH0VeX9hWSAOEnUuzymPm8krb11ioX70CDSSlfKt4oExrQxWpVXZvc2eeykNGMwBOg2nZwl91z8aUOnCyR1SGDspio8KsouURifvGx21R5wevKavCTz6quko7mkBMDuy0EbOfl74h7jTZHJr5JLpECAhp8yQTVPB+65j51PADDCbPi6b79PMcE6Pd2WX8gRR3ufyLUlI314zMz2Vql1n6UB46UHoypSqlNAx8zj8lIVTY7hcgs8W0Xqw81GOo6Mtlq2Bs+LENUF9aPIIwUSYggPFzyDLNe6hX/cj5qpQ+BdDImEmmUz86D+cwQldtT74q7uzYNtafCMQ+fz3GuL/ptpZAvO8f2Iwe+yz1hoDwHLjVjQfKZMbTkxSldrPko5hZd8spr3CtVbSVUFzfM528R2J8dx9MZo6guY/w1bcLJ6bCAJrmxgH3OYk4p3eH7EQOpuJYdi3Bn3HUPUODoZ0cpcwatYs4myR5fh+EPhHXL2CJtrUHGlQI4jmAIpQDd047jbSQ1obfxUQn8YUq6xNJjA4iMzVo4I2Mj8TAV+fGe+Q5y4ONQXGsqtMy5Dbn1UQ/sLBylPTrN1BLtuwvY+7nV0D1310h0EkBScFzCuzLQyNu+CPA9qTXzPX1B0r+Mm1uoWm0pXE+cNMjS75S1eey44wH4UwHygzqJNmiTUOF8Pz1o41ytbwLYnanBiuO0dV+MskruUYNxZm0Wu3MJ1zH3WHBKSKasC067nSe/sdBPmMaCC4YJh1xpNWesAuYWlEbpQ9zQywFXbeuvr5XCFgePeCDbXuMQa5lzKW54Lz+VdeL8sJ5uxUO0QBR0l+sTaNLCYsb+YBusrZFoeYzz2yDFP0jWOAqVYkjOLOUJCbjqULOLhNr0eKve377GAXs5AI5w9TQj351FtMo3X0LktiwIWr6CWJ8+dJVE1IJ/oYg1Y7PAZreMHg9mf63uxfuM3I/RHxgPVnPBEHY+9gqZDu3oA7sO6DOLIrDSZf2JHjThUJoZpWk3b+aOFStuRpXHCKLm8f4irNnyRMfFHYiG6t8bF8earN1PqhQOn2WxqHMoH1v3hgfDaCMR0JFfsrp+S5T0+IRKM8FahPg1TD4sxZshlnVPNZNOsfKiUSqjO2zNiGKwQIh3KvwND+L/07uQ+ophMDR/WZsXYccOGflZuD0g8Cg2UQe7zLkxOBIg/tn1spGUbTJ+7DMlOu587MZQ06KdxwMOAKr86WW72uQ2c27/sK2pA4p8BarxvAeDpvpCi3hUHyAjEfx7aKnEy9NDFnKgJvGsj+DDyaDWmyyElad8VDS/kMGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":757,"cache_read_input_tokens":138932,"cache_creation":{"ephemeral_5m_input_tokens":757,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"e1abe8e8-94df-4301-a58a-ad3dab158666","request_id":"req_011CePcUVngHNk7TwQ3qWBMP"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcUXQAXkpVUk97urMhd","type":"message","role":"assistant","content":[{"type":"text","text":"Couverture : **exigences 26 % → 77 %**, **séniorité 0 → 46 %** des offres publiables. Place au frontend — d'abord les types et helpers (`api.ts`) :"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":757,"cache_read_input_tokens":138932,"cache_creation":{"ephemeral_5m_input_tokens":757,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"43349677-371d-48a6-a6a6-d414184701c3","request_id":"req_011CePcUVngHNk7TwQ3qWBMP"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcUXQAXkpVUk97urMhd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Q6SeHehZUCmvhCGCkWkBYv","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","old_string":" * Fichier : frontend/src/api.ts\n * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n * Créé : 2026-08-17 Modifié : 2026-08-17","new_string":" * Fichier : frontend/src/api.ts\n * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n * Créé : 2026-08-17 Modifié : 2026-08-25"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":757,"cache_read_input_tokens":138932,"cache_creation":{"ephemeral_5m_input_tokens":757,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"c6984d06-06d1-48c5-9f4c-043659d46d7f","request_id":"req_011CePcUVngHNk7TwQ3qWBMP"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Q6SeHehZUCmvhCGCkWkBYv","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"16cd9925-2c17-400d-84e4-8e5d767b8c60","timestamp":"2026-08-25T14:19:39.567Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","oldString":" * Fichier : frontend/src/api.ts\n * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n * Créé : 2026-08-17 Modifié : 2026-08-17","newString":" * Fichier : frontend/src/api.ts\n * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n * Créé : 2026-08-17 Modifié : 2026-08-25","originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/api.ts\n * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n * Créé : 2026-08-17 Modifié : 2026-08-17\n * =============================================================================\n */\n\nexport interface Job {\n uid: string;\n source: string;\n external_id: string;\n url: string;\n employer: string;\n title: string;\n description?: string;\n address: string;\n city: string;\n region: string;\n postal_code: string;\n location_label: string;\n work_mode: string | null;\n employment_type: string | null;\n salary_min: number | null;\n salary_max: number | null;\n salary_unit: string | null;\n salary_label: string;\n salary_year_min: number | null;\n salary_year_max: number | null;\n salary_hour_min: number | null;\n salary_hour_max: number | null;\n benefits: string[];\n requirements: Record;\n date_posted: string | null;\n date_deadline: string | null;\n category: string;\n ats: string;\n details: Record;\n lat: number | null;\n lng: number | null;\n active: number;\n dup_sources?: string[];\n is_direct?: boolean;\n company_logo?: string | null;\n language?: string | null; // fr | en | bilingue\n apply_url?: string | null; // candidature directe (≠ url de la fiche)\n title_clean?: string | null; // titre d'affichage normalisé\n}\n\nexport interface JobList {\n total: number;\n count: number;\n jobs: Job[];\n}\n\nexport interface Facets {\n cities: string[];\n regions: { region: string; n: number }[];\n languages: { language: string; n: number }[];\n categories: { category: string; n: number }[];\n employers: { employer: string; n: number }[];\n work_modes: string[];\n employment_types: string[];\n sources: { source: string; n: number }[];\n}\n\nexport interface Source {\n id: string;\n name: string;\n url: string;\n careers_url: string;\n sectors: string[];\n connector: string;\n status: string;\n region: string;\n active_jobs: number;\n last_sync: number | null;\n}\n\nexport interface Stats {\n total: number;\n employers: number;\n sources: number;\n with_salary: number;\n avg_salary_year: number | null;\n remote: number;\n geocoded: number;\n direct_jobs?: number;\n direct_employers?: number;\n direct_sources?: number;\n portal_jobs?: number;\n duplicates_hidden?: number;\n top_cities: { city: string; n: number }[];\n categories: { category: string; n: number }[];\n recent_syncs: {\n source: string; ts: number; found: number; added: number;\n updated: number; removed: number; ok: number; message: string;\n }[];\n}\n\nasync function get(path: string, params?: Record): Promise {\n const url = new URL(path, window.location.origin);\n if (params) {\n for (const [k, v] of Object.entries(params)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n }\n const ctl = new AbortController();\n const timer = setTimeout(() => ctl.abort(), 20000);\n try {\n const resp = await fetch(url.toString(), { signal: ctl.signal });\n if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);\n return (await resp.json()) as T;\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport interface JobFilters {\n q?: string;\n city?: string;\n region?: string;\n category?: string;\n work_mode?: string;\n employment_type?: string;\n language?: string;\n salary_min?: number;\n with_salary?: number;\n sort?: string;\n}\n\nexport const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>\n get(\"/api/jobs\", { ...filters, limit, offset });\n\nexport const fetchJob = (uid: string) => get(`/api/jobs/${uid}`);\n\nexport const fetchFacets = () => get(\"/api/facets\");\n\nexport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n\nexport const fetchStats = () => get(\"/api/stats\");\n\nexport const geojsonUrl = (filters: JobFilters) => {\n const url = new URL(\"/api/jobs.geojson\", window.location.origin);\n for (const [k, v] of Object.entries(filters)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n return url.toString();\n};\n\n// --- présentation ------------------------------------------------------------\n\nconst UNIT_FR: Record = {\n hour: \"h\", day: \"jour\", week: \"sem.\", biweek: \"2 sem.\", month: \"mois\", year: \"an\",\n};\n\nexport function formatSalary(job: Pick): string | null {\n if (job.salary_min == null || !job.salary_unit) return null;\n const fmt = (n: number) =>\n n >= 1000 ? n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })\n : n.toLocaleString(\"fr-CA\", { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;\n if (job.salary_max != null && job.salary_max > job.salary_min) {\n return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;\n }\n return `${fmt(job.salary_min)} $ / ${unit}`;\n}\n\nexport const MODE_FR: Record = {\n presentiel: \"Présentiel\", hybride: \"Hybride\", teletravail: \"Télétravail\",\n};\n\nexport const TYPE_FR: Record = {\n temps_plein: \"Temps plein\", temps_partiel: \"Temps partiel\",\n contractuel: \"Contractuel\", stage: \"Stage\", saisonnier: \"Saisonnier\",\n};\n\nexport const LANG_FR: Record = {\n fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n};\n\n/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */\nexport const displayTitle = (job: Pick) =>\n job.title_clean || job.title;\n\nexport function formatDate(iso: string | null): string {\n if (!iso) return \"\";\n const d = new Date(`${iso}T12:00:00`);\n return d.toLocaleDateString(\"fr-CA\", { day: \"numeric\", month: \"long\", year: \"numeric\" });\n}\n","structuredPatch":[{"oldStart":5,"oldLines":7,"newStart":5,"newLines":7,"lines":[" * Contact : contact@spboucher.ai"," * Fichier : frontend/src/api.ts"," * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py","- * Créé : 2026-08-17 Modifié : 2026-08-17","+ * Créé : 2026-08-17 Modifié : 2026-08-25"," * ============================================================================="," */"," "]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcUXQAXkpVUk97urMhd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MJvXHWyvgMW9QZTtjtQuHk","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","old_string":" benefits: string[];\n requirements: Record;\n date_posted: string | null;","new_string":" benefits: string[];\n requirements: JobRequirements;\n date_posted: string | null;"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":757,"cache_read_input_tokens":138932,"cache_creation":{"ephemeral_5m_input_tokens":757,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"58ee8dfa-0916-4faf-ac59-88e3bf5ec3cd","request_id":"req_011CePcUVngHNk7TwQ3qWBMP"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MJvXHWyvgMW9QZTtjtQuHk","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"6210d44b-bd0e-49f9-beb6-4fa8444e29cc","timestamp":"2026-08-25T14:19:40.980Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","oldString":" benefits: string[];\n requirements: Record;\n date_posted: string | null;","newString":" benefits: string[];\n requirements: JobRequirements;\n date_posted: string | null;","originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/api.ts\n * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\n\nexport interface Job {\n uid: string;\n source: string;\n external_id: string;\n url: string;\n employer: string;\n title: string;\n description?: string;\n address: string;\n city: string;\n region: string;\n postal_code: string;\n location_label: string;\n work_mode: string | null;\n employment_type: string | null;\n salary_min: number | null;\n salary_max: number | null;\n salary_unit: string | null;\n salary_label: string;\n salary_year_min: number | null;\n salary_year_max: number | null;\n salary_hour_min: number | null;\n salary_hour_max: number | null;\n benefits: string[];\n requirements: Record;\n date_posted: string | null;\n date_deadline: string | null;\n category: string;\n ats: string;\n details: Record;\n lat: number | null;\n lng: number | null;\n active: number;\n dup_sources?: string[];\n is_direct?: boolean;\n company_logo?: string | null;\n language?: string | null; // fr | en | bilingue\n apply_url?: string | null; // candidature directe (≠ url de la fiche)\n title_clean?: string | null; // titre d'affichage normalisé\n}\n\nexport interface JobList {\n total: number;\n count: number;\n jobs: Job[];\n}\n\nexport interface Facets {\n cities: string[];\n regions: { region: string; n: number }[];\n languages: { language: string; n: number }[];\n categories: { category: string; n: number }[];\n employers: { employer: string; n: number }[];\n work_modes: string[];\n employment_types: string[];\n sources: { source: string; n: number }[];\n}\n\nexport interface Source {\n id: string;\n name: string;\n url: string;\n careers_url: string;\n sectors: string[];\n connector: string;\n status: string;\n region: string;\n active_jobs: number;\n last_sync: number | null;\n}\n\nexport interface Stats {\n total: number;\n employers: number;\n sources: number;\n with_salary: number;\n avg_salary_year: number | null;\n remote: number;\n geocoded: number;\n direct_jobs?: number;\n direct_employers?: number;\n direct_sources?: number;\n portal_jobs?: number;\n duplicates_hidden?: number;\n top_cities: { city: string; n: number }[];\n categories: { category: string; n: number }[];\n recent_syncs: {\n source: string; ts: number; found: number; added: number;\n updated: number; removed: number; ok: number; message: string;\n }[];\n}\n\nasync function get(path: string, params?: Record): Promise {\n const url = new URL(path, window.location.origin);\n if (params) {\n for (const [k, v] of Object.entries(params)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n }\n const ctl = new AbortController();\n const timer = setTimeout(() => ctl.abort(), 20000);\n try {\n const resp = await fetch(url.toString(), { signal: ctl.signal });\n if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);\n return (await resp.json()) as T;\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport interface JobFilters {\n q?: string;\n city?: string;\n region?: string;\n category?: string;\n work_mode?: string;\n employment_type?: string;\n language?: string;\n salary_min?: number;\n with_salary?: number;\n sort?: string;\n}\n\nexport const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>\n get(\"/api/jobs\", { ...filters, limit, offset });\n\nexport const fetchJob = (uid: string) => get(`/api/jobs/${uid}`);\n\nexport const fetchFacets = () => get(\"/api/facets\");\n\nexport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n\nexport const fetchStats = () => get(\"/api/stats\");\n\nexport const geojsonUrl = (filters: JobFilters) => {\n const url = new URL(\"/api/jobs.geojson\", window.location.origin);\n for (const [k, v] of Object.entries(filters)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n return url.toString();\n};\n\n// --- présentation ------------------------------------------------------------\n\nconst UNIT_FR: Record = {\n hour: \"h\", day: \"jour\", week: \"sem.\", biweek: \"2 sem.\", month: \"mois\", year: \"an\",\n};\n\nexport function formatSalary(job: Pick): string | null {\n if (job.salary_min == null || !job.salary_unit) return null;\n const fmt = (n: number) =>\n n >= 1000 ? n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })\n : n.toLocaleString(\"fr-CA\", { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;\n if (job.salary_max != null && job.salary_max > job.salary_min) {\n return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;\n }\n return `${fmt(job.salary_min)} $ / ${unit}`;\n}\n\nexport const MODE_FR: Record = {\n presentiel: \"Présentiel\", hybride: \"Hybride\", teletravail: \"Télétravail\",\n};\n\nexport const TYPE_FR: Record = {\n temps_plein: \"Temps plein\", temps_partiel: \"Temps partiel\",\n contractuel: \"Contractuel\", stage: \"Stage\", saisonnier: \"Saisonnier\",\n};\n\nexport const LANG_FR: Record = {\n fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n};\n\n/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */\nexport const displayTitle = (job: Pick) =>\n job.title_clean || job.title;\n\nexport function formatDate(iso: string | null): string {\n if (!iso) return \"\";\n const d = new Date(`${iso}T12:00:00`);\n return d.toLocaleDateString(\"fr-CA\", { day: \"numeric\", month: \"long\", year: \"numeric\" });\n}\n","structuredPatch":[{"oldStart":33,"oldLines":7,"newStart":33,"newLines":7,"lines":[" salary_hour_min: number | null;"," salary_hour_max: number | null;"," benefits: string[];","- requirements: Record;","+ requirements: JobRequirements;"," date_posted: string | null;"," date_deadline: string | null;"," category: string;"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcUXQAXkpVUk97urMhd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EMjmBmSWoXCSwKp9L6jWFd","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","old_string":" language?: string | null; // fr | en | bilingue\n apply_url?: string | null; // candidature directe (≠ url de la fiche)\n title_clean?: string | null; // titre d'affichage normalisé\n}","new_string":" language?: string | null; // fr | en | bilingue\n apply_url?: string | null; // candidature directe (≠ url de la fiche)\n title_clean?: string | null; // titre d'affichage normalisé\n seniority?: string | null; // stage | junior | intermediaire | senior | direction\n first_seen?: number | null; // epoch : première collecte par Job·Ka\n // fiche seulement (GET /api/jobs/{uid}) :\n salary_context?: SalaryContext | null;\n employer_jobs?: number; // offres actives du même employeur\n salary_history?: SalaryPoint[];\n}\n\n/** Exigences structurées extraites de l'offre (clés présentes si explicites). */\nexport interface JobRequirements {\n experience_years?: number; // années d'expérience minimales exigées\n education?: string; // plus bas diplôme exigé (DEC, baccalauréat…)\n languages?: string[]; // langues exigées\n [k: string]: unknown; // champs bruts hérités des ATS\n}\n\n/** Salaire de l'offre vs marché de sa catégorie (médiane/quartiles, $/an). */\nexport interface SalaryContext {\n category: string;\n n: number; // offres avec salaire dans la catégorie\n p25: number;\n median: number;\n p75: number;\n job_mid?: number; // point médian de la fourchette de l'offre\n delta_pct?: number; // écart % vs médiane de la catégorie\n}\n\nexport interface SalaryPoint {\n ts: number;\n s_min: number | null;\n s_max: number | null;\n unit: string | null;\n}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":757,"cache_read_input_tokens":138932,"cache_creation":{"ephemeral_5m_input_tokens":757,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"9d1a842c-1d0e-48a9-9a5d-0415a581ee61","request_id":"req_011CePcUVngHNk7TwQ3qWBMP"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EMjmBmSWoXCSwKp9L6jWFd","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"18a9bbfa-c3bd-4c8e-bfd1-2dbd7329a74f","timestamp":"2026-08-25T14:19:48.745Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","oldString":" language?: string | null; // fr | en | bilingue\n apply_url?: string | null; // candidature directe (≠ url de la fiche)\n title_clean?: string | null; // titre d'affichage normalisé\n}","newString":" language?: string | null; // fr | en | bilingue\n apply_url?: string | null; // candidature directe (≠ url de la fiche)\n title_clean?: string | null; // titre d'affichage normalisé\n seniority?: string | null; // stage | junior | intermediaire | senior | direction\n first_seen?: number | null; // epoch : première collecte par Job·Ka\n // fiche seulement (GET /api/jobs/{uid}) :\n salary_context?: SalaryContext | null;\n employer_jobs?: number; // offres actives du même employeur\n salary_history?: SalaryPoint[];\n}\n\n/** Exigences structurées extraites de l'offre (clés présentes si explicites). */\nexport interface JobRequirements {\n experience_years?: number; // années d'expérience minimales exigées\n education?: string; // plus bas diplôme exigé (DEC, baccalauréat…)\n languages?: string[]; // langues exigées\n [k: string]: unknown; // champs bruts hérités des ATS\n}\n\n/** Salaire de l'offre vs marché de sa catégorie (médiane/quartiles, $/an). */\nexport interface SalaryContext {\n category: string;\n n: number; // offres avec salaire dans la catégorie\n p25: number;\n median: number;\n p75: number;\n job_mid?: number; // point médian de la fourchette de l'offre\n delta_pct?: number; // écart % vs médiane de la catégorie\n}\n\nexport interface SalaryPoint {\n ts: number;\n s_min: number | null;\n s_max: number | null;\n unit: string | null;\n}","originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/api.ts\n * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\n\nexport interface Job {\n uid: string;\n source: string;\n external_id: string;\n url: string;\n employer: string;\n title: string;\n description?: string;\n address: string;\n city: string;\n region: string;\n postal_code: string;\n location_label: string;\n work_mode: string | null;\n employment_type: string | null;\n salary_min: number | null;\n salary_max: number | null;\n salary_unit: string | null;\n salary_label: string;\n salary_year_min: number | null;\n salary_year_max: number | null;\n salary_hour_min: number | null;\n salary_hour_max: number | null;\n benefits: string[];\n requirements: JobRequirements;\n date_posted: string | null;\n date_deadline: string | null;\n category: string;\n ats: string;\n details: Record;\n lat: number | null;\n lng: number | null;\n active: number;\n dup_sources?: string[];\n is_direct?: boolean;\n company_logo?: string | null;\n language?: string | null; // fr | en | bilingue\n apply_url?: string | null; // candidature directe (≠ url de la fiche)\n title_clean?: string | null; // titre d'affichage normalisé\n}\n\nexport interface JobList {\n total: number;\n count: number;\n jobs: Job[];\n}\n\nexport interface Facets {\n cities: string[];\n regions: { region: string; n: number }[];\n languages: { language: string; n: number }[];\n categories: { category: string; n: number }[];\n employers: { employer: string; n: number }[];\n work_modes: string[];\n employment_types: string[];\n sources: { source: string; n: number }[];\n}\n\nexport interface Source {\n id: string;\n name: string;\n url: string;\n careers_url: string;\n sectors: string[];\n connector: string;\n status: string;\n region: string;\n active_jobs: number;\n last_sync: number | null;\n}\n\nexport interface Stats {\n total: number;\n employers: number;\n sources: number;\n with_salary: number;\n avg_salary_year: number | null;\n remote: number;\n geocoded: number;\n direct_jobs?: number;\n direct_employers?: number;\n direct_sources?: number;\n portal_jobs?: number;\n duplicates_hidden?: number;\n top_cities: { city: string; n: number }[];\n categories: { category: string; n: number }[];\n recent_syncs: {\n source: string; ts: number; found: number; added: number;\n updated: number; removed: number; ok: number; message: string;\n }[];\n}\n\nasync function get(path: string, params?: Record): Promise {\n const url = new URL(path, window.location.origin);\n if (params) {\n for (const [k, v] of Object.entries(params)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n }\n const ctl = new AbortController();\n const timer = setTimeout(() => ctl.abort(), 20000);\n try {\n const resp = await fetch(url.toString(), { signal: ctl.signal });\n if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);\n return (await resp.json()) as T;\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport interface JobFilters {\n q?: string;\n city?: string;\n region?: string;\n category?: string;\n work_mode?: string;\n employment_type?: string;\n language?: string;\n salary_min?: number;\n with_salary?: number;\n sort?: string;\n}\n\nexport const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>\n get(\"/api/jobs\", { ...filters, limit, offset });\n\nexport const fetchJob = (uid: string) => get(`/api/jobs/${uid}`);\n\nexport const fetchFacets = () => get(\"/api/facets\");\n\nexport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n\nexport const fetchStats = () => get(\"/api/stats\");\n\nexport const geojsonUrl = (filters: JobFilters) => {\n const url = new URL(\"/api/jobs.geojson\", window.location.origin);\n for (const [k, v] of Object.entries(filters)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n return url.toString();\n};\n\n// --- présentation ------------------------------------------------------------\n\nconst UNIT_FR: Record = {\n hour: \"h\", day: \"jour\", week: \"sem.\", biweek: \"2 sem.\", month: \"mois\", year: \"an\",\n};\n\nexport function formatSalary(job: Pick): string | null {\n if (job.salary_min == null || !job.salary_unit) return null;\n const fmt = (n: number) =>\n n >= 1000 ? n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })\n : n.toLocaleString(\"fr-CA\", { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;\n if (job.salary_max != null && job.salary_max > job.salary_min) {\n return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;\n }\n return `${fmt(job.salary_min)} $ / ${unit}`;\n}\n\nexport const MODE_FR: Record = {\n presentiel: \"Présentiel\", hybride: \"Hybride\", teletravail: \"Télétravail\",\n};\n\nexport const TYPE_FR: Record = {\n temps_plein: \"Temps plein\", temps_partiel: \"Temps partiel\",\n contractuel: \"Contractuel\", stage: \"Stage\", saisonnier: \"Saisonnier\",\n};\n\nexport const LANG_FR: Record = {\n fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n};\n\n/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */\nexport const displayTitle = (job: Pick) =>\n job.title_clean || job.title;\n\nexport function formatDate(iso: string | null): string {\n if (!iso) return \"\";\n const d = new Date(`${iso}T12:00:00`);\n return d.toLocaleDateString(\"fr-CA\", { day: \"numeric\", month: \"long\", year: \"numeric\" });\n}\n","structuredPatch":[{"oldStart":48,"oldLines":8,"newStart":48,"newLines":40,"lines":[" language?: string | null; // fr | en | bilingue"," apply_url?: string | null; // candidature directe (≠ url de la fiche)"," title_clean?: string | null; // titre d'affichage normalisé","+ seniority?: string | null; // stage | junior | intermediaire | senior | direction","+ first_seen?: number | null; // epoch : première collecte par Job·Ka","+ // fiche seulement (GET /api/jobs/{uid}) :","+ salary_context?: SalaryContext | null;","+ employer_jobs?: number; // offres actives du même employeur","+ salary_history?: SalaryPoint[];"," }"," ","+/** Exigences structurées extraites de l'offre (clés présentes si explicites). */","+export interface JobRequirements {","+ experience_years?: number; // années d'expérience minimales exigées","+ education?: string; // plus bas diplôme exigé (DEC, baccalauréat…)","+ languages?: string[]; // langues exigées","+ [k: string]: unknown; // champs bruts hérités des ATS","+}","+","+/** Salaire de l'offre vs marché de sa catégorie (médiane/quartiles, $/an). */","+export interface SalaryContext {","+ category: string;","+ n: number; // offres avec salaire dans la catégorie","+ p25: number;","+ median: number;","+ p75: number;","+ job_mid?: number; // point médian de la fourchette de l'offre","+ delta_pct?: number; // écart % vs médiane de la catégorie","+}","+","+export interface SalaryPoint {","+ ts: number;","+ s_min: number | null;","+ s_max: number | null;","+ unit: string | null;","+}","+"," export interface JobList {"," total: number;"," count: number;"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcUXQAXkpVUk97urMhd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NvDQK1xGsgbt81uh8igcML","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","old_string":" work_modes: string[];\n employment_types: string[];\n sources: { source: string; n: number }[];\n}","new_string":" work_modes: string[];\n employment_types: string[];\n seniorities: { seniority: string; n: number }[];\n sources: { source: string; n: number }[];\n}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":757,"cache_read_input_tokens":138932,"cache_creation":{"ephemeral_5m_input_tokens":757,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"f0a1bb23-edf2-4dc2-b2dd-116d0913a61f","request_id":"req_011CePcUVngHNk7TwQ3qWBMP"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NvDQK1xGsgbt81uh8igcML","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"1b7176a1-e4df-43a2-868e-ae9f395e3e9a","timestamp":"2026-08-25T14:19:50.863Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","oldString":" work_modes: string[];\n employment_types: string[];\n sources: { source: string; n: number }[];\n}","newString":" work_modes: string[];\n employment_types: string[];\n seniorities: { seniority: string; n: number }[];\n sources: { source: string; n: number }[];\n}","originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/api.ts\n * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\n\nexport interface Job {\n uid: string;\n source: string;\n external_id: string;\n url: string;\n employer: string;\n title: string;\n description?: string;\n address: string;\n city: string;\n region: string;\n postal_code: string;\n location_label: string;\n work_mode: string | null;\n employment_type: string | null;\n salary_min: number | null;\n salary_max: number | null;\n salary_unit: string | null;\n salary_label: string;\n salary_year_min: number | null;\n salary_year_max: number | null;\n salary_hour_min: number | null;\n salary_hour_max: number | null;\n benefits: string[];\n requirements: JobRequirements;\n date_posted: string | null;\n date_deadline: string | null;\n category: string;\n ats: string;\n details: Record;\n lat: number | null;\n lng: number | null;\n active: number;\n dup_sources?: string[];\n is_direct?: boolean;\n company_logo?: string | null;\n language?: string | null; // fr | en | bilingue\n apply_url?: string | null; // candidature directe (≠ url de la fiche)\n title_clean?: string | null; // titre d'affichage normalisé\n seniority?: string | null; // stage | junior | intermediaire | senior | direction\n first_seen?: number | null; // epoch : première collecte par Job·Ka\n // fiche seulement (GET /api/jobs/{uid}) :\n salary_context?: SalaryContext | null;\n employer_jobs?: number; // offres actives du même employeur\n salary_history?: SalaryPoint[];\n}\n\n/** Exigences structurées extraites de l'offre (clés présentes si explicites). */\nexport interface JobRequirements {\n experience_years?: number; // années d'expérience minimales exigées\n education?: string; // plus bas diplôme exigé (DEC, baccalauréat…)\n languages?: string[]; // langues exigées\n [k: string]: unknown; // champs bruts hérités des ATS\n}\n\n/** Salaire de l'offre vs marché de sa catégorie (médiane/quartiles, $/an). */\nexport interface SalaryContext {\n category: string;\n n: number; // offres avec salaire dans la catégorie\n p25: number;\n median: number;\n p75: number;\n job_mid?: number; // point médian de la fourchette de l'offre\n delta_pct?: number; // écart % vs médiane de la catégorie\n}\n\nexport interface SalaryPoint {\n ts: number;\n s_min: number | null;\n s_max: number | null;\n unit: string | null;\n}\n\nexport interface JobList {\n total: number;\n count: number;\n jobs: Job[];\n}\n\nexport interface Facets {\n cities: string[];\n regions: { region: string; n: number }[];\n languages: { language: string; n: number }[];\n categories: { category: string; n: number }[];\n employers: { employer: string; n: number }[];\n work_modes: string[];\n employment_types: string[];\n sources: { source: string; n: number }[];\n}\n\nexport interface Source {\n id: string;\n name: string;\n url: string;\n careers_url: string;\n sectors: string[];\n connector: string;\n status: string;\n region: string;\n active_jobs: number;\n last_sync: number | null;\n}\n\nexport interface Stats {\n total: number;\n employers: number;\n sources: number;\n with_salary: number;\n avg_salary_year: number | null;\n remote: number;\n geocoded: number;\n direct_jobs?: number;\n direct_employers?: number;\n direct_sources?: number;\n portal_jobs?: number;\n duplicates_hidden?: number;\n top_cities: { city: string; n: number }[];\n categories: { category: string; n: number }[];\n recent_syncs: {\n source: string; ts: number; found: number; added: number;\n updated: number; removed: number; ok: number; message: string;\n }[];\n}\n\nasync function get(path: string, params?: Record): Promise {\n const url = new URL(path, window.location.origin);\n if (params) {\n for (const [k, v] of Object.entries(params)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n }\n const ctl = new AbortController();\n const timer = setTimeout(() => ctl.abort(), 20000);\n try {\n const resp = await fetch(url.toString(), { signal: ctl.signal });\n if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);\n return (await resp.json()) as T;\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport interface JobFilters {\n q?: string;\n city?: string;\n region?: string;\n category?: string;\n work_mode?: string;\n employment_type?: string;\n language?: string;\n salary_min?: number;\n with_salary?: number;\n sort?: string;\n}\n\nexport const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>\n get(\"/api/jobs\", { ...filters, limit, offset });\n\nexport const fetchJob = (uid: string) => get(`/api/jobs/${uid}`);\n\nexport const fetchFacets = () => get(\"/api/facets\");\n\nexport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n\nexport const fetchStats = () => get(\"/api/stats\");\n\nexport const geojsonUrl = (filters: JobFilters) => {\n const url = new URL(\"/api/jobs.geojson\", window.location.origin);\n for (const [k, v] of Object.entries(filters)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n return url.toString();\n};\n\n// --- présentation ------------------------------------------------------------\n\nconst UNIT_FR: Record = {\n hour: \"h\", day: \"jour\", week: \"sem.\", biweek: \"2 sem.\", month: \"mois\", year: \"an\",\n};\n\nexport function formatSalary(job: Pick): string | null {\n if (job.salary_min == null || !job.salary_unit) return null;\n const fmt = (n: number) =>\n n >= 1000 ? n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })\n : n.toLocaleString(\"fr-CA\", { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;\n if (job.salary_max != null && job.salary_max > job.salary_min) {\n return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;\n }\n return `${fmt(job.salary_min)} $ / ${unit}`;\n}\n\nexport const MODE_FR: Record = {\n presentiel: \"Présentiel\", hybride: \"Hybride\", teletravail: \"Télétravail\",\n};\n\nexport const TYPE_FR: Record = {\n temps_plein: \"Temps plein\", temps_partiel: \"Temps partiel\",\n contractuel: \"Contractuel\", stage: \"Stage\", saisonnier: \"Saisonnier\",\n};\n\nexport const LANG_FR: Record = {\n fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n};\n\n/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */\nexport const displayTitle = (job: Pick) =>\n job.title_clean || job.title;\n\nexport function formatDate(iso: string | null): string {\n if (!iso) return \"\";\n const d = new Date(`${iso}T12:00:00`);\n return d.toLocaleDateString(\"fr-CA\", { day: \"numeric\", month: \"long\", year: \"numeric\" });\n}\n","structuredPatch":[{"oldStart":96,"oldLines":6,"newStart":96,"newLines":7,"lines":[" employers: { employer: string; n: number }[];"," work_modes: string[];"," employment_types: string[];","+ seniorities: { seniority: string; n: number }[];"," sources: { source: string; n: number }[];"," }"," "]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcUXQAXkpVUk97urMhd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Vgu6x1aJfBiiR2kiGxRACA","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","old_string":" work_mode?: string;\n employment_type?: string;\n language?: string;\n salary_min?: number;\n with_salary?: number;\n sort?: string;\n}","new_string":" work_mode?: string;\n employment_type?: string;\n seniority?: string;\n language?: string;\n salary_min?: number;\n with_salary?: number;\n sort?: string;\n}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":757,"cache_read_input_tokens":138932,"cache_creation":{"ephemeral_5m_input_tokens":757,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"c529e0dc-d82c-4b61-98db-0e6656b07aee","request_id":"req_011CePcUVngHNk7TwQ3qWBMP"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Vgu6x1aJfBiiR2kiGxRACA","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"9fc49371-d47e-4ece-aa75-38a0a95fbe14","timestamp":"2026-08-25T14:19:53.021Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","oldString":" work_mode?: string;\n employment_type?: string;\n language?: string;\n salary_min?: number;\n with_salary?: number;\n sort?: string;\n}","newString":" work_mode?: string;\n employment_type?: string;\n seniority?: string;\n language?: string;\n salary_min?: number;\n with_salary?: number;\n sort?: string;\n}","originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/api.ts\n * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\n\nexport interface Job {\n uid: string;\n source: string;\n external_id: string;\n url: string;\n employer: string;\n title: string;\n description?: string;\n address: string;\n city: string;\n region: string;\n postal_code: string;\n location_label: string;\n work_mode: string | null;\n employment_type: string | null;\n salary_min: number | null;\n salary_max: number | null;\n salary_unit: string | null;\n salary_label: string;\n salary_year_min: number | null;\n salary_year_max: number | null;\n salary_hour_min: number | null;\n salary_hour_max: number | null;\n benefits: string[];\n requirements: JobRequirements;\n date_posted: string | null;\n date_deadline: string | null;\n category: string;\n ats: string;\n details: Record;\n lat: number | null;\n lng: number | null;\n active: number;\n dup_sources?: string[];\n is_direct?: boolean;\n company_logo?: string | null;\n language?: string | null; // fr | en | bilingue\n apply_url?: string | null; // candidature directe (≠ url de la fiche)\n title_clean?: string | null; // titre d'affichage normalisé\n seniority?: string | null; // stage | junior | intermediaire | senior | direction\n first_seen?: number | null; // epoch : première collecte par Job·Ka\n // fiche seulement (GET /api/jobs/{uid}) :\n salary_context?: SalaryContext | null;\n employer_jobs?: number; // offres actives du même employeur\n salary_history?: SalaryPoint[];\n}\n\n/** Exigences structurées extraites de l'offre (clés présentes si explicites). */\nexport interface JobRequirements {\n experience_years?: number; // années d'expérience minimales exigées\n education?: string; // plus bas diplôme exigé (DEC, baccalauréat…)\n languages?: string[]; // langues exigées\n [k: string]: unknown; // champs bruts hérités des ATS\n}\n\n/** Salaire de l'offre vs marché de sa catégorie (médiane/quartiles, $/an). */\nexport interface SalaryContext {\n category: string;\n n: number; // offres avec salaire dans la catégorie\n p25: number;\n median: number;\n p75: number;\n job_mid?: number; // point médian de la fourchette de l'offre\n delta_pct?: number; // écart % vs médiane de la catégorie\n}\n\nexport interface SalaryPoint {\n ts: number;\n s_min: number | null;\n s_max: number | null;\n unit: string | null;\n}\n\nexport interface JobList {\n total: number;\n count: number;\n jobs: Job[];\n}\n\nexport interface Facets {\n cities: string[];\n regions: { region: string; n: number }[];\n languages: { language: string; n: number }[];\n categories: { category: string; n: number }[];\n employers: { employer: string; n: number }[];\n work_modes: string[];\n employment_types: string[];\n seniorities: { seniority: string; n: number }[];\n sources: { source: string; n: number }[];\n}\n\nexport interface Source {\n id: string;\n name: string;\n url: string;\n careers_url: string;\n sectors: string[];\n connector: string;\n status: string;\n region: string;\n active_jobs: number;\n last_sync: number | null;\n}\n\nexport interface Stats {\n total: number;\n employers: number;\n sources: number;\n with_salary: number;\n avg_salary_year: number | null;\n remote: number;\n geocoded: number;\n direct_jobs?: number;\n direct_employers?: number;\n direct_sources?: number;\n portal_jobs?: number;\n duplicates_hidden?: number;\n top_cities: { city: string; n: number }[];\n categories: { category: string; n: number }[];\n recent_syncs: {\n source: string; ts: number; found: number; added: number;\n updated: number; removed: number; ok: number; message: string;\n }[];\n}\n\nasync function get(path: string, params?: Record): Promise {\n const url = new URL(path, window.location.origin);\n if (params) {\n for (const [k, v] of Object.entries(params)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n }\n const ctl = new AbortController();\n const timer = setTimeout(() => ctl.abort(), 20000);\n try {\n const resp = await fetch(url.toString(), { signal: ctl.signal });\n if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);\n return (await resp.json()) as T;\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport interface JobFilters {\n q?: string;\n city?: string;\n region?: string;\n category?: string;\n work_mode?: string;\n employment_type?: string;\n language?: string;\n salary_min?: number;\n with_salary?: number;\n sort?: string;\n}\n\nexport const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>\n get(\"/api/jobs\", { ...filters, limit, offset });\n\nexport const fetchJob = (uid: string) => get(`/api/jobs/${uid}`);\n\nexport const fetchFacets = () => get(\"/api/facets\");\n\nexport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n\nexport const fetchStats = () => get(\"/api/stats\");\n\nexport const geojsonUrl = (filters: JobFilters) => {\n const url = new URL(\"/api/jobs.geojson\", window.location.origin);\n for (const [k, v] of Object.entries(filters)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n return url.toString();\n};\n\n// --- présentation ------------------------------------------------------------\n\nconst UNIT_FR: Record = {\n hour: \"h\", day: \"jour\", week: \"sem.\", biweek: \"2 sem.\", month: \"mois\", year: \"an\",\n};\n\nexport function formatSalary(job: Pick): string | null {\n if (job.salary_min == null || !job.salary_unit) return null;\n const fmt = (n: number) =>\n n >= 1000 ? n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })\n : n.toLocaleString(\"fr-CA\", { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;\n if (job.salary_max != null && job.salary_max > job.salary_min) {\n return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;\n }\n return `${fmt(job.salary_min)} $ / ${unit}`;\n}\n\nexport const MODE_FR: Record = {\n presentiel: \"Présentiel\", hybride: \"Hybride\", teletravail: \"Télétravail\",\n};\n\nexport const TYPE_FR: Record = {\n temps_plein: \"Temps plein\", temps_partiel: \"Temps partiel\",\n contractuel: \"Contractuel\", stage: \"Stage\", saisonnier: \"Saisonnier\",\n};\n\nexport const LANG_FR: Record = {\n fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n};\n\n/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */\nexport const displayTitle = (job: Pick) =>\n job.title_clean || job.title;\n\nexport function formatDate(iso: string | null): string {\n if (!iso) return \"\";\n const d = new Date(`${iso}T12:00:00`);\n return d.toLocaleDateString(\"fr-CA\", { day: \"numeric\", month: \"long\", year: \"numeric\" });\n}\n","structuredPatch":[{"oldStart":159,"oldLines":6,"newStart":159,"newLines":7,"lines":[" category?: string;"," work_mode?: string;"," employment_type?: string;","+ seniority?: string;"," language?: string;"," salary_min?: number;"," with_salary?: number;"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcUXQAXkpVUk97urMhd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XL1wHRuf1HzhAfzxYyqYrn","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","old_string":"export const LANG_FR: Record = {\n fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n};","new_string":"export const LANG_FR: Record = {\n fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n};\n\nexport const SENIORITY_FR: Record = {\n stage: \"Stage\", junior: \"Junior\", intermediaire: \"Intermédiaire\",\n senior: \"Senior\", direction: \"Direction\",\n};\n\n/** « 62 500 $ » compacté en « 62,5 k$ » (métriques et badges). */\nexport function compactMoney(n: number): string {\n if (n >= 1000) {\n const k = n / 1000;\n return `${k.toLocaleString(\"fr-CA\", { maximumFractionDigits: k < 100 ? 1 : 0 })} k$`;\n }\n return `${n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })} $`;\n}\n\n/** Équivalent annuel « ≈ 52 k$ – 60 k$/an » pour un salaire non annuel. */\nexport function yearlyEquiv(job: Pick): string | null {\n if (job.salary_year_min == null || job.salary_unit === \"year\") return null;\n const lo = compactMoney(job.salary_year_min);\n const hi = job.salary_year_max != null && job.salary_year_max > job.salary_year_min\n ? ` – ${compactMoney(job.salary_year_max)}` : \"\";\n return `≈ ${lo}${hi}/an`;\n}\n\n/** Jours écoulés depuis une date ISO (null si absente ou invalide). */\nexport function daysSince(iso: string | null | undefined): number | null {\n if (!iso) return null;\n const d = new Date(`${iso}T12:00:00`).getTime();\n if (Number.isNaN(d)) return null;\n return Math.max(0, Math.round((Date.now() - d) / 86400000));\n}\n\n/** « aujourd'hui », « hier », « il y a N jours ». */\nexport function agoLabel(days: number): string {\n if (days === 0) return \"aujourd'hui\";\n if (days === 1) return \"hier\";\n return `il y a ${days} jours`;\n}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":757,"cache_read_input_tokens":138932,"cache_creation":{"ephemeral_5m_input_tokens":757,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"733d8465-a43b-42cf-899c-495f8d5c6745","request_id":"req_011CePcUVngHNk7TwQ3qWBMP"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XL1wHRuf1HzhAfzxYyqYrn","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"9d6cc61b-f388-4731-b051-92283d83b382","timestamp":"2026-08-25T14:20:00.796Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/api.ts","oldString":"export const LANG_FR: Record = {\n fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n};","newString":"export const LANG_FR: Record = {\n fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n};\n\nexport const SENIORITY_FR: Record = {\n stage: \"Stage\", junior: \"Junior\", intermediaire: \"Intermédiaire\",\n senior: \"Senior\", direction: \"Direction\",\n};\n\n/** « 62 500 $ » compacté en « 62,5 k$ » (métriques et badges). */\nexport function compactMoney(n: number): string {\n if (n >= 1000) {\n const k = n / 1000;\n return `${k.toLocaleString(\"fr-CA\", { maximumFractionDigits: k < 100 ? 1 : 0 })} k$`;\n }\n return `${n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })} $`;\n}\n\n/** Équivalent annuel « ≈ 52 k$ – 60 k$/an » pour un salaire non annuel. */\nexport function yearlyEquiv(job: Pick): string | null {\n if (job.salary_year_min == null || job.salary_unit === \"year\") return null;\n const lo = compactMoney(job.salary_year_min);\n const hi = job.salary_year_max != null && job.salary_year_max > job.salary_year_min\n ? ` – ${compactMoney(job.salary_year_max)}` : \"\";\n return `≈ ${lo}${hi}/an`;\n}\n\n/** Jours écoulés depuis une date ISO (null si absente ou invalide). */\nexport function daysSince(iso: string | null | undefined): number | null {\n if (!iso) return null;\n const d = new Date(`${iso}T12:00:00`).getTime();\n if (Number.isNaN(d)) return null;\n return Math.max(0, Math.round((Date.now() - d) / 86400000));\n}\n\n/** « aujourd'hui », « hier », « il y a N jours ». */\nexport function agoLabel(days: number): string {\n if (days === 0) return \"aujourd'hui\";\n if (days === 1) return \"hier\";\n return `il y a ${days} jours`;\n}","originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/api.ts\n * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\n\nexport interface Job {\n uid: string;\n source: string;\n external_id: string;\n url: string;\n employer: string;\n title: string;\n description?: string;\n address: string;\n city: string;\n region: string;\n postal_code: string;\n location_label: string;\n work_mode: string | null;\n employment_type: string | null;\n salary_min: number | null;\n salary_max: number | null;\n salary_unit: string | null;\n salary_label: string;\n salary_year_min: number | null;\n salary_year_max: number | null;\n salary_hour_min: number | null;\n salary_hour_max: number | null;\n benefits: string[];\n requirements: JobRequirements;\n date_posted: string | null;\n date_deadline: string | null;\n category: string;\n ats: string;\n details: Record;\n lat: number | null;\n lng: number | null;\n active: number;\n dup_sources?: string[];\n is_direct?: boolean;\n company_logo?: string | null;\n language?: string | null; // fr | en | bilingue\n apply_url?: string | null; // candidature directe (≠ url de la fiche)\n title_clean?: string | null; // titre d'affichage normalisé\n seniority?: string | null; // stage | junior | intermediaire | senior | direction\n first_seen?: number | null; // epoch : première collecte par Job·Ka\n // fiche seulement (GET /api/jobs/{uid}) :\n salary_context?: SalaryContext | null;\n employer_jobs?: number; // offres actives du même employeur\n salary_history?: SalaryPoint[];\n}\n\n/** Exigences structurées extraites de l'offre (clés présentes si explicites). */\nexport interface JobRequirements {\n experience_years?: number; // années d'expérience minimales exigées\n education?: string; // plus bas diplôme exigé (DEC, baccalauréat…)\n languages?: string[]; // langues exigées\n [k: string]: unknown; // champs bruts hérités des ATS\n}\n\n/** Salaire de l'offre vs marché de sa catégorie (médiane/quartiles, $/an). */\nexport interface SalaryContext {\n category: string;\n n: number; // offres avec salaire dans la catégorie\n p25: number;\n median: number;\n p75: number;\n job_mid?: number; // point médian de la fourchette de l'offre\n delta_pct?: number; // écart % vs médiane de la catégorie\n}\n\nexport interface SalaryPoint {\n ts: number;\n s_min: number | null;\n s_max: number | null;\n unit: string | null;\n}\n\nexport interface JobList {\n total: number;\n count: number;\n jobs: Job[];\n}\n\nexport interface Facets {\n cities: string[];\n regions: { region: string; n: number }[];\n languages: { language: string; n: number }[];\n categories: { category: string; n: number }[];\n employers: { employer: string; n: number }[];\n work_modes: string[];\n employment_types: string[];\n seniorities: { seniority: string; n: number }[];\n sources: { source: string; n: number }[];\n}\n\nexport interface Source {\n id: string;\n name: string;\n url: string;\n careers_url: string;\n sectors: string[];\n connector: string;\n status: string;\n region: string;\n active_jobs: number;\n last_sync: number | null;\n}\n\nexport interface Stats {\n total: number;\n employers: number;\n sources: number;\n with_salary: number;\n avg_salary_year: number | null;\n remote: number;\n geocoded: number;\n direct_jobs?: number;\n direct_employers?: number;\n direct_sources?: number;\n portal_jobs?: number;\n duplicates_hidden?: number;\n top_cities: { city: string; n: number }[];\n categories: { category: string; n: number }[];\n recent_syncs: {\n source: string; ts: number; found: number; added: number;\n updated: number; removed: number; ok: number; message: string;\n }[];\n}\n\nasync function get(path: string, params?: Record): Promise {\n const url = new URL(path, window.location.origin);\n if (params) {\n for (const [k, v] of Object.entries(params)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n }\n const ctl = new AbortController();\n const timer = setTimeout(() => ctl.abort(), 20000);\n try {\n const resp = await fetch(url.toString(), { signal: ctl.signal });\n if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);\n return (await resp.json()) as T;\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport interface JobFilters {\n q?: string;\n city?: string;\n region?: string;\n category?: string;\n work_mode?: string;\n employment_type?: string;\n seniority?: string;\n language?: string;\n salary_min?: number;\n with_salary?: number;\n sort?: string;\n}\n\nexport const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>\n get(\"/api/jobs\", { ...filters, limit, offset });\n\nexport const fetchJob = (uid: string) => get(`/api/jobs/${uid}`);\n\nexport const fetchFacets = () => get(\"/api/facets\");\n\nexport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n\nexport const fetchStats = () => get(\"/api/stats\");\n\nexport const geojsonUrl = (filters: JobFilters) => {\n const url = new URL(\"/api/jobs.geojson\", window.location.origin);\n for (const [k, v] of Object.entries(filters)) {\n if (v !== undefined && v !== \"\") url.searchParams.set(k, String(v));\n }\n return url.toString();\n};\n\n// --- présentation ------------------------------------------------------------\n\nconst UNIT_FR: Record = {\n hour: \"h\", day: \"jour\", week: \"sem.\", biweek: \"2 sem.\", month: \"mois\", year: \"an\",\n};\n\nexport function formatSalary(job: Pick): string | null {\n if (job.salary_min == null || !job.salary_unit) return null;\n const fmt = (n: number) =>\n n >= 1000 ? n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })\n : n.toLocaleString(\"fr-CA\", { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;\n if (job.salary_max != null && job.salary_max > job.salary_min) {\n return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;\n }\n return `${fmt(job.salary_min)} $ / ${unit}`;\n}\n\nexport const MODE_FR: Record = {\n presentiel: \"Présentiel\", hybride: \"Hybride\", teletravail: \"Télétravail\",\n};\n\nexport const TYPE_FR: Record = {\n temps_plein: \"Temps plein\", temps_partiel: \"Temps partiel\",\n contractuel: \"Contractuel\", stage: \"Stage\", saisonnier: \"Saisonnier\",\n};\n\nexport const LANG_FR: Record = {\n fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\",\n};\n\n/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */\nexport const displayTitle = (job: Pick) =>\n job.title_clean || job.title;\n\nexport function formatDate(iso: string | null): string {\n if (!iso) return \"\";\n const d = new Date(`${iso}T12:00:00`);\n return d.toLocaleDateString(\"fr-CA\", { day: \"numeric\", month: \"long\", year: \"numeric\" });\n}\n","structuredPatch":[{"oldStart":216,"oldLines":6,"newStart":216,"newLines":44,"lines":[" fr: \"Français\", en: \"Anglais\", bilingue: \"Bilingue\","," };"," ","+export const SENIORITY_FR: Record = {","+ stage: \"Stage\", junior: \"Junior\", intermediaire: \"Intermédiaire\",","+ senior: \"Senior\", direction: \"Direction\",","+};","+","+/** « 62 500 $ » compacté en « 62,5 k$ » (métriques et badges). */","+export function compactMoney(n: number): string {","+ if (n >= 1000) {","+ const k = n / 1000;","+ return `${k.toLocaleString(\"fr-CA\", { maximumFractionDigits: k < 100 ? 1 : 0 })} k$`;","+ }","+ return `${n.toLocaleString(\"fr-CA\", { maximumFractionDigits: 0 })} $`;","+}","+","+/** Équivalent annuel « ≈ 52 k$ – 60 k$/an » pour un salaire non annuel. */","+export function yearlyEquiv(job: Pick): string | null {","+ if (job.salary_year_min == null || job.salary_unit === \"year\") return null;","+ const lo = compactMoney(job.salary_year_min);","+ const hi = job.salary_year_max != null && job.salary_year_max > job.salary_year_min","+ ? ` – ${compactMoney(job.salary_year_max)}` : \"\";","+ return `≈ ${lo}${hi}/an`;","+}","+","+/** Jours écoulés depuis une date ISO (null si absente ou invalide). */","+export function daysSince(iso: string | null | undefined): number | null {","+ if (!iso) return null;","+ const d = new Date(`${iso}T12:00:00`).getTime();","+ if (Number.isNaN(d)) return null;","+ return Math.max(0, Math.round((Date.now() - d) / 86400000));","+}","+","+/** « aujourd'hui », « hier », « il y a N jours ». */","+export function agoLabel(days: number): string {","+ if (days === 0) return \"aujourd'hui\";","+ if (days === 1) return \"hier\";","+ return `il y a ${days} jours`;","+}","+"," /** Titre d'affichage : version normalisée si disponible, sinon le titre source. */"," export const displayTitle = (job: Pick) =>"," job.title_clean || job.title;"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"0a01bfbc-3973-4afd-9a3f-c8c89e849b11","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcY1VCB8ewnxHFWkdcm","type":"message","role":"assistant","content":[{"type":"text","text":"La nouvelle carte d'offre :"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4484,"cache_read_input_tokens":139689,"cache_creation":{"ephemeral_5m_input_tokens":4484,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"7241477e-3ff6-481c-ae01-5a3b04beaf26","request_id":"req_011CePcXynzeDnaRwy6MWAJZ"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcY1VCB8ewnxHFWkdcm","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014yizMKFViuP3MJijp8bFpD","name":"Write","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/components/JobCard.tsx\n * Rôle : Carte d'offre dans la liste — salaire avec équivalent annuel,\n * badges (nouveau, séniorité, exigences), fraîcheur, cœur ♥ favoris\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\nimport { Link } from \"react-router-dom\";\nimport {\n agoLabel, daysSince, displayTitle, formatSalary, Job,\n MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,\n} from \"../api\";\nimport { FavButton } from \"../favorites\";\n\nconst NEW_DAYS = 3; // « Nouveau » : publiée il y a 3 jours ou moins\n\nexport default function JobCard({ job }: { job: Job }) {\n const salary = formatSalary(job);\n const yearly = yearlyEquiv(job);\n const days = daysSince(job.date_posted);\n const isNew = days !== null && days <= NEW_DAYS;\n const exp = job.requirements?.experience_years;\n const langs = job.requirements?.languages;\n\n return (\n \n \n

{displayTitle(job)}

\n {job.employer}\n {job.city ? — {job.city} : null}\n
\n {isNew && Nouveau}\n {job.is_direct !== false && (\n \n Offre directe\n \n )}\n {salary && (\n \n {salary}{yearly ? {yearly} : null}\n \n )}\n {job.seniority && {SENIORITY_FR[job.seniority] ?? job.seniority}}\n {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n {typeof exp === \"number\" && (\n \n {exp} an{exp > 1 ? \"s\" : \"\"} d'exp. min.\n \n )}\n {langs && langs.length === 2 && Bilingue exigé}\n {job.category && {job.category}}\n
\n
\n {days !== null && Publiée {agoLabel(days)}}\n {job.benefits && job.benefits.length > 0 && (\n {job.benefits.length} avantage{job.benefits.length > 1 ? \"s\" : \"\"} listé{job.benefits.length > 1 ? \"s\" : \"\"}\n )}\n {job.ats && {job.ats}}\n
\n \n );\n}\n"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4484,"cache_read_input_tokens":139689,"cache_creation":{"ephemeral_5m_input_tokens":4484,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"5663ac17-1cc2-45b5-9cb0-a8a1376074d9","request_id":"req_011CePcXynzeDnaRwy6MWAJZ"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014yizMKFViuP3MJijp8bFpD","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"0944962a-6c57-43a8-85ee-51be1b603525","timestamp":"2026-08-25T14:20:17.971Z","tool_use_result":{"type":"update","filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/components/JobCard.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/components/JobCard.tsx\n * Rôle : Carte d'offre dans la liste — salaire avec équivalent annuel,\n * badges (nouveau, séniorité, exigences), fraîcheur, cœur ♥ favoris\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\nimport { Link } from \"react-router-dom\";\nimport {\n agoLabel, daysSince, displayTitle, formatSalary, Job,\n MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,\n} from \"../api\";\nimport { FavButton } from \"../favorites\";\n\nconst NEW_DAYS = 3; // « Nouveau » : publiée il y a 3 jours ou moins\n\nexport default function JobCard({ job }: { job: Job }) {\n const salary = formatSalary(job);\n const yearly = yearlyEquiv(job);\n const days = daysSince(job.date_posted);\n const isNew = days !== null && days <= NEW_DAYS;\n const exp = job.requirements?.experience_years;\n const langs = job.requirements?.languages;\n\n return (\n \n \n

{displayTitle(job)}

\n {job.employer}\n {job.city ? — {job.city} : null}\n
\n {isNew && Nouveau}\n {job.is_direct !== false && (\n \n Offre directe\n \n )}\n {salary && (\n \n {salary}{yearly ? {yearly} : null}\n \n )}\n {job.seniority && {SENIORITY_FR[job.seniority] ?? job.seniority}}\n {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n {typeof exp === \"number\" && (\n \n {exp} an{exp > 1 ? \"s\" : \"\"} d'exp. min.\n \n )}\n {langs && langs.length === 2 && Bilingue exigé}\n {job.category && {job.category}}\n
\n
\n {days !== null && Publiée {agoLabel(days)}}\n {job.benefits && job.benefits.length > 0 && (\n {job.benefits.length} avantage{job.benefits.length > 1 ? \"s\" : \"\"} listé{job.benefits.length > 1 ? \"s\" : \"\"}\n )}\n {job.ats && {job.ats}}\n
\n \n );\n}\n","structuredPatch":[{"oldStart":4,"oldLines":17,"newStart":4,"newLines":28,"lines":[" * Auteur : Simon-Pierre Boucher"," * Contact : contact@spboucher.ai"," * Fichier : frontend/src/components/JobCard.tsx","- * Rôle : Carte d'offre dans la liste (titre, employeur, badges, meta,","- * cœur ♥ favoris « Mon univers Ka »)","- * Créé : 2026-08-17 Modifié : 2026-08-23","+ * Rôle : Carte d'offre dans la liste — salaire avec équivalent annuel,","+ * badges (nouveau, séniorité, exigences), fraîcheur, cœur ♥ favoris","+ * Créé : 2026-08-17 Modifié : 2026-08-25"," * ============================================================================="," */"," import { Link } from \"react-router-dom\";","-import { displayTitle, formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from \"../api\";","+import {","+ agoLabel, daysSince, displayTitle, formatSalary, Job,","+ MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,","+} from \"../api\";"," import { FavButton } from \"../favorites\";"," ","+const NEW_DAYS = 3; // « Nouveau » : publiée il y a 3 jours ou moins","+"," export default function JobCard({ job }: { job: Job }) {"," const salary = formatSalary(job);","+ const yearly = yearlyEquiv(job);","+ const days = daysSince(job.date_posted);","+ const isNew = days !== null && days <= NEW_DAYS;","+ const exp = job.requirements?.experience_years;","+ const langs = job.requirements?.languages;","+"," return ("," "," "]},{"oldStart":22,"oldLines":6,"newStart":33,"newLines":7,"lines":[" {job.employer}"," {job.city ? — {job.city} : null}","
","+ {isNew && Nouveau}"," {job.is_direct !== false && ("," "," )}","- {salary && {salary}}","+ {salary && (","+ ","+ {salary}{yearly ? {yearly} : null}","+ ","+ )}","+ {job.seniority && {SENIORITY_FR[job.seniority] ?? job.seniority}}"," {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}"," {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}","+ {typeof exp === \"number\" && (","+ ","+ {exp} an{exp > 1 ? \"s\" : \"\"} d'exp. min.","+ ","+ )}","+ {langs && langs.length === 2 && Bilingue exigé}"," {job.category && {job.category}}","- {job.date_posted && Publiée le {formatDate(job.date_posted)}}","
","+
","+ {days !== null && Publiée {agoLabel(days)}}","+ {job.benefits && job.benefits.length > 0 && (","+ {job.benefits.length} avantage{job.benefits.length > 1 ? \"s\" : \"\"} listé{job.benefits.length > 1 ? \"s\" : \"\"}","+ )}","+ {job.ats && {job.ats}}","+
"," "," );"," }"]}],"originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/components/JobCard.tsx\n * Rôle : Carte d'offre dans la liste (titre, employeur, badges, meta,\n * cœur ♥ favoris « Mon univers Ka »)\n * Créé : 2026-08-17 Modifié : 2026-08-23\n * =============================================================================\n */\nimport { Link } from \"react-router-dom\";\nimport { displayTitle, formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from \"../api\";\nimport { FavButton } from \"../favorites\";\n\nexport default function JobCard({ job }: { job: Job }) {\n const salary = formatSalary(job);\n return (\n \n \n

{displayTitle(job)}

\n {job.employer}\n {job.city ? — {job.city} : null}\n
\n {job.is_direct !== false && (\n \n Offre directe\n \n )}\n {salary && {salary}}\n {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n {job.category && {job.category}}\n {job.date_posted && Publiée le {formatDate(job.date_posted)}}\n
\n \n );\n}\n","userModified":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"6136130e-be99-45d1-8be7-689c5ceb86f1","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"ff892e0b-4a9b-4a29-9f27-a83ecf64592b","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":91,"estimated_tokens_delta":41,"uuid":"1a41e82c-c18f-447a-b9ef-94400482e9f3","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcZGVrtnAkhQjyQhg2r","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS5AIKpwEIERgCKkCqK18Anc/X/imEuJVdlCXxgkpv4eFEhKnC+gb1ftHhL7h6QlncILBKkzyl67bBn1TlC/xoyrfmgf7HRlIljcR/Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAGnybbUBrABAhIMHpOkdRZ6HM9CIDtJGgx8VMQLB6Ev8LdyZrciMOIOBQMDh9CHEa6pE9WEkIvTzt6MKs24KNioWzq4Y1OUiLA6jM1ARSyb8h+t1F58sCpqA2m7i3a6X/q1QRwHYxEdx+Y8MZxrSctCH5ubwALuuGa0kM+Qyn3ZHU623Lef3Kq4g1yT8rzBzvB+WQZ0KGklfW4yotsUZSgdV5jAHJLcM+p29GX9vsDitIoIgN1nWO9Wx3bXwY8isS/8ChgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":1403,"cache_read_input_tokens":144173,"cache_creation":{"ephemeral_5m_input_tokens":1403,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"c544a5c6-bccd-4004-8bf1-c01c9fc33241","request_id":"req_011CePcZEpA4Bd19yky6PzYX"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcZGVrtnAkhQjyQhg2r","type":"message","role":"assistant","content":[{"type":"text","text":"La fiche d'offre avec le panneau de métriques « En un coup d'œil » et la comparaison au marché :"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":1403,"cache_read_input_tokens":144173,"cache_creation":{"ephemeral_5m_input_tokens":1403,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"73b38fc7-4f6f-4c78-a9a8-9684770263c5","request_id":"req_011CePcZEpA4Bd19yky6PzYX"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcZGVrtnAkhQjyQhg2r","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015fnzbKigiTCfNDxDxqYKLs","name":"Write","input":{"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Job.tsx\n * Rôle : Fiche d'une offre — panneau de métriques « En un coup d'œil »\n * (salaire vs marché, exigences, fraîcheur), description, lien vers\n * l'offre originale, cœur ♥ favoris « Mon univers Ka »\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\nimport { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport {\n agoLabel, compactMoney, daysSince, displayTitle, fetchJob, formatDate,\n formatSalary, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,\n} from \"../api\";\nimport { FavButton } from \"../favorites\";\n\n/** Tuile métrique : valeur mise en avant + libellé mono + précision optionnelle. */\nfunction Metric({ label, value, sub, title }: {\n label: string; value: React.ReactNode; sub?: React.ReactNode; title?: string;\n}) {\n return (\n
\n
{value}
\n
{label}
\n {sub &&
{sub}
}\n
\n );\n}\n\n/** Barre de position du salaire de l'offre dans la fourchette P25–P75 de sa\n * catégorie (fenêtre interquartile = zone « normale » du marché). */\nfunction MarketBar({ p25, p75, mid }: { p25: number; p75: number; mid: number }) {\n const lo = p25 * 0.75, hi = p75 * 1.25; // marges autour des quartiles\n const pct = (v: number) => Math.min(100, Math.max(0, ((v - lo) / (hi - lo)) * 100));\n return (\n
\n
\n
\n
\n
\n );\n}\n\nexport default function JobPage() {\n const { uid = \"\" } = useParams();\n const [job, setJob] = useState(null);\n const [error, setError] = useState(\"\");\n\n useEffect(() => {\n setJob(null);\n fetchJob(uid).then(setJob).catch((e) => setError(String(e)));\n window.scrollTo(0, 0);\n }, [uid]);\n\n if (error) return

Offre introuvable

← Retour aux offres
;\n if (!job) return
Chargement…
;\n\n const salary = formatSalary(job);\n const yearly = yearlyEquiv(job);\n const days = daysSince(job.date_posted);\n const deadlineDays = job.date_deadline ? daysSince(job.date_deadline) : null;\n const deadlineIn = job.date_deadline\n ? Math.round((new Date(`${job.date_deadline}T12:00:00`).getTime() - Date.now()) / 86400000)\n : null;\n const ctx = job.salary_context;\n const reqs = job.requirements ?? {};\n const langsReq = reqs.languages;\n const history = (job.salary_history ?? []).filter((h) => h.s_min != null);\n\n return (\n
\n
\n

← Toutes les offres

\n
\n {job.company_logo && (\n { (e.target as HTMLImageElement).style.display = \"none\"; }}\n />\n )}\n

{displayTitle(job)}

\n \n
\n

\n {job.employer}\n {typeof job.employer_jobs === \"number\" && job.employer_jobs > 1 && (\n · {job.employer_jobs} offres actives\n )}\n {job.location_label && — {job.location_label}}\n {job.region && job.region !== \"Québec\" && ({job.region})}\n

\n\n
\n {days !== null && days <= 3 && Nouveau}\n {job.is_direct !== false && Offre directe}\n {job.seniority && {SENIORITY_FR[job.seniority] ?? job.seniority}}\n {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n {job.language && {LANG_FR[job.language] ?? job.language}}\n {job.category && {job.category}}\n {job.active === 0 && Offre retirée}\n
\n\n {/* --- En un coup d'œil : métriques claires, jamais inventées --------- */}\n

En un coup d'œil

\n
\n \n {ctx && ctx.job_mid != null && ctx.delta_pct != null && (\n = 0 ? \"delta-up\" : \"delta-down\"}>\n {ctx.delta_pct > 0 ? \"+\" : \"\"}{ctx.delta_pct.toLocaleString(\"fr-CA\")} %\n \n }\n sub={\n <>\n \n médiane {compactMoney(ctx.median)} · P25 {compactMoney(ctx.p25)} – P75 {compactMoney(ctx.p75)} · {ctx.n.toLocaleString(\"fr-CA\")} offres comparées\n \n }\n 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\"\n />\n )}\n {typeof reqs.experience_years === \"number\" && (\n 1 ? \"s\" : \"\"} min.`}\n sub=\"Extrait du texte de l'offre\"\n />\n )}\n {typeof reqs.education === \"string\" && (\n \n )}\n {Array.isArray(langsReq) && langsReq.length > 0 && (\n \n )}\n {days !== null && (\n \n )}\n {deadlineIn !== null && deadlineDays !== null && (\n = 0 ? `dans ${deadlineIn} jour${deadlineIn > 1 ? \"s\" : \"\"}` : \"dépassée\"}\n sub={formatDate(job.date_deadline)}\n title=\"Date limite pour postuler, publiée par l'employeur\"\n />\n )}\n
\n\n

\n \n Postuler chez {job.employer} ↗\n \n

\n

\n Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka\n n'est pas un intermédiaire, la candidature se fait chez l'employeur.\n

\n\n {history.length > 1 && (\n <>\n

Évolution du salaire affiché

\n
    \n {history.map((h, i) => (\n
  • \n {new Date(h.ts * 1000).toLocaleDateString(\"fr-CA\")}\n {\" — \"}\n {formatSalary({ salary_min: h.s_min, salary_max: h.s_max, salary_unit: h.unit })}\n
  • \n ))}\n
\n \n )}\n\n {job.benefits && job.benefits.length > 0 && (\n <>\n

Avantages

\n
    \n {job.benefits.map((b, i) =>
  • {b}
  • )}\n
\n \n )}\n {job.description && (\n <>\n

Description du poste

\n
{job.description}
\n \n )}\n
\n
\n );\n}\n"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":131,"cache_creation_input_tokens":1403,"cache_read_input_tokens":144173,"cache_creation":{"ephemeral_5m_input_tokens":1403,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"deb5bd11-5dd1-4306-b34f-891e42d06b82","request_id":"req_011CePcZEpA4Bd19yky6PzYX"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015fnzbKigiTCfNDxDxqYKLs","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"d822d52e-da37-49ab-a6bb-f2960024b6a2","timestamp":"2026-08-25T14:20:58.140Z","tool_use_result":{"type":"update","filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx","content":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Job.tsx\n * Rôle : Fiche d'une offre — panneau de métriques « En un coup d'œil »\n * (salaire vs marché, exigences, fraîcheur), description, lien vers\n * l'offre originale, cœur ♥ favoris « Mon univers Ka »\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\nimport { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport {\n agoLabel, compactMoney, daysSince, displayTitle, fetchJob, formatDate,\n formatSalary, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,\n} from \"../api\";\nimport { FavButton } from \"../favorites\";\n\n/** Tuile métrique : valeur mise en avant + libellé mono + précision optionnelle. */\nfunction Metric({ label, value, sub, title }: {\n label: string; value: React.ReactNode; sub?: React.ReactNode; title?: string;\n}) {\n return (\n
\n
{value}
\n
{label}
\n {sub &&
{sub}
}\n
\n );\n}\n\n/** Barre de position du salaire de l'offre dans la fourchette P25–P75 de sa\n * catégorie (fenêtre interquartile = zone « normale » du marché). */\nfunction MarketBar({ p25, p75, mid }: { p25: number; p75: number; mid: number }) {\n const lo = p25 * 0.75, hi = p75 * 1.25; // marges autour des quartiles\n const pct = (v: number) => Math.min(100, Math.max(0, ((v - lo) / (hi - lo)) * 100));\n return (\n
\n
\n
\n
\n
\n );\n}\n\nexport default function JobPage() {\n const { uid = \"\" } = useParams();\n const [job, setJob] = useState(null);\n const [error, setError] = useState(\"\");\n\n useEffect(() => {\n setJob(null);\n fetchJob(uid).then(setJob).catch((e) => setError(String(e)));\n window.scrollTo(0, 0);\n }, [uid]);\n\n if (error) return

Offre introuvable

← Retour aux offres
;\n if (!job) return
Chargement…
;\n\n const salary = formatSalary(job);\n const yearly = yearlyEquiv(job);\n const days = daysSince(job.date_posted);\n const deadlineDays = job.date_deadline ? daysSince(job.date_deadline) : null;\n const deadlineIn = job.date_deadline\n ? Math.round((new Date(`${job.date_deadline}T12:00:00`).getTime() - Date.now()) / 86400000)\n : null;\n const ctx = job.salary_context;\n const reqs = job.requirements ?? {};\n const langsReq = reqs.languages;\n const history = (job.salary_history ?? []).filter((h) => h.s_min != null);\n\n return (\n
\n
\n

← Toutes les offres

\n
\n {job.company_logo && (\n { (e.target as HTMLImageElement).style.display = \"none\"; }}\n />\n )}\n

{displayTitle(job)}

\n \n
\n

\n {job.employer}\n {typeof job.employer_jobs === \"number\" && job.employer_jobs > 1 && (\n · {job.employer_jobs} offres actives\n )}\n {job.location_label && — {job.location_label}}\n {job.region && job.region !== \"Québec\" && ({job.region})}\n

\n\n
\n {days !== null && days <= 3 && Nouveau}\n {job.is_direct !== false && Offre directe}\n {job.seniority && {SENIORITY_FR[job.seniority] ?? job.seniority}}\n {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n {job.language && {LANG_FR[job.language] ?? job.language}}\n {job.category && {job.category}}\n {job.active === 0 && Offre retirée}\n
\n\n {/* --- En un coup d'œil : métriques claires, jamais inventées --------- */}\n

En un coup d'œil

\n
\n \n {ctx && ctx.job_mid != null && ctx.delta_pct != null && (\n = 0 ? \"delta-up\" : \"delta-down\"}>\n {ctx.delta_pct > 0 ? \"+\" : \"\"}{ctx.delta_pct.toLocaleString(\"fr-CA\")} %\n \n }\n sub={\n <>\n \n médiane {compactMoney(ctx.median)} · P25 {compactMoney(ctx.p25)} – P75 {compactMoney(ctx.p75)} · {ctx.n.toLocaleString(\"fr-CA\")} offres comparées\n \n }\n 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\"\n />\n )}\n {typeof reqs.experience_years === \"number\" && (\n 1 ? \"s\" : \"\"} min.`}\n sub=\"Extrait du texte de l'offre\"\n />\n )}\n {typeof reqs.education === \"string\" && (\n \n )}\n {Array.isArray(langsReq) && langsReq.length > 0 && (\n \n )}\n {days !== null && (\n \n )}\n {deadlineIn !== null && deadlineDays !== null && (\n = 0 ? `dans ${deadlineIn} jour${deadlineIn > 1 ? \"s\" : \"\"}` : \"dépassée\"}\n sub={formatDate(job.date_deadline)}\n title=\"Date limite pour postuler, publiée par l'employeur\"\n />\n )}\n
\n\n

\n \n Postuler chez {job.employer} ↗\n \n

\n

\n Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka\n n'est pas un intermédiaire, la candidature se fait chez l'employeur.\n

\n\n {history.length > 1 && (\n <>\n

Évolution du salaire affiché

\n
    \n {history.map((h, i) => (\n
  • \n {new Date(h.ts * 1000).toLocaleDateString(\"fr-CA\")}\n {\" — \"}\n {formatSalary({ salary_min: h.s_min, salary_max: h.s_max, salary_unit: h.unit })}\n
  • \n ))}\n
\n \n )}\n\n {job.benefits && job.benefits.length > 0 && (\n <>\n

Avantages

\n
    \n {job.benefits.map((b, i) =>
  • {b}
  • )}\n
\n \n )}\n {job.description && (\n <>\n

Description du poste

\n
{job.description}
\n \n )}\n
\n
\n );\n}\n","structuredPatch":[{"oldStart":4,"oldLines":16,"newStart":4,"newLines":47,"lines":[" * Auteur : Simon-Pierre Boucher"," * Contact : contact@spboucher.ai"," * Fichier : frontend/src/pages/Job.tsx","- * Rôle : Fiche d'une offre — faits, description, lien vers l'offre","- * originale, cœur ♥ favoris « Mon univers Ka »","- * Créé : 2026-08-17 Modifié : 2026-08-23","+ * Rôle : Fiche d'une offre — panneau de métriques « En un coup d'œil »","+ * (salaire vs marché, exigences, fraîcheur), description, lien vers","+ * l'offre originale, cœur ♥ favoris « Mon univers Ka »","+ * Créé : 2026-08-17 Modifié : 2026-08-25"," * ============================================================================="," */"," import { useEffect, useState } from \"react\";"," import { Link, useParams } from \"react-router-dom\";","-import { displayTitle, fetchJob, formatDate, formatSalary, Job, LANG_FR, MODE_FR, TYPE_FR } from \"../api\";","+import {","+ agoLabel, compactMoney, daysSince, displayTitle, fetchJob, formatDate,","+ formatSalary, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,","+} from \"../api\";"," import { FavButton } from \"../favorites\";"," ","+/** Tuile métrique : valeur mise en avant + libellé mono + précision optionnelle. */","+function Metric({ label, value, sub, title }: {","+ label: string; value: React.ReactNode; sub?: React.ReactNode; title?: string;","+}) {","+ return (","+
","+
{value}
","+
{label}
","+ {sub &&
{sub}
}","+
","+ );","+}","+","+/** Barre de position du salaire de l'offre dans la fourchette P25–P75 de sa","+ * catégorie (fenêtre interquartile = zone « normale » du marché). */","+function MarketBar({ p25, p75, mid }: { p25: number; p75: number; mid: number }) {","+ const lo = p25 * 0.75, hi = p75 * 1.25; // marges autour des quartiles","+ const pct = (v: number) => Math.min(100, Math.max(0, ((v - lo) / (hi - lo)) * 100));","+ return (","+
","+
","+
","+
","+
","+ );","+}","+"," export default function JobPage() {"," const { uid = \"\" } = useParams();"," const [job, setJob] = useState(null);"]},{"oldStart":29,"oldLines":9,"newStart":60,"newLines":16,"lines":[" if (!job) return
Chargement…
;"," "," const salary = formatSalary(job);","- const yearly = job.salary_year_min != null && job.salary_unit !== \"year\"","- ? `≈ ${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`","+ const yearly = yearlyEquiv(job);","+ const days = daysSince(job.date_posted);","+ const deadlineDays = job.date_deadline ? daysSince(job.date_deadline) : null;","+ const deadlineIn = job.date_deadline","+ ? Math.round((new Date(`${job.date_deadline}T12:00:00`).getTime() - Date.now()) / 86400000)"," : null;","+ const ctx = job.salary_context;","+ const reqs = job.requirements ?? {};","+ const langsReq = reqs.languages;","+ const history = (job.salary_history ?? []).filter((h) => h.s_min != null);"," "," return (","
"]},{"oldStart":51,"oldLines":20,"newStart":89,"newLines":88,"lines":["
","

"," {job.employer}","+ {typeof job.employer_jobs === \"number\" && job.employer_jobs > 1 && (","+ · {job.employer_jobs} offres actives","+ )}"," {job.location_label && — {job.location_label}}"," {job.region && job.region !== \"Québec\" && ({job.region})}","

","+","
","- {salary && {salary}{yearly ? ` (${yearly})` : \"\"}}","- {!salary && Salaire non affiché par l'employeur}","+ {days !== null && days <= 3 && Nouveau}","+ {job.is_direct !== false && Offre directe}","+ {job.seniority && {SENIORITY_FR[job.seniority] ?? job.seniority}}"," {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}"," {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}"," {job.language && {LANG_FR[job.language] ?? job.language}}"," {job.category && {job.category}}","- {job.date_posted && Publiée le {formatDate(job.date_posted)}}","- {job.date_deadline && Date limite : {formatDate(job.date_deadline)}}"," {job.active === 0 && Offre retirée}","
","+","+ {/* --- En un coup d'œil : métriques claires, jamais inventées --------- */}","+

En un coup d'œil

","+
","+ ","+ {ctx && ctx.job_mid != null && ctx.delta_pct != null && (","+ = 0 ? \"delta-up\" : \"delta-down\"}>","+ {ctx.delta_pct > 0 ? \"+\" : \"\"}{ctx.delta_pct.toLocaleString(\"fr-CA\")} %","+ ","+ }","+ sub={","+ <>","+ ","+ médiane {compactMoney(ctx.median)} · P25 {compactMoney(ctx.p25)} – P75 {compactMoney(ctx.p75)} · {ctx.n.toLocaleString(\"fr-CA\")} offres comparées","+ ","+ }","+ 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\"","+ />","+ )}","+ {typeof reqs.experience_years === \"number\" && (","+ 1 ? \"s\" : \"\"} min.`}","+ sub=\"Extrait du texte de l'offre\"","+ />","+ )}","+ {typeof reqs.education === \"string\" && (","+ ","+ )}","+ {Array.isArray(langsReq) && langsReq.length > 0 && (","+ ","+ )}","+ {days !== null && (","+ ","+ )}","+ {deadlineIn !== null && deadlineDays !== null && (","+ = 0 ? `dans ${deadlineIn} jour${deadlineIn > 1 ? \"s\" : \"\"}` : \"dépassée\"}","+ sub={formatDate(job.date_deadline)}","+ title=\"Date limite pour postuler, publiée par l'employeur\"","+ />","+ )}","+
","+","

"," "," Postuler chez {job.employer} ↗"]},{"oldStart":74,"oldLines":6,"newStart":180,"newLines":22,"lines":[" Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka"," n'est pas un intermédiaire, la candidature se fait chez l'employeur.","

","+","+ {history.length > 1 && (","+ <>","+

Évolution du salaire affiché

","+
    ","+ {history.map((h, i) => (","+
  • ","+ {new Date(h.ts * 1000).toLocaleDateString(\"fr-CA\")}","+ {\" — \"}","+ {formatSalary({ salary_min: h.s_min, salary_max: h.s_max, salary_unit: h.unit })}","+
  • ","+ ))}","+
","+ ","+ )}","+"," {job.benefits && job.benefits.length > 0 && ("," <>","

Avantages

"]}],"originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Job.tsx\n * Rôle : Fiche d'une offre — faits, description, lien vers l'offre\n * originale, cœur ♥ favoris « Mon univers Ka »\n * Créé : 2026-08-17 Modifié : 2026-08-23\n * =============================================================================\n */\nimport { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport { displayTitle, fetchJob, formatDate, formatSalary, Job, LANG_FR, MODE_FR, TYPE_FR } from \"../api\";\nimport { FavButton } from \"../favorites\";\n\nexport default function JobPage() {\n const { uid = \"\" } = useParams();\n const [job, setJob] = useState(null);\n const [error, setError] = useState(\"\");\n\n useEffect(() => {\n setJob(null);\n fetchJob(uid).then(setJob).catch((e) => setError(String(e)));\n window.scrollTo(0, 0);\n }, [uid]);\n\n if (error) return

Offre introuvable

← Retour aux offres
;\n if (!job) return
Chargement…
;\n\n const salary = formatSalary(job);\n const yearly = job.salary_year_min != null && job.salary_unit !== \"year\"\n ? `≈ ${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`\n : null;\n\n return (\n
\n
\n

← Toutes les offres

\n
\n {job.company_logo && (\n { (e.target as HTMLImageElement).style.display = \"none\"; }}\n />\n )}\n

{displayTitle(job)}

\n \n
\n

\n {job.employer}\n {job.location_label && — {job.location_label}}\n {job.region && job.region !== \"Québec\" && ({job.region})}\n

\n
\n {salary && {salary}{yearly ? ` (${yearly})` : \"\"}}\n {!salary && Salaire non affiché par l'employeur}\n {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n {job.language && {LANG_FR[job.language] ?? job.language}}\n {job.category && {job.category}}\n {job.date_posted && Publiée le {formatDate(job.date_posted)}}\n {job.date_deadline && Date limite : {formatDate(job.date_deadline)}}\n {job.active === 0 && Offre retirée}\n
\n

\n \n Postuler chez {job.employer} ↗\n \n

\n

\n Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka\n n'est pas un intermédiaire, la candidature se fait chez l'employeur.\n

\n {job.benefits && job.benefits.length > 0 && (\n <>\n

Avantages

\n
    \n {job.benefits.map((b, i) =>
  • {b}
  • )}\n
\n \n )}\n {job.description && (\n <>\n

Description du poste

\n
{job.description}
\n \n )}\n
\n
\n );\n}\n","userModified":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"bbf18d31-3167-4e85-97f6-be9f9274a60c","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"uuid":"c9b62da3-04f1-4a17-afcd-22407153fd1e","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":150,"estimated_tokens_delta":100,"uuid":"46232a07-6c97-4d01-945d-cccfe623b7cc","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":250,"estimated_tokens_delta":100,"uuid":"ac18adbe-b9bd-4456-92b6-436bbdbc1dd0","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"system","subtype":"thinking_tokens","estimated_tokens":261,"estimated_tokens_delta":11,"uuid":"ab0e43fd-9106-4c16-8679-abfefd280cb2","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePccEpz5DUeyVYWTuEvZ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISiQgKpwEIERgCKkCiDfnV6UYtTvS1XqcvtOv67MDmF+IUUjhXt1//bbykX7ailduxhqn8B8Sa5neYuJD/AM1M6ti/+lpgcs82RC8jMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chAdIVqGJg7PzozfP3tSqTZWiAEBqAHWybbUBrABAhIMgJwSiZAqsSFpvHTlGgw/lSq733bPDEF9LnMiMCWAFxZ21xG5g0i9J8YoX8RNBz59grIa8onJ1KNGSTNPMAwq+EfNIpRnxJxIfT7tAiqOBi6o+cXxlSAQvCU+wQaqEzFjjOqkpMVzPjq8oFPWlE5GPjh8aQT366pqfl8Hos1n9J+E4tciKXEK3pOwGRBd4MCTXZ4d1GDYYSU/Tjyb3Gk0acox5VzXkeOB4AgkpUNXJTpV3H0Z4RLe5GNRX+4BbYj5HxGsXOHL8finRfEgnnp2PHCSgHMN0QngAJGE6mDrW6R1/1sAeakIeb77jkm7o4jNpMiTAOoev3Mab7gFrEydtzZFVNYEMBz7ikFtmnf9ZYPa8iYKagAgiLkx25RZ2T0vaqv7NPU4mhG1qEd1kcM3CkrTLUQzseYgcPKFRj8jj9qIGpmALb6z6DV+ni13EK4muiuuaONYhrQC/70eCsyq16QweJo30Uwl6W3JECF+Il99yJgxN0PehB+F4OP7McWEEr0hqZ4H4Eg6o79cSWOutIxQMF2+RZEYrkuj+Dz/mjS6HYMEbK+bfUHDtDsDshTWHOtj8BwTFPzCynex7xRIAw87SFieGYLD8EMffh4qKu/gnePJPgZrQ9RCn2B34Hv81tjHO4L8lbTWefxC3ZOSGHIIoOzSdEdXuG7JU/xvj+S27uxOpKVlJs6N8ZOox+TQ9nBMxONMLT2cUSlrysuOCAcAUXZeH4Q551QbX/Mz7pPZhOQd5ipN1p7c1bEARfiF5sJ2HeUWidN2faIfRLwihuiGTIWVSOVvacCgaWuxtr7TEzazBuuhpn1P+dOA15TeoI4NXkZKQuhdZr6BYXKLWFGD/7JXjDwnFo5uh8xYMwIM1qzT+PkVcdr8G3D18FGpyR2GZUwp5DJ3D5lHtd1Njm1Hvn4KB0stv2L65urM9W2PhMZFKsOMxw5RhtZVjPN719GQ+DjihaLCpeeiSX2RXs1MnQ+xDal3sLTzNzv0dYyyBX52OSajxek/b4ZLyXHAQdnY2imF+ryr7zIobDDl7BCWjD5u3In0jnIxr87J0ZpmQPrfVAvAavTTJl08AmwycEvg3wZhQPcKJUEXEfiNtl4ETHy5ZygM8KKI6oACNX1bVfNCQU9XIc1VRrnJGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4187,"cache_read_input_tokens":145576,"cache_creation":{"ephemeral_5m_input_tokens":4187,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"e8f27392-7fa1-4112-95ba-6f53105ce6f5","request_id":"req_011CePccCeX1bxs1qufLQx7v"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePccEpz5DUeyVYWTuEvZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DuNeUqwEs5gQ7UykCHKgSb","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx","old_string":"import { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";","new_string":"import { ReactNode, useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4187,"cache_read_input_tokens":145576,"cache_creation":{"ephemeral_5m_input_tokens":4187,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"11e237f5-9fcf-408b-b4f0-eea68d0f0c70","request_id":"req_011CePccCeX1bxs1qufLQx7v"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DuNeUqwEs5gQ7UykCHKgSb","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"e54a45db-d2ad-4bfc-a5c4-a3729333885c","timestamp":"2026-08-25T14:21:12.495Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx","oldString":"import { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";","newString":"import { ReactNode, useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";","originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Job.tsx\n * Rôle : Fiche d'une offre — panneau de métriques « En un coup d'œil »\n * (salaire vs marché, exigences, fraîcheur), description, lien vers\n * l'offre originale, cœur ♥ favoris « Mon univers Ka »\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\nimport { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport {\n agoLabel, compactMoney, daysSince, displayTitle, fetchJob, formatDate,\n formatSalary, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,\n} from \"../api\";\nimport { FavButton } from \"../favorites\";\n\n/** Tuile métrique : valeur mise en avant + libellé mono + précision optionnelle. */\nfunction Metric({ label, value, sub, title }: {\n label: string; value: React.ReactNode; sub?: React.ReactNode; title?: string;\n}) {\n return (\n
\n
{value}
\n
{label}
\n {sub &&
{sub}
}\n
\n );\n}\n\n/** Barre de position du salaire de l'offre dans la fourchette P25–P75 de sa\n * catégorie (fenêtre interquartile = zone « normale » du marché). */\nfunction MarketBar({ p25, p75, mid }: { p25: number; p75: number; mid: number }) {\n const lo = p25 * 0.75, hi = p75 * 1.25; // marges autour des quartiles\n const pct = (v: number) => Math.min(100, Math.max(0, ((v - lo) / (hi - lo)) * 100));\n return (\n
\n
\n
\n
\n
\n );\n}\n\nexport default function JobPage() {\n const { uid = \"\" } = useParams();\n const [job, setJob] = useState(null);\n const [error, setError] = useState(\"\");\n\n useEffect(() => {\n setJob(null);\n fetchJob(uid).then(setJob).catch((e) => setError(String(e)));\n window.scrollTo(0, 0);\n }, [uid]);\n\n if (error) return

Offre introuvable

← Retour aux offres
;\n if (!job) return
Chargement…
;\n\n const salary = formatSalary(job);\n const yearly = yearlyEquiv(job);\n const days = daysSince(job.date_posted);\n const deadlineDays = job.date_deadline ? daysSince(job.date_deadline) : null;\n const deadlineIn = job.date_deadline\n ? Math.round((new Date(`${job.date_deadline}T12:00:00`).getTime() - Date.now()) / 86400000)\n : null;\n const ctx = job.salary_context;\n const reqs = job.requirements ?? {};\n const langsReq = reqs.languages;\n const history = (job.salary_history ?? []).filter((h) => h.s_min != null);\n\n return (\n
\n
\n

← Toutes les offres

\n
\n {job.company_logo && (\n { (e.target as HTMLImageElement).style.display = \"none\"; }}\n />\n )}\n

{displayTitle(job)}

\n \n
\n

\n {job.employer}\n {typeof job.employer_jobs === \"number\" && job.employer_jobs > 1 && (\n · {job.employer_jobs} offres actives\n )}\n {job.location_label && — {job.location_label}}\n {job.region && job.region !== \"Québec\" && ({job.region})}\n

\n\n
\n {days !== null && days <= 3 && Nouveau}\n {job.is_direct !== false && Offre directe}\n {job.seniority && {SENIORITY_FR[job.seniority] ?? job.seniority}}\n {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n {job.language && {LANG_FR[job.language] ?? job.language}}\n {job.category && {job.category}}\n {job.active === 0 && Offre retirée}\n
\n\n {/* --- En un coup d'œil : métriques claires, jamais inventées --------- */}\n

En un coup d'œil

\n
\n \n {ctx && ctx.job_mid != null && ctx.delta_pct != null && (\n = 0 ? \"delta-up\" : \"delta-down\"}>\n {ctx.delta_pct > 0 ? \"+\" : \"\"}{ctx.delta_pct.toLocaleString(\"fr-CA\")} %\n \n }\n sub={\n <>\n \n médiane {compactMoney(ctx.median)} · P25 {compactMoney(ctx.p25)} – P75 {compactMoney(ctx.p75)} · {ctx.n.toLocaleString(\"fr-CA\")} offres comparées\n \n }\n 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\"\n />\n )}\n {typeof reqs.experience_years === \"number\" && (\n 1 ? \"s\" : \"\"} min.`}\n sub=\"Extrait du texte de l'offre\"\n />\n )}\n {typeof reqs.education === \"string\" && (\n \n )}\n {Array.isArray(langsReq) && langsReq.length > 0 && (\n \n )}\n {days !== null && (\n \n )}\n {deadlineIn !== null && deadlineDays !== null && (\n = 0 ? `dans ${deadlineIn} jour${deadlineIn > 1 ? \"s\" : \"\"}` : \"dépassée\"}\n sub={formatDate(job.date_deadline)}\n title=\"Date limite pour postuler, publiée par l'employeur\"\n />\n )}\n
\n\n

\n \n Postuler chez {job.employer} ↗\n \n

\n

\n Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka\n n'est pas un intermédiaire, la candidature se fait chez l'employeur.\n

\n\n {history.length > 1 && (\n <>\n

Évolution du salaire affiché

\n
    \n {history.map((h, i) => (\n
  • \n {new Date(h.ts * 1000).toLocaleDateString(\"fr-CA\")}\n {\" — \"}\n {formatSalary({ salary_min: h.s_min, salary_max: h.s_max, salary_unit: h.unit })}\n
  • \n ))}\n
\n \n )}\n\n {job.benefits && job.benefits.length > 0 && (\n <>\n

Avantages

\n
    \n {job.benefits.map((b, i) =>
  • {b}
  • )}\n
\n \n )}\n {job.description && (\n <>\n

Description du poste

\n
{job.description}
\n \n )}\n
\n
\n );\n}\n","structuredPatch":[{"oldStart":10,"oldLines":7,"newStart":10,"newLines":7,"lines":[" * Créé : 2026-08-17 Modifié : 2026-08-25"," * ============================================================================="," */","-import { useEffect, useState } from \"react\";","+import { ReactNode, useEffect, useState } from \"react\";"," import { Link, useParams } from \"react-router-dom\";"," import {"," agoLabel, compactMoney, daysSince, displayTitle, fetchJob, formatDate,"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePccEpz5DUeyVYWTuEvZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PzWkTpEz9Z7YmX4LSVxFHP","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx","old_string":"function Metric({ label, value, sub, title }: {\n label: string; value: React.ReactNode; sub?: React.ReactNode; title?: string;\n}) {","new_string":"function Metric({ label, value, sub, title }: {\n label: string; value: ReactNode; sub?: ReactNode; title?: string;\n}) {"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4187,"cache_read_input_tokens":145576,"cache_creation":{"ephemeral_5m_input_tokens":4187,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"1356ba3f-ee7e-4416-801c-846e707f8784","request_id":"req_011CePccCeX1bxs1qufLQx7v"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PzWkTpEz9Z7YmX4LSVxFHP","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"2fd1ca2c-14fd-44cb-ab5f-4a103ef79b1b","timestamp":"2026-08-25T14:21:13.844Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Job.tsx","oldString":"function Metric({ label, value, sub, title }: {\n label: string; value: React.ReactNode; sub?: React.ReactNode; title?: string;\n}) {","newString":"function Metric({ label, value, sub, title }: {\n label: string; value: ReactNode; sub?: ReactNode; title?: string;\n}) {","originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Job.tsx\n * Rôle : Fiche d'une offre — panneau de métriques « En un coup d'œil »\n * (salaire vs marché, exigences, fraîcheur), description, lien vers\n * l'offre originale, cœur ♥ favoris « Mon univers Ka »\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\nimport { ReactNode, useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport {\n agoLabel, compactMoney, daysSince, displayTitle, fetchJob, formatDate,\n formatSalary, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,\n} from \"../api\";\nimport { FavButton } from \"../favorites\";\n\n/** Tuile métrique : valeur mise en avant + libellé mono + précision optionnelle. */\nfunction Metric({ label, value, sub, title }: {\n label: string; value: React.ReactNode; sub?: React.ReactNode; title?: string;\n}) {\n return (\n
\n
{value}
\n
{label}
\n {sub &&
{sub}
}\n
\n );\n}\n\n/** Barre de position du salaire de l'offre dans la fourchette P25–P75 de sa\n * catégorie (fenêtre interquartile = zone « normale » du marché). */\nfunction MarketBar({ p25, p75, mid }: { p25: number; p75: number; mid: number }) {\n const lo = p25 * 0.75, hi = p75 * 1.25; // marges autour des quartiles\n const pct = (v: number) => Math.min(100, Math.max(0, ((v - lo) / (hi - lo)) * 100));\n return (\n
\n
\n
\n
\n
\n );\n}\n\nexport default function JobPage() {\n const { uid = \"\" } = useParams();\n const [job, setJob] = useState(null);\n const [error, setError] = useState(\"\");\n\n useEffect(() => {\n setJob(null);\n fetchJob(uid).then(setJob).catch((e) => setError(String(e)));\n window.scrollTo(0, 0);\n }, [uid]);\n\n if (error) return

Offre introuvable

← Retour aux offres
;\n if (!job) return
Chargement…
;\n\n const salary = formatSalary(job);\n const yearly = yearlyEquiv(job);\n const days = daysSince(job.date_posted);\n const deadlineDays = job.date_deadline ? daysSince(job.date_deadline) : null;\n const deadlineIn = job.date_deadline\n ? Math.round((new Date(`${job.date_deadline}T12:00:00`).getTime() - Date.now()) / 86400000)\n : null;\n const ctx = job.salary_context;\n const reqs = job.requirements ?? {};\n const langsReq = reqs.languages;\n const history = (job.salary_history ?? []).filter((h) => h.s_min != null);\n\n return (\n
\n
\n

← Toutes les offres

\n
\n {job.company_logo && (\n { (e.target as HTMLImageElement).style.display = \"none\"; }}\n />\n )}\n

{displayTitle(job)}

\n \n
\n

\n {job.employer}\n {typeof job.employer_jobs === \"number\" && job.employer_jobs > 1 && (\n · {job.employer_jobs} offres actives\n )}\n {job.location_label && — {job.location_label}}\n {job.region && job.region !== \"Québec\" && ({job.region})}\n

\n\n
\n {days !== null && days <= 3 && Nouveau}\n {job.is_direct !== false && Offre directe}\n {job.seniority && {SENIORITY_FR[job.seniority] ?? job.seniority}}\n {job.work_mode && {MODE_FR[job.work_mode] ?? job.work_mode}}\n {job.employment_type && {TYPE_FR[job.employment_type] ?? job.employment_type}}\n {job.language && {LANG_FR[job.language] ?? job.language}}\n {job.category && {job.category}}\n {job.active === 0 && Offre retirée}\n
\n\n {/* --- En un coup d'œil : métriques claires, jamais inventées --------- */}\n

En un coup d'œil

\n
\n \n {ctx && ctx.job_mid != null && ctx.delta_pct != null && (\n = 0 ? \"delta-up\" : \"delta-down\"}>\n {ctx.delta_pct > 0 ? \"+\" : \"\"}{ctx.delta_pct.toLocaleString(\"fr-CA\")} %\n \n }\n sub={\n <>\n \n médiane {compactMoney(ctx.median)} · P25 {compactMoney(ctx.p25)} – P75 {compactMoney(ctx.p75)} · {ctx.n.toLocaleString(\"fr-CA\")} offres comparées\n \n }\n 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\"\n />\n )}\n {typeof reqs.experience_years === \"number\" && (\n 1 ? \"s\" : \"\"} min.`}\n sub=\"Extrait du texte de l'offre\"\n />\n )}\n {typeof reqs.education === \"string\" && (\n \n )}\n {Array.isArray(langsReq) && langsReq.length > 0 && (\n \n )}\n {days !== null && (\n \n )}\n {deadlineIn !== null && deadlineDays !== null && (\n = 0 ? `dans ${deadlineIn} jour${deadlineIn > 1 ? \"s\" : \"\"}` : \"dépassée\"}\n sub={formatDate(job.date_deadline)}\n title=\"Date limite pour postuler, publiée par l'employeur\"\n />\n )}\n
\n\n

\n \n Postuler chez {job.employer} ↗\n \n

\n

\n Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka\n n'est pas un intermédiaire, la candidature se fait chez l'employeur.\n

\n\n {history.length > 1 && (\n <>\n

Évolution du salaire affiché

\n
    \n {history.map((h, i) => (\n
  • \n {new Date(h.ts * 1000).toLocaleDateString(\"fr-CA\")}\n {\" — \"}\n {formatSalary({ salary_min: h.s_min, salary_max: h.s_max, salary_unit: h.unit })}\n
  • \n ))}\n
\n \n )}\n\n {job.benefits && job.benefits.length > 0 && (\n <>\n

Avantages

\n
    \n {job.benefits.map((b, i) =>
  • {b}
  • )}\n
\n \n )}\n {job.description && (\n <>\n

Description du poste

\n
{job.description}
\n \n )}\n
\n
\n );\n}\n","structuredPatch":[{"oldStart":20,"oldLines":7,"newStart":20,"newLines":7,"lines":[" "," /** Tuile métrique : valeur mise en avant + libellé mono + précision optionnelle. */"," function Metric({ label, value, sub, title }: {","- label: string; value: React.ReactNode; sub?: React.ReactNode; title?: string;","+ label: string; value: ReactNode; sub?: ReactNode; title?: string;"," }) {"," return (","
"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"f5ca9a68-37ab-4ef8-853a-05a700d13ab4","session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcdQ1R84wbCcMEF3TJu","type":"message","role":"assistant","content":[{"type":"text","text":"Le filtre séniorité sur l'accueil, puis le CSS :"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":957,"cache_read_input_tokens":149763,"cache_creation":{"ephemeral_5m_input_tokens":957,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"603df92d-a77d-4f84-a1a5-32d60be667b9","request_id":"req_011CePcdMgX2gmCfJFBw5wti"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcdQ1R84wbCcMEF3TJu","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Nk9FYmhjrucQkQZNkA19pi","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx","old_string":" * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres\n * Créé : 2026-08-17 Modifié : 2026-08-22","new_string":" * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres\n * Créé : 2026-08-17 Modifié : 2026-08-25"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":957,"cache_read_input_tokens":149763,"cache_creation":{"ephemeral_5m_input_tokens":957,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"76f2c969-f51d-4eff-b441-d4ec13c148ff","request_id":"req_011CePcdMgX2gmCfJFBw5wti"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Nk9FYmhjrucQkQZNkA19pi","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"24356ee4-0ffa-493a-bbd4-078d3b779648","timestamp":"2026-08-25T14:21:22.564Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx","oldString":" * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres\n * Créé : 2026-08-17 Modifié : 2026-08-22","newString":" * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres\n * Créé : 2026-08-17 Modifié : 2026-08-25","originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Home.tsx\n * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres\n * Créé : 2026-08-17 Modifié : 2026-08-22\n * =============================================================================\n */\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useSearchParams } from \"react-router-dom\";\nimport { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, TYPE_FR } from \"../api\";\nimport JobCard from \"../components/JobCard\";\n\nconst PAGE = 30;\n\nexport default function Home() {\n const [params, setParams] = useSearchParams();\n const [facets, setFacets] = useState(null);\n const [jobs, setJobs] = useState([]);\n const [total, setTotal] = useState(0);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(\"\");\n\n const filters = useMemo(() => ({\n q: params.get(\"q\") ?? \"\",\n city: params.get(\"ville\") ?? \"\",\n region: params.get(\"region\") ?? \"\",\n category: params.get(\"categorie\") ?? \"\",\n work_mode: params.get(\"mode\") ?? \"\",\n employment_type: params.get(\"type\") ?? \"\",\n language: params.get(\"langue\") ?? \"\",\n with_salary: params.get(\"salaire\") === \"1\" ? 1 : undefined,\n sort: params.get(\"tri\") ?? \"recent\",\n }), [params]);\n const page = Math.max(1, parseInt(params.get(\"page\") ?? \"1\", 10));\n\n useEffect(() => { fetchFacets().then(setFacets).catch(() => {}); }, []);\n\n useEffect(() => {\n setLoading(true);\n setError(\"\");\n fetchJobs(filters, PAGE, (page - 1) * PAGE)\n .then((r) => { setJobs(r.jobs); setTotal(r.total); })\n .catch((e) => setError(String(e)))\n .finally(() => setLoading(false));\n }, [filters, page]);\n\n const setParam = (key: string, value: string) => {\n const next = new URLSearchParams(params);\n if (value) next.set(key, value); else next.delete(key);\n next.delete(\"page\");\n setParams(next, { replace: true });\n };\n\n const pages = Math.max(1, Math.ceil(total / PAGE));\n\n return (\n <>\n
\n
\n Pages carrières · Zéro intermédiaire\n

L'emploi au Québec, à la source.

\n

\n Job·Ka agrège les offres directement depuis les pages carrières des\n employeurs québécois. Salaires transparents, lien vers l'offre\n originale, aucun intermédiaire.\n

\n
\n {\n if (e.key === \"Enter\") setParam(\"q\", (e.target as HTMLInputElement).value);\n }}\n />\n \n \n \n \n \n \n \n
\n
\n
\n\n
\n

\n {loading ? \"Chargement…\" : `${total.toLocaleString(\"fr-CA\")} offre${total > 1 ? \"s\" : \"\"} trouvée${total > 1 ? \"s\" : \"\"}`}\n {\" · \"}\n setParam(\"tri\", e.target.value)}\n style={{ border: \"none\", background: \"none\", color: \"inherit\", font: \"inherit\", cursor: \"pointer\" }}\n >\n \n \n \n

\n {error &&
Erreur de chargement : {error}
}\n
\n {jobs.map((j) => )}\n
\n {!loading && jobs.length === 0 && !error && (\n
Aucune offre ne correspond à ces filtres.
\n )}\n {pages > 1 && (\n
\n \n \n \n
\n )}\n
\n \n );\n}\n","structuredPatch":[{"oldStart":5,"oldLines":7,"newStart":5,"newLines":7,"lines":[" * Contact : contact@spboucher.ai"," * Fichier : frontend/src/pages/Home.tsx"," * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres","- * Créé : 2026-08-17 Modifié : 2026-08-22","+ * Créé : 2026-08-17 Modifié : 2026-08-25"," * ============================================================================="," */"," import { useEffect, useMemo, useState } from \"react\";"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcdQ1R84wbCcMEF3TJu","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015hAV6Zwgj8kozmaZTN6ixE","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx","old_string":"import { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, TYPE_FR } from \"../api\";","new_string":"import { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR } from \"../api\";"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":957,"cache_read_input_tokens":149763,"cache_creation":{"ephemeral_5m_input_tokens":957,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"734ddfc3-1014-4797-9ab2-cab8a55b0632","request_id":"req_011CePcdMgX2gmCfJFBw5wti"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015hAV6Zwgj8kozmaZTN6ixE","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"0714fc1e-bb1a-413e-b0b3-dc77e40ab630","timestamp":"2026-08-25T14:21:24.677Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx","oldString":"import { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, TYPE_FR } from \"../api\";","newString":"import { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR } from \"../api\";","originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Home.tsx\n * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useSearchParams } from \"react-router-dom\";\nimport { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, TYPE_FR } from \"../api\";\nimport JobCard from \"../components/JobCard\";\n\nconst PAGE = 30;\n\nexport default function Home() {\n const [params, setParams] = useSearchParams();\n const [facets, setFacets] = useState(null);\n const [jobs, setJobs] = useState([]);\n const [total, setTotal] = useState(0);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(\"\");\n\n const filters = useMemo(() => ({\n q: params.get(\"q\") ?? \"\",\n city: params.get(\"ville\") ?? \"\",\n region: params.get(\"region\") ?? \"\",\n category: params.get(\"categorie\") ?? \"\",\n work_mode: params.get(\"mode\") ?? \"\",\n employment_type: params.get(\"type\") ?? \"\",\n language: params.get(\"langue\") ?? \"\",\n with_salary: params.get(\"salaire\") === \"1\" ? 1 : undefined,\n sort: params.get(\"tri\") ?? \"recent\",\n }), [params]);\n const page = Math.max(1, parseInt(params.get(\"page\") ?? \"1\", 10));\n\n useEffect(() => { fetchFacets().then(setFacets).catch(() => {}); }, []);\n\n useEffect(() => {\n setLoading(true);\n setError(\"\");\n fetchJobs(filters, PAGE, (page - 1) * PAGE)\n .then((r) => { setJobs(r.jobs); setTotal(r.total); })\n .catch((e) => setError(String(e)))\n .finally(() => setLoading(false));\n }, [filters, page]);\n\n const setParam = (key: string, value: string) => {\n const next = new URLSearchParams(params);\n if (value) next.set(key, value); else next.delete(key);\n next.delete(\"page\");\n setParams(next, { replace: true });\n };\n\n const pages = Math.max(1, Math.ceil(total / PAGE));\n\n return (\n <>\n
\n
\n Pages carrières · Zéro intermédiaire\n

L'emploi au Québec, à la source.

\n

\n Job·Ka agrège les offres directement depuis les pages carrières des\n employeurs québécois. Salaires transparents, lien vers l'offre\n originale, aucun intermédiaire.\n

\n
\n {\n if (e.key === \"Enter\") setParam(\"q\", (e.target as HTMLInputElement).value);\n }}\n />\n \n \n \n \n \n \n \n
\n
\n
\n\n
\n

\n {loading ? \"Chargement…\" : `${total.toLocaleString(\"fr-CA\")} offre${total > 1 ? \"s\" : \"\"} trouvée${total > 1 ? \"s\" : \"\"}`}\n {\" · \"}\n setParam(\"tri\", e.target.value)}\n style={{ border: \"none\", background: \"none\", color: \"inherit\", font: \"inherit\", cursor: \"pointer\" }}\n >\n \n \n \n

\n {error &&
Erreur de chargement : {error}
}\n
\n {jobs.map((j) => )}\n
\n {!loading && jobs.length === 0 && !error && (\n
Aucune offre ne correspond à ces filtres.
\n )}\n {pages > 1 && (\n
\n \n \n \n
\n )}\n
\n \n );\n}\n","structuredPatch":[{"oldStart":10,"oldLines":7,"newStart":10,"newLines":7,"lines":[" */"," import { useEffect, useMemo, useState } from \"react\";"," import { useSearchParams } from \"react-router-dom\";","-import { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, TYPE_FR } from \"../api\";","+import { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR } from \"../api\";"," import JobCard from \"../components/JobCard\";"," "," const PAGE = 30;"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcdQ1R84wbCcMEF3TJu","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AunoQ521tjjDLPSdpTDvwM","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx","old_string":" work_mode: params.get(\"mode\") ?? \"\",\n employment_type: params.get(\"type\") ?? \"\",\n language: params.get(\"langue\") ?? \"\",","new_string":" work_mode: params.get(\"mode\") ?? \"\",\n employment_type: params.get(\"type\") ?? \"\",\n seniority: params.get(\"seniorite\") ?? \"\",\n language: params.get(\"langue\") ?? \"\","},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":957,"cache_read_input_tokens":149763,"cache_creation":{"ephemeral_5m_input_tokens":957,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"50b3a21c-b2f7-45c4-a66e-aed15ab6b137","request_id":"req_011CePcdMgX2gmCfJFBw5wti"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AunoQ521tjjDLPSdpTDvwM","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"61d6194e-72f4-493d-857f-9d8eca5b4a95","timestamp":"2026-08-25T14:21:26.803Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx","oldString":" work_mode: params.get(\"mode\") ?? \"\",\n employment_type: params.get(\"type\") ?? \"\",\n language: params.get(\"langue\") ?? \"\",","newString":" work_mode: params.get(\"mode\") ?? \"\",\n employment_type: params.get(\"type\") ?? \"\",\n seniority: params.get(\"seniorite\") ?? \"\",\n language: params.get(\"langue\") ?? \"\",","originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Home.tsx\n * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useSearchParams } from \"react-router-dom\";\nimport { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR } from \"../api\";\nimport JobCard from \"../components/JobCard\";\n\nconst PAGE = 30;\n\nexport default function Home() {\n const [params, setParams] = useSearchParams();\n const [facets, setFacets] = useState(null);\n const [jobs, setJobs] = useState([]);\n const [total, setTotal] = useState(0);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(\"\");\n\n const filters = useMemo(() => ({\n q: params.get(\"q\") ?? \"\",\n city: params.get(\"ville\") ?? \"\",\n region: params.get(\"region\") ?? \"\",\n category: params.get(\"categorie\") ?? \"\",\n work_mode: params.get(\"mode\") ?? \"\",\n employment_type: params.get(\"type\") ?? \"\",\n language: params.get(\"langue\") ?? \"\",\n with_salary: params.get(\"salaire\") === \"1\" ? 1 : undefined,\n sort: params.get(\"tri\") ?? \"recent\",\n }), [params]);\n const page = Math.max(1, parseInt(params.get(\"page\") ?? \"1\", 10));\n\n useEffect(() => { fetchFacets().then(setFacets).catch(() => {}); }, []);\n\n useEffect(() => {\n setLoading(true);\n setError(\"\");\n fetchJobs(filters, PAGE, (page - 1) * PAGE)\n .then((r) => { setJobs(r.jobs); setTotal(r.total); })\n .catch((e) => setError(String(e)))\n .finally(() => setLoading(false));\n }, [filters, page]);\n\n const setParam = (key: string, value: string) => {\n const next = new URLSearchParams(params);\n if (value) next.set(key, value); else next.delete(key);\n next.delete(\"page\");\n setParams(next, { replace: true });\n };\n\n const pages = Math.max(1, Math.ceil(total / PAGE));\n\n return (\n <>\n
\n
\n Pages carrières · Zéro intermédiaire\n

L'emploi au Québec, à la source.

\n

\n Job·Ka agrège les offres directement depuis les pages carrières des\n employeurs québécois. Salaires transparents, lien vers l'offre\n originale, aucun intermédiaire.\n

\n
\n {\n if (e.key === \"Enter\") setParam(\"q\", (e.target as HTMLInputElement).value);\n }}\n />\n \n \n \n \n \n \n \n
\n
\n
\n\n
\n

\n {loading ? \"Chargement…\" : `${total.toLocaleString(\"fr-CA\")} offre${total > 1 ? \"s\" : \"\"} trouvée${total > 1 ? \"s\" : \"\"}`}\n {\" · \"}\n setParam(\"tri\", e.target.value)}\n style={{ border: \"none\", background: \"none\", color: \"inherit\", font: \"inherit\", cursor: \"pointer\" }}\n >\n \n \n \n

\n {error &&
Erreur de chargement : {error}
}\n
\n {jobs.map((j) => )}\n
\n {!loading && jobs.length === 0 && !error && (\n
Aucune offre ne correspond à ces filtres.
\n )}\n {pages > 1 && (\n
\n \n \n \n
\n )}\n
\n \n );\n}\n","structuredPatch":[{"oldStart":30,"oldLines":6,"newStart":30,"newLines":7,"lines":[" category: params.get(\"categorie\") ?? \"\","," work_mode: params.get(\"mode\") ?? \"\","," employment_type: params.get(\"type\") ?? \"\",","+ seniority: params.get(\"seniorite\") ?? \"\","," language: params.get(\"langue\") ?? \"\","," with_salary: params.get(\"salaire\") === \"1\" ? 1 : undefined,"," sort: params.get(\"tri\") ?? \"recent\","]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CePcdQ1R84wbCcMEF3TJu","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01R6RYzN3uUqaLY3JKE5ThZR","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx","old_string":" ","new_string":" \n "},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":957,"cache_read_input_tokens":149763,"cache_creation":{"ephemeral_5m_input_tokens":957,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"5d889d06-d313-423c-8acd-834e883e3127","request_id":"req_011CePcdMgX2gmCfJFBw5wti"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01R6RYzN3uUqaLY3JKE5ThZR","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"9641f50b-d2f2-4268-bc12-9636b32158f8","uuid":"d5055f74-4829-4c42-b0e1-3578a7249d89","timestamp":"2026-08-25T14:21:30.351Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/job-ka/frontend/src/pages/Home.tsx","oldString":" ","newString":" \n ","originalFile":"/**\n * =============================================================================\n * Job·Ka — Groupe KA\n * Auteur : Simon-Pierre Boucher\n * Contact : contact@spboucher.ai\n * Fichier : frontend/src/pages/Home.tsx\n * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres\n * Créé : 2026-08-17 Modifié : 2026-08-25\n * =============================================================================\n */\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useSearchParams } from \"react-router-dom\";\nimport { Facets, fetchFacets, fetchJobs, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR } from \"../api\";\nimport JobCard from \"../components/JobCard\";\n\nconst PAGE = 30;\n\nexport default function Home() {\n const [params, setParams] = useSearchParams();\n const [facets, setFacets] = useState(null);\n const [jobs, setJobs] = useState([]);\n const [total, setTotal] = useState(0);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(\"\");\n\n const filters = useMemo(() => ({\n q: params.get(\"q\") ?? \"\",\n city: params.get(\"ville\") ?? \"\",\n region: params.get(\"region\") ?? \"\",\n category: params.get(\"categorie\") ?? \"\",\n work_mode: params.get(\"mode\") ?? \"\",\n employment_type: params.get(\"type\") ?? \"\",\n seniority: params.get(\"seniorite\") ?? \"\",\n language: params.get(\"langue\") ?? \"\",\n with_salary: params.get(\"salaire\") === \"1\" ? 1 : undefined,\n sort: params.get(\"tri\") ?? \"recent\",\n }), [params]);\n const page = Math.max(1, parseInt(params.get(\"page\") ?? \"1\", 10));\n\n useEffect(() => { fetchFacets().then(setFacets).catch(() => {}); }, []);\n\n useEffect(() => {\n setLoading(true);\n setError(\"\");\n fetchJobs(filters, PAGE, (page - 1) * PAGE)\n .then((r) => { setJobs(r.jobs); setTotal(r.total); })\n .catch((e) => setError(String(e)))\n .finally(() => setLoading(false));\n }, [filters, page]);\n\n const setParam = (key: string, value: string) => {\n const next = new URLSearchParams(params);\n if (value) next.set(key, value); else next.delete(key);\n next.delete(\"page\");\n setParams(next, { replace: true });\n };\n\n const pages = Math.max(1, Math.ceil(total / PAGE));\n\n return (\n <>\n
\n
\n Pages carrières · Zéro intermédiaire\n

L'emploi au Québec, à la source.

\n

\n Job·Ka agrège les offres directement depuis les pages carrières des\n employeurs québécois. Salaires transparents, lien vers l'offre\n originale, aucun intermédiaire.\n

\n
\n {\n if (e.key === \"Enter\") setParam(\"q\", (e.target as HTMLInputElement).value);\n }}\n />\n \n \n \n \n \n \n \n
\n
\n
\n\n
\n

\n {loading ? \"Chargement…\" : `${total.toLocaleString(\"fr-CA\")} offre${total > 1 ? \"s\" : \"\"} trouvée${total > 1 ? \"s\" : \"\"}`}\n {\" · \"}\n setParam(\"tri\", e.target.value)}\n style={{ border: \"none\", background: \"none\", color: \"inherit\", font: \"inherit\", cursor: \"pointer\" }}\n >\n \n \n \n

\n {error &&
Erreur de chargement : {error}
}\n
\n {jobs.map((j) => )}\n
\n {!loading && jobs.length === 0 && !error && (\n
Aucune offre ne correspond à ces filtres.
\n )}\n {pages > 1 && (\n
\n \n \n \n
\n )}\n
\n \n );\n}\n","structuredPatch":[{"oldStart":101,"oldLines":6,"newStart":101,"newLines":14,"lines":[" "," {facets?.employment_types.map((t) => )}"," ","+ ","