ProcurementGPT5 / app.py
PD03's picture
Update app.py
a123290 verified
import os
import time
import json
import random
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
import streamlit as st
import plotly.express as px
from streamlit_option_menu import option_menu
from faker import Faker
from datetime import datetime, timedelta
# =============================
# Page / Theme Configuration
# =============================
st.set_page_config(
page_title="SAP S/4HANA Agentic AI Procurement Analytics",
page_icon="🤖",
layout="wide",
initial_sidebar_state="expanded",
)
# --- CSS ---
st.markdown(
"""
<style>
:root {
--primary-color: #0066cc;
--secondary-color: #f0f8ff;
--accent-color: #ff6b35;
--success-color: #28a745;
--warning-color: #ffc107;
--danger-color: #dc3545;
}
#MainMenu {visibility: hidden;}
footer {visibility: hidden;}
header {visibility: hidden;}
.main-header {
background: linear-gradient(90deg, #0066cc, #004c99);
padding: 1rem;
border-radius: 10px;
margin-bottom: 2rem;
color: white;
text-align: center;
}
.metric-card {
background: white;
padding: 1.25rem;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
border-left: 4px solid var(--primary-color);
margin-bottom: 1rem;
}
.ai-insight {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem;
border-radius: 12px;
margin: 1rem 0;
}
.alert { padding: 1rem; border-radius: 10px; margin: 0.6rem 0; border-left: 4px solid; }
.alert-success { background-color: #d4edda; border-color: var(--success-color); color: #155724; }
.alert-warning { background-color: #fff3cd; border-color: var(--warning-color); color: #856404; }
.alert-info { background-color: #d1ecf1; border-color: #17a2b8; color: #0c5460; }
.stButton > button { background: linear-gradient(90deg, #0066cc, #004c99); color: white; border: none; border-radius: 8px; padding: 0.5rem 1rem; font-weight: 600; transition: all 0.2s ease; }
.stButton > button:hover { transform: translateY(-1px); box-shadow: 0 6px 14px rgba(0,0,0,0.15); }
</style>
""",
unsafe_allow_html=True,
)
# =============================
# Currency helpers (₹ with Indian grouping)
# =============================
CURRENCY = "₹"
def format_inr(x: float) -> str:
"""Format number with Indian digit grouping, no decimals."""
try:
n = int(round(float(x)))
except Exception:
return f"{CURRENCY}{x}"
s = str(n)
if len(s) <= 3:
return f"{CURRENCY}{s}"
last3 = s[-3:]
rest = s[:-3]
parts = []
while len(rest) > 2:
parts.insert(0, rest[-2:])
rest = rest[:-2]
if rest:
parts.insert(0, rest)
return f"{CURRENCY}{','.join(parts + [last3])}"
def fmt_currency(x: float) -> str:
return format_inr(x)
# =============================
# LLM client (resilient)
# =============================
@dataclass
class LLMConfig:
base_url: Optional[str] = os.getenv("OPENAI_BASE_URL")
api_key: Optional[str] = (
os.getenv("OPENAI_API_KEY")
or os.getenv("OPENAI_API_TOKEN")
or os.getenv("OPENAI_KEY")
)
model: str = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
timeout: int = int(os.getenv("OPENAI_TIMEOUT", "45"))
max_retries: int = int(os.getenv("OPENAI_MAX_RETRIES", "5"))
temperature: float = float(os.getenv("OPENAI_TEMPERATURE", "0.5"))
def _post_json(url: str, headers: Dict[str, str], payload: Dict[str, Any], timeout: int):
import requests
return requests.post(url, headers=headers, json=payload, timeout=timeout)
class UniversalLLMClient:
def __init__(self, cfg: LLMConfig):
self.cfg = cfg
self.available = bool(cfg.api_key)
self.last_error: Optional[str] = None
if self.available:
self._smoke_test()
def _headers(self) -> Dict[str, str]:
return {"Authorization": f"Bearer {self.cfg.api_key}", "Content-Type": "application/json"}
def _base_url(self) -> str:
return (self.cfg.base_url or "https://api.openai.com/v1").rstrip("/")
def _smoke_test(self) -> None:
try:
_ = self.chat([{"role": "user", "content": "ping"}], max_tokens=4)
except Exception as e:
self.available = False
self.last_error = str(e)
def chat(self, messages: List[Dict[str, str]], max_tokens: int = 400) -> str:
if not self.available:
raise RuntimeError("No API key configured")
headers = self._headers()
base = self._base_url()
chat_url = f"{base}/chat/completions"
payload = {
"model": self.cfg.model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": self.cfg.temperature,
}
delay = 1.0
for attempt in range(self.cfg.max_retries):
try:
resp = _post_json(chat_url, headers, payload, self.cfg.timeout)
if resp.status_code == 200:
data = resp.json()
return data["choices"][0]["message"]["content"].strip()
if resp.status_code in (429, 500, 502, 503, 504):
time.sleep(delay)
delay = min(delay * 2, 8.0)
continue
try:
j = resp.json()
msg = j.get("error", {}).get("message", str(j))
except Exception:
msg = resp.text
raise RuntimeError(f"API error {resp.status_code}: {msg}")
except Exception as e:
if attempt == self.cfg.max_retries - 1:
self.last_error = str(e)
raise
time.sleep(delay)
delay = min(delay * 2, 8.0)
raise RuntimeError("Exhausted retries")
# =============================
# Data Generation
# =============================
@st.cache_data(show_spinner=False)
def generate_synthetic_procurement_data(seed: int = 42) -> Tuple[pd.DataFrame, pd.DataFrame]:
fake = Faker()
np.random.seed(seed)
random.seed(seed)
vendors = [
"Siemens AG", "BASF SE", "BMW Group", "Mercedes-Benz", "Bosch GmbH",
"ThyssenKrupp", "Bayer AG", "Continental AG", "Henkel AG", "SAP SE",
]
categories = [
"Raw Materials", "Components", "Packaging", "Services",
"IT Equipment", "Office Supplies", "Machinery", "Chemicals",
]
purchase_orders: List[Dict[str, Any]] = []
for i in range(900):
order_date = fake.date_between(start_date='-24m', end_date='today')
promised_days = random.randint(3, 30)
promised_date = order_date + timedelta(days=promised_days)
actual_lag = max(1, int(np.random.normal(promised_days, 5)))
delivery_date = order_date + timedelta(days=actual_lag)
late = delivery_date > promised_date
unit_price = round(random.uniform(10, 500), 2)
qty = random.randint(1, 1200)
order_value = round(unit_price * qty, 2)
po = {
"po_number": f"PO{str(i+1).zfill(6)}",
"vendor": random.choice(vendors),
"material_category": random.choice(categories),
"order_date": order_date,
"promised_date": promised_date,
"delivery_date": delivery_date,
"late_delivery": late,
"order_value": order_value,
"quantity": qty,
"unit_price": unit_price,
"status": random.choice(["Open", "Delivered", "Invoiced", "Paid"]),
"plant": random.choice(["Plant_001", "Plant_002", "Plant_003"]),
"buyer": fake.name(),
"currency": "INR",
"payment_terms": random.choice(["30 Days", "45 Days", "60 Days", "90 Days"]),
"quality_score": round(np.clip(np.random.normal(8.5, 0.8), 5.0, 10.0), 1),
}
purchase_orders.append(po)
spend_rows = []
for v in vendors:
for c in categories:
spend_rows.append({
"vendor": v,
"category": c,
"total_spend": round(random.uniform(10000, 700000), 2),
"contract_compliance": round(random.uniform(78, 100), 1),
"risk_score": round(random.uniform(1, 10), 1),
"savings_potential": round(random.uniform(5, 25), 1),
})
po_df = pd.DataFrame(purchase_orders)
spend_df = pd.DataFrame(spend_rows)
return po_df, spend_df
# =============================
# Analytics Engine
# =============================
class ProcurementAnalytics:
def __init__(self, po_df: pd.DataFrame):
self.df = po_df.copy()
self.df["order_date"] = pd.to_datetime(self.df["order_date"])
self.df["month"] = self.df["order_date"].dt.to_period("M").dt.to_timestamp()
def kpis(self) -> Dict[str, Any]:
df = self.df
return {
"total_spend": float(df["order_value"].sum()),
"avg_order_value": float(df["order_value"].mean()),
"active_vendors": int(df["vendor"].nunique()),
"on_time_rate": float((~df["late_delivery"]).mean()),
"quality_avg": float(df["quality_score"].mean()),
}
def category_spend(self) -> pd.DataFrame:
return (
self.df.groupby("material_category", as_index=False)["order_value"]
.sum()
.sort_values("order_value", ascending=False)
)
def vendor_spend(self, top_n: int = 8) -> pd.DataFrame:
return (
self.df.groupby("vendor", as_index=False)["order_value"]
.sum()
.sort_values("order_value", ascending=False)
.head(top_n)
)
def monthly_spend(self) -> pd.DataFrame:
return (
self.df.groupby("month", as_index=False)["order_value"]
.sum()
.sort_values("month")
)
def vendor_performance(self) -> pd.DataFrame:
g = self.df.groupby("vendor").agg(
total_spend=("order_value", "sum"),
on_time=("late_delivery", lambda s: 1 - s.mean()),
quality=("quality_score", "mean"),
orders=("po_number", "count"),
)
g["on_time"] = (g["on_time"] * 100).round(1)
g["quality"] = g["quality"].round(2)
g["total_spend"] = g["total_spend"].round(2)
return g.sort_values("total_spend", ascending=False)
def top_n_categories(self, n: int = 3) -> List[Tuple[str, float]]:
cat = self.category_spend()
total = float(cat["order_value"].sum()) or 1.0
return [(r["material_category"], (r["order_value"] / total) * 100) for _, r in cat.head(n).iterrows()]
def top_n_vendors(self, n: int = 3) -> List[Tuple[str, float]]:
ven = (
self.df.groupby("vendor", as_index=False)["order_value"]
.sum()
.sort_values("order_value", ascending=False)
)
total = float(ven["order_value"].sum()) or 1.0
return [(r["vendor"], (r["order_value"] / total) * 100) for _, r in ven.head(n).iterrows()]
# =============================
# Agent with tighter prompts & INR formatting
# =============================
class UniversalProcurementAgent:
def __init__(self, po_df: pd.DataFrame, spend_df: pd.DataFrame, client: UniversalLLMClient):
self.po_data = po_df
self.spend_data = spend_df
self.llm = client
self.analytics = ProcurementAnalytics(po_df)
def executive_summary(self) -> str:
if not self.llm.available:
return self._rule_summary()
k = self.analytics.kpis()
top_cats = self.analytics.top_n_categories(3)
top_vens = self.analytics.top_n_vendors(3)
data_summary = {
"total_spend": k["total_spend"],
"total_orders": int(len(self.po_data)),
"vendor_count": int(self.po_data["vendor"].nunique()),
"avg_order_value": k["avg_order_value"],
"on_time_delivery": k["on_time_rate"],
"avg_quality": k["quality_avg"],
"top_categories": top_cats,
"top_vendors": top_vens,
}
messages = [
{
"role": "system",
"content": (
"You are a senior procurement analyst. Use bullet points, be concise, "
"and always use the ₹ symbol. When summarizing, include top categories "
"and vendors with percentages, then 2-3 quantified actions."
),
},
{
"role": "user",
"content": (
"Executive summary. Format amounts with Indian commas (e.g., ₹12,34,567).\n\n"
f"Data: {json.dumps(data_summary)}"
),
},
]
try:
return (
"🧠 **[AI-Powered Analysis]**\n\n"
+ self.llm.chat(messages, max_tokens=550)
)
except Exception as e:
return self._rule_summary() + f"\n\n*AI fallback due to: {e}*"
def _rule_summary(self) -> str:
k = self.analytics.kpis()
top_c = self.analytics.top_n_categories(3)
top_v = self.analytics.top_n_vendors(3)
topc_str = ", ".join([f"{n}{s:.0f}%" for n, s in top_c])
topv_str = ", ".join([f"{n}{s:.0f}%" for n, s in top_v])
return (
"🤖 **[Rule-Based Summary]**\n"
+ f"• Total spend: {fmt_currency(k['total_spend'])} across {len(self.po_data):,} POs\n"
+ f"• On-time delivery: {k['on_time_rate']*100:.1f}% | Avg quality: {k['quality_avg']:.1f}/10\n"
+ f"• Top categories: {topc_str}\n"
+ f"• Top vendors: {topv_str}\n"
+ "Actions: Consolidate long tail; multi-year terms with top vendors; auto-approve low-value POs."
)
def chat_with_data(self, question: str) -> str:
if not self.llm.available:
return self._rule_answer(question)
k = self.analytics.kpis()
top_c = self.analytics.top_n_categories(3)
top_v = self.analytics.top_n_vendors(3)
context = {
"total_spend": k["total_spend"],
"orders": int(len(self.po_data)),
"vendors": int(self.po_data["vendor"].nunique()),
"on_time": k["on_time_rate"],
"quality": k["quality_avg"],
"top_categories": top_c,
"top_vendors": top_v,
}
style_rules = (
"Rules: Answer in ≤6 bullet points, use ₹, no generic how-to steps. "
"If question mentions spend, list top 3 categories and top 3 vendors with shares. "
"If vendors, show best & worst by on-time and spend. If risk, show late % and actions."
)
messages = [
{"role": "system", "content": "You are a precise procurement co-pilot. Be direct, metric-first, and action-oriented."},
{"role": "user", "content": f"Q: {question}\n\nContext: {json.dumps(context)}\n\n{style_rules}"},
]
try:
return (
"🧠 **[AI Response]**\n\n"
+ self.llm.chat(messages, max_tokens=450)
)
except Exception as e:
return self._rule_answer(question) + f"\n\n*AI fallback due to: {e}*"
def _rule_answer(self, question: str) -> str:
q = question.lower()
k = self.analytics.kpis()
top_c = self.analytics.top_n_categories(3)
top_v = self.analytics.top_n_vendors(3)
if ("spend" in q) or ("spending" in q) or ("cost" in q):
lines = [
f"• Total spend: {fmt_currency(k['total_spend'])}",
"• Top categories: " + ", ".join([f"{n}{s:.0f}%" for n, s in top_c]),
"• Top vendors: " + ", ".join([f"{n}{s:.0f}%" for n, s in top_v]),
"• Action: Run sourcing events for top 2 categories; target 8–12% savings via volume tiers.",
]
return "🤖 **[Rule-Based Spend]**\n" + "\n".join(lines)
if ("vendor" in q) or ("supplier" in q) or ("partner" in q):
vp = self.po_data.groupby("vendor").agg(
spend=("order_value", "sum"),
late_rate=("late_delivery", "mean"),
quality=("quality_score", "mean"),
).sort_values("spend", ascending=False)
best = vp.head(1)
worst = vp.sort_values("late_rate", ascending=False).head(1)
bname, wname = best.index[0], worst.index[0]
blate = float(best.iloc[0]["late_rate"]) * 100
wlate = float(worst.iloc[0]["late_rate"]) * 100
lines = [
f"• Best by spend: {bname} (late {blate:.1f}%)",
f"• Worst by late deliveries: {wname} (late {wlate:.1f}%)",
"• Action: Extend terms with best performer; corrective plan and SLA penalties for the worst.",
]
return "🤖 **[Rule-Based Vendor]**\n" + "\n".join(lines)
if ("risk" in q) or ("late" in q) or ("delay" in q):
late = float(self.po_data["late_delivery"].mean()) * 100
lines = [
f"• Late delivery rate: {late:.1f}%",
"• Action: Add 5–10 day buffers; fast-track chronic offenders; add service credits for misses.",
]
return "🤖 **[Rule-Based Risk]**\n" + "\n".join(lines)
return (
"🤖 **[Rule-Based]**\n"
+ "• I can analyze spend (top categories/vendors), vendor performance (best/worst), risk (late %), and trends.\n"
+ f"• Snapshot: {fmt_currency(k['total_spend'])}, {len(self.po_data):,} POs, {self.po_data['vendor'].nunique()} vendors, on-time {k['on_time_rate']*100:.1f}%"
)
# =============================
# App State & Initialization
# =============================
if "data_loaded" not in st.session_state:
with st.spinner("🔄 Generating synthetic SAP S/4HANA procurement data..."):
st.session_state.po_df, st.session_state.spend_df = generate_synthetic_procurement_data()
st.session_state.data_loaded = True
@st.cache_resource(show_spinner=False)
def get_llm_client() -> UniversalLLMClient:
return UniversalLLMClient(LLMConfig())
client = get_llm_client()
agent = UniversalProcurementAgent(st.session_state.po_df, st.session_state.spend_df, client)
analytics = ProcurementAnalytics(st.session_state.po_df)
status = {
"available": client.available,
"last_error": client.last_error or "OK",
"model": client.cfg.model,
}
api_status = "🟢 Connected" if status["available"] else "🔴 Not Connected"
# =============================
# Header
# =============================
st.markdown(
(
"<div class=\"main-header\">"
"<h1>🤖 SAP S/4HANA Agentic AI Procurement Analytics</h1>"
"<p>Autonomous Intelligence for Procurement Excellence</p>"
+ f"<small>LLM: {api_status} · Data: {len(st.session_state.po_df):,} POs</small>"
"</div>"
),
unsafe_allow_html=True,
)
# =============================
# Sidebar
# =============================
with st.sidebar:
st.markdown("### 🤖 AI System Status")
st.markdown(f"**Connection:** {api_status}")
st.markdown(f"**Model:** {status['model']}")
with st.expander("🔍 System Information"):
st.json(status)
selected = option_menu(
"Navigation",
["🏠 Dashboard", "💬 AI Chat", "📊 Analytics", "🧪 What-If", "🎯 Recommendations"],
icons=["house", "chat", "bar-chart", "beaker", "target"],
menu_icon="cast",
default_index=0,
styles={
"container": {"padding": "0!important", "background-color": "#fafafa"},
"icon": {"color": "#0066cc", "font-size": "18px"},
"nav-link": {"font-size": "16px", "text-align": "left", "margin": "0px", "--hover-color": "#eee"},
"nav-link-selected": {"background-color": "#0066cc"},
},
)
# =============================
# Main Views
# =============================
if selected == "🏠 Dashboard":
st.markdown("### 🧠 AI Executive Summary")
with st.spinner("🤖 Analyzing procurement data..."):
summary = agent.executive_summary()
st.markdown(
(
"<div class=\"ai-insight\">"
"<h4>📊 Intelligent Analysis</h4>"
+ f"<div style=\"white-space: pre-line; line-height: 1.55;\">{summary}</div>"
"</div>"
),
unsafe_allow_html=True,
)
k = analytics.kpis()
c1, c2, c3, c4 = st.columns(4)
with c1:
st.markdown(
(
"<div class='metric-card'>"
"<h3 style='color: var(--primary-color); margin:0;'>Total Spend</h3>"
+ f"<h2 style='margin: .5rem 0;'>{fmt_currency(k['total_spend'])}</h2>"
"<p style='color:#28a745;margin:0;'>📈 Active Portfolio</p>"
"</div>"
),
unsafe_allow_html=True,
)
with c2:
st.markdown(
(
"<div class='metric-card'>"
"<h3 style='color: var(--primary-color); margin:0;'>Avg Order Value</h3>"
+ f"<h2 style='margin: .5rem 0;'>{fmt_currency(k['avg_order_value'])}</h2>"
"<p style='color:#17a2b8;margin:0;'>📊 Order Efficiency</p>"
"</div>"
),
unsafe_allow_html=True,
)
with c3:
st.markdown(
(
"<div class='metric-card'>"
"<h3 style='color: var(--primary-color); margin:0;'>Active Vendors</h3>"
+ f"<h2 style='margin: .5rem 0;'>{k['active_vendors']}</h2>"
"<p style='color:#6f42c1;margin:0;'>🤝 Strategic Partners</p>"
"</div>"
),
unsafe_allow_html=True,
)
with c4:
st.markdown(
(
"<div class='metric-card'>"
"<h3 style='color: var(--primary-color); margin:0;'>On-Time Delivery</h3>"
+ f"<h2 style='margin: .5rem 0;'>{k['on_time_rate']*100:.1f}%</h2>"
"<p style='color:#28a745;margin:0;'>⏱ Performance</p>"
"</div>"
),
unsafe_allow_html=True,
)
st.markdown("### 📊 Executive Dashboard")
colA, colB = st.columns(2)
with colA:
cat = analytics.category_spend()
fig = px.pie(cat, values="order_value", names="material_category", title="Spend Distribution by Category")
fig.update_layout(title_font_size=16, title_x=0.5, height=420)
st.plotly_chart(fig, use_container_width=True)
with colB:
vend = analytics.vendor_spend(top_n=8)
fig2 = px.bar(vend, x="vendor", y="order_value", title="Top Vendors by Spend")
fig2.update_layout(title_font_size=16, title_x=0.5, xaxis_tickangle=45, height=420)
st.plotly_chart(fig2, use_container_width=True)
colC, colD = st.columns(2)
with colC:
ms = analytics.monthly_spend()
fig3 = px.line(ms, x="month", y="order_value", markers=True, title="Monthly Spend Trend")
fig3.update_layout(title_font_size=16, title_x=0.5, height=420)
st.plotly_chart(fig3, use_container_width=True)
with colD:
st.markdown("#### 🔎 Quick Top Areas")
tcat = ", ".join([f"{n}{s:.0f}%" for n, s in analytics.top_n_categories(3)])
tven = ", ".join([f"{n}{s:.0f}%" for n, s in analytics.top_n_vendors(3)])
st.markdown(f"**Top Categories:** {tcat}")
st.markdown(f"**Top Vendors:** {tven}")
elif selected == "💬 AI Chat":
st.markdown("### 💬 Chat with Your Procurement Data")
st.markdown(
(
"<div class=\"ai-insight\">"
"<h4>🤖 Universal AI Assistant</h4>"
"<p>Ask me anything about your procurement data. I will answer with crisp bullets and actual metrics.</p>"
+ f"<p><small>Status: {api_status} | Model: {status['model']}</small></p>"
"</div>"
),
unsafe_allow_html=True,
)
if "messages" not in st.session_state:
st.session_state.messages = [
{"role": "assistant", "content": "Hello! Try: 'What are my biggest spending areas?' or 'Which vendor is the weakest on delivery?'"},
]
for m in st.session_state.messages:
with st.chat_message(m["role"]):
st.markdown(m["content"])
if prompt := st.chat_input("Ask about your procurement data…"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
with st.spinner("🤖 Analyzing…"):
reply = agent.chat_with_data(prompt)
st.markdown(reply)
st.session_state.messages.append({"role": "assistant", "content": reply})
st.markdown("#### 💡 Quick asks:")
c1, c2, c3 = st.columns(3)
qs = [
"What are my biggest spending areas?",
"Which vendors perform the best and worst?",
"What risks should I monitor right now?",
]
for i, (c, q) in enumerate(zip([c1, c2, c3], qs)):
with c:
if st.button(f"💭 {q}", key=f"q_{i}"):
st.session_state.messages.append({"role": "user", "content": q})
st.session_state.messages.append({"role": "assistant", "content": agent.chat_with_data(q)})
st.rerun()
elif selected == "📊 Analytics":
st.markdown("### 📈 Advanced Analytics Dashboard")
vp = analytics.vendor_performance()
st.dataframe(
vp.rename(
columns={
"total_spend": "Total Spend (₹)",
"on_time": "On-Time Delivery %",
"quality": "Quality Score",
"orders": "Order Count",
}
),
use_container_width=True,
)
st.download_button(
label="⬇️ Download Vendor Performance (CSV)",
data=vp.to_csv().encode("utf-8"),
file_name="vendor_performance.csv",
mime="text/csv",
)
elif selected == "🧪 What-If":
st.markdown("### 🧪 What-If: Vendor Consolidation Simulator")
top_n = st.slider("Keep top N vendors by spend", min_value=2, max_value=10, value=6, step=1)
g = st.session_state.po_df.groupby("vendor")["order_value"].sum().sort_values(ascending=False)
kept_vendors = list(g.head(top_n).index)
kept_spend = st.session_state.po_df[st.session_state.po_df["vendor"].isin(kept_vendors)]["order_value"].sum()
total_spend = st.session_state.po_df["order_value"].sum()
share = (kept_spend / total_spend) if total_spend else 0
est_savings = max(0.03, min(0.18, 0.05 + (0.12 * (1 - share))))
kept_names = ", ".join(kept_vendors)
st.markdown(
(
"<div class='alert alert-info'>"
+ f"<strong>Scenario:</strong> Keep top <b>{top_n}</b> vendors. Addressable share: <b>{share*100:.1f}%</b>.<br/>"
+ f"<strong>Potential savings:</strong> <b>{est_savings*100:.1f}%</b> (heuristic).<br/>"
+ f"<small>Kept Vendors:</small> {kept_names}"
+ "</div>"
),
unsafe_allow_html=True,
)
if st.checkbox("Show detailed vendor spend"):
st.dataframe(g.reset_index().rename(columns={"index": "vendor", "order_value": "spend (₹)"}), use_container_width=True)
elif selected == "🎯 Recommendations":
st.markdown("### 🚀 Strategic Recommendations")
recs = [
"🎯 **Vendor Consolidation**: Reduce long-tail suppliers; target 8–15% price improvement via volume tiers.",
"⚡ **Process Automation**: Auto-approve low-value POs to cut cycle time by 35–50%.",
"📊 **Performance Contracts**: KPI-linked clauses for on-time delivery; add service credits for misses.",
"🛡️ **Risk Monitoring**: Score suppliers on late rate, quality, and concentration; escalate chronic offenders.",
"🧠 **AI Copilot**: Use LLM to draft RFQs, summarize bids, and propose award scenarios.",
]
for i, rec in enumerate(recs, start=1):
st.markdown(
(
"<div class=\"alert alert-success\">"
+ f"<h4>Recommendation #{i}</h4>"
+ f"<p>{rec}</p>"
+ "</div>"
),
unsafe_allow_html=True,
)
# =============================
# Footer
# =============================
st.markdown("---")
st.markdown(
(
"<div style=\"text-align:center; padding: 1rem; color:#666;\">"
"<p>🤖 <strong>Universal AI Procurement Analytics</strong> | Crisp, metric-first answers in ₹</p>"
+ f"<p><em>Demo with synthetic data • {len(st.session_state.po_df):,} orders • LLM {api_status}</em></p>"
"</div>"
),
unsafe_allow_html=True,
)