PD03 commited on
Commit
05cffcc
Β·
verified Β·
1 Parent(s): 06ad2a5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +187 -199
app.py CHANGED
@@ -1,7 +1,6 @@
1
  import os
2
  import time
3
  import json
4
- import math
5
  import random
6
  from dataclasses import dataclass
7
  from typing import Any, Dict, List, Optional, Tuple
@@ -10,7 +9,6 @@ import numpy as np
10
  import pandas as pd
11
  import streamlit as st
12
  import plotly.express as px
13
- import plotly.graph_objects as go
14
  from streamlit_option_menu import option_menu
15
  from faker import Faker
16
  from datetime import datetime, timedelta
@@ -81,12 +79,23 @@ st.markdown(
81
  )
82
 
83
  # =============================
84
- # Config & LLM Client (robust, version-agnostic)
 
 
 
 
 
 
 
 
 
 
 
85
  # =============================
86
  @dataclass
87
  class LLMConfig:
88
- provider: str = os.getenv("LLM_PROVIDER", "openai").lower() # openai | azure | compatible
89
- base_url: Optional[str] = os.getenv("OPENAI_BASE_URL") # for compatible endpoints
90
  api_key: Optional[str] = (
91
  os.getenv("OPENAI_API_KEY")
92
  or os.getenv("OPENAI_API_TOKEN")
@@ -95,7 +104,7 @@ class LLMConfig:
95
  model: str = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
96
  timeout: int = int(os.getenv("OPENAI_TIMEOUT", "45"))
97
  max_retries: int = int(os.getenv("OPENAI_MAX_RETRIES", "5"))
98
- temperature: float = float(os.getenv("OPENAI_TEMPERATURE", "0.6"))
99
 
100
 
101
  def _post_json(url: str, headers: Dict[str, str], payload: Dict[str, Any], timeout: int):
@@ -104,12 +113,6 @@ def _post_json(url: str, headers: Dict[str, str], payload: Dict[str, Any], timeo
104
 
105
 
106
  class UniversalLLMClient:
107
- """A resilient client that works with OpenAI, Azure OpenAI, and compatible APIs.
108
- - Prefers /chat/completions
109
- - Falls back to /responses if available
110
- - Retries with exponential backoff and respects Retry-After
111
- """
112
-
113
  def __init__(self, cfg: LLMConfig):
114
  self.cfg = cfg
115
  self.available = bool(cfg.api_key)
@@ -121,20 +124,11 @@ class UniversalLLMClient:
121
  return {"Authorization": f"Bearer {self.cfg.api_key}", "Content-Type": "application/json"}
122
 
123
  def _base_url(self) -> str:
124
- if self.cfg.provider == "azure":
125
- # Use Azure env format if provided
126
- endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
127
- api_version = os.getenv("AZURE_OPENAI_API_VERSION", "2024-02-15-preview")
128
- deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT", self.cfg.model)
129
- # Azure uses deployment name in path
130
- return f"{endpoint}/openai/deployments/{deployment}?api-version={api_version}"
131
  return (self.cfg.base_url or "https://api.openai.com/v1").rstrip("/")
132
 
133
  def _smoke_test(self):
134
  try:
135
- _ = self.chat([
136
- {"role": "user", "content": "ping"}
137
- ], max_tokens=4)
138
  except Exception as e:
139
  self.available = False
140
  self.last_error = str(e)
@@ -142,22 +136,15 @@ class UniversalLLMClient:
142
  def chat(self, messages: List[Dict[str, str]], max_tokens: int = 400) -> str:
143
  if not self.available:
144
  raise RuntimeError("No API key configured")
145
-
146
  headers = self._headers()
147
  base = self._base_url()
148
-
149
- # Endpoint selection
150
- chat_url = f"{base}/chat/completions" if self.cfg.provider != "azure" else f"{base}&api-version-override=false" # azure path already includes params
151
- responses_url = f"{base}/responses"
152
-
153
  payload = {
154
  "model": self.cfg.model,
155
  "messages": messages,
156
  "max_tokens": max_tokens,
157
  "temperature": self.cfg.temperature,
158
  }
159
-
160
- # Retry with backoff
161
  delay = 1.0
162
  for attempt in range(self.cfg.max_retries):
163
  try:
@@ -165,23 +152,10 @@ class UniversalLLMClient:
165
  if resp.status_code == 200:
166
  data = resp.json()
167
  return data["choices"][0]["message"]["content"].strip()
168
- # Try /responses fallback for some providers
169
- if resp.status_code in (404, 400):
170
- alt = _post_json(
171
- responses_url,
172
- headers,
173
- {"model": self.cfg.model, "input": messages, "max_output_tokens": max_tokens, "temperature": self.cfg.temperature},
174
- self.cfg.timeout,
175
- )
176
- if alt.status_code == 200:
177
- return alt.json()["output"][0]["content"][0]["text"].strip()
178
-
179
  if resp.status_code in (429, 500, 502, 503, 504):
180
- retry_after = float(resp.headers.get("Retry-After", delay))
181
- time.sleep(retry_after)
182
  delay = min(delay * 2, 8.0)
183
  continue
184
- # Other errors β†’ raise
185
  try:
186
  j = resp.json()
187
  msg = j.get("error", {}).get("message", str(j))
@@ -196,13 +170,11 @@ class UniversalLLMClient:
196
  delay = min(delay * 2, 8.0)
197
  raise RuntimeError("Exhausted retries")
198
 
199
-
200
  # =============================
201
- # Data Generation & Utils
202
  # =============================
203
  @st.cache_data(show_spinner=False)
204
  def generate_synthetic_procurement_data(seed: int = 42) -> Tuple[pd.DataFrame, pd.DataFrame]:
205
- """Generate richer synthetic SAP S/4HANA procurement data, including lead times and late flags."""
206
  fake = Faker()
207
  np.random.seed(seed)
208
  random.seed(seed)
@@ -218,7 +190,6 @@ def generate_synthetic_procurement_data(seed: int = 42) -> Tuple[pd.DataFrame, p
218
  ]
219
 
220
  purchase_orders: List[Dict[str, Any]] = []
221
- today = datetime.utcnow().date()
222
 
223
  for i in range(900):
224
  order_date = fake.date_between(start_date='-24m', end_date='today')
@@ -239,8 +210,6 @@ def generate_synthetic_procurement_data(seed: int = 42) -> Tuple[pd.DataFrame, p
239
  'order_date': order_date,
240
  'promised_date': promised_date,
241
  'delivery_date': delivery_date,
242
- 'lead_time_days': (delivery_date - order_date).days,
243
- 'promised_days': promised_days,
244
  'late_delivery': late,
245
  'order_value': order_value,
246
  'quantity': qty,
@@ -248,7 +217,7 @@ def generate_synthetic_procurement_data(seed: int = 42) -> Tuple[pd.DataFrame, p
248
  'status': random.choice(['Open', 'Delivered', 'Invoiced', 'Paid']),
249
  'plant': random.choice(['Plant_001', 'Plant_002', 'Plant_003']),
250
  'buyer': fake.name(),
251
- 'currency': 'EUR',
252
  'payment_terms': random.choice(['30 Days', '45 Days', '60 Days', '90 Days']),
253
  'quality_score': round(np.clip(np.random.normal(8.5, 0.8), 5.0, 10.0), 1),
254
  }
@@ -270,11 +239,6 @@ def generate_synthetic_procurement_data(seed: int = 42) -> Tuple[pd.DataFrame, p
270
  spend_df = pd.DataFrame(spend_rows)
271
  return po_df, spend_df
272
 
273
-
274
- def eur(x: float) -> str:
275
- return f"€{x:,.0f}"
276
-
277
-
278
  # =============================
279
  # Analytics Engine
280
  # =============================
@@ -284,9 +248,8 @@ class ProcurementAnalytics:
284
  self.df['order_date'] = pd.to_datetime(self.df['order_date'])
285
  self.df['month'] = self.df['order_date'].dt.to_period('M').dt.to_timestamp()
286
 
287
- @st.cache_data(show_spinner=False)
288
- def kpis(_self, df_hash: int) -> Dict[str, Any]:
289
- df = _self.df
290
  return {
291
  'total_spend': float(df['order_value'].sum()),
292
  'avg_order_value': float(df['order_value'].mean()),
@@ -296,14 +259,10 @@ class ProcurementAnalytics:
296
  }
297
 
298
  def category_spend(self) -> pd.DataFrame:
299
- return (
300
- self.df.groupby('material_category', as_index=False)['order_value'].sum()
301
- .sort_values('order_value', ascending=False)
302
- )
303
 
304
  def vendor_spend(self, top_n: int = 8) -> pd.DataFrame:
305
- g = self.df.groupby('vendor', as_index=False)['order_value'].sum()
306
- return g.sort_values('order_value', ascending=False).head(top_n)
307
 
308
  def monthly_spend(self) -> pd.DataFrame:
309
  return self.df.groupby('month', as_index=False)['order_value'].sum().sort_values('month')
@@ -314,154 +273,179 @@ class ProcurementAnalytics:
314
  on_time=('late_delivery', lambda s: 1 - s.mean()),
315
  quality=('quality_score', 'mean'),
316
  orders=('po_number', 'count'),
317
- lead_time=('lead_time_days', 'mean'),
318
  )
319
  g['on_time'] = (g['on_time'] * 100).round(1)
320
  g['quality'] = g['quality'].round(2)
321
- g['lead_time'] = g['lead_time'].round(1)
322
  g['total_spend'] = g['total_spend'].round(2)
323
  return g.sort_values('total_spend', ascending=False)
324
 
325
- def anomalies(self) -> pd.DataFrame:
326
- # Simple IQR for order_value anomalies
327
- q1, q3 = self.df['order_value'].quantile([0.25, 0.75])
328
- iqr = q3 - q1
329
- hi = q3 + 1.5 * iqr
330
- lo = max(0, q1 - 1.5 * iqr)
331
- a = self.df[(self.df['order_value'] > hi) | (self.df['order_value'] < lo)].copy()
332
- a['anomaly_reason'] = np.where(a['order_value'] > hi, 'High value', 'Low value')
333
- return a.sort_values('order_value', ascending=False).head(50)
334
-
335
- def simulate_vendor_consolidation(self, keep_top: int) -> Dict[str, Any]:
336
- g = self.df.groupby('vendor')['order_value'].sum().sort_values(ascending=False)
337
- kept_vendors = list(g.head(keep_top).index)
338
- kept_spend = self.df[self.df['vendor'].isin(kept_vendors)]['order_value'].sum()
339
- total_spend = self.df['order_value'].sum()
340
- share = kept_spend / total_spend if total_spend else 0
341
- est_savings = 0.05 + (0.12 * (1 - share)) # heuristic: better leverage when consolidating
342
- return {
343
- 'kept_vendors': kept_vendors,
344
- 'kept_share': share,
345
- 'estimated_savings_pct': max(0.03, min(0.18, est_savings)),
346
- }
347
 
 
 
 
 
348
 
349
  # =============================
350
- # Agent (uses UniversalLLMClient with safe fallback)
351
  # =============================
352
  class UniversalProcurementAgent:
353
  def __init__(self, po_df: pd.DataFrame, spend_df: pd.DataFrame, client: UniversalLLMClient):
354
  self.po_data = po_df
355
  self.spend_data = spend_df
356
  self.llm = client
357
-
358
- def llm_status(self) -> Dict[str, Any]:
359
- return {
360
- "api_key_available": bool(self.llm.cfg.api_key),
361
- "llm_available": self.llm.available,
362
- "last_error": self.llm.last_error or "Connected successfully" if self.llm.available else "Unavailable",
363
- "provider": self.llm.cfg.provider,
364
- "model": self.llm.cfg.model,
365
- "base_url": self.llm.cfg.base_url or "https://api.openai.com/v1",
366
- }
367
-
368
- def _rule_summary(self) -> str:
369
- total_spend = float(self.po_data['order_value'].sum())
370
- on_time = float((~self.po_data['late_delivery']).mean()) * 100
371
- quality = float(self.po_data['quality_score'].mean())
372
- top_cat = self.po_data.groupby('material_category')['order_value'].sum().idxmax()
373
- top_vendor = self.po_data.groupby('vendor')['order_value'].sum().idxmax()
374
- return (
375
- "πŸ€– **[Smart Analysis - Rule-Based Engine]**\n"
376
- "**Executive Snapshot**\n"
377
- f"β€’ Total spend: {eur(total_spend)} across {len(self.po_data):,} POs\n"
378
- f"β€’ On-time delivery: {on_time:.1f}% β€’ Avg quality: {quality:.1f}/10\n"
379
- f"β€’ Top category: {top_cat} β€’ Lead vendor: {top_vendor}\n\n"
380
- "**Opportunities**\n"
381
- "β€’ Consolidate long tail vendors to improve pricing power (5–12% potential).\n"
382
- "β€’ Tighten SLAs for late deliveries and extend performance-based contracts.\n"
383
- "β€’ Automate low-value buys to reduce cycle time."
384
- )
385
 
386
  def executive_summary(self) -> str:
387
  if not self.llm.available:
388
  return self._rule_summary()
 
 
 
389
  data_summary = {
390
- "total_spend": float(self.po_data['order_value'].sum()),
391
  "total_orders": int(len(self.po_data)),
392
  "vendor_count": int(self.po_data['vendor'].nunique()),
393
- "avg_order_value": float(self.po_data['order_value'].mean()),
394
- "on_time_delivery": float((~self.po_data['late_delivery']).mean()),
395
- "avg_quality": float(self.po_data['quality_score'].mean()),
 
 
396
  }
397
  messages = [
398
- {"role": "system", "content": "You are a senior procurement analyst with expertise in SAP S/4HANA. Be concise, metric-driven, and actionable."},
 
 
 
399
  {"role": "user", "content": (
400
- "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"
 
 
401
  f"Data: {json.dumps(data_summary)}"
402
  )},
403
  ]
404
  try:
405
- return "🧠 **[AI-Powered Analysis]**\n\n" + self.llm.chat(messages, max_tokens=650)
 
 
406
  except Exception as e:
407
- return self._rule_summary() + f"\n\n*AI fallback due to: {e}*"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
 
409
  def chat_with_data(self, question: str) -> str:
410
  if not self.llm.available:
411
  return self._rule_answer(question)
 
 
 
412
  context = {
413
- "total_spend": float(self.po_data['order_value'].sum()),
414
  "orders": int(len(self.po_data)),
415
  "vendors": int(self.po_data['vendor'].nunique()),
416
- "on_time": float((~self.po_data['late_delivery']).mean()),
417
- "quality": float(self.po_data['quality_score'].mean()),
 
 
418
  }
 
 
 
 
 
419
  messages = [
420
- {"role": "system", "content": "You are an expert procurement co-pilot. Use the provided context and respond with precise metrics and concrete actions."},
421
- {"role": "user", "content": f"Question: {question}\nContext: {json.dumps(context)}"},
 
 
 
 
422
  ]
423
  try:
424
- return "🧠 **[AI Response]**\n\n" + self.llm.chat(messages, max_tokens=450)
 
 
425
  except Exception as e:
426
- return self._rule_answer(question) + f"\n\n*AI fallback due to: {e}*"
 
 
427
 
428
  def _rule_answer(self, question: str) -> str:
429
  q = question.lower()
430
- if any(w in q for w in ["spend", "cost", "budget"]):
431
- total = float(self.po_data['order_value'].sum())
432
- monthly = total / max(1, self.po_data['order_date'].nunique()/30)
433
- top_cat = self.po_data.groupby('material_category')['order_value'].sum().idxmax()
434
- return (
435
- "πŸ€– **[Smart Analysis] Spend**\n"
436
- f"β€’ Total spend: {eur(total)}\n"
437
- f"β€’ Monthly average (approx): {eur(monthly)}\n"
438
- f"β€’ Top category: {top_cat}\n"
439
- "Tip: prioritize competitive events for the top 2 categories to unlock 4–8% savings."
440
- )
441
- if any(w in q for w in ["vendor", "supplier", "partner"]):
 
 
442
  vp = self.po_data.groupby('vendor').agg(
443
  spend=('order_value','sum'),
444
- on_time=('late_delivery', lambda s: 1 - s.mean()),
445
- ).sort_values('spend', ascending=False).head(1)
446
- top = vp.index[0]
447
- on_time = float(vp.iloc[0]['on_time'])*100
448
- return (
449
- "πŸ€– **[Smart Analysis] Vendor**\n"
450
- f"β€’ Top vendor: {top} β€’ On-time: {on_time:.1f}%\n"
451
- "Action: lock in volume tiers and add delivery penalties to the contract."
452
- )
453
- if any(w in q for w in ["risk", "late", "delay"]):
454
- late_rate = float(self.po_data['late_delivery'].mean())*100
455
- return (
456
- "πŸ€– **[Smart Analysis] Risk**\n"
457
- f"β€’ Late delivery rate: {late_rate:.1f}%\n"
458
- "Action: add buffer to planning lead times and escalate chronic late suppliers."
459
- )
 
 
 
 
 
 
 
 
 
 
460
  return (
461
- "πŸ€– **[Smart Analysis]** I can help with spend, vendor performance, risk, savings, and trends. Try: \"Where can I save 10%?\""
 
 
 
 
462
  )
463
 
464
-
465
  # =============================
466
  # App State & Initialization
467
  # =============================
@@ -478,8 +462,12 @@ client = get_llm_client()
478
  agent = UniversalProcurementAgent(st.session_state.po_df, st.session_state.spend_df, client)
479
  analytics = ProcurementAnalytics(st.session_state.po_df)
480
 
481
- status = agent.llm_status()
482
- api_status = "🟒 Connected" if status['llm_available'] else "πŸ”΄ Not Connected"
 
 
 
 
483
 
484
  # =============================
485
  # Header
@@ -489,7 +477,7 @@ st.markdown(
489
  <div class="main-header">
490
  <h1>πŸ€– SAP S/4HANA Agentic AI Procurement Analytics</h1>
491
  <p>Autonomous Intelligence for Procurement Excellence</p>
492
- <small>OpenAI: {api_status} Β· Data: {len(st.session_state.po_df):,} POs</small>
493
  </div>
494
  """,
495
  unsafe_allow_html=True,
@@ -501,21 +489,10 @@ st.markdown(
501
  with st.sidebar:
502
  st.markdown("### πŸ€– AI System Status")
503
  st.markdown(f"**Connection:** {api_status}")
504
- st.markdown(f"**Provider:** {status['provider']} ")
505
  st.markdown(f"**Model:** {status['model']}")
506
 
507
  with st.expander("πŸ” System Information"):
508
- safe = status.copy()
509
- # Do not expose API key
510
- st.json({k: v for k, v in safe.items() if k != 'api_key'})
511
-
512
- if st.button("πŸ”„ Test AI Connection"):
513
- if status['llm_available']:
514
- st.success("LLM is reachable and ready.")
515
- else:
516
- st.error(f"LLM unavailable: {status['last_error']}")
517
-
518
- st.markdown("---")
519
 
520
  selected = option_menu(
521
  "Navigation",
@@ -545,13 +522,13 @@ if selected == "🏠 Dashboard":
545
  </div>
546
  """, unsafe_allow_html=True)
547
 
548
- k = analytics.kpis(hash(tuple(st.session_state.po_df['po_number'])))
549
 
550
  c1, c2, c3, c4 = st.columns(4)
551
  with c1:
552
- 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)
553
  with c2:
554
- 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)
555
  with c3:
556
  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)
