PD03 commited on
Commit
601f404
·
verified ·
1 Parent(s): afbbf29

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +692 -36
app.py CHANGED
@@ -1,40 +1,696 @@
1
- import altair as alt
 
 
 
 
 
 
 
2
  import numpy as np
3
  import pandas as pd
4
  import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
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
8
+
9
  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
17
+
18
+ # =============================
19
+ # Page / Theme Configuration
20
+ # =============================
21
+ st.set_page_config(
22
+ page_title="SAP S/4HANA Agentic AI Procurement Analytics",
23
+ page_icon="🤖",
24
+ layout="wide",
25
+ initial_sidebar_state="expanded",
26
+ )
27
+
28
+ # --- CSS ---
29
+ st.markdown(
30
+ """
31
+ <style>
32
+ :root {
33
+ --primary-color: #0066cc;
34
+ --secondary-color: #f0f8ff;
35
+ --accent-color: #ff6b35;
36
+ --success-color: #28a745;
37
+ --warning-color: #ffc107;
38
+ --danger-color: #dc3545;
39
+ }
40
+
41
+ #MainMenu {visibility: hidden;}
42
+ footer {visibility: hidden;}
43
+ header {visibility: hidden;}
44
+
45
+ .main-header {
46
+ background: linear-gradient(90deg, #0066cc, #004c99);
47
+ padding: 1rem;
48
+ border-radius: 10px;
49
+ margin-bottom: 2rem;
50
+ color: white;
51
+ text-align: center;
52
+ }
53
+
54
+ .metric-card {
55
+ background: white;
56
+ padding: 1.25rem;
57
+ border-radius: 12px;
58
+ box-shadow: 0 2px 10px rgba(0,0,0,0.08);
59
+ border-left: 4px solid var(--primary-color);
60
+ margin-bottom: 1rem;
61
+ }
62
+
63
+ .ai-insight {
64
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
65
+ color: white;
66
+ padding: 1rem;
67
+ border-radius: 12px;
68
+ margin: 1rem 0;
69
+ }
70
+
71
+ .alert { padding: 1rem; border-radius: 10px; margin: 0.6rem 0; border-left: 4px solid; }
72
+ .alert-success { background-color: #d4edda; border-color: var(--success-color); color: #155724; }
73
+ .alert-warning { background-color: #fff3cd; border-color: var(--warning-color); color: #856404; }
74
+ .alert-info { background-color: #d1ecf1; border-color: #17a2b8; color: #0c5460; }
75
+
76
+ .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; }
77
+ .stButton > button:hover { transform: translateY(-1px); box-shadow: 0 6px 14px rgba(0,0,0,0.15); }
78
+ </style>
79
+ """,
80
+ unsafe_allow_html=True,
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")
93
+ or os.getenv("OPENAI_KEY")
94
+ )
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):
102
+ import requests
103
+ return requests.post(url, headers=headers, json=payload, timeout=timeout)
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)
116
+ self.last_error: Optional[str] = None
117
+ if self.available:
118
+ self._smoke_test()
119
+
120
+ def _headers(self) -> Dict[str, str]:
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)
141
+
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:
164
+ resp = _post_json(chat_url, headers, payload, self.cfg.timeout)
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))
188
+ except Exception:
189
+ msg = resp.text
190
+ raise RuntimeError(f"API error {resp.status_code}: {msg}")
191
+ except Exception as e:
192
+ if attempt == self.cfg.max_retries - 1:
193
+ self.last_error = str(e)
194
+ raise
195
+ time.sleep(delay)
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)
209
+
210
+ vendors = [
211
+ "Siemens AG", "BASF SE", "BMW Group", "Mercedes-Benz", "Bosch GmbH",
212
+ "ThyssenKrupp", "Bayer AG", "Continental AG", "Henkel AG", "SAP SE",
213
+ ]
214
+
215
+ categories = [
216
+ "Raw Materials", "Components", "Packaging", "Services",
217
+ "IT Equipment", "Office Supplies", "Machinery", "Chemicals",
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')
225
+ promised_days = random.randint(3, 30)
226
+ promised_date = order_date + timedelta(days=promised_days)
227
+ actual_lag = max(1, int(np.random.normal(promised_days, 5)))
228
+ delivery_date = order_date + timedelta(days=actual_lag)
229
+ late = delivery_date > promised_date
230
+
231
+ unit_price = round(random.uniform(10, 500), 2)
232
+ qty = random.randint(1, 1200)
233
+ order_value = round(unit_price * qty, 2)
234
+
235
+ po = {
236
+ 'po_number': f"PO{str(i+1).zfill(6)}",
237
+ 'vendor': random.choice(vendors),
238
+ 'material_category': random.choice(categories),
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,
247
+ 'unit_price': unit_price,
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
+ }
255
+ purchase_orders.append(po)
256
+
257
+ spend_rows = []
258
+ for v in vendors:
259
+ for c in categories:
260
+ spend_rows.append({
261
+ 'vendor': v,
262
+ 'category': c,
263
+ 'total_spend': round(random.uniform(10000, 700000), 2),
264
+ 'contract_compliance': round(random.uniform(78, 100), 1),
265
+ 'risk_score': round(random.uniform(1, 10), 1),
266
+ 'savings_potential': round(random.uniform(5, 25), 1),
267
+ })
268
+
269
+ po_df = pd.DataFrame(purchase_orders)
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
+ # =============================
281
+ class ProcurementAnalytics:
282
+ def __init__(self, po_df: pd.DataFrame):
283
+ self.df = po_df.copy()
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()),
293
+ 'active_vendors': int(df['vendor'].nunique()),
294
+ 'on_time_rate': float((~df['late_delivery']).mean()),
295
+ 'quality_avg': float(df['quality_score'].mean()),
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')
310
+
311
+ def vendor_performance(self) -> pd.DataFrame:
312
+ g = self.df.groupby('vendor').agg(
313
+ total_spend=('order_value', 'sum'),
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
+ # =============================
468
+ if 'data_loaded' not in st.session_state:
469
+ with st.spinner('🔄 Generating synthetic SAP S/4HANA procurement data...'):
470
+ st.session_state.po_df, st.session_state.spend_df = generate_synthetic_procurement_data()
471
+ st.session_state.data_loaded = True
472
+
473
+ @st.cache_resource(show_spinner=False)
474
+ def get_llm_client() -> UniversalLLMClient:
475
+ return UniversalLLMClient(LLMConfig())
476
+
477
+ 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
486
+ # =============================
487
+ st.markdown(
488
+ f"""
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,
496
+ )
497
+
498
+ # =============================
499
+ # Sidebar
500
+ # =============================
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",
522
+ ["🏠 Dashboard", "💬 AI Chat", "📊 Analytics", "🧪 What‑If", "🎯 Recommendations"],
523
+ icons=['house', 'chat', 'bar-chart', 'beaker', 'target'],
524
+ menu_icon="cast",
525
+ default_index=0,
526
+ styles={
527
+ "container": {"padding": "0!important", "background-color": "#fafafa"},
528
+ "icon": {"color": "#0066cc", "font-size": "18px"},
529
+ "nav-link": {"font-size": "16px", "text-align": "left", "margin": "0px", "--hover-color": "#eee"},
530
+ "nav-link-selected": {"background-color": "#0066cc"},
531
+ },
532
+ )
533
+
534
+ # =============================
535
+ # Main Views
536
+ # =============================
537
+ if selected == "🏠 Dashboard":
538
+ st.markdown("### 🧠 AI Executive Summary")
539
+ with st.spinner('🤖 Analyzing procurement data...'):
540
+ summary = agent.executive_summary()
541
+ st.markdown(f"""
542
+ <div class="ai-insight">
543
+ <h4>📊 Intelligent Analysis</h4>
544
+ <div style="white-space: pre-line; line-height: 1.55;">{summary}</div>
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:
558
+ 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)
559
+
560
+ st.markdown("### 📊 Executive Dashboard")
561
+ colA, colB = st.columns(2)
562
+
563
+ with colA:
564
+ cat = analytics.category_spend()
565
+ fig = px.pie(cat, values='order_value', names='material_category', title='Spend Distribution by Category')
566
+ fig.update_layout(title_font_size=16, title_x=0.5, height=420)
567
+ st.plotly_chart(fig, use_container_width=True)
568
+
569
+ with colB:
570
+ vend = analytics.vendor_spend(top_n=8)
571
+ fig2 = px.bar(vend, x='vendor', y='order_value', title='Top Vendors by Spend')
572
+ fig2.update_layout(title_font_size=16, title_x=0.5, xaxis_tickangle=45, height=420)
573
+ st.plotly_chart(fig2, use_container_width=True)
574
+
575
+ colC, colD = st.columns(2)
576
+ with colC:
577
+ ms = analytics.monthly_spend()
578
+ fig3 = px.line(ms, x='month', y='order_value', markers=True, title='Monthly Spend Trend')
579
+ fig3.update_layout(title_font_size=16, title_x=0.5, height=420)
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:
603
+ with st.chat_message(m["role"]):
604
+ st.markdown(m["content"])
605
+
606
+ if prompt := st.chat_input("Ask about your procurement data…"):
607
+ st.session_state.messages.append({"role": "user", "content": prompt})
608
+ with st.chat_message("user"):
609
+ st.markdown(prompt)
610
+ with st.chat_message("assistant"):
611
+ with st.spinner("🤖 Analyzing…"):
612
+ reply = agent.chat_with_data(prompt)
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}"):
622
+ st.session_state.messages.append({"role": "user", "content": q})
623
+ st.session_state.messages.append({"role": "assistant", "content": agent.chat_with_data(q)})
624
+ st.rerun()
625
+
626
+ 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(
638
+ label="⬇️ Download Vendor Performance (CSV)",
639
+ data=vp.to_csv().encode('utf-8'),
640
+ file_name="vendor_performance.csv",
641
+ mime="text/csv",
642
+ )
643
+
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
+ """,
658
+ unsafe_allow_html=True,
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")
666
+ recs = [
667
+ "🎯 **Vendor Consolidation**: Reduce long-tail suppliers; target 8–15% price improvement via volume tiers.",
668
+ "⚡ **Process Automation**: Auto-approve low-value POs to cut cycle time by 35–50%.",
669
+ "📊 **Performance Contracts**: KPI-linked clauses for on-time delivery; add service credits for misses.",
670
+ "🛡️ **Risk Monitoring**: Score suppliers on late rate, quality, and concentration; escalate chronic offenders.",
671
+ "🧠 **AI Copilot**: Use LLM to draft RFQs, summarize bids, and propose award scenarios.",
672
+ ]
673
+ for i, rec in enumerate(recs, start=1):
674
+ st.markdown(
675
+ f"""
676
+ <div class="alert alert-success">
677
+ <h4>Recommendation #{i}</h4>
678
+ <p>{rec}</p>
679
+ </div>
680
+ """,
681
+ unsafe_allow_html=True,
682
+ )
683
 
684
+ # =============================
685
+ # Footer
686
+ # =============================
687
+ 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,
696
+ )