crazycrazypete commited on
Commit
5ee8a3b
·
verified ·
1 Parent(s): 8b8a60e

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. app.py +67 -121
  2. only-routers_ai_poc_v4_6.ipynb +768 -0
app.py CHANGED
@@ -2,7 +2,6 @@ import os
2
  import re
3
  import json
4
  import math
5
- import glob
6
  import hashlib
7
  from dataclasses import dataclass
8
  from datetime import datetime, date
@@ -29,7 +28,6 @@ OPENAI_REASONING = {"effort": "high"}
29
 
30
  MATCH_OK = 80
31
  EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
32
-
33
  PARSEC_CONTEXT_BEFORE = 900
34
  PARSEC_CONTEXT_AFTER = 1600
35
 
@@ -45,7 +43,7 @@ client = OpenAI(api_key=API_KEY) if API_KEY else None
45
 
46
 
47
  # ============================
48
- # Small utilities
49
  # ============================
50
  def norm_text(s: Any) -> str:
51
  try:
@@ -125,14 +123,14 @@ def _region_ok(x: Any) -> bool:
125
  if "region" in df_eos.columns:
126
  df_eos = df_eos[df_eos["region"].apply(_region_ok)].reset_index(drop=True)
127
 
128
- # Optional "Device Type" column
129
  device_type_col = None
130
  for c in df_eos.columns:
131
  if norm_text(c) == "device type":
132
  device_type_col = c
133
  break
134
 
135
- # Maker mapping
136
  CANON_MAKER = {
137
  "CRADLEPOINT": {"cradlepoint", "ericsson", "ericsson enterprise wireless"},
138
  "SIERRA": {"sierra", "sierra wireless", "semtech", "airlink"},
@@ -140,6 +138,7 @@ CANON_MAKER = {
140
  "DIGI": {"digi", "accelerated", "accelerated concepts"},
141
  "CISCO_MERAKI": {"meraki", "cisco meraki"},
142
  "CISCO": {"cisco"},
 
143
  }
144
  DISPLAY_MAKER = {
145
  "CRADLEPOINT": "Cradlepoint",
@@ -148,6 +147,7 @@ DISPLAY_MAKER = {
148
  "DIGI": "Digi",
149
  "CISCO_MERAKI": "Cisco Meraki",
150
  "CISCO": "Cisco",
 
151
  "UNKNOWN": "Unknown",
152
  }
153
 
@@ -168,14 +168,6 @@ df_dec["_canon_make"] = df_dec["Make"].apply(canon_maker_from_text) if "Make" in
168
  df_dec["_norm_model"] = df_dec["Model"].apply(norm_text) if "Model" in df_dec.columns else ""
169
  df_dec["_is5g"] = df_dec["Modem Type"].apply(_is_5g) if "Modem Type" in df_dec.columns else False
170
 
171
- def display_maker_for_row(life_row: pd.Series) -> str:
172
- canon = str(life_row.get("_canon_make","UNKNOWN"))
173
- if canon != "DIGI":
174
- return DISPLAY_MAKER.get(canon, "Unknown")
175
- desc = norm_text(life_row.get("description",""))
176
- notes = norm_text(life_row.get("notes",""))
177
- return "Accelerated Concepts (now Digi)" if ("accelerated" in desc or "accelerated" in notes) else "Digi"
178
-
179
 
180
  # ============================
181
  # Date helpers
@@ -238,11 +230,10 @@ def row_to_dates_and_status(life_row: pd.Series) -> Tuple[str, str, str]:
238
 
239
 
240
  # ============================
241
- # Embeddings + indices
242
  # ============================
243
  embedder = SentenceTransformer(EMBED_MODEL_NAME)
244
 
245
- # Parsec cards around "Standard SKU"
246
  def extract_pdf_text_pages(path: str) -> List[str]:
247
  doc = fitz.open(path)
248
  return [doc[i].get_text("text") for i in range(len(doc))]
@@ -309,7 +300,6 @@ def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> D
309
 
310
  def resolve_device(user_text: str) -> Dict[str, Any]:
311
  q = norm_text(user_text)
312
-
313
  exact_idxs = df_eos.index[df_eos["_norm_sku"] == q].tolist()
314
  if len(exact_idxs) == 1:
315
  return {"mode":"ok","row_idx": int(exact_idxs[0])}
@@ -341,7 +331,8 @@ def resolve_device(user_text: str) -> Dict[str, Any]:
341
 
342
 
343
  # ============================
344
- # Replacements — source of truth is lifecycle CSV (with GPT fallback)
 
345
  # ============================
346
  def _extract_model_token(text: str) -> str:
347
  s = _safe_str(text)
@@ -351,20 +342,33 @@ def _extract_model_token(text: str) -> str:
351
  candidates = parts[::-1] if parts else [s]
352
 
353
  for cand in candidates:
 
 
 
 
 
354
  m = re.search(r"\bIX\d{2}\b", cand, flags=re.IGNORECASE)
355
  if m:
356
  return m.group(0).upper()
 
357
  m = re.search(r"\b(R\d{3,4}|E\d{3,4}|S\d{3,4})\b", cand, flags=re.IGNORECASE)
358
  if m:
359
  return m.group(0).upper()
360
- m = re.search(r"\b[A-Z]{1,5}\d{2,4}[A-Z]?\b", cand.upper())
 
361
  if m:
362
  return m.group(0).upper()
363
 
364
  return candidates[0][:60]
365
 
366
- def _candidate_5g_models_from_lifecycle(canon_make: str) -> List[str]:
367
- pool = df_eos[df_eos["_canon_make"] == canon_make].copy()
 
 
 
 
 
 
368
  vals = pool["advanced_5g_option"].tolist() if "advanced_5g_option" in pool.columns else []
369
  out, seen = [], set()
370
  for v in vals:
@@ -373,8 +377,9 @@ def _candidate_5g_models_from_lifecycle(canon_make: str) -> List[str]:
373
  seen.add(tok); out.append(tok)
374
  return out
375
 
376
- def _candidate_4g_models_from_lifecycle(canon_make: str) -> List[str]:
377
- pool = df_eos[df_eos["_canon_make"] == canon_make].copy()
 
378
  vals = pool["suggested_replacement"].tolist() if "suggested_replacement" in pool.columns else []
379
  out, seen = [], set()
380
  for v in vals:
@@ -383,21 +388,21 @@ def _candidate_4g_models_from_lifecycle(canon_make: str) -> List[str]:
383
  seen.add(tok); out.append(tok)
384
  return out
385
 
386
- def _gpt_pick_from_lifecycle_models(old_row: pd.Series, candidates: List[str], need: str) -> str:
387
  if client is None or not candidates:
388
  return ""
389
  sys = "Pick the best replacement model. Choose only from candidates. Return strict JSON only."
390
  payload = {
391
  "old_device": {
392
  "sku": str(old_row.get("sku","")),
393
- "description": str(old_row.get("description","")),
394
  "manufacturer": str(old_row.get("manufacturer","")),
 
395
  "need": need,
396
  },
397
- "candidates": candidates[:30],
398
  "output_schema": {"choice":"string"}
399
  }
400
- out = gpt_json(sys, payload, max_tokens=220) or {}
401
  choice = str(out.get("choice","") or "").strip()
402
  return choice if choice in candidates else ""
403
 
@@ -405,36 +410,33 @@ def _fallback_5g_from_dec(canon_make: str) -> str:
405
  pool5 = df_dec[(df_dec["_canon_make"] == canon_make) & (df_dec["_is5g"] == True)]
406
  return str(pool5.iloc[0]["Model"]).strip() if not pool5.empty else ""
407
 
408
- def _device_is_4g(life_row: pd.Series) -> bool:
409
- t = norm_text(life_row.get("description","")) + " " + norm_text(life_row.get("notes",""))
410
- return (("lte" in t or "4g" in t) and ("5g" not in t and "nr" not in t))
411
-
412
  def pick_replacements_lifecycle(life_row: pd.Series, status: str) -> Dict[str, Any]:
413
  canon = str(life_row.get("_canon_make","UNKNOWN"))
414
- if canon == "UNKNOWN":
415
- return {"repl_4g":"Not applicable","repl_5g":"", "why":"", "sources":[]}
416
 
417
  is_4g_device = _device_is_4g(life_row)
418
  needs_4g_repl = is_4g_device and (status in {"End of Sale","End of Life"})
419
  want_5g = is_4g_device or (status in {"End of Sale","End of Life"})
420
 
 
421
  repl_4g = "Not applicable"
422
- if needs_4g_repl:
423
  repl_4g = _extract_model_token(_safe_str(life_row.get("suggested_replacement","")))
424
  if not repl_4g:
425
- cand4 = _candidate_4g_models_from_lifecycle(canon)
426
- repl_4g = _gpt_pick_from_lifecycle_models(life_row, cand4, "4G replacement") or (cand4[0] if cand4 else "")
427
  if not repl_4g:
428
  repl_4g = "Not applicable"
429
 
 
430
  repl_5g = "Not applicable"
431
  if want_5g:
432
  repl_5g = _extract_model_token(_safe_str(life_row.get("advanced_5g_option","")))
433
  if not repl_5g:
434
- cand5 = _candidate_5g_models_from_lifecycle(canon)
435
- repl_5g = _gpt_pick_from_lifecycle_models(life_row, cand5, "5G replacement/upgrade") or (cand5[0] if cand5 else "")
436
  if not repl_5g:
437
- # last resort: dec catalog
438
  repl_5g = _fallback_5g_from_dec(canon)
439
 
440
  if repl_5g.lower() == "nan":
@@ -444,7 +446,7 @@ def pick_replacements_lifecycle(life_row: pd.Series, status: str) -> Dict[str, A
444
  "repl_4g": repl_4g,
445
  "repl_5g": repl_5g,
446
  "why": "Lifecycle replacements (GPT fallback when missing).",
447
- "sources": ["lifecycle_csv"] + (["gpt"] if client else []) + (["dec_fallback"] if (want_5g and not _extract_model_token(_safe_str(life_row.get("advanced_5g_option","")))) else []),
448
  }
449
 
450
 
@@ -493,6 +495,7 @@ def _parsec_name_from_card(card_text: str) -> str:
493
  if fam:
494
  return fam
495
 
 
496
  sku_i = None
497
  for i, ln in enumerate(lines):
498
  if "standard sku" in ln.lower():
@@ -529,7 +532,6 @@ def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:
529
  "name": _parsec_name_from_card(card),
530
  "part_number": _parsec_part_from_card(card),
531
  "description": _parsec_desc_from_card(card),
532
- "card": card[:1100],
533
  })
