feat(mcp): tool rechercher_marches (filtres hybrides + pagination)
This commit is contained in:
+97
-3
@@ -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. '<a...>123</a>' -> '123'."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user