crazycrazypete commited on
Commit
7265825
·
verified ·
1 Parent(s): ae6522c

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. Updates/app.py +937 -0
  2. app.py +355 -105
  3. only-routers_ai_poc_hf_chat_prod_v2.ipynb +1221 -0
Updates/app.py ADDED
@@ -0,0 +1,937 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import math
5
+ import time
6
+ import hashlib
7
+ import tempfile
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, date
10
+ from functools import lru_cache
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ import fitz # PyMuPDF
17
+ import faiss
18
+ from sentence_transformers import SentenceTransformer
19
+ from rapidfuzz import fuzz, process
20
+
21
+ import gradio as gr
22
+ from openai import OpenAI
23
+
24
+ # ============================================================
25
+ # Only-Routers (Chat, production-lean)
26
+ # - Fast model by default (no reasoning payload)
27
+ # - One LLM call max per lookup (enrichment only, cached)
28
+ # - No HTTP crawling during normal lookup (links are deterministic)
29
+ # - Timing logs to HF console when DEBUG_TIMING=1
30
+ # ============================================================
31
+
32
+ # ----------------------------
33
+ # Settings
34
+ # ----------------------------
35
+ TODAY = date(2026, 1, 18)
36
+
37
+ # Fast default model (override via env)
38
+ OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-5.2").strip()
39
+
40
+ # Disable LLM at runtime: OPENAI_DISABLE=1
41
+ OPENAI_DISABLE = os.getenv("OPENAI_DISABLE", "0").strip() == "1"
42
+
43
+ # Timing logs
44
+ DEBUG_TIMING = os.getenv("DEBUG_TIMING", "0").strip() == "1"
45
+
46
+ # Matching thresholds
47
+ MATCH_OK = 82
48
+ MATCH_AUTOPICK = 95
49
+ MATCH_GAP = 8
50
+
51
+ # Embeddings
52
+ EMBED_MODEL_NAME = os.getenv("EMBED_MODEL_NAME", "sentence-transformers/all-MiniLM-L6-v2").strip()
53
+
54
+ # Parsec PDF slicing
55
+ PARSEC_CONTEXT_BEFORE = 900
56
+ PARSEC_CONTEXT_AFTER = 1600
57
+
58
+ # ----------------------------
59
+ # OpenAI client
60
+ # ----------------------------
61
+ API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
62
+ client = None if (not API_KEY or OPENAI_DISABLE) else OpenAI(api_key=API_KEY)
63
+
64
+ # ----------------------------
65
+ # Timing helper
66
+ # ----------------------------
67
+ def _tlog(label: str, t0: float) -> None:
68
+ if DEBUG_TIMING:
69
+ dt = time.perf_counter() - t0
70
+ print(f"[TIMER] {label}: {dt:.2f}s")
71
+
72
+ # ----------------------------
73
+ # JSON-safe helpers
74
+ # ----------------------------
75
+ def _json_load_safe(s: str) -> Dict[str, Any]:
76
+ try:
77
+ return json.loads(s)
78
+ except Exception:
79
+ return {}
80
+
81
+ def _json_dump_safe(obj: Any) -> str:
82
+ try:
83
+ return json.dumps(obj, ensure_ascii=False)
84
+ except Exception:
85
+ return "{}"
86
+
87
+ # ----------------------------
88
+ # Gradio state helpers (string JSON only)
89
+ # ----------------------------
90
+ def state_load(st_json: str) -> Dict[str, Any]:
91
+ try:
92
+ return json.loads(st_json) if isinstance(st_json, str) and st_json else {}
93
+ except Exception:
94
+ return {}
95
+
96
+ def state_dump(st: Dict[str, Any]) -> str:
97
+ return _json_dump_safe(st or {})
98
+
99
+ # ----------------------------
100
+ # Normalization
101
+ # ----------------------------
102
+ def norm_text(x: Any) -> str:
103
+ try:
104
+ if x is None or (isinstance(x, float) and math.isnan(x)) or pd.isna(x):
105
+ return ""
106
+ except Exception:
107
+ pass
108
+ s = str(x).strip().lower()
109
+ s = re.sub(r"[^a-z0-9\s\-\/]", " ", s)
110
+ s = re.sub(r"\s+", " ", s).strip()
111
+ return s
112
+
113
+ def safe_str(x: Any) -> str:
114
+ if x is None or (isinstance(x, float) and pd.isna(x)) or pd.isna(x):
115
+ return ""
116
+ return str(x).strip()
117
+
118
+ def is_5g_text(s: str) -> bool:
119
+ t = norm_text(s)
120
+ return ("5g" in t) or ("nr" in t)
121
+
122
+ def is_4g_lte_family(row: pd.Series) -> bool:
123
+ # Treat LTE categories as 4G
124
+ t = norm_text(row.get("description", "")) + " " + norm_text(row.get("notes", ""))
125
+ if "5g" in t or "nr" in t:
126
+ return False
127
+ if "lte" in t or "4g" in t:
128
+ return True
129
+ if re.search(r"\bcat\s*[-]?\s*(m1|m2)\b", t):
130
+ return True
131
+ if re.search(r"\bcat\s*[-]?\s*\d{1,2}\b", t):
132
+ return True
133
+ if "cat" in t:
134
+ return True
135
+ return False
136
+
137
+ # ----------------------------
138
+ # Lifecycle CSV normalization
139
+ # ----------------------------
140
+ def _normalize_lifecycle_df(df: pd.DataFrame) -> pd.DataFrame:
141
+ df = df.copy()
142
+ lower_cols = {c.lower(): c for c in df.columns}
143
+
144
+ def _pick(*names):
145
+ for n in names:
146
+ if n.lower() in lower_cols:
147
+ return lower_cols[n.lower()]
148
+ return None
149
+
150
+ col_map = {}
151
+
152
+ sku_col = _pick("sku", "SKU")
153
+ if sku_col:
154
+ col_map[sku_col] = "sku"
155
+
156
+ mfr_col = _pick("manufacturer", "Manufacturer")
157
+ if mfr_col:
158
+ col_map[mfr_col] = "manufacturer"
159
+
160
+ dt_col = _pick("device type", "Device Type", "device_type")
161
+ if dt_col:
162
+ col_map[dt_col] = "device_type"
163
+
164
+ eos_col = _pick("end_of_sale", "end of sale", "End of Sale", "eos")
165
+ if eos_col:
166
+ col_map[eos_col] = "end_of_sale"
167
+
168
+ eol_col = _pick("end_of_life", "end of life", "End of Life", "eol")
169
+ if eol_col:
170
+ col_map[eol_col] = "end_of_life"
171
+
172
+ sr_col = _pick("suggested_replacement", "Suggested Replacement")
173
+ if sr_col:
174
+ col_map[sr_col] = "suggested_replacement"
175
+
176
+ a5_col = _pick("advanced_5g_option", "Advanced 5G Option", "advanced 5g option")
177
+ if a5_col:
178
+ col_map[a5_col] = "advanced_5g_option"
179
+
180
+ df = df.rename(columns=col_map)
181
+
182
+ for req in ["sku", "manufacturer", "device_type", "end_of_sale", "end_of_life", "suggested_replacement", "advanced_5g_option"]:
183
+ if req not in df.columns:
184
+ df[req] = ""
185
+
186
+ # Compatibility fields used by matching/output
187
+ if "description" not in df.columns:
188
+ df["description"] = df["sku"].astype(str)
189
+ if "notes" not in df.columns:
190
+ df["notes"] = ""
191
+ if "region" not in df.columns:
192
+ df["region"] = ""
193
+
194
+ return df
195
+
196
+ # ----------------------------
197
+ # Maker mapping
198
+ # ----------------------------
199
+ CANON_MAKER = {
200
+ "CRADLEPOINT": {"cradlepoint", "ericsson", "ericsson enterprise wireless"},
201
+ "SIERRA": {"sierra", "sierra wireless", "semtech", "airlink"},
202
+ "FEENEY": {"feeney", "feeney wireless", "inseego"},
203
+ "DIGI": {"digi", "accelerated", "accelerated concepts"},
204
+ "CISCO_MERAKI": {"meraki", "cisco meraki"},
205
+ "CISCO": {"cisco"},
206
+ "TELTONIKA": {"teltonika"},
207
+ }
208
+
209
+ def canon_maker_from_text(s: Any) -> str:
210
+ t = norm_text(s)
211
+ for canon, terms in CANON_MAKER.items():
212
+ for term in terms:
213
+ if term in t:
214
+ return canon
215
+ return "UNKNOWN"
216
+
217
+ # ----------------------------
218
+ # Date parsing
219
+ # ----------------------------
220
+ @dataclass
221
+ class ParsedDate:
222
+ raw: str
223
+ kind: str
224
+ value: Optional[date]
225
+
226
+ def parse_date_field(x: Any) -> ParsedDate:
227
+ raw = safe_str(x)
228
+ if not raw:
229
+ return ParsedDate(raw="", kind="missing", value=None)
230
+
231
+ # MM/DD/YY or M/D/YY
232
+ if re.fullmatch(r"\d{1,2}/\d{1,2}/\d{2,4}", raw):
233
+ try:
234
+ parts = raw.split("/")
235
+ m = int(parts[0]); d = int(parts[1]); y = int(parts[2])
236
+ if y < 100:
237
+ y += 2000
238
+ dt = date(y, m, d)
239
+ return ParsedDate(raw=f"{y:04d}-{m:02d}-{d:02d}", kind="full", value=dt)
240
+ except Exception:
241
+ return ParsedDate(raw=raw, kind="bad", value=None)
242
+
243
+ # YYYY
244
+ if re.fullmatch(r"\d{4}", raw):
245
+ y = int(raw)
246
+ if y == TODAY.year:
247
+ return ParsedDate(raw=raw, kind="year", value=date(y, 1, 1))
248
+ if y < TODAY.year:
249
+ return ParsedDate(raw=raw, kind="year", value=date(y, 1, 1))
250
+ return ParsedDate(raw=raw, kind="year", value=date(y, 12, 31))
251
+
252
+ # YYYY-MM
253
+ if re.fullmatch(r"\d{4}-\d{2}", raw):
254
+ try:
255
+ y, m = raw.split("-")
256
+ dt = date(int(y), int(m), 1)
257
+ return ParsedDate(raw=raw, kind="year_month", value=dt)
258
+ except Exception:
259
+ return ParsedDate(raw=raw, kind="bad", value=None)
260
+
261
+ # YYYY-MM-DD
262
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw):
263
+ try:
264
+ dt = datetime.strptime(raw, "%Y-%m-%d").date()
265
+ return ParsedDate(raw=raw, kind="full", value=dt)
266
+ except Exception:
267
+ return ParsedDate(raw=raw, kind="bad", value=None)
268
+
269
+ return ParsedDate(raw=raw, kind="bad", value=None)
270
+
271
+ def display_date(pd_: ParsedDate) -> str:
272
+ if pd_.kind == "missing":
273
+ return "Not listed"
274
+ if pd_.kind == "bad":
275
+ return pd_.raw or "Not listed"
276
+ return pd_.raw
277
+
278
+ def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:
279
+ if eos.value is None and eol.value is None:
280
+ return "Unknown"
281
+ if eol.value is not None and eol.value <= TODAY:
282
+ return "End of Life"
283
+ if eos.value is not None and eos.value <= TODAY:
284
+ return "End of Sale"
285
+ return "Active"
286
+
287
+ def row_to_dates_and_status(row: pd.Series) -> Tuple[str, str, str]:
288
+ eos = parse_date_field(row.get("end_of_sale"))
289
+ eol = parse_date_field(row.get("end_of_life"))
290
+ return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)
291
+
292
+ # ----------------------------
293
+ # Files
294
+ # ----------------------------
295
+ EOS_PATH = "routers_eos_eol_by_sku.csv"
296
+ DEC_PATH = "dec2025routers.csv"
297
+ PARSEC_PDF = "ParsecCatalog.pdf"
298
+
299
+ if not os.path.exists(EOS_PATH):
300
+ raise FileNotFoundError(f"Missing {EOS_PATH} in repo.")
301
+ if not os.path.exists(DEC_PATH):
302
+ raise FileNotFoundError(f"Missing {DEC_PATH} in repo.")
303
+ if not os.path.exists(PARSEC_PDF):
304
+ raise FileNotFoundError(f"Missing {PARSEC_PDF} in repo.")
305
+
306
+ t0 = time.perf_counter()
307
+ df_eos = pd.read_csv(EOS_PATH).copy()
308
+ df_dec = pd.read_csv(DEC_PATH).copy()
309
+ df_eos = _normalize_lifecycle_df(df_eos)
310
+
311
+ # Canon columns
312
+ df_eos["_canon_make"] = df_eos["manufacturer"].apply(canon_maker_from_text)
313
+ df_eos["_norm_sku"] = df_eos["sku"].apply(norm_text)
314
+ df_eos["_norm_desc"] = df_eos["description"].apply(norm_text)
315
+ df_eos["_norm_notes"] = df_eos["notes"].apply(norm_text)
316
+
317
+ df_dec["_canon_make"] = df_dec["Make"].apply(canon_maker_from_text) if "Make" in df_dec.columns else "UNKNOWN"
318
+ df_dec["_norm_model"] = df_dec["Model"].apply(norm_text) if "Model" in df_dec.columns else ""
319
+ df_dec["_is5g"] = df_dec["Modem Type"].apply(lambda x: is_5g_text(str(x))) if "Modem Type" in df_dec.columns else False
320
+ _tlog("load csv", t0)
321
+
322
+ # ----------------------------
323
+ # Build fuzzy corpus for device matching
324
+ # ----------------------------
325
+ def _label_for_row(i: int) -> str:
326
+ r = df_eos.iloc[i]
327
+ return f"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}"[:220]
328
+
329
+ EOS_LABELS = [_label_for_row(i) for i in range(len(df_eos))]
330
+ EOS_CORPUS = []
331
+ for _, r in df_eos.iterrows():
332
+ EOS_CORPUS.append(" ".join([r.get("_norm_sku",""), r.get("_canon_make",""), r.get("_norm_desc",""), r.get("_norm_notes","")]))
333
+
334
+ def resolve_device(term: str) -> Dict[str, Any]:
335
+ q = norm_text(term)
336
+ if not q:
337
+ return {"mode": "not_found"}
338
+
339
+ exact = df_eos.index[df_eos["_norm_sku"] == q].tolist()
340
+ if len(exact) == 1:
341
+ return {"mode":"ok","row_idx": int(exact[0])}
342
+
343
+ hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=6)
344
+ cands = [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]
345
+
346
+ if not cands:
347
+ return {"mode":"not_found"}
348
+
349
+ if cands[0][1] >= MATCH_AUTOPICK and (len(cands) == 1 or (cands[0][1] - cands[1][1]) >= MATCH_GAP):
350
+ return {"mode":"ok","row_idx": cands[0][0]}
351
+
352
+ opts = [{"row_idx": cands[0][0], "label": cands[0][2]}]
353
+ if len(cands) > 1:
354
+ opts.append({"row_idx": cands[1][0], "label": cands[1][2]})
355
+ return {"mode":"pick","options": opts}
356
+
357
+ # ----------------------------
358
+ # Parsec RAG (FAISS)
359
+ # ----------------------------
360
+ t0 = time.perf_counter()
361
+ embedder = SentenceTransformer(EMBED_MODEL_NAME)
362
+
363
+ def extract_pdf_text_pages(path: str) -> List[str]:
364
+ doc = fitz.open(path)
365
+ return [doc[i].get_text("text") for i in range(len(doc))]
366
+
367
+ def build_parsec_cards(pages: List[str]) -> List[str]:
368
+ cards = []
369
+ for p in pages:
370
+ for m in re.finditer(r"Standard\s+SKU:", p):
371
+ start = max(0, m.start() - PARSEC_CONTEXT_BEFORE)
372
+ end = min(len(p), m.start() + PARSEC_CONTEXT_AFTER)
373
+ c = p[start:end].strip()
374
+ if len(c) >= 200:
375
+ cards.append(c)
376
+ out, seen = [], set()
377
+ for c in cards:
378
+ h = hashlib.sha1(c.encode("utf-8")).hexdigest()
379
+ if h not in seen:
380
+ seen.add(h); out.append(c)
381
+ return out
382
+
383
+ parsec_cards = build_parsec_cards(extract_pdf_text_pages(PARSEC_PDF))
384
+ parsec_emb = embedder.encode(parsec_cards, batch_size=64, show_progress_bar=False, normalize_embeddings=True)
385
+ parsec_emb = np.asarray(parsec_emb, dtype=np.float32)
386
+ parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])
387
+ parsec_index.add(parsec_emb)
388
+ _tlog("parsec index", t0)
389
+
390
+ PARSEC_FAMILY_WORDS = {"chinook","labrador","boxer","bloodhound","husky","beagle","mastiff","collie","shepherd","belgian","australian","terrier","pyrenees"}
391
+
392
+ def _parsec_name_from_card(card_text: str) -> str:
393
+ low = card_text.lower()
394
+ for fam in PARSEC_FAMILY_WORDS:
395
+ if fam in low:
396
+ return fam.capitalize()
397
+ return "Parsec antenna"
398
+
399
+ def _parsec_part_from_card(t: str) -> str:
400
+ m = re.search(r"Standard\s+SKU:\s*([A-Z0-9]+)", t)
401
+ return m.group(1).strip() if m else ""
402
+
403
+ def _parsec_desc_from_card(t: str) -> str:
404
+ m = re.search(r"Description:\s*(.+?)(?:\n|$)", t, flags=re.IGNORECASE)
405
+ return re.sub(r"\s+"," ",m.group(1).strip())[:220] if m else ""
406
+
407
+ def _parsec_connectors_from_card(t: str) -> str:
408
+ m = re.search(r"Standard\s+Connectors:\s*(.+)", t, flags=re.IGNORECASE)
409
+ return re.sub(r"\s+"," ",m.group(1).strip())[:80] if m else ""
410
+
411
+ def parsec_retrieve(query: str, top_k: int = 8) -> List[Dict[str, Any]]:
412
+ qv = embedder.encode([query], normalize_embeddings=True)
413
+ qv = np.asarray(qv, dtype=np.float32)
414
+ scores, ids = parsec_index.search(qv, top_k)
415
+ out = []
416
+ for sc, i in zip(scores[0].tolist(), ids[0].tolist()):
417
+ if 0 <= int(i) < len(parsec_cards):
418
+ card = parsec_cards[int(i)]
419
+ out.append({
420
+ "score": float(sc),
421
+ "name": _parsec_name_from_card(card),
422
+ "part_number": _parsec_part_from_card(card),
423
+ "description": _parsec_desc_from_card(card),
424
+ "connectors": _parsec_connectors_from_card(card),
425
+ })
426
+ return out
427
+
428
+ def antenna_pick(repl5: str, mode: str, detail: Optional[str]) -> Dict[str, Any]:
429
+ mimo = "4x4" # rule: all 5G -> 4x4
430
+ tech = "5G"
431
+ if mode == "vehicle":
432
+ q = f"{repl5} {tech} {mimo} omni vehicle mobile magnetic through-bolt"
433
+ c = parsec_retrieve(q, top_k=8)
434
+ best = c[0] if c else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
435
+ best.update({"mimo": mimo, "why": "Vehicle omni best match."})
436
+ return best
437
+
438
+ if detail == "directional":
439
+ q = f"{repl5} {tech} {mimo} directional fixed site"
440
+ c = parsec_retrieve(q, top_k=8)
441
+ best = c[0] if c else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
442
+ best.update({"mimo": mimo, "why": "Stationary directional best match."})
443
+ return best
444
+
445
+ if detail == "indoor":
446
+ q = f"{repl5} {tech} {mimo} omni indoor"
447
+ c = parsec_retrieve(q, top_k=8)
448
+ best = c[0] if c else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
449
+ best.update({"mimo": mimo, "why": "Stationary indoor omni best match."})
450
+ return best
451
+
452
+ q = f"{repl5} {tech} {mimo} omni outdoor pole wall fixed site"
453
+ c = parsec_retrieve(q, top_k=8)
454
+ best = c[0] if c else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
455
+ best.update({"mimo": mimo, "why": "Stationary outdoor omni best match."})
456
+ return best
457
+
458
+ # ----------------------------
459
+ # Replacement selection (lifecycle-first)
460
+ # ----------------------------
461
+ def extract_model_token(text: str) -> str:
462
+ s = safe_str(text)
463
+ if not s:
464
+ return ""
465
+ parts = [p.strip() for p in s.split("|") if p.strip()]
466
+ candidates = parts[::-1] if parts else [s]
467
+ for cand in candidates:
468
+ u = cand.upper()
469
+ m = re.search(r"\bRUT[A-Z]?\d{2,4}\b", u)
470
+ if m:
471
+ return m.group(0)
472
+ m = re.search(r"\bRUTM\d{2,3}\b", u)
473
+ if m:
474
+ return m.group(0)
475
+ m = re.search(r"\bIX\d{2}\b", u)
476
+ if m:
477
+ return m.group(0)
478
+ m = re.search(r"\b(R\d{3,4}|E\d{3,4}|S\d{3,4})\b", u)
479
+ if m:
480
+ return m.group(0)
481
+ m = re.search(r"\b[A-Z]{1,6}\d{2,4}[A-Z]?\b", u)
482
+ if m:
483
+ return m.group(0)
484
+ return candidates[0][:60]
485
+
486
+ def pick_replacements(row: pd.Series, status: str) -> Dict[str, str]:
487
+ sug = safe_str(row.get("suggested_replacement", ""))
488
+ adv = safe_str(row.get("advanced_5g_option", ""))
489
+
490
+ repl_4g = extract_model_token(sug) if sug else "Not applicable"
491
+ repl_5g = extract_model_token(adv) if adv else "Not listed"
492
+
493
+ # Always provide some 5G answer: if lifecycle missing, pick top 5G from dec (same maker)
494
+ if repl_5g in {"", "Not listed"}:
495
+ canon_make = str(row.get("_canon_make","UNKNOWN"))
496
+ pool = df_dec[(df_dec["_canon_make"] == canon_make) & (df_dec["_is5g"] == True)].copy()
497
+ repl_5g = str(pool.iloc[0]["Model"]).strip() if not pool.empty else "Not listed"
498
+
499
+ return {"repl_4g": repl_4g or "Not applicable", "repl_5g": repl_5g or "Not listed"}
500
+
501
+ # ----------------------------
502
+ # Features + Fit (dec first, single LLM enrichment call if needed)
503
+ # ----------------------------
504
+ FEATURE_COLS = ["Device", "Modem technology", "WiFi", "Ports", "Antennas", "Ruggedness", "Use case"]
505
+ FIT_COLS = ["Device", "Fit badges", "Ethernet ports", "Battery"]
506
+
507
+ def _features_from_dec(model: str, canon_make: str) -> Dict[str, str]:
508
+ if not model or model in {"Not listed", "Not applicable"}:
509
+ return {k: "Not listed" for k in FEATURE_COLS[1:]}
510
+ pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
511
+ if pool.empty:
512
+ return {k: "Not listed" for k in FEATURE_COLS[1:]}
513
+ hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
514
+ if not hit or hit[1] < MATCH_OK:
515
+ return {k: "Not listed" for k in FEATURE_COLS[1:]}
516
+ r = pool.iloc[int(hit[2])]
517
+ ports = f"WAN: {r.get('WAN ports and speed','')} | LAN: {r.get('LAN ports and speed','')}".strip()
518
+ return {
519
+ "Modem technology": str(r.get("Modem Type","") or "Not listed"),
520
+ "WiFi": str(r.get("WiFi type","") or "Not listed"),
521
+ "Ports": ports if ports else "Not listed",
522
+ "Antennas": str(r.get("Antennas (internal/external/both)","") or "Not listed"),
523
+ "Ruggedness": str(r.get("Ruggedization","") or "Not listed"),
524
+ "Use case": str(r.get("Primary use case","") or "Not listed"),
525
+ }
526
+
527
+ def _fit_from_dec(model: str, canon_make: str, is5: bool) -> Dict[str, str]:
528
+ badges = []
529
+ eth = "Not listed"
530
+ bat = "Not listed"
531
+ if is5:
532
+ badges.append("4x4 MIMO")
533
+
534
+ pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
535
+ if pool.empty or not model or model in {"Not listed", "Not applicable"}:
536
+ return {"Fit badges": ", ".join(badges) if badges else "Not listed", "Ethernet ports": eth, "Battery": bat}
537
+
538
+ hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
539
+ if not hit or hit[1] < MATCH_OK:
540
+ return {"Fit badges": ", ".join(badges) if badges else "Not listed", "Ethernet ports": eth, "Battery": bat}
541
+
542
+ r = pool.iloc[int(hit[2])]
543
+ use_case = str(r.get("Primary use case","") or "").lower()
544
+ rugged = str(r.get("Ruggedization","") or "").lower()
545
+ wifi = str(r.get("WiFi type","") or "").strip().lower()
546
+ serial = str(r.get("Serial port (yes/no)","") or "").strip().lower()
547
+ battery = str(r.get("Battery (internal/removable/none/optional)","") or "").strip().lower()
548
+ notes_blob = " ".join([str(r.get("Special notes","") or ""), str(r.get("summary and use case","") or "")]).lower()
549
+
550
+ if any(k in use_case for k in ["vehicle","mobile","fleet","in-vehicle"]) or "vehicle" in rugged:
551
+ badges.append("Vehicle")
552
+ else:
553
+ badges.append("Fixed site")
554
+
555
+ if wifi and wifi not in {"none","no","n/a"}:
556
+ badges.append("Wi‑Fi")
557
+ if any(k in rugged for k in ["rugged","industrial","ip","harsh"]):
558
+ badges.append("Rugged")
559
+ if "dual" in notes_blob and "sim" in notes_blob:
560
+ badges.append("Dual‑SIM")
561
+ if serial in {"yes","y","true"}:
562
+ badges.append("Serial")
563
+
564
+ if battery:
565
+ if "none" in battery:
566
+ bat = "No"
567
+ else:
568
+ bat = "Yes"
569
+
570
+ badges_csv = ", ".join(dict.fromkeys(badges)) if badges else "Not listed"
571
+ return {"Fit badges": badges_csv, "Ethernet ports": eth, "Battery": bat}
572
+
573
+ # Enrichment cache (one call per (make, repl4, repl5))
574
+ _ENRICH_CACHE: Dict[str, Dict[str, Any]] = {}
575
+
576
+ def _enrich_key(canon_make: str, repl4: str, repl5: str) -> str:
577
+ return hashlib.sha1(f"{canon_make}|{repl4}|{repl5}".encode("utf-8")).hexdigest()
578
+
579
+ def gpt_enrich(repl4: str, repl5: str, canon_make: str, feat4: Dict[str,str], feat5: Dict[str,str], fit4: Dict[str,str], fit5: Dict[str,str]) -> Dict[str, Any]:
580
+ if client is None:
581
+ return {"feat4": feat4, "feat5": feat5, "fit4": fit4, "fit5": fit5}
582
+
583
+ key = _enrich_key(canon_make, repl4, repl5)
584
+ if key in _ENRICH_CACHE:
585
+ return _ENRICH_CACHE[key]
586
+
587
+ def miss(d: Dict[str,str]) -> List[str]:
588
+ out=[]
589
+ for k,v in d.items():
590
+ if (not v) or str(v).strip().lower() in {"not listed","nan",""}:
591
+ out.append(k)
592
+ return out
593
+
594
+ m_feat4 = miss(feat4); m_feat5 = miss(feat5)
595
+ m_fit4 = miss(fit4); m_fit5 = miss(fit5)
596
+
597
+ if not (m_feat4 or m_feat5 or m_fit4 or m_fit5):
598
+ pack = {"feat4": feat4, "feat5": feat5, "fit4": fit4, "fit5": fit5}
599
+ _ENRICH_CACHE[key] = pack
600
+ return pack
601
+
602
+ sys = (
603
+ "You are helping a Verizon rep. Fill missing router feature fields and fit traits. Return strict JSON only. "
604
+ "Keep values short. "
605
+ "Fit badges must be chosen from: ['Vehicle','Fixed site','Wi‑Fi','Rugged','Dual‑SIM','4x4 MIMO','High throughput','Serial'] only. "
606
+ "Rule: if a router is 5G, include '4x4 MIMO'. "
607
+ "Ethernet ports must be a single integer as a string when possible; else 'Not listed'. "
608
+ "Battery must be 'Yes', 'No', or 'Not listed'."
609
+ )
610
+
611
+ payload = {
612
+ "maker_family": canon_make,
613
+ "models": {"repl4": repl4, "repl5": repl5},
614
+ "known": {"feat4": feat4, "feat5": feat5, "fit4": fit4, "fit5": fit5},
615
+ "missing": {"feat4": m_feat4, "feat5": m_feat5, "fit4": m_fit4, "fit5": m_fit5},
616
+ "output_schema": {
617
+ "feat4": {k: "string" for k in m_feat4},
618
+ "feat5": {k: "string" for k in m_feat5},
619
+ "fit4": {k: "string" for k in m_fit4},
620
+ "fit5": {k: "string" for k in m_fit5},
621
+ },
622
+ }
623
+
624
+ t0 = time.perf_counter()
625
+ resp = client.responses.create(
626
+ model=OPENAI_MODEL,
627
+ input=[{"role":"system","content":sys},{"role":"user","content":_json_dump_safe(payload)}],
628
+ max_output_tokens=420,
629
+ )
630
+ _tlog("llm enrich", t0)
631
+
632
+ out = _json_load_safe(getattr(resp, "output_text", "") or "")
633
+
634
+ def merge(base: Dict[str,str], patch: Any) -> Dict[str,str]:
635
+ if isinstance(patch, dict):
636
+ for k,v in patch.items():
637
+ sv = str(v or "").strip()
638
+ if sv:
639
+ base[k] = sv
640
+ return base
641
+
642
+ feat4x = merge(dict(feat4), out.get("feat4", {}))
643
+ feat5x = merge(dict(feat5), out.get("feat5", {}))
644
+ fit4x = merge(dict(fit4), out.get("fit4", {}))
645
+ fit5x = merge(dict(fit5), out.get("fit5", {}))
646
+
647
+ # Enforce 5G 4x4 badge
648
+ b = str(fit5x.get("Fit badges","") or "")
649
+ if "4x4 MIMO" not in b:
650
+ fit5x["Fit badges"] = (b + ", 4x4 MIMO").strip(", ").strip() if b and b != "Not listed" else "4x4 MIMO"
651
+
652
+ pack = {"feat4": feat4x, "feat5": feat5x, "fit4": fit4x, "fit5": fit5x}
653
+ _ENRICH_CACHE[key] = pack
654
+ return pack
655
+
656
+ def build_tables(repl4: str, repl5: str, canon_make: str) -> Tuple[pd.DataFrame, pd.DataFrame]:
657
+ feat4 = _features_from_dec(repl4, canon_make)
658
+ feat5 = _features_from_dec(repl5, canon_make)
659
+ fit4 = _fit_from_dec(repl4, canon_make, is5=False)
660
+ fit5 = _fit_from_dec(repl5, canon_make, is5=True)
661
+
662
+ pack = gpt_enrich(repl4, repl5, canon_make, feat4, feat5, fit4, fit5)
663
+
664
+ feat_df = pd.DataFrame([
665
+ {"Device":"4G alternative", **pack["feat4"]},
666
+ {"Device":"5G replacement", **pack["feat5"]},
667
+ ], columns=FEATURE_COLS)
668
+
669
+ fit_df = pd.DataFrame([
670
+ {"Device":"4G alternative", **pack["fit4"]},
671
+ {"Device":"5G replacement", **pack["fit5"]},
672
+ ], columns=FIT_COLS)
673
+
674
+ return feat_df, fit_df
675
+
676
+ # ----------------------------
677
+ # Manufacturer link (deterministic, no HTTP)
678
+ # ----------------------------
679
+ MAKER_DOMAINS = {
680
+ "CRADLEPOINT": "https://cradlepoint.com",
681
+ "SIERRA": "https://airlink.com",
682
+ "FEENEY": "https://inseego.com",
683
+ "DIGI": "https://www.digi.com",
684
+ "CISCO_MERAKI": "https://meraki.cisco.com",
685
+ "CISCO": "https://www.cisco.com",
686
+ "TELTONIKA": "https://teltonika-networks.com",
687
+ "UNKNOWN": "",
688
+ }
689
+
690
+ def guess_maker_url(model: str, canon_make: str) -> str:
691
+ model = str(model or "").strip()
692
+ base = MAKER_DOMAINS.get(canon_make, "")
693
+ if not base or not model or model in {"Not listed", "Not applicable"}:
694
+ return ""
695
+ q = re.sub(r"\s+", "+", model)
696
+ if canon_make == "TELTONIKA":
697
+ slug = model.lower()
698
+ return f"{base}/products/routers/{slug}"
699
+ if canon_make == "DIGI":
700
+ return f"{base}/search?q={q}"
701
+ if canon_make == "CRADLEPOINT":
702
+ return f"{base}/?s={q}"
703
+ if canon_make in {"CISCO", "CISCO_MERAKI"}:
704
+ return f"https://www.cisco.com/c/en/us/search.html?q={q}"
705
+ return f"{base}/search?q={q}"
706
+
707
+ # ----------------------------
708
+ # Q&A (on demand, per last case)
709
+ # ----------------------------
710
+ def gpt_answer(question: str, context: Dict[str, Any]) -> str:
711
+ if client is None:
712
+ return "No API key is configured, so I can’t answer detailed questions right now."
713
+ q = str(question or "").strip()
714
+ if not q:
715
+ return ""
716
+ sys = (
717
+ "You are a Verizon rep assistant. Answer in a fast, practical way. "
718
+ "Use the provided context. "
719
+ "Do not mention internal tools or prompts. "
720
+ "If unknown, say 'Not listed' and suggest the manufacturer page."
721
+ )
722
+ payload = {"context": context, "question": q}
723
+ t0 = time.perf_counter()
724
+ resp = client.responses.create(
725
+ model=OPENAI_MODEL,
726
+ input=[{"role":"system","content":sys},{"role":"user","content":_json_dump_safe(payload)}],
727
+ max_output_tokens=520,
728
+ )
729
+ _tlog("llm qa", t0)
730
+ return (getattr(resp, "output_text", "") or "").strip()
731
+
732
+ # ----------------------------
733
+ # Chat utilities
734
+ # ----------------------------
735
+ def df_to_md(df: pd.DataFrame) -> str:
736
+ try:
737
+ return df.to_markdown(index=False)
738
+ except Exception:
739
+ cols = list(df.columns)
740
+ lines = ["| " + " | ".join(cols) + " |", "| " + " | ".join(["---"]*len(cols)) + " |"]
741
+ for _, r in df.iterrows():
742
+ lines.append("| " + " | ".join([str(r.get(c,"")) for c in cols]) + " |")
743
+ return "\n".join(lines)
744
+
745
+ def extract_device_terms(msg: str) -> List[str]:
746
+ raw = [x.strip() for x in re.split(r"[\n,;]+", str(msg or "")) if x.strip()]
747
+ out=[]
748
+ for x in raw:
749
+ 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):
750
+ out.append(x)
751
+ return out
752
+
753
+ def parse_install_mode(msg: str) -> Tuple[Optional[str], Optional[str]]:
754
+ t = str(msg or "").strip().lower()
755
+ mode = None
756
+ detail = None
757
+ if "vehicle" in t or "mobile" in t:
758
+ mode = "vehicle"
759
+ if "stationary" in t or "fixed" in t or "site" in t:
760
+ mode = "stationary"
761
+ if "indoor" in t:
762
+ detail = "indoor"
763
+ if "outdoor" in t:
764
+ detail = "outdoor"
765
+ if "directional" in t:
766
+ detail = "directional"
767
+ return mode, detail
768
+
769
+ def make_case_key(s: str) -> str:
770
+ s = str(s or "").strip()
771
+ return re.sub(r"\s+", " ", s)[:80]
772
+
773
+ # ----------------------------
774
+ # Chat UI (schema-safe)
775
+ # ----------------------------
776
+ with gr.Blocks(title="Only-Routers") as demo:
777
+ gr.Markdown("## Only-Routers\nChat mode for Verizon reps (multiple devices per message).")
778
+ state = gr.State("{}")
779
+
780
+ chatbot = gr.Chatbot(label="Only-Routers Chat", height=560, type="tuples")
781
+ msg = gr.Textbox(label="Message", placeholder="Example: RUT240, WR21\nVehicle install", lines=2)
782
+ send = gr.Button("Send", variant="primary")
783
+
784
+ def chat_fn(user_msg, history, st_json):
785
+ t0 = time.perf_counter()
786
+ st = state_load(st_json)
787
+ st.setdefault("cases", {})
788
+ st.setdefault("last_case_keys", [])
789
+ st.setdefault("pending", {})
790
+ st.setdefault("awaiting_questions", False)
791
+
792
+ text = (user_msg or "").strip()
793
+ if not text:
794
+ return history, state_dump(st)
795
+
796
+ # Pending A/B pick
797
+ if st.get("pending", {}).get("type") == "pick":
798
+ opts = st["pending"].get("options", [])
799
+ choice = text.strip().lower()
800
+ idx = 0 if choice in {"a","1"} else (1 if choice in {"b","2"} else None)
801
+ if idx is None or idx >= len(opts):
802
+ history.append((text, "Please reply with **A** or **B**."))
803
+ return history, state_dump(st)
804
+
805
+ chosen_row = int(opts[idx]["row_idx"])
806
+ life_row = df_eos.iloc[chosen_row]
807
+ eos, eol, status = row_to_dates_and_status(life_row)
808
+ repl = pick_replacements(life_row, status)
809
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
810
+
811
+ feat_df, fit_df = build_tables(repl["repl_4g"], repl["repl_5g"], canon_make)
812
+ url4 = guess_maker_url(repl["repl_4g"], canon_make) if repl["repl_4g"] != "Not applicable" else ""
813
+ url5 = guess_maker_url(repl["repl_5g"], canon_make) if repl["repl_5g"] != "Not listed" else ""
814
+
815
+ ck = make_case_key(str(life_row.get("sku","")))
816
+ st["cases"][ck] = {"row_idx": chosen_row, "repl": repl, "canon_make": canon_make, "status": status, "eos": eos, "eol": eol, "urls": {"4g": url4, "5g": url5}}
817
+ st["last_case_keys"].append(ck)
818
+ st["pending"] = {"type":"install_mode", "case_keys":[ck]}
819
+ st["awaiting_questions"] = True
820
+
821
+ bot = []
822
+ bot.append(f"**{ck}**")
823
+ bot.append(f"- Status: **{status}** | EOS: **{eos}** | EOL: **{eol}**")
824
+ bot.append(f"- 4G alternative: **{repl['repl_4g']}**")
825
+ bot.append(f"- 5G replacement: **{repl['repl_5g']}**")
826
+ if url4:
827
+ bot.append(f"- 4G manufacturer page: {url4}")
828
+ if url5:
829
+ bot.append(f"- 5G manufacturer page: {url5}")
830
+ bot.append("\n**Replacement features**\n" + df_to_md(feat_df))
831
+ bot.append("\n**Verizon fit**\n" + df_to_md(fit_df))
832
+ bot.append("\nFor antennas: **Vehicle/Mobile** or **Stationary**? If Stationary: **Indoor**, **Outdoor**, or **Directional**.")
833
+ bot.append("Any questions about the suggested device(s)?")
834
+
835
+ history.append((text, "\n".join(bot)))
836
+ _tlog("chat pick flow", t0)
837
+ return history, state_dump(st)
838
+
839
+ # Pending install-mode
840
+ if st.get("pending", {}).get("type") == "install_mode":
841
+ mode, detail = parse_install_mode(text)
842
+ if mode is None:
843
+ history.append((text, "Quick one: **Vehicle/Mobile** or **Stationary**? If Stationary: **Indoor**, **Outdoor**, or **Directional**."))
844
+ return history, state_dump(st)
845
+
846
+ updates=[]
847
+ for ck in st["pending"].get("case_keys", []):
848
+ case = st["cases"].get(ck, {})
849
+ repl5 = (case.get("repl", {}) or {}).get("repl_5g","")
850
+ ant = antenna_pick(repl5, mode=mode, detail=detail)
851
+ case.setdefault("antennas", {})
852
+ case["antennas"][f"{mode}:{detail or ''}"] = ant
853
+ st["cases"][ck] = case
854
+ updates.append(f"**{ck}** antenna ({mode}{' / '+detail if detail else ''}): {ant.get('name','')} (PN {ant.get('part_number','')})")
855
+
856
+ st["pending"] = {}
857
+ history.append((text, "\n".join(updates)))
858
+ _tlog("chat antenna flow", t0)
859
+ return history, state_dump(st)
860
+
861
+ # Device lookup
862
+ device_terms = extract_device_terms(text)
863
+ if device_terms:
864
+ bots=[]
865
+ new_case_keys=[]
866
+ for term in device_terms:
867
+ res = resolve_device(term)
868
+ if res.get("mode") == "pick":
869
+ st["pending"] = {"type":"pick", "options": res.get("options", []), "raw": term}
870
+ opts = res.get("options", [])
871
+ bot = "I found more than one close match. Reply **A** or **B**:\n"
872
+ for i,o in enumerate(opts):
873
+ bot += f"- **{'A' if i==0 else 'B'}**: {o.get('label','')}\n"
874
+ history.append((text, bot.strip()))
875
+ _tlog("chat resolve->pick", t0)
876
+ return history, state_dump(st)
877
+
878
+ if res.get("mode") != "ok":
879
+ bots.append(f"**{term}**: not found in lifecycle list. Who makes it (manufacturer) and what's the exact model/SKU?")
880
+ continue
881
+
882
+ life_row = df_eos.iloc[int(res["row_idx"])]
883
+ eos, eol, status = row_to_dates_and_status(life_row)
884
+ repl = pick_replacements(life_row, status)
885
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
886
+
887
+ t1 = time.perf_counter()
888
+ feat_df, fit_df = build_tables(repl["repl_4g"], repl["repl_5g"], canon_make)
889
+ _tlog("tables", t1)
890
+
891
+ url4 = guess_maker_url(repl["repl_4g"], canon_make) if repl["repl_4g"] != "Not applicable" else ""
892
+ url5 = guess_maker_url(repl["repl_5g"], canon_make) if repl["repl_5g"] != "Not listed" else ""
893
+
894
+ ck = make_case_key(str(life_row.get("sku","")) or term)
895
+ st["cases"][ck] = {"row_idx": int(res["row_idx"]), "repl": repl, "canon_make": canon_make, "status": status, "eos": eos, "eol": eol, "urls": {"4g": url4, "5g": url5}}
896
+ st["last_case_keys"].append(ck)
897
+ new_case_keys.append(ck)
898
+
899
+ bot=[]
900
+ bot.append(f"**{ck}**")
901
+ bot.append(f"- Status: **{status}** | EOS: **{eos}** | EOL: **{eol}**")
902
+ bot.append(f"- 4G alternative: **{repl['repl_4g']}**")
903
+ bot.append(f"- 5G replacement: **{repl['repl_5g']}**")
904
+ if url4:
905
+ bot.append(f"- 4G manufacturer page: {url4}")
906
+ if url5:
907
+ bot.append(f"- 5G manufacturer page: {url5}")
908
+ bot.append("\n**Replacement features**\n" + df_to_md(feat_df))
909
+ bot.append("\n**Verizon fit**\n" + df_to_md(fit_df))
910
+ bots.append("\n".join(bot))
911
+
912
+ if new_case_keys:
913
+ st["pending"] = {"type":"install_mode", "case_keys": new_case_keys}
914
+ bots.append("\nFor antennas: **Vehicle/Mobile** or **Stationary**? If Stationary: **Indoor**, **Outdoor**, or **Directional**.")
915
+ bots.append("Any questions about the suggested device(s)?")
916
+ st["awaiting_questions"] = True
917
+
918
+ history.append((text, "\n\n---\n\n".join(bots)))
919
+ _tlog("chat lookup flow", t0)
920
+ return history, state_dump(st)
921
+
922
+ # Q&A about most recent case
923
+ if not st.get("last_case_keys"):
924
+ history.append((text, "Tell me the router model/SKU you’re working with (you can paste multiple)."))
925
+ return history, state_dump(st)
926
+
927
+ ck = st["last_case_keys"][-1]
928
+ case = st["cases"].get(ck, {})
929
+ ctx = {"case": ck, "replacements": case.get("repl", {}), "urls": case.get("urls", {}), "antennas": case.get("antennas", {})}
930
+ ans = gpt_answer(text, ctx)
931
+ history.append((text, ans))
932
+ _tlog("chat qa flow", t0)
933
+ return history, state_dump(st)
934
+
935
+ send.click(fn=chat_fn, inputs=[msg, chatbot, state], outputs=[chatbot, state], api_name=False)
936
+
937
+ demo.launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT","7860")), share=False, show_api=False)
app.py CHANGED
@@ -4,6 +4,7 @@ import json
4
  import math
