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

Upload folder using huggingface_hub

Browse files
app.py CHANGED
@@ -28,6 +28,7 @@ OPENAI_REASONING = {"effort": "high"}
28
 
29
  MATCH_OK = 80
30
  EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
 
31
  PARSEC_CONTEXT_BEFORE = 900
32
  PARSEC_CONTEXT_AFTER = 1600
33
 
@@ -130,7 +131,7 @@ for c in df_eos.columns:
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"},
@@ -140,16 +141,6 @@ CANON_MAKER = {
140
  "CISCO": {"cisco"},
141
  "TELTONIKA": {"teltonika"},
142
  }
143
- DISPLAY_MAKER = {
144
- "CRADLEPOINT": "Cradlepoint",
145
- "SIERRA": "Sierra Wireless",
146
- "FEENEY": "Feeney Wireless",
147
- "DIGI": "Digi",
148
- "CISCO_MERAKI": "Cisco Meraki",
149
- "CISCO": "Cisco",
150
- "TELTONIKA": "Teltonika",
151
- "UNKNOWN": "Unknown",
152
- }
153
 
154
  def canon_maker_from_text(s: Any) -> str:
155
  t = norm_text(s)
@@ -331,8 +322,7 @@ def resolve_device(user_text: str) -> Dict[str, Any]:
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,19 +332,15 @@ def _extract_model_token(text: str) -> str:
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()
@@ -366,7 +352,6 @@ def _device_is_4g(life_row: pd.Series) -> bool:
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 []
@@ -410,33 +395,29 @@ def _fallback_5g_from_dec(canon_make: str) -> str:
410
  pool5 = df_dec[(df_dec["_canon_make"] == canon_make) & (df_dec["_is5g"] == True)]
411
  return str(pool5.iloc[0]["Model"]).strip() if not pool5.empty else ""
412
 
413
- def pick_replacements_lifecycle(life_row: pd.Series, status: str) -> Dict[str, Any]:
414
  canon = str(life_row.get("_canon_make","UNKNOWN"))
415
  manufacturer = str(life_row.get("manufacturer","") or "")
416
 
417
  is_4g_device = _device_is_4g(life_row)
418
- needs_4g_repl = is_4g_device and (status in {"End of Sale","End of Life"})
419
  want_5g = is_4g_device or (status in {"End of Sale","End of Life"})
420
 
421
- # 4G alternative: ALWAYS if suggested_replacement exists for 4G devices
422
  repl_4g = "Not applicable"
423
  if is_4g_device:
424
  repl_4g = _extract_model_token(_safe_str(life_row.get("suggested_replacement","")))
425
  if not repl_4g:
426
  cand4 = _candidate_4g_models_from_lifecycle(manufacturer)
427
- repl_4g = _gpt_pick_from_candidates(life_row, cand4, "4G alternative") or (cand4[0] if cand4 else "")
428
  if not repl_4g:
429
  repl_4g = "Not applicable"
430
 
431
- # 5G replacement: ALWAYS when want_5g is true
432
  repl_5g = "Not applicable"
433
  if want_5g:
434
  repl_5g = _extract_model_token(_safe_str(life_row.get("advanced_5g_option","")))
435
  if not repl_5g:
436
  cand5 = _candidate_5g_models_from_lifecycle(manufacturer)
437
- repl_5g = _gpt_pick_from_candidates(life_row, cand5, "5G replacement/upgrade") or (cand5[0] if cand5 else "")
438
  if not repl_5g:
439
- # last resort: dec catalog fallback
440
  repl_5g = _fallback_5g_from_dec(canon)
441
 
442
  if repl_5g.lower() == "nan":
@@ -444,14 +425,14 @@ def pick_replacements_lifecycle(life_row: pd.Series, status: str) -> Dict[str, A
444
 
445
  return {
446
  "repl_4g": repl_4g,
447
- "repl_5g": repl_5g,
448
  "why": "Lifecycle replacements (GPT fallback when missing).",
449
- "sources": ["lifecycle_csv"] + (["gpt"] if client else []) + (["dec_fallback"] if (want_5g and not repl_5g) else []),
450
  }
451
 
452
 
453
  # ============================
454
- # Antennas (Parsec-only; family name extraction)
455
  # ============================
456
  PARSEC_FAMILY_WORDS = {
457
  "chinook","labrador","boxer","bloodhound","husky","beagle","mastiff","collie",
@@ -484,6 +465,13 @@ def _family_from_line(line: str) -> str:
484
  return fam.capitalize()
485
  return ""
486
 
 
 
 
 
 
 
 
487
  def _parsec_name_from_card(card_text: str) -> str:
488
  lines = [_clean_line(ln) for ln in str(card_text or "").splitlines()]
489
  lines = [ln for ln in lines if ln]
@@ -495,7 +483,6 @@ def _parsec_name_from_card(card_text: str) -> str:
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,25 +519,40 @@ def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:
532
  "name": _parsec_name_from_card(card),
533
  "part_number": _parsec_part_from_card(card),
534
  "description": _parsec_desc_from_card(card),
 
535
  })
536
  return out
537
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:
539
  q_stationary = f"{router_model} {tech} {mimo} omni stationary outdoor Parsec"
540
  q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile Parsec"
 
541
  cand_stationary = parsec_retrieve(q_stationary, top_k=10)
542
  cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)
543
 
544
- # deterministic fallback if no GPT
545
- s = cand_stationary[0] if cand_stationary else {"name":"Parsec antenna","part_number":"","description":""}
546
- v = cand_vehicle[0] if cand_vehicle else {"name":"Parsec antenna","part_number":"","description":""}
547
  s.update({"mimo": mimo, "why": "Stationary omni best match."})
548
  v.update({"mimo": mimo, "why": "Vehicle omni best match."})
549
  return {"stationary_omni": s, "vehicle_omni": v, "sources":["parsec_rag"]}
550
 
551
 
552
  # ============================
553
- # Feature table + GPT fill for missing fields
554
  # ============================
555
  FEATURE_COLS = ["Name","Modem technology","WiFi","Ports","Antennas","Ruggedness","Use case"]
556
 
@@ -628,7 +630,7 @@ def build_features_table(cur: Dict[str,str], r4: Dict[str,str], r5: Dict[str,str
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"))
@@ -640,8 +642,6 @@ def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl:
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}")
@@ -656,10 +656,13 @@ def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl:
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}")
@@ -668,37 +671,177 @@ def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl:
668
  lines.append("- dec2025routers.csv (features)")
669
  return "\n".join(lines)
670
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
671
  def run_lookup(user_text: str, st: Dict[str,Any]):
672
  user_text = str(user_text or "").strip()
673
  if not user_text:
674
- return "Enter a router SKU/model.", gr.update(visible=False), gr.update(visible=False), {}
675
 
676
  res = resolve_device(user_text)
677
  if res.get("mode") == "pick":
678
  opts = res.get("options", [])
679
  choices = [o["label"] for o in opts]
680
  st2 = {"mode":"pick","options": opts}
681
- return "Did you mean A or B? Pick one, then click Use selection.", gr.update(choices=choices, value=None, visible=True), gr.update(visible=True), st2
682
 
683
  if res.get("mode") != "ok":
684
- return "Not found.", gr.update(visible=False), gr.update(visible=False), {}
685
 
686
  life_row = df_eos.iloc[int(res["row_idx"])]
687
  eos, eol, status = row_to_dates_and_status(life_row)
688
 
689
- repl = pick_replacements_lifecycle(life_row, status)
690
 
 
 
691
  tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") not in {"Not applicable","Not listed"} else ("4G" if _device_is_4g(life_row) else "Unknown")
692
- mimo_guess = "4x4" if tech == "5G" else "2x2"
693
- ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo_guess)
694
 
695
- return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}
 
 
696
 
697
  def use_selection(selected_label: str, st: Dict[str,Any]):
698
  if not st or st.get("mode") != "pick":
699
- return "Run a search first.", gr.update(visible=False), gr.update(visible=False), {}
700
  if not selected_label:
701
- return "Pick A or B first.", gr.update(visible=True), gr.update(visible=True), st
702
 
703
  chosen_row = None
704
  for o in st.get("options", []):
@@ -706,29 +849,62 @@ def use_selection(selected_label: str, st: Dict[str,Any]):
706
  chosen_row = int(o["row_idx"])
707
  break
708
  if chosen_row is None:
709
- return "Pick a valid option.", gr.update(visible=True), gr.update(visible=True), st
710
 
711
  life_row = df_eos.iloc[int(chosen_row)]
712
  eos, eol, status = row_to_dates_and_status(life_row)
713
- repl = pick_replacements_lifecycle(life_row, status)
 
 
 
714
  tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") not in {"Not applicable","Not listed"} else ("4G" if _device_is_4g(life_row) else "Unknown")
715
- mimo_guess = "4x4" if tech == "5G" else "2x2"
716
- ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo_guess)
 
 
 
717
 
718
- return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}
 
 
 
 
 
 
719
 
720
  with gr.Blocks(title="Only-Routers") as demo:
721
- gr.Markdown("## Only-Routers\nEnter a router SKU/model. If ambiguous, you’ll get A/B choices.")
722
- user_text = gr.Textbox(label="Router SKU or model", placeholder="Examples: IBR650B, AER1600, ES450, WR21, RUT240", lines=1)
723
- st = gr.State({})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
724
 
725
- check_btn = gr.Button("Check", variant="primary")
726
- pick_dd = gr.Dropdown(label="Pick A or B", choices=[], visible=False)
727
- use_btn = gr.Button("Use selection", visible=False)
 
 
 
728
 
729
- output_md = gr.Markdown()
 
 
 
730
 
731
- check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st])
732
- use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st])
733
 
734
  demo.launch()
 
28
 
29
  MATCH_OK = 80
30
  EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
31
+
32
  PARSEC_CONTEXT_BEFORE = 900
33
  PARSEC_CONTEXT_AFTER = 1600
34
 
 
131
  device_type_col = c
132
  break
133
 
134
+ # Maker mapping (includes Teltonika)
135
  CANON_MAKER = {
136
  "CRADLEPOINT": {"cradlepoint", "ericsson", "ericsson enterprise wireless"},
137
  "SIERRA": {"sierra", "sierra wireless", "semtech", "airlink"},
 
141
  "CISCO": {"cisco"},
142
  "TELTONIKA": {"teltonika"},
143
  }
 
 
 
 
 
 
 
 
 
 
144
 
145
  def canon_maker_from_text(s: Any) -> str:
146
  t = norm_text(s)
 
322
 
323
 
324
  # ============================
325
+ # Replacements — lifecycle CSV source of truth
 
326
  # ============================
327
  def _extract_model_token(text: str) -> str:
328
  s = _safe_str(text)
 
332
  candidates = parts[::-1] if parts else [s]
333
 
334
  for cand in candidates:
 
335
  m = re.search(r"\bRUT[A-Z]?\d{2,4}\b", cand.upper())
336
  if m:
337
  return m.group(0).upper()
 
338
  m = re.search(r"\bIX\d{2}\b", cand, flags=re.IGNORECASE)
339
  if m:
340
  return m.group(0).upper()
 
341
  m = re.search(r"\b(R\d{3,4}|E\d{3,4}|S\d{3,4})\b", cand, flags=re.IGNORECASE)
342
  if m:
343
  return m.group(0).upper()
 
344
  m = re.search(r"\b[A-Z]{1,6}\d{2,4}[A-Z]?\b", cand.upper())
345
  if m:
346
  return m.group(0).upper()
 
352
  return (("lte" in t or "4g" in t) and ("5g" not in t and "nr" not in t))
353
 
354
  def _candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:
 
355
  mfr = norm_text(manufacturer)
356
  pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
357
  vals = pool["advanced_5g_option"].tolist() if "advanced_5g_option" in pool.columns else []
 
395
  pool5 = df_dec[(df_dec["_canon_make"] == canon_make) & (df_dec["_is5g"] == True)]
396
  return str(pool5.iloc[0]["Model"]).strip() if not pool5.empty else ""
397
 
398
+ def pick_replacements_lifecycle(life_row: pd.Series, status: str, use_gpt: bool = True) -> Dict[str, Any]:
399
  canon = str(life_row.get("_canon_make","UNKNOWN"))
400
  manufacturer = str(life_row.get("manufacturer","") or "")
401
 
402
  is_4g_device = _device_is_4g(life_row)
 
403
  want_5g = is_4g_device or (status in {"End of Sale","End of Life"})
404
 
 
405
  repl_4g = "Not applicable"
406
  if is_4g_device:
407
  repl_4g = _extract_model_token(_safe_str(life_row.get("suggested_replacement","")))
408
  if not repl_4g:
409
  cand4 = _candidate_4g_models_from_lifecycle(manufacturer)
410
+ repl_4g = (_gpt_pick_from_candidates(life_row, cand4, "4G alternative") if (use_gpt and client) else "") or (cand4[0] if cand4 else "")
411
  if not repl_4g:
412
  repl_4g = "Not applicable"
413
 
 
414
  repl_5g = "Not applicable"
415
  if want_5g:
416
  repl_5g = _extract_model_token(_safe_str(life_row.get("advanced_5g_option","")))
417
  if not repl_5g:
418
  cand5 = _candidate_5g_models_from_lifecycle(manufacturer)
419
+ repl_5g = (_gpt_pick_from_candidates(life_row, cand5, "5G replacement/upgrade") if (use_gpt and client) else "") or (cand5[0] if cand5 else "")
420
  if not repl_5g:
 
421
  repl_5g = _fallback_5g_from_dec(canon)
422
 
423
  if repl_5g.lower() == "nan":
 
425
 
426
  return {
427
  "repl_4g": repl_4g,
428
+ "repl_5g": repl_5g if repl_5g else "Not listed",
429
  "why": "Lifecycle replacements (GPT fallback when missing).",
430
+ "sources": ["lifecycle_csv"] + (["gpt"] if (use_gpt and client) else []) + (["dec_fallback"] if (want_5g and (repl_5g == "Not listed" or repl_5g == "")) else []),
431
  }
432
 
433
 
434
  # ============================
435
+ # Antennas (Parsec-only; family + connectors hint)
436
  # ============================
437
  PARSEC_FAMILY_WORDS = {
438
  "chinook","labrador","boxer","bloodhound","husky","beagle","mastiff","collie",
 
465
  return fam.capitalize()
466
  return ""
467
 
468
+ def _parsec_connectors_from_card(t: str) -> str:
469
+ m = re.search(r"Standard\s+Connectors:\s*(.+)", t, flags=re.IGNORECASE)
470
+ if m:
471
+ val = re.sub(r"\s+", " ", m.group(1).strip())
472
+ return val[:80]
473
+ return ""
474
+
475
  def _parsec_name_from_card(card_text: str) -> str:
476
  lines = [_clean_line(ln) for ln in str(card_text or "").splitlines()]
477
  lines = [ln for ln in lines if ln]
 
483
  if fam:
484
  return fam
485
 
 
486
  sku_i = None
487
  for i, ln in enumerate(lines):
488
  if "standard sku" in ln.lower():
 
519
  "name": _parsec_name_from_card(card),
520
  "part_number": _parsec_part_from_card(card),
521
  "description": _parsec_desc_from_card(card),
522
+ "connectors": _parsec_connectors_from_card(card),
523
  })
524
  return out
525
 
526
+ def infer_mimo_for_replacement(model: str, canon_make: str) -> str:
527
+ if not model or model in {"Not applicable","Not listed"}:
528
+ return "2x2"
529
+ pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
530
+ if pool.empty:
531
+ return "4x4" if ("5g" in model.lower()) else "2x2"
532
+ hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
533
+ if hit and hit[1] >= MATCH_OK:
534
+ row = pool.iloc[int(hit[2])]
535
+ txt = (str(row.get("Antennas (internal/external/both)","")) + " " + str(row.get("Modem Type",""))).lower()
536
+ if "4x4" in txt or "4 x 4" in txt:
537
+ return "4x4"
538
+ return "4x4" if ("5g" in model.lower()) else "2x2"
539
+
540
  def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:
541
  q_stationary = f"{router_model} {tech} {mimo} omni stationary outdoor Parsec"
542
  q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile Parsec"
543
+
544
  cand_stationary = parsec_retrieve(q_stationary, top_k=10)
545
  cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)
546
 
547
+ s = cand_stationary[0] if cand_stationary else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
548
+ v = cand_vehicle[0] if cand_vehicle else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
 
549
  s.update({"mimo": mimo, "why": "Stationary omni best match."})
550
  v.update({"mimo": mimo, "why": "Vehicle omni best match."})
551
  return {"stationary_omni": s, "vehicle_omni": v, "sources":["parsec_rag"]}
552
 
553
 
554
  # ============================
555
+ # Feature table + GPT fill for missing fields (not lazy: fill missing)
556
  # ============================
557
  FEATURE_COLS = ["Name","Modem technology","WiFi","Ports","Antennas","Ruggedness","Use case"]
558
 
 
630
 
631
 
632
  # ============================
633
+ # Output + install-ready checklist (Feature #9)
634
  # ============================
635
  def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
636
  canon_make = str(life_row.get("_canon_make","UNKNOWN"))
 
642
  cur_feats = current_features_guess(life_row)
643
  r4_feats = dec_features_by_model(repl.get("repl_4g",""), canon_make)
644
  r5_feats = dec_features_by_model(repl.get("repl_5g",""), canon_make)
 
 
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}")
 
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
+ conn_s = f" | Conn: {st.get('connectors','')}" if st.get("connectors") else ""
660
+ conn_v = f" | Conn: {vh.get('connectors','')}" if vh.get("connectors") else ""
661
+ lines.append(f" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')}{conn_s} — {st.get('why','')}")
662
+ lines.append(f" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')}{conn_v} — {vh.get('why','')}")
663
  lines.append("8. Recommended features table:")
664
  lines.append(table_md)
665
+
666
  lines.append("\nSources (debug):")
667
  for s in repl.get("sources", []) if isinstance(repl.get("sources"), list) else []:
668
  lines.append(f"- {s}")
 
671
  lines.append("- dec2025routers.csv (features)")
672
  return "\n".join(lines)
673
 
674
+ def install_ready_checklist(life_row: pd.Series, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
675
+ current_sku = str(life_row.get("sku","") or "").strip()
676
+ repl4 = str(repl.get("repl_4g","") or "")
677
+ repl5 = str(repl.get("repl_5g","") or "")
678
+ st = ant.get("stationary_omni", {})
679
+ vh = ant.get("vehicle_omni", {})
680
+
681
+ if client is not None:
682
+ sys = "Create a short, install-ready checklist for a Verizon rep. Keep it scannable. Return markdown only."
683
+ payload = {
684
+ "current_device": current_sku,
685
+ "replacements": {"4g_alternative": repl4, "5g_replacement": repl5},
686
+ "antennas": {"stationary": st, "vehicle": vh},
687
+ "rules": [
688
+ "Include: router(s), antennas, connector/cable notes, mounting notes, power notes, and 'next steps'.",
689
+ "Keep it concise and practical."
690
+ ]
691
+ }
692
+ resp = client.responses.create(
693
+ model=OPENAI_MODEL,
694
+ reasoning=OPENAI_REASONING,
695
+ input=[{"role":"system","content":sys},{"role":"user","content":json.dumps(payload)}],
696
+ max_output_tokens=550,
697
+ )
698
+ return (getattr(resp, "output_text", "") or "").strip()
699
+
700
+ lines = []
701
+ lines.append("### Install-ready checklist")
702
+ lines.append(f"- Current device: {current_sku}")
703
+ lines.append(f"- 5G replacement: {repl5}")
704
+ lines.append(f"- 4G alternative: {repl4 if repl4 else 'Not applicable'}")
705
+ lines.append(f"- Stationary omni antenna: {st.get('name','')} (PN {st.get('part_number','')})")
706
+ lines.append(f"- Vehicle omni antenna: {vh.get('name','')} (PN {vh.get('part_number','')})")
707
+ if st.get("connectors"):
708
+ lines.append(f"- Stationary connectors: {st.get('connectors')}")
709
+ if vh.get("connectors"):
710
+ lines.append(f"- Vehicle connectors: {vh.get('connectors')}")
711
+ lines.append("- Next steps: confirm mounting + cable lengths + power method; place order; schedule install.")
712
+ return "\n".join(lines)
713
+
714
+
715
+ # ============================
716
+ # Batch mode (Feature #4)
717
+ # ============================
718
+ def parse_batch_inputs(text_blob: str, file_obj: Optional[Any]) -> List[str]:
719
+ items = []
720
+ if file_obj is not None:
721
+ try:
722
+ path = file_obj.name if hasattr(file_obj, "name") else str(file_obj)
723
+ df = pd.read_csv(path)
724
+ col = df.columns[0]
725
+ items.extend([str(x).strip() for x in df[col].tolist() if str(x).strip()])
726
+ except Exception:
727
+ pass
728
+ if text_blob:
729
+ for ln in str(text_blob).splitlines():
730
+ ln = ln.strip()
731
+ if ln:
732
+ items.append(ln)
733
+ seen=set()
734
+ out=[]
735
+ for x in items:
736
+ k=norm_text(x)
737
+ if k and k not in seen:
738
+ seen.add(k); out.append(x)
739
+ return out
740
+
741
+ def run_batch(text_blob: str, file_obj: Optional[Any], include_antennas: bool):
742
+ inputs = parse_batch_inputs(text_blob, file_obj)
743
+ if not inputs:
744
+ return "", pd.DataFrame(), None, ""
745
+
746
+ rows=[]
747
+ for item in inputs:
748
+ res = resolve_device(item)
749
+ if res.get("mode") != "ok":
750
+ rows.append({
751
+ "Input": item,
752
+ "Matched": "",
753
+ "Status": "Needs review",
754
+ "EOS": "",
755
+ "EOL": "",
756
+ "4G alternative": "",
757
+ "5G replacement": "",
758
+ "Stationary antenna": "",
759
+ "Vehicle antenna": "",
760
+ "Notes": "Not found / ambiguous"
761
+ })
762
+ continue
763
+
764
+ life_row = df_eos.iloc[int(res["row_idx"])]
765
+ eos, eol, status = row_to_dates_and_status(life_row)
766
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=False) # fast: no GPT in batch
767
+
768
+ if include_antennas:
769
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
770
+ mimo = infer_mimo_for_replacement(repl.get("repl_5g",""), canon_make)
771
+ 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")
772
+ ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo)
773
+ stA = ant.get("stationary_omni", {})
774
+ vhA = ant.get("vehicle_omni", {})
775
+ ant_s = f"{stA.get('name','')} {stA.get('part_number','')}"
776
+ ant_v = f"{vhA.get('name','')} {vhA.get('part_number','')}"
777
+ else:
778
+ ant_s = ""
779
+ ant_v = ""
780
+
781
+ rows.append({
782
+ "Input": item,
783
+ "Matched": str(life_row.get("sku","")),
784
+ "Status": status,
785
+ "EOS": eos,
786
+ "EOL": eol,
787
+ "4G alternative": repl.get("repl_4g",""),
788
+ "5G replacement": repl.get("repl_5g",""),
789
+ "Stationary antenna": ant_s,
790
+ "Vehicle antenna": ant_v,
791
+ "Notes": "",
792
+ })
793
+
794
+ out_df = pd.DataFrame(rows)
795
+
796
+ # Summary counts + rollup
797
+ counts = out_df["Status"].value_counts(dropna=False).to_dict()
798
+ top_5g = out_df["5G replacement"].value_counts(dropna=False).head(5).to_dict()
799
+ summary = f"Rows: {len(out_df)} | " + " | ".join([f"{k}: {v}" for k,v in counts.items()])
800
+ rollup = "Top 5G recommendations:\n" + "\n".join([f"- {k}: {v}" for k,v in top_5g.items() if str(k).strip()])
801
+
802
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
803
+ out_df.to_csv(tmp.name, index=False)
804
+
805
+ return summary, out_df, tmp.name, rollup
806
+
807
+
808
+ # ============================
809
+ # Gradio app (Single + Batch + Install-ready)
810
+ # ============================
811
  def run_lookup(user_text: str, st: Dict[str,Any]):
812
  user_text = str(user_text or "").strip()
813
  if not user_text:
814
+ return "Enter a router SKU/model.", gr.update(visible=False), gr.update(visible=False), {}, gr.update(value="")
815
 
816
  res = resolve_device(user_text)
817
  if res.get("mode") == "pick":
818
  opts = res.get("options", [])
819
  choices = [o["label"] for o in opts]
820
  st2 = {"mode":"pick","options": opts}
821
+ return "Did you mean A or B? Pick one, then click Use selection.", gr.update(choices=choices, value=None, visible=True), gr.update(visible=True), st2, gr.update(value="")
822
 
823
  if res.get("mode") != "ok":
824
+ return "Not found.", gr.update(visible=False), gr.update(visible=False), {}, gr.update(value="")
825
 
826
  life_row = df_eos.iloc[int(res["row_idx"])]
827
  eos, eol, status = row_to_dates_and_status(life_row)
828
 
829
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)
830
 
831
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
832
+ mimo = infer_mimo_for_replacement(repl.get("repl_5g",""), canon_make)
833
  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")
834
+ ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo)
 
835
 
836
+ output = assemble_output(life_row, status, eos, eol, repl, ant)
837
+ st_out = {"row_idx": int(res["row_idx"]), "repl": repl, "ant": ant}
838
+ return output, gr.update(visible=False), gr.update(visible=False), st_out, gr.update(value="")
839
 
