Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
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 |
-
#
|
| 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
|
| 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
|
| 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
|
| 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=
|
| 303 |
|
| 304 |
def resolve_device(user_text: str) -> Dict[str, Any]:
|
| 305 |
q = norm_text(user_text)
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
if len(
|
| 310 |
-
|
| 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
|
| 450 |
# ============================
|
| 451 |
-
PARSEC_FAMILY_WORDS = {
|
| 452 |
-
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 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 (
|
| 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}
|
| 714 |
-
lines.append(f" - Vehicle (Omni): **{vh.get('name','')}** (Part #: {vh.get('part_number','')}) — {vh.get('description','')} — MIMO: {vh.get('mimo','')}{conn_v}
|
| 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
|
|
|
|
| 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 |
-
#
|
| 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 |
-
|
| 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)
|
|
|
|
|
|
|
|
|