ffbb-mcp-server 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ffbb_mcp/__init__.py +9 -0
- ffbb_mcp/__main__.py +6 -0
- ffbb_mcp/_state.py +72 -0
- ffbb_mcp/aliases.py +374 -0
- ffbb_mcp/app_factory.py +99 -0
- ffbb_mcp/benchmark.py +212 -0
- ffbb_mcp/cache_strategy.py +77 -0
- ffbb_mcp/client.py +117 -0
- ffbb_mcp/dashboard.py +312 -0
- ffbb_mcp/metrics.py +317 -0
- ffbb_mcp/prompts.py +622 -0
- ffbb_mcp/py.typed +0 -0
- ffbb_mcp/resources.py +54 -0
- ffbb_mcp/routes.py +259 -0
- ffbb_mcp/server.py +1123 -0
- ffbb_mcp/services.py +2912 -0
- ffbb_mcp/utils.py +250 -0
- ffbb_mcp_server-1.3.0.dist-info/METADATA +174 -0
- ffbb_mcp_server-1.3.0.dist-info/RECORD +23 -0
- ffbb_mcp_server-1.3.0.dist-info/WHEEL +5 -0
- ffbb_mcp_server-1.3.0.dist-info/entry_points.txt +2 -0
- ffbb_mcp_server-1.3.0.dist-info/licenses/LICENSE +21 -0
- ffbb_mcp_server-1.3.0.dist-info/top_level.txt +1 -0
ffbb_mcp/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""FFBB MCP Server — Fédération Française de Basketball."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _pkg_version
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = _pkg_version("ffbb-mcp-server")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
__version__ = "unknown"
|
ffbb_mcp/__main__.py
ADDED
ffbb_mcp/_state.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
from cachetools import TLRUCache, TTLCache
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _read_positive_int_env(key: str, default: int) -> int:
|
|
14
|
+
val_str = os.environ.get(key)
|
|
15
|
+
if val_str is not None:
|
|
16
|
+
try:
|
|
17
|
+
val = int(val_str)
|
|
18
|
+
if val > 0:
|
|
19
|
+
return val
|
|
20
|
+
except ValueError:
|
|
21
|
+
pass
|
|
22
|
+
return default
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class _ServiceState:
|
|
27
|
+
inflight_bilan: dict[str, asyncio.Task[Any]] = field(default_factory=dict)
|
|
28
|
+
inflight_calendrier: dict[str, asyncio.Task[Any]] = field(default_factory=dict)
|
|
29
|
+
inflight_poule: dict[str, asyncio.Task[Any]] = field(default_factory=dict)
|
|
30
|
+
inflight_detail: dict[str, asyncio.Task[Any]] = field(default_factory=dict)
|
|
31
|
+
inflight_search: dict[str, asyncio.Task[Any]] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
# Caches in-memory globaux
|
|
34
|
+
cache_lives: TTLCache[Any, Any] | None = None
|
|
35
|
+
cache_search: TTLCache[Any, Any] | None = None
|
|
36
|
+
cache_competition: TTLCache[Any, Any] | None = None
|
|
37
|
+
cache_organisme: TTLCache[Any, Any] | None = None
|
|
38
|
+
cache_saisons: TTLCache[Any, Any] | None = None
|
|
39
|
+
cache_calendrier: TTLCache[Any, Any] | TLRUCache[Any, Any] | None = None
|
|
40
|
+
cache_bilan: TLRUCache[Any, Any] | None = None
|
|
41
|
+
cache_classement: TLRUCache[Any, Any] | None = None
|
|
42
|
+
cache_poule: TLRUCache[Any, Any] | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
state = _ServiceState()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def reset_service_state() -> None:
|
|
49
|
+
global state
|
|
50
|
+
state.inflight_bilan.clear()
|
|
51
|
+
state.inflight_calendrier.clear()
|
|
52
|
+
state.inflight_poule.clear()
|
|
53
|
+
state.inflight_detail.clear()
|
|
54
|
+
state.inflight_search.clear()
|
|
55
|
+
if state.cache_lives is not None:
|
|
56
|
+
state.cache_lives.clear()
|
|
57
|
+
if state.cache_search is not None:
|
|
58
|
+
state.cache_search.clear()
|
|
59
|
+
if state.cache_competition is not None:
|
|
60
|
+
state.cache_competition.clear()
|
|
61
|
+
if state.cache_organisme is not None:
|
|
62
|
+
state.cache_organisme.clear()
|
|
63
|
+
if state.cache_saisons is not None:
|
|
64
|
+
state.cache_saisons.clear()
|
|
65
|
+
if state.cache_calendrier is not None:
|
|
66
|
+
state.cache_calendrier.clear()
|
|
67
|
+
if state.cache_bilan is not None:
|
|
68
|
+
state.cache_bilan.clear()
|
|
69
|
+
if state.cache_classement is not None:
|
|
70
|
+
state.cache_classement.clear()
|
|
71
|
+
if state.cache_poule is not None:
|
|
72
|
+
state.cache_poule.clear()
|
ffbb_mcp/aliases.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Gestion des alias et acronymes de clubs FFBB.
|
|
2
|
+
|
|
3
|
+
Ce module fournit :
|
|
4
|
+
- Un dictionnaire statique d'alias bien connus (CLUB_ALIASES)
|
|
5
|
+
- Un cache persistant d'acronymes auto-enrichi (acronyms_cache.json)
|
|
6
|
+
- normalize_query() : résolution d'alias dans les recherches
|
|
7
|
+
- resolve_acronym() : résolution spécifique d'acronymes (< 7 chars, tout en majuscules)
|
|
8
|
+
- enrich_acronym_cache() : enrichissement automatique après chaque recherche réussie
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from threading import Lock
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("ffbb-mcp")
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Dictionnaire statique d'alias (toujours en lowercase)
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
CLUB_ALIASES = {
|
|
25
|
+
"jav": "jeanne d'arc de vichy",
|
|
26
|
+
"ja vichy": "jeanne d'arc de vichy",
|
|
27
|
+
"scba": "stade clermontois basket auvergne",
|
|
28
|
+
"scbf": "stade clermontois basket feminin",
|
|
29
|
+
"asvel": "lyon villeurbanne",
|
|
30
|
+
"ldlc asvel": "lyon villeurbanne",
|
|
31
|
+
"chorale": "roanne",
|
|
32
|
+
"cb": "cholet basket",
|
|
33
|
+
"jlb": "jl bourg",
|
|
34
|
+
"bourg": "jl bourg",
|
|
35
|
+
"bcm": "gravelines dunkerque",
|
|
36
|
+
"gravelines": "bcm gravelines dunkerque",
|
|
37
|
+
"pau": "elan bearnais",
|
|
38
|
+
"msb": "le mans sarthe basket",
|
|
39
|
+
"le mans": "le mans sarthe basket",
|
|
40
|
+
"sluc": "nancy",
|
|
41
|
+
"nancy": "sluc nancy",
|
|
42
|
+
"sig": "strasbourg",
|
|
43
|
+
"csp": "limoges csp",
|
|
44
|
+
"essm": "essm le portel",
|
|
45
|
+
"sqbb": "saint quentin basket ball",
|
|
46
|
+
"ada": "ada blois basket 41",
|
|
47
|
+
"alm": "alm evreux basket",
|
|
48
|
+
"stb": "saint thomas basket le havre",
|
|
49
|
+
"rmb": "rouen metropole basket",
|
|
50
|
+
"amsb": "aix maurienne savoie basket",
|
|
51
|
+
"ujap": "ujap quimper",
|
|
52
|
+
"urb": "union rennes basket 35",
|
|
53
|
+
"cep": "cep lorient basket",
|
|
54
|
+
"olb": "orleans loiret basket",
|
|
55
|
+
"esbva": "esb villeneuve d'ascq lille metropole",
|
|
56
|
+
"blma": "basket lattes montpellier",
|
|
57
|
+
"bbd": "boulazac basket dordogne",
|
|
58
|
+
"htv": "hyeres toulon var basket",
|
|
59
|
+
"tgb": "tarbes gespe bigorre",
|
|
60
|
+
"asa": "alliance sport alsace",
|
|
61
|
+
"asm": "as monaco basket",
|
|
62
|
+
"jsf": "jsf nanterre",
|
|
63
|
+
"lmb": "lille metropole basket",
|
|
64
|
+
"lyonso": "lyonso basket",
|
|
65
|
+
"avbb": "aurore vitre basket bretagne",
|
|
66
|
+
"rac": "rac basket premiere",
|
|
67
|
+
"svbd": "saint-vallier basket drôme",
|
|
68
|
+
"mba": "mulhouse basket agglomeration",
|
|
69
|
+
"besac": "besancon avenir comtois",
|
|
70
|
+
"cbc": "caen basket calvados",
|
|
71
|
+
"utlpb": "union tarbes lourdes pyrenees basket",
|
|
72
|
+
"eab": "etoile angers basket",
|
|
73
|
+
"bco": "sasp bc orchies",
|
|
74
|
+
"fpb": "sas fos provence basket",
|
|
75
|
+
"usom": "uso mondeville basket",
|
|
76
|
+
"cnb": "cavigal nice basket 06",
|
|
77
|
+
"lbb": "landerneau bretagne basket",
|
|
78
|
+
"cbbs": "charnay basket bourgogne sud",
|
|
79
|
+
"fcba": "flammes carolo basket ardennes",
|
|
80
|
+
"sahb": "saint-amand hainaut basket",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Expressions régulières pré-compilées (Optimisation des performances)
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Le pré-calcul d'une regex globale combinée et d'un dictionnaire de lookup
|
|
87
|
+
# permet d'effectuer tous les remplacements en une seule passe C.
|
|
88
|
+
# Cela offre un gain de performance notable (~x2 par rapport à une boucle).
|
|
89
|
+
|
|
90
|
+
_VALID_ALIASES_DICT = {
|
|
91
|
+
alias: official
|
|
92
|
+
for alias, official in CLUB_ALIASES.items()
|
|
93
|
+
if not re.search(r"\b" + re.escape(alias) + r"\b", official)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Trie par longueur décroissante pour que les alias les plus longs matchent en priorité
|
|
97
|
+
_ALIASES_SORTED = sorted(_VALID_ALIASES_DICT.keys(), key=len, reverse=True)
|
|
98
|
+
_ALIAS_PATTERN_ALL = re.compile(
|
|
99
|
+
r"\b(" + "|".join(re.escape(k) for k in _ALIASES_SORTED) + r")\b"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Cache persistant d'acronymes (acronyms_cache.json)
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
_DEFAULT_ACRONYMS = {
|
|
107
|
+
"ASVEL": "Villeurbanne",
|
|
108
|
+
"JAV": "Jeanne d'Arc de Vichy",
|
|
109
|
+
"SLUC": "Nancy",
|
|
110
|
+
"SCBA": "Stade Clermontois",
|
|
111
|
+
"JDA": "Dijon",
|
|
112
|
+
"BCM": "Gravelines",
|
|
113
|
+
"SIG": "Strasbourg",
|
|
114
|
+
"MSB": "Le Mans",
|
|
115
|
+
"GB": "GERZAT BASKET",
|
|
116
|
+
"SCBF": "STADE CLERMONTOIS BASKET FEMININ",
|
|
117
|
+
"CSP": "LIMOGES CSP",
|
|
118
|
+
"ESSM": "ESSM LE PORTEL",
|
|
119
|
+
"SQBB": "SAINT QUENTIN BASKET BALL",
|
|
120
|
+
"ADA": "ADA BLOIS BASKET 41",
|
|
121
|
+
"ALM": "ALM EVREUX BASKET",
|
|
122
|
+
"STB": "SAINT THOMAS BASKET LE HAVRE",
|
|
123
|
+
"RMB": "ROUEN METROPOLE BASKET",
|
|
124
|
+
"AMSB": "AIX MAURIENNE SAVOIE BASKET",
|
|
125
|
+
"UJAP": "UJAP QUIMPER",
|
|
126
|
+
"URB": "UNION RENNES BASKET 35",
|
|
127
|
+
"CEP": "CEP LORIENT BASKET",
|
|
128
|
+
"OLB": "ORLEANS LOIRET BASKET",
|
|
129
|
+
"ESBVA": "ESB VILLENEUVE D'ASCQ LILLE METROPOLE",
|
|
130
|
+
"BLMA": "BASKET LATTES MONTPELLIER",
|
|
131
|
+
"BBD": "BOULAZAC BASKET DORDOGNE",
|
|
132
|
+
"HTV": "HYERES TOULON VAR BASKET",
|
|
133
|
+
"TGB": "TARBES GESPE BIGORRE",
|
|
134
|
+
"ASA": "ALLIANCE SPORT ALSACE",
|
|
135
|
+
"ASM": "AS MONACO BASKET",
|
|
136
|
+
"JSF": "JSF NANTERRE",
|
|
137
|
+
"LMB": "LILLE METROPOLE BASKET",
|
|
138
|
+
"LYONSO": "LYONSO BASKET",
|
|
139
|
+
"AVBB": "AURORE VITRE BASKET BRETAGNE",
|
|
140
|
+
"RAC": "RAC BASKET PREMIERE",
|
|
141
|
+
"SVBD": "SAINT-VALLIER BASKET DRÔME",
|
|
142
|
+
"MBA": "MULHOUSE BASKET AGGLOMERATION",
|
|
143
|
+
"BESAC": "BESANCON AVENIR COMTOIS",
|
|
144
|
+
"CBC": "CAEN BASKET CALVADOS",
|
|
145
|
+
"UTLPB": "UNION TARBES LOURDES PYRENEES BASKET",
|
|
146
|
+
"EAB": "ETOILE ANGERS BASKET",
|
|
147
|
+
"BCO": "SASP BC ORCHIES",
|
|
148
|
+
"FPB": "SAS FOS PROVENCE BASKET",
|
|
149
|
+
"USOM": "USO MONDEVILLE BASKET",
|
|
150
|
+
"CNB": "CAVIGAL NICE BASKET 06",
|
|
151
|
+
"LBB": "LANDERNEAU BRETAGNE BASKET",
|
|
152
|
+
"CBBS": "CHARNAY BASKET BOURGOGNE SUD",
|
|
153
|
+
"FCBA": "FLAMMES CAROLO BASKET ARDENNES",
|
|
154
|
+
"SAHB": "SAINT-AMAND HAINAUT BASKET",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _resolve_cache_dir() -> Path:
|
|
159
|
+
"""Retourne un dossier cache utilisateur robuste et écrivable."""
|
|
160
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
161
|
+
if xdg:
|
|
162
|
+
return Path(xdg).expanduser() / "ffbb-mcp"
|
|
163
|
+
return Path.home() / ".cache" / "ffbb-mcp"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
_CACHE_DIR = _resolve_cache_dir()
|
|
167
|
+
_CACHE_FILE = _CACHE_DIR / "acronyms_cache.json"
|
|
168
|
+
_cache_lock = Lock()
|
|
169
|
+
_acronyms_cache: dict[str, str] | None = None
|
|
170
|
+
_acronyms_cache_upper: dict[str, str] | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _load_acronyms_cache() -> dict[str, str]:
|
|
174
|
+
"""Charge le cache d'acronymes depuis le fichier JSON.
|
|
175
|
+
|
|
176
|
+
Si le fichier n'existe pas, l'initialise avec les valeurs par défaut.
|
|
177
|
+
"""
|
|
178
|
+
global _acronyms_cache
|
|
179
|
+
global _acronyms_cache_upper
|
|
180
|
+
if _acronyms_cache is not None:
|
|
181
|
+
return _acronyms_cache
|
|
182
|
+
|
|
183
|
+
with _cache_lock:
|
|
184
|
+
# Double-check après acquisition du lock
|
|
185
|
+
if _acronyms_cache is not None:
|
|
186
|
+
return _acronyms_cache
|
|
187
|
+
|
|
188
|
+
if _CACHE_FILE.exists():
|
|
189
|
+
try:
|
|
190
|
+
data = json.loads(_CACHE_FILE.read_text(encoding="utf-8"))
|
|
191
|
+
if isinstance(data, dict):
|
|
192
|
+
_acronyms_cache_upper = {k.upper(): v for k, v in data.items()}
|
|
193
|
+
_acronyms_cache = data
|
|
194
|
+
logger.info(
|
|
195
|
+
"Cache d'acronymes chargé: %d entrées depuis %s",
|
|
196
|
+
len(data),
|
|
197
|
+
_CACHE_FILE,
|
|
198
|
+
)
|
|
199
|
+
return _acronyms_cache
|
|
200
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
201
|
+
logger.warning(
|
|
202
|
+
"Erreur lecture %s: %s — réinitialisation", _CACHE_FILE, e
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Initialisation avec les valeurs par défaut
|
|
206
|
+
_acronyms_cache_upper = {k.upper(): v for k, v in _DEFAULT_ACRONYMS.items()}
|
|
207
|
+
_acronyms_cache = dict(_DEFAULT_ACRONYMS)
|
|
208
|
+
_save_acronyms_cache()
|
|
209
|
+
logger.info(
|
|
210
|
+
"Cache d'acronymes initialisé avec %d entrées par défaut",
|
|
211
|
+
len(_acronyms_cache),
|
|
212
|
+
)
|
|
213
|
+
return _acronyms_cache
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _save_acronyms_cache() -> None:
|
|
217
|
+
"""Sauvegarde le cache d'acronymes dans le fichier JSON."""
|
|
218
|
+
if _acronyms_cache is None:
|
|
219
|
+
return
|
|
220
|
+
try:
|
|
221
|
+
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
_CACHE_FILE.write_text(
|
|
223
|
+
json.dumps(_acronyms_cache, ensure_ascii=False, indent=2) + "\n",
|
|
224
|
+
encoding="utf-8",
|
|
225
|
+
)
|
|
226
|
+
except OSError as e:
|
|
227
|
+
logger.warning("Erreur sauvegarde %s: %s", _CACHE_FILE, e)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
_SKIP_WORDS: frozenset[str] = frozenset(
|
|
231
|
+
{"de", "du", "le", "la", "les", "et", "en", "des", "aux"}
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _extract_initials(name: str) -> str:
|
|
236
|
+
"""Extrait les initiales d'un nom officiel FFBB.
|
|
237
|
+
|
|
238
|
+
Prend la première lettre de chaque mot commençant par une majuscule.
|
|
239
|
+
Ignore les mots courants courts (de, du, le, la, les, d', l', et).
|
|
240
|
+
"""
|
|
241
|
+
words = name.split()
|
|
242
|
+
initials = []
|
|
243
|
+
for w in words:
|
|
244
|
+
# ⚡ Bolt: Accès direct par index plutôt que de créer un tuple et
|
|
245
|
+
# d'appeler startswith, évitant des allocations inutiles.
|
|
246
|
+
# Supprimer les articles collés (d', l', D', L')
|
|
247
|
+
if len(w) >= 2 and w[1] == "'" and w[0] in ("d", "l", "D", "L"):
|
|
248
|
+
clean = w[2:]
|
|
249
|
+
else:
|
|
250
|
+
clean = w
|
|
251
|
+
|
|
252
|
+
if not clean:
|
|
253
|
+
continue
|
|
254
|
+
if clean.lower() in _SKIP_WORDS:
|
|
255
|
+
continue
|
|
256
|
+
if clean[0].isupper():
|
|
257
|
+
initials.append(clean[0])
|
|
258
|
+
return "".join(initials)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
# API publique
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def resolve_acronym(query: str) -> str:
|
|
267
|
+
"""Résout un acronyme de club vers son nom complet.
|
|
268
|
+
|
|
269
|
+
Règle de détection : si le terme est entièrement en majuscules
|
|
270
|
+
et fait moins de 7 caractères, tenter une résolution via le cache.
|
|
271
|
+
|
|
272
|
+
Retourne le nom résolu si trouvé, sinon retourne `query` tel quel.
|
|
273
|
+
"""
|
|
274
|
+
if not query or len(query) >= 7:
|
|
275
|
+
return query
|
|
276
|
+
|
|
277
|
+
stripped = query.strip()
|
|
278
|
+
if not stripped:
|
|
279
|
+
return query
|
|
280
|
+
|
|
281
|
+
# Vérifier que c'est bien un acronyme (tout en majuscules, lettres uniquement)
|
|
282
|
+
if not stripped.isalpha() or not stripped.isupper():
|
|
283
|
+
return query
|
|
284
|
+
|
|
285
|
+
_load_acronyms_cache()
|
|
286
|
+
|
|
287
|
+
# Recherche case-insensitive dans le cache
|
|
288
|
+
assert _acronyms_cache_upper is not None
|
|
289
|
+
if value := _acronyms_cache_upper.get(stripped.upper()):
|
|
290
|
+
logger.info("Acronyme résolu: %s → %s", stripped, value)
|
|
291
|
+
return value
|
|
292
|
+
|
|
293
|
+
return query
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def enrich_acronym_cache(official_name: str) -> None:
|
|
297
|
+
"""Enrichit automatiquement le cache d'acronymes après une recherche réussie.
|
|
298
|
+
|
|
299
|
+
Extrait les initiales du nom officiel retourné par la FFBB.
|
|
300
|
+
Si ces initiales ne sont pas déjà dans le cache, les ajoute
|
|
301
|
+
avec le nom complet comme valeur et sauvegarde immédiatement.
|
|
302
|
+
"""
|
|
303
|
+
if not official_name or len(official_name) < 3:
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
initials = _extract_initials(official_name)
|
|
307
|
+
if not initials or len(initials) < 2 or len(initials) >= 7:
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
cache = _load_acronyms_cache()
|
|
311
|
+
|
|
312
|
+
# Vérifier si l'acronyme existe déjà (case-insensitive)
|
|
313
|
+
# _load_acronyms_cache guarantees _acronyms_cache_upper is initialized
|
|
314
|
+
initials_upper = initials.upper()
|
|
315
|
+
assert _acronyms_cache_upper is not None
|
|
316
|
+
if initials_upper in _acronyms_cache_upper:
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
with _cache_lock:
|
|
320
|
+
cache[initials] = official_name
|
|
321
|
+
_acronyms_cache_upper[initials_upper] = official_name
|
|
322
|
+
_save_acronyms_cache()
|
|
323
|
+
logger.info("Acronyme auto-enrichi: %s → %s", initials, official_name)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
_APOSTROPHES_MAP = str.maketrans("\u2019\u2018\u201b\u0060", "''''")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _normalize_apostrophes(text: str) -> str:
|
|
330
|
+
"""Normalise toutes les variantes typographiques d'apostrophe en apostrophe ASCII.
|
|
331
|
+
|
|
332
|
+
Variantes couvertes : \u2019 (U+2019), \u2018 (U+2018), \u201b (U+201B), \u0060 (backtick).
|
|
333
|
+
"""
|
|
334
|
+
# Fast-path : la plupart des textes sont déjà ASCII et ne contiennent pas de backtick.
|
|
335
|
+
if text.isascii() and "`" not in text:
|
|
336
|
+
return text
|
|
337
|
+
|
|
338
|
+
return text.translate(_APOSTROPHES_MAP)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def normalize_query(query: str) -> str:
|
|
342
|
+
"""Normalize a search query to replace common club abbreviations
|
|
343
|
+
or alternative names with their official FFBB names.
|
|
344
|
+
This helps the FFBB API find the correct results.
|
|
345
|
+
|
|
346
|
+
Applique aussi la résolution d'acronymes en premier.
|
|
347
|
+
"""
|
|
348
|
+
if not query:
|
|
349
|
+
return query
|
|
350
|
+
|
|
351
|
+
# 0. Normalisation des apostrophes typographiques → apostrophe ASCII
|
|
352
|
+
query = _normalize_apostrophes(query)
|
|
353
|
+
|
|
354
|
+
# 1. Résolution d'acronyme en priorité
|
|
355
|
+
resolved = resolve_acronym(query)
|
|
356
|
+
if resolved != query:
|
|
357
|
+
return resolved
|
|
358
|
+
|
|
359
|
+
# 2. Normalisation via le dictionnaire statique
|
|
360
|
+
normalized = query.lower().strip()
|
|
361
|
+
|
|
362
|
+
# Try exact match first
|
|
363
|
+
if normalized in CLUB_ALIASES:
|
|
364
|
+
return CLUB_ALIASES[normalized]
|
|
365
|
+
|
|
366
|
+
# Replace whole words in a single pass using the combined regex
|
|
367
|
+
if _ALIAS_PATTERN_ALL.search(normalized):
|
|
368
|
+
normalized = _ALIAS_PATTERN_ALL.sub(
|
|
369
|
+
lambda m: _VALID_ALIASES_DICT[m.group(1)], normalized
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# Remove excessive spaces
|
|
373
|
+
normalized = " ".join(normalized.split())
|
|
374
|
+
return normalized
|
ffbb_mcp/app_factory.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import AsyncGenerator
|
|
12
|
+
|
|
13
|
+
from mcp.server.fastmcp import FastMCP
|
|
14
|
+
|
|
15
|
+
from starlette.applications import Starlette
|
|
16
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
17
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
18
|
+
from starlette.middleware.gzip import GZipMiddleware
|
|
19
|
+
from starlette.responses import JSONResponse
|
|
20
|
+
from starlette.routing import Mount
|
|
21
|
+
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("ffbb-mcp")
|
|
24
|
+
|
|
25
|
+
_REQUEST_ID_RE = re.compile(r"^[a-zA-Z0-9\-]{1,64}$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_app(mcp: FastMCP, allowed_origins: list[str]) -> Starlette:
|
|
29
|
+
@contextlib.asynccontextmanager
|
|
30
|
+
async def lifespan(app: Starlette) -> AsyncGenerator[None]:
|
|
31
|
+
async with mcp.session_manager.run():
|
|
32
|
+
yield
|
|
33
|
+
|
|
34
|
+
mcp_app = mcp.streamable_http_app()
|
|
35
|
+
|
|
36
|
+
app = Starlette(
|
|
37
|
+
debug=False,
|
|
38
|
+
routes=[Mount("/", app=mcp_app)],
|
|
39
|
+
lifespan=lifespan,
|
|
40
|
+
)
|
|
41
|
+
app.router.redirect_slashes = False
|
|
42
|
+
|
|
43
|
+
# Default to trusting only localhost; set TRUSTED_PROXY_HOSTS for production
|
|
44
|
+
# reverse-proxy scenarios (e.g. "10.0.0.1,10.0.0.2").
|
|
45
|
+
_proxy_hosts_raw = os.environ.get("TRUSTED_PROXY_HOSTS", "127.0.0.1")
|
|
46
|
+
_trusted_hosts = [h.strip() for h in _proxy_hosts_raw.split(",") if h.strip()]
|
|
47
|
+
app.add_middleware(
|
|
48
|
+
ProxyHeadersMiddleware, trusted_hosts=_trusted_hosts or ["127.0.0.1"]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
app.add_middleware(
|
|
52
|
+
CORSMiddleware,
|
|
53
|
+
allow_origins=allowed_origins,
|
|
54
|
+
allow_methods=["GET", "POST", "OPTIONS", "DELETE"],
|
|
55
|
+
allow_headers=[
|
|
56
|
+
"Content-Type",
|
|
57
|
+
"Accept",
|
|
58
|
+
"Authorization",
|
|
59
|
+
"Mcp-Session-Id",
|
|
60
|
+
"MCP-Protocol-Version",
|
|
61
|
+
"X-Forwarded-For",
|
|
62
|
+
"X-Forwarded-Proto",
|
|
63
|
+
"X-Real-IP",
|
|
64
|
+
],
|
|
65
|
+
expose_headers=["Content-Type", "Mcp-Session-Id"],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
|
69
|
+
|
|
70
|
+
class RequestIdMiddleware(BaseHTTPMiddleware):
|
|
71
|
+
async def dispatch(self, request: Any, call_next: Any) -> Any:
|
|
72
|
+
raw_id = request.headers.get("X-Request-ID", "")
|
|
73
|
+
request_id = raw_id if _REQUEST_ID_RE.match(raw_id) else str(uuid.uuid4())
|
|
74
|
+
try:
|
|
75
|
+
response = await call_next(request)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
# Don't log broken pipes or client disconnects as errors
|
|
78
|
+
err_str = str(e).lower()
|
|
79
|
+
if any(
|
|
80
|
+
x in err_str
|
|
81
|
+
for x in ["broken pipe", "connection closed", "client disconnected"]
|
|
82
|
+
):
|
|
83
|
+
logger.debug("Client disconnected: %s", e)
|
|
84
|
+
else:
|
|
85
|
+
logger.error(
|
|
86
|
+
"Middleware error on %s: %s", request.url.path, e, exc_info=True
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
response = JSONResponse(
|
|
90
|
+
{"error": "Internal Server Error", "request_id": request_id},
|
|
91
|
+
status_code=500,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
response.headers["X-Request-ID"] = request_id
|
|
95
|
+
return response
|
|
96
|
+
|
|
97
|
+
app.add_middleware(RequestIdMiddleware)
|
|
98
|
+
|
|
99
|
+
return app
|