crazycrazypete commited on
Commit
3ebb3f1
·
verified ·
1 Parent(s): 6880711

Upload folder using huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +44 -148
app.py CHANGED
@@ -26,10 +26,9 @@ from openai import OpenAI
26
  TODAY = date(2026, 1, 18)
27
  OPENAI_MODEL = "gpt-5.2"
28
  OPENAI_REASONING = {"effort": "high"}
29
-
30
  MATCH_OK = 80
31
- EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
32
 
 
33
  PARSEC_CONTEXT_BEFORE = 900
34
  PARSEC_CONTEXT_AFTER = 1600
35
 
@@ -42,26 +41,7 @@ client = OpenAI(api_key=API_KEY) if API_KEY else None
42
 
43
 
44
  # ============================
45
- # Gradio state helpers
46
- # IMPORTANT: We keep state as a JSON STRING to avoid HF / Gradio API schema crashes.
47
- # ============================
48
- def state_load(st_json: str) -> Dict[str, Any]:
49
- try:
50
- if not st_json:
51
- return {}
52
- return json.loads(st_json) if isinstance(st_json, str) else {}
53
- except Exception:
54
- return {}
55
-
56
- def state_dump(st: Dict[str, Any]) -> str:
57
- try:
58
- return json.dumps(st or {}, ensure_ascii=False)
59
- except Exception:
60
- return "{}"
61
-
62
-
63
- # ============================
64
- # Utilities
65
  # ============================
66
  def norm_text(s: Any) -> str:
67
  try:
@@ -95,17 +75,14 @@ def gpt_json(system: str, payload: Dict[str, Any], max_tokens: int = 600) -> Dic
95
  resp = client.responses.create(
96
  model=OPENAI_MODEL,
97
  reasoning=OPENAI_REASONING,
98
- input=[
99
- {"role": "system", "content": system},
100
- {"role": "user", "content": json.dumps(payload)},
101
- ],
102
  max_output_tokens=max_tokens,
103
  )
104
  return json_load_safe(getattr(resp, "output_text", "") or "")
105
 
106
 
107
  # ============================
108
- # Load data files (must exist in repo)
109
  # ============================
110
  EOS_PATH = "routers_eos_eol_by_sku.csv"
111
  DEC_PATH = "dec2025routers.csv"
@@ -121,7 +98,6 @@ if not os.path.exists(PARSEC_PDF):
121
  df_eos = pd.read_csv(EOS_PATH).copy()
122
  df_dec = pd.read_csv(DEC_PATH).copy()
123
 
124
- # Region filter: keep USA / North America / blank / not specified
125
  def region_ok(x: Any) -> bool:
126
  s = str(x or "").strip().lower()
127
  if not s:
@@ -141,13 +117,6 @@ def region_ok(x: Any) -> bool:
141
  if "region" in df_eos.columns:
142
  df_eos = df_eos[df_eos["region"].apply(region_ok)].reset_index(drop=True)
143
 
144
- # Optional Device Type
145
- device_type_col = None
146
- for c in df_eos.columns:
147
- if norm_text(c) == "device type":
148
- device_type_col = c
149
- break
150
-
151
  # Maker mapping (includes Teltonika)
152
  CANON_MAKER = {
153
  "CRADLEPOINT": {"cradlepoint", "ericsson", "ericsson enterprise wireless"},
@@ -270,7 +239,7 @@ parsec_index.add(parsec_emb)
270
 
271
 
272
  # ============================
273
- # Device resolution (exact SKU -> GPT A/B)
274
  # ============================
275
  def label_for_row(i: int) -> str:
276
  r = df_eos.iloc[i]
@@ -294,22 +263,20 @@ def gpt_choose_device(user_text: str, candidates: List[Tuple[int,int,str]]) -> D
294
  "user_input": user_text,
295
  "candidates": [{"row_idx": i, "score": s, "label": lbl} for (i,s,lbl) in candidates],
296
  "rules": [
297
- "If one candidate is clearly correct, return mode='ok' with row_idx.",
298
  "If two are plausible, return mode='pick' with top 2 options."
299
  ],
300
  "output_schema": {"mode":"ok|pick","row_idx":"int","options":[{"row_idx":"int","label":"string"}]}
301
  }
