"""Visualisation DAG d'un pipeline composé — Sprint 95 (B.4). Sprint 95 — B.4 du plan d'évolution 2026. Outil d'inspection, pas de construction --------------------------------------- Le YAML reste source de vérité. Cette vue **affiche** le graphe orienté de la pipeline pour permettre l'inspection et le debug d'un benchmark d'axe B (Sprint 63+) — elle ne construit rien, ne supporte pas le drag-and-drop, n'exporte aucun JSON modifiable. Pattern identique aux autres rendus : SVG **server-side**, pas de JS, anti-injection systématique. Vue --- Layout horizontal de gauche à droite : - Chaque **nœud** est un rectangle annoté du nom du module et de ses types d'entrée/sortie. - Chaque **arête** porte une étiquette : type d'artefact + métrique principale + valeur, avec un code couleur vert/jaune/rouge selon le seuil sur la valeur. Adaptive : ``""`` si moins d'un nœud. Note d'intégration ------------------ Module pur — l'utilisateur compose les structures simples ``nodes`` et ``edges`` depuis sa ``PipelineSpec`` (Sprint 63) et son ``PipelineBenchmarkResult`` (Sprint 64) : .. code-block:: python from picarones.report.pipeline_dag_render import build_pipeline_dag_html nodes = [ {"name": s.name, "input_types": [t.value for t in s.module.input_types], "output_types": [t.value for t in s.module.output_types]} for s in spec.steps ] edges = [] for prev, curr in zip(spec.steps, spec.steps[1:]): agg = bench.aggregate_for_step(curr.name) for art_type, metrics in (agg.junction_metrics or {}).items(): for metric_name, value in metrics.items(): edges.append({ "from": prev.name, "to": curr.name, "artifact_type": art_type, "metric_name": metric_name, "metric_value": value.get("mean"), }) html = build_pipeline_dag_html(nodes, edges, labels) """ from __future__ import annotations from html import escape as _e from typing import Optional # Seuils par défaut sur les métriques d'erreur (CER-like, lower is better). _DEFAULT_THRESHOLDS = (0.05, 0.15) # vert ≤ 0.05, jaune ≤ 0.15, rouge > 0.15 def _classify_metric( value: Optional[float], thresholds: tuple[float, float], higher_is_better: bool, ) -> str: """Retourne ``"green"``, ``"yellow"``, ``"red"`` ou ``"none"``.""" if value is None: return "none" try: v = float(value) except (TypeError, ValueError): return "none" low, high = thresholds if higher_is_better: # Inversion : haut = bon if v >= 1.0 - low: return "green" if v >= 1.0 - high: return "yellow" return "red" if v <= low: return "green" if v <= high: return "yellow" return "red" # Sprint A7 (m-5) — palette Okabe-Ito daltonien-friendly importée # depuis le module canonique ``picarones.report.colors``. Avant # A7, les hex étaient hardcodés (rouge/vert classiques, problème # pour la deutéranopie) ; maintenant cohérent avec _cer_color et # difficulty_color. from picarones.report.colors import COLOR_GREEN, COLOR_RED, COLOR_YELLOW _QUALITY_COLORS = { "green": COLOR_GREEN, # Okabe-Ito blue (substitut sémantique « bon ») "yellow": COLOR_YELLOW, # Okabe-Ito yellow "red": COLOR_RED, # Okabe-Ito vermillion (substitut sémantique « mauvais ») "none": "#6b7280", } def _format_value(value: Optional[float]) -> str: if value is None: return "—" try: v = float(value) except (TypeError, ValueError): return "—" if abs(v) < 1.0: return f"{v * 100:.1f}%" return f"{v:.2f}" def build_pipeline_dag_html( nodes: Optional[list[dict]], labels: Optional[dict[str, str]] = None, edges: Optional[list[dict]] = None, *, thresholds: tuple[float, float] = _DEFAULT_THRESHOLDS, higher_is_better: bool = False, ) -> str: """Construit la vue HTML « Pipeline DAG ». Parameters ---------- nodes: Liste de dicts ``{"name", "input_types"?, "output_types"?}`` dans l'ordre topologique. Si vide ou ``None``, retourne ``""``. labels: Dict i18n. Clés sous le préfixe ``dag_*``. edges: Liste de dicts ``{"from", "to", "artifact_type"?, "metric_name"?, "metric_value"?}``. Optionnel — auto-déduit séquentiel sinon. thresholds: ``(seuil_vert, seuil_jaune)`` sur la valeur de métrique. Défaut ``(0.05, 0.15)`` — convention CER. higher_is_better: Si ``True``, la sémantique est inversée (1 = meilleur). """ nodes = list(nodes or []) if not nodes: return "" edges = list(edges or []) labels = labels or {} title = labels.get("dag_title", "Pipeline DAG") note = labels.get( "dag_note", "Graphe orienté du pipeline composé. Chaque arête porte " "le type d'artefact transmis et la métrique calculée à " "la jonction. Code couleur vert/orange/rouge selon le " "seuil. Outil d'inspection — le YAML reste source de " "vérité.", ) # Layout horizontal régulier n = len(nodes) box_width = 160 box_height = 70 h_gap = 110 # espace horizontal entre nœuds margin = 30 svg_width = margin * 2 + n * box_width + (n - 1) * h_gap svg_height = box_height + margin * 2 + 60 # +60 pour étiquettes arêtes centre_y = margin + box_height / 2 + 30 # offset pour étiquette de tête # Index des nœuds par name pour récupérer la position node_x: dict[str, float] = {} parts: list[str] = [ '
', f'

