YohanAcxias commited on
Commit
d70f155
·
verified ·
1 Parent(s): 9a73164

Update handler.py

Browse files
Files changed (1) hide show
  1. handler.py +413 -118
handler.py CHANGED
@@ -1,6 +1,9 @@
1
  import os
2
  import json
3
- from typing import Any, Dict, List, Optional
 
 
 
4
 
5
  import numpy as np
6
  import pandas as pd
@@ -8,23 +11,149 @@ import pandas as pd
8
  import torch
9
  from transformers import AutoTokenizer, AutoModelForSequenceClassification
10
 
11
- # Optional dependencies (kept in requirements.txt)
12
  import faiss
13
  from sentence_transformers import SentenceTransformer, CrossEncoder
14
 
15
 
16
- def softmax(x: np.ndarray) -> np.ndarray:
 
 
 
17
  x = x - np.max(x)
18
  e = np.exp(x)
19
  return e / np.sum(e)
20
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  class EndpointHandler:
23
  """
24
- Hugging Face Inference Endpoint Custom Handler
25
- - Loads supervised classifier from ./supervised_final
26
- - Loads FAISS index + label table for semantic fallback
27
- - Optionally loads a reranker (cross-encoder)
 
 
 
 
28
  """
29
 
30
  def __init__(self, path: str = ""):
@@ -33,63 +162,81 @@ class EndpointHandler:
33
  # ---- 1) Supervised classifier ----
34
  self.sup_dir = os.path.join(path, "supervised_final")
35
  self.tokenizer = AutoTokenizer.from_pretrained(self.sup_dir, use_fast=True)
36
- self.model = AutoModelForSequenceClassification.from_pretrained(self.sup_dir)
37
- self.model.to(self.device)
38
  self.model.eval()
39
 
40
- # id2label is often included in config; keep your json too as ground truth
41
  id2label_path = os.path.join(self.sup_dir, "id2label.json")
42
  if os.path.exists(id2label_path):
43
  with open(id2label_path, "r", encoding="utf-8") as f:
44
  self.id2label = {int(k): v for k, v in json.load(f).items()}
45
  else:
46
- # fallback to config mapping if json missing
47
  self.id2label = {int(k): v for k, v in self.model.config.id2label.items()}
48
 
49
- # ---- 2) Semantic retrieval (FAISS) ----
50
- self.faiss_index_path = os.path.join(path, "faiss_labels.index")
51
- self.label_table_path = os.path.join(path, "label_table.csv")
52
-
53
- if os.path.exists(self.faiss_index_path) and os.path.exists(self.label_table_path):
54
- self.index = faiss.read_index(self.faiss_index_path)
55
- self.label_table = pd.read_csv(self.label_table_path)
56
-
57
- # Expectation: label_table has at least one column that maps FAISS row -> label
58
- # Common patterns: ["label_id"], ["unspsc"], ["code"], etc.
59
- # We'll build a robust resolver:
60
- self.label_cols = [c.lower() for c in self.label_table.columns]
61
- else:
62
- self.index = None
63
- self.label_table = None
64
- self.label_cols = []
65
-
66
- # ---- 3) Bi-encoder model for embedding the query ----
67
- biencoder_name_path = os.path.join(path, "biencoder_model_name.txt")
68
- self.biencoder_name = None
69
  self.biencoder = None
 
 
70
  if os.path.exists(biencoder_name_path):
71
  with open(biencoder_name_path, "r", encoding="utf-8") as f:
72
  self.biencoder_name = f.read().strip()
73
  if self.biencoder_name:
74
  self.biencoder = SentenceTransformer(self.biencoder_name, device=self.device)
75
 
76
- # ---- 4) Optional cross-encoder reranker ----
77
- rerank_name_path = os.path.join(path, "rerank_model_name.txt")
78
- self.rerank_name = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  self.reranker = None
 
 
80
  if os.path.exists(rerank_name_path):
81
  with open(rerank_name_path, "r", encoding="utf-8") as f:
