crazycrazypete commited on
Commit
d410642
·
verified ·
1 Parent(s): 5497314

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. Updates/app2.py +751 -0
  2. app.py +126 -25
Updates/app2.py ADDED
@@ -0,0 +1,751 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import math
5
+ import hashlib
6
+ import tempfile
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, date
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+ import fitz # PyMuPDF
15
+ import faiss
16
+ from sentence_transformers import SentenceTransformer
17
+ from rapidfuzz import fuzz, process
18
+
19
+ import gradio as gr
20
+ from openai import OpenAI
21
+
22
+
23
+ # ============================
24
+ # Settings
25
+ # ============================
26
+ TODAY = date(2026, 1, 18)
27
+ OPENAI_MODEL = "gpt-5.2"
28
+ OPENAI_REASONING = {"effort": "high"}
29
+ MATCH_OK = 80
30
+
31
+ EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
32
+ PARSEC_CONTEXT_BEFORE = 900
33
+ PARSEC_CONTEXT_AFTER = 1600
34
+
35
+
36
+ # ============================
37
+ # OpenAI client (HF Space secret: OPENAI_API_KEY)
38
+ # ============================
39
+ API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
40
+ client = OpenAI(api_key=API_KEY) if API_KEY else None
41
+
42
+ # ----------------------------
43
+ # Gradio state helpers
44
+ # Keep state as a JSON STRING to avoid schema issues on Hugging Face.
45
+ # ----------------------------
46
+ def state_load(st_json: str) -> Dict[str, Any]:
47
+ try:
48
+ if not st_json:
49
+ return {}
50
+ return json.loads(st_json) if isinstance(st_json, str) else {}
51
+ except Exception:
52
+ return {}
53
+
54
+ def state_dump(st: Dict[str, Any]) -> str:
55
+ try:
56
+ return json.dumps(st or {}, ensure_ascii=False)
57
+ except Exception:
58
+ return "{}"
59
+
60
+
61
+
62
+ # ============================
63
+ # Helpers
64
+ # ============================
65
+ def norm_text(s: Any) -> str:
66
+ try:
67
+ if s is None or (isinstance(s, float) and math.isnan(s)) or pd.isna(s):
68
+ return ""
69
+ except Exception:
70
+ pass
71
+ s = str(s).strip().lower()
72
+ s = re.sub(r"[^a-z0-9\s\-\/]", " ", s)
73
+ s = re.sub(r"\s+", " ", s).strip()
74
+ return s
75
+
76
+ def safe_str(v: Any) -> str:
77
+ if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):
78
+ return ""
79
+ return str(v).strip()
80
+
81
+ def is_5g(modem_type: Any) -> bool:
82
+ s = norm_text(modem_type)
83
+ return ("5g" in s) or ("nr" in s)
84
+
85
+ def json_load_safe(s: str) -> Dict[str, Any]:
86
+ try:
87
+ return json.loads(s)
88
+ except Exception:
89
+ return {}
90
+
91
+ def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 600) -> Dict[str, Any]:
92
+ if client is None:
93
+ return {}
94
+ resp = client.responses.create(
95
+ model=OPENAI_MODEL,
96
+ reasoning=OPENAI_REASONING,
97
+ input=[{"role":"system","content":system},{"role":"user","content":json.dumps(payload)}],
98
+ max_output_tokens=max_tokens,
99
+ )
100
+ return json_load_safe(getattr(resp, "output_text", "") or "")
101
+
102
+
103
+ # ============================
104
+ # Load data
105
+ # ============================
106
+ EOS_PATH = "routers_eos_eol_by_sku.csv"
107
+ DEC_PATH = "dec2025routers.csv"
108
+ PARSEC_PDF = "ParsecCatalog.pdf"
109
+
110
+ if not os.path.exists(EOS_PATH):
111
+ raise FileNotFoundError(f"Missing {EOS_PATH} in repo.")
112
+ if not os.path.exists(DEC_PATH):
113
+ raise FileNotFoundError(f"Missing {DEC_PATH} in repo.")
114
+ if not os.path.exists(PARSEC_PDF):
115
+ raise FileNotFoundError(f"Missing {PARSEC_PDF} in repo.")
116
+
117
+ df_eos = pd.read_csv(EOS_PATH).copy()
118
+ df_dec = pd.read_csv(DEC_PATH).copy()
119
+
120
+ def region_ok(x: Any) -> bool:
121
+ s = str(x or "").strip().lower()
122
+ if not s:
123
+ return True
124
+ if "not specified" in s:
125
+ return True
126
+ if "north america" in s:
127
+ return True
128
+ if re.search(r"\busa\b", s):
129
+ return True
130
+ if re.search(r"\bunited\s+states\b", s):
131
+ return True
132
+ if re.search(r"\bu\.?s\.?\b", s):
133
+ return True
134
+ return False
135
+
136
+ if "region" in df_eos.columns:
137
+ df_eos = df_eos[df_eos["region"].apply(region_ok)].reset_index(drop=True)
138
+
139
+ # Maker mapping (includes Teltonika)
140
+ CANON_MAKER = {
141
+ "CRADLEPOINT": {"cradlepoint", "ericsson", "ericsson enterprise wireless"},
142
+ "SIERRA": {"sierra", "sierra wireless", "semtech", "airlink"},
143
+ "FEENEY": {"feeney", "feeney wireless", "inseego"},
144
+ "DIGI": {"digi", "accelerated", "accelerated concepts"},
145
+ "CISCO_MERAKI": {"meraki", "cisco meraki"},
146
+ "CISCO": {"cisco"},
147
+ "TELTONIKA": {"teltonika"},
148
+ }
149
+
150
+ def canon_maker_from_text(s: Any) -> str:
151
+ t = norm_text(s)
152
+ for canon, terms in CANON_MAKER.items():
153
+ for term in terms:
154
+ if term in t:
155
+ return canon
156
+ return "UNKNOWN"
157
+
158
+ df_eos["_canon_make"] = df_eos["manufacturer"].apply(canon_maker_from_text) if "manufacturer" in df_eos.columns else "UNKNOWN"
159
+ df_eos["_norm_sku"] = df_eos["sku"].apply(norm_text) if "sku" in df_eos.columns else ""
160
+ df_eos["_norm_desc"] = df_eos["description"].apply(norm_text) if "description" in df_eos.columns else ""
161
+ df_eos["_norm_notes"] = df_eos["notes"].apply(norm_text) if "notes" in df_eos.columns else ""
162
+
163
+ df_dec["_canon_make"] = df_dec["Make"].apply(canon_maker_from_text) if "Make" in df_dec.columns else "UNKNOWN"
164
+ df_dec["_norm_model"] = df_dec["Model"].apply(norm_text) if "Model" in df_dec.columns else ""
165
+ df_dec["_is5g"] = df_dec["Modem Type"].apply(is_5g) if "Modem Type" in df_dec.columns else False
166
+
167
+
168
+ # ============================
169
+ # Date helpers
170
+ # ============================
171
+ @dataclass
172
+ class ParsedDate:
173
+ raw: str
174
+ kind: str
175
+ value: Optional[date]
176
+
177
+ def parse_date_field(x: Any) -> ParsedDate:
178
+ raw = str(x or "").strip()
179
+ if not raw:
180
+ return ParsedDate(raw="", kind="missing", value=None)
181
+
182
+ if re.fullmatch(r"\d{4}", raw):
183
+ y = int(raw)
184
+ if y == TODAY.year:
185
+ return ParsedDate(raw=raw, kind="year", value=date(y, 1, 1))
186
+ if y < TODAY.year:
187
+ return ParsedDate(raw=raw, kind="year", value=date(y, 1, 1))
188
+ return ParsedDate(raw=raw, kind="year", value=date(y, 12, 31))
189
+
190
+ if re.fullmatch(r"\d{4}-\d{2}", raw):
191
+ try:
192
+ y, m = raw.split("-")
193
+ return ParsedDate(raw=raw, kind="year_month", value=date(int(y), int(m), 1))
194
+ except Exception:
195
+ return ParsedDate(raw=raw, kind="bad", value=None)
196
+
197
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw):
198
+ try:
199
+ dt = datetime.strptime(raw, "%Y-%m-%d").date()
200
+ return ParsedDate(raw=raw, kind="full", value=dt)
201
+ except Exception:
202
+ return ParsedDate(raw=raw, kind="bad", value=None)
203
+
204
+ return ParsedDate(raw=raw, kind="bad", value=None)
205
+
206
+ def display_date(pd_: ParsedDate) -> str:
207
+ if pd_.kind == "missing":
208
+ return "Not listed"
209
+ if pd_.kind == "bad":
210
+ return pd_.raw or "Not listed"
211
+ return pd_.raw
212
+
213
+ def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:
214
+ if eos.value is None and eol.value is None:
215
+ return "Unknown"
216
+ if eol.value is not None and eol.value <= TODAY:
217
+ return "End of Life"
218
+ if eos.value is not None and eos.value <= TODAY:
219
+ return "End of Sale"
220
+ return "Active"
221
+
222
+ def row_to_dates_and_status(row: pd.Series) -> Tuple[str, str, str]:
223
+ eos = parse_date_field(row.get("end_of_sale"))
224
+ eol = parse_date_field(row.get("end_of_life"))
225
+ return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)
226
+
227
+
228
+ # ============================
229
+ # Embeddings + Parsec index
230
+ # ============================
231
+ embedder = SentenceTransformer(EMBED_MODEL_NAME)
232
+
233
+ def extract_pdf_text_pages(path: str) -> List[str]:
234
+ doc = fitz.open(path)
235
+ return [doc[i].get_text("text") for i in range(len(doc))]
236
+
237
+ def build_parsec_cards(pages: List[str]) -> List[str]:
238
+ cards = []
239
+ for p in pages:
240
+ for m in re.finditer(r"Standard\s+SKU:", p):
241
+ start = max(0, m.start() - PARSEC_CONTEXT_BEFORE)
242
+ end = min(len(p), m.start() + PARSEC_CONTEXT_AFTER)
243
+ c = p[start:end].strip()
244
+ if len(c) >= 200:
245
+ cards.append(c)
246
+ out, seen = [], set()
247
+ for c in cards:
248
+ h = hashlib.sha1(c.encode("utf-8")).hexdigest()
249
+ if h not in seen:
250
+ seen.add(h); out.append(c)
251
+ return out
252
+
253
+ parsec_cards = build_parsec_cards(extract_pdf_text_pages(PARSEC_PDF))
254
+ parsec_emb = embedder.encode(parsec_cards, batch_size=64, show_progress_bar=False, normalize_embeddings=True)
255
+ parsec_emb = np.asarray(parsec_emb, dtype=np.float32)
256
+ parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])
257
+ parsec_index.add(parsec_emb)
258
+
259
+
260
+ # ============================
261
+ # Device resolution
262
+ # ============================
263
+ def label_for_row(i: int) -> str:
264
+ r = df_eos.iloc[i]
265
+ return f"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}"[:220]
266
+
267
+ EOS_LABELS = [label_for_row(i) for i in range(len(df_eos))]
268
+ EOS_CORPUS = []
269
+ for _, r in df_eos.iterrows():
270
+ EOS_CORPUS.append(" ".join([r.get("_norm_sku",""), r.get("_canon_make",""), r.get("_norm_desc",""), r.get("_norm_notes","")]))
271
+
272
+ def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int, int, str]]:
273
+ q = norm_text(query)
274
+ hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)
275
+ return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]
276
+
277
+ def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> Dict[str, Any]:
278
+ if client is None:
279
+ return {}
280
+ sys = "Pick which router the user meant. Never invent. Return strict JSON only."
281
+ payload = {
282
+ "user_input": user_text,
283
+ "candidates": [{"row_idx": i, "score": s, "label": lbl} for (i,s,lbl) in candidates],
284
+ "rules": [
285
+ "If one is clearly correct, return mode='ok' with row_idx.",
286
+ "If two are plausible, return mode='pick' with top 2 options."
287
+ ],
288
+ "output_schema": {"mode":"ok|pick","row_idx":"int","options":[{"row_idx":"int","label":"string"}]}
289
+ }
290
+ return gpt_json(sys, payload, max_tokens=280)
291
+
292
+ def resolve_device(user_text: str) -> Dict[str, Any]:
293
+ q = norm_text(user_text)
294
+ exact = df_eos.index[df_eos["_norm_sku"] == q].tolist()
295
+ if len(exact) == 1:
296
+ return {"mode":"ok","row_idx": int(exact[0])}
297
+ if len(exact) > 1:
298
+ opts = [{"row_idx": int(i), "label": EOS_LABELS[int(i)]} for i in exact[:2]]
299
+ return {"mode":"pick","options": opts}
300
+
301
+ cands = local_candidates(user_text, top_k=6)
302
+ if not cands:
303
+ return {"mode":"not_found"}
304
+
305
+ if cands[0][1] >= 95 and (len(cands) == 1 or (cands[0][1] - cands[1][1]) >= 8):
306
+ return {"mode":"ok","row_idx": cands[0][0]}
307
+
308
+ g = gpt_choose_device(user_text, cands)
309
+ if g.get("mode") == "ok" and isinstance(g.get("row_idx"), int):
310
+ return {"mode":"ok","row_idx": int(g["row_idx"])}
311
+
312
+ if g.get("mode") == "pick":
313
+ opts = g.get("options", []) or []
314
+ opts2 = [{"row_idx": int(o["row_idx"]), "label": str(o["label"])} for o in opts[:2] if "row_idx" in o]
315
+ if opts2:
316
+ return {"mode":"pick","options": opts2}
317
+
318
+ if len(cands) > 1:
319
+ return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]},{"row_idx":cands[1][0],"label":cands[1][2]}]}
320
+ return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]}]}
321
+
322
+
323
+ # ============================
324
+ # Replacements — lifecycle CSV source of truth
325
+ # ============================
326
+ def extract_model_token(text: str) -> str:
327
+ s = safe_str(text)
328
+ if not s:
329
+ return ""
330
+ parts = [p.strip() for p in s.split("|") if p.strip()]
331
+ candidates = parts[::-1] if parts else [s]
332
+ for cand in candidates:
333
+ m = re.search(r"\bRUT[A-Z]?\d{2,4}\b", cand.upper())
334
+ if m:
335
+ return m.group(0).upper()
336
+ m = re.search(r"\bIX\d{2}\b", cand, flags=re.IGNORECASE)
337
+ if m:
338
+ return m.group(0).upper()
339
+ m = re.search(r"\b(R\d{3,4}|E\d{3,4}|S\d{3,4})\b", cand, flags=re.IGNORECASE)
340
+ if m:
341
+ return m.group(0).upper()
342
+ m = re.search(r"\b[A-Z]{1,6}\d{2,4}[A-Z]?\b", cand.upper())
343
+ if m:
344
+ return m.group(0).upper()
345
+ return candidates[0][:60]
346
+
347
+ def device_is_4g(row: pd.Series) -> bool:
348
+ t = norm_text(row.get("description","")) + " " + norm_text(row.get("notes",""))
349
+ return (("lte" in t or "4g" in t) and ("5g" not in t and "nr" not in t))
350
+
351
+ def candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:
352
+ mfr = norm_text(manufacturer)
353
+ pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
354
+ vals = pool["advanced_5g_option"].tolist() if "advanced_5g_option" in pool.columns else []
355
+ out, seen = [], set()
356
+ for v in vals:
357
+ tok = extract_model_token(v)
358
+ if tok and tok.lower() != "nan" and tok not in seen:
359
+ seen.add(tok); out.append(tok)
360
+ return out
361
+
362
+ def candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:
363
+ mfr = norm_text(manufacturer)
364
+ pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
365
+ vals = pool["suggested_replacement"].tolist() if "suggested_replacement" in pool.columns else []
366
+ out, seen = [], set()
367
+ for v in vals:
368
+ tok = extract_model_token(v)
369
+ if tok and tok.lower() != "nan" and tok not in seen:
370
+ seen.add(tok); out.append(tok)
371
+ return out
372
+
373
+ def gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:
374
+ if client is None or not candidates:
375
+ return ""
376
+ sys = "Pick the best replacement model. Choose only from candidates. Return strict JSON only."
377
+ payload = {
378
+ "old_device": {
379
+ "sku": str(old_row.get("sku","")),
380
+ "manufacturer": str(old_row.get("manufacturer","")),
381
+ "description": str(old_row.get("description","")),
382
+ "need": need,
383
+ },
384
+ "candidates": candidates[:40],
385
+ "output_schema": {"choice":"string"}
386
+ }
387
+ out = gpt_json(sys, payload, max_tokens=240) or {}
388
+ choice = str(out.get("choice","") or "").strip()
389
+ return choice if choice in candidates else ""
390
+
391
+ def fallback_5g_from_dec(canon_make: str) -> str:
392
+ pool5 = df_dec[(df_dec["_canon_make"] == canon_make) & (df_dec["_is5g"] == True)]
393
+ return str(pool5.iloc[0]["Model"]).strip() if not pool5.empty else ""
394
+
395
+ def pick_replacements_lifecycle(row: pd.Series, status: str, use_gpt: bool = True) -> Dict[str, Any]:
396
+ canon = str(row.get("_canon_make","UNKNOWN"))
397
+ manufacturer = str(row.get("manufacturer","") or "")
398
+
399
+ is_4g = device_is_4g(row)
400
+ want_5g = is_4g or (status in {"End of Sale","End of Life"})
401
+
402
+ repl_4g = "Not applicable"
403
+ if is_4g:
404
+ repl_4g = extract_model_token(safe_str(row.get("suggested_replacement","")))
405
+ if not repl_4g:
406
+ cand4 = candidate_4g_models_from_lifecycle(manufacturer)
407
+ repl_4g = (gpt_pick_from_candidates(row, cand4, "4G alternative") if (use_gpt and client) else "") or (cand4[0] if cand4 else "")
408
+ if not repl_4g:
409
+ repl_4g = "Not applicable"
410
+
411
+ repl_5g = "Not listed"
412
+ if want_5g:
413
+ repl_5g = extract_model_token(safe_str(row.get("advanced_5g_option","")))
414
+ if not repl_5g:
415
+ cand5 = candidate_5g_models_from_lifecycle(manufacturer)
416
+ repl_5g = (gpt_pick_from_candidates(row, cand5, "5G replacement/upgrade") if (use_gpt and client) else "") or (cand5[0] if cand5 else "")
417
+ if not repl_5g:
418
+ repl_5g = fallback_5g_from_dec(canon) or "Not listed"
419
+
420
+ if repl_5g.lower() == "nan":
421
+ repl_5g = "Not listed"
422
+
423
+ return {"repl_4g": repl_4g, "repl_5g": repl_5g, "sources": ["lifecycle_csv"] + (["gpt"] if (use_gpt and client) else [])}
424
+
425
+
426
+ # ============================
427
+ # Antennas (Parsec-only)
428
+ # ============================
429
+ PARSEC_FAMILY_WORDS = {"chinook","labrador","boxer","bloodhound","husky","beagle","mastiff","collie","shepherd","belgian","australian","terrier","pyrenees"}
430
+ BAD_NAME_MARKERS = {"customization","standard connectors","connectors","features","benefits","specifications","mechanical","electrical","mounting","accessories","description:","standard sku"}
431
+
432
+ def clean_line(s: str) -> str:
433
+ s = re.sub(r"\s+", " ", str(s or "").strip())
434
+ if re.fullmatch(r"-[a-z0-9]+", s.lower()):
435
+ return ""
436
+ return s
437
+
438
+ def is_bad_name_line(line: str) -> bool:
439
+ low = line.lower()
440
+ if any(m in low for m in BAD_NAME_MARKERS):
441
+ return True
442
+ if re.search(r"\b-[a-z0-9]{1,4}\b", low) and len(low) <= 25:
443
+ return True
444
+ return False
445
+
446
+ def family_from_line(line: str) -> str:
447
+ low = line.lower()
448
+ for fam in PARSEC_FAMILY_WORDS:
449
+ if fam in low:
450
+ return fam.capitalize()
451
+ return ""
452
+
453
+ def parsec_connectors_from_card(t: str) -> str:
454
+ m = re.search(r"Standard\s+Connectors:\s*(.+)", t, flags=re.IGNORECASE)
455
+ if m:
456
+ return re.sub(r"\s+", " ", m.group(1).strip())[:80]
457
+ return ""
458
+
459
+ def parsec_name_from_card(card_text: str) -> str:
460
+ lines = [clean_line(ln) for ln in str(card_text or "").splitlines()]
461
+ lines = [ln for ln in lines if ln]
462
+ for ln in lines:
463
+ if is_bad_name_line(ln):
464
+ continue
465
+ fam = family_from_line(ln)
466
+ if fam:
467
+ return fam
468
+ return "Parsec antenna"
469
+
470
+ def parsec_part_from_card(t: str) -> str:
471
+ m = re.search(r"Standard\s+SKU:\s*([A-Z0-9]+)", t)
472
+ return m.group(1).strip() if m else ""
473
+
474
+ def parsec_desc_from_card(t: str) -> str:
475
+ m = re.search(r"Description:\s*(.+?)(?:\n|$)", t, flags=re.IGNORECASE)
476
+ return re.sub(r"\s+"," ",m.group(1).strip())[:220] if m else ""
477
+
478
+ def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:
479
+ qv = embedder.encode([query], normalize_embeddings=True)
480
+ qv = np.asarray(qv, dtype=np.float32)
481
+ scores, ids = parsec_index.search(qv, top_k)
482
+ out: List[Dict[str, Any]] = []
483
+ for sc, i in zip(scores[0].tolist(), ids[0].tolist()):
484
+ if 0 <= int(i) < len(parsec_cards):
485
+ card = parsec_cards[int(i)]
486
+ out.append({
487
+ "score": float(sc),
488
+ "name": parsec_name_from_card(card),
489
+ "part_number": parsec_part_from_card(card),
490
+ "description": parsec_desc_from_card(card),
491
+ "connectors": parsec_connectors_from_card(card),
492
+ })
493
+ return out
494
+
495
+ def infer_mimo_for_5g(model: str, canon_make: str) -> str:
496
+ if not model or model in {"Not applicable","Not listed"}:
497
+ return "2x2"
498
+ pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
499
+ if not pool.empty:
500
+ hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
501
+ if hit and hit[1] >= MATCH_OK:
502
+ row = pool.iloc[int(hit[2])]
503
+ txt = (str(row.get("Antennas (internal/external/both)","")) + " " + str(row.get("Modem Type",""))).lower()
504
+ if "4x4" in txt or "4 x 4" in txt:
505
+ return "4x4"
506
+ return "4x4" if ("5g" in model.lower() or model.upper().startswith(("R","E","S","IX","RUTM"))) else "2x2"
507
+
508
+ def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:
509
+ q_stationary = f"{router_model} {tech} {mimo} omni stationary outdoor Parsec"
510
+ q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile Parsec"
511
+ cand_stationary = parsec_retrieve(q_stationary, top_k=10)
512
+ cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)
513
+ s = cand_stationary[0] if cand_stationary else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
514
+ v = cand_vehicle[0] if cand_vehicle else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
515
+ s.update({"mimo": mimo, "why": "Stationary omni best match."})
516
+ v.update({"mimo": mimo, "why": "Vehicle omni best match."})
517
+ return {"stationary_omni": s, "vehicle_omni": v, "sources":["parsec_rag"]}
518
+
519
+
520
+ # ============================
521
+ # Install-ready checklist
522
+ # ============================
523
+ def install_ready_checklist(current_sku: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
524
+ st = ant.get("stationary_omni", {})
525
+ vh = ant.get("vehicle_omni", {})
526
+ if client is not None:
527
+ sys = "Create a short, install-ready checklist for a Verizon rep. Return markdown only."
528
+ payload = {"current_device": current_sku, "replacements": repl, "antennas": {"stationary": st, "vehicle": vh}}
529
+ resp = client.responses.create(
530
+ model=OPENAI_MODEL,
531
+ reasoning=OPENAI_REASONING,
532
+ input=[{"role":"system","content":sys},{"role":"user","content":json.dumps(payload)}],
533
+ max_output_tokens=520,
534
+ )
535
+ return (getattr(resp, "output_text", "") or "").strip()
536
+ return "\n".join([
537
+ "### Install-ready checklist",
538
+ f"- Current device: {current_sku}",
539
+ f"- 5G replacement: {repl.get('repl_5g','')}",
540
+ f"- 4G alternative: {repl.get('repl_4g','Not applicable')}",
541
+ f"- Stationary omni antenna: {st.get('name','')} (PN {st.get('part_number','')})",
542
+ f"- Vehicle omni antenna: {vh.get('name','')} (PN {vh.get('part_number','')})",
543
+ "- Next steps: confirm mounting + cable lengths + power; place order; schedule install.",
544
+ ])
545
+
546
+
547
+ # ============================
548
+ # Batch mode (NO GPT)
549
+ # ============================
550
+ def parse_batch_inputs(text_blob: str, file_obj: Any) -> List[str]:
551
+ items: List[str] = []
552
+ if file_obj is not None:
553
+ try:
554
+ path = file_obj.name if hasattr(file_obj, "name") else str(file_obj)
555
+ df = pd.read_csv(path)
556
+ col = df.columns[0]
557
+ items.extend([str(x).strip() for x in df[col].tolist() if str(x).strip()])
558
+ except Exception:
559
+ pass
560
+ if text_blob:
561
+ for ln in str(text_blob).splitlines():
562
+ ln = ln.strip()
563
+ if ln:
564
+ items.append(ln)
565
+ seen=set()
566
+ out=[]
567
+ for x in items:
568
+ k=norm_text(x)
569
+ if k and k not in seen:
570
+ seen.add(k); out.append(x)
571
+ return out
572
+
573
+ def run_batch(text_blob: str, file_obj: Any, include_antennas: bool):
574
+ inputs = parse_batch_inputs(text_blob, file_obj)
575
+ if not inputs:
576
+ return "", None, None, ""
577
+
578
+ rows=[]
579
+ for item in inputs:
580
+ res = resolve_device(item)
581
+ if res.get("mode") != "ok":
582
+ rows.append({"Input": item, "Matched":"", "Status":"Needs review", "EOS":"", "EOL":"", "4G alternative":"", "5G replacement":"", "Notes":"Not found/ambiguous"})
583
+ continue
584
+
585
+ life_row = df_eos.iloc[int(res["row_idx"])]
586
+ eos, eol, status = row_to_dates_and_status(life_row)
587
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=False)
588
+
589
+ rows.append({
590
+ "Input": item,
591
+ "Matched": str(life_row.get("sku","")),
592
+ "Status": status,
593
+ "EOS": eos,
594
+ "EOL": eol,
595
+ "4G alternative": repl.get("repl_4g",""),
596
+ "5G replacement": repl.get("repl_5g",""),
597
+ "Notes": "",
598
+ })
599
+
600
+ out_df = pd.DataFrame(rows)
601
+ counts = out_df["Status"].value_counts(dropna=False).to_dict()
602
+ top_5g = out_df["5G replacement"].value_counts(dropna=False).head(5).to_dict()
603
+ summary = f"Rows: {len(out_df)} | " + " | ".join([f"{k}: {v}" for k,v in counts.items()])
604
+ rollup = "Top 5G recommendations:\n" + "\n".join([f"- {k}: {v}" for k,v in top_5g.items() if str(k).strip()])
605
+
606
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
607
+ out_df.to_csv(tmp.name, index=False)
608
+
609
+ return summary, out_df, tmp.name, rollup
610
+
611
+
612
+ # ============================
613
+ # Output
614
+ # ============================
615
+ def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
616
+ current_name = f"{life_row.get('sku','')} — {life_row.get('description','')}".strip(" —")
617
+ st = ant.get("stationary_omni", {})
618
+ vh = ant.get("vehicle_omni", {})
619
+
620
+ lines = []
621
+ lines.append(f"1. Current device: **{current_name}**")
622
+ lines.append(f"2. Status: **{status}**")
623
+ lines.append(f"3. End of Sale date: **{eos}**")
624
+ lines.append(f"4. End of Life date: **{eol}**")
625
+ lines.append(f"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**")
626
+ lines.append(f"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**")
627
+ lines.append("7. Antenna options (Parsec-only):")
628
+ conn_s = f" | Conn: {st.get('connectors','')}" if st.get("connectors") else ""
629
+ conn_v = f" | Conn: {vh.get('connectors','')}" if vh.get("connectors") else ""
630
+ lines.append(f" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')}{conn_s}")
631
+ lines.append(f" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')}{conn_v}")
632
+
633
+ lines.append("\nSources (debug):")
634
+ for s in repl.get("sources", []) if isinstance(repl.get("sources"), list) else []:
635
+ lines.append(f"- {s}")
636
+ lines.append("- ParsecCatalog.pdf (local RAG)")
637
+ lines.append("- routers_eos_eol_by_sku.csv (replacements)")
638
+ return "\n".join(lines)
639
+
640
+
641
+ # ============================
642
+ # Gradio callbacks
643
+ # IMPORTANT: no dict state and ALL events have api_name=False (prevents api_info schema generation)
644
+ # ============================
645
+ def run_lookup(user_text: str, st_json: str):
646
+ user_text = str(user_text or "").strip()
647
+ if not user_text:
648
+ return "Enter a router SKU/model.", gr.update(visible=False), gr.update(visible=False), "{}", ""
649
+
650
+ res = resolve_device(user_text)
651
+
652
+ if res.get("mode") == "pick":
653
+ opts = res.get("options", [])
654
+ choices = [o["label"] for o in opts]
655
+ st2 = {"mode":"pick","options": opts, "raw": user_text}
656
+ return "Did you mean A or B? Pick one, then click Use selection.", gr.update(choices=choices, value=None, visible=True), gr.update(visible=True), state_dump(st2), ""
657
+
658
+ if res.get("mode") != "ok":
659
+ return "Not found.", gr.update(visible=False), gr.update(visible=False), "{}", ""
660
+
661
+ life_row = df_eos.iloc[int(res["row_idx"])]
662
+ eos, eol, status = row_to_dates_and_status(life_row)
663
+
664
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)
665
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
666
+ mimo = infer_mimo_for_5g(repl.get("repl_5g",""), canon_make)
667
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") != "Not listed" else ("4G" if device_is_4g(life_row) else "Unknown")
668
+ ant = antenna_options_for(repl.get("repl_5g") or str(life_row.get("sku","")), tech, mimo)
669
+
670
+ output = assemble_output(life_row, status, eos, eol, repl, ant)
671
+ st_out = {"row_idx": int(res["row_idx"]), "repl": repl, "ant": ant, "raw": user_text}
672
+ return output, gr.update(visible=False), gr.update(visible=False), state_dump(st_out), ""
673
+
674
+ def use_selection(selected_label: str, st_json: str):
675
+ st = state_load(st_json)
676
+ if not st or st.get("mode") != "pick":
677
+ return "Run a search first.", gr.update(visible=False), gr.update(visible=False), "{}", ""
678
+
679
+ if not selected_label:
680
+ return "Pick A or B first.", gr.update(visible=True), gr.update(visible=True), st_json, ""
681
+
682
+ chosen_row = None
683
+ for o in st.get("options", []):
684
+ if o.get("label") == selected_label:
685
+ chosen_row = int(o["row_idx"])
686
+ break
687
+ if chosen_row is None:
688
+ return "Pick a valid option.", gr.update(visible=True), gr.update(visible=True), st_json, ""
689
+
690
+ life_row = df_eos.iloc[int(chosen_row)]
691
+ eos, eol, status = row_to_dates_and_status(life_row)
692
+
693
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)
694
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
695
+ mimo = infer_mimo_for_5g(repl.get("repl_5g",""), canon_make)
696
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") != "Not listed" else ("4G" if device_is_4g(life_row) else "Unknown")
697
+ ant = antenna_options_for(repl.get("repl_5g") or str(life_row.get("sku","")), tech, mimo)
698
+
699
+ output = assemble_output(life_row, status, eos, eol, repl, ant)
700
+ st_out = {"row_idx": int(chosen_row), "repl": repl, "ant": ant, "raw": st.get("raw","")}
701
+ return output, gr.update(visible=False), gr.update(visible=False), state_dump(st_out), ""
702
+
703
+ def make_install_ready(st_json: str):
704
+ st = state_load(st_json)
705
+ if not st or "row_idx" not in st:
706
+ return "Run a lookup first."
707
+ life_row = df_eos.iloc[int(st["row_idx"])]
708
+ current_sku = str(life_row.get("sku","") or "")
709
+ return install_ready_checklist(current_sku, st.get("repl", {}) or {}, st.get("ant", {}) or {})
710
+
711
+
712
+ # ============================
713
+ # UI
714
+ # ============================
715
+ with gr.Blocks(title="Only-Routers") as demo:
716
+ gr.Markdown("## Only-Routers\nSingle lookup + Batch upload for Verizon reps.")
717
+
718
+ with gr.Tabs():
719
+ with gr.Tab("Single"):
720
+ user_text = gr.Textbox(label="Router SKU or model", placeholder="Examples: IBR650B, AER1600, ES450, WR21, RUT240", lines=1)
721
+ st = gr.State("{}") # JSON string
722
+
723
+ check_btn = gr.Button("Check", variant="primary")
724
+ pick_dd = gr.Dropdown(label="Pick A or B", choices=[], visible=False)
725
+ use_btn = gr.Button("Use selection", visible=False)
726
+
727
+ output_md = gr.Markdown()
728
+
729
+ install_btn = gr.Button("Make install-ready checklist")
730
+ install_md = gr.Markdown()
731
+
732
+ check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st, install_md], api_name=False)
733
+ use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st, install_md], api_name=False)
734
+ install_btn.click(fn=make_install_ready, inputs=[st], outputs=[install_md], api_name=False)
735
+
736
+ with gr.Tab("Batch"):
737
+ gr.Markdown("Paste one per line or upload a CSV (first column). Batch runs fast (no GPT).")
738
+ batch_text = gr.Textbox(label="Paste devices (one per line)", lines=8, placeholder="WR21\nRUT240\nIBR650B")
739
+ batch_file = gr.File(label="Upload CSV", file_types=[".csv"])
740
+ include_ant = gr.Checkbox(label="Include antenna picks (slower)", value=False)
741
+ run_btn = gr.Button("Run batch", variant="primary")
742
+
743
+ summary_md = gr.Markdown()
744
+ rollup_md = gr.Markdown()
745
+ table = gr.Dataframe(interactive=False, wrap=True)
746
+ dl = gr.File(label="Download results CSV")
747
+
748
+ run_btn.click(fn=run_batch, inputs=[batch_text, batch_file, include_ant], outputs=[summary_md, table, dl, rollup_md], api_name=False)
749
+
750
+ # IMPORTANT: On Spaces, demo.launch() is correct; do NOT use share=True.
751
+ demo.launch(show_api=False)
app.py CHANGED
@@ -345,8 +345,36 @@ def extract_model_token(text: str) -> str:
345
  return candidates[0][:60]
