crazycrazypete commited on
Commit
7ae1747
Β·
verified Β·
1 Parent(s): 16d96ed

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -5,18 +5,20 @@ colorFrom: blue
5
  colorTo: gray
6
  sdk: gradio
7
  sdk_version: "4.44.1"
 
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
  # Only-Routers
13
 
14
- Router lifecycle status, replacements, and Parsec antenna picks.
15
 
16
- ## Setup
 
 
17
 
18
- - Add a Space secret: `OPENAI_API_KEY`
19
- - Files shipped with the Space:
20
- - `routers_eos_eol_by_sku.csv`
21
- - `dec2025routers.csv`
22
- - `ParsecCatalog.pdf`
 
5
  colorTo: gray
6
  sdk: gradio
7
  sdk_version: "4.44.1"
8
+ python_version: "3.10"
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
  # Only-Routers
14
 
15
+ Single lookup + batch mode for router lifecycle, replacements, and Parsec antennas.
16
 
17
+ ## Secrets
18
+ Add a Space secret:
19
+ - `OPENAI_API_KEY`
20
 
21
+ ## Data files (in repo)
22
+ - `routers_eos_eol_by_sku.csv`
23
+ - `dec2025routers.csv`
24
+ - `ParsecCatalog.pdf`
 
Updates/README_hf_fixed.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Only-Routers
3
+ emoji: πŸ“‘
4
+ colorFrom: blue
5
+ colorTo: gray
6
+ sdk: gradio
7
+ sdk_version: "4.44.1"
8
+ python_version: "3.10"
9
+ app_file: app.py
10
+ pinned: false
11
+ ---
12
+
13
+ # Only-Routers
14
+
15
+ Single lookup + batch mode for router lifecycle, replacements, and Parsec antennas.
16
+
17
+ ## Secrets
18
+ Add a Space secret:
19
+ - `OPENAI_API_KEY`
20
+
21
+ ## Data files (in repo)
22
+ - `routers_eos_eol_by_sku.csv`
23
+ - `dec2025routers.csv`
24
+ - `ParsecCatalog.pdf`
Updates/app_old.py ADDED
@@ -0,0 +1,734 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import math
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, date
8
+ from typing import Dict, List, Optional, Tuple, Any
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ import fitz # PyMuPDF
14
+ import faiss
15
+ from sentence_transformers import SentenceTransformer
16
+ from rapidfuzz import fuzz, process
17
+
18
+ import gradio as gr
19
+ from openai import OpenAI
20
+
21
+
22
+ # ============================
23
+ # Settings
24
+ # ============================
25
+ TODAY = date(2026, 1, 18)
26
+ OPENAI_MODEL = "gpt-5.2"
27
+ OPENAI_REASONING = {"effort": "high"}
28
+
29
+ MATCH_OK = 80
30
+ EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
31
+ PARSEC_CONTEXT_BEFORE = 900
32
+ PARSEC_CONTEXT_AFTER = 1600
33
+
34
+ CACHE_DIR = os.path.join(os.getcwd(), ".onlyrouters_cache")
35
+ os.makedirs(CACHE_DIR, exist_ok=True)
36
+
37
+
38
+ # ============================
39
+ # OpenAI client (HF Space secret: OPENAI_API_KEY)
40
+ # ============================
41
+ API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
42
+ client = OpenAI(api_key=API_KEY) if API_KEY else None
43
+
44
+
45
+ # ============================
46
+ # Utilities
47
+ # ============================
48
+ def norm_text(s: Any) -> str:
49
+ try:
50
+ if s is None or (isinstance(s, float) and math.isnan(s)) or pd.isna(s):
51
+ return ""
52
+ except Exception:
53
+ pass
54
+ s = str(s).strip().lower()
55
+ s = re.sub(r"[^a-z0-9\s\-\/]", " ", s)
56
+ s = re.sub(r"\s+", " ", s).strip()
57
+ return s
58
+
59
+ def _safe_str(v: Any) -> str:
60
+ if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):
61
+ return ""
62
+ return str(v).strip()
63
+
64
+ def _is_5g(modem_type: Any) -> bool:
65
+ s = norm_text(modem_type)
66
+ return ("5g" in s) or ("nr" in s)
67
+
68
+ def _json_load_safe(s: str) -> Dict[str, Any]:
69
+ try:
70
+ return json.loads(s)
71
+ except Exception:
72
+ return {}
73
+
74
+ def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 700) -> Dict[str, Any]:
75
+ if client is None:
76
+ return {}
77
+ resp = client.responses.create(
78
+ model=OPENAI_MODEL,
79
+ reasoning=OPENAI_REASONING,
80
+ input=[
81
+ {"role": "system", "content": system},
82
+ {"role": "user", "content": json.dumps(payload)},
83
+ ],
84
+ max_output_tokens=max_tokens,
85
+ )
86
+ return _json_load_safe(getattr(resp, "output_text", "") or "")
87
+
88
+
89
+ # ============================
90
+ # Load data files (must exist in repo)
91
+ # ============================
92
+ EOS_PATH = "routers_eos_eol_by_sku.csv"
93
+ DEC_PATH = "dec2025routers.csv"
94
+ PARSEC_PDF = "ParsecCatalog.pdf"
95
+
96
+ if not os.path.exists(EOS_PATH):
97
+ raise FileNotFoundError(f"Missing {EOS_PATH} in repo.")
98
+ if not os.path.exists(DEC_PATH):
99
+ raise FileNotFoundError(f"Missing {DEC_PATH} in repo.")
100
+ if not os.path.exists(PARSEC_PDF):
101
+ raise FileNotFoundError(f"Missing {PARSEC_PDF} in repo.")
102
+
103
+ df_eos = pd.read_csv(EOS_PATH).copy()
104
+ df_dec = pd.read_csv(DEC_PATH).copy()
105
+
106
+ # Region filter: keep USA / North America / blank / not specified
107
+ def _region_ok(x: Any) -> bool:
108
+ s = str(x or "").strip().lower()
109
+ if not s:
110
+ return True
111
+ if "not specified" in s:
112
+ return True
113
+ if "north america" in s:
114
+ return True
115
+ if re.search(r"\busa\b", s):
116
+ return True
117
+ if re.search(r"\bunited\s+states\b", s):
118
+ return True
119
+ if re.search(r"\bu\.?s\.?\b", s):
120
+ return True
121
+ return False
122
+
123
+ if "region" in df_eos.columns:
124
+ df_eos = df_eos[df_eos["region"].apply(_region_ok)].reset_index(drop=True)
125
+
126
+ # Optional "Device Type"
127
+ device_type_col = None
128
+ for c in df_eos.columns:
129
+ if norm_text(c) == "device type":
130
+ device_type_col = c
131
+ break
132
+
133
+ # Maker mapping (expanded β€” adds Teltonika)
134
+ CANON_MAKER = {
135
+ "CRADLEPOINT": {"cradlepoint", "ericsson", "ericsson enterprise wireless"},
136
+ "SIERRA": {"sierra", "sierra wireless", "semtech", "airlink"},
137
+ "FEENEY": {"feeney", "feeney wireless", "inseego"},
138
+ "DIGI": {"digi", "accelerated", "accelerated concepts"},
139
+ "CISCO_MERAKI": {"meraki", "cisco meraki"},
140
+ "CISCO": {"cisco"},
141
+ "TELTONIKA": {"teltonika"},
142
+ }
143
+ DISPLAY_MAKER = {
144
+ "CRADLEPOINT": "Cradlepoint",
145
+ "SIERRA": "Sierra Wireless",
146
+ "FEENEY": "Feeney Wireless",
147
+ "DIGI": "Digi",
148
+ "CISCO_MERAKI": "Cisco Meraki",
149
+ "CISCO": "Cisco",
150
+ "TELTONIKA": "Teltonika",
151
+ "UNKNOWN": "Unknown",
152
+ }
153
+
154
+ def canon_maker_from_text(s: Any) -> str:
155
+ t = norm_text(s)
156
+ for canon, terms in CANON_MAKER.items():
157
+ for term in terms:
158
+ if term in t:
159
+ return canon
160
+ return "UNKNOWN"
161
+
162
+ df_eos["_canon_make"] = df_eos["manufacturer"].apply(canon_maker_from_text) if "manufacturer" in df_eos.columns else "UNKNOWN"
163
+ df_eos["_norm_sku"] = df_eos["sku"].apply(norm_text) if "sku" in df_eos.columns else ""
164
+ df_eos["_norm_desc"] = df_eos["description"].apply(norm_text) if "description" in df_eos.columns else ""
165
+ df_eos["_norm_notes"] = df_eos["notes"].apply(norm_text) if "notes" in df_eos.columns else ""
166
+
167
+ df_dec["_canon_make"] = df_dec["Make"].apply(canon_maker_from_text) if "Make" in df_dec.columns else "UNKNOWN"
168
+ df_dec["_norm_model"] = df_dec["Model"].apply(norm_text) if "Model" in df_dec.columns else ""
169
+ df_dec["_is5g"] = df_dec["Modem Type"].apply(_is_5g) if "Modem Type" in df_dec.columns else False
170
+
171
+
172
+ # ============================
173
+ # Date helpers
174
+ # ============================
175
+ @dataclass
176
+ class ParsedDate:
177
+ raw: str
178
+ kind: str
179
+ value: Optional[date]
180
+
181
+ def parse_date_field(x: Any) -> ParsedDate:
182
+ raw = str(x or "").strip()
183
+ if not raw:
184
+ return ParsedDate(raw="", kind="missing", value=None)
185
+
186
+ if re.fullmatch(r"\d{4}", raw):
187
+ y = int(raw)
188
+ if y == TODAY.year:
189
+ return ParsedDate(raw=raw, kind="year", value=date(y, 1, 1))
190
+ if y < TODAY.year:
191
+ return ParsedDate(raw=raw, kind="year", value=date(y, 1, 1))
192
+ return ParsedDate(raw=raw, kind="year", value=date(y, 12, 31))
193
+
194
+ if re.fullmatch(r"\d{4}-\d{2}", raw):
195
+ try:
196
+ y, m = raw.split("-")
197
+ return ParsedDate(raw=raw, kind="year_month", value=date(int(y), int(m), 1))
198
+ except Exception:
199
+ return ParsedDate(raw=raw, kind="bad", value=None)
200
+
201
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw):
202
+ try:
203
+ dt = datetime.strptime(raw, "%Y-%m-%d").date()
204
+ return ParsedDate(raw=raw, kind="full", value=dt)
205
+ except Exception:
206
+ return ParsedDate(raw=raw, kind="bad", value=None)
207
+
208
+ return ParsedDate(raw=raw, kind="bad", value=None)
209
+
210
+ def display_date(parsed: ParsedDate) -> str:
211
+ if parsed.kind == "missing":
212
+ return "Not listed"
213
+ if parsed.kind == "bad":
214
+ return parsed.raw or "Not listed"
215
+ return parsed.raw
216
+
217
+ def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:
218
+ if eos.value is None and eol.value is None:
219
+ return "Unknown"
220
+ if eol.value is not None and eol.value <= TODAY:
221
+ return "End of Life"
222
+ if eos.value is not None and eos.value <= TODAY:
223
+ return "End of Sale"
224
+ return "Active"
225
+
226
+ def row_to_dates_and_status(life_row: pd.Series) -> Tuple[str, str, str]:
227
+ eos = parse_date_field(life_row.get("end_of_sale"))
228
+ eol = parse_date_field(life_row.get("end_of_life"))
229
+ return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)
230
+
231
+
232
+ # ============================
233
+ # Embeddings + Parsec index
234
+ # ============================
235
+ embedder = SentenceTransformer(EMBED_MODEL_NAME)
236
+
237
+ def extract_pdf_text_pages(path: str) -> List[str]:
238
+ doc = fitz.open(path)
239
+ return [doc[i].get_text("text") for i in range(len(doc))]
240
+
241
+ def build_parsec_cards(pages: List[str]) -> List[str]:
242
+ cards = []
243
+ for p in pages:
244
+ for m in re.finditer(r"Standard\s+SKU:", p):
245
+ start = max(0, m.start() - PARSEC_CONTEXT_BEFORE)
246
+ end = min(len(p), m.start() + PARSEC_CONTEXT_AFTER)
247
+ c = p[start:end].strip()
248
+ if len(c) >= 200:
249
+ cards.append(c)
250
+ out, seen = [], set()
251
+ for c in cards:
252
+ h = hashlib.sha1(c.encode("utf-8")).hexdigest()
253
+ if h not in seen:
254
+ seen.add(h); out.append(c)
255
+ return out
256
+
257
+ parsec_cards = build_parsec_cards(extract_pdf_text_pages(PARSEC_PDF))
258
+ parsec_emb = embedder.encode(parsec_cards, batch_size=64, show_progress_bar=False, normalize_embeddings=True)
259
+ parsec_emb = np.asarray(parsec_emb, dtype=np.float32)
260
+ parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])
261
+ parsec_index.add(parsec_emb)
262
+
263
+
264
+ # ============================
265
+ # Device resolution (exact SKU -> GPT A/B)
266
+ # ============================
267
+ def _label_for_row(i: int) -> str:
268
+ r = df_eos.iloc[i]
269
+ return f"{r.get('sku','')} β€” {r.get('manufacturer','')} β€” {r.get('description','')}"[:220]
270
+
271
+ EOS_LABELS = [_label_for_row(i) for i in range(len(df_eos))]
272
+ EOS_CORPUS = []
273
+ for _, r in df_eos.iterrows():
274
+ EOS_CORPUS.append(" ".join([
275
+ r.get("_norm_sku",""),
276
+ r.get("_canon_make",""),
277
+ r.get("_norm_desc",""),
278
+ r.get("_norm_notes",""),
279
+ ]))
280
+
281
+ def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int,int,str]]:
282
+ q = norm_text(query)
283
+ hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)
284
+ return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]
285
+
286
+ def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> Dict[str, Any]:
287
+ if client is None:
288
+ return {}
289
+ sys = "Pick which router the user meant. Never invent. Return strict JSON only."
290
+ payload = {
291
+ "user_input": user_text,
292
+ "candidates": [{"row_idx": i, "score": s, "label": lbl} for (i,s,lbl) in candidates],
293
+ "rules": [
294
+ "If one candidate is clearly correct, return mode='ok' with row_idx.",
295
+ "If two are plausible, return mode='pick' with top 2 options."
296
+ ],
297
+ "output_schema": {"mode":"ok|pick","row_idx":"int","options":[{"row_idx":"int","label":"string"}]}
298
+ }
299
+ return gpt_json(sys, payload, max_tokens=300)
300
+
301
+ def resolve_device(user_text: str) -> Dict[str, Any]:
302
+ q = norm_text(user_text)
303
+ exact_idxs = df_eos.index[df_eos["_norm_sku"] == q].tolist()
304
+ if len(exact_idxs) == 1:
305
+ return {"mode":"ok","row_idx": int(exact_idxs[0])}
306
+ if len(exact_idxs) > 1:
307
+ opts = [{"row_idx": int(i), "label": EOS_LABELS[int(i)]} for i in exact_idxs[:2]]
308
+ return {"mode":"pick","options": opts}
309
+
310
+ cands = local_candidates(user_text, top_k=6)
311
+ if not cands:
312
+ return {"mode":"not_found"}
313
+
314
+ if cands[0][1] >= 95 and (len(cands) == 1 or (cands[0][1] - cands[1][1]) >= 8):
315
+ return {"mode":"ok","row_idx": cands[0][0]}
316
+
317
+ g = gpt_choose_device(user_text, cands)
318
+ if g.get("mode") == "ok" and isinstance(g.get("row_idx"), int):
319
+ return {"mode":"ok","row_idx": int(g["row_idx"])}
320
+
321
+ if g.get("mode") == "pick":
322
+ opts = g.get("options", []) or []
323
+ opts2 = [{"row_idx": int(o["row_idx"]), "label": str(o["label"])} for o in opts[:2] if "row_idx" in o]
324
+ if opts2:
325
+ return {"mode":"pick","options": opts2}
326
+
327
+ # fallback
328
+ if len(cands) > 1:
329
+ return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]},{"row_idx":cands[1][0],"label":cands[1][2]}]}
330
+ return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]}]}
331
+
332
+
333
+ # ============================
334
+ # Replacements β€” lifecycle CSV is source of truth
335
+ # Fix: always show 4G alternative if lifecycle suggests it (even if Active)
336
+ # ============================
337
+ def _extract_model_token(text: str) -> str:
338
+ s = _safe_str(text)
339
+ if not s:
340
+ return ""
341
+ parts = [p.strip() for p in s.split("|") if p.strip()]
342
+ candidates = parts[::-1] if parts else [s]
343
+
344
+ for cand in candidates:
345
+ # Teltonika family
346
+ m = re.search(r"\bRUT[A-Z]?\d{2,4}\b", cand.upper())
347
+ if m:
348
+ return m.group(0).upper()
349
+ # Digi IX-series
350
+ m = re.search(r"\bIX\d{2}\b", cand, flags=re.IGNORECASE)
351
+ if m:
352
+ return m.group(0).upper()
353
+ # Cradlepoint R/E/S
354
+ m = re.search(r"\b(R\d{3,4}|E\d{3,4}|S\d{3,4})\b", cand, flags=re.IGNORECASE)
355
+ if m:
356
+ return m.group(0).upper()
357
+ # Generic model token
358
+ m = re.search(r"\b[A-Z]{1,6}\d{2,4}[A-Z]?\b", cand.upper())
359
+ if m:
360
+ return m.group(0).upper()
361
+
362
+ return candidates[0][:60]
363
+
364
+ def _device_is_4g(life_row: pd.Series) -> bool:
365
+ t = norm_text(life_row.get("description","")) + " " + norm_text(life_row.get("notes",""))
366
+ return (("lte" in t or "4g" in t) and ("5g" not in t and "nr" not in t))
367
+
368
+ def _candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:
369
+ # Pool within same manufacturer text (not just canon) to support Teltonika etc
370
+ mfr = norm_text(manufacturer)
371
+ pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
372
+ vals = pool["advanced_5g_option"].tolist() if "advanced_5g_option" in pool.columns else []
373
+ out, seen = [], set()
374
+ for v in vals:
375
+ tok = _extract_model_token(v)
376
+ if tok and tok.lower() != "nan" and tok not in seen:
377
+ seen.add(tok); out.append(tok)
378
+ return out
379
+
380
+ def _candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:
381
+ mfr = norm_text(manufacturer)
382
+ pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
383
+ vals = pool["suggested_replacement"].tolist() if "suggested_replacement" in pool.columns else []
384
+ out, seen = [], set()
385
+ for v in vals:
386
+ tok = _extract_model_token(v)
387
+ if tok and tok.lower() != "nan" and tok not in seen:
388
+ seen.add(tok); out.append(tok)
389
+ return out
390
+
391
+ def _gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:
392
+ if client is None or not candidates:
393
+ return ""
394
+ sys = "Pick the best replacement model. Choose only from candidates. Return strict JSON only."
395
+ payload = {
396
+ "old_device": {
397
+ "sku": str(old_row.get("sku","")),
398
+ "manufacturer": str(old_row.get("manufacturer","")),
399
+ "description": str(old_row.get("description","")),
400
+ "need": need,
401
+ },
402
+ "candidates": candidates[:40],
403
+ "output_schema": {"choice":"string"}
404
+ }
405
+ out = gpt_json(sys, payload, max_tokens=240) or {}
406
+ choice = str(out.get("choice","") or "").strip()
407
+ return choice if choice in candidates else ""
408
+
409
+ def _fallback_5g_from_dec(canon_make: str) -> str:
410
+ pool5 = df_dec[(df_dec["_canon_make"] == canon_make) & (df_dec["_is5g"] == True)]
411
+ return str(pool5.iloc[0]["Model"]).strip() if not pool5.empty else ""
412
+
413
+ def pick_replacements_lifecycle(life_row: pd.Series, status: str) -> Dict[str, Any]:
414
+ canon = str(life_row.get("_canon_make","UNKNOWN"))
415
+ manufacturer = str(life_row.get("manufacturer","") or "")
416
+
417
+ is_4g_device = _device_is_4g(life_row)
418
+ needs_4g_repl = is_4g_device and (status in {"End of Sale","End of Life"})
419
+ want_5g = is_4g_device or (status in {"End of Sale","End of Life"})
420
+
421
+ # 4G alternative: ALWAYS if suggested_replacement exists for 4G devices
422
+ repl_4g = "Not applicable"
423
+ if is_4g_device:
424
+ repl_4g = _extract_model_token(_safe_str(life_row.get("suggested_replacement","")))
425
+ if not repl_4g:
426
+ cand4 = _candidate_4g_models_from_lifecycle(manufacturer)
427
+ repl_4g = _gpt_pick_from_candidates(life_row, cand4, "4G alternative") or (cand4[0] if cand4 else "")
428
+ if not repl_4g:
429
+ repl_4g = "Not applicable"
430
+
431
+ # 5G replacement: ALWAYS when want_5g is true
432
+ repl_5g = "Not applicable"
433
+ if want_5g:
434
+ repl_5g = _extract_model_token(_safe_str(life_row.get("advanced_5g_option","")))
435
+ if not repl_5g:
436
+ cand5 = _candidate_5g_models_from_lifecycle(manufacturer)
437
+ repl_5g = _gpt_pick_from_candidates(life_row, cand5, "5G replacement/upgrade") or (cand5[0] if cand5 else "")
438
+ if not repl_5g:
439
+ # last resort: dec catalog fallback
440
+ repl_5g = _fallback_5g_from_dec(canon)
441
+
442
+ if repl_5g.lower() == "nan":
443
+ repl_5g = ""
444
+
445
+ return {
446
+ "repl_4g": repl_4g,
447
+ "repl_5g": repl_5g,
448
+ "why": "Lifecycle replacements (GPT fallback when missing).",
449
+ "sources": ["lifecycle_csv"] + (["gpt"] if client else []) + (["dec_fallback"] if (want_5g and not repl_5g) else []),
450
+ }
451
+
452
+
453
+ # ============================
454
+ # Antennas (Parsec-only; family name extraction)
455
+ # ============================
456
+ PARSEC_FAMILY_WORDS = {
457
+ "chinook","labrador","boxer","bloodhound","husky","beagle","mastiff","collie",
458
+ "shepherd","belgian","australian","terrier","pyrenees"
459
+ }
460
+ BAD_NAME_MARKERS = {
461
+ "customization", "standard connectors", "connectors", "features", "benefits",
462
+ "specifications", "mechanical", "electrical", "mounting", "accessories",
463
+ "description:", "standard sku"
464
+ }
465
+
466
+ def _clean_line(s: str) -> str:
467
+ s = re.sub(r"\s+", " ", str(s or "").strip())
468
+ if re.fullmatch(r"-[a-z0-9]+", s.lower()):
469
+ return ""
470
+ return s
471
+
472
+ def _is_bad_name_line(line: str) -> bool:
473
+ low = line.lower()
474
+ if any(m in low for m in BAD_NAME_MARKERS):
475
+ return True
476
+ if re.search(r"\b-[a-z0-9]{1,4}\b", low) and len(low) <= 25:
477
+ return True
478
+ return False
479
+
480
+ def _family_from_line(line: str) -> str:
481
+ low = line.lower()
482
+ for fam in PARSEC_FAMILY_WORDS:
483
+ if fam in low:
484
+ return fam.capitalize()
485
+ return ""
486
+
487
+ def _parsec_name_from_card(card_text: str) -> str:
488
+ lines = [_clean_line(ln) for ln in str(card_text or "").splitlines()]
489
+ lines = [ln for ln in lines if ln]
490
+
491
+ for ln in lines:
492
+ if _is_bad_name_line(ln):
493
+ continue
494
+ fam = _family_from_line(ln)
495
+ if fam:
496
+ return fam
497
+
498
+ # fallback near SKU line
499
+ sku_i = None
500
+ for i, ln in enumerate(lines):
501
+ if "standard sku" in ln.lower():
502
+ sku_i = i
503
+ break
504
+ if sku_i is not None:
505
+ window = lines[max(0, sku_i - 12):sku_i]
506
+ for ln in reversed(window):
507
+ if _is_bad_name_line(ln):
508
+ continue
509
+ if 3 <= len(ln) <= 40 and re.search(r"[A-Za-z]", ln):
510
+ return ln.split()[0].capitalize()
511
+
512
+ return "Parsec antenna"
513
+
514
+ def _parsec_part_from_card(t: str) -> str:
515
+ m = re.search(r"Standard\s+SKU:\s*([A-Z0-9]+)", t)
516
+ return m.group(1).strip() if m else ""
517
+
518
+ def _parsec_desc_from_card(t: str) -> str:
519
+ m = re.search(r"Description:\s*(.+?)(?:\n|$)", t, flags=re.IGNORECASE)
520
+ return re.sub(r"\s+"," ",m.group(1).strip())[:220] if m else ""
521
+
522
+ def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:
523
+ qv = embedder.encode([query], normalize_embeddings=True)
524
+ qv = np.asarray(qv, dtype=np.float32)
525
+ scores, ids = parsec_index.search(qv, top_k)
526
+ out = []
527
+ for sc, i in zip(scores[0].tolist(), ids[0].tolist()):
528
+ if 0 <= int(i) < len(parsec_cards):
529
+ card = parsec_cards[int(i)]
530
+ out.append({
531
+ "score": float(sc),
532
+ "name": _parsec_name_from_card(card),
533
+ "part_number": _parsec_part_from_card(card),
534
+ "description": _parsec_desc_from_card(card),
535
+ })
536
+ return out
537
+
538
+ def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:
539
+ q_stationary = f"{router_model} {tech} {mimo} omni stationary outdoor Parsec"
540
+ q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile Parsec"
541
+ cand_stationary = parsec_retrieve(q_stationary, top_k=10)
542
+ cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)
543
+
544
+ # deterministic fallback if no GPT
545
+ s = cand_stationary[0] if cand_stationary else {"name":"Parsec antenna","part_number":"","description":""}
546
+ v = cand_vehicle[0] if cand_vehicle else {"name":"Parsec antenna","part_number":"","description":""}
547
+ s.update({"mimo": mimo, "why": "Stationary omni best match."})
548
+ v.update({"mimo": mimo, "why": "Vehicle omni best match."})
549
+ return {"stationary_omni": s, "vehicle_omni": v, "sources":["parsec_rag"]}
550
+
551
+
552
+ # ============================
553
+ # Feature table + GPT fill for missing fields
554
+ # ============================
555
+ FEATURE_COLS = ["Name","Modem technology","WiFi","Ports","Antennas","Ruggedness","Use case"]
556
+
557
+ def dec_features_by_model(model: str, canon_make: str) -> Dict[str, str]:
558
+ if not model or model in {"Not applicable","Not listed"}:
559
+ return {k:"Not listed" for k in FEATURE_COLS}
560
+ pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
561
+ if pool.empty:
562
+ return {k:"Not listed" for k in FEATURE_COLS}
563
+ hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
564
+ if not hit or hit[1] < MATCH_OK:
565
+ return {k:"Not listed" for k in FEATURE_COLS}
566
+ r = pool.iloc[int(hit[2])]
567
+ ports = f"WAN: {r.get('WAN ports and speed','')} | LAN: {r.get('LAN ports and speed','')}"
568
+ return {
569
+ "Name": str(r.get("Model","")),
570
+ "Modem technology": str(r.get("Modem Type","")),
571
+ "WiFi": str(r.get("WiFi type","")),
572
+ "Ports": ports,
573
+ "Antennas": str(r.get("Antennas (internal/external/both)","")),
574
+ "Ruggedness": str(r.get("Ruggedization","")),
575
+ "Use case": str(r.get("Primary use case","")),
576
+ }
577
+
578
+ def gpt_fill_features(device_label: str, feats: Dict[str,str], context: str) -> Dict[str,str]:
579
+ missing = [k for k,v in feats.items() if (not v) or v.strip().lower() in {"not listed","nan"}]
580
+ if client is None or not missing:
581
+ return feats
582
+ sys = "Fill missing router feature fields. Return strict JSON only."
583
+ payload = {
584
+ "device": device_label,
585
+ "known": feats,
586
+ "context": context[:2000],
587
+ "fill_only": missing,
588
+ "rules": ["Fill only requested fields. Best guess if needed. Return JSON only."],
589
+ "output_schema": {k:"string" for k in missing}
590
+ }
591
+ out = gpt_json(sys, payload, max_tokens=350) or {}
592
+ for k in missing:
593
+ v = str(out.get(k,"") or "").strip()
594
+ if v:
595
+ feats[k] = v
596
+ return feats
597
+
598
+ def current_features_guess(life_row: pd.Series) -> Dict[str,str]:
599
+ sku = str(life_row.get("sku","") or "").strip()
600
+ desc = str(life_row.get("description","") or "").strip()
601
+ notes = str(life_row.get("notes","") or "").strip()
602
+ base = {
603
+ "Name": sku,
604
+ "Modem technology": "4G" if _device_is_4g(life_row) else ("5G" if ("5g" in (desc+notes).lower() or "nr" in (desc+notes).lower()) else "Not listed"),
605
+ "WiFi": "Not listed",
606
+ "Ports": "Not listed",
607
+ "Antennas": "Not listed",
608
+ "Ruggedness": "Not listed",
609
+ "Use case": "Not listed",
610
+ }
611
+ return gpt_fill_features("Current device", base, f"{desc}\n{notes}")
612
+
613
+ def build_features_table(cur: Dict[str,str], r4: Dict[str,str], r5: Dict[str,str]) -> str:
614
+ cols = ["Device", "Modem technology", "WiFi", "Ports", "Antennas", "Ruggedness", "Use case"]
615
+ header = "| " + " | ".join(cols) + " |"
616
+ sep = "| " + " | ".join(["---"]*len(cols)) + " |"
617
+ def row(name: str, feats: Dict[str,str]) -> str:
618
+ return "| " + " | ".join([
619
+ name,
620
+ feats.get("Modem technology","Not listed"),
621
+ feats.get("WiFi","Not listed"),
622
+ feats.get("Ports","Not listed"),
623
+ feats.get("Antennas","Not listed"),
624
+ feats.get("Ruggedness","Not listed"),
625
+ feats.get("Use case","Not listed"),
626
+ ]) + " |"
627
+ return "\n".join([header, sep, row("Current", cur), row("4G alternative", r4), row("5G replacement", r5)])
628
+
629
+
630
+ # ============================
631
+ # Output + Gradio
632
+ # ============================
633
+ def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
634
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
635
+ current_name = f"{life_row.get('sku','')} β€” {life_row.get('description','')}".strip(" β€”")
636
+
637
+ st = ant.get("stationary_omni", {})
638
+ vh = ant.get("vehicle_omni", {})
639
+
640
+ cur_feats = current_features_guess(life_row)
641
+ r4_feats = dec_features_by_model(repl.get("repl_4g",""), canon_make)
642
+ r5_feats = dec_features_by_model(repl.get("repl_5g",""), canon_make)
643
+
644
+ # If dec doesn't know the model, ask GPT to fill missing cells (best guess)
645
+ if client is not None:
646
+ r4_feats = gpt_fill_features("4G alternative", r4_feats, f"Model: {repl.get('repl_4g','')}\nMake: {canon_make}")
647
+ r5_feats = gpt_fill_features("5G replacement", r5_feats, f"Model: {repl.get('repl_5g','')}\nMake: {canon_make}")
648
+
649
+ table_md = build_features_table(cur_feats, r4_feats, r5_feats)
650
+
651
+ lines = []
652
+ lines.append(f"1. Current device: **{current_name}**")
653
+ lines.append(f"2. Status: **{status}**")
654
+ lines.append(f"3. End of Sale date: **{eos}**")
655
+ lines.append(f"4. End of Life date: **{eol}**")
656
+ lines.append(f"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**")
657
+ lines.append(f"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**")
658
+ lines.append("7. Antenna options (Parsec-only):")
659
+ lines.append(f" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) β€” {st.get('description','')} β€” MIMO: {st.get('mimo','')} β€” {st.get('why','')}")
660
+ lines.append(f" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) β€” {vh.get('description','')} β€” MIMO: {vh.get('mimo','')} β€” {vh.get('why','')}")
661
+ lines.append("8. Recommended features table:")
662
+ lines.append(table_md)
663
+ lines.append("\nSources (debug):")
664
+ for s in repl.get("sources", []) if isinstance(repl.get("sources"), list) else []:
665
+ lines.append(f"- {s}")
666
+ lines.append("- ParsecCatalog.pdf (local RAG)")
667
+ lines.append("- routers_eos_eol_by_sku.csv (replacements)")
668
+ lines.append("- dec2025routers.csv (features)")
669
+ return "\n".join(lines)
670
+
671
+ def run_lookup(user_text: str, st: Dict[str,Any]):
672
+ user_text = str(user_text or "").strip()
673
+ if not user_text:
674
+ return "Enter a router SKU/model.", gr.update(visible=False), gr.update(visible=False), {}
675
+
676
+ res = resolve_device(user_text)
677
+ if res.get("mode") == "pick":
678
+ opts = res.get("options", [])
679
+ choices = [o["label"] for o in opts]
680
+ st2 = {"mode":"pick","options": opts}
681
+ 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), st2
682
+
683
+ if res.get("mode") != "ok":
684
+ return "Not found.", gr.update(visible=False), gr.update(visible=False), {}
685
+
686
+ life_row = df_eos.iloc[int(res["row_idx"])]
687
+ eos, eol, status = row_to_dates_and_status(life_row)
688
+
689
+ repl = pick_replacements_lifecycle(life_row, status)
690
+
691
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") not in {"Not applicable","Not listed"} else ("4G" if _device_is_4g(life_row) else "Unknown")
692
+ mimo_guess = "4x4" if tech == "5G" else "2x2"
693
+ ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo_guess)
694
+
695
+ return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}
696
+
697
+ def use_selection(selected_label: str, st: Dict[str,Any]):
698
+ if not st or st.get("mode") != "pick":
699
+ return "Run a search first.", gr.update(visible=False), gr.update(visible=False), {}
700
+ if not selected_label:
701
+ return "Pick A or B first.", gr.update(visible=True), gr.update(visible=True), st
702
+
703
+ chosen_row = None
704
+ for o in st.get("options", []):
705
+ if o.get("label") == selected_label:
706
+ chosen_row = int(o["row_idx"])
707
+ break
708
+ if chosen_row is None:
709
+ return "Pick a valid option.", gr.update(visible=True), gr.update(visible=True), st
710
+
711
+ life_row = df_eos.iloc[int(chosen_row)]
712
+ eos, eol, status = row_to_dates_and_status(life_row)
713
+ repl = pick_replacements_lifecycle(life_row, status)
714
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") not in {"Not applicable","Not listed"} else ("4G" if _device_is_4g(life_row) else "Unknown")
715
+ mimo_guess = "4x4" if tech == "5G" else "2x2"
716
+ ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo_guess)
717
+
718
+ return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}
719
+
720
+ with gr.Blocks(title="Only-Routers") as demo:
721
+ gr.Markdown("## Only-Routers\nEnter a router SKU/model. If ambiguous, you’ll get A/B choices.")
722
+ user_text = gr.Textbox(label="Router SKU or model", placeholder="Examples: IBR650B, AER1600, ES450, WR21, RUT240", lines=1)
723
+ st = gr.State({})
724
+
725
+ check_btn = gr.Button("Check", variant="primary")
726
+ pick_dd = gr.Dropdown(label="Pick A or B", choices=[], visible=False)
727
+ use_btn = gr.Button("Use selection", visible=False)
728
+
729
+ output_md = gr.Markdown()
730
+
731
+ check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st])
732
+ use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st])
733
+
734
+ demo.launch()
Updates/requirements_hf_fixed.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ gradio==4.44.1
2
+ gradio_client==0.10.2
3
+ pandas>=2.0.0
4
+ numpy>=1.24.0
5
+ rapidfuzz>=3.0.0
6
+ sentence-transformers>=2.2.2
7
+ faiss-cpu>=1.7.4
8
+ pymupdf>=1.23.0
9
+ openai>=1.40.0
app.py CHANGED
@@ -6,7 +6,7 @@ import hashlib
6
  import tempfile