82
  self.rerank_name = f.read().strip()
83
  if self.rerank_name:
84
  self.reranker = CrossEncoder(self.rerank_name, device=self.device)
85
 
86
- # ---------- Helpers ----------
87
- def _label_from_table_row(self, row: pd.Series) -> str:
88
- # FAISS row i corresponds to one label/code
89
- return str(row["s4_code"])
 
 
 
 
90
 
 
 
 
 
 
 
 
91
 
92
- def _supervised_topk(self, text: str, top_k: int) -> List[Dict[str, Any]]:
 
 
 
93
  inputs = self.tokenizer(
94
  text,
95
  truncation=True,
@@ -101,127 +248,275 @@ class EndpointHandler:
101
  with torch.no_grad():
102
  logits = self.model(**inputs).logits[0].detach().cpu().numpy()
103
 
104
- probs = softmax(logits)
105
  idx = np.argsort(-probs)[:top_k]
106
 
107
  out = []
108
  for i in idx:
109
- out.append(
110
- {
111
- "label_id": int(i),
112
- "label": self.id2label.get(int(i), str(i)),
113
- "score": float(probs[i]),
114
- }
115
- )
116
  return out
117
 
118
- def _semantic_topk(self, text: str, top_k: int) -> List[Dict[str, Any]]:
119
- if self.index is None or self.label_table is None or self.biencoder is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  return []
121
 
122
- emb = self.biencoder.encode([text], normalize_embeddings=True)
 
 
 
 
 
 
123
  emb = np.asarray(emb, dtype=np.float32)
124
 
125
- scores, idx = self.index.search(emb, top_k) # scores: (1,k), idx: (1,k)
126
  scores = scores[0].tolist()
127
  idx = idx[0].tolist()
128
 
129
  out = []
130
  for s, i in zip(scores, idx):
131
- if i < 0 or i >= len(self.label_table):
132
  continue
133
- row = self.label_table.iloc[i]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  out.append(
135
  {
 
136
  "faiss_id": int(i),
137
  "s4_code": str(row["s4_code"]),
138
- "label_text": str(row["label_text"]),
 
139
  "score": float(s),
 
 
140
  }
141
  )
142
-
143
  return out
144
 
145
- def _rerank(self, query: str, candidates: List[Dict[str, Any]], top_k: int) -> List[Dict[str, Any]]:
146
- """
147
- Rerank semantic candidates using cross-encoder if available.
148
- Assumes candidate["label"] is a text representation.
149
- """
150
- if self.reranker is None or not candidates:
151
- return candidates[:top_k]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
- pairs = [[query, c["label_text"]] for c in candidates]
154
- scores = self.reranker.predict(pairs).tolist()
 
155
 
156
- merged = []
157
- for c, s in zip(candidates, scores):
158
- cc = dict(c)
159
- cc["rerank_score"] = float(s)
160
- merged.append(cc)
161
 
162
- merged.sort(key=lambda x: x.get("rerank_score", -1e9), reverse=True)
163
- return merged[:top_k]
 
 
164
 
165
- # ---------- Main entrypoint ----------
 
 
 
 
 
 
 
 
 
 
 
166
  def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]:
167
- """
168
- Expected input payload examples:
169
- {"inputs": "desc...", "top_k": 20, "threshold": 0.80}
170
- {"inputs": ["desc1", "desc2"], "top_k": 20}
171
- """
172
  inputs = data.get("inputs")
173
  if inputs is None:
174
  return {"error": "Missing 'inputs'."}
175
 
176
  top_k = int(data.get("top_k", 20))
177
- threshold = float(data.get("threshold", 0.80)) # you asked 80% as prod-ready gate
 
178
  use_rerank = bool(data.get("use_rerank", True))
179
 
 
 
 
180
  if isinstance(inputs, str):
181
- return self._predict_one(inputs, top_k=top_k, threshold=threshold, use_rerank=use_rerank)
182
- elif isinstance(inputs, list):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  return {
184
- "results": [
185
- self._predict_one(x, top_k=top_k, threshold=threshold, use_rerank=use_rerank)
186
- for x in inputs
187
- ]
 
 
 
 
188
  }