346
 
347
  def device_is_4g(row: pd.Series) -> bool:
 
348
  t = norm_text(row.get("description","")) + " " + norm_text(row.get("notes",""))
349
- return (("lte" in t or "4g" in t) and ("5g" not in t and "nr" not in t))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
 
351
  def candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:
352
  mfr = norm_text(manufacturer)
@@ -396,21 +424,32 @@ def pick_replacements_lifecycle(row: pd.Series, status: str, use_gpt: bool = Tru
396
  canon = str(row.get("_canon_make","UNKNOWN"))
397
  manufacturer = str(row.get("manufacturer","") or "")
398
 
399
- is_4g = device_is_4g(row)
400
- want_5g = is_4g or (status in {"End of Sale","End of Life"})
 
 
 
 
 
 
 
 
 
401
 
 
402
  repl_4g = "Not applicable"
403
- if is_4g:
404
- repl_4g = extract_model_token(safe_str(row.get("suggested_replacement","")))
405
  if not repl_4g:
406
  cand4 = candidate_4g_models_from_lifecycle(manufacturer)
407
  repl_4g = (gpt_pick_from_candidates(row, cand4, "4G alternative") if (use_gpt and client) else "") or (cand4[0] if cand4 else "")
408
  if not repl_4g:
409
  repl_4g = "Not applicable"
410
 
 
411
  repl_5g = "Not listed"
412
  if want_5g:
413
- repl_5g = extract_model_token(safe_str(row.get("advanced_5g_option","")))
414
  if not repl_5g:
415
  cand5 = candidate_5g_models_from_lifecycle(manufacturer)
416
  repl_5g = (gpt_pick_from_candidates(row, cand5, "5G replacement/upgrade") if (use_gpt and client) else "") or (cand5[0] if cand5 else "")
@@ -456,15 +495,43 @@ def parsec_connectors_from_card(t: str) -> str:
456
  return re.sub(r"\s+", " ", m.group(1).strip())[:80]
457
  return ""
458
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  def parsec_name_from_card(card_text: str) -> str:
460
  lines = [clean_line(ln) for ln in str(card_text or "").splitlines()]
461
  lines = [ln for ln in lines if ln]
 
462
  for ln in lines:
463
  if is_bad_name_line(ln):
464
  continue
465
  fam = family_from_line(ln)
466
  if fam:
467
  return fam
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  return "Parsec antenna"
469
 
470
  def parsec_part_from_card(t: str) -> str:
@@ -475,7 +542,7 @@ def parsec_desc_from_card(t: str) -> str:
475
  m = re.search(r"Description:\s*(.+?)(?:\n|$)", t, flags=re.IGNORECASE)
476
  return re.sub(r"\s+"," ",m.group(1).strip())[:220] if m else ""
477
 
478
- def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:
479
  qv = embedder.encode([query], normalize_embeddings=True)
480
  qv = np.asarray(qv, dtype=np.float32)
481
  scores, ids = parsec_index.search(qv, top_k)
@@ -489,31 +556,65 @@ def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:
489
  "part_number": parsec_part_from_card(card),
490
  "description": parsec_desc_from_card(card),
491
  "connectors": parsec_connectors_from_card(card),
 
 
492
  })