302
- return gpt_json(sys, payload, max_tokens=300)
303
 
304
  def resolve_device(user_text: str) -> Dict[str, Any]:
305
  q = norm_text(user_text)
306
-
307
- # Exact SKU match first
308
- exact_idxs = df_eos.index[df_eos["_norm_sku"] == q].tolist()
309
- if len(exact_idxs) == 1:
310
- return {"mode":"ok","row_idx": int(exact_idxs[0])}
311
- if len(exact_idxs) > 1:
312
- opts = [{"row_idx": int(i), "label": EOS_LABELS[int(i)]} for i in exact_idxs[:2]]
313
  return {"mode":"pick","options": opts}
314
 
315
  cands = local_candidates(user_text, top_k=6)
@@ -329,7 +296,6 @@ def resolve_device(user_text: str) -> Dict[str, Any]:
329
  if opts2:
330
  return {"mode":"pick","options": opts2}
331
 
332
- # fallback top 2
333
  if len(cands) > 1:
334
  return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]},{"row_idx":cands[1][0],"label":cands[1][2]}]}
335
  return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]}]}
@@ -414,7 +380,6 @@ def pick_replacements_lifecycle(row: pd.Series, status: str, use_gpt: bool = Tru
414
  is_4g = device_is_4g(row)
415
  want_5g = is_4g or (status in {"End of Sale","End of Life"})
416
 
417
- # 4G alternative ALWAYS for 4G devices
418
  repl_4g = "Not applicable"
419
  if is_4g:
420
  repl_4g = extract_model_token(safe_str(row.get("suggested_replacement","")))
@@ -424,7 +389,6 @@ def pick_replacements_lifecycle(row: pd.Series, status: str, use_gpt: bool = Tru
424
  if not repl_4g:
425
  repl_4g = "Not applicable"
426
 
427
- # 5G replacement ALWAYS when want_5g
428
  repl_5g = "Not listed"
429
  if want_5g:
430
  repl_5g = extract_model_token(safe_str(row.get("advanced_5g_option","")))
@@ -437,26 +401,14 @@ def pick_replacements_lifecycle(row: pd.Series, status: str, use_gpt: bool = Tru
437
  if repl_5g.lower() == "nan":
438
  repl_5g = "Not listed"
439
 
440
- return {
441
- "repl_4g": repl_4g,
442
- "repl_5g": repl_5g,
443
- "why": "Lifecycle replacements (GPT fallback when missing).",
444
- "sources": ["lifecycle_csv"] + (["gpt"] if (use_gpt and client) else []) + (["dec_fallback"] if (want_5g and repl_5g == "Not listed") else []),
445
- }
446
 
447
 
448
  # ============================
449
- # Antennas (Parsec-only; family + connectors hint)
450
  # ============================
451
- PARSEC_FAMILY_WORDS = {
452
- "chinook","labrador","boxer","bloodhound","husky","beagle","mastiff","collie",
453
- "shepherd","belgian","australian","terrier","pyrenees"
454
- }
455
- BAD_NAME_MARKERS = {
456
- "customization", "standard connectors", "connectors", "features", "benefits",
457
- "specifications", "mechanical", "electrical", "mounting", "accessories",
458
- "description:", "standard sku"
459
- }
460
 
461
  def clean_line(s: str) -> str:
462
  s = re.sub(r"\s+", " ", str(s or "").strip())
@@ -482,34 +434,18 @@ def family_from_line(line: str) -> str:
482
  def parsec_connectors_from_card(t: str) -> str:
483
  m = re.search(r"Standard\s+Connectors:\s*(.+)", t, flags=re.IGNORECASE)
484
  if m:
485
- val = re.sub(r"\s+", " ", m.group(1).strip())
486
- return val[:80]
487
  return ""
488
 
489
  def parsec_name_from_card(card_text: str) -> str:
490
  lines = [clean_line(ln) for ln in str(card_text or "").splitlines()]
491
  lines = [ln for ln in lines if ln]
492
-
493
  for ln in lines:
494
  if is_bad_name_line(ln):
495
  continue
496
  fam = family_from_line(ln)
497
  if fam:
498
  return fam
499
-
500
- sku_i = None
501
- for i, ln in enumerate(lines):
502
- if "standard sku" in ln.lower():
503
- sku_i = i
504
- break
505
- if sku_i is not None:
506
- window = lines[max(0, sku_i - 12):sku_i]
507
- for ln in reversed(window):
508
- if is_bad_name_line(ln):
509
- continue
510
- if 3 <= len(ln) <= 40 and re.search(r"[A-Za-z]", ln):
511
- return ln.split()[0].capitalize()
512
-
513
  return "Parsec antenna"
514
 
515
  def parsec_part_from_card(t: str) -> str:
@@ -524,7 +460,7 @@ def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:
524
  qv = embedder.encode([query], normalize_embeddings=True)
525
  qv = np.asarray(qv, dtype=np.float32)
526
  scores, ids = parsec_index.search(qv, top_k)
527
- out = []
528
  for sc, i in zip(scores[0].tolist(), ids[0].tolist()):
529
  if 0 <= int(i) < len(parsec_cards):
530
  card = parsec_cards[int(i)]
@@ -538,7 +474,6 @@ def parsec_retrieve(query: str, top_k: int = 10) -> List[Dict[str, Any]]:
538
  return out
539
 
540
  def infer_mimo_for_5g(model: str, canon_make: str) -> str:
541
- # Use dec when possible; else simple heuristic
542
  if not model or model in {"Not applicable","Not listed"}:
543
  return "2x2"
544
  pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
@@ -556,7 +491,6 @@ def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, An
556
  q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile Parsec"
557
  cand_stationary = parsec_retrieve(q_stationary, top_k=10)
558
  cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)
559
-
560
  s = cand_stationary[0] if cand_stationary else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
561
  v = cand_vehicle[0] if cand_vehicle else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
562
  s.update({"mimo": mimo, "why": "Stationary omni best match."})
@@ -565,19 +499,14 @@ def antenna_options_for(router_model: str, tech: str, mimo: str) -> Dict[str, An
565
 
566
 
567
  # ============================
568
- # Install-ready checklist (Feature #9)
569
  # ============================
570
  def install_ready_checklist(current_sku: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
571
  st = ant.get("stationary_omni", {})
572
  vh = ant.get("vehicle_omni", {})
573
  if client is not None:
574
  sys = "Create a short, install-ready checklist for a Verizon rep. Return markdown only."
575
- payload = {
576
- "current_device": current_sku,
577
- "replacements": repl,
578
- "antennas": {"stationary": st, "vehicle": vh},
579
- "rules": ["Keep it short. Include power + mount + cables + next steps."]
580
- }
581
  resp = client.responses.create(
582
  model=OPENAI_MODEL,
583
  reasoning=OPENAI_REASONING,
@@ -585,24 +514,19 @@ def install_ready_checklist(current_sku: str, repl: Dict[str,Any], ant: Dict[str
585
  max_output_tokens=520,
586
  )
587
  return (getattr(resp, "output_text", "") or "").strip()
588
-
589
- lines = []
590
- lines.append("### Install-ready checklist")
591
- lines.append(f"- Current device: {current_sku}")
592
- lines.append(f"- 5G replacement: {repl.get('repl_5g','')}")
593
- lines.append(f"- 4G alternative: {repl.get('repl_4g','Not applicable')}")
594
- lines.append(f"- Stationary omni antenna: {st.get('name','')} (PN {st.get('part_number','')})")
595
- lines.append(f"- Vehicle omni antenna: {vh.get('name','')} (PN {vh.get('part_number','')})")
596
- if st.get("connectors"):
597
- lines.append(f"- Stationary connectors: {st.get('connectors')}")
598
- if vh.get("connectors"):
599
- lines.append(f"- Vehicle connectors: {vh.get('connectors')}")
600
- lines.append("- Next steps: confirm cable lengths + mounting + power; place order; schedule install.")
601
- return "\n".join(lines)
602
 
603
 
604
  # ============================
605
- # Batch mode (Feature #4) — NO GPT for speed
606
  # ============================
607
  def parse_batch_inputs(text_blob: str, file_obj: Any) -> List[str]:
608
  items: List[str] = []
@@ -636,36 +560,13 @@ def run_batch(text_blob: str, file_obj: Any, include_antennas: bool):
636
  for item in inputs:
637
  res = resolve_device(item)
638
  if res.get("mode") != "ok":
639
- rows.append({
640
- "Input": item,
641
- "Matched": "",
642
- "Status": "Needs review",
643
- "EOS": "",
644
- "EOL": "",
645
- "4G alternative": "",
646
- "5G replacement": "",
647
- "Stationary antenna": "",
648
- "Vehicle antenna": "",
649
- "Notes": "Not found / ambiguous"
650
- })
651
  continue
652
 
653
  life_row = df_eos.iloc[int(res["row_idx"])]
654
  eos, eol, status = row_to_dates_and_status(life_row)
655
  repl = pick_replacements_lifecycle(life_row, status, use_gpt=False)
656
 
657
- stA = ""
658
- vhA = ""
659
- if include_antennas:
660
- canon_make = str(life_row.get("_canon_make","UNKNOWN"))
661
- mimo = infer_mimo_for_5g(repl.get("repl_5g",""), canon_make)
662
- tech = "5G" if repl.get("repl_5g") and repl.get("repl_5g") != "Not listed" else ("4G" if device_is_4g(life_row) else "Unknown")
663
- ant = antenna_options_for(repl.get("repl_5g") or str(life_row.get("sku","")), tech, mimo)
664
- s = ant.get("stationary_omni", {})
665
- v = ant.get("vehicle_omni", {})
666
- stA = f"{s.get('name','')} {s.get('part_number','')}"
667
- vhA = f"{v.get('name','')} {v.get('part_number','')}"
668
-
669
  rows.append({
670
  "Input": item,
671
  "Matched": str(life_row.get("sku","")),
@@ -674,13 +575,10 @@ def run_batch(text_blob: str, file_obj: Any, include_antennas: bool):
674
  "EOL": eol,
675
  "4G alternative": repl.get("repl_4g",""),
676
  "5G replacement": repl.get("repl_5g",""),
677
- "Stationary antenna": stA,
678
- "Vehicle antenna": vhA,
679
  "Notes": "",
680
  })
681
 
682
  out_df = pd.DataFrame(rows)
683
-
684
  counts = out_df["Status"].value_counts(dropna=False).to_dict()
685
  top_5g = out_df["5G replacement"].value_counts(dropna=False).head(5).to_dict()
686
  summary = f"Rows: {len(out_df)} | " + " | ".join([f"{k}: {v}" for k,v in counts.items()])
@@ -710,8 +608,8 @@ def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl:
710
  lines.append("7. Antenna options (Parsec-only):")
711
  conn_s = f" | Conn: {st.get('connectors','')}" if st.get("connectors") else ""
712
  conn_v = f" | Conn: {vh.get('connectors','')}" if vh.get("connectors") else ""
713
- lines.append(f" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')}{conn_s} — {st.get('why','')}")
714
- lines.append(f" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')}{conn_v} — {vh.get('why','')}")
715
 
716
  lines.append("\nSources (debug):")
717
  for s in repl.get("sources", []) if isinstance(repl.get("sources"), list) else []:
@@ -722,7 +620,8 @@ def assemble_output(life_row: pd.Series, status: str, eos: str, eol: str, repl:
722
 
723
 
724
  # ============================
725
- # Gradio callbacks (NO dict state)
 
726
  # ============================
727
  def run_lookup(user_text: str, st_json: str):
728
  user_text = str(user_text or "").strip()
@@ -730,6 +629,7 @@ def run_lookup(user_text: str, st_json: str):
730
  return "Enter a router SKU/model.", gr.update(visible=False), gr.update(visible=False), "{}", ""
731
 
732
  res = resolve_device(user_text)
 
733
  if res.get("mode") == "pick":
734
  opts = res.get("options", [])
735
  choices = [o["label"] for o in opts]
@@ -791,7 +691,7 @@ def make_install_ready(st_json: str):
791
 
792
 
793
  # ============================
794
- # Gradio UI
795
  # ============================
796
  with gr.Blocks(title="Only-Routers") as demo:
797
  gr.Markdown("## Only-Routers\nSingle lookup + Batch upload for Verizon reps.")
@@ -799,7 +699,7 @@ with gr.Blocks(title="Only-Routers") as demo:
799
  with gr.Tabs():
800
  with gr.Tab("Single"):
801
  user_text = gr.Textbox(label="Router SKU or model", placeholder="Examples: IBR650B, AER1600, ES450, WR21, RUT240", lines=1)
802
- st = gr.State("{}")
803
 
804
  check_btn = gr.Button("Check", variant="primary")
805
  pick_dd = gr.Dropdown(label="Pick A or B", choices=[], visible=False)
@@ -810,9 +710,9 @@ with gr.Blocks(title="Only-Routers") as demo:
810
  install_btn = gr.Button("Make install-ready checklist")
811
  install_md = gr.Markdown()
812
 
813
- check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st, install_md])
814
- use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st, install_md])
815
- install_btn.click(fn=make_install_ready, inputs=[st], outputs=[install_md])
816
 
817
  with gr.Tab("Batch"):
818
  gr.Markdown("Paste one per line or upload a CSV (first column). Batch runs fast (no GPT).")
@@ -826,11 +726,7 @@ with gr.Blocks(title="Only-Routers") as demo:
826
  table = gr.Dataframe(interactive=False, wrap=True)
827
  dl = gr.File(label="Download results CSV")
828
 
829
- run_btn.click(fn=run_batch, inputs=[batch_text, batch_file, include_ant], outputs=[summary_md, table, dl, rollup_md])
830
-
831
 
832
- # ============================
833
- # Launch (Hugging Face Spaces)
834
- # NOTE: Don't use share=True on Spaces.
835
- # ============================
836
- demo.launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT", "7860")), show_api=False)
 
26
  TODAY = date(2026, 1, 18)
27
  OPENAI_MODEL = "gpt-5.2"
28
  OPENAI_REASONING = {"effort": "high"}
 
29
  MATCH_OK = 80
 
30
 
31
+ EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
32
  PARSEC_CONTEXT_BEFORE = 900
33
  PARSEC_CONTEXT_AFTER = 1600
34
 
 
41
 
42
 
43
  # ============================
44
+ # Helpers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  # ============================
46
  def norm_text(s: Any) -> str:
47
  try:
 
75
  resp = client.responses.create(
76
  model=OPENAI_MODEL,
77
  reasoning=OPENAI_REASONING,
78
+ input=[{"role":"system","content":system},{"role":"user","content":json.dumps(payload)}],
 
 
 
79
  max_output_tokens=max_tokens,
80
  )
81
  return json_load_safe(getattr(resp, "output_text", "") or "")
82
 
83
 
84
  # ============================
85
+ # Load data
86
  # ============================
87
  EOS_PATH = "routers_eos_eol_by_sku.csv"
88
  DEC_PATH = "dec2025routers.csv"
 
98
  df_eos = pd.read_csv(EOS_PATH).copy()
99
  df_dec = pd.read_csv(DEC_PATH).copy()
100
 
 
101
  def region_ok(x: Any) -> bool:
102
  s = str(x or "").strip().lower()
103
  if not s:
 
117
  if "region" in df_eos.columns:
118
  df_eos = df_eos[df_eos["region"].apply(region_ok)].reset_index(drop=True)
119
 
 
 
 
 
 
 
 
120
  # Maker mapping (includes Teltonika)
121
  CANON_MAKER = {
122
  "CRADLEPOINT": {"cradlepoint", "ericsson", "ericsson enterprise wireless"},
 
239
 
240
 
241
  # ============================
242
+ # Device resolution
243
  # ============================
244
  def label_for_row(i: int) -> str:
245
  r = df_eos.iloc[i]
 
263
  "user_input": user_text,
264
  "candidates": [{"row_idx": i, "score": s, "label": lbl} for (i,s,lbl) in candidates],
265
  "rules": [
266
+ "If one is clearly correct, return mode='ok' with row_idx.",
267
  "If two are plausible, return mode='pick' with top 2 options."
268
  ],
269
  "output_schema": {"mode":"ok|pick","row_idx":"int","options":[{"row_idx":"int","label":"string"}]}
270
  }
271
+ return gpt_json(sys, payload, max_tokens=280)
272
 
273
  def resolve_device(user_text: str) -> Dict[str, Any]:
274
  q = norm_text(user_text)
275
+ exact = df_eos.index[df_eos["_norm_sku"] == q].tolist()
276
+ if len(exact) == 1:
277
+ return {"mode":"ok","row_idx": int(exact[0])}
278
+ if len(exact) > 1:
279
+ opts = [{"row_idx": int(i), "label": EOS_LABELS[int(i)]} for i in exact[:2]]
 
 
280
  return {"mode":"pick","options": opts}
281
 
282
  cands = local_candidates(user_text, top_k=6)
 
296
  if opts2:
297
  return {"mode":"pick","options": opts2}
298
 
 
299
  if len(cands) > 1:
300
  return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]},{"row_idx":cands[1][0],"label":cands[1][2]}]}