534
  return out
535
 
@@ -539,60 +541,21 @@ def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, An
539
  cand_stationary = parsec_retrieve(q_stationary, top_k=10)
540
  cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)
541
 
542
- if client is None:
543
- s = cand_stationary[0] if cand_stationary else {"name":"Parsec antenna","part_number":"","description":""}
544
- v = cand_vehicle[0] if cand_vehicle else {"name":"Parsec antenna","part_number":"","description":""}
545
- s.update({"mimo": mimo, "why": "Stationary omni best match."})
546
- v.update({"mimo": mimo, "why": "Vehicle omni best match."})
547
- return {"stationary_omni": s, "vehicle_omni": v, "sources":["parsec_rag"]}
548
-
549
- sys = "Select Parsec antennas. Choose only from candidates. Return strict JSON only."
550
- payload = {
551
- "router_model": router_model,
552
- "tech": tech,
553
- "mimo": mimo,
554
- "stationary_candidates": cand_stationary,
555
- "vehicle_candidates": cand_vehicle,
556
- "rules": [
557
- "Return two options: stationary_omni and vehicle_omni.",
558
- "Use only candidates. Prefer family names like Labrador/Chinook/Boxer.",
559
- "Include name, part_number, description, mimo, why.",
560
- "Return JSON only."
561
- ],
562
- "output_schema": {
563
- "stationary_omni": {"name":"string","part_number":"string","description":"string","mimo":"2x2|4x4","why":"string"},
564
- "vehicle_omni": {"name":"string","part_number":"string","description":"string","mimo":"2x2|4x4","why":"string"}
565
- }
566
- }
567
- out = gpt_json(sys, payload, max_tokens=650) or {}
568
-
569
- def _fix(x: Dict[str, Any], default_why: str) -> Dict[str, str]:
570
- return {
571
- "name": str(x.get("name","Parsec antenna") or "Parsec antenna")[:110],
572
- "part_number": str(x.get("part_number","") or "")[:40],
573
- "description": str(x.get("description","") or "")[:220],
574
- "mimo": str(x.get("mimo", mimo) or mimo),
575
- "why": str(x.get("why", default_why) or default_why)[:160],
576
- }
577
-
578
- s = _fix(out.get("stationary_omni", {}) if isinstance(out, dict) else {}, "Stationary omni best match.")
579
- v = _fix(out.get("vehicle_omni", {}) if isinstance(out, dict) else {}, "Vehicle omni best match.")
580
- if not s.get("part_number") and cand_stationary:
581
- top = cand_stationary[0]
582
- s = {"name": top.get("name","Parsec antenna"), "part_number": top.get("part_number",""), "description": top.get("description",""), "mimo": mimo, "why":"Stationary omni best match."}
583
- if not v.get("part_number") and cand_vehicle:
584
- top = cand_vehicle[0]
585
- v = {"name": top.get("name","Parsec antenna"), "part_number": top.get("part_number",""), "description": top.get("description",""), "mimo": mimo, "why":"Vehicle omni best match."}
586
- return {"stationary_omni": s, "vehicle_omni": v, "sources":["parsec_rag","gpt"]}
587
 
588
 
589
  # ============================
590
- # Feature table + GPT fill for missing fields (no more ****; fill missing via GPT)
591
  # ============================
592
  FEATURE_COLS = ["Name","Modem technology","WiFi","Ports","Antennas","Ruggedness","Use case"]
593
 
594
  def dec_features_by_model(model: str, canon_make: str) -> Dict[str, str]:
595
- if not model or model == "Not applicable":
596
  return {k:"Not listed" for k in FEATURE_COLS}
597
  pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
598
  if pool.empty:
@@ -638,7 +601,7 @@ def current_features_guess(life_row: pd.Series) -> Dict[str,str]:
638
  notes = str(life_row.get("notes","") or "").strip()
