Merge branch '111-mcp-tools' into dev

This commit is contained in:
Colin Maudry
2026-07-09 21:53:57 +02:00
16 changed files with 1901 additions and 2 deletions
+6
View File
@@ -72,3 +72,9 @@ FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks
# Accès gratuit temporaire
TOUS_ABONNES=false # true = ouvre gratuitement les fonctionnalités d'abonné à tout compte
# Serveur MCP (issue #111). Laisser à false tant que l'authentification
# (OAuth + gate abonnement, scope B) n'est pas en place : sinon le serveur MCP
# est ouvert sans contrôle d'accès. Prérequis Matomo pour le tracking des appels :
# créer un Custom Dimension slot 1 (scope Action).
DASH_MCP_ENABLED=false
+1
View File
@@ -5,6 +5,7 @@
- Abonnement payant à colibre : période d'essai gratuite, souscription et gestion du moyen de paiement, résiliation, historique de facturation ([#90](https://github.com/ColinMaudry/colibre/issues/90))
- Sauvegarde de vues personnalisées (filtres, tris, colonnes) dans la page Tableau, réservée aux abonné·es ([#95](https://github.com/ColinMaudry/colibre/issues/95))
- Vote pour prioriser les fonctionnalités de la roadmap, réservé aux abonné·es une fois leur période d'essai terminée, avec une page roadmap publique en lecture seule ([#94](https://github.com/ColinMaudry/colibre/issues/94))
- Accès aux données de la commande publique via un serveur MCP (Model Context Protocol), pour interroger colibre directement depuis un agent IA (Claude, Cursor…) ([#111](https://github.com/ColinMaudry/colibre/issues/111))
**Autres améliorations**
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,228 @@
# Design — Serveur MCP colibre, lot 1 : les tools (scope A de l'issue #111)
**Date** : 2026-07-09
**Issue** : #111 — Serveur MCP des données colibre, conditionné à l'abonnement
**Périmètre de CE design** : **A seulement** — exposer des fonctions métier comme
_tools_ MCP via le décorateur `@mcp_enabled` de Dash. **Sans authentification.**
La couche d'autorisation OAuth 2.0 + gate abonnement fera l'objet d'un design
séparé (scope B).
## Contexte
- La migration Dash 4.x (#101) est terminée (Dash 4.4). Le serveur MCP de Dash est
disponible à partir de Dash 4.3.0 → prérequis satisfait.
- Dash fournit la couche protocole MCP (`enable_mcp=True`, décorateur
`@mcp_enabled`, `configure_mcp_server(...)`). Dash **n'implémente pas**
l'authentification (cf. `/dash-mcp/auth`) → c'est le scope B, hors de ce design.
- Les **DECP sont des données publiques ouvertes**. Exposer recherche/stats en MCP
n'est donc pas une fuite de confidentialité ; le gate abonnement (scope B) relève
du contrôle d'accès / monétisation, pas du secret. Conséquence : le scope A peut
tourner en dev/local sans risque de données.
## Ce qui existe déjà et qu'on réutilise
- `src/db.py` : `query_marches(where_sql, params, columns, order_by, limit, offset)`,
`count_marches(where_sql, params)`, `aggregate_marches(select_sql, where_sql, params, group_by, order_by, limit, offset)` — accès DuckDB paramétré renvoyant du
Polars.
- `src/api/filters.py` : `build_where(args, schema) -> (where_sql, params, order_by)`
et `parse_aggregators(...)` — moteur de filtres `col__op=valeur` déjà utilisé par
l'API REST (`src/api/routes.py`). **On le réutilise tel quel** pour que MCP et REST
partagent la même sémantique de filtrage.
- `src/utils/search.py` : `search_org(dff, query, org_type)` — recherche floue par
nom sur les acheteurs/titulaires (déjà utilisée par la page `/`).
- `src/utils/tracking.py` : `track_search(query, category)` — envoi direct à l'API
HTTP de tracking Matomo (`matomo.php`).
## Approches considérées
- **A — Module MCP fin réutilisant la couche données existante (RETENU).**
Nouveau package `src/mcp/`, 4 fonctions `@mcp_enabled` appelant directement
`db.*` / `filters.build_where` / `search_org`. Le « service layer » partagé
qu'on voudrait existe déjà (`src/db.py` + `src/api/filters.py`) → peu de code neuf.
- **B — Extraire un service commun** partagé entre `api/routes.py` et MCP. Meilleure
déduplication à terme mais gros refactor de l'API REST pour un gain marginal (la
logique est déjà factorisée). Rejeté (YAGNI).
- **C — MCP appelle l'API REST en HTTP.** Ajoute un saut HTTP interne en process,
perd le typage. Rejeté.
## Architecture
### Arborescence
```
src/mcp/
__init__.py
tools.py # les 4 fonctions @mcp_enabled (surface MCP)
serialization.py # Polars → JSON propre (dates ISO, montants, None-safe)
stats.py # helpers d'agrégation acheteur/titulaire, partagés par les 2 tools stats_*
```
### Activation (dans `src/app.py`, là où `Dash(...)` est construit)
```python
from dash.mcp import configure_mcp_server
app = Dash(__name__, ..., enable_mcp=os.getenv("DASH_MCP_ENABLED") == "true")
configure_mcp_server(
include_layout=False,
include_callbacks=False,
include_pages=False,
include_clientside_callbacks=False,
) # n'expose QUE les fonctions @mcp_enabled — aucun callback/layout/page d'UI
import src.mcp.tools # noqa: E402,F401 — l'import enregistre les @mcp_enabled
```
**Sécurité (point de vigilance #111)** : en coupant `include_callbacks/layout/pages/ clientside`, aucun callback d'UI ni nom interne (type `get_data_from_s3`) n'est
exposé. La surface se limite aux 4 tools nommés proprement, avec docstrings
maîtrisées.
### Isolation des unités
- `tools.py` : **uniquement** la surface MCP (signatures, docstrings destinées à
l'agent, validation des arguments, appels aux helpers). Ne contient pas de SQL.
- `stats.py` : logique d'agrégation acheteur/titulaire, testable sans MCP.
- `serialization.py` : conversion Polars → structures JSON-sérialisables, testable
isolément.
## Les 4 tools
Tous renvoient des structures JSON-sérialisables (dict / list). Montants en euros
(float ou int), dates en ISO 8601 (`YYYY-MM-DD`), valeurs manquantes en `null`.
Les docstrings sont exposées à l'agent (`expose_docstring=True`) : ce sont elles qui
documentent l'outil côté client.
### 1. `rechercher_organisations(query: str, type: str = "acheteur", limite: int = 20)`
- `type``{"acheteur", "titulaire"}`.
- Réutilise `search_org` sur la frame correspondante (mêmes données que la page `/`).
- Sortie : `[{ "id", "nom", "departement", "commune" }]`, triée par pertinence,
tronquée à `limite`.
- Rôle : **résoudre un nom → id** pour alimenter `stats_acheteur` / `stats_titulaire`.
### 2. `stats_acheteur(acheteur_id: str)`
Réutilise `aggregate_marches` avec un `where` filtrant sur `acheteur_id`.
Sortie :
```json
{
"identite": { "id", "nom", "departement", "commune" },
"nb_marches": 0,
"montant_total": 0,
"repartition_annuelle": [ { "annee", "nb_marches", "montant_total" } ],
"top_titulaires": [ { "id", "nom", "nb_marches", "montant_total" } ],
"top_cpv": [ { "cpv", "libelle", "nb_marches" } ]
}
```
- `top_*` limités (ex. 10). Si `acheteur_id` inconnu → `nb_marches: 0` et listes vides
(pas d'erreur).
### 3. `stats_titulaire(titulaire_id: str)`
Symétrique de `stats_acheteur` : `top_acheteurs` au lieu de `top_titulaires`,
montants remportés.
### 4. `rechercher_marches(...)` — signature **hybride**
```python
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, # ISO YYYY-MM-DD (dateNotification)
date_max: str | None = None,
departement: str | None = None,
page: int = 1,
filtres_avances: dict | None = None, # échappatoire moteur générique
)
```
- Les paramètres nommés sont traduits en tuples `col__op` (ex.
`montant_min``("montant__greater", ...)`, `objet_contient`
`("objet__contains", ...)`, `date_min``("dateNotification__greater", ...)`).
- `departement` mappe sur **`acheteur_departement_code`** (intention de requête la
plus courante). Pour filtrer sur le département du titulaire ou du lieu
d'exécution, l'agent passe par `filtres_avances`.
- `filtres_avances` : dict `{"col__op": valeur}` passant au moteur générique complet,
**fusionné** avec les paramètres nommés. Couvre toute colonne/opérateur supportés
par l'API REST.
- L'ensemble passe à `filters.build_where(args, duckdb_schema)` puis
`db.query_marches` / `db.count_marches`**même sémantique que l'API REST**.
- Pagination : `page_size` **fixe** (ex. 50), pagination par `page` (offset calculé).
- Sortie :
```json
{
"meta": { "page": 1, "page_size": 50, "total": 0 },
"marches": [
{
/* colonnes principales du marché */
}
]
}
```
- Erreurs de filtre (`FilterError`) → message d'erreur clair renvoyé à l'agent (pas
d'exception brute).
## Tracking Matomo des appels MCP
Nouveau helper dédié dans `src/utils/tracking.py` (on **ne** surcharge **pas**
`track_search`, qui gate sur `len(query) >= 4` et attend une requête texte) :
```python
def track_mcp_tool(tool_name: str, query: str | None = None) -> None:
...
```
- Même pattern que `track_search` : n'émet que si `not DEVELOPMENT` **et**
`MATOMO_DOMAIN` défini. Best-effort (ne doit jamais faire échouer l'appel du tool).
- Paramètres envoyés à `matomo.php` :
- `action_name = "MCP"` (hiérarchie `f"MCP / {tool_name}"` acceptable pour un arbre
lisible dans le rapport Actions),
- `dimension1 = tool_name`,
- `search` / `search_cat` en plus quand l'outil a une requête texte
(`rechercher_organisations`, `rechercher_marches`).
- Chaque tool appelle `track_mcp_tool(...)` en début d'exécution.
- **Prérequis de déploiement Matomo** : créer un _Custom Dimension_ slot 1, scope
**Action**, côté admin Matomo. Sinon `dimension1` est ignoré silencieusement.
## Déploiement / gating
- Activation via variable d'environnement `DASH_MCP_ENABLED` (Dash lit nativement
cette variable ; on la reflète dans le constructeur).
- **Off par défaut.** Activé en dev/local uniquement.
- **Pas activé en prod tant que le scope B (OAuth + gate abonnement) n'est pas
livré** — sinon le serveur MCP serait ouvert sans contrôle d'accès (feature
payante + coût compute).
- Documenter la variable dans `.template.env`.
## Tests
- Tests unitaires sur les 4 fonctions (données `tests/test.parquet`) :
- `rechercher_organisations` : résultats non vides, tri, `limite`, `type` invalide.
- `stats_acheteur` / `stats_titulaire` : forme de sortie, id inconnu → vides,
troncature des `top_*`.
- `rechercher_marches` : fusion params nommés ↔ `filtres_avances`, pagination
(`meta.total`, `page`), `FilterError` → message propre.
- `serialization` : dates ISO, `null`, montants.
- Smoke test : après import de `src.mcp.tools`, le registre MCP contient bien les
4 tools attendus (pas de test du protocole MCP de bout en bout, qui nécessiterait
un client MCP).
- `tests/test.parquet` étant réduit, vérifier que les colonnes utilisées (cpv,
montant, dateNotification, acheteur_id, titulaire_id, departement) y sont
présentes ; sinon compléter la fixture ou marquer les cas concernés.
## Hors périmètre (→ scope B, design séparé)
- Serveur d'autorisation OAuth 2.0 conforme à la spec MCP (2025-06-18) :
metadata protected-resource, PKCE, dynamic client registration.
- Branchement du gate `subscriptions.has_active_subscription(user_id)` sur
l'autorisation MCP.
- Documentation de connexion côté client (`claude mcp add …`).
+16
View File
@@ -74,6 +74,8 @@ cache.init_app(
},
)
_mcp_enabled = os.getenv("DASH_MCP_ENABLED") == "true"
app: Dash = Dash(
server=server,
# name="src" (et non "src.app") pour que use_pages enregistre les pages sous
@@ -87,6 +89,7 @@ app: Dash = Dash(
use_pages=True,
suppress_callback_exceptions=True,
compress=True,
enable_mcp=_mcp_enabled,
meta_tags=META_TAGS,
)
@@ -108,6 +111,19 @@ from src.api import init_api # noqa: E402 # inline: src.db.conn must be ready
init_api(app.server)
# Serveur MCP (issue #111, scope A) : n'expose QUE les fonctions @mcp_enabled,
# jamais les callbacks/layout/pages d'UI. Activé via DASH_MCP_ENABLED=true.
if _mcp_enabled:
from dash.mcp import configure_mcp_server # noqa: E402
configure_mcp_server(
include_layout=False,
include_callbacks=False,
include_pages=False,
include_clientside_callbacks=False,
)
import src.mcp.tools # noqa: E402,F401 # l'import enregistre les @mcp_enabled
from src.subscriptions.setup import init_subscriptions # noqa: E402
init_subscriptions(app.server)
View File
+222
View File
@@ -0,0 +1,222 @@
# src/mcp/queries.py
import re
from src.api.filters import FilterError, build_where
from src.db import aggregate_marches, count_marches, query_marches
from src.db import schema as duckdb_schema
from src.mcp.serialization import to_json_records
from src.utils.data import DF_ACHETEURS, DF_TITULAIRES
from src.utils.search import search_org
PAGE_SIZE = 50
TOP_N = 10
ORG_FRAMES = {"acheteur": DF_ACHETEURS, "titulaire": DF_TITULAIRES}
# Colonnes renvoyées par rechercher_marches (sortie ciblée, pas SELECT *).
MARCHES_COLUMNS = [
"uid",
"objet",
"montant",
"dateNotification",
"codeCPV",
"acheteur_id",
"acheteur_nom",
"acheteur_departement_code",
"titulaire_id",
"titulaire_nom",
]
# (param nommé, colonne decp, opérateur du moteur de filtres API).
# `greater` = >=, `less` = <=, `contains` = LIKE %v%, `exact` = =.
_NAMED_FILTERS = [
("acheteur_id", "acheteur_id", "exact"),
("titulaire_id", "titulaire_id", "exact"),
("cpv", "codeCPV", "contains"),
("objet_contient", "objet", "contains"),
("montant_min", "montant", "greater"),
("montant_max", "montant", "less"),
("date_min", "dateNotification", "greater"),
("date_max", "dateNotification", "less"),
("departement", "acheteur_departement_code", "exact"),
]
def build_where_args(
named: dict, filtres_avances: dict | None
) -> list[tuple[str, str]]:
"""Traduit les paramètres nommés + filtres avancés en tuples (col__op, valeur)."""
args: list[tuple[str, str]] = []
for param, col, op in _NAMED_FILTERS:
value = named.get(param)
if value is not None:
args.append((f"{col}__{op}", str(value)))
if filtres_avances:
for key, value in filtres_avances.items():
args.append((key, str(value)))
return args
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,
) -> dict:
"""Recherche paginée de marchés. Même sémantique de filtres que l'API REST."""
named = {
"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,
}
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}
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=MARCHES_COLUMNS,
order_by=order_by,
limit=PAGE_SIZE,
offset=offset,
)
total = count_marches(where_sql, params)
return {
"meta": {"page": page, "page_size": PAGE_SIZE, "total": total},
"marches": to_json_records(df),
}
def _extract_plain_text(html_str: str) -> str:
"""Extract plain text from HTML link, e.g. '<a...>123</a>' -> '123'."""
match = re.search(r">([^<]+)<", html_str)
return match.group(1) if match else html_str
def search_organisations(
query: str, org_type: str = "acheteur", limite: int = 20
) -> list[dict]:
"""Recherche des organisations par nom, résout un nom -> id."""
if org_type not in ORG_FRAMES:
raise ValueError(
f"type invalide: {org_type!r} (attendu 'acheteur' ou 'titulaire')"
)
df = search_org(ORG_FRAMES[org_type], query, org_type, track=False).head(limite)
return [
{
"id": _extract_plain_text(r[f"{org_type}_id"]),
"nom": _extract_plain_text(r[f"{org_type}_nom"]),
"departement": r.get("Département"),
}
for r in df.to_dicts()
]
def _org_identite(org_type: str, org_id: str) -> dict:
df = query_marches(
where_sql=f'"{org_type}_id" = ?',
params=[org_id],
columns=[
f"{org_type}_id",
f"{org_type}_nom",
f"{org_type}_departement_nom",
f"{org_type}_commune_nom",
],
limit=1,
)
if df.height == 0:
return {"id": org_id, "nom": None, "departement": None, "commune": None}
r = df.row(0, named=True)
return {
"id": org_id,
"nom": r[f"{org_type}_nom"],
"departement": r[f"{org_type}_departement_nom"],
"commune": r[f"{org_type}_commune_nom"],
}
def compute_org_stats(org_type: str, org_id: str) -> dict:
"""Statistiques agrégées d'un acheteur ou titulaire."""
if org_type not in ORG_FRAMES:
raise ValueError(
f"type invalide: {org_type!r} (attendu 'acheteur' ou 'titulaire')"
)
other = "titulaire" if org_type == "acheteur" else "acheteur"
where_sql = f'"{org_type}_id" = ?'
params = [org_id]
identite = _org_identite(org_type, org_id)
totals = aggregate_marches(
select_sql='COUNT("uid") AS nb, COALESCE(SUM("montant"), 0) AS montant_total',
where_sql=where_sql,
params=params,
)
nb = int(totals["nb"][0])
if nb == 0:
return {
"identite": identite,
"nb_marches": 0,
"montant_total": 0.0,
"repartition_annuelle": [],
f"top_{other}s": [],
"top_cpv": [],
}
annuelle = aggregate_marches(
select_sql=(
"CAST(date_part('year', \"dateNotification\") AS INTEGER) AS annee, "
'COUNT("uid") AS nb_marches, '
'COALESCE(SUM("montant"), 0) AS montant_total'
),
where_sql=where_sql,
params=params,
group_by="date_part('year', \"dateNotification\")",
order_by="annee",
)
top_other = aggregate_marches(
select_sql=(
f'"{other}_id" AS id, any_value("{other}_nom") AS nom, '
'COUNT("uid") AS nb_marches, '
'COALESCE(SUM("montant"), 0) AS montant_total'
),
where_sql=where_sql,
params=params,
group_by=f'"{other}_id"',
order_by="nb_marches DESC",
limit=TOP_N,
)
top_cpv = aggregate_marches(
select_sql='"codeCPV" AS cpv, COUNT("uid") AS nb_marches',
where_sql=where_sql,
params=params,
group_by='"codeCPV"',
order_by="nb_marches DESC",
limit=TOP_N,
)
return {
"identite": identite,
"nb_marches": nb,
"montant_total": float(totals["montant_total"][0]),
"repartition_annuelle": to_json_records(annuelle),
f"top_{other}s": to_json_records(top_other),
"top_cpv": to_json_records(top_cpv),
}
+21
View File
@@ -0,0 +1,21 @@
import datetime
from typing import Any
import polars as pl
def to_json_records(df: pl.DataFrame) -> list[dict[str, Any]]:
"""Convertit un DataFrame Polars en liste de dicts JSON-sérialisables.
Les dates/datetimes deviennent des chaînes ISO 8601 ; les valeurs nulles
restent None. Utilisé par tous les tools MCP pour produire une sortie propre.
"""
return [
{key: _jsonify(value) for key, value in row.items()} for row in df.to_dicts()
]
def _jsonify(value: Any) -> Any:
if isinstance(value, (datetime.date, datetime.datetime)):
return value.isoformat()
return value
+86
View File
@@ -0,0 +1,86 @@
from dash.mcp import mcp_enabled
from src.mcp import queries
from src.utils.tracking import track_mcp_tool
@mcp_enabled(name="rechercher_organisations", expose_docstring=True)
def rechercher_organisations(
query: str,
type: str = "acheteur",
limite: int = 20, # noqa: A002
) -> list[dict]:
"""Recherche des acheteurs ou titulaires publics par nom.
Utiliser en premier pour résoudre un nom d'organisation vers son
identifiant, à passer ensuite à stats_acheteur / stats_titulaire.
query: texte libre (nom d'organisation).
type: "acheteur" ou "titulaire".
Retourne une liste de {id, nom, departement}.
"""
track_mcp_tool("rechercher_organisations", query=query)
return queries.search_organisations(query, type, limite)
@mcp_enabled(name="stats_acheteur", expose_docstring=True)
def stats_acheteur(acheteur_id: str) -> dict:
"""Statistiques agrégées d'un acheteur public (par identifiant).
Retourne nombre de marchés, montant total, répartition annuelle,
principaux titulaires et principaux codes CPV.
"""
track_mcp_tool("stats_acheteur")
return queries.compute_org_stats("acheteur", acheteur_id)
@mcp_enabled(name="stats_titulaire", expose_docstring=True)
def stats_titulaire(titulaire_id: str) -> dict:
"""Statistiques agrégées d'un titulaire (entreprise) par identifiant.
Retourne nombre de marchés remportés, montant total, répartition
annuelle, principaux acheteurs et principaux codes CPV.
"""
track_mcp_tool("stats_titulaire")
return queries.compute_org_stats("titulaire", titulaire_id)
@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,
) -> 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 (mêmes colonnes/opérateurs que l'API REST 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,
)
+5 -2
View File
@@ -5,7 +5,9 @@ from src.utils.table import add_links
from src.utils.tracking import track_search
def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
def search_org(
dff: pl.DataFrame, query: str, org_type: str, track: bool = True
) -> pl.DataFrame:
"""
Search in either 'acheteur' or 'titulaire' DataFrame.
@@ -18,7 +20,8 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
return dff.select(pl.lit(False).alias("matches"))
# Enregistrement des recherche dans Matomo
track_search(query, "home_page_search")
if track:
track_search(query, "home_page_search")
# Normalize query
normalized_query = unidecode(query.strip()).upper()
+32
View File
@@ -28,3 +28,35 @@ def track_search(query, category):
url=f"https://{os.getenv('MATOMO_DOMAIN')}/matomo.php",
params=params,
).raise_for_status()
def track_mcp_tool(tool_name: str, query: str | None = None) -> None:
"""Enregistre un appel d'outil MCP dans Matomo (best-effort, prod uniquement).
`action_name="MCP / <tool>"`, `dimension1=<tool>`. Si l'outil porte une
requête texte, elle est envoyée en `search`. Nécessite un Custom Dimension
slot 1 (scope Action) configuré côté Matomo — sinon `dimension1` est ignoré.
Ne lève jamais : une panne Matomo ne doit pas casser l'appel du tool.
"""
if DEVELOPMENT or not os.getenv("MATOMO_DOMAIN"):
return
params = {
"idsite": os.getenv("MATOMO_ID_SITE"),
"url": "https://colibre.fr/_mcp",
"rec": "1",
"action_name": f"MCP / {tool_name}",
"dimension1": tool_name,
"rand": uuid.uuid4().hex,
"apiv": "1",
"h": localtime().tm_hour,
"m": localtime().tm_min,
"s": localtime().tm_sec,
"token_auth": os.getenv("MATOMO_TOKEN"),
}
if query:
params["search"] = query
params["search_cat"] = "mcp"
try:
post(url=f"https://{os.getenv('MATOMO_DOMAIN')}/matomo.php", params=params)
except Exception:
pass
View File
+156
View File
@@ -0,0 +1,156 @@
import pytest
import src.utils.search as search_mod
from src.mcp.queries import (
build_where_args,
compute_org_stats,
search_marches,
search_organisations,
)
from src.utils.data import DF_ACHETEURS
from src.utils.search import search_org
def test_search_org_track_false_skips_track_search(monkeypatch):
calls = []
monkeypatch.setattr(search_mod, "track_search", lambda q, c: calls.append((q, c)))
search_org(DF_ACHETEURS, "ACHETEUR", "acheteur", track=False)
assert calls == []
def test_search_org_track_true_calls_track_search(monkeypatch):
calls = []
monkeypatch.setattr(search_mod, "track_search", lambda q, c: calls.append((q, c)))
search_org(DF_ACHETEURS, "ACHETEUR", "acheteur", track=True)
assert calls == [("ACHETEUR", "home_page_search")]
def test_search_organisations_finds_known_acheteur():
result = search_organisations("ACHETEUR", "acheteur")
assert any(r["id"] == "123" for r in result)
first = next(r for r in result if r["id"] == "123")
assert set(first.keys()) == {"id", "nom", "departement"}
# Vérifier que le nom a été extrait en texte plain (HTML strippé)
assert first["nom"] == "ACHETEUR 1"
assert "<" not in first["nom"] # Défense : aucun markup HTML ne s'échappe
def test_search_organisations_finds_known_titulaire():
result = search_organisations("TITULAIRE", "titulaire")
assert any(r["id"] == "345" for r in result)
first = next(r for r in result if r["id"] == "345")
assert set(first.keys()) == {"id", "nom", "departement"}
# Vérifier que le nom a été extrait en texte plain (HTML strippé),
# même chemin SIRET/non-SIRET différent de celui du test acheteur.
assert first["id"] == "345"
assert first["nom"] == "TITULAIRE 1"
assert "<" not in first["id"]
assert "<" not in first["nom"]
def test_search_organisations_invalid_type_raises():
with pytest.raises(ValueError):
search_organisations("x", "autre")
def test_search_organisations_respects_limite():
result = search_organisations("ACHETEUR", "acheteur", limite=1)
assert len(result) <= 1
def test_build_where_args_named_params():
args = build_where_args(
{"acheteur_id": "123", "montant_min": 5, "objet_contient": "test"}, None
)
assert ("acheteur_id__exact", "123") in args
assert ("montant__greater", "5") in args
assert ("objet__contains", "test") in args
def test_build_where_args_merges_filtres_avances():
args = build_where_args(
{"acheteur_id": "123"}, {"titulaire_departement_code__exact": "35"}
)
assert ("acheteur_id__exact", "123") in args
assert ("titulaire_departement_code__exact", "35") in args
def test_search_marches_returns_meta_and_rows():
result = search_marches(acheteur_id="123")
assert result["meta"]["total"] >= 1
assert result["meta"]["page"] == 1
assert result["meta"]["page_size"] == 50
assert any(m["acheteur_id"] == "123" for m in result["marches"])
# dates sérialisées en ISO
assert result["marches"][0]["dateNotification"] == "2025-01-01"
def test_search_marches_no_match_is_empty():
result = search_marches(acheteur_id="inconnu-xyz")
assert result["meta"]["total"] == 0
assert result["marches"] == []
def test_search_marches_bad_filter_returns_error():
result = search_marches(filtres_avances={"colonne_bidon__exact": "x"})
assert "error" in result
def test_search_marches_montant_min_filters_correctly():
assert search_marches(montant_min=100)["meta"]["total"] == 0
assert search_marches(montant_min=1)["meta"]["total"] >= 1
def test_search_marches_date_min_max_filters_correctly():
assert search_marches(date_min="2025-01-01")["meta"]["total"] >= 1
assert search_marches(date_min="2025-01-02")["meta"]["total"] == 0
assert search_marches(date_max="2025-01-01")["meta"]["total"] >= 1
assert search_marches(date_max="2024-12-31")["meta"]["total"] == 0
def test_search_marches_cpv_filters_correctly():
assert search_marches(cpv="716")["meta"]["total"] >= 1
assert search_marches(cpv="999")["meta"]["total"] == 0
def test_search_marches_page_2_is_empty_with_correct_meta():
result = search_marches(acheteur_id="123", page=2)
assert result["meta"]["page"] == 2
assert result["marches"] == []
def test_search_marches_page_zero_is_clamped_to_one():
result = search_marches(acheteur_id="123", page=0)
assert result["meta"]["page"] == 1
def test_compute_org_stats_acheteur_known():
stats = compute_org_stats("acheteur", "123")
assert stats["nb_marches"] >= 1
assert stats["montant_total"] == 10
assert stats["identite"]["id"] == "123"
assert stats["identite"]["nom"] == "ACHETEUR 1"
assert "top_titulaires" in stats
assert "top_cpv" in stats
# répartition annuelle dérivée de dateNotification (2025)
annees = [row["annee"] for row in stats["repartition_annuelle"]]
assert 2025 in annees
def test_compute_org_stats_titulaire_known():
stats = compute_org_stats("titulaire", "345")
assert stats["nb_marches"] >= 1
assert "top_acheteurs" in stats
def test_compute_org_stats_unknown_is_empty():
stats = compute_org_stats("acheteur", "inconnu-xyz")
assert stats["nb_marches"] == 0
assert stats["montant_total"] == 0
assert stats["repartition_annuelle"] == []
assert stats["top_titulaires"] == []
assert stats["top_cpv"] == []
+20
View File
@@ -0,0 +1,20 @@
import datetime
import polars as pl
from src.mcp.serialization import to_json_records
def test_dates_become_iso_strings():
df = pl.DataFrame({"d": [datetime.date(2025, 1, 1)], "n": [10]})
assert to_json_records(df) == [{"d": "2025-01-01", "n": 10}]
def test_none_is_preserved():
df = pl.DataFrame({"nom": [None], "x": [3]})
assert to_json_records(df) == [{"nom": None, "x": 3}]
def test_strings_and_numbers_untouched():
df = pl.DataFrame({"s": ["abc"], "f": [1.5]})
assert to_json_records(df) == [{"s": "abc", "f": 1.5}]
+33
View File
@@ -0,0 +1,33 @@
from src.mcp import tools
def test_all_four_tools_are_callable():
for name in (
"rechercher_organisations",
"stats_acheteur",
"stats_titulaire",
"rechercher_marches",
):
assert callable(getattr(tools, name))
def test_rechercher_organisations_returns_list():
result = tools.rechercher_organisations("ACHETEUR", "acheteur")
assert isinstance(result, list)
assert any(r["id"] == "123" for r in result)
def test_rechercher_marches_returns_meta():
result = tools.rechercher_marches(acheteur_id="123")
assert result["meta"]["total"] >= 1
def test_stats_acheteur_returns_stats():
result = tools.stats_acheteur("123")
assert result["nb_marches"] >= 1
assert result["identite"]["id"] == "123"
def test_stats_titulaire_returns_stats():
result = tools.stats_titulaire("345")
assert result["nb_marches"] >= 1
+42
View File
@@ -0,0 +1,42 @@
import src.utils.tracking as tracking
def test_track_mcp_tool_sends_action_and_dimension(monkeypatch):
captured = {}
def fake_post(url, params):
captured["url"] = url
captured["params"] = params
class _R:
def raise_for_status(self):
pass
return _R()
monkeypatch.setattr(tracking, "DEVELOPMENT", False)
monkeypatch.setattr(tracking, "post", fake_post)
monkeypatch.setenv("MATOMO_DOMAIN", "matomo.example")
monkeypatch.setenv("MATOMO_ID_SITE", "1")
tracking.track_mcp_tool("rechercher_marches", query="informatique")
assert captured["params"]["action_name"] == "MCP / rechercher_marches"
assert captured["params"]["dimension1"] == "rechercher_marches"
assert captured["params"]["search"] == "informatique"
def test_track_mcp_tool_noop_in_development(monkeypatch):
called = False
def fake_post(url, params):
nonlocal called
called = True
monkeypatch.setattr(tracking, "DEVELOPMENT", True)
monkeypatch.setattr(tracking, "post", fake_post)
monkeypatch.setenv("MATOMO_DOMAIN", "matomo.example")
tracking.track_mcp_tool("stats_acheteur")
assert called is False