301
  return {"mode":"pick","options":[{"row_idx":cands[0][0],"label":cands[0][2]}]}
 
380
  is_4g = device_is_4g(row)
381
  want_5g = is_4g or (status in {"End of Sale","End of Life"})
382
 
 
383
  repl_4g = "Not applicable"
384
  if is_4g:
385
  repl_4g = extract_model_token(safe_str(row.get("suggested_replacement","")))
 
389
  if not repl_4g:
390
  repl_4g = "Not applicable"
391
 
 
392
  repl_5g = "Not listed"
393
  if want_5g:
394
  repl_5g = extract_model_token(safe_str(row.get("advanced_5g_option","")))
 
401
  if repl_5g.lower() == "nan":
402
  repl_5g = "Not listed"
403
 
404
+ return {"repl_4g": repl_4g, "repl_5g": repl_5g, "sources": ["lifecycle_csv"] + (["gpt"] if (use_gpt and client) else [])}
 
 
 
 
 
405
 
406
 
407
  # ============================
408
+ # Antennas (Parsec-only)
409
  # ============================
410
+ PARSEC_FAMILY_WORDS = {"chinook","labrador","boxer","bloodhound","husky","beagle","mastiff","collie","shepherd","belgian","australian","terrier","pyrenees"}
411
+ BAD_NAME_MARKERS = {"customization","standard connectors","connectors","features","benefits","specifications","mechanical","electrical","mounting","accessories","description:","standard sku"}
 
 
 
 
 
 
 