189
- else:
190
- return {"error": "'inputs' must be a string or a list of strings."}
191
-
192
- def _predict_one(self, text: str, top_k: int, threshold: float, use_rerank: bool) -> Dict[str, Any]:
193
- sup = self._supervised_topk(text, top_k=top_k)
194
- sem = self._semantic_topk(text, top_k=top_k)
195
 
196
- if use_rerank and sem:
197
- sem = self._rerank(text, sem, top_k=top_k)
198
-
199
- sup_labels = [x["label"] for x in sup]
200
- sem_set = set(x["s4_code"] for x in sem)
201
-
202
- # 1) If supervised top1 already high confidence -> return it directly
203
- if sup and sup[0]["score"] >= threshold:
204
- chosen = sup[0]
205
- chosen_method = "supervised_threshold"
206
- else:
207
- # 2) Intersection rule: pick first supervised label that appears in semantic topK
208
- chosen = None
209
- for cand in sup:
210
- if cand["label"] in sem_set:
211
- chosen = cand
212
- break
213
-
214
- if chosen is not None:
215
- chosen_method = "intersection"
216
- else:
217
- # 3) Fallback: if no intersection, keep supervised top1 (or semantic top1 if you prefer)
218
- chosen = sup[0] if sup else ({"label": sem[0]["label"], "score": sem[0]["score"]} if sem else None)
219
- chosen_method = "supervised_no_intersection" if sup else "semantic_only"
 
 
 
 
 
 
 
 
 
220
 
221
  return {
222
  "input": text,
223
- "prediction": chosen,
224
- "method": chosen_method,
225
- "supervised_topk": sup[:10], # keep it light; raise if you want
226
- "semantic_topk": sem[:10],
 
 
 
227
  }
 
1
  import os
2
  import json
3
+ import re
4
+ import unicodedata
5
+ from typing import Any, Dict, List, Optional, Tuple
6
+ from collections import defaultdict
7
 
8
  import numpy as np
9
  import pandas as pd
 
11
  import torch
12
  from transformers import AutoTokenizer, AutoModelForSequenceClassification
13
 
 
14
  import faiss
15
  from sentence_transformers import SentenceTransformer, CrossEncoder
16
 
17
 
18
+ # -----------------------------
19
+ # Basic math utils
20
+ # -----------------------------
21
+ def softmax_1d(x: np.ndarray) -> np.ndarray:
22
  x = x - np.max(x)
23
  e = np.exp(x)
24
  return e / np.sum(e)
25
 
26
 