639
  base = {
640
  "Name": sku,
641
- "Modem technology": "4G" if _device_is_4g(life_row) else ("5G" if (("5g" in (desc+notes).lower()) or ("nr" in (desc+notes).lower())) else "Not listed"),
642
  "WiFi": "Not listed",
643
  "Ports": "Not listed",
644
  "Antennas": "Not listed",
@@ -651,7 +614,6 @@ def build_features_table(cur: Dict[str,str], r4: Dict[str,str], r5: Dict[str,str
651
  cols = ["Device", "Modem technology", "WiFi", "Ports", "Antennas", "Ruggedness", "Use case"]
652
  header = "| " + " | ".join(cols) + " |"
653
  sep = "| " + " | ".join(["---"]*len(cols)) + " |"
654
-
655
  def row(name: str, feats: Dict[str,str]) -> str:
656
  return "| " + " | ".join([
657
  name,
@@ -662,34 +624,28 @@ def build_features_table(cur: Dict[str,str], r4: Dict[str,str], r5: Dict[str,str
662
  feats.get("Ruggedness","Not listed"),
663
  feats.get("Use case","Not listed"),
664
  ]) + " |"
665
-
666
- return "\n".join([header, sep, row("Current", cur), row("4G replacement", r4), row("5G replacement", r5)])
667
 
668
 
669
  # ============================
670
- # Output + Gradio UI
671
  # ============================
672
- def fmt(v: Any, fallback: str = "Not listed") -> str:
673
- s = _safe_str(v)
674
- if not s or s.lower() == "nan":
675
- return fallback
676
- return s
677
-
678
  def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
679
  canon_make = str(life_row.get("_canon_make","UNKNOWN"))
680
  current_name = f"{life_row.get('sku','')} — {life_row.get('description','')}".strip(" —")
681
 
682
- # Antenna
683
  st = ant.get("stationary_omni", {})
684
  vh = ant.get("vehicle_omni", {})
685
 
686
- # Feature table (fill missing via GPT)
687
  cur_feats = current_features_guess(life_row)
688
  r4_feats = dec_features_by_model(repl.get("repl_4g",""), canon_make)
689
  r5_feats = dec_features_by_model(repl.get("repl_5g",""), canon_make)
 
 
690
  if client is not None:
691
- r4_feats = gpt_fill_features("4G replacement", r4_feats, "")
692
- r5_feats = gpt_fill_features("5G replacement", r5_feats, "")
 
693
  table_md = build_features_table(cur_feats, r4_feats, r5_feats)
694
 
695
  lines = []
@@ -697,28 +653,19 @@ def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl:
697
  lines.append(f"2. Status: **{status}**")
698
  lines.append(f"3. End of Sale date: **{eos}**")
699
  lines.append(f"4. End of Life date: **{eol}**")
700
- lines.append(f"5. 4G recommended replacement: **{fmt(repl.get('repl_4g'), 'Not applicable')}**")
701
- # If 5G is empty, force GPT to pick from lifecycle pool
702
- repl5 = fmt(repl.get("repl_5g"), "")
703
- if (not repl5) and client is not None:
704
- cand5 = _candidate_5g_models_from_lifecycle(str(life_row.get('_canon_make','UNKNOWN')))
705
- repl5 = _gpt_pick_from_lifecycle_models(life_row, cand5, "5G replacement/upgrade") or (cand5[0] if cand5 else "")
706
- if not repl5:
707
- repl5 = "Not listed"
708
- lines.append(f"6. 5G recommended replacement: **{repl5}**")
709
-
710
  lines.append("7. Antenna options (Parsec-only):")
711
- lines.append(f" - Stationary (Omni): **{fmt(st.get('name'))}** (Part #: {fmt(st.get('part_number'))}) — {fmt(st.get('description'))} — MIMO: {fmt(st.get('mimo'))} — {fmt(st.get('why'))}")
712
- lines.append(f" - Vehicle (Omni): **{fmt(vh.get('name'))}** (Part #: {fmt(vh.get('part_number'))}) — {fmt(vh.get('description'))} — MIMO: {fmt(vh.get('mimo'))} — {fmt(vh.get('why'))}")
713
-
714
  lines.append("8. Recommended features table:")
715
  lines.append(table_md)
716
-
717
  lines.append("\nSources (debug):")
718
  for s in repl.get("sources", []) if isinstance(repl.get("sources"), list) else []:
719
  lines.append(f"- {s}")
720
  lines.append("- ParsecCatalog.pdf (local RAG)")
721
- lines.append("- dec2025routers.csv (features + fallback)")
 
722
  return "\n".join(lines)
723
 
724
  def run_lookup(user_text: str, st: Dict[str,Any]):
@@ -741,7 +688,7 @@ def run_lookup(user_text: str, st: Dict[str,Any]):
741
 
742
  repl = pick_replacements_lifecycle(life_row, status)
743
 
744
- tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") != "Not applicable" else ("4G" if _device_is_4g(life_row) else "Unknown")
745
  mimo_guess = "4x4" if tech == "5G" else "2x2"
746
  ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo_guess)
747
 
@@ -764,8 +711,7 @@ def use_selection(selected_label: str, st: Dict[str,Any]):
764
  life_row = df_eos.iloc[int(chosen_row)]
765
  eos, eol, status = row_to_dates_and_status(life_row)
766
  repl = pick_replacements_lifecycle(life_row, status)
767
-
768
- tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") != "Not applicable" else ("4G" if _device_is_4g(life_row) else "Unknown")
769
  mimo_guess = "4x4" if tech == "5G" else "2x2"
770
  ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo_guess)
771
 
@@ -773,7 +719,7 @@ def use_selection(selected_label: str, st: Dict[str,Any]):
773
 
774
  with gr.Blocks(title="Only-Routers") as demo:
775
  gr.Markdown("## Only-Routers\nEnter a router SKU/model. If ambiguous, you’ll get A/B choices.")
776
- user_text = gr.Textbox(label="Router SKU or model", placeholder="Examples: IBR650B, AER1600, ES450, WR21", lines=1)
777
  st = gr.State({})
778
 
779
  check_btn = gr.Button("Check", variant="primary")
 
2
  import re
3
  import json
4
  import math
 
5
  import hashlib
6
  from dataclasses import dataclass
7
  from datetime import datetime, date
 
28
 
29
  MATCH_OK = 80
30
  EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
 
31
  PARSEC_CONTEXT_BEFORE = 900
32
  PARSEC_CONTEXT_AFTER = 1600
33
 
 
43
 
44
 
45
  # ============================
46
+ # Utilities
47
  # ============================
48
  def norm_text(s: Any) -> str:
49
  try:
 
123
  if "region" in df_eos.columns:
124
  df_eos = df_eos[df_eos["region"].apply(_region_ok)].reset_index(drop=True)
125
 
126
+ # Optional "Device Type"
127
  device_type_col = None
128
  for c in df_eos.columns:
129
  if norm_text(c) == "device type":
130
  device_type_col = c
131
  break
132
 
133
+ # Maker mapping (expanded — adds Teltonika)
134
  CANON_MAKER = {
135
  "CRADLEPOINT": {"cradlepoint", "ericsson", "ericsson enterprise wireless"},
136
  "SIERRA": {"sierra", "sierra wireless", "semtech", "airlink"},
 
138
  "DIGI": {"digi", "accelerated", "accelerated concepts"},
139
  "CISCO_MERAKI": {"meraki", "cisco meraki"},
140
  "CISCO": {"cisco"},
141
+ "TELTONIKA": {"teltonika"},
142
  }
143
  DISPLAY_MAKER = {
144
  "CRADLEPOINT": "Cradlepoint",
 
147
  "DIGI": "Digi",
148
  "CISCO_MERAKI": "Cisco Meraki",
149
  "CISCO": "Cisco",
150
+ "TELTONIKA": "Teltonika",
151
  "UNKNOWN": "Unknown",
152
  }
153
 
 
168
  df_dec["_norm_model"] = df_dec["Model"].apply(norm_text) if "Model" in df_dec.columns else ""
169
  df_dec["_is5g"] = df_dec["Modem Type"].apply(_is_5g) if "Modem Type" in df_dec.columns else False
170
 
 
 
 
 
 
 
 
 
171
 
172
  # ============================
173
  # Date helpers
 
230
 
231
 
232
  # ============================
233
+ # Embeddings + Parsec index
234
  # ============================
235
  embedder = SentenceTransformer(EMBED_MODEL_NAME)
236
 
 
237
  def extract_pdf_text_pages(path: str) -> List[str]:
238
  doc = fitz.open(path)
239
  return [doc[i].get_text("text") for i in range(len(doc))]
 
300
 
301
  def resolve_device(user_text: str) -> Dict[str, Any]:
302
  q = norm_text(user_text)
 
303
  exact_idxs = df_eos.index[df_eos["_norm_sku"] == q].tolist()
304
  if len(exact_idxs) == 1:
305
  return {"mode":"ok","row_idx": int(exact_idxs[0])}
 
331
 
332
 
333
  # ============================
334
+ # Replacements — lifecycle CSV is source of truth
335
+ # Fix: always show 4G alternative if lifecycle suggests it (even if Active)
336
  # ============================
337
  def _extract_model_token(text: str) -> str:
338
  s = _safe_str(text)
 
342
  candidates = parts[::-1] if parts else [s]
343
 
344
  for cand in candidates:
345
+ # Teltonika family
346
+ m = re.search(r"\bRUT[A-Z]?\d{2,4}\b", cand.upper())
347
+ if m:
348
+ return m.group(0).upper()
349
+ # Digi IX-series
350
  m = re.search(r"\bIX\d{2}\b", cand, flags=re.IGNORECASE)
351
  if m:
352
  return m.group(0).upper()
353
+ # Cradlepoint R/E/S
354
  m = re.search(r"\b(R\d{3,4}|E\d{3,4}|S\d{3,4})\b", cand, flags=re.IGNORECASE)
355
  if m:
356
  return m.group(0).upper()
357
+ # Generic model token
358
+ m = re.search(r"\b[A-Z]{1,6}\d{2,4}[A-Z]?\b", cand.upper())
359
  if m:
360
  return m.group(0).upper()
361
 
362
  return candidates[0][:60]
363
 
364
+ def _device_is_4g(life_row: pd.Series) -> bool:
365
+ t = norm_text(life_row.get("description","")) + " " + norm_text(life_row.get("notes",""))
366
+ return (("lte" in t or "4g" in t) and ("5g" not in t and "nr" not in t))
367
+
368
+ def _candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:
369
+ # Pool within same manufacturer text (not just canon) to support Teltonika etc
370
+ mfr = norm_text(manufacturer)
371
+ pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
372
  vals = pool["advanced_5g_option"].tolist() if "advanced_5g_option" in pool.columns else []
373
  out, seen = [], set()
374
  for v in vals:
 
377
  seen.add(tok); out.append(tok)
378
  return out
379
 
380
+ def _candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:
381
+ mfr = norm_text(manufacturer)
382
+ pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
383
  vals = pool["suggested_replacement"].tolist() if "suggested_replacement" in pool.columns else []
384
  out, seen = [], set()
385
  for v in vals:
 
388
  seen.add(tok); out.append(tok)
389
  return out
390
 
391
+ def _gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:
392
  if client is None or not candidates:
393
  return ""
394
  sys = "Pick the best replacement model. Choose only from candidates. Return strict JSON only."
395
  payload = {
396
  "old_device": {
397
  "sku": str(old_row.get("sku","")),
 
398
  "manufacturer": str(old_row.get("manufacturer","")),
399
+ "description": str(old_row.get("description","")),
400
  "need": need,
401
  },
402
+ "candidates": candidates[:40],
403
  "output_schema": {"choice":"string"}
404
  }
405
+ out = gpt_json(sys, payload, max_tokens=240) or {}
406
  choice = str(out.get("choice","") or "").strip()
407
  return choice if choice in candidates else ""
408
 
 
410
  pool5 = df_dec[(df_dec["_canon_make"] == canon_make) & (df_dec["_is5g"] == True)]
411
  return str(pool5.iloc[0]["Model"]).strip() if not pool5.empty else ""
412
 
 
 
 
 
413
  def pick_replacements_lifecycle(life_row: pd.Series, status: str) -> Dict[str, Any]:
414
  canon = str(life_row.get("_canon_make","UNKNOWN"))
415
+ manufacturer = str(life_row.get("manufacturer","") or "")
 
416
 
417
  is_4g_device = _device_is_4g(life_row)
418
  needs_4g_repl = is_4g_device and (status in {"End of Sale","End of Life"})
419
  want_5g = is_4g_device or (status in {"End of Sale","End of Life"})
420
 
421
+ # 4G alternative: ALWAYS if suggested_replacement exists for 4G devices
422
  repl_4g = "Not applicable"
423
+ if is_4g_device:
424
  repl_4g = _extract_model_token(_safe_str(life_row.get("suggested_replacement","")))
425
  if not repl_4g:
426
+ cand4 = _candidate_4g_models_from_lifecycle(manufacturer)
427
+ repl_4g = _gpt_pick_from_candidates(life_row, cand4, "4G alternative") or (cand4[0] if cand4 else "")
428
  if not repl_4g:
429
  repl_4g = "Not applicable"
430
 
431
+ # 5G replacement: ALWAYS when want_5g is true
432
  repl_5g = "Not applicable"
433
  if want_5g:
434
  repl_5g = _extract_model_token(_safe_str(life_row.get("advanced_5g_option","")))
435
  if not repl_5g:
436
+ cand5 = _candidate_5g_models_from_lifecycle(manufacturer)
437
+ repl_5g = _gpt_pick_from_candidates(life_row, cand5, "5G replacement/upgrade") or (cand5[0] if cand5 else "")
438
  if not repl_5g:
439
+ # last resort: dec catalog fallback
440
  repl_5g = _fallback_5g_from_dec(canon)
441
 
442
  if repl_5g.lower() == "nan":
 
446
  "repl_4g": repl_4g,
447
  "repl_5g": repl_5g,
448
  "why": "Lifecycle replacements (GPT fallback when missing).",
449
+ "sources": ["lifecycle_csv"] + (["gpt"] if client else []) + (["dec_fallback"] if (want_5g and not repl_5g) else []),
450
  }
451
 
452
 
 
495
  if fam:
496
  return fam
497
 
498
+ # fallback near SKU line
499
  sku_i = None
500
  for i, ln in enumerate(lines):
501
  if "standard sku" in ln.lower():
 
532
  "name": _parsec_name_from_card(card),
533
  "part_number": _parsec_part_from_card(card),
534
  "description": _parsec_desc_from_card(card),
 
535
  })
536
  return out
537
 
 
541
  cand_stationary = parsec_retrieve(q_stationary, top_k=10)
542
  cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)
543
 