5
  import time
6
  import hashlib
 
7
  import tempfile
8
  from dataclasses import dataclass
9
  from datetime import datetime, date
@@ -386,6 +387,167 @@ parsec_emb = np.asarray(parsec_emb, dtype=np.float32)
386
  parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])
387
  parsec_index.add(parsec_emb)
388
  _tlog("parsec index", t0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
  PARSEC_FAMILY_WORDS = {"chinook","labrador","boxer","bloodhound","husky","beagle","mastiff","collie","shepherd","belgian","australian","terrier","pyrenees"}
391
 
@@ -774,10 +936,11 @@ def make_case_key(s: str) -> str:
774
  # Chat UI (schema-safe)
775
  # ----------------------------
776
  with gr.Blocks(title="Only-Routers") as demo:
777
- gr.Markdown("## Only-Routers\nChat mode for Verizon reps (multiple devices per message).")
 
778
  state = gr.State("{}")
779
 
780
- chatbot = gr.Chatbot(label="Only-Routers Chat", height=560, type="tuples")
781
  msg = gr.Textbox(label="Message", placeholder="Example: RUT240, WR21\nVehicle install", lines=2)
782
  send = gr.Button("Send", variant="primary")
783
 
@@ -787,114 +950,181 @@ with gr.Blocks(title="Only-Routers") as demo:
787
  st.setdefault("cases", {})
788
  st.setdefault("last_case_keys", [])
789
  st.setdefault("pending", {})
790
- st.setdefault("awaiting_questions", False)
791
 
792
  text = (user_msg or "").strip()
793
  if not text:
794
  return history, state_dump(st)
795
 
796
- # Pending A/B pick
797
- if st.get("pending", {}).get("type") == "pick":
798
- opts = st["pending"].get("options", [])
799
- choice = text.strip().lower()
800
- idx = 0 if choice in {"a","1"} else (1 if choice in {"b","2"} else None)
801
- if idx is None or idx >= len(opts):
802
- history.append((text, "Please reply with **A** or **B**."))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
803
  return history, state_dump(st)
804
 
805
- chosen_row = int(opts[idx]["row_idx"])
806
- life_row = df_eos.iloc[chosen_row]
807
- eos, eol, status = row_to_dates_and_status(life_row)
808
- repl = pick_replacements(life_row, status)
809
- canon_make = str(life_row.get("_canon_make","UNKNOWN"))
810
-
811
- feat_df, fit_df = build_tables(repl["repl_4g"], repl["repl_5g"], canon_make)
812
- url4 = guess_maker_url(repl["repl_4g"], canon_make) if repl["repl_4g"] != "Not applicable" else ""
813
- url5 = guess_maker_url(repl["repl_5g"], canon_make) if repl["repl_5g"] != "Not listed" else ""
814
-
815
- ck = make_case_key(str(life_row.get("sku","")))
816
- st["cases"][ck] = {"row_idx": chosen_row, "repl": repl, "canon_make": canon_make, "status": status, "eos": eos, "eol": eol, "urls": {"4g": url4, "5g": url5}}
817
- st["last_case_keys"].append(ck)
818
- st["pending"] = {"type":"install_mode", "case_keys":[ck]}
819
- st["awaiting_questions"] = True
820
-
821
- bot = []
822
- bot.append(f"**{ck}**")
823
- bot.append(f"- Status: **{status}** | EOS: **{eos}** | EOL: **{eol}**")
824
- bot.append(f"- 4G alternative: **{repl['repl_4g']}**")
825
- bot.append(f"- 5G replacement: **{repl['repl_5g']}**")
826
- if url4:
827
- bot.append(f"- 4G manufacturer page: {url4}")
828
- if url5:
829
- bot.append(f"- 5G manufacturer page: {url5}")
830
- bot.append("\n**Replacement features**\n" + df_to_md(feat_df))
831
- bot.append("\n**Verizon fit**\n" + df_to_md(fit_df))
832
- bot.append("\nFor antennas: **Vehicle/Mobile** or **Stationary**? If Stationary: **Indoor**, **Outdoor**, or **Directional**.")
833
- bot.append("Any questions about the suggested device(s)?")
834
-
835
- history.append((text, "\n".join(bot)))
836
- _tlog("chat pick flow", t0)
837
  return history, state_dump(st)
838
 
839
- # Pending install-mode
840
- if st.get("pending", {}).get("type") == "install_mode":
841
- mode, detail = parse_install_mode(text)
842
- if mode is None:
843
- history.append((text, "Quick one: **Vehicle/Mobile** or **Stationary**? If Stationary: **Indoor**, **Outdoor**, or **Directional**."))
 
844
  return history, state_dump(st)
845
 
846
- updates=[]
847
- for ck in st["pending"].get("case_keys", []):
848
- case = st["cases"].get(ck, {})
849
- repl5 = (case.get("repl", {}) or {}).get("repl_5g","")
850
- ant = antenna_pick(repl5, mode=mode, detail=detail)
851
- case.setdefault("antennas", {})
852
- case["antennas"][f"{mode}:{detail or ''}"] = ant
853
- st["cases"][ck] = case
854
- updates.append(f"**{ck}** antenna ({mode}{' / '+detail if detail else ''}): {ant.get('name','')} (PN {ant.get('part_number','')})")
 
 
 
 
 
 
 
 
 
 
 
 
 
855
 
856
- st["pending"] = {}
857
- history.append((text, "\n".join(updates)))
858
- _tlog("chat antenna flow", t0)
 
 
 
 
 
 
 
 
 
 
 
859
  return history, state_dump(st)
860
 
861
- # Device lookup
862
- device_terms = extract_device_terms(text)
863
- if device_terms:
864
- bots=[]
865
- new_case_keys=[]
866
- for term in device_terms:
867
- res = resolve_device(term)
868
- if res.get("mode") == "pick":
869
- st["pending"] = {"type":"pick", "options": res.get("options", []), "raw": term}
870
- opts = res.get("options", [])
871
- bot = "I found more than one close match. Reply **A** or **B**:\n"
872
- for i,o in enumerate(opts):
873
- bot += f"- **{'A' if i==0 else 'B'}**: {o.get('label','')}\n"
874
- history.append((text, bot.strip()))
875
- _tlog("chat resolve->pick", t0)
876
- return history, state_dump(st)
877
-
878
- if res.get("mode") != "ok":
879
- bots.append(f"**{term}**: not found in lifecycle list. Who makes it (manufacturer) and what's the exact model/SKU?")
880
- continue
881
-
882
- life_row = df_eos.iloc[int(res["row_idx"])]
883
  eos, eol, status = row_to_dates_and_status(life_row)
884
  repl = pick_replacements(life_row, status)
885
  canon_make = str(life_row.get("_canon_make","UNKNOWN"))
886
 
887
- t1 = time.perf_counter()
888
  feat_df, fit_df = build_tables(repl["repl_4g"], repl["repl_5g"], canon_make)
889
- _tlog("tables", t1)
890
-
891
  url4 = guess_maker_url(repl["repl_4g"], canon_make) if repl["repl_4g"] != "Not applicable" else ""
892
  url5 = guess_maker_url(repl["repl_5g"], canon_make) if repl["repl_5g"] != "Not listed" else ""
893
 
894
  ck = make_case_key(str(life_row.get("sku","")) or term)
895
- st["cases"][ck] = {"row_idx": int(res["row_idx"]), "repl": repl, "canon_make": canon_make, "status": status, "eos": eos, "eol": eol, "urls": {"4g": url4, "5g": url5}}
896
  st["last_case_keys"].append(ck)
897
- new_case_keys.append(ck)
898
 
899
  bot=[]
900
  bot.append(f"**{ck}**")
@@ -907,29 +1137,49 @@ with gr.Blocks(title="Only-Routers") as demo:
907
  bot.append(f"- 5G manufacturer page: {url5}")
908
  bot.append("\n**Replacement features**\n" + df_to_md(feat_df))
909
  bot.append("\n**Verizon fit**\n" + df_to_md(fit_df))
910
- bots.append("\n".join(bot))
 
911
 
912
- if new_case_keys:
913
- st["pending"] = {"type":"install_mode", "case_keys": new_case_keys}
914
- bots.append("\nFor antennas: **Vehicle/Mobile** or **Stationary**? If Stationary: **Indoor**, **Outdoor**, or **Directional**.")
915
- bots.append("Any questions about the suggested device(s)?")
916
- st["awaiting_questions"] = True
917
 
918
- history.append((text, "\n\n---\n\n".join(bots)))
919
- _tlog("chat lookup flow", t0)
920
- return history, state_dump(st)
 
 
 
 
 
921
 
922
- # Q&A about most recent case
923
- if not st.get("last_case_keys"):
924
- history.append((text, "Tell me the router model/SKU you’re working with (you can paste multiple)."))
925
- return history, state_dump(st)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
926
 
927
- ck = st["last_case_keys"][-1]
928
- case = st["cases"].get(ck, {})
929
- ctx = {"case": ck, "replacements": case.get("repl", {}), "urls": case.get("urls", {}), "antennas": case.get("antennas", {})}
930
- ans = gpt_answer(text, ctx)
931
- history.append((text, ans))
932
- _tlog("chat qa flow", t0)
933
  return history, state_dump(st)
934
 
935
  send.click(fn=chat_fn, inputs=[msg, chatbot, state], outputs=[chatbot, state], api_name=False)
 
4
  import math
5
  import time
6
  import hashlib
7
+ import base64
8
  import tempfile
9
  from dataclasses import dataclass
10
  from datetime import datetime, date
 
387
  parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])