27
+ def norm_cos_to_01(cos_sim: float) -> float:
28
+ # For IndexFlatIP + normalized vectors => cosine in [-1,1], map to [0,1]
29
+ return float((cos_sim + 1.0) / 2.0)
30
+
31
+
32
+ def minmax_norm(vals: List[float]) -> List[float]:
33
+ if not vals:
34
+ return vals
35
+ vmin, vmax = min(vals), max(vals)
36
+ if abs(vmax - vmin) < 1e-12:
37
+ return [0.0 for _ in vals]
38
+ return [(v - vmin) / (vmax - vmin) for v in vals]
39
+
40
+
41
+ # ============================================================
42
+ # Cleaning the request
43
+ # ============================================================
44
+ def _strip_after_first_bar(s: str) -> str:
45
+ """
46
+ Keep only what is after the first '|', if any.
47
+ Example:
48
+ "uom: UN | Fourniture ...\n..." -> "Fourniture ... ..."
49
+ """
50
+ if not s or not isinstance(s, str):
51
+ return ""
52
+ s = s.replace("\r", " ").replace("\n", " ")
53
+ if "|" in s:
54
+ s = s.split("|", 1)[1]
55
+ s = re.sub(r"\s+", " ", s).strip()
56
+ return s
57
+
58
+
59
+ def fix_encoding(text: str) -> str:
60
+ """
61
+ Best-effort fixer for common mojibake: "associés" -> "associés"
62
+ Works when UTF-8 bytes were decoded as latin1/cp1252.
63
+ """
64
+ if not text or not isinstance(text, str):
65
+ return ""
66
+
67
+ # Fast exit: if looks clean, keep
68
+ # (but still normalize later in clean_custom_code_name)
69
+ t = text
70
+
71
+ # Try latin1 -> utf8
72
+ try:
73
+ t2 = t.encode("latin1", errors="strict").decode("utf-8", errors="strict")
74
+ # Accept only if it actually improves typical patterns
75
+ if "Ã" in t or "�" in t:
76
+ return t2
77
+ except Exception:
78
+ pass
79
+
80
+ # Try cp1252 -> utf8
81
+ try:
82
+ t2 = t.encode("cp1252", errors="strict").decode("utf-8", errors="strict")
83
+ if "Ã" in t or "�" in t:
84
+ return t2
85
+ except Exception:
86
+ pass
87
+
88
+ return t
89
+
90
+
91
+ def clean_custom_code_name(name: str) -> str:
92
+ """
93
+ Cleans a custom code / label text to improve semantic matching.
94
+
95
+ Examples:
96
+ "NPO - 1615 - IT group services re-billing (0006111100)"
97
+ -> "IT group services re-billing"
98
+
99
+ "Fees frais de deplacement associés (0006228000)"
100
+ -> "Fees frais de deplacement associés"
101
+
102
+ "Reception (0006257000)"
103
+ -> "Reception"
104
+
105
+ "1403 - Maintenance matériel informatique (ordinateurs, PC)"
106
+ -> "Maintenance matériel informatique ordinateurs PC"
107
+ """
108
+ if not name or not isinstance(name, str):
109
+ return ""
110
+
111
+ # 1) Fix broken encoding
112
+ name = fix_encoding(name)
113
+
114
+ # 2) Remove numeric codes in parentheses: (0006111100)
115
+ name = re.sub(r"\(\d{5,}\)", "", name)
116
+
117
+ # 3) Remove prefixes like "NPO - 1615 -"
118
+ name = re.sub(r"^NPO\s*-\s*\d+\s*-\s*", "", name, flags=re.IGNORECASE)
119
+
120
+ # 4) Remove numeric prefixes like "1403 -" or "1403 :"
121
+ name = re.sub(r"^\d+\s*[-:]\s*", "", name)
122
+
123
+ # 5) Remove parentheses but keep content
124
+ name = re.sub(r"[()]", " ", name)
125
+
126
+ # 6) Remove square brackets but keep content
127
+ name = re.sub(r"[\[\]]", " ", name)
128
+
129
+ # 7) Remove noisy punctuation (keep letters/digits/spaces)
130
+ name = re.sub(r"[,;/\\|#@&*=+]", " ", name)
131
+
132
+ # Optional: normalize hyphens/long dashes as spaces (helps match)
133
+ name = re.sub(r"[-–—]", " ", name)
134
+
135
+ # 8) Normalize spaces
136
+ name = re.sub(r"\s+", " ", name).strip()
137
+
138
+ # 9) Unicode normalize
139
+ name = unicodedata.normalize("NFC", name)
140
+
141
+ return name
142
+
143
+
144
+ # ============================================================
145
+ # Handler
146
+ # ============================================================
147
  class EndpointHandler:
148
  """
149
+ HF Inference Endpoint Custom Handler (Dual Semantic + Cleaners)
150
+
151
+ Artifacts expected in repo root:
152
+ - supervised_final/ (HF transformers classifier)
153
+ - faiss_labels.index + label_table.csv (semantic labels)
154
+ - faiss_catalog_articles.index + catalog_articles_table.csv (semantic catalog)
155
+ - biencoder_model_name.txt
156
+ - optional: rerank_model_name.txt
157
  """
158
 
159
  def __init__(self, path: str = ""):
 
