feat(mcp): colonnes configurables + lien dans rechercher_marches (#114)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+29
-3
@@ -1,5 +1,7 @@
|
|||||||
# src/mcp/queries.py
|
# src/mcp/queries.py
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from src.api.filters import OPERATORS, FilterError, build_where
|
from src.api.filters import OPERATORS, FilterError, build_where
|
||||||
from src.db import aggregate_marches, count_marches, query_marches
|
from src.db import aggregate_marches, count_marches, query_marches
|
||||||
@@ -26,6 +28,15 @@ MARCHES_COLUMNS = [
|
|||||||
"titulaire_nom",
|
"titulaire_nom",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 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]
|
||||||
|
|
||||||
# (param nommé, colonne decp, opérateur du moteur de filtres API).
|
# (param nommé, colonne decp, opérateur du moteur de filtres API).
|
||||||
# `greater` = >=, `less` = <=, `contains` = LIKE %v%, `exact` = =.
|
# `greater` = >=, `less` = <=, `contains` = LIKE %v%, `exact` = =.
|
||||||
_NAMED_FILTERS = [
|
_NAMED_FILTERS = [
|
||||||
@@ -59,7 +70,8 @@ def describe_schema() -> dict:
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"colonnes_filtrables": colonnes,
|
"colonnes_filtrables": colonnes,
|
||||||
"colonnes_retournees": MARCHES_COLUMNS,
|
"colonnes_retournees": [*MARCHES_COLUMNS, "lien"],
|
||||||
|
"colonnes_disponibles": list(SELECTABLE_COLUMNS),
|
||||||
"operateurs": sorted(OPERATORS),
|
"operateurs": sorted(OPERATORS),
|
||||||
"filtres_nommes": {p: f"{c}__{o}" for p, c, o in _NAMED_FILTERS},
|
"filtres_nommes": {p: f"{c}__{o}" for p, c, o in _NAMED_FILTERS},
|
||||||
}
|
}
|
||||||
@@ -93,6 +105,7 @@ def search_marches(
|
|||||||
departement: str | None = None,
|
departement: str | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
filtres_avances: dict | None = None,
|
filtres_avances: dict | None = None,
|
||||||
|
colonnes: list[str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Recherche paginée de marchés. Même sémantique de filtres que l'API REST."""
|
"""Recherche paginée de marchés. Même sémantique de filtres que l'API REST."""
|
||||||
named = {
|
named = {
|
||||||
@@ -112,21 +125,34 @@ def search_marches(
|
|||||||
except FilterError as e:
|
except FilterError as e:
|
||||||
return {"error": str(e), "champ": e.field}
|
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))
|
page = max(1, int(page))
|
||||||
offset = (page - 1) * PAGE_SIZE
|
offset = (page - 1) * PAGE_SIZE
|
||||||
order_by = order_sql or '"dateNotification" DESC, "uid" DESC'
|
order_by = order_sql or '"dateNotification" DESC, "uid" DESC'
|
||||||
df = query_marches(
|
df = query_marches(
|
||||||
where_sql,
|
where_sql,
|
||||||
params,
|
params,
|
||||||
columns=MARCHES_COLUMNS,
|
columns=out_columns,
|
||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
limit=PAGE_SIZE,
|
limit=PAGE_SIZE,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
total = count_marches(where_sql, params)
|
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 {
|
return {
|
||||||
"meta": {"page": page, "page_size": PAGE_SIZE, "total": total},
|
"meta": {"page": page, "page_size": PAGE_SIZE, "total": total},
|
||||||
"marches": to_json_records(df),
|
"marches": marches,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -154,3 +154,54 @@ def test_compute_org_stats_unknown_is_empty():
|
|||||||
assert stats["repartition_annuelle"] == []
|
assert stats["repartition_annuelle"] == []
|
||||||
assert stats["top_titulaires"] == []
|
assert stats["top_titulaires"] == []
|
||||||
assert stats["top_cpv"] == []
|
assert stats["top_cpv"] == []
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user