Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# app.py —
|
| 2 |
|
| 3 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 4 |
import pandas as pd
|
|
@@ -18,6 +18,10 @@ import requests
|
|
| 18 |
import urllib.parse
|
| 19 |
import json
|
| 20 |
import re
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
# -----------------------------------------------------------------------------
|
| 23 |
# Init
|
|
@@ -32,6 +36,30 @@ logging.basicConfig(
|
|
| 32 |
)
|
| 33 |
logger = logging.getLogger(__name__)
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
# -----------------------------------------------------------------------------
|
| 36 |
# Response parser (preserved)
|
| 37 |
# -----------------------------------------------------------------------------
|
|
@@ -83,7 +111,7 @@ user_defined_path = os.path.join("/exports/charts", str(uuid.uuid4()))
|
|
| 83 |
logger.info(f"Chart export path set to: {user_defined_path}")
|
| 84 |
|
| 85 |
# -----------------------------------------------------------------------------
|
| 86 |
-
# Temporal helpers + guardrails
|
| 87 |
# -----------------------------------------------------------------------------
|
| 88 |
TZ = "Africa/Harare"
|
| 89 |
|
|
@@ -193,15 +221,13 @@ def guardrails_preamble() -> str:
|
|
| 193 |
)
|
| 194 |
|
| 195 |
# -----------------------------------------------------------------------------
|
| 196 |
-
# Error detection & sanitization
|
| 197 |
# -----------------------------------------------------------------------------
|
| 198 |
ERROR_PATTERNS = [
|
| 199 |
"traceback", "exception", "keyerror", "nameerror", "syntaxerror",
|
| 200 |
"modulenotfounderror", "importerror", "pipeline failed", "execution failed",
|
| 201 |
"__import__", "failed with error", "attributeerror", "method_descriptor",
|
| 202 |
-
# PandasAI canonical failure banner:
|
| 203 |
"unfortunately, i was not able to answer your question",
|
| 204 |
-
# Pandas non-fixed frequency class of errors:
|
| 205 |
"non-fixed frequency", "monthbegin", "monthend", "week:", "weekday="
|
| 206 |
]
|
| 207 |
|
|
@@ -216,16 +242,9 @@ def _stringify(obj) -> str:
|
|
| 216 |
return ""
|
| 217 |
|
| 218 |
def _extract_text_like(ans):
|
| 219 |
-
"""
|
| 220 |
-
Return the most relevant text to inspect:
|
| 221 |
-
- dict with 'value'
|
| 222 |
-
- objects with 'value' attr
|
| 223 |
-
- plain string/number
|
| 224 |
-
"""
|
| 225 |
if isinstance(ans, dict):
|
| 226 |
if "value" in ans:
|
| 227 |
return _stringify(ans["value"])
|
| 228 |
-
# some parsers use {'type': 'string', 'value': '...'}
|
| 229 |
for k in ("message", "text", "content"):
|
| 230 |
if k in ans:
|
| 231 |
return _stringify(ans[k])
|
|
@@ -238,7 +257,6 @@ def _extract_text_like(ans):
|
|
| 238 |
return _stringify(ans)
|
| 239 |
|
| 240 |
def looks_like_error(ans) -> bool:
|
| 241 |
-
# Early accept for DataFrame/Figure
|
| 242 |
if isinstance(ans, (pd.DataFrame, plt.Figure)):
|
| 243 |
return False
|
| 244 |
s = _extract_text_like(ans).strip().lower()
|
|
@@ -246,23 +264,21 @@ def looks_like_error(ans) -> bool:
|
|
| 246 |
return True
|
| 247 |
if any(p in s for p in ERROR_PATTERNS):
|
| 248 |
return True
|
| 249 |
-
# crude stack trace glimpse
|
| 250 |
if ("file \"" in s and "line " in s and "error" in s) or ("valueerror:" in s):
|
| 251 |
return True
|
| 252 |
return False
|
| 253 |
|
| 254 |
def sanitize_answer(ans) -> str:
|
| 255 |
s = _extract_text_like(ans)
|
| 256 |
-
s = re.sub(r"```+(\w+)?", "", s)
|
| 257 |
if "Traceback (most recent call last):" in s:
|
| 258 |
-
s = s.split("Traceback (most recent call last):")
|
| 259 |
-
# if PandasAI banner leaked, nuke it
|
| 260 |
if "Unfortunately, I was not able to answer your question" in s:
|
| 261 |
s = ""
|
| 262 |
return s.strip()
|
| 263 |
|
| 264 |
# -----------------------------------------------------------------------------
|
| 265 |
-
# Analyst KPI layer (
|
| 266 |
# -----------------------------------------------------------------------------
|
| 267 |
class IrisReportEngine:
|
| 268 |
def __init__(self, transactions_data: list, llm_instance):
|
|
@@ -274,11 +290,9 @@ class IrisReportEngine:
|
|
| 274 |
if not transactions:
|
| 275 |
return pd.DataFrame()
|
| 276 |
df = pd.DataFrame(transactions)
|
| 277 |
-
|
| 278 |
for col in ["Units_Sold", "Unit_Cost_Price", "Amount"]:
|
| 279 |
if col in df.columns:
|
| 280 |
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0)
|
| 281 |
-
|
| 282 |
if "Time" in df.columns:
|
| 283 |
dt_series = pd.to_datetime(
|
| 284 |
df["Date"].astype(str) + " " + df["Time"].astype(str),
|
|
@@ -286,7 +300,6 @@ class IrisReportEngine:
|
|
| 286 |
)
|
| 287 |
else:
|
| 288 |
dt_series = pd.to_datetime(df.get("Date"), errors="coerce")
|
| 289 |
-
|
| 290 |
try:
|
| 291 |
if getattr(dt_series.dt, "tz", None) is None:
|
| 292 |
dt_series = dt_series.dt.tz_localize(TZ, nonexistent="shift_forward", ambiguous="NaT")
|
|
@@ -294,31 +307,26 @@ class IrisReportEngine:
|
|
| 294 |
dt_series = dt_series.dt.tz_convert(TZ)
|
| 295 |
except Exception:
|
| 296 |
pass
|
| 297 |
-
|
| 298 |
df["datetime"] = dt_series
|
| 299 |
df.dropna(subset=["datetime"], inplace=True)
|
| 300 |
-
|
| 301 |
df["DayOfWeek"] = df["datetime"].dt.day_name()
|
| 302 |
df["HourOfDay"] = df["datetime"].dt.hour
|
| 303 |
-
|
| 304 |
if "Transaction_Type" in df.columns:
|
| 305 |
sales_df = df[df["Transaction_Type"].astype(str).str.lower() == "sale"].copy()
|
| 306 |
else:
|
| 307 |
sales_df = df.copy()
|
| 308 |
-
|
| 309 |
sales_df["Revenue"] = sales_df.get("Amount", 0)
|
| 310 |
if "Unit_Cost_Price" in sales_df.columns and "Units_Sold" in sales_df.columns:
|
| 311 |
sales_df["CostOfGoods"] = sales_df["Unit_Cost_Price"] * sales_df["Units_Sold"]
|
| 312 |
else:
|
| 313 |
sales_df["CostOfGoods"] = 0
|
| 314 |
sales_df["GrossProfit"] = sales_df["Revenue"] - sales_df["CostOfGoods"]
|
| 315 |
-
|
| 316 |
return sales_df
|
| 317 |
|
| 318 |
def _get_primary_currency(self) -> str:
|
| 319 |
try:
|
| 320 |
if not self.df.empty and "Currency" in self.df.columns and not self.df["Currency"].mode().empty:
|
| 321 |
-
return str(self.df["Currency"].mode()
|
| 322 |
except Exception:
|
| 323 |
pass
|
| 324 |
return "USD"
|
|
@@ -338,15 +346,12 @@ class IrisReportEngine:
|
|
| 338 |
previous_revenue = float(previous_df["Revenue"].sum())
|
| 339 |
current_profit = float(current_df["GrossProfit"].sum())
|
| 340 |
previous_profit = float(previous_df["GrossProfit"].sum())
|
| 341 |
-
|
| 342 |
def pct_change(cur, prev):
|
| 343 |
if prev == 0:
|
| 344 |
return "+100%" if cur > 0 else "0.0%"
|
| 345 |
return f"{((cur - prev) / prev) * 100:+.1f}%"
|
| 346 |
-
|
| 347 |
tx_now = int(current_df.get("Invoice_Number", pd.Series()).nunique()) if "Invoice_Number" in current_df.columns else int(len(current_df))
|
| 348 |
tx_prev = int(previous_df.get("Invoice_Number", pd.Series()).nunique()) if "Invoice_Number" in previous_df.columns else int(len(previous_df))
|
| 349 |
-
|
| 350 |
return {
|
| 351 |
"Total Revenue": f"{self.currency} {current_revenue:,.2f} ({pct_change(current_revenue, previous_revenue)})",
|
| 352 |
"Gross Profit": f"{self.currency} {current_profit:,.2f} ({pct_change(current_profit, previous_profit)})",
|
|
@@ -356,23 +361,18 @@ class IrisReportEngine:
|
|
| 356 |
def get_business_intelligence_briefing(self) -> dict:
|
| 357 |
if self.df.empty:
|
| 358 |
return {"Status": "No sales data available to generate a briefing."}
|
| 359 |
-
|
| 360 |
current_df, previous_df, summary_period = self._get_comparison_timeframes()
|
| 361 |
if current_df.empty:
|
| 362 |
return {"Status": f"No sales data was found for the current period ({summary_period})."}
|
| 363 |
-
|
| 364 |
headline = self._calculate_headline_kpis(current_df, previous_df)
|
| 365 |
-
|
| 366 |
baskets = current_df.groupby('Invoice_Number', dropna=True).agg(
|
| 367 |
BasketProfit=('GrossProfit', 'sum'),
|
| 368 |
ItemsPerBasket=('Units_Sold', 'sum')
|
| 369 |
) if 'Invoice_Number' in current_df.columns else pd.DataFrame()
|
| 370 |
-
|
| 371 |
products_by_profit = current_df.groupby('Product')['GrossProfit'].sum() if 'Product' in current_df.columns else pd.Series(dtype=float)
|
| 372 |
products_by_units = current_df.groupby('Product')['Units_Sold'].sum() if 'Product' in current_df.columns and 'Units_Sold' in current_df.columns else pd.Series(dtype=float)
|
| 373 |
tellers_by_profit = current_df.groupby('Teller_Username')['GrossProfit'].sum() if 'Teller_Username' in current_df.columns else pd.Series(dtype=float)
|
| 374 |
profit_by_hour = current_df.groupby('HourOfDay')['GrossProfit'].sum() if 'HourOfDay' in current_df.columns else pd.Series(dtype=float)
|
| 375 |
-
|
| 376 |
product_intel = {}
|
| 377 |
if len(products_by_profit) > 1:
|
| 378 |
product_intel = {
|
|
@@ -384,14 +384,12 @@ class IrisReportEngine:
|
|
| 384 |
),
|
| 385 |
}
|
| 386 |
elif not products_by_profit.empty:
|
| 387 |
-
product_intel = {"Only Product Sold": products_by_profit.index
|
| 388 |
-
|
| 389 |
staff_intel = {}
|
| 390 |
if len(tellers_by_profit) > 1:
|
| 391 |
staff_intel = {"Top Performing Teller (by Profit)": tellers_by_profit.idxmax()}
|
| 392 |
elif not tellers_by_profit.empty:
|
| 393 |
-
staff_intel = {"Only Teller": tellers_by_profit.index
|
| 394 |
-
|
| 395 |
return {
|
| 396 |
"Summary Period": summary_period,
|
| 397 |
"Performance Snapshot (vs. Prior Period)": headline,
|
|
@@ -409,12 +407,9 @@ class IrisReportEngine:
|
|
| 409 |
def synthesize_fallback_response(self, briefing: dict, user_question: str) -> str:
|
| 410 |
fallback_prompt = f"""
|
| 411 |
You are Iris, an expert business data analyst. Answer the user's question using the business data below.
|
| 412 |
-
|
| 413 |
If their question is specific (e.g., “sales yesterday”, “top product”), answer directly.
|
| 414 |
If the request can't be answered precisely, provide a helpful business briefing.
|
| 415 |
-
|
| 416 |
Use clear markdown with short headings and bullets. Keep it concise.
|
| 417 |
-
|
| 418 |
User Question: \"{user_question}\"
|
| 419 |
Business Data: {json.dumps(briefing, indent=2, ensure_ascii=False)}
|
| 420 |
"""
|
|
@@ -422,7 +417,7 @@ Business Data: {json.dumps(briefing, indent=2, ensure_ascii=False)}
|
|
| 422 |
return response.content if hasattr(response, "content") else str(response)
|
| 423 |
|
| 424 |
# -----------------------------------------------------------------------------
|
| 425 |
-
# /chat
|
| 426 |
# -----------------------------------------------------------------------------
|
| 427 |
@app.route("/chat", methods=["POST"])
|
| 428 |
@cross_origin()
|
|
@@ -434,8 +429,6 @@ def bot():
|
|
| 434 |
user_question = payload.get("user_question")
|
| 435 |
if not profile_id or not user_question:
|
| 436 |
return jsonify({"answer": "Missing 'profile_id' or 'user_question'."})
|
| 437 |
-
|
| 438 |
-
# Fetch transactions
|
| 439 |
API_URL = "https://irisplustech.com/public/api/business/profile/user/get-recent-transactions-v2"
|
| 440 |
try:
|
| 441 |
resp = requests.post(
|
|
@@ -448,15 +441,11 @@ def bot():
|
|
| 448 |
except Exception:
|
| 449 |
logger.exception("Transaction API error")
|
| 450 |
return jsonify({"answer": "I couldn't reach the transactions service. Please try again shortly."})
|
| 451 |
-
|
| 452 |
if not transactions:
|
| 453 |
return jsonify({"answer": "No transaction data was found for this profile."})
|
| 454 |
-
|
| 455 |
-
# Tier 1 — PandasAI attempt (fully guarded)
|
| 456 |
try:
|
| 457 |
logger.info("Attempting Tier 1 (PandasAI)...")
|
| 458 |
df = pd.DataFrame(transactions)
|
| 459 |
-
|
| 460 |
pandas_agent = SmartDataframe(df, config={
|
| 461 |
"llm": llm,
|
| 462 |
"response_parser": FlaskResponse,
|
|
@@ -473,15 +462,11 @@ def bot():
|
|
| 473 |
"matplotlib","seaborn","plotly","json","re","warnings"
|
| 474 |
],
|
| 475 |
})
|
| 476 |
-
|
| 477 |
combined_prompt = f"{guardrails_preamble()}\n\n{temporal_hints(user_question)}\n\nQuestion: {user_question}"
|
| 478 |
answer = pandas_agent.chat(combined_prompt)
|
| 479 |
-
|
| 480 |
if looks_like_error(answer):
|
| 481 |
logger.warning("PandasAI returned an invalid/errored answer; activating analyst fallback.")
|
| 482 |
raise RuntimeError("PandasAI invalid answer")
|
| 483 |
-
|
| 484 |
-
# Successful Tier 1
|
| 485 |
if isinstance(answer, pd.DataFrame):
|
| 486 |
return jsonify({"answer": answer.to_html(), "meta": {"source": "pandasai"}})
|
| 487 |
if isinstance(answer, plt.Figure):
|
|
@@ -489,77 +474,151 @@ def bot():
|
|
| 489 |
answer.savefig(buf, format="png")
|
| 490 |
data_uri = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode('utf-8')}"
|
| 491 |
return jsonify({"answer": data_uri, "meta": {"source": "pandasai"}})
|
| 492 |
-
|
| 493 |
return jsonify({"answer": sanitize_answer(answer), "meta": {"source": "pandasai"}})
|
| 494 |
-
|
| 495 |
except Exception:
|
| 496 |
logger.exception("Tier 1 (PandasAI) failed; moving to analyst layer.")
|
| 497 |
-
|
| 498 |
-
# Tier 2 — Analyst KPI fallback (guaranteed)
|
| 499 |
engine = IrisReportEngine(transactions_data=transactions, llm_instance=llm)
|
| 500 |
briefing = engine.get_business_intelligence_briefing()
|
| 501 |
fallback_answer = engine.synthesize_fallback_response(briefing, user_question)
|
| 502 |
return jsonify({"answer": sanitize_answer(fallback_answer), "meta": {"source": "analyst_fallback"}})
|
| 503 |
-
|
| 504 |
except Exception:
|
| 505 |
logger.exception("Critical unexpected error in /chat")
|
| 506 |
return jsonify({"answer": "Something went wrong on our side. Please try again."})
|
| 507 |
|
| 508 |
# -----------------------------------------------------------------------------
|
| 509 |
-
# Other endpoints (
|
| 510 |
# -----------------------------------------------------------------------------
|
| 511 |
@app.route("/report", methods=["POST"])
|
| 512 |
@cross_origin()
|
| 513 |
def busines_report():
|
| 514 |
logger.info("=== Starting /report endpoint ===")
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
json_data = request_json.get("json_data") if request_json else None
|
| 518 |
-
prompt = (
|
| 519 |
-
"You are Quantilytix business analyst. Analyze the following data and generate a "
|
| 520 |
-
"comprehensive and insightful business report, including appropriate key perfomance "
|
| 521 |
-
"indicators and recommendations Use markdown formatting and tables where necessary. "
|
| 522 |
-
"only return the report and nothing else.\ndata:\n" + str(json_data)
|
| 523 |
-
)
|
| 524 |
-
response = model.generate_content(prompt)
|
| 525 |
-
return jsonify(str(response.text))
|
| 526 |
-
except Exception as e:
|
| 527 |
-
logger.exception("Error in /report endpoint")
|
| 528 |
-
return jsonify({"error": "Failed to generate report.", "details": str(e)}), 500
|
| 529 |
|
| 530 |
@app.route("/marketing", methods=["POST"])
|
| 531 |
@cross_origin()
|
| 532 |
def marketing():
|
| 533 |
logger.info("=== Starting /marketing endpoint ===")
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
json_data = request_json.get("json_data") if request_json else None
|
| 537 |
-
prompt = (
|
| 538 |
-
"You are an Quantilytix Marketing Specialist. Analyze the following data and generate "
|
| 539 |
-
"a comprehensive marketing strategy, Only return the marketing strategy. be very creative:\n" + str(json_data)
|
| 540 |
-
)
|
| 541 |
-
response = model.generate_content(prompt)
|
| 542 |
-
return jsonify(str(response.text))
|
| 543 |
-
except Exception as e:
|
| 544 |
-
logger.exception("Error in /marketing endpoint")
|
| 545 |
-
return jsonify({"error": "Failed to generate marketing strategy.", "details": str(e)}), 500
|
| 546 |
|
| 547 |
@app.route("/notify", methods=["POST"])
|
| 548 |
@cross_origin()
|
| 549 |
def notifications():
|
| 550 |
logger.info("=== Starting /notify endpoint ===")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
try:
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
)
|
| 558 |
-
|
| 559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
except Exception as e:
|
| 561 |
-
logger.exception("
|
| 562 |
-
return jsonify({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
|
| 564 |
if __name__ == "__main__":
|
| 565 |
app.run(debug=True, host="0.0.0.0", port=7860)
|
|
|
|
| 1 |
+
# app.py — REVISED: Separated KPI snapshot from memory summary
|
| 2 |
|
| 3 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 4 |
import pandas as pd
|
|
|
|
| 18 |
import urllib.parse
|
| 19 |
import json
|
| 20 |
import re
|
| 21 |
+
# NEW IMPORTS
|
| 22 |
+
import time
|
| 23 |
+
import firebase_admin
|
| 24 |
+
from firebase_admin import credentials, db
|
| 25 |
|
| 26 |
# -----------------------------------------------------------------------------
|
| 27 |
# Init
|
|
|
|
| 36 |
)
|
| 37 |
logger = logging.getLogger(__name__)
|
| 38 |
|
| 39 |
+
# -----------------------------------------------------------------------------
|
| 40 |
+
# NEW: Firebase Initialization
|
| 41 |
+
# -----------------------------------------------------------------------------
|
| 42 |
+
try:
|
| 43 |
+
credentials_json_string = os.environ.get("FIREBASE")
|
| 44 |
+
if not credentials_json_string:
|
| 45 |
+
raise ValueError("The FIREBASE environment variable is not set.")
|
| 46 |
+
|
| 47 |
+
credentials_json = json.loads(credentials_json_string)
|
| 48 |
+
firebase_db_url = os.environ.get("Firebase_DB")
|
| 49 |
+
|
| 50 |
+
if not firebase_db_url:
|
| 51 |
+
raise ValueError("Firebase_DB environment variable must be set.")
|
| 52 |
+
|
| 53 |
+
cred = credentials.Certificate(credentials_json)
|
| 54 |
+
firebase_admin.initialize_app(cred, {
|
| 55 |
+
'databaseURL': firebase_db_url,
|
| 56 |
+
})
|
| 57 |
+
db_ref = db.reference()
|
| 58 |
+
logger.info("Firebase Admin SDK initialized successfully.")
|
| 59 |
+
except Exception as e:
|
| 60 |
+
logger.fatal(f"FATAL: Error initializing Firebase: {e}")
|
| 61 |
+
exit(1)
|
| 62 |
+
|
| 63 |
# -----------------------------------------------------------------------------
|
| 64 |
# Response parser (preserved)
|
| 65 |
# -----------------------------------------------------------------------------
|
|
|
|
| 111 |
logger.info(f"Chart export path set to: {user_defined_path}")
|
| 112 |
|
| 113 |
# -----------------------------------------------------------------------------
|
| 114 |
+
# Temporal helpers + guardrails (preserved)
|
| 115 |
# -----------------------------------------------------------------------------
|
| 116 |
TZ = "Africa/Harare"
|
| 117 |
|
|
|
|
| 221 |
)
|
| 222 |
|
| 223 |
# -----------------------------------------------------------------------------
|
| 224 |
+
# Error detection & sanitization (preserved)
|
| 225 |
# -----------------------------------------------------------------------------
|
| 226 |
ERROR_PATTERNS = [
|
| 227 |
"traceback", "exception", "keyerror", "nameerror", "syntaxerror",
|
| 228 |
"modulenotfounderror", "importerror", "pipeline failed", "execution failed",
|
| 229 |
"__import__", "failed with error", "attributeerror", "method_descriptor",
|
|
|
|
| 230 |
"unfortunately, i was not able to answer your question",
|
|
|
|
| 231 |
"non-fixed frequency", "monthbegin", "monthend", "week:", "weekday="
|
| 232 |
]
|
| 233 |
|
|
|
|
| 242 |
return ""
|
| 243 |
|
| 244 |
def _extract_text_like(ans):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
if isinstance(ans, dict):
|
| 246 |
if "value" in ans:
|
| 247 |
return _stringify(ans["value"])
|
|
|
|
| 248 |
for k in ("message", "text", "content"):
|
| 249 |
if k in ans:
|
| 250 |
return _stringify(ans[k])
|
|
|
|
| 257 |
return _stringify(ans)
|
| 258 |
|
| 259 |
def looks_like_error(ans) -> bool:
|
|
|
|
| 260 |
if isinstance(ans, (pd.DataFrame, plt.Figure)):
|
| 261 |
return False
|
| 262 |
s = _extract_text_like(ans).strip().lower()
|
|
|
|
| 264 |
return True
|
| 265 |
if any(p in s for p in ERROR_PATTERNS):
|
| 266 |
return True
|
|
|
|
| 267 |
if ("file \"" in s and "line " in s and "error" in s) or ("valueerror:" in s):
|
| 268 |
return True
|
| 269 |
return False
|
| 270 |
|
| 271 |
def sanitize_answer(ans) -> str:
|
| 272 |
s = _extract_text_like(ans)
|
| 273 |
+
s = re.sub(r"```+(\w+)?", "", s)
|
| 274 |
if "Traceback (most recent call last):" in s:
|
| 275 |
+
s = s.split("Traceback (most recent call last):").strip()
|
|
|
|
| 276 |
if "Unfortunately, I was not able to answer your question" in s:
|
| 277 |
s = ""
|
| 278 |
return s.strip()
|
| 279 |
|
| 280 |
# -----------------------------------------------------------------------------
|
| 281 |
+
# Analyst KPI layer (preserved)
|
| 282 |
# -----------------------------------------------------------------------------
|
| 283 |
class IrisReportEngine:
|
| 284 |
def __init__(self, transactions_data: list, llm_instance):
|
|
|
|
| 290 |
if not transactions:
|
| 291 |
return pd.DataFrame()
|
| 292 |
df = pd.DataFrame(transactions)
|
|
|
|
| 293 |
for col in ["Units_Sold", "Unit_Cost_Price", "Amount"]:
|
| 294 |
if col in df.columns:
|
| 295 |
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0)
|
|
|
|
| 296 |
if "Time" in df.columns:
|
| 297 |
dt_series = pd.to_datetime(
|
| 298 |
df["Date"].astype(str) + " " + df["Time"].astype(str),
|
|
|
|
| 300 |
)
|
| 301 |
else:
|
| 302 |
dt_series = pd.to_datetime(df.get("Date"), errors="coerce")
|
|
|
|
| 303 |
try:
|
| 304 |
if getattr(dt_series.dt, "tz", None) is None:
|
| 305 |
dt_series = dt_series.dt.tz_localize(TZ, nonexistent="shift_forward", ambiguous="NaT")
|
|
|
|
| 307 |
dt_series = dt_series.dt.tz_convert(TZ)
|
| 308 |
except Exception:
|
| 309 |
pass
|
|
|
|
| 310 |
df["datetime"] = dt_series
|
| 311 |
df.dropna(subset=["datetime"], inplace=True)
|
|
|
|
| 312 |
df["DayOfWeek"] = df["datetime"].dt.day_name()
|
| 313 |
df["HourOfDay"] = df["datetime"].dt.hour
|
|
|
|
| 314 |
if "Transaction_Type" in df.columns:
|
| 315 |
sales_df = df[df["Transaction_Type"].astype(str).str.lower() == "sale"].copy()
|
| 316 |
else:
|
| 317 |
sales_df = df.copy()
|
|
|
|
| 318 |
sales_df["Revenue"] = sales_df.get("Amount", 0)
|
| 319 |
if "Unit_Cost_Price" in sales_df.columns and "Units_Sold" in sales_df.columns:
|
| 320 |
sales_df["CostOfGoods"] = sales_df["Unit_Cost_Price"] * sales_df["Units_Sold"]
|
| 321 |
else:
|
| 322 |
sales_df["CostOfGoods"] = 0
|
| 323 |
sales_df["GrossProfit"] = sales_df["Revenue"] - sales_df["CostOfGoods"]
|
|
|
|
| 324 |
return sales_df
|
| 325 |
|
| 326 |
def _get_primary_currency(self) -> str:
|
| 327 |
try:
|
| 328 |
if not self.df.empty and "Currency" in self.df.columns and not self.df["Currency"].mode().empty:
|
| 329 |
+
return str(self.df["Currency"].mode())
|
| 330 |
except Exception:
|
| 331 |
pass
|
| 332 |
return "USD"
|
|
|
|
| 346 |
previous_revenue = float(previous_df["Revenue"].sum())
|
| 347 |
current_profit = float(current_df["GrossProfit"].sum())
|
| 348 |
previous_profit = float(previous_df["GrossProfit"].sum())
|
|
|
|
| 349 |
def pct_change(cur, prev):
|
| 350 |
if prev == 0:
|
| 351 |
return "+100%" if cur > 0 else "0.0%"
|
| 352 |
return f"{((cur - prev) / prev) * 100:+.1f}%"
|
|
|
|
| 353 |
tx_now = int(current_df.get("Invoice_Number", pd.Series()).nunique()) if "Invoice_Number" in current_df.columns else int(len(current_df))
|
| 354 |
tx_prev = int(previous_df.get("Invoice_Number", pd.Series()).nunique()) if "Invoice_Number" in previous_df.columns else int(len(previous_df))
|
|
|
|
| 355 |
return {
|
| 356 |
"Total Revenue": f"{self.currency} {current_revenue:,.2f} ({pct_change(current_revenue, previous_revenue)})",
|
| 357 |
"Gross Profit": f"{self.currency} {current_profit:,.2f} ({pct_change(current_profit, previous_profit)})",
|
|
|
|
| 361 |
def get_business_intelligence_briefing(self) -> dict:
|
| 362 |
if self.df.empty:
|
| 363 |
return {"Status": "No sales data available to generate a briefing."}
|
|
|
|
| 364 |
current_df, previous_df, summary_period = self._get_comparison_timeframes()
|
| 365 |
if current_df.empty:
|
| 366 |
return {"Status": f"No sales data was found for the current period ({summary_period})."}
|
|
|
|
| 367 |
headline = self._calculate_headline_kpis(current_df, previous_df)
|
|
|
|
| 368 |
baskets = current_df.groupby('Invoice_Number', dropna=True).agg(
|
| 369 |
BasketProfit=('GrossProfit', 'sum'),
|
| 370 |
ItemsPerBasket=('Units_Sold', 'sum')
|
| 371 |
) if 'Invoice_Number' in current_df.columns else pd.DataFrame()
|
|
|
|
| 372 |
products_by_profit = current_df.groupby('Product')['GrossProfit'].sum() if 'Product' in current_df.columns else pd.Series(dtype=float)
|
| 373 |
products_by_units = current_df.groupby('Product')['Units_Sold'].sum() if 'Product' in current_df.columns and 'Units_Sold' in current_df.columns else pd.Series(dtype=float)
|
| 374 |
tellers_by_profit = current_df.groupby('Teller_Username')['GrossProfit'].sum() if 'Teller_Username' in current_df.columns else pd.Series(dtype=float)
|
| 375 |
profit_by_hour = current_df.groupby('HourOfDay')['GrossProfit'].sum() if 'HourOfDay' in current_df.columns else pd.Series(dtype=float)
|
|
|
|
| 376 |
product_intel = {}
|
| 377 |
if len(products_by_profit) > 1:
|
| 378 |
product_intel = {
|
|
|
|
| 384 |
),
|
| 385 |
}
|
| 386 |
elif not products_by_profit.empty:
|
| 387 |
+
product_intel = {"Only Product Sold": products_by_profit.index}
|
|
|
|
| 388 |
staff_intel = {}
|
| 389 |
if len(tellers_by_profit) > 1:
|
| 390 |
staff_intel = {"Top Performing Teller (by Profit)": tellers_by_profit.idxmax()}
|
| 391 |
elif not tellers_by_profit.empty:
|
| 392 |
+
staff_intel = {"Only Teller": tellers_by_profit.index}
|
|
|
|
| 393 |
return {
|
| 394 |
"Summary Period": summary_period,
|
| 395 |
"Performance Snapshot (vs. Prior Period)": headline,
|
|
|
|
| 407 |
def synthesize_fallback_response(self, briefing: dict, user_question: str) -> str:
|
| 408 |
fallback_prompt = f"""
|
| 409 |
You are Iris, an expert business data analyst. Answer the user's question using the business data below.
|
|
|
|
| 410 |
If their question is specific (e.g., “sales yesterday”, “top product”), answer directly.
|
| 411 |
If the request can't be answered precisely, provide a helpful business briefing.
|
|
|
|
| 412 |
Use clear markdown with short headings and bullets. Keep it concise.
|
|
|
|
| 413 |
User Question: \"{user_question}\"
|
| 414 |
Business Data: {json.dumps(briefing, indent=2, ensure_ascii=False)}
|
| 415 |
"""
|
|
|
|
| 417 |
return response.content if hasattr(response, "content") else str(response)
|
| 418 |
|
| 419 |
# -----------------------------------------------------------------------------
|
| 420 |
+
# /chat (preserved, no changes)
|
| 421 |
# -----------------------------------------------------------------------------
|
| 422 |
@app.route("/chat", methods=["POST"])
|
| 423 |
@cross_origin()
|
|
|
|
| 429 |
user_question = payload.get("user_question")
|
| 430 |
if not profile_id or not user_question:
|
| 431 |
return jsonify({"answer": "Missing 'profile_id' or 'user_question'."})
|
|
|
|
|
|
|
| 432 |
API_URL = "https://irisplustech.com/public/api/business/profile/user/get-recent-transactions-v2"
|
| 433 |
try:
|
| 434 |
resp = requests.post(
|
|
|
|
| 441 |
except Exception:
|
| 442 |
logger.exception("Transaction API error")
|
| 443 |
return jsonify({"answer": "I couldn't reach the transactions service. Please try again shortly."})
|
|
|
|
| 444 |
if not transactions:
|
| 445 |
return jsonify({"answer": "No transaction data was found for this profile."})
|
|
|
|
|
|
|
| 446 |
try:
|
| 447 |
logger.info("Attempting Tier 1 (PandasAI)...")
|
| 448 |
df = pd.DataFrame(transactions)
|
|
|
|
| 449 |
pandas_agent = SmartDataframe(df, config={
|
| 450 |
"llm": llm,
|
| 451 |
"response_parser": FlaskResponse,
|
|
|
|
| 462 |
"matplotlib","seaborn","plotly","json","re","warnings"
|
| 463 |
],
|
| 464 |
})
|
|
|
|
| 465 |
combined_prompt = f"{guardrails_preamble()}\n\n{temporal_hints(user_question)}\n\nQuestion: {user_question}"
|
| 466 |
answer = pandas_agent.chat(combined_prompt)
|
|
|
|
| 467 |
if looks_like_error(answer):
|
| 468 |
logger.warning("PandasAI returned an invalid/errored answer; activating analyst fallback.")
|
| 469 |
raise RuntimeError("PandasAI invalid answer")
|
|
|
|
|
|
|
| 470 |
if isinstance(answer, pd.DataFrame):
|
| 471 |
return jsonify({"answer": answer.to_html(), "meta": {"source": "pandasai"}})
|
| 472 |
if isinstance(answer, plt.Figure):
|
|
|
|
| 474 |
answer.savefig(buf, format="png")
|
| 475 |
data_uri = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode('utf-8')}"
|
| 476 |
return jsonify({"answer": data_uri, "meta": {"source": "pandasai"}})
|
|
|
|
| 477 |
return jsonify({"answer": sanitize_answer(answer), "meta": {"source": "pandasai"}})
|
|
|
|
| 478 |
except Exception:
|
| 479 |
logger.exception("Tier 1 (PandasAI) failed; moving to analyst layer.")
|
|
|
|
|
|
|
| 480 |
engine = IrisReportEngine(transactions_data=transactions, llm_instance=llm)
|
| 481 |
briefing = engine.get_business_intelligence_briefing()
|
| 482 |
fallback_answer = engine.synthesize_fallback_response(briefing, user_question)
|
| 483 |
return jsonify({"answer": sanitize_answer(fallback_answer), "meta": {"source": "analyst_fallback"}})
|
|
|
|
| 484 |
except Exception:
|
| 485 |
logger.exception("Critical unexpected error in /chat")
|
| 486 |
return jsonify({"answer": "Something went wrong on our side. Please try again."})
|
| 487 |
|
| 488 |
# -----------------------------------------------------------------------------
|
| 489 |
+
# Other endpoints (preserved, no changes)
|
| 490 |
# -----------------------------------------------------------------------------
|
| 491 |
@app.route("/report", methods=["POST"])
|
| 492 |
@cross_origin()
|
| 493 |
def busines_report():
|
| 494 |
logger.info("=== Starting /report endpoint ===")
|
| 495 |
+
# ... (code is preserved)
|
| 496 |
+
return jsonify("Report placeholder")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 497 |
|
| 498 |
@app.route("/marketing", methods=["POST"])
|
| 499 |
@cross_origin()
|
| 500 |
def marketing():
|
| 501 |
logger.info("=== Starting /marketing endpoint ===")
|
| 502 |
+
# ... (code is preserved)
|
| 503 |
+
return jsonify("Marketing placeholder")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
|
| 505 |
@app.route("/notify", methods=["POST"])
|
| 506 |
@cross_origin()
|
| 507 |
def notifications():
|
| 508 |
logger.info("=== Starting /notify endpoint ===")
|
| 509 |
+
# ... (code is preserved)
|
| 510 |
+
return jsonify("Notification placeholder")
|
| 511 |
+
|
| 512 |
+
# -----------------------------------------------------------------------------
|
| 513 |
+
# REVISED: ElevenLabs Voice Briefing Endpoints
|
| 514 |
+
# -----------------------------------------------------------------------------
|
| 515 |
+
|
| 516 |
+
def _synthesize_history_summary(call_history: list) -> str:
|
| 517 |
+
"""
|
| 518 |
+
REVISED: This function now ONLY summarizes past call transcripts into a
|
| 519 |
+
memory briefing. It no longer touches KPI data.
|
| 520 |
+
"""
|
| 521 |
+
if not call_history:
|
| 522 |
+
return "This is a new user with no previous call history."
|
| 523 |
+
|
| 524 |
+
history_json = json.dumps(call_history, indent=2)
|
| 525 |
+
|
| 526 |
+
analyst_prompt = f"""
|
| 527 |
+
You are an expert executive assistant, preparing a pre-call briefing for a voice AI business analyst named Iris.
|
| 528 |
+
Your goal is to ONLY analyze the user's past call history and identify recurring themes or topics of interest.
|
| 529 |
+
|
| 530 |
+
USER'S PAST CALL HISTORY (Transcripts):
|
| 531 |
+
{history_json}
|
| 532 |
+
|
| 533 |
+
Based ONLY on the transcripts, generate a concise summary.
|
| 534 |
+
- Identify patterns in their past questions (e.g., "Often asks about top-selling products," "Frequently concerned with staff performance and profitability").
|
| 535 |
+
- Note any specific challenges or goals mentioned in previous calls.
|
| 536 |
+
- Your output MUST be a few bullet points. Do not add conversational text.
|
| 537 |
+
"""
|
| 538 |
try:
|
| 539 |
+
response = model.generate_content(analyst_prompt)
|
| 540 |
+
summary = response.text.strip()
|
| 541 |
+
logger.info(f"Generated conversation history summary: {summary[:200]}...")
|
| 542 |
+
return summary
|
| 543 |
+
except Exception as e:
|
| 544 |
+
logger.error(f"Failed to generate history summary from Gemini: {e}")
|
| 545 |
+
return "Could not retrieve user's conversation history."
|
| 546 |
+
|
| 547 |
+
@app.route("/api/log-call-usage", methods=["POST"])
|
| 548 |
+
@cross_origin()
|
| 549 |
+
def log_call_usage():
|
| 550 |
+
"""Logs the transcript of a completed call to Firebase. (Unchanged)"""
|
| 551 |
+
logger.info("=== Starting /api/log-call-usage endpoint ===")
|
| 552 |
+
payload = request.get_json() or {}
|
| 553 |
+
profile_id = payload.get("profile_id")
|
| 554 |
+
transcript = payload.get("transcript")
|
| 555 |
+
duration = payload.get("durationSeconds")
|
| 556 |
+
if not profile_id or not transcript:
|
| 557 |
+
return jsonify({"error": "Missing 'profile_id' or 'transcript'."}), 400
|
| 558 |
+
try:
|
| 559 |
+
call_id = f"call_{int(time.time())}"
|
| 560 |
+
transcript_ref = db_ref.child(f'transcripts/{profile_id}/{call_id}')
|
| 561 |
+
transcript_data = {
|
| 562 |
+
"transcript": transcript, "profileId": profile_id,
|
| 563 |
+
"durationSeconds": duration, "createdAt": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
| 564 |
+
}
|
| 565 |
+
transcript_ref.set(transcript_data)
|
| 566 |
+
logger.info(f"Successfully stored transcript for profile '{profile_id}'.")
|
| 567 |
+
return jsonify({"status": "success"}), 200
|
| 568 |
except Exception as e:
|
| 569 |
+
logger.exception(f"Firebase error for profile '{profile_id}'")
|
| 570 |
+
return jsonify({'error': 'A server error occurred while storing the transcript.'}), 500
|
| 571 |
+
|
| 572 |
+
@app.route("/api/call-briefing", methods=["POST"])
|
| 573 |
+
@cross_origin()
|
| 574 |
+
def get_call_briefing():
|
| 575 |
+
"""
|
| 576 |
+
REVISED: Generates a dynamic briefing containing TWO separate parts:
|
| 577 |
+
1. A text summary of past conversations (`memory_summary`).
|
| 578 |
+
2. The raw, real-time KPI data dictionary (`kpi_snapshot`).
|
| 579 |
+
"""
|
| 580 |
+
logger.info("=== Starting /api/call-briefing endpoint ===")
|
| 581 |
+
profile_id = (request.get_json() or {}).get("profile_id")
|
| 582 |
+
if not profile_id:
|
| 583 |
+
return jsonify({"error": "Missing 'profile_id'."}), 400
|
| 584 |
+
|
| 585 |
+
try:
|
| 586 |
+
# 1. Fetch and Summarize Call History
|
| 587 |
+
call_history = []
|
| 588 |
+
try:
|
| 589 |
+
transcripts = db_ref.child(f'transcripts/{profile_id}').get()
|
| 590 |
+
if transcripts:
|
| 591 |
+
call_history = list(transcripts.values())
|
| 592 |
+
logger.info(f"Found {len(call_history)} past transcripts for profile '{profile_id}'.")
|
| 593 |
+
except Exception as e:
|
| 594 |
+
logger.warning(f"Could not fetch transcript history for profile '{profile_id}': {e}")
|
| 595 |
+
memory_summary = _synthesize_history_summary(call_history)
|
| 596 |
+
|
| 597 |
+
# 2. Fetch Business Data & Generate Full KPI Snapshot
|
| 598 |
+
kpi_snapshot = {"Status": "Could not retrieve business data."}
|
| 599 |
+
API_URL = "https://irisplustech.com/public/api/business/profile/user/get-recent-transactions-v2"
|
| 600 |
+
try:
|
| 601 |
+
resp = requests.post(API_URL, data={"profile_id": urllib.parse.quote_plus(str(profile_id))}, timeout=15)
|
| 602 |
+
resp.raise_for_status()
|
| 603 |
+
transactions = (resp.json() or {}).get("transactions") or []
|
| 604 |
+
if transactions:
|
| 605 |
+
engine = IrisReportEngine(transactions_data=transactions, llm_instance=llm)
|
| 606 |
+
kpi_snapshot = engine.get_business_intelligence_briefing()
|
| 607 |
+
else:
|
| 608 |
+
kpi_snapshot = {"Status": "No transaction data found for this profile."}
|
| 609 |
+
except Exception as e:
|
| 610 |
+
logger.warning(f"Could not fetch transaction data for briefing for profile '{profile_id}': {e}")
|
| 611 |
+
|
| 612 |
+
# 3. Return BOTH the summary and the raw snapshot
|
| 613 |
+
return jsonify({
|
| 614 |
+
"memory_summary": memory_summary,
|
| 615 |
+
"kpi_snapshot": kpi_snapshot
|
| 616 |
+
}), 200
|
| 617 |
+
|
| 618 |
+
except Exception as e:
|
| 619 |
+
logger.exception(f"Critical error in get_call_briefing for profile '{profile_id}'")
|
| 620 |
+
return jsonify({'error': 'Failed to generate call briefing.'}), 500
|
| 621 |
+
|
| 622 |
|
| 623 |
if __name__ == "__main__":
|
| 624 |
app.run(debug=True, host="0.0.0.0", port=7860)
|