162
  # ---- 1) Supervised classifier ----
163
  self.sup_dir = os.path.join(path, "supervised_final")
164
  self.tokenizer = AutoTokenizer.from_pretrained(self.sup_dir, use_fast=True)
165
+ self.model = AutoModelForSequenceClassification.from_pretrained(self.sup_dir).to(self.device)
 
166
  self.model.eval()
167
 
 
168
  id2label_path = os.path.join(self.sup_dir, "id2label.json")
169
  if os.path.exists(id2label_path):
170
  with open(id2label_path, "r", encoding="utf-8") as f:
171
  self.id2label = {int(k): v for k, v in json.load(f).items()}
172
  else:
 
173
  self.id2label = {int(k): v for k, v in self.model.config.id2label.items()}
174
 
175
+ # ---- 2) Bi-encoder ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  self.biencoder = None
177
+ self.biencoder_name = None
178
+ biencoder_name_path = os.path.join(path, "biencoder_model_name.txt")
179
  if os.path.exists(biencoder_name_path):
180
  with open(biencoder_name_path, "r", encoding="utf-8") as f:
181
  self.biencoder_name = f.read().strip()
182
  if self.biencoder_name:
183
  self.biencoder = SentenceTransformer(self.biencoder_name, device=self.device)
184
 
185
+ # ---- 3) Semantic #1: label FAISS ----
186
+ self.seg_index = None
187
+ self.seg_table = None
188
+ self.seg_text_col = None
189
+
190
+ seg_index_path = os.path.join(path, "faiss_labels.index")
191
+ seg_table_path = os.path.join(path, "label_table.csv")
192
+ if os.path.exists(seg_index_path) and os.path.exists(seg_table_path):
193
+ self.seg_index = faiss.read_index(seg_index_path)
194
+ self.seg_table = pd.read_csv(seg_table_path)
195
+ self.seg_text_col = "label_text" if "label_text" in self.seg_table.columns else None
196
+
197
+ # ---- 4) Semantic #2: catalog FAISS ----
198
+ self.cat_index = None
199
+ self.cat_table = None
200
+ self.cat_text_col = None
201
+
202
+ cat_index_path = os.path.join(path, "faiss_catalog_articles.index")
203
+ cat_table_path = os.path.join(path, "catalog_articles_table.csv")
204
+ if os.path.exists(cat_index_path) and os.path.exists(cat_table_path):
205
+ self.cat_index = faiss.read_index(cat_index_path)
206
+ self.cat_table = pd.read_csv(cat_table_path)
207
+ self.cat_text_col = "doc_text" if "doc_text" in self.cat_table.columns else None
208
+
209
+ # ---- 5) Optional reranker ----
210
  self.reranker = None
211
+ self.rerank_name = None
212
+ rerank_name_path = os.path.join(path, "rerank_model_name.txt")
213
  if os.path.exists(rerank_name_path):
214
  with open(rerank_name_path, "r", encoding="utf-8") as f:
215
  self.rerank_name = f.read().strip()
216
  if self.rerank_name:
217
  self.reranker = CrossEncoder(self.rerank_name, device=self.device)
218
 
219
+ # -----------------------------
220
+ # Query formatting
221
+ # -----------------------------
222
+ def _make_query_text(self, text: str, uom: Optional[str] = None) -> str:
223
+ # classifier-friendly format (keep UOM)
224
+ t = (text or "").strip()
225
+ u = (uom or "UN").strip().upper()
226
+ return f"[UOM={u}] {t}".strip()
227
 
228
+ def _make_semantic_query(self, raw_text: str) -> str:
229
+ """
230
+ Semantic input cleaner (both semantics):
231
+ - keep only what is after first '|'
232
+ - replace newlines by spaces
233
+ """
234
+ return _strip_after_first_bar(raw_text)
235
 