840
  def use_selection(selected_label: str, st: Dict[str,Any]):
841
  if not st or st.get("mode") != "pick":
842
+ return "Run a search first.", gr.update(visible=False), gr.update(visible=False), {}, gr.update(value="")
843
  if not selected_label:
844
+ return "Pick A or B first.", gr.update(visible=True), gr.update(visible=True), st, gr.update(value="")
845
 
846
  chosen_row = None
847
  for o in st.get("options", []):
 
849
  chosen_row = int(o["row_idx"])
850
  break
851
  if chosen_row is None:
852
+ return "Pick a valid option.", gr.update(visible=True), gr.update(visible=True), st, gr.update(value="")
853
 
854
  life_row = df_eos.iloc[int(chosen_row)]
855
  eos, eol, status = row_to_dates_and_status(life_row)
856
+ repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)
857
+
858
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
859
+ mimo = infer_mimo_for_replacement(repl.get("repl_5g",""), canon_make)
860
  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")
861
+ ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo)
862
+
863
+ output = assemble_output(life_row, status, eos, eol, repl, ant)
864
+ st_out = {"row_idx": int(chosen_row), "repl": repl, "ant": ant}
865
+ return output, gr.update(visible=False), gr.update(visible=False), st_out, gr.update(value="")
866
 
867
+ def make_install_ready(st_state: Dict[str,Any]):
868
+ if not st_state or "row_idx" not in st_state:
869
+ return "Run a lookup first."
870
+ life_row = df_eos.iloc[int(st_state["row_idx"])]
871
+ repl = st_state.get("repl", {}) or {}
872
+ ant = st_state.get("ant", {}) or {}
873
+ return install_ready_checklist(life_row, repl, ant)
874
 
875
  with gr.Blocks(title="Only-Routers") as demo:
876
+ gr.Markdown("## Only-Routers\nSingle lookup + Batch upload for Verizon reps.")
877
+
878
+ with gr.Tabs():
879
+ with gr.Tab("Single"):
880
+ user_text = gr.Textbox(label="Router SKU or model", placeholder="Examples: IBR650B, AER1600, ES450, WR21, RUT240", lines=1)
881
+ st = gr.State({})
882
+
883
+ check_btn = gr.Button("Check", variant="primary")
884
+ pick_dd = gr.Dropdown(label="Pick A or B", choices=[], visible=False)
885
+ use_btn = gr.Button("Use selection", visible=False)
886
+
887
+ output_md = gr.Markdown()
888
+
889
+ install_btn = gr.Button("Make install-ready checklist")
890
+ install_md = gr.Markdown()
891
+
892
+ check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st, install_md])
893
+ use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st, install_md])
894
+ install_btn.click(fn=make_install_ready, inputs=[st], outputs=[install_md])
895
 
896
+ with gr.Tab("Batch"):
897
+ gr.Markdown("Paste one per line or upload a CSV (first column). Batch runs fast (no GPT), and can optionally include antenna picks.")
898
+ batch_text = gr.Textbox(label="Paste devices (one per line)", lines=8, placeholder="WR21\nRUT240\nIBR650B")
899
+ batch_file = gr.File(label="Upload CSV", file_types=[".csv"])
900
+ include_ant = gr.Checkbox(label="Include antenna picks (slower)", value=False)
901
+ run_btn = gr.Button("Run batch", variant="primary")
902
 
903
+ summary_md = gr.Markdown()
904
+ rollup_md = gr.Markdown()
905
+ table = gr.Dataframe(interactive=False, wrap=True)
906
+ dl = gr.File(label="Download results CSV")
907
 
908
+ run_btn.click(fn=run_batch, inputs=[batch_text, batch_file, include_ant], outputs=[summary_md, table, dl, rollup_md])
 
909
 
910
  demo.launch()