7
  from dataclasses import dataclass
8
  from datetime import datetime, date
9
- from typing import Dict, List, Optional, Tuple, Any
10
 
11
  import numpy as np
12
  import pandas as pd
@@ -33,9 +33,6 @@ EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
33
  PARSEC_CONTEXT_BEFORE = 900
34
  PARSEC_CONTEXT_AFTER = 1600
35
 
36
- CACHE_DIR = os.path.join(os.getcwd(), ".onlyrouters_cache")
37
- os.makedirs(CACHE_DIR, exist_ok=True)
38
-
39
 
40
  # ============================
41
  # OpenAI client (HF Space secret: OPENAI_API_KEY)
@@ -44,6 +41,25 @@ API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
44
  client = OpenAI(api_key=API_KEY) if API_KEY else None
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  # ============================
48
  # Utilities
49
  # ============================
@@ -58,22 +74,22 @@ def norm_text(s: Any) -> str:
58
  s = re.sub(r"\s+", " ", s).strip()
59
  return s
60
 
61
- def _safe_str(v: Any) -> str:
62
  if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):
63
  return ""
64
  return str(v).strip()
65
 
66
- def _is_5g(modem_type: Any) -> bool:
67
  s = norm_text(modem_type)
68
  return ("5g" in s) or ("nr" in s)