236
+ # -----------------------------
237
+ # Supervised topK
238
+ # -----------------------------
239
+ def _supervised_topk(self, text: str, top_k: int = 20) -> List[Dict[str, Any]]:
240
  inputs = self.tokenizer(
241
  text,
242
  truncation=True,
 
248
  with torch.no_grad():
249
  logits = self.model(**inputs).logits[0].detach().cpu().numpy()
250
 
251
+ probs = softmax_1d(logits)
252
  idx = np.argsort(-probs)[:top_k]
253
 
254
  out = []
255
  for i in idx:
256
+ s4 = self.id2label.get(int(i), str(i))
257
+ out.append({"label_id": int(i), "s4_code": str(s4), "score": float(probs[i])})
 
 
 
 
 
258
  return out
259
 
260
+ # -----------------------------
261
+ # Semantic retrieval
262
+ # -----------------------------
263
+ def _semantic_topk(
264
+ self,
265
+ index: Optional[faiss.Index],
266
+ table: Optional[pd.DataFrame],
267
+ text_col: Optional[str],
268
+ raw_query: str,
269
+ top_k: int = 50,
270
+ source: str = "semantic",
271
+ clean_label_text: bool = False,
272
+ ) -> List[Dict[str, Any]]:
273
+ if index is None or table is None or self.biencoder is None:
274
+ return []
275
+ if "s4_code" not in table.columns:
276
  return []
277
 
278
+ # Clean semantic query for both semantics
279
+ query = self._make_semantic_query(raw_query)
280
+ if not query:
281
+ # fallback to raw query without bar stripping if empty
282
+ query = re.sub(r"\s+", " ", (raw_query or "").replace("\n", " ").replace("\r", " ")).strip()
283
+
284
+ emb = self.biencoder.encode([query], normalize_embeddings=True)
285
  emb = np.asarray(emb, dtype=np.float32)
286
 
287
+ scores, idx = index.search(emb, top_k)
288
  scores = scores[0].tolist()
289
  idx = idx[0].tolist()
290
 
291
  out = []
292
  for s, i in zip(scores, idx):
293
+ if i < 0 or i >= len(table):
294
  continue
295
+ row = table.iloc[i]
296
+
297
+ txt = None
298
+ if text_col and text_col in table.columns:
299
+ txt = str(row[text_col])
300
+
301
+ # Extra cleaning for semantic label candidate texts
302
+ txt_clean = None
303
+ if txt is not None:
304
+ txt_one_line = txt.replace("\r", " ").replace("\n", " ")
305
+ if source == "semantic_label":
306
+ txt_clean = clean_custom_code_name(txt_one_line) if clean_label_text else txt_one_line
307
+ else:
308
+ # catalog: at least strip after bar + newline
309
+ txt_clean = _strip_after_first_bar(txt_one_line)
310
+
311
  out.append(
312
  {
313
+ "source": source,
314
  "faiss_id": int(i),
315
  "s4_code": str(row["s4_code"]),
316
+ "label_text": txt,
317
+ "label_text_clean": txt_clean,
318
  "score": float(s),
319
+ "score_01": norm_cos_to_01(float(s)),
320
+ "semantic_query": query,
321
  }
322
  )
 
323
  return out
324
 
325
+ # -----------------------------
326
+ # Fusion + optional rerank
327
+ # -----------------------------
328
+ def _fuse_and_choose(
329
+ self,
330
+ raw_query: str,
331
+ sup: List[Dict[str, Any]],
332
+ seg: List[Dict[str, Any]],
333
+ cat: List[Dict[str, Any]],
334
+ use_rerank: bool,
335
+ weights: Tuple[float, float, float, float] = (1.0, 0.6, 0.8, 0.5),
336
+ rerank_topn: int = 40,
337
+ debug_topn: int = 10,
338
+ ) -> Dict[str, Any]:
339
+ w_sup, w_seg, w_cat, w_rerank = weights
340
+
341
+ # maps
342
+ sup_map = {x["s4_code"]: float(x["score"]) for x in sup}
343
+ seg_map = defaultdict(float)
344
+ cat_map = defaultdict(float)
345
+
346
+ # candidate texts for rerank (prefer cleaned)
347
+ text_map = {}
348
+
349
+ for x in seg:
350
+ code = x["s4_code"]
351
+ seg_map[code] = max(seg_map[code], float(x.get("score_01", 0.0)))
352
+ txt = x.get("label_text_clean") or x.get("label_text") or code
353
+ text_map[code] = txt
354
+
355
+ for x in cat:
356
+ code = x["s4_code"]
357
+ cat_map[code] = max(cat_map[code], float(x.get("score_01", 0.0)))
358
+ txt = x.get("label_text_clean") or x.get("label_text") or code
359
+ # keep existing if seg provided (often better), else catalog
360
+ text_map.setdefault(code, txt)
361
+
362
+ candidates = set(sup_map.keys()) | set(seg_map.keys()) | set(cat_map.keys())
363
+ if not candidates:
364
+ return {"prediction": None, "method": "no_candidates", "fused_topk": []}
365
+
366
+ fused = []
367
+ for code in candidates:
368
+ s_sup = sup_map.get(code, 0.0)
369
+ s_seg = seg_map.get(code, 0.0)
370
+ s_cat = cat_map.get(code, 0.0)
371
+
372
+ base = (w_sup * s_sup) + (w_seg * s_seg) + (w_cat * s_cat)
373
+ fused.append(
374
+ {
375
+ "s4_code": code,
376
+ "score": float(base),
377
+ "components": {"sup": float(s_sup), "seg": float(s_seg), "cat": float(s_cat)},
378
+ "text_for_rerank": text_map.get(code, code),
379
+ }
380
+ )
381
+
382
+ fused.sort(key=lambda x: x["score"], reverse=True)
383
 
384
+ # semantic query cleaning also for rerank query side
385
+ rerank_query = self._make_semantic_query(raw_query) or raw_query.replace("\n", " ").replace("\r", " ")
386
+ rerank_query = re.sub(r"\s+", " ", rerank_query).strip()
387
 
388
+ if use_rerank and self.reranker is not None and fused:
389
+ topn = fused[: min(rerank_topn, len(fused))]
390
+ pairs = [[rerank_query, c["text_for_rerank"]] for c in topn]
391
+ rr_raw = self.reranker.predict(pairs).tolist()
392
+ rr_01 = minmax_norm([float(v) for v in rr_raw])
393
 
394
+ for c, rraw, r01 in zip(topn, rr_raw, rr_01):
395
+ c["rerank_raw"] = float(rraw)
396
+ c["rerank_01"] = float(r01)
397
+ c["score"] = float(c["score"] + w_rerank * float(r01))
398
 
399
+ fused[: len(topn)] = topn
400
+ fused.sort(key=lambda x: x["score"], reverse=True)
401
+
402
+ return {
403
+ "prediction": fused[0],
404
+ "method": "fused_score",
405
+ "fused_topk": fused[:debug_topn],
406
+ }
407
+
408
+ # -----------------------------
409
+ # HF entrypoint
410
+ # -----------------------------
411
  def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]:
 
 
 
 
 