412
 
413
  def clean_line(s: str) -> str:
414
  s = re.sub(r"\s+", " ", str(s or "").strip())
 
434
  def parsec_connectors_from_card(t: str) -> str:
435
  m = re.search(r"Standard\s+Connectors:\s*(.+)", t, flags=re.IGNORECASE)
436
  if m:
437
+ return re.sub(r"\s+", " ", m.group(1).strip())[:80]
 
438
  return ""
439
 
440
  def parsec_name_from_card(card_text: str) -> str:
441
  lines = [clean_line(ln) for ln in str(card_text or "").splitlines()]
442
  lines = [ln for ln in lines if ln]
 
443
  for ln in lines:
444
  if is_bad_name_line(ln):
445
  continue
446
  fam = family_from_line(ln)
447
  if fam:
448
  return fam
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
  return "Parsec antenna"
450
 
451
  def parsec_part_from_card(t: str) -> str:
 
460
  qv = embedder.encode([query], normalize_embeddings=True)
461
  qv = np.asarray(qv, dtype=np.float32)
462
  scores, ids = parsec_index.search(qv, top_k)
463
+ out: List[Dict[str, Any]] = []
464
  for sc, i in zip(scores[0].tolist(), ids[0].tolist()):
465
  if 0 <= int(i) < len(parsec_cards):
