SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
6.2 KB · 131 lines python
Raw Blame History
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