Spaces:
Runtime error
Runtime error
| import os | |
| import re | |
| import random | |
| from datetime import datetime, timedelta | |
| import pandas as pd | |
| import streamlit as st | |
| st.set_page_config(page_title="AI Lighting Quotation Agent", layout="wide") | |
| st.title("π‘ AI Lighting Quotation Agent (Prototype)") | |
| st.caption("Paste inquiry β extract specs β rank suppliers β recommend pricing β generate quote draft") | |
| # β Docker-safe writable location | |
| DATA_PATH = os.environ.get("SUPPLIER_DB_PATH", "/tmp/supplier_db.csv") | |
| random.seed(42) | |
| PRODUCT_CATALOG = [ | |
| ("LED Panel", ["panel", "led panel", "ceiling panel"], (14, 28)), | |
| ("Downlight", ["downlight", "spot", "spotlight"], (6, 18)), | |
| ("Flood Light", ["flood", "floodlight"], (18, 55)), | |
| ("High Bay", ["high bay", "warehouse bay"], (35, 120)), | |
| ("Tube Light", ["tube", "t8", "batten"], (4, 12)), | |
| ("Track Light", ["track", "rail light"], (10, 30)), | |
| ("Street Light", ["street", "road light"], (40, 160)), | |
| ("LED Strip", ["strip", "led strip", "tape"], (3, 15)), | |
| ] | |
| BRANDS = ["Philips", "Osram", "Panasonic", "Schneider", "Opple", "NVC", "Crompton", "Wipro", "Havells", "Generic"] | |
| REGIONS = ["SG Central", "SG East", "SG West", "SG North", "Johor", "KL", "Batam"] | |
| def make_supplier_name(i: int) -> str: | |
| prefixes = ["Bright", "Nova", "Apex", "Luma", "Spark", "Prime", "Zen", "Vertex", "Delta", "Orion"] | |
| suffixes = ["Lighting", "Electrics", "Solutions", "Supply", "Traders", "Distributors", "Imports", "Wholesale", "Mart", "Hub"] | |
| return f"{random.choice(prefixes)} {random.choice(suffixes)} Pte Ltd #{i:02d}" | |
| def generate_supplier_db(n_suppliers: int = 50) -> pd.DataFrame: | |
| rows = [] | |
| for i in range(1, n_suppliers + 1): | |
| supplier = make_supplier_name(i) | |
| region = random.choice(REGIONS) | |
| supported_categories = random.sample([c[0] for c in PRODUCT_CATALOG], k=random.randint(2, 4)) | |
| reliability = round(random.uniform(0.60, 0.98), 2) | |
| lead_days = random.randint(2, 21) | |
| moq = random.choice([1, 5, 10, 20, 30, 50]) | |
| competitiveness = round(random.uniform(0.85, 1.20), 2) | |
| brands_supported = random.sample(BRANDS, k=random.randint(2, 5)) | |
| rows.append({ | |
| "supplier_id": f"SUP-{1000+i}", | |
| "supplier_name": supplier, | |
| "region": region, | |
| "supported_categories": "|".join(supported_categories), | |
| "brands_supported": "|".join(brands_supported), | |
| "reliability_score": reliability, | |
| "lead_time_days": lead_days, | |
| "moq": moq, | |
| "price_competitiveness_factor": competitiveness, | |
| "contact_email": f"sales{i:02d}@example-supplier.com", | |
| "last_updated": (datetime.today() - timedelta(days=random.randint(0, 60))).strftime("%Y-%m-%d"), | |
| }) | |
| return pd.DataFrame(rows) | |
| def load_or_create_db() -> pd.DataFrame: | |
| if os.path.exists(DATA_PATH): | |
| return pd.read_csv(DATA_PATH) | |
| df = generate_supplier_db(50) | |
| df.to_csv(DATA_PATH, index=False) | |
| return df | |
| df_suppliers = load_or_create_db() | |
| def normalize_text(t: str) -> str: | |
| return re.sub(r"\s+", " ", (t or "").strip().lower()) | |
| def detect_quantity(text: str): | |
| patterns = [ | |
| r"\bqty[:\s]*([0-9]{1,5})\b", | |
| r"\bquantity[:\s]*([0-9]{1,5})\b", | |
| r"\b([0-9]{1,5})\s*(pcs|pc|pieces|nos|units)\b", | |
| ] | |
| for p in patterns: | |
| m = re.search(p, text, flags=re.IGNORECASE) | |
| if m: | |
| return int(m.group(1)) | |
| return None | |
| def detect_wattage(text: str): | |
| m = re.search(r"\b([0-9]{1,4})\s*(w|watt|watts)\b", text, flags=re.IGNORECASE) | |
| return int(m.group(1)) if m else None | |
| def detect_brand(text: str): | |
| t = (text or "").lower() | |
| for b in BRANDS: | |
| if b.lower() in t: | |
| return b | |
| return None | |
| def detect_category(text: str): | |
| t = normalize_text(text) | |
| for category, keywords, _rng in PRODUCT_CATALOG: | |
| for kw in keywords: | |
| if kw in t: | |
| return category | |
| return None | |
| def detect_location(text: str): | |
| t = normalize_text(text) | |
| loc_map = { | |
| "singapore": "SG", | |
| "sg": "SG", | |
| "jurong": "SG West", | |
| "tampines": "SG East", | |
| "woodlands": "SG North", | |
| "batam": "Batam", | |
| "johor": "Johor", | |
| "kuala lumpur": "KL", | |
| "kl": "KL", | |
| } | |
| for k, v in loc_map.items(): | |
| if k in t: | |
| return v | |
| return None | |
| def parse_inquiry(text: str) -> dict: | |
| return { | |
| "raw_text": (text or "").strip(), | |
| "quantity": detect_quantity(text) or 10, | |
| "wattage": detect_wattage(text), | |
| "brand": detect_brand(text), | |
| "category": detect_category(text), | |
| "location": detect_location(text), | |
| } | |
| def estimate_market_range(category: str | None, wattage: int | None): | |
| if not category: | |
| return (10.0, 40.0) | |
| base = None | |
| for c, _kw, rng in PRODUCT_CATALOG: | |
| if c == category: | |
| base = rng | |
| break | |
| if not base: | |
| return (10.0, 40.0) | |
| lo, hi = base | |
| if wattage: | |
| scale = min(2.0, max(0.7, wattage / 18.0)) | |
| lo = lo * (0.85 + 0.15 * scale) | |
| hi = hi * (0.85 + 0.20 * scale) | |
| return (round(lo, 2), round(hi, 2)) | |
| def pick_margin(pricing_mode: str, base_margin: float): | |
| if pricing_mode == "Competitive": | |
| return max(5, base_margin - 6) | |
| if pricing_mode == "High Margin": | |
| return min(40, base_margin + 8) | |
| return base_margin | |
| def compute_offers(req: dict, suppliers: pd.DataFrame, margin_pct: float): | |
| category = req.get("category") | |
| brand = req.get("brand") | |
| qty = int(req.get("quantity") or 10) | |
| candidates = suppliers.copy() | |
| if category: | |
| candidates = candidates[candidates["supported_categories"].astype(str).str.contains(category, na=False)] | |
| if brand: | |
| bm = candidates["brands_supported"].astype(str).str.contains(brand, na=False) | |
| if bm.sum() > 0: | |
| candidates = candidates[bm] | |
| candidates = candidates[candidates["moq"].fillna(1).astype(int) <= qty] | |
| if candidates.empty: | |
| return pd.DataFrame() | |
| market_lo, market_hi = estimate_market_range(category, req.get("wattage")) | |
| market_mid = (market_lo + market_hi) / 2 | |
| rows = [] | |
| for _, s in candidates.iterrows(): | |
| factor = float(s["price_competitiveness_factor"]) | |
| supplier_cost = market_mid * factor * random.uniform(0.92, 1.06) | |
| region = str(s["region"]) | |
| if region in ["Johor", "KL", "Batam"]: | |
| supplier_cost *= 1.05 | |
| supplier_cost = round(supplier_cost, 2) | |
| sell_price = round(supplier_cost / (1 - margin_pct / 100.0), 2) | |
| reliability = float(s["reliability_score"]) | |
| lead = int(s["lead_time_days"]) | |
| score = (1 / max(sell_price, 0.01)) * 100 + reliability * 10 + (1 / max(lead, 1)) * 5 | |
| rows.append({ | |
| "supplier_id": s["supplier_id"], | |
| "supplier_name": s["supplier_name"], | |
| "region": s["region"], | |
| "reliability_score": reliability, | |
| "lead_time_days": lead, | |
| "moq": int(s["moq"]), | |
| "est_supplier_cost_sgd": supplier_cost, | |
| "recommended_sell_price_sgd": sell_price, | |
| "score": round(score, 4), | |
| "contact_email": s["contact_email"], | |
| }) | |
| return pd.DataFrame(rows).sort_values("score", ascending=False).head(10).reset_index(drop=True) | |
| # Sidebar | |
| st.sidebar.header("βοΈ Controls") | |
| base_margin = st.sidebar.slider("Base Margin (%)", 5, 40, 20, 1) | |
| pricing_mode = st.sidebar.radio("Pricing Mode", ["Balanced", "Competitive", "High Margin"], index=0) | |
| top_n = st.sidebar.slider("Top offers to show", 3, 10, 5, 1) | |
| with st.sidebar.expander("π¦ Supplier DB", expanded=False): | |
| st.write(f"Loaded suppliers: **{len(df_suppliers)}**") | |
| st.download_button( | |
| "Download supplier_db.csv", | |
| data=df_suppliers.to_csv(index=False).encode("utf-8"), | |
| file_name="supplier_db.csv", | |
| mime="text/csv", | |
| use_container_width=True, | |
| ) | |
| if st.button("Regenerate DB (50 suppliers)"): | |
| df_suppliers = generate_supplier_db(50) | |
| df_suppliers.to_csv(DATA_PATH, index=False) | |
| st.success("Regenerated supplier database") | |
| st.rerun() | |
| # Main | |
| left, right = st.columns([1.2, 1.0], gap="large") | |
| with left: | |
| st.subheader("1) Paste Customer Inquiry") | |
| sample = "Hi, please quote best price for 50 pcs Philips 18W LED panel light. Delivery to Singapore in 2 weeks." | |
| inquiry = st.text_area("Inquiry", value=sample, height=150) | |
| req = parse_inquiry(inquiry) | |
| st.subheader("2) Agent Step: Requirement Extraction") | |
| st.json(req) | |
| with right: | |
| st.subheader("3) Agent Step: Market Intelligence (Demo)") | |
| market_lo, market_hi = estimate_market_range(req.get("category"), req.get("wattage")) | |
| st.metric("Estimated market low (SGD/unit)", f"{market_lo:.2f}") | |
| st.metric("Estimated market high (SGD/unit)", f"{market_hi:.2f}") | |
| st.divider() | |
| margin_to_use = pick_margin(pricing_mode, base_margin) | |
| st.subheader("4) Agent Step: Supplier Shortlist + Pricing Recommendation") | |
| st.caption(f"Mode: **{pricing_mode}** β Margin applied: **{margin_to_use:.0f}%**") | |
| offers_df = compute_offers(req, df_suppliers, margin_to_use) | |
| if offers_df.empty: | |
| st.error("No matching suppliers found (internal DB).") | |
| st.markdown("### π New Product / No-Match Mode (Prototype)") | |
| st.write("**Agent next actions:**") | |
| st.write("1) Search online for market range and equivalent SKUs.") | |
| st.write("2) Identify relevant supplier categories and shortlist outreach list.") | |
| st.write("3) Auto-send RFQs (email/WhatsApp) and wait for quotes.") | |
| st.write("4) Add new SKU to internal catalog once confirmed.") | |
| else: | |
| st.dataframe(offers_df.head(top_n), use_container_width=True) | |
| best = offers_df.iloc[0].to_dict() | |
| st.success( | |
| f"Recommended: **{best['supplier_name']}** | " | |
| f"Cost **SGD {best['est_supplier_cost_sgd']:.2f}** β Sell **SGD {best['recommended_sell_price_sgd']:.2f}** | " | |
| f"Lead **{best['lead_time_days']}d** | Reliability **{best['reliability_score']:.2f}**" | |
| ) | |
| st.divider() | |
| st.subheader("5) Quote Draft (Copy/Paste Demo)") | |
| company_name = st.text_input("Your company name", value="Delight Lighting (Demo)") | |
| customer_name = st.text_input("Customer name", value="Customer") | |
| quote_valid_days = st.number_input("Quote validity (days)", min_value=1, max_value=30, value=7) | |
| if offers_df.empty: | |
| st.info("Once suppliers match, the quote draft will be generated here.") | |
| else: | |
| qty = int(req.get("quantity") or 10) | |
| category = req.get("category") or "Lighting Product" | |
| brand = req.get("brand") or "Brand-agnostic" | |
| wattage = f"{req.get('wattage')}W" if req.get("wattage") else "" | |
| unit_price = float(offers_df.iloc[0]["recommended_sell_price_sgd"]) | |
| total = round(unit_price * qty, 2) | |
| valid_until = (datetime.today() + timedelta(days=int(quote_valid_days))).strftime("%Y-%m-%d") | |
| quote_text = f"""Subject: Quotation - {brand} {wattage} {category} (Qty: {qty}) | |
| Hi {customer_name}, | |
| Thanks for your inquiry. Please find our quotation below: | |
| Item: {brand} {wattage} {category} | |
| Quantity: {qty} | |
| Unit Price: SGD {unit_price:.2f} | |
| Total: SGD {total:.2f} | |
| Estimated Lead Time: {int(offers_df.iloc[0]["lead_time_days"])} days | |
| Validity: Until {valid_until} | |
| Terms: 50% advance, balance before delivery (demo terms) | |
| Regards, | |
| Sales Team | |
| {company_name} | |
| """ | |
| st.text_area("Generated Quote Draft", value=quote_text, height=240) | |
| st.download_button( | |
| "Download quote draft (.txt)", | |
| data=quote_text.encode("utf-8"), | |
| file_name="quote_draft.txt", | |
| mime="text/plain", | |
| use_container_width=True, | |
| ) | |