69
 
70
- def _json_load_safe(s: str) -> Dict[str, Any]:
71
  try:
72
  return json.loads(s)
73
  except Exception:
74
  return {}
75
 
76
- def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 700) -> Dict[str, Any]:
77
  if client is None:
78
  return {}
79
  resp = client.responses.create(
@@ -85,27 +101,7 @@ def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 700) -> Dic
85
  ],
86
  max_output_tokens=max_tokens,
87
  )
88
- return _json_load_safe(getattr(resp, "output_text", "") or "")
89
-
90
- # ----------------------------
91
- # Gradio state helpers (string JSON to avoid schema issues on Spaces)
92
- # ----------------------------
93
- def _state_load(st_json: str) -> Dict[str, Any]:
94
- try:
95
- if not st_json:
96
- return {}
97
- if isinstance(st_json, str):
98
- return json.loads(st_json)
99
- return {}
100
- except Exception:
101
- return {}
102
-
103
- def _state_dump(st: Dict[str, Any]) -> str:
104
- try:
105
- return json.dumps(st or {}, ensure_ascii=False)
106
- except Exception:
107
- return "{}"
108
-
109
 
110
 
111
  # ============================
@@ -126,7 +122,7 @@ df_eos = pd.read_csv(EOS_PATH).copy()
126
  df_dec = pd.read_csv(DEC_PATH).copy()
