PurchaseAgent / src /streamlit_app.py
PD03's picture
Update src/streamlit_app.py
a288d8d verified
import os
import time
import json
import math
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
import plotly.graph_objects as go
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,
)
# =============================
# Config & LLM Client (robust, version-agnostic)
# =============================
@dataclass
class LLMConfig:
provider: str = os.getenv("LLM_PROVIDER", "openai").lower() # openai | azure | compatible
base_url: Optional[str] = os.getenv("OPENAI_BASE_URL") # for compatible endpoints
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.6"))
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:
"""A resilient client that works with OpenAI, Azure OpenAI, and compatible APIs.
- Prefers /chat/completions
- Falls back to /responses if available
- Retries with exponential backoff and respects Retry-After
"""
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:
if self.cfg.provider == "azure":
# Use Azure env format if provided
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
api_version = os.getenv("AZURE_OPENAI_API_VERSION", "2024-02-15-preview")
deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT", self.cfg.model)
# Azure uses deployment name in path
return f"{endpoint}/openai/deployments/{deployment}?api-version={api_version}"
return (self.cfg.base_url or "https://api.openai.com/v1").rstrip("/")
def _smoke_test(self):
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()
# Endpoint selection
chat_url = f"{base}/chat/completions" if self.cfg.provider != "azure" else f"{base}&api-version-override=false" # azure path already includes params
responses_url = f"{base}/responses"
payload = {
"model": self.cfg.model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": self.cfg.temperature,
}
# Retry with backoff
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()
# Try /responses fallback for some providers
if resp.status_code in (404, 400):
alt = _post_json(
responses_url,
headers,
{"model": self.cfg.model, "input": messages, "max_output_tokens": max_tokens, "temperature": self.cfg.temperature},
self.cfg.timeout,
)
if alt.status_code == 200:
return alt.json()["output"][0]["content"][0]["text"].strip()
if resp.status_code in (429, 500, 502, 503, 504):
retry_after = float(resp.headers.get("Retry-After", delay))
time.sleep(retry_after)
delay = min(delay * 2, 8.0)
continue
# Other errors → raise
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 & Utils
# =============================
@st.cache_data(show_spinner=False)
def generate_synthetic_procurement_data(seed: int = 42) -> Tuple[pd.DataFrame, pd.DataFrame]:
"""Generate richer synthetic SAP S/4HANA procurement data, including lead times and late flags."""
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]] = []
today = datetime.utcnow().date()
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,
'lead_time_days': (delivery_date - order_date).days,
'promised_days': promised_days,
'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': 'EUR',
'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
def eur(x: float) -> str:
return f"€{x:,.0f}"
# =============================
# 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()
@st.cache_data(show_spinner=False)
def kpis(_self, df_hash: int) -> 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:
g = self.df.groupby('vendor', as_index=False)['order_value'].sum()
return g.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'),
lead_time=('lead_time_days', 'mean'),
)
g['on_time'] = (g['on_time'] * 100).round(1)
g['quality'] = g['quality'].round(2)
g['lead_time'] = g['lead_time'].round(1)
g['total_spend'] = g['total_spend'].round(2)
return g.sort_values('total_spend', ascending=False)
def anomalies(self) -> pd.DataFrame:
# Simple IQR for order_value anomalies
q1, q3 = self.df['order_value'].quantile([0.25, 0.75])
iqr = q3 - q1
hi = q3 + 1.5 * iqr
lo = max(0, q1 - 1.5 * iqr)
a = self.df[(self.df['order_value'] > hi) | (self.df['order_value'] < lo)].copy()
a['anomaly_reason'] = np.where(a['order_value'] > hi, 'High value', 'Low value')
return a.sort_values('order_value', ascending=False).head(50)
def simulate_vendor_consolidation(self, keep_top: int) -> Dict[str, Any]:
g = self.df.groupby('vendor')['order_value'].sum().sort_values(ascending=False)
kept_vendors = list(g.head(keep_top).index)
kept_spend = self.df[self.df['vendor'].isin(kept_vendors)]['order_value'].sum()
total_spend = self.df['order_value'].sum()
share = kept_spend / total_spend if total_spend else 0
est_savings = 0.05 + (0.12 * (1 - share)) # heuristic: better leverage when consolidating
return {
'kept_vendors': kept_vendors,
'kept_share': share,
'estimated_savings_pct': max(0.03, min(0.18, est_savings)),
}
# =============================
# Agent (uses UniversalLLMClient with safe fallback)
# =============================
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
def llm_status(self) -> Dict[str, Any]:
return {
"api_key_available": bool(self.llm.cfg.api_key),
"llm_available": self.llm.available,
"last_error": self.llm.last_error or "Connected successfully" if self.llm.available else "Unavailable",
"provider": self.llm.cfg.provider,
"model": self.llm.cfg.model,
"base_url": self.llm.cfg.base_url or "https://api.openai.com/v1",
}
def _rule_summary(self) -> str:
total_spend = float(self.po_data['order_value'].sum())
on_time = float((~self.po_data['late_delivery']).mean()) * 100
quality = float(self.po_data['quality_score'].mean())
top_cat = self.po_data.groupby('material_category')['order_value'].sum().idxmax()
top_vendor = self.po_data.groupby('vendor')['order_value'].sum().idxmax()
return (
"🤖 **[Smart Analysis - Rule-Based Engine]**\n"
"**Executive Snapshot**\n"
f"• Total spend: {eur(total_spend)} across {len(self.po_data):,} POs\n"
f"• On-time delivery: {on_time:.1f}% • Avg quality: {quality:.1f}/10\n"
f"• Top category: {top_cat} • Lead vendor: {top_vendor}\n\n"
"**Opportunities**\n"
"• Consolidate long tail vendors to improve pricing power (5–12% potential).\n"
"• Tighten SLAs for late deliveries and extend performance-based contracts.\n"
"• Automate low-value buys to reduce cycle time."
)
def executive_summary(self) -> str:
if not self.llm.available:
return self._rule_summary()
data_summary = {
"total_spend": float(self.po_data['order_value'].sum()),
"total_orders": int(len(self.po_data)),
"vendor_count": int(self.po_data['vendor'].nunique()),
"avg_order_value": float(self.po_data['order_value'].mean()),
"on_time_delivery": float((~self.po_data['late_delivery']).mean()),
"avg_quality": float(self.po_data['quality_score'].mean()),
}
messages = [
{"role": "system", "content": "You are a senior procurement analyst with expertise in SAP S/4HANA. Be concise, metric-driven, and actionable."},
{"role": "user", "content": (
"Create an executive summary covering: 1) overview (2-3 sentences), 2) KPI highlights, 3) risks/alerts, 4) 3-4 strategic recommendations with quantified impact.\n"
f"Data: {json.dumps(data_summary)}"
)},
]
try:
return "🧠 **[AI-Powered Analysis]**\n\n" + self.llm.chat(messages, max_tokens=650)
except Exception as e:
return self._rule_summary() + f"\n\n*AI fallback due to: {e}*"
def chat_with_data(self, question: str) -> str:
if not self.llm.available:
return self._rule_answer(question)
context = {
"total_spend": float(self.po_data['order_value'].sum()),
"orders": int(len(self.po_data)),
"vendors": int(self.po_data['vendor'].nunique()),
"on_time": float((~self.po_data['late_delivery']).mean()),
"quality": float(self.po_data['quality_score'].mean()),
}
messages = [
{"role": "system", "content": "You are an expert procurement co-pilot. Use the provided context and respond with precise metrics and concrete actions."},
{"role": "user", "content": f"Question: {question}\nContext: {json.dumps(context)}"},
]
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()
if any(w in q for w in ["spend", "cost", "budget"]):
total = float(self.po_data['order_value'].sum())
monthly = total / max(1, self.po_data['order_date'].nunique()/30)
top_cat = self.po_data.groupby('material_category')['order_value'].sum().idxmax()
return (
"🤖 **[Smart Analysis] Spend**\n"
f"• Total spend: {eur(total)}\n"
f"• Monthly average (approx): {eur(monthly)}\n"
f"• Top category: {top_cat}\n"
"Tip: prioritize competitive events for the top 2 categories to unlock 4–8% savings."
)
if any(w in q for w in ["vendor", "supplier", "partner"]):
vp = self.po_data.groupby('vendor').agg(
spend=('order_value','sum'),
on_time=('late_delivery', lambda s: 1 - s.mean()),
).sort_values('spend', ascending=False).head(1)
top = vp.index[0]
on_time = float(vp.iloc[0]['on_time'])*100
return (
"🤖 **[Smart Analysis] Vendor**\n"
f"• Top vendor: {top} • On-time: {on_time:.1f}%\n"
"Action: lock in volume tiers and add delivery penalties to the contract."
)
if any(w in q for w in ["risk", "late", "delay"]):
late_rate = float(self.po_data['late_delivery'].mean())*100
return (
"🤖 **[Smart Analysis] Risk**\n"
f"• Late delivery rate: {late_rate:.1f}%\n"
"Action: add buffer to planning lead times and escalate chronic late suppliers."
)
return (
"🤖 **[Smart Analysis]** I can help with spend, vendor performance, risk, savings, and trends. Try: \"Where can I save 10%?\""
)
# =============================
# 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 = agent.llm_status()
api_status = "🟢 Connected" if status['llm_available'] else "🔴 Not Connected"
# =============================
# Header
# =============================
st.markdown(
f"""
<div class="main-header">
<h1>🤖 SAP S/4HANA Agentic AI Procurement Analytics</h1>
<p>Autonomous Intelligence for Procurement Excellence</p>
<small>OpenAI: {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"**Provider:** {status['provider']} ")
st.markdown(f"**Model:** {status['model']}")
with st.expander("🔍 System Information"):
safe = status.copy()
# Do not expose API key
st.json({k: v for k, v in safe.items() if k != 'api_key'})
if st.button("🔄 Test AI Connection"):
if status['llm_available']:
st.success("LLM is reachable and ready.")
else:
st.error(f"LLM unavailable: {status['last_error']}")
st.markdown("---")
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(f"""
<div class="ai-insight">
<h4>📊 Intelligent Analysis</h4>
<div style="white-space: pre-line; line-height: 1.55;">{summary}</div>
</div>
""", unsafe_allow_html=True)
k = analytics.kpis(hash(tuple(st.session_state.po_df['po_number'])))
c1, c2, c3, c4 = st.columns(4)
with c1:
st.markdown(f"<div class='metric-card'><h3 style='color: var(--primary-color); margin:0;'>Total Spend</h3><h2 style='margin: .5rem 0;'>{eur(k['total_spend'])}</h2><p style='color:#28a745;margin:0;'>📈 Active Portfolio</p></div>", unsafe_allow_html=True)
with c2:
st.markdown(f"<div class='metric-card'><h3 style='color: var(--primary-color); margin:0;'>Avg Order Value</h3><h2 style='margin: .5rem 0;'>{eur(k['avg_order_value'])}</h2><p style='color:#17a2b8;margin:0;'>📊 Order Efficiency</p></div>", unsafe_allow_html=True)
with c3:
st.markdown(f"<div class='metric-card'><h3 style='color: var(--primary-color); margin:0;'>Active Vendors</h3><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(f"<div class='metric-card'><h3 style='color: var(--primary-color); margin:0;'>On‑Time Delivery</h3><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:
ano = analytics.anomalies()
st.markdown("#### 🔎 High/Low Value Anomalies (Top 50)")
st.dataframe(ano[['po_number','vendor','material_category','order_value','anomaly_reason']].reset_index(drop=True), use_container_width=True, height=380)
elif selected == "💬 AI Chat":
st.markdown("### 💬 Chat with Your Procurement Data")
st.markdown(f"""
<div class="ai-insight">
<h4>🤖 Universal AI Assistant</h4>
<p>Ask me anything about your procurement data! I'm provider-agnostic and resilient to API versions.</p>
<p><small>Status: {api_status} | Provider: {status['provider']} | 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! I loaded your data and I'm ready to help—try asking about spend, vendors, or risk."}
]
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("#### 💡 Try quick questions:")
c1, c2, c3 = st.columns(3)
qs = ["What are my biggest spending areas?", "How are my vendors performing?", "Where can I save 10%?"]
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',
'lead_time': 'Avg Lead Time (days)'
}), 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)
sim = analytics.simulate_vendor_consolidation(keep_top=top_n)
kept_names = ", ".join(sim['kept_vendors'])
st.markdown(
f"""
<div class='alert alert-info'>
<strong>Scenario:</strong> Keep top <b>{top_n}</b> vendors. Estimated addressable spend share: <b>{sim['kept_share']*100:.1f}%</b>.<br/>
<strong>Potential savings:</strong> <b>{sim['estimated_savings_pct']*100:.1f}%</b> (heuristic).<br/>
<small>Kept Vendors:</small> {kept_names}
</div>
""",
unsafe_allow_html=True,
)
if st.checkbox("Show detailed vendor spend"):
st.dataframe(analytics.vendor_spend(top_n=999), 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(
f"""
<div class="alert alert-success">
<h4>Recommendation #{i}</h4>
<p>{rec}</p>
</div>
""",
unsafe_allow_html=True,
)
# =============================
# Footer
# =============================
st.markdown("---")
st.markdown(
f"""
<div style="text-align:center; padding: 1rem; color:#666;">
<p>🤖 <strong>Universal AI Procurement Analytics</strong> | Provider‑agnostic LLM integration with resilient fallbacks</p>
<p><em>Demo with synthetic data • {len(st.session_state.po_df):,} orders • OpenAI {api_status}</em></p>
</div>
""",
unsafe_allow_html=True,
)