feat(mcp): tool rechercher_marches (filtres hybrides + pagination)

This commit is contained in:
Colin Maudry
2026-07-09 20:23:05 +02:00
parent 98cbcd9311
commit 6cea1fdc95
2 changed files with 136 additions and 4 deletions
+97 -3
View File
@@ -1,9 +1,10 @@
# src/mcp/queries.py # src/mcp/queries.py
import re import re
from src.mcp.serialization import ( from src.api.filters import FilterError, build_where
to_json_records, # noqa: F401 (utilisé aux tâches suivantes) from src.db import 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.data import DF_ACHETEURS, DF_TITULAIRES
from src.utils.search import search_org from src.utils.search import search_org
@@ -11,6 +12,99 @@ PAGE_SIZE = 50
TOP_N = 10 TOP_N = 10
ORG_FRAMES = {"acheteur": DF_ACHETEURS, "titulaire": DF_TITULAIRES} 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: def _extract_plain_text(html_str: str) -> str:
"""Extract plain text from HTML link, e.g. '<a...>123</a>' -> '123'.""" """Extract plain text from HTML link, e.g. '<a...>123</a>' -> '123'."""
+39 -1
View File
@@ -1,7 +1,7 @@
import pytest import pytest
import src.utils.search as search_mod import src.utils.search as search_mod
from src.mcp.queries import search_organisations from src.mcp.queries import build_where_args, search_marches, search_organisations
from src.utils.data import DF_ACHETEURS from src.utils.data import DF_ACHETEURS
from src.utils.search import search_org from src.utils.search import search_org
@@ -42,3 +42,41 @@ def test_search_organisations_invalid_type_raises():
def test_search_organisations_respects_limite(): def test_search_organisations_respects_limite():
result = search_organisations("ACHETEUR", "acheteur", limite=1) result = search_organisations("ACHETEUR", "acheteur", limite=1)
assert len(result) <= 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