466
  card = parsec_cards[int(i)]
 
474
  return out
475
 
476
  def infer_mimo_for_5g(model: str, canon_make: str) -> str:
 
477
  if not model or model in {"Not applicable","Not listed"}:
478
  return "2x2"
479
  pool = df_dec[df_dec["_canon_make"] == canon_make].copy()
 
491
  q_vehicle = f"{router_model} {tech} {mimo} omni vehicle mobile Parsec"
492
  cand_stationary = parsec_retrieve(q_stationary, top_k=10)
493
  cand_vehicle = parsec_retrieve(q_vehicle, top_k=10)
 
494
  s = cand_stationary[0] if cand_stationary else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
495
  v = cand_vehicle[0] if cand_vehicle else {"name":"Parsec antenna","part_number":"","description":"","connectors":""}
496
  s.update({"mimo": mimo, "why": "Stationary omni best match."})
 
499
 
500
 
501
  # ============================
502
+ # Install-ready checklist
503
  # ============================
504
  def install_ready_checklist(current_sku: str, repl: Dict[str,Any], ant: Dict[str,Any]) -> str:
505
  st = ant.get("stationary_omni", {})
506
  vh = ant.get("vehicle_omni", {})
507
  if client is not None:
508
  sys = "Create a short, install-ready checklist for a Verizon rep. Return markdown only."