388
  parsec_index.add(parsec_emb)
389
  _tlog("parsec index", t0)
390
+ # ----------------------------
391
+ # Antenna photos from ParsecCatalog.pdf (best effort)
392
+ # - Build a map from Standard SKU -> page indices once at startup
393
+ # - Extract the largest image on the matching page and embed as data URI in markdown
394
+ # (only used when user asks for antenna options)
395
+ # ----------------------------
396
+ PARSEC_PN_TO_PAGES: Dict[str, List[int]] = {}
397
+
398
+ try:
399
+ _doc = fitz.open(PARSEC_PDF)
400
+ for i in range(len(_doc)):
401
+ t = _doc[i].get_text("text") or ""
402
+ for m in re.finditer(r"Standard\s+SKU:\s*([A-Z0-9]+)", t):
403
+ pn = m.group(1).strip().upper()
404
+ PARSEC_PN_TO_PAGES.setdefault(pn, []).append(i)
405
+ except Exception:
406
+ PARSEC_PN_TO_PAGES = {}
407
+
408
+ def _extract_largest_image_data_uri(page_index: int, max_bytes: int = 350_000) -> str:
409
+ """
410
+ Extract the largest raster image on a PDF page and return as a data URI (PNG).
411
+ If the image is too large to embed, return empty string.
412
+ """
413
+ try:
414
+ doc = fitz.open(PARSEC_PDF)
415
+ page = doc[page_index]
416
+ imgs = page.get_images(full=True) or []
417
+ if not imgs:
418
+ return ""
419
+
420
+ best_xref = None
421
+ best_area = 0
422
+ for img in imgs:
423
+ xref = img[0]
424
+ pix = fitz.Pixmap(doc, xref)
425
+ area = pix.width * pix.height
426
+ if area > best_area and pix.width >= 200 and pix.height >= 200:
427
+ best_area = area
428
+ best_xref = xref
429
+ pix = None
430
+
431
+ if best_xref is None:
432
+ return ""
433
+
434
+ pix = fitz.Pixmap(doc, best_xref)
435
+ if pix.n >= 5: # CMYK
436
+ pix = fitz.Pixmap(fitz.csRGB, pix)
437
+
438
+ png_bytes = pix.tobytes("png")
439
+ if len(png_bytes) > max_bytes:
440
+ return ""
441
+
442
+ b64 = base64.b64encode(png_bytes).decode("ascii")
443
+ return f"data:image/png;base64,{b64}"
444
+ except Exception:
445
+ return ""
446
+
447
+ @lru_cache(maxsize=512)
448
+ def antenna_photo_data_uri(part_number: str) -> str:
449
+ pn = str(part_number or "").strip().upper()
450
+ if not pn:
451
+ return ""
452
+ pages = PARSEC_PN_TO_PAGES.get(pn, [])
453
+ if not pages:
454
+ return ""
455
+ for p in pages[:3]:
456
+ uri = _extract_largest_image_data_uri(p)
457
+ if uri:
458
+ return uri
459
+ return ""
460
+
461
+ # ----------------------------
462
+ # Stronger matching (regex normalization + fuzzy)
463
+ # ----------------------------
464
+ def _normalize_query_compact(s: str) -> str:
465
+ s = str(s or "").strip().upper()
466
+ return re.sub(r"[^A-Z0-9]", "", s)
467
+
468
+ def resolve_device_stronger(term: str) -> Dict[str, Any]:
469
+ raw = str(term or "").strip()
470
+ if not raw:
471
+ return {"mode":"not_found"}
472
+
473
+ q_compact = _normalize_query_compact(raw)
474
+ # exact compact SKU match
475
+ if q_compact:
476
+ for i, sku in enumerate(df_eos["_norm_sku"].tolist()):
477
+ if _normalize_query_compact(sku) == q_compact:
478
+ return {"mode":"ok", "row_idx": i, "confidence":"High"}
479
+
480
+ hits = process.extract(raw, EOS_CORPUS, scorer=fuzz.WRatio, limit=6)
481
+ cands = [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]
482
+ if not cands:
483
+ return {"mode":"not_found"}
484
+
485
+ if cands[0][1] >= MATCH_AUTOPICK and (len(cands)==1 or (cands[0][1]-cands[1][1]) >= MATCH_GAP):
486
+ return {"mode":"ok", "row_idx": cands[0][0], "confidence":"High"}
487
+
488
+ return {"mode":"guess", "row_idx": cands[0][0], "confidence":"Medium", "guess_label": cands[0][2], "raw": raw}
489
+
490
+ # ----------------------------
491
+ # LLM fallback: identify router + replacements (Verizon equipment only, no pricing)
492
+ # ----------------------------
493
+ def llm_identify_router_and_replacements(raw_text: str) -> Dict[str, Any]:
494
+ if client is None:
495
+ return {"found": False, "note": "No API key configured."}
496
+
497
+ sys = (
498
+ "You help Verizon reps identify cellular routers and suggest replacements. "
499
+ "Keep it to Verizon-sellable equipment families when possible "
500
+ "(Cradlepoint, Sierra/AirLink, Digi, Cisco/Meraki, Teltonika, Inseego). "
501
+ "No pricing. Return strict JSON only."
502
+ )
503
+ payload = {
504
+ "user_input": raw_text,
505
+ "output_schema": {
506
+ "best_guess_model": "string",
507
+ "maker_family": "CRADLEPOINT|SIERRA|DIGI|CISCO|CISCO_MERAKI|TELTONIKA|FEENEY|UNKNOWN",
508
+ "repl_5g": "string",
509
+ "repl_4g": "string",
510
+ "confidence": "High|Medium",
511
+ "note": "string"
512
+ }
513
+ }
514
+ resp = client.responses.create(
515
+ model=OPENAI_MODEL,
516
+ input=[{"role":"system","content":sys},{"role":"user","content":_json_dump_safe(payload)}],
517
+ max_output_tokens=360,
518
+ )
519
+ out = _json_load_safe(getattr(resp, "output_text", "") or "")
520
+ if not isinstance(out, dict) or not out.get("best_guess_model"):
521
+ return {"found": False, "note": "Could not identify router."}
522
+ out["found"] = True
523
+ return out
524
+
525
+ # ----------------------------
526
+ # Antenna options: Vehicle + Indoor + Outdoor + Directional
527
+ # (all omni except directional)
528
+ # ----------------------------
529
+ def antenna_options_4pack(repl5: str) -> Dict[str, Dict[str, Any]]:
530
+ # All 5G routers => 4x4
531
+ veh = antenna_pick(repl5, mode="vehicle", detail=None)
532
+ ind = antenna_pick(repl5, mode="stationary", detail="indoor")
533
+ outd = antenna_pick(repl5, mode="stationary", detail="outdoor")
534
+ direc = antenna_pick(repl5, mode="stationary", detail="directional")
535
+
536
+ for a in (veh, ind, outd, direc):
537
+ a["photo_uri"] = antenna_photo_data_uri(a.get("part_number",""))
538
+
539
+ return {"vehicle": veh, "indoor": ind, "outdoor": outd, "directional": direc}
540
+
541
+ def _fmt_ant(a: Dict[str, Any]) -> str:
542
+ name = a.get("name","")
543
+ pn = a.get("part_number","")
544
+ desc = a.get("description","")
545
+ conn = a.get("connectors","")
546
+ s = f"**{name}** (PN {pn}) — {desc}"
547
+ if conn:
548
+ s += f" | Conn: {conn}"
549
+ return s
550
+
551
 
