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

# Méthode du coût — connecteurs, sources et planificateur

Porté d'UQO Éval vers Vrai-Prix le 2026-09-08 — processus PM2 vrai-prix-cost-sync sur M3U96a.

Documentation du chantier « données réelles » de l'onglet Coût (2026-09-06). Code : src/lib/cost/connectors/, script scripts/cost-sync.ts, API admin src/app/api/admin/cost/.

# Principe

text
Sources externes → discover → fetch (Firecrawl / API / PDF, hash) → extract → normalize → validate → cost.db
  • Aucun connecteur ne tourne dans le chemin utilisateur : l'interface lit data/cost.db ; les synchronisations se font par npm run cost:sync (PM2 cron sur le nœud) ou l'API admin.
  • Toute observation brute est historisée (cost_raw_observations, statut accepted / rejected / unchanged, content_hash, parser_version). Rien n'est effacé ; un document inchangé (même hash) n'est pas retraité.
  • Les rejets gardent leur raison (reject_reason) : prix ≤ 0, devise ≠ CAD, unité inconnue, emballage mal interprété (prix par unité canonique hors bornes), hors bornes métier (×0,25…×4 du prix de référence), aberration statistique (MAD / ratio à la médiane, pricing.detectOutliers).
  • L'IA (Claude Haiku 4.5) ne fixe jamais un prix. Elle sert à (1) choisir le bon produit parmi des résultats de recherche (matching.ts, sortie JSON stricte, mapping en attente si confiance < 0,8) et (2) transcrire les grilles APCHQ dont la mise en page PDF est décalée, chaque ligne étant vérifiée contre l'extraction déterministe du même document (apchq-ai.ts : couple (taux, total) présent dans le regex ou somme des colonnes = total). Consommation enregistrée dans ai_usage (GET /api/admin/cost/usage).

# Sources

Clé Source Licence (cost_sources.license_status) Fréquence Ce qui est ingéré
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.
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.
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.
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 ».
bmr BMR public_open (URL SEO /fr/….html ; /catalog/product/view interdit par robots.txt et non utilisé) 1 j idem (product:price:amount).
patrickmorin Patrick Morin public_open 1 j idem.
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).
homedepot Home Depot Canada public_restricted — inactif (prix dépendants du magasin sélectionné, défaut hors Québec). 1 j adaptateur prêt.
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é).
rsmeans RSMeans / Gordian licensed : import CSV autorisé seulement, jamais redistribué ; inactif. manuel cost_item_prices (price_kind reference, source rsmeans).

Les prix de référence internes (cost_items.reference_price) restent la dernière solution de repli du catalogue et sont toujours étiquetés « hypothèse » par le moteur.

# Détaillants : de l'article à l'observation

  1. Chaque article du catalogue possède un retail_query (ex. « panneau de gypse 1/2 po 4 x 8 »), un contenu d'emballage par défaut (retail_pack_qty, ex. 32 pi²) et son unité canonique.
  2. Découverte : s'il existe déjà une URL approuvée (cost_item_sources), elle est réutilisée ; sinon recherche Firecrawl « <domaine> <retail_query> » (6 résultats), filtrage des URL de fiche, score déterministe (matching.scoreCandidate : toutes les dimensions de la requête doivent apparaître, puis recouvrement des mots). Score ≥ 0,85 avec marge → approuvé ; 0,35–0,85 → arbitrage IA (choix d'un candidat ou aucun) ; < 0,35 → aucun. Résultat dans product_mappings (auto_approved / pending / rejected) ; pending à valider dans l'admin.
  3. Fiche : scrape Firecrawl (markdown + métadonnées), hash incluant le prix affiché.
  4. Extraction : prix affiché, prix régulier (« Prix régulier », prix barré ~~) et promotion (« Spécial », « Économisez »…) détectés uniquement dans la zone du produit principal (avant « Utile avec cet achat » / produits associés). Le prix régulier est préféré ; une promo sans prix régulier est marquée is_regular_price = 0 et n'entre dans le prix canonique que s'il n'existe rien d'autre (pricing.canonicalPrice).
  5. Normalisation : contenu d'emballage lu sur la fiche (« couvre 39,8 pi² », « 100 pi », « 50 lb », « pqt/100 ») sinon défaut de l'article ; conversion units.convertPrice (source_unit, canonical_unit, conversion_factor conservés).
  6. Validation puis écriture : une observation par jour et par (article, source, URL).