{_e(title)}

', f'
' f'{_e(note)}
', f'', # Définition d'une flèche '' '' '' '' '', ] # Étape 1 : nœuds for i, node in enumerate(nodes): name = str(node.get("name") or f"step_{i}") x = margin + i * (box_width + h_gap) y = margin + 30 node_x[name] = x + box_width in_types = ", ".join(node.get("input_types") or []) out_types = ", ".join(node.get("output_types") or []) parts.append( f'' ) parts.append( f'{_e(name)}' ) if in_types: parts.append( f'in: {_e(in_types)}' ) if out_types: parts.append( f'out: {_e(out_types)}' ) # Étape 2 : arêtes (mappées sur paires séquentielles si pas de # "from"/"to" explicites — voir nodes par défaut) auto_edges: list[dict] = [] if not edges: for prev, curr in zip(nodes, nodes[1:]): auto_edges.append({ "from": prev.get("name"), "to": curr.get("name"), }) else: auto_edges = edges for edge in auto_edges: src = str(edge.get("from") or "") dst = str(edge.get("to") or "") if not src or not dst: continue # Position : du bord droit du src au bord gauche du dst # Heuristique : on prend la position du nœud src dans la # liste pour calculer x1, et celle de dst pour x2. try: i_src = next( i for i, n_ in enumerate(nodes) if n_.get("name") == src ) i_dst = next( i for i, n_ in enumerate(nodes) if n_.get("name") == dst ) except StopIteration: continue x1 = margin + i_src * (box_width + h_gap) + box_width x2 = margin + i_dst * (box_width + h_gap) y = centre_y # Classe la métrique pour le code couleur value = edge.get("metric_value") try: value_f = float(value) if value is not None else None except (TypeError, ValueError): value_f = None cls = _classify_metric(value_f, thresholds, higher_is_better) color = _QUALITY_COLORS[cls] # Trace la flèche parts.append( f'' ) # Étiquette : type + métrique : valeur artifact_type = edge.get("artifact_type") or "" metric_name = edge.get("metric_name") or "" value_str = _format_value(value_f) label_lines: list[str] = [] if artifact_type: label_lines.append(str(artifact_type)) if metric_name: label_lines.append(f"{metric_name}: {value_str}") if label_lines: label_x = (x1 + x2) / 2 for k, line in enumerate(label_lines): parts.append( f'' f'{_e(line)}' ) parts.append("") # Légende h_legend = labels.get("dag_legend", "Lecture") legend_green = labels.get("dag_legend_green", "qualité élevée") legend_yellow = labels.get("dag_legend_yellow", "qualité moyenne") legend_red = labels.get("dag_legend_red", "qualité faible") parts.append( '
' f'{_e(h_legend)} : ' f' {_e(legend_green)} ' f'(≤ {thresholds[0] * 100:.0f}%) ' f' {_e(legend_yellow)} ' f'(≤ {thresholds[1] * 100:.0f}%) ' f' {_e(legend_red)}' '
' ) parts.append("
") return "".join(parts) __all__ = ["build_pipeline_dag_html"]