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