Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import pandas as pd | |
| import numpy as np | |
| import plotly.express as px | |
| from datetime import datetime, timedelta | |
| from dateutil.parser import parse | |
| # --------------------------- | |
| # App Config and Theming | |
| # --------------------------- | |
| st.set_page_config( | |
| page_title="Procurement Agent – S/4HANA Embedded Analytics (Demo)", | |
| page_icon="🧭", | |
| layout="wide", | |
| initial_sidebar_state="expanded", | |
| ) | |
| # Subtle CSS polish for a premium feel | |
| st.markdown( | |
| """ | |
| <style> | |
| .kpi-card { | |
| padding: 14px 16px; border-radius: 12px; background: #0a0a0a0d; | |
| border: 1px solid #e6e6e6; box-shadow: 0 1px 2px rgba(0,0,0,0.04); | |
| } | |
| .metric-label { font-size: 12px; color: #666; margin-bottom: 6px; } | |
| .metric-value { font-size: 26px; font-weight: 700; } | |
| .metric-sub { font-size: 12px; color: #999; } | |
| .stChatFloatingInputContainer { border-top: 1px solid #eee; } | |
| .st-emotion-cache-1avcm0n { padding-top: 0 !important; } | |
| .rounded-img { border-radius: 50%; } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # --------------------------- | |
| # Data Loading (Synthetic “CDS-like”) | |
| # --------------------------- | |
| def load_data(): | |
| df = pd.read_csv("data/synthetic_procurement.csv", parse_dates=["PO_Date","DeliveryDate","GR_Date","IR_Date"]) | |
| # Derived fields similar to embedded analytics | |
| df["DaysToDeliver"] = (df["DeliveryDate"] - df["PO_Date"]).dt.days | |
| df["IsOpen"] = df["Status"].eq("Open") | |
| return df | |
| df = load_data() | |
| # --------------------------- | |
| # Sidebar Filters | |
| # --------------------------- | |
| with st.sidebar: | |
| st.image("https://huggingface.co/front/assets/huggingface_logo-noborder.svg", width=120) | |
| st.title("Procurement Agent") | |
| st.caption("S/4HANA Embedded Analytics – Learning Demo (Synthetic data)") | |
| # Time filter | |
| max_date = df["PO_Date"].max() | |
| default_start = max_date - timedelta(days=45) | |
| date_range = st.date_input("PO Date Range", (default_start, max_date)) | |
| # Codelists | |
| company = st.multiselect("Company Code", sorted(df["CompanyCode"].unique().tolist())) | |
| plants = st.multiselect("Plant", sorted(df["Plant"].unique().tolist())) | |
| mat_groups = st.multiselect("Material Group", sorted(df["MaterialGroup"].unique().tolist())) | |
| suppliers = st.multiselect("Supplier", sorted(df["Supplier"].unique().tolist())) | |
| buyers = st.multiselect("Buyer", sorted(df["Buyer"].unique().tolist())) | |
| status_sel = st.multiselect("Status", sorted(df["Status"].unique().tolist())) | |
| st.markdown("---") | |
| st.subheader("Demo actions") | |
| if st.button("Reset Filters"): | |
| st.session_state.clear() | |
| st.rerun() | |
| # Apply filters | |
| def apply_filters(df): | |
| dff = df.copy() | |
| if isinstance(date_range, tuple) and len(date_range) == 2: | |
| start_date, end_date = pd.to_datetime(date_range[0]), pd.to_datetime(date_range[1]) | |
| dff = dff[(dff["PO_Date"] >= start_date) & (dff["PO_Date"] <= end_date)] | |
| if company: | |
| dff = dff[dff["CompanyCode"].isin(company)] | |
| if plants: | |
| dff = dff[dff["Plant"].isin(plants)] | |
| if mat_groups: | |
| dff = dff[dff["MaterialGroup"].isin(mat_groups)] | |
| if suppliers: | |
| dff = dff[dff["Supplier"].isin(suppliers)] | |
| if buyers: | |
| dff = dff[dff["Buyer"].isin(buyers)] | |
| if status_sel: | |
| dff = dff[dff["Status"].isin(status_sel)] | |
| return dff | |
| fdf = apply_filters(df) | |
| # --------------------------- | |
| # KPI Header | |
| # --------------------------- | |
| def kpi_card(label, value, sub=""): | |
| st.markdown( | |
| f""" | |
| <div class="kpi-card"> | |
| <div class="metric-label">{label}</div> | |
| <div class="metric-value">{value}</div> | |
| <div class="metric-sub">{sub}</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| col1, col2, col3, col4 = st.columns(4) | |
| with col1: | |
| total_po = fdf["PO_ID"].nunique() | |
| kpi_card("Purchase Orders", f"{total_po:,}", "Unique POs in selection") | |
| with col2: | |
| spend = fdf["NetValue"].sum() | |
| kpi_card("Net Spend", f"${spend:,.0f}", "Sum of PO item values") | |
| with col3: | |
| avg_lt = fdf["LeadTimeDays"].mean() if len(fdf) else 0 | |
| kpi_card("Avg Lead Time", f"{avg_lt:.1f}d", "Supplier cycle time") | |
| with col4: | |
| otif = fdf["OTIF"].mean() * 100 if len(fdf) else 0 | |
| kpi_card("OTIF", f"{otif:.0f}%", "On-time in-full rate") | |
| st.markdown("") | |
| # --------------------------- | |
| # Tabs: Overview | Supplier Insights | Explorer | Simulations | |
| # --------------------------- | |
| tab1, tab2, tab3, tab4 = st.tabs(["Overview", "Supplier Insights", "Explorer", "Simulations"]) | |
| with tab1: | |
| c1, c2 = st.columns([1.3, 1]) | |
| with c1: | |
| st.subheader("Spend by Supplier") | |
| if len(fdf): | |
| fig = px.bar( | |
| fdf.groupby("Supplier", as_index=False)["NetValue"].sum().sort_values("NetValue", ascending=False), | |
| x="Supplier", y="NetValue", color="Supplier", height=380, template="plotly_white", | |
| hover_data={"NetValue":":,.0f"} | |
| ) | |
| st.plotly_chart(fig, use_container_width=True) | |
| else: | |
| st.info("No data for selected filters.") | |
| st.subheader("Material Group Mix") | |
| if len(fdf): | |
| fig2 = px.pie( | |
| fdf, names="MaterialGroup", values="NetValue", hole=0.45, template="plotly_white", | |
| height=380 | |
| ) | |
| st.plotly_chart(fig2, use_container_width=True) | |
| with c2: | |
| st.subheader("Lead Time by Supplier") | |
| if len(fdf): | |
| g = fdf.groupby("Supplier", as_index=False)["LeadTimeDays"].mean().sort_values("LeadTimeDays") | |
| fig3 = px.bar(g, x="LeadTimeDays", y="Supplier", orientation="h", height=380, template="plotly_white") | |
| st.plotly_chart(fig3, use_container_width=True) | |
| st.subheader("OTIF by Supplier") | |
| if len(fdf): | |
| g2 = fdf.groupby("Supplier", as_index=False)["OTIF"].mean() | |
| g2["OTIF%"] = (g2["OTIF"] * 100).round(1) | |
| fig4 = px.scatter(g2, x="Supplier", y="OTIF%", size="OTIF%", color="Supplier", height=340, template="plotly_white") | |
| st.plotly_chart(fig4, use_container_width=True) | |
| with tab2: | |
| st.subheader("Supplier Scorecard") | |
| sup = st.selectbox("Choose supplier", sorted(df["Supplier"].unique().tolist())) | |
| sdf = fdf[fdf["Supplier"] == sup] | |
| if len(sdf): | |
| c1, c2, c3 = st.columns(3) | |
| with c1: | |
| kpi_card("Spend", f"${sdf['NetValue'].sum():,.0f}") | |
| with c2: | |
| kpi_card("Avg Price", f"${sdf['NetPrice'].mean():.2f}/unit") | |
| with c3: | |
| kpi_card("OTIF", f"{(sdf['OTIF'].mean()*100):.0f}%") | |
| st.markdown("") | |
| c4, c5 = st.columns(2) | |
| with c4: | |
| st.caption("Lead time trend (by PO date)") | |
| trend = sdf.sort_values("PO_Date").groupby("PO_Date", as_index=False)["LeadTimeDays"].mean() | |
| fig5 = px.line(trend, x="PO_Date", y="LeadTimeDays", markers=True, template="plotly_white", height=340) | |
| st.plotly_chart(fig5, use_container_width=True) | |
| with c5: | |
| st.caption("Price distribution") | |
| fig6 = px.histogram(sdf, x="NetPrice", nbins=10, template="plotly_white", height=340) | |
| st.plotly_chart(fig6, use_container_width=True) | |
| st.subheader("Recent PO Lines") | |
| st.dataframe( | |
| sdf.sort_values("PO_Date", ascending=False)[ | |
| ["PO_ID","PO_Item","PO_Date","Material","Quantity","OrderUnit","NetPrice","NetValue","DeliveryDate","Status","LeadTimeDays","OTIF"] | |
| ].head(10), | |
| use_container_width=True, height=300 | |
| ) | |
| else: | |
| st.info("No lines for selected supplier within current filters.") | |
| with tab3: | |
| st.subheader("Interactive Explorer") | |
| dims = ["CompanyCode","Plant","MaterialGroup","Supplier","Buyer","Status"] | |
| sel_dim = st.selectbox("Dimension", dims, index=3) | |
| sel_mea = st.selectbox("Measure", ["NetValue","Quantity","NetPrice","LeadTimeDays","OTIF"], index=0) | |
| if len(fdf): | |
| g = fdf.groupby(sel_dim, as_index=False)[sel_mea].mean() if sel_mea in ["NetPrice","LeadTimeDays","OTIF"] else \ | |
| fdf.groupby(sel_dim, as_index=False)[sel_mea].sum() | |
| fig7 = px.bar(g.sort_values(sel_mea, ascending=False).head(15), x=sel_dim, y=sel_mea, color=sel_dim, template="plotly_white", height=420) | |
| st.plotly_chart(fig7, use_container_width=True) | |
| st.dataframe(g.sort_values(sel_mea, ascending=False), use_container_width=True, height=260) | |
| else: | |
| st.info("Adjust filters to see data.") | |
| with tab4: | |
| st.subheader("What-if: Payment Terms and Delivery Delays") | |
| # Simple simulation: change payment terms and hypothetical delay impact on OTIF | |
| term_delta = st.slider("Payment term change (days)", -30, 30, 0, step=5) | |
| delay_rate = st.slider("Simulate delivery delay rate (%)", 0, 50, 10, step=5) | |
| def run_sim(df_in, term_delta, delay_rate): | |
| sim = df_in.copy() | |
| # Adjust payment days | |
| sim["PaymentDaysSim"] = sim["PaymentDays"] + term_delta | |
| # Apply simple OTIF penalty based on delay rate | |
| penalty = delay_rate / 100.0 | |
| sim["OTIF_Sim"] = np.clip(sim["OTIF"] * (1 - penalty) + (1 - sim["OTIF"]) * (1 - penalty/2), 0, 1) | |
| # Assume carrying cost impact: +0.02% per extra payment day on spend | |
| delta_days = np.maximum(sim["PaymentDaysSim"] - sim["PaymentDays"], 0) | |
| sim["CarryingCostAdj"] = sim["NetValue"] * (0.0002 * delta_days) | |
| return sim | |
| if len(fdf): | |
| simdf = run_sim(fdf, term_delta, delay_rate) | |
| c1, c2, c3 = st.columns(3) | |
| with c1: | |
| kpi_card("OTIF (Simulated)", f"{(simdf['OTIF_Sim'].mean()*100):.0f}%") | |
| with c2: | |
| kpi_card("PaymentDays Δ", f"{term_delta:+d}d") | |
| with c3: | |
| kpi_card("Carrying Cost Adj", f"${simdf['CarryingCostAdj'].sum():,.0f}") | |
| st.caption("Supplier-level OTIF change") | |
| g = simdf.groupby("Supplier", as_index=False)[["OTIF","OTIF_Sim"]].mean() | |
| g["OTIF"] = (g["OTIF"]*100).round(1) | |
| g["OTIF_Sim"] = (g["OTIF_Sim"]*100).round(1) | |
| fig8 = px.bar(g.melt(id_vars="Supplier", value_vars=["OTIF","OTIF_Sim"], var_name="Metric", value_name="OTIF%"), x="Supplier", y="OTIF%", color="Metric", barmode="group", template="plotly_white", height=400) | |
| st.plotly_chart(fig8, use_container_width=True) | |
| st.dataframe(g.sort_values("OTIF_Sim", ascending=False), use_container_width=True, height=260) | |
| else: | |
| st.info("No data to simulate. Adjust filters.") | |
| # --------------------------- | |
| # Agentic Chat (Demo) | |
| # --------------------------- | |
| st.markdown("---") | |
| st.subheader("Agent Assistant") | |
| st.caption("Ask procurement questions, e.g., “Top suppliers by OTIF this month,” “Compare ACME vs GLOBAL_MFG on price and lead time,” “Show spend by RM group last 30 days.”") | |
| if "messages" not in st.session_state: | |
| st.session_state.messages = [ | |
| {"role": "assistant", "content": "Hello! I can analyze procurement data, compute KPIs, and run what‑if simulations. What would you like to see?"} | |
| ] | |
| # Simple tool functions (CDS-like queries) | |
| def tool_top_suppliers_by(metric="OTIF", topn=5): | |
| if not len(fdf): return "No data in current selection." | |
| g = fdf.groupby("Supplier", as_index=False)[metric].mean() | |
| if metric != "OTIF": | |
| # For value metrics that make sense as sum (e.g., NetValue) | |
| if metric in ["NetValue","Quantity"]: | |
| g = fdf.groupby("Supplier", as_index=False)[metric].sum() | |
| g = g.sort_values(metric, ascending=False).head(topn) | |
| return g | |
| def tool_compare_suppliers(sup_a, sup_b): | |
| sub = fdf[fdf["Supplier"].isin([sup_a, sup_b])] | |
| if not len(sub): return "No data for those suppliers in current selection." | |
| stats = sub.groupby("Supplier").agg( | |
| Spend=("NetValue","sum"), | |
| AvgPrice=("NetPrice","mean"), | |
| AvgLead=("LeadTimeDays","mean"), | |
| OTIF=("OTIF","mean") | |
| ).reset_index() | |
| stats["OTIF%"] = (stats["OTIF"]*100).round(1) | |
| return stats | |
| def tool_spend_by_dim(dim="MaterialGroup"): | |
| if not len(fdf): return None | |
| g = fdf.groupby(dim, as_index=False)["NetValue"].sum().sort_values("NetValue", ascending=False) | |
| return g | |
| def tool_show_recent_po_lines(n=10): | |
| if not len(fdf): return None | |
| cols = ["PO_ID","PO_Item","PO_Date","Supplier","Material","Quantity","OrderUnit","NetPrice","NetValue","DeliveryDate","Status","LeadTimeDays","OTIF"] | |
| return fdf.sort_values("PO_Date", ascending=False)[cols].head(n) | |
| # Heuristic “planner” to route user intents to tools | |
| def agent_router(prompt: str): | |
| p = prompt.lower().strip() | |
| # pattern routes | |
| if "top" in p and "supplier" in p and "otif" in p: | |
| n = 5 | |
| for tok in p.split(): | |
| if tok.isdigit(): | |
| n = int(tok) | |
| break | |
| return ("top_suppliers_otif", {"topn": n}) | |
| if "compare" in p and "supplier" in p: | |
| # naive extract A vs B | |
| tokens = p.replace("compare","").replace("supplier","").replace("suppliers","").replace(" vs "," ").split() | |
| # heuristic: choose two known supplier names intersected | |
| known = set(df["Supplier"].unique().tolist()) | |
| picks = [t for t in tokens if t.upper() in known] | |
| if len(picks) >= 2: | |
| return ("compare_suppliers", {"a": picks[0].upper(), "b": picks[1].upper()}) | |
| return ("compare_suppliers", {"a": "ACME_SUPPLY", "b": "GLOBAL_MFG"}) | |
| if "spend" in p and ("material group" in p or "group" in p): | |
| return ("spend_by_dim", {"dim": "MaterialGroup"}) | |
| if "recent" in p or ("last" in p and "po" in p): | |
| return ("recent_pos", {"n": 10}) | |
| if "lead time" in p and "supplier" in p: | |
| return ("lead_by_supplier", {}) | |
| if "price" in p and "supplier" in p: | |
| return ("price_by_supplier", {}) | |
| # default: summary | |
| return ("summary", {}) | |
| def agent_execute(route, args): | |
| if route == "top_suppliers_otif": | |
| g = tool_top_suppliers_by("OTIF", topn=args.get("topn",5)) | |
| if isinstance(g, str): | |
| return g, None | |
| g2 = g.copy() | |
| g2["OTIF%"] = (g2["OTIF"]*100).round(1) | |
| fig = px.bar(g2, x="Supplier", y="OTIF%", color="Supplier", template="plotly_white", height=360) | |
| return "Top suppliers by OTIF:", fig | |
| if route == "compare_suppliers": | |
| stats = tool_compare_suppliers(args.get("a"), args.get("b")) | |
| if isinstance(stats, str): | |
| return stats, None | |
| fig = px.bar(stats, x="Supplier", y=["Spend","AvgPrice","AvgLead","OTIF"], barmode="group", template="plotly_white", height=420) | |
| return "Comparison across Spend, AvgPrice, AvgLead, and OTIF:", fig | |
| if route == "spend_by_dim": | |
| g = tool_spend_by_dim(args.get("dim","MaterialGroup")) | |
| if g is None: | |
| return "No data for spend by dimension.", None | |
| fig = px.treemap(g, path=[args.get("dim","MaterialGroup")], values="NetValue", height=420) | |
| return f"Spend by {args.get('dim','MaterialGroup')}:", fig | |
| if route == "recent_pos": | |
| lines = tool_show_recent_po_lines(args.get("n",10)) | |
| if lines is None: | |
| return "No recent PO lines found.", None | |
| st.dataframe(lines, use_container_width=True, height=260) | |
| return f"Showing {len(lines)} most recent PO lines.", None | |
| if route == "lead_by_supplier": | |
| if not len(fdf): return "No data.", None | |
| g = fdf.groupby("Supplier", as_index=False)["LeadTimeDays"].mean() | |
| fig = px.bar(g.sort_values("LeadTimeDays"), x="LeadTimeDays", y="Supplier", orientation="h", template="plotly_white", height=420) | |
| return "Average lead time by supplier:", fig | |
| if route == "price_by_supplier": | |
| if not len(fdf): return "No data.", None | |
| g = fdf.groupby("Supplier", as_index=False)["NetPrice"].mean() | |
| fig = px.bar(g.sort_values("NetPrice", ascending=False), x="Supplier", y="NetPrice", template="plotly_white", height=420) | |
| return "Average price by supplier:", fig | |
| # summary | |
| msg = f"In current selection: {fdf['PO_ID'].nunique()} POs, spend ${fdf['NetValue'].sum():,.0f}, avg lead time {fdf['LeadTimeDays'].mean():.1f}d, OTIF {(fdf['OTIF'].mean()*100):.0f}%." | |
| return msg, None | |
| # Render chat history | |
| for m in st.session_state.messages: | |
| with st.chat_message(m["role"], avatar="🧭" if m["role"]=="assistant" else "🧑🏻"): | |
| st.write(m["content"]) | |
| prompt = st.chat_input("Ask about procurement performance, suppliers, KPIs, or simulations…") | |
| if prompt: | |
| st.session_state.messages.append({"role": "user", "content": prompt}) | |
| with st.chat_message("user", avatar="🧑🏻"): | |
| st.write(prompt) | |
| with st.chat_message("assistant", avatar="🧭"): | |
| with st.status("Thinking…", expanded=False): | |
| route, args = agent_router(prompt) | |
| text, fig = agent_execute(route, args) | |
| if text: | |
| st.write(text) | |
| if fig is not None: | |
| st.plotly_chart(fig, use_container_width=True) | |
| st.session_state.messages.append({"role": "assistant", "content": text or "(shown as chart/table)"}) | |