app_old.py ADDED
@@ -0,0 +1,734 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import math
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, date
8
+ from typing import Dict, List, Optional, Tuple, Any
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ import fitz # PyMuPDF
14
+ import faiss
15
+ from sentence_transformers import SentenceTransformer
16
+ from rapidfuzz import fuzz, process
17
+
18
+ import gradio as gr
19
+ from openai import OpenAI
20
+
21
+
22
+ # ============================
23
+ # Settings
24
+ # ============================
25
+ TODAY = date(2026, 1, 18)
26
+ OPENAI_MODEL = "gpt-5.2"
27
+ OPENAI_REASONING = {"effort": "high"}
28
+
29
+ MATCH_OK = 80
30
+ EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
31
+ PARSEC_CONTEXT_BEFORE = 900
32
+ PARSEC_CONTEXT_AFTER = 1600
33
+
34
+ CACHE_DIR = os.path.join(os.getcwd(), ".onlyrouters_cache")
35
+ os.makedirs(CACHE_DIR, exist_ok=True)
36
+
37
+
38
+ # ============================
39
+ # OpenAI client (HF Space secret: OPENAI_API_KEY)
40
+ # ============================
41
+ API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
42
+ client = OpenAI(api_key=API_KEY) if API_KEY else None
43
+
44
+
45
+ # ============================
46
+ # Utilities
47
+ # ============================
48
+ def norm_text(s: Any) -> str:
49
+ try:
50
+ if s is None or (isinstance(s, float) and math.isnan(s)) or pd.isna(s):
51
+ return ""
52
+ except Exception:
53
+ pass
54
+ s = str(s).strip().lower()
55
+ s = re.sub(r"[^a-z0-9\s\-\/]", " ", s)
56
+ s = re.sub(r"\s+", " ", s).strip()
57
+ return s
58
+
59
+ def _safe_str(v: Any) -> str:
60
+ if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):
61
+ return ""
62
+ return str(v).strip()
63
+
64
+ def _is_5g(modem_type: Any) -> bool:
65
+ s = norm_text(modem_type)
66
+ return ("5g" in s) or ("nr" in s)
67
+
68
+ def _json_load_safe(s: str) -> Dict[str, Any]:
69
+ try:
70
+ return json.loads(s)
71
+ except Exception:
72
+ return {}
73
+
74
+ def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 700) -> Dict[str, Any]:
75
+ if client is None:
76
+ return {}
77
+ resp = client.responses.create(
78
+ model=OPENAI_MODEL,
79
+ reasoning=OPENAI_REASONING,
80
+ input=[
81
+ {"role": "system", "content": system},
82
+ {"role": "user", "content": json.dumps(payload)},
83
+ ],
84
+ max_output_tokens=max_tokens,
85
+ )
86
+ return _json_load_safe(getattr(resp, "output_text", "") or "")
87
+
88
+
89
+ # ============================
90
+ # Load data files (must exist in repo)
91
+ # ============================
92
+ EOS_PATH = "routers_eos_eol_by_sku.csv"
93
+ DEC_PATH = "dec2025routers.csv"
94
+ PARSEC_PDF = "ParsecCatalog.pdf"
95
+
96
+ if not os.path.exists(EOS_PATH):
97
+ raise FileNotFoundError(f"Missing {EOS_PATH} in repo.")
98
+ if not os.path.exists(DEC_PATH):
99
+ raise FileNotFoundError(f"Missing {DEC_PATH} in repo.")
100
+ if not os.path.exists(PARSEC_PDF):
101
+ raise FileNotFoundError(f"Missing {PARSEC_PDF} in repo.")
102
+
103
+ df_eos = pd.read_csv(EOS_PATH).copy()
104
+ df_dec = pd.read_csv(DEC_PATH).copy()
105
+
106
+ # Region filter: keep USA / North America / blank / not specified
107
+ def _region_ok(x: Any) -> bool:
108
+ s = str(x or "").strip().lower()
109
+ if not s:
110
+ return True
111
+ if "not specified" in s:
112
+ return True
113
+ if "north america" in s:
114
+ return True
115
+ if re.search(r"\busa\b", s):
116
+ return True
117
+ if re.search(r"\bunited\s+states\b", s):
118
+ return True
119
+ if re.search(r"\bu\.?s\.?\b", s):
120
+ return True
121
+ return False
122
+
123
+ if "region" in df_eos.columns:
124
+ df_eos = df_eos[df_eos["region"].apply(_region_ok)].reset_index(drop=True)
125
+
126
+ # Optional "Device Type"
127
+ device_type_col = None
128
+ for c in df_eos.columns:
129
+ if norm_text(c) == "device type":
130
+ device_type_col = c
131
+ break
132
+
133
+ # Maker mapping (expanded — adds Teltonika)
134
+ CANON_MAKER = {
135
+ "CRADLEPOINT": {"cradlepoint", "ericsson", "ericsson enterprise wireless"},
136
+ "SIERRA": {"sierra", "sierra wireless", "semtech", "airlink"},
137
+ "FEENEY": {"feeney", "feeney wireless", "inseego"},
138
+ "DIGI": {"digi", "accelerated", "accelerated concepts"},
139
+ "CISCO_MERAKI": {"meraki", "cisco meraki"},
140
+ "CISCO": {"cisco"},
141
+ "TELTONIKA": {"teltonika"},
142
+ }
143
+ DISPLAY_MAKER = {
144
+ "CRADLEPOINT": "Cradlepoint",
145
+ "SIERRA": "Sierra Wireless",
146
+ "FEENEY": "Feeney Wireless",
147
+ "DIGI": "Digi",
148
+ "CISCO_MERAKI": "Cisco Meraki",
149
+ "CISCO": "Cisco",
150
+ "TELTONIKA": "Teltonika",
151
+ "UNKNOWN": "Unknown",
152
+ }
153
+
154
+ def canon_maker_from_text(s: Any) -> str:
155
+ t = norm_text(s)
156
+ for canon, terms in CANON_MAKER.items():
157
+ for term in terms:
158
+ if term in t:
159
+ return canon
160
+ return "UNKNOWN"
161
+
162
+ df_eos["_canon_make"] = df_eos["manufacturer"].apply(canon_maker_from_text) if "manufacturer" in df_eos.columns else "UNKNOWN"
163
+ df_eos["_norm_sku"] = df_eos["sku"].apply(norm_text) if "sku" in df_eos.columns else ""
164
+ df_eos["_norm_desc"] = df_eos["description"].apply(norm_text) if "description" in df_eos.columns else ""
165
+ df_eos["_norm_notes"] = df_eos["notes"].apply(norm_text) if "notes" in df_eos.columns else ""
166
+
167
+ df_dec["_canon_make"] = df_dec["Make"].apply(canon_maker_from_text) if "Make" in df_dec.columns else "UNKNOWN"
168
+ df_dec["_norm_model"] = df_dec["Model"].apply(norm_text) if "Model" in df_dec.columns else ""
169
+ df_dec["_is5g"] = df_dec["Modem Type"].apply(_is_5g) if "Modem Type" in df_dec.columns else False
170
+
171
+
172
+ # ============================
173
+ # Date helpers
174
+ # ============================
175
+ @dataclass
176
+ class ParsedDate:
177
+ raw: str
178
+ kind: str
179
+ value: Optional[date]
180
+
181
+ def parse_date_field(x: Any) -> ParsedDate:
182
+ raw = str(x or "").strip()
183
+ if not raw:
184
+ return ParsedDate(raw="", kind="missing", value=None)
185
+
186
+ if re.fullmatch(r"\d{4}", raw):
187
+ y = int(raw)
188
+ if y == TODAY.year:
189
+ return ParsedDate(raw=raw, kind="year", value=date(y, 1, 1))
190
+ if y < TODAY.year:
191
+ return ParsedDate(raw=raw, kind="year", value=date(y, 1, 1))
192
+ return ParsedDate(raw=raw, kind="year", value=date(y, 12, 31))
193
+
194
+ if re.fullmatch(r"\d{4}-\d{2}", raw):
195
+ try:
196
+ y, m = raw.split("-")
197
+ return ParsedDate(raw=raw, kind="year_month", value=date(int(y), int(m), 1))
198
+ except Exception:
199
+ return ParsedDate(raw=raw, kind="bad", value=None)
200
+
201
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw):
202
+ try:
203
+ dt = datetime.strptime(raw, "%Y-%m-%d").date()
204
+ return ParsedDate(raw=raw, kind="full", value=dt)
205
+ except Exception:
206
+ return ParsedDate(raw=raw, kind="bad", value=None)
207
+
208
+ return ParsedDate(raw=raw, kind="bad", value=None)
209
+
210
+ def display_date(parsed: ParsedDate) -> str:
211
+ if parsed.kind == "missing":
212
+ return "Not listed"
213
+ if parsed.kind == "bad":
214
+ return parsed.raw or "Not listed"
215
+ return parsed.raw
216
+
217
+ def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:
218
+ if eos.value is None and eol.value is None:
219
+ return "Unknown"
220
+ if eol.value is not None and eol.value <= TODAY:
221
+ return "End of Life"
222
+ if eos.value is not None and eos.value <= TODAY:
223
+ return "End of Sale"
224
+ return "Active"
225
+
226
+ def row_to_dates_and_status(life_row: pd.Series) -> Tuple[str, str, str]:
227
+ eos = parse_date_field(life_row.get("end_of_sale"))
228
+ eol = parse_date_field(life_row.get("end_of_life"))
229
+ return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)
230
+
231
+
232
+ # ============================
233
+ # Embeddings + Parsec index
234
+ # ============================
235
+ embedder = SentenceTransformer(EMBED_MODEL_NAME)
236
+
237
+ def extract_pdf_text_pages(path: str) -> List[str]:
238
+ doc = fitz.open(path)
239
+ return [doc[i].get_text("text") for i in range(len(doc))]
240
+
241
+ def build_parsec_cards(pages: List[str]) -> List[str]:
242
+ cards = []
243
+ for p in pages:
244
+ for m in re.finditer(r"Standard\s+SKU:", p):
245
+ start = max(0, m.start() - PARSEC_CONTEXT_BEFORE)
246
+ end = min(len(p), m.start() + PARSEC_CONTEXT_AFTER)
247
+ c = p[start:end].strip()
248
+ if len(c) >= 200:
249
+ cards.append(c)
250
+ out, seen = [], set()
251
+ for c in cards:
252
+ h = hashlib.sha1(c.encode("utf-8")).hexdigest()
253
+ if h not in seen:
254
+ seen.add(h); out.append(c)
255
+ return out
256
+
257
+ parsec_cards = build_parsec_cards(extract_pdf_text_pages(PARSEC_PDF))
258
+ parsec_emb = embedder.encode(parsec_cards, batch_size=64, show_progress_bar=False, normalize_embeddings=True)
259
+ parsec_emb = np.asarray(parsec_emb, dtype=np.float32)
260
+ parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])
261
+ parsec_index.add(parsec_emb)
262
+
263
+
264
+ # ============================
265
+ # Device resolution (exact SKU -> GPT A/B)
266
+ # ============================
267
+ def _label_for_row(i: int) -> str:
268
+ r = df_eos.iloc[i]
269
+ return f"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}"[:220]
270
+
271
+ EOS_LABELS = [_label_for_row(i) for i in range(len(df_eos))]
272
+ EOS_CORPUS = []
273
+ for _, r in df_eos.iterrows():
274
+ EOS_CORPUS.append(" ".join([
275
+ r.get("_norm_sku",""),
276
+ r.get("_canon_make",""),
277
+ r.get("_norm_desc",""),
278
+ r.get("_norm_notes",""),
279
+ ]))
280
+
281
+ def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int,int,str]]:
282
+ q = norm_text(query)
283
+ hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)
284
+ return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]
285
+
286
+ def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> Dict[str, Any]:
287
+ if client is None:
288
+ return {}
289
+ sys = "Pick which router the user meant. Never invent. Return strict JSON only."
290
+ payload = {
291
+ "user_input": user_text,
292
+ "candidates": [{"row_idx": i, "score": s, "label": lbl} for (i,s,lbl) in candidates],
293
+ "rules": [
294
+ "If one candidate is clearly correct, return mode='ok' with row_idx.",
295
+ "If two are plausible, return mode='pick' with top 2 options."
296
+ ],
297
+ "output_schema": {"mode":"ok|pick","row_idx":"int","options":[{"row_idx":"int","label":"string"}]}
298
+ }
299
+ return gpt_json(sys, payload, max_tokens=300)
300
+
301
+ def resolve_device(user_text: str) -> Dict[str, Any]:
302
+ q = norm_text(user_text)
303
+ exact_idxs = df_eos.index[df_eos["_norm_sku"] == q].tolist()
304
+ if len(exact_idxs) == 1:
305
+ return {"mode":"ok","row_idx": int(exact_idxs[0])}
306
+ if len(exact_idxs) > 1:
307
+ opts = [{"row_idx": int(i), "label": EOS_LABELS[int(i)]} for i in exact_idxs[:2]]
308
+ return {"mode":"pick","options": opts}
309
+
310
+ cands = local_candidates(user_text, top_k=6)
311
+ if not cands:
312
+ return {"mode":"not_found"}
313
+
314
+ if cands[0][1] >= 95 and (len(cands) == 1 or (cands[0][1] - cands[1][1]) >= 8):
315
+ return {"mode":"ok","row_idx": cands[0][0]}
316
+
317
+ g = gpt_choose_device(user_text, cands)
318
+ if g.get("mode") == "ok" and isinstance(g.get("row_idx"), int):
319
+ return {"mode":"ok","row_idx": int(g["row_idx"])}
320
+
321
+ if g.get("mode") == "pick":
322
+ opts = g.get("options", []) or []
323
+ opts2 = [{"row_idx": int(o["row_idx"]), "label": str(o["label"])} for o in opts[:2] if "row_idx" in o]
324
+ if opts2:
325
+ return {"mode":"pick","options": opts2}
326
+
327
+ # fallback
328
+ if len(cands) > 1:
329
+ return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]},{"row_idx":cands[1][0],"label":cands[1][2]}]}
330
+ return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]}]}
331
+
332
+
333
+ # ============================
334
+ # Replacements — lifecycle CSV is source of truth
335
+ # Fix: always show 4G alternative if lifecycle suggests it (even if Active)
336
+ # ============================
337
+ def _extract_model_token(text: str) -> str:
338
+ s = _safe_str(text)
339
+ if not s:
340
+ return ""
341
+ parts = [p.strip() for p in s.split("|") if p.strip()]
342
+ candidates = parts[::-1] if parts else [s]
343
+
344
+ for cand in candidates:
345
+ # Teltonika family
346
+ m = re.search(r"\bRUT[A-Z]?\d{2,4}\b", cand.upper())
347
+ if m:
348
+ return m.group(0).upper()
349
+ # Digi IX-series
350
+ m = re.search(r"\bIX\d{2}\b", cand, flags=re.IGNORECASE)
351
+ if m:
352
+ return m.group(0).upper()
353
+ # Cradlepoint R/E/S
354
+ m = re.search(r"\b(R\d{3,4}|E\d{3,4}|S\d{3,4})\b", cand, flags=re.IGNORECASE)
355
+ if m:
356
+ return m.group(0).upper()
357
+ # Generic model token
358
+ m = re.search(r"\b[A-Z]{1,6}\d{2,4}[A-Z]?\b", cand.upper())
359
+ if m:
360
+ return m.group(0).upper()
361
+
362
+ return candidates[0][:60]
363
+
364
+ def _device_is_4g(life_row: pd.Series) -> bool:
365
+ t = norm_text(life_row.get("description","")) + " " + norm_text(life_row.get("notes",""))
366
+ return (("lte" in t or "4g" in t) and ("5g" not in t and "nr" not in t))
367
+
368
+ def _candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:
369
+ # Pool within same manufacturer text (not just canon) to support Teltonika etc
370
+ mfr = norm_text(manufacturer)
371
+ pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
372
+ vals = pool["advanced_5g_option"].tolist() if "advanced_5g_option" in pool.columns else []
373
+ out, seen = [], set()
374
+ for v in vals:
375
+ tok = _extract_model_token(v)
376
+ if tok and tok.lower() != "nan" and tok not in seen:
377
+ seen.add(tok); out.append(tok)
378
+ return out
379
+
380
+ def _candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:
381
+ mfr = norm_text(manufacturer)
382
+ pool = df_eos[df_eos["manufacturer"].astype(str).str.lower().eq(mfr)].copy() if "manufacturer" in df_eos.columns else df_eos.copy()
383
+ vals = pool["suggested_replacement"].tolist() if "suggested_replacement" in pool.columns else []
384
+ out, seen = [], set()
385
+ for v in vals:
386
+ tok = _extract_model_token(v)
387
+ if tok and tok.lower() != "nan" and tok not in seen:
388
+ seen.add(tok); out.append(tok)
389
+ return out
390
+
391
+ def _gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:
392
+ if client is None or not candidates:
393
+ return ""
394
+ sys = "Pick the best replacement model. Choose only from candidates. Return strict JSON only."
395
+ payload = {
396
+ "old_device": {
397
+ "sku": str(old_row.get("sku","")),
398
+ "manufacturer": str(old_row.get("manufacturer","")),
399
+ "description": str(old_row.get("description","")),
400
+ "need": need,
401
+ },
402
+ "candidates": candidates[:40],
403
+ "output_schema": {"choice":"string"}
404
+ }
405
+ out = gpt_json(sys, payload, max_tokens=240) or {}
406
+ choice = str(out.get("choice","") or "").strip()
407
+ return choice if choice in candidates else ""
408
+
409
+ def _fallback_5g_from_dec(canon_make: str) -> str:
410
+ pool5 = df_dec[(df_dec["_canon_make"] == canon_make) & (df_dec["_is5g"] == True)]
411
+ return str(pool5.iloc[0]["Model"]).strip() if not pool5.empty else ""
412
+
413
+ def pick_replacements_lifecycle(life_row: pd.Series, status: str) -> Dict[str, Any]:
414
+ canon = str(life_row.get("_canon_make","UNKNOWN"))
415
+ manufacturer = str(life_row.get("manufacturer","") or "")
416
+
417
+ is_4g_device = _device_is_4g(life_row)
418
+ needs_4g_repl = is_4g_device and (status in {"End of Sale","End of Life"})
419
+ want_5g = is_4g_device or (status in {"End of Sale","End of Life"})
420
+
421
+ # 4G alternative: ALWAYS if suggested_replacement exists for 4G devices
422
+ repl_4g = "Not applicable"
423
+ if is_4g_device:
424
+ repl_4g = _extract_model_token(_safe_str(life_row.get("suggested_replacement","")))
425
+ if not repl_4g:
426
+ cand4 = _candidate_4g_models_from_lifecycle(manufacturer)
427
+ repl_4g = _gpt_pick_from_candidates(life_row, cand4, "4G alternative") or (cand4[0] if cand4 else "")
428
+ if not repl_4g:
429
+ repl_4g = "Not applicable"
430
+
431
+ # 5G replacement: ALWAYS when want_5g is true
432
+ repl_5g = "Not applicable"
433
+ if want_5g:
434
+ repl_5g = _extract_model_token(_safe_str(life_row.get("advanced_5g_option","")))
435
+ if not repl_5g:
436
+ cand5 = _candidate_5g_models_from_lifecycle(manufacturer)
437
+ repl_5g = _gpt_pick_from_candidates(life_row, cand5, "5G replacement/upgrade") or (cand5[0] if cand5 else "")
438
+ if not repl_5g:
439
+ # last resort: dec catalog fallback
440
+ repl_5g = _fallback_5g_from_dec(canon)
441
+
442
+ if repl_5g.lower() == "nan":
443
+ repl_5g = ""
444
+
445
+ return {
446
+ "repl_4g": repl_4g,
447
+ "repl_5g": repl_5g,
448
+ "why": "Lifecycle replacements (GPT fallback when missing).",
449
+ "sources": ["lifecycle_csv"] + (["gpt"] if client else []) + (["dec_fallback"] if (want_5g and not repl_5g) else []),
450
+ }
451
+
452
+
453
+ # ============================
454
+ # Antennas (Parsec-only; family name extraction)
455
+ # ============================
456
+ PARSEC_FAMILY_WORDS = {
457
+ "chinook","labrador","boxer","bloodhound","husky","beagle","mastiff","collie",
458
+ "shepherd","belgian","australian","terrier","pyrenees"
459
+ }
460
+ BAD_NAME_MARKERS = {
461
+ "customization", "standard connectors", "connectors", "features", "benefits",
462
+ "specifications", "mechanical", "electrical", "mounting", "accessories",
463
+ "description:", "standard sku"
464
+ }
465
+
466
+ def _clean_line(s: str) -> str:
467
+ s = re.sub(r"\s+", " ", str(s or "").strip())
468
+ if re.fullmatch(r"-[a-z0-9]+", s.lower()):
469
+ return ""
470
+ return s
471
+
472
+ def _is_bad_name_line(line: str) -> bool:
473
+ low = line.lower()
474
+ if any(m in low for m in BAD_NAME_MARKERS):
475
+ return True
476
+ if re.search(r"\b-[a-z0-9]{1,4}\b", low) and len(low) <= 25:
477
+ return True
478
+ return False
479
+
480
+ def _family_from_line(line: str) -> str:
481
+ low = line.lower()
482
+ for fam in PARSEC_FAMILY_WORDS:
483
+ if fam in low:
484
+ return fam.capitalize()
485
+ return ""
486
+
487
+ def _parsec_name_from_card(card_text: str) -> str:
488
+ lines = [_clean_line(ln) for ln in str(card_text or "").splitlines()]
489
+ lines = [ln for ln in lines if ln]
490
+
491
+ for ln in lines:
492
+ if _is_bad_name_line(ln):
493
+ continue
494
+ fam = _family_from_line(ln)
495
+ if fam:
496
+ return fam
497
+
498
+ # fallback near SKU line
499
+ sku_i = None
500
+ for i, ln in enumerate(lines):
501
+ if "standard sku" in ln.lower():
502
+ sku_i = i
503
+ break
504
+ if sku_i is not None:
505
+ window = lines[max(0, sku_i - 12):sku_i]
506
+ for ln in reversed(window):
507
+ if _is_bad_name_line(ln):
508
+ continue
509
+ if 3 <= len(ln) <= 40 and re.search(r"[A-Za-z]", ln):
510
+ return ln.split()[0].capitalize()
511
+
512
+ return "Parsec antenna"
513
+
514
+ def _parsec_part_from_card(t: str) -> str:
515
+ m = re.search(r"Standard\s+SKU:\s*([A-Z0-9]+)", t)
516
+ return m.group(1).strip() if m else ""
517
+
518
+ def _parsec_desc_from_card(t: str) -> str:
519
+ m = re.search(r"Description:\s*(.+?)(?:\n|$)", t, flags=re.IGNORECASE)
520
+ return re.sub(r"\s+"," ",m.group(1).strip())[:220] if m else ""
521
+
522
+ def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:
523
+ qv = embedder.encode([query], normalize_embeddings=True)
524
+ qv = np.asarray(qv, dtype=np.float32)
525
+ scores, ids = parsec_index.search(qv, top_k)
526
+ out = []
527
+ for sc, i in zip(scores[0].tolist(), ids[0].tolist()):
528
+ if 0 <= int(i) < len(parsec_cards):
529
+ card = parsec_cards[int(i)]
530
+ out.append({
531
+ "score": float(sc),
532
+ "name": _parsec_name_from_card(card),
533
+ "part_number": _parsec_part_from_card(card),
534
+ "description": _parsec_desc_from_card(card),
535
+ })
536
+ return out
537
+
538
+ def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:
539
+ q_stationary = f"{router_model} {tech} {mimo} omni stationary outdoor Parsec"
540
+ q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile Parsec"
541
+ cand_stationary = parsec_retrieve(q_stationary, top_k=10)
542
+ cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)
543
+
544
+ # deterministic fallback if no GPT
545
+ s = cand_stationary[0] if cand_stationary else {"name":"Parsec antenna","part_number":"","description":""}
546
+ v = cand_vehicle[0] if cand_vehicle else {"name":"Parsec antenna","part_number":"","description":""}
547
+ s.update({"mimo": mimo, "why": "Stationary omni best match."})
548
+ v.update({"mimo": mimo, "why": "Vehicle omni best match."})
549
+ return {"stationary_omni": s, "vehicle_omni": v, "sources":["parsec_rag"]}
550
+
551
+
552
+ # ============================
553
+ # Feature table + GPT fill for missing fields
554
+ # ============================
555
+ FEATURE_COLS = ["Name","Modem technology","WiFi","Ports","Antennas","Ruggedness","Use case"]
556
+
557
+ def dec_features_by_model(model: str, canon_make: str) -> Dict[str, str]:
558
+ if not model or model in {"Not applicable","Not listed"}:
559
+ return {k:"Not listed" for k in FEATURE_COLS}
560
+ pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
561
+ if pool.empty:
562
+ return {k:"Not listed" for k in FEATURE_COLS}
563
+ hit = process.extractOne(norm_text(model), pool["_norm_model"].tolist(), scorer=fuzz.WRatio)
564
+ if not hit or hit[1] < MATCH_OK:
565
+ return {k:"Not listed" for k in FEATURE_COLS}
566
+ r = pool.iloc[int(hit[2])]
567
+ ports = f"WAN: {r.get('WAN ports and speed','')} | LAN: {r.get('LAN ports and speed','')}"
568
+ return {
569
+ "Name": str(r.get("Model","")),
570
+ "Modem technology": str(r.get("Modem Type","")),
571
+ "WiFi": str(r.get("WiFi type","")),
572
+ "Ports": ports,
573
+ "Antennas": str(r.get("Antennas (internal/external/both)","")),
574
+ "Ruggedness": str(r.get("Ruggedization","")),
575
+ "Use case": str(r.get("Primary use case","")),
576
+ }
577
+
578
+ def gpt_fill_features(device_label: str, feats: Dict[str,str], context: str) -> Dict[str,str]:
579
+ missing = [k for k,v in feats.items() if (not v) or v.strip().lower() in {"not listed","nan"}]
580
+ if client is None or not missing:
581
+ return feats
582
+ sys = "Fill missing router feature fields. Return strict JSON only."
583
+ payload = {
584
+ "device": device_label,
585
+ "known": feats,
586
+ "context": context[:2000],
587
+ "fill_only": missing,
588
+ "rules": ["Fill only requested fields. Best guess if needed. Return JSON only."],
589
+ "output_schema": {k:"string" for k in missing}
590
+ }
591
+ out = gpt_json(sys, payload, max_tokens=350) or {}
592
+ for k in missing:
593
+ v = str(out.get(k,"") or "").strip()
594
+ if v:
595
+ feats[k] = v
596
+ return feats
597
+
598
+ def current_features_guess(life_row: pd.Series) -> Dict[str,str]:
599
+ sku = str(life_row.get("sku","") or "").strip()
600
+ desc = str(life_row.get("description","") or "").strip()
601
+ notes = str(life_row.get("notes","") or "").strip()
602
+ base = {
603
+ "Name": sku,
604
+ "Modem technology": "4G" if _device_is_4g(life_row) else ("5G" if ("5g" in (desc+notes).lower() or "nr" in (desc+notes).lower()) else "Not listed"),
605
+ "WiFi": "Not listed",
606
+ "Ports": "Not listed",
607
+ "Antennas": "Not listed",
608
+ "Ruggedness": "Not listed",
609
+ "Use case": "Not listed",
610
+ }
611
+ return gpt_fill_features("Current device", base, f"{desc}\n{notes}")
612
+
613
+ def build_features_table(cur: Dict[str,str], r4: Dict[str,str], r5: Dict[str,str]) -> str:
614
+ cols = ["Device", "Modem technology", "WiFi", "Ports", "Antennas", "Ruggedness", "Use case"]
615
+ header = "| " + " | ".join(cols) + " |"
616
+ sep = "| " + " | ".join(["---"]*len(cols)) + " |"
617
+ def row(name: str, feats: Dict[str,str]) -> str:
618
+ return "| " + " | ".join([
619
+ name,
620
+ feats.get("Modem technology","Not listed"),
621
+ feats.get("WiFi","Not listed"),
622
+ feats.get("Ports","Not listed"),
623
+ feats.get("Antennas","Not listed"),
624
+ feats.get("Ruggedness","Not listed"),
625
+ feats.get("Use case","Not listed"),
626
+ ]) + " |"
627
+ return "\n".join([header, sep, row("Current", cur), row("4G alternative", r4), row("5G replacement", r5)])
628
+
629
+
630
+ # ============================
631
+ # Output + Gradio
632
+ # ============================
633
+ def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
634
+ canon_make = str(life_row.get("_canon_make","UNKNOWN"))
635
+ current_name = f"{life_row.get('sku','')} — {life_row.get('description','')}".strip(" —")
636
+
637
+ st = ant.get("stationary_omni", {})
638
+ vh = ant.get("vehicle_omni", {})
639
+
640
+ cur_feats = current_features_guess(life_row)
641
+ r4_feats = dec_features_by_model(repl.get("repl_4g",""), canon_make)
642
+ r5_feats = dec_features_by_model(repl.get("repl_5g",""), canon_make)
643
+
644
+ # If dec doesn't know the model, ask GPT to fill missing cells (best guess)
645
+ if client is not None:
646
+ r4_feats = gpt_fill_features("4G alternative", r4_feats, f"Model: {repl.get('repl_4g','')}\nMake: {canon_make}")
647
+ r5_feats = gpt_fill_features("5G replacement", r5_feats, f"Model: {repl.get('repl_5g','')}\nMake: {canon_make}")
648
+
649
+ table_md = build_features_table(cur_feats, r4_feats, r5_feats)
650
+
651
+ lines = []
652
+ lines.append(f"1. Current device: **{current_name}**")
653
+ lines.append(f"2. Status: **{status}**")
654
+ lines.append(f"3. End of Sale date: **{eos}**")
655
+ lines.append(f"4. End of Life date: **{eol}**")
656
+ lines.append(f"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**")
657
+ lines.append(f"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**")
658
+ lines.append("7. Antenna options (Parsec-only):")
659
+ lines.append(f" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')} — {st.get('why','')}")
660
+ lines.append(f" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')} — {vh.get('why','')}")
661
+ lines.append("8. Recommended features table:")
662
+ lines.append(table_md)
663
+ lines.append("\nSources (debug):")
664
+ for s in repl.get("sources", []) if isinstance(repl.get("sources"), list) else []:
665
+ lines.append(f"- {s}")
666
+ lines.append("- ParsecCatalog.pdf (local RAG)")
667
+ lines.append("- routers_eos_eol_by_sku.csv (replacements)")
668
+ lines.append("- dec2025routers.csv (features)")
669
+ return "\n".join(lines)
670
+
671
+ def run_lookup(user_text: str, st: Dict[str,Any]):
672
+ user_text = str(user_text or "").strip()
673
+ if not user_text:
674
+ return "Enter a router SKU/model.", gr.update(visible=False), gr.update(visible=False), {}
675
+
676
+ res = resolve_device(user_text)
677
+ if res.get("mode") == "pick":
678
+ opts = res.get("options", [])
679
+ choices = [o["label"] for o in opts]
680
+ st2 = {"mode":"pick","options": opts}
681
+ return "Did you mean A or B? Pick one, then click Use selection.", gr.update(choices=choices, value=None, visible=True), gr.update(visible=True), st2
682
+
683
+ if res.get("mode") != "ok":
684
+ return "Not found.", gr.update(visible=False), gr.update(visible=False), {}
685
+
686
+ life_row = df_eos.iloc[int(res["row_idx"])]
687
+ eos, eol, status = row_to_dates_and_status(life_row)
688
+
689
+ repl = pick_replacements_lifecycle(life_row, status)
690
+
691
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") not in {"Not applicable","Not listed"} else ("4G" if _device_is_4g(life_row) else "Unknown")
692
+ mimo_guess = "4x4" if tech == "5G" else "2x2"
693
+ ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo_guess)
694
+
695
+ return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}
696
+
697
+ def use_selection(selected_label: str, st: Dict[str,Any]):
698
+ if not st or st.get("mode") != "pick":
699
+ return "Run a search first.", gr.update(visible=False), gr.update(visible=False), {}
700
+ if not selected_label:
701
+ return "Pick A or B first.", gr.update(visible=True), gr.update(visible=True), st
702
+
703
+ chosen_row = None
704
+ for o in st.get("options", []):
705
+ if o.get("label") == selected_label:
706
+ chosen_row = int(o["row_idx"])
707
+ break
708
+ if chosen_row is None:
709
+ return "Pick a valid option.", gr.update(visible=True), gr.update(visible=True), st
710
+
711
+ life_row = df_eos.iloc[int(chosen_row)]
712
+ eos, eol, status = row_to_dates_and_status(life_row)
713
+ repl = pick_replacements_lifecycle(life_row, status)
714
+ tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") not in {"Not applicable","Not listed"} else ("4G" if _device_is_4g(life_row) else "Unknown")
715
+ mimo_guess = "4x4" if tech == "5G" else "2x2"
716
+ ant = antenna_options_for(router_model=repl.get("repl_5g") or str(life_row.get("sku","")), tech=tech, mimo=mimo_guess)
717
+
718
+ return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}
719
+
720
+ with gr.Blocks(title="Only-Routers") as demo:
721
+ gr.Markdown("## Only-Routers\nEnter a router SKU/model. If ambiguous, you’ll get A/B choices.")
722
+ user_text = gr.Textbox(label="Router SKU or model", placeholder="Examples: IBR650B, AER1600, ES450, WR21, RUT240", lines=1)
723
+ st = gr.State({})
724
+
725
+ check_btn = gr.Button("Check", variant="primary")
726
+ pick_dd = gr.Dropdown(label="Pick A or B", choices=[], visible=False)
727
+ use_btn = gr.Button("Use selection", visible=False)
728
+
729
+ output_md = gr.Markdown()
730
+
731
+ check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st])
732
+ use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st])
733
+
734
+ demo.launch()
only-routers_ai_poc_v4_7_commented.ipynb ADDED
@@ -0,0 +1,1346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "0300079d",
6
+ "metadata": {},
7
+ "source": [
8
+ "**Cell summary:** Section header / instructions.\n",
9
+ "\n",
10
+ "# Only-Routers (v4.6)\n",
11
+ "\n",
12
+ "This notebook mirrors the Hugging Face Spaces `app.py` logic.\n"
13
+ ]
14
+ },
15
+ {
16
+ "cell_type": "code",
17
+ "execution_count": null,
18
+ "id": "39660795",
19
+ "metadata": {},
20
+ "outputs": [],
21
+ "source": [
22
+ "# Cell summary: This cell is part of the Only-Routers notebook; see inline comments for each line.\n",
23
+ "# import os\n",
24
+ "import os\n",
25
+ "# import re\n",
26
+ "import re\n",
27
+ "# import json\n",
28
+ "import json\n",
29
+ "# import math\n",
30
+ "import math\n",
31
+ "# import hashlib\n",
32
+ "import hashlib\n",
33
+ "# from dataclasses import dataclass\n",
34
+ "from dataclasses import dataclass\n",
35
+ "# from datetime import datetime, date\n",
36
+ "from datetime import datetime, date\n",
37
+ "# from typing import Dict, List, Optional, Tuple, Any\n",
38
+ "from typing import Dict, List, Optional, Tuple, Any\n",
39
+ "\n",
40
+ "# import numpy as np\n",
41
+ "import numpy as np\n",
42
+ "# import pandas as pd\n",
43
+ "import pandas as pd\n",
44
+ "\n",
45
+ "# import fitz # PyMuPDF\n",
46
+ "import fitz # PyMuPDF\n",
47
+ "# import faiss\n",
48
+ "import faiss\n",
49
+ "# from sentence_transformers import SentenceTransformer\n",
50
+ "from sentence_transformers import SentenceTransformer\n",
51
+ "# from rapidfuzz import fuzz, process\n",
52
+ "from rapidfuzz import fuzz, process\n",
53
+ "\n",
54
+ "# import gradio as gr\n",
55
+ "import gradio as gr\n",
56
+ "# from openai import OpenAI\n",
57
+ "from openai import OpenAI\n",
58
+ "\n",
59
+ "\n",
60
+ "# ============================\n",
61
+ "# Settings\n",
62
+ "# ============================\n",
63
+ "# TODAY = date(2026, 1, 18)\n",
64
+ "TODAY = date(2026, 1, 18)\n",
65
+ "# OPENAI_MODEL = \"gpt-5.2\"\n",
66
+ "OPENAI_MODEL = \"gpt-5.2\"\n",
67
+ "# OPENAI_REASONING = {\"effort\": \"high\"}\n",
68
+ "OPENAI_REASONING = {\"effort\": \"high\"}\n",
69
+ "\n",
70
+ "# MATCH_OK = 80\n",
71
+ "MATCH_OK = 80\n",
72
+ "# EMBED_MODEL_NAME = \"sentence-transformers/all-MiniLM-L6-v2\"\n",
73
+ "EMBED_MODEL_NAME = \"sentence-transformers/all-MiniLM-L6-v2\"\n",
74
+ "# PARSEC_CONTEXT_BEFORE = 900\n",
75
+ "PARSEC_CONTEXT_BEFORE = 900\n",
76
+ "# PARSEC_CONTEXT_AFTER = 1600\n",
77
+ "PARSEC_CONTEXT_AFTER = 1600\n",
78
+ "\n",
79
+ "# CACHE_DIR = os.path.join(os.getcwd(), \".onlyrouters_cache\")\n",
80
+ "CACHE_DIR = os.path.join(os.getcwd(), \".onlyrouters_cache\")\n",
81
+ "# os.makedirs(CACHE_DIR, exist_ok=True)\n",
82
+ "os.makedirs(CACHE_DIR, exist_ok=True)\n",
83
+ "\n",
84
+ "\n",
85
+ "# ============================\n",
86
+ "# OpenAI client (HF Space secret: OPENAI_API_KEY)\n",
87
+ "# ============================\n",
88
+ "# API_KEY = os.getenv(\"OPENAI_API_KEY\", \"\").strip()\n",
89
+ "API_KEY = os.getenv(\"OPENAI_API_KEY\", \"\").strip()\n",
90
+ "# client = OpenAI(api_key=API_KEY) if API_KEY else None\n",
91
+ "client = OpenAI(api_key=API_KEY) if API_KEY else None\n",
92
+ "\n",
93
+ "\n",
94
+ "# ============================\n",
95
+ "# Utilities\n",
96
+ "# ============================\n",
97
+ "# def norm_text(s: Any) -> str:\n",
98
+ "def norm_text(s: Any) -> str:\n",
99
+ "# try:\n",
100
+ " try:\n",
101
+ "# if s is None or (isinstance(s, float) and math.isnan(s)) or pd.isna(s):\n",
102
+ " if s is None or (isinstance(s, float) and math.isnan(s)) or pd.isna(s):\n",
103
+ "# return \"\"\n",
104
+ " return \"\"\n",
105
+ "# except Exception:\n",
106
+ " except Exception:\n",
107
+ "# pass\n",
108
+ " pass\n",
109
+ "# s = str(s).strip().lower()\n",
110
+ " s = str(s).strip().lower()\n",
111
+ "# s = re.sub(r\"[^a-z0-9\\s\\-\\/]\", \" \", s)\n",
112
+ " s = re.sub(r\"[^a-z0-9\\s\\-\\/]\", \" \", s)\n",
113
+ "# s = re.sub(r\"\\s+\", \" \", s).strip()\n",
114
+ " s = re.sub(r\"\\s+\", \" \", s).strip()\n",
115
+ "# return s\n",
116
+ " return s\n",
117
+ "\n",
118
+ "# def _safe_str(v: Any) -> str:\n",
119
+ "def _safe_str(v: Any) -> str:\n",
120
+ "# if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):\n",
121
+ " if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):\n",
122
+ "# return \"\"\n",
123
+ " return \"\"\n",
124
+ "# return str(v).strip()\n",
125
+ " return str(v).strip()\n",
126
+ "\n",
127
+ "# def _is_5g(modem_type: Any) -> bool:\n",
128
+ "def _is_5g(modem_type: Any) -> bool:\n",
129
+ "# s = norm_text(modem_type)\n",
130
+ " s = norm_text(modem_type)\n",
131
+ "# return (\"5g\" in s) or (\"nr\" in s)\n",
132
+ " return (\"5g\" in s) or (\"nr\" in s)\n",
133
+ "\n",
134
+ "# def _json_load_safe(s: str) -> Dict[str, Any]:\n",
135
+ "def _json_load_safe(s: str) -> Dict[str, Any]:\n",
136
+ "# try:\n",
137
+ " try:\n",
138
+ "# return json.loads(s)\n",
139
+ " return json.loads(s)\n",
140
+ "# except Exception:\n",
141
+ " except Exception:\n",
142
+ "# return {}\n",
143
+ " return {}\n",
144
+ "\n",
145
+ "# def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 700) -> Dict[str, Any]:\n",
146
+ "def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 700) -> Dict[str, Any]:\n",
147
+ "# if client is None:\n",
148
+ " if client is None:\n",
149
+ "# return {}\n",
150
+ " return {}\n",
151
+ "# resp = client.responses.create(\n",
152
+ " resp = client.responses.create(\n",
153
+ "# model=OPENAI_MODEL,\n",
154
+ " model=OPENAI_MODEL,\n",
155
+ "# reasoning=OPENAI_REASONING,\n",
156
+ " reasoning=OPENAI_REASONING,\n",
157
+ "# input=[\n",
158
+ " input=[\n",
159
+ "# {\"role\": \"system\", \"content\": system},\n",
160
+ " {\"role\": \"system\", \"content\": system},\n",
161
+ "# {\"role\": \"user\", \"content\": json.dumps(payload)},\n",
162
+ " {\"role\": \"user\", \"content\": json.dumps(payload)},\n",
163
+ "# ],\n",
164
+ " ],\n",
165
+ "# max_output_tokens=max_tokens,\n",
166
+ " max_output_tokens=max_tokens,\n",
167
+ "# )\n",
168
+ " )\n",
169
+ "# return _json_load_safe(getattr(resp, \"output_text\", \"\") or \"\")\n",
170
+ " return _json_load_safe(getattr(resp, \"output_text\", \"\") or \"\")\n",
171
+ "\n",
172
+ "\n",
173
+ "# ============================\n",
174
+ "# Load data files (must exist in repo)\n",
175
+ "# ============================\n",
176
+ "# EOS_PATH = \"routers_eos_eol_by_sku.csv\"\n",
177
+ "EOS_PATH = \"routers_eos_eol_by_sku.csv\"\n",
178
+ "# DEC_PATH = \"dec2025routers.csv\"\n",
179
+ "DEC_PATH = \"dec2025routers.csv\"\n",
180
+ "# PARSEC_PDF = \"ParsecCatalog.pdf\"\n",
181
+ "PARSEC_PDF = \"ParsecCatalog.pdf\"\n",
182
+ "\n",
183
+ "# if not os.path.exists(EOS_PATH):\n",
184
+ "if not os.path.exists(EOS_PATH):\n",
185
+ "# raise FileNotFoundError(f\"Missing {EOS_PATH} in repo.\")\n",
186
+ " raise FileNotFoundError(f\"Missing {EOS_PATH} in repo.\")\n",
187
+ "# if not os.path.exists(DEC_PATH):\n",
188
+ "if not os.path.exists(DEC_PATH):\n",
189
+ "# raise FileNotFoundError(f\"Missing {DEC_PATH} in repo.\")\n",
190
+ " raise FileNotFoundError(f\"Missing {DEC_PATH} in repo.\")\n",
191
+ "# if not os.path.exists(PARSEC_PDF):\n",
192
+ "if not os.path.exists(PARSEC_PDF):\n",
193
+ "# raise FileNotFoundError(f\"Missing {PARSEC_PDF} in repo.\")\n",
194
+ " raise FileNotFoundError(f\"Missing {PARSEC_PDF} in repo.\")\n",
195
+ "\n",
196
+ "# df_eos = pd.read_csv(EOS_PATH).copy()\n",
197
+ "df_eos = pd.read_csv(EOS_PATH).copy()\n",
198
+ "# df_dec = pd.read_csv(DEC_PATH).copy()\n",
199
+ "df_dec = pd.read_csv(DEC_PATH).copy()\n",
200
+ "\n",
201
+ "# Region filter: keep USA / North America / blank / not specified\n",
202
+ "# def _region_ok(x: Any) -> bool:\n",
203
+ "def _region_ok(x: Any) -> bool:\n",
204
+ "# s = str(x or \"\").strip().lower()\n",
205
+ " s = str(x or \"\").strip().lower()\n",
206
+ "# if not s:\n",
207
+ " if not s:\n",
208
+ "# return True\n",
209
+ " return True\n",
210
+ "# if \"not specified\" in s:\n",
211
+ " if \"not specified\" in s:\n",
212
+ "# return True\n",
213
+ " return True\n",
214
+ "# if \"north america\" in s:\n",
215
+ " if \"north america\" in s:\n",
216
+ "# return True\n",
217
+ " return True\n",
218
+ "# if re.search(r\"\\busa\\b\", s):\n",
219
+ " if re.search(r\"\\busa\\b\", s):\n",
220
+ "# return True\n",
221
+ " return True\n",
222
+ "# if re.search(r\"\\bunited\\s+states\\b\", s):\n",
223
+ " if re.search(r\"\\bunited\\s+states\\b\", s):\n",
224
+ "# return True\n",
225
+ " return True\n",
226
+ "# if re.search(r\"\\bu\\.?s\\.?\\b\", s):\n",
227
+ " if re.search(r\"\\bu\\.?s\\.?\\b\", s):\n",
228
+ "# return True\n",
229
+ " return True\n",
230
+ "# return False\n",
231
+ " return False\n",
232
+ "\n",
233
+ "# if \"region\" in df_eos.columns:\n",
234
+ "if \"region\" in df_eos.columns:\n",
235
+ "# df_eos = df_eos[df_eos[\"region\"].apply(_region_ok)].reset_index(drop=True)\n",
236
+ " df_eos = df_eos[df_eos[\"region\"].apply(_region_ok)].reset_index(drop=True)\n",
237
+ "\n",
238
+ "# Optional \"Device Type\"\n",
239
+ "# device_type_col = None\n",
240
+ "device_type_col = None\n",
241
+ "# for c in df_eos.columns:\n",
242
+ "for c in df_eos.columns:\n",
243
+ "# if norm_text(c) == \"device type\":\n",
244
+ " if norm_text(c) == \"device type\":\n",
245
+ "# device_type_col = c\n",
246
+ " device_type_col = c\n",
247
+ "# break\n",
248
+ " break\n",
249
+ "\n",
250
+ "# Maker mapping (expanded — adds Teltonika)\n",
251
+ "# CANON_MAKER = {\n",
252
+ "CANON_MAKER = {\n",
253
+ "# \"CRADLEPOINT\": {\"cradlepoint\", \"ericsson\", \"ericsson enterprise wireless\"},\n",
254
+ " \"CRADLEPOINT\": {\"cradlepoint\", \"ericsson\", \"ericsson enterprise wireless\"},\n",
255
+ "# \"SIERRA\": {\"sierra\", \"sierra wireless\", \"semtech\", \"airlink\"},\n",
256
+ " \"SIERRA\": {\"sierra\", \"sierra wireless\", \"semtech\", \"airlink\"},\n",
257
+ "# \"FEENEY\": {\"feeney\", \"feeney wireless\", \"inseego\"},\n",
258
+ " \"FEENEY\": {\"feeney\", \"feeney wireless\", \"inseego\"},\n",
259
+ "# \"DIGI\": {\"digi\", \"accelerated\", \"accelerated concepts\"},\n",
260
+ " \"DIGI\": {\"digi\", \"accelerated\", \"accelerated concepts\"},\n",
261
+ "# \"CISCO_MERAKI\": {\"meraki\", \"cisco meraki\"},\n",
262
+ " \"CISCO_MERAKI\": {\"meraki\", \"cisco meraki\"},\n",
263
+ "# \"CISCO\": {\"cisco\"},\n",
264
+ " \"CISCO\": {\"cisco\"},\n",
265
+ "# \"TELTONIKA\": {\"teltonika\"},\n",
266
+ " \"TELTONIKA\": {\"teltonika\"},\n",
267
+ "# }\n",
268
+ "}\n",
269
+ "# DISPLAY_MAKER = {\n",
270
+ "DISPLAY_MAKER = {\n",
271
+ "# \"CRADLEPOINT\": \"Cradlepoint\",\n",
272
+ " \"CRADLEPOINT\": \"Cradlepoint\",\n",
273
+ "# \"SIERRA\": \"Sierra Wireless\",\n",
274
+ " \"SIERRA\": \"Sierra Wireless\",\n",
275
+ "# \"FEENEY\": \"Feeney Wireless\",\n",
276
+ " \"FEENEY\": \"Feeney Wireless\",\n",
277
+ "# \"DIGI\": \"Digi\",\n",
278
+ " \"DIGI\": \"Digi\",\n",
279
+ "# \"CISCO_MERAKI\": \"Cisco Meraki\",\n",
280
+ " \"CISCO_MERAKI\": \"Cisco Meraki\",\n",
281
+ "# \"CISCO\": \"Cisco\",\n",
282
+ " \"CISCO\": \"Cisco\",\n",
283
+ "# \"TELTONIKA\": \"Teltonika\",\n",
284
+ " \"TELTONIKA\": \"Teltonika\",\n",
285
+ "# \"UNKNOWN\": \"Unknown\",\n",
286
+ " \"UNKNOWN\": \"Unknown\",\n",
287
+ "# }\n",
288
+ "}\n",
289
+ "\n",
290
+ "# def canon_maker_from_text(s: Any) -> str:\n",
291
+ "def canon_maker_from_text(s: Any) -> str:\n",
292
+ "# t = norm_text(s)\n",
293
+ " t = norm_text(s)\n",
294
+ "# for canon, terms in CANON_MAKER.items():\n",
295
+ " for canon, terms in CANON_MAKER.items():\n",
296
+ "# for term in terms:\n",
297
+ " for term in terms:\n",
298
+ "# if term in t:\n",
299
+ " if term in t:\n",
300
+ "# return canon\n",
301
+ " return canon\n",
302
+ "# return \"UNKNOWN\"\n",
303
+ " return \"UNKNOWN\"\n",
304
+ "\n",
305
+ "# df_eos[\"_canon_make\"] = df_eos[\"manufacturer\"].apply(canon_maker_from_text) if \"manufacturer\" in df_eos.columns else \"UNKNOWN\"\n",
306
+ "df_eos[\"_canon_make\"] = df_eos[\"manufacturer\"].apply(canon_maker_from_text) if \"manufacturer\" in df_eos.columns else \"UNKNOWN\"\n",
307
+ "# df_eos[\"_norm_sku\"] = df_eos[\"sku\"].apply(norm_text) if \"sku\" in df_eos.columns else \"\"\n",
308
+ "df_eos[\"_norm_sku\"] = df_eos[\"sku\"].apply(norm_text) if \"sku\" in df_eos.columns else \"\"\n",
309
+ "# df_eos[\"_norm_desc\"] = df_eos[\"description\"].apply(norm_text) if \"description\" in df_eos.columns else \"\"\n",
310
+ "df_eos[\"_norm_desc\"] = df_eos[\"description\"].apply(norm_text) if \"description\" in df_eos.columns else \"\"\n",
311
+ "# df_eos[\"_norm_notes\"] = df_eos[\"notes\"].apply(norm_text) if \"notes\" in df_eos.columns else \"\"\n",
312
+ "df_eos[\"_norm_notes\"] = df_eos[\"notes\"].apply(norm_text) if \"notes\" in df_eos.columns else \"\"\n",
313
+ "\n",
314
+ "# df_dec[\"_canon_make\"] = df_dec[\"Make\"].apply(canon_maker_from_text) if \"Make\" in df_dec.columns else \"UNKNOWN\"\n",
315
+ "df_dec[\"_canon_make\"] = df_dec[\"Make\"].apply(canon_maker_from_text) if \"Make\" in df_dec.columns else \"UNKNOWN\"\n",
316
+ "# df_dec[\"_norm_model\"] = df_dec[\"Model\"].apply(norm_text) if \"Model\" in df_dec.columns else \"\"\n",
317
+ "df_dec[\"_norm_model\"] = df_dec[\"Model\"].apply(norm_text) if \"Model\" in df_dec.columns else \"\"\n",
318
+ "# df_dec[\"_is5g\"] = df_dec[\"Modem Type\"].apply(_is_5g) if \"Modem Type\" in df_dec.columns else False\n",
319
+ "df_dec[\"_is5g\"] = df_dec[\"Modem Type\"].apply(_is_5g) if \"Modem Type\" in df_dec.columns else False\n",
320
+ "\n",
321
+ "\n",
322
+ "# ============================\n",
323
+ "# Date helpers\n",
324
+ "# ============================\n",
325
+ "# @dataclass\n",
326
+ "@dataclass\n",
327
+ "# class ParsedDate:\n",
328
+ "class ParsedDate:\n",
329
+ "# raw: str\n",
330
+ " raw: str\n",
331
+ "# kind: str\n",
332
+ " kind: str\n",
333
+ "# value: Optional[date]\n",
334
+ " value: Optional[date]\n",
335
+ "\n",
336
+ "# def parse_date_field(x: Any) -> ParsedDate:\n",
337
+ "def parse_date_field(x: Any) -> ParsedDate:\n",
338
+ "# raw = str(x or \"\").strip()\n",
339
+ " raw = str(x or \"\").strip()\n",
340
+ "# if not raw:\n",
341
+ " if not raw:\n",
342
+ "# return ParsedDate(raw=\"\", kind=\"missing\", value=None)\n",
343
+ " return ParsedDate(raw=\"\", kind=\"missing\", value=None)\n",
344
+ "\n",
345
+ "# if re.fullmatch(r\"\\d{4}\", raw):\n",
346
+ " if re.fullmatch(r\"\\d{4}\", raw):\n",
347
+ "# y = int(raw)\n",
348
+ " y = int(raw)\n",
349
+ "# if y == TODAY.year:\n",
350
+ " if y == TODAY.year:\n",
351
+ "# return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
352
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
353
+ "# if y < TODAY.year:\n",
354
+ " if y < TODAY.year:\n",
355
+ "# return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
356
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
357
+ "# return ParsedDate(raw=raw, kind=\"year\", value=date(y, 12, 31))\n",
358
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 12, 31))\n",
359
+ "\n",
360
+ "# if re.fullmatch(r\"\\d{4}-\\d{2}\", raw):\n",
361
+ " if re.fullmatch(r\"\\d{4}-\\d{2}\", raw):\n",
362
+ "# try:\n",
363
+ " try:\n",
364
+ "# y, m = raw.split(\"-\")\n",
365
+ " y, m = raw.split(\"-\")\n",
366
+ "# return ParsedDate(raw=raw, kind=\"year_month\", value=date(int(y), int(m), 1))\n",
367
+ " return ParsedDate(raw=raw, kind=\"year_month\", value=date(int(y), int(m), 1))\n",
368
+ "# except Exception:\n",
369
+ " except Exception:\n",
370
+ "# return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
371
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
372
+ "\n",
373
+ "# if re.fullmatch(r\"\\d{4}-\\d{2}-\\d{2}\", raw):\n",
374
+ " if re.fullmatch(r\"\\d{4}-\\d{2}-\\d{2}\", raw):\n",
375
+ "# try:\n",
376
+ " try:\n",
377
+ "# dt = datetime.strptime(raw, \"%Y-%m-%d\").date()\n",
378
+ " dt = datetime.strptime(raw, \"%Y-%m-%d\").date()\n",
379
+ "# return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
380
+ " return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
381
+ "# except Exception:\n",
382
+ " except Exception:\n",
383
+ "# return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
384
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
385
+ "\n",
386
+ "# return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
387
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
388
+ "\n",
389
+ "# def display_date(parsed: ParsedDate) -> str:\n",
390
+ "def display_date(parsed: ParsedDate) -> str:\n",
391
+ "# if parsed.kind == \"missing\":\n",
392
+ " if parsed.kind == \"missing\":\n",
393
+ "# return \"Not listed\"\n",
394
+ " return \"Not listed\"\n",
395
+ "# if parsed.kind == \"bad\":\n",
396
+ " if parsed.kind == \"bad\":\n",
397
+ "# return parsed.raw or \"Not listed\"\n",
398
+ " return parsed.raw or \"Not listed\"\n",
399
+ "# return parsed.raw\n",
400
+ " return parsed.raw\n",
401
+ "\n",
402
+ "# def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:\n",
403
+ "def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:\n",
404
+ "# if eos.value is None and eol.value is None:\n",
405
+ " if eos.value is None and eol.value is None:\n",
406
+ "# return \"Unknown\"\n",
407
+ " return \"Unknown\"\n",
408
+ "# if eol.value is not None and eol.value <= TODAY:\n",
409
+ " if eol.value is not None and eol.value <= TODAY:\n",
410
+ "# return \"End of Life\"\n",
411
+ " return \"End of Life\"\n",
412
+ "# if eos.value is not None and eos.value <= TODAY:\n",
413
+ " if eos.value is not None and eos.value <= TODAY:\n",
414
+ "# return \"End of Sale\"\n",
415
+ " return \"End of Sale\"\n",
416
+ "# return \"Active\"\n",
417
+ " return \"Active\"\n",
418
+ "\n",
419
+ "# def row_to_dates_and_status(life_row: pd.Series) -> Tuple[str, str, str]:\n",
420
+ "def row_to_dates_and_status(life_row: pd.Series) -> Tuple[str, str, str]:\n",
421
+ "# eos = parse_date_field(life_row.get(\"end_of_sale\"))\n",
422
+ " eos = parse_date_field(life_row.get(\"end_of_sale\"))\n",
423
+ "# eol = parse_date_field(life_row.get(\"end_of_life\"))\n",
424
+ " eol = parse_date_field(life_row.get(\"end_of_life\"))\n",
425
+ "# return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)\n",
426
+ " return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)\n",
427
+ "\n",
428
+ "\n",
429
+ "# ============================\n",
430
+ "# Embeddings + Parsec index\n",
431
+ "# ============================\n",
432
+ "# embedder = SentenceTransformer(EMBED_MODEL_NAME)\n",
433
+ "embedder = SentenceTransformer(EMBED_MODEL_NAME)\n",
434
+ "\n",
435
+ "# def extract_pdf_text_pages(path: str) -> List[str]:\n",
436
+ "def extract_pdf_text_pages(path: str) -> List[str]:\n",
437
+ "# doc = fitz.open(path)\n",
438
+ " doc = fitz.open(path)\n",
439
+ "# return [doc[i].get_text(\"text\") for i in range(len(doc))]\n",
440
+ " return [doc[i].get_text(\"text\") for i in range(len(doc))]\n",
441
+ "\n",
442
+ "# def build_parsec_cards(pages: List[str]) -> List[str]:\n",
443
+ "def build_parsec_cards(pages: List[str]) -> List[str]:\n",
444
+ "# cards = []\n",
445
+ " cards = []\n",
446
+ "# for p in pages:\n",
447
+ " for p in pages:\n",
448
+ "# for m in re.finditer(r\"Standard\\s+SKU:\", p):\n",
449
+ " for m in re.finditer(r\"Standard\\s+SKU:\", p):\n",
450
+ "# start = max(0, m.start() - PARSEC_CONTEXT_BEFORE)\n",
451
+ " start = max(0, m.start() - PARSEC_CONTEXT_BEFORE)\n",
452
+ "# end = min(len(p), m.start() + PARSEC_CONTEXT_AFTER)\n",
453
+ " end = min(len(p), m.start() + PARSEC_CONTEXT_AFTER)\n",
454
+ "# c = p[start:end].strip()\n",
455
+ " c = p[start:end].strip()\n",
456
+ "# if len(c) >= 200:\n",
457
+ " if len(c) >= 200:\n",
458
+ "# cards.append(c)\n",
459
+ " cards.append(c)\n",
460
+ "# out, seen = [], set()\n",
461
+ " out, seen = [], set()\n",
462
+ "# for c in cards:\n",
463
+ " for c in cards:\n",
464
+ "# h = hashlib.sha1(c.encode(\"utf-8\")).hexdigest()\n",
465
+ " h = hashlib.sha1(c.encode(\"utf-8\")).hexdigest()\n",
466
+ "# if h not in seen:\n",
467
+ " if h not in seen:\n",
468
+ "# seen.add(h); out.append(c)\n",
469
+ " seen.add(h); out.append(c)\n",
470
+ "# return out\n",
471
+ " return out\n",
472
+ "\n",
473
+ "# parsec_cards = build_parsec_cards(extract_pdf_text_pages(PARSEC_PDF))\n",
474
+ "parsec_cards = build_parsec_cards(extract_pdf_text_pages(PARSEC_PDF))\n",
475
+ "# parsec_emb = embedder.encode(parsec_cards, batch_size=64, show_progress_bar=False, normalize_embeddings=True)\n",
476
+ "parsec_emb = embedder.encode(parsec_cards, batch_size=64, show_progress_bar=False, normalize_embeddings=True)\n",
477
+ "# parsec_emb = np.asarray(parsec_emb, dtype=np.float32)\n",
478
+ "parsec_emb = np.asarray(parsec_emb, dtype=np.float32)\n",
479
+ "# parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])\n",
480
+ "parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])\n",
481
+ "# parsec_index.add(parsec_emb)\n",
482
+ "parsec_index.add(parsec_emb)\n",
483
+ "\n",
484
+ "\n",
485
+ "# ============================\n",
486
+ "# Device resolution (exact SKU -> GPT A/B)\n",
487
+ "# ============================\n",
488
+ "# def _label_for_row(i: int) -> str:\n",
489
+ "def _label_for_row(i: int) -> str:\n",
490
+ "# r = df_eos.iloc[i]\n",
491
+ " r = df_eos.iloc[i]\n",
492
+ "# return f\"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}\"[:220]\n",
493
+ " return f\"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}\"[:220]\n",
494
+ "\n",
495
+ "# EOS_LABELS = [_label_for_row(i) for i in range(len(df_eos))]\n",
496
+ "EOS_LABELS = [_label_for_row(i) for i in range(len(df_eos))]\n",
497
+ "# EOS_CORPUS = []\n",
498
+ "EOS_CORPUS = []\n",
499
+ "# for _, r in df_eos.iterrows():\n",
500
+ "for _, r in df_eos.iterrows():\n",
501
+ "# EOS_CORPUS.append(\" \".join([\n",
502
+ " EOS_CORPUS.append(\" \".join([\n",
503
+ "# r.get(\"_norm_sku\",\"\"),\n",
504
+ " r.get(\"_norm_sku\",\"\"),\n",
505
+ "# r.get(\"_canon_make\",\"\"),\n",
506
+ " r.get(\"_canon_make\",\"\"),\n",
507
+ "# r.get(\"_norm_desc\",\"\"),\n",
508
+ " r.get(\"_norm_desc\",\"\"),\n",
509
+ "# r.get(\"_norm_notes\",\"\"),\n",
510
+ " r.get(\"_norm_notes\",\"\"),\n",
511
+ "# ]))\n",
512
+ " ]))\n",
513
+ "\n",
514
+ "# def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int,int,str]]:\n",
515
+ "def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int,int,str]]:\n",
516
+ "# q = norm_text(query)\n",
517
+ " q = norm_text(query)\n",
518
+ "# hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)\n",
519
+ " hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)\n",
520
+ "# return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]\n",
521
+ " return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]\n",
522
+ "\n",
523
+ "# def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> Dict[str, Any]:\n",
524
+ "def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> Dict[str, Any]:\n",
525
+ "# if client is None:\n",
526
+ " if client is None:\n",
527
+ "# return {}\n",
528
+ " return {}\n",
529
+ "# sys = \"Pick which router the user meant. Never invent. Return strict JSON only.\"\n",
530
+ " sys = \"Pick which router the user meant. Never invent. Return strict JSON only.\"\n",
531
+ "# payload = {\n",
532
+ " payload = {\n",
533
+ "# \"user_input\": user_text,\n",
534
+ " \"user_input\": user_text,\n",
535
+ "# \"candidates\": [{\"row_idx\": i, \"score\": s, \"label\": lbl} for (i,s,lbl) in candidates],\n",
536
+ " \"candidates\": [{\"row_idx\": i, \"score\": s, \"label\": lbl} for (i,s,lbl) in candidates],\n",
537
+ "# \"rules\": [\n",
538
+ " \"rules\": [\n",
539
+ "# \"If one candidate is clearly correct, return mode='ok' with row_idx.\",\n",
540
+ " \"If one candidate is clearly correct, return mode='ok' with row_idx.\",\n",
541
+ "# \"If two are plausible, return mode='pick' with top 2 options.\"\n",
542
+ " \"If two are plausible, return mode='pick' with top 2 options.\"\n",
543
+ "# ],\n",
544
+ " ],\n",
545
+ "# \"output_schema\": {\"mode\":\"ok|pick\",\"row_idx\":\"int\",\"options\":[{\"row_idx\":\"int\",\"label\":\"string\"}]}\n",
546
+ " \"output_schema\": {\"mode\":\"ok|pick\",\"row_idx\":\"int\",\"options\":[{\"row_idx\":\"int\",\"label\":\"string\"}]}\n",
547
+ "# }\n",
548
+ " }\n",
549
+ "# return gpt_json(sys, payload, max_tokens=300)\n",
550
+ " return gpt_json(sys, payload, max_tokens=300)\n",
551
+ "\n",
552
+ "# def resolve_device(user_text: str) -> Dict[str, Any]:\n",
553
+ "def resolve_device(user_text: str) -> Dict[str, Any]:\n",
554
+ "# q = norm_text(user_text)\n",
555
+ " q = norm_text(user_text)\n",
556
+ "# exact_idxs = df_eos.index[df_eos[\"_norm_sku\"] == q].tolist()\n",
557
+ " exact_idxs = df_eos.index[df_eos[\"_norm_sku\"] == q].tolist()\n",
558
+ "# if len(exact_idxs) == 1:\n",
559
+ " if len(exact_idxs) == 1:\n",
560
+ "# return {\"mode\":\"ok\",\"row_idx\": int(exact_idxs[0])}\n",
561
+ " return {\"mode\":\"ok\",\"row_idx\": int(exact_idxs[0])}\n",
562
+ "# if len(exact_idxs) > 1:\n",
563
+ " if len(exact_idxs) > 1:\n",
564
+ "# opts = [{\"row_idx\": int(i), \"label\": EOS_LABELS[int(i)]} for i in exact_idxs[:2]]\n",
565
+ " opts = [{\"row_idx\": int(i), \"label\": EOS_LABELS[int(i)]} for i in exact_idxs[:2]]\n",
566
+ "# return {\"mode\":\"pick\",\"options\": opts}\n",
567
+ " return {\"mode\":\"pick\",\"options\": opts}\n",
568
+ "\n",
569
+ "# cands = local_candidates(user_text, top_k=6)\n",
570
+ " cands = local_candidates(user_text, top_k=6)\n",
571
+ "# if not cands:\n",
572
+ " if not cands:\n",
573
+ "# return {\"mode\":\"not_found\"}\n",
574
+ " return {\"mode\":\"not_found\"}\n",
575
+ "\n",
576
+ "# if cands[0][1] >= 95 and (len(cands) == 1 or (cands[0][1] - cands[1][1]) >= 8):\n",
577
+ " if cands[0][1] >= 95 and (len(cands) == 1 or (cands[0][1] - cands[1][1]) >= 8):\n",
578
+ "# return {\"mode\":\"ok\",\"row_idx\": cands[0][0]}\n",
579
+ " return {\"mode\":\"ok\",\"row_idx\": cands[0][0]}\n",
580
+ "\n",
581
+ "# g = gpt_choose_device(user_text, cands)\n",
582
+ " g = gpt_choose_device(user_text, cands)\n",
583
+ "# if g.get(\"mode\") == \"ok\" and isinstance(g.get(\"row_idx\"), int):\n",
584
+ " if g.get(\"mode\") == \"ok\" and isinstance(g.get(\"row_idx\"), int):\n",
585
+ "# return {\"mode\":\"ok\",\"row_idx\": int(g[\"row_idx\"])}\n",
586
+ " return {\"mode\":\"ok\",\"row_idx\": int(g[\"row_idx\"])}\n",
587
+ "\n",
588
+ "# if g.get(\"mode\") == \"pick\":\n",
589
+ " if g.get(\"mode\") == \"pick\":\n",
590
+ "# opts = g.get(\"options\", []) or []\n",
591
+ " opts = g.get(\"options\", []) or []\n",
592
+ "# opts2 = [{\"row_idx\": int(o[\"row_idx\"]), \"label\": str(o[\"label\"])} for o in opts[:2] if \"row_idx\" in o]\n",
593
+ " opts2 = [{\"row_idx\": int(o[\"row_idx\"]), \"label\": str(o[\"label\"])} for o in opts[:2] if \"row_idx\" in o]\n",
594
+ "# if opts2:\n",
595
+ " if opts2:\n",
596
+ "# return {\"mode\":\"pick\",\"options\": opts2}\n",
597
+ " return {\"mode\":\"pick\",\"options\": opts2}\n",
598
+ "\n",
599
+ " # fallback\n",
600
+ "# if len(cands) > 1:\n",
601
+ " if len(cands) > 1:\n",
602
+ "# return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]},{\"row_idx\":cands[1][0],\"label\":cands[1][2]}]}\n",
603
+ " return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]},{\"row_idx\":cands[1][0],\"label\":cands[1][2]}]}\n",
604
+ "# return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]}]}\n",
605
+ " return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]}]}\n",
606
+ "\n",
607
+ "\n",
608
+ "# ============================\n",
609
+ "# Replacements — lifecycle CSV is source of truth\n",
610
+ "# Fix: always show 4G alternative if lifecycle suggests it (even if Active)\n",
611
+ "# ============================\n",
612
+ "# def _extract_model_token(text: str) -> str:\n",
613
+ "def _extract_model_token(text: str) -> str:\n",
614
+ "# s = _safe_str(text)\n",
615
+ " s = _safe_str(text)\n",
616
+ "# if not s:\n",
617
+ " if not s:\n",
618
+ "# return \"\"\n",
619
+ " return \"\"\n",
620
+ "# parts = [p.strip() for p in s.split(\"|\") if p.strip()]\n",
621
+ " parts = [p.strip() for p in s.split(\"|\") if p.strip()]\n",
622
+ "# candidates = parts[::-1] if parts else [s]\n",
623
+ " candidates = parts[::-1] if parts else [s]\n",
624
+ "\n",
625
+ "# for cand in candidates:\n",
626
+ " for cand in candidates:\n",
627
+ " # Teltonika family\n",
628
+ "# m = re.search(r\"\\bRUT[A-Z]?\\d{2,4}\\b\", cand.upper())\n",
629
+ " m = re.search(r\"\\bRUT[A-Z]?\\d{2,4}\\b\", cand.upper())\n",
630
+ "# if m:\n",
631
+ " if m:\n",
632
+ "# return m.group(0).upper()\n",
633
+ " return m.group(0).upper()\n",
634
+ " # Digi IX-series\n",
635
+ "# m = re.search(r\"\\bIX\\d{2}\\b\", cand, flags=re.IGNORECASE)\n",
636
+ " m = re.search(r\"\\bIX\\d{2}\\b\", cand, flags=re.IGNORECASE)\n",
637
+ "# if m:\n",
638
+ " if m:\n",
639
+ "# return m.group(0).upper()\n",
640
+ " return m.group(0).upper()\n",
641
+ " # Cradlepoint R/E/S\n",
642
+ "# m = re.search(r\"\\b(R\\d{3,4}|E\\d{3,4}|S\\d{3,4})\\b\", cand, flags=re.IGNORECASE)\n",
643
+ " m = re.search(r\"\\b(R\\d{3,4}|E\\d{3,4}|S\\d{3,4})\\b\", cand, flags=re.IGNORECASE)\n",
644
+ "# if m:\n",
645
+ " if m:\n",
646
+ "# return m.group(0).upper()\n",
647
+ " return m.group(0).upper()\n",
648
+ " # Generic model token\n",
649
+ "# m = re.search(r\"\\b[A-Z]{1,6}\\d{2,4}[A-Z]?\\b\", cand.upper())\n",
650
+ " m = re.search(r\"\\b[A-Z]{1,6}\\d{2,4}[A-Z]?\\b\", cand.upper())\n",
651
+ "# if m:\n",
652
+ " if m:\n",
653
+ "# return m.group(0).upper()\n",
654
+ " return m.group(0).upper()\n",
655
+ "\n",
656
+ "# return candidates[0][:60]\n",
657
+ " return candidates[0][:60]\n",
658
+ "\n",
659
+ "# def _device_is_4g(life_row: pd.Series) -> bool:\n",
660
+ "def _device_is_4g(life_row: pd.Series) -> bool:\n",
661
+ "# t = norm_text(life_row.get(\"description\",\"\")) + \" \" + norm_text(life_row.get(\"notes\",\"\"))\n",
662
+ " t = norm_text(life_row.get(\"description\",\"\")) + \" \" + norm_text(life_row.get(\"notes\",\"\"))\n",
663
+ "# return ((\"lte\" in t or \"4g\" in t) and (\"5g\" not in t and \"nr\" not in t))\n",
664
+ " return ((\"lte\" in t or \"4g\" in t) and (\"5g\" not in t and \"nr\" not in t))\n",
665
+ "\n",
666
+ "# def _candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
667
+ "def _candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
668
+ " # Pool within same manufacturer text (not just canon) to support Teltonika etc\n",
669
+ "# mfr = norm_text(manufacturer)\n",
670
+ " mfr = norm_text(manufacturer)\n",
671
+ "# pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
672
+ " pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
673
+ "# vals = pool[\"advanced_5g_option\"].tolist() if \"advanced_5g_option\" in pool.columns else []\n",
674
+ " vals = pool[\"advanced_5g_option\"].tolist() if \"advanced_5g_option\" in pool.columns else []\n",
675
+ "# out, seen = [], set()\n",
676
+ " out, seen = [], set()\n",
677
+ "# for v in vals:\n",
678
+ " for v in vals:\n",
679
+ "# tok = _extract_model_token(v)\n",
680
+ " tok = _extract_model_token(v)\n",
681
+ "# if tok and tok.lower() != \"nan\" and tok not in seen:\n",
682
+ " if tok and tok.lower() != \"nan\" and tok not in seen:\n",
683
+ "# seen.add(tok); out.append(tok)\n",
684
+ " seen.add(tok); out.append(tok)\n",
685
+ "# return out\n",
686
+ " return out\n",
687
+ "\n",
688
+ "# def _candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
689
+ "def _candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
690
+ "# mfr = norm_text(manufacturer)\n",
691
+ " mfr = norm_text(manufacturer)\n",
692
+ "# pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
693
+ " pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
694
+ "# vals = pool[\"suggested_replacement\"].tolist() if \"suggested_replacement\" in pool.columns else []\n",
695
+ " vals = pool[\"suggested_replacement\"].tolist() if \"suggested_replacement\" in pool.columns else []\n",
696
+ "# out, seen = [], set()\n",
697
+ " out, seen = [], set()\n",
698
+ "# for v in vals:\n",
699
+ " for v in vals:\n",
700
+ "# tok = _extract_model_token(v)\n",
701
+ " tok = _extract_model_token(v)\n",
702
+ "# if tok and tok.lower() != \"nan\" and tok not in seen:\n",
703
+ " if tok and tok.lower() != \"nan\" and tok not in seen:\n",
704
+ "# seen.add(tok); out.append(tok)\n",
705
+ " seen.add(tok); out.append(tok)\n",
706
+ "# return out\n",
707
+ " return out\n",
708
+ "\n",
709
+ "# def _gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:\n",
710
+ "def _gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:\n",
711
+ "# if client is None or not candidates:\n",
712
+ " if client is None or not candidates:\n",
713
+ "# return \"\"\n",
714
+ " return \"\"\n",
715
+ "# sys = \"Pick the best replacement model. Choose only from candidates. Return strict JSON only.\"\n",
716
+ " sys = \"Pick the best replacement model. Choose only from candidates. Return strict JSON only.\"\n",
717
+ "# payload = {\n",
718
+ " payload = {\n",
719
+ "# \"old_device\": {\n",
720
+ " \"old_device\": {\n",
721
+ "# \"sku\": str(old_row.get(\"sku\",\"\")),\n",
722
+ " \"sku\": str(old_row.get(\"sku\",\"\")),\n",
723
+ "# \"manufacturer\": str(old_row.get(\"manufacturer\",\"\")),\n",
724
+ " \"manufacturer\": str(old_row.get(\"manufacturer\",\"\")),\n",
725
+ "# \"description\": str(old_row.get(\"description\",\"\")),\n",
726
+ " \"description\": str(old_row.get(\"description\",\"\")),\n",
727
+ "# \"need\": need,\n",
728
+ " \"need\": need,\n",
729
+ "# },\n",
730
+ " },\n",
731
+ "# \"candidates\": candidates[:40],\n",
732
+ " \"candidates\": candidates[:40],\n",
733
+ "# \"output_schema\": {\"choice\":\"string\"}\n",
734
+ " \"output_schema\": {\"choice\":\"string\"}\n",
735
+ "# }\n",
736
+ " }\n",
737
+ "# out = gpt_json(sys, payload, max_tokens=240) or {}\n",
738
+ " out = gpt_json(sys, payload, max_tokens=240) or {}\n",
739
+ "# choice = str(out.get(\"choice\",\"\") or \"\").strip()\n",
740
+ " choice = str(out.get(\"choice\",\"\") or \"\").strip()\n",
741
+ "# return choice if choice in candidates else \"\"\n",
742
+ " return choice if choice in candidates else \"\"\n",
743
+ "\n",
744
+ "# def _fallback_5g_from_dec(canon_make: str) -> str:\n",
745
+ "def _fallback_5g_from_dec(canon_make: str) -> str:\n",
746
+ "# pool5 = df_dec[(df_dec[\"_canon_make\"] == canon_make) & (df_dec[\"_is5g\"] == True)]\n",
747
+ " pool5 = df_dec[(df_dec[\"_canon_make\"] == canon_make) & (df_dec[\"_is5g\"] == True)]\n",
748
+ "# return str(pool5.iloc[0][\"Model\"]).strip() if not pool5.empty else \"\"\n",
749
+ " return str(pool5.iloc[0][\"Model\"]).strip() if not pool5.empty else \"\"\n",
750
+ "\n",
751
+ "# def pick_replacements_lifecycle(life_row: pd.Series, status: str) -> Dict[str, Any]:\n",
752
+ "def pick_replacements_lifecycle(life_row: pd.Series, status: str) -> Dict[str, Any]:\n",
753
+ "# canon = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
754
+ " canon = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
755
+ "# manufacturer = str(life_row.get(\"manufacturer\",\"\") or \"\")\n",
756
+ " manufacturer = str(life_row.get(\"manufacturer\",\"\") or \"\")\n",
757
+ "\n",
758
+ "# is_4g_device = _device_is_4g(life_row)\n",
759
+ " is_4g_device = _device_is_4g(life_row)\n",
760
+ "# needs_4g_repl = is_4g_device and (status in {\"End of Sale\",\"End of Life\"})\n",
761
+ " needs_4g_repl = is_4g_device and (status in {\"End of Sale\",\"End of Life\"})\n",
762
+ "# want_5g = is_4g_device or (status in {\"End of Sale\",\"End of Life\"})\n",
763
+ " want_5g = is_4g_device or (status in {\"End of Sale\",\"End of Life\"})\n",
764
+ "\n",
765
+ " # 4G alternative: ALWAYS if suggested_replacement exists for 4G devices\n",
766
+ "# repl_4g = \"Not applicable\"\n",
767
+ " repl_4g = \"Not applicable\"\n",
768
+ "# if is_4g_device:\n",
769
+ " if is_4g_device:\n",
770
+ "# repl_4g = _extract_model_token(_safe_str(life_row.get(\"suggested_replacement\",\"\")))\n",
771
+ " repl_4g = _extract_model_token(_safe_str(life_row.get(\"suggested_replacement\",\"\")))\n",
772
+ "# if not repl_4g:\n",
773
+ " if not repl_4g:\n",
774
+ "# cand4 = _candidate_4g_models_from_lifecycle(manufacturer)\n",
775
+ " cand4 = _candidate_4g_models_from_lifecycle(manufacturer)\n",
776
+ "# repl_4g = _gpt_pick_from_candidates(life_row, cand4, \"4G alternative\") or (cand4[0] if cand4 else \"\")\n",
777
+ " repl_4g = _gpt_pick_from_candidates(life_row, cand4, \"4G alternative\") or (cand4[0] if cand4 else \"\")\n",
778
+ "# if not repl_4g:\n",
779
+ " if not repl_4g:\n",
780
+ "# repl_4g = \"Not applicable\"\n",
781
+ " repl_4g = \"Not applicable\"\n",
782
+ "\n",
783
+ " # 5G replacement: ALWAYS when want_5g is true\n",
784
+ "# repl_5g = \"Not applicable\"\n",
785
+ " repl_5g = \"Not applicable\"\n",
786
+ "# if want_5g:\n",
787
+ " if want_5g:\n",
788
+ "# repl_5g = _extract_model_token(_safe_str(life_row.get(\"advanced_5g_option\",\"\")))\n",
789
+ " repl_5g = _extract_model_token(_safe_str(life_row.get(\"advanced_5g_option\",\"\")))\n",
790
+ "# if not repl_5g:\n",
791
+ " if not repl_5g:\n",
792
+ "# cand5 = _candidate_5g_models_from_lifecycle(manufacturer)\n",
793
+ " cand5 = _candidate_5g_models_from_lifecycle(manufacturer)\n",
794
+ "# repl_5g = _gpt_pick_from_candidates(life_row, cand5, \"5G replacement/upgrade\") or (cand5[0] if cand5 else \"\")\n",
795
+ " repl_5g = _gpt_pick_from_candidates(life_row, cand5, \"5G replacement/upgrade\") or (cand5[0] if cand5 else \"\")\n",
796
+ "# if not repl_5g:\n",
797
+ " if not repl_5g:\n",
798
+ " # last resort: dec catalog fallback\n",
799
+ "# repl_5g = _fallback_5g_from_dec(canon)\n",
800
+ " repl_5g = _fallback_5g_from_dec(canon)\n",
801
+ "\n",
802
+ "# if repl_5g.lower() == \"nan\":\n",
803
+ " if repl_5g.lower() == \"nan\":\n",
804
+ "# repl_5g = \"\"\n",
805
+ " repl_5g = \"\"\n",
806
+ "\n",
807
+ "# return {\n",
808
+ " return {\n",
809
+ "# \"repl_4g\": repl_4g,\n",
810
+ " \"repl_4g\": repl_4g,\n",
811
+ "# \"repl_5g\": repl_5g,\n",
812
+ " \"repl_5g\": repl_5g,\n",
813
+ "# \"why\": \"Lifecycle replacements (GPT fallback when missing).\",\n",
814
+ " \"why\": \"Lifecycle replacements (GPT fallback when missing).\",\n",
815
+ "# \"sources\": [\"lifecycle_csv\"] + ([\"gpt\"] if client else []) + ([\"dec_fallback\"] if (want_5g and not repl_5g) else []),\n",
816
+ " \"sources\": [\"lifecycle_csv\"] + ([\"gpt\"] if client else []) + ([\"dec_fallback\"] if (want_5g and not repl_5g) else []),\n",
817
+ "# }\n",
818
+ " }\n",
819
+ "\n",
820
+ "\n",
821
+ "# ============================\n",
822
+ "# Antennas (Parsec-only; family name extraction)\n",
823
+ "# ============================\n",
824
+ "# PARSEC_FAMILY_WORDS = {\n",
825
+ "PARSEC_FAMILY_WORDS = {\n",
826
+ "# \"chinook\",\"labrador\",\"boxer\",\"bloodhound\",\"husky\",\"beagle\",\"mastiff\",\"collie\",\n",
827
+ " \"chinook\",\"labrador\",\"boxer\",\"bloodhound\",\"husky\",\"beagle\",\"mastiff\",\"collie\",\n",
828
+ "# \"shepherd\",\"belgian\",\"australian\",\"terrier\",\"pyrenees\"\n",
829
+ " \"shepherd\",\"belgian\",\"australian\",\"terrier\",\"pyrenees\"\n",
830
+ "# }\n",
831
+ "}\n",
832
+ "# BAD_NAME_MARKERS = {\n",
833
+ "BAD_NAME_MARKERS = {\n",
834
+ "# \"customization\", \"standard connectors\", \"connectors\", \"features\", \"benefits\",\n",
835
+ " \"customization\", \"standard connectors\", \"connectors\", \"features\", \"benefits\",\n",
836
+ "# \"specifications\", \"mechanical\", \"electrical\", \"mounting\", \"accessories\",\n",
837
+ " \"specifications\", \"mechanical\", \"electrical\", \"mounting\", \"accessories\",\n",
838
+ "# \"description:\", \"standard sku\"\n",
839
+ " \"description:\", \"standard sku\"\n",
840
+ "# }\n",
841
+ "}\n",
842
+ "\n",
843
+ "# def _clean_line(s: str) -> str:\n",
844
+ "def _clean_line(s: str) -> str:\n",
845
+ "# s = re.sub(r\"\\s+\", \" \", str(s or \"\").strip())\n",
846
+ " s = re.sub(r\"\\s+\", \" \", str(s or \"\").strip())\n",
847
+ "# if re.fullmatch(r\"-[a-z0-9]+\", s.lower()):\n",
848
+ " if re.fullmatch(r\"-[a-z0-9]+\", s.lower()):\n",
849
+ "# return \"\"\n",
850
+ " return \"\"\n",
851
+ "# return s\n",
852
+ " return s\n",
853
+ "\n",
854
+ "# def _is_bad_name_line(line: str) -> bool:\n",
855
+ "def _is_bad_name_line(line: str) -> bool:\n",
856
+ "# low = line.lower()\n",
857
+ " low = line.lower()\n",
858
+ "# if any(m in low for m in BAD_NAME_MARKERS):\n",
859
+ " if any(m in low for m in BAD_NAME_MARKERS):\n",
860
+ "# return True\n",
861
+ " return True\n",
862
+ "# if re.search(r\"\\b-[a-z0-9]{1,4}\\b\", low) and len(low) <= 25:\n",
863
+ " if re.search(r\"\\b-[a-z0-9]{1,4}\\b\", low) and len(low) <= 25:\n",
864
+ "# return True\n",
865
+ " return True\n",
866
+ "# return False\n",
867
+ " return False\n",
868
+ "\n",
869
+ "# def _family_from_line(line: str) -> str:\n",
870
+ "def _family_from_line(line: str) -> str:\n",
871
+ "# low = line.lower()\n",
872
+ " low = line.lower()\n",
873
+ "# for fam in PARSEC_FAMILY_WORDS:\n",
874
+ " for fam in PARSEC_FAMILY_WORDS:\n",
875
+ "# if fam in low:\n",
876
+ " if fam in low:\n",
877
+ "# return fam.capitalize()\n",
878
+ " return fam.capitalize()\n",
879
+ "# return \"\"\n",
880
+ " return \"\"\n",
881
+ "\n",
882
+ "# def _parsec_name_from_card(card_text: str) -> str:\n",
883
+ "def _parsec_name_from_card(card_text: str) -> str:\n",
884
+ "# lines = [_clean_line(ln) for ln in str(card_text or \"\").splitlines()]\n",
885
+ " lines = [_clean_line(ln) for ln in str(card_text or \"\").splitlines()]\n",
886
+ "# lines = [ln for ln in lines if ln]\n",
887
+ " lines = [ln for ln in lines if ln]\n",
888
+ "\n",
889
+ "# for ln in lines:\n",
890
+ " for ln in lines:\n",
891
+ "# if _is_bad_name_line(ln):\n",
892
+ " if _is_bad_name_line(ln):\n",
893
+ "# continue\n",
894
+ " continue\n",
895
+ "# fam = _family_from_line(ln)\n",
896
+ " fam = _family_from_line(ln)\n",
897
+ "# if fam:\n",
898
+ " if fam:\n",
899
+ "# return fam\n",
900
+ " return fam\n",
901
+ "\n",
902
+ " # fallback near SKU line\n",
903
+ "# sku_i = None\n",
904
+ " sku_i = None\n",
905
+ "# for i, ln in enumerate(lines):\n",
906
+ " for i, ln in enumerate(lines):\n",
907
+ "# if \"standard sku\" in ln.lower():\n",
908
+ " if \"standard sku\" in ln.lower():\n",
909
+ "# sku_i = i\n",
910
+ " sku_i = i\n",
911
+ "# break\n",
912
+ " break\n",
913
+ "# if sku_i is not None:\n",
914
+ " if sku_i is not None:\n",
915
+ "# window = lines[max(0, sku_i - 12):sku_i]\n",
916
+ " window = lines[max(0, sku_i - 12):sku_i]\n",
917
+ "# for ln in reversed(window):\n",
918
+ " for ln in reversed(window):\n",
919
+ "# if _is_bad_name_line(ln):\n",
920
+ " if _is_bad_name_line(ln):\n",
921
+ "# continue\n",
922
+ " continue\n",
923
+ "# if 3 <= len(ln) <= 40 and re.search(r\"[A-Za-z]\", ln):\n",
924
+ " if 3 <= len(ln) <= 40 and re.search(r\"[A-Za-z]\", ln):\n",
925
+ "# return ln.split()[0].capitalize()\n",
926
+ " return ln.split()[0].capitalize()\n",
927
+ "\n",
928
+ "# return \"Parsec antenna\"\n",
929
+ " return \"Parsec antenna\"\n",
930
+ "\n",
931
+ "# def _parsec_part_from_card(t: str) -> str:\n",
932
+ "def _parsec_part_from_card(t: str) -> str:\n",
933
+ "# m = re.search(r\"Standard\\s+SKU:\\s*([A-Z0-9]+)\", t)\n",
934
+ " m = re.search(r\"Standard\\s+SKU:\\s*([A-Z0-9]+)\", t)\n",
935
+ "# return m.group(1).strip() if m else \"\"\n",
936
+ " return m.group(1).strip() if m else \"\"\n",
937
+ "\n",
938
+ "# def _parsec_desc_from_card(t: str) -> str:\n",
939
+ "def _parsec_desc_from_card(t: str) -> str:\n",
940
+ "# m = re.search(r\"Description:\\s*(.+?)(?:\\n|$)\", t, flags=re.IGNORECASE)\n",
941
+ " m = re.search(r\"Description:\\s*(.+?)(?:\\n|$)\", t, flags=re.IGNORECASE)\n",
942
+ "# return re.sub(r\"\\s+\",\" \",m.group(1).strip())[:220] if m else \"\"\n",
943
+ " return re.sub(r\"\\s+\",\" \",m.group(1).strip())[:220] if m else \"\"\n",
944
+ "\n",
945
+ "# def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:\n",
946
+ "def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:\n",
947
+ "# qv = embedder.encode([query], normalize_embeddings=True)\n",
948
+ " qv = embedder.encode([query], normalize_embeddings=True)\n",
949
+ "# qv = np.asarray(qv, dtype=np.float32)\n",
950
+ " qv = np.asarray(qv, dtype=np.float32)\n",
951
+ "# scores, ids = parsec_index.search(qv, top_k)\n",
952
+ " scores, ids = parsec_index.search(qv, top_k)\n",
953
+ "# out = []\n",
954
+ " out = []\n",
955
+ "# for sc, i in zip(scores[0].tolist(), ids[0].tolist()):\n",
956
+ " for sc, i in zip(scores[0].tolist(), ids[0].tolist()):\n",
957
+ "# if 0 <= int(i) < len(parsec_cards):\n",
958
+ " if 0 <= int(i) < len(parsec_cards):\n",
959
+ "# card = parsec_cards[int(i)]\n",
960
+ " card = parsec_cards[int(i)]\n",
961
+ "# out.append({\n",
962
+ " out.append({\n",
963
+ "# \"score\": float(sc),\n",
964
+ " \"score\": float(sc),\n",
965
+ "# \"name\": _parsec_name_from_card(card),\n",
966
+ " \"name\": _parsec_name_from_card(card),\n",
967
+ "# \"part_number\": _parsec_part_from_card(card),\n",
968
+ " \"part_number\": _parsec_part_from_card(card),\n",
969
+ "# \"description\": _parsec_desc_from_card(card),\n",
970
+ " \"description\": _parsec_desc_from_card(card),\n",
971
+ "# })\n",
972
+ " })\n",
973
+ "# return out\n",
974
+ " return out\n",
975
+ "\n",
976
+ "# def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:\n",
977
+ "def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:\n",
978
+ "# q_stationary = f\"{router_model} {tech} {mimo} omni stationary outdoor Parsec\"\n",
979
+ " q_stationary = f\"{router_model} {tech} {mimo} omni stationary outdoor Parsec\"\n",
980
+ "# q_vehicle = f\"{router_model} {tech} {mimo} omni vehicle mobile Parsec\"\n",
981
+ " q_vehicle = f\"{router_model} {tech} {mimo} omni vehicle mobile Parsec\"\n",
982
+ "# cand_stationary = parsec_retrieve(q_stationary, top_k=10)\n",
983
+ " cand_stationary = parsec_retrieve(q_stationary, top_k=10)\n",
984
+ "# cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)\n",
985
+ " cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)\n",
986
+ "\n",
987
+ " # deterministic fallback if no GPT\n",
988
+ "# s = cand_stationary[0] if cand_stationary else {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\"}\n",
989
+ " s = cand_stationary[0] if cand_stationary else {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\"}\n",
990
+ "# v = cand_vehicle[0] if cand_vehicle else {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\"}\n",
991
+ " v = cand_vehicle[0] if cand_vehicle else {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\"}\n",
992
+ "# s.update({\"mimo\": mimo, \"why\": \"Stationary omni best match.\"})\n",
993
+ " s.update({\"mimo\": mimo, \"why\": \"Stationary omni best match.\"})\n",
994
+ "# v.update({\"mimo\": mimo, \"why\": \"Vehicle omni best match.\"})\n",
995
+ " v.update({\"mimo\": mimo, \"why\": \"Vehicle omni best match.\"})\n",
996
+ "# return {\"stationary_omni\": s, \"vehicle_omni\": v, \"sources\":[\"parsec_rag\"]}\n",
997
+ " return {\"stationary_omni\": s, \"vehicle_omni\": v, \"sources\":[\"parsec_rag\"]}\n",
998
+ "\n",
999
+ "\n",
1000
+ "# ============================\n",
1001
+ "# Feature table + GPT fill for missing fields\n",
1002
+ "# ============================\n",
1003
+ "# FEATURE_COLS = [\"Name\",\"Modem technology\",\"WiFi\",\"Ports\",\"Antennas\",\"Ruggedness\",\"Use case\"]\n",
1004
+ "FEATURE_COLS = [\"Name\",\"Modem technology\",\"WiFi\",\"Ports\",\"Antennas\",\"Ruggedness\",\"Use case\"]\n",
1005
+ "\n",
1006
+ "# def dec_features_by_model(model: str, canon_make: str) -> Dict[str, str]:\n",
1007
+ "def dec_features_by_model(model: str, canon_make: str) -> Dict[str, str]:\n",
1008
+ "# if not model or model in {\"Not applicable\",\"Not listed\"}:\n",
1009
+ " if not model or model in {\"Not applicable\",\"Not listed\"}:\n",
1010
+ "# return {k:\"Not listed\" for k in FEATURE_COLS}\n",
1011
+ " return {k:\"Not listed\" for k in FEATURE_COLS}\n",
1012
+ "# pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
1013
+ " pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
1014
+ "# if pool.empty:\n",
1015
+ " if pool.empty:\n",
1016
+ "# return {k:\"Not listed\" for k in FEATURE_COLS}\n",
1017
+ " return {k:\"Not listed\" for k in FEATURE_COLS}\n",
1018
+ "# hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
1019
+ " hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
1020
+ "# if not hit or hit[1] < MATCH_OK:\n",
1021
+ " if not hit or hit[1] < MATCH_OK:\n",
1022
+ "# return {k:\"Not listed\" for k in FEATURE_COLS}\n",
1023
+ " return {k:\"Not listed\" for k in FEATURE_COLS}\n",
1024
+ "# r = pool.iloc[int(hit[2])]\n",
1025
+ " r = pool.iloc[int(hit[2])]\n",
1026
+ "# ports = f\"WAN: {r.get('WAN ports and speed','')} | LAN: {r.get('LAN ports and speed','')}\"\n",
1027
+ " ports = f\"WAN: {r.get('WAN ports and speed','')} | LAN: {r.get('LAN ports and speed','')}\"\n",
1028
+ "# return {\n",
1029
+ " return {\n",
1030
+ "# \"Name\": str(r.get(\"Model\",\"\")),\n",
1031
+ " \"Name\": str(r.get(\"Model\",\"\")),\n",
1032
+ "# \"Modem technology\": str(r.get(\"Modem Type\",\"\")),\n",
1033
+ " \"Modem technology\": str(r.get(\"Modem Type\",\"\")),\n",
1034
+ "# \"WiFi\": str(r.get(\"WiFi type\",\"\")),\n",
1035
+ " \"WiFi\": str(r.get(\"WiFi type\",\"\")),\n",
1036
+ "# \"Ports\": ports,\n",
1037
+ " \"Ports\": ports,\n",
1038
+ "# \"Antennas\": str(r.get(\"Antennas (internal/external/both)\",\"\")),\n",
1039
+ " \"Antennas\": str(r.get(\"Antennas (internal/external/both)\",\"\")),\n",
1040
+ "# \"Ruggedness\": str(r.get(\"Ruggedization\",\"\")),\n",
1041
+ " \"Ruggedness\": str(r.get(\"Ruggedization\",\"\")),\n",
1042
+ "# \"Use case\": str(r.get(\"Primary use case\",\"\")),\n",
1043
+ " \"Use case\": str(r.get(\"Primary use case\",\"\")),\n",
1044
+ "# }\n",
1045
+ " }\n",
1046
+ "\n",
1047
+ "# def gpt_fill_features(device_label: str, feats: Dict[str,str], context: str) -> Dict[str,str]:\n",
1048
+ "def gpt_fill_features(device_label: str, feats: Dict[str,str], context: str) -> Dict[str,str]:\n",
1049
+ "# missing = [k for k,v in feats.items() if (not v) or v.strip().lower() in {\"not listed\",\"nan\"}]\n",
1050
+ " missing = [k for k,v in feats.items() if (not v) or v.strip().lower() in {\"not listed\",\"nan\"}]\n",
1051
+ "# if client is None or not missing:\n",
1052
+ " if client is None or not missing:\n",
1053
+ "# return feats\n",
1054
+ " return feats\n",
1055
+ "# sys = \"Fill missing router feature fields. Return strict JSON only.\"\n",
1056
+ " sys = \"Fill missing router feature fields. Return strict JSON only.\"\n",
1057
+ "# payload = {\n",
1058
+ " payload = {\n",
1059
+ "# \"device\": device_label,\n",
1060
+ " \"device\": device_label,\n",
1061
+ "# \"known\": feats,\n",
1062
+ " \"known\": feats,\n",
1063
+ "# \"context\": context[:2000],\n",
1064
+ " \"context\": context[:2000],\n",
1065
+ "# \"fill_only\": missing,\n",
1066
+ " \"fill_only\": missing,\n",
1067
+ "# \"rules\": [\"Fill only requested fields. Best guess if needed. Return JSON only.\"],\n",
1068
+ " \"rules\": [\"Fill only requested fields. Best guess if needed. Return JSON only.\"],\n",
1069
+ "# \"output_schema\": {k:\"string\" for k in missing}\n",
1070
+ " \"output_schema\": {k:\"string\" for k in missing}\n",
1071
+ "# }\n",
1072
+ " }\n",
1073
+ "# out = gpt_json(sys, payload, max_tokens=350) or {}\n",
1074
+ " out = gpt_json(sys, payload, max_tokens=350) or {}\n",
1075
+ "# for k in missing:\n",
1076
+ " for k in missing:\n",
1077
+ "# v = str(out.get(k,\"\") or \"\").strip()\n",
1078
+ " v = str(out.get(k,\"\") or \"\").strip()\n",
1079
+ "# if v:\n",
1080
+ " if v:\n",
1081
+ "# feats[k] = v\n",
1082
+ " feats[k] = v\n",
1083
+ "# return feats\n",
1084
+ " return feats\n",
1085
+ "\n",
1086
+ "# def current_features_guess(life_row: pd.Series) -> Dict[str,str]:\n",
1087
+ "def current_features_guess(life_row: pd.Series) -> Dict[str,str]:\n",
1088
+ "# sku = str(life_row.get(\"sku\",\"\") or \"\").strip()\n",
1089
+ " sku = str(life_row.get(\"sku\",\"\") or \"\").strip()\n",
1090
+ "# desc = str(life_row.get(\"description\",\"\") or \"\").strip()\n",
1091
+ " desc = str(life_row.get(\"description\",\"\") or \"\").strip()\n",
1092
+ "# notes = str(life_row.get(\"notes\",\"\") or \"\").strip()\n",
1093
+ " notes = str(life_row.get(\"notes\",\"\") or \"\").strip()\n",
1094
+ "# base = {\n",
1095
+ " base = {\n",
1096
+ "# \"Name\": sku,\n",
1097
+ " \"Name\": sku,\n",
1098
+ "# \"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",
1099
+ " \"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",
1100
+ "# \"WiFi\": \"Not listed\",\n",
1101
+ " \"WiFi\": \"Not listed\",\n",
1102
+ "# \"Ports\": \"Not listed\",\n",
1103
+ " \"Ports\": \"Not listed\",\n",
1104
+ "# \"Antennas\": \"Not listed\",\n",
1105
+ " \"Antennas\": \"Not listed\",\n",
1106
+ "# \"Ruggedness\": \"Not listed\",\n",
1107
+ " \"Ruggedness\": \"Not listed\",\n",
1108
+ "# \"Use case\": \"Not listed\",\n",
1109
+ " \"Use case\": \"Not listed\",\n",
1110
+ "# }\n",
1111
+ " }\n",
1112
+ "# return gpt_fill_features(\"Current device\", base, f\"{desc}\\n{notes}\")\n",
1113
+ " return gpt_fill_features(\"Current device\", base, f\"{desc}\\n{notes}\")\n",
1114
+ "\n",
1115
+ "# def build_features_table(cur: Dict[str,str], r4: Dict[str,str], r5: Dict[str,str]) -> str:\n",
1116
+ "def build_features_table(cur: Dict[str,str], r4: Dict[str,str], r5: Dict[str,str]) -> str:\n",
1117
+ "# cols = [\"Device\", \"Modem technology\", \"WiFi\", \"Ports\", \"Antennas\", \"Ruggedness\", \"Use case\"]\n",
1118
+ " cols = [\"Device\", \"Modem technology\", \"WiFi\", \"Ports\", \"Antennas\", \"Ruggedness\", \"Use case\"]\n",
1119
+ "# header = \"| \" + \" | \".join(cols) + \" |\"\n",
1120
+ " header = \"| \" + \" | \".join(cols) + \" |\"\n",
1121
+ "# sep = \"| \" + \" | \".join([\"---\"]*len(cols)) + \" |\"\n",
1122
+ " sep = \"| \" + \" | \".join([\"---\"]*len(cols)) + \" |\"\n",
1123
+ "# def row(name: str, feats: Dict[str,str]) -> str:\n",
1124
+ " def row(name: str, feats: Dict[str,str]) -> str:\n",
1125
+ "# return \"| \" + \" | \".join([\n",
1126
+ " return \"| \" + \" | \".join([\n",
1127
+ "# name,\n",
1128
+ " name,\n",
1129
+ "# feats.get(\"Modem technology\",\"Not listed\"),\n",
1130
+ " feats.get(\"Modem technology\",\"Not listed\"),\n",
1131
+ "# feats.get(\"WiFi\",\"Not listed\"),\n",
1132
+ " feats.get(\"WiFi\",\"Not listed\"),\n",
1133
+ "# feats.get(\"Ports\",\"Not listed\"),\n",
1134
+ " feats.get(\"Ports\",\"Not listed\"),\n",
1135
+ "# feats.get(\"Antennas\",\"Not listed\"),\n",
1136
+ " feats.get(\"Antennas\",\"Not listed\"),\n",
1137
+ "# feats.get(\"Ruggedness\",\"Not listed\"),\n",
1138
+ " feats.get(\"Ruggedness\",\"Not listed\"),\n",
1139
+ "# feats.get(\"Use case\",\"Not listed\"),\n",
1140
+ " feats.get(\"Use case\",\"Not listed\"),\n",
1141
+ "# ]) + \" |\"\n",
1142
+ " ]) + \" |\"\n",
1143
+ "# return \"\\n\".join([header, sep, row(\"Current\", cur), row(\"4G alternative\", r4), row(\"5G replacement\", r5)])\n",
1144
+ " return \"\\n\".join([header, sep, row(\"Current\", cur), row(\"4G alternative\", r4), row(\"5G replacement\", r5)])\n",
1145
+ "\n",
1146
+ "\n",
1147
+ "# ============================\n",
1148
+ "# Output + Gradio\n",
1149
+ "# ============================\n",
1150
+ "# def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:\n",
1151
+ "def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:\n",
1152
+ "# canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
1153
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
1154
+ "# current_name = f\"{life_row.get('sku','')} — {life_row.get('description','')}\".strip(\" —\")\n",
1155
+ " current_name = f\"{life_row.get('sku','')} — {life_row.get('description','')}\".strip(\" —\")\n",
1156
+ "\n",
1157
+ "# st = ant.get(\"stationary_omni\", {})\n",
1158
+ " st = ant.get(\"stationary_omni\", {})\n",
1159
+ "# vh = ant.get(\"vehicle_omni\", {})\n",
1160
+ " vh = ant.get(\"vehicle_omni\", {})\n",
1161
+ "\n",
1162
+ "# cur_feats = current_features_guess(life_row)\n",
1163
+ " cur_feats = current_features_guess(life_row)\n",
1164
+ "# r4_feats = dec_features_by_model(repl.get(\"repl_4g\",\"\"), canon_make)\n",
1165
+ " r4_feats = dec_features_by_model(repl.get(\"repl_4g\",\"\"), canon_make)\n",
1166
+ "# r5_feats = dec_features_by_model(repl.get(\"repl_5g\",\"\"), canon_make)\n",
1167
+ " r5_feats = dec_features_by_model(repl.get(\"repl_5g\",\"\"), canon_make)\n",
1168
+ "\n",
1169
+ " # If dec doesn't know the model, ask GPT to fill missing cells (best guess)\n",
1170
+ "# if client is not None:\n",
1171
+ " if client is not None:\n",
1172
+ "# r4_feats = gpt_fill_features(\"4G alternative\", r4_feats, f\"Model: {repl.get('repl_4g','')}\\nMake: {canon_make}\")\n",
1173
+ " r4_feats = gpt_fill_features(\"4G alternative\", r4_feats, f\"Model: {repl.get('repl_4g','')}\\nMake: {canon_make}\")\n",
1174
+ "# r5_feats = gpt_fill_features(\"5G replacement\", r5_feats, f\"Model: {repl.get('repl_5g','')}\\nMake: {canon_make}\")\n",
1175
+ " r5_feats = gpt_fill_features(\"5G replacement\", r5_feats, f\"Model: {repl.get('repl_5g','')}\\nMake: {canon_make}\")\n",
1176
+ "\n",
1177
+ "# table_md = build_features_table(cur_feats, r4_feats, r5_feats)\n",
1178
+ " table_md = build_features_table(cur_feats, r4_feats, r5_feats)\n",
1179
+ "\n",
1180
+ "# lines = []\n",
1181
+ " lines = []\n",
1182
+ "# lines.append(f\"1. Current device: **{current_name}**\")\n",
1183
+ " lines.append(f\"1. Current device: **{current_name}**\")\n",
1184
+ "# lines.append(f\"2. Status: **{status}**\")\n",
1185
+ " lines.append(f\"2. Status: **{status}**\")\n",
1186
+ "# lines.append(f\"3. End of Sale date: **{eos}**\")\n",
1187
+ " lines.append(f\"3. End of Sale date: **{eos}**\")\n",
1188
+ "# lines.append(f\"4. End of Life date: **{eol}**\")\n",
1189
+ " lines.append(f\"4. End of Life date: **{eol}**\")\n",
1190
+ "# lines.append(f\"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**\")\n",
1191
+ " lines.append(f\"5. 4G alternative (lifecycle): **{repl.get('repl_4g','Not applicable')}**\")\n",
1192
+ "# lines.append(f\"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**\")\n",
1193
+ " lines.append(f\"6. 5G replacement (lifecycle): **{repl.get('repl_5g','Not listed')}**\")\n",
1194
+ "# lines.append(\"7. Antenna options (Parsec-only):\")\n",
1195
+ " lines.append(\"7. Antenna options (Parsec-only):\")\n",
1196
+ "# lines.append(f\" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')} — {\n",
1197
+ " lines.append(f\" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')} — {st.get('why','')}\")\n",
1198
+ "# lines.append(f\" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')} — {vh.\n",
1199
+ " lines.append(f\" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')} — {vh.get('why','')}\")\n",
1200
+ "# lines.append(\"8. Recommended features table:\")\n",
1201
+ " lines.append(\"8. Recommended features table:\")\n",
1202
+ "# lines.append(table_md)\n",
1203
+ " lines.append(table_md)\n",
1204
+ "# lines.append(\"\\nSources (debug):\")\n",
1205
+ " lines.append(\"\\nSources (debug):\")\n",
1206
+ "# for s in repl.get(\"sources\", []) if isinstance(repl.get(\"sources\"), list) else []:\n",
1207
+ " for s in repl.get(\"sources\", []) if isinstance(repl.get(\"sources\"), list) else []:\n",
1208
+ "# lines.append(f\"- {s}\")\n",
1209
+ " lines.append(f\"- {s}\")\n",
1210
+ "# lines.append(\"- ParsecCatalog.pdf (local RAG)\")\n",
1211
+ " lines.append(\"- ParsecCatalog.pdf (local RAG)\")\n",
1212
+ "# lines.append(\"- routers_eos_eol_by_sku.csv (replacements)\")\n",
1213
+ " lines.append(\"- routers_eos_eol_by_sku.csv (replacements)\")\n",
1214
+ "# lines.append(\"- dec2025routers.csv (features)\")\n",
1215
+ " lines.append(\"- dec2025routers.csv (features)\")\n",
1216
+ "# return \"\\n\".join(lines)\n",
1217
+ " return \"\\n\".join(lines)\n",
1218
+ "\n",
1219
+ "# def run_lookup(user_text: str, st: Dict[str,Any]):\n",
1220
+ "def run_lookup(user_text: str, st: Dict[str,Any]):\n",
1221
+ "# user_text = str(user_text or \"\").strip()\n",
1222
+ " user_text = str(user_text or \"\").strip()\n",
1223
+ "# if not user_text:\n",
1224
+ " if not user_text:\n",
1225
+ "# return \"Enter a router SKU/model.\", gr.update(visible=False), gr.update(visible=False), {}\n",
1226
+ " return \"Enter a router SKU/model.\", gr.update(visible=False), gr.update(visible=False), {}\n",
1227
+ "\n",
1228
+ "# res = resolve_device(user_text)\n",
1229
+ " res = resolve_device(user_text)\n",
1230
+ "# if res.get(\"mode\") == \"pick\":\n",
1231
+ " if res.get(\"mode\") == \"pick\":\n",
1232
+ "# opts = res.get(\"options\", [])\n",
1233
+ " opts = res.get(\"options\", [])\n",
1234
+ "# choices = [o[\"label\"] for o in opts]\n",
1235
+ " choices = [o[\"label\"] for o in opts]\n",
1236
+ "# st2 = {\"mode\":\"pick\",\"options\": opts}\n",
1237
+ " st2 = {\"mode\":\"pick\",\"options\": opts}\n",
1238
+ "# 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",
1239
+ " 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",
1240
+ "\n",
1241
+ "# if res.get(\"mode\") != \"ok\":\n",
1242
+ " if res.get(\"mode\") != \"ok\":\n",
1243
+ "# return \"Not found.\", gr.update(visible=False), gr.update(visible=False), {}\n",
1244
+ " return \"Not found.\", gr.update(visible=False), gr.update(visible=False), {}\n",
1245
+ "\n",
1246
+ "# life_row = df_eos.iloc[int(res[\"row_idx\"])]\n",
1247
+ " life_row = df_eos.iloc[int(res[\"row_idx\"])]\n",
1248
+ "# eos, eol, status = row_to_dates_and_status(life_row)\n",
1249
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
1250
+ "\n",
1251
+ "# repl = pick_replacements_lifecycle(life_row, status)\n",
1252
+ " repl = pick_replacements_lifecycle(life_row, status)\n",
1253
+ "\n",
1254
+ "# 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",
1255
+ " 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",
1256
+ "# mimo_guess = \"4x4\" if tech == \"5G\" else \"2x2\"\n",
1257
+ " mimo_guess = \"4x4\" if tech == \"5G\" else \"2x2\"\n",
1258
+ "# ant = antenna_options_for(router_model=repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech=tech, mimo=mimo_guess)\n",
1259
+ " ant = antenna_options_for(router_model=repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech=tech, mimo=mimo_guess)\n",
1260
+ "\n",
1261
+ "# return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}\n",
1262
+ " return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}\n",
1263
+ "\n",
1264
+ "# def use_selection(selected_label: str, st: Dict[str,Any]):\n",
1265
+ "def use_selection(selected_label: str, st: Dict[str,Any]):\n",
1266
+ "# if not st or st.get(\"mode\") != \"pick\":\n",
1267
+ " if not st or st.get(\"mode\") != \"pick\":\n",
1268
+ "# return \"Run a search first.\", gr.update(visible=False), gr.update(visible=False), {}\n",
1269
+ " return \"Run a search first.\", gr.update(visible=False), gr.update(visible=False), {}\n",
1270
+ "# if not selected_label:\n",
1271
+ " if not selected_label:\n",
1272
+ "# return \"Pick A or B first.\", gr.update(visible=True), gr.update(visible=True), st\n",
1273
+ " return \"Pick A or B first.\", gr.update(visible=True), gr.update(visible=True), st\n",
1274
+ "\n",
1275
+ "# chosen_row = None\n",
1276
+ " chosen_row = None\n",
1277
+ "# for o in st.get(\"options\", []):\n",
1278
+ " for o in st.get(\"options\", []):\n",
1279
+ "# if o.get(\"label\") == selected_label:\n",
1280
+ " if o.get(\"label\") == selected_label:\n",
1281
+ "# chosen_row = int(o[\"row_idx\"])\n",
1282
+ " chosen_row = int(o[\"row_idx\"])\n",
1283
+ "# break\n",
1284
+ " break\n",
1285
+ "# if chosen_row is None:\n",
1286
+ " if chosen_row is None:\n",
1287
+ "# return \"Pick a valid option.\", gr.update(visible=True), gr.update(visible=True), st\n",
1288
+ " return \"Pick a valid option.\", gr.update(visible=True), gr.update(visible=True), st\n",
1289
+ "\n",
1290
+ "# life_row = df_eos.iloc[int(chosen_row)]\n",
1291
+ " life_row = df_eos.iloc[int(chosen_row)]\n",
1292
+ "# eos, eol, status = row_to_dates_and_status(life_row)\n",
1293
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
1294
+ "# repl = pick_replacements_lifecycle(life_row, status)\n",
1295
+ " repl = pick_replacements_lifecycle(life_row, status)\n",
1296
+ "# 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",
1297
+ " 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",
1298
+ "# mimo_guess = \"4x4\" if tech == \"5G\" else \"2x2\"\n",
1299
+ " mimo_guess = \"4x4\" if tech == \"5G\" else \"2x2\"\n",
1300
+ "# ant = antenna_options_for(router_model=repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech=tech, mimo=mimo_guess)\n",
1301
+ " ant = antenna_options_for(router_model=repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech=tech, mimo=mimo_guess)\n",
1302
+ "\n",
1303
+ "# return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}\n",
1304
+ " return assemble_output(life_row, status, eos, eol, repl, ant), gr.update(visible=False), gr.update(visible=False), {}\n",
1305
+ "\n",
1306
+ "# with gr.Blocks(title=\"Only-Routers\") as demo:\n",
1307
+ "with gr.Blocks(title=\"Only-Routers\") as demo:\n",
1308
+ "# gr.Markdown(\"## Only-Routers\\nEnter a router SKU/model. If ambiguous, you’ll get A/B choices.\")\n",
1309
+ " gr.Markdown(\"## Only-Routers\\nEnter a router SKU/model. If ambiguous, you’ll get A/B choices.\")\n",
1310
+ "# user_text = gr.Textbox(label=\"Router SKU or model\", placeholder=\"Examples: IBR650B, AER1600, ES450, WR21, RUT240\", lines=1)\n",
1311
+ " user_text = gr.Textbox(label=\"Router SKU or model\", placeholder=\"Examples: IBR650B, AER1600, ES450, WR21, RUT240\", lines=1)\n",
1312
+ "# st = gr.State({})\n",
1313
+ " st = gr.State({})\n",
1314
+ "\n",
1315
+ "# check_btn = gr.Button(\"Check\", variant=\"primary\")\n",
1316
+ " check_btn = gr.Button(\"Check\", variant=\"primary\")\n",
1317
+ "# pick_dd = gr.Dropdown(label=\"Pick A or B\", choices=[], visible=False)\n",
1318
+ " pick_dd = gr.Dropdown(label=\"Pick A or B\", choices=[], visible=False)\n",
1319
+ "# use_btn = gr.Button(\"Use selection\", visible=False)\n",
1320
+ " use_btn = gr.Button(\"Use selection\", visible=False)\n",
1321
+ "\n",
1322
+ "# output_md = gr.Markdown()\n",
1323
+ " output_md = gr.Markdown()\n",
1324
+ "\n",
1325
+ "# check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st])\n",
1326
+ " check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st])\n",
1327
+ "# use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st])\n",
1328
+ " use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st])\n",
1329
+ "\n",
1330
+ "# demo.launch()\n",
1331
+ "demo.launch()\n"
1332
+ ]
1333
+ }
1334
+ ],
1335
+ "metadata": {
1336
+ "kernelspec": {
1337
+ "display_name": "Python 3",
1338
+ "name": "python3"
1339
+ },
1340
+ "language_info": {
1341
+ "name": "python"
1342
+ }
1343
+ },
1344
+ "nbformat": 4,
1345
+ "nbformat_minor": 5
1346
+ }
only-routers_ai_poc_v4_8.ipynb ADDED
@@ -0,0 +1,944 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "01ada72b",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Only-Routers (v4.8)\n",
9
+ "\n",
10
+ "Notebook mirror of the Space `app.py`.\n"
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "code",
15
+ "execution_count": null,
16
+ "id": "9dbcb826",
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
+ "\n",
51
+ "PARSEC_CONTEXT_BEFORE = 900\n",
52
+ "PARSEC_CONTEXT_AFTER = 1600\n",
53
+ "\n",
54
+ "CACHE_DIR = os.path.join(os.getcwd(), \".onlyrouters_cache\")\n",
55
+ "os.makedirs(CACHE_DIR, exist_ok=True)\n",
56
+ "\n",
57
+ "\n",
58
+ "# ============================\n",
59
+ "# OpenAI client (HF Space secret: OPENAI_API_KEY)\n",
60
+ "# ============================\n",
61
+ "API_KEY = os.getenv(\"OPENAI_API_KEY\", \"\").strip()\n",
62
+ "client = OpenAI(api_key=API_KEY) if API_KEY else None\n",
63
+ "\n",
64
+ "\n",
65
+ "# ============================\n",
66
+ "# Utilities\n",
67
+ "# ============================\n",
68
+ "def norm_text(s: Any) -> str:\n",
69
+ " try:\n",
70
+ " if s is None or (isinstance(s, float) and math.isnan(s)) or pd.isna(s):\n",
71
+ " return \"\"\n",
72
+ " except Exception:\n",
73
+ " pass\n",
74
+ " s = str(s).strip().lower()\n",
75
+ " s = re.sub(r\"[^a-z0-9\\s\\-\\/]\", \" \", s)\n",
76
+ " s = re.sub(r\"\\s+\", \" \", s).strip()\n",
77
+ " return s\n",
78
+ "\n",
79
+ "def _safe_str(v: Any) -> str:\n",
80
+ " if v is None or (isinstance(v, float) and pd.isna(v)) or pd.isna(v):\n",
81
+ " return \"\"\n",
82
+ " return str(v).strip()\n",
83
+ "\n",
84
+ "def _is_5g(modem_type: Any) -> bool:\n",
85
+ " s = norm_text(modem_type)\n",
86
+ " return (\"5g\" in s) or (\"nr\" in s)\n",
87
+ "\n",
88
+ "def _json_load_safe(s: str) -> Dict[str, Any]:\n",
89
+ " try:\n",
90
+ " return json.loads(s)\n",
91
+ " except Exception:\n",
92
+ " return {}\n",
93
+ "\n",
94
+ "def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 700) -> Dict[str, Any]:\n",
95
+ " if client is None:\n",
96
+ " return {}\n",
97
+ " resp = client.responses.create(\n",
98
+ " model=OPENAI_MODEL,\n",
99
+ " reasoning=OPENAI_REASONING,\n",
100
+ " input=[\n",
101
+ " {\"role\": \"system\", \"content\": system},\n",
102
+ " {\"role\": \"user\", \"content\": json.dumps(payload)},\n",
103
+ " ],\n",
104
+ " max_output_tokens=max_tokens,\n",
105
+ " )\n",
106
+ " return _json_load_safe(getattr(resp, \"output_text\", \"\") or \"\")\n",
107
+ "\n",
108
+ "\n",
109
+ "# ============================\n",
110
+ "# Load data files (must exist in repo)\n",
111
+ "# ============================\n",
112
+ "EOS_PATH = \"routers_eos_eol_by_sku.csv\"\n",
113
+ "DEC_PATH = \"dec2025routers.csv\"\n",
114
+ "PARSEC_PDF = \"ParsecCatalog.pdf\"\n",
115
+ "\n",
116
+ "if not os.path.exists(EOS_PATH):\n",
117
+ " raise FileNotFoundError(f\"Missing {EOS_PATH} in repo.\")\n",
118
+ "if not os.path.exists(DEC_PATH):\n",
119
+ " raise FileNotFoundError(f\"Missing {DEC_PATH} in repo.\")\n",
120
+ "if not os.path.exists(PARSEC_PDF):\n",
121
+ " raise FileNotFoundError(f\"Missing {PARSEC_PDF} in repo.\")\n",
122
+ "\n",
123
+ "df_eos = pd.read_csv(EOS_PATH).copy()\n",
124
+ "df_dec = pd.read_csv(DEC_PATH).copy()\n",
125
+ "\n",
126
+ "# Region filter: keep USA / North America / blank / not specified\n",
127
+ "def _region_ok(x: Any) -> bool:\n",
128
+ " s = str(x or \"\").strip().lower()\n",
129
+ " if not s:\n",
130
+ " return True\n",
131
+ " if \"not specified\" in s:\n",
132
+ " return True\n",
133
+ " if \"north america\" in s:\n",
134
+ " return True\n",
135
+ " if re.search(r\"\\busa\\b\", s):\n",
136
+ " return True\n",
137
+ " if re.search(r\"\\bunited\\s+states\\b\", s):\n",
138
+ " return True\n",
139
+ " if re.search(r\"\\bu\\.?s\\.?\\b\", s):\n",
140
+ " return True\n",
141
+ " return False\n",
142
+ "\n",
143
+ "if \"region\" in df_eos.columns:\n",
144
+ " df_eos = df_eos[df_eos[\"region\"].apply(_region_ok)].reset_index(drop=True)\n",
145
+ "\n",
146
+ "# Optional \"Device Type\"\n",
147
+ "device_type_col = None\n",
148
+ "for c in df_eos.columns:\n",
149
+ " if norm_text(c) == \"device type\":\n",
150
+ " device_type_col = c\n",
151
+ " break\n",
152
+ "\n",
153
+ "# Maker mapping (includes Teltonika)\n",
154
+ "CANON_MAKER = {\n",
155
+ " \"CRADLEPOINT\": {\"cradlepoint\", \"ericsson\", \"ericsson enterprise wireless\"},\n",
156
+ " \"SIERRA\": {\"sierra\", \"sierra wireless\", \"semtech\", \"airlink\"},\n",
157
+ " \"FEENEY\": {\"feeney\", \"feeney wireless\", \"inseego\"},\n",
158
+ " \"DIGI\": {\"digi\", \"accelerated\", \"accelerated concepts\"},\n",
159
+ " \"CISCO_MERAKI\": {\"meraki\", \"cisco meraki\"},\n",
160
+ " \"CISCO\": {\"cisco\"},\n",
161
+ " \"TELTONIKA\": {\"teltonika\"},\n",
162
+ "}\n",
163
+ "\n",
164
+ "def canon_maker_from_text(s: Any) -> str:\n",
165
+ " t = norm_text(s)\n",
166
+ " for canon, terms in CANON_MAKER.items():\n",
167
+ " for term in terms:\n",
168
+ " if term in t:\n",
169
+ " return canon\n",
170
+ " return \"UNKNOWN\"\n",
171
+ "\n",
172
+ "df_eos[\"_canon_make\"] = df_eos[\"manufacturer\"].apply(canon_maker_from_text) if \"manufacturer\" in df_eos.columns else \"UNKNOWN\"\n",
173
+ "df_eos[\"_norm_sku\"] = df_eos[\"sku\"].apply(norm_text) if \"sku\" in df_eos.columns else \"\"\n",
174
+ "df_eos[\"_norm_desc\"] = df_eos[\"description\"].apply(norm_text) if \"description\" in df_eos.columns else \"\"\n",
175
+ "df_eos[\"_norm_notes\"] = df_eos[\"notes\"].apply(norm_text) if \"notes\" in df_eos.columns else \"\"\n",
176
+ "\n",
177
+ "df_dec[\"_canon_make\"] = df_dec[\"Make\"].apply(canon_maker_from_text) if \"Make\" in df_dec.columns else \"UNKNOWN\"\n",
178
+ "df_dec[\"_norm_model\"] = df_dec[\"Model\"].apply(norm_text) if \"Model\" in df_dec.columns else \"\"\n",
179
+ "df_dec[\"_is5g\"] = df_dec[\"Modem Type\"].apply(_is_5g) if \"Modem Type\" in df_dec.columns else False\n",
180
+ "\n",
181
+ "\n",
182
+ "# ============================\n",
183
+ "# Date helpers\n",
184
+ "# ============================\n",
185
+ "@dataclass\n",
186
+ "class ParsedDate:\n",
187
+ " raw: str\n",
188
+ " kind: str\n",
189
+ " value: Optional[date]\n",
190
+ "\n",
191
+ "def parse_date_field(x: Any) -> ParsedDate:\n",
192
+ " raw = str(x or \"\").strip()\n",
193
+ " if not raw:\n",
194
+ " return ParsedDate(raw=\"\", kind=\"missing\", value=None)\n",
195
+ "\n",
196
+ " if re.fullmatch(r\"\\d{4}\", raw):\n",
197
+ " y = int(raw)\n",
198
+ " if y == TODAY.year:\n",
199
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
200
+ " if y < TODAY.year:\n",
201
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 1, 1))\n",
202
+ " return ParsedDate(raw=raw, kind=\"year\", value=date(y, 12, 31))\n",
203
+ "\n",
204
+ " if re.fullmatch(r\"\\d{4}-\\d{2}\", raw):\n",
205
+ " try:\n",
206
+ " y, m = raw.split(\"-\")\n",
207
+ " return ParsedDate(raw=raw, kind=\"year_month\", value=date(int(y), int(m), 1))\n",
208
+ " except Exception:\n",
209
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
210
+ "\n",
211
+ " if re.fullmatch(r\"\\d{4}-\\d{2}-\\d{2}\", raw):\n",
212
+ " try:\n",
213
+ " dt = datetime.strptime(raw, \"%Y-%m-%d\").date()\n",
214
+ " return ParsedDate(raw=raw, kind=\"full\", value=dt)\n",
215
+ " except Exception:\n",
216
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
217
+ "\n",
218
+ " return ParsedDate(raw=raw, kind=\"bad\", value=None)\n",
219
+ "\n",
220
+ "def display_date(parsed: ParsedDate) -> str:\n",
221
+ " if parsed.kind == \"missing\":\n",
222
+ " return \"Not listed\"\n",
223
+ " if parsed.kind == \"bad\":\n",
224
+ " return parsed.raw or \"Not listed\"\n",
225
+ " return parsed.raw\n",
226
+ "\n",
227
+ "def status_from_eos_eol(eos: ParsedDate, eol: ParsedDate) -> str:\n",
228
+ " if eos.value is None and eol.value is None:\n",
229
+ " return \"Unknown\"\n",
230
+ " if eol.value is not None and eol.value <= TODAY:\n",
231
+ " return \"End of Life\"\n",
232
+ " if eos.value is not None and eos.value <= TODAY:\n",
233
+ " return \"End of Sale\"\n",
234
+ " return \"Active\"\n",
235
+ "\n",
236
+ "def row_to_dates_and_status(life_row: pd.Series) -> Tuple[str, str, str]:\n",
237
+ " eos = parse_date_field(life_row.get(\"end_of_sale\"))\n",
238
+ " eol = parse_date_field(life_row.get(\"end_of_life\"))\n",
239
+ " return display_date(eos), display_date(eol), status_from_eos_eol(eos, eol)\n",
240
+ "\n",
241
+ "\n",
242
+ "# ============================\n",
243
+ "# Embeddings + Parsec index\n",
244
+ "# ============================\n",
245
+ "embedder = SentenceTransformer(EMBED_MODEL_NAME)\n",
246
+ "\n",
247
+ "def extract_pdf_text_pages(path: str) -> List[str]:\n",
248
+ " doc = fitz.open(path)\n",
249
+ " return [doc[i].get_text(\"text\") for i in range(len(doc))]\n",
250
+ "\n",
251
+ "def build_parsec_cards(pages: List[str]) -> List[str]:\n",
252
+ " cards = []\n",
253
+ " for p in pages:\n",
254
+ " for m in re.finditer(r\"Standard\\s+SKU:\", p):\n",
255
+ " start = max(0, m.start() - PARSEC_CONTEXT_BEFORE)\n",
256
+ " end = min(len(p), m.start() + PARSEC_CONTEXT_AFTER)\n",
257
+ " c = p[start:end].strip()\n",
258
+ " if len(c) >= 200:\n",
259
+ " cards.append(c)\n",
260
+ " out, seen = [], set()\n",
261
+ " for c in cards:\n",
262
+ " h = hashlib.sha1(c.encode(\"utf-8\")).hexdigest()\n",
263
+ " if h not in seen:\n",
264
+ " seen.add(h); out.append(c)\n",
265
+ " return out\n",
266
+ "\n",
267
+ "parsec_cards = build_parsec_cards(extract_pdf_text_pages(PARSEC_PDF))\n",
268
+ "parsec_emb = embedder.encode(parsec_cards, batch_size=64, show_progress_bar=False, normalize_embeddings=True)\n",
269
+ "parsec_emb = np.asarray(parsec_emb, dtype=np.float32)\n",
270
+ "parsec_index = faiss.IndexFlatIP(parsec_emb.shape[1])\n",
271
+ "parsec_index.add(parsec_emb)\n",
272
+ "\n",
273
+ "\n",
274
+ "# ============================\n",
275
+ "# Device resolution (exact SKU -> GPT A/B)\n",
276
+ "# ============================\n",
277
+ "def _label_for_row(i: int) -> str:\n",
278
+ " r = df_eos.iloc[i]\n",
279
+ " return f\"{r.get('sku','')} — {r.get('manufacturer','')} — {r.get('description','')}\"[:220]\n",
280
+ "\n",
281
+ "EOS_LABELS = [_label_for_row(i) for i in range(len(df_eos))]\n",
282
+ "EOS_CORPUS = []\n",
283
+ "for _, r in df_eos.iterrows():\n",
284
+ " EOS_CORPUS.append(\" \".join([\n",
285
+ " r.get(\"_norm_sku\",\"\"),\n",
286
+ " r.get(\"_canon_make\",\"\"),\n",
287
+ " r.get(\"_norm_desc\",\"\"),\n",
288
+ " r.get(\"_norm_notes\",\"\"),\n",
289
+ " ]))\n",
290
+ "\n",
291
+ "def local_candidates(query: str, top_k: int = 6) -> List[Tuple[int,int,str]]:\n",
292
+ " q = norm_text(query)\n",
293
+ " hits = process.extract(q, EOS_CORPUS, scorer=fuzz.WRatio, limit=top_k)\n",
294
+ " return [(int(idx), int(score), EOS_LABELS[int(idx)]) for _, score, idx in hits]\n",
295
+ "\n",
296
+ "def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> Dict[str, Any]:\n",
297
+ " if client is None:\n",
298
+ " return {}\n",
299
+ " sys = \"Pick which router the user meant. Never invent. Return strict JSON only.\"\n",
300
+ " payload = {\n",
301
+ " \"user_input\": user_text,\n",
302
+ " \"candidates\": [{\"row_idx\": i, \"score\": s, \"label\": lbl} for (i,s,lbl) in candidates],\n",
303
+ " \"rules\": [\n",
304
+ " \"If one candidate is clearly correct, return mode='ok' with row_idx.\",\n",
305
+ " \"If two are plausible, return mode='pick' with top 2 options.\"\n",
306
+ " ],\n",
307
+ " \"output_schema\": {\"mode\":\"ok|pick\",\"row_idx\":\"int\",\"options\":[{\"row_idx\":\"int\",\"label\":\"string\"}]}\n",
308
+ " }\n",
309
+ " return gpt_json(sys, payload, max_tokens=300)\n",
310
+ "\n",
311
+ "def resolve_device(user_text: str) -> Dict[str, Any]:\n",
312
+ " q = norm_text(user_text)\n",
313
+ " exact_idxs = df_eos.index[df_eos[\"_norm_sku\"] == q].tolist()\n",
314
+ " if len(exact_idxs) == 1:\n",
315
+ " return {\"mode\":\"ok\",\"row_idx\": int(exact_idxs[0])}\n",
316
+ " if len(exact_idxs) > 1:\n",
317
+ " opts = [{\"row_idx\": int(i), \"label\": EOS_LABELS[int(i)]} for i in exact_idxs[:2]]\n",
318
+ " return {\"mode\":\"pick\",\"options\": opts}\n",
319
+ "\n",
320
+ " cands = local_candidates(user_text, top_k=6)\n",
321
+ " if not cands:\n",
322
+ " return {\"mode\":\"not_found\"}\n",
323
+ "\n",
324
+ " if cands[0][1] >= 95 and (len(cands) == 1 or (cands[0][1] - cands[1][1]) >= 8):\n",
325
+ " return {\"mode\":\"ok\",\"row_idx\": cands[0][0]}\n",
326
+ "\n",
327
+ " g = gpt_choose_device(user_text, cands)\n",
328
+ " if g.get(\"mode\") == \"ok\" and isinstance(g.get(\"row_idx\"), int):\n",
329
+ " return {\"mode\":\"ok\",\"row_idx\": int(g[\"row_idx\"])}\n",
330
+ "\n",
331
+ " if g.get(\"mode\") == \"pick\":\n",
332
+ " opts = g.get(\"options\", []) or []\n",
333
+ " opts2 = [{\"row_idx\": int(o[\"row_idx\"]), \"label\": str(o[\"label\"])} for o in opts[:2] if \"row_idx\" in o]\n",
334
+ " if opts2:\n",
335
+ " return {\"mode\":\"pick\",\"options\": opts2}\n",
336
+ "\n",
337
+ " # fallback\n",
338
+ " if len(cands) > 1:\n",
339
+ " return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]},{\"row_idx\":cands[1][0],\"label\":cands[1][2]}]}\n",
340
+ " return {\"mode\":\"pick\",\"options\":[{\"row_idx\":cands[0][0],\"label\":cands[0][2]}]}\n",
341
+ "\n",
342
+ "\n",
343
+ "# ============================\n",
344
+ "# Replacements — lifecycle CSV source of truth\n",
345
+ "# ============================\n",
346
+ "def _extract_model_token(text: str) -> str:\n",
347
+ " s = _safe_str(text)\n",
348
+ " if not s:\n",
349
+ " return \"\"\n",
350
+ " parts = [p.strip() for p in s.split(\"|\") if p.strip()]\n",
351
+ " candidates = parts[::-1] if parts else [s]\n",
352
+ "\n",
353
+ " for cand in candidates:\n",
354
+ " m = re.search(r\"\\bRUT[A-Z]?\\d{2,4}\\b\", cand.upper())\n",
355
+ " if m:\n",
356
+ " return m.group(0).upper()\n",
357
+ " m = re.search(r\"\\bIX\\d{2}\\b\", cand, flags=re.IGNORECASE)\n",
358
+ " if m:\n",
359
+ " return m.group(0).upper()\n",
360
+ " m = re.search(r\"\\b(R\\d{3,4}|E\\d{3,4}|S\\d{3,4})\\b\", cand, flags=re.IGNORECASE)\n",
361
+ " if m:\n",
362
+ " return m.group(0).upper()\n",
363
+ " m = re.search(r\"\\b[A-Z]{1,6}\\d{2,4}[A-Z]?\\b\", cand.upper())\n",
364
+ " if m:\n",
365
+ " return m.group(0).upper()\n",
366
+ "\n",
367
+ " return candidates[0][:60]\n",
368
+ "\n",
369
+ "def _device_is_4g(life_row: pd.Series) -> bool:\n",
370
+ " t = norm_text(life_row.get(\"description\",\"\")) + \" \" + norm_text(life_row.get(\"notes\",\"\"))\n",
371
+ " return ((\"lte\" in t or \"4g\" in t) and (\"5g\" not in t and \"nr\" not in t))\n",
372
+ "\n",
373
+ "def _candidate_5g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
374
+ " mfr = norm_text(manufacturer)\n",
375
+ " pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
376
+ " vals = pool[\"advanced_5g_option\"].tolist() if \"advanced_5g_option\" in pool.columns else []\n",
377
+ " out, seen = [], set()\n",
378
+ " for v in vals:\n",
379
+ " tok = _extract_model_token(v)\n",
380
+ " if tok and tok.lower() != \"nan\" and tok not in seen:\n",
381
+ " seen.add(tok); out.append(tok)\n",
382
+ " return out\n",
383
+ "\n",
384
+ "def _candidate_4g_models_from_lifecycle(manufacturer: str) -> List[str]:\n",
385
+ " mfr = norm_text(manufacturer)\n",
386
+ " pool = df_eos[df_eos[\"manufacturer\"].astype(str).str.lower().eq(mfr)].copy() if \"manufacturer\" in df_eos.columns else df_eos.copy()\n",
387
+ " vals = pool[\"suggested_replacement\"].tolist() if \"suggested_replacement\" in pool.columns else []\n",
388
+ " out, seen = [], set()\n",
389
+ " for v in vals:\n",
390
+ " tok = _extract_model_token(v)\n",
391
+ " if tok and tok.lower() != \"nan\" and tok not in seen:\n",
392
+ " seen.add(tok); out.append(tok)\n",
393
+ " return out\n",
394
+ "\n",
395
+ "def _gpt_pick_from_candidates(old_row: pd.Series, candidates: List[str], need: str) -> str:\n",
396
+ " if client is None or not candidates:\n",
397
+ " return \"\"\n",
398
+ " sys = \"Pick the best replacement model. Choose only from candidates. Return strict JSON only.\"\n",
399
+ " payload = {\n",
400
+ " \"old_device\": {\n",
401
+ " \"sku\": str(old_row.get(\"sku\",\"\")),\n",
402
+ " \"manufacturer\": str(old_row.get(\"manufacturer\",\"\")),\n",
403
+ " \"description\": str(old_row.get(\"description\",\"\")),\n",
404
+ " \"need\": need,\n",
405
+ " },\n",
406
+ " \"candidates\": candidates[:40],\n",
407
+ " \"output_schema\": {\"choice\":\"string\"}\n",
408
+ " }\n",
409
+ " out = gpt_json(sys, payload, max_tokens=240) or {}\n",
410
+ " choice = str(out.get(\"choice\",\"\") or \"\").strip()\n",
411
+ " return choice if choice in candidates else \"\"\n",
412
+ "\n",
413
+ "def _fallback_5g_from_dec(canon_make: str) -> str:\n",
414
+ " pool5 = df_dec[(df_dec[\"_canon_make\"] == canon_make) & (df_dec[\"_is5g\"] == True)]\n",
415
+ " return str(pool5.iloc[0][\"Model\"]).strip() if not pool5.empty else \"\"\n",
416
+ "\n",
417
+ "def pick_replacements_lifecycle(life_row: pd.Series, status: str, use_gpt: bool = True) -> Dict[str, Any]:\n",
418
+ " canon = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
419
+ " manufacturer = str(life_row.get(\"manufacturer\",\"\") or \"\")\n",
420
+ "\n",
421
+ " is_4g_device = _device_is_4g(life_row)\n",
422
+ " want_5g = is_4g_device or (status in {\"End of Sale\",\"End of Life\"})\n",
423
+ "\n",
424
+ " repl_4g = \"Not applicable\"\n",
425
+ " if is_4g_device:\n",
426
+ " repl_4g = _extract_model_token(_safe_str(life_row.get(\"suggested_replacement\",\"\")))\n",
427
+ " if not repl_4g:\n",
428
+ " cand4 = _candidate_4g_models_from_lifecycle(manufacturer)\n",
429
+ " repl_4g = (_gpt_pick_from_candidates(life_row, cand4, \"4G alternative\") if (use_gpt and client) else \"\") or (cand4[0] if cand4 else \"\")\n",
430
+ " if not repl_4g:\n",
431
+ " repl_4g = \"Not applicable\"\n",
432
+ "\n",
433
+ " repl_5g = \"Not applicable\"\n",
434
+ " if want_5g:\n",
435
+ " repl_5g = _extract_model_token(_safe_str(life_row.get(\"advanced_5g_option\",\"\")))\n",
436
+ " if not repl_5g:\n",
437
+ " cand5 = _candidate_5g_models_from_lifecycle(manufacturer)\n",
438
+ " repl_5g = (_gpt_pick_from_candidates(life_row, cand5, \"5G replacement/upgrade\") if (use_gpt and client) else \"\") or (cand5[0] if cand5 else \"\")\n",
439
+ " if not repl_5g:\n",
440
+ " repl_5g = _fallback_5g_from_dec(canon)\n",
441
+ "\n",
442
+ " if repl_5g.lower() == \"nan\":\n",
443
+ " repl_5g = \"\"\n",
444
+ "\n",
445
+ " return {\n",
446
+ " \"repl_4g\": repl_4g,\n",
447
+ " \"repl_5g\": repl_5g if repl_5g else \"Not listed\",\n",
448
+ " \"why\": \"Lifecycle replacements (GPT fallback when missing).\",\n",
449
+ " \"sources\": [\"lifecycle_csv\"] + ([\"gpt\"] if (use_gpt and client) else []) + ([\"dec_fallback\"] if (want_5g and (repl_5g == \"Not listed\" or repl_5g == \"\")) else []),\n",
450
+ " }\n",
451
+ "\n",
452
+ "\n",
453
+ "# ============================\n",
454
+ "# Antennas (Parsec-only; family + connectors hint)\n",
455
+ "# ============================\n",
456
+ "PARSEC_FAMILY_WORDS = {\n",
457
+ " \"chinook\",\"labrador\",\"boxer\",\"bloodhound\",\"husky\",\"beagle\",\"mastiff\",\"collie\",\n",
458
+ " \"shepherd\",\"belgian\",\"australian\",\"terrier\",\"pyrenees\"\n",
459
+ "}\n",
460
+ "BAD_NAME_MARKERS = {\n",
461
+ " \"customization\", \"standard connectors\", \"connectors\", \"features\", \"benefits\",\n",
462
+ " \"specifications\", \"mechanical\", \"electrical\", \"mounting\", \"accessories\",\n",
463
+ " \"description:\", \"standard sku\"\n",
464
+ "}\n",
465
+ "\n",
466
+ "def _clean_line(s: str) -> str:\n",
467
+ " s = re.sub(r\"\\s+\", \" \", str(s or \"\").strip())\n",
468
+ " if re.fullmatch(r\"-[a-z0-9]+\", s.lower()):\n",
469
+ " return \"\"\n",
470
+ " return s\n",
471
+ "\n",
472
+ "def _is_bad_name_line(line: str) -> bool:\n",
473
+ " low = line.lower()\n",
474
+ " if any(m in low for m in BAD_NAME_MARKERS):\n",
475
+ " return True\n",
476
+ " if re.search(r\"\\b-[a-z0-9]{1,4}\\b\", low) and len(low) <= 25:\n",
477
+ " return True\n",
478
+ " return False\n",
479
+ "\n",
480
+ "def _family_from_line(line: str) -> str:\n",
481
+ " low = line.lower()\n",
482
+ " for fam in PARSEC_FAMILY_WORDS:\n",
483
+ " if fam in low:\n",
484
+ " return fam.capitalize()\n",
485
+ " return \"\"\n",
486
+ "\n",
487
+ "def _parsec_connectors_from_card(t: str) -> str:\n",
488
+ " m = re.search(r\"Standard\\s+Connectors:\\s*(.+)\", t, flags=re.IGNORECASE)\n",
489
+ " if m:\n",
490
+ " val = re.sub(r\"\\s+\", \" \", m.group(1).strip())\n",
491
+ " return val[:80]\n",
492
+ " return \"\"\n",
493
+ "\n",
494
+ "def _parsec_name_from_card(card_text: str) -> str:\n",
495
+ " lines = [_clean_line(ln) for ln in str(card_text or \"\").splitlines()]\n",
496
+ " lines = [ln for ln in lines if ln]\n",
497
+ "\n",
498
+ " for ln in lines:\n",
499
+ " if _is_bad_name_line(ln):\n",
500
+ " continue\n",
501
+ " fam = _family_from_line(ln)\n",
502
+ " if fam:\n",
503
+ " return fam\n",
504
+ "\n",
505
+ " sku_i = None\n",
506
+ " for i, ln in enumerate(lines):\n",
507
+ " if \"standard sku\" in ln.lower():\n",
508
+ " sku_i = i\n",
509
+ " break\n",
510
+ " if sku_i is not None:\n",
511
+ " window = lines[max(0, sku_i - 12):sku_i]\n",
512
+ " for ln in reversed(window):\n",
513
+ " if _is_bad_name_line(ln):\n",
514
+ " continue\n",
515
+ " if 3 <= len(ln) <= 40 and re.search(r\"[A-Za-z]\", ln):\n",
516
+ " return ln.split()[0].capitalize()\n",
517
+ "\n",
518
+ " return \"Parsec antenna\"\n",
519
+ "\n",
520
+ "def _parsec_part_from_card(t: str) -> str:\n",
521
+ " m = re.search(r\"Standard\\s+SKU:\\s*([A-Z0-9]+)\", t)\n",
522
+ " return m.group(1).strip() if m else \"\"\n",
523
+ "\n",
524
+ "def _parsec_desc_from_card(t: str) -> str:\n",
525
+ " m = re.search(r\"Description:\\s*(.+?)(?:\\n|$)\", t, flags=re.IGNORECASE)\n",
526
+ " return re.sub(r\"\\s+\",\" \",m.group(1).strip())[:220] if m else \"\"\n",
527
+ "\n",
528
+ "def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:\n",
529
+ " qv = embedder.encode([query], normalize_embeddings=True)\n",
530
+ " qv = np.asarray(qv, dtype=np.float32)\n",
531
+ " scores, ids = parsec_index.search(qv, top_k)\n",
532
+ " out = []\n",
533
+ " for sc, i in zip(scores[0].tolist(), ids[0].tolist()):\n",
534
+ " if 0 <= int(i) < len(parsec_cards):\n",
535
+ " card = parsec_cards[int(i)]\n",
536
+ " out.append({\n",
537
+ " \"score\": float(sc),\n",
538
+ " \"name\": _parsec_name_from_card(card),\n",
539
+ " \"part_number\": _parsec_part_from_card(card),\n",
540
+ " \"description\": _parsec_desc_from_card(card),\n",
541
+ " \"connectors\": _parsec_connectors_from_card(card),\n",
542
+ " })\n",
543
+ " return out\n",
544
+ "\n",
545
+ "def infer_mimo_for_replacement(model: str, canon_make: str) -> str:\n",
546
+ " if not model or model in {\"Not applicable\",\"Not listed\"}:\n",
547
+ " return \"2x2\"\n",
548
+ " pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
549
+ " if pool.empty:\n",
550
+ " return \"4x4\" if (\"5g\" in model.lower()) else \"2x2\"\n",
551
+ " hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
552
+ " if hit and hit[1] >= MATCH_OK:\n",
553
+ " row = pool.iloc[int(hit[2])]\n",
554
+ " txt = (str(row.get(\"Antennas (internal/external/both)\",\"\")) + \" \" + str(row.get(\"Modem Type\",\"\"))).lower()\n",
555
+ " if \"4x4\" in txt or \"4 x 4\" in txt:\n",
556
+ " return \"4x4\"\n",
557
+ " return \"4x4\" if (\"5g\" in model.lower()) else \"2x2\"\n",
558
+ "\n",
559
+ "def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, Any]:\n",
560
+ " q_stationary = f\"{router_model} {tech} {mimo} omni stationary outdoor Parsec\"\n",
561
+ " q_vehicle = f\"{router_model} {tech} {mimo} omni vehicle mobile Parsec\"\n",
562
+ "\n",
563
+ " cand_stationary = parsec_retrieve(q_stationary, top_k=10)\n",
564
+ " cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)\n",
565
+ "\n",
566
+ " s = cand_stationary[0] if cand_stationary else {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\",\"connectors\":\"\"}\n",
567
+ " v = cand_vehicle[0] if cand_vehicle else {\"name\":\"Parsec antenna\",\"part_number\":\"\",\"description\":\"\",\"connectors\":\"\"}\n",
568
+ " s.update({\"mimo\": mimo, \"why\": \"Stationary omni best match.\"})\n",
569
+ " v.update({\"mimo\": mimo, \"why\": \"Vehicle omni best match.\"})\n",
570
+ " return {\"stationary_omni\": s, \"vehicle_omni\": v, \"sources\":[\"parsec_rag\"]}\n",
571
+ "\n",
572
+ "\n",
573
+ "# ============================\n",
574
+ "# Feature table + GPT fill for missing fields (not lazy: fill missing)\n",
575
+ "# ============================\n",
576
+ "FEATURE_COLS = [\"Name\",\"Modem technology\",\"WiFi\",\"Ports\",\"Antennas\",\"Ruggedness\",\"Use case\"]\n",
577
+ "\n",
578
+ "def dec_features_by_model(model: str, canon_make: str) -> Dict[str, str]:\n",
579
+ " if not model or model in {\"Not applicable\",\"Not listed\"}:\n",
580
+ " return {k:\"Not listed\" for k in FEATURE_COLS}\n",
581
+ " pool = df_dec[df_dec[\"_canon_make\"] == canon_make].copy()\n",
582
+ " if pool.empty:\n",
583
+ " return {k:\"Not listed\" for k in FEATURE_COLS}\n",
584
+ " hit = process.extractOne(norm_text(model), pool[\"_norm_model\"].tolist(), scorer=fuzz.WRatio)\n",
585
+ " if not hit or hit[1] < MATCH_OK:\n",
586
+ " return {k:\"Not listed\" for k in FEATURE_COLS}\n",
587
+ " r = pool.iloc[int(hit[2])]\n",
588
+ " ports = f\"WAN: {r.get('WAN ports and speed','')} | LAN: {r.get('LAN ports and speed','')}\"\n",
589
+ " return {\n",
590
+ " \"Name\": str(r.get(\"Model\",\"\")),\n",
591
+ " \"Modem technology\": str(r.get(\"Modem Type\",\"\")),\n",
592
+ " \"WiFi\": str(r.get(\"WiFi type\",\"\")),\n",
593
+ " \"Ports\": ports,\n",
594
+ " \"Antennas\": str(r.get(\"Antennas (internal/external/both)\",\"\")),\n",
595
+ " \"Ruggedness\": str(r.get(\"Ruggedization\",\"\")),\n",
596
+ " \"Use case\": str(r.get(\"Primary use case\",\"\")),\n",
597
+ " }\n",
598
+ "\n",
599
+ "def gpt_fill_features(device_label: str, feats: Dict[str,str], context: str) -> Dict[str,str]:\n",
600
+ " missing = [k for k,v in feats.items() if (not v) or v.strip().lower() in {\"not listed\",\"nan\"}]\n",
601
+ " if client is None or not missing:\n",
602
+ " return feats\n",
603
+ " sys = \"Fill missing router feature fields. Return strict JSON only.\"\n",
604
+ " payload = {\n",
605
+ " \"device\": device_label,\n",
606
+ " \"known\": feats,\n",
607
+ " \"context\": context[:2000],\n",
608
+ " \"fill_only\": missing,\n",
609
+ " \"rules\": [\"Fill only requested fields. Best guess if needed. Return JSON only.\"],\n",
610
+ " \"output_schema\": {k:\"string\" for k in missing}\n",
611
+ " }\n",
612
+ " out = gpt_json(sys, payload, max_tokens=350) or {}\n",
613
+ " for k in missing:\n",
614
+ " v = str(out.get(k,\"\") or \"\").strip()\n",
615
+ " if v:\n",
616
+ " feats[k] = v\n",
617
+ " return feats\n",
618
+ "\n",
619
+ "def current_features_guess(life_row: pd.Series) -> Dict[str,str]:\n",
620
+ " sku = str(life_row.get(\"sku\",\"\") or \"\").strip()\n",
621
+ " desc = str(life_row.get(\"description\",\"\") or \"\").strip()\n",
622
+ " notes = str(life_row.get(\"notes\",\"\") or \"\").strip()\n",
623
+ " base = {\n",
624
+ " \"Name\": sku,\n",
625
+ " \"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",
626
+ " \"WiFi\": \"Not listed\",\n",
627
+ " \"Ports\": \"Not listed\",\n",
628
+ " \"Antennas\": \"Not listed\",\n",
629
+ " \"Ruggedness\": \"Not listed\",\n",
630
+ " \"Use case\": \"Not listed\",\n",
631
+ " }\n",
632
+ " return gpt_fill_features(\"Current device\", base, f\"{desc}\\n{notes}\")\n",
633
+ "\n",
634
+ "def build_features_table(cur: Dict[str,str], r4: Dict[str,str], r5: Dict[str,str]) -> str:\n",
635
+ " cols = [\"Device\", \"Modem technology\", \"WiFi\", \"Ports\", \"Antennas\", \"Ruggedness\", \"Use case\"]\n",
636
+ " header = \"| \" + \" | \".join(cols) + \" |\"\n",
637
+ " sep = \"| \" + \" | \".join([\"---\"]*len(cols)) + \" |\"\n",
638
+ " def row(name: str, feats: Dict[str,str]) -> str:\n",
639
+ " return \"| \" + \" | \".join([\n",
640
+ " name,\n",
641
+ " feats.get(\"Modem technology\",\"Not listed\"),\n",
642
+ " feats.get(\"WiFi\",\"Not listed\"),\n",
643
+ " feats.get(\"Ports\",\"Not listed\"),\n",
644
+ " feats.get(\"Antennas\",\"Not listed\"),\n",
645
+ " feats.get(\"Ruggedness\",\"Not listed\"),\n",
646
+ " feats.get(\"Use case\",\"Not listed\"),\n",
647
+ " ]) + \" |\"\n",
648
+ " return \"\\n\".join([header, sep, row(\"Current\", cur), row(\"4G alternative\", r4), row(\"5G replacement\", r5)])\n",
649
+ "\n",
650
+ "\n",
651
+ "# ============================\n",
652
+ "# Output + install-ready checklist (Feature #9)\n",
653
+ "# ============================\n",
654
+ "def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:\n",
655
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
656
+ " current_name = f\"{life_row.get('sku','')} — {life_row.get('description','')}\".strip(\" —\")\n",
657
+ "\n",
658
+ " st = ant.get(\"stationary_omni\", {})\n",
659
+ " vh = ant.get(\"vehicle_omni\", {})\n",
660
+ "\n",
661
+ " cur_feats = current_features_guess(life_row)\n",
662
+ " r4_feats = dec_features_by_model(repl.get(\"repl_4g\",\"\"), canon_make)\n",
663
+ " r5_feats = dec_features_by_model(repl.get(\"repl_5g\",\"\"), canon_make)\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
+ " conn_s = f\" | Conn: {st.get('connectors','')}\" if st.get(\"connectors\") else \"\"\n",
679
+ " conn_v = f\" | Conn: {vh.get('connectors','')}\" if vh.get(\"connectors\") else \"\"\n",
680
+ " lines.append(f\" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')}{conn_s} — {st.get('why','')}\")\n",
681
+ " lines.append(f\" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')}{conn_v} — {vh.get('why','')}\")\n",
682
+ " lines.append(\"8. Recommended features table:\")\n",
683
+ " lines.append(table_md)\n",
684
+ "\n",
685
+ " lines.append(\"\\nSources (debug):\")\n",
686
+ " for s in repl.get(\"sources\", []) if isinstance(repl.get(\"sources\"), list) else []:\n",
687
+ " lines.append(f\"- {s}\")\n",
688
+ " lines.append(\"- ParsecCatalog.pdf (local RAG)\")\n",
689
+ " lines.append(\"- routers_eos_eol_by_sku.csv (replacements)\")\n",
690
+ " lines.append(\"- dec2025routers.csv (features)\")\n",
691
+ " return \"\\n\".join(lines)\n",
692
+ "\n",
693
+ "def install_ready_checklist(life_row: pd.Series, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:\n",
694
+ " current_sku = str(life_row.get(\"sku\",\"\") or \"\").strip()\n",
695
+ " repl4 = str(repl.get(\"repl_4g\",\"\") or \"\")\n",
696
+ " repl5 = str(repl.get(\"repl_5g\",\"\") or \"\")\n",
697
+ " st = ant.get(\"stationary_omni\", {})\n",
698
+ " vh = ant.get(\"vehicle_omni\", {})\n",
699
+ "\n",
700
+ " if client is not None:\n",
701
+ " sys = \"Create a short, install-ready checklist for a Verizon rep. Keep it scannable. Return markdown only.\"\n",
702
+ " payload = {\n",
703
+ " \"current_device\": current_sku,\n",
704
+ " \"replacements\": {\"4g_alternative\": repl4, \"5g_replacement\": repl5},\n",
705
+ " \"antennas\": {\"stationary\": st, \"vehicle\": vh},\n",
706
+ " \"rules\": [\n",
707
+ " \"Include: router(s), antennas, connector/cable notes, mounting notes, power notes, and 'next steps'.\",\n",
708
+ " \"Keep it concise and practical.\"\n",
709
+ " ]\n",
710
+ " }\n",
711
+ " resp = client.responses.create(\n",
712
+ " model=OPENAI_MODEL,\n",
713
+ " reasoning=OPENAI_REASONING,\n",
714
+ " input=[{\"role\":\"system\",\"content\":sys},{\"role\":\"user\",\"content\":json.dumps(payload)}],\n",
715
+ " max_output_tokens=550,\n",
716
+ " )\n",
717
+ " return (getattr(resp, \"output_text\", \"\") or \"\").strip()\n",
718
+ "\n",
719
+ " lines = []\n",
720
+ " lines.append(\"### Install-ready checklist\")\n",
721
+ " lines.append(f\"- Current device: {current_sku}\")\n",
722
+ " lines.append(f\"- 5G replacement: {repl5}\")\n",
723
+ " lines.append(f\"- 4G alternative: {repl4 if repl4 else 'Not applicable'}\")\n",
724
+ " lines.append(f\"- Stationary omni antenna: {st.get('name','')} (PN {st.get('part_number','')})\")\n",
725
+ " lines.append(f\"- Vehicle omni antenna: {vh.get('name','')} (PN {vh.get('part_number','')})\")\n",
726
+ " if st.get(\"connectors\"):\n",
727
+ " lines.append(f\"- Stationary connectors: {st.get('connectors')}\")\n",
728
+ " if vh.get(\"connectors\"):\n",
729
+ " lines.append(f\"- Vehicle connectors: {vh.get('connectors')}\")\n",
730
+ " lines.append(\"- Next steps: confirm mounting + cable lengths + power method; place order; schedule install.\")\n",
731
+ " return \"\\n\".join(lines)\n",
732
+ "\n",
733
+ "\n",
734
+ "# ============================\n",
735
+ "# Batch mode (Feature #4)\n",
736
+ "# ============================\n",
737
+ "def parse_batch_inputs(text_blob: str, file_obj: Optional[Any]) -> List[str]:\n",
738
+ " items = []\n",
739
+ " if file_obj is not None:\n",
740
+ " try:\n",
741
+ " path = file_obj.name if hasattr(file_obj, \"name\") else str(file_obj)\n",
742
+ " df = pd.read_csv(path)\n",
743
+ " col = df.columns[0]\n",
744
+ " items.extend([str(x).strip() for x in df[col].tolist() if str(x).strip()])\n",
745
+ " except Exception:\n",
746
+ " pass\n",
747
+ " if text_blob:\n",
748
+ " for ln in str(text_blob).splitlines():\n",
749
+ " ln = ln.strip()\n",
750
+ " if ln:\n",
751
+ " items.append(ln)\n",
752
+ " seen=set()\n",
753
+ " out=[]\n",
754
+ " for x in items:\n",
755
+ " k=norm_text(x)\n",
756
+ " if k and k not in seen:\n",
757
+ " seen.add(k); out.append(x)\n",
758
+ " return out\n",
759
+ "\n",
760
+ "def run_batch(text_blob: str, file_obj: Optional[Any], include_antennas: bool):\n",
761
+ " inputs = parse_batch_inputs(text_blob, file_obj)\n",
762
+ " if not inputs:\n",
763
+ " return \"\", pd.DataFrame(), None, \"\"\n",
764
+ "\n",
765
+ " rows=[]\n",
766
+ " for item in inputs:\n",
767
+ " res = resolve_device(item)\n",
768
+ " if res.get(\"mode\") != \"ok\":\n",
769
+ " rows.append({\n",
770
+ " \"Input\": item,\n",
771
+ " \"Matched\": \"\",\n",
772
+ " \"Status\": \"Needs review\",\n",
773
+ " \"EOS\": \"\",\n",
774
+ " \"EOL\": \"\",\n",
775
+ " \"4G alternative\": \"\",\n",
776
+ " \"5G replacement\": \"\",\n",
777
+ " \"Stationary antenna\": \"\",\n",
778
+ " \"Vehicle antenna\": \"\",\n",
779
+ " \"Notes\": \"Not found / ambiguous\"\n",
780
+ " })\n",
781
+ " continue\n",
782
+ "\n",
783
+ " life_row = df_eos.iloc[int(res[\"row_idx\"])]\n",
784
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
785
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=False) # fast: no GPT in batch\n",
786
+ "\n",
787
+ " if include_antennas:\n",
788
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
789
+ " mimo = infer_mimo_for_replacement(repl.get(\"repl_5g\",\"\"), canon_make)\n",
790
+ " 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",
791
+ " ant = antenna_options_for(router_model=repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech=tech, mimo=mimo)\n",
792
+ " stA = ant.get(\"stationary_omni\", {})\n",
793
+ " vhA = ant.get(\"vehicle_omni\", {})\n",
794
+ " ant_s = f\"{stA.get('name','')} {stA.get('part_number','')}\"\n",
795
+ " ant_v = f\"{vhA.get('name','')} {vhA.get('part_number','')}\"\n",
796
+ " else:\n",
797
+ " ant_s = \"\"\n",
798
+ " ant_v = \"\"\n",
799
+ "\n",
800
+ " rows.append({\n",
801
+ " \"Input\": item,\n",
802
+ " \"Matched\": str(life_row.get(\"sku\",\"\")),\n",
803
+ " \"Status\": status,\n",
804
+ " \"EOS\": eos,\n",
805
+ " \"EOL\": eol,\n",
806
+ " \"4G alternative\": repl.get(\"repl_4g\",\"\"),\n",
807
+ " \"5G replacement\": repl.get(\"repl_5g\",\"\"),\n",
808
+ " \"Stationary antenna\": ant_s,\n",
809
+ " \"Vehicle antenna\": ant_v,\n",
810
+ " \"Notes\": \"\",\n",
811
+ " })\n",
812
+ "\n",
813
+ " out_df = pd.DataFrame(rows)\n",
814
+ "\n",
815
+ " # Summary counts + rollup\n",
816
+ " counts = out_df[\"Status\"].value_counts(dropna=False).to_dict()\n",
817
+ " top_5g = out_df[\"5G replacement\"].value_counts(dropna=False).head(5).to_dict()\n",
818
+ " summary = f\"Rows: {len(out_df)} | \" + \" | \".join([f\"{k}: {v}\" for k,v in counts.items()])\n",
819
+ " rollup = \"Top 5G recommendations:\\n\" + \"\\n\".join([f\"- {k}: {v}\" for k,v in top_5g.items() if str(k).strip()])\n",
820
+ "\n",
821
+ " tmp = tempfile.NamedTemporaryFile(delete=False, suffix=\".csv\")\n",
822
+ " out_df.to_csv(tmp.name, index=False)\n",
823
+ "\n",
824
+ " return summary, out_df, tmp.name, rollup\n",
825
+ "\n",
826
+ "\n",
827
+ "# ============================\n",
828
+ "# Gradio app (Single + Batch + Install-ready)\n",
829
+ "# ============================\n",
830
+ "def run_lookup(user_text: str, st: Dict[str,Any]):\n",
831
+ " user_text = str(user_text or \"\").strip()\n",
832
+ " if not user_text:\n",
833
+ " return \"Enter a router SKU/model.\", gr.update(visible=False), gr.update(visible=False), {}, gr.update(value=\"\")\n",
834
+ "\n",
835
+ " res = resolve_device(user_text)\n",
836
+ " if res.get(\"mode\") == \"pick\":\n",
837
+ " opts = res.get(\"options\", [])\n",
838
+ " choices = [o[\"label\"] for o in opts]\n",
839
+ " st2 = {\"mode\":\"pick\",\"options\": opts}\n",
840
+ " return \"Did you mean A or B? Pick one, then click Use selection.\", gr.update(choices=choices, value=None, visible=True), gr.update(visible=True), st2, gr.update(value=\"\")\n",
841
+ "\n",
842
+ " if res.get(\"mode\") != \"ok\":\n",
843
+ " return \"Not found.\", gr.update(visible=False), gr.update(visible=False), {}, gr.update(value=\"\")\n",
844
+ "\n",
845
+ " life_row = df_eos.iloc[int(res[\"row_idx\"])]\n",
846
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
847
+ "\n",
848
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)\n",
849
+ "\n",
850
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
851
+ " mimo = infer_mimo_for_replacement(repl.get(\"repl_5g\",\"\"), canon_make)\n",
852
+ " 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",
853
+ " ant = antenna_options_for(router_model=repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech=tech, mimo=mimo)\n",
854
+ "\n",
855
+ " output = assemble_output(life_row, status, eos, eol, repl, ant)\n",
856
+ " st_out = {\"row_idx\": int(res[\"row_idx\"]), \"repl\": repl, \"ant\": ant}\n",
857
+ " return output, gr.update(visible=False), gr.update(visible=False), st_out, gr.update(value=\"\")\n",
858
+ "\n",
859
+ "def use_selection(selected_label: str, st: Dict[str,Any]):\n",
860
+ " if not st or st.get(\"mode\") != \"pick\":\n",
861
+ " return \"Run a search first.\", gr.update(visible=False), gr.update(visible=False), {}, gr.update(value=\"\")\n",
862
+ " if not selected_label:\n",
863
+ " return \"Pick A or B first.\", gr.update(visible=True), gr.update(visible=True), st, gr.update(value=\"\")\n",
864
+ "\n",
865
+ " chosen_row = None\n",
866
+ " for o in st.get(\"options\", []):\n",
867
+ " if o.get(\"label\") == selected_label:\n",
868
+ " chosen_row = int(o[\"row_idx\"])\n",
869
+ " break\n",
870
+ " if chosen_row is None:\n",
871
+ " return \"Pick a valid option.\", gr.update(visible=True), gr.update(visible=True), st, gr.update(value=\"\")\n",
872
+ "\n",
873
+ " life_row = df_eos.iloc[int(chosen_row)]\n",
874
+ " eos, eol, status = row_to_dates_and_status(life_row)\n",
875
+ " repl = pick_replacements_lifecycle(life_row, status, use_gpt=True)\n",
876
+ "\n",
877
+ " canon_make = str(life_row.get(\"_canon_make\",\"UNKNOWN\"))\n",
878
+ " mimo = infer_mimo_for_replacement(repl.get(\"repl_5g\",\"\"), canon_make)\n",
879
+ " 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",
880
+ " ant = antenna_options_for(router_model=repl.get(\"repl_5g\") or str(life_row.get(\"sku\",\"\")), tech=tech, mimo=mimo)\n",
881
+ "\n",
882
+ " output = assemble_output(life_row, status, eos, eol, repl, ant)\n",
883
+ " st_out = {\"row_idx\": int(chosen_row), \"repl\": repl, \"ant\": ant}\n",
884
+ " return output, gr.update(visible=False), gr.update(visible=False), st_out, gr.update(value=\"\")\n",
885
+ "\n",
886
+ "def make_install_ready(st_state: Dict[str,Any]):\n",
887
+ " if not st_state or \"row_idx\" not in st_state:\n",
888
+ " return \"Run a lookup first.\"\n",
889
+ " life_row = df_eos.iloc[int(st_state[\"row_idx\"])]\n",
890
+ " repl = st_state.get(\"repl\", {}) or {}\n",
891
+ " ant = st_state.get(\"ant\", {}) or {}\n",
892
+ " return install_ready_checklist(life_row, repl, ant)\n",
893
+ "\n",
894
+ "with gr.Blocks(title=\"Only-Routers\") as demo:\n",
895
+ " gr.Markdown(\"## Only-Routers\\nSingle lookup + Batch upload for Verizon reps.\")\n",
896
+ "\n",
897
+ " with gr.Tabs():\n",
898
+ " with gr.Tab(\"Single\"):\n",
899
+ " user_text = gr.Textbox(label=\"Router SKU or model\", placeholder=\"Examples: IBR650B, AER1600, ES450, WR21, RUT240\", lines=1)\n",
900
+ " st = gr.State({})\n",
901
+ "\n",
902
+ " check_btn = gr.Button(\"Check\", variant=\"primary\")\n",
903
+ " pick_dd = gr.Dropdown(label=\"Pick A or B\", choices=[], visible=False)\n",
904
+ " use_btn = gr.Button(\"Use selection\", visible=False)\n",
905
+ "\n",
906
+ " output_md = gr.Markdown()\n",
907
+ "\n",
908
+ " install_btn = gr.Button(\"Make install-ready checklist\")\n",
909
+ " install_md = gr.Markdown()\n",
910
+ "\n",
911
+ " check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st, install_md])\n",
912
+ " use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st, install_md])\n",
913
+ " install_btn.click(fn=make_install_ready, inputs=[st], outputs=[install_md])\n",
914
+ "\n",
915
+ " with gr.Tab(\"Batch\"):\n",
916
+ " gr.Markdown(\"Paste one per line or upload a CSV (first column). Batch runs fast (no GPT), and can optionally include antenna picks.\")\n",
917
+ " batch_text = gr.Textbox(label=\"Paste devices (one per line)\", lines=8, placeholder=\"WR21\\nRUT240\\nIBR650B\")\n",
918
+ " batch_file = gr.File(label=\"Upload CSV\", file_types=[\".csv\"])\n",
919
+ " include_ant = gr.Checkbox(label=\"Include antenna picks (slower)\", value=False)\n",
920
+ " run_btn = gr.Button(\"Run batch\", variant=\"primary\")\n",
921
+ "\n",
922
+ " summary_md = gr.Markdown()\n",
923
+ " rollup_md = gr.Markdown()\n",
924
+ " table = gr.Dataframe(interactive=False, wrap=True)\n",
925
+ " dl = gr.File(label=\"Download results CSV\")\n",
926
+ "\n",
927
+ " run_btn.click(fn=run_batch, inputs=[batch_text, batch_file, include_ant], outputs=[summary_md, table, dl, rollup_md])\n",
928
+ "\n",
929
+ "demo.launch()\n"
930
+ ]
931
+ }
932
+ ],
933
+ "metadata": {
934
+ "kernelspec": {
935
+ "display_name": "Python 3",
936
+ "name": "python3"
937
+ },
938
+ "language_info": {
939
+ "name": "python"
940
+ }
941
+ },
942
+ "nbformat": 4,
943
+ "nbformat_minor": 5
944
+ }