crazycrazypete commited on
Commit
84e62d2
·
verified ·
1 Parent(s): 0fbe5f9

Upload folder using huggingface_hub

Browse files
Old Working version/app.py ADDED
@@ -0,0 +1,1665 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ def gpt_answer_md(system: str, user: str, max_tokens: int = 650) -> str:
104
+ """Return a rep-friendly markdown answer."""
105
+ if client is None:
106
+ return "No API key is configured, so I can't answer detailed questions right now."
107
+ resp = client.responses.create(
108
+ model=OPENAI_MODEL,
109
+ reasoning=OPENAI_REASONING,
110
+ input=[
111
+ {"role": "system", "content": system},
112
+ {"role": "user", "content": user},
113
+ ],
114
+ max_output_tokens=max_tokens,
115
+ )
116
+ return (getattr(resp, "output_text", "") or "").strip()
117
+
118
+
119
+ # ============================
120
+ # Load data
121
+ # ============================
122
+ EOS_PATH = "routers_eos_eol_by_sku.csv"
123
+ DEC_PATH = "dec2025routers.csv"
124
+ PARSEC_PDF = "ParsecCatalog.pdf"
125
+
126
+ if not os.path.exists(EOS_PATH):
127
+ raise FileNotFoundError(f"Missing {EOS_PATH} in repo.")
128
+ if not os.path.exists(DEC_PATH):
129
+ raise FileNotFoundError(f"Missing {DEC_PATH} in repo.")
130
+ if not os.path.exists(PARSEC_PDF):
131
+ raise FileNotFoundError(f"Missing {PARSEC_PDF} in repo.")
132
+
133
+ df_eos = pd.read_csv(EOS_PATH).copy()
134
+ df_dec = pd.read_csv(DEC_PATH).copy()
135
+
136
+
137
+ def _canonize_eos_columns(df: pd.DataFrame) -> pd.DataFrame:
138
+ """Normalize lifecycle CSV column names (case-insensitive) and create expected columns."""
139
+ # Map various header spellings to canonical names used by the app
140
+ mapping = {}
141
+ for c in df.columns:
142
+ k = str(c).strip().lower().replace(" ", "_")
143
+ if k in {"sku", "model", "device", "device_sku"}:
144
+ mapping[c] = "sku"
145
+ elif k in {"manufacturer", "make", "vendor"}:
146
+ mapping[c] = "manufacturer"
147
+ elif k in {"device_type", "type"}:
148
+ mapping[c] = "device_type"
149
+ elif k in {"end_of_sale", "eos", "end_sale", "end_of_sales"}:
150
+ mapping[c] = "end_of_sale"
151
+ elif k in {"end_of_life", "eol", "end_life"}:
152
+ mapping[c] = "end_of_life"
153
+ elif k in {"suggested_replacement", "replacement_4g", "lte_replacement", "replacement_lte", "replacement"}:
154
+ mapping[c] = "suggested_replacement"
155
+ elif k in {"advanced_5g_option", "replacement_5g", "fiveg_replacement", "5g_replacement", "upgrade_5g"}:
156
+ mapping[c] = "advanced_5g_option"
157
+ elif k in {"region", "market"}:
158
+ mapping[c] = "region"
159
+ elif k in {"notes", "note"}:
160
+ mapping[c] = "notes"
161
+ elif k in {"description", "device_description", "name"}:
162
+ mapping[c] = "description"
163
+
164
+ df = df.rename(columns=mapping).copy()
165
+
166
+ # Create expected columns if missing
167
+ if "sku" not in df.columns:
168
+ # Try the common capitalized header as a fallback
169
+ if "SKU" in df.columns:
170
+ df["sku"] = df["SKU"].astype(str)
171
+ else:
172
+ df["sku"] = ""
173
+
174
+ if "manufacturer" not in df.columns:
175
+ df["manufacturer"] = ""
176
+
177
+ if "device_type" not in df.columns:
178
+ df["device_type"] = ""
179
+
180
+ if "description" not in df.columns:
181
+ # If the simplified file removed description, use SKU as description (still searchable)
182
+ df["description"] = df["sku"].astype(str)
183
+
184
+ if "notes" not in df.columns:
185
+ df["notes"] = ""
186
+
187
+ if "region" not in df.columns:
188
+ df["region"] = ""
189
+
190
+ if "suggested_replacement" not in df.columns:
191
+ df["suggested_replacement"] = ""
192
+
193
+ if "advanced_5g_option" not in df.columns:
194
+ df["advanced_5g_option"] = ""
195
+
196
+ if "end_of_sale" not in df.columns:
197
+ df["end_of_sale"] = ""
198
+
199
+ if "end_of_life" not in df.columns:
200
+ df["end_of_life"] = ""
201
+
202
+ return df
203
+
204
+ df_eos = _canonize_eos_columns(df_eos)
205
+
206
+
207
+ def region_ok(x: Any) -> bool:
208
+ s = str(x or "").strip().lower()
209
+ if not s:
210
+ return True
211
+ if "not specified" in s:
212
+ return True
213
+ if "north america" in s:
214
+ return True
215
+ if re.search(r"\busa\b", s):
216
+ return True
217
+ if re.search(r"\bunited\s+states\b", s):
218
+ return True
219
+ if re.search(r"\bu\.?s\.?\b", s):
220
+ return True
221
+ return False
222
+
223
+ if "region" in df_eos.columns:
224
+ df_eos = df_eos[df_eos["region"].apply(region_ok)].reset_index(drop=True)
225
+
226
+ # Maker mapping (includes Teltonika)
227
+ CANON_MAKER = {
228
+ "CRADLEPOINT": {"cradlepoint", "ericsson", "ericsson enterprise wireless"},
229
+ "SIERRA": {"sierra", "sierra wireless", "semtech", "airlink"},
230
+ "FEENEY": {"feeney", "feeney wireless", "inseego"},
231
+ "DIGI": {"digi", "accelerated", "accelerated concepts"},
232
+ "CISCO_MERAKI": {"meraki", "cisco meraki"},
233
+ "CISCO": {"cisco"},
234
+ "TELTONIKA": {"teltonika"},
235
+ }
236
+
237
+ def canon_maker_from_text(s: Any) -> str:
238
+ t = norm_text(s)
239
+ for canon, terms in CANON_MAKER.items():
240
+ for term in terms:
241
+ if term in t:
242
+ return canon
243
+ return "UNKNOWN"
244
+
245
+ df_eos["_canon_make"] = df_eos["manufacturer"].apply(canon_maker_from_text) if "manufacturer" in df_eos.columns else "UNKNOWN"
246
+ df_eos["_norm_sku"] = df_eos["sku"].apply(norm_text) if "sku" in df_eos.columns else ""
247
+ df_eos["_norm_desc"] = df_eos["description"].apply(norm_text) if "description" in df_eos.columns else ""
248
+ df_eos["_norm_notes"] = df_eos["notes"].apply(norm_text) if "notes" in df_eos.columns else ""
249
+
250
+ df_dec["_canon_make"] = df_dec["Make"].apply(canon_maker_from_text) if "Make" in df_dec.columns else "UNKNOWN"
251
+ df_dec["_norm_model"] = df_dec["Model"].apply(norm_text) if "Model" in df_dec.columns else ""
252
+ df_dec["_is5g"] = df_dec["Modem Type"].apply(is_5g) if "Modem Type" in df_dec.columns else False
253
+
254
+
255
+ # ============================
256
+ # Date helpers
257
+ # ============================
258
+ @dataclass
259
+ class ParsedDate:
260
+ raw: str
261
+ kind: str
262
+ value: Optional[date]
263
+
264
+ def parse_date_field(x: Any) -> ParsedDate:
265
+ raw = str(x or "").strip()
266
+ if not raw:
267
+ return ParsedDate(raw="", kind="missing", value=None)
268
+
269
+ # Common US formats: M/D/YY or M/D/YYYY (e.g., 6/24/24, 9/30/21)
270
+ for fmt in ("%m/%d/%y", "%m/%d/%Y", "%-m/%-d/%y", "%-m/%-d/%Y"):
271
+ try:
272
+ dt = datetime.strptime(raw, fmt).date()
273
+ return ParsedDate(raw=raw, kind="full", value=dt)
274
+ except Exception:
275
+ pass
276
+
277
+ # ISO-ish: YYYY
278
+ if re.fullmatch(r"\d{4}", raw):
279
+ y = int(raw)
280
+ if y == TODAY.year:
281
+ return ParsedDate(raw=raw, kind="year", value=date(y, 1, 1))
282
+ if y < TODAY.year:
283
+ return ParsedDate(raw=raw, kind="year", value=date(y, 1, 1))
284
+ return ParsedDate(raw=raw, kind="year", value=date(y, 12, 31))
285
+
286
+ # YYYY-MM
287
+ if re.fullmatch(r"\d{4}-\d{2}", raw):
288
+ try:
289
+ y, m = raw.split("-")
290
+ return ParsedDate(raw=raw, kind="year_month", value=date(int(y), int(m), 1))
291
+ except Exception:
292
+ return ParsedDate(raw=raw, kind="bad", value=None)
293
+
294
+ # YYYY-MM-DD
295
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw):
296
+ try:
297
+ dt = datetime.strptime(raw, "%Y-%m-%d").date()
298
+ return ParsedDate(raw=raw, kind="full", value=dt)
299
+ except Exception:
300
+ return ParsedDate(raw=raw, kind="bad", value=None)
301
+
302
+ # Last resort: leave as raw (unparsed)
303
+ return ParsedDate(raw=raw, kind="bad", value=None)
304
+
305
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw):
306
+ try:
307
+ dt = datetime.strptime(raw, "%Y-%m-%d").date()
308
+ return ParsedDate(raw=raw, kind="full", value=dt)
309
+ except Exception:
310
+ return ParsedDate(raw=raw, kind="bad", value=None)
311
+
312
+ return ParsedDate(raw=raw, kind="bad", value=None)
313
+
314
+ def display_date(pd_: ParsedDate) -> str:
315
+ if pd_.kind == "missing":
316
+ return "Not listed"
317
+ if pd_.kind == "bad":
318
+ return pd_.raw or "Not listed"
319
+ return pd_.raw
320
+
321
+ def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:
322
+ if eos.value is None and eol.value is None:
323
+ return "Unknown"
324
+ if eol.value is not None and eol.value <= TODAY:
325
+ return "End of Life"
326
+ if eos.value is not None and eos.value <= TODAY:
327
+ return "End of Sale"
328
+ return "Active"
329
+
330
+ def row_to_dates_and_status(row: pd.Series) -> Tuple[str, str, str]:
331
+ eos = parse_date_field(row.get("end_of_sale"))
332
+ eol = parse_date_field(row.get("end_of_life"))
333
+ return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)
334
+
335
+
336
+ # ============================
337
+ # Embeddings + Parsec index
338
+ # ============================
339
+ embedder = SentenceTransformer(EMBED_MODEL_NAME)
340
+
341
+ def extract_pdf_text_pages(path: str) -> List[str]:
342
+ doc = fitz.open(path)
343
+ return [doc[i].get_text("text") for i in range(len(doc))]
344
+
345
+ def build_parsec_cards(pages: List[str]) -> List[str]:
346
+ cards = []
347
+ for p in pages:
348
+ for m in re.finditer(r"Standard\s+SKU:", p):
349
+ start = max(0, m.start() - PARSEC_CONTEXT_BEFORE)
350
+ end = min(len(p), m.start() + PARSEC_CONTEXT_AFTER)
351
+ c = p[start:end].strip()
352
+ if len(c) >= 200:
353
+ cards.append(c)
354
+ out, seen = [], set()
355
+ for c in cards:
356
+ h = hashlib.sha1(c.encode("utf-8")).hexdigest()
357
+ if h not in seen:
358
+ seen.add(h); out.append(c)
359
+ return out
360
+
361
+ parsec_cards = build_parsec_cards(extract_pdf_text_pages(PARSEC_PDF))
362
+ parsec_emb = embedder.encode(parsec_cards, batch_size=64, show_progress_bar=False, normalize_embeddings=True)
363
+ parsec_emb = np.asarray(parsec_emb, dtype=np.float32)
364
+ parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])
365
+ parsec_index.add(parsec_emb)
366
+
367
+
368
+ # ============================
369
+ # Device resolution
370
+ # ============================
371
+ def label_for_row(i: int) -> str:
372
+ r = df_eos.iloc[i]
373
+ return f"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}"[:220]
374
+
375
+ EOS_LABELS = [label_for_row(i) for i in range(len(df_eos))]
376
+ EOS_CORPUS = []
377
+ for _, r in df_eos.iterrows():
378
+ EOS_CORPUS.append(" ".join([r.get("_norm_sku",""), r.get("_canon_make",""), r.get("_norm_desc",""), r.get("_norm_notes","")]))
379
+
380
+ def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int, int, str]]:
381
+ q = norm_text(query)
382
+ hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)
383
+ return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]
384
+
385
+ def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> Dict[str, Any]:
386
+ if client is None:
387
+ return {}
388
+ sys = "Pick which router the user meant. Never invent. Return strict JSON only."
389
+ payload = {
390
+ "user_input": user_text,
391
+ "candidates": [{"row_idx": i, "score": s, "label": lbl} for (i,s,lbl) in candidates],
392
+ "rules": [
393
+ "If one is clearly correct, return mode='ok' with row_idx.",
394
+ "If two are plausible, return mode='pick' with top 2 options."
395
+ ],
396
+ "output_schema": {"mode":"ok|pick","row_idx":"int","options":[{"row_idx":"int","label":"string"}]}
397
+ }
398
+ return gpt_json(sys, payload, max_tokens=280)
399
+
400
+ def resolve_device(user_text: str) -> Dict[str, Any]:
401
+ q = norm_text(user_text)
402
+ exact = df_eos.index[df_eos["_norm_sku"] == q].tolist()
403
+ if len(exact) == 1:
404
+ return {"mode":"ok","row_idx": int(exact[0])}
405
+ if len(exact) > 1:
406
+ opts = [{"row_idx": int(i), "label": EOS_LABELS[int(i)]} for i in exact[:2]]
407
+ return {"mode":"pick","options": opts}
408
+
409
+ cands = local_candidates(user_text, top_k=6)
410
+ if not cands:
411
+ return {"mode":"not_found"}
412
+
413
+ if cands[0][1] >= 95 and (len(cands) == 1 or (cands[0][1] - cands[1][1]) >= 8):
414
+ return {"mode":"ok","row_idx": cands[0][0]}
415
+
416
+ g = gpt_choose_device(user_text, cands)
417
+ if g.get("mode") == "ok" and isinstance(g.get("row_idx"), int):
418
+ return {"mode":"ok","row_idx": int(g["row_idx"])}
419
+
420
+ if g.get("mode") == "pick":
421
+ opts = g.get("options", []) or []
422
+ opts2 = [{"row_idx": int(o["row_idx"]), "label": str(o["label"])} for o in opts[:2] if "row_idx" in o]
423
+ if opts2:
424
+ return {"mode":"pick","options": opts2}
425
+
426
+ if len(cands) > 1:
427
+ return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]},{"row_idx":cands[1][0],"label":cands[1][2]}]}
428
+ return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]}]}
429
+
430
+
431
+ # ============================
432
+ # Replacements — lifecycle CSV source of truth
433
+ # ============================
434
+ def extract_model_token(text: str) -> str:
435
+ s = safe_str(text)
436
+ if not s:
437
+ return ""
438
+ parts = [p.strip() for p in s.split("|") if p.strip()]
439
+ candidates = parts[::-1] if parts else [s]
440
+ for cand in candidates:
441
+ m = re.search(r"\bRUT[A-Z]?\d{2,4}\b", cand.upper())
442
+ if m:
443
+ return m.group(0).upper()
444
+ m = re.search(r"\bIX\d{2}\b", cand, flags=re.IGNORECASE)
445
+ if m:
446
+ return m.group(0).upper()
447
+ m = re.search(r"\b(R\d{3,4}|E\d{3,4}|S\d{3,4})\b", cand, flags=re.IGNORECASE)
448
+ if m:
449
+ return m.group(0).upper()
450
+ m = re.search(r"\b[A-Z]{1,6}\d{2,4}[A-Z]?\b", cand.upper())
451
+ if m:
452
+ return m.group(0).upper()
453
+ return candidates[0][:60]
454
+
455
+ def device_is_4g(row: pd.Series) -> bool:
456
+ # Detect LTE/4G even when the description uses "Cat 4 / Cat6 / Cat 12" without saying "LTE"
457
+ t = norm_text(row.get("description","")) + " " + norm_text(row.get("notes","")) + " " + norm_text(row.get("sku",""))
458
+
459
+ # If it explicitly says 5G/NR, treat as not 4G-only
460
+ if ("5g" in t) or ("nr" in t):
461
+ return False
462
+
463
+ # Classic signals
464
+ if ("lte" in t) or ("4g" in t):
465
+ return True
466
+
467
+ # LTE category signals (Cat 1..20 are LTE categories; Cat M1/M2 are LTE-M)
468
+ if re.search(r"\bcat\s*[-]?\s*(m1|m2)\b", t):
469
+ return True
470
+
471
+ m = re.search(r"\bcat\s*[-]?\s*(\d{1,2})\b", t)
472
+ if m:
473
+ try:
474
+ cat = int(m.group(1))
475
+ if 0 < cat <= 20:
476
+ return True
477
+ except Exception:
478
+ pass
479
+
480
+ # If "cat" appears at all, it's almost always LTE-family
481
+ if "cat" in t:
482
+ return True
483
+
484
+ return False
485
+
486
+ # If it explicitly says 5G/NR, treat as not 4G-only
487
+ if ("5g" in t) or ("nr" in t):
488
+ return False
489
+
490
+ # Classic signals
491
+ if ("lte" in t) or ("4g" in t):
492
+ return True
493
+
494
+ # LTE category signals (Cat 1..20 are LTE categories; Cat M1/M2 are LTE-M)
495
+ if re.search(r"\bcat\s*[-]?\s*(m1|m2)\b", t):
496
+ return True
497
+
498
+ m = re.search(r"\bcat\s*[-]?\s*(\d{1,2})\b", t)
499
+ if m:
500
+ try:
501
+ cat = int(m.group(1))
502
+ if 0 < cat <= 20:
503
+ return True
504
+ except Exception:
505
+ pass
506
+
507
+ # If "cat" appears at all, it's almost always LTE-family
508
+ if "cat" in t:
509
+ return True
510
+
511
+ return False
512
+
513
+
514
+ def candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:
515
+ mfr = norm_text(manufacturer)
516
+ pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
517
+ vals = pool["advanced_5g_option"].tolist() if "advanced_5g_option" in pool.columns else []
518
+ out, seen = [], set()
519
+ for v in vals:
520
+ tok = extract_model_token(v)
521
+ if tok and tok.lower() != "nan" and tok not in seen:
522
+ seen.add(tok); out.append(tok)
523
+ return out
524
+
525
+ def candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:
526
+ mfr = norm_text(manufacturer)
527
+ pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
528
+ vals = pool["suggested_replacement"].tolist() if "suggested_replacement" in pool.columns else []
529
+ out, seen = [], set()
530
+ for v in vals:
531
+ tok = extract_model_token(v)
532
+ if tok and tok.lower() != "nan" and tok not in seen:
533
+ seen.add(tok); out.append(tok)
534
+ return out
535
+
536
+ def gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:
537
+ if client is None or not candidates:
538
+ return ""
539
+ sys = "Pick the best replacement model. Choose only from candidates. Return strict JSON only."
540
+ payload = {
541
+ "old_device": {
542
+ "sku": str(old_row.get("sku","")),
543
+ "manufacturer": str(old_row.get("manufacturer","")),
544
+ "description": str(old_row.get("description","")),
545
+ "need": need,
546
+ },
547
+ "candidates": candidates[:40],
548
+ "output_schema": {"choice":"string"}
549
+ }
550
+ out = gpt_json(sys, payload, max_tokens=240) or {}
551
+ choice = str(out.get("choice","") or "").strip()
552
+ return choice if choice in candidates else ""
553
+
554
+ def fallback_5g_from_dec(canon_make: str) -> str:
555
+ pool5 = df_dec[(df_dec["_canon_make"] == canon_make) & (df_dec["_is5g"] == True)]
556
+ return str(pool5.iloc[0]["Model"]).strip() if not pool5.empty else ""
557
+
558
+ def pick_replacements_lifecycle(row: pd.Series, status: str, use_gpt: bool = True) -> Dict[str, Any]:
559
+ canon = str(row.get("_canon_make","UNKNOWN"))
560
+ manufacturer = str(row.get("manufacturer","") or "")
561
+
562
+ sug_raw = safe_str(row.get("suggested_replacement",""))
563
+ adv_raw = safe_str(row.get("advanced_5g_option",""))
564
+
565
+ has_4g_alt = bool(sug_raw.strip())
566
+ has_5g_alt = bool(adv_raw.strip())
567
+
568
+ # Treat as 4G if the description indicates LTE OR lifecycle provides a 4G suggested replacement
569
+ is_4g = device_is_4g(row) or has_4g_alt
570
+
571
+ # Provide 5G option if the unit is 4G, EOS/EOL, or lifecycle explicitly provides advanced_5g_option
572
+ want_5g = is_4g or (status in {"End of Sale","End of Life"}) or has_5g_alt
573
+
574
+ # 4G alternative: show whenever lifecycle provides it (or device appears 4G)
575
+ repl_4g = "Not applicable"
576
+ if is_4g or has_4g_alt:
577
+ repl_4g = extract_model_token(sug_raw)
578
+ if not repl_4g:
579
+ cand4 = candidate_4g_models_from_lifecycle(manufacturer)
580
+ repl_4g = (gpt_pick_from_candidates(row, cand4, "4G alternative") if (use_gpt and client) else "") or (cand4[0] if cand4 else "")
581
+ if not repl_4g:
582
+ repl_4g = "Not applicable"
583
+
584
+ # 5G replacement: prefer lifecycle advanced_5g_option whenever present
585
+ repl_5g = "Not listed"
586
+ if want_5g:
587
+ repl_5g = extract_model_token(adv_raw)
588
+ if not repl_5g:
589
+ cand5 = candidate_5g_models_from_lifecycle(manufacturer)
590
+ repl_5g = (gpt_pick_from_candidates(row, cand5, "5G replacement/upgrade") if (use_gpt and client) else "") or (cand5[0] if cand5 else "")
591
+ if not repl_5g:
592
+ repl_5g = fallback_5g_from_dec(canon) or "Not listed"
593
+
594
+ if repl_5g.lower() == "nan":
595
+ repl_5g = "Not listed"
596
+
597
+ return {"repl_4g": repl_4g, "repl_5g": repl_5g, "sources": ["lifecycle_csv"] + (["gpt"] if (use_gpt and client) else [])}
598
+
599
+
600
+ # ============================
601
+ # Antennas (Parsec-only)
602
+ # ============================
603
+ PARSEC_FAMILY_WORDS = {"chinook","labrador","boxer","bloodhound","husky","beagle","mastiff","collie","shepherd","belgian","australian","terrier","pyrenees"}
604
+ BAD_NAME_MARKERS = {"customization","standard connectors","connectors","features","benefits","specifications","mechanical","electrical","mounting","accessories","description:","standard sku"}
605
+
606
+ def clean_line(s: str) -> str:
607
+ s = re.sub(r"\s+", " ", str(s or "").strip())
608
+ if re.fullmatch(r"-[a-z0-9]+", s.lower()):
609
+ return ""
610
+ return s
611
+
612
+ def is_bad_name_line(line: str) -> bool:
613
+ low = line.lower()
614
+ if any(m in low for m in BAD_NAME_MARKERS):
615
+ return True
616
+ if re.search(r"\b-[a-z0-9]{1,4}\b", low) and len(low) <= 25:
617
+ return True
618
+ return False
619
+
620
+ def family_from_line(line: str) -> str:
621
+ low = line.lower()
622
+ for fam in PARSEC_FAMILY_WORDS:
623
+ if fam in low:
624
+ return fam.capitalize()
625
+ return ""
626
+
627
+ def parsec_connectors_from_card(t: str) -> str:
628
+ m = re.search(r"Standard\s+Connectors:\s*(.+)", t, flags=re.IGNORECASE)
629
+ if m:
630
+ return re.sub(r"\s+", " ", m.group(1).strip())[:80]
631
+ return ""
632
+
633
+ def parsec_mounts_from_card(t: str) -> List[str]:
634
+ mounts = []
635
+ for m in re.finditer(r"Mount:\s*(.+)", t, flags=re.IGNORECASE):
636
+ val = re.sub(r"\s+", " ", m.group(1).strip())
637
+ parts = [p.strip().lower() for p in val.split(",") if p.strip()]
638
+ mounts.extend(parts)
639
+ out = []
640
+ seen = set()
641
+ for x in mounts:
642
+ if x not in seen:
643
+ seen.add(x); out.append(x)
644
+ return out
645
+
646
+ def parsec_name_from_card(card_text: str) -> str:
647
+ lines = [clean_line(ln) for ln in str(card_text or "").splitlines()]
648
+ lines = [ln for ln in lines if ln]
649
+
650
+ for ln in lines:
651
+ if is_bad_name_line(ln):
652
+ continue
653
+ fam = family_from_line(ln)
654
+ if fam:
655
+ return fam
656
+
657
+ sku_i = None
658
+ for i, ln in enumerate(lines):
659
+ if "standard sku" in ln.lower():
660
+ sku_i = i
661
+ break
662
+ if sku_i is not None:
663
+ window = lines[max(0, sku_i - 12):sku_i]
664
+ for ln in reversed(window):
665
+ if is_bad_name_line(ln):
666
+ continue
667
+ if 3 <= len(ln) <= 40 and re.search(r"[A-Za-z]", ln):
668
+ return ln.split()[0].capitalize()
669
+
670
+ return "Parsec antenna"
671
+
672
+ def parsec_part_from_card(t: str) -> str:
673
+ m = re.search(r"Standard\s+SKU:\s*([A-Z0-9]+)", t)
674
+ return m.group(1).strip() if m else ""
675
+
676
+ def parsec_desc_from_card(t: str) -> str:
677
+ m = re.search(r"Description:\s*(.+?)(?:\n|$)", t, flags=re.IGNORECASE)
678
+ return re.sub(r"\s+"," ",m.group(1).strip())[:220] if m else ""
679
+
680
+ def parsec_retrieve(query: str, top_k: int = 12) -> List[Dict[str, Any]]:
681
+ qv = embedder.encode([query], normalize_embeddings=True)
682
+ qv = np.asarray(qv, dtype=np.float32)
683
+ scores, ids = parsec_index.search(qv, top_k)
684
+ out: List[Dict[str, Any]] = []
685
+ for sc, i in zip(scores[0].tolist(), ids[0].tolist()):
686
+ if 0 <= int(i) < len(parsec_cards):
687
+ card = parsec_cards[int(i)]
688
+ out.append({
689
+ "score": float(sc),
690
+ "name": parsec_name_from_card(card),
691
+ "part_number": parsec_part_from_card(card),
692
+ "description": parsec_desc_from_card(card),
693
+ "connectors": parsec_connectors_from_card(card),
694
+ "mounts": parsec_mounts_from_card(card),
695
+ "_card": card.lower(),
696
+ })
697
+ return out
698
+
699
+ def choose_best_parsec(cands: List[Dict[str, Any]], mode: str) -> Dict[str, Any]:
700
+ best = None
701
+ best_score = -1e9
702
+
703
+ for c in cands:
704
+ card = c.get("_card","")
705
+ mounts = c.get("mounts", []) or []
706
+ score = float(c.get("score", 0.0))
707
+
708
+ if "omni" in card:
709
+ score += 0.6
710
+ if "directional" in card:
711
+ score -= 1.5
712
+
713
+ if mode == "vehicle":
714
+ if any("magnetic" in m for m in mounts):
715
+ score += 3.0
716
+ if any("through" in m for m in mounts):
717
+ score += 2.0
718
+ if any("wall" in m for m in mounts) or any("pole" in m for m in mounts):
719
+ score -= 1.2
720
+ if "app: fixed" in card and "mobile" not in card:
721
+ score -= 2.0
722
+
723
+ if mode == "stationary":
724
+ if any("wall" in m for m in mounts):
725
+ score += 2.0
726
+ if any("pole" in m for m in mounts):
727
+ score += 1.8
728
+
729
+ if score > best_score:
730
+ best_score = score
731
+ best = c
732
+
733
+ if not best:
734
+ return {"name":"Parsec antenna","part_number":"","description":"","connectors":"","mounts":[]}
735
+
736
+ best = dict(best)
737
+ best.pop("_card", None)
738
+ return best
739
+
740
+
741
+ def infer_mimo_for_5g(repl_5g_model: str) -> str:
742
+ """Rule: every 5G router uses a 4x4 antenna."""
743
+ return "4x4"
744
+
745
+ # If the model name hints 5G, lean 4x4
746
+ if "5g" in model.lower() or model.upper().startswith(("R", "E", "S", "IX", "RUTM")):
747
+ default = "4x4"
748
+ else:
749
+ default = "2x2"
750
+
751
+ # Use dec2025routers.csv if we can match the model under the same maker family
752
+ try:
753
+ pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
754
+ if pool.empty:
755
+ return default
756
+ hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
757
+ if not hit or hit[1] < MATCH_OK:
758
+ return default
759
+ row = pool.iloc[int(hit[2])]
760
+ txt2 = (str(row.get("Antennas (internal/external/both)", "")) + " " + str(row.get("Modem Type", "")) + " " + str(row.get("Special notes",""))).lower()
761
+ if "4x4" in txt2 or "4 x 4" in txt2 or "4x 4" in txt2:
762
+ return "4x4"
763
+ if "2x2" in txt2 or "2 x 2" in txt2:
764
+ return "2x2"
765
+ # If modem type includes 5G, lean 4x4
766
+ if "5g" in txt2 or "nr" in txt2:
767
+ return "4x4"
768
+ return default
769
+ except Exception:
770
+ return default
771
+
772
+ def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:
773
+ q_stationary = f"{router_model} {tech} {mimo} omni stationary pole wall fixed site Parsec"
774
+ q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile magnetic through-bolt Parsec"
775
+
776
+ cand_stationary = parsec_retrieve(q_stationary, top_k=12)
777
+ cand_vehicle = parsec_retrieve(q_vehicle, top_k=12)
778
+
779
+ s = choose_best_parsec(cand_stationary, mode="stationary")
780
+ v = choose_best_parsec(cand_vehicle, mode="vehicle")
781
+
782
+ s.update({"mimo": mimo, "why": "Stationary omni best match."})
783
+ v.update({"mimo": mimo, "why": "Vehicle omni best match."})
784
+
785
+ return {"stationary_omni": s, "vehicle_omni": v, "sources":["parsec_rag"]}
786
+
787
+
788
+ # ============================
789
+ # Install-ready checklist
790
+ # ============================
791
+ def install_ready_checklist(current_sku: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
792
+ st = ant.get("stationary_omni", {})
793
+ vh = ant.get("vehicle_omni", {})
794
+ if client is not None:
795
+ sys = "Create a short, install-ready checklist for a Verizon rep. Return markdown only."
796
+ payload = {"current_device": current_sku, "replacements": repl, "antennas": {"stationary": st, "vehicle": vh}}
797
+ resp = client.responses.create(
798
+ model=OPENAI_MODEL,
799
+ reasoning=OPENAI_REASONING,
800
+ input=[{"role":"system","content":sys},{"role":"user","content":json.dumps(payload)}],
801
+ max_output_tokens=520,
802
+ )
803
+ return (getattr(resp, "output_text", "") or "").strip()
804
+ return "\n".join([
805
+ "### Install-ready checklist",
806
+ f"- Current device: {current_sku}",
807
+ f"- 5G replacement: {repl.get('repl_5g','')}",
808
+ f"- 4G alternative: {repl.get('repl_4g','Not applicable')}",
809
+ f"- Stationary omni antenna: {st.get('name','')} (PN {st.get('part_number','')})",
810
+ f"- Vehicle omni antenna: {vh.get('name','')} (PN {vh.get('part_number','')})",
811
+ "- Next steps: confirm mounting + cable lengths + power; place order; schedule install.",
812
+ ])
813
+
814
+
815
+ # ============================
816
+ # Batch mode (NO GPT)
817
+ # ============================
818
+ def parse_batch_inputs(text_blob: str, file_obj: Any) -> List[str]:
819
+ items: List[str] = []
820
+ if file_obj is not None:
821
+ try:
822
+ path = file_obj.name if hasattr(file_obj, "name") else str(file_obj)
823
+ df = pd.read_csv(path)
824
+ col = df.columns[0]
825
+ items.extend([str(x).strip() for x in df[col].tolist() if str(x).strip()])
826
+ except Exception:
827
+ pass
828
+ if text_blob:
829
+ for ln in str(text_blob).splitlines():
830
+ ln = ln.strip()
831
+ if ln:
832
+ items.append(ln)
833
+ seen=set()
834
+ out=[]
835
+ for x in items:
836
+ k=norm_text(x)
837
+ if k and k not in seen:
838
+ seen.add(k); out.append(x)
839
+ return out
840
+
841
+ def run_batch(text_blob: str, file_obj: Any, include_antennas: bool):
842
+ inputs = parse_batch_inputs(text_blob, file_obj)
843
+ if not inputs:
844
+ return "", None, None, ""
845
+
846
+ rows=[]
847
+ for item in inputs:
848
+ res = resolve_device(item)
849
+ if res.get("mode") != "ok":
850
+ rows.append({"Input": item, "Matched":"", "Status":"Needs review", "EOS":"", "EOL":"", "4G alternative":"", "5G replacement":"", "Notes":"Not found/ambiguous"})
851
+ continue
852
+
853
+ life_row = df_eos.iloc[int(res["row_idx"])]
854
+ eos, eol, status = row_to_dates_and_status(life_row)
855
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=False)
856
+
857
+ rows.append({
858
+ "Input": item,
859
+ "Matched": str(life_row.get("sku","")),
860
+ "Status": status,
861
+ "EOS": eos,
862
+ "EOL": eol,
863
+ "4G alternative": repl.get("repl_4g",""),
864
+ "5G replacement": repl.get("repl_5g",""),
865
+ "Notes": "",
866
+ })
867
+
868
+ out_df = pd.DataFrame(rows)
869
+ counts = out_df["Status"].value_counts(dropna=False).to_dict()
870
+ top_5g = out_df["5G replacement"].value_counts(dropna=False).head(5).to_dict()
871
+ summary = f"Rows: {len(out_df)} | " + " | ".join([f"{k}: {v}" for k,v in counts.items()])
872
+ rollup = "Top 5G recommendations:\n" + "\n".join([f"- {k}: {v}" for k,v in top_5g.items() if str(k).strip()])
873
+
874
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
875
+ out_df.to_csv(tmp.name, index=False)
876
+
877
+ return summary, out_df, tmp.name, rollup
878
+
879
+
880
+ # ============================
881
+ # Replacement feature table + manufacturer link (5G device)
882
+ # ============================
883
+
884
+ FEATURE_COLS = ["Device", "Modem technology", "WiFi", "Ports", "Antennas", "Ruggedness", "Use case"]
885
+
886
+ # Manufacturer domains used for best-effort link resolution (no non-maker domains).
887
+ MAKER_DOMAINS = {
888
+ "CRADLEPOINT": ["cradlepoint.com", "ericsson.com"],
889
+ "SIERRA": ["semtech.com", "airlink.com"],
890
+ "FEENEY": ["inseego.com"],
891
+ "DIGI": ["digi.com"],
892
+ "CISCO_MERAKI": ["meraki.cisco.com", "cisco.com"],
893
+ "CISCO": ["cisco.com"],
894
+ "TELTONIKA": ["teltonika-networks.com"],
895
+ "UNKNOWN": [],
896
+ }
897
+
898
+ HTTP_HEADERS = {
899
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
900
+ "(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
901
+ }
902
+ HTTP_TIMEOUT = 12
903
+
904
+ def _best_effort_manufacturer_url(model: str, canon_make: str) -> str:
905
+ """Try to find a manufacturer page or datasheet link using simple on-domain searches.
906
+ If we can't confirm a page, return the manufacturer homepage for the maker family.
907
+ """
908
+ model = str(model or "").strip()
909
+ if not model or model in {"Not listed", "Not applicable"}:
910
+ return ""
911
+
912
+ domains = MAKER_DOMAINS.get(canon_make, []) or []
913
+ if not domains:
914
+ return ""
915
+
916
+ # Candidate on-domain search URLs (common patterns across sites).
917
+ # We keep these on the manufacturer domain (no Google/Bing).
918
+ q = re.sub(r"\s+", "+", model)
919
+ url_candidates = []
920
+ for d in domains:
921
+ url_candidates += [
922
+ f"https://{d}/search?q={q}",
923
+ f"https://{d}/search?query={q}",
924
+ f"https://{d}/?s={q}",
925
+ f"https://www.{d}/search?q={q}",
926
+ f"https://www.{d}/search?query={q}",
927
+ f"https://www.{d}/?s={q}",
928
+ ]
929
+
930
+ # Also try a few direct product patterns for known makers (best effort).
931
+ if canon_make == "TELTONIKA":
932
+ slug = model.lower()
933
+ url_candidates += [
934
+ f"https://teltonika-networks.com/products/routers/{slug}",
935
+ f"https://teltonika-networks.com/product/{slug}",
936
+ "https://teltonika-networks.com/products/routers/",
937
+ ]
938
+ if canon_make == "DIGI":
939
+ url_candidates += [
940
+ "https://www.digi.com/products/networking/cellular-routers",
941
+ f"https://www.digi.com/search?q={q}",
942
+ ]
943
+ if canon_make == "CRADLEPOINT":
944
+ url_candidates += [
945
+ "https://cradlepoint.com/products/",
946
+ f"https://cradlepoint.com/?s={q}",
947
+ ]
948
+ if canon_make in {"CISCO", "CISCO_MERAKI"}:
949
+ url_candidates += [
950
+ f"https://www.cisco.com/c/en/us/search.html?q={q}",
951
+ ]
952
+
953
+ # Try to confirm a working page (HTTP 200 and model string somewhere in HTML).
954
+ for u in url_candidates[:18]:
955
+ try:
956
+ import requests
957
+ r = requests.get(u, headers=HTTP_HEADERS, timeout=HTTP_TIMEOUT, allow_redirects=True)
958
+ if r.status_code != 200:
959
+ continue
960
+ html = (r.text or "").lower()
961
+ if model.lower() in html or "datasheet" in html or "data sheet" in html:
962
+ return r.url
963
+ except Exception:
964
+ continue
965
+
966
+ # Fallback: maker homepage
967
+ d0 = domains[0]
968
+ return f"https://{d0}"
969
+
970
+ def _fetch_page_text(url: str, max_chars: int = 12000) -> str:
971
+ """Fetch page HTML and return a simplified text blob for GPT (best effort)."""
972
+ if not url:
973
+ return ""
974
+ try:
975
+ import requests
976
+ r = requests.get(url, headers=HTTP_HEADERS, timeout=HTTP_TIMEOUT, allow_redirects=True)
977
+ if r.status_code != 200:
978
+ return ""
979
+ html = r.text or ""
980
+ html = re.sub(r"(?is)<script.*?>.*?</script>", " ", html)
981
+ html = re.sub(r"(?is)<style.*?>.*?</style>", " ", html)
982
+ text = re.sub(r"(?is)<[^>]+>", " ", html)
983
+ text = re.sub(r"\s+", " ", text).strip()
984
+ return text[:max_chars]
985
+ except Exception:
986
+ return ""
987
+
988
+
989
+ def _features_from_dec(model: str, canon_make: str) -> Dict[str, str]:
990
+ """Lookup a router model in dec2025routers.csv and return the key feature fields."""
991
+ if not model or model in {"Not listed", "Not applicable"}:
992
+ return {k: "Not listed" for k in FEATURE_COLS[1:]}
993
+
994
+ pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
995
+ if pool.empty:
996
+ return {k: "Not listed" for k in FEATURE_COLS[1:]}
997
+
998
+ hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
999
+ if not hit or hit[1] < MATCH_OK:
1000
+ return {k: "Not listed" for k in FEATURE_COLS[1:]}
1001
+
1002
+ r = pool.iloc[int(hit[2])]
1003
+ ports = f"WAN: {r.get('WAN ports and speed','')} | LAN: {r.get('LAN ports and speed','')}"
1004
+ return {
1005
+ "Modem technology": str(r.get("Modem Type","")) or "Not listed",
1006
+ "WiFi": str(r.get("WiFi type","")) or "Not listed",
1007
+ "Ports": ports.strip() if ports.strip() else "Not listed",
1008
+ "Antennas": str(r.get("Antennas (internal/external/both)","")) or "Not listed",
1009
+ "Ruggedness": str(r.get("Ruggedization","")) or "Not listed",
1010
+ "Use case": str(r.get("Primary use case","")) or "Not listed",
1011
+ }
1012
+
1013
+ def _gpt_fill_feature_row(device_label: str, model: str, canon_make: str, row: Dict[str, str], manufacturer_url: str = "", page_text: str = "") -> Dict[str, str]:
1014
+ """If dec can't supply values, ask GPT to fill missing ones (best guess)."""
1015
+ if client is None:
1016
+ return row
1017
+
1018
+ missing = [k for k,v in row.items() if (not v) or str(v).strip().lower() in {"not listed","nan",""}]
1019
+ if not missing:
1020
+ return row
1021
+
1022
+ sys = (
1023
+ "Fill missing router feature fields for a Verizon rep. Return strict JSON only. "
1024
+ "Use manufacturer page text when available. If still unknown, make a best-guess."
1025
+ )
1026
+ payload = {
1027
+ "device_label": device_label,
1028
+ "model": model,
1029
+ "maker_family": canon_make,
1030
+ "manufacturer_url": manufacturer_url,
1031
+ "manufacturer_page_text": page_text[:8000],
1032
+ "known": row,
1033
+ "fill_only": missing,
1034
+ "rules": ["Fill only requested fields.", "Short phrases only.", "Return JSON only."],
1035
+ "output_schema": {k: "string" for k in missing},
1036
+ }
1037
+ out = gpt_json(sys, payload, max_tokens=320) or {}
1038
+ for k in missing:
1039
+ val = str(out.get(k, "") or "").strip()
1040
+ if val:
1041
+ row[k] = val
1042
+ return row
1043
+ missing = [k for k,v in row.items() if (not v) or str(v).strip().lower() in {"not listed","nan",""}]
1044
+ if not missing:
1045
+ return row
1046
+
1047
+ sys = "Fill missing router feature fields for a Verizon rep. Return strict JSON only."
1048
+ payload = {
1049
+ "device_label": device_label,
1050
+ "model": model,
1051
+ "maker_family": canon_make,
1052
+ "known": row,
1053
+ "fill_only": missing,
1054
+ "rules": [
1055
+ "Fill only the requested fields.",
1056
+ "Best guess if needed. Short phrases only.",
1057
+ "Return JSON only."
1058
+ ],
1059
+ "output_schema": {k: "string" for k in missing}
1060
+ }
1061
+ out = gpt_json(sys, payload, max_tokens=260) or {}
1062
+ for k in missing:
1063
+ val = str(out.get(k, "") or "").strip()
1064
+ if val:
1065
+ row[k] = val
1066
+ return row
1067
+
1068
+ def build_replacement_features_table(repl_4g: str, repl_5g: str, canon_make: str) -> pd.DataFrame:
1069
+ rows = []
1070
+
1071
+ # 4G alternative row
1072
+ row4 = _features_from_dec(repl_4g, canon_make)
1073
+ url4 = _best_effort_manufacturer_url(repl_4g, canon_make) if repl_4g else ""
1074
+ txt4 = _fetch_page_text(url4) if url4 else ""
1075
+ row4 = _gpt_fill_feature_row("4G alternative", repl_4g, canon_make, row4, manufacturer_url=url4, page_text=txt4)
1076
+ rows.append({"Device": "4G alternative", **row4})
1077
+
1078
+ # 5G replacement row
1079
+ row5 = _features_from_dec(repl_5g, canon_make)
1080
+ url5 = _best_effort_manufacturer_url(repl_5g, canon_make) if repl_5g else ""
1081
+ txt5 = _fetch_page_text(url5) if url5 else ""
1082
+ row5 = _gpt_fill_feature_row("5G replacement", repl_5g, canon_make, row5, manufacturer_url=url5, page_text=txt5)
1083
+ rows.append({"Device": "5G replacement", **row5})
1084
+
1085
+ df = pd.DataFrame(rows, columns=FEATURE_COLS)
1086
+ return df
1087
+ # ============================
1088
+ # Verizon fit badges (small table) for recommended devices
1089
+ # ============================
1090
+
1091
+ FIT_COLS = ["Device", "Fit badges", "Ethernet ports", "Battery"]
1092
+
1093
+ def _parse_ethernet_ports(wan_field: str, lan_field: str) -> str:
1094
+ """Best-effort total ethernet ports based on WAN/LAN text."""
1095
+ def _count(field: str) -> int:
1096
+ s = str(field or "")
1097
+ # Common forms: "1x GbE", "2 x 10/100", "WAN: 1", etc.
1098
+ nums = [int(x) for x in re.findall(r"(\\d+)\\s*x", s.lower())]
1099
+ if nums:
1100
+ return sum(nums)
1101
+ # Fallback: if it contains 'port' with a number
1102
+ m = re.search(r"(\\d+)\\s*port", s.lower())
1103
+ if m:
1104
+ return int(m.group(1))
1105
+ # If it contains '1' and 'wan' in short text, guess 1
1106
+ if "wan" in s.lower() and re.search(r"\\b1\\b", s):
1107
+ return 1
1108
+ return 0
1109
+
1110
+ total = _count(wan_field) + _count(lan_field)
1111
+ return str(total) if total > 0 else "Not listed"
1112
+
1113
+ def _battery_badge(battery_field: str) -> str:
1114
+ s = str(battery_field or "").strip().lower()
1115
+ if not s or s in {"none", "no", "n/a", "not listed"}:
1116
+ return "No"
1117
+ return "Yes"
1118
+
1119
+ def _bool_badge(flag: bool) -> str:
1120
+ return "Yes" if flag else "No"
1121
+
1122
+ def _dual_sim_from_row_text(*fields: str) -> bool:
1123
+ txt = " ".join([str(x or "") for x in fields]).lower()
1124
+ return ("dual sim" in txt) or ("2 sim" in txt) or ("two sim" in txt) or ("dual-sim" in txt)
1125
+
1126
+ def _throughput_high(throughput_field: str) -> bool:
1127
+ t = str(throughput_field or "").lower()
1128
+ # Heuristic: anything mentioning gbps or >=1000 mbps
1129
+ if "gbps" in t:
1130
+ return True
1131
+ m = re.search(r"(\\d+(?:\\.\\d+)?)\\s*mbps", t)
1132
+ if m:
1133
+ try:
1134
+ return float(m.group(1)) >= 1000.0
1135
+ except Exception:
1136
+ pass
1137
+ return False
1138
+
1139
+ def _gpt_fit_badges(model: str, canon_make: str, is_5g: bool, dec_row: Optional[pd.Series]) -> Tuple[str, str, str]:
1140
+ """
1141
+ GPT-based fill for Fit badges / Ethernet ports / Battery, used when dec is missing or incomplete.
1142
+ Returns (badges_csv, ethernet_ports, battery_yesno).
1143
+ """
1144
+ if client is None:
1145
+ return ("Not listed", "Not listed", "Not listed")
1146
+
1147
+ dec_ctx = {}
1148
+ if dec_row is not None:
1149
+ try:
1150
+ dec_ctx = {
1151
+ "Model": str(dec_row.get("Model","")),
1152
+ "Modem Type": str(dec_row.get("Modem Type","")),
1153
+ "Ruggedization": str(dec_row.get("Ruggedization","")),
1154
+ "WAN ports and speed": str(dec_row.get("WAN ports and speed","")),
1155
+ "LAN ports and speed": str(dec_row.get("LAN ports and speed","")),
1156
+ "Antennas": str(dec_row.get("Antennas (internal/external/both)","")),
1157
+ "WiFi type": str(dec_row.get("WiFi type","")),
1158
+ "Primary use case": str(dec_row.get("Primary use case","")),
1159
+ "Serial port": str(dec_row.get("Serial port (yes/no)","")),
1160
+ "VPN": str(dec_row.get("VPN capabilities","")),
1161
+ "Throughput": str(dec_row.get("Router throughput","")),
1162
+ "Battery": str(dec_row.get("Battery (internal/removable/none/optional)","")),
1163
+ "Special notes": str(dec_row.get("Special notes","")),
1164
+ "Summary": str(dec_row.get("summary and use case","")),
1165
+ }
1166
+ except Exception:
1167
+ dec_ctx = {}
1168
+
1169
+ sys = (
1170
+ "You are helping a Verizon rep. Based on the provided router context, output fit badges and a couple quick traits.\n"
1171
+ "Return STRICT JSON only.\n"
1172
+ "Badges must be chosen from this set only:\n"
1173
+ "['Vehicle','Fixed site','Wi‑Fi','Rugged','Dual‑SIM','4x4 MIMO','High throughput','Serial'].\n"
1174
+ "Rules:\n"
1175
+ "- If is_5g is true, ALWAYS include '4x4 MIMO'.\n"
1176
+ "- Ethernet ports: return a single integer as a string if you can infer total ethernet ports, otherwise 'Not listed'.\n"
1177
+ "- Battery: return 'Yes' or 'No' if you can infer, otherwise 'Not listed'.\n"
1178
+ "- If uncertain between Vehicle vs Fixed site, pick the most likely based on use case/ruggedization.\n"
1179
+ )
1180
+
1181
+ payload = {
1182
+ "model": model,
1183
+ "maker_family": canon_make,
1184
+ "is_5g": bool(is_5g),
1185
+ "dec_context": dec_ctx,
1186
+ "output_schema": {
1187
+ "badges": ["string"],
1188
+ "ethernet_ports": "string",
1189
+ "battery": "Yes|No|Not listed"
1190
+ }
1191
+ }
1192
+
1193
+ out = gpt_json(sys, payload, max_tokens=260) or {}
1194
+
1195
+ badges = out.get("badges", []) or []
1196
+ allowed = {"Vehicle","Fixed site","Wi‑Fi","Rugged","Dual‑SIM","4x4 MIMO","High throughput","Serial"}
1197
+ clean = []
1198
+ for b in badges:
1199
+ bs = str(b).strip()
1200
+ if bs in allowed:
1201
+ clean.append(bs)
1202
+
1203
+ if is_5g and "4x4 MIMO" not in clean:
1204
+ clean.append("4x4 MIMO")
1205
+
1206
+ eth = str(out.get("ethernet_ports","") or "").strip()
1207
+ if not eth or eth.lower() in {"nan","none"}:
1208
+ eth = "Not listed"
1209
+ m = re.search(r"\d+", eth)
1210
+ eth = m.group(0) if m else ("Not listed" if eth == "Not listed" else eth)
1211
+
1212
+ bat = str(out.get("battery","") or "").strip()
1213
+ if not bat:
1214
+ bat = "Not listed"
1215
+ if bat.lower().startswith("y"):
1216
+ bat = "Yes"
1217
+ elif bat.lower().startswith("n"):
1218
+ bat = "No"
1219
+ elif bat not in {"Yes","No","Not listed"}:
1220
+ bat = "Not listed"
1221
+
1222
+ dedup=[]
1223
+ seen=set()
1224
+ for b in clean:
1225
+ if b not in seen:
1226
+ seen.add(b); dedup.append(b)
1227
+ badges_csv = ", ".join(dedup) if dedup else "Not listed"
1228
+ return (badges_csv, eth, bat)
1229
+
1230
+
1231
+ def _fit_badges_for_model(model: str, canon_make: str, is_5g: bool) -> Tuple[str, str, str]:
1232
+ """Return (badges_csv, ethernet_ports, battery_yesno). Uses dec2025routers.csv first, then GPT fill."""
1233
+ model = str(model or "").strip()
1234
+ if not model or model in {"Not listed", "Not applicable"}:
1235
+ return ("Not listed", "Not listed", "Not listed")
1236
+
1237
+ pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
1238
+ row = None
1239
+ if not pool.empty:
1240
+ hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
1241
+ if hit and hit[1] >= MATCH_OK:
1242
+ row = pool.iloc[int(hit[2])]
1243
+
1244
+ badges = []
1245
+ eth = "Not listed"
1246
+ bat_yes = "Not listed"
1247
+
1248
+ if row is not None:
1249
+ use_case = str(row.get("Primary use case","") or "").lower()
1250
+ rugged = str(row.get("Ruggedization","") or "").lower()
1251
+
1252
+ if any(k in use_case for k in ["vehicle","mobile","fleet","in-vehicle"]) or "vehicle" in rugged:
1253
+ badges.append("Vehicle")
1254
+ else:
1255
+ badges.append("Fixed site")
1256
+
1257
+ wifi = str(row.get("WiFi type","") or "").strip()
1258
+ if wifi and wifi.lower() not in {"none","no","n/a"}:
1259
+ badges.append("Wi‑Fi")
1260
+
1261
+ if any(k in rugged for k in ["rugged","industrial","ip","harsh"]):
1262
+ badges.append("Rugged")
1263
+
1264
+ notes_blob = " ".join([
1265
+ str(row.get("Special notes","") or ""),
1266
+ str(row.get("summary and use case","") or ""),
1267
+ ]).lower()
1268
+ if "dual" in notes_blob and "sim" in notes_blob:
1269
+ badges.append("Dual‑SIM")
1270
+
1271
+ if is_5g:
1272
+ badges.append("4x4 MIMO")
1273
+
1274
+ thr = str(row.get("Router throughput","") or "").lower()
1275
+ m = re.search(r"(\d+(\.\d+)?)\s*gb", thr)
1276
+ if m:
1277
+ try:
1278
+ if float(m.group(1)) >= 1.0:
1279
+ badges.append("High throughput")
1280
+ except Exception:
1281
+ pass
1282
+
1283
+ serial = str(row.get("Serial port (yes/no)","") or "").strip().lower()
1284
+ if serial in {"yes","y","true"}:
1285
+ badges.append("Serial")
1286
+
1287
+ wan = str(row.get("WAN ports and speed","") or "")
1288
+ lan = str(row.get("LAN ports and speed","") or "")
1289
+ m1 = re.search(r"(\d+)\s*x", wan.lower())
1290
+ m2 = re.search(r"(\d+)\s*x", lan.lower())
1291
+ if m1 or m2:
1292
+ total = (int(m1.group(1)) if m1 else 0) + (int(m2.group(1)) if m2 else 0)
1293
+ eth = str(total) if total > 0 else "Not listed"
1294
+
1295
+ bat = str(row.get("Battery (internal/removable/none/optional)","") or "")
1296
+ bat_l = bat.lower().strip()
1297
+ if bat_l:
1298
+ if "none" in bat_l:
1299
+ bat_yes = "No"
1300
+ else:
1301
+ bat_yes = "Yes"
1302
+
1303
+ # Use GPT when anything is missing (instead of best-effort inference)
1304
+ if (row is None) or (eth == "Not listed") or (bat_yes == "Not listed") or (not badges):
1305
+ g_badges, g_eth, g_bat = _gpt_fit_badges(model, canon_make, is_5g, row)
1306
+
1307
+ if badges:
1308
+ if is_5g and "4x4 MIMO" not in badges:
1309
+ badges.append("4x4 MIMO")
1310
+ dedup=[]
1311
+ seen=set()
1312
+ for b in badges:
1313
+ if b not in seen:
1314
+ seen.add(b); dedup.append(b)
1315
+ badges_csv = ", ".join(dedup)
1316
+ else:
1317
+ badges_csv = g_badges
1318
+
1319
+ eth = eth if eth != "Not listed" else g_eth
1320
+ bat_yes = bat_yes if bat_yes != "Not listed" else g_bat
1321
+ return (badges_csv or "Not listed", eth or "Not listed", bat_yes or "Not listed")
1322
+
1323
+ dedup=[]
1324
+ seen=set()
1325
+ for b in badges:
1326
+ if b not in seen:
1327
+ seen.add(b); dedup.append(b)
1328
+ badges_csv = ", ".join(dedup) if dedup else "Not listed"
1329
+ return (badges_csv, eth, bat_yes)
1330
+
1331
+ def build_fit_table(repl_4g: str, repl_5g: str, canon_make: str) -> pd.DataFrame:
1332
+ rows = []
1333
+ # 4G alt row (is_5g False)
1334
+ b4, eth4, bat4 = _fit_badges_for_model(repl_4g, canon_make, is_5g=False)
1335
+ rows.append({"Device": "4G alternative", "Fit badges": b4, "Ethernet ports": eth4, "Battery": bat4})
1336
+ # 5G row (is_5g True)
1337
+ b5, eth5, bat5 = _fit_badges_for_model(repl_5g, canon_make, is_5g=True)
1338
+ rows.append({"Device": "5G replacement", "Fit badges": b5, "Ethernet ports": eth5, "Battery": bat5})
1339
+ return pd.DataFrame(rows, columns=FIT_COLS)
1340
+
1341
+ # ============================
1342
+ # Output
1343
+ # ============================
1344
+ def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
1345
+ current_name = f"{life_row.get('sku','')} — {life_row.get('description','')}".strip(" —")
1346
+ st = ant.get("stationary_omni", {})
1347
+ vh = ant.get("vehicle_omni", {})
1348
+
1349
+ lines = []
1350
+ lines.append(f"1. Current device: **{current_name}**")
1351
+ lines.append(f"2. Status: **{status}**")
1352
+ lines.append(f"3. End of Sale date: **{eos}**")
1353
+ lines.append(f"4. End of Life date: **{eol}**")
1354
+ lines.append(f"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**")
1355
+ lines.append(f"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**")
1356
+ lines.append("7. Antenna options (Parsec-only):")
1357
+ conn_s = f" | Conn: {st.get('connectors','')}" if st.get("connectors") else ""
1358
+ conn_v = f" | Conn: {vh.get('connectors','')}" if vh.get("connectors") else ""
1359
+ lines.append(f" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')}{conn_s}")
1360
+ lines.append(f" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')}{conn_v}")
1361
+
1362
+ lines.append("\nSources (debug):")
1363
+ for s in repl.get("sources", []) if isinstance(repl.get("sources"), list) else []:
1364
+ lines.append(f"- {s}")
1365
+ lines.append("- ParsecCatalog.pdf (local RAG)")
1366
+ lines.append("- routers_eos_eol_by_sku.csv (replacements)")
1367
+ return "\n".join(lines)
1368
+
1369
+
1370
+ # ============================
1371
+ # Customer-ready email summary (single lookup only)
1372
+ # ============================
1373
+
1374
+ def build_customer_email(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any], link5: str) -> str:
1375
+ """Email-style summary the rep can paste to a customer (lightly sales-y)."""
1376
+ current = f"{life_row.get('sku','')} — {life_row.get('description','')}".strip(" —")
1377
+ repl5 = str(repl.get("repl_5g","") or "").strip()
1378
+ repl4 = str(repl.get("repl_4g","") or "").strip()
1379
+
1380
+ st = ant.get("stationary_omni", {}) or {}
1381
+ vh = ant.get("vehicle_omni", {}) or {}
1382
+
1383
+ lines = []
1384
+ lines.append("Subject: Router replacement recommendation")
1385
+ lines.append("")
1386
+ lines.append("Hi there,")
1387
+ lines.append("")
1388
+ lines.append(f"We reviewed your current router (**{current}**) and recommend the following path forward:")
1389
+ lines.append("")
1390
+ lines.append(f"- **Status:** {status}")
1391
+ lines.append(f"- **End of Sale:** {eos}")
1392
+ lines.append(f"- **End of Life:** {eol}")
1393
+ lines.append("")
1394
+ lines.append("**Recommended replacement (5G):**")
1395
+ lines.append(f"- {repl5 if repl5 else 'Not listed'}")
1396
+ if link5:
1397
+ lines.append(f"- Manufacturer page (best effort): {link5}")
1398
+ lines.append("")
1399
+ lines.append("**Optional 4G alternative (if needed):**")
1400
+ lines.append(f"- {repl4 if repl4 and repl4.lower() != 'not applicable' else 'Not applicable'}")
1401
+ lines.append("")
1402
+ lines.append("**Antenna suggestions (Parsec):**")
1403
+ lines.append(f"- Stationary (Omni): {st.get('name','')} (PN {st.get('part_number','')})")
1404
+ lines.append(f"- Vehicle (Omni): {vh.get('name','')} (PN {vh.get('part_number','')})")
1405
+ lines.append("")
1406
+ lines.append("If you’d like, we can confirm the best-fit option for your install environment and provide pricing.")
1407
+ lines.append("")
1408
+ lines.append("Contact Peter Dunn @ 786.999.9127 or peter.dunn@masterstelecom.com for pricing.")
1409
+ lines.append("")
1410
+ lines.append("Thanks,")
1411
+ lines.append("Peter Dunn")
1412
+ return "\n".join(lines)
1413
+
1414
+ def generate_customer_email(st_json: str) -> str:
1415
+ st = state_load(st_json)
1416
+ if not st or "row_idx" not in st:
1417
+ return "Run a lookup first."
1418
+ try:
1419
+ life_row = df_eos.iloc[int(st["row_idx"])]
1420
+ except Exception:
1421
+ return "Run a lookup first."
1422
+
1423
+ eos, eol, status = row_to_dates_and_status(life_row)
1424
+ repl = st.get("repl", {}) or {}
1425
+ ant = st.get("ant", {}) or {}
1426
+
1427
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
1428
+ url5 = _best_effort_manufacturer_url(str(repl.get("repl_5g","") or ""), canon_make)
1429
+ return build_customer_email(life_row, status, eos, eol, repl, ant, url5)
1430
+
1431
+ # ============================
1432
+ # Gradio callbacks
1433
+ # IMPORTANT: no dict state and ALL events have api_name=False (prevents api_info schema generation)
1434
+ # ============================
1435
+ def run_lookup(user_text: str, st_json: str):
1436
+ user_text = str(user_text or "").strip()
1437
+ if not user_text:
1438
+ return "Enter a router SKU/model.", "", None, None, "", gr.update(visible=False), gr.update(visible=False), "{}", "", ""
1439
+
1440
+ res = resolve_device(user_text)
1441
+
1442
+ if res.get("mode") == "pick":
1443
+ opts = res.get("options", [])
1444
+ choices = [o["label"] for o in opts]
1445
+ st2 = {"mode":"pick","options": opts, "raw": user_text}
1446
+ return "Did you mean A or B? Pick one, then click Use selection.", "", None, None, "", gr.update(choices=choices, value=None, visible=True), gr.update(visible=True), state_dump(st2), "", ""
1447
+
1448
+ if res.get("mode") != "ok":
1449
+ return "Not found.", "", None, None, "", gr.update(visible=False), gr.update(visible=False), "{}", "", ""
1450
+
1451
+ life_row = df_eos.iloc[int(res["row_idx"])]
1452
+ eos, eol, status = row_to_dates_and_status(life_row)
1453
+
1454
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)
1455
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
1456
+ mimo = infer_mimo_for_5g(repl.get("repl_5g",""))
1457
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") != "Not listed" else ("4G" if device_is_4g(life_row) else "Unknown")
1458
+ ant = antenna_options_for(repl.get("repl_5g") or str(life_row.get("sku","")), tech, mimo)
1459
+
1460
+ output = assemble_output(life_row, status, eos, eol, repl, ant)
1461
+ st_out = {"row_idx": int(res["row_idx"]), "repl": repl, "ant": ant, "raw": user_text}
1462
+ url5 = _best_effort_manufacturer_url(repl.get('repl_5g',''), canon_make)
1463
+ link = f"**5G manufacturer page (best effort):** {url5}" if url5 else ""
1464
+ feat_df = build_replacement_features_table(repl.get('repl_4g',''), repl.get('repl_5g',''), canon_make)
1465
+ fit = build_fit_table(repl.get('repl_4g',''), repl.get('repl_5g',''), canon_make)
1466
+ return output, link, feat_df, fit, "", gr.update(visible=False), gr.update(visible=False), state_dump(st_out), "", ""
1467
+
1468
+ def use_selection(selected_label: str, st_json: str):
1469
+ st = state_load(st_json)
1470
+ if not st or st.get("mode") != "pick":
1471
+ return "Run a search first.", "", None, None, "", gr.update(visible=False), gr.update(visible=False), "{}", "", ""
1472
+
1473
+ if not selected_label:
1474
+ return "Pick A or B first.", "", None, None, "", gr.update(visible=True), gr.update(visible=True), st_json, "", ""
1475
+
1476
+ chosen_row = None
1477
+ for o in st.get("options", []):
1478
+ if o.get("label") == selected_label:
1479
+ chosen_row = int(o["row_idx"])
1480
+ break
1481
+ if chosen_row is None:
1482
+ return "Pick a valid option.", "", None, None, "", gr.update(visible=True), gr.update(visible=True), st_json, "", ""
1483
+
1484
+ life_row = df_eos.iloc[int(chosen_row)]
1485
+ eos, eol, status = row_to_dates_and_status(life_row)
1486
+
1487
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)
1488
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
1489
+ mimo = infer_mimo_for_5g(repl.get("repl_5g",""))
1490
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") != "Not listed" else ("4G" if device_is_4g(life_row) else "Unknown")
1491
+ ant = antenna_options_for(repl.get("repl_5g") or str(life_row.get("sku","")), tech, mimo)
1492
+
1493
+ output = assemble_output(life_row, status, eos, eol, repl, ant)
1494
+ st_out = {"row_idx": int(chosen_row), "repl": repl, "ant": ant, "raw": st.get("raw","")}
1495
+ url5 = _best_effort_manufacturer_url(repl.get('repl_5g',''), canon_make)
1496
+ link = f"**5G manufacturer page (best effort):** {url5}" if url5 else ""
1497
+ feat_df = build_replacement_features_table(repl.get('repl_4g',''), repl.get('repl_5g',''), canon_make)
1498
+ fit = build_fit_table(repl.get('repl_4g',''), repl.get('repl_5g',''), canon_make)
1499
+ return output, link, feat_df, fit, "", gr.update(visible=False), gr.update(visible=False), state_dump(st_out), "", ""
1500
+
1501
+ def make_install_ready(st_json: str):
1502
+ st = state_load(st_json)
1503
+ if not st or "row_idx" not in st:
1504
+ return "Run a lookup first."
1505
+ life_row = df_eos.iloc[int(st["row_idx"])]
1506
+ current_sku = str(life_row.get("sku","") or "")
1507
+ return install_ready_checklist(current_sku, st.get("repl", {}) or {}, st.get("ant", {}) or {})
1508
+
1509
+
1510
+
1511
+ # ============================
1512
+ # Q&A about the suggested device (post-recommendation)
1513
+ # ============================
1514
+ def answer_question(question: str, st_json: str) -> str:
1515
+ q = str(question or "").strip()
1516
+ if not q:
1517
+ return ""
1518
+ st = state_load(st_json)
1519
+ if not st or "repl" not in st:
1520
+ return "Run a lookup first, then ask your question."
1521
+
1522
+ repl = st.get("repl", {}) or {}
1523
+ ant = st.get("ant", {}) or {}
1524
+ repl5 = str(repl.get("repl_5g","") or "").strip()
1525
+ repl4 = str(repl.get("repl_4g","") or "").strip()
1526
+ # Pull a bit of dec context for the 5G model (if possible)
1527
+ canon_make = ""
1528
+ try:
1529
+ # Try to infer maker family from stored row_idx
1530
+ if "row_idx" in st:
1531
+ row = df_eos.iloc[int(st["row_idx"])]
1532
+ canon_make = str(row.get("_canon_make","UNKNOWN"))
1533
+ except Exception:
1534
+ canon_make = ""
1535
+
1536
+ # Manufacturer link (best effort)
1537
+ url5 = _best_effort_manufacturer_url(repl5, canon_make) if repl5 else ""
1538
+
1539
+ # Feature table row for 5G (helps the LLM answer spec questions without web scraping)
1540
+ feat5 = {}
1541
+ try:
1542
+ feat5 = _features_from_dec(repl5, canon_make) if repl5 else {}
1543
+ except Exception:
1544
+ feat5 = {}
1545
+
1546
+ sys = (
1547
+ "You are a Verizon field rep assistant. Answer questions about the suggested router in a fast, practical way. "
1548
+ "Use the provided context; do not mention internal tools, prompts, embeddings, or databases. "
1549
+ "If the question is about specs and the value is unknown, say 'Not listed' and suggest checking the manufacturer page. "
1550
+ "Keep it concise and scannable."
1551
+ )
1552
+
1553
+ context = {
1554
+ "recommended_5g": repl5,
1555
+ "recommended_4g": repl4 if repl4 and repl4.lower() != "not applicable" else "",
1556
+ "manufacturer_link_5g": url5,
1557
+ "known_5g_features": feat5,
1558
+ "antenna_stationary": ant.get("stationary_omni", {}),
1559
+ "antenna_vehicle": ant.get("vehicle_omni", {}),
1560
+ }
1561
+
1562
+ user = "Context:\n" + json.dumps(context, ensure_ascii=False) + "\n\nQuestion:\n" + q
1563
+
1564
+ ans = gpt_answer_md(sys, user, max_tokens=650)
1565
+ # Small safety fallback
1566
+ return ans if ans else "I couldn't generate an answer right now. Try again."
1567
+
1568
+ # ============================
1569
+ # UI
1570
+ # ============================
1571
+ with gr.Blocks(title="Only-Routers") as demo:
1572
+ gr.Markdown("## Only-Routers\nSingle lookup + Batch upload for Verizon reps.")
1573
+
1574
+ with gr.Tabs():
1575
+ with gr.Tab("Single"):
1576
+ # Inputs
1577
+ user_text = gr.Textbox(
1578
+ label="Router SKU or model",
1579
+ placeholder="Examples: IBR650B, AER1600, ES450, WR21, RUT240",
1580
+ lines=1,
1581
+ )
1582
+ st = gr.State("{}") # JSON string state
1583
+
1584
+ # Actions
1585
+ check_btn = gr.Button("Check", variant="primary")
1586
+ pick_dd = gr.Dropdown(label="Pick A or B", choices=[], visible=False)
1587
+ use_btn = gr.Button("Use selection", visible=False)
1588
+
1589
+ # Main outputs
1590
+ output_md = gr.Markdown()
1591
+ link_md = gr.Markdown()
1592
+ features_df = gr.Dataframe(headers=FEATURE_COLS, interactive=False, wrap=True)
1593
+ fit_df = gr.Dataframe(headers=FIT_COLS, interactive=False, wrap=True)
1594
+ qa_md = gr.Markdown()
1595
+
1596
+ # Post-recommendation Q&A
1597
+ gr.Markdown("### Questions about the suggested device?")
1598
+ question_box = gr.Textbox(
1599
+ label="Ask a question (optional)",
1600
+ placeholder="Example: Does the 5G device support dual-SIM? How many ethernet ports? Does it support Wi‑Fi?",
1601
+ lines=2,
1602
+ )
1603
+ ask_btn = gr.Button("Ask", variant="secondary")
1604
+
1605
+ # Install-ready checklist
1606
+ install_btn = gr.Button("Make install-ready checklist")
1607
+ install_md = gr.Markdown()
1608
+
1609
+ # Customer-ready email summary
1610
+ gr.Markdown("### Customer-ready email")
1611
+ email_btn = gr.Button("Generate customer email")
1612
+ customer_email_box = gr.Textbox(label="Email draft", lines=10)
1613
+
1614
+ # Wiring (api_name=False avoids HF/Gradio API schema issues)
1615
+ check_btn.click(
1616
+ fn=run_lookup,
1617
+ inputs=[user_text, st],
1618
+ outputs=[output_md, link_md, features_df, fit_df, qa_md, pick_dd, use_btn, st, install_md, customer_email_box],
1619
+ api_name=False,
1620
+ )
1621
+ use_btn.click(
1622
+ fn=use_selection,
1623
+ inputs=[pick_dd, st],
1624
+ outputs=[output_md, link_md, features_df, fit_df, qa_md, pick_dd, use_btn, st, install_md, customer_email_box],
1625
+ api_name=False,
1626
+ )
1627
+ ask_btn.click(
1628
+ fn=answer_question,
1629
+ inputs=[question_box, st],
1630
+ outputs=[qa_md],
1631
+ api_name=False,
1632
+ )
1633
+ install_btn.click(
1634
+ fn=make_install_ready,
1635
+ inputs=[st],
1636
+ outputs=[install_md],
1637
+ api_name=False,
1638
+ )
1639
+ email_btn.click(
1640
+ fn=generate_customer_email,
1641
+ inputs=[st],
1642
+ outputs=[customer_email_box],
1643
+ api_name=False,
1644
+ )
1645
+
1646
+ with gr.Tab("Batch"):
1647
+ gr.Markdown("Paste one per line or upload a CSV (first column). Batch runs fast (no GPT).")
1648
+ batch_text = gr.Textbox(label="Paste devices (one per line)", lines=8, placeholder="WR21\nRUT240\nIBR650B")
1649
+ batch_file = gr.File(label="Upload CSV", file_types=[".csv"])
1650
+ include_ant = gr.Checkbox(label="Include antenna picks (slower)", value=False)
1651
+ run_btn = gr.Button("Run batch", variant="primary")
1652
+
1653
+ summary_md = gr.Markdown()
1654
+ rollup_md = gr.Markdown()
1655
+ table = gr.Dataframe(interactive=False, wrap=True)
1656
+ dl = gr.File(label="Download results CSV")
1657
+
1658
+ run_btn.click(
1659
+ fn=run_batch,
1660
+ inputs=[batch_text, batch_file, include_ant],
1661
+ outputs=[summary_md, table, dl, rollup_md],
1662
+ api_name=False,
1663
+ )
1664
+
1665
+ demo.launch(show_api=False)
Old Working version/requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ gradio==4.44.1
2
+ # Let gradio manage a compatible gradio_client version (prevents pip resolution failures on HF).
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
Updates/Parts to load masters.xlsx ADDED
Binary file (14.1 kB). View file
 
Updates/app_hf_chat_v12_prod.py ADDED
@@ -0,0 +1,1175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Only Routers (chat)
2
+
3
+ A Gradio chat app for Verizon reps to:
4
+ - Look up a router SKU (or close match)
5
+ - Show suggested 4G + 5G replacements
6
+ - Show a small feature table for each suggested device
7
+ - Produce a customer-ready email-style summary
8
+ - Answer follow-up questions about the suggested device(s)
9
+
10
+ Data files (keep them next to this app.py)
11
+ - routers_eos_eol_by_sku.csv (EoS/EoL + suggested replacements)
12
+ - dec2025routers.csv (device detail table)
13
+
14
+ OpenAI
15
+ - Set OPENAI_API_KEY as a Space secret.
16
+ - Default model is a non-thinking GPT-5.2 variant for lower latency.
17
+
18
+ Notes
19
+ - For 5G replacements, cellular MIMO is always treated as 4x4.
20
+ - The app uses the Dec 2025 device table first. If a field is missing, it can ask GPT.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import csv
26
+ import json
27
+ import logging
28
+ import os
29
+ import re
30
+ from dataclasses import dataclass, field
31
+ from functools import lru_cache
32
+ from pathlib import Path
33
+ from typing import Any, Dict, List, Optional, Tuple
34
+
35
+ import gradio as gr
36
+
37
+ # Optional deps. The app runs without them, with fewer niceties.
38
+ try:
39
+ from openai import OpenAI # type: ignore
40
+ except Exception: # pragma: no cover
41
+ OpenAI = None # type: ignore
42
+
43
+ try:
44
+ import requests # type: ignore
45
+ except Exception: # pragma: no cover
46
+ requests = None # type: ignore
47
+
48
+ try:
49
+ from rapidfuzz import fuzz as rf_fuzz # type: ignore
50
+ from rapidfuzz import process as rf_process # type: ignore
51
+ except Exception: # pragma: no cover
52
+ rf_fuzz = None # type: ignore
53
+ rf_process = None # type: ignore
54
+
55
+
56
+ # -----------------------------
57
+ # App config
58
+ # -----------------------------
59
+
60
+ LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
61
+ logging.basicConfig(level=LOG_LEVEL, format="%(levelname)s | %(message)s")
62
+ log = logging.getLogger("only-routers")
63
+
64
+ HERE = Path(__file__).resolve().parent
65
+
66
+ EOS_CSV_PATH = Path(os.getenv("EOS_CSV_PATH", str(HERE / "routers_eos_eol_by_sku.csv")))
67
+ CATALOG_CSV_PATH = Path(os.getenv("CATALOG_CSV_PATH", str(HERE / "dec2025routers.csv")))
68
+
69
+ # Default to the faster, non-thinking GPT-5.2 route.
70
+ OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-5.2-chat-latest")
71
+ OPENAI_MODEL_QA = os.getenv("OPENAI_MODEL_QA", OPENAI_MODEL)
72
+ OPENAI_TEMPERATURE = float(os.getenv("OPENAI_TEMPERATURE", "0.2"))
73
+ OPENAI_MAX_TOKENS = int(os.getenv("OPENAI_MAX_TOKENS", "700"))
74
+
75
+ # Fast timeouts help avoid hanging requests in a Space.
76
+ OPENAI_TIMEOUT_SEC = float(os.getenv("OPENAI_TIMEOUT_SEC", "25"))
77
+ HTTP_TIMEOUT_SEC = float(os.getenv("HTTP_TIMEOUT_SEC", "7"))
78
+
79
+ # If fuzzy matching is present, these control auto-pick.
80
+ FUZZY_AUTOPICK_MIN = int(os.getenv("FUZZY_AUTOPICK_MIN", "92"))
81
+ FUZZY_AUTOPICK_GAP = int(os.getenv("FUZZY_AUTOPICK_GAP", "5"))
82
+
83
+ CONTACT_LINE = "Contact Peter Dunn @ 786.999.9127 or peter.dunn@masterstelecom.com for pricing."
84
+
85
+
86
+ # -----------------------------
87
+ # Data models
88
+ # -----------------------------
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class EosRecord:
93
+ sku: str
94
+ manufacturer: str
95
+ end_of_sale: str
96
+ end_of_life: str
97
+ description: str
98
+ suggested_replacement: str
99
+ advanced_5g_option: str
100
+ region: str
101
+ source: str
102
+ source_detail: str
103
+ notes: str
104
+
105
+
106
+ @dataclass
107
+ class DeviceDetails:
108
+ make: str = ""
109
+ model: str = ""
110
+ modem_type: str = ""
111
+ rugged: str = "" # Yes/No/Unknown
112
+ wifi: str = "" # WiFi type, or "None"
113
+ primary_use_case: str = ""
114
+ serial: str = "" # Yes/No/Unknown
115
+ throughput: str = "" # keep as text
116
+ antennas: str = "" # keep as text
117
+ ethernet_ports_total: Optional[int] = None
118
+ battery: str = "" # Yes/No/Unknown
119
+ dual_sim: str = "" # Yes/No/Unknown
120
+ cell_mimo: str = "" # 2x2 / 4x4
121
+ link: str = "" # manufacturer page or datasheet
122
+ raw: Dict[str, str] = field(default_factory=dict)
123
+
124
+ def is_5g(self) -> bool:
125
+ txt = f"{self.modem_type} {self.model} {self.raw.get('Notes','')}".lower()
126
+ return "5g" in txt
127
+
128
+
129
+ # -----------------------------
130
+ # Small helpers
131
+ # -----------------------------
132
+
133
+
134
+ _SKU_CLEAN_RE = re.compile(r"[^A-Z0-9\-]+")
135
+
136
+
137
+ def norm_sku(s: str) -> str:
138
+ """Normalize a user-entered SKU for matching."""
139
+ s = (s or "").strip().upper()
140
+ return _SKU_CLEAN_RE.sub("", s)
141
+
142
+
143
+ def clean_str(x: Any) -> str:
144
+ if x is None:
145
+ return ""
146
+ s = str(x)
147
+ if s.strip().lower() in {"nan", "none"}:
148
+ return ""
149
+ return s.strip()
150
+
151
+
152
+ def first_nonempty(*vals: str) -> str:
153
+ for v in vals:
154
+ v = clean_str(v)
155
+ if v:
156
+ return v
157
+ return ""
158
+
159
+
160
+ def safe_int(x: Any) -> Optional[int]:
161
+ try:
162
+ return int(str(x).strip())
163
+ except Exception:
164
+ return None
165
+
166
+
167
+ def looks_like_sku(s: str) -> bool:
168
+ """Heuristic: user typed something like RUT240, IBR900, CBA850, etc."""
169
+ s2 = norm_sku(s)
170
+ return len(s2) >= 4 and any(c.isdigit() for c in s2)
171
+
172
+
173
+ def parse_yes_no(val: str) -> str:
174
+ v = clean_str(val).lower()
175
+ if not v:
176
+ return "Unknown"
177
+ if v in {"y", "yes", "true", "1"}:
178
+ return "Yes"
179
+ if v in {"n", "no", "false", "0"}:
180
+ return "No"
181
+ # handle mixed text
182
+ if "yes" in v:
183
+ return "Yes"
184
+ if "no" in v:
185
+ return "No"
186
+ return val.strip()
187
+
188
+
189
+ def is_wifi_present(wifi_type: str) -> bool:
190
+ v = clean_str(wifi_type).lower()
191
+ if not v:
192
+ return False
193
+ if v in {"none", "n/a", "na", "no"}:
194
+ return False
195
+ return True
196
+
197
+
198
+ def extract_models_from_text(text: str, known_models: List[str]) -> List[str]:
199
+ """Find known model tokens inside a longer string."""
200
+ t = (text or "")
201
+ if not t:
202
+ return []
203
+ t_up = t.upper()
204
+ hits: List[str] = []
205
+ for m in known_models:
206
+ if m and m.upper() in t_up:
207
+ hits.append(m)
208
+ # De-dupe while keeping order
209
+ seen: set[str] = set()
210
+ out: List[str] = []
211
+ for h in hits:
212
+ if h.upper() not in seen:
213
+ out.append(h)
214
+ seen.add(h.upper())
215
+ return out
216
+
217
+
218
+ _PORT_TOKEN_RE = re.compile(r"(\d+)\s*[x×]\s*")
219
+
220
+
221
+ def parse_port_count(text: str) -> int:
222
+ """Best-effort parse of strings like '1x GbE', '2 x FE', 'WAN: 1x, LAN: 4x'."""
223
+ t = clean_str(text)
224
+ if not t:
225
+ return 0
226
+
227
+ total = 0
228
+ for m in _PORT_TOKEN_RE.finditer(t.lower()):
229
+ n = safe_int(m.group(1))
230
+ if n:
231
+ total += n
232
+
233
+ # Fallback: if the string is literally '1' or '2'
234
+ if total == 0:
235
+ n = safe_int(t)
236
+ if n is not None and 0 <= n <= 16:
237
+ total = n
238
+
239
+ return total
240
+
241
+
242
+ def best_effort_total_ethernet(wan: str, lan: str) -> Optional[int]:
243
+ wan_n = parse_port_count(wan)
244
+ lan_n = parse_port_count(lan)
245
+ total = wan_n + lan_n
246
+ return total if total > 0 else None
247
+
248
+
249
+ def fit_badges(details: DeviceDetails) -> List[str]:
250
+ """Derive simple badges for quick scanning."""
251
+ badges: List[str] = []
252
+
253
+ use_case = clean_str(details.primary_use_case).lower()
254
+ if "vehicle" in use_case or "mobile" in use_case or "fleet" in use_case:
255
+ badges.append("Vehicle")
256
+ if "fixed" in use_case or "branch" in use_case or "site" in use_case or "industrial" in use_case:
257
+ badges.append("Fixed site")
258
+
259
+ if is_wifi_present(details.wifi):
260
+ badges.append("Wi-Fi")
261
+
262
+ if parse_yes_no(details.rugged) == "Yes":
263
+ badges.append("Rugged")
264
+
265
+ if parse_yes_no(details.dual_sim) == "Yes":
266
+ badges.append("Dual-SIM")
267
+
268
+ if clean_str(details.cell_mimo):
269
+ if "4" in details.cell_mimo and "x" in details.cell_mimo:
270
+ badges.append("4x4 MIMO")
271
+ elif "2" in details.cell_mimo and "x" in details.cell_mimo:
272
+ badges.append("2x2 MIMO")
273
+
274
+ # Very rough check for higher throughput.
275
+ thr = clean_str(details.throughput).lower()
276
+ if any(tok in thr for tok in ["1 gb", "1000", "2 gb", "2000", "multi-g", "gig"]):
277
+ badges.append("High throughput")
278
+
279
+ if parse_yes_no(details.serial) == "Yes":
280
+ badges.append("Serial")
281
+
282
+ return badges
283
+
284
+
285
+ # -----------------------------
286
+ # CSV loading
287
+ # -----------------------------
288
+
289
+
290
+ def _read_csv(path: Path) -> List[Dict[str, str]]:
291
+ if not path.exists():
292
+ raise FileNotFoundError(f"Missing file: {path}")
293
+
294
+ with path.open("r", encoding="utf-8-sig", newline="") as f:
295
+ reader = csv.DictReader(f)
296
+ rows: List[Dict[str, str]] = []
297
+ for r in reader:
298
+ rows.append({k: clean_str(v) for k, v in (r or {}).items()})
299
+ return rows
300
+
301
+
302
+ @lru_cache(maxsize=1)
303
+ def load_eos_records() -> List[EosRecord]:
304
+ rows = _read_csv(EOS_CSV_PATH)
305
+ out: List[EosRecord] = []
306
+ for r in rows:
307
+ out.append(
308
+ EosRecord(
309
+ sku=clean_str(r.get("sku")),
310
+ manufacturer=clean_str(r.get("manufacturer")),
311
+ end_of_sale=clean_str(r.get("end_of_sale")),
312
+ end_of_life=clean_str(r.get("end_of_life")),
313
+ description=clean_str(r.get("description")),
314
+ suggested_replacement=clean_str(r.get("suggested_replacement")),
315
+ advanced_5g_option=clean_str(r.get("advanced_5g_option")),
316
+ region=clean_str(r.get("region")),
317
+ source=clean_str(r.get("source")),
318
+ source_detail=clean_str(r.get("source_detail")),
319
+ notes=clean_str(r.get("notes")),
320
+ )
321
+ )
322
+ return out
323
+
324
+
325
+ @lru_cache(maxsize=1)
326
+ def eos_index() -> Dict[str, EosRecord]:
327
+ """Index by normalized SKU."""
328
+ idx: Dict[str, EosRecord] = {}
329
+ for rec in load_eos_records():
330
+ key = norm_sku(rec.sku)
331
+ if key:
332
+ idx[key] = rec
333
+ return idx
334
+
335
+
336
+ @lru_cache(maxsize=1)
337
+ def eos_choices() -> List[str]:
338
+ """List of raw SKU strings, for fuzzy match UI."""
339
+ return [r.sku for r in load_eos_records() if clean_str(r.sku)]
340
+
341
+
342
+ @lru_cache(maxsize=1)
343
+ def load_catalog_rows() -> List[Dict[str, str]]:
344
+ return _read_csv(CATALOG_CSV_PATH)
345
+
346
+
347
+ @lru_cache(maxsize=1)
348
+ def catalog_models() -> List[str]:
349
+ models: List[str] = []
350
+ for r in load_catalog_rows():
351
+ m = clean_str(r.get("Model"))
352
+ if m:
353
+ models.append(m)
354
+ # De-dupe
355
+ seen: set[str] = set()
356
+ out: List[str] = []
357
+ for m in models:
358
+ mu = m.upper()
359
+ if mu not in seen:
360
+ out.append(m)
361
+ seen.add(mu)
362
+ return out
363
+
364
+
365
+ @lru_cache(maxsize=1)
366
+ def catalog_index_by_model() -> Dict[str, Dict[str, str]]:
367
+ idx: Dict[str, Dict[str, str]] = {}
368
+ for r in load_catalog_rows():
369
+ m = clean_str(r.get("Model"))
370
+ if not m:
371
+ continue
372
+ idx[m.upper()] = r
373
+ return idx
374
+
375
+
376
+ def catalog_lookup(model: str) -> Optional[Dict[str, str]]:
377
+ if not model:
378
+ return None
379
+ return catalog_index_by_model().get(model.strip().upper())
380
+
381
+
382
+ # -----------------------------
383
+ # OpenAI client + calls
384
+ # -----------------------------
385
+
386
+
387
+ @lru_cache(maxsize=1)
388
+ def get_openai_client() -> Optional[Any]:
389
+ if OpenAI is None:
390
+ return None
391
+
392
+ api_key = os.getenv("OPENAI_API_KEY", "").strip()
393
+ if not api_key:
394
+ return None
395
+
396
+ try:
397
+ # OpenAI python SDK v1
398
+ return OpenAI(api_key=api_key)
399
+ except Exception:
400
+ return None
401
+
402
+
403
+ def openai_chat(
404
+ *,
405
+ model: str,
406
+ messages: List[Dict[str, str]],
407
+ max_tokens: int = OPENAI_MAX_TOKENS,
408
+ temperature: float = OPENAI_TEMPERATURE,
409
+ ) -> str:
410
+ """Small wrapper that works across common OpenAI SDK shapes."""
411
+ client = get_openai_client()
412
+ if client is None:
413
+ return ""
414
+
415
+ # Try chat.completions first.
416
+ try:
417
+ resp = client.chat.completions.create(
418
+ model=model,
419
+ messages=messages,
420
+ temperature=temperature,
421
+ max_tokens=max_tokens,
422
+ timeout=OPENAI_TIMEOUT_SEC,
423
+ )
424
+ return (resp.choices[0].message.content or "").strip()
425
+ except Exception as e:
426
+ log.warning("OpenAI chat call failed: %s", e)
427
+
428
+ # Fallback to responses API when available.
429
+ try:
430
+ resp = client.responses.create(
431
+ model=model,
432
+ input=messages,
433
+ temperature=temperature,
434
+ max_output_tokens=max_tokens,
435
+ timeout=OPENAI_TIMEOUT_SEC,
436
+ )
437
+ # Best-effort text extraction.
438
+ if hasattr(resp, "output_text"):
439
+ return (resp.output_text or "").strip()
440
+ return ""
441
+ except Exception as e:
442
+ log.warning("OpenAI responses call failed: %s", e)
443
+ return ""
444
+
445
+
446
+ def try_parse_json(text: str) -> Optional[Dict[str, Any]]:
447
+ """Parse JSON from an LLM response.
448
+
449
+ The model is asked for strict JSON.
450
+ This still handles the common case where JSON is wrapped in fences.
451
+ """
452
+ raw = clean_str(text)
453
+ if not raw:
454
+ return None
455
+
456
+ # Strip ```json fences
457
+ raw = raw.strip()
458
+ raw = re.sub(r"^```json\s*", "", raw, flags=re.IGNORECASE).strip()
459
+ raw = re.sub(r"^```\s*", "", raw, flags=re.IGNORECASE).strip()
460
+ raw = re.sub(r"```\s*$", "", raw).strip()
461
+
462
+ try:
463
+ obj = json.loads(raw)
464
+ if isinstance(obj, dict):
465
+ return obj
466
+ return None
467
+ except Exception:
468
+ return None
469
+
470
+
471
+ def validate_url(url: str) -> str:
472
+ """Return url if it looks usable. No throw."""
473
+ u = clean_str(url)
474
+ if not u:
475
+ return ""
476
+ if not (u.startswith("http://") or u.startswith("https://")):
477
+ return ""
478
+
479
+ if requests is None:
480
+ return u
481
+
482
+ # Quick HEAD/GET check; if blocked, keep the URL anyway.
483
+ try:
484
+ r = requests.head(u, timeout=HTTP_TIMEOUT_SEC, allow_redirects=True)
485
+ if 200 <= int(getattr(r, "status_code", 0)) < 400:
486
+ return u
487
+ except Exception:
488
+ pass
489
+
490
+ try:
491
+ r = requests.get(u, timeout=HTTP_TIMEOUT_SEC, allow_redirects=True)
492
+ if 200 <= int(getattr(r, "status_code", 0)) < 400:
493
+ return u
494
+ except Exception:
495
+ pass
496
+
497
+ return u
498
+
499
+
500
+ # -----------------------------
501
+ # Device detail build + GPT fill
502
+ # -----------------------------
503
+
504
+
505
+ def details_from_catalog_row(row: Dict[str, str]) -> DeviceDetails:
506
+ """Convert a Dec 2025 catalog row into the normalized DeviceDetails."""
507
+ det = DeviceDetails(
508
+ make=clean_str(row.get("Make")),
509
+ model=clean_str(row.get("Model")),
510
+ modem_type=clean_str(row.get("Modem Type")),
511
+ rugged=parse_yes_no(row.get("Ruggedization", "")),
512
+ wifi=clean_str(row.get("WiFi type")),
513
+ primary_use_case=clean_str(row.get("Primary use case")),
514
+ serial=parse_yes_no(row.get("Serial port", "")),
515
+ throughput=clean_str(row.get("Router throughput")),
516
+ antennas=clean_str(row.get("Antennas")),
517
+ ethernet_ports_total=best_effort_total_ethernet(
518
+ row.get("WAN ports and speed", ""),
519
+ row.get("LAN ports and speed", ""),
520
+ ),
521
+ battery=parse_yes_no(row.get("Battery", "")),
522
+ # Dual-SIM is not a column in this sheet today.
523
+ dual_sim="Unknown",
524
+ raw=row,
525
+ )
526
+
527
+ # Best-effort cellular MIMO guess from the Antennas cell.
528
+ ant = det.antennas.lower()
529
+ if "4x4" in ant or "4 x 4" in ant:
530
+ det.cell_mimo = "4x4"
531
+ elif "2x2" in ant or "2 x 2" in ant:
532
+ det.cell_mimo = "2x2"
533
+
534
+ return det
535
+
536
+
537
+ def enforce_5g_rules(det: DeviceDetails) -> DeviceDetails:
538
+ """Apply hard rules that should never vary."""
539
+ if det.is_5g():
540
+ det.cell_mimo = "4x4" # always
541
+ return det
542
+
543
+
544
+ @lru_cache(maxsize=256)
545
+ def _gpt_enrich_missing_fields_cached(
546
+ make: str,
547
+ model: str,
548
+ is_5g: bool,
549
+ catalog_blob: str,
550
+ want_link: bool,
551
+ ) -> dict:
552
+ """Cached GPT call used by :func:`gpt_fill_missing_fields`.
553
+
554
+ Cache key is (make, model, is_5g, catalog_blob, want_link).
555
+ """
556
+ client = get_openai_client()
557
+ if client is None:
558
+ return {}
559
+
560
+ system = (
561
+ "You are helping a Verizon seller prep a device swap. "
562
+ "The catalog text is the main reference. "
563
+ "For dual_sim and ethernet_ports_total, only use the catalog text. "
564
+ "If not stated, return null. "
565
+ "For link, you may propose an official manufacturer product page or datasheet URL "
566
+ "if you are confident it is correct; else return null. "
567
+ "Return strict JSON only."
568
+ )
569
+
570
+ user = {
571
+ "task": "Fill missing device fields.",
572
+ "device_make": make,
573
+ "device_model": model,
574
+ "device_is_5g": is_5g,
575
+ "catalog_text": catalog_blob,
576
+ "fields": {
577
+ "dual_sim": "Yes/No or null",
578
+ "ethernet_ports_total": "integer or null",
579
+ "link": "URL string or null",
580
+ },
581
+ "notes": {
582
+ "link_requested": want_link,
583
+ "cell_mimo_rule": "If device_is_5g is true, cellular MIMO is 4x4 (already enforced elsewhere).",
584
+ },
585
+ }
586
+
587
+ resp = openai_chat(
588
+ model=OPENAI_MODEL,
589
+ messages=[
590
+ {"role": "system", "content": system},
591
+ {"role": "user", "content": json.dumps(user, ensure_ascii=False)},
592
+ ],
593
+ max_tokens=420,
594
+ temperature=0.0,
595
+ )
596
+
597
+ return try_parse_json(resp) or {}
598
+
599
+
600
+ def gpt_fill_missing_fields(det: DeviceDetails) -> DeviceDetails:
601
+ """Fill missing fields via GPT.
602
+
603
+ This only runs when we have gaps, then writes back into the passed DeviceDetails.
604
+ """
605
+
606
+ if get_openai_client() is None:
607
+ return det
608
+
609
+ # Only ask GPT when we have gaps.
610
+ needs_dual_sim = parse_yes_no(det.dual_sim) == "Unknown"
611
+ needs_ports = det.ethernet_ports_total is None
612
+
613
+ # Links can be slow (and are not always needed). Default: fetch for 5G only.
614
+ links_mode = clean_str(os.getenv("FETCH_DEVICE_LINKS", "5g")).lower() # 5g | all | none
615
+ needs_link = False
616
+ if links_mode != "none" and not clean_str(det.link):
617
+ if links_mode == "all":
618
+ needs_link = True
619
+ elif links_mode == "5g" and det.is_5g():
620
+ needs_link = True
621
+
622
+ if not (needs_dual_sim or needs_ports or needs_link):
623
+ return det
624
+
625
+ # Use the catalog row text as context.
626
+ catalog_blob = "\n".join([f"{k}: {v}" for k, v in det.raw.items() if clean_str(v)])
627
+
628
+ obj = _gpt_enrich_missing_fields_cached(
629
+ make=det.make,
630
+ model=det.model,
631
+ is_5g=det.is_5g(),
632
+ catalog_blob=catalog_blob,
633
+ want_link=needs_link,
634
+ )
635
+
636
+ if not obj:
637
+ return det
638
+
639
+ if needs_dual_sim:
640
+ det.dual_sim = parse_yes_no(clean_str(obj.get("dual_sim")))
641
+
642
+ if needs_ports:
643
+ det.ethernet_ports_total = safe_int(obj.get("ethernet_ports_total"))
644
+
645
+ if needs_link:
646
+ det.link = validate_url(clean_str(obj.get("link")))
647
+
648
+ return det
649
+
650
+
651
+ # -----------------------------
652
+ # Lookup + recommendation
653
+ # -----------------------------
654
+
655
+
656
+ def fuzzy_match_sku(query: str) -> Tuple[Optional[str], List[Tuple[str, int]]]:
657
+ """Return (autopick, ranked list)."""
658
+ q = clean_str(query)
659
+ if not q:
660
+ return None, []
661
+
662
+ # If rapidfuzz is missing, we cannot do ranked match.
663
+ if rf_process is None or rf_fuzz is None:
664
+ return None, []
665
+
666
+ choices = eos_choices()
667
+ ranked = rf_process.extract(
668
+ q,
669
+ choices,
670
+ scorer=rf_fuzz.WRatio,
671
+ limit=8,
672
+ )
673
+ ranked2: List[Tuple[str, int]] = [(m[0], int(m[1])) for m in ranked]
674
+
675
+ if not ranked2:
676
+ return None, []
677
+
678
+ best_sku, best_score = ranked2[0]
679
+ second_score = ranked2[1][1] if len(ranked2) > 1 else 0
680
+
681
+ if best_score >= FUZZY_AUTOPICK_MIN and (best_score - second_score) >= FUZZY_AUTOPICK_GAP:
682
+ return best_sku, ranked2
683
+
684
+ return None, ranked2
685
+
686
+
687
+ def find_eos_record(query: str) -> Tuple[Optional[EosRecord], List[Tuple[str, int]]]:
688
+ """Try exact SKU match, then fuzzy match."""
689
+
690
+ q_norm = norm_sku(query)
691
+ if q_norm and q_norm in eos_index():
692
+ return eos_index()[q_norm], []
693
+
694
+ autopick, ranked = fuzzy_match_sku(query)
695
+ if autopick:
696
+ rec = eos_index().get(norm_sku(autopick))
697
+ if rec:
698
+ return rec, ranked
699
+
700
+ return None, ranked
701
+
702
+
703
+ def pick_model_for_replacement(text: str) -> Optional[str]:
704
+ """Pick the best catalog model that appears inside the replacement text."""
705
+ hits = extract_models_from_text(text, catalog_models())
706
+ if hits:
707
+ # prefer the longest token (tends to be the real model)
708
+ hits_sorted = sorted(hits, key=lambda x: len(x), reverse=True)
709
+ return hits_sorted[0]
710
+ return None
711
+
712
+
713
+ def build_device_details(model: str) -> Optional[DeviceDetails]:
714
+ row = catalog_lookup(model)
715
+ if not row:
716
+ return None
717
+ det = details_from_catalog_row(row)
718
+ det = enforce_5g_rules(det)
719
+ det = gpt_fill_missing_fields(det)
720
+ det = enforce_5g_rules(det)
721
+ return det
722
+
723
+
724
+ def build_recommendation(rec: EosRecord) -> Dict[str, Any]:
725
+ """Return a dict with 4G/5G device details, plus a small table and summary."""
726
+
727
+ # Replacement strings from the EoS table.
728
+ repl_4g_text = clean_str(rec.suggested_replacement)
729
+ repl_5g_text = clean_str(rec.advanced_5g_option)
730
+
731
+ # Try to map them to catalog models.
732
+ repl_4g_model = pick_model_for_replacement(repl_4g_text) if repl_4g_text else None
733
+ repl_5g_model = pick_model_for_replacement(repl_5g_text) if repl_5g_text else None
734
+
735
+ det_4g = build_device_details(repl_4g_model) if repl_4g_model else None
736
+ det_5g = build_device_details(repl_5g_model) if repl_5g_model else None
737
+
738
+ # Feature table
739
+ table_cols = [
740
+ "Role",
741
+ "Make",
742
+ "Model",
743
+ "Modem",
744
+ "Fit badges",
745
+ "Cell MIMO",
746
+ "Eth ports",
747
+ "Battery",
748
+ "Wi-Fi",
749
+ "Rugged",
750
+ "Dual-SIM",
751
+ "Serial",
752
+ "Throughput",
753
+ "Link",
754
+ ]
755
+
756
+ def _row(role: str, d: Optional[DeviceDetails]) -> List[Any]:
757
+ if d is None:
758
+ return [role] + [""] * (len(table_cols) - 1)
759
+ badges = ", ".join(fit_badges(d))
760
+ link = d.link
761
+ return [
762
+ role,
763
+ d.make,
764
+ d.model,
765
+ d.modem_type,
766
+ badges,
767
+ d.cell_mimo,
768
+ d.ethernet_ports_total if d.ethernet_ports_total is not None else "",
769
+ parse_yes_no(d.battery),
770
+ d.wifi,
771
+ parse_yes_no(d.rugged),
772
+ parse_yes_no(d.dual_sim),
773
+ parse_yes_no(d.serial),
774
+ d.throughput,
775
+ link,
776
+ ]
777
+
778
+ table_rows = [
779
+ _row("4G option", det_4g),
780
+ _row("5G option", det_5g),
781
+ ]
782
+
783
+ summary = customer_email_summary(rec, det_4g, det_5g)
784
+
785
+ return {
786
+ "eos": rec.__dict__,
787
+ "repl_4g_text": repl_4g_text,
788
+ "repl_5g_text": repl_5g_text,
789
+ "repl_4g_model": repl_4g_model or "",
790
+ "repl_5g_model": repl_5g_model or "",
791
+ "det_4g": det_4g.__dict__ if det_4g else {},
792
+ "det_5g": det_5g.__dict__ if det_5g else {},
793
+ "table_cols": table_cols,
794
+ "table_rows": table_rows,
795
+ "summary": summary,
796
+ }
797
+
798
+
799
+ def customer_email_summary(rec: EosRecord, det_4g: Optional[DeviceDetails], det_5g: Optional[DeviceDetails]) -> str:
800
+ """Customer-ready email-style summary."""
801
+
802
+ sku = rec.sku
803
+ make = rec.manufacturer
804
+
805
+ eos = clean_str(rec.end_of_sale)
806
+ eol = clean_str(rec.end_of_life)
807
+
808
+ subject = f"Subject: Replacement options for {make} {sku}".strip()
809
+
810
+ lines: List[str] = [subject, "", "Hi," , "", "Quick heads-up on lifecycle timing:"]
811
+
812
+ if eos:
813
+ lines.append(f"- End of sale: {eos}")
814
+ if eol:
815
+ lines.append(f"- End of life: {eol}")
816
+
817
+ desc = clean_str(rec.description)
818
+ if desc:
819
+ lines.append(f"- Current device: {desc}")
820
+
821
+ lines.append("")
822
+ lines.append("Recommended replacement paths:")
823
+
824
+ def _device_block(label: str, det: Optional[DeviceDetails], fallback_text: str) -> None:
825
+ if det is None:
826
+ if fallback_text:
827
+ lines.append(f"- {label}: {fallback_text}")
828
+ else:
829
+ lines.append(f"- {label}: (no suggestion on file)")
830
+ return
831
+
832
+ ports = det.ethernet_ports_total
833
+ ports_txt = f"{ports} total" if ports is not None else "Unknown"
834
+ bat = parse_yes_no(det.battery)
835
+
836
+ badges = fit_badges(det)
837
+ badges_txt = ", ".join(badges) if badges else ""
838
+
839
+ line = f"- {label}: {det.make} {det.model} ({det.modem_type})"
840
+ if badges_txt:
841
+ line += f" | {badges_txt}"
842
+ lines.append(line)
843
+ lines.append(f" - Ethernet ports: {ports_txt}")
844
+ lines.append(f" - Battery: {bat}")
845
+ if clean_str(det.link):
846
+ lines.append(f" - Info link: {det.link}")
847
+
848
+ _device_block("4G option", det_4g, rec.suggested_replacement)
849
+ _device_block("5G option", det_5g, rec.advanced_5g_option)
850
+
851
+ lines.append("")
852
+ lines.append("If you tell me vehicle vs fixed site (and indoor vs outdoor), I can sanity-check antennas too.")
853
+ lines.append("")
854
+ lines.append(CONTACT_LINE)
855
+
856
+ return "\n".join(lines).strip()
857
+
858
+
859
+ # -----------------------------
860
+ # Chat state + handlers
861
+ # -----------------------------
862
+
863
+
864
+ def state_default() -> Dict[str, Any]:
865
+ return {
866
+ "pending_choices": [], # list[str]
867
+ "last_reco": {},
868
+ }
869
+
870
+
871
+ def state_to_json(st: Dict[str, Any]) -> str:
872
+ return json.dumps(st, ensure_ascii=False)
873
+
874
+
875
+ def state_from_json(st_json: str) -> Dict[str, Any]:
876
+ try:
877
+ obj = json.loads(st_json or "{}")
878
+ if isinstance(obj, dict):
879
+ return obj
880
+ return state_default()
881
+ except Exception:
882
+ return state_default()
883
+
884
+
885
+ def format_match_choices(ranked: List[Tuple[str, int]]) -> str:
886
+ if not ranked:
887
+ return ""
888
+ top = ranked[:6]
889
+ lines = ["I found a few close matches. Reply with the number:"]
890
+ for i, (sku, score) in enumerate(top, 1):
891
+ lines.append(f"{i}) {sku} ({score})")
892
+ return "\n".join(lines)
893
+
894
+
895
+ def handle_user_message(
896
+ message: str,
897
+ chat_history: List[List[str]],
898
+ st_json: str,
899
+ ) -> Tuple[List[List[str]], str, List[List[Any]], str, str]:
900
+ """Main chat handler.
901
+
902
+ Returns:
903
+ - chat_history
904
+ - new_state_json
905
+ - table_rows
906
+ - summary_text
907
+ - links_md
908
+ """
909
+
910
+ st = state_from_json(st_json)
911
+ msg = clean_str(message)
912
+
913
+ if not msg:
914
+ return chat_history, state_to_json(st), [], "", ""
915
+
916
+ # User wants a fresh case.
917
+ if msg.lower().strip() in {"new", "new case", "reset", "clear"}:
918
+ st = state_default()
919
+ chat_history = chat_history + [[msg, "Got it. Send a router SKU to start."]]
920
+ return chat_history, state_to_json(st), [], "", ""
921
+
922
+ # Step 1: waiting for disambiguation selection
923
+ pending = st.get("pending_choices", []) or []
924
+ if pending:
925
+ chosen_sku = ""
926
+ m = re.match(r"^(\d+)\s*$", msg)
927
+ if m:
928
+ idx = int(m.group(1)) - 1
929
+ if 0 <= idx < len(pending):
930
+ chosen_sku = pending[idx]
931
+ else:
932
+ # User typed an SKU directly.
933
+ chosen_sku = msg
934
+
935
+ rec = eos_index().get(norm_sku(chosen_sku))
936
+ st["pending_choices"] = []
937
+
938
+ if not rec:
939
+ chat_history = chat_history + [[msg, "I couldn't map that choice to a device. Try the SKU again."]]
940
+ return chat_history, state_to_json(st), [], "", ""
941
+
942
+ reco = build_recommendation(rec)
943
+ st["last_reco"] = reco
944
+
945
+ assistant_msg = render_reco_message(rec, reco)
946
+ chat_history = chat_history + [[msg, assistant_msg]]
947
+
948
+ return (
949
+ chat_history,
950
+ state_to_json(st),
951
+ reco["table_rows"],
952
+ reco["summary"],
953
+ render_links_md(reco),
954
+ )
955
+
956
+ # Step 2: detect SKU lookup
957
+ if looks_like_sku(msg) or len(msg) <= 40:
958
+ rec, ranked = find_eos_record(msg)
959
+
960
+ if rec is None:
961
+ if ranked:
962
+ # Ask user to pick
963
+ st["pending_choices"] = [r[0] for r in ranked[:6]]
964
+ assistant_msg = format_match_choices(ranked)
965
+ chat_history = chat_history + [[msg, assistant_msg]]
966
+ return chat_history, state_to_json(st), [], "", ""
967
+
968
+ chat_history = chat_history + [[msg, "No match found in the lifecycle table."]]
969
+ return chat_history, state_to_json(st), [], "", ""
970
+
971
+ reco = build_recommendation(rec)
972
+ st["last_reco"] = reco
973
+
974
+ assistant_msg = render_reco_message(rec, reco)
975
+ chat_history = chat_history + [[msg, assistant_msg]]
976
+
977
+ return (
978
+ chat_history,
979
+ state_to_json(st),
980
+ reco["table_rows"],
981
+ reco["summary"],
982
+ render_links_md(reco),
983
+ )
984
+
985
+ # Step 3: treat as follow-up question
986
+ last = st.get("last_reco") or {}
987
+ if last:
988
+ answer = answer_question_with_context(msg, last)
989
+ if not answer:
990
+ answer = "I couldn't reach the model right now. Try again in a moment."
991
+ chat_history = chat_history + [[msg, answer]]
992
+ return chat_history, state_to_json(st), last.get("table_rows", []), last.get("summary", ""), render_links_md(last)
993
+
994
+ chat_history = chat_history + [[msg, "Send a router SKU (example: IBR900) to get started."]]
995
+ return chat_history, state_to_json(st), [], "", ""
996
+
997
+
998
+ def render_reco_message(rec: EosRecord, reco: Dict[str, Any]) -> str:
999
+ sku = rec.sku
1000
+ make = rec.manufacturer
1001
+
1002
+ lines: List[str] = []
1003
+ lines.append(f"**{make} {sku}**")
1004
+
1005
+ if clean_str(rec.end_of_sale) or clean_str(rec.end_of_life):
1006
+ lines.append("Lifecycle:")
1007
+ if clean_str(rec.end_of_sale):
1008
+ lines.append(f"- End of sale: {rec.end_of_sale}")
1009
+ if clean_str(rec.end_of_life):
1010
+ lines.append(f"- End of life: {rec.end_of_life}")
1011
+
1012
+ if clean_str(rec.description):
1013
+ lines.append(f"Description: {rec.description}")
1014
+
1015
+ lines.append("")
1016
+ lines.append("Suggested replacements:")
1017
+
1018
+ r4 = clean_str(reco.get("repl_4g_text", ""))
1019
+ r5 = clean_str(reco.get("repl_5g_text", ""))
1020
+ if r4:
1021
+ lines.append(f"- 4G option: {r4}")
1022
+ else:
1023
+ lines.append("- 4G option: (none on file)")
1024
+
1025
+ if r5:
1026
+ lines.append(f"- 5G option: {r5}")
1027
+ else:
1028
+ lines.append("- 5G option: (none on file)")
1029
+
1030
+ lines.append("")
1031
+ lines.append("I dropped a feature table and a customer-ready email draft below.")
1032
+ lines.append("Got questions about the suggested devices? Ask here.")
1033
+
1034
+ return "\n".join(lines).strip()
1035
+
1036
+
1037
+ def render_links_md(reco: Dict[str, Any]) -> str:
1038
+ """Nice clickable links under the table."""
1039
+ det5 = reco.get("det_5g") or {}
1040
+ det4 = reco.get("det_4g") or {}
1041
+
1042
+ links: List[str] = []
1043
+
1044
+ l5 = clean_str(det5.get("link"))
1045
+ if l5:
1046
+ links.append(f"**5G info link:** {l5}")
1047
+
1048
+ l4 = clean_str(det4.get("link"))
1049
+ if l4:
1050
+ links.append(f"**4G info link:** {l4}")
1051
+
1052
+ return "\n\n".join(links)
1053
+
1054
+
1055
+ def answer_question_with_context(question: str, reco: Dict[str, Any]) -> str:
1056
+ """Answer user follow-up using device details as grounding."""
1057
+
1058
+ client = get_openai_client()
1059
+ if client is None:
1060
+ return ""
1061
+
1062
+ # Keep context tight so we stay fast.
1063
+ ctx = {
1064
+ "router": reco.get("eos", {}),
1065
+ "4g": reco.get("det_4g", {}),
1066
+ "5g": reco.get("det_5g", {}),
1067
+ }
1068
+
1069
+ system = (
1070
+ "You are a router replacement assistant for a Verizon seller. "
1071
+ "Answer using only the provided JSON context. "
1072
+ "If the context lacks the answer, say what is missing and suggest what to check."
1073
+ )
1074
+
1075
+ user = {
1076
+ "context": ctx,
1077
+ "question": question,
1078
+ }
1079
+
1080
+ resp = openai_chat(
1081
+ model=OPENAI_MODEL_QA,
1082
+ messages=[
1083
+ {"role": "system", "content": system},
1084
+ {"role": "user", "content": json.dumps(user, ensure_ascii=False)},
1085
+ ],
1086
+ max_tokens=500,
1087
+ temperature=0.2,
1088
+ )
1089
+
1090
+ return clean_str(resp)
1091
+
1092
+
1093
+ # -----------------------------
1094
+ # Gradio UI
1095
+ # -----------------------------
1096
+
1097
+
1098
+ TABLE_HEADERS = [
1099
+ "Role",
1100
+ "Make",
1101
+ "Model",
1102
+ "Modem",
1103
+ "Fit badges",
1104
+ "Cell MIMO",
1105
+ "Eth ports",
1106
+ "Battery",
1107
+ "Wi-Fi",
1108
+ "Rugged",
1109
+ "Dual-SIM",
1110
+ "Serial",
1111
+ "Throughput",
1112
+ "Link",
1113
+ ]
1114
+
1115
+
1116
+ def build_demo() -> gr.Blocks:
1117
+ with gr.Blocks(title="Only Routers") as demo:
1118
+ gr.Markdown(
1119
+ "# Only Routers\n"
1120
+ "Type a router SKU to get a 4G + 5G replacement suggestion."
1121
+ )
1122
+
1123
+ st = gr.State(value=state_to_json(state_default()))
1124
+
1125
+ with gr.Row():
1126
+ with gr.Column(scale=3):
1127
+ chatbot = gr.Chatbot(label="Chat")
1128
+ user_in = gr.Textbox(label="Message", placeholder="Example: IBR900", lines=1)
1129
+ send = gr.Button("Send")
1130
+ clear = gr.Button("Clear")
1131
+
1132
+ with gr.Column(scale=2):
1133
+ gr.Markdown("### Replacement device details")
1134
+ table = gr.Dataframe(
1135
+ headers=TABLE_HEADERS,
1136
+ value=[],
1137
+ interactive=False,
1138
+ )
1139
+
1140
+ links_md = gr.Markdown("")
1141
+
1142
+ gr.Markdown("### Customer-ready email")
1143
+ summary = gr.Textbox(
1144
+ value="",
1145
+ lines=14,
1146
+ label="",
1147
+ interactive=False,
1148
+ show_copy_button=True,
1149
+ )
1150
+
1151
+ def _on_send(msg: str, hist: List[List[str]], st_json: str):
1152
+ hist2, st2, rows, summary_txt, links_txt = handle_user_message(msg, hist or [], st_json)
1153
+ return "", hist2, st2, rows, summary_txt, links_txt
1154
+
1155
+ send.click(_on_send, inputs=[user_in, chatbot, st], outputs=[user_in, chatbot, st, table, summary, links_md])
1156
+ user_in.submit(_on_send, inputs=[user_in, chatbot, st], outputs=[user_in, chatbot, st, table, summary, links_md])
1157
+
1158
+ def _on_clear():
1159
+ return [], state_to_json(state_default()), [], "", ""
1160
+
1161
+ clear.click(_on_clear, inputs=[], outputs=[chatbot, st, table, summary, links_md])
1162
+
1163
+ return demo
1164
+
1165
+
1166
+ demo = build_demo()
1167
+
1168
+ if __name__ == "__main__":
1169
+ demo.queue()
1170
+ demo.launch(
1171
+ server_name="0.0.0.0",
1172
+ server_port=int(os.getenv("PORT", "7860")),
1173
+ share=False,
1174
+ show_api=False,
1175
+ )
Updates/only-routers_ai_poc_hf_chat.ipynb ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Only Routers \u2013 dev notebook\n\nThis notebook is for quick local checks:\n- Load the two CSV files\n- Run a lookup\n- See the feature rows that will show in the app\n\nFiles expected next to the notebook (or adjust the paths in the code):\n- `routers_eos_eol_by_sku.csv`\n- `dec2025routers.csv`\n\nTo enable LLM-based fill-ins, set `OPENAI_API_KEY` in your env.\n"
8
+ ]
9
+ },
10
+ {
11
+ "cell_type": "code",
12
+ "metadata": {},
13
+ "execution_count": null,
14
+ "outputs": [],
15
+ "source": [
16
+ "from pathlib import Path\nimport os\n\n# Point these at your local copies if needed\nDATA_DIR = Path('.').resolve()\nEOS_FILE = DATA_DIR / 'routers_eos_eol_by_sku.csv'\nCATALOG_FILE = DATA_DIR / 'dec2025routers.csv'\n\nassert EOS_FILE.exists(), f\"Missing: {EOS_FILE}\"\nassert CATALOG_FILE.exists(), f\"Missing: {CATALOG_FILE}\"\n\n# Import the app helpers (does not launch Gradio in a notebook)\nimport app_hf_chat_v12_prod as app\n\n# Override file paths for the imported module\napp.EOS_FILE = EOS_FILE\napp.CATALOG_FILE = CATALOG_FILE\n\n# Quick sanity: load counts\nprint('EOS rows:', len(app.load_eos_rows()))\nprint('Catalog rows:', len(app.load_catalog_rows()))\n\n# Try a lookup\nquery = 'CBA850'\nmatch = app.find_eos_record(query)\n\nprint('Match:', match.match_sku)\nprint('Score:', match.score)\nprint('Ambiguous candidates:', len(match.candidates))\n\npayload = app.build_reco_payload(match.record)\nprint(payload['summary_text'])\n\n# Show table rows as plain python objects\nheaders, rows = app.render_feature_table(payload['devices'])\nprint(headers)\nfor r in rows:\n print(r)\n"
17
+ ]
18
+ }
19
+ ],
20
+ "metadata": {
21
+ "kernelspec": {
22
+ "display_name": "Python 3",
23
+ "language": "python",
24
+ "name": "python3"
25
+ },
26
+ "language_info": {
27
+ "name": "python",
28
+ "version": "3.10"
29
+ }
30
+ },
31
+ "nbformat": 4,
32
+ "nbformat_minor": 5
33
+ }
Updates/only-routers_ai_poc_hf_chat_v11.ipynb ADDED
@@ -0,0 +1,1912 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "c3bdbfa3",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Only-Routers Chat (v11)\n",
9
+ "\n",
10
+ "Chat UI + multi-device messages + follow-up antenna questions + multi-case memory."
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "code",
15
+ "execution_count": null,
16
+ "id": "db538e3d",
17
+ "metadata": {},
18
+ "outputs": [],
19
+ "source": [
20
+ "import os\n",
21
+ "import re\n",
22
+ "import json\n",
23
+ "import math\n",
24
+ "import hashlib\n",
25
+ "import tempfile\n",
26
+ "from dataclasses import dataclass\n",
27
+ "from datetime import datetime, date\n",
28
+ "from typing import Any, Dict, List, Optional, Tuple\n",
29
+ "\n",
30
+ "import numpy as np\n",
31
+ "import pandas as pd\n",
32
+ "\n",
33
+ "import fitz # PyMuPDF\n",
34
+ "import faiss\n",
35
+ "from sentence_transformers import SentenceTransformer\n",
36
+ "from rapidfuzz import fuzz, process\n",
37
+ "\n",
38
+ "import gradio as gr\n",
39
+ "from openai import OpenAI\n",
40
+ "\n",
41
+ "\n",
42
+ "# ============================\n",
43
+ "# Settings\n",
44
+ "# ============================\n",
45
+ "TODAY = date(2026, 1, 18)\n",
46
+ "OPENAI_MODEL = \"gpt-5.2\"\n",
47
+ "OPENAI_REASONING = {\"effort\": \"high\"}\n",
48
+ "MATCH_OK = 80\n",
49
+ "\n",
50
+ "EMBED_MODEL_NAME = \"sentence-transformers/all-MiniLM-L6-v2\"\n",
51
+ "PARSEC_CONTEXT_BEFORE = 900\n",
52
+ "PARSEC_CONTEXT_AFTER = 1600\n",
53
+ "\n",
54
+ "\n",
55
+ "# ============================\n",
56
+ "# OpenAI client (HF Space secret: OPENAI_API_KEY)\n",
57
+ "# ============================\n",
58
+ "API_KEY = os.getenv(\"OPENAI_API_KEY\", \"\").strip()\n",
59
+ "client = OpenAI(api_key=API_KEY) if API_KEY else None\n",
60
+ "\n",
61
+ "# ----------------------------\n",
62
+ "# Gradio state helpers\n",
63
+ "# Keep state as a JSON STRING to avoid schema issues on Hugging Face.\n",
64
+ "# ----------------------------\n",
65
+ "def state_load(st_json: str) -> Dict[str, Any]:\n",
66
+ " try:\n",
67
+ " if not st_json:\n",
68
+ " return {}\n",
69
+ " return json.loads(st_json) if isinstance(st_json, str) else {}\n",
70
+ " except Exception:\n",
71
+ " return {}\n",
72
+ "\n",
73
+ "def state_dump(st: Dict[str, Any]) -> str:\n",
74
+ " try:\n",
75
+ " return json.dumps(st or {}, ensure_ascii=False)\n",
76
+ " except Exception:\n",
77
+ " return \"{}\"\n",
78
+ "\n",
79
+ "\n",
80
+ "\n",
81
+ "# ============================\n",
82
+ "# Helpers\n",
83
+ "# ============================\n",
84
+ "def norm_text(s: Any) -> str:\n",
85
+ " try:\n",
86
+ " if s is None or (isinstance(s, float) and math.isnan(s)) or pd.isna(s):\n",
87
+ " return \"\"\n",
88
+ " except Exception:\n",
89
+ " pass\n",
90
+ " s = str(s).strip().lower()\n",
91
+ " s = re.sub(r\"[^a-z0-9\\s\\-\\/]\", \" \", s)\n",
92
+ " s = re.sub(r\"\\s+\", \" \", s).strip()\n",
93
+ " return s\n",
94
+ "\n",
95
+ "def safe_str(v: Any) -> str:\n",
96
+ " if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):\n",
97
+ " return \"\"\n",
98
+ " return str(v).strip()\n",
99
+ "\n",
100
+ "def is_5g(modem_type: Any) -> bool:\n",
101
+ " s = norm_text(modem_type)\n",
102
+ " return (\"5g\" in s) or (\"nr\" in s)\n",
103
+ "\n",
104
+ "def json_load_safe(s: str) -> Dict[str, Any]:\n",
105
+ " try:\n",
106
+ " return json.loads(s)\n",
107
+ " except Exception:\n",
108
+ " return {}\n",
109
+ "\n",
110
+ "def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 600) -> Dict[str, Any]:\n",
111
+ " if client is None:\n",
112
+ " return {}\n",
113
+ " resp = client.responses.create(\n",
114
+ " model=OPENAI_MODEL,\n",
115
+ " reasoning=OPENAI_REASONING,\n",
116
+ " input=[{\"role\":\"system\",\"content\":system},{\"role\":\"user\",\"content\":json.dumps(payload)}],\n",
117
+ " max_output_tokens=max_tokens,\n",
118
+ " )\n",
119
+ " return json_load_safe(getattr(resp, \"output_text\", \"\") or \"\")\n",
120
+ "\n",
121
+ "\n",
122
+ "def gpt_answer_md(system: str, user: str, max_tokens: int = 650) -> str:\n",
123
+ " \"\"\"Return a rep-friendly markdown answer.\"\"\"\n",
124
+ " if client is None:\n",
125
+ " return \"No API key is configured, so I can't answer detailed questions right now.\"\n",
126
+ " resp = client.responses.create(\n",
127
+ " model=OPENAI_MODEL,\n",
128
+ " reasoning=OPENAI_REASONING,\n",
129
+ " input=[\n",
130
+ " {\"role\": \"system\", \"content\": system},\n",
131
+ " {\"role\": \"user\", \"content\": user},\n",
132
+ " ],\n",
133
+ " max_output_tokens=max_tokens,\n",
134
+ " )\n",
135
+ " return (getattr(resp, \"output_text\", \"\") or \"\").strip()\n",
136
+ "\n",
137
+ "\n",
138
+ "# ============================\n",
139
+ "# Load data\n",
140
+ "# ============================\n",
141
+ "EOS_PATH = \"routers_eos_eol_by_sku.csv\"\n",
142
+ "DEC_PATH = \"dec2025routers.csv\"\n",
143
+ "PARSEC_PDF = \"ParsecCatalog.pdf\"\n",
144
+ "\n",
145
+ "if not os.path.exists(EOS_PATH):\n",
146
+ " raise FileNotFoundError(f\"Missing {EOS_PATH} in repo.\")\n",
147
+ "if not os.path.exists(DEC_PATH):\n",
148
+ " raise FileNotFoundError(f\"Missing {DEC_PATH} in repo.\")\n",
149
+ "if not os.path.exists(PARSEC_PDF):\n",
150
+ " raise FileNotFoundError(f\"Missing {PARSEC_PDF} in repo.\")\n",
151
+ "\n",
152
+ "df_eos = pd.read_csv(EOS_PATH).copy()\n",
153
+ "df_dec = pd.read_csv(DEC_PATH).copy()# ----------------------------\n",
154
+ "# Lifecycle CSV normalization (supports simplified format)\n",
155
+ "# ----------------------------\n",
156
+ "# New format example columns:\n",
157
+ "# SKU, manufacturer, Device Type, end_of_sale, end_of_life, suggested_replacement, advanced_5g_option\n",
158
+ "# We normalize to internal lowercase names and synthesize missing fields used by matching.\n",
159
+ "def _normalize_lifecycle_df(df: pd.DataFrame) -> pd.DataFrame:\n",
160
+ " df = df.copy()\n",
161
+ " # map columns case-insensitively\n",
162
+ " col_map = {}\n",
163
+ " lower_cols = {c.lower(): c for c in df.columns}\n",
164
+ "\n",
165
+ " def _pick(*names):\n",
166
+ " for n in names:\n",
167
+ " if n.lower() in lower_cols:\n",
168
+ " return lower_cols[n.lower()]\n",
169
+ " return None\n",
170
+ "\n",
171
+ " sku_col = _pick(\"sku\", \"SKU\")\n",
172
+ " if sku_col:\n",
173
+ " col_map[sku_col] = \"sku\"\n",
174
+ " mfr_col = _pick(\"manufacturer\", \"Manufacturer\")\n",
175
+ " if mfr_col:\n",
176
+ " col_map[mfr_col] = \"manufacturer\"\n",
177
+ " dt_col = _pick(\"device type\", \"Device Type\", \"device_type\")\n",
178
+ " if dt_col:\n",
179
+ " col_map[dt_col] = \"device_type\"\n",
180
+ " eos_col = _pick(\"end_of_sale\", \"end of sale\", \"End of Sale\", \"eos\")\n",
181
+ " if eos_col:\n",
182
+ " col_map[eos_col] = \"end_of_sale\"\n",
183
+ " eol_col = _pick(\"end_of_life\", \"end of life\", \"End of Life\", \"eol\")\n",
184
+ " if eol_col:\n",
185
+ " col_map[eol_col] = \"end_of_life\"\n",
186
+ " sr_col = _pick(\"suggested_replacement\", \"Suggested Replacement\")\n",
187
+ " if sr_col:\n",
188
+ " col_map[sr_col] = \"suggested_replacement\"\n",
189
+ " a5_col = _pick(\"advanced_5g_option\", \"Advanced 5G Option\", \"advanced 5g option\")\n",
190
+ " if a5_col:\n",
191
+ " col_map[a5_col] = \"advanced_5g_option\"\n",
192
+ "\n",
193
+ " df = df.rename(columns=col_map)\n",
194
+ "\n",
195
+ " # Ensure required columns exist\n",
196
+ " for req in [\"sku\", \"manufacturer\", \"device_type\", \"end_of_sale\", \"end_of_life\", \"suggested_replacement\", \"advanced_5g_option\"]:\n",
197
+ " if req not in df.columns:\n",
198
+ " df[req] = \"\"\n",
199
+ "\n",
200
+ " # Synthesize description/notes/region for backward compatibility (matching + display)\n",
201
+ " if \"description\" not in df.columns:\n",
202
+ " df[\"description\"] = df[\"sku\"].astype(str)\n",
203
+ " if \"notes\" not in df.columns:\n",
204
+ " df[\"notes\"] = \"\"\n",
205
+ " if \"region\" not in df.columns:\n",
206
+ " df[\"region\"] = \"\"\n",
207
+ "\n",
208
+ " return df\n",
209
+ "\n",
210
+ "df_eos = _normalize_lifecycle_df(df_eos)\n",
211
+ "\n",
212
+ "\n",
213
+ "\n",
214
+ "\n",
215
+ "def _canonize_eos_columns(df: pd.DataFrame) -> pd.DataFrame:\n",
216
+ " \"\"\"Normalize lifecycle CSV column names (case-insensitive) and create expected columns.\"\"\"\n",
217
+ " # Map various header spellings to canonical names used by the app\n",
218
+ " mapping = {}\n",
219
+ " for c in df.columns:\n",
220
+ " k = str(c).strip().lower().replace(\" \", \"_\")\n",
221
+ " if k in {\"sku\", \"model\", \"device\", \"device_sku\"}:\n",
222
+ " mapping[c] = \"sku\"\n",
223
+ " elif k in {\"manufacturer\", \"make\", \"vendor\"}:\n",
224
+ " mapping[c] = \"manufacturer\"\n",
225
+ " elif k in {\"device_type\", \"type\"}:\n",
226
+ " mapping[c] = \"device_type\"\n",
227
+ " elif k in {\"end_of_sale\", \"eos\", \"end_sale\", \"end_of_sales\"}:\n",
228
+ " mapping[c] = \"end_of_sale\"\n",
229
+ " elif k in {\"end_of_life\", \"eol\", \"end_life\"}:\n",
230
+ " mapping[c] = \"end_of_life\"\n",
231
+ " elif k in {\"suggested_replacement\", \"replacement_4g\", \"lte_replacement\", \"replacement_lte\", \"replacement\"}:\n",
232
+ " mapping[c] = \"suggested_replacement\"\n",
233
+ " elif k in {\"advanced_5g_option\", \"replacement_5g\", \"fiveg_replacement\", \"5g_replacement\", \"upgrade_5g\"}:\n",
234
+ " mapping[c] = \"advanced_5g_option\"\n",
235
+ " elif k in {\"region\", \"market\"}:\n",
236
+ " mapping[c] = \"region\"\n",
237
+ " elif k in {\"notes\", \"note\"}:\n",
238
+ " mapping[c] = \"notes\"\n",
239
+ " elif k in {\"description\", \"device_description\", \"name\"}:\n",
240
+ " mapping[c] = \"description\"\n",
241
+ "\n",
242
+ " df = df.rename(columns=mapping).copy()\n",
243
+ "\n",
244
+ " # Create expected columns if missing\n",
245
+ " if \"sku\" not in df.columns:\n",
246
+ " # Try the common capitalized header as a fallback\n",
247
+ " if \"SKU\" in df.columns:\n",
248
+ " df[\"sku\"] = df[\"SKU\"].astype(str)\n",
249
+ " else:\n",
250
+ " df[\"sku\"] = \"\"\n",
251
+ "\n",
252
+ " if \"manufacturer\" not in df.columns:\n",
253
+ " df[\"manufacturer\"] = \"\"\n",
254
+ "\n",
255
+ " if \"device_type\" not in df.columns:\n",
256
+ " df[\"device_type\"] = \"\"\n",
257
+ "\n",
258
+ " if \"description\" not in df.columns:\n",
259
+ " # If the simplified file removed description, use SKU as description (still searchable)\n",
260
+ " df[\"description\"] = df[\"sku\"].astype(str)\n",
261
+ "\n",
262
+ " if \"notes\" not in df.columns:\n",
263
+ " df[\"notes\"] = \"\"\n",
264
+ "\n",
265
+ " if \"region\" not in df.columns:\n",
266
+ " df[\"region\"] = \"\"\n",
267
+ "\n",
268
+ " if \"suggested_replacement\" not in df.columns:\n",
269
+ " df[\"suggested_replacement\"] = \"\"\n",
270
+ "\n",
271
+ " if \"advanced_5g_option\" not in df.columns:\n",
272
+ " df[\"advanced_5g_option\"] = \"\"\n",
273
+ "\n",
274
+ " if \"end_of_sale\" not in df.columns:\n",
275
+ " df[\"end_of_sale\"] = \"\"\n",
276
+ "\n",
277
+ " if \"end_of_life\" not in df.columns:\n",
278
+ " df[\"end_of_life\"] = \"\"\n",
279
+ "\n",
280
+ " return df\n",
281
+ "\n",
282
+ "df_eos = _canonize_eos_columns(df_eos)\n",
283
+ "\n",
284
+ "\n",
285
+ "def region_ok(x: Any) -> bool:\n",
286
+ " s = str(x or \"\").strip().lower()\n",
287
+ " if not s:\n",
288
+ " return True\n",
289
+ " if \"not specified\" in s:\n",
290
+ " return True\n",
291
+ " if \"north america\" in s:\n",
292
+ " return True\n",
293
+ " if re.search(r\"\\busa\\b\", s):\n",
294
+ " return True\n",
295
+ " if re.search(r\"\\bunited\\s+states\\b\", s):\n",
296
+ " return True\n",
297
+ " if re.search(r\"\\bu\\.?s\\.?\\b\", s):\n",
298
+ " return True\n",
299
+ " return False\n",
300
+ "\n",
301
+ "if \"region\" in df_eos.columns:\n",
302
+ " df_eos = df_eos[df_eos[\"region\"].apply(region_ok)].reset_index(drop=True)\n",
303
+ "\n",
304
+ "# Maker mapping (includes Teltonika)\n",
305
+ "CANON_MAKER = {\n",
306
+ " \"CRADLEPOINT\": {\"cradlepoint\", \"ericsson\", \"ericsson enterprise wireless\"},\n",
307
+ " \"SIERRA\": {\"sierra\", \"sierra wireless\", \"semtech\", \"airlink\"},\n",
308
+ " \"FEENEY\": {\"feeney\", \"feeney wireless\", \"inseego\"},\n",
309
+ " \"DIGI\": {\"digi\", \"accelerated\", \"accelerated concepts\"},\n",
310
+ " \"CISCO_MERAKI\": {\"meraki\", \"cisco meraki\"},\n",
311
+ " \"CISCO\": {\"cisco\"},\n",
312
+ " \"TELTONIKA\": {\"teltonika\"},\n",
313
+ "}\n",
314
+ "\n",
315
+ "def canon_maker_from_text(s: Any) -> str:\n",
316
+ " t = norm_text(s)\n",
317
+ " for canon, terms in CANON_MAKER.items():\n",
318
+ " for term in terms:\n",
319
+ " if term in t:\n",
320
+ " return canon\n",
321
+ " return \"UNKNOWN\"\n",
322
+ "\n",
323
+ "df_eos[\"_canon_make\"] = df_eos[\"manufacturer\"].apply(canon_maker_from_text) if \"manufacturer\" in df_eos.columns else \"UNKNOWN\"\n",
324
+ "df_eos[\"_norm_sku\"] = df_eos[\"sku\"].apply(norm_text) if \"sku\" in df_eos.columns else \"\"\n",
325
+ "df_eos[\"_norm_desc\"] = df_eos[\"description\"].apply(norm_text) if \"description\" in df_eos.columns else \"\"\n",
326
+ "df_eos[\"_norm_notes\"] = df_eos[\"notes\"].apply(norm_text) if \"notes\" in df_eos.columns else \"\"\n",
327
+ "\n",
328
+ "df_dec[\"_canon_make\"] = df_dec[\"Make\"].apply(canon_maker_from_text) if \"Make\" in df_dec.columns else \"UNKNOWN\"\n",
329
+ "df_dec[\"_norm_model\"] = df_dec[\"Model\"].apply(norm_text) if \"Model\" in df_dec.columns else \"\"\n",
330
+ "df_dec[\"_is5g\"] = df_dec[\"Modem Type\"].apply(is_5g) if \"Modem Type\" in df_dec.columns else False\n",
331
+ "\n",
332
+ "\n",
333
+ "# ============================\n",
334
+ "# Date helpers\n",
335
+ "# ============================\n",
336
+ "@dataclass\n",
337
+ "class ParsedDate:\n",
338
+ " raw: str\n",
339
+ " kind: str\n",
340
+ " value: Optional[date]\n",
341
+ "\n",
342
+ "def parse_date_field(x: Any) -> ParsedDate:\n",
343
+ " raw = str(x or \"\").strip()\n",
344
+ " if not raw:\n",
345
+ " return ParsedDate(raw=\"\", kind=\"missing\", value=None)\n",
346
+ "\n",
347
+ " # Common US formats: M/D/YY or M/D/YYYY (e.g., 6/24/24, 9/30/21)\n",
348
+ " for fmt in (\"%m/%d/%y\", \"%m/%d/%Y\", \"%-m/%-d/%y\", \"%-m/%-d/%Y\"):\n",
349
+ " try:\n",
350
+ " dt = datetime.strptime(raw, fmt).date()\n",
351
+ " return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
352
+ " except Exception:\n",
353
+ " pass\n",
354
+ "\n",
355
+ " # ISO-ish: YYYY\n",
356
+ " if re.fullmatch(r\"\\d{4}\", raw):\n",
357
+ " y = int(raw)\n",
358
+ " if y == TODAY.year:\n",
359
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
360
+ " if y < TODAY.year:\n",
361
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
362
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 12, 31))\n",
363
+ "\n",
364
+ " # YYYY-MM\n",
365
+ " if re.fullmatch(r\"\\d{4}-\\d{2}\", raw):\n",
366
+ " try:\n",
367
+ " y, m = raw.split(\"-\")\n",
368
+ " return ParsedDate(raw=raw, kind=\"year_month\", value=date(int(y), int(m), 1))\n",
369
+ " except Exception:\n",
370
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
371
+ "\n",
372
+ " # YYYY-MM-DD\n",
373
+ " if re.fullmatch(r\"\\d{4}-\\d{2}-\\d{2}\", raw):\n",
374
+ " try:\n",
375
+ " dt = datetime.strptime(raw, \"%Y-%m-%d\").date()\n",
376
+ " return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
377
+ " except Exception:\n",
378
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
379
+ "\n",
380
+ " # Last resort: leave as raw (unparsed)\n",
381
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
382
+ "\n",
383
+ " if re.fullmatch(r\"\\d{4}-\\d{2}-\\d{2}\", raw):\n",
384
+ " try:\n",
385
+ " dt = datetime.strptime(raw, \"%Y-%m-%d\").date()\n",
386
+ " return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
387
+ " except Exception:\n",
388
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
389
+ "\n",
390
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
391
+ "\n",
392
+ "def display_date(pd_: ParsedDate) -> str:\n",
393
+ " if pd_.kind == \"missing\":\n",
394
+ " return \"Not listed\"\n",
395
+ " if pd_.kind == \"bad\":\n",
396
+ " return pd_.raw or \"Not listed\"\n",
397
+ " return pd_.raw\n",
398
+ "\n",
399
+ "def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:\n",
400
+ " if eos.value is None and eol.value is None:\n",
401
+ " return \"Unknown\"\n",
402
+ " if eol.value is not None and eol.value <= TODAY:\n",
403
+ " return \"End of Life\"\n",
404
+ " if eos.value is not None and eos.value <= TODAY:\n",
405
+ " return \"End of Sale\"\n",
406
+ " return \"Active\"\n",
407
+ "\n",
408
+ "def row_to_dates_and_status(row: pd.Series) -> Tuple[str, str, str]:\n",
409
+ " eos = parse_date_field(row.get(\"end_of_sale\"))\n",
410
+ " eol = parse_date_field(row.get(\"end_of_life\"))\n",
411
+ " return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)\n",
412
+ "\n",
413
+ "\n",
414
+ "# ============================\n",
415
+ "# Embeddings + Parsec index\n",
416
+ "# ============================\n",
417
+ "embedder = SentenceTransformer(EMBED_MODEL_NAME)\n",
418
+ "\n",
419
+ "def extract_pdf_text_pages(path: str) -> List[str]:\n",
420
+ " doc = fitz.open(path)\n",
421
+ " return [doc[i].get_text(\"text\") for i in range(len(doc))]\n",
422
+ "\n",
423
+ "def build_parsec_cards(pages: List[str]) -> List[str]:\n",
424
+ " cards = []\n",
425
+ " for p in pages:\n",
426
+ " for m in re.finditer(r\"Standard\\s+SKU:\", p):\n",
427
+ " start = max(0, m.start() - PARSEC_CONTEXT_BEFORE)\n",
428
+ " end = min(len(p), m.start() + PARSEC_CONTEXT_AFTER)\n",
429
+ " c = p[start:end].strip()\n",
430
+ " if len(c) >= 200:\n",
431
+ " cards.append(c)\n",
432
+ " out, seen = [], set()\n",
433
+ " for c in cards:\n",
434
+ " h = hashlib.sha1(c.encode(\"utf-8\")).hexdigest()\n",
435
+ " if h not in seen:\n",
436
+ " seen.add(h); out.append(c)\n",
437
+ " return out\n",
438
+ "\n",
439
+ "parsec_cards = build_parsec_cards(extract_pdf_text_pages(PARSEC_PDF))\n",
440
+ "parsec_emb = embedder.encode(parsec_cards, batch_size=64, show_progress_bar=False, normalize_embeddings=True)\n",
441
+ "parsec_emb = np.asarray(parsec_emb, dtype=np.float32)\n",
442
+ "parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])\n",
443
+ "parsec_index.add(parsec_emb)\n",
444
+ "\n",
445
+ "\n",
446
+ "# ============================\n",
447
+ "# Device resolution\n",
448
+ "# ============================\n",
449
+ "def label_for_row(i: int) -> str:\n",
450
+ " r = df_eos.iloc[i]\n",
451
+ " return f\"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}\"[:220]\n",
452
+ "\n",
453
+ "EOS_LABELS = [label_for_row(i) for i in range(len(df_eos))]\n",
454
+ "EOS_CORPUS = []\n",
455
+ "for _, r in df_eos.iterrows():\n",
456
+ " EOS_CORPUS.append(\" \".join([r.get(\"_norm_sku\",\"\"), r.get(\"_canon_make\",\"\"), r.get(\"_norm_desc\",\"\"), r.get(\"_norm_notes\",\"\")]))\n",
457
+ "\n",
458
+ "def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int, int, str]]:\n",
459
+ " q = norm_text(query)\n",
460
+ " hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)\n",
461
+ " return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]\n",
462
+ "\n",
463
+ "def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> Dict[str, Any]:\n",
464
+ " if client is None:\n",
465
+ " return {}\n",
466
+ " sys = \"Pick which router the user meant. Never invent. Return strict JSON only.\"\n",
467
+ " payload = {\n",
468
+ " \"user_input\": user_text,\n",
469
+ " \"candidates\": [{\"row_idx\": i, \"score\": s, \"label\": lbl} for (i,s,lbl) in candidates],\n",
470
+ " \"rules\": [\n",
471
+ " \"If one is clearly correct, return mode='ok' with row_idx.\",\n",
472
+ " \"If two are plausible, return mode='pick' with top 2 options.\"\n",
473
+ " ],\n",
474
+ " \"output_schema\": {\"mode\":\"ok|pick\",\"row_idx\":\"int\",\"options\":[{\"row_idx\":\"int\",\"label\":\"string\"}]}\n",
475
+ " }\n",
476
+ " return gpt_json(sys, payload, max_tokens=280)\n",
477
+ "\n",
478
+ "def resolve_device(user_text: str) -> Dict[str, Any]:\n",
479
+ " q = norm_text(user_text)\n",
480
+ " exact = df_eos.index[df_eos[\"_norm_sku\"] == q].tolist()\n",
481
+ " if len(exact) == 1:\n",
482
+ " return {\"mode\":\"ok\",\"row_idx\": int(exact[0])}\n",
483
+ " if len(exact) > 1:\n",
484
+ " opts = [{\"row_idx\": int(i), \"label\": EOS_LABELS[int(i)]} for i in exact[:2]]\n",
485
+ " return {\"mode\":\"pick\",\"options\": opts}\n",
486
+ "\n",
487
+ " cands = local_candidates(user_text, top_k=6)\n",
488
+ " if not cands:\n",
489
+ " return {\"mode\":\"not_found\"}\n",
490
+ "\n",
491
+ " if cands[0][1] >= 95 and (len(cands) == 1 or (cands[0][1] - cands[1][1]) >= 8):\n",
492
+ " return {\"mode\":\"ok\",\"row_idx\": cands[0][0]}\n",
493
+ "\n",
494
+ " g = gpt_choose_device(user_text, cands)\n",
495
+ " if g.get(\"mode\") == \"ok\" and isinstance(g.get(\"row_idx\"), int):\n",
496
+ " return {\"mode\":\"ok\",\"row_idx\": int(g[\"row_idx\"])}\n",
497
+ "\n",
498
+ " if g.get(\"mode\") == \"pick\":\n",
499
+ " opts = g.get(\"options\", []) or []\n",
500
+ " opts2 = [{\"row_idx\": int(o[\"row_idx\"]), \"label\": str(o[\"label\"])} for o in opts[:2] if \"row_idx\" in o]\n",
501
+ " if opts2:\n",
502
+ " return {\"mode\":\"pick\",\"options\": opts2}\n",
503
+ "\n",
504
+ " if len(cands) > 1:\n",
505
+ " return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]},{\"row_idx\":cands[1][0],\"label\":cands[1][2]}]}\n",
506
+ " return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]}]}\n",
507
+ "\n",
508
+ "\n",
509
+ "# ============================\n",
510
+ "# Replacements — lifecycle CSV source of truth\n",
511
+ "# ============================\n",
512
+ "def extract_model_token(text: str) -> str:\n",
513
+ " s = safe_str(text)\n",
514
+ " if not s:\n",
515
+ " return \"\"\n",
516
+ " parts = [p.strip() for p in s.split(\"|\") if p.strip()]\n",
517
+ " candidates = parts[::-1] if parts else [s]\n",
518
+ " for cand in candidates:\n",
519
+ " m = re.search(r\"\\bRUT[A-Z]?\\d{2,4}\\b\", cand.upper())\n",
520
+ " if m:\n",
521
+ " return m.group(0).upper()\n",
522
+ " m = re.search(r\"\\bIX\\d{2}\\b\", cand, flags=re.IGNORECASE)\n",
523
+ " if m:\n",
524
+ " return m.group(0).upper()\n",
525
+ " m = re.search(r\"\\b(R\\d{3,4}|E\\d{3,4}|S\\d{3,4})\\b\", cand, flags=re.IGNORECASE)\n",
526
+ " if m:\n",
527
+ " return m.group(0).upper()\n",
528
+ " m = re.search(r\"\\b[A-Z]{1,6}\\d{2,4}[A-Z]?\\b\", cand.upper())\n",
529
+ " if m:\n",
530
+ " return m.group(0).upper()\n",
531
+ " return candidates[0][:60]\n",
532
+ "\n",
533
+ "def device_is_4g(row: pd.Series) -> bool:\n",
534
+ " # Detect LTE/4G even when the description uses \"Cat 4 / Cat6 / Cat 12\" without saying \"LTE\"\n",
535
+ " t = norm_text(row.get(\"description\",\"\")) + \" \" + norm_text(row.get(\"notes\",\"\")) + \" \" + norm_text(row.get(\"sku\",\"\"))\n",
536
+ "\n",
537
+ " # If it explicitly says 5G/NR, treat as not 4G-only\n",
538
+ " if (\"5g\" in t) or (\"nr\" in t):\n",
539
+ " return False\n",
540
+ "\n",
541
+ " # Classic signals\n",
542
+ " if (\"lte\" in t) or (\"4g\" in t):\n",
543
+ " return True\n",
544
+ "\n",
545
+ " # LTE category signals (Cat 1..20 are LTE categories; Cat M1/M2 are LTE-M)\n",
546
+ " if re.search(r\"\\bcat\\s*[-]?\\s*(m1|m2)\\b\", t):\n",
547
+ " return True\n",
548
+ "\n",
549
+ " m = re.search(r\"\\bcat\\s*[-]?\\s*(\\d{1,2})\\b\", t)\n",
550
+ " if m:\n",
551
+ " try:\n",
552
+ " cat = int(m.group(1))\n",
553
+ " if 0 < cat <= 20:\n",
554
+ " return True\n",
555
+ " except Exception:\n",
556
+ " pass\n",
557
+ "\n",
558
+ " # If \"cat\" appears at all, it's almost always LTE-family\n",
559
+ " if \"cat\" in t:\n",
560
+ " return True\n",
561
+ "\n",
562
+ " return False\n",
563
+ "\n",
564
+ " # If it explicitly says 5G/NR, treat as not 4G-only\n",
565
+ " if (\"5g\" in t) or (\"nr\" in t):\n",
566
+ " return False\n",
567
+ "\n",
568
+ " # Classic signals\n",
569
+ " if (\"lte\" in t) or (\"4g\" in t):\n",
570
+ " return True\n",
571
+ "\n",
572
+ " # LTE category signals (Cat 1..20 are LTE categories; Cat M1/M2 are LTE-M)\n",
573
+ " if re.search(r\"\\bcat\\s*[-]?\\s*(m1|m2)\\b\", t):\n",
574
+ " return True\n",
575
+ "\n",
576
+ " m = re.search(r\"\\bcat\\s*[-]?\\s*(\\d{1,2})\\b\", t)\n",
577
+ " if m:\n",
578
+ " try:\n",
579
+ " cat = int(m.group(1))\n",
580
+ " if 0 < cat <= 20:\n",
581
+ " return True\n",
582
+ " except Exception:\n",
583
+ " pass\n",
584
+ "\n",
585
+ " # If \"cat\" appears at all, it's almost always LTE-family\n",
586
+ " if \"cat\" in t:\n",
587
+ " return True\n",
588
+ "\n",
589
+ " return False\n",
590
+ "\n",
591
+ "\n",
592
+ "def candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
593
+ " mfr = norm_text(manufacturer)\n",
594
+ " pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
595
+ " vals = pool[\"advanced_5g_option\"].tolist() if \"advanced_5g_option\" in pool.columns else []\n",
596
+ " out, seen = [], set()\n",
597
+ " for v in vals:\n",
598
+ " tok = extract_model_token(v)\n",
599
+ " if tok and tok.lower() != \"nan\" and tok not in seen:\n",
600
+ " seen.add(tok); out.append(tok)\n",
601
+ " return out\n",
602
+ "\n",
603
+ "def candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
604
+ " mfr = norm_text(manufacturer)\n",
605
+ " pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
606
+ " vals = pool[\"suggested_replacement\"].tolist() if \"suggested_replacement\" in pool.columns else []\n",
607
+ " out, seen = [], set()\n",
608
+ " for v in vals:\n",
609
+ " tok = extract_model_token(v)\n",
610
+ " if tok and tok.lower() != \"nan\" and tok not in seen:\n",
611
+ " seen.add(tok); out.append(tok)\n",
612
+ " return out\n",
613
+ "\n",
614
+ "def gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:\n",
615
+ " if client is None or not candidates:\n",
616
+ " return \"\"\n",
617
+ " sys = \"Pick the best replacement model. Choose only from candidates. Return strict JSON only.\"\n",
618
+ " payload = {\n",
619
+ " \"old_device\": {\n",
620
+ " \"sku\": str(old_row.get(\"sku\",\"\")),\n",
621
+ " \"manufacturer\": str(old_row.get(\"manufacturer\",\"\")),\n",
622
+ " \"description\": str(old_row.get(\"description\",\"\")),\n",
623
+ " \"need\": need,\n",
624
+ " },\n",
625
+ " \"candidates\": candidates[:40],\n",
626
+ " \"output_schema\": {\"choice\":\"string\"}\n",
627
+ " }\n",
628
+ " out = gpt_json(sys, payload, max_tokens=240) or {}\n",
629
+ " choice = str(out.get(\"choice\",\"\") or \"\").strip()\n",
630
+ " return choice if choice in candidates else \"\"\n",
631
+ "\n",
632
+ "def fallback_5g_from_dec(canon_make: str) -> str:\n",
633
+ " pool5 = df_dec[(df_dec[\"_canon_make\"] == canon_make) & (df_dec[\"_is5g\"] == True)]\n",
634
+ " return str(pool5.iloc[0][\"Model\"]).strip() if not pool5.empty else \"\"\n",
635
+ "\n",
636
+ "def pick_replacements_lifecycle(row: pd.Series, status: str, use_gpt: bool = True) -> Dict[str, Any]:\n",
637
+ " canon = str(row.get(\"_canon_make\",\"UNKNOWN\"))\n",
638
+ " manufacturer = str(row.get(\"manufacturer\",\"\") or \"\")\n",
639
+ "\n",
640
+ " sug_raw = safe_str(row.get(\"suggested_replacement\",\"\"))\n",
641
+ " adv_raw = safe_str(row.get(\"advanced_5g_option\",\"\"))\n",
642
+ "\n",
643
+ " has_4g_alt = bool(sug_raw.strip())\n",
644
+ " has_5g_alt = bool(adv_raw.strip())\n",
645
+ "\n",
646
+ " # Treat as 4G if the description indicates LTE OR lifecycle provides a 4G suggested replacement\n",
647
+ " is_4g = device_is_4g(row) or has_4g_alt\n",
648
+ "\n",
649
+ " # Provide 5G option if the unit is 4G, EOS/EOL, or lifecycle explicitly provides advanced_5g_option\n",
650
+ " want_5g = is_4g or (status in {\"End of Sale\",\"End of Life\"}) or has_5g_alt\n",
651
+ "\n",
652
+ " # 4G alternative: show whenever lifecycle provides it (or device appears 4G)\n",
653
+ " repl_4g = \"Not applicable\"\n",
654
+ " if is_4g or has_4g_alt:\n",
655
+ " repl_4g = extract_model_token(sug_raw)\n",
656
+ " if not repl_4g:\n",
657
+ " cand4 = candidate_4g_models_from_lifecycle(manufacturer)\n",
658
+ " repl_4g = (gpt_pick_from_candidates(row, cand4, \"4G alternative\") if (use_gpt and client) else \"\") or (cand4[0] if cand4 else \"\")\n",
659
+ " if not repl_4g:\n",
660
+ " repl_4g = \"Not applicable\"\n",
661
+ "\n",
662
+ " # 5G replacement: prefer lifecycle advanced_5g_option whenever present\n",
663
+ " repl_5g = \"Not listed\"\n",
664
+ " if want_5g:\n",
665
+ " repl_5g = extract_model_token(adv_raw)\n",
666
+ " if not repl_5g:\n",
667
+ " cand5 = candidate_5g_models_from_lifecycle(manufacturer)\n",
668
+ " repl_5g = (gpt_pick_from_candidates(row, cand5, \"5G replacement/upgrade\") if (use_gpt and client) else \"\") or (cand5[0] if cand5 else \"\")\n",
669
+ " if not repl_5g:\n",
670
+ " repl_5g = fallback_5g_from_dec(canon) or \"Not listed\"\n",
671
+ "\n",
672
+ " if repl_5g.lower() == \"nan\":\n",
673
+ " repl_5g = \"Not listed\"\n",
674
+ "\n",
675
+ " return {\"repl_4g\": repl_4g, \"repl_5g\": repl_5g, \"sources\": [\"lifecycle_csv\"] + ([\"gpt\"] if (use_gpt and client) else [])}\n",
676
+ "\n",
677
+ "\n",
678
+ "# ============================\n",
679
+ "# Antennas (Parsec-only)\n",
680
+ "# ============================\n",
681
+ "PARSEC_FAMILY_WORDS = {\"chinook\",\"labrador\",\"boxer\",\"bloodhound\",\"husky\",\"beagle\",\"mastiff\",\"collie\",\"shepherd\",\"belgian\",\"australian\",\"terrier\",\"pyrenees\"}\n",
682
+ "BAD_NAME_MARKERS = {\"customization\",\"standard connectors\",\"connectors\",\"features\",\"benefits\",\"specifications\",\"mechanical\",\"electrical\",\"mounting\",\"accessories\",\"description:\",\"standard sku\"}\n",
683
+ "\n",
684
+ "def clean_line(s: str) -> str:\n",
685
+ " s = re.sub(r\"\\s+\", \" \", str(s or \"\").strip())\n",
686
+ " if re.fullmatch(r\"-[a-z0-9]+\", s.lower()):\n",
687
+ " return \"\"\n",
688
+ " return s\n",
689
+ "\n",
690
+ "def is_bad_name_line(line: str) -> bool:\n",
691
+ " low = line.lower()\n",
692
+ " if any(m in low for m in BAD_NAME_MARKERS):\n",
693
+ " return True\n",
694
+ " if re.search(r\"\\b-[a-z0-9]{1,4}\\b\", low) and len(low) <= 25:\n",
695
+ " return True\n",
696
+ " return False\n",
697
+ "\n",
698
+ "def family_from_line(line: str) -> str:\n",
699
+ " low = line.lower()\n",
700
+ " for fam in PARSEC_FAMILY_WORDS:\n",
701
+ " if fam in low:\n",
702
+ " return fam.capitalize()\n",
703
+ " return \"\"\n",
704
+ "\n",
705
+ "def parsec_connectors_from_card(t: str) -> str:\n",
706
+ " m = re.search(r\"Standard\\s+Connectors:\\s*(.+)\", t, flags=re.IGNORECASE)\n",
707
+ " if m:\n",
708
+ " return re.sub(r\"\\s+\", \" \", m.group(1).strip())[:80]\n",
709
+ " return \"\"\n",
710
+ "\n",
711
+ "def parsec_mounts_from_card(t: str) -> List[str]:\n",
712
+ " mounts = []\n",
713
+ " for m in re.finditer(r\"Mount:\\s*(.+)\", t, flags=re.IGNORECASE):\n",
714
+ " val = re.sub(r\"\\s+\", \" \", m.group(1).strip())\n",
715
+ " parts = [p.strip().lower() for p in val.split(\",\") if p.strip()]\n",
716
+ " mounts.extend(parts)\n",
717
+ " out = []\n",
718
+ " seen = set()\n",
719
+ " for x in mounts:\n",
720
+ " if x not in seen:\n",
721
+ " seen.add(x); out.append(x)\n",
722
+ " return out\n",
723
+ "\n",
724
+ "def parsec_name_from_card(card_text: str) -> str:\n",
725
+ " lines = [clean_line(ln) for ln in str(card_text or \"\").splitlines()]\n",
726
+ " lines = [ln for ln in lines if ln]\n",
727
+ "\n",
728
+ " for ln in lines:\n",
729
+ " if is_bad_name_line(ln):\n",
730
+ " continue\n",
731
+ " fam = family_from_line(ln)\n",
732
+ " if fam:\n",
733
+ " return fam\n",
734
+ "\n",
735
+ " sku_i = None\n",
736
+ " for i, ln in enumerate(lines):\n",
737
+ " if \"standard sku\" in ln.lower():\n",
738
+ " sku_i = i\n",
739
+ " break\n",
740
+ " if sku_i is not None:\n",
741
+ " window = lines[max(0, sku_i - 12):sku_i]\n",
742
+ " for ln in reversed(window):\n",
743
+ " if is_bad_name_line(ln):\n",
744
+ " continue\n",
745
+ " if 3 <= len(ln) <= 40 and re.search(r\"[A-Za-z]\", ln):\n",
746
+ " return ln.split()[0].capitalize()\n",
747
+ "\n",
748
+ " return \"Parsec antenna\"\n",
749
+ "\n",
750
+ "def parsec_part_from_card(t: str) -> str:\n",
751
+ " m = re.search(r\"Standard\\s+SKU:\\s*([A-Z0-9]+)\", t)\n",
752
+ " return m.group(1).strip() if m else \"\"\n",
753
+ "\n",
754
+ "def parsec_desc_from_card(t: str) -> str:\n",
755
+ " m = re.search(r\"Description:\\s*(.+?)(?:\\n|$)\", t, flags=re.IGNORECASE)\n",
756
+ " return re.sub(r\"\\s+\",\" \",m.group(1).strip())[:220] if m else \"\"\n",
757
+ "\n",
758
+ "def parsec_retrieve(query: str, top_k: int = 12) -> List[Dict[str, Any]]:\n",
759
+ " qv = embedder.encode([query], normalize_embeddings=True)\n",
760
+ " qv = np.asarray(qv, dtype=np.float32)\n",
761
+ " scores, ids = parsec_index.search(qv, top_k)\n",
762
+ " out: List[Dict[str, Any]] = []\n",
763
+ " for sc, i in zip(scores[0].tolist(), ids[0].tolist()):\n",
764
+ " if 0 <= int(i) < len(parsec_cards):\n",
765
+ " card = parsec_cards[int(i)]\n",
766
+ " out.append({\n",
767
+ " \"score\": float(sc),\n",
768
+ " \"name\": parsec_name_from_card(card),\n",
769
+ " \"part_number\": parsec_part_from_card(card),\n",
770
+ " \"description\": parsec_desc_from_card(card),\n",
771
+ " \"connectors\": parsec_connectors_from_card(card),\n",
772
+ " \"mounts\": parsec_mounts_from_card(card),\n",
773
+ " \"_card\": card.lower(),\n",
774
+ " })\n",
775
+ " return out\n",
776
+ "\n",
777
+ "def choose_best_parsec(cands: List[Dict[str, Any]], mode: str) -> Dict[str, Any]:\n",
778
+ " best = None\n",
779
+ " best_score = -1e9\n",
780
+ "\n",
781
+ " for c in cands:\n",
782
+ " card = c.get(\"_card\",\"\")\n",
783
+ " mounts = c.get(\"mounts\", []) or []\n",
784
+ " score = float(c.get(\"score\", 0.0))\n",
785
+ "\n",
786
+ " if \"omni\" in card:\n",
787
+ " score += 0.6\n",
788
+ " if \"directional\" in card:\n",
789
+ " score -= 1.5\n",
790
+ "\n",
791
+ " if mode == \"vehicle\":\n",
792
+ " if any(\"magnetic\" in m for m in mounts):\n",
793
+ " score += 3.0\n",
794
+ " if any(\"through\" in m for m in mounts):\n",
795
+ " score += 2.0\n",
796
+ " if any(\"wall\" in m for m in mounts) or any(\"pole\" in m for m in mounts):\n",
797
+ " score -= 1.2\n",
798
+ " if \"app: fixed\" in card and \"mobile\" not in card:\n",
799
+ " score -= 2.0\n",
800
+ "\n",
801
+ " if mode == \"stationary\":\n",
802
+ " if any(\"wall\" in m for m in mounts):\n",
803
+ " score += 2.0\n",
804
+ " if any(\"pole\" in m for m in mounts):\n",
805
+ " score += 1.8\n",
806
+ "\n",
807
+ " if score > best_score:\n",
808
+ " best_score = score\n",
809
+ " best = c\n",
810
+ "\n",
811
+ " if not best:\n",
812
+ " return {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\",\"connectors\":\"\",\"mounts\":[]}\n",
813
+ "\n",
814
+ " best = dict(best)\n",
815
+ " best.pop(\"_card\", None)\n",
816
+ " return best\n",
817
+ "\n",
818
+ "\n",
819
+ "def infer_mimo_for_5g(repl_5g_model: str) -> str:\n",
820
+ " \"\"\"Rule: every 5G router uses a 4x4 antenna.\"\"\"\n",
821
+ " return \"4x4\"\n",
822
+ "\n",
823
+ " # If the model name hints 5G, lean 4x4\n",
824
+ " if \"5g\" in model.lower() or model.upper().startswith((\"R\", \"E\", \"S\", \"IX\", \"RUTM\")):\n",
825
+ " default = \"4x4\"\n",
826
+ " else:\n",
827
+ " default = \"2x2\"\n",
828
+ "\n",
829
+ " # Use dec2025routers.csv if we can match the model under the same maker family\n",
830
+ " try:\n",
831
+ " pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
832
+ " if pool.empty:\n",
833
+ " return default\n",
834
+ " hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
835
+ " if not hit or hit[1] < MATCH_OK:\n",
836
+ " return default\n",
837
+ " row = pool.iloc[int(hit[2])]\n",
838
+ " txt2 = (str(row.get(\"Antennas (internal/external/both)\", \"\")) + \" \" + str(row.get(\"Modem Type\", \"\")) + \" \" + str(row.get(\"Special notes\",\"\"))).lower()\n",
839
+ " if \"4x4\" in txt2 or \"4 x 4\" in txt2 or \"4x 4\" in txt2:\n",
840
+ " return \"4x4\"\n",
841
+ " if \"2x2\" in txt2 or \"2 x 2\" in txt2:\n",
842
+ " return \"2x2\"\n",
843
+ " # If modem type includes 5G, lean 4x4\n",
844
+ " if \"5g\" in txt2 or \"nr\" in txt2:\n",
845
+ " return \"4x4\"\n",
846
+ " return default\n",
847
+ " except Exception:\n",
848
+ " return default\n",
849
+ "\n",
850
+ "def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:\n",
851
+ " q_stationary = f\"{router_model} {tech} {mimo} omni stationary pole wall fixed site Parsec\"\n",
852
+ " q_vehicle = f\"{router_model} {tech} {mimo} omni vehicle mobile magnetic through-bolt Parsec\"\n",
853
+ "\n",
854
+ " cand_stationary = parsec_retrieve(q_stationary, top_k=12)\n",
855
+ " cand_vehicle = parsec_retrieve(q_vehicle, top_k=12)\n",
856
+ "\n",
857
+ " s = choose_best_parsec(cand_stationary, mode=\"stationary\")\n",
858
+ " v = choose_best_parsec(cand_vehicle, mode=\"vehicle\")\n",
859
+ "\n",
860
+ " s.update({\"mimo\": mimo, \"why\": \"Stationary omni best match.\"})\n",
861
+ " v.update({\"mimo\": mimo, \"why\": \"Vehicle omni best match.\"})\n",
862
+ "\n",
863
+ " return {\"stationary_omni\": s, \"vehicle_omni\": v, \"sources\":[\"parsec_rag\"]}\n",
864
+ "\n",
865
+ "\n",
866
+ "# ============================\n",
867
+ "# Install-ready checklist\n",
868
+ "# ============================\n",
869
+ "def install_ready_checklist(current_sku: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:\n",
870
+ " st = ant.get(\"stationary_omni\", {})\n",
871
+ " vh = ant.get(\"vehicle_omni\", {})\n",
872
+ " if client is not None:\n",
873
+ " sys = \"Create a short, install-ready checklist for a Verizon rep. Return markdown only.\"\n",
874
+ " payload = {\"current_device\": current_sku, \"replacements\": repl, \"antennas\": {\"stationary\": st, \"vehicle\": vh}}\n",
875
+ " resp = client.responses.create(\n",
876
+ " model=OPENAI_MODEL,\n",
877
+ " reasoning=OPENAI_REASONING,\n",
878
+ " input=[{\"role\":\"system\",\"content\":sys},{\"role\":\"user\",\"content\":json.dumps(payload)}],\n",
879
+ " max_output_tokens=520,\n",
880
+ " )\n",
881
+ " return (getattr(resp, \"output_text\", \"\") or \"\").strip()\n",
882
+ " return \"\\n\".join([\n",
883
+ " \"### Install-ready checklist\",\n",
884
+ " f\"- Current device: {current_sku}\",\n",
885
+ " f\"- 5G replacement: {repl.get('repl_5g','')}\",\n",
886
+ " f\"- 4G alternative: {repl.get('repl_4g','Not applicable')}\",\n",
887
+ " f\"- Stationary omni antenna: {st.get('name','')} (PN {st.get('part_number','')})\",\n",
888
+ " f\"- Vehicle omni antenna: {vh.get('name','')} (PN {vh.get('part_number','')})\",\n",
889
+ " \"- Next steps: confirm mounting + cable lengths + power; place order; schedule install.\",\n",
890
+ " ])\n",
891
+ "\n",
892
+ "\n",
893
+ "# ============================\n",
894
+ "# Batch mode (NO GPT)\n",
895
+ "# ============================\n",
896
+ "def parse_batch_inputs(text_blob: str, file_obj: Any) -> List[str]:\n",
897
+ " items: List[str] = []\n",
898
+ " if file_obj is not None:\n",
899
+ " try:\n",
900
+ " path = file_obj.name if hasattr(file_obj, \"name\") else str(file_obj)\n",
901
+ " df = pd.read_csv(path)\n",
902
+ " col = df.columns[0]\n",
903
+ " items.extend([str(x).strip() for x in df[col].tolist() if str(x).strip()])\n",
904
+ " except Exception:\n",
905
+ " pass\n",
906
+ " if text_blob:\n",
907
+ " for ln in str(text_blob).splitlines():\n",
908
+ " ln = ln.strip()\n",
909
+ " if ln:\n",
910
+ " items.append(ln)\n",
911
+ " seen=set()\n",
912
+ " out=[]\n",
913
+ " for x in items:\n",
914
+ " k=norm_text(x)\n",
915
+ " if k and k not in seen:\n",
916
+ " seen.add(k); out.append(x)\n",
917
+ " return out\n",
918
+ "\n",
919
+ "def run_batch(text_blob: str, file_obj: Any, include_antennas: bool):\n",
920
+ " inputs = parse_batch_inputs(text_blob, file_obj)\n",
921
+ " if not inputs:\n",
922
+ " return \"\", None, None, \"\"\n",
923
+ "\n",
924
+ " rows=[]\n",
925
+ " for item in inputs:\n",
926
+ " res = resolve_device(item)\n",
927
+ " if res.get(\"mode\") != \"ok\":\n",
928
+ " rows.append({\"Input\": item, \"Matched\":\"\", \"Status\":\"Needs review\", \"EOS\":\"\", \"EOL\":\"\", \"4G alternative\":\"\", \"5G replacement\":\"\", \"Notes\":\"Not found/ambiguous\"})\n",
929
+ " continue\n",
930
+ "\n",
931
+ " life_row = df_eos.iloc[int(res[\"row_idx\"])]\n",
932
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
933
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=False)\n",
934
+ "\n",
935
+ " rows.append({\n",
936
+ " \"Input\": item,\n",
937
+ " \"Matched\": str(life_row.get(\"sku\",\"\")),\n",
938
+ " \"Status\": status,\n",
939
+ " \"EOS\": eos,\n",
940
+ " \"EOL\": eol,\n",
941
+ " \"4G alternative\": repl.get(\"repl_4g\",\"\"),\n",
942
+ " \"5G replacement\": repl.get(\"repl_5g\",\"\"),\n",
943
+ " \"Notes\": \"\",\n",
944
+ " })\n",
945
+ "\n",
946
+ " out_df = pd.DataFrame(rows)\n",
947
+ " counts = out_df[\"Status\"].value_counts(dropna=False).to_dict()\n",
948
+ " top_5g = out_df[\"5G replacement\"].value_counts(dropna=False).head(5).to_dict()\n",
949
+ " summary = f\"Rows: {len(out_df)} | \" + \" | \".join([f\"{k}: {v}\" for k,v in counts.items()])\n",
950
+ " rollup = \"Top 5G recommendations:\\n\" + \"\\n\".join([f\"- {k}: {v}\" for k,v in top_5g.items() if str(k).strip()])\n",
951
+ "\n",
952
+ " tmp = tempfile.NamedTemporaryFile(delete=False, suffix=\".csv\")\n",
953
+ " out_df.to_csv(tmp.name, index=False)\n",
954
+ "\n",
955
+ " return summary, out_df, tmp.name, rollup\n",
956
+ "\n",
957
+ "\n",
958
+ "# ============================\n",
959
+ "# Replacement feature table + manufacturer link (5G device)\n",
960
+ "# ============================\n",
961
+ "\n",
962
+ "FEATURE_COLS = [\"Device\", \"Modem technology\", \"WiFi\", \"Ports\", \"Antennas\", \"Ruggedness\", \"Use case\"]\n",
963
+ "\n",
964
+ "# Manufacturer domains used for best-effort link resolution (no non-maker domains).\n",
965
+ "MAKER_DOMAINS = {\n",
966
+ " \"CRADLEPOINT\": [\"cradlepoint.com\", \"ericsson.com\"],\n",
967
+ " \"SIERRA\": [\"semtech.com\", \"airlink.com\"],\n",
968
+ " \"FEENEY\": [\"inseego.com\"],\n",
969
+ " \"DIGI\": [\"digi.com\"],\n",
970
+ " \"CISCO_MERAKI\": [\"meraki.cisco.com\", \"cisco.com\"],\n",
971
+ " \"CISCO\": [\"cisco.com\"],\n",
972
+ " \"TELTONIKA\": [\"teltonika-networks.com\"],\n",
973
+ " \"UNKNOWN\": [],\n",
974
+ "}\n",
975
+ "\n",
976
+ "HTTP_HEADERS = {\n",
977
+ " \"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \"\n",
978
+ " \"(KHTML, like Gecko) Chrome/120.0 Safari/537.36\"\n",
979
+ "}\n",
980
+ "HTTP_TIMEOUT = 12\n",
981
+ "\n",
982
+ "def _best_effort_manufacturer_url(model: str, canon_make: str) -> str:\n",
983
+ " \"\"\"Try to find a manufacturer page or datasheet link using simple on-domain searches.\n",
984
+ " If we can't confirm a page, return the manufacturer homepage for the maker family.\n",
985
+ " \"\"\"\n",
986
+ " model = str(model or \"\").strip()\n",
987
+ " if not model or model in {\"Not listed\", \"Not applicable\"}:\n",
988
+ " return \"\"\n",
989
+ "\n",
990
+ " domains = MAKER_DOMAINS.get(canon_make, []) or []\n",
991
+ " if not domains:\n",
992
+ " return \"\"\n",
993
+ "\n",
994
+ " # Candidate on-domain search URLs (common patterns across sites).\n",
995
+ " # We keep these on the manufacturer domain (no Google/Bing).\n",
996
+ " q = re.sub(r\"\\s+\", \"+\", model)\n",
997
+ " url_candidates = []\n",
998
+ " for d in domains:\n",
999
+ " url_candidates += [\n",
1000
+ " f\"https://{d}/search?q={q}\",\n",
1001
+ " f\"https://{d}/search?query={q}\",\n",
1002
+ " f\"https://{d}/?s={q}\",\n",
1003
+ " f\"https://www.{d}/search?q={q}\",\n",
1004
+ " f\"https://www.{d}/search?query={q}\",\n",
1005
+ " f\"https://www.{d}/?s={q}\",\n",
1006
+ " ]\n",
1007
+ "\n",
1008
+ " # Also try a few direct product patterns for known makers (best effort).\n",
1009
+ " if canon_make == \"TELTONIKA\":\n",
1010
+ " slug = model.lower()\n",
1011
+ " url_candidates += [\n",
1012
+ " f\"https://teltonika-networks.com/products/routers/{slug}\",\n",
1013
+ " f\"https://teltonika-networks.com/product/{slug}\",\n",
1014
+ " \"https://teltonika-networks.com/products/routers/\",\n",
1015
+ " ]\n",
1016
+ " if canon_make == \"DIGI\":\n",
1017
+ " url_candidates += [\n",
1018
+ " \"https://www.digi.com/products/networking/cellular-routers\",\n",
1019
+ " f\"https://www.digi.com/search?q={q}\",\n",
1020
+ " ]\n",
1021
+ " if canon_make == \"CRADLEPOINT\":\n",
1022
+ " url_candidates += [\n",
1023
+ " \"https://cradlepoint.com/products/\",\n",
1024
+ " f\"https://cradlepoint.com/?s={q}\",\n",
1025
+ " ]\n",
1026
+ " if canon_make in {\"CISCO\", \"CISCO_MERAKI\"}:\n",
1027
+ " url_candidates += [\n",
1028
+ " f\"https://www.cisco.com/c/en/us/search.html?q={q}\",\n",
1029
+ " ]\n",
1030
+ "\n",
1031
+ " # Try to confirm a working page (HTTP 200 and model string somewhere in HTML).\n",
1032
+ " for u in url_candidates[:18]:\n",
1033
+ " try:\n",
1034
+ " import requests\n",
1035
+ " r = requests.get(u, headers=HTTP_HEADERS, timeout=HTTP_TIMEOUT, allow_redirects=True)\n",
1036
+ " if r.status_code != 200:\n",
1037
+ " continue\n",
1038
+ " html = (r.text or \"\").lower()\n",
1039
+ " if model.lower() in html or \"datasheet\" in html or \"data sheet\" in html:\n",
1040
+ " return r.url\n",
1041
+ " except Exception:\n",
1042
+ " continue\n",
1043
+ "\n",
1044
+ " # Fallback: maker homepage\n",
1045
+ " d0 = domains[0]\n",
1046
+ " return f\"https://{d0}\"\n",
1047
+ "\n",
1048
+ "def _fetch_page_text(url: str, max_chars: int = 12000) -> str:\n",
1049
+ " \"\"\"Fetch page HTML and return a simplified text blob for GPT (best effort).\"\"\"\n",
1050
+ " if not url:\n",
1051
+ " return \"\"\n",
1052
+ " try:\n",
1053
+ " import requests\n",
1054
+ " r = requests.get(url, headers=HTTP_HEADERS, timeout=HTTP_TIMEOUT, allow_redirects=True)\n",
1055
+ " if r.status_code != 200:\n",
1056
+ " return \"\"\n",
1057
+ " html = r.text or \"\"\n",
1058
+ " html = re.sub(r\"(?is)<script.*?>.*?</script>\", \" \", html)\n",
1059
+ " html = re.sub(r\"(?is)<style.*?>.*?</style>\", \" \", html)\n",
1060
+ " text = re.sub(r\"(?is)<[^>]+>\", \" \", html)\n",
1061
+ " text = re.sub(r\"\\s+\", \" \", text).strip()\n",
1062
+ " return text[:max_chars]\n",
1063
+ " except Exception:\n",
1064
+ " return \"\"\n",
1065
+ "\n",
1066
+ "\n",
1067
+ "def _features_from_dec(model: str, canon_make: str) -> Dict[str, str]:\n",
1068
+ " \"\"\"Lookup a router model in dec2025routers.csv and return the key feature fields.\"\"\"\n",
1069
+ " if not model or model in {\"Not listed\", \"Not applicable\"}:\n",
1070
+ " return {k: \"Not listed\" for k in FEATURE_COLS[1:]}\n",
1071
+ "\n",
1072
+ " pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
1073
+ " if pool.empty:\n",
1074
+ " return {k: \"Not listed\" for k in FEATURE_COLS[1:]}\n",
1075
+ "\n",
1076
+ " hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
1077
+ " if not hit or hit[1] < MATCH_OK:\n",
1078
+ " return {k: \"Not listed\" for k in FEATURE_COLS[1:]}\n",
1079
+ "\n",
1080
+ " r = pool.iloc[int(hit[2])]\n",
1081
+ " ports = f\"WAN: {r.get('WAN ports and speed','')} | LAN: {r.get('LAN ports and speed','')}\"\n",
1082
+ " return {\n",
1083
+ " \"Modem technology\": str(r.get(\"Modem Type\",\"\")) or \"Not listed\",\n",
1084
+ " \"WiFi\": str(r.get(\"WiFi type\",\"\")) or \"Not listed\",\n",
1085
+ " \"Ports\": ports.strip() if ports.strip() else \"Not listed\",\n",
1086
+ " \"Antennas\": str(r.get(\"Antennas (internal/external/both)\",\"\")) or \"Not listed\",\n",
1087
+ " \"Ruggedness\": str(r.get(\"Ruggedization\",\"\")) or \"Not listed\",\n",
1088
+ " \"Use case\": str(r.get(\"Primary use case\",\"\")) or \"Not listed\",\n",
1089
+ " }\n",
1090
+ "\n",
1091
+ "def _gpt_fill_feature_row(device_label: str, model: str, canon_make: str, row: Dict[str, str], manufacturer_url: str = \"\", page_text: str = \"\") -> Dict[str, str]:\n",
1092
+ " \"\"\"If dec can't supply values, ask GPT to fill missing ones (best guess).\"\"\"\n",
1093
+ " if client is None:\n",
1094
+ " return row\n",
1095
+ "\n",
1096
+ " missing = [k for k,v in row.items() if (not v) or str(v).strip().lower() in {\"not listed\",\"nan\",\"\"}]\n",
1097
+ " if not missing:\n",
1098
+ " return row\n",
1099
+ "\n",
1100
+ " sys = (\n",
1101
+ " \"Fill missing router feature fields for a Verizon rep. Return strict JSON only. \"\n",
1102
+ " \"Use manufacturer page text when available. If still unknown, make a best-guess.\"\n",
1103
+ " )\n",
1104
+ " payload = {\n",
1105
+ " \"device_label\": device_label,\n",
1106
+ " \"model\": model,\n",
1107
+ " \"maker_family\": canon_make,\n",
1108
+ " \"manufacturer_url\": manufacturer_url,\n",
1109
+ " \"manufacturer_page_text\": page_text[:8000],\n",
1110
+ " \"known\": row,\n",
1111
+ " \"fill_only\": missing,\n",
1112
+ " \"rules\": [\"Fill only requested fields.\", \"Short phrases only.\", \"Return JSON only.\"],\n",
1113
+ " \"output_schema\": {k: \"string\" for k in missing},\n",
1114
+ " }\n",
1115
+ " out = gpt_json(sys, payload, max_tokens=320) or {}\n",
1116
+ " for k in missing:\n",
1117
+ " val = str(out.get(k, \"\") or \"\").strip()\n",
1118
+ " if val:\n",
1119
+ " row[k] = val\n",
1120
+ " return row\n",
1121
+ " missing = [k for k,v in row.items() if (not v) or str(v).strip().lower() in {\"not listed\",\"nan\",\"\"}]\n",
1122
+ " if not missing:\n",
1123
+ " return row\n",
1124
+ "\n",
1125
+ " sys = \"Fill missing router feature fields for a Verizon rep. Return strict JSON only.\"\n",
1126
+ " payload = {\n",
1127
+ " \"device_label\": device_label,\n",
1128
+ " \"model\": model,\n",
1129
+ " \"maker_family\": canon_make,\n",
1130
+ " \"known\": row,\n",
1131
+ " \"fill_only\": missing,\n",
1132
+ " \"rules\": [\n",
1133
+ " \"Fill only the requested fields.\",\n",
1134
+ " \"Best guess if needed. Short phrases only.\",\n",
1135
+ " \"Return JSON only.\"\n",
1136
+ " ],\n",
1137
+ " \"output_schema\": {k: \"string\" for k in missing}\n",
1138
+ " }\n",
1139
+ " out = gpt_json(sys, payload, max_tokens=260) or {}\n",
1140
+ " for k in missing:\n",
1141
+ " val = str(out.get(k, \"\") or \"\").strip()\n",
1142
+ " if val:\n",
1143
+ " row[k] = val\n",
1144
+ " return row\n",
1145
+ "\n",
1146
+ "def build_replacement_features_table(repl_4g: str, repl_5g: str, canon_make: str) -> pd.DataFrame:\n",
1147
+ " rows = []\n",
1148
+ "\n",
1149
+ " # 4G alternative row\n",
1150
+ " row4 = _features_from_dec(repl_4g, canon_make)\n",
1151
+ " url4 = _best_effort_manufacturer_url(repl_4g, canon_make) if repl_4g else \"\"\n",
1152
+ " txt4 = _fetch_page_text(url4) if url4 else \"\"\n",
1153
+ " row4 = _gpt_fill_feature_row(\"4G alternative\", repl_4g, canon_make, row4, manufacturer_url=url4, page_text=txt4)\n",
1154
+ " rows.append({\"Device\": \"4G alternative\", **row4})\n",
1155
+ "\n",
1156
+ " # 5G replacement row\n",
1157
+ " row5 = _features_from_dec(repl_5g, canon_make)\n",
1158
+ " url5 = _best_effort_manufacturer_url(repl_5g, canon_make) if repl_5g else \"\"\n",
1159
+ " txt5 = _fetch_page_text(url5) if url5 else \"\"\n",
1160
+ " row5 = _gpt_fill_feature_row(\"5G replacement\", repl_5g, canon_make, row5, manufacturer_url=url5, page_text=txt5)\n",
1161
+ " rows.append({\"Device\": \"5G replacement\", **row5})\n",
1162
+ "\n",
1163
+ " df = pd.DataFrame(rows, columns=FEATURE_COLS)\n",
1164
+ " return df\n",
1165
+ "# ============================\n",
1166
+ "# Verizon fit badges (small table) for recommended devices\n",
1167
+ "# ============================\n",
1168
+ "\n",
1169
+ "FIT_COLS = [\"Device\", \"Fit badges\", \"Ethernet ports\", \"Battery\"]\n",
1170
+ "\n",
1171
+ "def _parse_ethernet_ports(wan_field: str, lan_field: str) -> str:\n",
1172
+ " \"\"\"Best-effort total ethernet ports based on WAN/LAN text.\"\"\"\n",
1173
+ " def _count(field: str) -> int:\n",
1174
+ " s = str(field or \"\")\n",
1175
+ " # Common forms: \"1x GbE\", \"2 x 10/100\", \"WAN: 1\", etc.\n",
1176
+ " nums = [int(x) for x in re.findall(r\"(\\\\d+)\\\\s*x\", s.lower())]\n",
1177
+ " if nums:\n",
1178
+ " return sum(nums)\n",
1179
+ " # Fallback: if it contains 'port' with a number\n",
1180
+ " m = re.search(r\"(\\\\d+)\\\\s*port\", s.lower())\n",
1181
+ " if m:\n",
1182
+ " return int(m.group(1))\n",
1183
+ " # If it contains '1' and 'wan' in short text, guess 1\n",
1184
+ " if \"wan\" in s.lower() and re.search(r\"\\\\b1\\\\b\", s):\n",
1185
+ " return 1\n",
1186
+ " return 0\n",
1187
+ "\n",
1188
+ " total = _count(wan_field) + _count(lan_field)\n",
1189
+ " return str(total) if total > 0 else \"Not listed\"\n",
1190
+ "\n",
1191
+ "def _battery_badge(battery_field: str) -> str:\n",
1192
+ " s = str(battery_field or \"\").strip().lower()\n",
1193
+ " if not s or s in {\"none\", \"no\", \"n/a\", \"not listed\"}:\n",
1194
+ " return \"No\"\n",
1195
+ " return \"Yes\"\n",
1196
+ "\n",
1197
+ "def _bool_badge(flag: bool) -> str:\n",
1198
+ " return \"Yes\" if flag else \"No\"\n",
1199
+ "\n",
1200
+ "def _dual_sim_from_row_text(*fields: str) -> bool:\n",
1201
+ " txt = \" \".join([str(x or \"\") for x in fields]).lower()\n",
1202
+ " return (\"dual sim\" in txt) or (\"2 sim\" in txt) or (\"two sim\" in txt) or (\"dual-sim\" in txt)\n",
1203
+ "\n",
1204
+ "def _throughput_high(throughput_field: str) -> bool:\n",
1205
+ " t = str(throughput_field or \"\").lower()\n",
1206
+ " # Heuristic: anything mentioning gbps or >=1000 mbps\n",
1207
+ " if \"gbps\" in t:\n",
1208
+ " return True\n",
1209
+ " m = re.search(r\"(\\\\d+(?:\\\\.\\\\d+)?)\\\\s*mbps\", t)\n",
1210
+ " if m:\n",
1211
+ " try:\n",
1212
+ " return float(m.group(1)) >= 1000.0\n",
1213
+ " except Exception:\n",
1214
+ " pass\n",
1215
+ " return False\n",
1216
+ "\n",
1217
+ "def _gpt_fit_badges(model: str, canon_make: str, is_5g: bool, dec_row: Optional[pd.Series]) -> Tuple[str, str, str]:\n",
1218
+ " \"\"\"\n",
1219
+ " GPT-based fill for Fit badges / Ethernet ports / Battery, used when dec is missing or incomplete.\n",
1220
+ " Returns (badges_csv, ethernet_ports, battery_yesno).\n",
1221
+ " \"\"\"\n",
1222
+ " if client is None:\n",
1223
+ " return (\"Not listed\", \"Not listed\", \"Not listed\")\n",
1224
+ "\n",
1225
+ " dec_ctx = {}\n",
1226
+ " if dec_row is not None:\n",
1227
+ " try:\n",
1228
+ " dec_ctx = {\n",
1229
+ " \"Model\": str(dec_row.get(\"Model\",\"\")),\n",
1230
+ " \"Modem Type\": str(dec_row.get(\"Modem Type\",\"\")),\n",
1231
+ " \"Ruggedization\": str(dec_row.get(\"Ruggedization\",\"\")),\n",
1232
+ " \"WAN ports and speed\": str(dec_row.get(\"WAN ports and speed\",\"\")),\n",
1233
+ " \"LAN ports and speed\": str(dec_row.get(\"LAN ports and speed\",\"\")),\n",
1234
+ " \"Antennas\": str(dec_row.get(\"Antennas (internal/external/both)\",\"\")),\n",
1235
+ " \"WiFi type\": str(dec_row.get(\"WiFi type\",\"\")),\n",
1236
+ " \"Primary use case\": str(dec_row.get(\"Primary use case\",\"\")),\n",
1237
+ " \"Serial port\": str(dec_row.get(\"Serial port (yes/no)\",\"\")),\n",
1238
+ " \"VPN\": str(dec_row.get(\"VPN capabilities\",\"\")),\n",
1239
+ " \"Throughput\": str(dec_row.get(\"Router throughput\",\"\")),\n",
1240
+ " \"Battery\": str(dec_row.get(\"Battery (internal/removable/none/optional)\",\"\")),\n",
1241
+ " \"Special notes\": str(dec_row.get(\"Special notes\",\"\")),\n",
1242
+ " \"Summary\": str(dec_row.get(\"summary and use case\",\"\")),\n",
1243
+ " }\n",
1244
+ " except Exception:\n",
1245
+ " dec_ctx = {}\n",
1246
+ "\n",
1247
+ " sys = (\n",
1248
+ " \"You are helping a Verizon rep. Based on the provided router context, output fit badges and a couple quick traits.\\n\"\n",
1249
+ " \"Return STRICT JSON only.\\n\"\n",
1250
+ " \"Badges must be chosen from this set only:\\n\"\n",
1251
+ " \"['Vehicle','Fixed site','Wi‑Fi','Rugged','Dual‑SIM','4x4 MIMO','High throughput','Serial'].\\n\"\n",
1252
+ " \"Rules:\\n\"\n",
1253
+ " \"- If is_5g is true, ALWAYS include '4x4 MIMO'.\\n\"\n",
1254
+ " \"- Ethernet ports: return a single integer as a string if you can infer total ethernet ports, otherwise 'Not listed'.\\n\"\n",
1255
+ " \"- Battery: return 'Yes' or 'No' if you can infer, otherwise 'Not listed'.\\n\"\n",
1256
+ " \"- If uncertain between Vehicle vs Fixed site, pick the most likely based on use case/ruggedization.\\n\"\n",
1257
+ " )\n",
1258
+ "\n",
1259
+ " payload = {\n",
1260
+ " \"model\": model,\n",
1261
+ " \"maker_family\": canon_make,\n",
1262
+ " \"is_5g\": bool(is_5g),\n",
1263
+ " \"dec_context\": dec_ctx,\n",
1264
+ " \"output_schema\": {\n",
1265
+ " \"badges\": [\"string\"],\n",
1266
+ " \"ethernet_ports\": \"string\",\n",
1267
+ " \"battery\": \"Yes|No|Not listed\"\n",
1268
+ " }\n",
1269
+ " }\n",
1270
+ "\n",
1271
+ " out = gpt_json(sys, payload, max_tokens=260) or {}\n",
1272
+ "\n",
1273
+ " badges = out.get(\"badges\", []) or []\n",
1274
+ " allowed = {\"Vehicle\",\"Fixed site\",\"Wi‑Fi\",\"Rugged\",\"Dual‑SIM\",\"4x4 MIMO\",\"High throughput\",\"Serial\"}\n",
1275
+ " clean = []\n",
1276
+ " for b in badges:\n",
1277
+ " bs = str(b).strip()\n",
1278
+ " if bs in allowed:\n",
1279
+ " clean.append(bs)\n",
1280
+ "\n",
1281
+ " if is_5g and \"4x4 MIMO\" not in clean:\n",
1282
+ " clean.append(\"4x4 MIMO\")\n",
1283
+ "\n",
1284
+ " eth = str(out.get(\"ethernet_ports\",\"\") or \"\").strip()\n",
1285
+ " if not eth or eth.lower() in {\"nan\",\"none\"}:\n",
1286
+ " eth = \"Not listed\"\n",
1287
+ " m = re.search(r\"\\d+\", eth)\n",
1288
+ " eth = m.group(0) if m else (\"Not listed\" if eth == \"Not listed\" else eth)\n",
1289
+ "\n",
1290
+ " bat = str(out.get(\"battery\",\"\") or \"\").strip()\n",
1291
+ " if not bat:\n",
1292
+ " bat = \"Not listed\"\n",
1293
+ " if bat.lower().startswith(\"y\"):\n",
1294
+ " bat = \"Yes\"\n",
1295
+ " elif bat.lower().startswith(\"n\"):\n",
1296
+ " bat = \"No\"\n",
1297
+ " elif bat not in {\"Yes\",\"No\",\"Not listed\"}:\n",
1298
+ " bat = \"Not listed\"\n",
1299
+ "\n",
1300
+ " dedup=[]\n",
1301
+ " seen=set()\n",
1302
+ " for b in clean:\n",
1303
+ " if b not in seen:\n",
1304
+ " seen.add(b); dedup.append(b)\n",
1305
+ " badges_csv = \", \".join(dedup) if dedup else \"Not listed\"\n",
1306
+ " return (badges_csv, eth, bat)\n",
1307
+ "\n",
1308
+ "\n",
1309
+ "def _fit_badges_for_model(model: str, canon_make: str, is_5g: bool) -> Tuple[str, str, str]:\n",
1310
+ " \"\"\"Return (badges_csv, ethernet_ports, battery_yesno). Uses dec2025routers.csv first, then GPT fill.\"\"\"\n",
1311
+ " model = str(model or \"\").strip()\n",
1312
+ " if not model or model in {\"Not listed\", \"Not applicable\"}:\n",
1313
+ " return (\"Not listed\", \"Not listed\", \"Not listed\")\n",
1314
+ "\n",
1315
+ " pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
1316
+ " row = None\n",
1317
+ " if not pool.empty:\n",
1318
+ " hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
1319
+ " if hit and hit[1] >= MATCH_OK:\n",
1320
+ " row = pool.iloc[int(hit[2])]\n",
1321
+ "\n",
1322
+ " badges = []\n",
1323
+ " eth = \"Not listed\"\n",
1324
+ " bat_yes = \"Not listed\"\n",
1325
+ "\n",
1326
+ " if row is not None:\n",
1327
+ " use_case = str(row.get(\"Primary use case\",\"\") or \"\").lower()\n",
1328
+ " rugged = str(row.get(\"Ruggedization\",\"\") or \"\").lower()\n",
1329
+ "\n",
1330
+ " if any(k in use_case for k in [\"vehicle\",\"mobile\",\"fleet\",\"in-vehicle\"]) or \"vehicle\" in rugged:\n",
1331
+ " badges.append(\"Vehicle\")\n",
1332
+ " else:\n",
1333
+ " badges.append(\"Fixed site\")\n",
1334
+ "\n",
1335
+ " wifi = str(row.get(\"WiFi type\",\"\") or \"\").strip()\n",
1336
+ " if wifi and wifi.lower() not in {\"none\",\"no\",\"n/a\"}:\n",
1337
+ " badges.append(\"Wi‑Fi\")\n",
1338
+ "\n",
1339
+ " if any(k in rugged for k in [\"rugged\",\"industrial\",\"ip\",\"harsh\"]):\n",
1340
+ " badges.append(\"Rugged\")\n",
1341
+ "\n",
1342
+ " notes_blob = \" \".join([\n",
1343
+ " str(row.get(\"Special notes\",\"\") or \"\"),\n",
1344
+ " str(row.get(\"summary and use case\",\"\") or \"\"),\n",
1345
+ " ]).lower()\n",
1346
+ " if \"dual\" in notes_blob and \"sim\" in notes_blob:\n",
1347
+ " badges.append(\"Dual‑SIM\")\n",
1348
+ "\n",
1349
+ " if is_5g:\n",
1350
+ " badges.append(\"4x4 MIMO\")\n",
1351
+ "\n",
1352
+ " thr = str(row.get(\"Router throughput\",\"\") or \"\").lower()\n",
1353
+ " m = re.search(r\"(\\d+(\\.\\d+)?)\\s*gb\", thr)\n",
1354
+ " if m:\n",
1355
+ " try:\n",
1356
+ " if float(m.group(1)) >= 1.0:\n",
1357
+ " badges.append(\"High throughput\")\n",
1358
+ " except Exception:\n",
1359
+ " pass\n",
1360
+ "\n",
1361
+ " serial = str(row.get(\"Serial port (yes/no)\",\"\") or \"\").strip().lower()\n",
1362
+ " if serial in {\"yes\",\"y\",\"true\"}:\n",
1363
+ " badges.append(\"Serial\")\n",
1364
+ "\n",
1365
+ " wan = str(row.get(\"WAN ports and speed\",\"\") or \"\")\n",
1366
+ " lan = str(row.get(\"LAN ports and speed\",\"\") or \"\")\n",
1367
+ " m1 = re.search(r\"(\\d+)\\s*x\", wan.lower())\n",
1368
+ " m2 = re.search(r\"(\\d+)\\s*x\", lan.lower())\n",
1369
+ " if m1 or m2:\n",
1370
+ " total = (int(m1.group(1)) if m1 else 0) + (int(m2.group(1)) if m2 else 0)\n",
1371
+ " eth = str(total) if total > 0 else \"Not listed\"\n",
1372
+ "\n",
1373
+ " bat = str(row.get(\"Battery (internal/removable/none/optional)\",\"\") or \"\")\n",
1374
+ " bat_l = bat.lower().strip()\n",
1375
+ " if bat_l:\n",
1376
+ " if \"none\" in bat_l:\n",
1377
+ " bat_yes = \"No\"\n",
1378
+ " else:\n",
1379
+ " bat_yes = \"Yes\"\n",
1380
+ "\n",
1381
+ " # Use GPT when anything is missing (instead of best-effort inference)\n",
1382
+ " if (row is None) or (eth == \"Not listed\") or (bat_yes == \"Not listed\") or (not badges):\n",
1383
+ " g_badges, g_eth, g_bat = _gpt_fit_badges(model, canon_make, is_5g, row)\n",
1384
+ "\n",
1385
+ " if badges:\n",
1386
+ " if is_5g and \"4x4 MIMO\" not in badges:\n",
1387
+ " badges.append(\"4x4 MIMO\")\n",
1388
+ " dedup=[]\n",
1389
+ " seen=set()\n",
1390
+ " for b in badges:\n",
1391
+ " if b not in seen:\n",
1392
+ " seen.add(b); dedup.append(b)\n",
1393
+ " badges_csv = \", \".join(dedup)\n",
1394
+ " else:\n",
1395
+ " badges_csv = g_badges\n",
1396
+ "\n",
1397
+ " eth = eth if eth != \"Not listed\" else g_eth\n",
1398
+ " bat_yes = bat_yes if bat_yes != \"Not listed\" else g_bat\n",
1399
+ " return (badges_csv or \"Not listed\", eth or \"Not listed\", bat_yes or \"Not listed\")\n",
1400
+ "\n",
1401
+ " dedup=[]\n",
1402
+ " seen=set()\n",
1403
+ " for b in badges:\n",
1404
+ " if b not in seen:\n",
1405
+ " seen.add(b); dedup.append(b)\n",
1406
+ " badges_csv = \", \".join(dedup) if dedup else \"Not listed\"\n",
1407
+ " return (badges_csv, eth, bat_yes)\n",
1408
+ "\n",
1409
+ "def build_fit_table(repl_4g: str, repl_5g: str, canon_make: str) -> pd.DataFrame:\n",
1410
+ " rows = []\n",
1411
+ " # 4G alt row (is_5g False)\n",
1412
+ " b4, eth4, bat4 = _fit_badges_for_model(repl_4g, canon_make, is_5g=False)\n",
1413
+ " rows.append({\"Device\": \"4G alternative\", \"Fit badges\": b4, \"Ethernet ports\": eth4, \"Battery\": bat4})\n",
1414
+ " # 5G row (is_5g True)\n",
1415
+ " b5, eth5, bat5 = _fit_badges_for_model(repl_5g, canon_make, is_5g=True)\n",
1416
+ " rows.append({\"Device\": \"5G replacement\", \"Fit badges\": b5, \"Ethernet ports\": eth5, \"Battery\": bat5})\n",
1417
+ " return pd.DataFrame(rows, columns=FIT_COLS)\n",
1418
+ "\n",
1419
+ "# ============================\n",
1420
+ "# Output\n",
1421
+ "# ============================\n",
1422
+ "def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:\n",
1423
+ " current_name = f\"{life_row.get('sku','')} — {life_row.get('description','')}\".strip(\" —\")\n",
1424
+ " st = ant.get(\"stationary_omni\", {})\n",
1425
+ " vh = ant.get(\"vehicle_omni\", {})\n",
1426
+ "\n",
1427
+ " lines = []\n",
1428
+ " lines.append(f\"1. Current device: **{current_name}**\")\n",
1429
+ " lines.append(f\"2. Status: **{status}**\")\n",
1430
+ " lines.append(f\"3. End of Sale date: **{eos}**\")\n",
1431
+ " lines.append(f\"4. End of Life date: **{eol}**\")\n",
1432
+ " lines.append(f\"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**\")\n",
1433
+ " lines.append(f\"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**\")\n",
1434
+ " lines.append(\"7. Antenna options (Parsec-only):\")\n",
1435
+ " conn_s = f\" | Conn: {st.get('connectors','')}\" if st.get(\"connectors\") else \"\"\n",
1436
+ " conn_v = f\" | Conn: {vh.get('connectors','')}\" if vh.get(\"connectors\") else \"\"\n",
1437
+ " lines.append(f\" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')}{conn_s}\")\n",
1438
+ " lines.append(f\" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')}{conn_v}\")\n",
1439
+ "\n",
1440
+ " lines.append(\"\\nSources (debug):\")\n",
1441
+ " for s in repl.get(\"sources\", []) if isinstance(repl.get(\"sources\"), list) else []:\n",
1442
+ " lines.append(f\"- {s}\")\n",
1443
+ " lines.append(\"- ParsecCatalog.pdf (local RAG)\")\n",
1444
+ " lines.append(\"- routers_eos_eol_by_sku.csv (replacements)\")\n",
1445
+ " return \"\\n\".join(lines)\n",
1446
+ "\n",
1447
+ "\n",
1448
+ "# ============================\n",
1449
+ "# Customer-ready email summary (single lookup only)\n",
1450
+ "# ============================\n",
1451
+ "\n",
1452
+ "def build_customer_email(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any], link5: str) -> str:\n",
1453
+ " \"\"\"Email-style summary the rep can paste to a customer (lightly sales-y).\"\"\"\n",
1454
+ " current = f\"{life_row.get('sku','')} — {life_row.get('description','')}\".strip(\" —\")\n",
1455
+ " repl5 = str(repl.get(\"repl_5g\",\"\") or \"\").strip()\n",
1456
+ " repl4 = str(repl.get(\"repl_4g\",\"\") or \"\").strip()\n",
1457
+ "\n",
1458
+ " st = ant.get(\"stationary_omni\", {}) or {}\n",
1459
+ " vh = ant.get(\"vehicle_omni\", {}) or {}\n",
1460
+ "\n",
1461
+ " lines = []\n",
1462
+ " lines.append(\"Subject: Router replacement recommendation\")\n",
1463
+ " lines.append(\"\")\n",
1464
+ " lines.append(\"Hi there,\")\n",
1465
+ " lines.append(\"\")\n",
1466
+ " lines.append(f\"We reviewed your current router (**{current}**) and recommend the following path forward:\")\n",
1467
+ " lines.append(\"\")\n",
1468
+ " lines.append(f\"- **Status:** {status}\")\n",
1469
+ " lines.append(f\"- **End of Sale:** {eos}\")\n",
1470
+ " lines.append(f\"- **End of Life:** {eol}\")\n",
1471
+ " lines.append(\"\")\n",
1472
+ " lines.append(\"**Recommended replacement (5G):**\")\n",
1473
+ " lines.append(f\"- {repl5 if repl5 else 'Not listed'}\")\n",
1474
+ " if link5:\n",
1475
+ " lines.append(f\"- Manufacturer page (best effort): {link5}\")\n",
1476
+ " lines.append(\"\")\n",
1477
+ " lines.append(\"**Optional 4G alternative (if needed):**\")\n",
1478
+ " lines.append(f\"- {repl4 if repl4 and repl4.lower() != 'not applicable' else 'Not applicable'}\")\n",
1479
+ " lines.append(\"\")\n",
1480
+ " lines.append(\"**Antenna suggestions (Parsec):**\")\n",
1481
+ " lines.append(f\"- Stationary (Omni): {st.get('name','')} (PN {st.get('part_number','')})\")\n",
1482
+ " lines.append(f\"- Vehicle (Omni): {vh.get('name','')} (PN {vh.get('part_number','')})\")\n",
1483
+ " lines.append(\"\")\n",
1484
+ " lines.append(\"If you’d like, we can confirm the best-fit option for your install environment and provide pricing.\")\n",
1485
+ " lines.append(\"\")\n",
1486
+ " lines.append(\"Contact Peter Dunn @ 786.999.9127 or peter.dunn@masterstelecom.com for pricing.\")\n",
1487
+ " lines.append(\"\")\n",
1488
+ " lines.append(\"Thanks,\")\n",
1489
+ " lines.append(\"Peter Dunn\")\n",
1490
+ " return \"\\n\".join(lines)\n",
1491
+ "\n",
1492
+ "def generate_customer_email(st_json: str) -> str:\n",
1493
+ " st = state_load(st_json)\n",
1494
+ " if not st or \"row_idx\" not in st:\n",
1495
+ " return \"Run a lookup first.\"\n",
1496
+ " try:\n",
1497
+ " life_row = df_eos.iloc[int(st[\"row_idx\"])]\n",
1498
+ " except Exception:\n",
1499
+ " return \"Run a lookup first.\"\n",
1500
+ "\n",
1501
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
1502
+ " repl = st.get(\"repl\", {}) or {}\n",
1503
+ " ant = st.get(\"ant\", {}) or {}\n",
1504
+ "\n",
1505
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
1506
+ " url5 = _best_effort_manufacturer_url(str(repl.get(\"repl_5g\",\"\") or \"\"), canon_make)\n",
1507
+ " return build_customer_email(life_row, status, eos, eol, repl, ant, url5)\n",
1508
+ "\n",
1509
+ "# ============================\n",
1510
+ "# Gradio callbacks\n",
1511
+ "# IMPORTANT: no dict state and ALL events have api_name=False (prevents api_info schema generation)\n",
1512
+ "# ============================\n",
1513
+ "def run_lookup(user_text: str, st_json: str):\n",
1514
+ " user_text = str(user_text or \"\").strip()\n",
1515
+ " if not user_text:\n",
1516
+ " return \"Enter a router SKU/model.\", \"\", None, None, \"\", gr.update(visible=False), gr.update(visible=False), \"{}\", \"\", \"\"\n",
1517
+ "\n",
1518
+ " res = resolve_device(user_text)\n",
1519
+ "\n",
1520
+ " if res.get(\"mode\") == \"pick\":\n",
1521
+ " opts = res.get(\"options\", [])\n",
1522
+ " choices = [o[\"label\"] for o in opts]\n",
1523
+ " st2 = {\"mode\":\"pick\",\"options\": opts, \"raw\": user_text}\n",
1524
+ " return \"Did you mean A or B? Pick one, then click Use selection.\", \"\", None, None, \"\", gr.update(choices=choices, value=None, visible=True), gr.update(visible=True), state_dump(st2), \"\", \"\"\n",
1525
+ "\n",
1526
+ " if res.get(\"mode\") != \"ok\":\n",
1527
+ " return \"Not found.\", \"\", None, None, \"\", gr.update(visible=False), gr.update(visible=False), \"{}\", \"\", \"\"\n",
1528
+ "\n",
1529
+ " life_row = df_eos.iloc[int(res[\"row_idx\"])]\n",
1530
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
1531
+ "\n",
1532
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)\n",
1533
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
1534
+ " mimo = infer_mimo_for_5g(repl.get(\"repl_5g\",\"\"))\n",
1535
+ " tech = \"5G\" if repl.get(\"repl_5g\") and repl.get(\"repl_5g\") != \"Not listed\" else (\"4G\" if device_is_4g(life_row) else \"Unknown\")\n",
1536
+ " ant = antenna_options_for(repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech, mimo)\n",
1537
+ "\n",
1538
+ " output = assemble_output(life_row, status, eos, eol, repl, ant)\n",
1539
+ " st_out = {\"row_idx\": int(res[\"row_idx\"]), \"repl\": repl, \"ant\": ant, \"raw\": user_text}\n",
1540
+ " url5 = _best_effort_manufacturer_url(repl.get('repl_5g',''), canon_make)\n",
1541
+ " link = f\"**5G manufacturer page (best effort):** {url5}\" if url5 else \"\"\n",
1542
+ " feat_df = build_replacement_features_table(repl.get('repl_4g',''), repl.get('repl_5g',''), canon_make)\n",
1543
+ " fit = build_fit_table(repl.get('repl_4g',''), repl.get('repl_5g',''), canon_make)\n",
1544
+ " return output, link, feat_df, fit, \"\", gr.update(visible=False), gr.update(visible=False), state_dump(st_out), \"\", \"\"\n",
1545
+ "\n",
1546
+ "def use_selection(selected_label: str, st_json: str):\n",
1547
+ " st = state_load(st_json)\n",
1548
+ " if not st or st.get(\"mode\") != \"pick\":\n",
1549
+ " return \"Run a search first.\", \"\", None, None, \"\", gr.update(visible=False), gr.update(visible=False), \"{}\", \"\", \"\"\n",
1550
+ "\n",
1551
+ " if not selected_label:\n",
1552
+ " return \"Pick A or B first.\", \"\", None, None, \"\", gr.update(visible=True), gr.update(visible=True), st_json, \"\", \"\"\n",
1553
+ "\n",
1554
+ " chosen_row = None\n",
1555
+ " for o in st.get(\"options\", []):\n",
1556
+ " if o.get(\"label\") == selected_label:\n",
1557
+ " chosen_row = int(o[\"row_idx\"])\n",
1558
+ " break\n",
1559
+ " if chosen_row is None:\n",
1560
+ " return \"Pick a valid option.\", \"\", None, None, \"\", gr.update(visible=True), gr.update(visible=True), st_json, \"\", \"\"\n",
1561
+ "\n",
1562
+ " life_row = df_eos.iloc[int(chosen_row)]\n",
1563
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
1564
+ "\n",
1565
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)\n",
1566
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
1567
+ " mimo = infer_mimo_for_5g(repl.get(\"repl_5g\",\"\"))\n",
1568
+ " tech = \"5G\" if repl.get(\"repl_5g\") and repl.get(\"repl_5g\") != \"Not listed\" else (\"4G\" if device_is_4g(life_row) else \"Unknown\")\n",
1569
+ " ant = antenna_options_for(repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech, mimo)\n",
1570
+ "\n",
1571
+ " output = assemble_output(life_row, status, eos, eol, repl, ant)\n",
1572
+ " st_out = {\"row_idx\": int(chosen_row), \"repl\": repl, \"ant\": ant, \"raw\": st.get(\"raw\",\"\")}\n",
1573
+ " url5 = _best_effort_manufacturer_url(repl.get('repl_5g',''), canon_make)\n",
1574
+ " link = f\"**5G manufacturer page (best effort):** {url5}\" if url5 else \"\"\n",
1575
+ " feat_df = build_replacement_features_table(repl.get('repl_4g',''), repl.get('repl_5g',''), canon_make)\n",
1576
+ " fit = build_fit_table(repl.get('repl_4g',''), repl.get('repl_5g',''), canon_make)\n",
1577
+ " return output, link, feat_df, fit, \"\", gr.update(visible=False), gr.update(visible=False), state_dump(st_out), \"\", \"\"\n",
1578
+ "\n",
1579
+ "def make_install_ready(st_json: str):\n",
1580
+ " st = state_load(st_json)\n",
1581
+ " if not st or \"row_idx\" not in st:\n",
1582
+ " return \"Run a lookup first.\"\n",
1583
+ " life_row = df_eos.iloc[int(st[\"row_idx\"])]\n",
1584
+ " current_sku = str(life_row.get(\"sku\",\"\") or \"\")\n",
1585
+ " return install_ready_checklist(current_sku, st.get(\"repl\", {}) or {}, st.get(\"ant\", {}) or {})\n",
1586
+ "\n",
1587
+ "\n",
1588
+ "\n",
1589
+ "# ============================\n",
1590
+ "# Q&A about the suggested device (post-recommendation)\n",
1591
+ "# ============================\n",
1592
+ "def answer_question(question: str, st_json: str) -> str:\n",
1593
+ " q = str(question or \"\").strip()\n",
1594
+ " if not q:\n",
1595
+ " return \"\"\n",
1596
+ " st = state_load(st_json)\n",
1597
+ " if not st or \"repl\" not in st:\n",
1598
+ " return \"Run a lookup first, then ask your question.\"\n",
1599
+ "\n",
1600
+ " repl = st.get(\"repl\", {}) or {}\n",
1601
+ " ant = st.get(\"ant\", {}) or {}\n",
1602
+ " repl5 = str(repl.get(\"repl_5g\",\"\") or \"\").strip()\n",
1603
+ " repl4 = str(repl.get(\"repl_4g\",\"\") or \"\").strip()\n",
1604
+ " # Pull a bit of dec context for the 5G model (if possible)\n",
1605
+ " canon_make = \"\"\n",
1606
+ " try:\n",
1607
+ " # Try to infer maker family from stored row_idx\n",
1608
+ " if \"row_idx\" in st:\n",
1609
+ " row = df_eos.iloc[int(st[\"row_idx\"])]\n",
1610
+ " canon_make = str(row.get(\"_canon_make\",\"UNKNOWN\"))\n",
1611
+ " except Exception:\n",
1612
+ " canon_make = \"\"\n",
1613
+ "\n",
1614
+ " # Manufacturer link (best effort)\n",
1615
+ " url5 = _best_effort_manufacturer_url(repl5, canon_make) if repl5 else \"\"\n",
1616
+ "\n",
1617
+ " # Feature table row for 5G (helps the LLM answer spec questions without web scraping)\n",
1618
+ " feat5 = {}\n",
1619
+ " try:\n",
1620
+ " feat5 = _features_from_dec(repl5, canon_make) if repl5 else {}\n",
1621
+ " except Exception:\n",
1622
+ " feat5 = {}\n",
1623
+ "\n",
1624
+ " sys = (\n",
1625
+ " \"You are a Verizon field rep assistant. Answer questions about the suggested router in a fast, practical way. \"\n",
1626
+ " \"Use the provided context; do not mention internal tools, prompts, embeddings, or databases. \"\n",
1627
+ " \"If the question is about specs and the value is unknown, say 'Not listed' and suggest checking the manufacturer page. \"\n",
1628
+ " \"Keep it concise and scannable.\"\n",
1629
+ " )\n",
1630
+ "\n",
1631
+ " context = {\n",
1632
+ " \"recommended_5g\": repl5,\n",
1633
+ " \"recommended_4g\": repl4 if repl4 and repl4.lower() != \"not applicable\" else \"\",\n",
1634
+ " \"manufacturer_link_5g\": url5,\n",
1635
+ " \"known_5g_features\": feat5,\n",
1636
+ " \"antenna_stationary\": ant.get(\"stationary_omni\", {}),\n",
1637
+ " \"antenna_vehicle\": ant.get(\"vehicle_omni\", {}),\n",
1638
+ " }\n",
1639
+ "\n",
1640
+ " user = \"Context:\\n\" + json.dumps(context, ensure_ascii=False) + \"\\n\\nQuestion:\\n\" + q\n",
1641
+ "\n",
1642
+ " ans = gpt_answer_md(sys, user, max_tokens=650)\n",
1643
+ " # Small safety fallback\n",
1644
+ " return ans if ans else \"I couldn't generate an answer right now. Try again.\"\n",
1645
+ "\n",
1646
+ "# ============================\n",
1647
+ "# UI\n",
1648
+ "# ============================\n",
1649
+ "\n",
1650
+ "\n",
1651
+ "# ============================\n",
1652
+ "# Chat helpers\n",
1653
+ "# ============================\n",
1654
+ "def _df_to_md(df: pd.DataFrame) -> str:\n",
1655
+ " if df is None or (hasattr(df, \"empty\") and df.empty):\n",
1656
+ " return \"\"\n",
1657
+ " try:\n",
1658
+ " return df.to_markdown(index=False)\n",
1659
+ " except Exception:\n",
1660
+ " cols = list(df.columns)\n",
1661
+ " lines = [\"| \" + \" | \".join(cols) + \" |\", \"| \" + \" | \".join([\"---\"]*len(cols)) + \" |\"]\n",
1662
+ " for _, r in df.iterrows():\n",
1663
+ " lines.append(\"| \" + \" | \".join([str(r.get(c,\"\")) for c in cols]) + \" |\")\n",
1664
+ " return \"\\n\".join(lines)\n",
1665
+ "\n",
1666
+ "def _extract_device_terms(msg: str) -> List[str]:\n",
1667
+ " raw = [x.strip() for x in re.split(r\"[\\n,;]+\", str(msg or \"\")) if x.strip()]\n",
1668
+ " out=[]\n",
1669
+ " for x in raw:\n",
1670
+ " if re.search(r\"\\d\", x) or re.search(r\"\\b(IBR|AER|WR|XR|IR|RUT|MBR|E\\d{3}|R\\d{3})\\b\", x, flags=re.IGNORECASE):\n",
1671
+ " out.append(x)\n",
1672
+ " return out\n",
1673
+ "\n",
1674
+ "def _looks_like_yes(msg: str) -> bool:\n",
1675
+ " return str(msg or \"\").strip().lower() in {\"yes\",\"y\",\"yeah\",\"yep\",\"sure\",\"ok\",\"okay\"}\n",
1676
+ "\n",
1677
+ "def _parse_install_mode(msg: str) -> Tuple[Optional[str], Optional[str]]:\n",
1678
+ " t = str(msg or \"\").strip().lower()\n",
1679
+ " mode = None\n",
1680
+ " detail = None\n",
1681
+ " if \"vehicle\" in t or \"mobile\" in t:\n",
1682
+ " mode = \"vehicle\"\n",
1683
+ " if \"stationary\" in t or \"fixed\" in t or \"site\" in t:\n",
1684
+ " mode = \"stationary\"\n",
1685
+ " if \"indoor\" in t:\n",
1686
+ " detail = \"indoor\"\n",
1687
+ " if \"outdoor\" in t:\n",
1688
+ " detail = \"outdoor\"\n",
1689
+ " if \"directional\" in t:\n",
1690
+ " detail = \"directional\"\n",
1691
+ " return mode, detail\n",
1692
+ "\n",
1693
+ "def _antenna_for_mode(repl5: str, canon_make: str, mode: str, detail: Optional[str]) -> Dict[str, Any]:\n",
1694
+ " mimo = \"4x4\" # rule: all 5G = 4x4\n",
1695
+ " tech = \"5G\"\n",
1696
+ " if mode == \"vehicle\":\n",
1697
+ " return antenna_options_for(repl5, tech, mimo).get(\"vehicle_omni\", {})\n",
1698
+ " if detail == \"directional\":\n",
1699
+ " return antenna_options_for(repl5 + \" directional\", tech, mimo).get(\"stationary_omni\", {})\n",
1700
+ " if detail == \"indoor\":\n",
1701
+ " return antenna_options_for(repl5 + \" indoor\", tech, mimo).get(\"stationary_omni\", {})\n",
1702
+ " return antenna_options_for(repl5, tech, mimo).get(\"stationary_omni\", {})\n",
1703
+ "\n",
1704
+ "def _make_case_key(s: str) -> str:\n",
1705
+ " s = str(s or \"\").strip()\n",
1706
+ " return re.sub(r\"\\s+\", \" \", s)[:80]\n",
1707
+ "\n",
1708
+ "with gr.Blocks(title=\"Only-Routers\") as demo:\n",
1709
+ " gr.Markdown(\"## Only-Routers\\nChat mode for Verizon reps (multiple devices per message) + Batch tab.\")\n",
1710
+ "\n",
1711
+ " state = gr.State(\"{}\")\n",
1712
+ "\n",
1713
+ " with gr.Tabs():\n",
1714
+ " with gr.Tab(\"Chat\"):\n",
1715
+ " chatbot = gr.Chatbot(label=\"Only-Routers Chat\", height=520)\n",
1716
+ " msg = gr.Textbox(label=\"Message\", placeholder=\"Example: IBR650B, WR21\\nVehicle install\", lines=2)\n",
1717
+ " send = gr.Button(\"Send\", variant=\"primary\")\n",
1718
+ "\n",
1719
+ " def chat_fn(user_msg: str, history, st_json: str):\n",
1720
+ " st = state_load(st_json)\n",
1721
+ " st.setdefault(\"cases\", {})\n",
1722
+ " st.setdefault(\"last_case_keys\", [])\n",
1723
+ " st.setdefault(\"pending\", {})\n",
1724
+ " st.setdefault(\"awaiting_questions\", False)\n",
1725
+ "\n",
1726
+ " text = (user_msg or \"\").strip()\n",
1727
+ " if not text:\n",
1728
+ " return history, state_dump(st)\n",
1729
+ "\n",
1730
+ " # Pending pick (A/B)\n",
1731
+ " if st.get(\"pending\", {}).get(\"type\") == \"pick\":\n",
1732
+ " pend = st[\"pending\"]\n",
1733
+ " opts = pend.get(\"options\", [])\n",
1734
+ " choice = text.strip().lower()\n",
1735
+ " idx = None\n",
1736
+ " if choice in {\"a\",\"1\",\"option a\"} and len(opts) >= 1:\n",
1737
+ " idx = 0\n",
1738
+ " elif choice in {\"b\",\"2\",\"option b\"} and len(opts) >= 2:\n",
1739
+ " idx = 1\n",
1740
+ " if idx is None:\n",
1741
+ " for i,o in enumerate(opts):\n",
1742
+ " if str(o.get(\"label\",\"\")).lower() in choice:\n",
1743
+ " idx = i\n",
1744
+ " break\n",
1745
+ " if idx is None:\n",
1746
+ " history.append([text, \"Please reply with **A** or **B**.\"])\n",
1747
+ " return history, state_dump(st)\n",
1748
+ "\n",
1749
+ " chosen_row = int(opts[idx][\"row_idx\"])\n",
1750
+ " life_row = df_eos.iloc[chosen_row]\n",
1751
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
1752
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)\n",
1753
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
1754
+ "\n",
1755
+ " feat_df = build_replacement_features_table(repl.get(\"repl_4g\",\"\"), repl.get(\"repl_5g\",\"\"), canon_make)\n",
1756
+ " fit_df = build_fit_table(repl.get(\"repl_4g\",\"\"), repl.get(\"repl_5g\",\"\"), canon_make)\n",
1757
+ "\n",
1758
+ " url4 = _best_effort_manufacturer_url(repl.get(\"repl_4g\",\"\"), canon_make) if repl.get(\"repl_4g\",\"\") not in {\"Not applicable\",\"\"} else \"\"\n",
1759
+ " url5 = _best_effort_manufacturer_url(repl.get(\"repl_5g\",\"\"), canon_make) if repl.get(\"repl_5g\",\"\") not in {\"Not listed\",\"\"} else \"\"\n",
1760
+ "\n",
1761
+ " case_key = _make_case_key(str(life_row.get(\"sku\",\"\")) or pend.get(\"raw\",\"\"))\n",
1762
+ " st[\"cases\"][case_key] = {\"row_idx\": chosen_row, \"repl\": repl, \"canon_make\": canon_make, \"eos\": eos, \"eol\": eol, \"status\": status, \"urls\": {\"4g\": url4, \"5g\": url5}}\n",
1763
+ " st[\"last_case_keys\"].append(case_key)\n",
1764
+ " st[\"pending\"] = {\"type\": \"install_mode\", \"case_keys\": [case_key]}\n",
1765
+ "\n",
1766
+ " bot = []\n",
1767
+ " bot.append(f\"**{case_key}**\")\n",
1768
+ " bot.append(f\"- Status: **{status}** | EOS: **{eos}** | EOL: **{eol}**\")\n",
1769
+ " bot.append(f\"- 4G alternative: **{repl.get('repl_4g','Not applicable')}**\")\n",
1770
+ " bot.append(f\"- 5G replacement: **{repl.get('repl_5g','Not listed')}**\")\n",
1771
+ " if url4:\n",
1772
+ " bot.append(f\"- 4G manufacturer page: {url4}\")\n",
1773
+ " if url5:\n",
1774
+ " bot.append(f\"- 5G manufacturer page: {url5}\")\n",
1775
+ " bot.append(\"\\n**Replacement features**\\n\" + _df_to_md(feat_df))\n",
1776
+ " bot.append(\"\\n**Verizon fit**\\n\" + _df_to_md(fit_df))\n",
1777
+ " bot.append(\"\\nFor antennas: **Vehicle/Mobile** or **Stationary**? If Stationary: **Indoor**, **Outdoor**, or **Directional**.\")\n",
1778
+ " bot.append(\"\\nAny questions about the suggested device(s)?\")\n",
1779
+ " history.append([text, \"\\n\".join(bot)])\n",
1780
+ " st[\"awaiting_questions\"] = True\n",
1781
+ " return history, state_dump(st)\n",
1782
+ "\n",
1783
+ " # Pending install mode\n",
1784
+ " if st.get(\"pending\", {}).get(\"type\") == \"install_mode\":\n",
1785
+ " mode, detail = _parse_install_mode(text)\n",
1786
+ " if mode is None:\n",
1787
+ " history.append([text, \"Quick one: **Vehicle/Mobile** or **Stationary**? If Stationary: **Indoor**, **Outdoor**, or **Directional**.\"])\n",
1788
+ " return history, state_dump(st)\n",
1789
+ "\n",
1790
+ " case_keys = st[\"pending\"].get(\"case_keys\", []) or st.get(\"last_case_keys\", [])\n",
1791
+ " updates=[]\n",
1792
+ " for ck in case_keys:\n",
1793
+ " case = st[\"cases\"].get(ck, {})\n",
1794
+ " repl5 = (case.get(\"repl\", {}) or {}).get(\"repl_5g\",\"\")\n",
1795
+ " canon_make = case.get(\"canon_make\",\"UNKNOWN\")\n",
1796
+ " ant = _antenna_for_mode(repl5, canon_make, mode, detail)\n",
1797
+ " case.setdefault(\"antennas\", {})\n",
1798
+ " case[\"antennas\"][f\"{mode}:{detail or ''}\"] = ant\n",
1799
+ " st[\"cases\"][ck] = case\n",
1800
+ " updates.append(f\"**{ck}** antenna ({mode}{' / '+detail if detail else ''}): {ant.get('name','')} (PN {ant.get('part_number','')})\")\n",
1801
+ "\n",
1802
+ " st[\"pending\"] = {}\n",
1803
+ " history.append([text, \"\\n\".join(updates)])\n",
1804
+ " return history, state_dump(st)\n",
1805
+ "\n",
1806
+ " # If user says yes to questions\n",
1807
+ " if st.get(\"awaiting_questions\") and _looks_like_yes(text):\n",
1808
+ " history.append([text, \"Ask away — what do you want to know about the suggested device(s)?\"])\n",
1809
+ " return history, state_dump(st)\n",
1810
+ "\n",
1811
+ " # Device lookup\n",
1812
+ " device_terms = _extract_device_terms(text)\n",
1813
+ " if device_terms:\n",
1814
+ " bots=[]\n",
1815
+ " new_case_keys=[]\n",
1816
+ " for term in device_terms:\n",
1817
+ " res = resolve_device(term)\n",
1818
+ " if res.get(\"mode\") == \"pick\":\n",
1819
+ " st[\"pending\"] = {\"type\":\"pick\", \"options\": res.get(\"options\", []), \"raw\": term}\n",
1820
+ " opts = res.get(\"options\", [])\n",
1821
+ " bot = \"I found more than one close match. Reply **A** or **B**:\\n\"\n",
1822
+ " for i,o in enumerate(opts):\n",
1823
+ " bot += f\"- **{'A' if i==0 else 'B'}**: {o.get('label','')}\\n\"\n",
1824
+ " history.append([text, bot.strip()])\n",
1825
+ " return history, state_dump(st)\n",
1826
+ " if res.get(\"mode\") != \"ok\":\n",
1827
+ " bots.append(f\"**{term}**: not found in lifecycle list. Who makes it (manufacturer) and what's the exact model/SKU?\")\n",
1828
+ " continue\n",
1829
+ "\n",
1830
+ " life_row = df_eos.iloc[int(res[\"row_idx\"])]\n",
1831
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
1832
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)\n",
1833
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
1834
+ "\n",
1835
+ " feat_df = build_replacement_features_table(repl.get(\"repl_4g\",\"\"), repl.get(\"repl_5g\",\"\"), canon_make)\n",
1836
+ " fit_df = build_fit_table(repl.get(\"repl_4g\",\"\"), repl.get(\"repl_5g\",\"\"), canon_make)\n",
1837
+ "\n",
1838
+ " url4 = _best_effort_manufacturer_url(repl.get(\"repl_4g\",\"\"), canon_make) if repl.get(\"repl_4g\",\"\") not in {\"Not applicable\",\"\"} else \"\"\n",
1839
+ " url5 = _best_effort_manufacturer_url(repl.get(\"repl_5g\",\"\"), canon_make) if repl.get(\"repl_5g\",\"\") not in {\"Not listed\",\"\"} else \"\"\n",
1840
+ "\n",
1841
+ " ck = _make_case_key(str(life_row.get(\"sku\",\"\")) or term)\n",
1842
+ " st[\"cases\"][ck] = {\"row_idx\": int(res[\"row_idx\"]), \"repl\": repl, \"canon_make\": canon_make, \"eos\": eos, \"eol\": eol, \"status\": status, \"urls\": {\"4g\": url4, \"5g\": url5}}\n",
1843
+ " st[\"last_case_keys\"].append(ck)\n",
1844
+ " new_case_keys.append(ck)\n",
1845
+ "\n",
1846
+ " bot=[]\n",
1847
+ " bot.append(f\"**{ck}**\")\n",
1848
+ " bot.append(f\"- Status: **{status}** | EOS: **{eos}** | EOL: **{eol}**\")\n",
1849
+ " bot.append(f\"- 4G alternative: **{repl.get('repl_4g','Not applicable')}**\")\n",
1850
+ " bot.append(f\"- 5G replacement: **{repl.get('repl_5g','Not listed')}**\")\n",
1851
+ " if url4:\n",
1852
+ " bot.append(f\"- 4G manufacturer page: {url4}\")\n",
1853
+ " if url5:\n",
1854
+ " bot.append(f\"- 5G manufacturer page: {url5}\")\n",
1855
+ " bot.append(\"\\n**Replacement features**\\n\" + _df_to_md(feat_df))\n",
1856
+ " bot.append(\"\\n**Verizon fit**\\n\" + _df_to_md(fit_df))\n",
1857
+ " bots.append(\"\\n\".join(bot))\n",
1858
+ "\n",
1859
+ " if new_case_keys:\n",
1860
+ " st[\"pending\"] = {\"type\":\"install_mode\", \"case_keys\": new_case_keys}\n",
1861
+ " bots.append(\"\\nFor antennas: **Vehicle/Mobile** or **Stationary**? If Stationary: **Indoor**, **Outdoor**, or **Directional**.\")\n",
1862
+ " bots.append(\"Any questions about the suggested device(s)?\")\n",
1863
+ " st[\"awaiting_questions\"] = True\n",
1864
+ "\n",
1865
+ " history.append([text, \"\\n\\n---\\n\\n\".join(bots)])\n",
1866
+ " return history, state_dump(st)\n",
1867
+ "\n",
1868
+ " # Treat as question about most recent case\n",
1869
+ " last_keys = st.get(\"last_case_keys\", [])\n",
1870
+ " if not last_keys:\n",
1871
+ " history.append([text, \"Tell me the router model/SKU you’re working with (you can paste multiple).\"])\n",
1872
+ " return history, state_dump(st)\n",
1873
+ "\n",
1874
+ " ck = last_keys[-1]\n",
1875
+ " case = st[\"cases\"].get(ck, {})\n",
1876
+ " mini = {\"row_idx\": case.get(\"row_idx\"), \"repl\": case.get(\"repl\", {}), \"ant\": case.get(\"antennas\", {})}\n",
1877
+ " ans = answer_question(text, state_dump(mini))\n",
1878
+ " history.append([text, ans])\n",
1879
+ " return history, state_dump(st)\n",
1880
+ "\n",
1881
+ " send.click(fn=chat_fn, inputs=[msg, chatbot, state], outputs=[chatbot, state], api_name=False)\n",
1882
+ "\n",
1883
+ " with gr.Tab(\"Batch\"):\n",
1884
+ " gr.Markdown(\"Paste one per line or upload a CSV (first column). Batch runs fast (no GPT).\")\n",
1885
+ " batch_text = gr.Textbox(label=\"Paste devices (one per line)\", lines=8, placeholder=\"WR21\\nRUT240\\nIBR650B\")\n",
1886
+ " batch_file = gr.File(label=\"Upload CSV\", file_types=[\".csv\"])\n",
1887
+ " include_ant = gr.Checkbox(label=\"Include antenna picks (slower)\", value=False)\n",
1888
+ " run_btn = gr.Button(\"Run batch\", variant=\"primary\")\n",
1889
+ "\n",
1890
+ " summary_md = gr.Markdown()\n",
1891
+ " rollup_md = gr.Markdown()\n",
1892
+ " table = gr.Dataframe(interactive=False, wrap=True)\n",
1893
+ " dl = gr.File(label=\"Download results CSV\")\n",
1894
+ "\n",
1895
+ " run_btn.click(fn=run_batch, inputs=[batch_text, batch_file, include_ant], outputs=[summary_md, table, dl, rollup_md], api_name=False)\n",
1896
+ "\n",
1897
+ "demo.launch(show_api=False)\n"
1898
+ ]
1899
+ }
1900
+ ],
1901
+ "metadata": {
1902
+ "kernelspec": {
1903
+ "display_name": "Python 3",
1904
+ "name": "python3"
1905
+ },
1906
+ "language_info": {
1907
+ "name": "python"
1908
+ }
1909
+ },
1910
+ "nbformat": 4,
1911
+ "nbformat_minor": 5
1912
+ }
Updates/only-routers_ai_poc_hf_fixed_v10_3.ipynb ADDED
@@ -0,0 +1,1699 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "bb819d45",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Only-Routers (v10.3)\n",
9
+ "\n",
10
+ "4G feature row now uses manufacturer page fetch + GPT fill (same as 5G)."
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "code",
15
+ "execution_count": null,
16
+ "id": "cf67e3c9",
17
+ "metadata": {},
18
+ "outputs": [],
19
+ "source": [
20
+ "import os\n",
21
+ "import re\n",
22
+ "import json\n",
23
+ "import math\n",
24
+ "import hashlib\n",
25
+ "import tempfile\n",
26
+ "from dataclasses import dataclass\n",
27
+ "from datetime import datetime, date\n",
28
+ "from typing import Any, Dict, List, Optional, Tuple\n",
29
+ "\n",
30
+ "import numpy as np\n",
31
+ "import pandas as pd\n",
32
+ "\n",
33
+ "import fitz # PyMuPDF\n",
34
+ "import faiss\n",
35
+ "from sentence_transformers import SentenceTransformer\n",
36
+ "from rapidfuzz import fuzz, process\n",
37
+ "\n",
38
+ "import gradio as gr\n",
39
+ "from openai import OpenAI\n",
40
+ "\n",
41
+ "\n",
42
+ "# ============================\n",
43
+ "# Settings\n",
44
+ "# ============================\n",
45
+ "TODAY = date(2026, 1, 18)\n",
46
+ "OPENAI_MODEL = \"gpt-5.2\"\n",
47
+ "OPENAI_REASONING = {\"effort\": \"high\"}\n",
48
+ "MATCH_OK = 80\n",
49
+ "\n",
50
+ "EMBED_MODEL_NAME = \"sentence-transformers/all-MiniLM-L6-v2\"\n",
51
+ "PARSEC_CONTEXT_BEFORE = 900\n",
52
+ "PARSEC_CONTEXT_AFTER = 1600\n",
53
+ "\n",
54
+ "\n",
55
+ "# ============================\n",
56
+ "# OpenAI client (HF Space secret: OPENAI_API_KEY)\n",
57
+ "# ============================\n",
58
+ "API_KEY = os.getenv(\"OPENAI_API_KEY\", \"\").strip()\n",
59
+ "client = OpenAI(api_key=API_KEY) if API_KEY else None\n",
60
+ "\n",
61
+ "# ----------------------------\n",
62
+ "# Gradio state helpers\n",
63
+ "# Keep state as a JSON STRING to avoid schema issues on Hugging Face.\n",
64
+ "# ----------------------------\n",
65
+ "def state_load(st_json: str) -> Dict[str, Any]:\n",
66
+ " try:\n",
67
+ " if not st_json:\n",
68
+ " return {}\n",
69
+ " return json.loads(st_json) if isinstance(st_json, str) else {}\n",
70
+ " except Exception:\n",
71
+ " return {}\n",
72
+ "\n",
73
+ "def state_dump(st: Dict[str, Any]) -> str:\n",
74
+ " try:\n",
75
+ " return json.dumps(st or {}, ensure_ascii=False)\n",
76
+ " except Exception:\n",
77
+ " return \"{}\"\n",
78
+ "\n",
79
+ "\n",
80
+ "\n",
81
+ "# ============================\n",
82
+ "# Helpers\n",
83
+ "# ============================\n",
84
+ "def norm_text(s: Any) -> str:\n",
85
+ " try:\n",
86
+ " if s is None or (isinstance(s, float) and math.isnan(s)) or pd.isna(s):\n",
87
+ " return \"\"\n",
88
+ " except Exception:\n",
89
+ " pass\n",
90
+ " s = str(s).strip().lower()\n",
91
+ " s = re.sub(r\"[^a-z0-9\\s\\-\\/]\", \" \", s)\n",
92
+ " s = re.sub(r\"\\s+\", \" \", s).strip()\n",
93
+ " return s\n",
94
+ "\n",
95
+ "def safe_str(v: Any) -> str:\n",
96
+ " if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):\n",
97
+ " return \"\"\n",
98
+ " return str(v).strip()\n",
99
+ "\n",
100
+ "def is_5g(modem_type: Any) -> bool:\n",
101
+ " s = norm_text(modem_type)\n",
102
+ " return (\"5g\" in s) or (\"nr\" in s)\n",
103
+ "\n",
104
+ "def json_load_safe(s: str) -> Dict[str, Any]:\n",
105
+ " try:\n",
106
+ " return json.loads(s)\n",
107
+ " except Exception:\n",
108
+ " return {}\n",
109
+ "\n",
110
+ "def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 600) -> Dict[str, Any]:\n",
111
+ " if client is None:\n",
112
+ " return {}\n",
113
+ " resp = client.responses.create(\n",
114
+ " model=OPENAI_MODEL,\n",
115
+ " reasoning=OPENAI_REASONING,\n",
116
+ " input=[{\"role\":\"system\",\"content\":system},{\"role\":\"user\",\"content\":json.dumps(payload)}],\n",
117
+ " max_output_tokens=max_tokens,\n",
118
+ " )\n",
119
+ " return json_load_safe(getattr(resp, \"output_text\", \"\") or \"\")\n",
120
+ "\n",
121
+ "\n",
122
+ "def gpt_answer_md(system: str, user: str, max_tokens: int = 650) -> str:\n",
123
+ " \"\"\"Return a rep-friendly markdown answer.\"\"\"\n",
124
+ " if client is None:\n",
125
+ " return \"No API key is configured, so I can't answer detailed questions right now.\"\n",
126
+ " resp = client.responses.create(\n",
127
+ " model=OPENAI_MODEL,\n",
128
+ " reasoning=OPENAI_REASONING,\n",
129
+ " input=[\n",
130
+ " {\"role\": \"system\", \"content\": system},\n",
131
+ " {\"role\": \"user\", \"content\": user},\n",
132
+ " ],\n",
133
+ " max_output_tokens=max_tokens,\n",
134
+ " )\n",
135
+ " return (getattr(resp, \"output_text\", \"\") or \"\").strip()\n",
136
+ "\n",
137
+ "\n",
138
+ "# ============================\n",
139
+ "# Load data\n",
140
+ "# ============================\n",
141
+ "EOS_PATH = \"routers_eos_eol_by_sku.csv\"\n",
142
+ "DEC_PATH = \"dec2025routers.csv\"\n",
143
+ "PARSEC_PDF = \"ParsecCatalog.pdf\"\n",
144
+ "\n",
145
+ "if not os.path.exists(EOS_PATH):\n",
146
+ " raise FileNotFoundError(f\"Missing {EOS_PATH} in repo.\")\n",
147
+ "if not os.path.exists(DEC_PATH):\n",
148
+ " raise FileNotFoundError(f\"Missing {DEC_PATH} in repo.\")\n",
149
+ "if not os.path.exists(PARSEC_PDF):\n",
150
+ " raise FileNotFoundError(f\"Missing {PARSEC_PDF} in repo.\")\n",
151
+ "\n",
152
+ "df_eos = pd.read_csv(EOS_PATH).copy()\n",
153
+ "df_dec = pd.read_csv(DEC_PATH).copy()\n",
154
+ "\n",
155
+ "\n",
156
+ "def _canonize_eos_columns(df: pd.DataFrame) -> pd.DataFrame:\n",
157
+ " \"\"\"Normalize lifecycle CSV column names (case-insensitive) and create expected columns.\"\"\"\n",
158
+ " # Map various header spellings to canonical names used by the app\n",
159
+ " mapping = {}\n",
160
+ " for c in df.columns:\n",
161
+ " k = str(c).strip().lower().replace(\" \", \"_\")\n",
162
+ " if k in {\"sku\", \"model\", \"device\", \"device_sku\"}:\n",
163
+ " mapping[c] = \"sku\"\n",
164
+ " elif k in {\"manufacturer\", \"make\", \"vendor\"}:\n",
165
+ " mapping[c] = \"manufacturer\"\n",
166
+ " elif k in {\"device_type\", \"type\"}:\n",
167
+ " mapping[c] = \"device_type\"\n",
168
+ " elif k in {\"end_of_sale\", \"eos\", \"end_sale\", \"end_of_sales\"}:\n",
169
+ " mapping[c] = \"end_of_sale\"\n",
170
+ " elif k in {\"end_of_life\", \"eol\", \"end_life\"}:\n",
171
+ " mapping[c] = \"end_of_life\"\n",
172
+ " elif k in {\"suggested_replacement\", \"replacement_4g\", \"lte_replacement\", \"replacement_lte\", \"replacement\"}:\n",
173
+ " mapping[c] = \"suggested_replacement\"\n",
174
+ " elif k in {\"advanced_5g_option\", \"replacement_5g\", \"fiveg_replacement\", \"5g_replacement\", \"upgrade_5g\"}:\n",
175
+ " mapping[c] = \"advanced_5g_option\"\n",
176
+ " elif k in {\"region\", \"market\"}:\n",
177
+ " mapping[c] = \"region\"\n",
178
+ " elif k in {\"notes\", \"note\"}:\n",
179
+ " mapping[c] = \"notes\"\n",
180
+ " elif k in {\"description\", \"device_description\", \"name\"}:\n",
181
+ " mapping[c] = \"description\"\n",
182
+ "\n",
183
+ " df = df.rename(columns=mapping).copy()\n",
184
+ "\n",
185
+ " # Create expected columns if missing\n",
186
+ " if \"sku\" not in df.columns:\n",
187
+ " # Try the common capitalized header as a fallback\n",
188
+ " if \"SKU\" in df.columns:\n",
189
+ " df[\"sku\"] = df[\"SKU\"].astype(str)\n",
190
+ " else:\n",
191
+ " df[\"sku\"] = \"\"\n",
192
+ "\n",
193
+ " if \"manufacturer\" not in df.columns:\n",
194
+ " df[\"manufacturer\"] = \"\"\n",
195
+ "\n",
196
+ " if \"device_type\" not in df.columns:\n",
197
+ " df[\"device_type\"] = \"\"\n",
198
+ "\n",
199
+ " if \"description\" not in df.columns:\n",
200
+ " # If the simplified file removed description, use SKU as description (still searchable)\n",
201
+ " df[\"description\"] = df[\"sku\"].astype(str)\n",
202
+ "\n",
203
+ " if \"notes\" not in df.columns:\n",
204
+ " df[\"notes\"] = \"\"\n",
205
+ "\n",
206
+ " if \"region\" not in df.columns:\n",
207
+ " df[\"region\"] = \"\"\n",
208
+ "\n",
209
+ " if \"suggested_replacement\" not in df.columns:\n",
210
+ " df[\"suggested_replacement\"] = \"\"\n",
211
+ "\n",
212
+ " if \"advanced_5g_option\" not in df.columns:\n",
213
+ " df[\"advanced_5g_option\"] = \"\"\n",
214
+ "\n",
215
+ " if \"end_of_sale\" not in df.columns:\n",
216
+ " df[\"end_of_sale\"] = \"\"\n",
217
+ "\n",
218
+ " if \"end_of_life\" not in df.columns:\n",
219
+ " df[\"end_of_life\"] = \"\"\n",
220
+ "\n",
221
+ " return df\n",
222
+ "\n",
223
+ "df_eos = _canonize_eos_columns(df_eos)\n",
224
+ "\n",
225
+ "\n",
226
+ "def region_ok(x: Any) -> bool:\n",
227
+ " s = str(x or \"\").strip().lower()\n",
228
+ " if not s:\n",
229
+ " return True\n",
230
+ " if \"not specified\" in s:\n",
231
+ " return True\n",
232
+ " if \"north america\" in s:\n",
233
+ " return True\n",
234
+ " if re.search(r\"\\busa\\b\", s):\n",
235
+ " return True\n",
236
+ " if re.search(r\"\\bunited\\s+states\\b\", s):\n",
237
+ " return True\n",
238
+ " if re.search(r\"\\bu\\.?s\\.?\\b\", s):\n",
239
+ " return True\n",
240
+ " return False\n",
241
+ "\n",
242
+ "if \"region\" in df_eos.columns:\n",
243
+ " df_eos = df_eos[df_eos[\"region\"].apply(region_ok)].reset_index(drop=True)\n",
244
+ "\n",
245
+ "# Maker mapping (includes Teltonika)\n",
246
+ "CANON_MAKER = {\n",
247
+ " \"CRADLEPOINT\": {\"cradlepoint\", \"ericsson\", \"ericsson enterprise wireless\"},\n",
248
+ " \"SIERRA\": {\"sierra\", \"sierra wireless\", \"semtech\", \"airlink\"},\n",
249
+ " \"FEENEY\": {\"feeney\", \"feeney wireless\", \"inseego\"},\n",
250
+ " \"DIGI\": {\"digi\", \"accelerated\", \"accelerated concepts\"},\n",
251
+ " \"CISCO_MERAKI\": {\"meraki\", \"cisco meraki\"},\n",
252
+ " \"CISCO\": {\"cisco\"},\n",
253
+ " \"TELTONIKA\": {\"teltonika\"},\n",
254
+ "}\n",
255
+ "\n",
256
+ "def canon_maker_from_text(s: Any) -> str:\n",
257
+ " t = norm_text(s)\n",
258
+ " for canon, terms in CANON_MAKER.items():\n",
259
+ " for term in terms:\n",
260
+ " if term in t:\n",
261
+ " return canon\n",
262
+ " return \"UNKNOWN\"\n",
263
+ "\n",
264
+ "df_eos[\"_canon_make\"] = df_eos[\"manufacturer\"].apply(canon_maker_from_text) if \"manufacturer\" in df_eos.columns else \"UNKNOWN\"\n",
265
+ "df_eos[\"_norm_sku\"] = df_eos[\"sku\"].apply(norm_text) if \"sku\" in df_eos.columns else \"\"\n",
266
+ "df_eos[\"_norm_desc\"] = df_eos[\"description\"].apply(norm_text) if \"description\" in df_eos.columns else \"\"\n",
267
+ "df_eos[\"_norm_notes\"] = df_eos[\"notes\"].apply(norm_text) if \"notes\" in df_eos.columns else \"\"\n",
268
+ "\n",
269
+ "df_dec[\"_canon_make\"] = df_dec[\"Make\"].apply(canon_maker_from_text) if \"Make\" in df_dec.columns else \"UNKNOWN\"\n",
270
+ "df_dec[\"_norm_model\"] = df_dec[\"Model\"].apply(norm_text) if \"Model\" in df_dec.columns else \"\"\n",
271
+ "df_dec[\"_is5g\"] = df_dec[\"Modem Type\"].apply(is_5g) if \"Modem Type\" in df_dec.columns else False\n",
272
+ "\n",
273
+ "\n",
274
+ "# ============================\n",
275
+ "# Date helpers\n",
276
+ "# ============================\n",
277
+ "@dataclass\n",
278
+ "class ParsedDate:\n",
279
+ " raw: str\n",
280
+ " kind: str\n",
281
+ " value: Optional[date]\n",
282
+ "\n",
283
+ "def parse_date_field(x: Any) -> ParsedDate:\n",
284
+ " raw = str(x or \"\").strip()\n",
285
+ " if not raw:\n",
286
+ " return ParsedDate(raw=\"\", kind=\"missing\", value=None)\n",
287
+ "\n",
288
+ " # Common US formats: M/D/YY or M/D/YYYY (e.g., 6/24/24, 9/30/21)\n",
289
+ " for fmt in (\"%m/%d/%y\", \"%m/%d/%Y\", \"%-m/%-d/%y\", \"%-m/%-d/%Y\"):\n",
290
+ " try:\n",
291
+ " dt = datetime.strptime(raw, fmt).date()\n",
292
+ " return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
293
+ " except Exception:\n",
294
+ " pass\n",
295
+ "\n",
296
+ " # ISO-ish: YYYY\n",
297
+ " if re.fullmatch(r\"\\d{4}\", raw):\n",
298
+ " y = int(raw)\n",
299
+ " if y == TODAY.year:\n",
300
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
301
+ " if y < TODAY.year:\n",
302
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
303
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 12, 31))\n",
304
+ "\n",
305
+ " # YYYY-MM\n",
306
+ " if re.fullmatch(r\"\\d{4}-\\d{2}\", raw):\n",
307
+ " try:\n",
308
+ " y, m = raw.split(\"-\")\n",
309
+ " return ParsedDate(raw=raw, kind=\"year_month\", value=date(int(y), int(m), 1))\n",
310
+ " except Exception:\n",
311
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
312
+ "\n",
313
+ " # YYYY-MM-DD\n",
314
+ " if re.fullmatch(r\"\\d{4}-\\d{2}-\\d{2}\", raw):\n",
315
+ " try:\n",
316
+ " dt = datetime.strptime(raw, \"%Y-%m-%d\").date()\n",
317
+ " return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
318
+ " except Exception:\n",
319
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
320
+ "\n",
321
+ " # Last resort: leave as raw (unparsed)\n",
322
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
323
+ "\n",
324
+ " if re.fullmatch(r\"\\d{4}-\\d{2}-\\d{2}\", raw):\n",
325
+ " try:\n",
326
+ " dt = datetime.strptime(raw, \"%Y-%m-%d\").date()\n",
327
+ " return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
328
+ " except Exception:\n",
329
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
330
+ "\n",
331
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
332
+ "\n",
333
+ "def display_date(pd_: ParsedDate) -> str:\n",
334
+ " if pd_.kind == \"missing\":\n",
335
+ " return \"Not listed\"\n",
336
+ " if pd_.kind == \"bad\":\n",
337
+ " return pd_.raw or \"Not listed\"\n",
338
+ " return pd_.raw\n",
339
+ "\n",
340
+ "def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:\n",
341
+ " if eos.value is None and eol.value is None:\n",
342
+ " return \"Unknown\"\n",
343
+ " if eol.value is not None and eol.value <= TODAY:\n",
344
+ " return \"End of Life\"\n",
345
+ " if eos.value is not None and eos.value <= TODAY:\n",
346
+ " return \"End of Sale\"\n",
347
+ " return \"Active\"\n",
348
+ "\n",
349
+ "def row_to_dates_and_status(row: pd.Series) -> Tuple[str, str, str]:\n",
350
+ " eos = parse_date_field(row.get(\"end_of_sale\"))\n",
351
+ " eol = parse_date_field(row.get(\"end_of_life\"))\n",
352
+ " return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)\n",
353
+ "\n",
354
+ "\n",
355
+ "# ============================\n",
356
+ "# Embeddings + Parsec index\n",
357
+ "# ============================\n",
358
+ "embedder = SentenceTransformer(EMBED_MODEL_NAME)\n",
359
+ "\n",
360
+ "def extract_pdf_text_pages(path: str) -> List[str]:\n",
361
+ " doc = fitz.open(path)\n",
362
+ " return [doc[i].get_text(\"text\") for i in range(len(doc))]\n",
363
+ "\n",
364
+ "def build_parsec_cards(pages: List[str]) -> List[str]:\n",
365
+ " cards = []\n",
366
+ " for p in pages:\n",
367
+ " for m in re.finditer(r\"Standard\\s+SKU:\", p):\n",
368
+ " start = max(0, m.start() - PARSEC_CONTEXT_BEFORE)\n",
369
+ " end = min(len(p), m.start() + PARSEC_CONTEXT_AFTER)\n",
370
+ " c = p[start:end].strip()\n",
371
+ " if len(c) >= 200:\n",
372
+ " cards.append(c)\n",
373
+ " out, seen = [], set()\n",
374
+ " for c in cards:\n",
375
+ " h = hashlib.sha1(c.encode(\"utf-8\")).hexdigest()\n",
376
+ " if h not in seen:\n",
377
+ " seen.add(h); out.append(c)\n",
378
+ " return out\n",
379
+ "\n",
380
+ "parsec_cards = build_parsec_cards(extract_pdf_text_pages(PARSEC_PDF))\n",
381
+ "parsec_emb = embedder.encode(parsec_cards, batch_size=64, show_progress_bar=False, normalize_embeddings=True)\n",
382
+ "parsec_emb = np.asarray(parsec_emb, dtype=np.float32)\n",
383
+ "parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])\n",
384
+ "parsec_index.add(parsec_emb)\n",
385
+ "\n",
386
+ "\n",
387
+ "# ============================\n",
388
+ "# Device resolution\n",
389
+ "# ============================\n",
390
+ "def label_for_row(i: int) -> str:\n",
391
+ " r = df_eos.iloc[i]\n",
392
+ " return f\"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}\"[:220]\n",
393
+ "\n",
394
+ "EOS_LABELS = [label_for_row(i) for i in range(len(df_eos))]\n",
395
+ "EOS_CORPUS = []\n",
396
+ "for _, r in df_eos.iterrows():\n",
397
+ " EOS_CORPUS.append(\" \".join([r.get(\"_norm_sku\",\"\"), r.get(\"_canon_make\",\"\"), r.get(\"_norm_desc\",\"\"), r.get(\"_norm_notes\",\"\")]))\n",
398
+ "\n",
399
+ "def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int, int, str]]:\n",
400
+ " q = norm_text(query)\n",
401
+ " hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)\n",
402
+ " return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]\n",
403
+ "\n",
404
+ "def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> Dict[str, Any]:\n",
405
+ " if client is None:\n",
406
+ " return {}\n",
407
+ " sys = \"Pick which router the user meant. Never invent. Return strict JSON only.\"\n",
408
+ " payload = {\n",
409
+ " \"user_input\": user_text,\n",
410
+ " \"candidates\": [{\"row_idx\": i, \"score\": s, \"label\": lbl} for (i,s,lbl) in candidates],\n",
411
+ " \"rules\": [\n",
412
+ " \"If one is clearly correct, return mode='ok' with row_idx.\",\n",
413
+ " \"If two are plausible, return mode='pick' with top 2 options.\"\n",
414
+ " ],\n",
415
+ " \"output_schema\": {\"mode\":\"ok|pick\",\"row_idx\":\"int\",\"options\":[{\"row_idx\":\"int\",\"label\":\"string\"}]}\n",
416
+ " }\n",
417
+ " return gpt_json(sys, payload, max_tokens=280)\n",
418
+ "\n",
419
+ "def resolve_device(user_text: str) -> Dict[str, Any]:\n",
420
+ " q = norm_text(user_text)\n",
421
+ " exact = df_eos.index[df_eos[\"_norm_sku\"] == q].tolist()\n",
422
+ " if len(exact) == 1:\n",
423
+ " return {\"mode\":\"ok\",\"row_idx\": int(exact[0])}\n",
424
+ " if len(exact) > 1:\n",
425
+ " opts = [{\"row_idx\": int(i), \"label\": EOS_LABELS[int(i)]} for i in exact[:2]]\n",
426
+ " return {\"mode\":\"pick\",\"options\": opts}\n",
427
+ "\n",
428
+ " cands = local_candidates(user_text, top_k=6)\n",
429
+ " if not cands:\n",
430
+ " return {\"mode\":\"not_found\"}\n",
431
+ "\n",
432
+ " if cands[0][1] >= 95 and (len(cands) == 1 or (cands[0][1] - cands[1][1]) >= 8):\n",
433
+ " return {\"mode\":\"ok\",\"row_idx\": cands[0][0]}\n",
434
+ "\n",
435
+ " g = gpt_choose_device(user_text, cands)\n",
436
+ " if g.get(\"mode\") == \"ok\" and isinstance(g.get(\"row_idx\"), int):\n",
437
+ " return {\"mode\":\"ok\",\"row_idx\": int(g[\"row_idx\"])}\n",
438
+ "\n",
439
+ " if g.get(\"mode\") == \"pick\":\n",
440
+ " opts = g.get(\"options\", []) or []\n",
441
+ " opts2 = [{\"row_idx\": int(o[\"row_idx\"]), \"label\": str(o[\"label\"])} for o in opts[:2] if \"row_idx\" in o]\n",
442
+ " if opts2:\n",
443
+ " return {\"mode\":\"pick\",\"options\": opts2}\n",
444
+ "\n",
445
+ " if len(cands) > 1:\n",
446
+ " return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]},{\"row_idx\":cands[1][0],\"label\":cands[1][2]}]}\n",
447
+ " return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]}]}\n",
448
+ "\n",
449
+ "\n",
450
+ "# ============================\n",
451
+ "# Replacements — lifecycle CSV source of truth\n",
452
+ "# ============================\n",
453
+ "def extract_model_token(text: str) -> str:\n",
454
+ " s = safe_str(text)\n",
455
+ " if not s:\n",
456
+ " return \"\"\n",
457
+ " parts = [p.strip() for p in s.split(\"|\") if p.strip()]\n",
458
+ " candidates = parts[::-1] if parts else [s]\n",
459
+ " for cand in candidates:\n",
460
+ " m = re.search(r\"\\bRUT[A-Z]?\\d{2,4}\\b\", cand.upper())\n",
461
+ " if m:\n",
462
+ " return m.group(0).upper()\n",
463
+ " m = re.search(r\"\\bIX\\d{2}\\b\", cand, flags=re.IGNORECASE)\n",
464
+ " if m:\n",
465
+ " return m.group(0).upper()\n",
466
+ " m = re.search(r\"\\b(R\\d{3,4}|E\\d{3,4}|S\\d{3,4})\\b\", cand, flags=re.IGNORECASE)\n",
467
+ " if m:\n",
468
+ " return m.group(0).upper()\n",
469
+ " m = re.search(r\"\\b[A-Z]{1,6}\\d{2,4}[A-Z]?\\b\", cand.upper())\n",
470
+ " if m:\n",
471
+ " return m.group(0).upper()\n",
472
+ " return candidates[0][:60]\n",
473
+ "\n",
474
+ "def device_is_4g(row: pd.Series) -> bool:\n",
475
+ " # Detect LTE/4G even when the description uses \"Cat 4 / Cat6 / Cat 12\" without saying \"LTE\"\n",
476
+ " t = norm_text(row.get(\"description\",\"\")) + \" \" + norm_text(row.get(\"notes\",\"\")) + \" \" + norm_text(row.get(\"sku\",\"\"))\n",
477
+ "\n",
478
+ " # If it explicitly says 5G/NR, treat as not 4G-only\n",
479
+ " if (\"5g\" in t) or (\"nr\" in t):\n",
480
+ " return False\n",
481
+ "\n",
482
+ " # Classic signals\n",
483
+ " if (\"lte\" in t) or (\"4g\" in t):\n",
484
+ " return True\n",
485
+ "\n",
486
+ " # LTE category signals (Cat 1..20 are LTE categories; Cat M1/M2 are LTE-M)\n",
487
+ " if re.search(r\"\\bcat\\s*[-]?\\s*(m1|m2)\\b\", t):\n",
488
+ " return True\n",
489
+ "\n",
490
+ " m = re.search(r\"\\bcat\\s*[-]?\\s*(\\d{1,2})\\b\", t)\n",
491
+ " if m:\n",
492
+ " try:\n",
493
+ " cat = int(m.group(1))\n",
494
+ " if 0 < cat <= 20:\n",
495
+ " return True\n",
496
+ " except Exception:\n",
497
+ " pass\n",
498
+ "\n",
499
+ " # If \"cat\" appears at all, it's almost always LTE-family\n",
500
+ " if \"cat\" in t:\n",
501
+ " return True\n",
502
+ "\n",
503
+ " return False\n",
504
+ "\n",
505
+ " # If it explicitly says 5G/NR, treat as not 4G-only\n",
506
+ " if (\"5g\" in t) or (\"nr\" in t):\n",
507
+ " return False\n",
508
+ "\n",
509
+ " # Classic signals\n",
510
+ " if (\"lte\" in t) or (\"4g\" in t):\n",
511
+ " return True\n",
512
+ "\n",
513
+ " # LTE category signals (Cat 1..20 are LTE categories; Cat M1/M2 are LTE-M)\n",
514
+ " if re.search(r\"\\bcat\\s*[-]?\\s*(m1|m2)\\b\", t):\n",
515
+ " return True\n",
516
+ "\n",
517
+ " m = re.search(r\"\\bcat\\s*[-]?\\s*(\\d{1,2})\\b\", t)\n",
518
+ " if m:\n",
519
+ " try:\n",
520
+ " cat = int(m.group(1))\n",
521
+ " if 0 < cat <= 20:\n",
522
+ " return True\n",
523
+ " except Exception:\n",
524
+ " pass\n",
525
+ "\n",
526
+ " # If \"cat\" appears at all, it's almost always LTE-family\n",
527
+ " if \"cat\" in t:\n",
528
+ " return True\n",
529
+ "\n",
530
+ " return False\n",
531
+ "\n",
532
+ "\n",
533
+ "def candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
534
+ " mfr = norm_text(manufacturer)\n",
535
+ " pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
536
+ " vals = pool[\"advanced_5g_option\"].tolist() if \"advanced_5g_option\" in pool.columns else []\n",
537
+ " out, seen = [], set()\n",
538
+ " for v in vals:\n",
539
+ " tok = extract_model_token(v)\n",
540
+ " if tok and tok.lower() != \"nan\" and tok not in seen:\n",
541
+ " seen.add(tok); out.append(tok)\n",
542
+ " return out\n",
543
+ "\n",
544
+ "def candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
545
+ " mfr = norm_text(manufacturer)\n",
546
+ " pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
547
+ " vals = pool[\"suggested_replacement\"].tolist() if \"suggested_replacement\" in pool.columns else []\n",
548
+ " out, seen = [], set()\n",
549
+ " for v in vals:\n",
550
+ " tok = extract_model_token(v)\n",
551
+ " if tok and tok.lower() != \"nan\" and tok not in seen:\n",
552
+ " seen.add(tok); out.append(tok)\n",
553
+ " return out\n",
554
+ "\n",
555
+ "def gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:\n",
556
+ " if client is None or not candidates:\n",
557
+ " return \"\"\n",
558
+ " sys = \"Pick the best replacement model. Choose only from candidates. Return strict JSON only.\"\n",
559
+ " payload = {\n",
560
+ " \"old_device\": {\n",
561
+ " \"sku\": str(old_row.get(\"sku\",\"\")),\n",
562
+ " \"manufacturer\": str(old_row.get(\"manufacturer\",\"\")),\n",
563
+ " \"description\": str(old_row.get(\"description\",\"\")),\n",
564
+ " \"need\": need,\n",
565
+ " },\n",
566
+ " \"candidates\": candidates[:40],\n",
567
+ " \"output_schema\": {\"choice\":\"string\"}\n",
568
+ " }\n",
569
+ " out = gpt_json(sys, payload, max_tokens=240) or {}\n",
570
+ " choice = str(out.get(\"choice\",\"\") or \"\").strip()\n",
571
+ " return choice if choice in candidates else \"\"\n",
572
+ "\n",
573
+ "def fallback_5g_from_dec(canon_make: str) -> str:\n",
574
+ " pool5 = df_dec[(df_dec[\"_canon_make\"] == canon_make) & (df_dec[\"_is5g\"] == True)]\n",
575
+ " return str(pool5.iloc[0][\"Model\"]).strip() if not pool5.empty else \"\"\n",
576
+ "\n",
577
+ "def pick_replacements_lifecycle(row: pd.Series, status: str, use_gpt: bool = True) -> Dict[str, Any]:\n",
578
+ " canon = str(row.get(\"_canon_make\",\"UNKNOWN\"))\n",
579
+ " manufacturer = str(row.get(\"manufacturer\",\"\") or \"\")\n",
580
+ "\n",
581
+ " sug_raw = safe_str(row.get(\"suggested_replacement\",\"\"))\n",
582
+ " adv_raw = safe_str(row.get(\"advanced_5g_option\",\"\"))\n",
583
+ "\n",
584
+ " has_4g_alt = bool(sug_raw.strip())\n",
585
+ " has_5g_alt = bool(adv_raw.strip())\n",
586
+ "\n",
587
+ " # Treat as 4G if the description indicates LTE OR lifecycle provides a 4G suggested replacement\n",
588
+ " is_4g = device_is_4g(row) or has_4g_alt\n",
589
+ "\n",
590
+ " # Provide 5G option if the unit is 4G, EOS/EOL, or lifecycle explicitly provides advanced_5g_option\n",
591
+ " want_5g = is_4g or (status in {\"End of Sale\",\"End of Life\"}) or has_5g_alt\n",
592
+ "\n",
593
+ " # 4G alternative: show whenever lifecycle provides it (or device appears 4G)\n",
594
+ " repl_4g = \"Not applicable\"\n",
595
+ " if is_4g or has_4g_alt:\n",
596
+ " repl_4g = extract_model_token(sug_raw)\n",
597
+ " if not repl_4g:\n",
598
+ " cand4 = candidate_4g_models_from_lifecycle(manufacturer)\n",
599
+ " repl_4g = (gpt_pick_from_candidates(row, cand4, \"4G alternative\") if (use_gpt and client) else \"\") or (cand4[0] if cand4 else \"\")\n",
600
+ " if not repl_4g:\n",
601
+ " repl_4g = \"Not applicable\"\n",
602
+ "\n",
603
+ " # 5G replacement: prefer lifecycle advanced_5g_option whenever present\n",
604
+ " repl_5g = \"Not listed\"\n",
605
+ " if want_5g:\n",
606
+ " repl_5g = extract_model_token(adv_raw)\n",
607
+ " if not repl_5g:\n",
608
+ " cand5 = candidate_5g_models_from_lifecycle(manufacturer)\n",
609
+ " repl_5g = (gpt_pick_from_candidates(row, cand5, \"5G replacement/upgrade\") if (use_gpt and client) else \"\") or (cand5[0] if cand5 else \"\")\n",
610
+ " if not repl_5g:\n",
611
+ " repl_5g = fallback_5g_from_dec(canon) or \"Not listed\"\n",
612
+ "\n",
613
+ " if repl_5g.lower() == \"nan\":\n",
614
+ " repl_5g = \"Not listed\"\n",
615
+ "\n",
616
+ " return {\"repl_4g\": repl_4g, \"repl_5g\": repl_5g, \"sources\": [\"lifecycle_csv\"] + ([\"gpt\"] if (use_gpt and client) else [])}\n",
617
+ "\n",
618
+ "\n",
619
+ "# ============================\n",
620
+ "# Antennas (Parsec-only)\n",
621
+ "# ============================\n",
622
+ "PARSEC_FAMILY_WORDS = {\"chinook\",\"labrador\",\"boxer\",\"bloodhound\",\"husky\",\"beagle\",\"mastiff\",\"collie\",\"shepherd\",\"belgian\",\"australian\",\"terrier\",\"pyrenees\"}\n",
623
+ "BAD_NAME_MARKERS = {\"customization\",\"standard connectors\",\"connectors\",\"features\",\"benefits\",\"specifications\",\"mechanical\",\"electrical\",\"mounting\",\"accessories\",\"description:\",\"standard sku\"}\n",
624
+ "\n",
625
+ "def clean_line(s: str) -> str:\n",
626
+ " s = re.sub(r\"\\s+\", \" \", str(s or \"\").strip())\n",
627
+ " if re.fullmatch(r\"-[a-z0-9]+\", s.lower()):\n",
628
+ " return \"\"\n",
629
+ " return s\n",
630
+ "\n",
631
+ "def is_bad_name_line(line: str) -> bool:\n",
632
+ " low = line.lower()\n",
633
+ " if any(m in low for m in BAD_NAME_MARKERS):\n",
634
+ " return True\n",
635
+ " if re.search(r\"\\b-[a-z0-9]{1,4}\\b\", low) and len(low) <= 25:\n",
636
+ " return True\n",
637
+ " return False\n",
638
+ "\n",
639
+ "def family_from_line(line: str) -> str:\n",
640
+ " low = line.lower()\n",
641
+ " for fam in PARSEC_FAMILY_WORDS:\n",
642
+ " if fam in low:\n",
643
+ " return fam.capitalize()\n",
644
+ " return \"\"\n",
645
+ "\n",
646
+ "def parsec_connectors_from_card(t: str) -> str:\n",
647
+ " m = re.search(r\"Standard\\s+Connectors:\\s*(.+)\", t, flags=re.IGNORECASE)\n",
648
+ " if m:\n",
649
+ " return re.sub(r\"\\s+\", \" \", m.group(1).strip())[:80]\n",
650
+ " return \"\"\n",
651
+ "\n",
652
+ "def parsec_mounts_from_card(t: str) -> List[str]:\n",
653
+ " mounts = []\n",
654
+ " for m in re.finditer(r\"Mount:\\s*(.+)\", t, flags=re.IGNORECASE):\n",
655
+ " val = re.sub(r\"\\s+\", \" \", m.group(1).strip())\n",
656
+ " parts = [p.strip().lower() for p in val.split(\",\") if p.strip()]\n",
657
+ " mounts.extend(parts)\n",
658
+ " out = []\n",
659
+ " seen = set()\n",
660
+ " for x in mounts:\n",
661
+ " if x not in seen:\n",
662
+ " seen.add(x); out.append(x)\n",
663
+ " return out\n",
664
+ "\n",
665
+ "def parsec_name_from_card(card_text: str) -> str:\n",
666
+ " lines = [clean_line(ln) for ln in str(card_text or \"\").splitlines()]\n",
667
+ " lines = [ln for ln in lines if ln]\n",
668
+ "\n",
669
+ " for ln in lines:\n",
670
+ " if is_bad_name_line(ln):\n",
671
+ " continue\n",
672
+ " fam = family_from_line(ln)\n",
673
+ " if fam:\n",
674
+ " return fam\n",
675
+ "\n",
676
+ " sku_i = None\n",
677
+ " for i, ln in enumerate(lines):\n",
678
+ " if \"standard sku\" in ln.lower():\n",
679
+ " sku_i = i\n",
680
+ " break\n",
681
+ " if sku_i is not None:\n",
682
+ " window = lines[max(0, sku_i - 12):sku_i]\n",
683
+ " for ln in reversed(window):\n",
684
+ " if is_bad_name_line(ln):\n",
685
+ " continue\n",
686
+ " if 3 <= len(ln) <= 40 and re.search(r\"[A-Za-z]\", ln):\n",
687
+ " return ln.split()[0].capitalize()\n",
688
+ "\n",
689
+ " return \"Parsec antenna\"\n",
690
+ "\n",
691
+ "def parsec_part_from_card(t: str) -> str:\n",
692
+ " m = re.search(r\"Standard\\s+SKU:\\s*([A-Z0-9]+)\", t)\n",
693
+ " return m.group(1).strip() if m else \"\"\n",
694
+ "\n",
695
+ "def parsec_desc_from_card(t: str) -> str:\n",
696
+ " m = re.search(r\"Description:\\s*(.+?)(?:\\n|$)\", t, flags=re.IGNORECASE)\n",
697
+ " return re.sub(r\"\\s+\",\" \",m.group(1).strip())[:220] if m else \"\"\n",
698
+ "\n",
699
+ "def parsec_retrieve(query: str, top_k: int = 12) -> List[Dict[str, Any]]:\n",
700
+ " qv = embedder.encode([query], normalize_embeddings=True)\n",
701
+ " qv = np.asarray(qv, dtype=np.float32)\n",
702
+ " scores, ids = parsec_index.search(qv, top_k)\n",
703
+ " out: List[Dict[str, Any]] = []\n",
704
+ " for sc, i in zip(scores[0].tolist(), ids[0].tolist()):\n",
705
+ " if 0 <= int(i) < len(parsec_cards):\n",
706
+ " card = parsec_cards[int(i)]\n",
707
+ " out.append({\n",
708
+ " \"score\": float(sc),\n",
709
+ " \"name\": parsec_name_from_card(card),\n",
710
+ " \"part_number\": parsec_part_from_card(card),\n",
711
+ " \"description\": parsec_desc_from_card(card),\n",
712
+ " \"connectors\": parsec_connectors_from_card(card),\n",
713
+ " \"mounts\": parsec_mounts_from_card(card),\n",
714
+ " \"_card\": card.lower(),\n",
715
+ " })\n",
716
+ " return out\n",
717
+ "\n",
718
+ "def choose_best_parsec(cands: List[Dict[str, Any]], mode: str) -> Dict[str, Any]:\n",
719
+ " best = None\n",
720
+ " best_score = -1e9\n",
721
+ "\n",
722
+ " for c in cands:\n",
723
+ " card = c.get(\"_card\",\"\")\n",
724
+ " mounts = c.get(\"mounts\", []) or []\n",
725
+ " score = float(c.get(\"score\", 0.0))\n",
726
+ "\n",
727
+ " if \"omni\" in card:\n",
728
+ " score += 0.6\n",
729
+ " if \"directional\" in card:\n",
730
+ " score -= 1.5\n",
731
+ "\n",
732
+ " if mode == \"vehicle\":\n",
733
+ " if any(\"magnetic\" in m for m in mounts):\n",
734
+ " score += 3.0\n",
735
+ " if any(\"through\" in m for m in mounts):\n",
736
+ " score += 2.0\n",
737
+ " if any(\"wall\" in m for m in mounts) or any(\"pole\" in m for m in mounts):\n",
738
+ " score -= 1.2\n",
739
+ " if \"app: fixed\" in card and \"mobile\" not in card:\n",
740
+ " score -= 2.0\n",
741
+ "\n",
742
+ " if mode == \"stationary\":\n",
743
+ " if any(\"wall\" in m for m in mounts):\n",
744
+ " score += 2.0\n",
745
+ " if any(\"pole\" in m for m in mounts):\n",
746
+ " score += 1.8\n",
747
+ "\n",
748
+ " if score > best_score:\n",
749
+ " best_score = score\n",
750
+ " best = c\n",
751
+ "\n",
752
+ " if not best:\n",
753
+ " return {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\",\"connectors\":\"\",\"mounts\":[]}\n",
754
+ "\n",
755
+ " best = dict(best)\n",
756
+ " best.pop(\"_card\", None)\n",
757
+ " return best\n",
758
+ "\n",
759
+ "\n",
760
+ "def infer_mimo_for_5g(repl_5g_model: str) -> str:\n",
761
+ " \"\"\"Rule: every 5G router uses a 4x4 antenna.\"\"\"\n",
762
+ " return \"4x4\"\n",
763
+ "\n",
764
+ " # If the model name hints 5G, lean 4x4\n",
765
+ " if \"5g\" in model.lower() or model.upper().startswith((\"R\", \"E\", \"S\", \"IX\", \"RUTM\")):\n",
766
+ " default = \"4x4\"\n",
767
+ " else:\n",
768
+ " default = \"2x2\"\n",
769
+ "\n",
770
+ " # Use dec2025routers.csv if we can match the model under the same maker family\n",
771
+ " try:\n",
772
+ " pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
773
+ " if pool.empty:\n",
774
+ " return default\n",
775
+ " hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
776
+ " if not hit or hit[1] < MATCH_OK:\n",
777
+ " return default\n",
778
+ " row = pool.iloc[int(hit[2])]\n",
779
+ " txt2 = (str(row.get(\"Antennas (internal/external/both)\", \"\")) + \" \" + str(row.get(\"Modem Type\", \"\")) + \" \" + str(row.get(\"Special notes\",\"\"))).lower()\n",
780
+ " if \"4x4\" in txt2 or \"4 x 4\" in txt2 or \"4x 4\" in txt2:\n",
781
+ " return \"4x4\"\n",
782
+ " if \"2x2\" in txt2 or \"2 x 2\" in txt2:\n",
783
+ " return \"2x2\"\n",
784
+ " # If modem type includes 5G, lean 4x4\n",
785
+ " if \"5g\" in txt2 or \"nr\" in txt2:\n",
786
+ " return \"4x4\"\n",
787
+ " return default\n",
788
+ " except Exception:\n",
789
+ " return default\n",
790
+ "\n",
791
+ "def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:\n",
792
+ " q_stationary = f\"{router_model} {tech} {mimo} omni stationary pole wall fixed site Parsec\"\n",
793
+ " q_vehicle = f\"{router_model} {tech} {mimo} omni vehicle mobile magnetic through-bolt Parsec\"\n",
794
+ "\n",
795
+ " cand_stationary = parsec_retrieve(q_stationary, top_k=12)\n",
796
+ " cand_vehicle = parsec_retrieve(q_vehicle, top_k=12)\n",
797
+ "\n",
798
+ " s = choose_best_parsec(cand_stationary, mode=\"stationary\")\n",
799
+ " v = choose_best_parsec(cand_vehicle, mode=\"vehicle\")\n",
800
+ "\n",
801
+ " s.update({\"mimo\": mimo, \"why\": \"Stationary omni best match.\"})\n",
802
+ " v.update({\"mimo\": mimo, \"why\": \"Vehicle omni best match.\"})\n",
803
+ "\n",
804
+ " return {\"stationary_omni\": s, \"vehicle_omni\": v, \"sources\":[\"parsec_rag\"]}\n",
805
+ "\n",
806
+ "\n",
807
+ "# ============================\n",
808
+ "# Install-ready checklist\n",
809
+ "# ============================\n",
810
+ "def install_ready_checklist(current_sku: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:\n",
811
+ " st = ant.get(\"stationary_omni\", {})\n",
812
+ " vh = ant.get(\"vehicle_omni\", {})\n",
813
+ " if client is not None:\n",
814
+ " sys = \"Create a short, install-ready checklist for a Verizon rep. Return markdown only.\"\n",
815
+ " payload = {\"current_device\": current_sku, \"replacements\": repl, \"antennas\": {\"stationary\": st, \"vehicle\": vh}}\n",
816
+ " resp = client.responses.create(\n",
817
+ " model=OPENAI_MODEL,\n",
818
+ " reasoning=OPENAI_REASONING,\n",
819
+ " input=[{\"role\":\"system\",\"content\":sys},{\"role\":\"user\",\"content\":json.dumps(payload)}],\n",
820
+ " max_output_tokens=520,\n",
821
+ " )\n",
822
+ " return (getattr(resp, \"output_text\", \"\") or \"\").strip()\n",
823
+ " return \"\\n\".join([\n",
824
+ " \"### Install-ready checklist\",\n",
825
+ " f\"- Current device: {current_sku}\",\n",
826
+ " f\"- 5G replacement: {repl.get('repl_5g','')}\",\n",
827
+ " f\"- 4G alternative: {repl.get('repl_4g','Not applicable')}\",\n",
828
+ " f\"- Stationary omni antenna: {st.get('name','')} (PN {st.get('part_number','')})\",\n",
829
+ " f\"- Vehicle omni antenna: {vh.get('name','')} (PN {vh.get('part_number','')})\",\n",
830
+ " \"- Next steps: confirm mounting + cable lengths + power; place order; schedule install.\",\n",
831
+ " ])\n",
832
+ "\n",
833
+ "\n",
834
+ "# ============================\n",
835
+ "# Batch mode (NO GPT)\n",
836
+ "# ============================\n",
837
+ "def parse_batch_inputs(text_blob: str, file_obj: Any) -> List[str]:\n",
838
+ " items: List[str] = []\n",
839
+ " if file_obj is not None:\n",
840
+ " try:\n",
841
+ " path = file_obj.name if hasattr(file_obj, \"name\") else str(file_obj)\n",
842
+ " df = pd.read_csv(path)\n",
843
+ " col = df.columns[0]\n",
844
+ " items.extend([str(x).strip() for x in df[col].tolist() if str(x).strip()])\n",
845
+ " except Exception:\n",
846
+ " pass\n",
847
+ " if text_blob:\n",
848
+ " for ln in str(text_blob).splitlines():\n",
849
+ " ln = ln.strip()\n",
850
+ " if ln:\n",
851
+ " items.append(ln)\n",
852
+ " seen=set()\n",
853
+ " out=[]\n",
854
+ " for x in items:\n",
855
+ " k=norm_text(x)\n",
856
+ " if k and k not in seen:\n",
857
+ " seen.add(k); out.append(x)\n",
858
+ " return out\n",
859
+ "\n",
860
+ "def run_batch(text_blob: str, file_obj: Any, include_antennas: bool):\n",
861
+ " inputs = parse_batch_inputs(text_blob, file_obj)\n",
862
+ " if not inputs:\n",
863
+ " return \"\", None, None, \"\"\n",
864
+ "\n",
865
+ " rows=[]\n",
866
+ " for item in inputs:\n",
867
+ " res = resolve_device(item)\n",
868
+ " if res.get(\"mode\") != \"ok\":\n",
869
+ " rows.append({\"Input\": item, \"Matched\":\"\", \"Status\":\"Needs review\", \"EOS\":\"\", \"EOL\":\"\", \"4G alternative\":\"\", \"5G replacement\":\"\", \"Notes\":\"Not found/ambiguous\"})\n",
870
+ " continue\n",
871
+ "\n",
872
+ " life_row = df_eos.iloc[int(res[\"row_idx\"])]\n",
873
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
874
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=False)\n",
875
+ "\n",
876
+ " rows.append({\n",
877
+ " \"Input\": item,\n",
878
+ " \"Matched\": str(life_row.get(\"sku\",\"\")),\n",
879
+ " \"Status\": status,\n",
880
+ " \"EOS\": eos,\n",
881
+ " \"EOL\": eol,\n",
882
+ " \"4G alternative\": repl.get(\"repl_4g\",\"\"),\n",
883
+ " \"5G replacement\": repl.get(\"repl_5g\",\"\"),\n",
884
+ " \"Notes\": \"\",\n",
885
+ " })\n",
886
+ "\n",
887
+ " out_df = pd.DataFrame(rows)\n",
888
+ " counts = out_df[\"Status\"].value_counts(dropna=False).to_dict()\n",
889
+ " top_5g = out_df[\"5G replacement\"].value_counts(dropna=False).head(5).to_dict()\n",
890
+ " summary = f\"Rows: {len(out_df)} | \" + \" | \".join([f\"{k}: {v}\" for k,v in counts.items()])\n",
891
+ " rollup = \"Top 5G recommendations:\\n\" + \"\\n\".join([f\"- {k}: {v}\" for k,v in top_5g.items() if str(k).strip()])\n",
892
+ "\n",
893
+ " tmp = tempfile.NamedTemporaryFile(delete=False, suffix=\".csv\")\n",
894
+ " out_df.to_csv(tmp.name, index=False)\n",
895
+ "\n",
896
+ " return summary, out_df, tmp.name, rollup\n",
897
+ "\n",
898
+ "\n",
899
+ "# ============================\n",
900
+ "# Replacement feature table + manufacturer link (5G device)\n",
901
+ "# ============================\n",
902
+ "\n",
903
+ "FEATURE_COLS = [\"Device\", \"Modem technology\", \"WiFi\", \"Ports\", \"Antennas\", \"Ruggedness\", \"Use case\"]\n",
904
+ "\n",
905
+ "# Manufacturer domains used for best-effort link resolution (no non-maker domains).\n",
906
+ "MAKER_DOMAINS = {\n",
907
+ " \"CRADLEPOINT\": [\"cradlepoint.com\", \"ericsson.com\"],\n",
908
+ " \"SIERRA\": [\"semtech.com\", \"airlink.com\"],\n",
909
+ " \"FEENEY\": [\"inseego.com\"],\n",
910
+ " \"DIGI\": [\"digi.com\"],\n",
911
+ " \"CISCO_MERAKI\": [\"meraki.cisco.com\", \"cisco.com\"],\n",
912
+ " \"CISCO\": [\"cisco.com\"],\n",
913
+ " \"TELTONIKA\": [\"teltonika-networks.com\"],\n",
914
+ " \"UNKNOWN\": [],\n",
915
+ "}\n",
916
+ "\n",
917
+ "HTTP_HEADERS = {\n",
918
+ " \"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \"\n",
919
+ " \"(KHTML, like Gecko) Chrome/120.0 Safari/537.36\"\n",
920
+ "}\n",
921
+ "HTTP_TIMEOUT = 12\n",
922
+ "\n",
923
+ "def _best_effort_manufacturer_url(model: str, canon_make: str) -> str:\n",
924
+ " \"\"\"Try to find a manufacturer page or datasheet link using simple on-domain searches.\n",
925
+ " If we can't confirm a page, return the manufacturer homepage for the maker family.\n",
926
+ " \"\"\"\n",
927
+ " model = str(model or \"\").strip()\n",
928
+ " if not model or model in {\"Not listed\", \"Not applicable\"}:\n",
929
+ " return \"\"\n",
930
+ "\n",
931
+ " domains = MAKER_DOMAINS.get(canon_make, []) or []\n",
932
+ " if not domains:\n",
933
+ " return \"\"\n",
934
+ "\n",
935
+ " # Candidate on-domain search URLs (common patterns across sites).\n",
936
+ " # We keep these on the manufacturer domain (no Google/Bing).\n",
937
+ " q = re.sub(r\"\\s+\", \"+\", model)\n",
938
+ " url_candidates = []\n",
939
+ " for d in domains:\n",
940
+ " url_candidates += [\n",
941
+ " f\"https://{d}/search?q={q}\",\n",
942
+ " f\"https://{d}/search?query={q}\",\n",
943
+ " f\"https://{d}/?s={q}\",\n",
944
+ " f\"https://www.{d}/search?q={q}\",\n",
945
+ " f\"https://www.{d}/search?query={q}\",\n",
946
+ " f\"https://www.{d}/?s={q}\",\n",
947
+ " ]\n",
948
+ "\n",
949
+ " # Also try a few direct product patterns for known makers (best effort).\n",
950
+ " if canon_make == \"TELTONIKA\":\n",
951
+ " slug = model.lower()\n",
952
+ " url_candidates += [\n",
953
+ " f\"https://teltonika-networks.com/products/routers/{slug}\",\n",
954
+ " f\"https://teltonika-networks.com/product/{slug}\",\n",
955
+ " \"https://teltonika-networks.com/products/routers/\",\n",
956
+ " ]\n",
957
+ " if canon_make == \"DIGI\":\n",
958
+ " url_candidates += [\n",
959
+ " \"https://www.digi.com/products/networking/cellular-routers\",\n",
960
+ " f\"https://www.digi.com/search?q={q}\",\n",
961
+ " ]\n",
962
+ " if canon_make == \"CRADLEPOINT\":\n",
963
+ " url_candidates += [\n",
964
+ " \"https://cradlepoint.com/products/\",\n",
965
+ " f\"https://cradlepoint.com/?s={q}\",\n",
966
+ " ]\n",
967
+ " if canon_make in {\"CISCO\", \"CISCO_MERAKI\"}:\n",
968
+ " url_candidates += [\n",
969
+ " f\"https://www.cisco.com/c/en/us/search.html?q={q}\",\n",
970
+ " ]\n",
971
+ "\n",
972
+ " # Try to confirm a working page (HTTP 200 and model string somewhere in HTML).\n",
973
+ " for u in url_candidates[:18]:\n",
974
+ " try:\n",
975
+ " import requests\n",
976
+ " r = requests.get(u, headers=HTTP_HEADERS, timeout=HTTP_TIMEOUT, allow_redirects=True)\n",
977
+ " if r.status_code != 200:\n",
978
+ " continue\n",
979
+ " html = (r.text or \"\").lower()\n",
980
+ " if model.lower() in html or \"datasheet\" in html or \"data sheet\" in html:\n",
981
+ " return r.url\n",
982
+ " except Exception:\n",
983
+ " continue\n",
984
+ "\n",
985
+ " # Fallback: maker homepage\n",
986
+ " d0 = domains[0]\n",
987
+ " return f\"https://{d0}\"\n",
988
+ "\n",
989
+ "def _fetch_page_text(url: str, max_chars: int = 12000) -> str:\n",
990
+ " \"\"\"Fetch page HTML and return a simplified text blob for GPT (best effort).\"\"\"\n",
991
+ " if not url:\n",
992
+ " return \"\"\n",
993
+ " try:\n",
994
+ " import requests\n",
995
+ " r = requests.get(url, headers=HTTP_HEADERS, timeout=HTTP_TIMEOUT, allow_redirects=True)\n",
996
+ " if r.status_code != 200:\n",
997
+ " return \"\"\n",
998
+ " html = r.text or \"\"\n",
999
+ " html = re.sub(r\"(?is)<script.*?>.*?</script>\", \" \", html)\n",
1000
+ " html = re.sub(r\"(?is)<style.*?>.*?</style>\", \" \", html)\n",
1001
+ " text = re.sub(r\"(?is)<[^>]+>\", \" \", html)\n",
1002
+ " text = re.sub(r\"\\s+\", \" \", text).strip()\n",
1003
+ " return text[:max_chars]\n",
1004
+ " except Exception:\n",
1005
+ " return \"\"\n",
1006
+ "\n",
1007
+ "\n",
1008
+ "def _features_from_dec(model: str, canon_make: str) -> Dict[str, str]:\n",
1009
+ " \"\"\"Lookup a router model in dec2025routers.csv and return the key feature fields.\"\"\"\n",
1010
+ " if not model or model in {\"Not listed\", \"Not applicable\"}:\n",
1011
+ " return {k: \"Not listed\" for k in FEATURE_COLS[1:]}\n",
1012
+ "\n",
1013
+ " pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
1014
+ " if pool.empty:\n",
1015
+ " return {k: \"Not listed\" for k in FEATURE_COLS[1:]}\n",
1016
+ "\n",
1017
+ " hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
1018
+ " if not hit or hit[1] < MATCH_OK:\n",
1019
+ " return {k: \"Not listed\" for k in FEATURE_COLS[1:]}\n",
1020
+ "\n",
1021
+ " r = pool.iloc[int(hit[2])]\n",
1022
+ " ports = f\"WAN: {r.get('WAN ports and speed','')} | LAN: {r.get('LAN ports and speed','')}\"\n",
1023
+ " return {\n",
1024
+ " \"Modem technology\": str(r.get(\"Modem Type\",\"\")) or \"Not listed\",\n",
1025
+ " \"WiFi\": str(r.get(\"WiFi type\",\"\")) or \"Not listed\",\n",
1026
+ " \"Ports\": ports.strip() if ports.strip() else \"Not listed\",\n",
1027
+ " \"Antennas\": str(r.get(\"Antennas (internal/external/both)\",\"\")) or \"Not listed\",\n",
1028
+ " \"Ruggedness\": str(r.get(\"Ruggedization\",\"\")) or \"Not listed\",\n",
1029
+ " \"Use case\": str(r.get(\"Primary use case\",\"\")) or \"Not listed\",\n",
1030
+ " }\n",
1031
+ "\n",
1032
+ "def _gpt_fill_feature_row(device_label: str, model: str, canon_make: str, row: Dict[str, str], manufacturer_url: str = \"\", page_text: str = \"\") -> Dict[str, str]:\n",
1033
+ " \"\"\"If dec can't supply values, ask GPT to fill missing ones (best guess).\"\"\"\n",
1034
+ " if client is None:\n",
1035
+ " return row\n",
1036
+ "\n",
1037
+ " missing = [k for k,v in row.items() if (not v) or str(v).strip().lower() in {\"not listed\",\"nan\",\"\"}]\n",
1038
+ " if not missing:\n",
1039
+ " return row\n",
1040
+ "\n",
1041
+ " sys = (\n",
1042
+ " \"Fill missing router feature fields for a Verizon rep. Return strict JSON only. \"\n",
1043
+ " \"Use manufacturer page text when available. If still unknown, make a best-guess.\"\n",
1044
+ " )\n",
1045
+ " payload = {\n",
1046
+ " \"device_label\": device_label,\n",
1047
+ " \"model\": model,\n",
1048
+ " \"maker_family\": canon_make,\n",
1049
+ " \"manufacturer_url\": manufacturer_url,\n",
1050
+ " \"manufacturer_page_text\": page_text[:8000],\n",
1051
+ " \"known\": row,\n",
1052
+ " \"fill_only\": missing,\n",
1053
+ " \"rules\": [\"Fill only requested fields.\", \"Short phrases only.\", \"Return JSON only.\"],\n",
1054
+ " \"output_schema\": {k: \"string\" for k in missing},\n",
1055
+ " }\n",
1056
+ " out = gpt_json(sys, payload, max_tokens=320) or {}\n",
1057
+ " for k in missing:\n",
1058
+ " val = str(out.get(k, \"\") or \"\").strip()\n",
1059
+ " if val:\n",
1060
+ " row[k] = val\n",
1061
+ " return row\n",
1062
+ " missing = [k for k,v in row.items() if (not v) or str(v).strip().lower() in {\"not listed\",\"nan\",\"\"}]\n",
1063
+ " if not missing:\n",
1064
+ " return row\n",
1065
+ "\n",
1066
+ " sys = \"Fill missing router feature fields for a Verizon rep. Return strict JSON only.\"\n",
1067
+ " payload = {\n",
1068
+ " \"device_label\": device_label,\n",
1069
+ " \"model\": model,\n",
1070
+ " \"maker_family\": canon_make,\n",
1071
+ " \"known\": row,\n",
1072
+ " \"fill_only\": missing,\n",
1073
+ " \"rules\": [\n",
1074
+ " \"Fill only the requested fields.\",\n",
1075
+ " \"Best guess if needed. Short phrases only.\",\n",
1076
+ " \"Return JSON only.\"\n",
1077
+ " ],\n",
1078
+ " \"output_schema\": {k: \"string\" for k in missing}\n",
1079
+ " }\n",
1080
+ " out = gpt_json(sys, payload, max_tokens=260) or {}\n",
1081
+ " for k in missing:\n",
1082
+ " val = str(out.get(k, \"\") or \"\").strip()\n",
1083
+ " if val:\n",
1084
+ " row[k] = val\n",
1085
+ " return row\n",
1086
+ "\n",
1087
+ "def build_replacement_features_table(repl_4g: str, repl_5g: str, canon_make: str) -> pd.DataFrame:\n",
1088
+ " rows = []\n",
1089
+ "\n",
1090
+ " # 4G alternative row\n",
1091
+ " row4 = _features_from_dec(repl_4g, canon_make)\n",
1092
+ " url4 = _best_effort_manufacturer_url(repl_4g, canon_make) if repl_4g else \"\"\n",
1093
+ " txt4 = _fetch_page_text(url4) if url4 else \"\"\n",
1094
+ " row4 = _gpt_fill_feature_row(\"4G alternative\", repl_4g, canon_make, row4, manufacturer_url=url4, page_text=txt4)\n",
1095
+ " rows.append({\"Device\": \"4G alternative\", **row4})\n",
1096
+ "\n",
1097
+ " # 5G replacement row\n",
1098
+ " row5 = _features_from_dec(repl_5g, canon_make)\n",
1099
+ " url5 = _best_effort_manufacturer_url(repl_5g, canon_make) if repl_5g else \"\"\n",
1100
+ " txt5 = _fetch_page_text(url5) if url5 else \"\"\n",
1101
+ " row5 = _gpt_fill_feature_row(\"5G replacement\", repl_5g, canon_make, row5, manufacturer_url=url5, page_text=txt5)\n",
1102
+ " rows.append({\"Device\": \"5G replacement\", **row5})\n",
1103
+ "\n",
1104
+ " df = pd.DataFrame(rows, columns=FEATURE_COLS)\n",
1105
+ " return df\n",
1106
+ "# ============================\n",
1107
+ "# Verizon fit badges (small table) for recommended devices\n",
1108
+ "# ============================\n",
1109
+ "\n",
1110
+ "FIT_COLS = [\"Device\", \"Fit badges\", \"Ethernet ports\", \"Battery\"]\n",
1111
+ "\n",
1112
+ "def _parse_ethernet_ports(wan_field: str, lan_field: str) -> str:\n",
1113
+ " \"\"\"Best-effort total ethernet ports based on WAN/LAN text.\"\"\"\n",
1114
+ " def _count(field: str) -> int:\n",
1115
+ " s = str(field or \"\")\n",
1116
+ " # Common forms: \"1x GbE\", \"2 x 10/100\", \"WAN: 1\", etc.\n",
1117
+ " nums = [int(x) for x in re.findall(r\"(\\\\d+)\\\\s*x\", s.lower())]\n",
1118
+ " if nums:\n",
1119
+ " return sum(nums)\n",
1120
+ " # Fallback: if it contains 'port' with a number\n",
1121
+ " m = re.search(r\"(\\\\d+)\\\\s*port\", s.lower())\n",
1122
+ " if m:\n",
1123
+ " return int(m.group(1))\n",
1124
+ " # If it contains '1' and 'wan' in short text, guess 1\n",
1125
+ " if \"wan\" in s.lower() and re.search(r\"\\\\b1\\\\b\", s):\n",
1126
+ " return 1\n",
1127
+ " return 0\n",
1128
+ "\n",
1129
+ " total = _count(wan_field) + _count(lan_field)\n",
1130
+ " return str(total) if total > 0 else \"Not listed\"\n",
1131
+ "\n",
1132
+ "def _battery_badge(battery_field: str) -> str:\n",
1133
+ " s = str(battery_field or \"\").strip().lower()\n",
1134
+ " if not s or s in {\"none\", \"no\", \"n/a\", \"not listed\"}:\n",
1135
+ " return \"No\"\n",
1136
+ " return \"Yes\"\n",
1137
+ "\n",
1138
+ "def _bool_badge(flag: bool) -> str:\n",
1139
+ " return \"Yes\" if flag else \"No\"\n",
1140
+ "\n",
1141
+ "def _dual_sim_from_row_text(*fields: str) -> bool:\n",
1142
+ " txt = \" \".join([str(x or \"\") for x in fields]).lower()\n",
1143
+ " return (\"dual sim\" in txt) or (\"2 sim\" in txt) or (\"two sim\" in txt) or (\"dual-sim\" in txt)\n",
1144
+ "\n",
1145
+ "def _throughput_high(throughput_field: str) -> bool:\n",
1146
+ " t = str(throughput_field or \"\").lower()\n",
1147
+ " # Heuristic: anything mentioning gbps or >=1000 mbps\n",
1148
+ " if \"gbps\" in t:\n",
1149
+ " return True\n",
1150
+ " m = re.search(r\"(\\\\d+(?:\\\\.\\\\d+)?)\\\\s*mbps\", t)\n",
1151
+ " if m:\n",
1152
+ " try:\n",
1153
+ " return float(m.group(1)) >= 1000.0\n",
1154
+ " except Exception:\n",
1155
+ " pass\n",
1156
+ " return False\n",
1157
+ "\n",
1158
+ "def _gpt_fit_badges(model: str, canon_make: str, is_5g: bool, dec_row: Optional[pd.Series]) -> Tuple[str, str, str]:\n",
1159
+ " \"\"\"\n",
1160
+ " GPT-based fill for Fit badges / Ethernet ports / Battery, used when dec is missing or incomplete.\n",
1161
+ " Returns (badges_csv, ethernet_ports, battery_yesno).\n",
1162
+ " \"\"\"\n",
1163
+ " if client is None:\n",
1164
+ " return (\"Not listed\", \"Not listed\", \"Not listed\")\n",
1165
+ "\n",
1166
+ " dec_ctx = {}\n",
1167
+ " if dec_row is not None:\n",
1168
+ " try:\n",
1169
+ " dec_ctx = {\n",
1170
+ " \"Model\": str(dec_row.get(\"Model\",\"\")),\n",
1171
+ " \"Modem Type\": str(dec_row.get(\"Modem Type\",\"\")),\n",
1172
+ " \"Ruggedization\": str(dec_row.get(\"Ruggedization\",\"\")),\n",
1173
+ " \"WAN ports and speed\": str(dec_row.get(\"WAN ports and speed\",\"\")),\n",
1174
+ " \"LAN ports and speed\": str(dec_row.get(\"LAN ports and speed\",\"\")),\n",
1175
+ " \"Antennas\": str(dec_row.get(\"Antennas (internal/external/both)\",\"\")),\n",
1176
+ " \"WiFi type\": str(dec_row.get(\"WiFi type\",\"\")),\n",
1177
+ " \"Primary use case\": str(dec_row.get(\"Primary use case\",\"\")),\n",
1178
+ " \"Serial port\": str(dec_row.get(\"Serial port (yes/no)\",\"\")),\n",
1179
+ " \"VPN\": str(dec_row.get(\"VPN capabilities\",\"\")),\n",
1180
+ " \"Throughput\": str(dec_row.get(\"Router throughput\",\"\")),\n",
1181
+ " \"Battery\": str(dec_row.get(\"Battery (internal/removable/none/optional)\",\"\")),\n",
1182
+ " \"Special notes\": str(dec_row.get(\"Special notes\",\"\")),\n",
1183
+ " \"Summary\": str(dec_row.get(\"summary and use case\",\"\")),\n",
1184
+ " }\n",
1185
+ " except Exception:\n",
1186
+ " dec_ctx = {}\n",
1187
+ "\n",
1188
+ " sys = (\n",
1189
+ " \"You are helping a Verizon rep. Based on the provided router context, output fit badges and a couple quick traits.\\n\"\n",
1190
+ " \"Return STRICT JSON only.\\n\"\n",
1191
+ " \"Badges must be chosen from this set only:\\n\"\n",
1192
+ " \"['Vehicle','Fixed site','Wi‑Fi','Rugged','Dual‑SIM','4x4 MIMO','High throughput','Serial'].\\n\"\n",
1193
+ " \"Rules:\\n\"\n",
1194
+ " \"- If is_5g is true, ALWAYS include '4x4 MIMO'.\\n\"\n",
1195
+ " \"- Ethernet ports: return a single integer as a string if you can infer total ethernet ports, otherwise 'Not listed'.\\n\"\n",
1196
+ " \"- Battery: return 'Yes' or 'No' if you can infer, otherwise 'Not listed'.\\n\"\n",
1197
+ " \"- If uncertain between Vehicle vs Fixed site, pick the most likely based on use case/ruggedization.\\n\"\n",
1198
+ " )\n",
1199
+ "\n",
1200
+ " payload = {\n",
1201
+ " \"model\": model,\n",
1202
+ " \"maker_family\": canon_make,\n",
1203
+ " \"is_5g\": bool(is_5g),\n",
1204
+ " \"dec_context\": dec_ctx,\n",
1205
+ " \"output_schema\": {\n",
1206
+ " \"badges\": [\"string\"],\n",
1207
+ " \"ethernet_ports\": \"string\",\n",
1208
+ " \"battery\": \"Yes|No|Not listed\"\n",
1209
+ " }\n",
1210
+ " }\n",
1211
+ "\n",
1212
+ " out = gpt_json(sys, payload, max_tokens=260) or {}\n",
1213
+ "\n",
1214
+ " badges = out.get(\"badges\", []) or []\n",
1215
+ " allowed = {\"Vehicle\",\"Fixed site\",\"Wi‑Fi\",\"Rugged\",\"Dual‑SIM\",\"4x4 MIMO\",\"High throughput\",\"Serial\"}\n",
1216
+ " clean = []\n",
1217
+ " for b in badges:\n",
1218
+ " bs = str(b).strip()\n",
1219
+ " if bs in allowed:\n",
1220
+ " clean.append(bs)\n",
1221
+ "\n",
1222
+ " if is_5g and \"4x4 MIMO\" not in clean:\n",
1223
+ " clean.append(\"4x4 MIMO\")\n",
1224
+ "\n",
1225
+ " eth = str(out.get(\"ethernet_ports\",\"\") or \"\").strip()\n",
1226
+ " if not eth or eth.lower() in {\"nan\",\"none\"}:\n",
1227
+ " eth = \"Not listed\"\n",
1228
+ " m = re.search(r\"\\d+\", eth)\n",
1229
+ " eth = m.group(0) if m else (\"Not listed\" if eth == \"Not listed\" else eth)\n",
1230
+ "\n",
1231
+ " bat = str(out.get(\"battery\",\"\") or \"\").strip()\n",
1232
+ " if not bat:\n",
1233
+ " bat = \"Not listed\"\n",
1234
+ " if bat.lower().startswith(\"y\"):\n",
1235
+ " bat = \"Yes\"\n",
1236
+ " elif bat.lower().startswith(\"n\"):\n",
1237
+ " bat = \"No\"\n",
1238
+ " elif bat not in {\"Yes\",\"No\",\"Not listed\"}:\n",
1239
+ " bat = \"Not listed\"\n",
1240
+ "\n",
1241
+ " dedup=[]\n",
1242
+ " seen=set()\n",
1243
+ " for b in clean:\n",
1244
+ " if b not in seen:\n",
1245
+ " seen.add(b); dedup.append(b)\n",
1246
+ " badges_csv = \", \".join(dedup) if dedup else \"Not listed\"\n",
1247
+ " return (badges_csv, eth, bat)\n",
1248
+ "\n",
1249
+ "\n",
1250
+ "def _fit_badges_for_model(model: str, canon_make: str, is_5g: bool) -> Tuple[str, str, str]:\n",
1251
+ " \"\"\"Return (badges_csv, ethernet_ports, battery_yesno). Uses dec2025routers.csv first, then GPT fill.\"\"\"\n",
1252
+ " model = str(model or \"\").strip()\n",
1253
+ " if not model or model in {\"Not listed\", \"Not applicable\"}:\n",
1254
+ " return (\"Not listed\", \"Not listed\", \"Not listed\")\n",
1255
+ "\n",
1256
+ " pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
1257
+ " row = None\n",
1258
+ " if not pool.empty:\n",
1259
+ " hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
1260
+ " if hit and hit[1] >= MATCH_OK:\n",
1261
+ " row = pool.iloc[int(hit[2])]\n",
1262
+ "\n",
1263
+ " badges = []\n",
1264
+ " eth = \"Not listed\"\n",
1265
+ " bat_yes = \"Not listed\"\n",
1266
+ "\n",
1267
+ " if row is not None:\n",
1268
+ " use_case = str(row.get(\"Primary use case\",\"\") or \"\").lower()\n",
1269
+ " rugged = str(row.get(\"Ruggedization\",\"\") or \"\").lower()\n",
1270
+ "\n",
1271
+ " if any(k in use_case for k in [\"vehicle\",\"mobile\",\"fleet\",\"in-vehicle\"]) or \"vehicle\" in rugged:\n",
1272
+ " badges.append(\"Vehicle\")\n",
1273
+ " else:\n",
1274
+ " badges.append(\"Fixed site\")\n",
1275
+ "\n",
1276
+ " wifi = str(row.get(\"WiFi type\",\"\") or \"\").strip()\n",
1277
+ " if wifi and wifi.lower() not in {\"none\",\"no\",\"n/a\"}:\n",
1278
+ " badges.append(\"Wi‑Fi\")\n",
1279
+ "\n",
1280
+ " if any(k in rugged for k in [\"rugged\",\"industrial\",\"ip\",\"harsh\"]):\n",
1281
+ " badges.append(\"Rugged\")\n",
1282
+ "\n",
1283
+ " notes_blob = \" \".join([\n",
1284
+ " str(row.get(\"Special notes\",\"\") or \"\"),\n",
1285
+ " str(row.get(\"summary and use case\",\"\") or \"\"),\n",
1286
+ " ]).lower()\n",
1287
+ " if \"dual\" in notes_blob and \"sim\" in notes_blob:\n",
1288
+ " badges.append(\"Dual‑SIM\")\n",
1289
+ "\n",
1290
+ " if is_5g:\n",
1291
+ " badges.append(\"4x4 MIMO\")\n",
1292
+ "\n",
1293
+ " thr = str(row.get(\"Router throughput\",\"\") or \"\").lower()\n",
1294
+ " m = re.search(r\"(\\d+(\\.\\d+)?)\\s*gb\", thr)\n",
1295
+ " if m:\n",
1296
+ " try:\n",
1297
+ " if float(m.group(1)) >= 1.0:\n",
1298
+ " badges.append(\"High throughput\")\n",
1299
+ " except Exception:\n",
1300
+ " pass\n",
1301
+ "\n",
1302
+ " serial = str(row.get(\"Serial port (yes/no)\",\"\") or \"\").strip().lower()\n",
1303
+ " if serial in {\"yes\",\"y\",\"true\"}:\n",
1304
+ " badges.append(\"Serial\")\n",
1305
+ "\n",
1306
+ " wan = str(row.get(\"WAN ports and speed\",\"\") or \"\")\n",
1307
+ " lan = str(row.get(\"LAN ports and speed\",\"\") or \"\")\n",
1308
+ " m1 = re.search(r\"(\\d+)\\s*x\", wan.lower())\n",
1309
+ " m2 = re.search(r\"(\\d+)\\s*x\", lan.lower())\n",
1310
+ " if m1 or m2:\n",
1311
+ " total = (int(m1.group(1)) if m1 else 0) + (int(m2.group(1)) if m2 else 0)\n",
1312
+ " eth = str(total) if total > 0 else \"Not listed\"\n",
1313
+ "\n",
1314
+ " bat = str(row.get(\"Battery (internal/removable/none/optional)\",\"\") or \"\")\n",
1315
+ " bat_l = bat.lower().strip()\n",
1316
+ " if bat_l:\n",
1317
+ " if \"none\" in bat_l:\n",
1318
+ " bat_yes = \"No\"\n",
1319
+ " else:\n",
1320
+ " bat_yes = \"Yes\"\n",
1321
+ "\n",
1322
+ " # Use GPT when anything is missing (instead of best-effort inference)\n",
1323
+ " if (row is None) or (eth == \"Not listed\") or (bat_yes == \"Not listed\") or (not badges):\n",
1324
+ " g_badges, g_eth, g_bat = _gpt_fit_badges(model, canon_make, is_5g, row)\n",
1325
+ "\n",
1326
+ " if badges:\n",
1327
+ " if is_5g and \"4x4 MIMO\" not in badges:\n",
1328
+ " badges.append(\"4x4 MIMO\")\n",
1329
+ " dedup=[]\n",
1330
+ " seen=set()\n",
1331
+ " for b in badges:\n",
1332
+ " if b not in seen:\n",
1333
+ " seen.add(b); dedup.append(b)\n",
1334
+ " badges_csv = \", \".join(dedup)\n",
1335
+ " else:\n",
1336
+ " badges_csv = g_badges\n",
1337
+ "\n",
1338
+ " eth = eth if eth != \"Not listed\" else g_eth\n",
1339
+ " bat_yes = bat_yes if bat_yes != \"Not listed\" else g_bat\n",
1340
+ " return (badges_csv or \"Not listed\", eth or \"Not listed\", bat_yes or \"Not listed\")\n",
1341
+ "\n",
1342
+ " dedup=[]\n",
1343
+ " seen=set()\n",
1344
+ " for b in badges:\n",
1345
+ " if b not in seen:\n",
1346
+ " seen.add(b); dedup.append(b)\n",
1347
+ " badges_csv = \", \".join(dedup) if dedup else \"Not listed\"\n",
1348
+ " return (badges_csv, eth, bat_yes)\n",
1349
+ "\n",
1350
+ "def build_fit_table(repl_4g: str, repl_5g: str, canon_make: str) -> pd.DataFrame:\n",
1351
+ " rows = []\n",
1352
+ " # 4G alt row (is_5g False)\n",
1353
+ " b4, eth4, bat4 = _fit_badges_for_model(repl_4g, canon_make, is_5g=False)\n",
1354
+ " rows.append({\"Device\": \"4G alternative\", \"Fit badges\": b4, \"Ethernet ports\": eth4, \"Battery\": bat4})\n",
1355
+ " # 5G row (is_5g True)\n",
1356
+ " b5, eth5, bat5 = _fit_badges_for_model(repl_5g, canon_make, is_5g=True)\n",
1357
+ " rows.append({\"Device\": \"5G replacement\", \"Fit badges\": b5, \"Ethernet ports\": eth5, \"Battery\": bat5})\n",
1358
+ " return pd.DataFrame(rows, columns=FIT_COLS)\n",
1359
+ "\n",
1360
+ "# ============================\n",
1361
+ "# Output\n",
1362
+ "# ============================\n",
1363
+ "def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:\n",
1364
+ " current_name = f\"{life_row.get('sku','')} — {life_row.get('description','')}\".strip(\" —\")\n",
1365
+ " st = ant.get(\"stationary_omni\", {})\n",
1366
+ " vh = ant.get(\"vehicle_omni\", {})\n",
1367
+ "\n",
1368
+ " lines = []\n",
1369
+ " lines.append(f\"1. Current device: **{current_name}**\")\n",
1370
+ " lines.append(f\"2. Status: **{status}**\")\n",
1371
+ " lines.append(f\"3. End of Sale date: **{eos}**\")\n",
1372
+ " lines.append(f\"4. End of Life date: **{eol}**\")\n",
1373
+ " lines.append(f\"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**\")\n",
1374
+ " lines.append(f\"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**\")\n",
1375
+ " lines.append(\"7. Antenna options (Parsec-only):\")\n",
1376
+ " conn_s = f\" | Conn: {st.get('connectors','')}\" if st.get(\"connectors\") else \"\"\n",
1377
+ " conn_v = f\" | Conn: {vh.get('connectors','')}\" if vh.get(\"connectors\") else \"\"\n",
1378
+ " lines.append(f\" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')}{conn_s}\")\n",
1379
+ " lines.append(f\" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')}{conn_v}\")\n",
1380
+ "\n",
1381
+ " lines.append(\"\\nSources (debug):\")\n",
1382
+ " for s in repl.get(\"sources\", []) if isinstance(repl.get(\"sources\"), list) else []:\n",
1383
+ " lines.append(f\"- {s}\")\n",
1384
+ " lines.append(\"- ParsecCatalog.pdf (local RAG)\")\n",
1385
+ " lines.append(\"- routers_eos_eol_by_sku.csv (replacements)\")\n",
1386
+ " return \"\\n\".join(lines)\n",
1387
+ "\n",
1388
+ "\n",
1389
+ "# ============================\n",
1390
+ "# Customer-ready email summary (single lookup only)\n",
1391
+ "# ============================\n",
1392
+ "\n",
1393
+ "def build_customer_email(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any], link5: str) -> str:\n",
1394
+ " \"\"\"Email-style summary the rep can paste to a customer (lightly sales-y).\"\"\"\n",
1395
+ " current = f\"{life_row.get('sku','')} — {life_row.get('description','')}\".strip(\" —\")\n",
1396
+ " repl5 = str(repl.get(\"repl_5g\",\"\") or \"\").strip()\n",
1397
+ " repl4 = str(repl.get(\"repl_4g\",\"\") or \"\").strip()\n",
1398
+ "\n",
1399
+ " st = ant.get(\"stationary_omni\", {}) or {}\n",
1400
+ " vh = ant.get(\"vehicle_omni\", {}) or {}\n",
1401
+ "\n",
1402
+ " lines = []\n",
1403
+ " lines.append(\"Subject: Router replacement recommendation\")\n",
1404
+ " lines.append(\"\")\n",
1405
+ " lines.append(\"Hi there,\")\n",
1406
+ " lines.append(\"\")\n",
1407
+ " lines.append(f\"We reviewed your current router (**{current}**) and recommend the following path forward:\")\n",
1408
+ " lines.append(\"\")\n",
1409
+ " lines.append(f\"- **Status:** {status}\")\n",
1410
+ " lines.append(f\"- **End of Sale:** {eos}\")\n",
1411
+ " lines.append(f\"- **End of Life:** {eol}\")\n",
1412
+ " lines.append(\"\")\n",
1413
+ " lines.append(\"**Recommended replacement (5G):**\")\n",
1414
+ " lines.append(f\"- {repl5 if repl5 else 'Not listed'}\")\n",
1415
+ " if link5:\n",
1416
+ " lines.append(f\"- Manufacturer page (best effort): {link5}\")\n",
1417
+ " lines.append(\"\")\n",
1418
+ " lines.append(\"**Optional 4G alternative (if needed):**\")\n",
1419
+ " lines.append(f\"- {repl4 if repl4 and repl4.lower() != 'not applicable' else 'Not applicable'}\")\n",
1420
+ " lines.append(\"\")\n",
1421
+ " lines.append(\"**Antenna suggestions (Parsec):**\")\n",
1422
+ " lines.append(f\"- Stationary (Omni): {st.get('name','')} (PN {st.get('part_number','')})\")\n",
1423
+ " lines.append(f\"- Vehicle (Omni): {vh.get('name','')} (PN {vh.get('part_number','')})\")\n",
1424
+ " lines.append(\"\")\n",
1425
+ " lines.append(\"If you’d like, we can confirm the best-fit option for your install environment and provide pricing.\")\n",
1426
+ " lines.append(\"\")\n",
1427
+ " lines.append(\"Contact Peter Dunn @ 786.999.9127 or peter.dunn@masterstelecom.com for pricing.\")\n",
1428
+ " lines.append(\"\")\n",
1429
+ " lines.append(\"Thanks,\")\n",
1430
+ " lines.append(\"Peter Dunn\")\n",
1431
+ " return \"\\n\".join(lines)\n",
1432
+ "\n",
1433
+ "def generate_customer_email(st_json: str) -> str:\n",
1434
+ " st = state_load(st_json)\n",
1435
+ " if not st or \"row_idx\" not in st:\n",
1436
+ " return \"Run a lookup first.\"\n",
1437
+ " try:\n",
1438
+ " life_row = df_eos.iloc[int(st[\"row_idx\"])]\n",
1439
+ " except Exception:\n",
1440
+ " return \"Run a lookup first.\"\n",
1441
+ "\n",
1442
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
1443
+ " repl = st.get(\"repl\", {}) or {}\n",
1444
+ " ant = st.get(\"ant\", {}) or {}\n",
1445
+ "\n",
1446
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
1447
+ " url5 = _best_effort_manufacturer_url(str(repl.get(\"repl_5g\",\"\") or \"\"), canon_make)\n",
1448
+ " return build_customer_email(life_row, status, eos, eol, repl, ant, url5)\n",
1449
+ "\n",
1450
+ "# ============================\n",
1451
+ "# Gradio callbacks\n",
1452
+ "# IMPORTANT: no dict state and ALL events have api_name=False (prevents api_info schema generation)\n",
1453
+ "# ============================\n",
1454
+ "def run_lookup(user_text: str, st_json: str):\n",
1455
+ " user_text = str(user_text or \"\").strip()\n",
1456
+ " if not user_text:\n",
1457
+ " return \"Enter a router SKU/model.\", \"\", None, None, \"\", gr.update(visible=False), gr.update(visible=False), \"{}\", \"\", \"\"\n",
1458
+ "\n",
1459
+ " res = resolve_device(user_text)\n",
1460
+ "\n",
1461
+ " if res.get(\"mode\") == \"pick\":\n",
1462
+ " opts = res.get(\"options\", [])\n",
1463
+ " choices = [o[\"label\"] for o in opts]\n",
1464
+ " st2 = {\"mode\":\"pick\",\"options\": opts, \"raw\": user_text}\n",
1465
+ " return \"Did you mean A or B? Pick one, then click Use selection.\", \"\", None, None, \"\", gr.update(choices=choices, value=None, visible=True), gr.update(visible=True), state_dump(st2), \"\", \"\"\n",
1466
+ "\n",
1467
+ " if res.get(\"mode\") != \"ok\":\n",
1468
+ " return \"Not found.\", \"\", None, None, \"\", gr.update(visible=False), gr.update(visible=False), \"{}\", \"\", \"\"\n",
1469
+ "\n",
1470
+ " life_row = df_eos.iloc[int(res[\"row_idx\"])]\n",
1471
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
1472
+ "\n",
1473
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)\n",
1474
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
1475
+ " mimo = infer_mimo_for_5g(repl.get(\"repl_5g\",\"\"))\n",
1476
+ " tech = \"5G\" if repl.get(\"repl_5g\") and repl.get(\"repl_5g\") != \"Not listed\" else (\"4G\" if device_is_4g(life_row) else \"Unknown\")\n",
1477
+ " ant = antenna_options_for(repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech, mimo)\n",
1478
+ "\n",
1479
+ " output = assemble_output(life_row, status, eos, eol, repl, ant)\n",
1480
+ " st_out = {\"row_idx\": int(res[\"row_idx\"]), \"repl\": repl, \"ant\": ant, \"raw\": user_text}\n",
1481
+ " url5 = _best_effort_manufacturer_url(repl.get('repl_5g',''), canon_make)\n",
1482
+ " link = f\"**5G manufacturer page (best effort):** {url5}\" if url5 else \"\"\n",
1483
+ " feat_df = build_replacement_features_table(repl.get('repl_4g',''), repl.get('repl_5g',''), canon_make)\n",
1484
+ " fit = build_fit_table(repl.get('repl_4g',''), repl.get('repl_5g',''), canon_make)\n",
1485
+ " return output, link, feat_df, fit, \"\", gr.update(visible=False), gr.update(visible=False), state_dump(st_out), \"\", \"\"\n",
1486
+ "\n",
1487
+ "def use_selection(selected_label: str, st_json: str):\n",
1488
+ " st = state_load(st_json)\n",
1489
+ " if not st or st.get(\"mode\") != \"pick\":\n",
1490
+ " return \"Run a search first.\", \"\", None, None, \"\", gr.update(visible=False), gr.update(visible=False), \"{}\", \"\", \"\"\n",
1491
+ "\n",
1492
+ " if not selected_label:\n",
1493
+ " return \"Pick A or B first.\", \"\", None, None, \"\", gr.update(visible=True), gr.update(visible=True), st_json, \"\", \"\"\n",
1494
+ "\n",
1495
+ " chosen_row = None\n",
1496
+ " for o in st.get(\"options\", []):\n",
1497
+ " if o.get(\"label\") == selected_label:\n",
1498
+ " chosen_row = int(o[\"row_idx\"])\n",
1499
+ " break\n",
1500
+ " if chosen_row is None:\n",
1501
+ " return \"Pick a valid option.\", \"\", None, None, \"\", gr.update(visible=True), gr.update(visible=True), st_json, \"\", \"\"\n",
1502
+ "\n",
1503
+ " life_row = df_eos.iloc[int(chosen_row)]\n",
1504
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
1505
+ "\n",
1506
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)\n",
1507
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
1508
+ " mimo = infer_mimo_for_5g(repl.get(\"repl_5g\",\"\"))\n",
1509
+ " tech = \"5G\" if repl.get(\"repl_5g\") and repl.get(\"repl_5g\") != \"Not listed\" else (\"4G\" if device_is_4g(life_row) else \"Unknown\")\n",
1510
+ " ant = antenna_options_for(repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech, mimo)\n",
1511
+ "\n",
1512
+ " output = assemble_output(life_row, status, eos, eol, repl, ant)\n",
1513
+ " st_out = {\"row_idx\": int(chosen_row), \"repl\": repl, \"ant\": ant, \"raw\": st.get(\"raw\",\"\")}\n",
1514
+ " url5 = _best_effort_manufacturer_url(repl.get('repl_5g',''), canon_make)\n",
1515
+ " link = f\"**5G manufacturer page (best effort):** {url5}\" if url5 else \"\"\n",
1516
+ " feat_df = build_replacement_features_table(repl.get('repl_4g',''), repl.get('repl_5g',''), canon_make)\n",
1517
+ " fit = build_fit_table(repl.get('repl_4g',''), repl.get('repl_5g',''), canon_make)\n",
1518
+ " return output, link, feat_df, fit, \"\", gr.update(visible=False), gr.update(visible=False), state_dump(st_out), \"\", \"\"\n",
1519
+ "\n",
1520
+ "def make_install_ready(st_json: str):\n",
1521
+ " st = state_load(st_json)\n",
1522
+ " if not st or \"row_idx\" not in st:\n",
1523
+ " return \"Run a lookup first.\"\n",
1524
+ " life_row = df_eos.iloc[int(st[\"row_idx\"])]\n",
1525
+ " current_sku = str(life_row.get(\"sku\",\"\") or \"\")\n",
1526
+ " return install_ready_checklist(current_sku, st.get(\"repl\", {}) or {}, st.get(\"ant\", {}) or {})\n",
1527
+ "\n",
1528
+ "\n",
1529
+ "\n",
1530
+ "# ============================\n",
1531
+ "# Q&A about the suggested device (post-recommendation)\n",
1532
+ "# ============================\n",
1533
+ "def answer_question(question: str, st_json: str) -> str:\n",
1534
+ " q = str(question or \"\").strip()\n",
1535
+ " if not q:\n",
1536
+ " return \"\"\n",
1537
+ " st = state_load(st_json)\n",
1538
+ " if not st or \"repl\" not in st:\n",
1539
+ " return \"Run a lookup first, then ask your question.\"\n",
1540
+ "\n",
1541
+ " repl = st.get(\"repl\", {}) or {}\n",
1542
+ " ant = st.get(\"ant\", {}) or {}\n",
1543
+ " repl5 = str(repl.get(\"repl_5g\",\"\") or \"\").strip()\n",
1544
+ " repl4 = str(repl.get(\"repl_4g\",\"\") or \"\").strip()\n",
1545
+ " # Pull a bit of dec context for the 5G model (if possible)\n",
1546
+ " canon_make = \"\"\n",
1547
+ " try:\n",
1548
+ " # Try to infer maker family from stored row_idx\n",
1549
+ " if \"row_idx\" in st:\n",
1550
+ " row = df_eos.iloc[int(st[\"row_idx\"])]\n",
1551
+ " canon_make = str(row.get(\"_canon_make\",\"UNKNOWN\"))\n",
1552
+ " except Exception:\n",
1553
+ " canon_make = \"\"\n",
1554
+ "\n",
1555
+ " # Manufacturer link (best effort)\n",
1556
+ " url5 = _best_effort_manufacturer_url(repl5, canon_make) if repl5 else \"\"\n",
1557
+ "\n",
1558
+ " # Feature table row for 5G (helps the LLM answer spec questions without web scraping)\n",
1559
+ " feat5 = {}\n",
1560
+ " try:\n",
1561
+ " feat5 = _features_from_dec(repl5, canon_make) if repl5 else {}\n",
1562
+ " except Exception:\n",
1563
+ " feat5 = {}\n",
1564
+ "\n",
1565
+ " sys = (\n",
1566
+ " \"You are a Verizon field rep assistant. Answer questions about the suggested router in a fast, practical way. \"\n",
1567
+ " \"Use the provided context; do not mention internal tools, prompts, embeddings, or databases. \"\n",
1568
+ " \"If the question is about specs and the value is unknown, say 'Not listed' and suggest checking the manufacturer page. \"\n",
1569
+ " \"Keep it concise and scannable.\"\n",
1570
+ " )\n",
1571
+ "\n",
1572
+ " context = {\n",
1573
+ " \"recommended_5g\": repl5,\n",
1574
+ " \"recommended_4g\": repl4 if repl4 and repl4.lower() != \"not applicable\" else \"\",\n",
1575
+ " \"manufacturer_link_5g\": url5,\n",
1576
+ " \"known_5g_features\": feat5,\n",
1577
+ " \"antenna_stationary\": ant.get(\"stationary_omni\", {}),\n",
1578
+ " \"antenna_vehicle\": ant.get(\"vehicle_omni\", {}),\n",
1579
+ " }\n",
1580
+ "\n",
1581
+ " user = \"Context:\\n\" + json.dumps(context, ensure_ascii=False) + \"\\n\\nQuestion:\\n\" + q\n",
1582
+ "\n",
1583
+ " ans = gpt_answer_md(sys, user, max_tokens=650)\n",
1584
+ " # Small safety fallback\n",
1585
+ " return ans if ans else \"I couldn't generate an answer right now. Try again.\"\n",
1586
+ "\n",
1587
+ "# ============================\n",
1588
+ "# UI\n",
1589
+ "# ============================\n",
1590
+ "with gr.Blocks(title=\"Only-Routers\") as demo:\n",
1591
+ " gr.Markdown(\"## Only-Routers\\nSingle lookup + Batch upload for Verizon reps.\")\n",
1592
+ "\n",
1593
+ " with gr.Tabs():\n",
1594
+ " with gr.Tab(\"Single\"):\n",
1595
+ " # Inputs\n",
1596
+ " user_text = gr.Textbox(\n",
1597
+ " label=\"Router SKU or model\",\n",
1598
+ " placeholder=\"Examples: IBR650B, AER1600, ES450, WR21, RUT240\",\n",
1599
+ " lines=1,\n",
1600
+ " )\n",
1601
+ " st = gr.State(\"{}\") # JSON string state\n",
1602
+ "\n",
1603
+ " # Actions\n",
1604
+ " check_btn = gr.Button(\"Check\", variant=\"primary\")\n",
1605
+ " pick_dd = gr.Dropdown(label=\"Pick A or B\", choices=[], visible=False)\n",
1606
+ " use_btn = gr.Button(\"Use selection\", visible=False)\n",
1607
+ "\n",
1608
+ " # Main outputs\n",
1609
+ " output_md = gr.Markdown()\n",
1610
+ " link_md = gr.Markdown()\n",
1611
+ " features_df = gr.Dataframe(headers=FEATURE_COLS, interactive=False, wrap=True)\n",
1612
+ " fit_df = gr.Dataframe(headers=FIT_COLS, interactive=False, wrap=True)\n",
1613
+ " qa_md = gr.Markdown()\n",
1614
+ "\n",
1615
+ " # Post-recommendation Q&A\n",
1616
+ " gr.Markdown(\"### Questions about the suggested device?\")\n",
1617
+ " question_box = gr.Textbox(\n",
1618
+ " label=\"Ask a question (optional)\",\n",
1619
+ " placeholder=\"Example: Does the 5G device support dual-SIM? How many ethernet ports? Does it support Wi‑Fi?\",\n",
1620
+ " lines=2,\n",
1621
+ " )\n",
1622
+ " ask_btn = gr.Button(\"Ask\", variant=\"secondary\")\n",
1623
+ "\n",
1624
+ " # Install-ready checklist\n",
1625
+ " install_btn = gr.Button(\"Make install-ready checklist\")\n",
1626
+ " install_md = gr.Markdown()\n",
1627
+ "\n",
1628
+ " # Customer-ready email summary\n",
1629
+ " gr.Markdown(\"### Customer-ready email\")\n",
1630
+ " email_btn = gr.Button(\"Generate customer email\")\n",
1631
+ " customer_email_box = gr.Textbox(label=\"Email draft\", lines=10)\n",
1632
+ "\n",
1633
+ " # Wiring (api_name=False avoids HF/Gradio API schema issues)\n",
1634
+ " check_btn.click(\n",
1635
+ " fn=run_lookup,\n",
1636
+ " inputs=[user_text, st],\n",
1637
+ " outputs=[output_md, link_md, features_df, fit_df, qa_md, pick_dd, use_btn, st, install_md, customer_email_box],\n",
1638
+ " api_name=False,\n",
1639
+ " )\n",
1640
+ " use_btn.click(\n",
1641
+ " fn=use_selection,\n",
1642
+ " inputs=[pick_dd, st],\n",
1643
+ " outputs=[output_md, link_md, features_df, fit_df, qa_md, pick_dd, use_btn, st, install_md, customer_email_box],\n",
1644
+ " api_name=False,\n",
1645
+ " )\n",
1646
+ " ask_btn.click(\n",
1647
+ " fn=answer_question,\n",
1648
+ " inputs=[question_box, st],\n",
1649
+ " outputs=[qa_md],\n",
1650
+ " api_name=False,\n",
1651
+ " )\n",
1652
+ " install_btn.click(\n",
1653
+ " fn=make_install_ready,\n",
1654
+ " inputs=[st],\n",
1655
+ " outputs=[install_md],\n",
1656
+ " api_name=False,\n",
1657
+ " )\n",
1658
+ " email_btn.click(\n",
1659
+ " fn=generate_customer_email,\n",
1660
+ " inputs=[st],\n",
1661
+ " outputs=[customer_email_box],\n",
1662
+ " api_name=False,\n",
1663
+ " )\n",
1664
+ "\n",
1665
+ " with gr.Tab(\"Batch\"):\n",
1666
+ " gr.Markdown(\"Paste one per line or upload a CSV (first column). Batch runs fast (no GPT).\")\n",
1667
+ " batch_text = gr.Textbox(label=\"Paste devices (one per line)\", lines=8, placeholder=\"WR21\\nRUT240\\nIBR650B\")\n",
1668
+ " batch_file = gr.File(label=\"Upload CSV\", file_types=[\".csv\"])\n",
1669
+ " include_ant = gr.Checkbox(label=\"Include antenna picks (slower)\", value=False)\n",
1670
+ " run_btn = gr.Button(\"Run batch\", variant=\"primary\")\n",
1671
+ "\n",
1672
+ " summary_md = gr.Markdown()\n",
1673
+ " rollup_md = gr.Markdown()\n",
1674
+ " table = gr.Dataframe(interactive=False, wrap=True)\n",
1675
+ " dl = gr.File(label=\"Download results CSV\")\n",
1676
+ "\n",
1677
+ " run_btn.click(\n",
1678
+ " fn=run_batch,\n",
1679
+ " inputs=[batch_text, batch_file, include_ant],\n",
1680
+ " outputs=[summary_md, table, dl, rollup_md],\n",
1681
+ " api_name=False,\n",
1682
+ " )\n",
1683
+ "\n",
1684
+ "demo.launch(show_api=False)\n"
1685
+ ]
1686
+ }
1687
+ ],
1688
+ "metadata": {
1689
+ "kernelspec": {
1690
+ "display_name": "Python 3",
1691
+ "name": "python3"
1692
+ },
1693
+ "language_info": {
1694
+ "name": "python"
1695
+ }
1696
+ },
1697
+ "nbformat": 4,
1698
+ "nbformat_minor": 5
1699
+ }
app.py CHANGED
The diff for this file is too large to render. See raw diff
 
requirements_prod.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Lean requirements for this app.
2
+ # Gradio is preinstalled by the Hugging Face Space base image.
3
+ openai>=1.0.0
4
+ rapidfuzz>=3.0.0
5
+ requests>=2.31.0