557
  with c4:
@@ -580,23 +557,25 @@ if selected == "🏠 Dashboard":
580
  st.plotly_chart(fig3, use_container_width=True)
581
 
582
  with colD:
583
- ano = analytics.anomalies()
584
- st.markdown("#### πŸ”Ž High/Low Value Anomalies (Top 50)")
585
- st.dataframe(ano[['po_number','vendor','material_category','order_value','anomaly_reason']].reset_index(drop=True), use_container_width=True, height=380)
 
 
586
 
587
  elif selected == "πŸ’¬ AI Chat":
588
  st.markdown("### πŸ’¬ Chat with Your Procurement Data")
589
  st.markdown(f"""
590
- <div class="ai-insight">
591
  <h4>πŸ€– Universal AI Assistant</h4>
592
- <p>Ask me anything about your procurement data! I'm provider-agnostic and resilient to API versions.</p>
593
- <p><small>Status: {api_status} | Provider: {status['provider']} | Model: {status['model']}</small></p>
594
  </div>
595
  """, unsafe_allow_html=True)
596
 
597
  if "messages" not in st.session_state:
598
  st.session_state.messages = [
599
- {"role": "assistant", "content": "Hello! I loaded your data and I'm ready to helpβ€”try asking about spend, vendors, or risk."}
600
  ]
