Files
colibre/docs/superpowers/plans/2026-07-14-mcp-rechercher-marches-colonnes.md
T

382 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Colonnes configurables pour `rechercher_marches` (MCP) — 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:** Rendre configurable la sélection des colonnes du tool MCP `rechercher_marches`, avec un champ `lien` vers chaque marché et un paramètre `colonnes` typé en `enum`.
**Architecture:** Le paramètre `colonnes` (défaut = jeu actuel, sinon « remplace ») est validé au runtime contre un ensemble sélectionnable dérivé du schéma de référence `DATA_SCHEMA ∩ duckdb_schema`, uni au défaut. Un champ virtuel `lien = APP_BASE_URL/marche/{uid}` est ajouté en Python après la requête. L'UX passe par une annotation `Literal` (enum) exposée dans le schéma du tool.
**Tech Stack:** Python, dash 4.4 (`mcp_enabled`), DuckDB, Polars, pytest, `typing.Literal`, pydantic `TypeAdapter`.
Design de référence : `docs/superpowers/specs/2026-07-14-mcp-rechercher-marches-colonnes-design.md`.
## Global Constraints
- Imports internes toujours préfixés `src.` (ex. `from src.mcp.queries import ColonneMarche`).
- `pre-commit run --files <fichiers>` avant chaque `git add` (ruff formate).
- Lancer les tests avec `uv run pytest` (l'activation du venv dans le shell n'est pas fiable ici).
- Tests ciblés sur leur propre fichier ; ne pas lancer toute la suite (Selenium/Chrome) avant la fin.
- `lien` toujours présent, non désactivable ; `uid` toujours en sortie.
- Sémantique « remplace » : `colonnes=[...]` renvoie exactement ces colonnes (+ `uid` + `lien`).
- Erreur colonne invalide : `{"error": "colonne inconnue: <col>", "champ": <col>}` (même patron que les erreurs de filtre).
- `base = os.getenv("APP_BASE_URL", "").rstrip("/")` (cohérent avec `src/mcp/auth.py`, `oauth/routes.py`).
---
## File Structure
- Modify `src/mcp/queries.py` — ajoute `SELECTABLE_COLUMNS`, `ColonneMarche`, le paramètre `colonnes` + `lien` dans `search_marches`, et `colonnes_disponibles` dans `describe_schema`.
- Modify `src/mcp/tools.py` — ajoute le paramètre `colonnes: list[ColonneMarche] | None` (annotation enum) + docstring, et le transmet à `queries.search_marches`.
- Modify `tests/mcp/test_queries.py` — comportement `search_marches` / `describe_schema`.
- Modify `tests/mcp/test_tools.py` — enum du paramètre + passthrough bout-en-bout.
---
### Task 1: `queries.py` — colonnes configurables, `lien`, schéma
**Files:**
- Modify: `src/mcp/queries.py`
- Test: `tests/mcp/test_queries.py`
**Interfaces:**
- Consumes : `DATA_SCHEMA` (`src.utils.data`), `duckdb_schema` (= `from src.db import schema as duckdb_schema`, déjà importé), `MARCHES_COLUMNS`, `to_json_records`, `query_marches`, `count_marches`, `build_where`.
- Produces :
- `SELECTABLE_COLUMNS: tuple[str, ...]` — ensemble sélectionnable (défaut (DATA_SCHEMA ∩ duckdb_schema)).
- `ColonneMarche``typing.Literal[SELECTABLE_COLUMNS]`.
- `search_marches(..., colonnes: list[str] | None = None) -> dict` — inchangé si `colonnes is None` ; sinon exactement ces colonnes (+ `uid` + `lien`) ; chaque marché gagne `lien`.
- `describe_schema()` renvoie en plus `colonnes_disponibles: list[str]`, et `colonnes_retournees` inclut `"lien"`.
- [ ] **Step 1: Écrire les tests qui échouent**
Ajouter à la fin de `tests/mcp/test_queries.py` :
```python
def test_search_marches_default_columns_and_lien(monkeypatch):
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
from src.mcp.queries import MARCHES_COLUMNS
result = search_marches(acheteur_id="123")
m = result["marches"][0]
# Toutes les colonnes du défaut + le lien
assert set(MARCHES_COLUMNS).issubset(m.keys())
assert m["lien"] == f"https://colibre.fr/marche/{m['uid']}"
def test_search_marches_custom_columns_replace(monkeypatch):
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
result = search_marches(acheteur_id="123", colonnes=["objet", "montant"])
m = result["marches"][0]
# « remplace » : exactement les colonnes demandées + uid (clé) + lien
assert set(m.keys()) == {"uid", "objet", "montant", "lien"}
def test_search_marches_custom_columns_include_uid_only_once(monkeypatch):
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
result = search_marches(acheteur_id="123", colonnes=["uid", "objet"])
m = result["marches"][0]
assert set(m.keys()) == {"uid", "objet", "lien"}
def test_search_marches_invalid_column_rejected():
result = search_marches(acheteur_id="123", colonnes=["nexiste_pas"])
assert result["error"] == "colonne inconnue: nexiste_pas"
assert result["champ"] == "nexiste_pas"
assert "marches" not in result
def test_search_marches_lien_relative_when_base_unset(monkeypatch):
monkeypatch.delenv("APP_BASE_URL", raising=False)
result = search_marches(acheteur_id="123", colonnes=["objet"])
m = result["marches"][0]
assert m["lien"] == f"/marche/{m['uid']}"
def test_describe_schema_exposes_colonnes_disponibles():
from src.mcp.queries import describe_schema
schema = describe_schema()
dispo = schema["colonnes_disponibles"]
assert isinstance(dispo, list) and dispo
# surensemble des colonnes filtrables (inclut le défaut)
assert set(schema["colonnes_filtrables"]).issubset(set(dispo))
assert "lien" in schema["colonnes_retournees"]
```
- [ ] **Step 2: Lancer les tests pour vérifier l'échec**
Run: `uv run pytest tests/mcp/test_queries.py -k "colonnes or lien or disponibles or replace or invalid_column" -v`
Expected: FAIL (`TypeError: search_marches() got an unexpected keyword argument 'colonnes'` et `KeyError: 'colonnes_disponibles'`).
- [ ] **Step 3: Ajouter `import os` et `Literal`**
En tête de `src/mcp/queries.py`, remplacer :
```python
# src/mcp/queries.py
import re
```
par :
```python
# src/mcp/queries.py
import os
import re
from typing import Literal
```
- [ ] **Step 4: Définir `SELECTABLE_COLUMNS` et `ColonneMarche`**
Juste après la définition de `MARCHES_COLUMNS` (la liste des 10 colonnes) dans `src/mcp/queries.py`, ajouter :
```python
# Colonnes sélectionnables par le client : le schéma de référence (présent en
# base) uni aux colonnes du défaut, pour que tout le défaut reste re-sélectionnable
# même si une colonne enrichie (ex. acheteur_nom) est absente de DATA_SCHEMA.
_FILTRABLES = tuple(name for name in DATA_SCHEMA if name in duckdb_schema)
SELECTABLE_COLUMNS = tuple(dict.fromkeys((*MARCHES_COLUMNS, *_FILTRABLES)))
# Enum exposé dans le schéma du tool (UX : liste fermée pour l'agent/le client).
ColonneMarche = Literal[SELECTABLE_COLUMNS]
```
- [ ] **Step 5: Implémenter la résolution des colonnes + `lien` dans `search_marches`**
Dans `src/mcp/queries.py`, remplacer la signature de `search_marches` pour ajouter le paramètre `colonnes` (à la fin, après `filtres_avances`) :
```python
def search_marches(
*,
acheteur_id: str | None = None,
titulaire_id: str | None = None,
cpv: str | None = None,
objet_contient: str | None = None,
montant_min: float | None = None,
montant_max: float | None = None,
date_min: str | None = None,
date_max: str | None = None,
departement: str | None = None,
page: int = 1,
filtres_avances: dict | None = None,
colonnes: list[str] | None = None,
) -> dict:
```
Puis, dans le corps, remplacer le bloc allant de `args = build_where_args(...)` jusqu'au `return {...}` final par :
```python
args = build_where_args(named, filtres_avances)
try:
where_sql, params, order_sql = build_where(args, duckdb_schema)
except FilterError as e:
return {"error": str(e), "champ": e.field}
if colonnes is None:
out_columns = list(MARCHES_COLUMNS)
else:
invalid = [c for c in colonnes if c not in SELECTABLE_COLUMNS]
if invalid:
return {"error": f"colonne inconnue: {invalid[0]}", "champ": invalid[0]}
# uid toujours présent (clé primaire + nécessaire au lien), sans doublon.
out_columns = ["uid"] + [c for c in colonnes if c != "uid"]
page = max(1, int(page))
offset = (page - 1) * PAGE_SIZE
order_by = order_sql or '"dateNotification" DESC, "uid" DESC'
df = query_marches(
where_sql,
params,
columns=out_columns,
order_by=order_by,
limit=PAGE_SIZE,
offset=offset,
)
total = count_marches(where_sql, params)
base = os.getenv("APP_BASE_URL", "").rstrip("/")
marches = to_json_records(df)
for marche in marches:
marche["lien"] = f"{base}/marche/{marche['uid']}"
return {
"meta": {"page": page, "page_size": PAGE_SIZE, "total": total},
"marches": marches,
}
```
- [ ] **Step 6: Exposer `colonnes_disponibles` et `lien` dans `describe_schema`**
Dans `src/mcp/queries.py`, dans `describe_schema`, remplacer le `return {...}` final par :
```python
return {
"colonnes_filtrables": colonnes,
"colonnes_retournees": [*MARCHES_COLUMNS, "lien"],
"colonnes_disponibles": list(SELECTABLE_COLUMNS),
"operateurs": sorted(OPERATORS),
"filtres_nommes": {p: f"{c}__{o}" for p, c, o in _NAMED_FILTERS},
}
```
- [ ] **Step 7: Lancer les tests pour vérifier le succès**
Run: `uv run pytest tests/mcp/test_queries.py -v`
Expected: PASS (tous, y compris les tests existants inchangés).
- [ ] **Step 8: Commit**
```bash
pre-commit run --files src/mcp/queries.py tests/mcp/test_queries.py
git add src/mcp/queries.py tests/mcp/test_queries.py
git commit -m "feat(mcp): colonnes configurables + lien dans rechercher_marches (#114)"
```
---
### Task 2: `tools.py` — paramètre `colonnes` enum + docstring
**Files:**
- Modify: `src/mcp/tools.py`
- Test: `tests/mcp/test_tools.py`
**Interfaces:**
- Consumes : `ColonneMarche`, `search_marches` (Task 1).
- Produces : `rechercher_marches(..., colonnes: list[ColonneMarche] | None = None) -> dict` — le paramètre `colonnes` porte un `enum` dans le schéma du tool et est transmis à `queries.search_marches`.
- [ ] **Step 1: Écrire les tests qui échouent**
Ajouter à la fin de `tests/mcp/test_tools.py` :
```python
def test_rechercher_marches_colonnes_param_is_enum():
import typing
from pydantic import TypeAdapter
hints = typing.get_type_hints(tools.rechercher_marches)
schema = TypeAdapter(hints["colonnes"]).json_schema()
# list[ColonneMarche] | None -> anyOf[array(items.enum), null]
array_schema = next(s for s in schema["anyOf"] if s.get("type") == "array")
enum = array_schema["items"]["enum"]
assert "objet" in enum
assert "montant" in enum
assert "uid" in enum
def test_rechercher_marches_colonnes_passthrough(monkeypatch):
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
result = tools.rechercher_marches(acheteur_id="123", colonnes=["objet"])
m = result["marches"][0]
assert set(m.keys()) == {"uid", "objet", "lien"}
assert m["lien"] == f"https://colibre.fr/marche/{m['uid']}"
```
- [ ] **Step 2: Lancer les tests pour vérifier l'échec**
Run: `uv run pytest tests/mcp/test_tools.py -k "colonnes" -v`
Expected: FAIL (`KeyError: 'colonnes'` sur `get_type_hints`, et `TypeError` sur l'appel avec `colonnes=`).
- [ ] **Step 3: Importer `ColonneMarche`**
En tête de `src/mcp/tools.py`, remplacer :
```python
from src.mcp import queries
```
par :
```python
from src.mcp import queries
from src.mcp.queries import ColonneMarche
```
- [ ] **Step 4: Ajouter le paramètre `colonnes` (signature, docstring, appel)**
Dans `src/mcp/tools.py`, remplacer entièrement la fonction `rechercher_marches` par :
```python
@mcp_enabled(name="rechercher_marches", expose_docstring=True)
def rechercher_marches(
acheteur_id: str | None = None,
titulaire_id: str | None = None,
cpv: str | None = None,
objet_contient: str | None = None,
montant_min: float | None = None,
montant_max: float | None = None,
date_min: str | None = None,
date_max: str | None = None,
departement: str | None = None,
page: int = 1,
filtres_avances: dict | None = None,
colonnes: list[ColonneMarche] | None = None,
) -> dict:
"""Recherche paginée de marchés publics (DECP).
Filtres nommés : acheteur_id, titulaire_id, cpv (code CPV, correspondance
partielle), objet_contient (texte de l'objet), montant_min, montant_max,
date_min / date_max (format YYYY-MM-DD, sur dateNotification),
departement (code département de l'acheteur).
filtres_avances : dict optionnel {"colonne__operateur": valeur} pour les
besoins pointus. Colonnes et opérateurs disponibles via l'outil
schema_donnees().
colonnes : liste optionnelle de colonnes à renvoyer. Par défaut, un jeu
standard (uid, objet, montant, dateNotification, codeCPV, acheteur_id,
acheteur_nom, acheteur_departement_code, titulaire_id, titulaire_nom). Si
fournie, REMPLACE le jeu par défaut (le champ uid reste toujours présent).
Colonnes disponibles via schema_donnees().colonnes_disponibles.
Chaque marché renvoyé contient en plus un champ `lien` (URL de la fiche
marché sur colibre).
page : numéro de page (50 résultats par page).
Retourne {meta: {page, page_size, total}, marches: [...]}.
"""
track_mcp_tool("rechercher_marches", query=objet_contient)
return queries.search_marches(
acheteur_id=acheteur_id,
titulaire_id=titulaire_id,
cpv=cpv,
objet_contient=objet_contient,
montant_min=montant_min,
montant_max=montant_max,
date_min=date_min,
date_max=date_max,
departement=departement,
page=page,
filtres_avances=filtres_avances,
colonnes=colonnes,
)
```
- [ ] **Step 5: Lancer les tests pour vérifier le succès**
Run: `uv run pytest tests/mcp/test_tools.py -v`
Expected: PASS (tous).
- [ ] **Step 6: Vérification finale des tests MCP**
Run: `uv run pytest tests/mcp/ -v`
Expected: PASS (aucune régression dans les autres modules MCP).
- [ ] **Step 7: Commit**
```bash
pre-commit run --files src/mcp/tools.py tests/mcp/test_tools.py
git add src/mcp/tools.py tests/mcp/test_tools.py
git commit -m "feat(mcp): parametre colonnes (enum) pour rechercher_marches (#114)"
```
---
## Notes d'implémentation
- **Sécurité SQL** : `query_marches` (`src/db.py`) interpole les colonnes sans quoting. La validation `c not in SELECTABLE_COLUMNS` (Step 5, Task 1) est la barrière — ne pas la retirer. `SELECTABLE_COLUMNS` ne contient que des noms issus du schéma / du défaut, jamais d'entrée libre.
- **`Literal[SELECTABLE_COLUMNS]`** : `Literal` accepte un tuple de littéraux (vérifié : produit bien `items.enum` via `TypeAdapter`). L'ordre suit `MARCHES_COLUMNS` puis les colonnes filtrables.
- **`get_type_hints`** dans le test enum : `tools.py` n'utilise pas `from __future__ import annotations`, donc l'annotation est un objet résoluble ; `ColonneMarche` doit être importable au niveau module (Step 3).
```
```