493
  return out
494
 
495
- def infer_mimo_for_5g(model: str, canon_make: str) -> str:
496
- if not model or model in {"Not applicable","Not listed"}:
497
- return "2x2"
498
- pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
499
- if not pool.empty:
500
- hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
501
- if hit and hit[1] >= MATCH_OK:
502
- row = pool.iloc[int(hit[2])]
503
- txt = (str(row.get("Antennas (internal/external/both)","")) + " " + str(row.get("Modem Type",""))).lower()
504
- if "4x4" in txt or "4 x 4" in txt:
505
- return "4x4"
506
- return "4x4" if ("5g" in model.lower() or model.upper().startswith(("R","E","S","IX","RUTM"))) else "2x2"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
 
508
  def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:
509
- q_stationary = f"{router_model} {tech} {mimo} omni stationary outdoor Parsec"
510
- q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile Parsec"
511
- cand_stationary = parsec_retrieve(q_stationary, top_k=10)
512
- cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)
513
- s = cand_stationary[0] if cand_stationary else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
514
- v = cand_vehicle[0] if cand_vehicle else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
 
 
 
515
  s.update({"mimo": mimo, "why": "Stationary omni best match."})
516
  v.update({"mimo": mimo, "why": "Vehicle omni best match."})
 
517
  return {"stationary_omni": s, "vehicle_omni": v, "sources":["parsec_rag"]}