601
 
602
  for m in st.session_state.messages:
@@ -613,9 +592,13 @@ elif selected == "πŸ’¬ AI Chat":
613
  st.markdown(reply)
614
  st.session_state.messages.append({"role": "assistant", "content": reply})
615
 
616
- st.markdown("#### πŸ’‘ Try quick questions:")
617
  c1, c2, c3 = st.columns(3)
618
- qs = ["What are my biggest spending areas?", "How are my vendors performing?", "Where can I save 10%?"]
 
 
 
 
619
  for i, (c, q) in enumerate(zip([c1, c2, c3], qs)):
620
  with c:
621
  if st.button(f"πŸ’­ {q}", key=f"q_{i}"):
@@ -627,11 +610,10 @@ elif selected == "πŸ“Š Analytics":
627
  st.markdown("### πŸ“ˆ Advanced Analytics Dashboard")
628
  vp = analytics.vendor_performance()
629
  st.dataframe(vp.rename(columns={
630
- 'total_spend': 'Total Spend (€)',
631
  'on_time': 'On-Time Delivery %',
632
  'quality': 'Quality Score',
633
  'orders': 'Order Count',
634
- 'lead_time': 'Avg Lead Time (days)'
635
  }), use_container_width=True)
636
 
637
  st.download_button(
@@ -644,14 +626,20 @@ elif selected == "πŸ“Š Analytics":
644
  elif selected == "πŸ§ͺ What‑If":
645
  st.markdown("### πŸ§ͺ What‑If: Vendor Consolidation Simulator")
646
  top_n = st.slider("Keep top N vendors by spend", min_value=2, max_value=10, value=6, step=1)
647
- sim = analytics.simulate_vendor_consolidation(keep_top=top_n)
648
 
649
- kept_names = ", ".join(sim['kept_vendors'])
 
 
 
 
 
 
 
650
  st.markdown(
651
  f"""
652
  <div class='alert alert-info'>
653
- <strong>Scenario:</strong> Keep top <b>{top_n}</b> vendors. Estimated addressable spend share: <b>{sim['kept_share']*100:.1f}%</b>.<br/>
654
- <strong>Potential savings:</strong> <b>{sim['estimated_savings_pct']*100:.1f}%</b> (heuristic).<br/>
655
  <small>Kept Vendors:</small> {kept_names}
656
  </div>
657
  """,
@@ -659,7 +647,7 @@ elif selected == "πŸ§ͺ What‑If":
659
  )
660
 
661
  if st.checkbox("Show detailed vendor spend"):
662
- st.dataframe(analytics.vendor_spend(top_n=999), use_container_width=True)
663
 
664
  elif selected == "🎯 Recommendations":
665
  st.markdown("### πŸš€ Strategic Recommendations")
@@ -688,8 +676,8 @@ st.markdown("---")
688
  st.markdown(
689
  f"""
690
  <div style="text-align:center; padding: 1rem; color:#666;">
691
- <p>πŸ€– <strong>Universal AI Procurement Analytics</strong> | Provider‑agnostic LLM integration with resilient fallbacks</p>
692
- <p><em>Demo with synthetic data β€’ {len(st.session_state.po_df):,} orders β€’ OpenAI {api_status}</em></p>
693
  </div>
694
  """,
695
  unsafe_allow_html=True,
 
1
  import os
2
  import time
3
  import json
 
4
  import random
5
  from dataclasses import dataclass
6
  from typing import Any, Dict, List, Optional, Tuple
 
9
  import pandas as pd
10
  import streamlit as st
11
  import plotly.express as px
 
12
  from streamlit_option_menu import option_menu
13
  from faker import Faker
14
  from datetime import datetime, timedelta
 
79
  )
80
 
81
  # =============================
82
+ # Currency Helper (β‚Ή)
83
+ # =============================
84
+ CURRENCY = "β‚Ή"
85
+
86
+ def fmt_currency(x: float) -> str:
87
+ try:
88
+ return f"{CURRENCY}{x:,.0f}"
89
+ except Exception:
90
+ return f"{CURRENCY}{x}"
91
+
92
+ # =============================
93
+ # Config & LLM Client (resilient)
94
  # =============================
95
  @dataclass
96
  class LLMConfig:
97
+ provider: str = os.getenv("LLM_PROVIDER", "openai").lower()
98
+ base_url: Optional[str] = os.getenv("OPENAI_BASE_URL")
99
  api_key: Optional[str] = (
100
  os.getenv("OPENAI_API_KEY")
101
  or os.getenv("OPENAI_API_TOKEN")
 
104
  model: str = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
105
  timeout: int = int(os.getenv("OPENAI_TIMEOUT", "45"))
106
  max_retries: int = int(os.getenv("OPENAI_MAX_RETRIES", "5"))
107
+ temperature: float = float(os.getenv("OPENAI_TEMPERATURE", "0.5"))
108
 
109
 
110
  def _post_json(url: str, headers: Dict[str, str], payload: Dict[str, Any], timeout: int):
 
113
 
114
 
115
  class UniversalLLMClient:
 
 
 
 
 
 
116
  def __init__(self, cfg: LLMConfig):
117
  self.cfg = cfg
118
  self.available = bool(cfg.api_key)
 
124
  return {"Authorization": f"Bearer {self.cfg.api_key}", "Content-Type": "application/json"}
125
 
126
  def _base_url(self) -> str:
 
 
 
 
 
 
 
127
  return (self.cfg.base_url or "https://api.openai.com/v1").rstrip("/")
128
 
129
  def _smoke_test(self):
130
  try:
131
+ _ = self.chat([{"role": "user", "content": "ping"}], max_tokens=4)
 
 
132
  except Exception as e:
133
  self.available = False
134
  self.last_error = str(e)
 
136
  def chat(self, messages: List[Dict[str, str]], max_tokens: int = 400) -> str:
137
  if not self.available:
138
  raise RuntimeError("No API key configured")
 
139
  headers = self._headers()
140
  base = self._base_url()
141
+ chat_url = f"{base}/chat/completions"
 
 
 
 
142
  payload = {
143
  "model": self.cfg.model,
144
  "messages": messages,
145
  "max_tokens": max_tokens,
146
  "temperature": self.cfg.temperature,
147
  }
 
 
148
  delay = 1.0
149
  for attempt in range(self.cfg.max_retries):
150
  try:
 
152
  if resp.status_code == 200:
153
  data = resp.json()
154
  return data["choices"][0]["message"]["content"].strip()
 
 
 
 
 
 
 
 
 
 
 
155
  if resp.status_code in (429, 500, 502, 503, 504):
156
+ time.sleep(delay)
 
157
  delay = min(delay * 2, 8.0)
158
  continue
 
159
  try:
160
  j = resp.json()
161
  msg = j.get("error", {}).get("message", str(j))
 
170
  delay = min(delay * 2, 8.0)
171
  raise RuntimeError("Exhausted retries")
172
 
 
173
  # =============================
174
+ # Data Generation
175
  # =============================
176
  @st.cache_data(show_spinner=False)
177
  def generate_synthetic_procurement_data(seed: int = 42) -> Tuple[pd.DataFrame, pd.DataFrame]:
 
178
  fake = Faker()
179
  np.random.seed(seed)
180
  random.seed(seed)
 
190
  ]
191
 
192
  purchase_orders: List[Dict[str, Any]] = []
 
193
 
194
  for i in range(900):
195
  order_date = fake.date_between(start_date='-24m', end_date='today')
 
210
  'order_date': order_date,
211
  'promised_date': promised_date,
212
  'delivery_date': delivery_date,
 
 
213
  'late_delivery': late,
214
  'order_value': order_value,
215
  'quantity': qty,
 
217
  'status': random.choice(['Open', 'Delivered', 'Invoiced', 'Paid']),
218
  'plant': random.choice(['Plant_001', 'Plant_002', 'Plant_003']),
219
  'buyer': fake.name(),
220
+ 'currency': 'INR',
221
  'payment_terms': random.choice(['30 Days', '45 Days', '60 Days', '90 Days']),
222
  'quality_score': round(np.clip(np.random.normal(8.5, 0.8), 5.0, 10.0), 1),
223
  }
 