127
 
128
  # Region filter: keep USA / North America / blank / not specified
129
- def _region_ok(x: Any) -> bool:
130
  s = str(x or "").strip().lower()
131
  if not s:
132
  return True
@@ -143,9 +139,9 @@ def _region_ok(x: Any) -> bool:
143
  return False
144
 
145
  if "region" in df_eos.columns:
146
- df_eos = df_eos[df_eos["region"].apply(_region_ok)].reset_index(drop=True)
147
 
148
- # Optional "Device Type"
149
  device_type_col = None
150
  for c in df_eos.columns:
151
  if norm_text(c) == "device type":
@@ -178,7 +174,7 @@ df_eos["_norm_notes"] = df_eos["notes"].apply(norm_text) if "notes" in df_eos.co
178
 
179
  df_dec["_canon_make"] = df_dec["Make"].apply(canon_maker_from_text) if "Make" in df_dec.columns else "UNKNOWN"
180
  df_dec["_norm_model"] = df_dec["Model"].apply(norm_text) if "Model" in df_dec.columns else ""
181
- df_dec["_is5g"] = df_dec["Modem Type"].apply(_is_5g) if "Modem Type" in df_dec.columns else False
182
 
183
 
184
  # ============================
@@ -219,12 +215,12 @@ def parse_date_field(x: Any) -> ParsedDate:
219
 
220
  return ParsedDate(raw=raw, kind="bad", value=None)
221
 
222
- def display_date(parsed: ParsedDate) -> str:
223
- if parsed.kind == "missing":
224
  return "Not listed"
225
- if parsed.kind == "bad":
226
- return parsed.raw or "Not listed"
227
- return parsed.raw
228
 
229
  def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:
230
  if eos.value is None and eol.value is None:
@@ -235,9 +231,9 @@ def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:
235
  return "End of Sale"
236
  return "Active"
237
 
238
- def row_to_dates_and_status(life_row: pd.Series) -> Tuple[str, str, str]:
239
- eos = parse_date_field(life_row.get("end_of_sale"))
240
- eol = parse_date_field(life_row.get("end_of_life"))
241
  return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)
242
 
243
 
@@ -276,21 +272,16 @@ parsec_index.add(parsec_emb)
276
  # ============================
277
  # Device resolution (exact SKU -> GPT A/B)
278
  # ============================
279
- def _label_for_row(i: int) -> str:
280
  r = df_eos.iloc[i]
281
  return f"{r.get('sku','')} β€” {r.get('manufacturer','')} β€” {r.get('description','')}"[:220]
282
 
283
- EOS_LABELS = [_label_for_row(i) for i in range(len(df_eos))]
284
  EOS_CORPUS = []
285
  for _, r in df_eos.iterrows():
286
- EOS_CORPUS.append(" ".join([
287
- r.get("_norm_sku",""),
288
- r.get("_canon_make",""),
289
- r.get("_norm_desc",""),
290
- r.get("_norm_notes",""),
291
- ]))
292
-
293
- def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int,int,str]]:
294
  q = norm_text(query)
295
  hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)
296
  return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]
@@ -312,6 +303,8 @@ def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> D
312
 
313
  def resolve_device(user_text: str) -> Dict[str, Any]:
314
  q = norm_text(user_text)
 
 
315
  exact_idxs = df_eos.index[df_eos["_norm_sku"] == q].tolist()
316
  if len(exact_idxs) == 1:
317
  return {"mode":"ok","row_idx": int(exact_idxs[0])}
@@ -336,7 +329,7 @@ def resolve_device(user_text: str) -> Dict[str, Any]:
336
  if opts2:
337
  return {"mode":"pick","options": opts2}
338
 
339
- # fallback
340
  if len(cands) > 1:
341
  return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]},{"row_idx":cands[1][0],"label":cands[1][2]}]}
342
  return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]}]}
@@ -345,13 +338,12 @@ def resolve_device(user_text: str) -> Dict[str, Any]:
345
  # ============================
346
  # Replacements β€” lifecycle CSV source of truth
347
  # ============================
348
- def _extract_model_token(text: str) -> str:
349
- s = _safe_str(text)
350
  if not s:
351
  return ""
352
  parts = [p.strip() for p in s.split("|") if p.strip()]
353
  candidates = parts[::-1] if parts else [s]
354
-
355
  for cand in candidates:
356
  m = re.search(r"\bRUT[A-Z]?\d{2,4}\b", cand.upper())
357
  if m:
@@ -365,36 +357,35 @@ def _extract_model_token(text: str) -> str:
365
  m = re.search(r"\b[A-Z]{1,6}\d{2,4}[A-Z]?\b", cand.upper())
366
  if m:
367
  return m.group(0).upper()
368
-
369
  return candidates[0][:60]
370
 
371
- def _device_is_4g(life_row: pd.Series) -> bool:
372
- t = norm_text(life_row.get("description","")) + " " + norm_text(life_row.get("notes",""))
373
  return (("lte" in t or "4g" in t) and ("5g" not in t and "nr" not in t))
374
 
375
- def _candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:
376
  mfr = norm_text(manufacturer)
377
  pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
378
  vals = pool["advanced_5g_option"].tolist() if "advanced_5g_option" in pool.columns else []
379
  out, seen = [], set()
380
  for v in vals:
381
- tok = _extract_model_token(v)
382
  if tok and tok.lower() != "nan" and tok not in seen:
383
  seen.add(tok); out.append(tok)
384
  return out
385
 
386
- def _candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:
387
  mfr = norm_text(manufacturer)
388
  pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
389
  vals = pool["suggested_replacement"].tolist() if "suggested_replacement" in pool.columns else []
390
  out, seen = [], set()
391
  for v in vals:
392
- tok = _extract_model_token(v)
393
  if tok and tok.lower() != "nan" and tok not in seen:
394
  seen.add(tok); out.append(tok)
395
  return out
396
 
397
- def _gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:
398
  if client is None or not candidates:
399
  return ""
400
  sys = "Pick the best replacement model. Choose only from candidates. Return strict JSON only."
@@ -412,43 +403,45 @@ def _gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: s
412
  choice = str(out.get("choice","") or "").strip()
413
  return choice if choice in candidates else ""
414
 
415
- def _fallback_5g_from_dec(canon_make: str) -> str:
416
  pool5 = df_dec[(df_dec["_canon_make"] == canon_make) & (df_dec["_is5g"] == True)]
417
  return str(pool5.iloc[0]["Model"]).strip() if not pool5.empty else ""
418
 
419
- def pick_replacements_lifecycle(life_row: pd.Series, status: str, use_gpt: bool = True) -> Dict[str, Any]:
420
- canon = str(life_row.get("_canon_make","UNKNOWN"))
421
- manufacturer = str(life_row.get("manufacturer","") or "")
422
 
423
- is_4g_device = _device_is_4g(life_row)
424
- want_5g = is_4g_device or (status in {"End of Sale","End of Life"})
425
 
 
426
  repl_4g = "Not applicable"
427
- if is_4g_device:
428
- repl_4g = _extract_model_token(_safe_str(life_row.get("suggested_replacement","")))
429
  if not repl_4g:
430
- cand4 = _candidate_4g_models_from_lifecycle(manufacturer)
431
- repl_4g = (_gpt_pick_from_candidates(life_row, cand4, "4G alternative") if (use_gpt and client) else "") or (cand4[0] if cand4 else "")
432
  if not repl_4g:
