Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -94,7 +94,6 @@ def fmt_currency(x: float) -> str:
|
|
| 94 |
# =============================
|
| 95 |
@dataclass
|
| 96 |
class LLMConfig:
|
| 97 |
-
provider: str = os.getenv("LLM_PROVIDER", "openai").lower()
|
| 98 |
base_url: Optional[str] = os.getenv("OPENAI_BASE_URL")
|
| 99 |
api_key: Optional[str] = (
|
| 100 |
os.getenv("OPENAI_API_KEY")
|
|
@@ -126,7 +125,7 @@ class UniversalLLMClient:
|
|
| 126 |
def _base_url(self) -> str:
|
| 127 |
return (self.cfg.base_url or "https://api.openai.com/v1").rstrip("/")
|
| 128 |
|
| 129 |
-
def _smoke_test(self):
|
| 130 |
try:
|
| 131 |
_ = self.chat([{"role": "user", "content": "ping"}], max_tokens=4)
|
| 132 |
except Exception as e:
|
|
@@ -279,7 +278,6 @@ class ProcurementAnalytics:
|
|
| 279 |
g['total_spend'] = g['total_spend'].round(2)
|
| 280 |
return g.sort_values('total_spend', ascending=False)
|
| 281 |
|
| 282 |
-
# helper: top N with shares
|
| 283 |
def top_n_categories(self, n: int = 3) -> List[Tuple[str, float]]:
|
| 284 |
cat = self.category_spend()
|
| 285 |
total = float(cat['order_value'].sum()) or 1.0
|
|
@@ -316,33 +314,34 @@ class UniversalProcurementAgent:
|
|
| 316 |
"top_categories": top_cats,
|
| 317 |
"top_vendors": top_vens,
|
| 318 |
}
|
| 319 |
-
messages = [
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
f"Executive summary. Format amounts with commas (e.g., βΉ12,34,567).\n\n"
|
| 333 |
-
f"Data: {json.dumps(data_summary)}"
|
| 334 |
-
)
|
| 335 |
-
},
|
| 336 |
-
]
|
| 337 |
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
|
|
|
| 345 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
|
| 347 |
*AI fallback due to: {e}*"
|
| 348 |
|
|
@@ -355,15 +354,15 @@ except Exception as e:
|
|
| 355 |
return (
|
| 356 |
"π€ **[Rule-Based Summary]**
|
| 357 |
"
|
| 358 |
-
f"β’ Total spend: {fmt_currency(k['total_spend'])} across {len(self.po_data):,} POs
|
| 359 |
"
|
| 360 |
-
f"β’ On-time delivery: {k['on_time_rate']*100:.1f}% | Avg quality: {k['quality_avg']:.1f}/10
|
| 361 |
"
|
| 362 |
-
f"β’ Top categories: {topc_str}
|
| 363 |
"
|
| 364 |
-
f"β’ Top vendors: {topv_str}
|
| 365 |
"
|
| 366 |
-
"Actions: Consolidate long tail; multi-year terms with top vendors; auto-approve low-value POs."
|
| 367 |
)
|
| 368 |
|
| 369 |
def chat_with_data(self, question: str) -> str:
|
|
@@ -395,9 +394,12 @@ Context: {json.dumps(context)}
|
|
| 395 |
{style_rules}"},
|
| 396 |
]
|
| 397 |
try:
|
| 398 |
-
return
|
|
|
|
| 399 |
|
| 400 |
-
"
|
|
|
|
|
|
|
| 401 |
except Exception as e:
|
| 402 |
return self._rule_answer(question) + f"
|
| 403 |
|
|
@@ -408,7 +410,7 @@ Context: {json.dumps(context)}
|
|
| 408 |
k = self.analytics.kpis()
|
| 409 |
top_c = self.analytics.top_n_categories(3)
|
| 410 |
top_v = self.analytics.top_n_vendors(3)
|
| 411 |
-
if "spend" in q or "spending" in q or "cost" in q:
|
| 412 |
lines = [
|
| 413 |
f"β’ Total spend: {fmt_currency(k['total_spend'])}",
|
| 414 |
"β’ Top categories: " + ", ".join([f"{n} β {s:.0f}%" for n, s in top_c]),
|
|
@@ -418,7 +420,7 @@ Context: {json.dumps(context)}
|
|
| 418 |
return "π€ **[Rule-Based Spend]**
|
| 419 |
" + "
|
| 420 |
".join(lines)
|
| 421 |
-
if "vendor" in q or "supplier" in q or "partner" in q:
|
| 422 |
vp = self.po_data.groupby('vendor').agg(
|
| 423 |
spend=('order_value','sum'),
|
| 424 |
late_rate=('late_delivery','mean'),
|
|
@@ -437,7 +439,7 @@ Context: {json.dumps(context)}
|
|
| 437 |
return "π€ **[Rule-Based Vendor]**
|
| 438 |
" + "
|
| 439 |
".join(lines)
|
| 440 |
-
if "risk" in q or "late" in q or "delay" in q:
|
| 441 |
late = float(self.po_data['late_delivery'].mean())*100
|
| 442 |
lines = [
|
| 443 |
f"β’ Late delivery rate: {late:.1f}%",
|
|
@@ -446,13 +448,12 @@ Context: {json.dumps(context)}
|
|
| 446 |
return "π€ **[Rule-Based Risk]**
|
| 447 |
" + "
|
| 448 |
".join(lines)
|
| 449 |
-
# default
|
| 450 |
return (
|
| 451 |
"π€ **[Rule-Based]**
|
| 452 |
"
|
| 453 |
-
"β’ I can analyze spend (top categories/vendors), vendor performance (best/worst), risk (late %), and trends.
|
| 454 |
"
|
| 455 |
-
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}%"
|
| 456 |
)
|
| 457 |
|
| 458 |
# =============================
|
|
@@ -482,13 +483,13 @@ api_status = "π’ Connected" if status['available'] else "π΄ Not Connected"
|
|
| 482 |
# Header
|
| 483 |
# =============================
|
| 484 |
st.markdown(
|
| 485 |
-
|
| 486 |
-
<div class
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
</div>
|
| 491 |
-
|
| 492 |
unsafe_allow_html=True,
|
| 493 |
)
|
| 494 |
|
|
@@ -524,24 +525,63 @@ if selected == "π Dashboard":
|
|
| 524 |
st.markdown("### π§ AI Executive Summary")
|
| 525 |
with st.spinner('π€ Analyzing procurement data...'):
|
| 526 |
summary = agent.executive_summary()
|
| 527 |
-
st.markdown(
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
|
|
|
|
|
|
|
|
|
| 533 |
|
| 534 |
k = analytics.kpis()
|
| 535 |
|
| 536 |
c1, c2, c3, c4 = st.columns(4)
|
| 537 |
with c1:
|
| 538 |
-
st.markdown(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
with c2:
|
| 540 |
-
st.markdown(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
with c3:
|
| 542 |
-
st.markdown(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
with c4:
|
| 544 |
-
st.markdown(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
|
| 546 |
st.markdown("### π Executive Dashboard")
|
| 547 |
colA, colB = st.columns(2)
|
|
@@ -574,13 +614,16 @@ if selected == "π Dashboard":
|
|
| 574 |
|
| 575 |
elif selected == "π¬ AI Chat":
|
| 576 |
st.markdown("### π¬ Chat with Your Procurement Data")
|
| 577 |
-
st.markdown(
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
|
|
|
|
|
|
|
|
|
| 584 |
|
| 585 |
if "messages" not in st.session_state:
|
| 586 |
st.session_state.messages = [
|
|
@@ -645,13 +688,13 @@ elif selected == "π§ͺ WhatβIf":
|
|
| 645 |
|
| 646 |
kept_names = ", ".join(kept_vendors)
|
| 647 |
st.markdown(
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
<strong>Scenario:</strong> Keep top <b>{top_n}</b> vendors. Addressable share: <b>{share*100:.1f}%</b>.<br/>
|
| 651 |
-
<strong>Potential savings:</strong> <b>{est_savings*100:.1f}%</b> (heuristic).<br/>
|
| 652 |
-
<small>Kept Vendors:</small> {kept_names}
|
| 653 |
-
|
| 654 |
-
|
| 655 |
unsafe_allow_html=True,
|
| 656 |
)
|
| 657 |
|
|
@@ -669,12 +712,12 @@ elif selected == "π― Recommendations":
|
|
| 669 |
]
|
| 670 |
for i, rec in enumerate(recs, start=1):
|
| 671 |
st.markdown(
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
<h4>Recommendation #{i}</h4>
|
| 675 |
-
<p>{rec}</p>
|
| 676 |
-
|
| 677 |
-
|
| 678 |
unsafe_allow_html=True,
|
| 679 |
)
|
| 680 |
|
|
@@ -683,11 +726,11 @@ elif selected == "π― Recommendations":
|
|
| 683 |
# =============================
|
| 684 |
st.markdown("---")
|
| 685 |
st.markdown(
|
| 686 |
-
|
| 687 |
-
<div style
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
</div>
|
| 691 |
-
|
| 692 |
unsafe_allow_html=True,
|
| 693 |
)
|
|
|
|
| 94 |
# =============================
|
| 95 |
@dataclass
|
| 96 |
class LLMConfig:
|
|
|
|
| 97 |
base_url: Optional[str] = os.getenv("OPENAI_BASE_URL")
|
| 98 |
api_key: Optional[str] = (
|
| 99 |
os.getenv("OPENAI_API_KEY")
|
|
|
|
| 125 |
def _base_url(self) -> str:
|
| 126 |
return (self.cfg.base_url or "https://api.openai.com/v1").rstrip("/")
|
| 127 |
|
| 128 |
+
def _smoke_test(self) -> None:
|
| 129 |
try:
|
| 130 |
_ = self.chat([{"role": "user", "content": "ping"}], max_tokens=4)
|
| 131 |
except Exception as e:
|
|
|
|
| 278 |
g['total_spend'] = g['total_spend'].round(2)
|
| 279 |
return g.sort_values('total_spend', ascending=False)
|
| 280 |
|
|
|
|
| 281 |
def top_n_categories(self, n: int = 3) -> List[Tuple[str, float]]:
|
| 282 |
cat = self.category_spend()
|
| 283 |
total = float(cat['order_value'].sum()) or 1.0
|
|
|
|
| 314 |
"top_categories": top_cats,
|
| 315 |
"top_vendors": top_vens,
|
| 316 |
}
|
| 317 |
+
messages = [
|
| 318 |
+
{
|
| 319 |
+
"role": "system",
|
| 320 |
+
"content": (
|
| 321 |
+
"You are a senior procurement analyst. Use bullet points, be concise, "
|
| 322 |
+
"and always use the βΉ symbol. When summarizing, include top categories "
|
| 323 |
+
"and vendors with percentages, then 2-3 quantified actions."
|
| 324 |
+
),
|
| 325 |
+
},
|
| 326 |
+
{
|
| 327 |
+
"role": "user",
|
| 328 |
+
"content": (
|
| 329 |
+
"Executive summary. Format amounts with commas (e.g., βΉ12,34,567).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
|
| 331 |
+
"
|
| 332 |
+
+ f"Data: {json.dumps(data_summary)}"
|
| 333 |
+
),
|
| 334 |
+
},
|
| 335 |
+
]
|
| 336 |
+
try:
|
| 337 |
+
return (
|
| 338 |
+
"π§ **[AI-Powered Analysis]**
|
| 339 |
|
| 340 |
+
"
|
| 341 |
+
+ self.llm.chat(messages, max_tokens=550)
|
| 342 |
+
)
|
| 343 |
+
except Exception as e:
|
| 344 |
+
return self._rule_summary() + f"
|
| 345 |
|
| 346 |
*AI fallback due to: {e}*"
|
| 347 |
|
|
|
|
| 354 |
return (
|
| 355 |
"π€ **[Rule-Based Summary]**
|
| 356 |
"
|
| 357 |
+
+ f"β’ Total spend: {fmt_currency(k['total_spend'])} across {len(self.po_data):,} POs
|
| 358 |
"
|
| 359 |
+
+ f"β’ On-time delivery: {k['on_time_rate']*100:.1f}% | Avg quality: {k['quality_avg']:.1f}/10
|
| 360 |
"
|
| 361 |
+
+ f"β’ Top categories: {topc_str}
|
| 362 |
"
|
| 363 |
+
+ f"β’ Top vendors: {topv_str}
|
| 364 |
"
|
| 365 |
+
+ "Actions: Consolidate long tail; multi-year terms with top vendors; auto-approve low-value POs."
|
| 366 |
)
|
| 367 |
|
| 368 |
def chat_with_data(self, question: str) -> str:
|
|
|
|
| 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 |
|
|
|
|
| 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'])}",
|
| 416 |
"β’ Top categories: " + ", ".join([f"{n} β {s:.0f}%" for n, s in top_c]),
|
|
|
|
| 420 |
return "π€ **[Rule-Based Spend]**
|
| 421 |
" + "
|
| 422 |
".join(lines)
|
| 423 |
+
if ("vendor" in q) or ("supplier" in q) or ("partner" in q):
|
| 424 |
vp = self.po_data.groupby('vendor').agg(
|
| 425 |
spend=('order_value','sum'),
|
| 426 |
late_rate=('late_delivery','mean'),
|
|
|
|
| 439 |
return "π€ **[Rule-Based Vendor]**
|
| 440 |
" + "
|
| 441 |
".join(lines)
|
| 442 |
+
if ("risk" in q) or ("late" in q) or ("delay" in q):
|
| 443 |
late = float(self.po_data['late_delivery'].mean())*100
|
| 444 |
lines = [
|
| 445 |
f"β’ Late delivery rate: {late:.1f}%",
|
|
|
|
| 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 |
# =============================
|
|
|
|
| 483 |
# Header
|
| 484 |
# =============================
|
| 485 |
st.markdown(
|
| 486 |
+
(
|
| 487 |
+
"<div class=\"main-header\">"
|
| 488 |
+
"<h1>π€ SAP S/4HANA Agentic AI Procurement Analytics</h1>"
|
| 489 |
+
"<p>Autonomous Intelligence for Procurement Excellence</p>"
|
| 490 |
+
+ f"<small>LLM: {api_status} Β· Data: {len(st.session_state.po_df):,} POs</small>"
|
| 491 |
+
"</div>"
|
| 492 |
+
),
|
| 493 |
unsafe_allow_html=True,
|
| 494 |
)
|
| 495 |
|
|
|
|
| 525 |
st.markdown("### π§ AI Executive Summary")
|
| 526 |
with st.spinner('π€ Analyzing procurement data...'):
|
| 527 |
summary = agent.executive_summary()
|
| 528 |
+
st.markdown(
|
| 529 |
+
(
|
| 530 |
+
"<div class=\"ai-insight\">"
|
| 531 |
+
"<h4>π Intelligent Analysis</h4>"
|
| 532 |
+
+ f"<div style=\"white-space: pre-line; line-height: 1.55;\">{summary}</div>"
|
| 533 |
+
"</div>"
|
| 534 |
+
),
|
| 535 |
+
unsafe_allow_html=True,
|
| 536 |
+
)
|
| 537 |
|
| 538 |
k = analytics.kpis()
|
| 539 |
|
| 540 |
c1, c2, c3, c4 = st.columns(4)
|
| 541 |
with c1:
|
| 542 |
+
st.markdown(
|
| 543 |
+
(
|
| 544 |
+
"<div class='metric-card'>"
|
| 545 |
+
"<h3 style='color: var(--primary-color); margin:0;'>Total Spend</h3>"
|
| 546 |
+
+ f"<h2 style='margin: .5rem 0;'>{fmt_currency(k['total_spend'])}</h2>"
|
| 547 |
+
"<p style='color:#28a745;margin:0;'>π Active Portfolio</p>"
|
| 548 |
+
"</div>"
|
| 549 |
+
),
|
| 550 |
+
unsafe_allow_html=True,
|
| 551 |
+
)
|
| 552 |
with c2:
|
| 553 |
+
st.markdown(
|
| 554 |
+
(
|
| 555 |
+
"<div class='metric-card'>"
|
| 556 |
+
"<h3 style='color: var(--primary-color); margin:0;'>Avg Order Value</h3>"
|
| 557 |
+
+ f"<h2 style='margin: .5rem 0;'>{fmt_currency(k['avg_order_value'])}</h2>"
|
| 558 |
+
"<p style='color:#17a2b8;margin:0;'>π Order Efficiency</p>"
|
| 559 |
+
"</div>"
|
| 560 |
+
),
|
| 561 |
+
unsafe_allow_html=True,
|
| 562 |
+
)
|
| 563 |
with c3:
|
| 564 |
+
st.markdown(
|
| 565 |
+
(
|
| 566 |
+
"<div class='metric-card'>"
|
| 567 |
+
"<h3 style='color: var(--primary-color); margin:0;'>Active Vendors</h3>"
|
| 568 |
+
+ f"<h2 style='margin: .5rem 0;'>{k['active_vendors']}</h2>"
|
| 569 |
+
"<p style='color:#6f42c1;margin:0;'>π€ Strategic Partners</p>"
|
| 570 |
+
"</div>"
|
| 571 |
+
),
|
| 572 |
+
unsafe_allow_html=True,
|
| 573 |
+
)
|
| 574 |
with c4:
|
| 575 |
+
st.markdown(
|
| 576 |
+
(
|
| 577 |
+
"<div class='metric-card'>"
|
| 578 |
+
"<h3 style='color: var(--primary-color); margin:0;'>OnβTime Delivery</h3>"
|
| 579 |
+
+ f"<h2 style='margin: .5rem 0;'>{k['on_time_rate']*100:.1f}%</h2>"
|
| 580 |
+
"<p style='color:#28a745;margin:0;'>β± Performance</p>"
|
| 581 |
+
"</div>"
|
| 582 |
+
),
|
| 583 |
+
unsafe_allow_html=True,
|
| 584 |
+
)
|
| 585 |
|
| 586 |
st.markdown("### π Executive Dashboard")
|
| 587 |
colA, colB = st.columns(2)
|
|
|
|
| 614 |
|
| 615 |
elif selected == "π¬ AI Chat":
|
| 616 |
st.markdown("### π¬ Chat with Your Procurement Data")
|
| 617 |
+
st.markdown(
|
| 618 |
+
(
|
| 619 |
+
"<div class=\"ai-insight\">"
|
| 620 |
+
"<h4>π€ Universal AI Assistant</h4>"
|
| 621 |
+
"<p>Ask me anything about your procurement data. I will answer with crisp bullets and actual metrics.</p>"
|
| 622 |
+
+ f"<p><small>Status: {api_status} | Model: {status['model']}</small></p>"
|
| 623 |
+
"</div>"
|
| 624 |
+
),
|
| 625 |
+
unsafe_allow_html=True,
|
| 626 |
+
)
|
| 627 |
|
| 628 |
if "messages" not in st.session_state:
|
| 629 |
st.session_state.messages = [
|
|
|
|
| 688 |
|
| 689 |
kept_names = ", ".join(kept_vendors)
|
| 690 |
st.markdown(
|
| 691 |
+
(
|
| 692 |
+
"<div class='alert alert-info'>"
|
| 693 |
+
+ f"<strong>Scenario:</strong> Keep top <b>{top_n}</b> vendors. Addressable share: <b>{share*100:.1f}%</b>.<br/>"
|
| 694 |
+
+ f"<strong>Potential savings:</strong> <b>{est_savings*100:.1f}%</b> (heuristic).<br/>"
|
| 695 |
+
+ f"<small>Kept Vendors:</small> {kept_names}"
|
| 696 |
+
+ "</div>"
|
| 697 |
+
),
|
| 698 |
unsafe_allow_html=True,
|
| 699 |
)
|
| 700 |
|
|
|
|
| 712 |
]
|
| 713 |
for i, rec in enumerate(recs, start=1):
|
| 714 |
st.markdown(
|
| 715 |
+
(
|
| 716 |
+
"<div class=\"alert alert-success\">"
|
| 717 |
+
+ f"<h4>Recommendation #{i}</h4>"
|
| 718 |
+
+ f"<p>{rec}</p>"
|
| 719 |
+
+ "</div>"
|
| 720 |
+
),
|
| 721 |
unsafe_allow_html=True,
|
| 722 |
)
|
| 723 |
|
|
|
|
| 726 |
# =============================
|
| 727 |
st.markdown("---")
|
| 728 |
st.markdown(
|
| 729 |
+
(
|
| 730 |
+
"<div style=\"text-align:center; padding: 1rem; color:#666;\">"
|
| 731 |
+
"<p>π€ <strong>Universal AI Procurement Analytics</strong> | Crisp, metric-first answers in βΉ</p>"
|
| 732 |
+
+ f"<p><em>Demo with synthetic data β’ {len(st.session_state.po_df):,} orders β’ LLM {api_status}</em></p>"
|
| 733 |
+
"</div>"
|
| 734 |
+
),
|
| 735 |
unsafe_allow_html=True,
|
| 736 |
)
|