PD03 commited on
Commit
3ea351b
·
verified ·
1 Parent(s): 50bf773

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +402 -38
src/streamlit_app.py CHANGED
@@ -1,40 +1,404 @@
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 streamlit as st
2
+ import pandas as pd
3
+ import numpy as np
4
+ import plotly.express as px
5
+ from datetime import datetime, timedelta
6
+ from dateutil.parser import parse
7
+
8
+ # ---------------------------
9
+ # App Config and Theming
10
+ # ---------------------------
11
+ st.set_page_config(
12
+ page_title="Procurement Agent – S/4HANA Embedded Analytics (Demo)",
13
+ page_icon="🧭",
14
+ layout="wide",
15
+ initial_sidebar_state="expanded",
16
+ )
17
+
18
+ # Subtle CSS polish for a premium feel
19
+ st.markdown(
20
+ """
21
+ <style>
22
+ .kpi-card {
23
+ padding: 14px 16px; border-radius: 12px; background: #0a0a0a0d;
24
+ border: 1px solid #e6e6e6; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
25
+ }
26
+ .metric-label { font-size: 12px; color: #666; margin-bottom: 6px; }
27
+ .metric-value { font-size: 26px; font-weight: 700; }
28
+ .metric-sub { font-size: 12px; color: #999; }
29
+ .stChatFloatingInputContainer { border-top: 1px solid #eee; }
30
+ .st-emotion-cache-1avcm0n { padding-top: 0 !important; }
31
+ .rounded-img { border-radius: 50%; }
32
+ </style>
33
+ """,
34
+ unsafe_allow_html=True,
35
+ )
36
+
37
+ # ---------------------------
38
+ # Data Loading (Synthetic “CDS-like”)
39
+ # ---------------------------
40
+ @st.cache_data
41
+ def load_data():
42
+ df = pd.read_csv("data/synthetic_procurement.csv", parse_dates=["PO_Date","DeliveryDate","GR_Date","IR_Date"])
43
+ # Derived fields similar to embedded analytics
44
+ df["DaysToDeliver"] = (df["DeliveryDate"] - df["PO_Date"]).dt.days
45
+ df["IsOpen"] = df["Status"].eq("Open")
46
+ return df
47
+
48
+ df = load_data()
49
+
50
+ # ---------------------------
51
+ # Sidebar Filters
52
+ # ---------------------------
53
+ with st.sidebar:
54
+ st.image("https://huggingface.co/front/assets/huggingface_logo-noborder.svg", width=120)
55
+ st.title("Procurement Agent")
56
+ st.caption("S/4HANA Embedded Analytics – Learning Demo (Synthetic data)")
57
+
58
+ # Time filter
59
+ max_date = df["PO_Date"].max()
60
+ default_start = max_date - timedelta(days=45)
61
+ date_range = st.date_input("PO Date Range", (default_start, max_date))
62
+
63
+ # Codelists
64
+ company = st.multiselect("Company Code", sorted(df["CompanyCode"].unique().tolist()))
65
+ plants = st.multiselect("Plant", sorted(df["Plant"].unique().tolist()))
66
+ mat_groups = st.multiselect("Material Group", sorted(df["MaterialGroup"].unique().tolist()))
67
+ suppliers = st.multiselect("Supplier", sorted(df["Supplier"].unique().tolist()))
68
+ buyers = st.multiselect("Buyer", sorted(df["Buyer"].unique().tolist()))
69
+ status_sel = st.multiselect("Status", sorted(df["Status"].unique().tolist()))
70
+
71
+ st.markdown("---")
72
+ st.subheader("Demo actions")
73
+ if st.button("Reset Filters"):
74
+ st.session_state.clear()
75
+ st.rerun()
76
+
77
+ # Apply filters
78
+ def apply_filters(df):
79
+ dff = df.copy()
80
+ if isinstance(date_range, tuple) and len(date_range) == 2:
81
+ start_date, end_date = pd.to_datetime(date_range[0]), pd.to_datetime(date_range[1])
82
+ dff = dff[(dff["PO_Date"] >= start_date) & (dff["PO_Date"] <= end_date)]
83
+ if company:
84
+ dff = dff[dff["CompanyCode"].isin(company)]
85
+ if plants:
86
+ dff = dff[dff["Plant"].isin(plants)]
87
+ if mat_groups:
88
+ dff = dff[dff["MaterialGroup"].isin(mat_groups)]
89
+ if suppliers:
90
+ dff = dff[dff["Supplier"].isin(suppliers)]
91
+ if buyers:
92
+ dff = dff[dff["Buyer"].isin(buyers)]
93
+ if status_sel:
94
+ dff = dff[dff["Status"].isin(status_sel)]
95
+ return dff
96
+
97
+ fdf = apply_filters(df)
98
+
99
+ # ---------------------------
100
+ # KPI Header
101
+ # ---------------------------
102
+ def kpi_card(label, value, sub=""):
103
+ st.markdown(
104
+ f"""
105
+ <div class="kpi-card">
106
+ <div class="metric-label">{label}</div>
107
+ <div class="metric-value">{value}</div>
108
+ <div class="metric-sub">{sub}</div>
109
+ </div>
110
+ """,
111
+ unsafe_allow_html=True,
112
+ )
113
+
114
+ col1, col2, col3, col4 = st.columns(4)
115
+ with col1:
116
+ total_po = fdf["PO_ID"].nunique()
117
+ kpi_card("Purchase Orders", f"{total_po:,}", "Unique POs in selection")
118
+ with col2:
119
+ spend = fdf["NetValue"].sum()
120
+ kpi_card("Net Spend", f"${spend:,.0f}", "Sum of PO item values")
121
+ with col3:
122
+ avg_lt = fdf["LeadTimeDays"].mean() if len(fdf) else 0
123
+ kpi_card("Avg Lead Time", f"{avg_lt:.1f}d", "Supplier cycle time")
124
+ with col4:
125
+ otif = fdf["OTIF"].mean() * 100 if len(fdf) else 0
126
+ kpi_card("OTIF", f"{otif:.0f}%", "On-time in-full rate")
127
+
128
+ st.markdown("")
129
+
130
+ # ---------------------------
131
+ # Tabs: Overview | Supplier Insights | Explorer | Simulations
132
+ # ---------------------------
133
+ tab1, tab2, tab3, tab4 = st.tabs(["Overview", "Supplier Insights", "Explorer", "Simulations"])
134
+
135
+ with tab1:
136
+ c1, c2 = st.columns([1.3, 1])
137
+ with c1:
138
+ st.subheader("Spend by Supplier")
139
+ if len(fdf):
140
+ fig = px.bar(
141
+ fdf.groupby("Supplier", as_index=False)["NetValue"].sum().sort_values("NetValue", ascending=False),
142
+ x="Supplier", y="NetValue", color="Supplier", height=380, template="plotly_white",
143
+ hover_data={"NetValue":":,.0f"}
144
+ )
145
+ st.plotly_chart(fig, use_container_width=True)
146
+ else:
147
+ st.info("No data for selected filters.")
148
+
149
+ st.subheader("Material Group Mix")
150
+ if len(fdf):
151
+ fig2 = px.pie(
152
+ fdf, names="MaterialGroup", values="NetValue", hole=0.45, template="plotly_white",
153
+ height=380
154
+ )
155
+ st.plotly_chart(fig2, use_container_width=True)
156
+
157
+ with c2:
158
+ st.subheader("Lead Time by Supplier")
159
+ if len(fdf):
160
+ g = fdf.groupby("Supplier", as_index=False)["LeadTimeDays"].mean().sort_values("LeadTimeDays")
161
+ fig3 = px.bar(g, x="LeadTimeDays", y="Supplier", orientation="h", height=380, template="plotly_white")
162
+ st.plotly_chart(fig3, use_container_width=True)
163
+
164
+ st.subheader("OTIF by Supplier")
165
+ if len(fdf):
166
+ g2 = fdf.groupby("Supplier", as_index=False)["OTIF"].mean()
167
+ g2["OTIF%"] = (g2["OTIF"] * 100).round(1)
168
+ fig4 = px.scatter(g2, x="Supplier", y="OTIF%", size="OTIF%", color="Supplier", height=340, template="plotly_white")
169
+ st.plotly_chart(fig4, use_container_width=True)
170
+
171
+ with tab2:
172
+ st.subheader("Supplier Scorecard")
173
+ sup = st.selectbox("Choose supplier", sorted(df["Supplier"].unique().tolist()))
174
+ sdf = fdf[fdf["Supplier"] == sup]
175
+ if len(sdf):
176
+ c1, c2, c3 = st.columns(3)
177
+ with c1:
178
+ kpi_card("Spend", f"${sdf['NetValue'].sum():,.0f}")
179
+ with c2:
180
+ kpi_card("Avg Price", f"${sdf['NetPrice'].mean():.2f}/unit")
181
+ with c3:
182
+ kpi_card("OTIF", f"{(sdf['OTIF'].mean()*100):.0f}%")
183
+
184
+ st.markdown("")
185
+
186
+ c4, c5 = st.columns(2)
187
+ with c4:
188
+ st.caption("Lead time trend (by PO date)")
189
+ trend = sdf.sort_values("PO_Date").groupby("PO_Date", as_index=False)["LeadTimeDays"].mean()
190
+ fig5 = px.line(trend, x="PO_Date", y="LeadTimeDays", markers=True, template="plotly_white", height=340)
191
+ st.plotly_chart(fig5, use_container_width=True)
192
+ with c5:
193
+ st.caption("Price distribution")
194
+ fig6 = px.histogram(sdf, x="NetPrice", nbins=10, template="plotly_white", height=340)
195
+ st.plotly_chart(fig6, use_container_width=True)
196
+
197
+ st.subheader("Recent PO Lines")
198
+ st.dataframe(
199
+ sdf.sort_values("PO_Date", ascending=False)[
200
+ ["PO_ID","PO_Item","PO_Date","Material","Quantity","OrderUnit","NetPrice","NetValue","DeliveryDate","Status","LeadTimeDays","OTIF"]
201
+ ].head(10),
202
+ use_container_width=True, height=300
203
+ )
204
+ else:
205
+ st.info("No lines for selected supplier within current filters.")
206
+
207
+ with tab3:
208
+ st.subheader("Interactive Explorer")
209
+ dims = ["CompanyCode","Plant","MaterialGroup","Supplier","Buyer","Status"]
210
+ sel_dim = st.selectbox("Dimension", dims, index=3)
211
+ sel_mea = st.selectbox("Measure", ["NetValue","Quantity","NetPrice","LeadTimeDays","OTIF"], index=0)
212
+
213
+ if len(fdf):
214
+ g = fdf.groupby(sel_dim, as_index=False)[sel_mea].mean() if sel_mea in ["NetPrice","LeadTimeDays","OTIF"] else \
215
+ fdf.groupby(sel_dim, as_index=False)[sel_mea].sum()
216
+ fig7 = px.bar(g.sort_values(sel_mea, ascending=False).head(15), x=sel_dim, y=sel_mea, color=sel_dim, template="plotly_white", height=420)
217
+ st.plotly_chart(fig7, use_container_width=True)
218
+ st.dataframe(g.sort_values(sel_mea, ascending=False), use_container_width=True, height=260)
219
+ else:
220
+ st.info("Adjust filters to see data.")
221
+
222
+ with tab4:
223
+ st.subheader("What-if: Payment Terms and Delivery Delays")
224
+
225
+ # Simple simulation: change payment terms and hypothetical delay impact on OTIF
226
+ term_delta = st.slider("Payment term change (days)", -30, 30, 0, step=5)
227
+ delay_rate = st.slider("Simulate delivery delay rate (%)", 0, 50, 10, step=5)
228
+
229
+ def run_sim(df_in, term_delta, delay_rate):
230
+ sim = df_in.copy()
231
+ # Adjust payment days
232
+ sim["PaymentDaysSim"] = sim["PaymentDays"] + term_delta
233
+ # Apply simple OTIF penalty based on delay rate
234
+ penalty = delay_rate / 100.0
235
+ sim["OTIF_Sim"] = np.clip(sim["OTIF"] * (1 - penalty) + (1 - sim["OTIF"]) * (1 - penalty/2), 0, 1)
236
+ # Assume carrying cost impact: +0.02% per extra payment day on spend
237
+ delta_days = np.maximum(sim["PaymentDaysSim"] - sim["PaymentDays"], 0)
238
+ sim["CarryingCostAdj"] = sim["NetValue"] * (0.0002 * delta_days)
239
+ return sim
240
+
241
+ if len(fdf):
242
+ simdf = run_sim(fdf, term_delta, delay_rate)
243
+ c1, c2, c3 = st.columns(3)
244
+ with c1:
245
+ kpi_card("OTIF (Simulated)", f"{(simdf['OTIF_Sim'].mean()*100):.0f}%")
246
+ with c2:
247
+ kpi_card("PaymentDays Δ", f"{term_delta:+d}d")
248
+ with c3:
249
+ kpi_card("Carrying Cost Adj", f"${simdf['CarryingCostAdj'].sum():,.0f}")
250
+
251
+ st.caption("Supplier-level OTIF change")
252
+ g = simdf.groupby("Supplier", as_index=False)[["OTIF","OTIF_Sim"]].mean()
253
+ g["OTIF"] = (g["OTIF"]*100).round(1)
254
+ g["OTIF_Sim"] = (g["OTIF_Sim"]*100).round(1)
255
+ fig8 = px.bar(g.melt(id_vars="Supplier", value_vars=["OTIF","OTIF_Sim"], var_name="Metric", value_name="OTIF%"), x="Supplier", y="OTIF%", color="Metric", barmode="group", template="plotly_white", height=400)
256
+ st.plotly_chart(fig8, use_container_width=True)
257
+ st.dataframe(g.sort_values("OTIF_Sim", ascending=False), use_container_width=True, height=260)
258
+ else:
259
+ st.info("No data to simulate. Adjust filters.")
260
+
261
+ # ---------------------------
262
+ # Agentic Chat (Demo)
263
+ # ---------------------------
264
+ st.markdown("---")
265
+ st.subheader("Agent Assistant")
266
+ st.caption("Ask procurement questions, e.g., “Top suppliers by OTIF this month,” “Compare ACME vs GLOBAL_MFG on price and lead time,” “Show spend by RM group last 30 days.”")
267
+
268
+ if "messages" not in st.session_state:
269
+ st.session_state.messages = [
270
+ {"role": "assistant", "content": "Hello! I can analyze procurement data, compute KPIs, and run what‑if simulations. What would you like to see?"}
271
+ ]
272
+
273
+ # Simple tool functions (CDS-like queries)
274
+ def tool_top_suppliers_by(metric="OTIF", topn=5):
275
+ if not len(fdf): return "No data in current selection."
276
+ g = fdf.groupby("Supplier", as_index=False)[metric].mean()
277
+ if metric != "OTIF":
278
+ # For value metrics that make sense as sum (e.g., NetValue)
279
+ if metric in ["NetValue","Quantity"]:
280
+ g = fdf.groupby("Supplier", as_index=False)[metric].sum()
281
+ g = g.sort_values(metric, ascending=False).head(topn)
282
+ return g
283
+
284
+ def tool_compare_suppliers(sup_a, sup_b):
285
+ sub = fdf[fdf["Supplier"].isin([sup_a, sup_b])]
286
+ if not len(sub): return "No data for those suppliers in current selection."
287
+ stats = sub.groupby("Supplier").agg(
288
+ Spend=("NetValue","sum"),
289
+ AvgPrice=("NetPrice","mean"),
290
+ AvgLead=("LeadTimeDays","mean"),
291
+ OTIF=("OTIF","mean")
292
+ ).reset_index()
293
+ stats["OTIF%"] = (stats["OTIF"]*100).round(1)
294
+ return stats
295
+
296
+ def tool_spend_by_dim(dim="MaterialGroup"):
297
+ if not len(fdf): return None
298
+ g = fdf.groupby(dim, as_index=False)["NetValue"].sum().sort_values("NetValue", ascending=False)
299
+ return g
300
+
301
+ def tool_show_recent_po_lines(n=10):
302
+ if not len(fdf): return None
303
+ cols = ["PO_ID","PO_Item","PO_Date","Supplier","Material","Quantity","OrderUnit","NetPrice","NetValue","DeliveryDate","Status","LeadTimeDays","OTIF"]
304
+ return fdf.sort_values("PO_Date", ascending=False)[cols].head(n)
305
+
306
+ # Heuristic “planner” to route user intents to tools
307
+ def agent_router(prompt: str):
308
+ p = prompt.lower().strip()
309
+ # pattern routes
310
+ if "top" in p and "supplier" in p and "otif" in p:
311
+ n = 5
312
+ for tok in p.split():
313
+ if tok.isdigit():
314
+ n = int(tok)
315
+ break
316
+ return ("top_suppliers_otif", {"topn": n})
317
+ if "compare" in p and "supplier" in p:
318
+ # naive extract A vs B
319
+ tokens = p.replace("compare","").replace("supplier","").replace("suppliers","").replace(" vs "," ").split()
320
+ # heuristic: choose two known supplier names intersected
321
+ known = set(df["Supplier"].unique().tolist())
322
+ picks = [t for t in tokens if t.upper() in known]
323
+ if len(picks) >= 2:
324
+ return ("compare_suppliers", {"a": picks[0].upper(), "b": picks[1].upper()})
325
+ return ("compare_suppliers", {"a": "ACME_SUPPLY", "b": "GLOBAL_MFG"})
326
+ if "spend" in p and ("material group" in p or "group" in p):
327
+ return ("spend_by_dim", {"dim": "MaterialGroup"})
328
+ if "recent" in p or ("last" in p and "po" in p):
329
+ return ("recent_pos", {"n": 10})
330
+ if "lead time" in p and "supplier" in p:
331
+ return ("lead_by_supplier", {})
332
+ if "price" in p and "supplier" in p:
333
+ return ("price_by_supplier", {})
334
+ # default: summary
335
+ return ("summary", {})
336
+
337
+ def agent_execute(route, args):
338
+ if route == "top_suppliers_otif":
339
+ g = tool_top_suppliers_by("OTIF", topn=args.get("topn",5))
340
+ if isinstance(g, str):
341
+ return g, None
342
+ g2 = g.copy()
343
+ g2["OTIF%"] = (g2["OTIF"]*100).round(1)
344
+ fig = px.bar(g2, x="Supplier", y="OTIF%", color="Supplier", template="plotly_white", height=360)
345
+ return "Top suppliers by OTIF:", fig
346
+
347
+ if route == "compare_suppliers":
348
+ stats = tool_compare_suppliers(args.get("a"), args.get("b"))
349
+ if isinstance(stats, str):
350
+ return stats, None
351
+ fig = px.bar(stats, x="Supplier", y=["Spend","AvgPrice","AvgLead","OTIF"], barmode="group", template="plotly_white", height=420)
352
+ return "Comparison across Spend, AvgPrice, AvgLead, and OTIF:", fig
353
+
354
+ if route == "spend_by_dim":
355
+ g = tool_spend_by_dim(args.get("dim","MaterialGroup"))
356
+ if g is None:
357
+ return "No data for spend by dimension.", None
358
+ fig = px.treemap(g, path=[args.get("dim","MaterialGroup")], values="NetValue", height=420)
359
+ return f"Spend by {args.get('dim','MaterialGroup')}:", fig
360
+
361
+ if route == "recent_pos":
362
+ lines = tool_show_recent_po_lines(args.get("n",10))
363
+ if lines is None:
364
+ return "No recent PO lines found.", None
365
+ st.dataframe(lines, use_container_width=True, height=260)
366
+ return f"Showing {len(lines)} most recent PO lines.", None
367
+
368
+ if route == "lead_by_supplier":
369
+ if not len(fdf): return "No data.", None
370
+ g = fdf.groupby("Supplier", as_index=False)["LeadTimeDays"].mean()
371
+ fig = px.bar(g.sort_values("LeadTimeDays"), x="LeadTimeDays", y="Supplier", orientation="h", template="plotly_white", height=420)
372
+ return "Average lead time by supplier:", fig
373
+
374
+ if route == "price_by_supplier":
375
+ if not len(fdf): return "No data.", None
376
+ g = fdf.groupby("Supplier", as_index=False)["NetPrice"].mean()
377
+ fig = px.bar(g.sort_values("NetPrice", ascending=False), x="Supplier", y="NetPrice", template="plotly_white", height=420)
378
+ return "Average price by supplier:", fig
379
+
380
+ # summary
381
+ msg = f"In current selection: {fdf['PO_ID'].nunique()} POs, spend ${fdf['NetValue'].sum():,.0f}, avg lead time {fdf['LeadTimeDays'].mean():.1f}d, OTIF {(fdf['OTIF'].mean()*100):.0f}%."
382
+ return msg, None
383
+
384
+ # Render chat history
385
+ for m in st.session_state.messages:
386
+ with st.chat_message(m["role"], avatar="🧭" if m["role"]=="assistant" else "🧑🏻"):
387
+ st.write(m["content"])
388
+
389
+ prompt = st.chat_input("Ask about procurement performance, suppliers, KPIs, or simulations…")
390
+ if prompt:
391
+ st.session_state.messages.append({"role": "user", "content": prompt})
392
+ with st.chat_message("user", avatar="🧑🏻"):
393
+ st.write(prompt)
394
+
395
+ with st.chat_message("assistant", avatar="🧭"):
396
+ with st.status("Thinking…", expanded=False):
397
+ route, args = agent_router(prompt)
398
+ text, fig = agent_execute(route, args)
399
 
400
+ if text:
401
+ st.write(text)
402
+ if fig is not None:
403
+ st.plotly_chart(fig, use_container_width=True)
404
+ st.session_state.messages.append({"role": "assistant", "content": text or "(shown as chart/table)"})