552
  PARSEC_FAMILY_WORDS = {"chinook","labrador","boxer","bloodhound","husky","beagle","mastiff","collie","shepherd","belgian","australian","terrier","pyrenees"}
553
 
 
936
  # Chat UI (schema-safe)
937
  # ----------------------------
938
  with gr.Blocks(title="Only-Routers") as demo:
939
+ gr.Markdown("## Only-Routers\n\n**Please enter the router models you would like to verify for replacement.**\n\nPaste multiple models/SKUs separated by commas or new lines.")
940
+
941
  state = gr.State("{}")
942
 
943
+ chatbot = gr.Chatbot(label="Only-Routers Chat", height=600, type="tuples")
944
  msg = gr.Textbox(label="Message", placeholder="Example: RUT240, WR21\nVehicle install", lines=2)
945
  send = gr.Button("Send", variant="primary")
946
 
 
950
  st.setdefault("cases", {})
951
  st.setdefault("last_case_keys", [])
952
  st.setdefault("pending", {})
 
953
 
954
  text = (user_msg or "").strip()
955
  if not text:
956
  return history, state_dump(st)
957
 
958
+ # ----------------------------
959
+ # Pending: confirm best guess
960
+ # ----------------------------
961
+ if st.get("pending", {}).get("type") == "confirm_guess":
962
+ pend = st["pending"]
963
+ raw = pend.get("raw","")
964
+ row_idx = int(pend.get("row_idx",-1))
965
+ low = text.lower().strip()
966
+
967
+ if low in {"yes","y","yeah","yep","correct","right","ok","okay"}:
968
+ life_row = df_eos.iloc[row_idx]
969
+ eos, eol, status = row_to_dates_and_status(life_row)
970
+ repl = pick_replacements(life_row, status)
971
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
972
+
973
+ feat_df, fit_df = build_tables(repl["repl_4g"], repl["repl_5g"], canon_make)
974
+ url4 = guess_maker_url(repl["repl_4g"], canon_make) if repl["repl_4g"] != "Not applicable" else ""
975
+ url5 = guess_maker_url(repl["repl_5g"], canon_make) if repl["repl_5g"] != "Not listed" else ""
976
+
977
+ ck = make_case_key(str(life_row.get("sku","")) or raw)
978
+ st["cases"][ck] = {"row_idx": row_idx, "repl": repl, "canon_make": canon_make, "status": status, "eos": eos, "eol": eol, "urls": {"4g": url4, "5g": url5}}
979
+ st["last_case_keys"].append(ck)
980
+
981
+ bot=[]
982
+ bot.append(f"**{ck}**")
983
+ bot.append(f"- Status: **{status}** | EOS: **{eos}** | EOL: **{eol}**")
984
+ bot.append(f"- 4G alternative: **{repl['repl_4g']}**")
985
+ bot.append(f"- 5G replacement: **{repl['repl_5g']}**")
986
+ if url4:
987
+ bot.append(f"- 4G manufacturer page: {url4}")
988
+ if url5:
989
+ bot.append(f"- 5G manufacturer page: {url5}")
990
+ bot.append("\n**Replacement features**\n" + df_to_md(feat_df))
991
+ bot.append("\n**Verizon fit**\n" + df_to_md(fit_df))
992
+ bot.append("\nWould you like to see the **antenna options** (Vehicle, Indoor, Outdoor, Directional) for this router? Reply **Yes** or **No**.")
993
+ st["pending"] = {"type":"ask_antennas", "case_keys":[ck]}
994
+
995
+ history.append((text, "\n".join(bot)))
996
+ _tlog("confirm guess", t0)
997
+ return history, state_dump(st)
998
+
999
+ if low in {"no","n","nope","wrong","incorrect"}:
1000
+ st["pending"] = {"type":"await_corrected_model"}
1001
+ history.append((text, "No problem — please reply with the corrected router model/SKU."))
1002
+ return history, state_dump(st)
1003
+
1004
+ # If they pasted corrected model instead of yes/no, fall through as new input
1005
+ st["pending"] = {}
1006
+
1007
+ # ----------------------------
1008
+ # Pending: waiting for corrected model
1009
+ # ----------------------------
1010
+ if st.get("pending", {}).get("type") == "await_corrected_model":
1011
+ st["pending"] = {} # treat message as a new lookup
1012
+
1013
+ # ----------------------------
1014
+ # Pending: ask antennas yes/no
1015
+ # ----------------------------
1016
+ if st.get("pending", {}).get("type") == "ask_antennas":
1017
+ low = text.lower().strip()
1018
+ want = low in {"yes","y","yeah","yep","sure","ok","okay"}
1019
+ case_keys = st["pending"].get("case_keys", []) or st.get("last_case_keys", [])
1020
+
1021
+ if want:
1022
+ blocks=[]
1023
+ for ck in case_keys:
1024
+ case = st["cases"].get(ck, {})
1025
+ repl5 = (case.get("repl", {}) or {}).get("repl_5g","")
1026
+ if not repl5 or repl5 == "Not listed":
1027
+ blocks.append(f"**{ck}**: No 5G replacement available to anchor antenna picks.")
1028
+ continue
1029
+
1030
+ opts = antenna_options_4pack(repl5)
1031
+ case["antenna_options"] = opts
1032
+ st["cases"][ck] = case
1033
+
1034
+ b=[]
1035
+ b.append(f"**{ck} — Antenna options (Parsec)**")
1036
+ b.append(f"- Vehicle (Omni): {_fmt_ant(opts['vehicle'])}")
1037
+ b.append(f"- Indoor (Omni): {_fmt_ant(opts['indoor'])}")
1038
+ b.append(f"- Outdoor (Omni): {_fmt_ant(opts['outdoor'])}")
1039
+ b.append(f"- Directional: {_fmt_ant(opts['directional'])}")
1040
+
1041
+ # Photos (best effort, may be empty if too large or not found)
1042
+ for label in ["vehicle","indoor","outdoor","directional"]:
1043
+ uri = opts[label].get("photo_uri","")
1044
+ if uri:
1045
+ b.append(f"\n**{label.capitalize()} photo**\n![]({uri})\n")
1046
+
1047
+ blocks.append("\n".join(b))
1048
+
1049
+ blocks.append("\nAny questions about the router(s) — including alternatives and comparisons? Ask anything router-related (no pricing).")
1050
+ st["pending"] = {"type":"await_questions"}
1051
+ history.append((text, "\n\n---\n\n".join(blocks)))
1052
+ _tlog("antennas yes", t0)
1053
  return history, state_dump(st)
1054
 
1055
+ # No antennas
1056
+ st["pending"] = {"type":"await_questions"}
1057
+ history.append((text, "Got it. Any questions about the router(s) — including alternatives and comparisons? Ask anything router-related (no pricing)."))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1058
  return history, state_dump(st)
1059
 
1060
+ # ----------------------------
1061
+ # Pending: questions phase
1062
+ # ----------------------------
1063
+ if st.get("pending", {}).get("type") == "await_questions":
1064
+ if not st.get("last_case_keys"):
1065
+ history.append((text, "Please enter the router models you would like to verify for replacement."))
1066
  return history, state_dump(st)
1067
 
1068
+ # Route to most recent unless message mentions a case key
1069
+ target = st["last_case_keys"][-1]
1070
+ t_low = text.lower()
1071
+ for ck in reversed(st["last_case_keys"]):
1072
+ if ck.lower() in t_low:
1073
+ target = ck
1074
+ break
1075
+
1076
+ case = st["cases"].get(target, {})
1077
+ ctx = {
1078
+ "case": target,
1079
+ "status": case.get("status",""),
1080
+ "eos": case.get("eos",""),
1081
+ "eol": case.get("eol",""),
1082
+ "replacements": case.get("repl", {}),
1083
+ "urls": case.get("urls", {}),
1084
+ "antenna_options": case.get("antenna_options", {}),
1085
+ }
1086
+ ans = gpt_answer(text, ctx)
1087
+ history.append((text, ans))
1088
+ _tlog("qa", t0)
1089
+ return history, state_dump(st)
1090
 
1091
+ # ----------------------------
1092
+ # Normal device intake
1093
+ # ----------------------------
1094
+ terms = extract_device_terms(text)
1095
+ if not terms:
1096
+ # If not a device list, treat as question about last router if possible
1097
+ if st.get("last_case_keys"):
1098
+ case = st["cases"].get(st["last_case_keys"][-1], {})
1099
+ ctx = {"replacements": case.get("repl", {}), "urls": case.get("urls", {}), "antenna_options": case.get("antenna_options", {})}
1100
+ ans = gpt_answer(text, ctx)
1101
+ history.append((text, ans))
1102
+ return history, state_dump(st)
1103
+
1104
+ history.append((text, "Please enter the router models you would like to verify for replacement."))
1105
  return history, state_dump(st)
1106
 
1107
+ blocks=[]
1108
+ case_keys=[]
1109
+
1110
+ for term in terms:
1111
+ res = resolve_device_stronger(term)
1112
+
1113
+ if res.get("mode") == "ok":
1114
+ row_idx = int(res["row_idx"])
1115
+ life_row = df_eos.iloc[row_idx]
 
 
 
 
 
 
 
 
 
 
 
 
 
1116
  eos, eol, status = row_to_dates_and_status(life_row)
1117
  repl = pick_replacements(life_row, status)
1118
  canon_make = str(life_row.get("_canon_make","UNKNOWN"))
1119
 
 
1120
  feat_df, fit_df = build_tables(repl["repl_4g"], repl["repl_5g"], canon_make)
 
 
1121
  url4 = guess_maker_url(repl["repl_4g"], canon_make) if repl["repl_4g"] != "Not applicable" else ""
1122
  url5 = guess_maker_url(repl["repl_5g"], canon_make) if repl["repl_5g"] != "Not listed" else ""
1123
 
1124
  ck = make_case_key(str(life_row.get("sku","")) or term)
1125
+ st["cases"][ck] = {"row_idx": row_idx, "repl": repl, "canon_make": canon_make, "status": status, "eos": eos, "eol": eol, "urls": {"4g": url4, "5g": url5}}
1126
  st["last_case_keys"].append(ck)
1127
+ case_keys.append(ck)
1128
 
1129
  bot=[]
1130
  bot.append(f"**{ck}**")
 
1137
  bot.append(f"- 5G manufacturer page: {url5}")
1138
  bot.append("\n**Replacement features**\n" + df_to_md(feat_df))
1139
  bot.append("\n**Verizon fit**\n" + df_to_md(fit_df))
1140
+ blocks.append("\n".join(bot))
1141
+ continue
1142
 
1143
+ if res.get("mode") == "guess":
1144
+ st["pending"] = {"type":"confirm_guess", "row_idx": int(res["row_idx"]), "raw": res.get("raw","")}
1145
+ history.append((text, f"I think you mean: **{res.get('guess_label','')}**. Is that correct? Reply **Yes** or **No** (or paste the corrected model)."))
1146
+ return history, state_dump(st)
 
1147
 
1148
+ # Not found locally: ask to clarify AND attempt LLM best effort
1149
+ llm = llm_identify_router_and_replacements(term)
1150
+ if llm.get("found"):
1151
+ ck = make_case_key(llm.get("best_guess_model","") or term)
1152
+ repl = {"repl_4g": llm.get("repl_4g","Not applicable") or "Not applicable", "repl_5g": llm.get("repl_5g","Not listed") or "Not listed"}
1153
+ canon_make = llm.get("maker_family","UNKNOWN")
1154
+ url4 = guess_maker_url(repl["repl_4g"], canon_make) if repl["repl_4g"] != "Not applicable" else ""
1155
+ url5 = guess_maker_url(repl["repl_5g"], canon_make) if repl["repl_5g"] != "Not listed" else ""
1156
 
1157
+ st["cases"][ck] = {"row_idx": None, "repl": repl, "canon_make": canon_make, "status": "Unknown", "eos": "Not listed", "eol": "Not listed", "urls": {"4g": url4, "5g": url5}, "llm_note": llm.get("note","")}
1158
+ st["last_case_keys"].append(ck)
1159
+ case_keys.append(ck)
1160
+
1161
+ bot=[]
1162
+ bot.append(f"**{ck}** (best effort)")
1163
+ bot.append(f"- Note: {llm.get('note','')}")
1164
+ bot.append(f"- 4G alternative: **{repl['repl_4g']}**")
1165
+ bot.append(f"- 5G replacement: **{repl['repl_5g']}**")
1166
+ if url4:
1167
+ bot.append(f"- 4G manufacturer page: {url4}")
1168
+ if url5:
1169
+ bot.append(f"- 5G manufacturer page: {url5}")
1170
+ bot.append("\nIf this is not the correct router, reply with the exact model and manufacturer.")
1171
+ blocks.append("\n".join(bot))
1172
+ else:
1173
+ blocks.append(f"**{term}**: not found. Who makes it (manufacturer) and what's the exact model/SKU?")
1174
+
1175
+ if case_keys:
1176
+ blocks.append("\nWould you like to see the **antenna options** (Vehicle, Indoor, Outdoor, Directional) for each router? Reply **Yes** or **No**.")
1177
+ st["pending"] = {"type":"ask_antennas", "case_keys": case_keys}
1178
+ else:
1179
+ st["pending"] = {"type":"await_questions"}
1180
 
1181
+ history.append((text, "\n\n---\n\n".join(blocks)))
1182
+ _tlog("lookup", t0)
 
 
 
 
1183
  return history, state_dump(st)