518
 
519
 
 
345
  return candidates[0][:60]
346
 
347
  def device_is_4g(row: pd.Series) -> bool:
348
+ # Detect LTE/4G even when the description uses "Cat 4 / Cat6 / Cat 12" without saying "LTE"
349
  t = norm_text(row.get("description","")) + " " + norm_text(row.get("notes",""))
350
+
351
+ # If it explicitly says 5G/NR, treat as not 4G-only
352
+ if ("5g" in t) or ("nr" in t):
353
+ return False
354
+
355
+ # Classic signals
356
+ if ("lte" in t) or ("4g" in t):
357
+ return True
358
+
359
+ # LTE category signals (Cat 1..20 are LTE categories; Cat M1/M2 are LTE-M)
360
+ if re.search(r"\bcat\s*[-]?\s*(m1|m2)\b", t):
361
+ return True
362
+
363
+ m = re.search(r"\bcat\s*[-]?\s*(\d{1,2})\b", t)
364
+ if m:
365
+ try:
366
+ cat = int(m.group(1))
367
+ if 0 < cat <= 20:
368
+ return True
369
+ except Exception:
370
+ pass
371
+
372
+ # If "cat" appears at all, it's almost always LTE-family
373
+ if "cat" in t:
374
+ return True
375
+
376
+ return False
377
+
378
 
