PD03 commited on
Commit
fad5f5f
·
verified ·
1 Parent(s): 3ffc475

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +451 -36
src/streamlit_app.py CHANGED
@@ -1,40 +1,455 @@
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 re
3
+ import math
4
+ from datetime import datetime, date
5
+ from dateutil.relativedelta import relativedelta
6
+
7
  import numpy as np
8
  import pandas as pd
9
+ import plotly.express as px
10
  import streamlit as st
11
 
12
+ # =========================
13
+ # Theming and Page Config
14
+ # =========================
15
+ st.set_page_config(
16
+ page_title="Procurement Insight Agent",
17
+ page_icon="🧭",
18
+ layout="wide",
19
+ initial_sidebar_state="expanded"
20
+ )
21
+
22
+ # Custom CSS for a premium look
23
+ st.markdown("""
24
+ <style>
25
+ /* Global font and spacing */
26
+ html, body, [class*="css"] {
27
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
28
+ }
29
+ section[data-testid="stSidebar"] {
30
+ background: linear-gradient(180deg, #0f172a 0%, #111827 100%);
31
+ border-right: 1px solid #1f2937;
32
+ }
33
+ section[data-testid="stSidebar"] * {
34
+ color: #e5e7eb !important;
35
+ }
36
+ .block-container {
37
+ padding-top: 1.5rem;
38
+ }
39
+ .card {
40
+ background: #0b1220;
41
+ border: 1px solid #1f2937;
42
+ border-radius: 14px;
43
+ padding: 16px;
44
+ }
45
+ .kpi {
46
+ background: linear-gradient(180deg, #0b1220 0%, #0f172a 100%);
47
+ border: 1px solid #1f2937;
48
+ border-radius: 14px;
49
+ padding: 18px;
50
+ }
51
+ .kpi h3 {
52
+ margin: 0;
53
+ color: #93c5fd;
54
+ font-weight: 600;
55
+ font-size: 0.95rem;
56
+ }
57
+ .kpi .value {
58
+ margin-top: 6px;
59
+ font-size: 1.6rem;
60
+ font-weight: 700;
61
+ color: #e5e7eb;
62
+ }
63
+ .caption-note {
64
+ color: #94a3b8;
65
+ font-size: 0.85rem;
66
+ }
67
+ .prompt-box {
68
+ background: #0b1220;
69
+ border: 1px solid #1f2937;
70
+ border-radius: 12px;
71
+ padding: 10px 12px;
72
+ }
73
+ .footer-note {
74
+ color: #94a3b8;
75
+ font-size: 0.8rem;
76
+ text-align: center;
77
+ margin-top: 24px;
78
+ }
79
+ .stTabs [data-baseweb="tab-list"] {
80
+ gap: 12px;
81
+ }
82
+ .stTabs [data-baseweb="tab"] {
83
+ background-color: #0b1220;
84
+ border-radius: 10px 10px 0 0;
85
+ padding: 10px 16px;
86
+ color: #e5e7eb;
87
+ border: 1px solid #1f2937;
88
+ }
89
+ .stTabs [aria-selected="true"] {
90
+ background: linear-gradient(180deg, #0b1220 0%, #0f172a 100%) !important;
91
+ color: #93c5fd !important;
92
+ }
93
+ </style>
94
+ """, unsafe_allow_html=True)
95
+
96
+ # =========================
97
+ # Synthetic Data Generator
98
+ # =========================
99
+ @st.cache_data(show_spinner=False)
100
+ def generate_synthetic_procurement(seed=42, start_year=2023, end_year=2025, rows=40_000):
101
+ """
102
+ Generates synthetic procurement line items reflecting common S/4HANA Embedded Analytics procurement fields.
103
+ """
104
+ rng = np.random.default_rng(seed)
105
+ months = pd.date_range(f"{start_year}-01-01", f"{end_year}-12-31", freq="MS")
106
+
107
+ purchasing_orgs = ["1000", "2000", "3000", "4000"]
108
+ company_codes = ["C100", "C200", "C300"]
109
+ suppliers = [f"SUPP-{i:03d}" for i in range(1, 61)]
110
+ material_groups = ["RAW", "PACK", "SERV", "CAPEX", "MRO"]
111
+ currencies = ["USD", "EUR", "GBP", "INR"]
112
+
113
+ # Seasonality and org effects
114
+ def base_amount(month):
115
+ # Seasonality: higher in Q2/Q4
116
+ q = (month.month-1)//3 + 1
117
+ base = 1.0
118
+ if q in (2, 4):
119
+ base *= 1.2
120
+ if month.month in (11, 12):
121
+ base *= 1.1
122
+ return base
123
+
124
+ data = {
125
+ "CalendarMonth": rng.choice(months, size=rows),
126
+ "PurchasingOrganization": rng.choice(purchasing_orgs, size=rows, p=[0.35, 0.25, 0.25, 0.15]),
127
+ "CompanyCode": rng.choice(company_codes, size=rows, p=[0.45, 0.35, 0.20]),
128
+ "Supplier": rng.choice(suppliers, size=rows),
129
+ "MaterialGroup": rng.choice(material_groups, size=rows, p=[0.3, 0.2, 0.25, 0.15, 0.10]),
130
+ "Currency": rng.choice(currencies, size=rows, p=[0.5, 0.25, 0.1, 0.15]),
131
+ }
132
+ df = pd.DataFrame(data)
133
+ df["CalendarYear"] = df["CalendarMonth"].dt.year
134
+ df["Month"] = df["CalendarMonth"].dt.to_period("M").astype(str)
135
+
136
+ # Amount generation with org/supplier effects
137
+ org_factor = df["PurchasingOrganization"].map({"1000":1.2, "2000":0.9, "3000":1.0, "4000":0.8})
138
+ supp_strength = df["Supplier"].str[-3:].astype(int)
139
+ supp_factor = 0.8 + (supp_strength / 1000.0) # small lift for higher IDs
140
+ seasonal = df["CalendarMonth"].apply(base_amount).astype(float)
141
+ base_val = rng.lognormal(mean=7.5, sigma=0.6, size=rows) # realistic skew
142
+ df["NetAmount"] = (base_val * org_factor * supp_factor * seasonal).round(2)
143
+
144
+ # Random off-contract flag
145
+ df["OffContract"] = rng.choice([True, False], size=rows, p=[0.18, 0.82])
146
+
147
+ # For “service POs”
148
+ df["IsService"] = df["MaterialGroup"].eq("SERV")
149
+
150
+ return df
151
+
152
+ # Load synthetic data
153
+ df_raw = generate_synthetic_procurement()
154
+
155
+ # ==================================
156
+ # Helper: Natural Language to Query
157
+ # ==================================
158
+ def parse_prompt(prompt: str):
159
+ """
160
+ Very lightweight rules to detect:
161
+ - metric (po_value, off_contract, service_spend)
162
+ - time grain (month, quarter, year)
163
+ - time window (YTD, QTD, last quarter, range)
164
+ - grouping (Supplier, PurchasingOrganization, CompanyCode, MaterialGroup)
165
+ - top_n
166
+ """
167
+ text = (prompt or "").lower()
168
+
169
+ # Metric
170
+ if "off-contract" in text or "off contract" in text or "leakage" in text:
171
+ metric = "off_contract"
172
+ elif "service" in text:
173
+ metric = "service_spend"
174
+ else:
175
+ metric = "po_value"
176
+
177
+ # Grain
178
+ if "by month" in text or "monthly" in text:
179
+ grain = "month"
180
+ elif "by quarter" in text or "quarterly" in text or "qtr" in text:
181
+ grain = "quarter"
182
+ else:
183
+ grain = "month" if "trend" in text else "year"
184
+
185
+ # Grouping
186
+ group_map = {
187
+ "supplier": "Supplier",
188
+ "purchasing org": "PurchasingOrganization",
189
+ "purchasing organization": "PurchasingOrganization",
190
+ "company": "CompanyCode",
191
+ "companycode": "CompanyCode",
192
+ "material group": "MaterialGroup",
193
+ "material": "MaterialGroup",
194
+ }
195
+ group_by = None
196
+ for k, v in group_map.items():
197
+ if k in text:
198
+ group_by = v
199
+ break
200
+
201
+ # Top-N
202
+ top_n = None
203
+ m = re.search(r"top\s+(\d+)", text)
204
+ if m:
205
+ top_n = int(m.group(1))
206
+
207
+ # Time window
208
+ today = date.today()
209
+ year = today.year
210
+ if "last year" in text or "previous year" in text:
211
+ start = date(year-1, 1, 1)
212
+ end = date(year-1, 12, 31)
213
+ elif "this year" in text or "ytd" in text:
214
+ start = date(year, 1, 1)
215
+ end = today
216
+ elif "last quarter" in text or "previous quarter" in text:
217
+ this_q = (today.month - 1)//3 + 1
218
+ last_q_end = date(year, (this_q-1)*3, 1) - relativedelta(days=1) if this_q > 1 else date(year-1, 12, 31)
219
+ last_q = (last_q_end.month - 1)//3 + 1
220
+ last_q_start = date(last_q_end.year, (last_q-1)*3 + 1, 1)
221
+ start, end = last_q_start, last_q_end
222
+ elif "q" in text and re.search(r"q[1-4]\s*\d{4}", text):
223
+ qm = re.search(r"q([1-4])\s*(\d{4})", text)
224
+ q, y = int(qm.group(1)), int(qm.group(2))
225
+ start = date(y, (q-1)*3 + 1, 1)
226
+ end = (start + relativedelta(months=3)) - relativedelta(days=1)
227
+ elif re.search(r"\b20\d{2}\b", text):
228
+ y = int(re.search(r"\b(20\d{2})\b", text).group(1))
229
+ start, end = date(y, 1, 1), date(y, 12, 31)
230
+ else:
231
+ # Default to this year to keep it demo-friendly
232
+ start, end = date(year, 1, 1), today
233
+
234
+ return {
235
+ "metric": metric,
236
+ "grain": grain,
237
+ "group_by": group_by,
238
+ "top_n": top_n,
239
+ "start": start,
240
+ "end": end
241
+ }
242
+
243
+ # =========================
244
+ # Query/Compute Functions
245
+ # =========================
246
+ def filter_timeframe(df, start: date, end: date):
247
+ return df[(df["CalendarMonth"].dt.date >= start) & (df["CalendarMonth"].dt.date <= end)]
248
+
249
+ def compute_metric(df, metric: str):
250
+ if metric == "off_contract":
251
+ return df[df["OffContract"]]
252
+ if metric == "service_spend":
253
+ return df[df["IsService"]]
254
+ return df
255
+
256
+ def group_and_aggregate(df, grain: str, group_by: str | None):
257
+ work = df.copy()
258
+ # Derive time buckets
259
+ work["Year"] = work["CalendarMonth"].dt.year
260
+ work["Quarter"] = work["CalendarMonth"].dt.to_period("Q").astype(str)
261
+ work["Month"] = work["CalendarMonth"].dt.to_period("M").astype(str)
262
+
263
+ time_col = {"year":"Year", "quarter":"Quarter", "month":"Month"}[grain]
264
+ group_cols = [time_col] + ([group_by] if group_by else [])
265
+
266
+ agg = work.groupby(group_cols, dropna=False)["NetAmount"].sum().reset_index()
267
+ agg = agg.rename(columns={"NetAmount":"TotalAmount"})
268
+ agg = agg.sort_values("TotalAmount", ascending=False)
269
+ return agg, time_col
270
+
271
+ def topn_if_needed(df, top_n: int | None, group_by: str | None, time_col: str = None):
272
+ if top_n and group_by:
273
+ # For time series with group, take top entities over total, then filter
274
+ total_by_entity = df.groupby(group_by)["TotalAmount"].sum().sort_values(ascending=False)
275
+ keep = list(total_by_entity.head(top_n).index)
276
+ return df[df[group_by].isin(keep)]
277
+ return df
278
+
279
+ def kpi_summary(df_filtered, df_metric):
280
+ total_spend = df_metric["NetAmount"].sum()
281
+ total_pos = len(df_metric)
282
+ suppliers = df_metric["Supplier"].nunique()
283
+ off_ratio = df_metric["OffContract"].mean()
284
+
285
+ return {
286
+ "total_spend": total_spend,
287
+ "lines": total_pos,
288
+ "suppliers": suppliers,
289
+ "off_ratio": off_ratio
290
+ }
291
+
292
+ def insight_sentence(parsed, kpis, group_by):
293
+ metric_name = {
294
+ "po_value": "PO value",
295
+ "off_contract": "Off-contract spend",
296
+ "service_spend": "Service PO spend"
297
+ }[parsed["metric"]]
298
+ date_str = f'{parsed["start"].isoformat()} to {parsed["end"].isoformat()}'
299
+ base = f"{metric_name} from {date_str}"
300
+ details = f"{kpis['suppliers']} suppliers across {kpis['lines']} line items; off-contract ratio {kpis['off_ratio']:.1%}."
301
+ if group_by:
302
+ return f"{base}. Grouped by {group_by}. {details}"
303
+ return f"{base}. {details}"
304
+
305
+ # =========================
306
+ # UI: Sidebar Controls
307
+ # =========================
308
+ with st.sidebar:
309
+ st.image("https://huggingface.co/front/assets/huggingface_logo-noborder.svg", width=140)
310
+ st.markdown("## 🧭 Procurement Insight Agent")
311
+ st.markdown("Elegant Streamlit demo with synthetic procurement data.")
312
+ st.markdown("---")
313
+
314
+ seed = st.slider("Random seed", 1, 9999, 42)
315
+ rows = st.select_slider("Dataset size", options=[10_000, 20_000, 40_000, 80_000, 120_000], value=40_000)
316
+ st.caption("Higher rows = richer charts, slower compute.")
317
+ st.markdown("---")
318
+
319
+ default_prompt = "Top 5 suppliers by PO value this year by month"
320
+ user_prompt = st.text_area("Ask a question", value=default_prompt, height=96, label_visibility="visible", placeholder="e.g., Off-contract spend by purchasing org last quarter")
321
+
322
+ st.markdown("---")
323
+ st.caption("Tip: Try prompts like:")
324
+ st.code("PO value by month in 2025\nTop 5 suppliers this year by month\nOff-contract spend by purchasing org last quarter\nService spend by company in Q2 2024", language="text")
325
+
326
+ # Regenerate data if settings changed
327
+ if (seed != 42) or (rows != 40_000):
328
+ df_raw = generate_synthetic_procurement(seed=seed, rows=rows)
329
+
330
+ # =========================
331
+ # Header
332
+ # =========================
333
+ st.markdown("# 🧠 Procurement Insight Agent")
334
+ st.markdown(
335
+ "Turn natural-language questions into procurement insights using a polished Streamlit UI and synthetic S/4HANA-style analytics data."
336
+ )
337
+
338
+ # =========================
339
+ # Agent Parse + Compute
340
+ # =========================
341
+ parsed = parse_prompt(user_prompt)
342
+ df_time = filter_timeframe(df_raw, parsed["start"], parsed["end"])
343
+ df_metric = compute_metric(df_time, parsed["metric"])
344
+
345
+ kpis = kpi_summary(df_time, df_metric)
346
+ summary_text = insight_sentence(parsed, kpis, parsed["group_by"])
347
+
348
+ # =========================
349
+ # KPI Row
350
+ # =========================
351
+ c1, c2, c3, c4 = st.columns(4)
352
+ with c1:
353
+ st.markdown('<div class="kpi"><h3>Total Spend</h3><div class="value">${:,.0f}</div></div>'.format(kpis["total_spend"]), unsafe_allow_html=True)
354
+ with c2:
355
+ st.markdown('<div class="kpi"><h3>Line Items</h3><div class="value">{:,}</div></div>'.format(kpis["lines"]), unsafe_allow_html=True)
356
+ with c3:
357
+ st.markdown('<div class="kpi"><h3>Suppliers</h3><div class="value">{:,}</div></div>'.format(kpis["suppliers"]), unsafe_allow_html=True)
358
+ with c4:
359
+ st.markdown('<div class="kpi"><h3>Off-Contract Ratio</h3><div class="value">{:.1%}</div></div>'.format(kpis["off_ratio"]), unsafe_allow_html=True)
360
+
361
+ st.markdown(f"#### {summary_text}")
362
+
363
+ # =========================
364
+ # Main Tabs
365
+ # =========================
366
+ tab_trend, tab_breakdown, tab_table, tab_agent = st.tabs(["Trend & Composition", "Breakdowns & Drilldowns", "Data Table", "Agent Plan"])
367
+
368
+ # --- Trend & Composition ---
369
+ with tab_trend:
370
+ agg, time_col = group_and_aggregate(df_metric, parsed["grain"], parsed["group_by"])
371
+ agg_top = topn_if_needed(agg, parsed["top_n"], parsed["group_by"], time_col)
372
+
373
+ if parsed["group_by"]:
374
+ fig = px.line(
375
+ agg_top.sort_values(time_col),
376
+ x=time_col, y="TotalAmount", color=parsed["group_by"],
377
+ markers=True, line_shape="spline",
378
+ title="Trend"
379
+ )
380
+ else:
381
+ fig = px.bar(
382
+ agg_top.sort_values(time_col),
383
+ x=time_col, y="TotalAmount", title="Trend"
384
+ )
385
+
386
+ fig.update_layout(
387
+ margin=dict(l=10, r=10, t=50, b=10),
388
+ height=420,
389
+ template="plotly_dark",
390
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1)
391
+ )
392
+ st.plotly_chart(fig, use_container_width=True)
393
+
394
+ # Composition pie for current period
395
+ comp_group = parsed["group_by"] or "Supplier"
396
+ comp = df_metric.groupby(comp_group)["NetAmount"].sum().reset_index().sort_values("NetAmount", ascending=False).head(10)
397
+ fig2 = px.pie(comp, values="NetAmount", names=comp_group, title=f"Composition by {comp_group} (Top 10)")
398
+ fig2.update_layout(template="plotly_dark", height=420)
399
+ st.plotly_chart(fig2, use_container_width=True)
400
+
401
+ # --- Breakdowns & Drilldowns ---
402
+ with tab_breakdown:
403
+ st.markdown("##### Drilldown Controls")
404
+ col_a, col_b, col_c = st.columns(3)
405
+ with col_a:
406
+ dim1 = st.selectbox("Primary dimension", ["Supplier", "PurchasingOrganization", "CompanyCode", "MaterialGroup"], index=0)
407
+ with col_b:
408
+ dim2 = st.selectbox("Secondary dimension (optional)", ["None", "PurchasingOrganization", "CompanyCode", "MaterialGroup", "Supplier"], index=1)
409
+ dim2 = None if dim2 == "None" else dim2
410
+ with col_c:
411
+ n = st.slider("Top N", 5, 25, 10, step=5)
412
+
413
+ # Calculate totals
414
+ if dim2:
415
+ gb = df_metric.groupby([dim1, dim2])["NetAmount"].sum().reset_index().rename(columns={"NetAmount":"TotalAmount"})
416
+ top_entities = gb.groupby(dim1)["TotalAmount"].sum().sort_values(ascending=False).head(n).index
417
+ gb = gb[gb[dim1].isin(top_entities)]
418
+ fig3 = px.bar(gb, x="TotalAmount", y=dim1, color=dim2, orientation="h", title=f"Top {n} by {dim1} with {dim2}")
419
+ else:
420
+ gb = df_metric.groupby(dim1)["NetAmount"].sum().reset_index().rename(columns={"NetAmount":"TotalAmount"})
421
+ gb = gb.sort_values("TotalAmount", ascending=False).head(n)
422
+ fig3 = px.bar(gb, x="TotalAmount", y=dim1, orientation="h", title=f"Top {n} by {dim1}")
423
+
424
+ fig3.update_layout(template="plotly_dark", height=520, margin=dict(l=10, r=10, t=50, b=10))
425
+ st.plotly_chart(fig3, use_container_width=True)
426
+
427
+ st.markdown("##### Cohort Trend")
428
+ cohort_value = st.selectbox(f"Pick a {dim1} cohort for trend", options=sorted(df_metric[dim1].unique().tolist())[:250])
429
+ cohort = df_metric[df_metric[dim1] == cohort_value]
430
+ cohort_trend = cohort.groupby(cohort["CalendarMonth"].dt.to_period("M").astype(str))["NetAmount"].sum().reset_index()
431
+ cohort_trend.columns = ["Month", "TotalAmount"]
432
+ fig4 = px.area(cohort_trend, x="Month", y="TotalAmount", title=f"Trend for {dim1}: {cohort_value}")
433
+ fig4.update_layout(template="plotly_dark", height=380)
434
+ st.plotly_chart(fig4, use_container_width=True)
435
+
436
+ # --- Data Table ---
437
+ with tab_table:
438
+ st.markdown("##### Result Table")
439
+ # Prepare final display table aligned with parsed settings
440
+ final = agg_top.copy()
441
+ # Beautify column order
442
+ cols = [c for c in ["Year", "Quarter", "Month", parsed.get("group_by"), "TotalAmount"] if c in final.columns]
443
+ final = final[cols]
444
+ st.dataframe(final, use_container_width=True, hide_index=True)
445
+ st.download_button("Download CSV", data=final.to_csv(index=False).encode("utf-8"), file_name="procurement_results.csv", mime="text/csv")
446
+
447
+ # --- Agent Plan ---
448
+ with tab_agent:
449
+ st.markdown("##### Agent Interpretation")
450
+ st.json({
451
+ "parsed_prompt": parsed,
452
+ "notes": "Replace synthetic data functions with an OData client to C_* CDS queries in S/4HANA. The rest of the pipeline remains unchanged."
453
+ })
454
+
455
+ st.markdown('<div class="footer-note">UI inspired by enterprise analytics dashboards. Built with Streamlit, Plotly, and synthetic data that mimics S/4HANA Embedded Analytics structures.</div>', unsafe_allow_html=True)