Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -79,18 +79,34 @@ st.markdown(
|
|
| 79 |
)
|
| 80 |
|
| 81 |
# =============================
|
| 82 |
-
# Currency
|
| 83 |
# =============================
|
| 84 |
CURRENCY = "βΉ"
|
| 85 |
|
| 86 |
-
def
|
|
|
|
| 87 |
try:
|
| 88 |
-
|
| 89 |
except Exception:
|
| 90 |
return f"{CURRENCY}{x}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
# =============================
|
| 93 |
-
#
|
| 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 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 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 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 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[
|
| 248 |
-
self.df[
|
| 249 |
|
| 250 |
def kpis(self) -> Dict[str, Any]:
|
| 251 |
df = self.df
|
| 252 |
return {
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
}
|
| 259 |
|
| 260 |
def category_spend(self) -> pd.DataFrame:
|
| 261 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
|
| 263 |
def vendor_spend(self, top_n: int = 8) -> pd.DataFrame:
|
| 264 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
def monthly_spend(self) -> pd.DataFrame:
|
| 267 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
def vendor_performance(self) -> pd.DataFrame:
|
| 270 |
-
g = self.df.groupby(
|
| 271 |
-
total_spend=(
|
| 272 |
-
on_time=(
|
| 273 |
-
quality=(
|
| 274 |
-
orders=(
|
| 275 |
)
|
| 276 |
-
g[
|
| 277 |
-
g[
|
| 278 |
-
g[
|
| 279 |
-
return g.sort_values(
|
| 280 |
|
| 281 |
def top_n_categories(self, n: int = 3) -> List[Tuple[str, float]]:
|
| 282 |
cat = self.category_spend()
|
| 283 |
-
total = float(cat[
|
| 284 |
-
return [(r[
|
| 285 |
|
| 286 |
def top_n_vendors(self, n: int = 3) -> List[Tuple[str, float]]:
|
| 287 |
-
ven =
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 303 |
-
|
| 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 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"β’
|
| 358 |
-
"
|
| 359 |
-
+ f"β’
|
| 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[
|
| 376 |
"orders": int(len(self.po_data)),
|
| 377 |
-
"vendors": int(self.po_data[
|
| 378 |
-
"on_time": k[
|
| 379 |
-
"quality": k[
|
| 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(
|
| 425 |
-
spend=(
|
| 426 |
-
late_rate=(
|
| 427 |
-
quality=(
|
| 428 |
-
).sort_values(
|
| 429 |
best = vp.head(1)
|
| 430 |
-
worst = vp.sort_values(
|
| 431 |
bname, wname = best.index[0], worst.index[0]
|
| 432 |
-
blate = float(best.iloc[0][
|
| 433 |
-
wlate = float(worst.iloc[0][
|
| 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[
|
| 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
|
| 463 |
-
with st.spinner(
|
| 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[
|
| 481 |
|
| 482 |
# =============================
|
| 483 |
# Header
|
|
@@ -506,8 +518,8 @@ with st.sidebar:
|
|
| 506 |
|
| 507 |
selected = option_menu(
|
| 508 |
"Navigation",
|
| 509 |
-
["π Dashboard", "π¬ AI Chat", "π Analytics", "π§ͺ What
|
| 510 |
-
icons=[
|
| 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(
|
| 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
|
| 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=
|
| 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=
|
| 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=
|
| 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(
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
|
| 671 |
st.download_button(
|
| 672 |
label="β¬οΈ Download Vendor Performance (CSV)",
|
| 673 |
-
data=vp.to_csv().encode(
|
| 674 |
file_name="vendor_performance.csv",
|
| 675 |
mime="text/csv",
|
| 676 |
)
|
| 677 |
|
| 678 |
-
elif selected == "π§ͺ What
|
| 679 |
-
st.markdown("### π§ͺ What
|
| 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(
|
| 683 |
kept_vendors = list(g.head(top_n).index)
|
| 684 |
-
kept_spend = st.session_state.po_df[st.session_state.po_df[
|
| 685 |
-
total_spend = st.session_state.po_df[
|
| 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={
|
| 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")
|