PD03 commited on
Commit
a123290
Β·
verified Β·
1 Parent(s): 7803ef6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +183 -166
app.py CHANGED
@@ -79,18 +79,34 @@ st.markdown(
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:
@@ -105,12 +121,10 @@ class LLMConfig:
105
  max_retries: int = int(os.getenv("OPENAI_MAX_RETRIES", "5"))
106
  temperature: float = float(os.getenv("OPENAI_TEMPERATURE", "0.5"))
107
 
108
-
109
  def _post_json(url: str, headers: Dict[str, str], payload: Dict[str, Any], timeout: int):
110
  import requests
111
  return requests.post(url, headers=headers, json=payload, timeout=timeout)
112
 
113
-
114
  class UniversalLLMClient:
115
  def __init__(self, cfg: LLMConfig):
116
  self.cfg = cfg
@@ -203,22 +217,22 @@ def generate_synthetic_procurement_data(seed: int = 42) -> Tuple[pd.DataFrame, p
203
  order_value = round(unit_price * qty, 2)
204
 
205
  po = {
206
- 'po_number': f"PO{str(i+1).zfill(6)}",
207
- 'vendor': random.choice(vendors),
208
- 'material_category': random.choice(categories),
209
- 'order_date': order_date,
210
- 'promised_date': promised_date,
211
- 'delivery_date': delivery_date,
212
- 'late_delivery': late,
213
- 'order_value': order_value,
214
- 'quantity': qty,
215
- 'unit_price': unit_price,
216
- 'status': random.choice(['Open', 'Delivered', 'Invoiced', 'Paid']),
217
- 'plant': random.choice(['Plant_001', 'Plant_002', 'Plant_003']),
218
- 'buyer': fake.name(),
219
- 'currency': 'INR',
220
- 'payment_terms': random.choice(['30 Days', '45 Days', '60 Days', '90 Days']),
221
- 'quality_score': round(np.clip(np.random.normal(8.5, 0.8), 5.0, 10.0), 1),
222
  }
223
  purchase_orders.append(po)
224
 
@@ -226,12 +240,12 @@ def generate_synthetic_procurement_data(seed: int = 42) -> Tuple[pd.DataFrame, p
226
  for v in vendors:
227
  for c in categories:
228
  spend_rows.append({
229
- 'vendor': v,
230
- 'category': c,
231
- 'total_spend': round(random.uniform(10000, 700000), 2),
232
- 'contract_compliance': round(random.uniform(78, 100), 1),
233
- 'risk_score': round(random.uniform(1, 10), 1),
234
- 'savings_potential': round(random.uniform(5, 25), 1),
235
  })
236
 
237
  po_df = pd.DataFrame(purchase_orders)
@@ -244,49 +258,66 @@ def generate_synthetic_procurement_data(seed: int = 42) -> Tuple[pd.DataFrame, p
244
  class ProcurementAnalytics:
245
  def __init__(self, po_df: pd.DataFrame):
246
  self.df = po_df.copy()
247
- self.df['order_date'] = pd.to_datetime(self.df['order_date'])
248
- self.df['month'] = self.df['order_date'].dt.to_period('M').dt.to_timestamp()
249
 
250
  def kpis(self) -> Dict[str, Any]:
251
  df = self.df
252
  return {
253
- 'total_spend': float(df['order_value'].sum()),
254
- 'avg_order_value': float(df['order_value'].mean()),
255
- 'active_vendors': int(df['vendor'].nunique()),
256
- 'on_time_rate': float((~df['late_delivery']).mean()),
257
- 'quality_avg': float(df['quality_score'].mean()),
258
  }
259
 
260
  def category_spend(self) -> pd.DataFrame:
261
- return self.df.groupby('material_category', as_index=False)['order_value'].sum().sort_values('order_value', ascending=False)
 
 
 
 
262
 
263
  def vendor_spend(self, top_n: int = 8) -> pd.DataFrame:
264
- return self.df.groupby('vendor', as_index=False)['order_value'].sum().sort_values('order_value', ascending=False).head(top_n)
 
 
 
 
 
265
 
266
  def monthly_spend(self) -> pd.DataFrame:
267
- return self.df.groupby('month', as_index=False)['order_value'].sum().sort_values('month')
 
 
 
 
268
 
269
  def vendor_performance(self) -> pd.DataFrame:
270
- g = self.df.groupby('vendor').agg(
271
- total_spend=('order_value', 'sum'),
272
- on_time=('late_delivery', lambda s: 1 - s.mean()),
273
- quality=('quality_score', 'mean'),
274
- orders=('po_number', 'count'),
275
  )
276
- g['on_time'] = (g['on_time'] * 100).round(1)
277
- g['quality'] = g['quality'].round(2)
278
- g['total_spend'] = g['total_spend'].round(2)
279
- return g.sort_values('total_spend', ascending=False)
280
 
281
  def top_n_categories(self, n: int = 3) -> List[Tuple[str, float]]:
282
  cat = self.category_spend()
283
- total = float(cat['order_value'].sum()) or 1.0
284
- return [(r['material_category'], (r['order_value']/total)*100) for _, r in cat.head(n).iterrows()]
285
 
286
  def top_n_vendors(self, n: int = 3) -> List[Tuple[str, float]]:
287
- ven = self.df.groupby('vendor', as_index=False)['order_value'].sum().sort_values('order_value', ascending=False)
288
- total = float(ven['order_value'].sum()) or 1.0
289
- return [(r['vendor'], (r['order_value']/total)*100) for _, r in ven.head(n).iterrows()]
 
 
 
 
290
 
291
  # =============================
292
  # Agent with tighter prompts & INR formatting
@@ -299,51 +330,48 @@ class UniversalProcurementAgent:
299
  self.analytics = ProcurementAnalytics(po_df)
300
 
301
  def executive_summary(self) -> str:
302
- if not self.llm.available:
303
- return self._rule_summary()
304
-
305
- k = self.analytics.kpis()
306
- top_cats = self.analytics.top_n_categories(3)
307
- top_vens = self.analytics.top_n_vendors(3)
308
- data_summary = {
309
- "total_spend": k['total_spend'],
310
- "total_orders": int(len(self.po_data)),
311
- "vendor_count": int(self.po_data['vendor'].nunique()),
312
- "avg_order_value": k['avg_order_value'],
313
- "on_time_delivery": k['on_time_rate'],
314
- "avg_quality": k['quality_avg'],
315
- "top_categories": top_cats,
316
- "top_vendors": top_vens,
317
- }
318
-
319
- messages = [
320
- {
321
- "role": "system",
322
- "content": (
323
- "You are a senior procurement analyst. Use bullet points, be concise, "
324
- "and always use the β‚Ή symbol. When summarizing, include top categories "
325
- "and vendors with percentages, then 2-3 quantified actions."
326
- ),
327
- },
328
- {
329
- "role": "user",
330
- "content": (
331
- "Executive summary. Format amounts with commas (e.g., β‚Ή12,34,567).\n\n"
332
- f"Data: {json.dumps(data_summary)}"
333
- ),
334
- },
335
- ]
336
 
337
- try:
338
- return (
339
- "🧠 **[AI-Powered Analysis]**\n\n"
340
- + self.llm.chat(messages, max_tokens=550)
341
- )
342
- except Exception as e:
343
- return self._rule_summary() + f"\n\n*AI fallback due to: {e}*"
 
 
 
 
 
 
344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
 
346
- *AI fallback due to: {e}*"
 
 
 
 
 
 
347
 
348
  def _rule_summary(self) -> str:
349
  k = self.analytics.kpis()
@@ -352,31 +380,27 @@ class UniversalProcurementAgent:
352
  topc_str = ", ".join([f"{n} – {s:.0f}%" for n, s in top_c])
353
  topv_str = ", ".join([f"{n} – {s:.0f}%" for n, s in top_v])
354
  return (
355
- "πŸ€– **[Rule-Based Summary]**
356
- "
357
- + f"β€’ Total spend: {fmt_currency(k['total_spend'])} across {len(self.po_data):,} POs
358
- "
359
- + f"β€’ On-time delivery: {k['on_time_rate']*100:.1f}% | Avg quality: {k['quality_avg']:.1f}/10
360
- "
361
- + f"β€’ Top categories: {topc_str}
362
- "
363
- + f"β€’ Top vendors: {topv_str}
364
- "
365
  + "Actions: Consolidate long tail; multi-year terms with top vendors; auto-approve low-value POs."
366
  )
367
 
368
  def chat_with_data(self, question: str) -> str:
369
  if not self.llm.available:
370
  return self._rule_answer(question)
 
371
  k = self.analytics.kpis()
372
  top_c = self.analytics.top_n_categories(3)
373
  top_v = self.analytics.top_n_vendors(3)
374
  context = {
375
- "total_spend": k['total_spend'],
376
  "orders": int(len(self.po_data)),
377
- "vendors": int(self.po_data['vendor'].nunique()),
378
- "on_time": k['on_time_rate'],
379
- "quality": k['quality_avg'],
380
  "top_categories": top_c,
381
  "top_vendors": top_v,
382
  }
@@ -387,29 +411,22 @@ class UniversalProcurementAgent:
387
  )
388
  messages = [
389
  {"role": "system", "content": "You are a precise procurement co-pilot. Be direct, metric-first, and action-oriented."},
390
- {"role": "user", "content": f"Q: {question}
391
-
392
- Context: {json.dumps(context)}
393
-
394
- {style_rules}"},
395
  ]
396
  try:
397
  return (
398
- "🧠 **[AI Response]**
399
-
400
- "
401
  + self.llm.chat(messages, max_tokens=450)
402
  )
403
  except Exception as e:
404
- return self._rule_answer(question) + f"
405
-
406
- *AI fallback due to: {e}*"
407
 
408
  def _rule_answer(self, question: str) -> str:
409
  q = question.lower()
410
  k = self.analytics.kpis()
411
  top_c = self.analytics.top_n_categories(3)
412
  top_v = self.analytics.top_n_vendors(3)
 
413
  if ("spend" in q) or ("spending" in q) or ("cost" in q):
414
  lines = [
415
  f"β€’ Total spend: {fmt_currency(k['total_spend'])}",
@@ -417,50 +434,45 @@ Context: {json.dumps(context)}
417
  "β€’ Top vendors: " + ", ".join([f"{n} – {s:.0f}%" for n, s in top_v]),
418
  "β€’ Action: Run sourcing events for top 2 categories; target 8–12% savings via volume tiers.",
419
  ]
420
- return "πŸ€– **[Rule-Based Spend]**
421
- " + "
422
- ".join(lines)
423
  if ("vendor" in q) or ("supplier" in q) or ("partner" in q):
424
- vp = self.po_data.groupby('vendor').agg(
425
- spend=('order_value','sum'),
426
- late_rate=('late_delivery','mean'),
427
- quality=('quality_score','mean'),
428
- ).sort_values('spend', ascending=False)
429
  best = vp.head(1)
430
- worst = vp.sort_values('late_rate', ascending=False).head(1)
431
  bname, wname = best.index[0], worst.index[0]
432
- blate = float(best.iloc[0]['late_rate'])*100
433
- wlate = float(worst.iloc[0]['late_rate'])*100
434
  lines = [
435
  f"β€’ Best by spend: {bname} (late {blate:.1f}%)",
436
  f"β€’ Worst by late deliveries: {wname} (late {wlate:.1f}%)",
437
  "β€’ Action: Extend terms with best performer; corrective plan and SLA penalties for the worst.",
438
  ]
439
- return "πŸ€– **[Rule-Based Vendor]**
440
- " + "
441
- ".join(lines)
442
  if ("risk" in q) or ("late" in q) or ("delay" in q):
443
- late = float(self.po_data['late_delivery'].mean())*100
444
  lines = [
445
  f"β€’ Late delivery rate: {late:.1f}%",
446
  "β€’ Action: Add 5–10 day buffers; fast-track chronic offenders; add service credits for misses.",
447
  ]
448
- return "πŸ€– **[Rule-Based Risk]**
449
- " + "
450
- ".join(lines)
451
  return (
452
- "πŸ€– **[Rule-Based]**
453
- "
454
- + "β€’ I can analyze spend (top categories/vendors), vendor performance (best/worst), risk (late %), and trends.
455
- "
456
  + 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}%"
457
  )
458
 
459
  # =============================
460
  # App State & Initialization
461
  # =============================
462
- if 'data_loaded' not in st.session_state:
463
- with st.spinner('πŸ”„ Generating synthetic SAP S/4HANA procurement data...'):
464
  st.session_state.po_df, st.session_state.spend_df = generate_synthetic_procurement_data()
465
  st.session_state.data_loaded = True
466
 
@@ -477,7 +489,7 @@ status = {
477
  "last_error": client.last_error or "OK",
478
  "model": client.cfg.model,
479
  }
480
- api_status = "🟒 Connected" if status['available'] else "πŸ”΄ Not Connected"
481
 
482
  # =============================
483
  # Header
@@ -506,8 +518,8 @@ with st.sidebar:
506
 
507
  selected = option_menu(
508
  "Navigation",
509
- ["🏠 Dashboard", "πŸ’¬ AI Chat", "πŸ“Š Analytics", "πŸ§ͺ What‑If", "🎯 Recommendations"],
510
- icons=['house', 'chat', 'bar-chart', 'beaker', 'target'],
511
  menu_icon="cast",
512
  default_index=0,
513
  styles={
@@ -523,7 +535,7 @@ with st.sidebar:
523
  # =============================
524
  if selected == "🏠 Dashboard":
525
  st.markdown("### 🧠 AI Executive Summary")
526
- with st.spinner('πŸ€– Analyzing procurement data...'):
527
  summary = agent.executive_summary()
528
  st.markdown(
529
  (
@@ -575,7 +587,7 @@ if selected == "🏠 Dashboard":
575
  st.markdown(
576
  (
577
  "<div class='metric-card'>"
578
- "<h3 style='color: var(--primary-color); margin:0;'>On‑Time Delivery</h3>"
579
  + f"<h2 style='margin: .5rem 0;'>{k['on_time_rate']*100:.1f}%</h2>"
580
  "<p style='color:#28a745;margin:0;'>⏱ Performance</p>"
581
  "</div>"
@@ -588,20 +600,20 @@ if selected == "🏠 Dashboard":
588
 
589
  with colA:
590
  cat = analytics.category_spend()
591
- fig = px.pie(cat, values='order_value', names='material_category', title='Spend Distribution by Category')
592
  fig.update_layout(title_font_size=16, title_x=0.5, height=420)
593
  st.plotly_chart(fig, use_container_width=True)
594
 
595
  with colB:
596
  vend = analytics.vendor_spend(top_n=8)
597
- fig2 = px.bar(vend, x='vendor', y='order_value', title='Top Vendors by Spend')
598
  fig2.update_layout(title_font_size=16, title_x=0.5, xaxis_tickangle=45, height=420)
599
  st.plotly_chart(fig2, use_container_width=True)
600
 
601
  colC, colD = st.columns(2)
602
  with colC:
603
  ms = analytics.monthly_spend()
604
- fig3 = px.line(ms, x='month', y='order_value', markers=True, title='Monthly Spend Trend')
605
  fig3.update_layout(title_font_size=16, title_x=0.5, height=420)
606
  st.plotly_chart(fig3, use_container_width=True)
607
 
@@ -627,7 +639,7 @@ elif selected == "πŸ’¬ AI Chat":
627
 
628
  if "messages" not in st.session_state:
629
  st.session_state.messages = [
630
- {"role": "assistant", "content": "Hello! Try: 'What are my biggest spending areas?' or 'Which vendor is the weakest on delivery?'"}
631
  ]
632
 
633
  for m in st.session_state.messages:
@@ -661,29 +673,34 @@ elif selected == "πŸ’¬ AI Chat":
661
  elif selected == "πŸ“Š Analytics":
662
  st.markdown("### πŸ“ˆ Advanced Analytics Dashboard")
663
  vp = analytics.vendor_performance()
664
- st.dataframe(vp.rename(columns={
665
- 'total_spend': 'Total Spend (β‚Ή)',
666
- 'on_time': 'On-Time Delivery %',
667
- 'quality': 'Quality Score',
668
- 'orders': 'Order Count',
669
- }), use_container_width=True)
 
 
 
 
 
670
 
671
  st.download_button(
672
  label="⬇️ Download Vendor Performance (CSV)",
673
- data=vp.to_csv().encode('utf-8'),
674
  file_name="vendor_performance.csv",
675
  mime="text/csv",
676
  )
677
 
678
- elif selected == "πŸ§ͺ What‑If":
679
- st.markdown("### πŸ§ͺ What‑If: Vendor Consolidation Simulator")
680
  top_n = st.slider("Keep top N vendors by spend", min_value=2, max_value=10, value=6, step=1)
681
 
682
- g = st.session_state.po_df.groupby('vendor')['order_value'].sum().sort_values(ascending=False)
683
  kept_vendors = list(g.head(top_n).index)
684
- kept_spend = st.session_state.po_df[st.session_state.po_df['vendor'].isin(kept_vendors)]['order_value'].sum()
685
- total_spend = st.session_state.po_df['order_value'].sum()
686
- share = (kept_spend/total_spend) if total_spend else 0
687
  est_savings = max(0.03, min(0.18, 0.05 + (0.12 * (1 - share))))
688
 
689
  kept_names = ", ".join(kept_vendors)
@@ -699,7 +716,7 @@ elif selected == "πŸ§ͺ What‑If":
699
  )
700
 
701
  if st.checkbox("Show detailed vendor spend"):
702
- st.dataframe(g.reset_index().rename(columns={'index':'vendor','order_value':'spend (β‚Ή)'}), use_container_width=True)
703
 
704
  elif selected == "🎯 Recommendations":
705
  st.markdown("### πŸš€ Strategic Recommendations")
 
79
  )
80
 
81
  # =============================
82
+ # Currency helpers (β‚Ή with Indian grouping)
83
  # =============================
84
  CURRENCY = "β‚Ή"
85
 
86
+ def format_inr(x: float) -> str:
87
+ """Format number with Indian digit grouping, no decimals."""
88
  try:
89
+ n = int(round(float(x)))
90
  except Exception:
91
  return f"{CURRENCY}{x}"
92
+ s = str(n)
93
+ if len(s) <= 3:
94
+ return f"{CURRENCY}{s}"
95
+ last3 = s[-3:]
96
+ rest = s[:-3]
97
+ parts = []
98
+ while len(rest) > 2:
99
+ parts.insert(0, rest[-2:])
100
+ rest = rest[:-2]
101
+ if rest:
102
+ parts.insert(0, rest)
103
+ return f"{CURRENCY}{','.join(parts + [last3])}"
104
+
105
+ def fmt_currency(x: float) -> str:
106
+ return format_inr(x)
107
 
108
  # =============================
109
+ # LLM client (resilient)
110
  # =============================
111
  @dataclass
112
  class LLMConfig:
 
121
  max_retries: int = int(os.getenv("OPENAI_MAX_RETRIES", "5"))
122
  temperature: float = float(os.getenv("OPENAI_TEMPERATURE", "0.5"))
123
 
 
124
  def _post_json(url: str, headers: Dict[str, str], payload: Dict[str, Any], timeout: int):
125
  import requests
126
  return requests.post(url, headers=headers, json=payload, timeout=timeout)
127
 
 
128
  class UniversalLLMClient:
129
  def __init__(self, cfg: LLMConfig):
130
  self.cfg = cfg
 
217
  order_value = round(unit_price * qty, 2)
218
 
219
  po = {
220
+ "po_number": f"PO{str(i+1).zfill(6)}",
221
+ "vendor": random.choice(vendors),
222
+ "material_category": random.choice(categories),
223
+ "order_date": order_date,
224
+ "promised_date": promised_date,
225
+ "delivery_date": delivery_date,
226
+ "late_delivery": late,
227
+ "order_value": order_value,
228
+ "quantity": qty,
229
+ "unit_price": unit_price,
230
+ "status": random.choice(["Open", "Delivered", "Invoiced", "Paid"]),
231
+ "plant": random.choice(["Plant_001", "Plant_002", "Plant_003"]),
232
+ "buyer": fake.name(),
233
+ "currency": "INR",
234
+ "payment_terms": random.choice(["30 Days", "45 Days", "60 Days", "90 Days"]),
235
+ "quality_score": round(np.clip(np.random.normal(8.5, 0.8), 5.0, 10.0), 1),
236
  }
237
  purchase_orders.append(po)
238
 
 
240
  for v in vendors:
241
  for c in categories:
242
  spend_rows.append({
243
+ "vendor": v,
244
+ "category": c,
245
+ "total_spend": round(random.uniform(10000, 700000), 2),
246
+ "contract_compliance": round(random.uniform(78, 100), 1),
247
+ "risk_score": round(random.uniform(1, 10), 1),
248
+ "savings_potential": round(random.uniform(5, 25), 1),
249
  })
250
 
251
  po_df = pd.DataFrame(purchase_orders)
 
258
  class ProcurementAnalytics:
259
  def __init__(self, po_df: pd.DataFrame):
260
  self.df = po_df.copy()
261
+ self.df["order_date"] = pd.to_datetime(self.df["order_date"])
262
+ self.df["month"] = self.df["order_date"].dt.to_period("M").dt.to_timestamp()
263
 
264
  def kpis(self) -> Dict[str, Any]:
265
  df = self.df
266
  return {
267
+ "total_spend": float(df["order_value"].sum()),
268
+ "avg_order_value": float(df["order_value"].mean()),
269
+ "active_vendors": int(df["vendor"].nunique()),
270
+ "on_time_rate": float((~df["late_delivery"]).mean()),
271
+ "quality_avg": float(df["quality_score"].mean()),
272
  }
273
 
274
  def category_spend(self) -> pd.DataFrame:
275
+ return (
276
+ self.df.groupby("material_category", as_index=False)["order_value"]
277
+ .sum()
278
+ .sort_values("order_value", ascending=False)
279
+ )
280
 
281
  def vendor_spend(self, top_n: int = 8) -> pd.DataFrame:
282
+ return (
283
+ self.df.groupby("vendor", as_index=False)["order_value"]
284
+ .sum()
285
+ .sort_values("order_value", ascending=False)
286
+ .head(top_n)
287
+ )
288
 
289
  def monthly_spend(self) -> pd.DataFrame:
290
+ return (
291
+ self.df.groupby("month", as_index=False)["order_value"]
292
+ .sum()
293
+ .sort_values("month")
294
+ )
295
 
296
  def vendor_performance(self) -> pd.DataFrame:
297
+ g = self.df.groupby("vendor").agg(
298
+ total_spend=("order_value", "sum"),
299
+ on_time=("late_delivery", lambda s: 1 - s.mean()),
300
+ quality=("quality_score", "mean"),
301
+ orders=("po_number", "count"),
302
  )
303
+ g["on_time"] = (g["on_time"] * 100).round(1)
304
+ g["quality"] = g["quality"].round(2)
305
+ g["total_spend"] = g["total_spend"].round(2)
306
+ return g.sort_values("total_spend", ascending=False)
307
 
308
  def top_n_categories(self, n: int = 3) -> List[Tuple[str, float]]:
309
  cat = self.category_spend()
310
+ total = float(cat["order_value"].sum()) or 1.0
311
+ return [(r["material_category"], (r["order_value"] / total) * 100) for _, r in cat.head(n).iterrows()]
312
 
313
  def top_n_vendors(self, n: int = 3) -> List[Tuple[str, float]]:
314
+ ven = (
315
+ self.df.groupby("vendor", as_index=False)["order_value"]
316
+ .sum()
317
+ .sort_values("order_value", ascending=False)
318
+ )
319
+ total = float(ven["order_value"].sum()) or 1.0
320
+ return [(r["vendor"], (r["order_value"] / total) * 100) for _, r in ven.head(n).iterrows()]
321
 
322
  # =============================
323
  # Agent with tighter prompts & INR formatting
 
330
  self.analytics = ProcurementAnalytics(po_df)
331
 
332
  def executive_summary(self) -> str:
333
+ if not self.llm.available:
334
+ return self._rule_summary()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
 
336
+ k = self.analytics.kpis()
337
+ top_cats = self.analytics.top_n_categories(3)
338
+ top_vens = self.analytics.top_n_vendors(3)
339
+ data_summary = {
340
+ "total_spend": k["total_spend"],
341
+ "total_orders": int(len(self.po_data)),
342
+ "vendor_count": int(self.po_data["vendor"].nunique()),
343
+ "avg_order_value": k["avg_order_value"],
344
+ "on_time_delivery": k["on_time_rate"],
345
+ "avg_quality": k["quality_avg"],
346
+ "top_categories": top_cats,
347
+ "top_vendors": top_vens,
348
+ }
349
 
350
+ messages = [
351
+ {
352
+ "role": "system",
353
+ "content": (
354
+ "You are a senior procurement analyst. Use bullet points, be concise, "
355
+ "and always use the β‚Ή symbol. When summarizing, include top categories "
356
+ "and vendors with percentages, then 2-3 quantified actions."
357
+ ),
358
+ },
359
+ {
360
+ "role": "user",
361
+ "content": (
362
+ "Executive summary. Format amounts with Indian commas (e.g., β‚Ή12,34,567).\n\n"
363
+ f"Data: {json.dumps(data_summary)}"
364
+ ),
365
+ },
366
+ ]
367
 
368
+ try:
369
+ return (
370
+ "🧠 **[AI-Powered Analysis]**\n\n"
371
+ + self.llm.chat(messages, max_tokens=550)
372
+ )
373
+ except Exception as e:
374
+ return self._rule_summary() + f"\n\n*AI fallback due to: {e}*"
375
 
376
  def _rule_summary(self) -> str:
377
  k = self.analytics.kpis()
 
380
  topc_str = ", ".join([f"{n} – {s:.0f}%" for n, s in top_c])
381
  topv_str = ", ".join([f"{n} – {s:.0f}%" for n, s in top_v])
382
  return (
383
+ "πŸ€– **[Rule-Based Summary]**\n"
384
+ + f"β€’ Total spend: {fmt_currency(k['total_spend'])} across {len(self.po_data):,} POs\n"
385
+ + f"β€’ On-time delivery: {k['on_time_rate']*100:.1f}% | Avg quality: {k['quality_avg']:.1f}/10\n"
386
+ + f"β€’ Top categories: {topc_str}\n"
387
+ + f"β€’ Top vendors: {topv_str}\n"
 
 
 
 
 
388
  + "Actions: Consolidate long tail; multi-year terms with top vendors; auto-approve low-value POs."
389
  )
390
 
391
  def chat_with_data(self, question: str) -> str:
392
  if not self.llm.available:
393
  return self._rule_answer(question)
394
+
395
  k = self.analytics.kpis()
396
  top_c = self.analytics.top_n_categories(3)
397
  top_v = self.analytics.top_n_vendors(3)
398
  context = {
399
+ "total_spend": k["total_spend"],
400
  "orders": int(len(self.po_data)),
401
+ "vendors": int(self.po_data["vendor"].nunique()),
402
+ "on_time": k["on_time_rate"],
403
+ "quality": k["quality_avg"],
404
  "top_categories": top_c,
405
  "top_vendors": top_v,
406
  }
 
411
  )
412
  messages = [
413
  {"role": "system", "content": "You are a precise procurement co-pilot. Be direct, metric-first, and action-oriented."},
414
+ {"role": "user", "content": f"Q: {question}\n\nContext: {json.dumps(context)}\n\n{style_rules}"},
 
 
 
 
415
  ]
416
  try:
417
  return (
418
+ "🧠 **[AI Response]**\n\n"
 
 
419
  + self.llm.chat(messages, max_tokens=450)
420
  )
421
  except Exception as e:
422
+ return self._rule_answer(question) + f"\n\n*AI fallback due to: {e}*"
 
 
423
 
424
  def _rule_answer(self, question: str) -> str:
425
  q = question.lower()
426
  k = self.analytics.kpis()
427
  top_c = self.analytics.top_n_categories(3)
428
  top_v = self.analytics.top_n_vendors(3)
429
+
430
  if ("spend" in q) or ("spending" in q) or ("cost" in q):
431
  lines = [
432
  f"β€’ Total spend: {fmt_currency(k['total_spend'])}",
 
434
  "β€’ Top vendors: " + ", ".join([f"{n} – {s:.0f}%" for n, s in top_v]),
435
  "β€’ Action: Run sourcing events for top 2 categories; target 8–12% savings via volume tiers.",
436
  ]
437
+ return "πŸ€– **[Rule-Based Spend]**\n" + "\n".join(lines)
438
+
 
439
  if ("vendor" in q) or ("supplier" in q) or ("partner" in q):
440
+ vp = self.po_data.groupby("vendor").agg(
441
+ spend=("order_value", "sum"),
442
+ late_rate=("late_delivery", "mean"),
443
+ quality=("quality_score", "mean"),
444
+ ).sort_values("spend", ascending=False)
445
  best = vp.head(1)
446
+ worst = vp.sort_values("late_rate", ascending=False).head(1)
447
  bname, wname = best.index[0], worst.index[0]
448
+ blate = float(best.iloc[0]["late_rate"]) * 100
449
+ wlate = float(worst.iloc[0]["late_rate"]) * 100
450
  lines = [
451
  f"β€’ Best by spend: {bname} (late {blate:.1f}%)",
452
  f"β€’ Worst by late deliveries: {wname} (late {wlate:.1f}%)",
453
  "β€’ Action: Extend terms with best performer; corrective plan and SLA penalties for the worst.",
454
  ]
455
+ return "πŸ€– **[Rule-Based Vendor]**\n" + "\n".join(lines)
456
+
 
457
  if ("risk" in q) or ("late" in q) or ("delay" in q):
458
+ late = float(self.po_data["late_delivery"].mean()) * 100
459
  lines = [
460
  f"β€’ Late delivery rate: {late:.1f}%",
461
  "β€’ Action: Add 5–10 day buffers; fast-track chronic offenders; add service credits for misses.",
462
  ]
463
+ return "πŸ€– **[Rule-Based Risk]**\n" + "\n".join(lines)
464
+
 
465
  return (
466
+ "πŸ€– **[Rule-Based]**\n"
467
+ + "β€’ I can analyze spend (top categories/vendors), vendor performance (best/worst), risk (late %), and trends.\n"
 
 
468
  + 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}%"
469
  )
470
 
471
  # =============================
472
  # App State & Initialization
473
  # =============================
474
+ if "data_loaded" not in st.session_state:
475
+ with st.spinner("πŸ”„ Generating synthetic SAP S/4HANA procurement data..."):
476
  st.session_state.po_df, st.session_state.spend_df = generate_synthetic_procurement_data()
477
  st.session_state.data_loaded = True
478
 
 
489
  "last_error": client.last_error or "OK",
490
  "model": client.cfg.model,
491
  }
492
+ api_status = "🟒 Connected" if status["available"] else "πŸ”΄ Not Connected"
493
 
494
  # =============================
495
  # Header
 
518
 
519
  selected = option_menu(
520
  "Navigation",
521
+ ["🏠 Dashboard", "πŸ’¬ AI Chat", "πŸ“Š Analytics", "πŸ§ͺ What-If", "🎯 Recommendations"],
522
+ icons=["house", "chat", "bar-chart", "beaker", "target"],
523
  menu_icon="cast",
524
  default_index=0,
525
  styles={
 
535
  # =============================
536
  if selected == "🏠 Dashboard":
537
  st.markdown("### 🧠 AI Executive Summary")
538
+ with st.spinner("πŸ€– Analyzing procurement data..."):
539
  summary = agent.executive_summary()
540
  st.markdown(
541
  (
 
587
  st.markdown(
588
  (
589
  "<div class='metric-card'>"
590
+ "<h3 style='color: var(--primary-color); margin:0;'>On-Time Delivery</h3>"
591
  + f"<h2 style='margin: .5rem 0;'>{k['on_time_rate']*100:.1f}%</h2>"
592
  "<p style='color:#28a745;margin:0;'>⏱ Performance</p>"
593
  "</div>"
 
600
 
601
  with colA:
602
  cat = analytics.category_spend()
603
+ fig = px.pie(cat, values="order_value", names="material_category", title="Spend Distribution by Category")
604
  fig.update_layout(title_font_size=16, title_x=0.5, height=420)
605
  st.plotly_chart(fig, use_container_width=True)
606
 
607
  with colB:
608
  vend = analytics.vendor_spend(top_n=8)
609
+ fig2 = px.bar(vend, x="vendor", y="order_value", title="Top Vendors by Spend")
610
  fig2.update_layout(title_font_size=16, title_x=0.5, xaxis_tickangle=45, height=420)
611
  st.plotly_chart(fig2, use_container_width=True)
612
 
613
  colC, colD = st.columns(2)
614
  with colC:
615
  ms = analytics.monthly_spend()
616
+ fig3 = px.line(ms, x="month", y="order_value", markers=True, title="Monthly Spend Trend")
617
  fig3.update_layout(title_font_size=16, title_x=0.5, height=420)
618
  st.plotly_chart(fig3, use_container_width=True)
619
 
 
639
 
640
  if "messages" not in st.session_state:
641
  st.session_state.messages = [
642
+ {"role": "assistant", "content": "Hello! Try: 'What are my biggest spending areas?' or 'Which vendor is the weakest on delivery?'"},
643
  ]
644
 
645
  for m in st.session_state.messages:
 
673
  elif selected == "πŸ“Š Analytics":
674
  st.markdown("### πŸ“ˆ Advanced Analytics Dashboard")
675
  vp = analytics.vendor_performance()
676
+ st.dataframe(
677
+ vp.rename(
678
+ columns={
679
+ "total_spend": "Total Spend (β‚Ή)",
680
+ "on_time": "On-Time Delivery %",
681
+ "quality": "Quality Score",
682
+ "orders": "Order Count",
683
+ }
684
+ ),
685
+ use_container_width=True,
686
+ )
687
 
688
  st.download_button(
689
  label="⬇️ Download Vendor Performance (CSV)",
690
+ data=vp.to_csv().encode("utf-8"),
691
  file_name="vendor_performance.csv",
692
  mime="text/csv",
693
  )
694
 
695
+ elif selected == "πŸ§ͺ What-If":
696
+ st.markdown("### πŸ§ͺ What-If: Vendor Consolidation Simulator")
697
  top_n = st.slider("Keep top N vendors by spend", min_value=2, max_value=10, value=6, step=1)
698
 
699
+ g = st.session_state.po_df.groupby("vendor")["order_value"].sum().sort_values(ascending=False)
700
  kept_vendors = list(g.head(top_n).index)
701
+ kept_spend = st.session_state.po_df[st.session_state.po_df["vendor"].isin(kept_vendors)]["order_value"].sum()
702
+ total_spend = st.session_state.po_df["order_value"].sum()
703
+ share = (kept_spend / total_spend) if total_spend else 0
704
  est_savings = max(0.03, min(0.18, 0.05 + (0.12 * (1 - share))))
705
 
706
  kept_names = ", ".join(kept_vendors)
 
716
  )
717
 
718
  if st.checkbox("Show detailed vendor spend"):
719
+ st.dataframe(g.reset_index().rename(columns={"index": "vendor", "order_value": "spend (β‚Ή)"}), use_container_width=True)
720
 
721
  elif selected == "🎯 Recommendations":
722
  st.markdown("### πŸš€ Strategic Recommendations")