379
  def candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:
380
  mfr = norm_text(manufacturer)
 
424
  canon = str(row.get("_canon_make","UNKNOWN"))
425
  manufacturer = str(row.get("manufacturer","") or "")
426
 
427
+ sug_raw = safe_str(row.get("suggested_replacement",""))
428
+ adv_raw = safe_str(row.get("advanced_5g_option",""))
429
+
430
+ has_4g_alt = bool(sug_raw.strip())
431
+ has_5g_alt = bool(adv_raw.strip())
432
+
433
+ # Treat as 4G if the description indicates LTE OR lifecycle provides a 4G suggested replacement
434
+ is_4g = device_is_4g(row) or has_4g_alt
435
+
436
+ # Provide 5G option if the unit is 4G, EOS/EOL, or lifecycle explicitly provides advanced_5g_option
437
+ want_5g = is_4g or (status in {"End of Sale","End of Life"}) or has_5g_alt
438
 
439
+ # 4G alternative: show whenever lifecycle provides it (or device appears 4G)
440
  repl_4g = "Not applicable"
441
+ if is_4g or has_4g_alt:
442
+ repl_4g = extract_model_token(sug_raw)
443
  if not repl_4g:
444
  cand4 = candidate_4g_models_from_lifecycle(manufacturer)