433
  repl_4g = "Not applicable"
434
 
435
- repl_5g = "Not applicable"
 
436
  if want_5g:
437
- repl_5g = _extract_model_token(_safe_str(life_row.get("advanced_5g_option","")))
438
  if not repl_5g:
439
- cand5 = _candidate_5g_models_from_lifecycle(manufacturer)
440
- repl_5g = (_gpt_pick_from_candidates(life_row, cand5, "5G replacement/upgrade") if (use_gpt and client) else "") or (cand5[0] if cand5 else "")
441
  if not repl_5g:
442
- repl_5g = _fallback_5g_from_dec(canon)
443
 
444
  if repl_5g.lower() == "nan":
445
- repl_5g = ""
446
 
447
  return {
448
  "repl_4g": repl_4g,
449
- "repl_5g": repl_5g if repl_5g else "Not listed",
450
  "why": "Lifecycle replacements (GPT fallback when missing).",
451
- "sources": ["lifecycle_csv"] + (["gpt"] if (use_gpt and client) else []) + (["dec_fallback"] if (want_5g and (repl_5g == "Not listed" or repl_5g == "")) else []),
452
  }
453
 
454
 
@@ -465,13 +458,13 @@ BAD_NAME_MARKERS = {
465
  "description:", "standard sku"
466
  }
467
 
468
- def _clean_line(s: str) -> str:
469
  s = re.sub(r"\s+", " ", str(s or "").strip())
470
  if re.fullmatch(r"-[a-z0-9]+", s.lower()):
471
  return ""
472
  return s
473
 
474
- def _is_bad_name_line(line: str) -> bool:
475
  low = line.lower()
476
  if any(m in low for m in BAD_NAME_MARKERS):
477
  return True
@@ -479,28 +472,28 @@ def _is_bad_name_line(line: str) -> bool:
479
  return True
480
  return False
481
 
482
- def _family_from_line(line: str) -> str:
483
  low = line.lower()
484
  for fam in PARSEC_FAMILY_WORDS:
485
  if fam in low:
486
  return fam.capitalize()
487
  return ""
488
 
489
- def _parsec_connectors_from_card(t: str) -> str:
490
  m = re.search(r"Standard\s+Connectors:\s*(.+)", t, flags=re.IGNORECASE)
491
  if m:
492
  val = re.sub(r"\s+", " ", m.group(1).strip())
493
  return val[:80]
494
  return ""
495
 
496
- def _parsec_name_from_card(card_text: str) -> str:
497
- lines = [_clean_line(ln) for ln in str(card_text or "").splitlines()]
498
  lines = [ln for ln in lines if ln]
499
 
500
  for ln in lines:
501
- if _is_bad_name_line(ln):
502
  continue
503
- fam = _family_from_line(ln)
504
  if fam:
505
  return fam
506
 
@@ -512,18 +505,18 @@ def _parsec_name_from_card(card_text: str) -> str:
512
  if sku_i is not None:
513
  window = lines[max(0, sku_i - 12):sku_i]
514
  for ln in reversed(window):
515
- if _is_bad_name_line(ln):
516
  continue
517
  if 3 <= len(ln) <= 40 and re.search(r"[A-Za-z]", ln):
518
  return ln.split()[0].capitalize()
519
 
520
  return "Parsec antenna"
521
 
522
- def _parsec_part_from_card(t: str) -> str:
523
  m = re.search(r"Standard\s+SKU:\s*([A-Z0-9]+)", t)
524
  return m.group(1).strip() if m else ""
525
 
526
- def _parsec_desc_from_card(t: str) -> str:
527
  m = re.search(r"Description:\s*(.+?)(?:\n|$)", t, flags=re.IGNORECASE)
528
  return re.sub(r"\s+"," ",m.group(1).strip())[:220] if m else ""
529
 
@@ -537,31 +530,30 @@ def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:
537
  card = parsec_cards[int(i)]
538
  out.append({
539
  "score": float(sc),
540
- "name": _parsec_name_from_card(card),
541
- "part_number": _parsec_part_from_card(card),
542
- "description": _parsec_desc_from_card(card),
543
- "connectors": _parsec_connectors_from_card(card),
544
  })
545
  return out
546
 
547
- def infer_mimo_for_replacement(model: str, canon_make: str) -> str:
 
548
  if not model or model in {"Not applicable","Not listed"}:
549
  return "2x2"
550
  pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
551
- if pool.empty:
552
- return "4x4" if ("5g" in model.lower()) else "2x2"
553
- hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
554
- if hit and hit[1] >= MATCH_OK:
555
- row = pool.iloc[int(hit[2])]
556
- txt = (str(row.get("Antennas (internal/external/both)","")) + " " + str(row.get("Modem Type",""))).lower()
557
- if "4x4" in txt or "4 x 4" in txt:
558
- return "4x4"
559
- return "4x4" if ("5g" in model.lower()) else "2x2"
560
 
561
  def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:
562
  q_stationary = f"{router_model} {tech} {mimo} omni stationary outdoor Parsec"
563
  q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile Parsec"
564
-
565
  cand_stationary = parsec_retrieve(q_stationary, top_k=10)
566
  cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)
567
 
@@ -573,171 +565,47 @@ def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, An
573
 
574
 
575
  # ============================
576
- # Feature table + GPT fill for missing fields (not lazy: fill missing)
577
  # ============================
578
- FEATURE_COLS = ["Name","Modem technology","WiFi","Ports","Antennas","Ruggedness","Use case"]
579
-
580
- def dec_features_by_model(model: str, canon_make: str) -> Dict[str, str]:
581
- if not model or model in {"Not applicable","Not listed"}:
582
- return {k:"Not listed" for k in FEATURE_COLS}
583
- pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
584
- if pool.empty:
585
- return {k:"Not listed" for k in FEATURE_COLS}
586
- hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
587
- if not hit or hit[1] < MATCH_OK:
588
- return {k:"Not listed" for k in FEATURE_COLS}
589
- r = pool.iloc[int(hit[2])]
590
- ports = f"WAN: {r.get('WAN ports and speed','')} | LAN: {r.get('LAN ports and speed','')}"
591
- return {
592
- "Name": str(r.get("Model","")),
593
- "Modem technology": str(r.get("Modem Type","")),
594
- "WiFi": str(r.get("WiFi type","")),
595
- "Ports": ports,
596
- "Antennas": str(r.get("Antennas (internal/external/both)","")),
597
- "Ruggedness": str(r.get("Ruggedization","")),
598
- "Use case": str(r.get("Primary use case","")),
599
- }
600
-
601
- def gpt_fill_features(device_label: str, feats: Dict[str,str], context: str) -> Dict[str,str]:
602
- missing = [k for k,v in feats.items() if (not v) or v.strip().lower() in {"not listed","nan"}]
603
- if client is None or not missing:
604
- return feats
605
- sys = "Fill missing router feature fields. Return strict JSON only."
606
- payload = {
607
- "device": device_label,
608
- "known": feats,
609
- "context": context[:2000],
610
- "fill_only": missing,
611
- "rules": ["Fill only requested fields. Best guess if needed. Return JSON only."],
612
- "output_schema": {k:"string" for k in missing}
613
- }
614
- out = gpt_json(sys, payload, max_tokens=350) or {}
615
- for k in missing:
616
- v = str(out.get(k,"") or "").strip()
617
- if v:
618
- feats[k] = v
619
- return feats
620
-
621
- def current_features_guess(life_row: pd.Series) -> Dict[str,str]:
622
- sku = str(life_row.get("sku","") or "").strip()
623
- desc = str(life_row.get("description","") or "").strip()
624
- notes = str(life_row.get("notes","") or "").strip()
625
- base = {
626
- "Name": sku,
627
- "Modem technology": "4G" if _device_is_4g(life_row) else ("5G" if ("5g" in (desc+notes).lower() or "nr" in (desc+notes).lower()) else "Not listed"),
628
- "WiFi": "Not listed",
629
- "Ports": "Not listed",
630
- "Antennas": "Not listed",
631
- "Ruggedness": "Not listed",
632
- "Use case": "Not listed",
633
- }
634
- return gpt_fill_features("Current device", base, f"{desc}\n{notes}")
635
-
636
- def build_features_table(cur: Dict[str,str], r4: Dict[str,str], r5: Dict[str,str]) -> str:
637
- cols = ["Device", "Modem technology", "WiFi", "Ports", "Antennas", "Ruggedness", "Use case"]
638
- header = "| " + " | ".join(cols) + " |"
639
- sep = "| " + " | ".join(["---"]*len(cols)) + " |"
640
- def row(name: str, feats: Dict[str,str]) -> str:
641
- return "| " + " | ".join([
642
- name,
643
- feats.get("Modem technology","Not listed"),
644
- feats.get("WiFi","Not listed"),
645
- feats.get("Ports","Not listed"),
646
- feats.get("Antennas","Not listed"),
647
- feats.get("Ruggedness","Not listed"),
648
- feats.get("Use case","Not listed"),
649
- ]) + " |"
650
- return "\n".join([header, sep, row("Current", cur), row("4G alternative", r4), row("5G replacement", r5)])
651
-
652
-
653
- # ============================
654
- # Output + install-ready checklist (Feature #9)
655
- # ============================
656
- def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
657
- canon_make = str(life_row.get("_canon_make","UNKNOWN"))
658
- current_name = f"{life_row.get('sku','')} β€” {life_row.get('description','')}".strip(" β€”")
659
-
660
- st = ant.get("stationary_omni", {})
661
- vh = ant.get("vehicle_omni", {})
662
-
663
- cur_feats = current_features_guess(life_row)
664
- r4_feats = dec_features_by_model(repl.get("repl_4g",""), canon_make)
665
- r5_feats = dec_features_by_model(repl.get("repl_5g",""), canon_make)
666
- if client is not None:
667
- r4_feats = gpt_fill_features("4G alternative", r4_feats, f"Model: {repl.get('repl_4g','')}\nMake: {canon_make}")
668
- r5_feats = gpt_fill_features("5G replacement", r5_feats, f"Model: {repl.get('repl_5g','')}\nMake: {canon_make}")
669
-
670
- table_md = build_features_table(cur_feats, r4_feats, r5_feats)
671
-
672
- lines = []
673
- lines.append(f"1. Current device: **{current_name}**")
674
- lines.append(f"2. Status: **{status}**")
675
- lines.append(f"3. End of Sale date: **{eos}**")
676
- lines.append(f"4. End of Life date: **{eol}**")
677
- lines.append(f"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**")
678
- lines.append(f"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**")
679
- lines.append("7. Antenna options (Parsec-only):")
680
- conn_s = f" | Conn: {st.get('connectors','')}" if st.get("connectors") else ""
681
- conn_v = f" | Conn: {vh.get('connectors','')}" if vh.get("connectors") else ""
682
- lines.append(f" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) β€” {st.get('description','')} β€” MIMO: {st.get('mimo','')}{conn_s} β€” {st.get('why','')}")
683
- lines.append(f" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) β€” {vh.get('description','')} β€” MIMO: {vh.get('mimo','')}{conn_v} β€” {vh.get('why','')}")
684
- lines.append("8. Recommended features table:")
685
- lines.append(table_md)
686
-
687
- lines.append("\nSources (debug):")
688
- for s in repl.get("sources", []) if isinstance(repl.get("sources"), list) else []:
689
- lines.append(f"- {s}")
690
- lines.append("- ParsecCatalog.pdf (local RAG)")
691
- lines.append("- routers_eos_eol_by_sku.csv (replacements)")
692
- lines.append("- dec2025routers.csv (features)")
693
- return "\n".join(lines)
694
-
695
- def install_ready_checklist(life_row: pd.Series, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
696
- current_sku = str(life_row.get("sku","") or "").strip()
697
- repl4 = str(repl.get("repl_4g","") or "")
698
- repl5 = str(repl.get("repl_5g","") or "")
699
  st = ant.get("stationary_omni", {})
700
  vh = ant.get("vehicle_omni", {})
701
-
702
  if client is not None:
703
- sys = "Create a short, install-ready checklist for a Verizon rep. Keep it scannable. Return markdown only."
704
  payload = {
705
  "current_device": current_sku,
706
- "replacements": {"4g_alternative": repl4, "5g_replacement": repl5},
707
  "antennas": {"stationary": st, "vehicle": vh},
708
- "rules": [
709
- "Include: router(s), antennas, connector/cable notes, mounting notes, power notes, and 'next steps'.",
710
- "Keep it concise and practical."
711
- ]
712
  }
713
  resp = client.responses.create(
714
  model=OPENAI_MODEL,
715
  reasoning=OPENAI_REASONING,
716
  input=[{"role":"system","content":sys},{"role":"user","content":json.dumps(payload)}],
717
- max_output_tokens=550,
718
  )
719
  return (getattr(resp, "output_text", "") or "").strip()
720
 
721
  lines = []
722
  lines.append("### Install-ready checklist")
723
  lines.append(f"- Current device: {current_sku}")
724
- lines.append(f"- 5G replacement: {repl5}")
725
- lines.append(f"- 4G alternative: {repl4 if repl4 else 'Not applicable'}")
726
  lines.append(f"- Stationary omni antenna: {st.get('name','')} (PN {st.get('part_number','')})")
727
  lines.append(f"- Vehicle omni antenna: {vh.get('name','')} (PN {vh.get('part_number','')})")
728
  if st.get("connectors"):
729
  lines.append(f"- Stationary connectors: {st.get('connectors')}")
730
  if vh.get("connectors"):
731
  lines.append(f"- Vehicle connectors: {vh.get('connectors')}")
732
- lines.append("- Next steps: confirm mounting + cable lengths + power method; place order; schedule install.")
733
  return "\n".join(lines)
734
 
735
 
736
  # ============================
737
- # Batch mode (Feature #4)
738
  # ============================
739
- def parse_batch_inputs(text_blob: str, file_obj: Optional[Any]) -> List[str]:
740
- items = []
741
  if file_obj is not None:
742
  try:
743
  path = file_obj.name if hasattr(file_obj, "name") else str(file_obj)
@@ -759,10 +627,10 @@ def parse_batch_inputs(text_blob: str, file_obj: Optional[Any]) -> List[str]:
759
  seen.add(k); out.append(x)
760
  return out
761
 
762
- def run_batch(text_blob: str, file_obj: Optional[Any], include_antennas: bool):
763
  inputs = parse_batch_inputs(text_blob, file_obj)
764
  if not inputs:
765
- return "", pd.DataFrame(), None, ""
766
 
767
  rows=[]
768
  for item in inputs:
@@ -784,20 +652,19 @@ def run_batch(text_blob: str, file_obj: Optional[Any], include_antennas: bool):
784
 
785
  life_row = df_eos.iloc[int(res["row_idx"])]
786
  eos, eol, status = row_to_dates_and_status(life_row)
787
- repl = pick_replacements_lifecycle(life_row, status, use_gpt=False) # fast: no GPT in batch
788
 
 
 
789
  if include_antennas:
790
  canon_make = str(life_row.get("_canon_make","UNKNOWN"))
791
- mimo = infer_mimo_for_replacement(repl.get("repl_5g",""), canon_make)
792
- tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") not in {"Not applicable","Not listed"} else ("4G" if _device_is_4g(life_row) else "Unknown")
793
- ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo)
794
- stA = ant.get("stationary_omni", {})
795
- vhA = ant.get("vehicle_omni", {})
796
- ant_s = f"{stA.get('name','')} {stA.get('part_number','')}"
797
- ant_v = f"{vhA.get('name','')} {vhA.get('part_number','')}"
798
- else:
799
- ant_s = ""
800
- ant_v = ""
801
 
