spb/forma-ka Public
Python 65.1%
TypeScript 17.9%
CSS 16.4%
HTML 0.5%
1# -----------------------------------------------------------------------------2# Forma-Ka — Agrégateur de formations (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# schema.py : modèle de données standardisé (Formation) + normalisation finale5# -----------------------------------------------------------------------------6"""Schéma standard d'une formation et normalisation des champs.78Chaque connecteur, peu importe le site source (université, cégep, firme de9formation, plateforme…), produit des objets `Formation` conformes à ce schéma.10`finalize()` applique la couche commune (formaka/normalize.py).1112Philosophie : le PRIX EST OPTIONNEL (un cours universitaire n'affiche pas de13prix) — ce qui compte, ce sont les DÉTAILS de la formation : description,14objectifs, préalables, plan de cours, durée, crédits/UEC, mode, clientèle…15"""16from __future__ import annotations1718import hashlib19import json20from dataclasses import dataclass, field, asdict2122from .normalize import ( # ré-exportés pour les connecteurs23 clean_text,24 extract_details,25 merge_details,26 normalize_language,27 normalize_mode,28 normalize_type,29 parse_date_fr,30 parse_duration_hours,31 parse_price,32 price_is_from,33 strip_accents,34)3536__all__ = [37 "Formation", "clean_text", "normalize_type", "normalize_mode",38 "normalize_language", "parse_price", "parse_duration_hours",39 "parse_date_fr", "strip_accents",40]414243@dataclass44class Formation:45 """Formation standardisée Forma-Ka."""4647 source: str # id de la source (voir data/sources.json)48 external_id: str # identifiant chez la source49 url: str # page de la formation chez la source50 title: str = "" # ex. « Gestion de projet agile »51 training_type: str = "" # Cours universitaire, Séminaire, Atelier…52 category: str = "" # domaine : Informatique, Gestion, RH…53 mode: str = "" # en ligne | présentiel | hybride | asynchrone54 city: str = "" # ville (si présentiel/hybride)55 language: str = "" # fr | en | fr/en56 price: float | None = None # $ CAD — None = non affiché (normal !)57 price_label: str = "" # texte original (« 1 295 $ + tx »)58 is_free: bool | None = None # gratuit (None = inconnu)59 duration: str = "" # texte original (« 2 jours », « 45 h »)60 duration_hours: float | None = None61 start_date: str | None = None # ISO — prochaine séance/session62 schedule_label: str = "" # texte original des dates/horaires63 sessions: list[str] = field(default_factory=list) # toutes les dates offertes64 level: str = "" # débutant | intermédiaire | avancé65 credits: str = "" # « 3 crédits », « 1,4 UEC »66 credential: str = "" # attestation, certificat, diplôme, UEC…67 instructor: str = "" # formateur / professeur68 code: str = "" # sigle du cours (ex. « GSF-1020 »)69 description: str = "" # description complète70 objectives: list[str] = field(default_factory=list) # objectifs d'apprentissage71 prerequisites: str = "" # préalables / conditions d'admission72 audience: str = "" # clientèle visée73 program: list[str] = field(default_factory=list) # plan / contenu détaillé74 tags: list[str] = field(default_factory=list)75 details: dict = field(default_factory=dict) # champs structurés (JSON)76 images: list[str] = field(default_factory=list) # URLs absolues7778 @property79 def uid(self) -> str:80 return f"{self.source}:{self.external_id}"8182 def content_hash(self) -> str:83 """Hash du contenu pour la détection de changements (pseudo-webhook)."""84 payload = asdict(self)85 blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)86 return hashlib.sha256(blob.encode("utf-8")).hexdigest()8788 def finalize(self) -> "Formation":89 """Applique la normalisation commune. Appelé par le pipeline d'ingestion.9091 Idempotent ; ne remplace jamais une valeur explicite du connecteur.92 """93 self.title = clean_text(self.title)94 self.description = (self.description or "").strip()95 self.training_type = normalize_type(self.training_type)96 self.mode = normalize_mode(self.mode)97 self.language = normalize_language(self.language)98 self.objectives = [clean_text(o) for o in self.objectives if clean_text(o)]99 self.program = [clean_text(p) for p in self.program if clean_text(p)]100 self.tags = sorted({clean_text(t) for t in self.tags if clean_text(t)})101102 if self.price is None:103 self.price = parse_price(self.price_label)104 if self.is_free is None and self.price is not None:105 self.is_free = self.price == 0.0106 if self.duration_hours is None:107 self.duration_hours = parse_duration_hours(self.duration)108 if self.start_date is None:109 self.start_date = parse_date_fr(self.schedule_label)110 if not self.sessions and self.start_date:111 self.sessions = [self.start_date]112113 # détails dérivés des textes libres (le connecteur garde priorité)114 derived = extract_details(self.duration, self.description,115 self.credits, self.schedule_label)116 if self.price_label and price_is_from(self.price_label):117 derived["price_from"] = True118 self.details = merge_details(self.details, derived)119120 if not self.level:121 self.level = self.details.get("level", "")122 if self.duration_hours is None:123 dh = self.details.get("duration_hours")124 self.duration_hours = float(dh) if dh is not None else None125 if not self.credits:126 if self.details.get("uec") is not None:127 self.credits = f"{self.details['uec']:g} UEC".replace(".", ",")128 elif self.details.get("credits") is not None:129 self.credits = f"{self.details['credits']:g} crédits"130 return self131