# Commandes

bash
npm run cost:sync                          # connecteurs dus (fréquence nominale)
npx tsx scripts/cost-sync.ts apchq statcan # connecteurs choisis
npx tsx scripts/cost-sync.ts canac --force -v            # ignore hash/fréquence, journal détaillé
npx tsx scripts/cost-sync.ts --items LUM-2X6-8,GYP-1/2-4X8 bmr
npx tsx scripts/cost-sync.ts --max 20 patrickmorin

Variables (.env.local, serveur seulement) : FIRECRAWL_API_KEY, ANTHROPIC_API_KEY (matching, lecture APCHQ), COST_ADMIN_TOKEN, optionnel AI_DAILY_BUDGET_USD / AI_MONTHLY_BUDGET_USD (alertes de l'endpoint usage), COST_DB (chemin de la base, défaut data/cost.db).

PM2 (nœud) : processus vrai-prix-cost-sync avec cron_restart quotidien (/opt/homebrew/bin/npx tsx scripts/cost-sync.ts, autorestart: false).

# API admin (jeton COST_ADMIN_TOKEN, Authorization: Bearer … ou ?token=)

Route Rôle
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
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)
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
GET /api/admin/cost/mappings[?status=pending] · POST {source,itemCode,url,title?,packQty?,packUnit?} liste / création-approbation manuelle
POST /api/admin/cost/mappings/<id>/approve · /reject validation humaine (met à jour cost_item_sources)
POST /api/admin/cost/benchmarks/import {kind:"benchmarks"|"rsmeans", data: CSV texte ou tableau JSON} (ou corps text/csv + ?kind=)
GET /api/admin/cost/usage ai_usage jour / mois / par usage, budgets et alertes

# Format d'import Altus (benchmarks)

csv
source,building_type,market,unit,low,high,year,notes
Altus Group — Canadian Cost Guide 2026,single_family,Montréal,$/pi2,255,395,2026,Single family residential (custom)
Altus Group — Canadian Cost Guide 2026,townhouse,Montréal,$/pi2,190,300,2026,

building_type ∈ {single_family, townhouse, plex, condo} ; market = Montréal | Québec | Ottawa-Gatineau (le moteur se rabat sur Montréal). Les valeurs doivent être recopiées du guide téléchargé légalement ; elles restent à usage informatif (avis de l'éditeur).

# Format d'import RSMeans (prix licenciés)

csv
item_code,price,unit,pack_qty,date,notes
LUM-2X6-8,6.12,chaque,,2026-06-01,RSMeans 2026 Q2 — Montréal CCI

# Tests

npx vitest run src/lib/cost/connectors — fixtures dans __fixtures__/ (grilles APCHQ 2025 et 2026, page APCHQ avec ses liens, grille IC 2026, fiches Canac / BMR / Patrick Morin, réponses WDS synthétiques). Couvre : dates/secteurs, découverte et exclusions, rangées fusionnées/décalées, réconciliation IA, StatCan (yoy/qoq), CCQ indisponible, prix/promo/emballage, page sans prix, 403 / contenu vide, unité inconnue, aberrations, hash inchangé, imports.

# Dépannage

  • FIRECRAWL_API_KEY manquante : le script lit .env.local du répertoire courant.
  • Run unavailable pour CCQ : normal tant que l'incident dure ; rien n'est écrit.
  • Mapping pending : GET /api/admin/cost/anomalies → POST …/mappings/<id>/approve. Un article rejeté récemment n'est pas recherché de nouveau avant 7 jours (sauf --force).
  • Prix aberrant légitime (nouveau niveau de marché) : il est rejeté tant que les autres sources ne suivent pas ; après une semaine d'observations cohérentes il sera accepté.
  • Lecture IA APCHQ « insuffisante » : le parseur déterministe est conservé ; vérifier connector_runs.log_json.
  • RONA / Home Depot : inactifs par conception (voir tableau) ; activer dans cost_sources seulement après vérification manuelle de quelques fiches.