509
+ payload = {"current_device": current_sku, "replacements": repl, "antennas": {"stationary": st, "vehicle": vh}}
 
 
 
 
 
510
  resp = client.responses.create(
511
  model=OPENAI_MODEL,
512
  reasoning=OPENAI_REASONING,
 
514
  max_output_tokens=520,
515
  )
516
  return (getattr(resp, "output_text", "") or "").strip()
517
+ return "\n".join([
518
+ "### Install-ready checklist",
519
+ f"- Current device: {current_sku}",
520
+ f"- 5G replacement: {repl.get('repl_5g','')}",
521
+ f"- 4G alternative: {repl.get('repl_4g','Not applicable')}",
522
+ f"- Stationary omni antenna: {st.get('name','')} (PN {st.get('part_number','')})",
523
+ f"- Vehicle omni antenna: {vh.get('name','')} (PN {vh.get('part_number','')})",
524
+ "- Next steps: confirm mounting + cable lengths + power; place order; schedule install.",
525
+ ])
 
 
 
 
 
526
 
527
 
528
  # ============================
529
+ # Batch mode (NO GPT)
530
  # ============================
531
  def parse_batch_inputs(text_blob: str, file_obj: Any) -> List[str]:
532
  items: List[str] = []
 
560
  for item in inputs:
561
  res = resolve_device(item)
562
  if res.get("mode") != "ok":
563
+ rows.append({"Input": item, "Matched":"", "Status":"Needs review", "EOS":"", "EOL":"", "4G alternative":"", "5G replacement":"", "Notes":"Not found/ambiguous"})
 
 
 
 
 
 
 
 
 
 
 
564
  continue
565
 
566
  life_row = df_eos.iloc[int(res["row_idx"])]
567
  eos, eol, status = row_to_dates_and_status(life_row)
568
  repl = pick_replacements_lifecycle(life_row, status, use_gpt=False)
569
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  rows.append({
571
  "Input": item,
572
  "Matched": str(life_row.get("sku","")),
 
575
  "EOL": eol,
576
  "4G alternative": repl.get("repl_4g",""),
577
  "5G replacement": repl.get("repl_5g",""),
 
 
578
  "Notes": "",
579
  })
580
 
581
  out_df = pd.DataFrame(rows)
 
582
  counts = out_df["Status"].value_counts(dropna=False).to_dict()
583
  top_5g = out_df["5G replacement"].value_counts(dropna=False).head(5).to_dict()
584
  summary = f"Rows: {len(out_df)} | " + " | ".join([f"{k}: {v}" for k,v in counts.items()])
 
608
  lines.append("7. Antenna options (Parsec-only):")