802
  rows.append({
803
  "Input": item,
@@ -807,14 +674,13 @@ def run_batch(text_blob: str, file_obj: Optional[Any], include_antennas: bool):
807
  "EOL": eol,
808
  "4G alternative": repl.get("repl_4g",""),
809
  "5G replacement": repl.get("repl_5g",""),
810
- "Stationary antenna": ant_s,
811
- "Vehicle antenna": ant_v,
812
  "Notes": "",
813
  })
814
 
815
  out_df = pd.DataFrame(rows)
816
 
817
- # Summary counts + rollup
818
  counts = out_df["Status"].value_counts(dropna=False).to_dict()
819
  top_5g = out_df["5G replacement"].value_counts(dropna=False).head(5).to_dict()
820
  summary = f"Rows: {len(out_df)} | " + " | ".join([f"{k}: {v}" for k,v in counts.items()])
@@ -827,44 +693,72 @@ def run_batch(text_blob: str, file_obj: Optional[Any], include_antennas: bool):
827
 
828
 
829
  # ============================
830
- # Gradio app (Single + Batch + Install-ready)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
831
  # ============================
832
  def run_lookup(user_text: str, st_json: str):
833
  user_text = str(user_text or "").strip()
834
  if not user_text:
835
- return "Enter a router SKU/model.", gr.update(visible=False), gr.update(visible=False), "{}", gr.update(value="")
836
 
837
  res = resolve_device(user_text)
838
  if res.get("mode") == "pick":
839
  opts = res.get("options", [])
840
  choices = [o["label"] for o in opts]
841
- st2 = {"mode":"pick","options": opts}
842
- 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), st2, gr.update(value="")
843
 
844
  if res.get("mode") != "ok":
845
- return "Not found.", gr.update(visible=False), gr.update(visible=False), "{}", gr.update(value="")
846
 
847
  life_row = df_eos.iloc[int(res["row_idx"])]
848
  eos, eol, status = row_to_dates_and_status(life_row)
849
 
850
  repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)
851
-
852
  canon_make = str(life_row.get("_canon_make","UNKNOWN"))
853
- mimo = infer_mimo_for_replacement(repl.get("repl_5g",""), canon_make)
854
- tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") not in {"Not applicable","Not listed"} else ("4G" if _device_is_4g(life_row) else "Unknown")
855
- ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo)
856
 
857
  output = assemble_output(life_row, status, eos, eol, repl, ant)
858
- st_out = {"row_idx": int(res["row_idx"]), "repl": repl, "ant": ant}
859
- return output, gr.update(visible=False), gr.update(visible=False), st_out, gr.update(value="")
860
 
861
  def use_selection(selected_label: str, st_json: str):
862
- st = _state_load(st_json)
863
- st = _state_load(st_json)
864
  if not st or st.get("mode") != "pick":
865
- return "Run a search first.", gr.update(visible=False), gr.update(visible=False), "{}", gr.update(value="")
 
866
  if not selected_label:
867
- return "Pick A or B first.", gr.update(visible=True), gr.update(visible=True), st, gr.update(value="")
868
 
869
  chosen_row = None
870
  for o in st.get("options", []):
@@ -872,30 +766,33 @@ def use_selection(selected_label: str, st_json: str):
872
  chosen_row = int(o["row_idx"])
873
  break
874
  if chosen_row is None:
875
- return "Pick a valid option.", gr.update(visible=True), gr.update(visible=True), st, gr.update(value="")
876
 
877
  life_row = df_eos.iloc[int(chosen_row)]
878
  eos, eol, status = row_to_dates_and_status(life_row)
879
- repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)
880
 
 
881
  canon_make = str(life_row.get("_canon_make","UNKNOWN"))
882
- mimo = infer_mimo_for_replacement(repl.get("repl_5g",""), canon_make)
883
- tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") not in {"Not applicable","Not listed"} else ("4G" if _device_is_4g(life_row) else "Unknown")
884
- ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo)
885
 
886
  output = assemble_output(life_row, status, eos, eol, repl, ant)
887
- st_out = {"row_idx": int(chosen_row), "repl": repl, "ant": ant}
888
- return output, gr.update(visible=False), gr.update(visible=False), st_out, gr.update(value="")
889
 
890
  def make_install_ready(st_json: str):
891
- st_state = _state_load(st_json)
892
- if not st_state or "row_idx" not in st_state:
893
  return "Run a lookup first."
894
- life_row = df_eos.iloc[int(st_state["row_idx"])]
895
- repl = st_state.get("repl", {}) or {}
896
- ant = st_state.get("ant", {}) or {}
897
- return install_ready_checklist(life_row, repl, ant)
898
 
 
 
 
899
  with gr.Blocks(title="Only-Routers") as demo:
900
  gr.Markdown("## Only-Routers\nSingle lookup + Batch upload for Verizon reps.")
901
 
@@ -918,7 +815,7 @@ with gr.Blocks(title="Only-Routers") as demo:
918
  install_btn.click(fn=make_install_ready, inputs=[st], outputs=[install_md])
919
 
920
  with gr.Tab("Batch"):
921
- gr.Markdown("Paste one per line or upload a CSV (first column). Batch runs fast (no GPT), and can optionally include antenna picks.")
922
  batch_text = gr.Textbox(label="Paste devices (one per line)", lines=8, placeholder="WR21\nRUT240\nIBR650B")
923
  batch_file = gr.File(label="Upload CSV", file_types=[".csv"])
924
  include_ant = gr.Checkbox(label="Include antenna picks (slower)", value=False)
@@ -931,4 +828,9 @@ with gr.Blocks(title="Only-Routers") as demo:
931
 
932
  run_btn.click(fn=run_batch, inputs=[batch_text, batch_file, include_ant], outputs=[summary_md, table, dl, rollup_md])
933
 
934
- demo.launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT","7860")), share=False, show_api=False)
 
 
 
 
 
 
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
 
33
  PARSEC_CONTEXT_BEFORE = 900
34
  PARSEC_CONTEXT_AFTER = 1600
35
 
 
 
 
36
 
37
  # ============================
38
  # OpenAI client (HF Space secret: OPENAI_API_KEY)
 
41
  client = OpenAI(api_key=API_KEY) if API_KEY else None
42
 
43
 
44
+ # ============================
45
+ # Gradio state helpers
46
+ # IMPORTANT: We keep state as a JSON STRING to avoid HF / Gradio API schema crashes.
47
+ # ============================
48
+ def state_load(st_json: str) -> Dict[str, Any]:
49
+ try:
50
+ if not st_json:
51
+ return {}
52
+ return json.loads(st_json) if isinstance(st_json, str) else {}
53
+ except Exception:
54
+ return {}
55
+
56
+ def state_dump(st: Dict[str, Any]) -> str:
57
+ try:
58
+ return json.dumps(st or {}, ensure_ascii=False)
59
+ except Exception:
60
+ return "{}"
61
+
62
+
63
  # ============================
64
  # Utilities
65
  # ============================
 
74
  s = re.sub(r"\s+", " ", s).strip()
75
  return s
76
 
77
+ def safe_str(v: Any) -> str:
78
  if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):
79
  return ""
80
  return str(v).strip()
81
 
82
+ def is_5g(modem_type: Any) -> bool:
83
  s = norm_text(modem_type)
84
  return ("5g" in s) or ("nr" in s)
85
 
86
+ def json_load_safe(s: str) -> Dict[str, Any]:
87
  try:
88
  return json.loads(s)
89
  except Exception:
90
  return {}
91
 
92
+ def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 600) -> Dict[str, Any]:
93
  if client is None:
94
  return {}