412
  inputs = data.get("inputs")
413
  if inputs is None:
414
  return {"error": "Missing 'inputs'."}
415
 
416
  top_k = int(data.get("top_k", 20))
417
+ sem_top_k = int(data.get("sem_top_k", 50))
418
+ threshold = float(data.get("threshold", 0.80)) # supervised gate
419
  use_rerank = bool(data.get("use_rerank", True))
420
 
421
+ # optional global uom
422
+ global_uom = data.get("uom") or data.get("CODE_UNITE") or data.get("code_unite")
423
+
424
  if isinstance(inputs, str):
425
+ return self._predict_one(inputs, uom=global_uom, top_k=top_k, sem_top_k=sem_top_k,
426
+ threshold=threshold, use_rerank=use_rerank)
427
+
428
+ if isinstance(inputs, dict):
429
+ text = inputs.get("text") or inputs.get("description") or inputs.get("inputs") or ""
430
+ uom = inputs.get("uom") or inputs.get("CODE_UNITE") or inputs.get("code_unite") or global_uom
431
+ return self._predict_one(text, uom=uom, top_k=top_k, sem_top_k=sem_top_k,
432
+ threshold=threshold, use_rerank=use_rerank)
433
+
434
+ if isinstance(inputs, list):
435
+ results = []
436
+ for x in inputs:
437
+ if isinstance(x, str):
438
+ results.append(self._predict_one(x, uom=global_uom, top_k=top_k, sem_top_k=sem_top_k,
439
+ threshold=threshold, use_rerank=use_rerank))
440
+ elif isinstance(x, dict):
441
+ text = x.get("text") or x.get("description") or x.get("inputs") or ""
442
+ uom = x.get("uom") or x.get("CODE_UNITE") or x.get("code_unite") or global_uom
443
+ results.append(self._predict_one(text, uom=uom, top_k=top_k, sem_top_k=sem_top_k,
444
+ threshold=threshold, use_rerank=use_rerank))
445
+ else:
446
+ results.append({"error": "Each item in inputs list must be str or dict."})
447
+ return {"results": results}
448
+
449
+ return {"error": "'inputs' must be a string, dict, or a list of strings/dicts."}
450
+
451
+ def _predict_one(
452
+ self,
453
+ text: str,
454
+ uom: Optional[str],
455
+ top_k: int,
456
+ sem_top_k: int,
457
+ threshold: float,
458
+ use_rerank: bool,
459
+ ) -> Dict[str, Any]:
460
+ # classifier query keeps UOM (good for supervised)
461
+ clf_query = self._make_query_text(text, uom=uom)
462
+
463
+ # supervised
464
+ sup = self._supervised_topk(clf_query, top_k=top_k)
465
+
466
+ # fast return if high confidence
467
+ if sup and sup[0]["score"] >= threshold:
468
  return {
469
+ "input": text,
470
+ "uom": uom or "UN",
471
+ "prediction": {"s4_code": sup[0]["s4_code"], "score": float(sup[0]["score"]), "components": {"sup": float(sup[0]["score"]), "seg": 0.0, "cat": 0.0}},
472
+ "method": "supervised_threshold",
473
+ "supervised_topk": sup[:10],
474
+ "semantic_label_topk": [],
475
+ "semantic_catalog_topk": [],
476
+ "fused_topk": [],
477
  }
 
 
 
 
 
 
