tagsearch 1.0.0__tar.gz
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.
- tagsearch-1.0.0/PKG-INFO +8 -0
- tagsearch-1.0.0/README.md +64 -0
- tagsearch-1.0.0/pyproject.toml +21 -0
- tagsearch-1.0.0/setup.cfg +4 -0
- tagsearch-1.0.0/tagsearch/__init__.py +12 -0
- tagsearch-1.0.0/tagsearch/engine.py +242 -0
- tagsearch-1.0.0/tagsearch/models.py +81 -0
- tagsearch-1.0.0/tagsearch/scoring.py +177 -0
- tagsearch-1.0.0/tagsearch/structures.py +360 -0
- tagsearch-1.0.0/tagsearch.egg-info/PKG-INFO +8 -0
- tagsearch-1.0.0/tagsearch.egg-info/SOURCES.txt +13 -0
- tagsearch-1.0.0/tagsearch.egg-info/dependency_links.txt +1 -0
- tagsearch-1.0.0/tagsearch.egg-info/requires.txt +4 -0
- tagsearch-1.0.0/tagsearch.egg-info/top_level.txt +1 -0
- tagsearch-1.0.0/tests/test_cross_language.py +187 -0
tagsearch-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tagsearch
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: In-memory tag search engine for e-commerce (DAWG + BM25 + hybrid ranking)
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Provides-Extra: dev
|
|
7
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
8
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# tagsearch — Python Engine
|
|
2
|
+
|
|
3
|
+
In-memory tag search engine for e-commerce. DAWG + BM25 + 5-dimension hybrid ranking.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e ".[dev]"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from tagsearch import SearchEngine, ProductMeta, ProductType, SearchPreferences
|
|
15
|
+
|
|
16
|
+
engine = SearchEngine(cache_size=200)
|
|
17
|
+
engine.load_catalog("../shared-assets/catalog.json")
|
|
18
|
+
|
|
19
|
+
# Recherche simple
|
|
20
|
+
results, status = engine.search("gaming")
|
|
21
|
+
|
|
22
|
+
# Recherche avec préférences
|
|
23
|
+
prefs = SearchPreferences(preferred_type=ProductType.DIGITAL, max_price=50.0)
|
|
24
|
+
results, status = engine.search("cuisine", prefs=prefs, fuzzy=True, max_dist=1)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## API
|
|
28
|
+
|
|
29
|
+
### `SearchEngine.search(query, prefs, fuzzy, max_dist, top_k)`
|
|
30
|
+
|
|
31
|
+
| Paramètre | Type | Défaut | Description |
|
|
32
|
+
|-----------|------|--------|-------------|
|
|
33
|
+
| `query` | `str` | — | Requête utilisateur |
|
|
34
|
+
| `prefs` | `SearchPreferences` | `None` | Préférences de re-ranking |
|
|
35
|
+
| `fuzzy` | `bool` | `True` | Active la recherche floue (Levenshtein) |
|
|
36
|
+
| `max_dist`| `int` | `1` | Distance de Levenshtein maximale |
|
|
37
|
+
| `top_k` | `int` | `10` | Nombre de résultats retournés |
|
|
38
|
+
|
|
39
|
+
Retourne `(list[dict], status)` où `status` ∈ `{"CALCULÉ", "CACHÉ", "MISS"}`.
|
|
40
|
+
|
|
41
|
+
### Score final (formule canonique)
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
score = bm25_norm × quality_mult × type_factor × niche_boost × proximity × price_factor
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- `bm25_norm` = `min(bm25_raw / 10.0, 1.0)`, plancher à `0.05`
|
|
48
|
+
- `quality_mult` = `(bayesian_rating / 5.0) ** 1.5`
|
|
49
|
+
- `bayesian_rating` = `(count × rating + 3.5 × 10) / (count + 10)`
|
|
50
|
+
|
|
51
|
+
## Tests
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pytest --cov=tagsearch
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Benchmark attendu
|
|
58
|
+
|
|
59
|
+
| Opération | Complexité | Temps typique (10k tags) |
|
|
60
|
+
|-----------|-----------|--------------------------|
|
|
61
|
+
| Autocomplete | O(L) | < 0.1 ms |
|
|
62
|
+
| Fuzzy search (max_dist=1) | O(L × 5%) | < 1 ms |
|
|
63
|
+
| Scoring 100 candidats | O(n) | < 0.5 ms |
|
|
64
|
+
| Cache HIT | O(1) | < 0.01 ms |
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tagsearch"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "In-memory tag search engine for e-commerce (DAWG + BM25 + hybrid ranking)"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
dev = ["pytest>=8.0", "pytest-cov"]
|
|
14
|
+
|
|
15
|
+
[tool.pytest.ini_options]
|
|
16
|
+
testpaths = ["tests"]
|
|
17
|
+
pythonpath = ["."]
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["."]
|
|
21
|
+
include = ["tagsearch*"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""tagsearch — In-memory tag search engine for e-commerce."""
|
|
2
|
+
from .engine import SearchEngine
|
|
3
|
+
from .models import ProductMeta, ProductType, SearchPreferences
|
|
4
|
+
from .scoring import calculate_final_score
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"SearchEngine",
|
|
8
|
+
"ProductMeta",
|
|
9
|
+
"ProductType",
|
|
10
|
+
"SearchPreferences",
|
|
11
|
+
"calculate_final_score",
|
|
12
|
+
]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tagsearch.engine
|
|
3
|
+
================
|
|
4
|
+
SearchEngine : point d'entrée public de la bibliothèque.
|
|
5
|
+
Orchestre DAWG, BM25Index, CompressedPostingsList, LRUCache et calculate_final_score.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .models import ProductMeta, ProductType, SearchPreferences
|
|
15
|
+
from .scoring import BM25Index, calculate_final_score
|
|
16
|
+
from .structures import DAWG, CompressedPostingsList, LRUCache
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SearchEngine:
|
|
22
|
+
"""
|
|
23
|
+
Moteur de recherche in-memory pour tags e-commerce.
|
|
24
|
+
|
|
25
|
+
Architecture :
|
|
26
|
+
DAWG → index des tags (autocomplete + fuzzy Levenshtein)
|
|
27
|
+
CompressedPostingsList → inverted index compressé (delta + VarInt)
|
|
28
|
+
BM25Index → scoring textuel
|
|
29
|
+
calculate_final_score → re-ranking 5 dimensions
|
|
30
|
+
LRUCache → cache des résultats de recherche
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
>>> engine = SearchEngine()
|
|
34
|
+
>>> engine.load_catalog("shared-assets/catalog.json")
|
|
35
|
+
>>> results, status = engine.search("gaming", fuzzy=True)
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
cache_size: Capacité du cache LRU (nombre de requêtes mémorisées).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, cache_size: int = 200) -> None:
|
|
42
|
+
self._all_tags: list[str] = []
|
|
43
|
+
self.dawg: DAWG = DAWG()
|
|
44
|
+
self.inverted_index: dict[str, CompressedPostingsList] = {}
|
|
45
|
+
self.catalog: dict[int, ProductMeta] = {}
|
|
46
|
+
self.bm25: BM25Index = BM25Index()
|
|
47
|
+
self.cache: LRUCache = LRUCache(capacity=cache_size)
|
|
48
|
+
logger.info("SearchEngine initialisé (cache_size=%d)", cache_size)
|
|
49
|
+
|
|
50
|
+
# ── Chargement ──────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def load_catalog(self, path: str | Path) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Charge un catalogue depuis un fichier JSON (format shared-assets/catalog.json).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
path: Chemin vers le fichier JSON.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
FileNotFoundError: Si le fichier n'existe pas.
|
|
61
|
+
ValueError: Si le JSON est malformé ou un produit est invalide.
|
|
62
|
+
"""
|
|
63
|
+
path = Path(path)
|
|
64
|
+
if not path.exists():
|
|
65
|
+
raise FileNotFoundError(f"Catalogue introuvable : {path}")
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
68
|
+
except json.JSONDecodeError as exc:
|
|
69
|
+
raise ValueError(f"JSON malformé dans {path} : {exc}") from exc
|
|
70
|
+
|
|
71
|
+
products = data.get("products", [])
|
|
72
|
+
if not products:
|
|
73
|
+
logger.warning("Catalogue vide : aucun produit chargé depuis %s", path)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
for raw in products:
|
|
77
|
+
try:
|
|
78
|
+
meta = ProductMeta(
|
|
79
|
+
name = raw["name"],
|
|
80
|
+
product_type = ProductType.from_str(raw["type"]),
|
|
81
|
+
niche = raw["niche"],
|
|
82
|
+
price = float(raw["price"]),
|
|
83
|
+
location = raw.get("location"),
|
|
84
|
+
avg_rating = float(raw["avg_rating"]),
|
|
85
|
+
review_count = int(raw["review_count"]),
|
|
86
|
+
tags = [str(t) for t in raw["tags"]],
|
|
87
|
+
)
|
|
88
|
+
self.add_product(int(raw["id"]), meta)
|
|
89
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
90
|
+
raise ValueError(f"Produit invalide (id={raw.get('id', '?')}) : {exc}") from exc
|
|
91
|
+
|
|
92
|
+
logger.info("Catalogue chargé : %d produits depuis %s", len(products), path)
|
|
93
|
+
|
|
94
|
+
def add_product(self, product_id: int, meta: ProductMeta) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Indexe un produit.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
product_id: Identifiant unique (entier positif).
|
|
100
|
+
meta: Métadonnées complètes du produit.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: Si product_id est négatif ou déjà présent.
|
|
104
|
+
"""
|
|
105
|
+
if product_id < 0:
|
|
106
|
+
raise ValueError(f"product_id doit être >= 0, reçu : {product_id}")
|
|
107
|
+
if product_id in self.catalog:
|
|
108
|
+
logger.warning("Produit %d déjà indexé — écrasement.", product_id)
|
|
109
|
+
|
|
110
|
+
self.catalog[product_id] = meta
|
|
111
|
+
|
|
112
|
+
for tag in meta.tags:
|
|
113
|
+
tag_lower = tag.lower()
|
|
114
|
+
if tag_lower not in self.inverted_index:
|
|
115
|
+
self.inverted_index[tag_lower] = CompressedPostingsList()
|
|
116
|
+
self._all_tags.append(tag_lower)
|
|
117
|
+
self.inverted_index[tag_lower].add(product_id)
|
|
118
|
+
|
|
119
|
+
self.bm25.index(product_id, meta.tags)
|
|
120
|
+
|
|
121
|
+
for pl in self.inverted_index.values():
|
|
122
|
+
pl.flush()
|
|
123
|
+
self.dawg = DAWG.build(self._all_tags)
|
|
124
|
+
self.cache.invalidate()
|
|
125
|
+
logger.debug("Produit %d indexé : '%s'", product_id, meta.name)
|
|
126
|
+
|
|
127
|
+
# ── Recherche ────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
def search(
|
|
130
|
+
self,
|
|
131
|
+
query : str,
|
|
132
|
+
prefs : SearchPreferences | None = None,
|
|
133
|
+
fuzzy : bool = True,
|
|
134
|
+
max_dist : int = 1,
|
|
135
|
+
top_k : int = 10,
|
|
136
|
+
) -> tuple[list[dict], str]:
|
|
137
|
+
"""
|
|
138
|
+
Recherche multidimensionnelle in-memory.
|
|
139
|
+
|
|
140
|
+
Flux :
|
|
141
|
+
1. DAWG fuzzy/autocomplete → matched_tags
|
|
142
|
+
2. CompressedPostingsList → candidate_ids
|
|
143
|
+
3. BM25Index.score() → bm25_raw par produit
|
|
144
|
+
4. calculate_final_score() → score final (5 dimensions)
|
|
145
|
+
5. Tri décroissant + top_k + cache LRU
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
query: Requête utilisateur (chaîne brute, normalisée en interne).
|
|
149
|
+
prefs: Préférences de re-ranking. None = comportement neutre.
|
|
150
|
+
fuzzy: Si True, utilise la recherche floue (Levenshtein + pruning).
|
|
151
|
+
max_dist: Distance de Levenshtein maximale (1 = 1 faute de frappe).
|
|
152
|
+
top_k: Nombre maximum de résultats retournés.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Tuple (results, status) où :
|
|
156
|
+
results : liste de dicts triés par score décroissant.
|
|
157
|
+
Champs : id, name, type, niche, price, rating, reviews, score.
|
|
158
|
+
status : "CACHÉ" | "CALCULÉ" | "MISS".
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ValueError: Si query est vide ou max_dist < 0.
|
|
162
|
+
"""
|
|
163
|
+
if not query or not query.strip():
|
|
164
|
+
raise ValueError("La requête ne peut pas être vide.")
|
|
165
|
+
if max_dist < 0:
|
|
166
|
+
raise ValueError(f"max_dist doit être >= 0, reçu : {max_dist}")
|
|
167
|
+
if not self.catalog:
|
|
168
|
+
logger.warning("search() appelé sur un index vide.")
|
|
169
|
+
return [], "MISS"
|
|
170
|
+
|
|
171
|
+
if prefs is None:
|
|
172
|
+
prefs = SearchPreferences()
|
|
173
|
+
|
|
174
|
+
cache_key = f"{query.strip().lower()}|{prefs}"
|
|
175
|
+
cached = self.cache.get(cache_key)
|
|
176
|
+
if cached is not None:
|
|
177
|
+
logger.debug("Cache HIT pour '%s'", query)
|
|
178
|
+
return cached, "CACHÉ"
|
|
179
|
+
|
|
180
|
+
query_lower = query.strip().lower()
|
|
181
|
+
|
|
182
|
+
# 1. Tags via DAWG
|
|
183
|
+
try:
|
|
184
|
+
matched_tags = (
|
|
185
|
+
self.dawg.fuzzy_search(query_lower, max_dist)
|
|
186
|
+
if fuzzy else
|
|
187
|
+
self.dawg.autocomplete(query_lower)
|
|
188
|
+
)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
logger.error("Erreur DAWG pour '%s' : %s", query, exc)
|
|
191
|
+
return [], "MISS"
|
|
192
|
+
|
|
193
|
+
if not matched_tags:
|
|
194
|
+
logger.debug("Aucun tag trouvé pour '%s'", query)
|
|
195
|
+
return [], "MISS"
|
|
196
|
+
|
|
197
|
+
# 2. Candidats via inverted index
|
|
198
|
+
candidate_ids: set[int] = set()
|
|
199
|
+
for tag in matched_tags:
|
|
200
|
+
if tag in self.inverted_index:
|
|
201
|
+
try:
|
|
202
|
+
candidate_ids.update(self.inverted_index[tag].decompress())
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
logger.error("Erreur décompression postings '%s' : %s", tag, exc)
|
|
205
|
+
|
|
206
|
+
if not candidate_ids:
|
|
207
|
+
return [], "MISS"
|
|
208
|
+
|
|
209
|
+
# 3. BM25 + re-ranking
|
|
210
|
+
results: list[dict] = []
|
|
211
|
+
for pid in candidate_ids:
|
|
212
|
+
meta = self.catalog.get(pid)
|
|
213
|
+
if meta is None:
|
|
214
|
+
logger.warning("product_id %d dans l'index mais absent du catalogue.", pid)
|
|
215
|
+
continue
|
|
216
|
+
try:
|
|
217
|
+
bm25_raw = self.bm25.score(pid, matched_tags)
|
|
218
|
+
final_score = calculate_final_score(pid, meta, bm25_raw, prefs)
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
logger.error("Erreur scoring produit %d : %s", pid, exc)
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
if final_score is None:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
results.append({
|
|
227
|
+
"id" : pid,
|
|
228
|
+
"name" : meta.name,
|
|
229
|
+
"type" : meta.product_type.name,
|
|
230
|
+
"niche" : meta.niche,
|
|
231
|
+
"price" : meta.price,
|
|
232
|
+
"rating" : meta.avg_rating,
|
|
233
|
+
"reviews": meta.review_count,
|
|
234
|
+
"score" : final_score,
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
results.sort(key=lambda x: x["score"], reverse=True)
|
|
238
|
+
final_list = results[:top_k]
|
|
239
|
+
|
|
240
|
+
self.cache.put(cache_key, final_list)
|
|
241
|
+
logger.info("Recherche '%s' : %d résultats (%d candidats)", query, len(final_list), len(candidate_ids))
|
|
242
|
+
return final_list, "CALCULÉ"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tagsearch.models
|
|
3
|
+
================
|
|
4
|
+
Structures de données métier : ProductType, ProductMeta, SearchPreferences.
|
|
5
|
+
Tous les dataclasses utilisent slots=True (Python 3.10+) pour minimiser la RAM.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProductType(Enum):
|
|
14
|
+
"""Type de produit. Encodé comme entier pour comparaison O(1)."""
|
|
15
|
+
PHYSICAL = 0
|
|
16
|
+
DIGITAL = 1
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_str(cls, value: str) -> "ProductType":
|
|
20
|
+
"""Parse depuis une chaîne JSON ('PHYSICAL' | 'DIGITAL')."""
|
|
21
|
+
try:
|
|
22
|
+
return cls[value.upper()]
|
|
23
|
+
except KeyError:
|
|
24
|
+
raise ValueError(f"ProductType invalide : '{value}'. Attendu : PHYSICAL | DIGITAL")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class ProductMeta:
|
|
29
|
+
"""
|
|
30
|
+
Métadonnées complètes d'un produit.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
name: Nom affiché du produit.
|
|
34
|
+
product_type: PHYSICAL ou DIGITAL.
|
|
35
|
+
niche: Catégorie (gaming, cuisine, beauté…). Interné via sys.intern.
|
|
36
|
+
price: Prix en euros (float).
|
|
37
|
+
location: Zone de livraison. None pour les produits digitaux.
|
|
38
|
+
avg_rating: Note moyenne des avis (0.0–5.0).
|
|
39
|
+
review_count: Nombre d'avis. Pondère la fiabilité du rating (Bayesian).
|
|
40
|
+
tags: Liste de tags indexés.
|
|
41
|
+
"""
|
|
42
|
+
name : str
|
|
43
|
+
product_type : ProductType
|
|
44
|
+
niche : str
|
|
45
|
+
price : float
|
|
46
|
+
location : str | None
|
|
47
|
+
avg_rating : float
|
|
48
|
+
review_count : int
|
|
49
|
+
tags : list[str]
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
import sys
|
|
53
|
+
self.niche = sys.intern(self.niche.lower())
|
|
54
|
+
if not 0.0 <= self.avg_rating <= 5.0:
|
|
55
|
+
raise ValueError(f"avg_rating doit être dans [0.0, 5.0], reçu : {self.avg_rating}")
|
|
56
|
+
if self.review_count < 0:
|
|
57
|
+
raise ValueError(f"review_count doit être >= 0, reçu : {self.review_count}")
|
|
58
|
+
if self.price < 0:
|
|
59
|
+
raise ValueError(f"price doit être >= 0, reçu : {self.price}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True)
|
|
63
|
+
class SearchPreferences:
|
|
64
|
+
"""
|
|
65
|
+
Préférences utilisateur pour le re-ranking multidimensionnel.
|
|
66
|
+
Toutes les valeurs sont optionnelles (None = neutre).
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
preferred_type: Filtre doux sur le type (PHYSICAL/DIGITAL).
|
|
70
|
+
preferred_niche: Boost +20% si la niche correspond.
|
|
71
|
+
max_price: Filtre dur : exclut les produits au-dessus.
|
|
72
|
+
min_price: Filtre dur : exclut les produits en-dessous.
|
|
73
|
+
user_location: Bonus +15% si la zone de livraison correspond.
|
|
74
|
+
min_rating: Filtre dur : exclut les produits en-dessous de ce seuil.
|
|
75
|
+
"""
|
|
76
|
+
preferred_type : ProductType | None = None
|
|
77
|
+
preferred_niche : str | None = None
|
|
78
|
+
max_price : float | None = None
|
|
79
|
+
min_price : float | None = None
|
|
80
|
+
user_location : str | None = None
|
|
81
|
+
min_rating : float = 0.0
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tagsearch.scoring
|
|
3
|
+
=================
|
|
4
|
+
BM25Index + calculate_final_score.
|
|
5
|
+
|
|
6
|
+
Formules canoniques documentées ici — source de vérité pour le portage TS/PHP.
|
|
7
|
+
Toute modification doit être répercutée dans shared-assets/catalog.json
|
|
8
|
+
(section scoring_constants) et dans les tests cross-langage.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import logging
|
|
13
|
+
import math
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
|
|
16
|
+
from .models import ProductMeta, ProductType, SearchPreferences
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# ── Constantes de scoring (synchronisées avec shared-assets/catalog.json) ───
|
|
21
|
+
BM25_K1 = 1.5
|
|
22
|
+
BM25_B = 0.75
|
|
23
|
+
BM25_CAP = 10.0 # Plafond de normalisation BM25 → score dans [0,1]
|
|
24
|
+
BM25_FLOOR = 0.05 # Plancher : tag très fréquent reste visible
|
|
25
|
+
QUALITY_EXPONENT = 1.5 # Courbe convexe : pénalise sévèrement les notes < 3/5
|
|
26
|
+
PRIOR_MEAN = 3.5 # Note a priori marché e-commerce
|
|
27
|
+
PRIOR_WEIGHT = 10.0 # Équivalent 10 avis fictifs (lissage Bayesian)
|
|
28
|
+
TYPE_MISMATCH = 0.1 # Facteur si type ne correspond pas (filtre doux)
|
|
29
|
+
NICHE_BOOST = 1.2 # +20% si niche correspond
|
|
30
|
+
PROXIMITY_BONUS = 1.15 # +15% si zone de livraison correspond
|
|
31
|
+
PRICE_BUDGET_BOOST = 1.1 # +10% si prix ≤ 50% du budget max
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BM25Index:
|
|
35
|
+
"""
|
|
36
|
+
BM25 (Okapi BM25) in-memory sur les tags produits.
|
|
37
|
+
|
|
38
|
+
Formule IDF :
|
|
39
|
+
IDF(q) = log( (N - df(q) + 0.5) / (df(q) + 0.5) + 1 )
|
|
40
|
+
Le +1 évite les valeurs négatives pour les termes très fréquents.
|
|
41
|
+
|
|
42
|
+
Formule TF normalisée :
|
|
43
|
+
TF_norm(q, d) = tf(q,d) × (K1+1) / (tf(q,d) + K1×(1 - B + B×dl/avgdl))
|
|
44
|
+
|
|
45
|
+
Score BM25(d, Q) = Σ IDF(qi) × TF_norm(qi, d)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self.tf: dict[str, dict[int, int]] = defaultdict(lambda: defaultdict(int))
|
|
50
|
+
self.df: dict[str, int] = defaultdict(int)
|
|
51
|
+
self.dl: dict[int, int] = {}
|
|
52
|
+
self.N: int = 0
|
|
53
|
+
self.avgdl: float = 0.0
|
|
54
|
+
|
|
55
|
+
def index(self, product_id: int, tags: list[str]) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Indexe les tags d'un produit.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
product_id: Identifiant unique du produit.
|
|
61
|
+
tags: Liste de tags (non normalisés, la normalisation est interne).
|
|
62
|
+
"""
|
|
63
|
+
tags_lower = [t.lower() for t in tags]
|
|
64
|
+
self.dl[product_id] = len(tags_lower)
|
|
65
|
+
self.N += 1
|
|
66
|
+
self.avgdl = sum(self.dl.values()) / self.N
|
|
67
|
+
for tag in tags_lower:
|
|
68
|
+
self.tf[tag][product_id] += 1
|
|
69
|
+
if self.tf[tag][product_id] == 1:
|
|
70
|
+
self.df[tag] += 1
|
|
71
|
+
logger.debug("BM25: produit %d indexé (%d tags, avgdl=%.2f)", product_id, len(tags_lower), self.avgdl)
|
|
72
|
+
|
|
73
|
+
def score(self, product_id: int, query_tags: list[str]) -> float:
|
|
74
|
+
"""
|
|
75
|
+
Calcule le score BM25 brut pour un produit et une liste de tags requête.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
product_id: ID du produit à scorer.
|
|
79
|
+
query_tags: Tags issus de la recherche DAWG (matched_tags).
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Score BM25 brut (non normalisé, non plafonné). Typiquement dans [0, 15].
|
|
83
|
+
"""
|
|
84
|
+
if self.N == 0 or self.avgdl == 0:
|
|
85
|
+
return 0.0
|
|
86
|
+
total = 0.0
|
|
87
|
+
dl_d = self.dl.get(product_id, 0)
|
|
88
|
+
for tag in query_tags:
|
|
89
|
+
tag = tag.lower()
|
|
90
|
+
tf_val = self.tf[tag].get(product_id, 0)
|
|
91
|
+
if tf_val == 0: continue
|
|
92
|
+
df_val = self.df.get(tag, 0)
|
|
93
|
+
idf = math.log((self.N - df_val + 0.5) / (df_val + 0.5) + 1)
|
|
94
|
+
norm = tf_val * (BM25_K1 + 1) / (tf_val + BM25_K1 * (1 - BM25_B + BM25_B * dl_d / self.avgdl))
|
|
95
|
+
total += idf * norm
|
|
96
|
+
return total
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def calculate_final_score(
|
|
100
|
+
product_id: int,
|
|
101
|
+
meta: ProductMeta,
|
|
102
|
+
bm25_raw: float,
|
|
103
|
+
prefs: SearchPreferences,
|
|
104
|
+
) -> float | None:
|
|
105
|
+
"""
|
|
106
|
+
Re-ranking multidimensionnel Phase 2.
|
|
107
|
+
|
|
108
|
+
Formule (multiplicative) :
|
|
109
|
+
final = bm25_norm × quality_mult × type_factor × niche_boost
|
|
110
|
+
× proximity_bonus × price_factor
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
product_id: ID du produit (utilisé pour le logging).
|
|
114
|
+
meta: Métadonnées complètes du produit.
|
|
115
|
+
bm25_raw: Score BM25 brut retourné par BM25Index.score().
|
|
116
|
+
prefs: Préférences utilisateur pour le re-ranking.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
float arrondi à 4 décimales, ou None si le produit est exclu par un filtre dur.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
TypeError: Si meta ou prefs ne sont pas du bon type.
|
|
123
|
+
"""
|
|
124
|
+
if not isinstance(meta, ProductMeta):
|
|
125
|
+
raise TypeError(f"meta doit être ProductMeta, reçu : {type(meta)}")
|
|
126
|
+
if not isinstance(prefs, SearchPreferences):
|
|
127
|
+
raise TypeError(f"prefs doit être SearchPreferences, reçu : {type(prefs)}")
|
|
128
|
+
|
|
129
|
+
# ── Filtres durs ────────────────────────────────────────────────────────
|
|
130
|
+
if prefs.max_price is not None and meta.price > prefs.max_price:
|
|
131
|
+
logger.debug("Produit %d exclu : prix %.2f > max %.2f", product_id, meta.price, prefs.max_price)
|
|
132
|
+
return None
|
|
133
|
+
if prefs.min_price is not None and meta.price < prefs.min_price:
|
|
134
|
+
logger.debug("Produit %d exclu : prix %.2f < min %.2f", product_id, meta.price, prefs.min_price)
|
|
135
|
+
return None
|
|
136
|
+
if meta.avg_rating < prefs.min_rating:
|
|
137
|
+
logger.debug("Produit %d exclu : rating %.1f < min %.1f", product_id, meta.avg_rating, prefs.min_rating)
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
# ── Dim 1 : BM25 normalisé ──────────────────────────────────────────────
|
|
141
|
+
bm25_norm = max(min(bm25_raw / BM25_CAP, 1.0), BM25_FLOOR)
|
|
142
|
+
|
|
143
|
+
# ── Dim 2 : Qualité Bayesian ────────────────────────────────────────────
|
|
144
|
+
bayesian = (meta.review_count * meta.avg_rating + PRIOR_MEAN * PRIOR_WEIGHT) / (meta.review_count + PRIOR_WEIGHT)
|
|
145
|
+
quality_mult = (bayesian / 5.0) ** QUALITY_EXPONENT
|
|
146
|
+
|
|
147
|
+
# ── Dim 3 : Type + Niche ────────────────────────────────────────────────
|
|
148
|
+
type_factor = TYPE_MISMATCH if (
|
|
149
|
+
prefs.preferred_type is not None and meta.product_type != prefs.preferred_type
|
|
150
|
+
) else 1.0
|
|
151
|
+
|
|
152
|
+
niche_factor = NICHE_BOOST if (
|
|
153
|
+
prefs.preferred_niche is not None
|
|
154
|
+
and meta.niche == prefs.preferred_niche.lower()
|
|
155
|
+
) else 1.0
|
|
156
|
+
|
|
157
|
+
# ── Dim 4 : Proximité ───────────────────────────────────────────────────
|
|
158
|
+
if meta.location is None:
|
|
159
|
+
proximity = 1.0
|
|
160
|
+
elif prefs.user_location is not None and meta.location.lower() == prefs.user_location.lower():
|
|
161
|
+
proximity = PROXIMITY_BONUS
|
|
162
|
+
else:
|
|
163
|
+
proximity = 1.0
|
|
164
|
+
|
|
165
|
+
# ── Dim 5 : Prix (filtre doux) ──────────────────────────────────────────
|
|
166
|
+
if prefs.max_price is not None and prefs.max_price > 0:
|
|
167
|
+
price_factor = PRICE_BUDGET_BOOST if (meta.price / prefs.max_price) <= 0.5 else 1.0
|
|
168
|
+
else:
|
|
169
|
+
price_factor = 1.0
|
|
170
|
+
|
|
171
|
+
final = bm25_norm * quality_mult * type_factor * niche_factor * proximity * price_factor
|
|
172
|
+
result = round(final, 4)
|
|
173
|
+
logger.debug(
|
|
174
|
+
"Produit %d | bm25=%.4f qmult=%.4f type=%.1f niche=%.1f prox=%.2f price=%.1f → %.4f",
|
|
175
|
+
product_id, bm25_norm, quality_mult, type_factor, niche_factor, proximity, price_factor, result
|
|
176
|
+
)
|
|
177
|
+
return result
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tagsearch.structures
|
|
3
|
+
====================
|
|
4
|
+
Structures de données bas niveau (Phase 1) :
|
|
5
|
+
- TrieNode / Trie : __slots__ + tableau fixe O(1)
|
|
6
|
+
- DAWGNode / DAWG : FST simplifié, partage de suffixes
|
|
7
|
+
- VarInt : encodage variable-length integer
|
|
8
|
+
- CompressedPostingsList: delta-encoding + VarInt
|
|
9
|
+
- LRUCache : OrderedDict-based O(1) get/put
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# ── Alphabet ────────────────────────────────────────────────────────────────
|
|
19
|
+
ALPHA_SIZE = 38 # a-z(0-25), 0-9(26-35), -(36), espace(37)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _char_index(c: str) -> int:
|
|
23
|
+
if 'a' <= c <= 'z': return ord(c) - ord('a')
|
|
24
|
+
if '0' <= c <= '9': return ord(c) - ord('0') + 26
|
|
25
|
+
if c == '-': return 36
|
|
26
|
+
if c == ' ': return 37
|
|
27
|
+
return -1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _index_to_char(idx: int) -> str:
|
|
31
|
+
if idx <= 25: return chr(ord('a') + idx)
|
|
32
|
+
if idx <= 35: return chr(ord('0') + idx - 26)
|
|
33
|
+
if idx == 36: return '-'
|
|
34
|
+
return ' '
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── Trie (Phase 1.1 + 1.2) ──────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
class TrieNode:
|
|
40
|
+
"""Nœud Trie : __slots__ + tableau fixe de 38 pointeurs (lazy init)."""
|
|
41
|
+
__slots__ = ('children', 'is_terminal', 'tag')
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self.children: list | None = None
|
|
45
|
+
self.is_terminal: bool = False
|
|
46
|
+
self.tag: str | None = None
|
|
47
|
+
|
|
48
|
+
def get_child(self, c: str) -> TrieNode | None:
|
|
49
|
+
if self.children is None: return None
|
|
50
|
+
idx = _char_index(c)
|
|
51
|
+
return None if idx == -1 else self.children[idx]
|
|
52
|
+
|
|
53
|
+
def set_child(self, c: str, node: TrieNode) -> None:
|
|
54
|
+
if self.children is None:
|
|
55
|
+
self.children = [None] * ALPHA_SIZE
|
|
56
|
+
idx = _char_index(c)
|
|
57
|
+
if idx != -1:
|
|
58
|
+
self.children[idx] = node
|
|
59
|
+
|
|
60
|
+
def iter_children(self):
|
|
61
|
+
if self.children is None: return
|
|
62
|
+
for idx, child in enumerate(self.children):
|
|
63
|
+
if child is not None: yield idx, child
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Trie:
|
|
67
|
+
"""Trie optimisé mémoire. Utilisé comme buffer d'écriture avant rebuild DAWG."""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self.root = TrieNode()
|
|
71
|
+
|
|
72
|
+
def insert(self, word: str) -> None:
|
|
73
|
+
node = self.root
|
|
74
|
+
for char in word.lower():
|
|
75
|
+
child = node.get_child(char)
|
|
76
|
+
if child is None:
|
|
77
|
+
child = TrieNode()
|
|
78
|
+
node.set_child(char, child)
|
|
79
|
+
node = child
|
|
80
|
+
node.is_terminal = True
|
|
81
|
+
node.tag = word
|
|
82
|
+
|
|
83
|
+
def autocomplete(self, prefix: str) -> list[str]:
|
|
84
|
+
node = self.root
|
|
85
|
+
for char in prefix.lower():
|
|
86
|
+
node = node.get_child(char)
|
|
87
|
+
if node is None: return []
|
|
88
|
+
results: list[str] = []
|
|
89
|
+
self._dfs(node, results)
|
|
90
|
+
return results
|
|
91
|
+
|
|
92
|
+
def _dfs(self, node: TrieNode, results: list[str]) -> None:
|
|
93
|
+
if node.is_terminal: results.append(node.tag) # type: ignore[arg-type]
|
|
94
|
+
for _, child in node.iter_children():
|
|
95
|
+
self._dfs(child, results)
|
|
96
|
+
|
|
97
|
+
def fuzzy_search(self, query: str, max_distance: int = 1) -> list[str]:
|
|
98
|
+
"""Levenshtein DP avec pruning : coupe les branches si min(row) > max_distance."""
|
|
99
|
+
query = query.lower()
|
|
100
|
+
results: list[tuple[str, int]] = []
|
|
101
|
+
init_row = list(range(len(query) + 1))
|
|
102
|
+
for idx, child in self.root.iter_children():
|
|
103
|
+
self._fuzzy_dfs(child, _index_to_char(idx), query, init_row, max_distance, results)
|
|
104
|
+
results.sort(key=lambda x: x[1])
|
|
105
|
+
return [tag for tag, _ in results]
|
|
106
|
+
|
|
107
|
+
def _fuzzy_dfs(self, node: TrieNode, char: str, query: str,
|
|
108
|
+
prev_row: list[int], max_dist: int,
|
|
109
|
+
results: list[tuple[str, int]]) -> None:
|
|
110
|
+
cur = [prev_row[0] + 1]
|
|
111
|
+
for col in range(1, len(query) + 1):
|
|
112
|
+
cur.append(min(cur[col-1]+1, prev_row[col]+1,
|
|
113
|
+
prev_row[col-1] + (0 if query[col-1] == char else 1)))
|
|
114
|
+
if min(cur) > max_dist: return
|
|
115
|
+
if cur[-1] <= max_dist and node.is_terminal:
|
|
116
|
+
results.append((node.tag, cur[-1])) # type: ignore[arg-type]
|
|
117
|
+
for idx, child in node.iter_children():
|
|
118
|
+
self._fuzzy_dfs(child, _index_to_char(idx), query, cur, max_dist, results)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── DAWG (Phase 1.3) ────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
class DAWGNode:
|
|
124
|
+
"""Nœud DAWG avec identité structurelle pour déduplication de suffixes."""
|
|
125
|
+
__slots__ = ('children', 'is_terminal', 'tag', 'frozen', '_hash_cache')
|
|
126
|
+
|
|
127
|
+
def __init__(self) -> None:
|
|
128
|
+
self.children: dict[str, DAWGNode] = {}
|
|
129
|
+
self.is_terminal: bool = False
|
|
130
|
+
self.tag: str | None = None
|
|
131
|
+
self.frozen: bool = False
|
|
132
|
+
self._hash_cache: int | None = None
|
|
133
|
+
|
|
134
|
+
def structural_key(self) -> tuple:
|
|
135
|
+
return (self.is_terminal, self.tag,
|
|
136
|
+
tuple(sorted((c, id(n)) for c, n in self.children.items())))
|
|
137
|
+
|
|
138
|
+
def __hash__(self) -> int:
|
|
139
|
+
if self._hash_cache is None:
|
|
140
|
+
self._hash_cache = hash(self.structural_key())
|
|
141
|
+
return self._hash_cache
|
|
142
|
+
|
|
143
|
+
def __eq__(self, other: object) -> bool:
|
|
144
|
+
return isinstance(other, DAWGNode) and self.structural_key() == other.structural_key()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class DAWG:
|
|
148
|
+
"""
|
|
149
|
+
DAWG (Directed Acyclic Word Graph) via algorithme de Daciuk.
|
|
150
|
+
Partage les suffixes communs → 30-50% moins de nœuds vs Trie sur corpus e-commerce.
|
|
151
|
+
IMMUTABLE après finish(). Utiliser Trie comme buffer pour les insertions dynamiques.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(self) -> None:
|
|
155
|
+
self.root = DAWGNode()
|
|
156
|
+
self._minimized: dict[tuple, DAWGNode] = {}
|
|
157
|
+
self._last_word: str = ""
|
|
158
|
+
self._unchecked: list[tuple[DAWGNode, str, DAWGNode]] = []
|
|
159
|
+
|
|
160
|
+
def _minimize(self, down_to: int) -> None:
|
|
161
|
+
for i in range(len(self._unchecked) - 1, down_to - 1, -1):
|
|
162
|
+
parent, char, child = self._unchecked[i]
|
|
163
|
+
key = child.structural_key()
|
|
164
|
+
if key in self._minimized:
|
|
165
|
+
parent.children[char] = self._minimized[key]
|
|
166
|
+
else:
|
|
167
|
+
child.frozen = True
|
|
168
|
+
self._minimized[key] = child
|
|
169
|
+
self._unchecked.pop()
|
|
170
|
+
|
|
171
|
+
def insert(self, word: str) -> None:
|
|
172
|
+
word = word.lower()
|
|
173
|
+
if word <= self._last_word and self._last_word:
|
|
174
|
+
raise ValueError(f"DAWG: insertion hors ordre — '{word}' après '{self._last_word}'.")
|
|
175
|
+
common = 0
|
|
176
|
+
for i, (c1, c2) in enumerate(zip(word, self._last_word)):
|
|
177
|
+
if c1 != c2: break
|
|
178
|
+
common = i + 1
|
|
179
|
+
else:
|
|
180
|
+
common = min(len(word), len(self._last_word))
|
|
181
|
+
self._minimize(common)
|
|
182
|
+
node = self.root if not self._unchecked else self._unchecked[-1][2]
|
|
183
|
+
for char in word[common:]:
|
|
184
|
+
new_node = DAWGNode()
|
|
185
|
+
node.children[char] = new_node
|
|
186
|
+
self._unchecked.append((node, char, new_node))
|
|
187
|
+
node = new_node
|
|
188
|
+
node.is_terminal = True
|
|
189
|
+
node.tag = word
|
|
190
|
+
self._last_word = word
|
|
191
|
+
|
|
192
|
+
def finish(self) -> None:
|
|
193
|
+
self._minimize(0)
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def build(cls, tags: list[str]) -> DAWG:
|
|
197
|
+
"""Construit un DAWG depuis une liste de tags (tri automatique)."""
|
|
198
|
+
dawg = cls()
|
|
199
|
+
for tag in sorted(set(t.lower() for t in tags)):
|
|
200
|
+
dawg.insert(tag)
|
|
201
|
+
dawg.finish()
|
|
202
|
+
logger.debug("DAWG construit : %d nœuds uniques", dawg.node_count())
|
|
203
|
+
return dawg
|
|
204
|
+
|
|
205
|
+
def contains(self, word: str) -> bool:
|
|
206
|
+
node = self.root
|
|
207
|
+
for char in word.lower():
|
|
208
|
+
node = node.children.get(char) # type: ignore[assignment]
|
|
209
|
+
if node is None: return False
|
|
210
|
+
return node.is_terminal # type: ignore[union-attr]
|
|
211
|
+
|
|
212
|
+
def autocomplete(self, prefix: str) -> list[str]:
|
|
213
|
+
node = self.root
|
|
214
|
+
for char in prefix.lower():
|
|
215
|
+
node = node.children.get(char) # type: ignore[assignment]
|
|
216
|
+
if node is None: return []
|
|
217
|
+
results: list[str] = []
|
|
218
|
+
self._dfs(node, results) # type: ignore[arg-type]
|
|
219
|
+
return results
|
|
220
|
+
|
|
221
|
+
def _dfs(self, node: DAWGNode, results: list[str]) -> None:
|
|
222
|
+
if node.is_terminal: results.append(node.tag) # type: ignore[arg-type]
|
|
223
|
+
for child in node.children.values():
|
|
224
|
+
self._dfs(child, results)
|
|
225
|
+
|
|
226
|
+
def fuzzy_search(self, query: str, max_distance: int = 1) -> list[str]:
|
|
227
|
+
query = query.lower()
|
|
228
|
+
results: list[tuple[str, int]] = []
|
|
229
|
+
init_row = list(range(len(query) + 1))
|
|
230
|
+
for char, child in self.root.children.items():
|
|
231
|
+
self._fuzzy_dfs(child, char, query, init_row, max_distance, results)
|
|
232
|
+
results.sort(key=lambda x: x[1])
|
|
233
|
+
return [tag for tag, _ in results]
|
|
234
|
+
|
|
235
|
+
def _fuzzy_dfs(self, node: DAWGNode, char: str, query: str,
|
|
236
|
+
prev_row: list[int], max_dist: int,
|
|
237
|
+
results: list[tuple[str, int]]) -> None:
|
|
238
|
+
cur = [prev_row[0] + 1]
|
|
239
|
+
for col in range(1, len(query) + 1):
|
|
240
|
+
cur.append(min(cur[col-1]+1, prev_row[col]+1,
|
|
241
|
+
prev_row[col-1] + (0 if query[col-1] == char else 1)))
|
|
242
|
+
if min(cur) > max_dist: return
|
|
243
|
+
if cur[-1] <= max_dist and node.is_terminal:
|
|
244
|
+
results.append((node.tag, cur[-1])) # type: ignore[arg-type]
|
|
245
|
+
for next_char, child in node.children.items():
|
|
246
|
+
self._fuzzy_dfs(child, next_char, query, cur, max_dist, results)
|
|
247
|
+
|
|
248
|
+
def node_count(self) -> int:
|
|
249
|
+
visited: set[int] = set()
|
|
250
|
+
self._count(self.root, visited)
|
|
251
|
+
return len(visited)
|
|
252
|
+
|
|
253
|
+
def _count(self, node: DAWGNode, visited: set[int]) -> None:
|
|
254
|
+
if id(node) in visited: return
|
|
255
|
+
visited.add(id(node))
|
|
256
|
+
for child in node.children.values():
|
|
257
|
+
self._count(child, visited)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ── VarInt + CompressedPostingsList (Phase 1.4) ─────────────────────────────
|
|
261
|
+
|
|
262
|
+
class VarInt:
|
|
263
|
+
"""Encodage variable-length integer (Protocol Buffers style)."""
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def encode(value: int) -> bytes:
|
|
267
|
+
if value < 0:
|
|
268
|
+
raise ValueError(f"VarInt: valeur négative non supportée : {value}")
|
|
269
|
+
buf = []
|
|
270
|
+
while True:
|
|
271
|
+
byte = value & 0x7F
|
|
272
|
+
value >>= 7
|
|
273
|
+
buf.append(byte | 0x80 if value else byte)
|
|
274
|
+
if not value: break
|
|
275
|
+
return bytes(buf)
|
|
276
|
+
|
|
277
|
+
@staticmethod
|
|
278
|
+
def decode(data: bytes, offset: int = 0) -> tuple[int, int]:
|
|
279
|
+
result, shift = 0, 0
|
|
280
|
+
while True:
|
|
281
|
+
if offset >= len(data):
|
|
282
|
+
raise ValueError("VarInt: données tronquées (offset hors limites).")
|
|
283
|
+
byte = data[offset]; offset += 1
|
|
284
|
+
result |= (byte & 0x7F) << shift
|
|
285
|
+
if not (byte & 0x80): break
|
|
286
|
+
shift += 7
|
|
287
|
+
return result, offset
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class CompressedPostingsList:
|
|
291
|
+
"""
|
|
292
|
+
Postings list compressée : delta-encoding + VarInt.
|
|
293
|
+
~98% de réduction vs set Python sur 500 IDs aléatoires.
|
|
294
|
+
Dirty buffer : flush() déclenche la compression en batch.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def __init__(self) -> None:
|
|
298
|
+
self._compressed: bytes = b''
|
|
299
|
+
self._pending: list[int] = []
|
|
300
|
+
self._dirty: bool = False
|
|
301
|
+
|
|
302
|
+
def add(self, product_id: int) -> None:
|
|
303
|
+
if product_id < 0:
|
|
304
|
+
raise ValueError(f"product_id doit être >= 0, reçu : {product_id}")
|
|
305
|
+
self._pending.append(product_id)
|
|
306
|
+
self._dirty = True
|
|
307
|
+
|
|
308
|
+
def flush(self) -> None:
|
|
309
|
+
if not self._dirty: return
|
|
310
|
+
all_ids = sorted(set(self.decompress()) | set(self._pending))
|
|
311
|
+
buf = bytearray()
|
|
312
|
+
prev = 0
|
|
313
|
+
for pid in all_ids:
|
|
314
|
+
buf += VarInt.encode(pid - prev)
|
|
315
|
+
prev = pid
|
|
316
|
+
self._compressed = bytes(buf)
|
|
317
|
+
self._pending.clear()
|
|
318
|
+
self._dirty = False
|
|
319
|
+
|
|
320
|
+
def decompress(self) -> list[int]:
|
|
321
|
+
ids, offset, current = [], 0, 0
|
|
322
|
+
while offset < len(self._compressed):
|
|
323
|
+
delta, offset = VarInt.decode(self._compressed, offset)
|
|
324
|
+
current += delta
|
|
325
|
+
ids.append(current)
|
|
326
|
+
if self._pending:
|
|
327
|
+
ids = sorted(set(ids) | set(self._pending))
|
|
328
|
+
return ids
|
|
329
|
+
|
|
330
|
+
def __contains__(self, pid: int) -> bool:
|
|
331
|
+
return pid in self.decompress()
|
|
332
|
+
|
|
333
|
+
def memory_bytes(self) -> int:
|
|
334
|
+
return len(self._compressed) + len(self._pending) * 28
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ── LRU Cache ────────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
class LRUCache:
|
|
340
|
+
"""Cache LRU O(1) get/put via OrderedDict."""
|
|
341
|
+
|
|
342
|
+
def __init__(self, capacity: int) -> None:
|
|
343
|
+
if capacity <= 0:
|
|
344
|
+
raise ValueError(f"LRUCache capacity doit être > 0, reçu : {capacity}")
|
|
345
|
+
self._cache: OrderedDict = OrderedDict()
|
|
346
|
+
self.capacity = capacity
|
|
347
|
+
|
|
348
|
+
def get(self, key: str):
|
|
349
|
+
if key not in self._cache: return None
|
|
350
|
+
self._cache.move_to_end(key)
|
|
351
|
+
return self._cache[key]
|
|
352
|
+
|
|
353
|
+
def put(self, key: str, value) -> None:
|
|
354
|
+
if key in self._cache: self._cache.move_to_end(key)
|
|
355
|
+
self._cache[key] = value
|
|
356
|
+
if len(self._cache) > self.capacity:
|
|
357
|
+
self._cache.popitem(last=False)
|
|
358
|
+
|
|
359
|
+
def invalidate(self) -> None:
|
|
360
|
+
self._cache.clear()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tagsearch
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: In-memory tag search engine for e-commerce (DAWG + BM25 + hybrid ranking)
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Provides-Extra: dev
|
|
7
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
8
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tagsearch/__init__.py
|
|
4
|
+
tagsearch/engine.py
|
|
5
|
+
tagsearch/models.py
|
|
6
|
+
tagsearch/scoring.py
|
|
7
|
+
tagsearch/structures.py
|
|
8
|
+
tagsearch.egg-info/PKG-INFO
|
|
9
|
+
tagsearch.egg-info/SOURCES.txt
|
|
10
|
+
tagsearch.egg-info/dependency_links.txt
|
|
11
|
+
tagsearch.egg-info/requires.txt
|
|
12
|
+
tagsearch.egg-info/top_level.txt
|
|
13
|
+
tests/test_cross_language.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tagsearch
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests cross-langage — source de vérité Python.
|
|
3
|
+
Charge shared-assets/catalog.json et génère les scores de référence.
|
|
4
|
+
Ces mêmes cas sont rejoués en TS (Vitest) et PHP (PHPUnit) avec tolérance 1e-9.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import pytest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from tagsearch import SearchEngine, SearchPreferences
|
|
13
|
+
from tagsearch.models import ProductType
|
|
14
|
+
from tagsearch.scoring import (
|
|
15
|
+
BM25Index, calculate_final_score,
|
|
16
|
+
BM25_K1, BM25_B, BM25_CAP, BM25_FLOOR,
|
|
17
|
+
QUALITY_EXPONENT, PRIOR_MEAN, PRIOR_WEIGHT,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
CATALOG_PATH = Path(__file__).parent.parent.parent / "shared-assets" / "catalog.json"
|
|
21
|
+
TOLERANCE = 1e-9
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(scope="module")
|
|
25
|
+
def engine() -> SearchEngine:
|
|
26
|
+
eng = SearchEngine(cache_size=50)
|
|
27
|
+
eng.load_catalog(CATALOG_PATH)
|
|
28
|
+
return eng
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── Tests unitaires BM25 (composants isolés) ────────────────────────────────
|
|
32
|
+
|
|
33
|
+
class TestBM25Components:
|
|
34
|
+
"""Valide chaque composant BM25 séparément pour le portage cross-langage."""
|
|
35
|
+
|
|
36
|
+
def test_idf_formula(self):
|
|
37
|
+
"""IDF = log((N - df + 0.5) / (df + 0.5) + 1)"""
|
|
38
|
+
N, df = 10, 3
|
|
39
|
+
expected = math.log((N - df + 0.5) / (df + 0.5) + 1)
|
|
40
|
+
assert abs(expected - math.log(7.5 / 3.5 + 1)) < TOLERANCE
|
|
41
|
+
|
|
42
|
+
def test_tf_norm_saturation(self):
|
|
43
|
+
"""TF normalisé sature : tf=10 ne vaut pas 10× tf=1."""
|
|
44
|
+
dl, avgdl = 5, 5.0
|
|
45
|
+
tf1 = 1 * (BM25_K1 + 1) / (1 + BM25_K1 * (1 - BM25_B + BM25_B * dl / avgdl))
|
|
46
|
+
tf10 = 10 * (BM25_K1 + 1) / (10 + BM25_K1 * (1 - BM25_B + BM25_B * dl / avgdl))
|
|
47
|
+
assert tf10 < tf1 * 10, "BM25 doit saturer la fréquence des termes"
|
|
48
|
+
|
|
49
|
+
def test_bm25_zero_on_missing_tag(self):
|
|
50
|
+
idx = BM25Index()
|
|
51
|
+
idx.index(1, ["gaming", "tech"])
|
|
52
|
+
assert idx.score(1, ["cuisine"]) == 0.0
|
|
53
|
+
|
|
54
|
+
def test_bm25_positive_on_matching_tag(self):
|
|
55
|
+
idx = BM25Index()
|
|
56
|
+
idx.index(1, ["gaming", "tech"])
|
|
57
|
+
idx.index(2, ["cuisine"])
|
|
58
|
+
score = idx.score(1, ["gaming"])
|
|
59
|
+
assert score > 0.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Tests scoring multidimensionnel ─────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
class TestScoringDimensions:
|
|
65
|
+
|
|
66
|
+
def _make_engine(self) -> SearchEngine:
|
|
67
|
+
eng = SearchEngine(cache_size=10)
|
|
68
|
+
eng.load_catalog(CATALOG_PATH)
|
|
69
|
+
return eng
|
|
70
|
+
|
|
71
|
+
def test_tc01_cuisine_physical_beats_digital(self, engine):
|
|
72
|
+
"""TC01 : Robot pâtissier (Physical) > E-book (Digital) sans filtre."""
|
|
73
|
+
results, _ = engine.search("cuisine", fuzzy=False)
|
|
74
|
+
ids = [r["id"] for r in results]
|
|
75
|
+
assert 4 in ids and 5 in ids
|
|
76
|
+
score_robot = next(r["score"] for r in results if r["id"] == 4)
|
|
77
|
+
score_ebook = next(r["score"] for r in results if r["id"] == 5)
|
|
78
|
+
assert score_robot > score_ebook
|
|
79
|
+
|
|
80
|
+
def test_tc02_digital_filter_inverts_order(self, engine):
|
|
81
|
+
"""TC02 : preferred_type=DIGITAL → E-book devant Robot."""
|
|
82
|
+
prefs = SearchPreferences(preferred_type=ProductType.DIGITAL)
|
|
83
|
+
results, _ = engine.search("cuisine", prefs=prefs, fuzzy=False)
|
|
84
|
+
ids = [r["id"] for r in results]
|
|
85
|
+
assert ids.index(5) < ids.index(4)
|
|
86
|
+
|
|
87
|
+
def test_tc03_price_filter_excludes_clavier(self, engine):
|
|
88
|
+
"""TC03 : budget 100€ → Clavier (129.99€) exclu."""
|
|
89
|
+
prefs = SearchPreferences(max_price=100.0)
|
|
90
|
+
results, _ = engine.search("gaming", prefs=prefs, fuzzy=False)
|
|
91
|
+
ids = [r["id"] for r in results]
|
|
92
|
+
assert 1 not in ids # Clavier exclu
|
|
93
|
+
assert 2 in ids # Souris présente
|
|
94
|
+
|
|
95
|
+
def test_tc04_min_rating_excludes_serum(self, engine):
|
|
96
|
+
"""TC04 : min_rating=4.0 → Sérum (2.8★) exclu."""
|
|
97
|
+
prefs = SearchPreferences(min_rating=4.0)
|
|
98
|
+
results, _ = engine.search("beauté", prefs=prefs, fuzzy=False)
|
|
99
|
+
ids = [r["id"] for r in results]
|
|
100
|
+
assert 7 not in ids # Sérum exclu
|
|
101
|
+
assert 8 in ids # Palette présente
|
|
102
|
+
|
|
103
|
+
def test_tc05_fuzzy_gaming_typo(self, engine):
|
|
104
|
+
"""TC05 : 'gaiming' → résultats gaming via fuzzy."""
|
|
105
|
+
results, _ = engine.search("gaiming", fuzzy=True, max_dist=1)
|
|
106
|
+
niches = {r["niche"] for r in results}
|
|
107
|
+
assert "gaming" in niches
|
|
108
|
+
|
|
109
|
+
def test_bayesian_rating_smoothing(self, engine):
|
|
110
|
+
"""Produit avec peu d'avis est tiré vers la moyenne marché."""
|
|
111
|
+
serum = engine.catalog[7] # 2.8★, 12 avis
|
|
112
|
+
palette = engine.catalog[8] # 4.5★, 276 avis
|
|
113
|
+
br_serum = (serum.review_count * serum.avg_rating + PRIOR_MEAN * PRIOR_WEIGHT) / (serum.review_count + PRIOR_WEIGHT)
|
|
114
|
+
br_palette = (palette.review_count * palette.avg_rating + PRIOR_MEAN * PRIOR_WEIGHT) / (palette.review_count + PRIOR_WEIGHT)
|
|
115
|
+
# Le sérum est tiré vers 3.5 (PRIOR_MEAN), la palette reste proche de 4.5
|
|
116
|
+
assert br_serum > serum.avg_rating # Tiré vers le haut (était très bas)
|
|
117
|
+
assert br_palette < palette.avg_rating # Légèrement tiré vers le bas
|
|
118
|
+
assert br_palette > br_serum
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── Génération des scores de référence (cross-language fixture) ──────────────
|
|
122
|
+
|
|
123
|
+
class TestCrossLanguageFixtures:
|
|
124
|
+
"""
|
|
125
|
+
Génère et valide les scores de référence pour TS et PHP.
|
|
126
|
+
Ces scores sont la source de vérité : les autres langages doivent
|
|
127
|
+
tomber dans |score_ref - score_impl| < TOLERANCE (1e-9).
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def test_reference_scores_stable(self, engine):
|
|
131
|
+
"""Les scores de référence ne doivent pas changer entre runs."""
|
|
132
|
+
results, _ = engine.search("cuisine", fuzzy=False)
|
|
133
|
+
scores = {r["id"]: r["score"] for r in results}
|
|
134
|
+
# Scores attendus (calculés lors de la validation Phase 2)
|
|
135
|
+
assert abs(scores[4] - 0.1354) < 1e-3 # Robot pâtissier
|
|
136
|
+
assert abs(scores[5] - 0.1082) < 1e-3 # E-book
|
|
137
|
+
|
|
138
|
+
def test_type_mismatch_factor(self, engine):
|
|
139
|
+
"""Le facteur TYPE_MISMATCH=0.1 doit diviser le score par ~10.
|
|
140
|
+
Tolérance 1e-3 : les scores sont arrondis à 4 décimales avant division,
|
|
141
|
+
ce qui introduit une erreur d'arrondi de l'ordre de 1e-4/score_base.
|
|
142
|
+
"""
|
|
143
|
+
results_no_filter, _ = engine.search("cuisine", fuzzy=False)
|
|
144
|
+
prefs = SearchPreferences(preferred_type=ProductType.DIGITAL)
|
|
145
|
+
results_digital, _ = engine.search("cuisine", prefs=prefs, fuzzy=False)
|
|
146
|
+
|
|
147
|
+
score_robot_base = next(r["score"] for r in results_no_filter if r["id"] == 4)
|
|
148
|
+
score_robot_digital = next(r["score"] for r in results_digital if r["id"] == 4)
|
|
149
|
+
ratio = score_robot_digital / score_robot_base
|
|
150
|
+
assert abs(ratio - 0.1) < 1e-3, f"Ratio attendu ~0.1, obtenu {ratio}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── Tests robustesse & gestion d'erreurs ────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
class TestRobustness:
|
|
156
|
+
|
|
157
|
+
def test_empty_query_raises(self, engine):
|
|
158
|
+
with pytest.raises(ValueError, match="vide"):
|
|
159
|
+
engine.search("")
|
|
160
|
+
|
|
161
|
+
def test_whitespace_query_raises(self, engine):
|
|
162
|
+
with pytest.raises(ValueError, match="vide"):
|
|
163
|
+
engine.search(" ")
|
|
164
|
+
|
|
165
|
+
def test_negative_max_dist_raises(self, engine):
|
|
166
|
+
with pytest.raises(ValueError, match="max_dist"):
|
|
167
|
+
engine.search("gaming", max_dist=-1)
|
|
168
|
+
|
|
169
|
+
def test_unknown_query_returns_miss(self, engine):
|
|
170
|
+
results, status = engine.search("xyzqwerty123", fuzzy=False)
|
|
171
|
+
assert results == []
|
|
172
|
+
assert status == "MISS"
|
|
173
|
+
|
|
174
|
+
def test_cache_hit_on_repeat(self, engine):
|
|
175
|
+
engine.search("yoga", fuzzy=False)
|
|
176
|
+
_, status = engine.search("yoga", fuzzy=False)
|
|
177
|
+
assert status == "CACHÉ"
|
|
178
|
+
|
|
179
|
+
def test_load_nonexistent_file_raises(self):
|
|
180
|
+
eng = SearchEngine()
|
|
181
|
+
with pytest.raises(FileNotFoundError):
|
|
182
|
+
eng.load_catalog("/nonexistent/path/catalog.json")
|
|
183
|
+
|
|
184
|
+
def test_invalid_rating_raises(self):
|
|
185
|
+
from tagsearch.models import ProductMeta, ProductType
|
|
186
|
+
with pytest.raises(ValueError, match="avg_rating"):
|
|
187
|
+
ProductMeta("Test", ProductType.PHYSICAL, "gaming", 10.0, None, 6.0, 10, ["tag"])
|