Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 KiB
Tuile « Considérations sociales et environnementales » — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ajouter dans /observatoire, juste après la tuile « Type d'achat », une tuile montrant via deux barres de progression la part des marchés filtrés comportant au moins une considération sociale (rouge) et au moins une considération environnementale (vert).
Architecture: Une fonction pure de calcul (compute_considerations_stats) dans src/figures.py, testée directement, alimente une fonction de rendu (get_considerations_card_content) qui produit un html.Div de deux dbc.Progress. La tuile est ajoutée dans _compute_dashboard_children via le make_card existant.
Tech Stack: Polars (LazyFrame), Dash / Dash Bootstrap Components (dbc.Progress), pytest.
Global Constraints
- Imports internes toujours préfixés
src.(ex.from src.figures import ...), jamaisfiguresouutils. - Dédoublonnage par
uid: un marché compté une fois (première valeur paruid). - « Au moins une considération » = la valeur de colonne contient (insensible casse)
Clause,CritèreouMarché réservé. Regex exacte :(?i)Clause|Critère|Marché réservé. - Dénominateur = tous les marchés filtrés (uid distincts), y compris
Sans objetet non renseignés. pct = round(100 * numérateur / dénominateur); si dénominateur = 0 →pct = 0.- Couleurs issues de
px.colors.qualitative.Safe: sociales =rgb(204, 102, 119)(index 1, rouge) ; environnementales =rgb(17, 119, 51)(index 3, vert). - Robustesse : si une colonne
considerations*est absente du schéma, son(count, pct)vaut(0, 0)sans exception.
Task 1: Fonction de calcul compute_considerations_stats
Files:
- Modify:
src/figures.py(ajouter la fonction aprèsget_dashboard_summary_table, ~ ligne 729) - Test:
tests/test_figures.py(créer)
Interfaces:
-
Consumes: rien (fonction pure prenant un
pl.LazyFrame). -
Produces:
compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]renvoyant{"sociales": (count, pct), "environnementales": (count, pct)}oùcount= nombre de marchés (uid distincts) avec au moins une considération etpct= pourcentage entier sur le total des uid distincts. -
Step 1: Write the failing test
Créer tests/test_figures.py :
import polars as pl
def _make_lff(rows):
return pl.LazyFrame(rows)
def test_compute_considerations_stats_basic():
from src.figures import compute_considerations_stats
lff = _make_lff(
[
# uid u1 : social oui (Clause), env non (Sans objet)
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
# uid u2 : social non (Sans objet), env oui (Critère)
{"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Critère environnemental"},
# uid u3 : social oui (Marché réservé compte), env null
{"uid": "u3", "considerationsSociales": "Marché réservé", "considerationsEnvironnementales": None},
# uid u4 : aucune considération
{"uid": "u4", "considerationsSociales": "Pas de considération sociale", "considerationsEnvironnementales": "Sans objet"},
]
)
stats = compute_considerations_stats(lff)
# 4 marchés au total. Social : u1, u3 -> 2/4 = 50%. Env : u2 -> 1/4 = 25%.
assert stats["sociales"] == (2, 50)
assert stats["environnementales"] == (1, 25)
def test_compute_considerations_stats_dedup_per_uid():
from src.figures import compute_considerations_stats
lff = _make_lff(
[
# uid u1 présent 2 fois (2 titulaires) -> compté une seule fois
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
{"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Sans objet"},
]
)
stats = compute_considerations_stats(lff)
# 2 marchés distincts. Social : u1 -> 1/2 = 50%.
assert stats["sociales"] == (1, 50)
assert stats["environnementales"] == (0, 0)
def test_compute_considerations_stats_missing_column():
from src.figures import compute_considerations_stats
lff = _make_lff(
[
{"uid": "u1", "considerationsSociales": "Clause sociale"},
{"uid": "u2", "considerationsSociales": "Sans objet"},
]
)
stats = compute_considerations_stats(lff)
# Colonne env absente -> (0, 0) sans exception. Social : 1/2 = 50%.
assert stats["sociales"] == (1, 50)
assert stats["environnementales"] == (0, 0)
def test_compute_considerations_stats_empty():
from src.figures import compute_considerations_stats
lff = pl.LazyFrame(
{
"uid": pl.Series([], dtype=pl.String),
"considerationsSociales": pl.Series([], dtype=pl.String),
"considerationsEnvironnementales": pl.Series([], dtype=pl.String),
}
)
stats = compute_considerations_stats(lff)
assert stats["sociales"] == (0, 0)
assert stats["environnementales"] == (0, 0)
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_figures.py -v
Expected: FAIL avec ImportError: cannot import name 'compute_considerations_stats'.
- Step 3: Write minimal implementation
Dans src/figures.py, ajouter après get_dashboard_summary_table (avant make_card) :
CONSIDERATIONS_REGEX = r"(?i)Clause|Critère|Marché réservé"
CONSIDERATIONS_COLUMNS = {
"sociales": "considerationsSociales",
"environnementales": "considerationsEnvironnementales",
}
def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]:
"""Part des marchés (uid distincts) ayant au moins une considération.
Renvoie {"sociales": (count, pct), "environnementales": (count, pct)}.
Dénominateur = tous les uid distincts. Colonne absente -> (0, 0).
"""
names = lff.collect_schema().names()
present = {
key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names
}
stats = {key: (0, 0) for key in CONSIDERATIONS_COLUMNS}
if not present:
return stats
agg = (
lff.select(["uid"] + list(present.values()))
.group_by("uid")
.agg([pl.col(col).first() for col in present.values()])
.collect(engine="streaming")
)
total = agg.height
if total == 0:
return stats
for key, col in present.items():
count = agg.filter(
pl.col(col).str.contains(CONSIDERATIONS_REGEX)
).height
pct = round(100 * count / total)
stats[key] = (count, pct)
return stats
- Step 4: Run test to verify it passes
Run: uv run pytest tests/test_figures.py -v
Expected: 4 tests PASS.
- Step 5: Commit
git add src/figures.py tests/test_figures.py
git commit -m "feat(observatoire): calcul part marchés avec considération sociale/env
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 2: Rendu de la tuile get_considerations_card_content
Files:
- Modify:
src/figures.py(ajouter aprèscompute_considerations_stats) - Test:
tests/test_figures.py(ajouter au fichier de la Task 1)
Interfaces:
-
Consumes:
compute_considerations_stats(lff) -> dict[str, tuple[int, int]](Task 1) ;format_number(déjà importé en tête desrc/figures.py:from src.utils.table import add_links, format_number, setup_table_columns). -
Produces:
get_considerations_card_content(lff: pl.LazyFrame) -> html.Div: unhtml.Divcontenant deux blocs (sociales, environnementales), chacun avec un libellé, undbc.Progresscoloré rempli au pourcentage, et le nombre de marchés. -
Step 1: Write the failing test
Ajouter à tests/test_figures.py :
def test_get_considerations_card_content_returns_two_progress_bars():
import dash_bootstrap_components as dbc
from dash import html
from src.figures import get_considerations_card_content
lff = pl.LazyFrame(
[
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
{"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Critère environnemental"},
]
)
div = get_considerations_card_content(lff)
assert isinstance(div, html.Div)
# Récupère récursivement tous les dbc.Progress
def find_progress(component, found):
children = getattr(component, "children", None)
if isinstance(component, dbc.Progress):
found.append(component)
if isinstance(children, (list, tuple)):
for c in children:
find_progress(c, found)
elif children is not None:
find_progress(children, found)
return found
bars = find_progress(div, [])
assert len(bars) == 2
# Sociales (rouge) : u1 -> 50%. Environnementales (vert) : u2 -> 50%.
social_bar, env_bar = bars[0], bars[1]
assert social_bar.value == 50
assert social_bar.label == "50 %"
assert social_bar.style["backgroundColor"] == "rgb(204, 102, 119)"
assert env_bar.value == 50
assert env_bar.label == "50 %"
assert env_bar.style["backgroundColor"] == "rgb(17, 119, 51)"
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_figures.py::test_get_considerations_card_content_returns_two_progress_bars -v
Expected: FAIL avec ImportError: cannot import name 'get_considerations_card_content'.
- Step 3: Write minimal implementation
Dans src/figures.py, ajouter après compute_considerations_stats :
CONSIDERATIONS_DISPLAY = [
# (clé, libellé, couleur Safe)
("sociales", "Sociales", "rgb(204, 102, 119)"),
("environnementales", "Environnementales", "rgb(17, 119, 51)"),
]
def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div:
"""Deux barres de progression : part des marchés avec considération."""
stats = compute_considerations_stats(lff)
blocks = []
for key, label, color in CONSIDERATIONS_DISPLAY:
count, pct = stats[key]
blocks.append(
html.Div(
className="mb-3",
children=[
html.Div(
className="d-flex justify-content-between",
children=[
html.Span(label),
html.Span(
f"{format_number(count)} marchés",
className="text-muted",
),
],
),
dbc.Progress(
value=pct,
label=f"{pct} %",
style={"backgroundColor": color},
),
],
)
)
return html.Div(children=blocks)
- Step 4: Run test to verify it passes
Run: uv run pytest tests/test_figures.py -v
Expected: tous les tests PASS (5 au total).
- Step 5: Commit
git add src/figures.py tests/test_figures.py
git commit -m "feat(observatoire): tuile considérations en barres de progression
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 3: Intégration dans l'Observatoire
Files:
- Modify:
src/pages/observatoire.py(import ~ lignes 20-31 ; appel dans_compute_dashboard_childrenaprès le bloc « Type d'achat », ~ ligne 723)
Interfaces:
-
Consumes:
get_considerations_card_content(lff) -> html.Div(Task 2) ;make_card(déjà importé). -
Produces: une nouvelle
dbc.Col(card) insérée dans la listecardsentre « Type d'achat » et « Distance acheteur–titulaire ». -
Step 1: Ajouter l'import
Dans src/pages/observatoire.py, dans le bloc from src.figures import (...) (lignes 20-31), ajouter get_considerations_card_content en respectant l'ordre alphabétique existant (après get_barchart_sources) :
from src.figures import (
DataTable,
get_barchart_sources,
get_considerations_card_content,
get_dashboard_summary_table,
get_distance_histogram,
get_duplicate_matrix,
get_geographic_maps,
get_top_org_table,
make_card,
make_column_picker,
make_donut,
)
- Step 2: Insérer la tuile après « Type d'achat »
Dans _compute_dashboard_children, juste après le cards.append(...) du donut « Type d'achat » (qui se termine ligne ~723) et avant distance_histogram = ..., insérer :
considerations_content = get_considerations_card_content(lff)
cards.append(
make_card(
title="Considérations sociales et environnementales",
subtitle="part des marchés concernés",
fig=considerations_content,
)
)
Le bloc résultant doit ressembler à :
donut_marche_type = make_donut(lff, "type", per_uid=True, nulls="?")
cards.append(
make_card(
title="Type d'achat",
subtitle="en nombre de marchés attribués",
fig=donut_marche_type,
)
)
considerations_content = get_considerations_card_content(lff)
cards.append(
make_card(
title="Considérations sociales et environnementales",
subtitle="part des marchés concernés",
fig=considerations_content,
)
)
distance_histogram = get_distance_histogram(lff)
- Step 3: Vérifier que la page se charge (test d'import/rendu)
Run: uv run pytest tests/test_page_loads.py -v
Expected: PASS (aucune régression sur le chargement des pages). Si tests/test_page_loads.py ne couvre pas /observatoire, lancer en complément :
Run: uv run python -c "import src.pages.observatoire"
Expected: aucune erreur d'import.
- Step 4: Lancer l'ensemble de la suite figures + observatoire
Run: uv run pytest tests/test_figures.py -v
Expected: tous PASS.
- Step 5: Commit
git add src/pages/observatoire.py
git commit -m "feat(observatoire): afficher la tuile considérations après Type d'achat
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Self-Review
Spec coverage :
- Définition « au moins une considération » (regex incl.
Marché réservé) → Task 1,CONSIDERATIONS_REGEX, testsbasic. - Dédoublonnage par
uid→ Task 1, testdedup_per_uid. - Dénominateur = tous les marchés filtrés → Task 1 (
total = agg.height), tests. - Colonne absente → 0 % → Task 1, test
missing_column; cas vide → testempty. - Deux barres
dbc.Progress, couleurs Safe rouge/vert, labelsXX %+N marchés→ Task 2. - Insertion après « Type d'achat », dimensions par défaut
make_card→ Task 3. - Hors périmètre (pas de tooltip, pas de filtre) → respecté, rien d'ajouté.
Placeholder scan : aucun TODO/TBD ; tout le code est fourni.
Type consistency : compute_considerations_stats renvoie dict[str, tuple[int, int]] clés sociales/environnementales, consommé tel quel par get_considerations_card_content (Task 2) ; get_considerations_card_content renvoie html.Div, passé à make_card(fig=...) (Task 3). Cohérent.