95
  resp = client.responses.create(
 
101
  ],
102
  max_output_tokens=max_tokens,
103
  )
104
+ return json_load_safe(getattr(resp, "output_text", "") or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
 
107
  # ============================
 
122
  df_dec = pd.read_csv(DEC_PATH).copy()
123
 
124
  # Region filter: keep USA / North America / blank / not specified
125
+ def region_ok(x: Any) -> bool:
126
  s = str(x or "").strip().lower()
127
  if not s:
128
  return True
 
139
  return False
140
 
141
  if "region" in df_eos.columns:
142
+ df_eos = df_eos[df_eos["region"].apply(region_ok)].reset_index(drop=True)
143
 
144
+ # Optional Device Type
145
  device_type_col = None
146
  for c in df_eos.columns:
147
  if norm_text(c) == "device type":
 
174
 
175
  df_dec["_canon_make"] = df_dec["Make"].apply(canon_maker_from_text) if "Make" in df_dec.columns else "UNKNOWN"
176
  df_dec["_norm_model"] = df_dec["Model"].apply(norm_text) if "Model" in df_dec.columns else ""
177
+ df_dec["_is5g"] = df_dec["Modem Type"].apply(is_5g) if "Modem Type" in df_dec.columns else False
178
 
179
 
180
  # ============================
 
215
 
216
  return ParsedDate(raw=raw, kind="bad", value=None)
217
 
218
+ def display_date(pd_: ParsedDate) -> str:
219
+ if pd_.kind == "missing":
220
  return "Not listed"
221
+ if pd_.kind == "bad":
222
+ return pd_.raw or "Not listed"
223
+ return pd_.raw
224
 
225
  def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:
226
  if eos.value is None and eol.value is None:
 
231
  return "End of Sale"
232
  return "Active"
233
 
234
+ def row_to_dates_and_status(row: pd.Series) -> Tuple[str, str, str]:
235
+ eos = parse_date_field(row.get("end_of_sale"))
236
+ eol = parse_date_field(row.get("end_of_life"))
237
  return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)
238
 
239
 
 
272
  # ============================
273
  # Device resolution (exact SKU -> GPT A/B)
274
  # ============================
275
+ def label_for_row(i: int) -> str:
276
  r = df_eos.iloc[i]
277
  return f"{r.get('sku','')} β€” {r.get('manufacturer','')} β€” {r.get('description','')}"[:220]
278
 
279
+ EOS_LABELS = [label_for_row(i) for i in range(len(df_eos))]
280
  EOS_CORPUS = []
281
  for _, r in df_eos.iterrows():
282
+ EOS_CORPUS.append(" ".join([r.get("_norm_sku",""), r.get("_canon_make",""), r.get("_norm_desc",""), r.get("_norm_notes","")]))
283
+
284
+ def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int, int, str]]:
 
 
 
 
 
285
  q = norm_text(query)
286
  hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)
287
  return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]
 
303
 
304
  def resolve_device(user_text: str) -> Dict[str, Any]:
305
  q = norm_text(user_text)
306
+
307
+ # Exact SKU match first
308
  exact_idxs = df_eos.index[df_eos["_norm_sku"] == q].tolist()
309
  if len(exact_idxs) == 1:
310
  return {"mode":"ok","row_idx": int(exact_idxs[0])}
 
329
  if opts2:
330
  return {"mode":"pick","options": opts2}
331
 
332
+ # fallback top 2
333
  if len(cands) > 1:
334
  return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]},{"row_idx":cands[1][0],"label":cands[1][2]}]}
335
  return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]}]}
 
338
  # ============================
339
  # Replacements β€” lifecycle CSV source of truth
340
  # ============================
341
+ def extract_model_token(text: str) -> str:
342
+ s = safe_str(text)
343
  if not s:
344
  return ""
345
  parts = [p.strip() for p in s.split("|") if p.strip()]
346
  candidates = parts[::-1] if parts else [s]
 
347
  for cand in candidates:
348
  m = re.search(r"\bRUT[A-Z]?\d{2,4}\b", cand.upper())
349
  if m:
 
357
  m = re.search(r"\b[A-Z]{1,6}\d{2,4}[A-Z]?\b", cand.upper())
358
  if m:
359
  return m.group(0).upper()
 
360
  return candidates[0][:60]
361
 
362
+ def device_is_4g(row: pd.Series) -> bool:
363
+ t = norm_text(row.get("description","")) + " " + norm_text(row.get("notes",""))
364
  return (("lte" in t or "4g" in t) and ("5g" not in t and "nr" not in t))
365
 
366
+ def candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:
367
  mfr = norm_text(manufacturer)
368
  pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
369
  vals = pool["advanced_5g_option"].tolist() if "advanced_5g_option" in pool.columns else []
370
  out, seen = [], set()
371
  for v in vals:
372
+ tok = extract_model_token(v)
373
  if tok and tok.lower() != "nan" and tok not in seen:
374
  seen.add(tok); out.append(tok)
375
  return out
376
 
377
+ def candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:
378
  mfr = norm_text(manufacturer)
379
  pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
380
  vals = pool["suggested_replacement"].tolist() if "suggested_replacement" in pool.columns else []
381
  out, seen = [], set()
382
  for v in vals:
383
+ tok = extract_model_token(v)
384
  if tok and tok.lower() != "nan" and tok not in seen:
385
  seen.add(tok); out.append(tok)
386
  return out
387
 
388
+ def gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:
389
  if client is None or not candidates:
390
  return ""
391
  sys = "Pick the best replacement model. Choose only from candidates. Return strict JSON only."
 
403
  choice = str(out.get("choice","") or "").strip()
404
  return choice if choice in candidates else ""
405
 
406
+ def fallback_5g_from_dec(canon_make: str) -> str:
407
  pool5 = df_dec[(df_dec["_canon_make"] == canon_make) & (df_dec["_is5g"] == True)]
408
  return str(pool5.iloc[0]["Model"]).strip() if not pool5.empty else ""
409
 
410
+ def pick_replacements_lifecycle(row: pd.Series, status: str, use_gpt: bool = True) -> Dict[str, Any]:
411
+ canon = str(row.get("_canon_make","UNKNOWN"))
412
+ manufacturer = str(row.get("manufacturer","") or "")
413
 
414
+ is_4g = device_is_4g(row)
415
+ want_5g = is_4g or (status in {"End of Sale","End of Life"})
416
 
417
+ # 4G alternative ALWAYS for 4G devices
418
  repl_4g = "Not applicable"
419
+ if is_4g:
420
+ repl_4g = extract_model_token(safe_str(row.get("suggested_replacement","")))
421
  if not repl_4g:
422
+ cand4 = candidate_4g_models_from_lifecycle(manufacturer)
423
+ repl_4g = (gpt_pick_from_candidates(row, cand4, "4G alternative") if (use_gpt and client) else "") or (cand4[0] if cand4 else "")
424
  if not repl_4g:
425
  repl_4g = "Not applicable"
426
 
427
+ # 5G replacement ALWAYS when want_5g
428
+ repl_5g = "Not listed"
429
  if want_5g:
430
+ repl_5g = extract_model_token(safe_str(row.get("advanced_5g_option","")))
431
  if not repl_5g:
432
+ cand5 = candidate_5g_models_from_lifecycle(manufacturer)
433
+ repl_5g = (gpt_pick_from_candidates(row, cand5, "5G replacement/upgrade") if (use_gpt and client) else "") or (cand5[0] if cand5 else "")
434
  if not repl_5g:
435
+ repl_5g = fallback_5g_from_dec(canon) or "Not listed"
436
 
437
  if repl_5g.lower() == "nan":
438
+ repl_5g = "Not listed"
439
 
440
  return {
441
  "repl_4g": repl_4g,
442
+ "repl_5g": repl_5g,
443
  "why": "Lifecycle replacements (GPT fallback when missing).",
444
+ "sources": ["lifecycle_csv"] + (["gpt"] if (use_gpt and client) else []) + (["dec_fallback"] if (want_5g and repl_5g == "Not listed") else []),
445
  }
446
 
447
 
 
458
  "description:", "standard sku"
459
  }
460
 
461
+ def clean_line(s: str) -> str:
462
  s = re.sub(r"\s+", " ", str(s or "").strip())
463
  if re.fullmatch(r"-[a-z0-9]+", s.lower()):
464
  return ""
465
  return s
466
 
467
+ def is_bad_name_line(line: str) -> bool:
468
  low = line.lower()
469
  if any(m in low for m in BAD_NAME_MARKERS):
470
  return True
 
472
  return True
473
  return False
474
 
475
+ def family_from_line(line: str) -> str:
476
  low = line.lower()
477
  for fam in PARSEC_FAMILY_WORDS:
478
  if fam in low:
479
  return fam.capitalize()
480
  return ""
481
 
482
+ def parsec_connectors_from_card(t: str) -> str:
483
  m = re.search(r"Standard\s+Connectors:\s*(.+)", t, flags=re.IGNORECASE)
484
  if m:
485
  val = re.sub(r"\s+", " ", m.group(1).strip())
486
  return val[:80]
487
  return ""
488
 
489
+ def parsec_name_from_card(card_text: str) -> str:
490
+ lines = [clean_line(ln) for ln in str(card_text or "").splitlines()]
491
  lines = [ln for ln in lines if ln]
492
 
493
  for ln in lines:
494
+ if is_bad_name_line(ln):
495
  continue
496
+ fam = family_from_line(ln)
497
  if fam:
498
  return fam
499
 
 
505
  if sku_i is not None:
506
  window = lines[max(0, sku_i - 12):sku_i]
507
  for ln in reversed(window):
508
+ if is_bad_name_line(ln):
509
  continue
510
  if 3 <= len(ln) <= 40 and re.search(r"[A-Za-z]", ln):
511
  return ln.split()[0].capitalize()
512
 
513
  return "Parsec antenna"
514
 
515
+ def parsec_part_from_card(t: str) -> str:
516
  m = re.search(r"Standard\s+SKU:\s*([A-Z0-9]+)", t)
517
  return m.group(1).strip() if m else ""
518
 
519
+ def parsec_desc_from_card(t: str) -> str:
520
  m = re.search(r"Description:\s*(.+?)(?:\n|$)", t, flags=re.IGNORECASE)
521
  return re.sub(r"\s+"," ",m.group(1).strip())[:220] if m else ""
522
 
 
530
  card = parsec_cards[int(i)]
531
  out.append({
532
  "score": float(sc),
533
+ "name": parsec_name_from_card(card),
534
+ "part_number": parsec_part_from_card(card),
535
+ "description": parsec_desc_from_card(card),
536
+ "connectors": parsec_connectors_from_card(card),
537
  })
538
  return out
539
 
540
+ def infer_mimo_for_5g(model: str, canon_make: str) -> str:
541
+ # Use dec when possible; else simple heuristic
542
  if not model or model in {"Not applicable","Not listed"}:
543
  return "2x2"
544
  pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
545
+ if not pool.empty:
546
+ hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
547
+ if hit and hit[1] >= MATCH_OK:
548
+ row = pool.iloc[int(hit[2])]
549
+ txt = (str(row.get("Antennas (internal/external/both)","")) + " " + str(row.get("Modem Type",""))).lower()
550
+ if "4x4" in txt or "4 x 4" in txt:
551
+ return "4x4"
552
+ return "4x4" if ("5g" in model.lower() or model.upper().startswith(("R","E","S","IX","RUTM"))) else "2x2"
 
553
 
554
  def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:
555
  q_stationary = f"{router_model} {tech} {mimo} omni stationary outdoor Parsec"
556
  q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile Parsec"
 
557
  cand_stationary = parsec_retrieve(q_stationary, top_k=10)
