From d920e1ca8e83185283a128a60046fa4266e8874f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 14 Jul 2026 21:16:08 +0200 Subject: [PATCH] =?UTF-8?q?Support=20dans=20l'API=20et=20le=20MCP=20de=20l?= =?UTF-8?q?'op=C3=A9rateur=20startswith,=20utile=20pour=20les=20codes=20CP?= =?UTF-8?q?V?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/filters.py | 4 ++++ src/api/routes.py | 6 ++++-- src/app.py | 2 +- src/mcp/queries.py | 2 +- src/mcp/tools.py | 5 +++-- tests/api/test_filters.py | 6 ++++++ tests/mcp/test_account_page.py | 10 ++++++++++ tests/mcp/test_queries.py | 13 +++++++++++++ 8 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/api/filters.py b/src/api/filters.py index f30a7c7..5440ec3 100644 --- a/src/api/filters.py +++ b/src/api/filters.py @@ -7,6 +7,7 @@ OPERATORS = { "exact", "contains", "notcontains", + "startswith", "differs", "less", "greater", @@ -186,6 +187,9 @@ def build_where( elif op == "notcontains": where_parts.append(f'"{col}" NOT LIKE ?') params.append(f"%{v}%") + elif op == "startswith": + where_parts.append(f'"{col}" LIKE ?') + params.append(f"{v}%") elif op == "differs": where_parts.append(f'"{col}" IS DISTINCT FROM ?') params.append(v) diff --git a/src/api/routes.py b/src/api/routes.py index 783b7d0..34c0731 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -131,6 +131,7 @@ def schema(): "- `exact` : égal à la valeur\n" "- `differs` : différent de la valeur (null-safe, `IS DISTINCT FROM`)\n" "- `contains` / `notcontains` : contient / ne contient pas (LIKE)\n" + "- `startswith` : commence par (préfixe, ex. `codeCPV__startswith=72`)\n" "- `in` / `notin` : dans / hors d'une liste séparée par des virgules\n" "- `less` / `greater` : ≤ / ≥\n" "- `strictly_less` / `strictly_greater` : < / >\n" @@ -157,8 +158,9 @@ def data(): """Récupère des marchés publics filtrés, triés ou agrégés. Filtres en query string : `__=`. - Opérateurs de filtre : exact, differs, contains, notcontains, in, notin, - less, greater, strictly_less, strictly_greater, isnull, isnotnull, sort. + Opérateurs de filtre : exact, differs, contains, notcontains, startswith, + in, notin, less, greater, strictly_less, strictly_greater, isnull, + isnotnull, sort. Agrégation (drapeaux sans valeur) : `__groupby`, `__count|sum|avg|min|max`. Les colonnes agrégées sont nommées diff --git a/src/app.py b/src/app.py index f837c24..58ac103 100644 --- a/src/app.py +++ b/src/app.py @@ -74,7 +74,7 @@ cache.init_app( }, ) -_mcp_enabled = os.getenv("DASH_MCP_ENABLED") == "true" +_mcp_enabled = os.getenv("DASH_MCP_ENABLED", "").lower() == "true" app: Dash = Dash( server=server, diff --git a/src/mcp/queries.py b/src/mcp/queries.py index 76f9887..451774a 100644 --- a/src/mcp/queries.py +++ b/src/mcp/queries.py @@ -42,7 +42,7 @@ ColonneMarche = Literal[SELECTABLE_COLUMNS] _NAMED_FILTERS = [ ("acheteur_id", "acheteur_id", "exact"), ("titulaire_id", "titulaire_id", "exact"), - ("cpv", "codeCPV", "contains"), + ("cpv", "codeCPV", "startswith"), ("objet_contient", "objet", "contains"), ("montant_min", "montant", "greater"), ("montant_max", "montant", "less"), diff --git a/src/mcp/tools.py b/src/mcp/tools.py index 6e1b2da..e1c57d4 100644 --- a/src/mcp/tools.py +++ b/src/mcp/tools.py @@ -79,8 +79,9 @@ def rechercher_marches( ) -> 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, + Filtres nommés : acheteur_id, titulaire_id, cpv (code CPV, filtre par + préfixe — ex. « 72 » = informatique, « 7220 » = logiciels), + 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 diff --git a/tests/api/test_filters.py b/tests/api/test_filters.py index b3ecd39..4c25f87 100644 --- a/tests/api/test_filters.py +++ b/tests/api/test_filters.py @@ -39,6 +39,12 @@ def test_notcontains_filter(): assert params == ["%x%"] +def test_startswith_filter_uses_prefix_wildcard(): + where, params, _ = build_where([("objet__startswith", "72")], SCHEMA) + assert where == '"objet" LIKE ?' + assert params == ["72%"] + + def test_comparison_operators_on_int(): where, params, _ = build_where([("annee__strictly_greater", "2023")], SCHEMA) assert where == '"annee" > ?' diff --git a/tests/mcp/test_account_page.py b/tests/mcp/test_account_page.py index 6bfc9d7..8e52dbe 100644 --- a/tests/mcp/test_account_page.py +++ b/tests/mcp/test_account_page.py @@ -34,6 +34,16 @@ def test_client_instructions_include_oauth_apps(): assert any("ChatGPT" in t for t in titles) +def test_prompt_tips_mentions_columns_and_examples(): + from src.app import app # noqa: F401 + from src.pages.compte.mcp import prompt_tips + + text = str(prompt_tips()) + assert "Morbihan" in text # un exemple de prompt concret + assert "colonnes" in text # l'astuce sur le choix des colonnes + assert "lien" in text # mention du lien vers la fiche marché + + def _collect_titles(component): # Parcourt récursivement les AccordionItem pour collecter leurs `title`. found = [] diff --git a/tests/mcp/test_queries.py b/tests/mcp/test_queries.py index cdcf7d4..aed8820 100644 --- a/tests/mcp/test_queries.py +++ b/tests/mcp/test_queries.py @@ -117,6 +117,19 @@ def test_search_marches_cpv_filters_correctly(): assert search_marches(cpv="999")["meta"]["total"] == 0 +def test_build_where_args_cpv_is_prefix(): + # le filtre nommé cpv est un « commence par » (code CPV hiérarchique) + args = build_where_args({"cpv": "72"}, None) + assert ("codeCPV__startswith", "72") in args + + +def test_search_marches_cpv_is_prefix_not_contains(): + # "1600" est contenu dans le codeCPV de test (71600000) mais n'en est pas + # le préfixe : en sémantique « commence par », aucun résultat. + assert search_marches(cpv="1600")["meta"]["total"] == 0 + assert search_marches(cpv="716")["meta"]["total"] >= 1 + + def test_search_marches_page_2_is_empty_with_correct_meta(): result = search_marches(acheteur_id="123", page=2) assert result["meta"]["page"] == 2