239
  spend_df = pd.DataFrame(spend_rows)
240
  return po_df, spend_df
241
 
 
 
 
 
 
242
  # =============================
243
  # Analytics Engine
244
  # =============================
 
248
  self.df['order_date'] = pd.to_datetime(self.df['order_date'])
249
  self.df['month'] = self.df['order_date'].dt.to_period('M').dt.to_timestamp()
250
 
251
+ def kpis(self) -> Dict[str, Any]:
252
+ df = self.df
 
253
  return {
254
  'total_spend': float(df['order_value'].sum()),
255
  'avg_order_value': float(df['order_value'].mean()),
 
259
  }
260
 
261
  def category_spend(self) -> pd.DataFrame:
262
+ return self.df.groupby('material_category', as_index=False)['order_value'].sum().sort_values('order_value', ascending=False)
 
 
 
263
 
264
  def vendor_spend(self, top_n: int = 8) -> pd.DataFrame:
265
+ return self.df.groupby('vendor', as_index=False)['order_value'].sum().sort_values('order_value', ascending=False).head(top_n)
 
266
 
267
  def monthly_spend(self) -> pd.DataFrame:
268
  return self.df.groupby('month', as_index=False)['order_value'].sum().sort_values('month')
 
273
  on_time=('late_delivery', lambda s: 1 - s.mean()),