1184
 
1185
  send.click(fn=chat_fn, inputs=[msg, chatbot, state], outputs=[chatbot, state], api_name=False)
only-routers_ai_poc_hf_chat_prod_v2.ipynb ADDED
@@ -0,0 +1,1221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "74c10174",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Only-Routers Chat (prod v2)\n",
9
+ "\n",
10
+ "Implements: greeting prompt, antenna yes/no gate + 4 antenna modes per device + photos (best effort), router Q&A phase, stronger matching with typos, best-guess confirmation (no A/B), LLM fallback on no match.\n"
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "code",
15
+ "execution_count": null,
16
+ "id": "8c717211",
17
+ "metadata": {},
18
+ "outputs": [],
19
+ "source": [
20
+ "import os\n",
21
+ "import re\n",
22
+ "import json\n",
23
+ "import math\n",
24
+ "import time\n",
25
+ "import hashlib\n",
26
+ "import base64\n",
27
+ "import tempfile\n",
28
+ "from dataclasses import dataclass\n",
29
+ "from datetime import datetime, date\n",
30
+ "from functools import lru_cache\n",
31
+ "from typing import Any, Dict, List, Optional, Tuple\n",
32
+ "\n",
33
+ "import numpy as np\n",
34
+ "import pandas as pd\n",
35
+ "\n",
36
+ "import fitz # PyMuPDF\n",
37
+ "import faiss\n",
38
+ "from sentence_transformers import SentenceTransformer\n",
39
+ "from rapidfuzz import fuzz, process\n",
40
+ "\n",
41
+ "import gradio as gr\n",
42
+ "from openai import OpenAI\n",
43
+ "\n",
44
+ "# ============================================================\n",
45
+ "# Only-Routers (Chat, production-lean)\n",
46
+ "# - Fast model by default (no reasoning payload)\n",
47
+ "# - One LLM call max per lookup (enrichment only, cached)\n",
48
+ "# - No HTTP crawling during normal lookup (links are deterministic)\n",
49
+ "# - Timing logs to HF console when DEBUG_TIMING=1\n",
50
+ "# ============================================================\n",
51
+ "\n",
52
+ "# ----------------------------\n",
53
+ "# Settings\n",
54
+ "# ----------------------------\n",
55
+ "TODAY = date(2026, 1, 18)\n",
56
+ "\n",
57
+ "# Fast default model (override via env)\n",
58
+ "OPENAI_MODEL = os.getenv(\"OPENAI_MODEL\", \"gpt-5.2\").strip()\n",
59
+ "\n",
60
+ "# Disable LLM at runtime: OPENAI_DISABLE=1\n",
61
+ "OPENAI_DISABLE = os.getenv(\"OPENAI_DISABLE\", \"0\").strip() == \"1\"\n",
62
+ "\n",
63
+ "# Timing logs\n",
64
+ "DEBUG_TIMING = os.getenv(\"DEBUG_TIMING\", \"0\").strip() == \"1\"\n",
65
+ "\n",
66
+ "# Matching thresholds\n",
67
+ "MATCH_OK = 82\n",
68
+ "MATCH_AUTOPICK = 95\n",
69
+ "MATCH_GAP = 8\n",
70
+ "\n",
71
+ "# Embeddings\n",
72
+ "EMBED_MODEL_NAME = os.getenv(\"EMBED_MODEL_NAME\", \"sentence-transformers/all-MiniLM-L6-v2\").strip()\n",
73
+ "\n",
74
+ "# Parsec PDF slicing\n",
75
+ "PARSEC_CONTEXT_BEFORE = 900\n",
76
+ "PARSEC_CONTEXT_AFTER = 1600\n",
77
+ "\n",
78
+ "# ----------------------------\n",
79
+ "# OpenAI client\n",
80
+ "# ----------------------------\n",
81
+ "API_KEY = os.getenv(\"OPENAI_API_KEY\", \"\").strip()\n",
82
+ "client = None if (not API_KEY or OPENAI_DISABLE) else OpenAI(api_key=API_KEY)\n",
83
+ "\n",
84
+ "# ----------------------------\n",
85
+ "# Timing helper\n",
86
+ "# ----------------------------\n",
87
+ "def _tlog(label: str, t0: float) -> None:\n",
88
+ " if DEBUG_TIMING:\n",
89
+ " dt = time.perf_counter() - t0\n",
90
+ " print(f\"[TIMER] {label}: {dt:.2f}s\")\n",
91
+ "\n",
92
+ "# ----------------------------\n",
93
+ "# JSON-safe helpers\n",
94
+ "# ----------------------------\n",
95
+ "def _json_load_safe(s: str) -> Dict[str, Any]:\n",
96
+ " try:\n",
97
+ " return json.loads(s)\n",
98
+ " except Exception:\n",
99
+ " return {}\n",
100
+ "\n",
101
+ "def _json_dump_safe(obj: Any) -> str:\n",
102
+ " try:\n",
103
+ " return json.dumps(obj, ensure_ascii=False)\n",
104
+ " except Exception:\n",
105
+ " return \"{}\"\n",
106
+ "\n",
107
+ "# ----------------------------\n",
108
+ "# Gradio state helpers (string JSON only)\n",
109
+ "# ----------------------------\n",
110
+ "def state_load(st_json: str) -> Dict[str, Any]:\n",
111
+ " try:\n",
112
+ " return json.loads(st_json) if isinstance(st_json, str) and st_json else {}\n",
113
+ " except Exception:\n",
114
+ " return {}\n",
115
+ "\n",
116
+ "def state_dump(st: Dict[str, Any]) -> str:\n",
117
+ " return _json_dump_safe(st or {})\n",
118
+ "\n",
119
+ "# ----------------------------\n",
120
+ "# Normalization\n",
121
+ "# ----------------------------\n",
122
+ "def norm_text(x: Any) -> str:\n",
123
+ " try:\n",
124
+ " if x is None or (isinstance(x, float) and math.isnan(x)) or pd.isna(x):\n",
125
+ " return \"\"\n",
126
+ " except Exception:\n",
127
+ " pass\n",
128
+ " s = str(x).strip().lower()\n",
129
+ " s = re.sub(r\"[^a-z0-9\\s\\-\\/]\", \" \", s)\n",
130
+ " s = re.sub(r\"\\s+\", \" \", s).strip()\n",
131
+ " return s\n",
132
+ "\n",
133
+ "def safe_str(x: Any) -> str:\n",
134
+ " if x is None or (isinstance(x, float) and pd.isna(x)) or pd.isna(x):\n",
135
+ " return \"\"\n",
136
+ " return str(x).strip()\n",
137
+ "\n",
138
+ "def is_5g_text(s: str) -> bool:\n",
139
+ " t = norm_text(s)\n",
140
+ " return (\"5g\" in t) or (\"nr\" in t)\n",
141
+ "\n",
142
+ "def is_4g_lte_family(row: pd.Series) -> bool:\n",
143
+ " # Treat LTE categories as 4G\n",
144
+ " t = norm_text(row.get(\"description\", \"\")) + \" \" + norm_text(row.get(\"notes\", \"\"))\n",
145
+ " if \"5g\" in t or \"nr\" in t:\n",
146
+ " return False\n",
147
+ " if \"lte\" in t or \"4g\" in t:\n",
148
+ " return True\n",
149
+ " if re.search(r\"\\bcat\\s*[-]?\\s*(m1|m2)\\b\", t):\n",
150
+ " return True\n",
151
+ " if re.search(r\"\\bcat\\s*[-]?\\s*\\d{1,2}\\b\", t):\n",
152
+ " return True\n",
153
+ " if \"cat\" in t:\n",
154
+ " return True\n",
155
+ " return False\n",
156
+ "\n",
157
+ "# ----------------------------\n",
158
+ "# Lifecycle CSV normalization\n",
159
+ "# ----------------------------\n",
160
+ "def _normalize_lifecycle_df(df: pd.DataFrame) -> pd.DataFrame:\n",
161
+ " df = df.copy()\n",
162
+ " lower_cols = {c.lower(): c for c in df.columns}\n",
163
+ "\n",
164
+ " def _pick(*names):\n",
165
+ " for n in names:\n",
166
+ " if n.lower() in lower_cols:\n",
167
+ " return lower_cols[n.lower()]\n",
168
+ " return None\n",
169
+ "\n",
170
+ " col_map = {}\n",
171
+ "\n",
172
+ " sku_col = _pick(\"sku\", \"SKU\")\n",
173
+ " if sku_col:\n",
174
+ " col_map[sku_col] = \"sku\"\n",
175
+ "\n",
176
+ " mfr_col = _pick(\"manufacturer\", \"Manufacturer\")\n",
177
+ " if mfr_col:\n",
178
+ " col_map[mfr_col] = \"manufacturer\"\n",
179
+ "\n",
180
+ " dt_col = _pick(\"device type\", \"Device Type\", \"device_type\")\n",
181
+ " if dt_col:\n",
182
+ " col_map[dt_col] = \"device_type\"\n",
183
+ "\n",
184
+ " eos_col = _pick(\"end_of_sale\", \"end of sale\", \"End of Sale\", \"eos\")\n",
185
+ " if eos_col:\n",
186
+ " col_map[eos_col] = \"end_of_sale\"\n",
187
+ "\n",
188
+ " eol_col = _pick(\"end_of_life\", \"end of life\", \"End of Life\", \"eol\")\n",
189
+ " if eol_col:\n",
190
+ " col_map[eol_col] = \"end_of_life\"\n",
191
+ "\n",
192
+ " sr_col = _pick(\"suggested_replacement\", \"Suggested Replacement\")\n",
193
+ " if sr_col:\n",
194
+ " col_map[sr_col] = \"suggested_replacement\"\n",
195
+ "\n",
196
+ " a5_col = _pick(\"advanced_5g_option\", \"Advanced 5G Option\", \"advanced 5g option\")\n",
197
+ " if a5_col:\n",
198
+ " col_map[a5_col] = \"advanced_5g_option\"\n",
199
+ "\n",
200
+ " df = df.rename(columns=col_map)\n",
201
+ "\n",
202
+ " for req in [\"sku\", \"manufacturer\", \"device_type\", \"end_of_sale\", \"end_of_life\", \"suggested_replacement\", \"advanced_5g_option\"]:\n",
203
+ " if req not in df.columns:\n",
204
+ " df[req] = \"\"\n",
205
+ "\n",
206
+ " # Compatibility fields used by matching/output\n",
207
+ " if \"description\" not in df.columns:\n",
208
+ " df[\"description\"] = df[\"sku\"].astype(str)\n",
209
+ " if \"notes\" not in df.columns:\n",
210
+ " df[\"notes\"] = \"\"\n",
211
+ " if \"region\" not in df.columns:\n",
212
+ " df[\"region\"] = \"\"\n",
213
+ "\n",
214
+ " return df\n",
215
+ "\n",
216
+ "# ----------------------------\n",
217
+ "# Maker mapping\n",
218
+ "# ----------------------------\n",
219
+ "CANON_MAKER = {\n",
220
+ " \"CRADLEPOINT\": {\"cradlepoint\", \"ericsson\", \"ericsson enterprise wireless\"},\n",
221
+ " \"SIERRA\": {\"sierra\", \"sierra wireless\", \"semtech\", \"airlink\"},\n",
222
+ " \"FEENEY\": {\"feeney\", \"feeney wireless\", \"inseego\"},\n",
223
+ " \"DIGI\": {\"digi\", \"accelerated\", \"accelerated concepts\"},\n",
224
+ " \"CISCO_MERAKI\": {\"meraki\", \"cisco meraki\"},\n",
225
+ " \"CISCO\": {\"cisco\"},\n",
226
+ " \"TELTONIKA\": {\"teltonika\"},\n",
227
+ "}\n",
228
+ "\n",
229
+ "def canon_maker_from_text(s: Any) -> str:\n",
230
+ " t = norm_text(s)\n",
231
+ " for canon, terms in CANON_MAKER.items():\n",
232
+ " for term in terms:\n",
233
+ " if term in t:\n",
234
+ " return canon\n",
235
+ " return \"UNKNOWN\"\n",
236
+ "\n",
237
+ "# ----------------------------\n",
238
+ "# Date parsing\n",
239
+ "# ----------------------------\n",
240
+ "@dataclass\n",
241
+ "class ParsedDate:\n",
242
+ " raw: str\n",
243
+ " kind: str\n",
244
+ " value: Optional[date]\n",
245
+ "\n",
246
+ "def parse_date_field(x: Any) -> ParsedDate:\n",
247
+ " raw = safe_str(x)\n",
248
+ " if not raw:\n",
249
+ " return ParsedDate(raw=\"\", kind=\"missing\", value=None)\n",
250
+ "\n",
251
+ " # MM/DD/YY or M/D/YY\n",
252
+ " if re.fullmatch(r\"\\d{1,2}/\\d{1,2}/\\d{2,4}\", raw):\n",
253
+ " try:\n",
254
+ " parts = raw.split(\"/\")\n",
255
+ " m = int(parts[0]); d = int(parts[1]); y = int(parts[2])\n",
256
+ " if y < 100:\n",
257
+ " y += 2000\n",
258
+ " dt = date(y, m, d)\n",
259
+ " return ParsedDate(raw=f\"{y:04d}-{m:02d}-{d:02d}\", kind=\"full\", value=dt)\n",
260
+ " except Exception:\n",
261
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
262
+ "\n",
263
+ " # YYYY\n",
264
+ " if re.fullmatch(r\"\\d{4}\", raw):\n",
265
+ " y = int(raw)\n",
266
+ " if y == TODAY.year:\n",
267
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
268
+ " if y < TODAY.year:\n",
269
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
270
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 12, 31))\n",
271
+ "\n",
272
+ " # YYYY-MM\n",
273
+ " if re.fullmatch(r\"\\d{4}-\\d{2}\", raw):\n",
274
+ " try:\n",
275
+ " y, m = raw.split(\"-\")\n",
276
+ " dt = date(int(y), int(m), 1)\n",
277
+ " return ParsedDate(raw=raw, kind=\"year_month\", value=dt)\n",
278
+ " except Exception:\n",
279
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
280
+ "\n",
281
+ " # YYYY-MM-DD\n",
282
+ " if re.fullmatch(r\"\\d{4}-\\d{2}-\\d{2}\", raw):\n",
283
+ " try:\n",
284
+ " dt = datetime.strptime(raw, \"%Y-%m-%d\").date()\n",
285
+ " return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
286
+ " except Exception:\n",
287
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
288
+ "\n",
289
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
290
+ "\n",
291
+ "def display_date(pd_: ParsedDate) -> str:\n",
292
+ " if pd_.kind == \"missing\":\n",
293
+ " return \"Not listed\"\n",
294
+ " if pd_.kind == \"bad\":\n",
295
+ " return pd_.raw or \"Not listed\"\n",
296
+ " return pd_.raw\n",
297
+ "\n",
298
+ "def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:\n",
299
+ " if eos.value is None and eol.value is None:\n",
300
+ " return \"Unknown\"\n",
301
+ " if eol.value is not None and eol.value <= TODAY:\n",
302
+ " return \"End of Life\"\n",
303
+ " if eos.value is not None and eos.value <= TODAY:\n",
304
+ " return \"End of Sale\"\n",
305
+ " return \"Active\"\n",
306
+ "\n",
307
+ "def row_to_dates_and_status(row: pd.Series) -> Tuple[str, str, str]:\n",
308
+ " eos = parse_date_field(row.get(\"end_of_sale\"))\n",
309
+ " eol = parse_date_field(row.get(\"end_of_life\"))\n",
310
+ " return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)\n",
311
+ "\n",
312
+ "# ----------------------------\n",
313
+ "# Files\n",
314
+ "# ----------------------------\n",
315
+ "EOS_PATH = \"routers_eos_eol_by_sku.csv\"\n",
316
+ "DEC_PATH = \"dec2025routers.csv\"\n",
317
+ "PARSEC_PDF = \"ParsecCatalog.pdf\"\n",
318
+ "\n",
319
+ "if not os.path.exists(EOS_PATH):\n",
320
+ " raise FileNotFoundError(f\"Missing {EOS_PATH} in repo.\")\n",
321
+ "if not os.path.exists(DEC_PATH):\n",
322
+ " raise FileNotFoundError(f\"Missing {DEC_PATH} in repo.\")\n",
323
+ "if not os.path.exists(PARSEC_PDF):\n",
324
+ " raise FileNotFoundError(f\"Missing {PARSEC_PDF} in repo.\")\n",
325
+ "\n",
326
+ "t0 = time.perf_counter()\n",
327
+ "df_eos = pd.read_csv(EOS_PATH).copy()\n",
328
+ "df_dec = pd.read_csv(DEC_PATH).copy()\n",
329
+ "df_eos = _normalize_lifecycle_df(df_eos)\n",
330
+ "\n",
331
+ "# Canon columns\n",
332
+ "df_eos[\"_canon_make\"] = df_eos[\"manufacturer\"].apply(canon_maker_from_text)\n",
333
+ "df_eos[\"_norm_sku\"] = df_eos[\"sku\"].apply(norm_text)\n",
334
+ "df_eos[\"_norm_desc\"] = df_eos[\"description\"].apply(norm_text)\n",
335
+ "df_eos[\"_norm_notes\"] = df_eos[\"notes\"].apply(norm_text)\n",
336
+ "\n",
337
+ "df_dec[\"_canon_make\"] = df_dec[\"Make\"].apply(canon_maker_from_text) if \"Make\" in df_dec.columns else \"UNKNOWN\"\n",
338
+ "df_dec[\"_norm_model\"] = df_dec[\"Model\"].apply(norm_text) if \"Model\" in df_dec.columns else \"\"\n",
339
+ "df_dec[\"_is5g\"] = df_dec[\"Modem Type\"].apply(lambda x: is_5g_text(str(x))) if \"Modem Type\" in df_dec.columns else False\n",
340
+ "_tlog(\"load csv\", t0)\n",
341
+ "\n",
342
+ "# ----------------------------\n",
343
+ "# Build fuzzy corpus for device matching\n",
344
+ "# ----------------------------\n",
345
+ "def _label_for_row(i: int) -> str:\n",
346
+ " r = df_eos.iloc[i]\n",
347
+ " return f\"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}\"[:220]\n",
348
+ "\n",
349
+ "EOS_LABELS = [_label_for_row(i) for i in range(len(df_eos))]\n",
350
+ "EOS_CORPUS = []\n",
351
+ "for _, r in df_eos.iterrows():\n",
352
+ " EOS_CORPUS.append(\" \".join([r.get(\"_norm_sku\",\"\"), r.get(\"_canon_make\",\"\"), r.get(\"_norm_desc\",\"\"), r.get(\"_norm_notes\",\"\")]))\n",
353
+ "\n",
354
+ "def resolve_device(term: str) -> Dict[str, Any]:\n",
355
+ " q = norm_text(term)\n",
356
+ " if not q:\n",
357
+ " return {\"mode\": \"not_found\"}\n",
358
+ "\n",
359
+ " exact = df_eos.index[df_eos[\"_norm_sku\"] == q].tolist()\n",
360
+ " if len(exact) == 1:\n",
361
+ " return {\"mode\":\"ok\",\"row_idx\": int(exact[0])}\n",
362
+ "\n",
363
+ " hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=6)\n",
364
+ " cands = [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]\n",
365
+ "\n",
366
+ " if not cands:\n",
367
+ " return {\"mode\":\"not_found\"}\n",
368
+ "\n",
369
+ " if cands[0][1] >= MATCH_AUTOPICK and (len(cands) == 1 or (cands[0][1] - cands[1][1]) >= MATCH_GAP):\n",
370
+ " return {\"mode\":\"ok\",\"row_idx\": cands[0][0]}\n",
371
+ "\n",
372
+ " opts = [{\"row_idx\": cands[0][0], \"label\": cands[0][2]}]\n",
373
+ " if len(cands) > 1:\n",
374
+ " opts.append({\"row_idx\": cands[1][0], \"label\": cands[1][2]})\n",
375
+ " return {\"mode\":\"pick\",\"options\": opts}\n",
376
+ "\n",
377
+ "# ----------------------------\n",
378
+ "# Parsec RAG (FAISS)\n",
379
+ "# ----------------------------\n",
380
+ "t0 = time.perf_counter()\n",
381
+ "embedder = SentenceTransformer(EMBED_MODEL_NAME)\n",
382
+ "\n",
383
+ "def extract_pdf_text_pages(path: str) -> List[str]:\n",
384
+ " doc = fitz.open(path)\n",
385
+ " return [doc[i].get_text(\"text\") for i in range(len(doc))]\n",
386
+ "\n",
387
+ "def build_parsec_cards(pages: List[str]) -> List[str]:\n",
388
+ " cards = []\n",
389
+ " for p in pages:\n",
390
+ " for m in re.finditer(r\"Standard\\s+SKU:\", p):\n",
391
+ " start = max(0, m.start() - PARSEC_CONTEXT_BEFORE)\n",
392
+ " end = min(len(p), m.start() + PARSEC_CONTEXT_AFTER)\n",
393
+ " c = p[start:end].strip()\n",
394
+ " if len(c) >= 200:\n",
395
+ " cards.append(c)\n",
396
+ " out, seen = [], set()\n",
397
+ " for c in cards:\n",
398
+ " h = hashlib.sha1(c.encode(\"utf-8\")).hexdigest()\n",
399
+ " if h not in seen:\n",
400
+ " seen.add(h); out.append(c)\n",
401
+ " return out\n",
402
+ "\n",
403
+ "parsec_cards = build_parsec_cards(extract_pdf_text_pages(PARSEC_PDF))\n",
404
+ "parsec_emb = embedder.encode(parsec_cards, batch_size=64, show_progress_bar=False, normalize_embeddings=True)\n",
405
+ "parsec_emb = np.asarray(parsec_emb, dtype=np.float32)\n",
406
+ "parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])\n",
407
+ "parsec_index.add(parsec_emb)\n",
408
+ "_tlog(\"parsec index\", t0)\n",
409
+ "# ----------------------------\n",
410
+ "# Antenna photos from ParsecCatalog.pdf (best effort)\n",
411
+ "# - Build a map from Standard SKU -> page indices once at startup\n",
412
+ "# - Extract the largest image on the matching page and embed as data URI in markdown\n",
413
+ "# (only used when user asks for antenna options)\n",
414
+ "# ----------------------------\n",
415
+ "PARSEC_PN_TO_PAGES: Dict[str, List[int]] = {}\n",
416
+ "\n",
417
+ "try:\n",
418
+ " _doc = fitz.open(PARSEC_PDF)\n",
419
+ " for i in range(len(_doc)):\n",
420
+ " t = _doc[i].get_text(\"text\") or \"\"\n",
421
+ " for m in re.finditer(r\"Standard\\s+SKU:\\s*([A-Z0-9]+)\", t):\n",
422
+ " pn = m.group(1).strip().upper()\n",
423
+ " PARSEC_PN_TO_PAGES.setdefault(pn, []).append(i)\n",
424
+ "except Exception:\n",
425
+ " PARSEC_PN_TO_PAGES = {}\n",
426
+ "\n",
427
+ "def _extract_largest_image_data_uri(page_index: int, max_bytes: int = 350_000) -> str:\n",
428
+ " \"\"\"\n",
429
+ " Extract the largest raster image on a PDF page and return as a data URI (PNG).\n",
430
+ " If the image is too large to embed, return empty string.\n",
431
+ " \"\"\"\n",
432
+ " try:\n",
433
+ " doc = fitz.open(PARSEC_PDF)\n",
434
+ " page = doc[page_index]\n",
435
+ " imgs = page.get_images(full=True) or []\n",
436
+ " if not imgs:\n",
437
+ " return \"\"\n",
438
+ "\n",
439
+ " best_xref = None\n",
440
+ " best_area = 0\n",
441
+ " for img in imgs:\n",
442
+ " xref = img[0]\n",
443
+ " pix = fitz.Pixmap(doc, xref)\n",
444
+ " area = pix.width * pix.height\n",
445
+ " if area > best_area and pix.width >= 200 and pix.height >= 200:\n",
446
+ " best_area = area\n",
447
+ " best_xref = xref\n",
448
+ " pix = None\n",
449
+ "\n",
450
+ " if best_xref is None:\n",
451
+ " return \"\"\n",
452
+ "\n",
453
+ " pix = fitz.Pixmap(doc, best_xref)\n",
454
+ " if pix.n >= 5: # CMYK\n",
455
+ " pix = fitz.Pixmap(fitz.csRGB, pix)\n",
456
+ "\n",
457
+ " png_bytes = pix.tobytes(\"png\")\n",
458
+ " if len(png_bytes) > max_bytes:\n",
459
+ " return \"\"\n",
460
+ "\n",
461
+ " b64 = base64.b64encode(png_bytes).decode(\"ascii\")\n",
462
+ " return f\"data:image/png;base64,{b64}\"\n",
463
+ " except Exception:\n",
464
+ " return \"\"\n",
465
+ "\n",
466
+ "@lru_cache(maxsize=512)\n",
467
+ "def antenna_photo_data_uri(part_number: str) -> str:\n",
468
+ " pn = str(part_number or \"\").strip().upper()\n",
469
+ " if not pn:\n",
470
+ " return \"\"\n",
471
+ " pages = PARSEC_PN_TO_PAGES.get(pn, [])\n",
472
+ " if not pages:\n",
473
+ " return \"\"\n",
474
+ " for p in pages[:3]:\n",
475
+ " uri = _extract_largest_image_data_uri(p)\n",
476
+ " if uri:\n",
477
+ " return uri\n",
478
+ " return \"\"\n",
479
+ "\n",
480
+ "# ----------------------------\n",
481
+ "# Stronger matching (regex normalization + fuzzy)\n",
482
+ "# ----------------------------\n",
483
+ "def _normalize_query_compact(s: str) -> str:\n",
484
+ " s = str(s or \"\").strip().upper()\n",
485
+ " return re.sub(r\"[^A-Z0-9]\", \"\", s)\n",
486
+ "\n",
487
+ "def resolve_device_stronger(term: str) -> Dict[str, Any]:\n",
488
+ " raw = str(term or \"\").strip()\n",
489
+ " if not raw:\n",
490
+ " return {\"mode\":\"not_found\"}\n",
491
+ "\n",
492
+ " q_compact = _normalize_query_compact(raw)\n",
493
+ " # exact compact SKU match\n",
494
+ " if q_compact:\n",
495
+ " for i, sku in enumerate(df_eos[\"_norm_sku\"].tolist()):\n",
496
+ " if _normalize_query_compact(sku) == q_compact:\n",
497
+ " return {\"mode\":\"ok\", \"row_idx\": i, \"confidence\":\"High\"}\n",
498
+ "\n",
499
+ " hits = process.extract(raw, EOS_CORPUS, scorer=fuzz.WRatio, limit=6)\n",
500
+ " cands = [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]\n",
501
+ " if not cands:\n",
502
+ " return {\"mode\":\"not_found\"}\n",
503
+ "\n",
504
+ " if cands[0][1] >= MATCH_AUTOPICK and (len(cands)==1 or (cands[0][1]-cands[1][1]) >= MATCH_GAP):\n",
505
+ " return {\"mode\":\"ok\", \"row_idx\": cands[0][0], \"confidence\":\"High\"}\n",
506
+ "\n",
507
+ " return {\"mode\":\"guess\", \"row_idx\": cands[0][0], \"confidence\":\"Medium\", \"guess_label\": cands[0][2], \"raw\": raw}\n",
508
+ "\n",
509
+ "# ----------------------------\n",
510
+ "# LLM fallback: identify router + replacements (Verizon equipment only, no pricing)\n",
511
+ "# ----------------------------\n",
512
+ "def llm_identify_router_and_replacements(raw_text: str) -> Dict[str, Any]:\n",
513
+ " if client is None:\n",
514
+ " return {\"found\": False, \"note\": \"No API key configured.\"}\n",
515
+ "\n",
516
+ " sys = (\n",
517
+ " \"You help Verizon reps identify cellular routers and suggest replacements. \"\n",
518
+ " \"Keep it to Verizon-sellable equipment families when possible \"\n",
519
+ " \"(Cradlepoint, Sierra/AirLink, Digi, Cisco/Meraki, Teltonika, Inseego). \"\n",
520
+ " \"No pricing. Return strict JSON only.\"\n",
521
+ " )\n",
522
+ " payload = {\n",
523
+ " \"user_input\": raw_text,\n",
524
+ " \"output_schema\": {\n",
525
+ " \"best_guess_model\": \"string\",\n",
526
+ " \"maker_family\": \"CRADLEPOINT|SIERRA|DIGI|CISCO|CISCO_MERAKI|TELTONIKA|FEENEY|UNKNOWN\",\n",
527
+ " \"repl_5g\": \"string\",\n",
528
+ " \"repl_4g\": \"string\",\n",
529
+ " \"confidence\": \"High|Medium\",\n",
530
+ " \"note\": \"string\"\n",
531
+ " }\n",
532
+ " }\n",
533
+ " resp = client.responses.create(\n",
534
+ " model=OPENAI_MODEL,\n",
535
+ " input=[{\"role\":\"system\",\"content\":sys},{\"role\":\"user\",\"content\":_json_dump_safe(payload)}],\n",
536
+ " max_output_tokens=360,\n",
537
+ " )\n",
538
+ " out = _json_load_safe(getattr(resp, \"output_text\", \"\") or \"\")\n",
539
+ " if not isinstance(out, dict) or not out.get(\"best_guess_model\"):\n",
540
+ " return {\"found\": False, \"note\": \"Could not identify router.\"}\n",
541
+ " out[\"found\"] = True\n",
542
+ " return out\n",
543
+ "\n",
544
+ "# ----------------------------\n",
545
+ "# Antenna options: Vehicle + Indoor + Outdoor + Directional\n",
546
+ "# (all omni except directional)\n",
547
+ "# ----------------------------\n",
548
+ "def antenna_options_4pack(repl5: str) -> Dict[str, Dict[str, Any]]:\n",
549
+ " # All 5G routers => 4x4\n",
550
+ " veh = antenna_pick(repl5, mode=\"vehicle\", detail=None)\n",
551
+ " ind = antenna_pick(repl5, mode=\"stationary\", detail=\"indoor\")\n",
552
+ " outd = antenna_pick(repl5, mode=\"stationary\", detail=\"outdoor\")\n",
553
+ " direc = antenna_pick(repl5, mode=\"stationary\", detail=\"directional\")\n",
554
+ "\n",
555
+ " for a in (veh, ind, outd, direc):\n",
556
+ " a[\"photo_uri\"] = antenna_photo_data_uri(a.get(\"part_number\",\"\"))\n",
557
+ "\n",
558
+ " return {\"vehicle\": veh, \"indoor\": ind, \"outdoor\": outd, \"directional\": direc}\n",
559
+ "\n",
560
+ "def _fmt_ant(a: Dict[str, Any]) -> str:\n",
561
+ " name = a.get(\"name\",\"\")\n",
562
+ " pn = a.get(\"part_number\",\"\")\n",
563
+ " desc = a.get(\"description\",\"\")\n",
564
+ " conn = a.get(\"connectors\",\"\")\n",
565
+ " s = f\"**{name}** (PN {pn}) — {desc}\"\n",
566
+ " if conn:\n",
567
+ " s += f\" | Conn: {conn}\"\n",
568
+ " return s\n",
569
+ "\n",
570
+ "\n",
571
+ "PARSEC_FAMILY_WORDS = {\"chinook\",\"labrador\",\"boxer\",\"bloodhound\",\"husky\",\"beagle\",\"mastiff\",\"collie\",\"shepherd\",\"belgian\",\"australian\",\"terrier\",\"pyrenees\"}\n",
572
+ "\n",
573
+ "def _parsec_name_from_card(card_text: str) -> str:\n",
574
+ " low = card_text.lower()\n",
575
+ " for fam in PARSEC_FAMILY_WORDS:\n",
576
+ " if fam in low:\n",
577
+ " return fam.capitalize()\n",
578
+ " return \"Parsec antenna\"\n",
579
+ "\n",
580
+ "def _parsec_part_from_card(t: str) -> str:\n",
581
+ " m = re.search(r\"Standard\\s+SKU:\\s*([A-Z0-9]+)\", t)\n",
582
+ " return m.group(1).strip() if m else \"\"\n",
583
+ "\n",
584
+ "def _parsec_desc_from_card(t: str) -> str:\n",
585
+ " m = re.search(r\"Description:\\s*(.+?)(?:\\n|$)\", t, flags=re.IGNORECASE)\n",
586
+ " return re.sub(r\"\\s+\",\" \",m.group(1).strip())[:220] if m else \"\"\n",
587
+ "\n",
588
+ "def _parsec_connectors_from_card(t: str) -> str:\n",
589
+ " m = re.search(r\"Standard\\s+Connectors:\\s*(.+)\", t, flags=re.IGNORECASE)\n",
590
+ " return re.sub(r\"\\s+\",\" \",m.group(1).strip())[:80] if m else \"\"\n",
591
+ "\n",
592
+ "def parsec_retrieve(query: str, top_k: int = 8) -> List[Dict[str, Any]]:\n",
593
+ " qv = embedder.encode([query], normalize_embeddings=True)\n",
594
+ " qv = np.asarray(qv, dtype=np.float32)\n",
595
+ " scores, ids = parsec_index.search(qv, top_k)\n",
596
+ " out = []\n",
597
+ " for sc, i in zip(scores[0].tolist(), ids[0].tolist()):\n",
598
+ " if 0 <= int(i) < len(parsec_cards):\n",
599
+ " card = parsec_cards[int(i)]\n",
600
+ " out.append({\n",
601
+ " \"score\": float(sc),\n",
602
+ " \"name\": _parsec_name_from_card(card),\n",
603
+ " \"part_number\": _parsec_part_from_card(card),\n",
604
+ " \"description\": _parsec_desc_from_card(card),\n",
605
+ " \"connectors\": _parsec_connectors_from_card(card),\n",
606
+ " })\n",
607
+ " return out\n",
608
+ "\n",
609
+ "def antenna_pick(repl5: str, mode: str, detail: Optional[str]) -> Dict[str, Any]:\n",
610
+ " mimo = \"4x4\" # rule: all 5G -> 4x4\n",
611
+ " tech = \"5G\"\n",
612
+ " if mode == \"vehicle\":\n",
613
+ " q = f\"{repl5} {tech} {mimo} omni vehicle mobile magnetic through-bolt\"\n",
614
+ " c = parsec_retrieve(q, top_k=8)\n",
615
+ " best = c[0] if c else {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\",\"connectors\":\"\"}\n",
616
+ " best.update({\"mimo\": mimo, \"why\": \"Vehicle omni best match.\"})\n",
617
+ " return best\n",
618
+ "\n",
619
+ " if detail == \"directional\":\n",
620
+ " q = f\"{repl5} {tech} {mimo} directional fixed site\"\n",
621
+ " c = parsec_retrieve(q, top_k=8)\n",
622
+ " best = c[0] if c else {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\",\"connectors\":\"\"}\n",
623
+ " best.update({\"mimo\": mimo, \"why\": \"Stationary directional best match.\"})\n",
624
+ " return best\n",
625
+ "\n",
626
+ " if detail == \"indoor\":\n",
627
+ " q = f\"{repl5} {tech} {mimo} omni indoor\"\n",
628
+ " c = parsec_retrieve(q, top_k=8)\n",
629
+ " best = c[0] if c else {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\",\"connectors\":\"\"}\n",
630
+ " best.update({\"mimo\": mimo, \"why\": \"Stationary indoor omni best match.\"})\n",
631
+ " return best\n",
632
+ "\n",
633
+ " q = f\"{repl5} {tech} {mimo} omni outdoor pole wall fixed site\"\n",
634
+ " c = parsec_retrieve(q, top_k=8)\n",
635
+ " best = c[0] if c else {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\",\"connectors\":\"\"}\n",
636
+ " best.update({\"mimo\": mimo, \"why\": \"Stationary outdoor omni best match.\"})\n",
637
+ " return best\n",
638
+ "\n",
639
+ "# ----------------------------\n",
640
+ "# Replacement selection (lifecycle-first)\n",
641
+ "# ----------------------------\n",
642
+ "def extract_model_token(text: str) -> str:\n",
643
+ " s = safe_str(text)\n",
644
+ " if not s:\n",
645
+ " return \"\"\n",
646
+ " parts = [p.strip() for p in s.split(\"|\") if p.strip()]\n",
647
+ " candidates = parts[::-1] if parts else [s]\n",
648
+ " for cand in candidates:\n",
649
+ " u = cand.upper()\n",
650
+ " m = re.search(r\"\\bRUT[A-Z]?\\d{2,4}\\b\", u)\n",
651
+ " if m:\n",
652
+ " return m.group(0)\n",
653
+ " m = re.search(r\"\\bRUTM\\d{2,3}\\b\", u)\n",
654
+ " if m:\n",
655
+ " return m.group(0)\n",
656
+ " m = re.search(r\"\\bIX\\d{2}\\b\", u)\n",
657
+ " if m:\n",
658
+ " return m.group(0)\n",
659
+ " m = re.search(r\"\\b(R\\d{3,4}|E\\d{3,4}|S\\d{3,4})\\b\", u)\n",
660
+ " if m:\n",
661
+ " return m.group(0)\n",
662
+ " m = re.search(r\"\\b[A-Z]{1,6}\\d{2,4}[A-Z]?\\b\", u)\n",
663
+ " if m:\n",
664
+ " return m.group(0)\n",
665
+ " return candidates[0][:60]\n",
666
+ "\n",
667
+ "def pick_replacements(row: pd.Series, status: str) -> Dict[str, str]:\n",
668
+ " sug = safe_str(row.get(\"suggested_replacement\", \"\"))\n",
669
+ " adv = safe_str(row.get(\"advanced_5g_option\", \"\"))\n",
670
+ "\n",
671
+ " repl_4g = extract_model_token(sug) if sug else \"Not applicable\"\n",
672
+ " repl_5g = extract_model_token(adv) if adv else \"Not listed\"\n",
673
+ "\n",
674
+ " # Always provide some 5G answer: if lifecycle missing, pick top 5G from dec (same maker)\n",
675
+ " if repl_5g in {\"\", \"Not listed\"}:\n",
676
+ " canon_make = str(row.get(\"_canon_make\",\"UNKNOWN\"))\n",
677
+ " pool = df_dec[(df_dec[\"_canon_make\"] == canon_make) & (df_dec[\"_is5g\"] == True)].copy()\n",
678
+ " repl_5g = str(pool.iloc[0][\"Model\"]).strip() if not pool.empty else \"Not listed\"\n",
679
+ "\n",
680
+ " return {\"repl_4g\": repl_4g or \"Not applicable\", \"repl_5g\": repl_5g or \"Not listed\"}\n",
681
+ "\n",
682
+ "# ----------------------------\n",
683
+ "# Features + Fit (dec first, single LLM enrichment call if needed)\n",
684
+ "# ----------------------------\n",
685
+ "FEATURE_COLS = [\"Device\", \"Modem technology\", \"WiFi\", \"Ports\", \"Antennas\", \"Ruggedness\", \"Use case\"]\n",
686
+ "FIT_COLS = [\"Device\", \"Fit badges\", \"Ethernet ports\", \"Battery\"]\n",
687
+ "\n",
688
+ "def _features_from_dec(model: str, canon_make: str) -> Dict[str, str]:\n",
689
+ " if not model or model in {\"Not listed\", \"Not applicable\"}:\n",
690
+ " return {k: \"Not listed\" for k in FEATURE_COLS[1:]}\n",
691
+ " pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
692
+ " if pool.empty:\n",
693
+ " return {k: \"Not listed\" for k in FEATURE_COLS[1:]}\n",
694
+ " hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
695
+ " if not hit or hit[1] < MATCH_OK:\n",
696
+ " return {k: \"Not listed\" for k in FEATURE_COLS[1:]}\n",
697
+ " r = pool.iloc[int(hit[2])]\n",
698
+ " ports = f\"WAN: {r.get('WAN ports and speed','')} | LAN: {r.get('LAN ports and speed','')}\".strip()\n",
699
+ " return {\n",
700
+ " \"Modem technology\": str(r.get(\"Modem Type\",\"\") or \"Not listed\"),\n",
701
+ " \"WiFi\": str(r.get(\"WiFi type\",\"\") or \"Not listed\"),\n",
702
+ " \"Ports\": ports if ports else \"Not listed\",\n",
703
+ " \"Antennas\": str(r.get(\"Antennas (internal/external/both)\",\"\") or \"Not listed\"),\n",
704
+ " \"Ruggedness\": str(r.get(\"Ruggedization\",\"\") or \"Not listed\"),\n",
705
+ " \"Use case\": str(r.get(\"Primary use case\",\"\") or \"Not listed\"),\n",
706
+ " }\n",
707
+ "\n",
708
+ "def _fit_from_dec(model: str, canon_make: str, is5: bool) -> Dict[str, str]:\n",
709
+ " badges = []\n",
710
+ " eth = \"Not listed\"\n",
711
+ " bat = \"Not listed\"\n",
712
+ " if is5:\n",
713
+ " badges.append(\"4x4 MIMO\")\n",
714
+ "\n",
715
+ " pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
716
+ " if pool.empty or not model or model in {\"Not listed\", \"Not applicable\"}:\n",
717
+ " return {\"Fit badges\": \", \".join(badges) if badges else \"Not listed\", \"Ethernet ports\": eth, \"Battery\": bat}\n",
718
+ "\n",
719
+ " hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
720
+ " if not hit or hit[1] < MATCH_OK:\n",
721
+ " return {\"Fit badges\": \", \".join(badges) if badges else \"Not listed\", \"Ethernet ports\": eth, \"Battery\": bat}\n",
722
+ "\n",
723
+ " r = pool.iloc[int(hit[2])]\n",
724
+ " use_case = str(r.get(\"Primary use case\",\"\") or \"\").lower()\n",
725
+ " rugged = str(r.get(\"Ruggedization\",\"\") or \"\").lower()\n",
726
+ " wifi = str(r.get(\"WiFi type\",\"\") or \"\").strip().lower()\n",
727
+ " serial = str(r.get(\"Serial port (yes/no)\",\"\") or \"\").strip().lower()\n",
728
+ " battery = str(r.get(\"Battery (internal/removable/none/optional)\",\"\") or \"\").strip().lower()\n",
729
+ " notes_blob = \" \".join([str(r.get(\"Special notes\",\"\") or \"\"), str(r.get(\"summary and use case\",\"\") or \"\")]).lower()\n",
730
+ "\n",
731
+ " if any(k in use_case for k in [\"vehicle\",\"mobile\",\"fleet\",\"in-vehicle\"]) or \"vehicle\" in rugged:\n",
732
+ " badges.append(\"Vehicle\")\n",
733
+ " else:\n",
734
+ " badges.append(\"Fixed site\")\n",
735
+ "\n",
736
+ " if wifi and wifi not in {\"none\",\"no\",\"n/a\"}:\n",
737
+ " badges.append(\"Wi‑Fi\")\n",
738
+ " if any(k in rugged for k in [\"rugged\",\"industrial\",\"ip\",\"harsh\"]):\n",
739
+ " badges.append(\"Rugged\")\n",
740
+ " if \"dual\" in notes_blob and \"sim\" in notes_blob:\n",
741
+ " badges.append(\"Dual‑SIM\")\n",
742
+ " if serial in {\"yes\",\"y\",\"true\"}:\n",
743
+ " badges.append(\"Serial\")\n",
744
+ "\n",
745
+ " if battery:\n",
746
+ " if \"none\" in battery:\n",
747
+ " bat = \"No\"\n",
748
+ " else:\n",
749
+ " bat = \"Yes\"\n",
750
+ "\n",
751
+ " badges_csv = \", \".join(dict.fromkeys(badges)) if badges else \"Not listed\"\n",
752
+ " return {\"Fit badges\": badges_csv, \"Ethernet ports\": eth, \"Battery\": bat}\n",
753
+ "\n",
754
+ "# Enrichment cache (one call per (make, repl4, repl5))\n",
755
+ "_ENRICH_CACHE: Dict[str, Dict[str, Any]] = {}\n",
756
+ "\n",
757
+ "def _enrich_key(canon_make: str, repl4: str, repl5: str) -> str:\n",
758
+ " return hashlib.sha1(f\"{canon_make}|{repl4}|{repl5}\".encode(\"utf-8\")).hexdigest()\n",
759
+ "\n",
760
+ "def gpt_enrich(repl4: str, repl5: str, canon_make: str, feat4: Dict[str,str], feat5: Dict[str,str], fit4: Dict[str,str], fit5: Dict[str,str]) -> Dict[str, Any]:\n",
761
+ " if client is None:\n",
762
+ " return {\"feat4\": feat4, \"feat5\": feat5, \"fit4\": fit4, \"fit5\": fit5}\n",
763
+ "\n",
764
+ " key = _enrich_key(canon_make, repl4, repl5)\n",
765
+ " if key in _ENRICH_CACHE:\n",
766
+ " return _ENRICH_CACHE[key]\n",
767
+ "\n",
768
+ " def miss(d: Dict[str,str]) -> List[str]:\n",
769
+ " out=[]\n",
770
+ " for k,v in d.items():\n",
771
+ " if (not v) or str(v).strip().lower() in {\"not listed\",\"nan\",\"\"}:\n",
772
+ " out.append(k)\n",
773
+ " return out\n",
774
+ "\n",
775
+ " m_feat4 = miss(feat4); m_feat5 = miss(feat5)\n",
776
+ " m_fit4 = miss(fit4); m_fit5 = miss(fit5)\n",
777
+ "\n",
778
+ " if not (m_feat4 or m_feat5 or m_fit4 or m_fit5):\n",
779
+ " pack = {\"feat4\": feat4, \"feat5\": feat5, \"fit4\": fit4, \"fit5\": fit5}\n",
780
+ " _ENRICH_CACHE[key] = pack\n",
781
+ " return pack\n",
782
+ "\n",
783
+ " sys = (\n",
784
+ " \"You are helping a Verizon rep. Fill missing router feature fields and fit traits. Return strict JSON only. \"\n",
785
+ " \"Keep values short. \"\n",
786
+ " \"Fit badges must be chosen from: ['Vehicle','Fixed site','Wi‑Fi','Rugged','Dual‑SIM','4x4 MIMO','High throughput','Serial'] only. \"\n",
787
+ " \"Rule: if a router is 5G, include '4x4 MIMO'. \"\n",
788
+ " \"Ethernet ports must be a single integer as a string when possible; else 'Not listed'. \"\n",
789
+ " \"Battery must be 'Yes', 'No', or 'Not listed'.\"\n",
790
+ " )\n",
791
+ "\n",
792
+ " payload = {\n",
793
+ " \"maker_family\": canon_make,\n",
794
+ " \"models\": {\"repl4\": repl4, \"repl5\": repl5},\n",
795
+ " \"known\": {\"feat4\": feat4, \"feat5\": feat5, \"fit4\": fit4, \"fit5\": fit5},\n",
796
+ " \"missing\": {\"feat4\": m_feat4, \"feat5\": m_feat5, \"fit4\": m_fit4, \"fit5\": m_fit5},\n",
797
+ " \"output_schema\": {\n",
798
+ " \"feat4\": {k: \"string\" for k in m_feat4},\n",
799
+ " \"feat5\": {k: \"string\" for k in m_feat5},\n",
800
+ " \"fit4\": {k: \"string\" for k in m_fit4},\n",
801
+ " \"fit5\": {k: \"string\" for k in m_fit5},\n",
802
+ " },\n",
803
+ " }\n",
804
+ "\n",
805
+ " t0 = time.perf_counter()\n",
806
+ " resp = client.responses.create(\n",
807
+ " model=OPENAI_MODEL,\n",
808
+ " input=[{\"role\":\"system\",\"content\":sys},{\"role\":\"user\",\"content\":_json_dump_safe(payload)}],\n",
809
+ " max_output_tokens=420,\n",
810
+ " )\n",
811
+ " _tlog(\"llm enrich\", t0)\n",
812
+ "\n",
813
+ " out = _json_load_safe(getattr(resp, \"output_text\", \"\") or \"\")\n",
814
+ "\n",
815
+ " def merge(base: Dict[str,str], patch: Any) -> Dict[str,str]:\n",
816
+ " if isinstance(patch, dict):\n",
817
+ " for k,v in patch.items():\n",
818
+ " sv = str(v or \"\").strip()\n",
819
+ " if sv:\n",
820
+ " base[k] = sv\n",
821
+ " return base\n",
822
+ "\n",
823
+ " feat4x = merge(dict(feat4), out.get(\"feat4\", {}))\n",
824
+ " feat5x = merge(dict(feat5), out.get(\"feat5\", {}))\n",
825
+ " fit4x = merge(dict(fit4), out.get(\"fit4\", {}))\n",
826
+ " fit5x = merge(dict(fit5), out.get(\"fit5\", {}))\n",
827
+ "\n",
828
+ " # Enforce 5G 4x4 badge\n",
829
+ " b = str(fit5x.get(\"Fit badges\",\"\") or \"\")\n",
830
+ " if \"4x4 MIMO\" not in b:\n",
831
+ " fit5x[\"Fit badges\"] = (b + \", 4x4 MIMO\").strip(\", \").strip() if b and b != \"Not listed\" else \"4x4 MIMO\"\n",
832
+ "\n",
833
+ " pack = {\"feat4\": feat4x, \"feat5\": feat5x, \"fit4\": fit4x, \"fit5\": fit5x}\n",
834
+ " _ENRICH_CACHE[key] = pack\n",
835
+ " return pack\n",
836
+ "\n",
837
+ "def build_tables(repl4: str, repl5: str, canon_make: str) -> Tuple[pd.DataFrame, pd.DataFrame]:\n",
838
+ " feat4 = _features_from_dec(repl4, canon_make)\n",
839
+ " feat5 = _features_from_dec(repl5, canon_make)\n",
840
+ " fit4 = _fit_from_dec(repl4, canon_make, is5=False)\n",
841
+ " fit5 = _fit_from_dec(repl5, canon_make, is5=True)\n",
842
+ "\n",
843
+ " pack = gpt_enrich(repl4, repl5, canon_make, feat4, feat5, fit4, fit5)\n",
844
+ "\n",
845
+ " feat_df = pd.DataFrame([\n",
846
+ " {\"Device\":\"4G alternative\", **pack[\"feat4\"]},\n",
847
+ " {\"Device\":\"5G replacement\", **pack[\"feat5\"]},\n",
848
+ " ], columns=FEATURE_COLS)\n",
849
+ "\n",
850
+ " fit_df = pd.DataFrame([\n",
851
+ " {\"Device\":\"4G alternative\", **pack[\"fit4\"]},\n",
852
+ " {\"Device\":\"5G replacement\", **pack[\"fit5\"]},\n",
853
+ " ], columns=FIT_COLS)\n",
854
+ "\n",
855
+ " return feat_df, fit_df\n",
856
+ "\n",
857
+ "# ----------------------------\n",
858
+ "# Manufacturer link (deterministic, no HTTP)\n",
859
+ "# ----------------------------\n",
860
+ "MAKER_DOMAINS = {\n",
861
+ " \"CRADLEPOINT\": \"https://cradlepoint.com\",\n",
862
+ " \"SIERRA\": \"https://airlink.com\",\n",
863
+ " \"FEENEY\": \"https://inseego.com\",\n",
864
+ " \"DIGI\": \"https://www.digi.com\",\n",
865
+ " \"CISCO_MERAKI\": \"https://meraki.cisco.com\",\n",
866
+ " \"CISCO\": \"https://www.cisco.com\",\n",
867
+ " \"TELTONIKA\": \"https://teltonika-networks.com\",\n",
868
+ " \"UNKNOWN\": \"\",\n",
869
+ "}\n",
870
+ "\n",
871
+ "def guess_maker_url(model: str, canon_make: str) -> str:\n",
872
+ " model = str(model or \"\").strip()\n",
873
+ " base = MAKER_DOMAINS.get(canon_make, \"\")\n",
874
+ " if not base or not model or model in {\"Not listed\", \"Not applicable\"}:\n",
875
+ " return \"\"\n",
876
+ " q = re.sub(r\"\\s+\", \"+\", model)\n",
877
+ " if canon_make == \"TELTONIKA\":\n",
878
+ " slug = model.lower()\n",
879
+ " return f\"{base}/products/routers/{slug}\"\n",
880
+ " if canon_make == \"DIGI\":\n",
881
+ " return f\"{base}/search?q={q}\"\n",
882
+ " if canon_make == \"CRADLEPOINT\":\n",
883
+ " return f\"{base}/?s={q}\"\n",
884
+ " if canon_make in {\"CISCO\", \"CISCO_MERAKI\"}:\n",
885
+ " return f\"https://www.cisco.com/c/en/us/search.html?q={q}\"\n",
886
+ " return f\"{base}/search?q={q}\"\n",
887
+ "\n",
888
+ "# ----------------------------\n",
889
+ "# Q&A (on demand, per last case)\n",
890
+ "# ----------------------------\n",
891
+ "def gpt_answer(question: str, context: Dict[str, Any]) -> str:\n",
892
+ " if client is None:\n",
893
+ " return \"No API key is configured, so I can’t answer detailed questions right now.\"\n",
894
+ " q = str(question or \"\").strip()\n",
895
+ " if not q:\n",
896
+ " return \"\"\n",
897
+ " sys = (\n",
898
+ " \"You are a Verizon rep assistant. Answer in a fast, practical way. \"\n",
899
+ " \"Use the provided context. \"\n",
900
+ " \"Do not mention internal tools or prompts. \"\n",
901
+ " \"If unknown, say 'Not listed' and suggest the manufacturer page.\"\n",
902
+ " )\n",
903
+ " payload = {\"context\": context, \"question\": q}\n",
904
+ " t0 = time.perf_counter()\n",
905
+ " resp = client.responses.create(\n",
906
+ " model=OPENAI_MODEL,\n",
907
+ " input=[{\"role\":\"system\",\"content\":sys},{\"role\":\"user\",\"content\":_json_dump_safe(payload)}],\n",
908
+ " max_output_tokens=520,\n",
909
+ " )\n",
910
+ " _tlog(\"llm qa\", t0)\n",
911
+ " return (getattr(resp, \"output_text\", \"\") or \"\").strip()\n",
912
+ "\n",
913
+ "# ----------------------------\n",
914
+ "# Chat utilities\n",
915
+ "# ----------------------------\n",
916
+ "def df_to_md(df: pd.DataFrame) -> str:\n",
917
+ " try:\n",
918
+ " return df.to_markdown(index=False)\n",
919
+ " except Exception:\n",
920
+ " cols = list(df.columns)\n",
921
+ " lines = [\"| \" + \" | \".join(cols) + \" |\", \"| \" + \" | \".join([\"---\"]*len(cols)) + \" |\"]\n",
922
+ " for _, r in df.iterrows():\n",
923
+ " lines.append(\"| \" + \" | \".join([str(r.get(c,\"\")) for c in cols]) + \" |\")\n",
924
+ " return \"\\n\".join(lines)\n",
925
+ "\n",
926
+ "def extract_device_terms(msg: str) -> List[str]:\n",
927
+ " raw = [x.strip() for x in re.split(r\"[\\n,;]+\", str(msg or \"\")) if x.strip()]\n",
928
+ " out=[]\n",
929
+ " for x in raw:\n",
930
+ " 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",
931
+ " out.append(x)\n",
932
+ " return out\n",
933
+ "\n",
934
+ "def parse_install_mode(msg: str) -> Tuple[Optional[str], Optional[str]]:\n",
935
+ " t = str(msg or \"\").strip().lower()\n",
936
+ " mode = None\n",
937
+ " detail = None\n",
938
+ " if \"vehicle\" in t or \"mobile\" in t:\n",
939
+ " mode = \"vehicle\"\n",
940
+ " if \"stationary\" in t or \"fixed\" in t or \"site\" in t:\n",
941
+ " mode = \"stationary\"\n",
942
+ " if \"indoor\" in t:\n",
943
+ " detail = \"indoor\"\n",
944
+ " if \"outdoor\" in t:\n",
945
+ " detail = \"outdoor\"\n",
946
+ " if \"directional\" in t:\n",
947
+ " detail = \"directional\"\n",
948
+ " return mode, detail\n",
949
+ "\n",
950
+ "def make_case_key(s: str) -> str:\n",
951
+ " s = str(s or \"\").strip()\n",
952
+ " return re.sub(r\"\\s+\", \" \", s)[:80]\n",
953
+ "\n",
954
+ "# ----------------------------\n",
955
+ "# Chat UI (schema-safe)\n",
956
+ "# ----------------------------\n",
957
+ "with gr.Blocks(title=\"Only-Routers\") as demo:\n",
958
+ " gr.Markdown(\"## Only-Routers\\n\\n**Please enter the router models you would like to verify for replacement.**\\n\\nPaste multiple models/SKUs separated by commas or new lines.\")\n",
959
+ "\n",
960
+ " state = gr.State(\"{}\")\n",
961
+ "\n",
962
+ " chatbot = gr.Chatbot(label=\"Only-Routers Chat\", height=600, type=\"tuples\")\n",
963
+ " msg = gr.Textbox(label=\"Message\", placeholder=\"Example: RUT240, WR21\\nVehicle install\", lines=2)\n",
964
+ " send = gr.Button(\"Send\", variant=\"primary\")\n",
965
+ "\n",
966
+ " def chat_fn(user_msg, history, st_json):\n",
967
+ " t0 = time.perf_counter()\n",
968
+ " st = state_load(st_json)\n",
969
+ " st.setdefault(\"cases\", {})\n",
970
+ " st.setdefault(\"last_case_keys\", [])\n",
971
+ " st.setdefault(\"pending\", {})\n",
972
+ "\n",
973
+ " text = (user_msg or \"\").strip()\n",
974
+ " if not text:\n",
975
+ " return history, state_dump(st)\n",
976
+ "\n",
977
+ " # ----------------------------\n",
978
+ " # Pending: confirm best guess\n",
979
+ " # ----------------------------\n",
980
+ " if st.get(\"pending\", {}).get(\"type\") == \"confirm_guess\":\n",
981
+ " pend = st[\"pending\"]\n",
982
+ " raw = pend.get(\"raw\",\"\")\n",
983
+ " row_idx = int(pend.get(\"row_idx\",-1))\n",
984
+ " low = text.lower().strip()\n",
985
+ "\n",
986
+ " if low in {\"yes\",\"y\",\"yeah\",\"yep\",\"correct\",\"right\",\"ok\",\"okay\"}:\n",
987
+ " life_row = df_eos.iloc[row_idx]\n",
988
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
989
+ " repl = pick_replacements(life_row, status)\n",
990
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
991
+ "\n",
992
+ " feat_df, fit_df = build_tables(repl[\"repl_4g\"], repl[\"repl_5g\"], canon_make)\n",
993
+ " url4 = guess_maker_url(repl[\"repl_4g\"], canon_make) if repl[\"repl_4g\"] != \"Not applicable\" else \"\"\n",
994
+ " url5 = guess_maker_url(repl[\"repl_5g\"], canon_make) if repl[\"repl_5g\"] != \"Not listed\" else \"\"\n",
995
+ "\n",
996
+ " ck = make_case_key(str(life_row.get(\"sku\",\"\")) or raw)\n",
997
+ " st[\"cases\"][ck] = {\"row_idx\": row_idx, \"repl\": repl, \"canon_make\": canon_make, \"status\": status, \"eos\": eos, \"eol\": eol, \"urls\": {\"4g\": url4, \"5g\": url5}}\n",
998
+ " st[\"last_case_keys\"].append(ck)\n",
999
+ "\n",
1000
+ " bot=[]\n",
1001
+ " bot.append(f\"**{ck}**\")\n",
1002
+ " bot.append(f\"- Status: **{status}** | EOS: **{eos}** | EOL: **{eol}**\")\n",
1003
+ " bot.append(f\"- 4G alternative: **{repl['repl_4g']}**\")\n",
1004
+ " bot.append(f\"- 5G replacement: **{repl['repl_5g']}**\")\n",
1005
+ " if url4:\n",
1006
+ " bot.append(f\"- 4G manufacturer page: {url4}\")\n",
1007
+ " if url5:\n",
1008
+ " bot.append(f\"- 5G manufacturer page: {url5}\")\n",
1009
+ " bot.append(\"\\n**Replacement features**\\n\" + df_to_md(feat_df))\n",
1010
+ " bot.append(\"\\n**Verizon fit**\\n\" + df_to_md(fit_df))\n",
1011
+ " bot.append(\"\\nWould you like to see the **antenna options** (Vehicle, Indoor, Outdoor, Directional) for this router? Reply **Yes** or **No**.\")\n",
1012
+ " st[\"pending\"] = {\"type\":\"ask_antennas\", \"case_keys\":[ck]}\n",
1013
+ "\n",
1014
+ " history.append((text, \"\\n\".join(bot)))\n",
1015
+ " _tlog(\"confirm guess\", t0)\n",
1016
+ " return history, state_dump(st)\n",
1017
+ "\n",
1018
+ " if low in {\"no\",\"n\",\"nope\",\"wrong\",\"incorrect\"}:\n",
1019
+ " st[\"pending\"] = {\"type\":\"await_corrected_model\"}\n",
1020
+ " history.append((text, \"No problem — please reply with the corrected router model/SKU.\"))\n",
1021
+ " return history, state_dump(st)\n",
1022
+ "\n",
1023
+ " # If they pasted corrected model instead of yes/no, fall through as new input\n",
1024
+ " st[\"pending\"] = {}\n",
1025
+ "\n",
1026
+ " # ----------------------------\n",
1027
+ " # Pending: waiting for corrected model\n",
1028
+ " # ----------------------------\n",
1029
+ " if st.get(\"pending\", {}).get(\"type\") == \"await_corrected_model\":\n",
1030
+ " st[\"pending\"] = {} # treat message as a new lookup\n",
1031
+ "\n",
1032
+ " # ----------------------------\n",
1033
+ " # Pending: ask antennas yes/no\n",
1034
+ " # ----------------------------\n",
1035
+ " if st.get(\"pending\", {}).get(\"type\") == \"ask_antennas\":\n",
1036
+ " low = text.lower().strip()\n",
1037
+ " want = low in {\"yes\",\"y\",\"yeah\",\"yep\",\"sure\",\"ok\",\"okay\"}\n",
1038
+ " case_keys = st[\"pending\"].get(\"case_keys\", []) or st.get(\"last_case_keys\", [])\n",
1039
+ "\n",
1040
+ " if want:\n",
1041
+ " blocks=[]\n",
1042
+ " for ck in case_keys:\n",
1043
+ " case = st[\"cases\"].get(ck, {})\n",
1044
+ " repl5 = (case.get(\"repl\", {}) or {}).get(\"repl_5g\",\"\")\n",
1045
+ " if not repl5 or repl5 == \"Not listed\":\n",
1046
+ " blocks.append(f\"**{ck}**: No 5G replacement available to anchor antenna picks.\")\n",
1047
+ " continue\n",
1048
+ "\n",
1049
+ " opts = antenna_options_4pack(repl5)\n",
1050
+ " case[\"antenna_options\"] = opts\n",
1051
+ " st[\"cases\"][ck] = case\n",
1052
+ "\n",
1053
+ " b=[]\n",
1054
+ " b.append(f\"**{ck} — Antenna options (Parsec)**\")\n",
1055
+ " b.append(f\"- Vehicle (Omni): {_fmt_ant(opts['vehicle'])}\")\n",
1056
+ " b.append(f\"- Indoor (Omni): {_fmt_ant(opts['indoor'])}\")\n",
1057
+ " b.append(f\"- Outdoor (Omni): {_fmt_ant(opts['outdoor'])}\")\n",
1058
+ " b.append(f\"- Directional: {_fmt_ant(opts['directional'])}\")\n",
1059
+ "\n",
1060
+ " # Photos (best effort, may be empty if too large or not found)\n",
1061
+ " for label in [\"vehicle\",\"indoor\",\"outdoor\",\"directional\"]:\n",
1062
+ " uri = opts[label].get(\"photo_uri\",\"\")\n",
1063
+ " if uri:\n",
1064
+ " b.append(f\"\\n**{label.capitalize()} photo**\\n![]({uri})\\n\")\n",
1065
+ "\n",
1066
+ " blocks.append(\"\\n\".join(b))\n",
1067
+ "\n",
1068
+ " blocks.append(\"\\nAny questions about the router(s) — including alternatives and comparisons? Ask anything router-related (no pricing).\")\n",
1069
+ " st[\"pending\"] = {\"type\":\"await_questions\"}\n",
1070
+ " history.append((text, \"\\n\\n---\\n\\n\".join(blocks)))\n",
1071
+ " _tlog(\"antennas yes\", t0)\n",
1072
+ " return history, state_dump(st)\n",
1073
+ "\n",
1074
+ " # No antennas\n",
1075
+ " st[\"pending\"] = {\"type\":\"await_questions\"}\n",
1076
+ " history.append((text, \"Got it. Any questions about the router(s) — including alternatives and comparisons? Ask anything router-related (no pricing).\"))\n",
1077
+ " return history, state_dump(st)\n",
1078
+ "\n",
1079
+ " # ----------------------------\n",
1080
+ " # Pending: questions phase\n",
1081
+ " # ----------------------------\n",
1082
+ " if st.get(\"pending\", {}).get(\"type\") == \"await_questions\":\n",
1083
+ " if not st.get(\"last_case_keys\"):\n",
1084
+ " history.append((text, \"Please enter the router models you would like to verify for replacement.\"))\n",
1085
+ " return history, state_dump(st)\n",
1086
+ "\n",
1087
+ " # Route to most recent unless message mentions a case key\n",
1088
+ " target = st[\"last_case_keys\"][-1]\n",
1089
+ " t_low = text.lower()\n",
1090
+ " for ck in reversed(st[\"last_case_keys\"]):\n",
1091
+ " if ck.lower() in t_low:\n",
1092
+ " target = ck\n",
1093
+ " break\n",
1094
+ "\n",
1095
+ " case = st[\"cases\"].get(target, {})\n",
1096
+ " ctx = {\n",
1097
+ " \"case\": target,\n",
1098
+ " \"status\": case.get(\"status\",\"\"),\n",
1099
+ " \"eos\": case.get(\"eos\",\"\"),\n",
1100
+ " \"eol\": case.get(\"eol\",\"\"),\n",
1101
+ " \"replacements\": case.get(\"repl\", {}),\n",
1102
+ " \"urls\": case.get(\"urls\", {}),\n",
1103
+ " \"antenna_options\": case.get(\"antenna_options\", {}),\n",
1104
+ " }\n",
1105
+ " ans = gpt_answer(text, ctx)\n",
1106
+ " history.append((text, ans))\n",
1107
+ " _tlog(\"qa\", t0)\n",
1108
+ " return history, state_dump(st)\n",
1109
+ "\n",
1110
+ " # ----------------------------\n",
1111
+ " # Normal device intake\n",
1112
+ " # ----------------------------\n",
1113
+ " terms = extract_device_terms(text)\n",
1114
+ " if not terms:\n",
1115
+ " # If not a device list, treat as question about last router if possible\n",
1116
+ " if st.get(\"last_case_keys\"):\n",
1117
+ " case = st[\"cases\"].get(st[\"last_case_keys\"][-1], {})\n",
1118
+ " ctx = {\"replacements\": case.get(\"repl\", {}), \"urls\": case.get(\"urls\", {}), \"antenna_options\": case.get(\"antenna_options\", {})}\n",
1119
+ " ans = gpt_answer(text, ctx)\n",
1120
+ " history.append((text, ans))\n",
1121
+ " return history, state_dump(st)\n",
1122
+ "\n",
1123
+ " history.append((text, \"Please enter the router models you would like to verify for replacement.\"))\n",
1124
+ " return history, state_dump(st)\n",
1125
+ "\n",
1126
+ " blocks=[]\n",
1127
+ " case_keys=[]\n",
1128
+ "\n",
1129
+ " for term in terms:\n",
1130
+ " res = resolve_device_stronger(term)\n",
1131
+ "\n",
1132
+ " if res.get(\"mode\") == \"ok\":\n",
1133
+ " row_idx = int(res[\"row_idx\"])\n",
1134
+ " life_row = df_eos.iloc[row_idx]\n",
1135
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
1136
+ " repl = pick_replacements(life_row, status)\n",
1137
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
1138
+ "\n",
1139
+ " feat_df, fit_df = build_tables(repl[\"repl_4g\"], repl[\"repl_5g\"], canon_make)\n",
1140
+ " url4 = guess_maker_url(repl[\"repl_4g\"], canon_make) if repl[\"repl_4g\"] != \"Not applicable\" else \"\"\n",
1141
+ " url5 = guess_maker_url(repl[\"repl_5g\"], canon_make) if repl[\"repl_5g\"] != \"Not listed\" else \"\"\n",
1142
+ "\n",
1143
+ " ck = make_case_key(str(life_row.get(\"sku\",\"\")) or term)\n",
1144
+ " st[\"cases\"][ck] = {\"row_idx\": row_idx, \"repl\": repl, \"canon_make\": canon_make, \"status\": status, \"eos\": eos, \"eol\": eol, \"urls\": {\"4g\": url4, \"5g\": url5}}\n",
1145
+ " st[\"last_case_keys\"].append(ck)\n",
1146
+ " case_keys.append(ck)\n",
1147
+ "\n",
1148
+ " bot=[]\n",
1149
+ " bot.append(f\"**{ck}**\")\n",
1150
+ " bot.append(f\"- Status: **{status}** | EOS: **{eos}** | EOL: **{eol}**\")\n",
1151
+ " bot.append(f\"- 4G alternative: **{repl['repl_4g']}**\")\n",
1152
+ " bot.append(f\"- 5G replacement: **{repl['repl_5g']}**\")\n",
1153
+ " if url4:\n",
1154
+ " bot.append(f\"- 4G manufacturer page: {url4}\")\n",
1155
+ " if url5:\n",
1156
+ " bot.append(f\"- 5G manufacturer page: {url5}\")\n",
1157
+ " bot.append(\"\\n**Replacement features**\\n\" + df_to_md(feat_df))\n",
1158
+ " bot.append(\"\\n**Verizon fit**\\n\" + df_to_md(fit_df))\n",
1159
+ " blocks.append(\"\\n\".join(bot))\n",
1160
+ " continue\n",
1161
+ "\n",
1162
+ " if res.get(\"mode\") == \"guess\":\n",
1163
+ " st[\"pending\"] = {\"type\":\"confirm_guess\", \"row_idx\": int(res[\"row_idx\"]), \"raw\": res.get(\"raw\",\"\")}\n",
1164
+ " history.append((text, f\"I think you mean: **{res.get('guess_label','')}**. Is that correct? Reply **Yes** or **No** (or paste the corrected model).\"))\n",
1165
+ " return history, state_dump(st)\n",
1166
+ "\n",
1167
+ " # Not found locally: ask to clarify AND attempt LLM best effort\n",
1168
+ " llm = llm_identify_router_and_replacements(term)\n",
1169
+ " if llm.get(\"found\"):\n",
1170
+ " ck = make_case_key(llm.get(\"best_guess_model\",\"\") or term)\n",
1171
+ " repl = {\"repl_4g\": llm.get(\"repl_4g\",\"Not applicable\") or \"Not applicable\", \"repl_5g\": llm.get(\"repl_5g\",\"Not listed\") or \"Not listed\"}\n",
1172
+ " canon_make = llm.get(\"maker_family\",\"UNKNOWN\")\n",
1173
+ " url4 = guess_maker_url(repl[\"repl_4g\"], canon_make) if repl[\"repl_4g\"] != \"Not applicable\" else \"\"\n",
1174
+ " url5 = guess_maker_url(repl[\"repl_5g\"], canon_make) if repl[\"repl_5g\"] != \"Not listed\" else \"\"\n",
1175
+ "\n",
1176
+ " st[\"cases\"][ck] = {\"row_idx\": None, \"repl\": repl, \"canon_make\": canon_make, \"status\": \"Unknown\", \"eos\": \"Not listed\", \"eol\": \"Not listed\", \"urls\": {\"4g\": url4, \"5g\": url5}, \"llm_note\": llm.get(\"note\",\"\")}\n",
1177
+ " st[\"last_case_keys\"].append(ck)\n",
1178
+ " case_keys.append(ck)\n",
1179
+ "\n",
1180
+ " bot=[]\n",
1181
+ " bot.append(f\"**{ck}** (best effort)\")\n",
1182
+ " bot.append(f\"- Note: {llm.get('note','')}\")\n",
1183
+ " bot.append(f\"- 4G alternative: **{repl['repl_4g']}**\")\n",
1184
+ " bot.append(f\"- 5G replacement: **{repl['repl_5g']}**\")\n",
1185
+ " if url4:\n",
1186
+ " bot.append(f\"- 4G manufacturer page: {url4}\")\n",
1187
+ " if url5:\n",
1188
+ " bot.append(f\"- 5G manufacturer page: {url5}\")\n",
1189
+ " bot.append(\"\\nIf this is not the correct router, reply with the exact model and manufacturer.\")\n",
1190
+ " blocks.append(\"\\n\".join(bot))\n",
1191
+ " else:\n",
1192
+ " blocks.append(f\"**{term}**: not found. Who makes it (manufacturer) and what's the exact model/SKU?\")\n",
1193
+ "\n",
1194
+ " if case_keys:\n",
1195
+ " blocks.append(\"\\nWould you like to see the **antenna options** (Vehicle, Indoor, Outdoor, Directional) for each router? Reply **Yes** or **No**.\")\n",
1196
+ " st[\"pending\"] = {\"type\":\"ask_antennas\", \"case_keys\": case_keys}\n",
1197
+ " else:\n",
1198
+ " st[\"pending\"] = {\"type\":\"await_questions\"}\n",
1199
+ "\n",
1200
+ " history.append((text, \"\\n\\n---\\n\\n\".join(blocks)))\n",
1201
+ " _tlog(\"lookup\", t0)\n",
1202
+ " return history, state_dump(st)\n",
1203
+ "\n",
1204
+ " send.click(fn=chat_fn, inputs=[msg, chatbot, state], outputs=[chatbot, state], api_name=False)\n",
1205
+ "\n",
1206
+ "demo.launch(server_name=\"0.0.0.0\", server_port=int(os.getenv(\"PORT\",\"7860\")), share=False, show_api=False)\n"
1207
+ ]
1208
+ }
1209
+ ],
1210
+ "metadata": {
1211
+ "kernelspec": {
1212
+ "display_name": "Python 3",
1213
+ "name": "python3"
1214
+ },
1215
+ "language_info": {
1216
+ "name": "python"
1217
+ }
1218
+ },
1219
+ "nbformat": 4,
1220
+ "nbformat_minor": 5
1221
+ }