544
+ # deterministic fallback if no GPT
545
+ s = cand_stationary[0] if cand_stationary else {"name":"Parsec antenna","part_number":"","description":""}
546
+ v = cand_vehicle[0] if cand_vehicle else {"name":"Parsec antenna","part_number":"","description":""}
547
+ s.update({"mimo": mimo, "why": "Stationary omni best match."})
548
+ v.update({"mimo": mimo, "why": "Vehicle omni best match."})
549
+ return {"stationary_omni": s, "vehicle_omni": v, "sources":["parsec_rag"]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
 
551
 
552
  # ============================
553
+ # Feature table + GPT fill for missing fields
554
  # ============================
555
  FEATURE_COLS = ["Name","Modem technology","WiFi","Ports","Antennas","Ruggedness","Use case"]
556
 
557
  def dec_features_by_model(model: str, canon_make: str) -> Dict[str, str]:
558
+ if not model or model in {"Not applicable","Not listed"}:
559
  return {k:"Not listed" for k in FEATURE_COLS}
560
  pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
561
  if pool.empty:
 
601
  notes = str(life_row.get("notes","") or "").strip()
602
  base = {
603
  "Name": sku,
604
+ "Modem technology": "4G" if _device_is_4g(life_row) else ("5G" if ("5g" in (desc+notes).lower() or "nr" in (desc+notes).lower()) else "Not listed"),
605
  "WiFi": "Not listed",
606
  "Ports": "Not listed",
607
  "Antennas": "Not listed",
 
614
  cols = ["Device", "Modem technology", "WiFi", "Ports", "Antennas", "Ruggedness", "Use case"]
615
  header = "| " + " | ".join(cols) + " |"
616
  sep = "| " + " | ".join(["---"]*len(cols)) + " |"
 
617
  def row(name: str, feats: Dict[str,str]) -> str:
618
  return "| " + " | ".join([
619
  name,
 
624
  feats.get("Ruggedness","Not listed"),
625
  feats.get("Use case","Not listed"),
626
  ]) + " |"
627
+ return "\n".join([header, sep, row("Current", cur), row("4G alternative", r4), row("5G replacement", r5)])
 
628
 
629
 
630
  # ============================
631
+ # Output + Gradio
632
  # ============================
 
 
 
 
 
 
633
  def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
634
  canon_make = str(life_row.get("_canon_make","UNKNOWN"))
635
  current_name = f"{life_row.get('sku','')} — {life_row.get('description','')}".strip(" —")
636
 
 
637
  st = ant.get("stationary_omni", {})
638
  vh = ant.get("vehicle_omni", {})
639
 
 
640
  cur_feats = current_features_guess(life_row)
641
  r4_feats = dec_features_by_model(repl.get("repl_4g",""), canon_make)
642
  r5_feats = dec_features_by_model(repl.get("repl_5g",""), canon_make)
643
+
644
+ # If dec doesn't know the model, ask GPT to fill missing cells (best guess)
645
  if client is not None:
646
+ r4_feats = gpt_fill_features("4G alternative", r4_feats, f"Model: {repl.get('repl_4g','')}\nMake: {canon_make}")
647
+ r5_feats = gpt_fill_features("5G replacement", r5_feats, f"Model: {repl.get('repl_5g','')}\nMake: {canon_make}")
648
+
649
  table_md = build_features_table(cur_feats, r4_feats, r5_feats)
650
 
651
  lines = []
 
653
  lines.append(f"2. Status: **{status}**")
654
  lines.append(f"3. End of Sale date: **{eos}**")
655
  lines.append(f"4. End of Life date: **{eol}**")
656
+ lines.append(f"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**")
657
+ lines.append(f"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**")
 
 
 
 
 
 
 
 
658
  lines.append("7. Antenna options (Parsec-only):")
659
+ lines.append(f" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')} — {st.get('why','')}")
660
+ lines.append(f" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')} — {vh.get('why','')}")
 
661
  lines.append("8. Recommended features table:")
662
  lines.append(table_md)
 
663
  lines.append("\nSources (debug):")
664
  for s in repl.get("sources", []) if isinstance(repl.get("sources"), list) else []:
665
  lines.append(f"- {s}")
666
  lines.append("- ParsecCatalog.pdf (local RAG)")
667
+ lines.append("- routers_eos_eol_by_sku.csv (replacements)")
668
+ lines.append("- dec2025routers.csv (features)")
669
  return "\n".join(lines)
670
 
671
  def run_lookup(user_text: str, st: Dict[str,Any]):
 
688
 
689
  repl = pick_replacements_lifecycle(life_row, status)
690
 
691
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") not in {"Not applicable","Not listed"} else ("4G" if _device_is_4g(life_row) else "Unknown")
692
  mimo_guess = "4x4" if tech == "5G" else "2x2"
693
  ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo_guess)
694
 
 
711
  life_row = df_eos.iloc[int(chosen_row)]
712
  eos, eol, status = row_to_dates_and_status(life_row)
713
  repl = pick_replacements_lifecycle(life_row, status)
714
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") not in {"Not applicable","Not listed"} else ("4G" if _device_is_4g(life_row) else "Unknown")
 
715
  mimo_guess = "4x4" if tech == "5G" else "2x2"
716
  ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo_guess)
717
 
 
719
 
720
  with gr.Blocks(title="Only-Routers") as demo:
721
  gr.Markdown("## Only-Routers\nEnter a router SKU/model. If ambiguous, you’ll get A/B choices.")
722
+ user_text = gr.Textbox(label="Router SKU or model", placeholder="Examples: IBR650B, AER1600, ES450, WR21, RUT240", lines=1)
723
  st = gr.State({})
724
 
725
  check_btn = gr.Button("Check", variant="primary")