274
  quality=('quality_score', 'mean'),
275
  orders=('po_number', 'count'),
 
276
  )
277
  g['on_time'] = (g['on_time'] * 100).round(1)
278
  g['quality'] = g['quality'].round(2)
 
279
  g['total_spend'] = g['total_spend'].round(2)
280
  return g.sort_values('total_spend', ascending=False)
281
 
282
+ # helper: top N with shares
283
+ def top_n_categories(self, n: int = 3) -> List[Tuple[str, float]]:
284
+ cat = self.category_spend()
285
+ total = float(cat['order_value'].sum()) or 1.0
286
+ return [(r['material_category'], (r['order_value']/total)*100) for _, r in cat.head(n).iterrows()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
 
288
+ def top_n_vendors(self, n: int = 3) -> List[Tuple[str, float]]:
289
+ ven = self.df.groupby('vendor', as_index=False)['order_value'].sum().sort_values('order_value', ascending=False)
290
+ total = float(ven['order_value'].sum()) or 1.0
291
+ return [(r['vendor'], (r['order_value']/total)*100) for _, r in ven.head(n).iterrows()]
292
 
293
  # =============================
294
+ # Agent with tighter prompts & INR formatting
295
  # =============================
296
  class UniversalProcurementAgent:
297
  def __init__(self, po_df: pd.DataFrame, spend_df: pd.DataFrame, client: UniversalLLMClient):
298
  self.po_data = po_df
299
  self.spend_data = spend_df
300
  self.llm = client
301
+ self.analytics = ProcurementAnalytics(po_df)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
 
303
  def executive_summary(self) -> str:
304
  if not self.llm.available:
305
  return self._rule_summary()
306
+ k = self.analytics.kpis()
307
+ top_cats = self.analytics.top_n_categories(3)
308
+ top_vens = self.analytics.top_n_vendors(3)
309
  data_summary = {
310
+ "total_spend": k['total_spend'],
311
  "total_orders": int(len(self.po_data)),
312
  "vendor_count": int(self.po_data['vendor'].nunique()),
313
+ "avg_order_value": k['avg_order_value'],
314
+ "on_time_delivery": k['on_time_rate'],
315
+ "avg_quality": k['quality_avg'],
316
+ "top_categories": top_cats,
317
+ "top_vendors": top_vens,
318
  }
319
  messages = [
320
+ {"role": "system", "content": (
321
+ "You are a senior procurement analyst. Use bullet points, be concise, and always use the β‚Ή symbol. "
322
+ "When summarizing, include top categories and vendors with percentages, then 2-3 quantified actions."
323
+ )},
324
  {"role": "user", "content": (
325
+ f"Executive summary. Format amounts with commas (e.g., β‚Ή12,34,567).
326
+
327
+ "
328
  f"Data: {json.dumps(data_summary)}"
329
  )},
330
  ]
331
  try:
332
+ return "🧠 **[AI-Powered Analysis]**
333
+
334
+ " + self.llm.chat(messages, max_tokens=550)
335
  except Exception as e:
336
+ return self._rule_summary() + f"
337
+
338
+ *AI fallback due to: {e}*"
339
+
340
+ def _rule_summary(self) -> str:
341
+ k = self.analytics.kpis()
342
+ top_c = self.analytics.top_n_categories(3)
343
+ top_v = self.analytics.top_n_vendors(3)
344
+ topc_str = ", ".join([f"{n} – {s:.0f}%" for n, s in top_c])
345
+ topv_str = ", ".join([f"{n} – {s:.0f}%" for n, s in top_v])
346
+ return (
347
+ "πŸ€– **[Rule-Based Summary]**
348
+ "
349
+ f"β€’ Total spend: {fmt_currency(k['total_spend'])} across {len(self.po_data):,} POs
350
+ "
351
+ f"β€’ On-time delivery: {k['on_time_rate']*100:.1f}% | Avg quality: {k['quality_avg']:.1f}/10
352
+ "
353
+ f"β€’ Top categories: {topc_str}
354
+ "
355
+ f"β€’ Top vendors: {topv_str}
356
+ "
357
+ "Actions: Consolidate long tail; multi-year terms with top vendors; auto-approve low-value POs."
358
+ )
359
 
360
  def chat_with_data(self, question: str) -> str:
361
  if not self.llm.available:
362
  return self._rule_answer(question)
363
+ k = self.analytics.kpis()
364
+ top_c = self.analytics.top_n_categories(3)
365
+ top_v = self.analytics.top_n_vendors(3)
366
  context = {
367
+ "total_spend": k['total_spend'],
368
  "orders": int(len(self.po_data)),
369
  "vendors": int(self.po_data['vendor'].nunique()),
370
+ "on_time": k['on_time_rate'],
371
+ "quality": k['quality_avg'],
372
+ "top_categories": top_c,
373
+ "top_vendors": top_v,
374
  }
375
+ style_rules = (
376
+ "Rules: Answer in ≀6 bullet points, use β‚Ή, no generic how-to steps. "
377
+ "If question mentions spend, list top 3 categories and top 3 vendors with shares. "
378
+ "If vendors, show best & worst by on-time and spend. If risk, show late % and actions."
379
+ )
380
  messages = [
381
+ {"role": "system", "content": "You are a precise procurement co-pilot. Be direct, metric-first, and action-oriented."},
382
+ {"role": "user", "content": f"Q: {question}
383
+
384
+ Context: {json.dumps(context)}
385
+
386
+ {style_rules}"},
387
  ]
388
  try:
389
+ return "🧠 **[AI Response]**
390
+
391
+ " + self.llm.chat(messages, max_tokens=450)
392
  except Exception as e:
393
+ return self._rule_answer(question) + f"
394
+
395
+ *AI fallback due to: {e}*"
396
 
397
  def _rule_answer(self, question: str) -> str:
398
  q = question.lower()
399
+ k = self.analytics.kpis()
400
+ top_c = self.analytics.top_n_categories(3)
401
+ top_v = self.analytics.top_n_vendors(3)
402
+ if "spend" in q or "spending" in q or "cost" in q:
403
+ lines = [
404
+ f"β€’ Total spend: {fmt_currency(k['total_spend'])}",
405
+ "β€’ Top categories: " + ", ".join([f"{n} – {s:.0f}%" for n, s in top_c]),
406
+ "β€’ Top vendors: " + ", ".join([f"{n} – {s:.0f}%" for n, s in top_v]),
407
+ "β€’ Action: Run sourcing events for top 2 categories; target 8–12% savings via volume tiers.",
408
+ ]
409
+ return "πŸ€– **[Rule-Based Spend]**
410
+ " + "
411
+ ".join(lines)
412
+ if "vendor" in q or "supplier" in q or "partner" in q:
413
  vp = self.po_data.groupby('vendor').agg(
414
  spend=('order_value','sum'),
415
+ late_rate=('late_delivery','mean'),
416
+ quality=('quality_score','mean'),
417
+ ).sort_values('spend', ascending=False)
418
+ best = vp.head(1)
419
+ worst = vp.sort_values('late_rate', ascending=False).head(1)
420
+ bname, wname = best.index[0], worst.index[0]
421
+ blate = float(best.iloc[0]['late_rate'])*100
422
+ wlate = float(worst.iloc[0]['late_rate'])*100
423
+ lines = [
424
+ f"β€’ Best by spend: {bname} (late {blate:.1f}%)",
425
+ f"β€’ Worst by late deliveries: {wname} (late {wlate:.1f}%)",
426
+ "β€’ Action: Extend terms with best performer; corrective plan and SLA penalties for the worst.",
427
+ ]
428
+ return "πŸ€– **[Rule-Based Vendor]**
429
+ " + "
430
+ ".join(lines)
431
+ if "risk" in q or "late" in q or "delay" in q:
432
+ late = float(self.po_data['late_delivery'].mean())*100
433
+ lines = [
434
+ f"β€’ Late delivery rate: {late:.1f}%",
435
+ "β€’ Action: Add 5–10 day buffers; fast-track chronic offenders; add service credits for misses.",
436
+ ]
437
+ return "πŸ€– **[Rule-Based Risk]**
438
+ " + "
439
+ ".join(lines)
440
+ # default
441
  return (
442
+ "πŸ€– **[Rule-Based]**
443
+ "
444
+ "β€’ I can analyze spend (top categories/vendors), vendor performance (best/worst), risk (late %), and trends.
445
+ "
446
+ 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}%"
447
  )
448
 
 
449
  # =============================
450
  # App State & Initialization
451
  # =============================
 
462
  agent = UniversalProcurementAgent(st.session_state.po_df, st.session_state.spend_df, client)
463
  analytics = ProcurementAnalytics(st.session_state.po_df)
464
 
465
+ status = {
466
+ "available": client.available,
467
+ "last_error": client.last_error or "OK",
468
+ "model": client.cfg.model,
469
+ }
470
+ api_status = "🟒 Connected" if status['available'] else "πŸ”΄ Not Connected"
471
 
472
  # =============================
473
  # Header
 
477
  <div class="main-header">
478
  <h1>πŸ€– SAP S/4HANA Agentic AI Procurement Analytics</h1>
479
  <p>Autonomous Intelligence for Procurement Excellence</p>
480
+ <small>LLM: {api_status} Β· Data: {len(st.session_state.po_df):,} POs</small>
481
  </div>
482
  """,
483
  unsafe_allow_html=True,
 
489
  with st.sidebar:
490
  st.markdown("### πŸ€– AI System Status")
491
  st.markdown(f"**Connection:** {api_status}")
 
492
  st.markdown(f"**Model:** {status['model']}")
493
 
494
  with st.expander("πŸ” System Information"):
495
+ st.json(status)
 
 
 
 
 
 
 
 
 
 
496
 
497
  selected = option_menu(
498
  "Navigation",
 
522
  </div>
523
  """, unsafe_allow_html=True)