445
  repl_4g = (gpt_pick_from_candidates(row, cand4, "4G alternative") if (use_gpt and client) else "") or (cand4[0] if cand4 else "")
446
  if not repl_4g:
447
  repl_4g = "Not applicable"
448
 
449
+ # 5G replacement: prefer lifecycle advanced_5g_option whenever present
450
  repl_5g = "Not listed"
451
  if want_5g:
452
+ repl_5g = extract_model_token(adv_raw)
453
  if not repl_5g:
454
  cand5 = candidate_5g_models_from_lifecycle(manufacturer)
455
  repl_5g = (gpt_pick_from_candidates(row, cand5, "5G replacement/upgrade") if (use_gpt and client) else "") or (cand5[0] if cand5 else "")
 
495
  return re.sub(r"\s+", " ", m.group(1).strip())[:80]
496
  return ""
497
 
498
+ def parsec_mounts_from_card(t: str) -> List[str]:
499
+ mounts = []
500
+ for m in re.finditer(r"Mount:\s*(.+)", t, flags=re.IGNORECASE):
501
+ val = re.sub(r"\s+", " ", m.group(1).strip())
502
+ parts = [p.strip().lower() for p in val.split(",") if p.strip()]
503
+ mounts.extend(parts)
504
+ out = []
505
+ seen = set()
506
+ for x in mounts:
507
+ if x not in seen:
508
+ seen.add(x); out.append(x)
509
+ return out
510
+
511
  def parsec_name_from_card(card_text: str) -> str:
