From 6cea1fdc95721d4b3a2aa247f5d9163c9d99a803 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 9 Jul 2026 20:23:05 +0200 Subject: [PATCH] feat(mcp): tool rechercher_marches (filtres hybrides + pagination) --- src/mcp/queries.py | 100 ++++++++++++++++++++++++++++++++++++-- tests/mcp/test_queries.py | 40 ++++++++++++++- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src/mcp/queries.py b/src/mcp/queries.py index cbc9d98..d52f8a1 100644 --- a/src/mcp/queries.py +++ b/src/mcp/queries.py @@ -1,9 +1,10 @@ # src/mcp/queries.py import re -from src.mcp.serialization import ( - to_json_records, # noqa: F401 (utilisé aux tâches suivantes) -) +from src.api.filters import FilterError, build_where +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.search import search_org @@ -11,6 +12,99 @@ 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. '123' -> '123'.""" diff --git a/tests/mcp/test_queries.py b/tests/mcp/test_queries.py index 7f19ac4..b8031da 100644 --- a/tests/mcp/test_queries.py +++ b/tests/mcp/test_queries.py @@ -1,7 +1,7 @@ import pytest 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.search import search_org @@ -42,3 +42,41 @@ def test_search_organisations_invalid_type_raises(): 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