524
 
525
+ k = analytics.kpis()
526
 
527
  c1, c2, c3, c4 = st.columns(4)
528
  with c1:
529
+ st.markdown(f"<div class='metric-card'><h3 style='color: var(--primary-color); margin:0;'>Total Spend</h3><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)
530
  with c2:
531
+ st.markdown(f"<div class='metric-card'><h3 style='color: var(--primary-color); margin:0;'>Avg Order Value</h3><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)
532
  with c3:
533
  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)
534
  with c4:
 
557
  st.plotly_chart(fig3, use_container_width=True)
558
 
559
  with colD:
560
+ st.markdown("#### πŸ”Ž Quick Top Areas")
561
+ tcat = ", ".join([f"{n} – {s:.0f}%" for n, s in analytics.top_n_categories(3)])
562
+ tven = ", ".join([f"{n} – {s:.0f}%" for n, s in analytics.top_n_vendors(3)])
563
+ st.markdown(f"**Top Categories:** {tcat}")
564
+ st.markdown(f"**Top Vendors:** {tven}")
565
 
566
  elif selected == "πŸ’¬ AI Chat":
567
  st.markdown("### πŸ’¬ Chat with Your Procurement Data")
568
  st.markdown(f"""
569
+ <div class=\"ai-insight\">
570
  <h4>πŸ€– Universal AI Assistant</h4>
571
+ <p>Ask me anything about your procurement data. I will answer with crisp bullets and actual metrics.</p>
572
+ <p><small>Status: {api_status} | Model: {status['model']}</small></p>
573
  </div>
574
  """, unsafe_allow_html=True)
575
 
576
  if "messages" not in st.session_state:
577
  st.session_state.messages = [
578
+ {"role": "assistant", "content": "Hello! Try: 'What are my biggest spending areas?' or 'Which vendor is the weakest on delivery?'"}
579
  ]
580
 
581
  for m in st.session_state.messages:
 
592
  st.markdown(reply)
593
  st.session_state.messages.append({"role": "assistant", "content": reply})
594
 
595
+ st.markdown("#### πŸ’‘ Quick asks:")
596
  c1, c2, c3 = st.columns(3)
597
+ qs = [
598
+ "What are my biggest spending areas?",
599
+ "Which vendors perform the best and worst?",
600
+ "What risks should I monitor right now?",
601
+ ]
602
  for i, (c, q) in enumerate(zip([c1, c2, c3], qs)):
603
  with c:
604
  if st.button(f"πŸ’­ {q}", key=f"q_{i}"):
 
610
  st.markdown("### πŸ“ˆ Advanced Analytics Dashboard")
611
  vp = analytics.vendor_performance()
612
  st.dataframe(vp.rename(columns={
613
+ 'total_spend': 'Total Spend (β‚Ή)',
614
  'on_time': 'On-Time Delivery %',
615
  'quality': 'Quality Score',
616
  'orders': 'Order Count',
 
617
  }), use_container_width=True)
618
 
619
  st.download_button(
 
626
  elif selected == "πŸ§ͺ What‑If":
627
  st.markdown("### πŸ§ͺ What‑If: Vendor Consolidation Simulator")
628
  top_n = st.slider("Keep top N vendors by spend", min_value=2, max_value=10, value=6, step=1)
 
629
 
630
+ g = st.session_state.po_df.groupby('vendor')['order_value'].sum().sort_values(ascending=False)
631
+ kept_vendors = list(g.head(top_n).index)
632
+ kept_spend = st.session_state.po_df[st.session_state.po_df['vendor'].isin(kept_vendors)]['order_value'].sum()
633
+ total_spend = st.session_state.po_df['order_value'].sum()
634
+ share = (kept_spend/total_spend) if total_spend else 0
635
+ est_savings = max(0.03, min(0.18, 0.05 + (0.12 * (1 - share))))
636
+
637
+ kept_names = ", ".join(kept_vendors)
638
  st.markdown(
639
  f"""
640
  <div class='alert alert-info'>
641
+ <strong>Scenario:</strong> Keep top <b>{top_n}</b> vendors. Addressable share: <b>{share*100:.1f}%</b>.<br/>
642
+ <strong>Potential savings:</strong> <b>{est_savings*100:.1f}%</b> (heuristic).<br/>
643
  <small>Kept Vendors:</small> {kept_names}
644
  </div>
645
  """,
 
647
  )
648
 
649
  if st.checkbox("Show detailed vendor spend"):
650
+ st.dataframe(g.reset_index().rename(columns={'index':'vendor','order_value':'spend (β‚Ή)'}), use_container_width=True)
651
 
652
  elif selected == "🎯 Recommendations":
653
  st.markdown("### πŸš€ Strategic Recommendations")
 
676
  st.markdown(
677
  f"""
678
  <div style="text-align:center; padding: 1rem; color:#666;">
679
+ <p>πŸ€– <strong>Universal AI Procurement Analytics</strong> | Crisp, metric-first answers in β‚Ή</p>
680
+ <p><em>Demo with synthetic data β€’ {len(st.session_state.po_df):,} orders β€’ LLM {api_status}</em></p>
681
  </div>
682
  """,
683
  unsafe_allow_html=True,