only-routers_ai_poc_v4_6.ipynb ADDED
@@ -0,0 +1,768 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "0300079d",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Only-Routers (v4.6)\n",
9
+ "\n",
10
+ "This notebook mirrors the Hugging Face Spaces `app.py` logic.\n"
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "code",
15
+ "execution_count": null,
16
+ "id": "39660795",
17
+ "metadata": {},
18
+ "outputs": [],
19
+ "source": [
20
+ "import os\n",
21
+ "import re\n",
22
+ "import json\n",
23
+ "import math\n",
24
+ "import hashlib\n",
25
+ "from dataclasses import dataclass\n",
26
+ "from datetime import datetime, date\n",
27
+ "from typing import Dict, List, Optional, Tuple, Any\n",
28
+ "\n",
29
+ "import numpy as np\n",
30
+ "import pandas as pd\n",
31
+ "\n",
32
+ "import fitz # PyMuPDF\n",
33
+ "import faiss\n",
34
+ "from sentence_transformers import SentenceTransformer\n",
35
+ "from rapidfuzz import fuzz, process\n",
36
+ "\n",
37
+ "import gradio as gr\n",
38
+ "from openai import OpenAI\n",
39
+ "\n",
40
+ "\n",
41
+ "# ============================\n",
42
+ "# Settings\n",
43
+ "# ============================\n",
44
+ "TODAY = date(2026, 1, 18)\n",
45
+ "OPENAI_MODEL = \"gpt-5.2\"\n",
46
+ "OPENAI_REASONING = {\"effort\": \"high\"}\n",
47
+ "\n",
48
+ "MATCH_OK = 80\n",
49
+ "EMBED_MODEL_NAME = \"sentence-transformers/all-MiniLM-L6-v2\"\n",
50
+ "PARSEC_CONTEXT_BEFORE = 900\n",
51
+ "PARSEC_CONTEXT_AFTER = 1600\n",
52
+ "\n",
53
+ "CACHE_DIR = os.path.join(os.getcwd(), \".onlyrouters_cache\")\n",
54
+ "os.makedirs(CACHE_DIR, exist_ok=True)\n",
55
+ "\n",
56
+ "\n",
57
+ "# ============================\n",
58
+ "# OpenAI client (HF Space secret: OPENAI_API_KEY)\n",
59
+ "# ============================\n",
60
+ "API_KEY = os.getenv(\"OPENAI_API_KEY\", \"\").strip()\n",
61
+ "client = OpenAI(api_key=API_KEY) if API_KEY else None\n",
62
+ "\n",
63
+ "\n",
64
+ "# ============================\n",
65
+ "# Utilities\n",
66
+ "# ============================\n",
67
+ "def norm_text(s: Any) -> str:\n",
68
+ " try:\n",
69
+ " if s is None or (isinstance(s, float) and math.isnan(s)) or pd.isna(s):\n",
70
+ " return \"\"\n",
71
+ " except Exception:\n",
72
+ " pass\n",
73
+ " s = str(s).strip().lower()\n",
74
+ " s = re.sub(r\"[^a-z0-9\\s\\-\\/]\", \" \", s)\n",
75
+ " s = re.sub(r\"\\s+\", \" \", s).strip()\n",
76
+ " return s\n",
77
+ "\n",
78
+ "def _safe_str(v: Any) -> str:\n",
79
+ " if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):\n",
80
+ " return \"\"\n",
81
+ " return str(v).strip()\n",
82
+ "\n",
83
+ "def _is_5g(modem_type: Any) -> bool:\n",
84
+ " s = norm_text(modem_type)\n",
85
+ " return (\"5g\" in s) or (\"nr\" in s)\n",
86
+ "\n",
87
+ "def _json_load_safe(s: str) -> Dict[str, Any]:\n",
88
+ " try:\n",
89
+ " return json.loads(s)\n",
90
+ " except Exception:\n",
91
+ " return {}\n",
92
+ "\n",
93
+ "def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 700) -> Dict[str, Any]:\n",
94
+ " if client is None:\n",
95
+ " return {}\n",
96
+ " resp = client.responses.create(\n",
97
+ " model=OPENAI_MODEL,\n",
98
+ " reasoning=OPENAI_REASONING,\n",
99
+ " input=[\n",
100
+ " {\"role\": \"system\", \"content\": system},\n",
101
+ " {\"role\": \"user\", \"content\": json.dumps(payload)},\n",
102
+ " ],\n",
103
+ " max_output_tokens=max_tokens,\n",
104
+ " )\n",
105
+ " return _json_load_safe(getattr(resp, \"output_text\", \"\") or \"\")\n",
106
+ "\n",
107
+ "\n",
108
+ "# ============================\n",
109
+ "# Load data files (must exist in repo)\n",
110
+ "# ============================\n",
111
+ "EOS_PATH = \"routers_eos_eol_by_sku.csv\"\n",
112
+ "DEC_PATH = \"dec2025routers.csv\"\n",
113
+ "PARSEC_PDF = \"ParsecCatalog.pdf\"\n",
114
+ "\n",
115
+ "if not os.path.exists(EOS_PATH):\n",
116
+ " raise FileNotFoundError(f\"Missing {EOS_PATH} in repo.\")\n",
117
+ "if not os.path.exists(DEC_PATH):\n",
118
+ " raise FileNotFoundError(f\"Missing {DEC_PATH} in repo.\")\n",
119
+ "if not os.path.exists(PARSEC_PDF):\n",
120
+ " raise FileNotFoundError(f\"Missing {PARSEC_PDF} in repo.\")\n",
121
+ "\n",
122
+ "df_eos = pd.read_csv(EOS_PATH).copy()\n",
123
+ "df_dec = pd.read_csv(DEC_PATH).copy()\n",
124
+ "\n",
125
+ "# Region filter: keep USA / North America / blank / not specified\n",
126
+ "def _region_ok(x: Any) -> bool:\n",
127
+ " s = str(x or \"\").strip().lower()\n",
128
+ " if not s:\n",
129
+ " return True\n",
130
+ " if \"not specified\" in s:\n",
131
+ " return True\n",
132
+ " if \"north america\" in s:\n",
133
+ " return True\n",
134
+ " if re.search(r\"\\busa\\b\", s):\n",
135
+ " return True\n",
136
+ " if re.search(r\"\\bunited\\s+states\\b\", s):\n",
137
+ " return True\n",
138
+ " if re.search(r\"\\bu\\.?s\\.?\\b\", s):\n",
139
+ " return True\n",
140
+ " return False\n",
141
+ "\n",
142
+ "if \"region\" in df_eos.columns:\n",
143
+ " df_eos = df_eos[df_eos[\"region\"].apply(_region_ok)].reset_index(drop=True)\n",
144
+ "\n",
145
+ "# Optional \"Device Type\"\n",
146
+ "device_type_col = None\n",
147
+ "for c in df_eos.columns:\n",
148
+ " if norm_text(c) == \"device type\":\n",
149
+ " device_type_col = c\n",
150
+ " break\n",
151
+ "\n",
152
+ "# Maker mapping (expanded — adds Teltonika)\n",
153
+ "CANON_MAKER = {\n",
154
+ " \"CRADLEPOINT\": {\"cradlepoint\", \"ericsson\", \"ericsson enterprise wireless\"},\n",
155
+ " \"SIERRA\": {\"sierra\", \"sierra wireless\", \"semtech\", \"airlink\"},\n",
156
+ " \"FEENEY\": {\"feeney\", \"feeney wireless\", \"inseego\"},\n",
157
+ " \"DIGI\": {\"digi\", \"accelerated\", \"accelerated concepts\"},\n",
158
+ " \"CISCO_MERAKI\": {\"meraki\", \"cisco meraki\"},\n",
159
+ " \"CISCO\": {\"cisco\"},\n",
160
+ " \"TELTONIKA\": {\"teltonika\"},\n",
161
+ "}\n",
162
+ "DISPLAY_MAKER = {\n",
163
+ " \"CRADLEPOINT\": \"Cradlepoint\",\n",
164
+ " \"SIERRA\": \"Sierra Wireless\",\n",
165
+ " \"FEENEY\": \"Feeney Wireless\",\n",
166
+ " \"DIGI\": \"Digi\",\n",
167
+ " \"CISCO_MERAKI\": \"Cisco Meraki\",\n",
168
+ " \"CISCO\": \"Cisco\",\n",
169
+ " \"TELTONIKA\": \"Teltonika\",\n",
170
+ " \"UNKNOWN\": \"Unknown\",\n",
171
+ "}\n",
172
+ "\n",
173
+ "def canon_maker_from_text(s: Any) -> str:\n",
174
+ " t = norm_text(s)\n",
175
+ " for canon, terms in CANON_MAKER.items():\n",
176
+ " for term in terms:\n",
177
+ " if term in t:\n",
178
+ " return canon\n",
179
+ " return \"UNKNOWN\"\n",
180
+ "\n",
181
+ "df_eos[\"_canon_make\"] = df_eos[\"manufacturer\"].apply(canon_maker_from_text) if \"manufacturer\" in df_eos.columns else \"UNKNOWN\"\n",
182
+ "df_eos[\"_norm_sku\"] = df_eos[\"sku\"].apply(norm_text) if \"sku\" in df_eos.columns else \"\"\n",
183
+ "df_eos[\"_norm_desc\"] = df_eos[\"description\"].apply(norm_text) if \"description\" in df_eos.columns else \"\"\n",
184
+ "df_eos[\"_norm_notes\"] = df_eos[\"notes\"].apply(norm_text) if \"notes\" in df_eos.columns else \"\"\n",
185
+ "\n",
186
+ "df_dec[\"_canon_make\"] = df_dec[\"Make\"].apply(canon_maker_from_text) if \"Make\" in df_dec.columns else \"UNKNOWN\"\n",
187
+ "df_dec[\"_norm_model\"] = df_dec[\"Model\"].apply(norm_text) if \"Model\" in df_dec.columns else \"\"\n",
188
+ "df_dec[\"_is5g\"] = df_dec[\"Modem Type\"].apply(_is_5g) if \"Modem Type\" in df_dec.columns else False\n",
189
+ "\n",
190
+ "\n",
191
+ "# ============================\n",
192
+ "# Date helpers\n",
193
+ "# ============================\n",
194
+ "@dataclass\n",
195
+ "class ParsedDate:\n",
196
+ " raw: str\n",
197
+ " kind: str\n",
198
+ " value: Optional[date]\n",
199
+ "\n",
200
+ "def parse_date_field(x: Any) -> ParsedDate:\n",
201
+ " raw = str(x or \"\").strip()\n",
202
+ " if not raw:\n",
203
+ " return ParsedDate(raw=\"\", kind=\"missing\", value=None)\n",
204
+ "\n",
205
+ " if re.fullmatch(r\"\\d{4}\", raw):\n",
206
+ " y = int(raw)\n",
207
+ " if y == TODAY.year:\n",
208
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
209
+ " if y < TODAY.year:\n",
210
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
211
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 12, 31))\n",
212
+ "\n",
213
+ " if re.fullmatch(r\"\\d{4}-\\d{2}\", raw):\n",
214
+ " try:\n",
215
+ " y, m = raw.split(\"-\")\n",
216
+ " return ParsedDate(raw=raw, kind=\"year_month\", value=date(int(y), int(m), 1))\n",
217
+ " except Exception:\n",
218
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
219
+ "\n",
220
+ " if re.fullmatch(r\"\\d{4}-\\d{2}-\\d{2}\", raw):\n",
221
+ " try:\n",
222
+ " dt = datetime.strptime(raw, \"%Y-%m-%d\").date()\n",
223
+ " return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
224
+ " except Exception:\n",
225
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
226
+ "\n",
227
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
228
+ "\n",
229
+ "def display_date(parsed: ParsedDate) -> str:\n",
230
+ " if parsed.kind == \"missing\":\n",
231
+ " return \"Not listed\"\n",
232
+ " if parsed.kind == \"bad\":\n",
233
+ " return parsed.raw or \"Not listed\"\n",
234
+ " return parsed.raw\n",
235
+ "\n",
236
+ "def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:\n",
237
+ " if eos.value is None and eol.value is None:\n",
238
+ " return \"Unknown\"\n",
239
+ " if eol.value is not None and eol.value <= TODAY:\n",
240
+ " return \"End of Life\"\n",
241
+ " if eos.value is not None and eos.value <= TODAY:\n",
242
+ " return \"End of Sale\"\n",
243
+ " return \"Active\"\n",
244
+ "\n",
245
+ "def row_to_dates_and_status(life_row: pd.Series) -> Tuple[str, str, str]:\n",
246
+ " eos = parse_date_field(life_row.get(\"end_of_sale\"))\n",
247
+ " eol = parse_date_field(life_row.get(\"end_of_life\"))\n",
248
+ " return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)\n",
249
+ "\n",
250
+ "\n",
251
+ "# ============================\n",
252
+ "# Embeddings + Parsec index\n",
253
+ "# ============================\n",
254
+ "embedder = SentenceTransformer(EMBED_MODEL_NAME)\n",
255
+ "\n",
256
+ "def extract_pdf_text_pages(path: str) -> List[str]:\n",
257
+ " doc = fitz.open(path)\n",
258
+ " return [doc[i].get_text(\"text\") for i in range(len(doc))]\n",
259
+ "\n",
260
+ "def build_parsec_cards(pages: List[str]) -> List[str]:\n",
261
+ " cards = []\n",
262
+ " for p in pages:\n",
263
+ " for m in re.finditer(r\"Standard\\s+SKU:\", p):\n",
264
+ " start = max(0, m.start() - PARSEC_CONTEXT_BEFORE)\n",
265
+ " end = min(len(p), m.start() + PARSEC_CONTEXT_AFTER)\n",
266
+ " c = p[start:end].strip()\n",
267
+ " if len(c) >= 200:\n",
268
+ " cards.append(c)\n",
269
+ " out, seen = [], set()\n",
270
+ " for c in cards:\n",
271
+ " h = hashlib.sha1(c.encode(\"utf-8\")).hexdigest()\n",
272
+ " if h not in seen:\n",
273
+ " seen.add(h); out.append(c)\n",
274
+ " return out\n",
275
+ "\n",
276
+ "parsec_cards = build_parsec_cards(extract_pdf_text_pages(PARSEC_PDF))\n",
277
+ "parsec_emb = embedder.encode(parsec_cards, batch_size=64, show_progress_bar=False, normalize_embeddings=True)\n",
278
+ "parsec_emb = np.asarray(parsec_emb, dtype=np.float32)\n",
279
+ "parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])\n",
280
+ "parsec_index.add(parsec_emb)\n",
281
+ "\n",
282
+ "\n",
283
+ "# ============================\n",
284
+ "# Device resolution (exact SKU -> GPT A/B)\n",
285
+ "# ============================\n",
286
+ "def _label_for_row(i: int) -> str:\n",
287
+ " r = df_eos.iloc[i]\n",
288
+ " return f\"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}\"[:220]\n",
289
+ "\n",
290
+ "EOS_LABELS = [_label_for_row(i) for i in range(len(df_eos))]\n",
291
+ "EOS_CORPUS = []\n",
292
+ "for _, r in df_eos.iterrows():\n",
293
+ " EOS_CORPUS.append(\" \".join([\n",
294
+ " r.get(\"_norm_sku\",\"\"),\n",
295
+ " r.get(\"_canon_make\",\"\"),\n",
296
+ " r.get(\"_norm_desc\",\"\"),\n",
297
+ " r.get(\"_norm_notes\",\"\"),\n",
298
+ " ]))\n",
299
+ "\n",
300
+ "def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int,int,str]]:\n",
301
+ " q = norm_text(query)\n",
302
+ " hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)\n",
303
+ " return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]\n",
304
+ "\n",
305
+ "def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> Dict[str, Any]:\n",
306
+ " if client is None:\n",
307
+ " return {}\n",
308
+ " sys = \"Pick which router the user meant. Never invent. Return strict JSON only.\"\n",
309
+ " payload = {\n",
310
+ " \"user_input\": user_text,\n",
311
+ " \"candidates\": [{\"row_idx\": i, \"score\": s, \"label\": lbl} for (i,s,lbl) in candidates],\n",
312
+ " \"rules\": [\n",
313
+ " \"If one candidate is clearly correct, return mode='ok' with row_idx.\",\n",
314
+ " \"If two are plausible, return mode='pick' with top 2 options.\"\n",
315
+ " ],\n",
316
+ " \"output_schema\": {\"mode\":\"ok|pick\",\"row_idx\":\"int\",\"options\":[{\"row_idx\":\"int\",\"label\":\"string\"}]}\n",
317
+ " }\n",
318
+ " return gpt_json(sys, payload, max_tokens=300)\n",
319
+ "\n",
320
+ "def resolve_device(user_text: str) -> Dict[str, Any]:\n",
321
+ " q = norm_text(user_text)\n",
322
+ " exact_idxs = df_eos.index[df_eos[\"_norm_sku\"] == q].tolist()\n",
323
+ " if len(exact_idxs) == 1:\n",
324
+ " return {\"mode\":\"ok\",\"row_idx\": int(exact_idxs[0])}\n",
325
+ " if len(exact_idxs) > 1:\n",
326
+ " opts = [{\"row_idx\": int(i), \"label\": EOS_LABELS[int(i)]} for i in exact_idxs[:2]]\n",
327
+ " return {\"mode\":\"pick\",\"options\": opts}\n",
328
+ "\n",
329
+ " cands = local_candidates(user_text, top_k=6)\n",
330
+ " if not cands:\n",
331
+ " return {\"mode\":\"not_found\"}\n",
332
+ "\n",
333
+ " if cands[0][1] >= 95 and (len(cands) == 1 or (cands[0][1] - cands[1][1]) >= 8):\n",
334
+ " return {\"mode\":\"ok\",\"row_idx\": cands[0][0]}\n",
335
+ "\n",
336
+ " g = gpt_choose_device(user_text, cands)\n",
337
+ " if g.get(\"mode\") == \"ok\" and isinstance(g.get(\"row_idx\"), int):\n",
338
+ " return {\"mode\":\"ok\",\"row_idx\": int(g[\"row_idx\"])}\n",
339
+ "\n",
340
+ " if g.get(\"mode\") == \"pick\":\n",
341
+ " opts = g.get(\"options\", []) or []\n",
342
+ " opts2 = [{\"row_idx\": int(o[\"row_idx\"]), \"label\": str(o[\"label\"])} for o in opts[:2] if \"row_idx\" in o]\n",
343
+ " if opts2:\n",
344
+ " return {\"mode\":\"pick\",\"options\": opts2}\n",
345
+ "\n",
346
+ " # fallback\n",
347
+ " if len(cands) > 1:\n",
348
+ " return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]},{\"row_idx\":cands[1][0],\"label\":cands[1][2]}]}\n",
349
+ " return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]}]}\n",
350
+ "\n",
351
+ "\n",
352
+ "# ============================\n",
353
+ "# Replacements — lifecycle CSV is source of truth\n",
354
+ "# Fix: always show 4G alternative if lifecycle suggests it (even if Active)\n",
355
+ "# ============================\n",
356
+ "def _extract_model_token(text: str) -> str:\n",
357
+ " s = _safe_str(text)\n",
358
+ " if not s:\n",
359
+ " return \"\"\n",
360
+ " parts = [p.strip() for p in s.split(\"|\") if p.strip()]\n",
361
+ " candidates = parts[::-1] if parts else [s]\n",
362
+ "\n",
363
+ " for cand in candidates:\n",
364
+ " # Teltonika family\n",
365
+ " m = re.search(r\"\\bRUT[A-Z]?\\d{2,4}\\b\", cand.upper())\n",
366
+ " if m:\n",
367
+ " return m.group(0).upper()\n",
368
+ " # Digi IX-series\n",
369
+ " m = re.search(r\"\\bIX\\d{2}\\b\", cand, flags=re.IGNORECASE)\n",
370
+ " if m:\n",
371
+ " return m.group(0).upper()\n",
372
+ " # Cradlepoint R/E/S\n",
373
+ " m = re.search(r\"\\b(R\\d{3,4}|E\\d{3,4}|S\\d{3,4})\\b\", cand, flags=re.IGNORECASE)\n",
374
+ " if m:\n",
375
+ " return m.group(0).upper()\n",
376
+ " # Generic model token\n",
377
+ " m = re.search(r\"\\b[A-Z]{1,6}\\d{2,4}[A-Z]?\\b\", cand.upper())\n",
378
+ " if m:\n",
379
+ " return m.group(0).upper()\n",
380
+ "\n",
381
+ " return candidates[0][:60]\n",
382
+ "\n",
383
+ "def _device_is_4g(life_row: pd.Series) -> bool:\n",
384
+ " t = norm_text(life_row.get(\"description\",\"\")) + \" \" + norm_text(life_row.get(\"notes\",\"\"))\n",
385
+ " return ((\"lte\" in t or \"4g\" in t) and (\"5g\" not in t and \"nr\" not in t))\n",
386
+ "\n",
387
+ "def _candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
388
+ " # Pool within same manufacturer text (not just canon) to support Teltonika etc\n",
389
+ " mfr = norm_text(manufacturer)\n",
390
+ " pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
391
+ " vals = pool[\"advanced_5g_option\"].tolist() if \"advanced_5g_option\" in pool.columns else []\n",
392
+ " out, seen = [], set()\n",
393
+ " for v in vals:\n",
394
+ " tok = _extract_model_token(v)\n",
395
+ " if tok and tok.lower() != \"nan\" and tok not in seen:\n",
396
+ " seen.add(tok); out.append(tok)\n",
397
+ " return out\n",
398
+ "\n",
399
+ "def _candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
400
+ " mfr = norm_text(manufacturer)\n",
401
+ " pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
402
+ " vals = pool[\"suggested_replacement\"].tolist() if \"suggested_replacement\" in pool.columns else []\n",
403
+ " out, seen = [], set()\n",
404
+ " for v in vals:\n",
405
+ " tok = _extract_model_token(v)\n",
406
+ " if tok and tok.lower() != \"nan\" and tok not in seen:\n",
407
+ " seen.add(tok); out.append(tok)\n",
408
+ " return out\n",
409
+ "\n",
410
+ "def _gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:\n",
411
+ " if client is None or not candidates:\n",
412
+ " return \"\"\n",
413
+ " sys = \"Pick the best replacement model. Choose only from candidates. Return strict JSON only.\"\n",
414
+ " payload = {\n",
415
+ " \"old_device\": {\n",
416
+ " \"sku\": str(old_row.get(\"sku\",\"\")),\n",
417
+ " \"manufacturer\": str(old_row.get(\"manufacturer\",\"\")),\n",
418
+ " \"description\": str(old_row.get(\"description\",\"\")),\n",
419
+ " \"need\": need,\n",
420
+ " },\n",
421
+ " \"candidates\": candidates[:40],\n",
422
+ " \"output_schema\": {\"choice\":\"string\"}\n",
423
+ " }\n",
424
+ " out = gpt_json(sys, payload, max_tokens=240) or {}\n",
425
+ " choice = str(out.get(\"choice\",\"\") or \"\").strip()\n",
426
+ " return choice if choice in candidates else \"\"\n",
427
+ "\n",
428
+ "def _fallback_5g_from_dec(canon_make: str) -> str:\n",
429
+ " pool5 = df_dec[(df_dec[\"_canon_make\"] == canon_make) & (df_dec[\"_is5g\"] == True)]\n",
430
+ " return str(pool5.iloc[0][\"Model\"]).strip() if not pool5.empty else \"\"\n",
431
+ "\n",
432
+ "def pick_replacements_lifecycle(life_row: pd.Series, status: str) -> Dict[str, Any]:\n",
433
+ " canon = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
434
+ " manufacturer = str(life_row.get(\"manufacturer\",\"\") or \"\")\n",
435
+ "\n",
436
+ " is_4g_device = _device_is_4g(life_row)\n",
437
+ " needs_4g_repl = is_4g_device and (status in {\"End of Sale\",\"End of Life\"})\n",
438
+ " want_5g = is_4g_device or (status in {\"End of Sale\",\"End of Life\"})\n",
439
+ "\n",
440
+ " # 4G alternative: ALWAYS if suggested_replacement exists for 4G devices\n",
441
+ " repl_4g = \"Not applicable\"\n",
442
+ " if is_4g_device:\n",
443
+ " repl_4g = _extract_model_token(_safe_str(life_row.get(\"suggested_replacement\",\"\")))\n",
444
+ " if not repl_4g:\n",
445
+ " cand4 = _candidate_4g_models_from_lifecycle(manufacturer)\n",
446
+ " repl_4g = _gpt_pick_from_candidates(life_row, cand4, \"4G alternative\") or (cand4[0] if cand4 else \"\")\n",
447
+ " if not repl_4g:\n",
448
+ " repl_4g = \"Not applicable\"\n",
449
+ "\n",
450
+ " # 5G replacement: ALWAYS when want_5g is true\n",
451
+ " repl_5g = \"Not applicable\"\n",
452
+ " if want_5g:\n",
453
+ " repl_5g = _extract_model_token(_safe_str(life_row.get(\"advanced_5g_option\",\"\")))\n",
454
+ " if not repl_5g:\n",
455
+ " cand5 = _candidate_5g_models_from_lifecycle(manufacturer)\n",
456
+ " repl_5g = _gpt_pick_from_candidates(life_row, cand5, \"5G replacement/upgrade\") or (cand5[0] if cand5 else \"\")\n",
457
+ " if not repl_5g:\n",
458
+ " # last resort: dec catalog fallback\n",
459
+ " repl_5g = _fallback_5g_from_dec(canon)\n",
460
+ "\n",
461
+ " if repl_5g.lower() == \"nan\":\n",
462
+ " repl_5g = \"\"\n",
463
+ "\n",
464
+ " return {\n",
465
+ " \"repl_4g\": repl_4g,\n",
466
+ " \"repl_5g\": repl_5g,\n",
467
+ " \"why\": \"Lifecycle replacements (GPT fallback when missing).\",\n",
468
+ " \"sources\": [\"lifecycle_csv\"] + ([\"gpt\"] if client else []) + ([\"dec_fallback\"] if (want_5g and not repl_5g) else []),\n",
469
+ " }\n",
470
+ "\n",
471
+ "\n",
472
+ "# ============================\n",
473
+ "# Antennas (Parsec-only; family name extraction)\n",
474
+ "# ============================\n",
475
+ "PARSEC_FAMILY_WORDS = {\n",
476
+ " \"chinook\",\"labrador\",\"boxer\",\"bloodhound\",\"husky\",\"beagle\",\"mastiff\",\"collie\",\n",
477
+ " \"shepherd\",\"belgian\",\"australian\",\"terrier\",\"pyrenees\"\n",
478
+ "}\n",
479
+ "BAD_NAME_MARKERS = {\n",
480
+ " \"customization\", \"standard connectors\", \"connectors\", \"features\", \"benefits\",\n",
481
+ " \"specifications\", \"mechanical\", \"electrical\", \"mounting\", \"accessories\",\n",
482
+ " \"description:\", \"standard sku\"\n",
483
+ "}\n",
484
+ "\n",
485
+ "def _clean_line(s: str) -> str:\n",
486
+ " s = re.sub(r\"\\s+\", \" \", str(s or \"\").strip())\n",
487
+ " if re.fullmatch(r\"-[a-z0-9]+\", s.lower()):\n",
488
+ " return \"\"\n",
489
+ " return s\n",
490
+ "\n",
491
+ "def _is_bad_name_line(line: str) -> bool:\n",
492
+ " low = line.lower()\n",
493
+ " if any(m in low for m in BAD_NAME_MARKERS):\n",
494
+ " return True\n",
495
+ " if re.search(r\"\\b-[a-z0-9]{1,4}\\b\", low) and len(low) <= 25:\n",
496
+ " return True\n",
497
+ " return False\n",
498
+ "\n",
499
+ "def _family_from_line(line: str) -> str:\n",
500
+ " low = line.lower()\n",
501
+ " for fam in PARSEC_FAMILY_WORDS:\n",
502
+ " if fam in low:\n",
503
+ " return fam.capitalize()\n",
504
+ " return \"\"\n",
505
+ "\n",
506
+ "def _parsec_name_from_card(card_text: str) -> str:\n",
507
+ " lines = [_clean_line(ln) for ln in str(card_text or \"\").splitlines()]\n",
508
+ " lines = [ln for ln in lines if ln]\n",
509
+ "\n",
510
+ " for ln in lines:\n",
511
+ " if _is_bad_name_line(ln):\n",
512
+ " continue\n",
513
+ " fam = _family_from_line(ln)\n",
514
+ " if fam:\n",
515
+ " return fam\n",
516
+ "\n",
517
+ " # fallback near SKU line\n",
518
+ " sku_i = None\n",
519
+ " for i, ln in enumerate(lines):\n",
520
+ " if \"standard sku\" in ln.lower():\n",
521
+ " sku_i = i\n",
522
+ " break\n",
523
+ " if sku_i is not None:\n",
524
+ " window = lines[max(0, sku_i - 12):sku_i]\n",
525
+ " for ln in reversed(window):\n",
526
+ " if _is_bad_name_line(ln):\n",
527
+ " continue\n",
528
+ " if 3 <= len(ln) <= 40 and re.search(r\"[A-Za-z]\", ln):\n",
529
+ " return ln.split()[0].capitalize()\n",
530
+ "\n",
531
+ " return \"Parsec antenna\"\n",
532
+ "\n",
533
+ "def _parsec_part_from_card(t: str) -> str:\n",
534
+ " m = re.search(r\"Standard\\s+SKU:\\s*([A-Z0-9]+)\", t)\n",
535
+ " return m.group(1).strip() if m else \"\"\n",
536
+ "\n",
537
+ "def _parsec_desc_from_card(t: str) -> str:\n",
538
+ " m = re.search(r\"Description:\\s*(.+?)(?:\\n|$)\", t, flags=re.IGNORECASE)\n",
539
+ " return re.sub(r\"\\s+\",\" \",m.group(1).strip())[:220] if m else \"\"\n",
540
+ "\n",
541
+ "def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:\n",
542
+ " qv = embedder.encode([query], normalize_embeddings=True)\n",
543
+ " qv = np.asarray(qv, dtype=np.float32)\n",
544
+ " scores, ids = parsec_index.search(qv, top_k)\n",
545
+ " out = []\n",
546
+ " for sc, i in zip(scores[0].tolist(), ids[0].tolist()):\n",
547
+ " if 0 <= int(i) < len(parsec_cards):\n",
548
+ " card = parsec_cards[int(i)]\n",
549
+ " out.append({\n",
550
+ " \"score\": float(sc),\n",
551
+ " \"name\": _parsec_name_from_card(card),\n",
552
+ " \"part_number\": _parsec_part_from_card(card),\n",
553
+ " \"description\": _parsec_desc_from_card(card),\n",
554
+ " })\n",
555
+ " return out\n",
556
+ "\n",
557
+ "def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:\n",
558
+ " q_stationary = f\"{router_model} {tech} {mimo} omni stationary outdoor Parsec\"\n",
559
+ " q_vehicle = f\"{router_model} {tech} {mimo} omni vehicle mobile Parsec\"\n",
560
+ " cand_stationary = parsec_retrieve(q_stationary, top_k=10)\n",
561
+ " cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)\n",
562
+ "\n",
563
+ " # deterministic fallback if no GPT\n",
564
+ " s = cand_stationary[0] if cand_stationary else {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\"}\n",
565
+ " v = cand_vehicle[0] if cand_vehicle else {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\"}\n",
566
+ " s.update({\"mimo\": mimo, \"why\": \"Stationary omni best match.\"})\n",
567
+ " v.update({\"mimo\": mimo, \"why\": \"Vehicle omni best match.\"})\n",
568
+ " return {\"stationary_omni\": s, \"vehicle_omni\": v, \"sources\":[\"parsec_rag\"]}\n",
569
+ "\n",
570
+ "\n",
571
+ "# ============================\n",
572
+ "# Feature table + GPT fill for missing fields\n",
573
+ "# ============================\n",
574
+ "FEATURE_COLS = [\"Name\",\"Modem technology\",\"WiFi\",\"Ports\",\"Antennas\",\"Ruggedness\",\"Use case\"]\n",
575
+ "\n",
576
+ "def dec_features_by_model(model: str, canon_make: str) -> Dict[str, str]:\n",
577
+ " if not model or model in {\"Not applicable\",\"Not listed\"}:\n",
578
+ " return {k:\"Not listed\" for k in FEATURE_COLS}\n",
579
+ " pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
580
+ " if pool.empty:\n",
581
+ " return {k:\"Not listed\" for k in FEATURE_COLS}\n",
582
+ " hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
583
+ " if not hit or hit[1] < MATCH_OK:\n",
584
+ " return {k:\"Not listed\" for k in FEATURE_COLS}\n",
585
+ " r = pool.iloc[int(hit[2])]\n",
586
+ " ports = f\"WAN: {r.get('WAN ports and speed','')} | LAN: {r.get('LAN ports and speed','')}\"\n",
587
+ " return {\n",
588
+ " \"Name\": str(r.get(\"Model\",\"\")),\n",
589
+ " \"Modem technology\": str(r.get(\"Modem Type\",\"\")),\n",
590
+ " \"WiFi\": str(r.get(\"WiFi type\",\"\")),\n",
591
+ " \"Ports\": ports,\n",
592
+ " \"Antennas\": str(r.get(\"Antennas (internal/external/both)\",\"\")),\n",
593
+ " \"Ruggedness\": str(r.get(\"Ruggedization\",\"\")),\n",
594
+ " \"Use case\": str(r.get(\"Primary use case\",\"\")),\n",
595
+ " }\n",
596
+ "\n",
597
+ "def gpt_fill_features(device_label: str, feats: Dict[str,str], context: str) -> Dict[str,str]:\n",
598
+ " missing = [k for k,v in feats.items() if (not v) or v.strip().lower() in {\"not listed\",\"nan\"}]\n",
599
+ " if client is None or not missing:\n",
600
+ " return feats\n",
601
+ " sys = \"Fill missing router feature fields. Return strict JSON only.\"\n",
602
+ " payload = {\n",
603
+ " \"device\": device_label,\n",
604
+ " \"known\": feats,\n",
605
+ " \"context\": context[:2000],\n",
606
+ " \"fill_only\": missing,\n",
607
+ " \"rules\": [\"Fill only requested fields. Best guess if needed. Return JSON only.\"],\n",
608
+ " \"output_schema\": {k:\"string\" for k in missing}\n",
609
+ " }\n",
610
+ " out = gpt_json(sys, payload, max_tokens=350) or {}\n",
611
+ " for k in missing:\n",
612
+ " v = str(out.get(k,\"\") or \"\").strip()\n",
613
+ " if v:\n",
614
+ " feats[k] = v\n",
615
+ " return feats\n",
616
+ "\n",
617
+ "def current_features_guess(life_row: pd.Series) -> Dict[str,str]:\n",
618
+ " sku = str(life_row.get(\"sku\",\"\") or \"\").strip()\n",
619
+ " desc = str(life_row.get(\"description\",\"\") or \"\").strip()\n",
620
+ " notes = str(life_row.get(\"notes\",\"\") or \"\").strip()\n",
621
+ " base = {\n",
622
+ " \"Name\": sku,\n",
623
+ " \"Modem technology\": \"4G\" if _device_is_4g(life_row) else (\"5G\" if (\"5g\" in (desc+notes).lower() or \"nr\" in (desc+notes).lower()) else \"Not listed\"),\n",
624
+ " \"WiFi\": \"Not listed\",\n",
625
+ " \"Ports\": \"Not listed\",\n",
626
+ " \"Antennas\": \"Not listed\",\n",
627
+ " \"Ruggedness\": \"Not listed\",\n",
628
+ " \"Use case\": \"Not listed\",\n",
629
+ " }\n",
630
+ " return gpt_fill_features(\"Current device\", base, f\"{desc}\\n{notes}\")\n",
631
+ "\n",
632
+ "def build_features_table(cur: Dict[str,str], r4: Dict[str,str], r5: Dict[str,str]) -> str:\n",
633
+ " cols = [\"Device\", \"Modem technology\", \"WiFi\", \"Ports\", \"Antennas\", \"Ruggedness\", \"Use case\"]\n",
634
+ " header = \"| \" + \" | \".join(cols) + \" |\"\n",
635
+ " sep = \"| \" + \" | \".join([\"---\"]*len(cols)) + \" |\"\n",
636
+ " def row(name: str, feats: Dict[str,str]) -> str:\n",
637
+ " return \"| \" + \" | \".join([\n",
638
+ " name,\n",
639
+ " feats.get(\"Modem technology\",\"Not listed\"),\n",
640
+ " feats.get(\"WiFi\",\"Not listed\"),\n",
641
+ " feats.get(\"Ports\",\"Not listed\"),\n",
642
+ " feats.get(\"Antennas\",\"Not listed\"),\n",
643
+ " feats.get(\"Ruggedness\",\"Not listed\"),\n",
644
+ " feats.get(\"Use case\",\"Not listed\"),\n",
645
+ " ]) + \" |\"\n",
646
+ " return \"\\n\".join([header, sep, row(\"Current\", cur), row(\"4G alternative\", r4), row(\"5G replacement\", r5)])\n",
647
+ "\n",
648
+ "\n",
649
+ "# ============================\n",
650
+ "# Output + Gradio\n",
651
+ "# ============================\n",
652
+ "def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:\n",
653
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
654
+ " current_name = f\"{life_row.get('sku','')} — {life_row.get('description','')}\".strip(\" —\")\n",
655
+ "\n",
656
+ " st = ant.get(\"stationary_omni\", {})\n",
657
+ " vh = ant.get(\"vehicle_omni\", {})\n",
658
+ "\n",
659
+ " cur_feats = current_features_guess(life_row)\n",
660
+ " r4_feats = dec_features_by_model(repl.get(\"repl_4g\",\"\"), canon_make)\n",
661
+ " r5_feats = dec_features_by_model(repl.get(\"repl_5g\",\"\"), canon_make)\n",
662
+ "\n",
663
+ " # If dec doesn't know the model, ask GPT to fill missing cells (best guess)\n",
664
+ " if client is not None:\n",
665
+ " r4_feats = gpt_fill_features(\"4G alternative\", r4_feats, f\"Model: {repl.get('repl_4g','')}\\nMake: {canon_make}\")\n",
666
+ " r5_feats = gpt_fill_features(\"5G replacement\", r5_feats, f\"Model: {repl.get('repl_5g','')}\\nMake: {canon_make}\")\n",
667
+ "\n",
668
+ " table_md = build_features_table(cur_feats, r4_feats, r5_feats)\n",
669
+ "\n",
670
+ " lines = []\n",
671
+ " lines.append(f\"1. Current device: **{current_name}**\")\n",
672
+ " lines.append(f\"2. Status: **{status}**\")\n",
673
+ " lines.append(f\"3. End of Sale date: **{eos}**\")\n",
674
+ " lines.append(f\"4. End of Life date: **{eol}**\")\n",
675
+ " lines.append(f\"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**\")\n",
676
+ " lines.append(f\"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**\")\n",
677
+ " lines.append(\"7. Antenna options (Parsec-only):\")\n",
678
+ " lines.append(f\" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')} — {st.get('why','')}\")\n",
679
+ " lines.append(f\" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')} — {vh.get('why','')}\")\n",
680
+ " lines.append(\"8. Recommended features table:\")\n",
681
+ " lines.append(table_md)\n",
682
+ " lines.append(\"\\nSources (debug):\")\n",
683
+ " for s in repl.get(\"sources\", []) if isinstance(repl.get(\"sources\"), list) else []:\n",
684
+ " lines.append(f\"- {s}\")\n",
685
+ " lines.append(\"- ParsecCatalog.pdf (local RAG)\")\n",
686
+ " lines.append(\"- routers_eos_eol_by_sku.csv (replacements)\")\n",
687
+ " lines.append(\"- dec2025routers.csv (features)\")\n",
688
+ " return \"\\n\".join(lines)\n",
689
+ "\n",
690
+ "def run_lookup(user_text: str, st: Dict[str,Any]):\n",
691
+ " user_text = str(user_text or \"\").strip()\n",
692
+ " if not user_text:\n",
693
+ " return \"Enter a router SKU/model.\", gr.update(visible=False), gr.update(visible=False), {}\n",
694
+ "\n",
695
+ " res = resolve_device(user_text)\n",
696
+ " if res.get(\"mode\") == \"pick\":\n",
697
+ " opts = res.get(\"options\", [])\n",
698
+ " choices = [o[\"label\"] for o in opts]\n",
699
+ " st2 = {\"mode\":\"pick\",\"options\": opts}\n",
700
+ " return \"Did you mean A or B? Pick one, then click Use selection.\", gr.update(choices=choices, value=None, visible=True), gr.update(visible=True), st2\n",
701
+ "\n",
702
+ " if res.get(\"mode\") != \"ok\":\n",
703
+ " return \"Not found.\", gr.update(visible=False), gr.update(visible=False), {}\n",
704
+ "\n",
705
+ " life_row = df_eos.iloc[int(res[\"row_idx\"])]\n",
706
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
707
+ "\n",
708
+ " repl = pick_replacements_lifecycle(life_row, status)\n",
709
+ "\n",
710
+ " tech = \"5G\" if repl.get(\"repl_5g\") and repl.get(\"repl_5g\") not in {\"Not applicable\",\"Not listed\"} else (\"4G\" if _device_is_4g(life_row) else \"Unknown\")\n",
711
+ " mimo_guess = \"4x4\" if tech == \"5G\" else \"2x2\"\n",
712
+ " ant = antenna_options_for(router_model=repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech=tech, mimo=mimo_guess)\n",
713
+ "\n",
714
+ " return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}\n",
715
+ "\n",
716
+ "def use_selection(selected_label: str, st: Dict[str,Any]):\n",
717
+ " if not st or st.get(\"mode\") != \"pick\":\n",
718
+ " return \"Run a search first.\", gr.update(visible=False), gr.update(visible=False), {}\n",
719
+ " if not selected_label:\n",
720
+ " return \"Pick A or B first.\", gr.update(visible=True), gr.update(visible=True), st\n",
721
+ "\n",
722
+ " chosen_row = None\n",
723
+ " for o in st.get(\"options\", []):\n",
724
+ " if o.get(\"label\") == selected_label:\n",
725
+ " chosen_row = int(o[\"row_idx\"])\n",
726
+ " break\n",
727
+ " if chosen_row is None:\n",
728
+ " return \"Pick a valid option.\", gr.update(visible=True), gr.update(visible=True), st\n",
729
+ "\n",
730
+ " life_row = df_eos.iloc[int(chosen_row)]\n",
731
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
732
+ " repl = pick_replacements_lifecycle(life_row, status)\n",
733
+ " tech = \"5G\" if repl.get(\"repl_5g\") and repl.get(\"repl_5g\") not in {\"Not applicable\",\"Not listed\"} else (\"4G\" if _device_is_4g(life_row) else \"Unknown\")\n",
734
+ " mimo_guess = \"4x4\" if tech == \"5G\" else \"2x2\"\n",
735
+ " ant = antenna_options_for(router_model=repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech=tech, mimo=mimo_guess)\n",
736
+ "\n",
737
+ " return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}\n",
738
+ "\n",
739
+ "with gr.Blocks(title=\"Only-Routers\") as demo:\n",
740
+ " gr.Markdown(\"## Only-Routers\\nEnter a router SKU/model. If ambiguous, you’ll get A/B choices.\")\n",
741
+ " user_text = gr.Textbox(label=\"Router SKU or model\", placeholder=\"Examples: IBR650B, AER1600, ES450, WR21, RUT240\", lines=1)\n",
742
+ " st = gr.State({})\n",
743
+ "\n",
744
+ " check_btn = gr.Button(\"Check\", variant=\"primary\")\n",
745
+ " pick_dd = gr.Dropdown(label=\"Pick A or B\", choices=[], visible=False)\n",
746
+ " use_btn = gr.Button(\"Use selection\", visible=False)\n",
747
+ "\n",
748
+ " output_md = gr.Markdown()\n",
749
+ "\n",
750
+ " check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st])\n",
751
+ " use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st])\n",
752
+ "\n",
753
+ "demo.launch()\n"
754
+ ]
755
+ }
756
+ ],
757
+ "metadata": {
758
+ "kernelspec": {
759
+ "display_name": "Python 3",
760
+ "name": "python3"
761
+ },
762
+ "language_info": {
763
+ "name": "python"
764
+ }
765
+ },
766
+ "nbformat": 4,
767
+ "nbformat_minor": 5
768
+ }