512
  lines = [clean_line(ln) for ln in str(card_text or "").splitlines()]
513
  lines = [ln for ln in lines if ln]
514
+
515
  for ln in lines:
516
  if is_bad_name_line(ln):
517
  continue
518
  fam = family_from_line(ln)
519
  if fam:
520
  return fam
521
+
522
+ sku_i = None
523
+ for i, ln in enumerate(lines):
524
+ if "standard sku" in ln.lower():
525
+ sku_i = i
526
+ break
527
+ if sku_i is not None:
528
+ window = lines[max(0, sku_i - 12):sku_i]
529
+ for ln in reversed(window):
530
+ if is_bad_name_line(ln):
531
+ continue
532
+ if 3 <= len(ln) <= 40 and re.search(r"[A-Za-z]", ln):
533
+ return ln.split()[0].capitalize()
534
+
535
  return "Parsec antenna"
536
 
537
  def parsec_part_from_card(t: str) -> str:
 
542
  m = re.search(r"Description:\s*(.+?)(?:\n|$)", t, flags=re.IGNORECASE)
543
  return re.sub(r"\s+"," ",m.group(1).strip())[:220] if m else ""
544
 
545
+ def parsec_retrieve(query: str, top_k: int = 12) -> List[Dict[str, Any]]:
546
  qv = embedder.encode([query], normalize_embeddings=True)