478
 
479
+
480
+ raw_sem_query = text if isinstance(text, str) else str(text)
481
+
482
+ seg = self._semantic_topk(
483
+ index=self.seg_index,
484
+ table=self.seg_table,
485
+ text_col=self.seg_text_col,
486
+ raw_query=raw_sem_query,
487
+ top_k=sem_top_k,
488
+ source="semantic_label",
489
+ clean_label_text=True, # deeper cleaning for semantic label
490
+ )
491
+
492
+ cat = self._semantic_topk(
493
+ index=self.cat_index,
494
+ table=self.cat_table,
495
+ text_col=self.cat_text_col,
496
+ raw_query=raw_sem_query,
497
+ top_k=sem_top_k,
498
+ source="semantic_catalog",
499
+ clean_label_text=False,
500
+ )
501
+
502
+ fused = self._fuse_and_choose(
503
+ raw_query=raw_sem_query,
504
+ sup=sup,
505
+ seg=seg,
506
+ cat=cat,
507
+ use_rerank=use_rerank,
508
+ weights=(1.0, 0.6, 0.8, 0.5),
509
+ rerank_topn=40,
510
+ debug_topn=10,
511
+ )
512
 
513
  return {
514
  "input": text,
515
+ "uom": uom or "UN",
516
+ "prediction": fused.get("prediction"),
517
+ "method": fused.get("method"),
518
+ "supervised_topk": sup[:10],
519
+ "semantic_label_topk": seg[:10],
520
+ "semantic_catalog_topk": cat[:10],
521
+ "fused_topk": fused.get("fused_topk", []),
522
  }