SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
11.0 KB · 136 lines markdown
Rendered Raw Blame History
1# Méthode du coût — connecteurs, sources et planificateur23> Porté d'UQO Éval vers Vrai-Prix le 2026-09-08 — processus PM2 `vrai-prix-cost-sync` sur M3U96a.45> Documentation du chantier « données réelles » de l'onglet Coût (2026-09-06).6> Code : `src/lib/cost/connectors/`, script `scripts/cost-sync.ts`, API admin `src/app/api/admin/cost/`.78## Principe910```11Sources externes → discover → fetch (Firecrawl / API / PDF, hash) → extract → normalize → validate → cost.db12```1314- Aucun connecteur ne tourne dans le chemin utilisateur : l'interface lit `data/cost.db` ; les15  synchronisations se font par `npm run cost:sync` (PM2 cron sur le nœud) ou l'API admin.16- Toute observation brute est **historisée** (`cost_raw_observations`, statut `accepted` /17  `rejected` / `unchanged`, `content_hash`, `parser_version`). Rien n'est effacé ; un document18  inchangé (même hash) n'est pas retraité.19- Les rejets gardent leur raison (`reject_reason`) : prix ≤ 0, devise ≠ CAD, unité inconnue,20  emballage mal interprété (prix par unité canonique hors bornes), hors bornes métier (×0,25…×421  du prix de référence), aberration statistique (MAD / ratio à la médiane, `pricing.detectOutliers`).22- L'IA (Claude Haiku 4.5) ne fixe **jamais** un prix. Elle sert à (1) choisir le bon produit23  parmi des résultats de recherche (`matching.ts`, sortie JSON stricte, mapping en attente si24  confiance < 0,8) et (2) transcrire les grilles APCHQ dont la mise en page PDF est décalée,25  chaque ligne étant vérifiée contre l'extraction déterministe du même document26  (`apchq-ai.ts` : couple (taux, total) présent dans le regex **ou** somme des colonnes = total).27  Consommation enregistrée dans `ai_usage` (`GET /api/admin/cost/usage`).2829## Sources3031| Clé | Source | Licence (`cost_sources.license_status`) | Fréquence | Ce qui est ingéré |32|---|---|---|---|---|33| `apchq` | APCHQ — grilles « Coût horaire de la main-d'œuvre » (PDF publics, media.apchq.com) | `public_open` (documents publics ; valeurs + URL, pas de copie du document) | 7 j + hash | `labour_rates` : taux horaire, vacances (13 %), avantages sociaux, cotisations employeur (AE, RQAP, RRQ, FSS, CNESST), autres prélèvements fixes (taxe assurance, équipement, CCQ, AECQ, fonds), **total employeur**, par métier × classification (compagnon, apprenti 1-5) × secteur (résidentiel léger, résidentiel lourd, IC), historique depuis 2023 (`effective_from` / `effective_to`). Spécialités dérivées : poseur de systèmes intérieurs ← charpentier-menuisier, poseur de bardeaux ← couvreur. Exclus : temps demi/double, R-2, chantiers isolés / Baie-James, exemples de paie. |34| `ccq` | CCQ — Salaire et taux | `public_open` | 7 j + hash | Détection de changement seulement. Site en incident de sécurité (fermé jusqu'au 2026-09-08) : le run se termine en `unavailable`, aucune valeur n'est écrite. Quand des tableaux « métier / taux $ » apparaîtront, `hasRateTable` passe à vrai et les observations brutes sont conservées (statut `new`) — la normalisation exige une confirmation de la structure (secteur, cotisations) avant écriture dans `labour_rates`. |35| `statcan` | Statistique Canada — tableau 18-10-0289-01 (indices des prix de la construction de bâtiments), API WDS | `public_open` (licence ouverte) | 30 j | `construction_cost_indices` : 38 séries × 40 trimestres — Québec (province), RMR de Québec, Montréal, Ottawa–Gatineau × bâtiments résidentiels / appartements / maison individuelle / maison en rangée (agrégat) + 11 divisions pour Montréal et Québec résidentiel. Codes `statcan:18100289:<geo>:<type>:<division>` ; `statcan:18100289:10:1:1` = série d'actualisation par défaut, `…:10:1:8` = proxy « bois, plastiques et composites ». Variations trimestrielle et annuelle calculées. |36| `canac` | Canac | `public_open` (robots.txt permissif, fiches publiques) | 1 j | `cost_item_prices` (observed) via fiches produit ; prix dans `product:price:amount`, unité « / Chaque ». |37| `bmr` | BMR | `public_open` (URL SEO `/fr/….html` ; `/catalog/product/view` interdit par robots.txt et non utilisé) | 1 j | idem (`product:price:amount`). |38| `patrickmorin` | Patrick Morin | `public_open` | 1 j | idem. |39| `rona` | RONA | `public_restricted` — **inactif** (Cloudflare, recherche interdite par robots.txt ; fiches permises). Activer via `UPDATE cost_sources SET is_active=1 WHERE key='rona'` si les fiches répondent de façon stable. | 1 j | adaptateur prêt (`ronaAdapter`). |40| `homedepot` | Home Depot Canada | `public_restricted` — **inactif** (prix dépendants du magasin sélectionné, défaut hors Québec). | 1 j | adaptateur prêt. |41| `altus` | Altus Group — Canadian Cost Guide | `public_restricted` : PDF gratuit derrière un formulaire → **import manuel** après téléchargement légal. Aucun scraping. | annuel | `cost_benchmarks` ($/pi² low/high par type et marché). |42| `rsmeans` | RSMeans / Gordian | `licensed` : import CSV autorisé seulement, jamais redistribué ; inactif. | manuel | `cost_item_prices` (price_kind `reference`, source rsmeans). |4344Les prix de référence internes (`cost_items.reference_price`) restent la dernière solution de45repli du catalogue et sont toujours étiquetés « hypothèse » par le moteur.4647## Détaillants : de l'article à l'observation48491. Chaque article du catalogue possède un `retail_query` (ex. « panneau de gypse 1/2 po 4 x 8 »),50   un contenu d'emballage par défaut (`retail_pack_qty`, ex. 32 pi²) et son unité canonique.512. **Découverte** : s'il existe déjà une URL approuvée (`cost_item_sources`), elle est réutilisée ;52   sinon recherche Firecrawl `« <domaine> <retail_query> »` (6 résultats), filtrage des URL de fiche,53   score déterministe (`matching.scoreCandidate` : toutes les dimensions de la requête doivent54   apparaître, puis recouvrement des mots). Score ≥ 0,85 avec marge → approuvé ; 0,35–0,85 →55   arbitrage IA (choix d'un candidat ou aucun) ; < 0,35 → aucun. Résultat dans `product_mappings`56   (`auto_approved` / `pending` / `rejected`) ; `pending` à valider dans l'admin.573. **Fiche** : scrape Firecrawl (markdown + métadonnées), hash incluant le prix affiché.584. **Extraction** : prix affiché, prix régulier (« Prix régulier », prix barré `~~`) et promotion59   (« Spécial », « Économisez »…) détectés uniquement dans la zone du produit principal (avant60   « Utile avec cet achat » / produits associés). Le prix **régulier** est préféré ; une promo61   sans prix régulier est marquée `is_regular_price = 0` et n'entre dans le prix canonique que62   s'il n'existe rien d'autre (`pricing.canonicalPrice`).635. **Normalisation** : contenu d'emballage lu sur la fiche (« couvre 39,8 pi² », « 100 pi »,64   « 50 lb », « pqt/100 ») sinon défaut de l'article ; conversion `units.convertPrice`65   (source_unit, canonical_unit, conversion_factor conservés).666. **Validation** puis écriture : une observation par jour et par (article, source, URL).6768## Commandes6970```bash71npm run cost:sync                          # connecteurs dus (fréquence nominale)72npx tsx scripts/cost-sync.ts apchq statcan # connecteurs choisis73npx tsx scripts/cost-sync.ts canac --force -v            # ignore hash/fréquence, journal détaillé74npx tsx scripts/cost-sync.ts --items LUM-2X6-8,GYP-1/2-4X8 bmr75npx tsx scripts/cost-sync.ts --max 20 patrickmorin76```7778Variables (`.env.local`, serveur seulement) : `FIRECRAWL_API_KEY`, `ANTHROPIC_API_KEY` (matching,79lecture APCHQ), `COST_ADMIN_TOKEN`, optionnel `AI_DAILY_BUDGET_USD` / `AI_MONTHLY_BUDGET_USD`80(alertes de l'endpoint usage), `COST_DB` (chemin de la base, défaut `data/cost.db`).8182PM2 (nœud) : processus `vrai-prix-cost-sync` avec `cron_restart` quotidien83(`/opt/homebrew/bin/npx tsx scripts/cost-sync.ts`, `autorestart: false`).8485## API admin (jeton `COST_ADMIN_TOKEN`, `Authorization: Bearer …` ou `?token=`)8687| Route | Rôle |88|---|---|89| `GET /api/admin/cost/connectors` | état par source (licence, actif, dernière synchro, dernière erreur, volumes, mappings en attente, dû ?), 40 derniers runs, indices, métriques (taux de succès 30 j, observations par source, âge du dernier prix, taux d'échec de mapping, taux d'aberrations, usage IA), runs en cours |90| `POST /api/admin/cost/sync/<connecteur>[?force=1&items=A,B]` | lance un run en arrière-plan (202 + `runId`, 409 si déjà en cours) |91| `GET /api/admin/cost/anomalies` | observations rejetées (aberrations…), mappings `pending`, sources sans synchro depuis 14 j, sauts de prix > 30 % entre deux observations, articles sans observation récente |92| `GET /api/admin/cost/mappings[?status=pending]` · `POST` `{source,itemCode,url,title?,packQty?,packUnit?}` | liste / création-approbation manuelle |93| `POST /api/admin/cost/mappings/<id>/approve` · `/reject` | validation humaine (met à jour `cost_item_sources`) |94| `POST /api/admin/cost/benchmarks/import` | `{kind:"benchmarks"\|"rsmeans", data: CSV texte ou tableau JSON}` (ou corps `text/csv` + `?kind=`) |95| `GET /api/admin/cost/usage` | `ai_usage` jour / mois / par usage, budgets et alertes |9697### Format d'import Altus (benchmarks)9899```csv100source,building_type,market,unit,low,high,year,notes101Altus Group — Canadian Cost Guide 2026,single_family,Montréal,$/pi2,255,395,2026,Single family residential (custom)102Altus Group — Canadian Cost Guide 2026,townhouse,Montréal,$/pi2,190,300,2026,103```104105`building_type ∈ {single_family, townhouse, plex, condo}` ; `market` = Montréal | Québec |106Ottawa-Gatineau (le moteur se rabat sur Montréal). Les valeurs doivent être recopiées du guide107téléchargé légalement ; elles restent à usage informatif (avis de l'éditeur).108109### Format d'import RSMeans (prix licenciés)110111```csv112item_code,price,unit,pack_qty,date,notes113LUM-2X6-8,6.12,chaque,,2026-06-01,RSMeans 2026 Q2 — Montréal CCI114```115116## Tests117118`npx vitest run src/lib/cost/connectors` — fixtures dans `__fixtures__/` (grilles APCHQ 2025 et1192026, page APCHQ avec ses liens, grille IC 2026, fiches Canac / BMR / Patrick Morin, réponses WDS120synthétiques). Couvre : dates/secteurs, découverte et exclusions, rangées fusionnées/décalées,121réconciliation IA, StatCan (yoy/qoq), CCQ indisponible, prix/promo/emballage, page sans prix,122403 / contenu vide, unité inconnue, aberrations, hash inchangé, imports.123124## Dépannage125126- **`FIRECRAWL_API_KEY manquante`** : le script lit `.env.local` du répertoire courant.127- **Run `unavailable` pour CCQ** : normal tant que l'incident dure ; rien n'est écrit.128- **Mapping `pending`** : `GET /api/admin/cost/anomalies` → `POST …/mappings/<id>/approve`.129  Un article rejeté récemment n'est pas recherché de nouveau avant 7 jours (sauf `--force`).130- **Prix aberrant légitime** (nouveau niveau de marché) : il est rejeté tant que les autres131  sources ne suivent pas ; après une semaine d'observations cohérentes il sera accepté.132- **Lecture IA APCHQ « insuffisante »** : le parseur déterministe est conservé ; vérifier133  `connector_runs.log_json`.134- **RONA / Home Depot** : inactifs par conception (voir tableau) ; activer dans `cost_sources`135  seulement après vérification manuelle de quelques fiches.136