558
  cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)
559
 
 
565
 
566
 
567
  # ============================
568
+ # Install-ready checklist (Feature #9)
569
  # ============================
570
+ def install_ready_checklist(current_sku: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571
  st = ant.get("stationary_omni", {})
572
  vh = ant.get("vehicle_omni", {})
 
573
  if client is not None:
574
+ sys = "Create a short, install-ready checklist for a Verizon rep. Return markdown only."
575
  payload = {
576
  "current_device": current_sku,
577
+ "replacements": repl,
578
  "antennas": {"stationary": st, "vehicle": vh},
579
+ "rules": ["Keep it short. Include power + mount + cables + next steps."]
 
 
 
580
  }
581
  resp = client.responses.create(
582
  model=OPENAI_MODEL,
583
  reasoning=OPENAI_REASONING,
584
  input=[{"role":"system","content":sys},{"role":"user","content":json.dumps(payload)}],
585
+ max_output_tokens=520,
586
  )
587
  return (getattr(resp, "output_text", "") or "").strip()
588
 
589
  lines = []
590
  lines.append("### Install-ready checklist")
591
  lines.append(f"- Current device: {current_sku}")
592
+ lines.append(f"- 5G replacement: {repl.get('repl_5g','')}")
593
+ lines.append(f"- 4G alternative: {repl.get('repl_4g','Not applicable')}")
594
  lines.append(f"- Stationary omni antenna: {st.get('name','')} (PN {st.get('part_number','')})")
595
  lines.append(f"- Vehicle omni antenna: {vh.get('name','')} (PN {vh.get('part_number','')})")
596
  if st.get("connectors"):
597
  lines.append(f"- Stationary connectors: {st.get('connectors')}")
598
  if vh.get("connectors"):
599
  lines.append(f"- Vehicle connectors: {vh.get('connectors')}")
600
+ lines.append("- Next steps: confirm cable lengths + mounting + power; place order; schedule install.")
601
  return "\n".join(lines)
602
 
603
 
604
  # ============================
605
+ # Batch mode (Feature #4) β€” NO GPT for speed
606
  # ============================
607
+ def parse_batch_inputs(text_blob: str, file_obj: Any) -> List[str]:
608
+ items: List[str] = []
609
  if file_obj is not None:
610
  try:
611
  path = file_obj.name if hasattr(file_obj, "name") else str(file_obj)
 
627
  seen.add(k); out.append(x)
628
  return out
629
 
630
+ def run_batch(text_blob: str, file_obj: Any, include_antennas: bool):
631
  inputs = parse_batch_inputs(text_blob, file_obj)
632
  if not inputs:
633
+ return "", None, None, ""
634
 
635
  rows=[]
636
  for item in inputs:
 
652
 
653
  life_row = df_eos.iloc[int(res["row_idx"])]
654
  eos, eol, status = row_to_dates_and_status(life_row)
655
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=False)
656
 
657
+ stA = ""
658
+ vhA = ""
659
  if include_antennas:
660
  canon_make = str(life_row.get("_canon_make","UNKNOWN"))
661
+ mimo = infer_mimo_for_5g(repl.get("repl_5g",""), canon_make)
662
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") != "Not listed" else ("4G" if device_is_4g(life_row) else "Unknown")
663
+ ant = antenna_options_for(repl.get("repl_5g") or str(life_row.get("sku","")), tech, mimo)
664
+ s = ant.get("stationary_omni", {})
665
+ v = ant.get("vehicle_omni", {})
666
+ stA = f"{s.get('name','')} {s.get('part_number','')}"
667
+ vhA = f"{v.get('name','')} {v.get('part_number','')}"
 
 
 
668
 
669
  rows.append({
670
  "Input": item,
 
674
  "EOL": eol,
675
  "4G alternative": repl.get("repl_4g",""),
676
  "5G replacement": repl.get("repl_5g",""),
677
+ "Stationary antenna": stA,
678
+ "Vehicle antenna": vhA,
679
  "Notes": "",
680
  })
681
 
682
  out_df = pd.DataFrame(rows)
683
 
 
684
  counts = out_df["Status"].value_counts(dropna=False).to_dict()
685
  top_5g = out_df["5G replacement"].value_counts(dropna=False).head(5).to_dict()
686
  summary = f"Rows: {len(out_df)} | " + " | ".join([f"{k}: {v}" for k,v in counts.items()])
 
693
 
694
 
695
  # ============================
696
+ # Output
697
+ # ============================
698
+ def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
699
+ current_name = f"{life_row.get('sku','')} β€” {life_row.get('description','')}".strip(" β€”")
700
+ st = ant.get("stationary_omni", {})
701
+ vh = ant.get("vehicle_omni", {})
702
+
703
+ lines = []
704
+ lines.append(f"1. Current device: **{current_name}**")
705
+ lines.append(f"2. Status: **{status}**")
706
+ lines.append(f"3. End of Sale date: **{eos}**")
707
+ lines.append(f"4. End of Life date: **{eol}**")
708
+ lines.append(f"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**")
709
+ lines.append(f"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**")
710
+ lines.append("7. Antenna options (Parsec-only):")
711
+ conn_s = f" | Conn: {st.get('connectors','')}" if st.get("connectors") else ""
712
+ conn_v = f" | Conn: {vh.get('connectors','')}" if vh.get("connectors") else ""
713
+ lines.append(f" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) β€” {st.get('description','')} β€” MIMO: {st.get('mimo','')}{conn_s} β€” {st.get('why','')}")
714
+ lines.append(f" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) β€” {vh.get('description','')} β€” MIMO: {vh.get('mimo','')}{conn_v} β€” {vh.get('why','')}")
715
+
716
+ lines.append("\nSources (debug):")
717
+ for s in repl.get("sources", []) if isinstance(repl.get("sources"), list) else []:
718
+ lines.append(f"- {s}")
719
+ lines.append("- ParsecCatalog.pdf (local RAG)")
720
+ lines.append("- routers_eos_eol_by_sku.csv (replacements)")
721
+ return "\n".join(lines)
722
+
723
+
724
+ # ============================
725
+ # Gradio callbacks (NO dict state)
726
  # ============================
727
  def run_lookup(user_text: str, st_json: str):
728
  user_text = str(user_text or "").strip()
729
  if not user_text:
730
+ return "Enter a router SKU/model.", gr.update(visible=False), gr.update(visible=False), "{}", ""
731
 
732
  res = resolve_device(user_text)
733
  if res.get("mode") == "pick":
734
  opts = res.get("options", [])
735
  choices = [o["label"] for o in opts]
736
+ st2 = {"mode":"pick","options": opts, "raw": user_text}
737
+ 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), ""
738
 
739
  if res.get("mode") != "ok":
740
+ return "Not found.", gr.update(visible=False), gr.update(visible=False), "{}", ""
741
 
742
  life_row = df_eos.iloc[int(res["row_idx"])]
743
  eos, eol, status = row_to_dates_and_status(life_row)
744
 
745
  repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)
 
746
  canon_make = str(life_row.get("_canon_make","UNKNOWN"))
747
+ mimo = infer_mimo_for_5g(repl.get("repl_5g",""), canon_make)
748
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") != "Not listed" else ("4G" if device_is_4g(life_row) else "Unknown")
749
+ ant = antenna_options_for(repl.get("repl_5g") or str(life_row.get("sku","")), tech, mimo)
750
 
751
  output = assemble_output(life_row, status, eos, eol, repl, ant)
752
+ st_out = {"row_idx": int(res["row_idx"]), "repl": repl, "ant": ant, "raw": user_text}
753
+ return output, gr.update(visible=False), gr.update(visible=False), state_dump(st_out), ""
754
 
755
  def use_selection(selected_label: str, st_json: str):
756
+ st = state_load(st_json)
 
757
  if not st or st.get("mode") != "pick":
758
+ return "Run a search first.", gr.update(visible=False), gr.update(visible=False), "{}", ""
759
+
760
  if not selected_label:
761
+ return "Pick A or B first.", gr.update(visible=True), gr.update(visible=True), st_json, ""
762
 
763
  chosen_row = None
764
  for o in st.get("options", []):
 
766
  chosen_row = int(o["row_idx"])
767
  break
768
  if chosen_row is None:
769
+ return "Pick a valid option.", gr.update(visible=True), gr.update(visible=True), st_json, ""
770
 
771
  life_row = df_eos.iloc[int(chosen_row)]
772
  eos, eol, status = row_to_dates_and_status(life_row)
 
773
 
774
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)
775
  canon_make = str(life_row.get("_canon_make","UNKNOWN"))
776
+ mimo = infer_mimo_for_5g(repl.get("repl_5g",""), canon_make)
777
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") != "Not listed" else ("4G" if device_is_4g(life_row) else "Unknown")
778
+ ant = antenna_options_for(repl.get("repl_5g") or str(life_row.get("sku","")), tech, mimo)
779
 
780
  output = assemble_output(life_row, status, eos, eol, repl, ant)
781
+ st_out = {"row_idx": int(chosen_row), "repl": repl, "ant": ant, "raw": st.get("raw","")}
782
+ return output, gr.update(visible=False), gr.update(visible=False), state_dump(st_out), ""
783
 
784
  def make_install_ready(st_json: str):
785
+ st = state_load(st_json)
786
+ if not st or "row_idx" not in st:
787
  return "Run a lookup first."
788
+ life_row = df_eos.iloc[int(st["row_idx"])]
789
+ current_sku = str(life_row.get("sku","") or "")
790
+ return install_ready_checklist(current_sku, st.get("repl", {}) or {}, st.get("ant", {}) or {})
791
+
792
 
793
+ # ============================
794
+ # Gradio UI
795
+ # ============================
796
  with gr.Blocks(title="Only-Routers") as demo:
797
  gr.Markdown("## Only-Routers\nSingle lookup + Batch upload for Verizon reps.")
798
 
 
815
  install_btn.click(fn=make_install_ready, inputs=[st], outputs=[install_md])
816
 
817
  with gr.Tab("Batch"):
818
+ gr.Markdown("Paste one per line or upload a CSV (first column). Batch runs fast (no GPT).")
819
  batch_text = gr.Textbox(label="Paste devices (one per line)", lines=8, placeholder="WR21\nRUT240\nIBR650B")
820
  batch_file = gr.File(label="Upload CSV", file_types=[".csv"])
821
  include_ant = gr.Checkbox(label="Include antenna picks (slower)", value=False)
 
828
 
829
  run_btn.click(fn=run_batch, inputs=[batch_text, batch_file, include_ant], outputs=[summary_md, table, dl, rollup_md])
830
 
831
+
832
+ # ============================
833
+ # Launch (Hugging Face Spaces)
834
+ # NOTE: Don't use share=True on Spaces.
835
+ # ============================
836
+ demo.launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT", "7860")), show_api=False)
requirements.txt CHANGED
@@ -1,10 +1,9 @@
1
- gradio>=4.44.0
 
2
  pandas>=2.0.0
3
  numpy>=1.24.0
4
  rapidfuzz>=3.0.0
5
  sentence-transformers>=2.2.2
6
  faiss-cpu>=1.7.4
7
  pymupdf>=1.23.0
8
- beautifulsoup4>=4.12.0
9
- requests>=2.31.0
10
  openai>=1.40.0
 
1
+ gradio==4.44.1
2
+ gradio_client==0.10.2
3
  pandas>=2.0.0
4
  numpy>=1.24.0
5
  rapidfuzz>=3.0.0
6
  sentence-transformers>=2.2.2
7
  faiss-cpu>=1.7.4
8
  pymupdf>=1.23.0
 
 
9
  openai>=1.40.0