547
  qv = np.asarray(qv, dtype=np.float32)
548
  scores, ids = parsec_index.search(qv, top_k)
 
556
  "part_number": parsec_part_from_card(card),
557
  "description": parsec_desc_from_card(card),
558
  "connectors": parsec_connectors_from_card(card),
559
+ "mounts": parsec_mounts_from_card(card),
560
+ "_card": card.lower(),
561
  })
562
  return out
563
 
564
+ def choose_best_parsec(cands: List[Dict[str, Any]], mode: str) -> Dict[str, Any]:
565
+ best = None
566
+ best_score = -1e9
567
+
568
+ for c in cands:
569
+ card = c.get("_card","")
570
+ mounts = c.get("mounts", []) or []
571
+ score = float(c.get("score", 0.0))
572
+
573
+ if "omni" in card:
574
+ score += 0.6
575
+ if "directional" in card:
576
+ score -= 1.5
577
+
578
+ if mode == "vehicle":
579
+ if any("magnetic" in m for m in mounts):
580
+ score += 3.0
581
+ if any("through" in m for m in mounts):
582
+ score += 2.0
583
+ if any("wall" in m for m in mounts) or any("pole" in m for m in mounts):
584
+ score -= 1.2
585
+ if "app: fixed" in card and "mobile" not in card:
586
+ score -= 2.0
587
+
588
+ if mode == "stationary":
589
+ if any("wall" in m for m in mounts):
590
+ score += 2.0
591
+ if any("pole" in m for m in mounts):
592
+ score += 1.8
593
+
594
+ if score > best_score:
595
+ best_score = score
596
+ best = c
597
+
598
+ if not best:
599
+ return {"name":"Parsec antenna","part_number":"","description":"","connectors":"","mounts":[]}
600
+
601
+ best = dict(best)
602
+ best.pop("_card", None)
603
+ return best
604
 
605
  def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:
606
+ q_stationary = f"{router_model} {tech} {mimo} omni stationary pole wall fixed site Parsec"
607
+ q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile magnetic through-bolt Parsec"
608
+
609
+ cand_stationary = parsec_retrieve(q_stationary, top_k=12)
610
+ cand_vehicle = parsec_retrieve(q_vehicle, top_k=12)
611
+
612
+ s = choose_best_parsec(cand_stationary, mode="stationary")
613
+ v = choose_best_parsec(cand_vehicle, mode="vehicle")
614
+
615
  s.update({"mimo": mimo, "why": "Stationary omni best match."})
616
  v.update({"mimo": mimo, "why": "Vehicle omni best match."})
617
+
618
  return {"stationary_omni": s, "vehicle_omni": v, "sources":["parsec_rag"]}
619
 
620