Support dans l'API et le MCP de l'opérateur startswith, utile pour les codes CPV
This commit is contained in:
@@ -7,6 +7,7 @@ OPERATORS = {
|
|||||||
"exact",
|
"exact",
|
||||||
"contains",
|
"contains",
|
||||||
"notcontains",
|
"notcontains",
|
||||||
|
"startswith",
|
||||||
"differs",
|
"differs",
|
||||||
"less",
|
"less",
|
||||||
"greater",
|
"greater",
|
||||||
@@ -186,6 +187,9 @@ def build_where(
|
|||||||
elif op == "notcontains":
|
elif op == "notcontains":
|
||||||
where_parts.append(f'"{col}" NOT LIKE ?')
|
where_parts.append(f'"{col}" NOT LIKE ?')
|
||||||
params.append(f"%{v}%")
|
params.append(f"%{v}%")
|
||||||
|
elif op == "startswith":
|
||||||
|
where_parts.append(f'"{col}" LIKE ?')
|
||||||
|
params.append(f"{v}%")
|
||||||
elif op == "differs":
|
elif op == "differs":
|
||||||
where_parts.append(f'"{col}" IS DISTINCT FROM ?')
|
where_parts.append(f'"{col}" IS DISTINCT FROM ?')
|
||||||
params.append(v)
|
params.append(v)
|
||||||
|
|||||||
+4
-2
@@ -131,6 +131,7 @@ def schema():
|
|||||||
"- `exact` : égal à la valeur\n"
|
"- `exact` : égal à la valeur\n"
|
||||||
"- `differs` : différent de la valeur (null-safe, `IS DISTINCT FROM`)\n"
|
"- `differs` : différent de la valeur (null-safe, `IS DISTINCT FROM`)\n"
|
||||||
"- `contains` / `notcontains` : contient / ne contient pas (LIKE)\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"
|
"- `in` / `notin` : dans / hors d'une liste séparée par des virgules\n"
|
||||||
"- `less` / `greater` : ≤ / ≥\n"
|
"- `less` / `greater` : ≤ / ≥\n"
|
||||||
"- `strictly_less` / `strictly_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.
|
"""Récupère des marchés publics filtrés, triés ou agrégés.
|
||||||
|
|
||||||
Filtres en query string : `<colonne>__<opérateur>=<valeur>`.
|
Filtres en query string : `<colonne>__<opérateur>=<valeur>`.
|
||||||
Opérateurs de filtre : exact, differs, contains, notcontains, in, notin,
|
Opérateurs de filtre : exact, differs, contains, notcontains, startswith,
|
||||||
less, greater, strictly_less, strictly_greater, isnull, isnotnull, sort.
|
in, notin, less, greater, strictly_less, strictly_greater, isnull,
|
||||||
|
isnotnull, sort.
|
||||||
|
|
||||||
Agrégation (drapeaux sans valeur) : `<colonne>__groupby`,
|
Agrégation (drapeaux sans valeur) : `<colonne>__groupby`,
|
||||||
`<colonne>__count|sum|avg|min|max`. Les colonnes agrégées sont nommées
|
`<colonne>__count|sum|avg|min|max`. Les colonnes agrégées sont nommées
|
||||||
|
|||||||
+1
-1
@@ -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(
|
app: Dash = Dash(
|
||||||
server=server,
|
server=server,
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ ColonneMarche = Literal[SELECTABLE_COLUMNS]
|
|||||||
_NAMED_FILTERS = [
|
_NAMED_FILTERS = [
|
||||||
("acheteur_id", "acheteur_id", "exact"),
|
("acheteur_id", "acheteur_id", "exact"),
|
||||||
("titulaire_id", "titulaire_id", "exact"),
|
("titulaire_id", "titulaire_id", "exact"),
|
||||||
("cpv", "codeCPV", "contains"),
|
("cpv", "codeCPV", "startswith"),
|
||||||
("objet_contient", "objet", "contains"),
|
("objet_contient", "objet", "contains"),
|
||||||
("montant_min", "montant", "greater"),
|
("montant_min", "montant", "greater"),
|
||||||
("montant_max", "montant", "less"),
|
("montant_max", "montant", "less"),
|
||||||
|
|||||||
+3
-2
@@ -79,8 +79,9 @@ def rechercher_marches(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Recherche paginée de marchés publics (DECP).
|
"""Recherche paginée de marchés publics (DECP).
|
||||||
|
|
||||||
Filtres nommés : acheteur_id, titulaire_id, cpv (code CPV, correspondance
|
Filtres nommés : acheteur_id, titulaire_id, cpv (code CPV, filtre par
|
||||||
partielle), objet_contient (texte de l'objet), montant_min, montant_max,
|
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),
|
date_min / date_max (format YYYY-MM-DD, sur dateNotification),
|
||||||
departement (code département de l'acheteur).
|
departement (code département de l'acheteur).
|
||||||
filtres_avances : dict optionnel {"colonne__operateur": valeur} pour les
|
filtres_avances : dict optionnel {"colonne__operateur": valeur} pour les
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ def test_notcontains_filter():
|
|||||||
assert params == ["%x%"]
|
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():
|
def test_comparison_operators_on_int():
|
||||||
where, params, _ = build_where([("annee__strictly_greater", "2023")], SCHEMA)
|
where, params, _ = build_where([("annee__strictly_greater", "2023")], SCHEMA)
|
||||||
assert where == '"annee" > ?'
|
assert where == '"annee" > ?'
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ def test_client_instructions_include_oauth_apps():
|
|||||||
assert any("ChatGPT" in t for t in titles)
|
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):
|
def _collect_titles(component):
|
||||||
# Parcourt récursivement les AccordionItem pour collecter leurs `title`.
|
# Parcourt récursivement les AccordionItem pour collecter leurs `title`.
|
||||||
found = []
|
found = []
|
||||||
|
|||||||
@@ -117,6 +117,19 @@ def test_search_marches_cpv_filters_correctly():
|
|||||||
assert search_marches(cpv="999")["meta"]["total"] == 0
|
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():
|
def test_search_marches_page_2_is_empty_with_correct_meta():
|
||||||
result = search_marches(acheteur_id="123", page=2)
|
result = search_marches(acheteur_id="123", page=2)
|
||||||
assert result["meta"]["page"] == 2
|
assert result["meta"]["page"] == 2
|
||||||
|
|||||||
Reference in New Issue
Block a user