609
  conn_s = f" | Conn: {st.get('connectors','')}" if st.get("connectors") else ""
610
  conn_v = f" | Conn: {vh.get('connectors','')}" if vh.get("connectors") else ""
611
+ lines.append(f" - Stationary (Omni): **{st.get('name','')}** (Part #: {st.get('part_number','')}) — {st.get('description','')} — MIMO: {st.get('mimo','')}{conn_s}")
612
+ lines.append(f" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')}{conn_v}")
613
 
614
  lines.append("\nSources (debug):")
615
  for s in repl.get("sources", []) if isinstance(repl.get("sources"), list) else []:
 
620
 
621
 
622
  # ============================
623
+ # Gradio callbacks
624
+ # IMPORTANT: no dict state and ALL events have api_name=False (prevents api_info schema generation)
625
  # ============================
626
  def run_lookup(user_text: str, st_json: str):
627
  user_text = str(user_text or "").strip()
 
629
  return "Enter a router SKU/model.", gr.update(visible=False), gr.update(visible=False), "{}", ""
630
 
631
  res = resolve_device(user_text)
632
+
633
  if res.get("mode") == "pick":
634
  opts = res.get("options", [])
635
  choices = [o["label"] for o in opts]
 
691
 
692
 
693
  # ============================
694
+ # UI
695
  # ============================
696
  with gr.Blocks(title="Only-Routers") as demo:
697
  gr.Markdown("## Only-Routers\nSingle lookup + Batch upload for Verizon reps.")
 
699
  with gr.Tabs():
700
  with gr.Tab("Single"):
701
  user_text = gr.Textbox(label="Router SKU or model", placeholder="Examples: IBR650B, AER1600, ES450, WR21, RUT240", lines=1)
702
+ st = gr.State("{}") # JSON string
703
 
704
  check_btn = gr.Button("Check", variant="primary")
705
  pick_dd = gr.Dropdown(label="Pick A or B", choices=[], visible=False)
 
710
  install_btn = gr.Button("Make install-ready checklist")
711
  install_md = gr.Markdown()
712
 
713
+ check_btn.click(fn=run_lookup, inputs=[user_text, st], outputs=[output_md, pick_dd, use_btn, st, install_md], api_name=False)
714
+ use_btn.click(fn=use_selection, inputs=[pick_dd, st], outputs=[output_md, pick_dd, use_btn, st, install_md], api_name=False)
715
+ install_btn.click(fn=make_install_ready, inputs=[st], outputs=[install_md], api_name=False)
716
 
717
  with gr.Tab("Batch"):
718
  gr.Markdown("Paste one per line or upload a CSV (first column). Batch runs fast (no GPT).")
 
726
  table = gr.Dataframe(interactive=False, wrap=True)
727
  dl = gr.File(label="Download results CSV")
728
 
729
+ run_btn.click(fn=run_batch, inputs=[batch_text, batch_file, include_ant], outputs=[summary_md, table, dl, rollup_md], api_name=False)
 
730
 
731
+ # IMPORTANT: On Spaces, demo.launch() is correct; do NOT use share=True.
732
+ demo.launch(show_api=False)