rairo commited on
Commit
90ac18d
·
verified ·
1 Parent(s): 9899cf5

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +303 -152
main.py CHANGED
@@ -4,8 +4,7 @@
4
  # - Deterministic time windows (Harare); explicit start/end on API calls
5
  # - KPI engine never uses LLM for numbers (LLM is narration-only fallback)
6
  # - JSON-safe snapshot; deep DEBUG logs (optional mirror to Firebase)
7
- # - Drop-in Firebase + AI wiring identical in spirit to your prior server
8
-
9
  from __future__ import annotations
10
 
11
  import os, io, re, json, time, uuid, base64, logging
@@ -49,7 +48,6 @@ try:
49
  credentials_json_string = os.environ.get("FIREBASE")
50
  if not credentials_json_string:
51
  raise ValueError("FIREBASE env var is not set")
52
-
53
  credentials_json = json.loads(credentials_json_string)
54
  firebase_db_url = os.environ.get("Firebase_DB")
55
  if not firebase_db_url:
@@ -113,16 +111,11 @@ logger.info(f"Chart export path set to: {user_defined_path}")
113
  # -----------------------------------------------------------------------------
114
  # Admin API client (client-supplied credentials; holistic admin scope)
115
  # -----------------------------------------------------------------------------
116
- # --- Auth + Request client (robust: bearer OR cookie session) ---
117
-
118
- import requests
119
- from typing import Dict, Optional
120
-
121
  SC_BASE_URL = os.getenv("SC_BASE_URL", "https://delta-api.pricelyst.co.zw").rstrip("/")
122
 
123
  class SCAuth:
124
  """Caches a requests.Session per admin email; supports bearer or cookie sessions."""
125
- _cache: Dict[str, Dict[str, any]] = {}
126
 
127
  @classmethod
128
  def invalidate(cls, email: str) -> None:
@@ -137,7 +130,6 @@ class SCAuth:
137
  def _extract_token(cls, js: dict) -> Optional[str]:
138
  if not isinstance(js, dict):
139
  return None
140
- # Try common token shapes
141
  candidates = [
142
  js.get("token"),
143
  js.get("access_token"),
@@ -152,42 +144,38 @@ class SCAuth:
152
  return None
153
 
154
  @classmethod
155
- def login(cls, email: str, password: str) -> Dict[str, any]:
156
  s = requests.Session()
157
  s.headers.update({"Accept": "application/json"})
158
  url = f"{SC_BASE_URL}/api/auth/admin/login"
159
- # IMPORTANT: many APIs expect JSON, not form data
160
  resp = s.post(url, json={"email": email, "password": password}, timeout=30)
161
 
162
- # Try JSON parse (may fail if empty body)
163
- body_text = ""
164
- body_json = {}
165
  try:
166
  body_json = resp.json() or {}
167
  except Exception:
168
- body_text = (resp.text or "")[:800] # keep it short for logs
169
-
170
  token = cls._extract_token(body_json)
171
 
172
  if token:
173
  s.headers.update({"Authorization": f"Bearer {token}"})
174
  entry = {"session": s, "auth": "bearer", "token": token}
175
  cls._cache[email] = entry
 
176
  return entry
177
 
178
- # If no token but we got Set-Cookie, assume cookie session auth
179
  if resp.cookies and (resp.status_code // 100) == 2:
180
  entry = {"session": s, "auth": "cookie"}
181
  cls._cache[email] = entry
 
182
  return entry
183
 
184
- # Neither token nor cookie: raise, but include short body for diagnosis
185
  snippet = body_text or (str(body_json)[:800])
186
  raise RuntimeError(f"Login did not return a token or cookie session. HTTP {resp.status_code}. Body≈ {snippet}")
187
 
188
  def sc_request(method: str, path: str, email: str, password: str, *,
189
  params: dict = None, json_body: dict = None, timeout: int = 30):
190
- """Authenticated request with 401 auto-refresh (once)."""
191
  if not path.startswith("/"):
192
  path = "/" + path
193
  url = f"{SC_BASE_URL}{path}"
@@ -195,7 +183,6 @@ def sc_request(method: str, path: str, email: str, password: str, *,
195
  def _do(s: requests.Session):
196
  return s.request(method.upper(), url, params=params, json=json_body, timeout=timeout)
197
 
198
- # get or create session
199
  entry = SCAuth._cache.get(email)
200
  if not entry:
201
  entry = SCAuth.login(email, password)
@@ -203,77 +190,83 @@ def sc_request(method: str, path: str, email: str, password: str, *,
203
 
204
  resp = _do(s)
205
  if resp.status_code == 401:
206
- # refresh & retry once
207
  SCAuth.invalidate(email)
208
  entry = SCAuth.login(email, password)
209
  s = entry["session"]
210
  resp = _do(s)
211
 
212
- # Raise for other errors
213
  try:
214
  resp.raise_for_status()
215
  except Exception as e:
216
- # include small snippet to aid debugging
217
  snippet = (getattr(resp, "text", "") or "")[:800]
218
  raise RuntimeError(f"SC request error {method.upper()} {path}: HTTP {resp.status_code} – {snippet}") from e
219
 
220
- # Return JSON or text as appropriate
221
  try:
222
- return resp.json()
223
  except Exception:
224
- return resp.text
225
- # -----------------------------------------------------------------------------
226
- # Temporal helpers
227
- # -----------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  # -----------------------------------------------------------------------------
229
- # Timezone Configuration (fix for "_TZ is not defined")
230
  # -----------------------------------------------------------------------------
231
- import os
232
- import pandas as pd
233
-
234
- # Canonical timezone for Brave Retail Insights
235
  TZ = os.getenv("APP_TZ", "Africa/Harare")
236
-
237
- # Backward-compatible alias for older references
238
- _TZ = TZ
239
 
240
  def now_harare() -> pd.Timestamp:
241
- """Return the current timestamp in Harare timezone."""
242
  return pd.Timestamp.now(tz=TZ)
243
 
244
- def week_bounds_from(ts: pd.Timestamp) -> tuple[pd.Timestamp, pd.Timestamp]:
245
- """Return Monday–Sunday bounds for the given timestamp."""
246
  monday = ts.tz_convert(TZ).normalize() - pd.Timedelta(days=ts.weekday())
247
  sunday = monday + pd.Timedelta(days=6, hours=23, minutes=59, seconds=59)
248
  return monday, sunday
249
 
250
- def this_month_bounds(ts: pd.Timestamp) -> tuple[pd.Timestamp, pd.Timestamp]:
251
- """Return start and end of the current month."""
252
  first_this = ts.normalize().replace(day=1)
253
- next_month = first_this.replace(
254
- year=first_this.year + 1, month=1
255
- ) if first_this.month == 12 else first_this.replace(month=first_this.month + 1)
256
- last_this = next_month - pd.Timedelta(seconds=1)
 
257
  return first_this, last_this
258
-
259
 
260
  def period_to_bounds(period: str) -> Tuple[pd.Timestamp, pd.Timestamp, str]:
261
  p = (period or "week").strip().lower()
262
  now = now_harare()
263
  if p == "today":
264
  start = now.normalize()
265
- end = start + pd.Timedelta(hours=23, minutes=59, seconds=59)
266
- lbl = "Today"
267
  elif p in ("week", "this_week"):
268
- start, end = week_bounds_from(now)
269
- lbl = "This Week"
270
  elif p in ("month", "this_month"):
271
- start, end = this_month_bounds(now)
272
- lbl = "This Month"
273
  elif p in ("year", "this_year"):
274
  start = now.normalize().replace(month=1, day=1, hour=0, minute=0, second=0)
275
- end = now.normalize().replace(month=12, day=31, hour=23, minute=59, second=59)
276
- lbl = "This Year"
277
  else:
278
  start, end = week_bounds_from(now); lbl = "This Week"
279
  return start, end, lbl
@@ -338,32 +331,20 @@ def sanitize_answer(ans) -> str:
338
  if tb in s: s = s.split(tb, 1)[0]
339
  return (s or "").strip()
340
 
341
-
342
-
343
- # -------------------- Robust normalizers (paste once, near your engine class) --------------------
344
- from typing import Any, Iterable, List, Dict, Optional
345
- import json
346
- import pandas as pd
347
- import numpy as np
348
-
349
  def _to_list(x: Any) -> List[Any]:
350
- """Return x as a list. Accept list/dict/JSON-string/None gracefully."""
351
- if x is None:
352
- return []
353
- if isinstance(x, list):
354
- return x
355
- if isinstance(x, dict):
356
- return [x]
357
  if isinstance(x, str):
358
- # try to parse JSON strings like '[{...}]' or '{"a":1}'
359
  try:
360
  j = json.loads(x)
361
- if isinstance(j, list):
362
- return j
363
- if isinstance(j, dict):
364
- return [j]
365
  except Exception:
366
- return [x] # treat as scalar string row (will be skipped later)
367
  return [x]
368
 
369
  def _to_float(x: Any) -> Optional[float]:
@@ -382,13 +363,10 @@ def _to_int(x: Any) -> Optional[int]:
382
  return None
383
 
384
  def _coerce_date(s: Any) -> Optional[pd.Timestamp]:
385
- if s is None:
386
- return None
387
  try:
388
  dt = pd.to_datetime(s, errors="coerce")
389
- if pd.isna(dt):
390
- return None
391
- # align to Harare if naive; respects your global TZ
392
  try:
393
  return dt.tz_localize(TZ, nonexistent="shift_forward", ambiguous="NaT")
394
  except Exception:
@@ -396,10 +374,186 @@ def _coerce_date(s: Any) -> Optional[pd.Timestamp]:
396
  except Exception:
397
  return None
398
 
399
- # -------------------- Hardened sales series (drop-in replacement) --------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
 
401
  # -----------------------------------------------------------------------------
402
- # Admin KPI Engine (holistic view)
403
  # -----------------------------------------------------------------------------
404
  class AdminAnalyticsEngine:
405
  """Single-tenant holistic admin analytics. No shop/brand filters; admin sees entire dataset."""
@@ -410,44 +564,27 @@ class AdminAnalyticsEngine:
410
  self.period = (period or "week").lower().strip()
411
  self.t_start, self.t_end, self.period_label = period_to_bounds(self.period)
412
 
413
- # -------------------- helpers --------------------
414
  @staticmethod
415
  def _unwrap_data(payload: dict) -> dict:
416
  if isinstance(payload, dict):
417
- # common backend pattern: {"status":"success","data":{...}}
418
  return payload.get("data") if isinstance(payload.get("data"), dict) else payload
419
  return {}
420
 
421
- # -------------------- API pulls (no shop/brand params at all) --------------------
422
  def _dashboard(self) -> dict:
423
- raw = sc_request(
424
- "GET",
425
- "/api/analytics/dashboard",
426
- self.email,
427
- self.password,
428
- params={"period": self.period},
429
- )
430
  data = self._unwrap_data(raw)
431
  emit_kpi_debug(self.tenant_key, "dashboard", data or raw or {})
 
 
432
  return data or {}
433
 
434
  def _sales_series(self) -> pd.DataFrame:
435
- """
436
- Fetches /api/analytics/sales and returns a tidy daily series:
437
- columns: ['_date','total_sales','total_orders','aov']
438
- - Robust to strings/dicts/mixed payloads
439
- - Converts numeric strings to floats/ints
440
- - Skips malformed rows instead of crashing
441
- """
442
  params = {
443
  "start_date": self.t_start.strftime("%Y-%m-%d"),
444
  "end_date": self.t_end.strftime("%Y-%m-%d"),
445
  "group_by": "day",
446
  }
447
  raw = sc_request("GET", "/api/analytics/sales", self.email, self.password, params=params)
448
-
449
- # Expect raw like:
450
- # {"status":"success","data":{"sales_over_time":[{"date":"YYYY-MM-DD","total_sales":"141.11","total_orders":3}], ...}}
451
  data = {}
452
  if isinstance(raw, dict):
453
  data = (raw.get("data") or raw) if isinstance(raw.get("data"), (dict, list)) else raw
@@ -458,78 +595,105 @@ class AdminAnalyticsEngine:
458
  except Exception:
459
  data = {}
460
 
 
 
 
 
 
 
 
 
 
 
461
  series = []
462
- sales_ot = data.get("sales_over_time")
463
- for r in _to_list(sales_ot):
464
  if not isinstance(r, dict):
465
  continue
466
  date_str = r.get("date") or r.get("day") or r.get("period")
467
  dt = _coerce_date(date_str)
468
  if dt is None:
469
  continue
470
-
471
  total_sales = _to_float(r.get("total_sales") or r.get("total") or r.get("revenue"))
472
  total_orders = _to_int(r.get("total_orders") or r.get("orders") or r.get("count"))
473
  aov = _to_float(r.get("average_order_value") or r.get("aov"))
474
-
475
  if aov is None and total_sales is not None and (total_orders or 0) > 0:
476
  aov = float(total_sales) / int(total_orders)
477
-
478
  series.append({
479
  "_date": dt,
480
  "total_sales": float(total_sales) if total_sales is not None else 0.0,
481
  "total_orders": int(total_orders) if total_orders is not None else 0,
482
  "aov": float(aov) if aov is not None else None,
483
  })
484
-
485
  df = pd.DataFrame(series)
486
  if df.empty:
487
  return pd.DataFrame(columns=["_date", "total_sales", "total_orders", "aov"])
488
-
489
  df = df.sort_values("_date").reset_index(drop=True)
490
  emit_kpi_debug(self.tenant_key, "sales_series_raw", (raw if isinstance(raw, dict) else {"raw": raw}))
 
 
 
 
 
 
 
 
 
 
 
491
  return df
492
 
493
  def _products(self) -> dict:
494
  raw = sc_request(
495
- "GET",
496
- "/api/analytics/products",
497
- self.email,
498
- self.password,
499
- params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")},
500
  )
501
  data = self._unwrap_data(raw)
502
  emit_kpi_debug(self.tenant_key, "products", data or raw or {})
 
 
 
 
503
  return data or {}
504
 
505
  def _customers(self) -> dict:
506
  raw = sc_request(
507
- "GET",
508
- "/api/analytics/customers",
509
- self.email,
510
- self.password,
511
- params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")},
512
  )
513
  data = self._unwrap_data(raw)
514
  emit_kpi_debug(self.tenant_key, "customers", data or raw or {})
 
 
 
 
 
 
 
 
515
  return data or {}
516
 
517
  def _inventory(self) -> dict:
518
  raw = sc_request("GET", "/api/analytics/inventory", self.email, self.password)
519
  data = self._unwrap_data(raw)
520
  emit_kpi_debug(self.tenant_key, "inventory", data or raw or {})
 
 
 
 
 
521
  return data or {}
522
 
523
  def _comparisons(self) -> dict:
524
  raw = sc_request(
525
- "GET",
526
- "/api/analytics/comparisons",
527
- self.email,
528
- self.password,
529
- params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")},
530
  )
531
  data = self._unwrap_data(raw)
532
  emit_kpi_debug(self.tenant_key, "comparisons", data or raw or {})
 
 
 
 
533
  return data or {}
534
 
535
  # -------------------- deterministic snapshot --------------------
@@ -541,8 +705,6 @@ class AdminAnalyticsEngine:
541
  inv = self._inventory()
542
  comps = self._comparisons()
543
 
544
- # Dashboard numbers can be at the top level or under 'metrics' etc.
545
- # We’ll try a few keys before falling back to sums from sales_df.
546
  def _get_num(d: dict, *keys, default=0.0):
547
  for k in keys:
548
  v = d.get(k)
@@ -558,10 +720,8 @@ class AdminAnalyticsEngine:
558
  transactions = int(_get_num(dash, "transactions", "orders", default=0.0))
559
 
560
  if (total_revenue == 0.0 or transactions == 0) and isinstance(sales_df, pd.DataFrame) and not sales_df.empty:
561
- # Fallback: use the sales series we just fetched
562
  total_revenue = float(sales_df["total_sales"].sum())
563
  transactions = int(sales_df["total_orders"].sum())
564
- # We don't have GP in this endpoint; leave as 0.0 if not provided by dashboard
565
 
566
  product_lb = {
567
  "top_by_revenue": prods.get("top_by_revenue") or prods.get("topRevenue") or [],
@@ -622,32 +782,21 @@ class AdminAnalyticsEngine:
622
  return json_safe(snapshot)
623
 
624
  def _temporal_patterns_from_sales(self, df: pd.DataFrame) -> Dict[str, Any]:
625
- """
626
- Build simple temporal readouts from the sales series returned by _sales_series():
627
- - series: [{date, total_sales, total_orders, aov}]
628
- - best_day_by_sales: highest total_sales by weekday
629
- """
630
  if df is None or df.empty:
631
  return {"series": [], "best_day_by_sales": None}
632
-
633
  d = df.copy()
634
- # Derive DOW and safe date strings
635
  d["dow"] = d["_date"].dt.day_name()
636
  d["date"] = d["_date"].dt.strftime("%Y-%m-%d")
637
-
638
- # Aggregate by DOW using total_sales as revenue proxy
639
  g = d.groupby("dow", dropna=False).agg(
640
  total_sales=("total_sales", "sum"),
641
  total_orders=("total_orders", "sum"),
642
  ).reset_index()
643
-
644
  best_row = None if g.empty else g.loc[g["total_sales"].idxmax()]
645
  best_day = None if g.empty else {
646
  "day": str(best_row["dow"]),
647
  "total_sales": float(best_row["total_sales"]),
648
  "total_orders": int(best_row["total_orders"]),
649
  }
650
-
651
  series = d[["date", "total_sales", "total_orders", "aov"]].to_dict(orient="records")
652
  return {"series": series, "best_day_by_sales": best_day}
653
 
@@ -664,6 +813,7 @@ class AdminAnalyticsEngine:
664
  return sanitize_answer(text)
665
  except Exception:
666
  return "### Business Snapshot\n\n```\n" + json.dumps(json_safe(snapshot), indent=2) + "\n```"
 
667
  # -----------------------------------------------------------------------------
668
  # /chat — PandasAI first on sales series, else deterministic snapshot + narration
669
  # -----------------------------------------------------------------------------
@@ -686,15 +836,22 @@ def chat():
686
  return jsonify({"error": "Missing 'email' or 'password'."}), 400
687
 
688
  engine = AdminAnalyticsEngine(tenant_key, email, password, period)
 
 
 
 
 
689
  sales_df = engine._sales_series()
690
- if sales_df.empty:
691
  snapshot = engine.build_snapshot()
692
  answer = engine.narrate(snapshot, user_question)
693
  return jsonify({"answer": sanitize_answer(answer), "meta": {"source": "analyst_fallback"}})
694
 
695
  try:
696
  logger.info(f"[{rid}] PandasAI attempt …")
697
- pandas_agent = SmartDataframe(sales_df, config={
 
 
698
  "llm": llm,
699
  "response_parser": FlaskResponse,
700
  "security": "none",
@@ -726,7 +883,6 @@ def chat():
726
  return jsonify({"answer": data_uri, "meta": {"source": "pandasai"}})
727
 
728
  return jsonify({"answer": sanitize_answer(answer), "meta": {"source": "pandasai"}})
729
-
730
  except Exception:
731
  snapshot = engine.build_snapshot()
732
  answer = engine.narrate(snapshot, user_question)
@@ -747,8 +903,7 @@ def report():
747
  payload = request.get_json() or {}
748
  tenant_key = str(payload.get("tenant_key") or "admin")
749
  period = (payload.get("period") or "week").strip().lower()
750
- email = payload.get("email")
751
- password = payload.get("password")
752
  if not email or not password:
753
  return jsonify({"error": "Missing 'email' or 'password'."}), 400
754
 
@@ -773,8 +928,7 @@ def marketing():
773
  payload = request.get_json() or {}
774
  tenant_key = str(payload.get("tenant_key") or "admin")
775
  period = (payload.get("period") or "week").strip().lower()
776
- email = payload.get("email")
777
- password = payload.get("password")
778
  if not email or not password:
779
  return jsonify({"error": "Missing 'email' or 'password'."}), 400
780
 
@@ -799,8 +953,7 @@ def notify():
799
  payload = request.get_json() or {}
800
  tenant_key = str(payload.get("tenant_key") or "admin")
801
  period = (payload.get("period") or "week").strip().lower()
802
- email = payload.get("email")
803
- password = payload.get("password")
804
  if not email or not password:
805
  return jsonify({"error": "Missing 'email' or 'password'."}), 400
806
 
@@ -871,7 +1024,6 @@ def get_call_briefing():
871
  if not email or not password:
872
  return jsonify({"error": "Missing 'email' or 'password'."}), 400
873
  try:
874
- # 1) Summarize call history
875
  call_history = []
876
  try:
877
  transcripts = db_ref.child(f"transcripts/{profile_id}").get()
@@ -880,7 +1032,6 @@ def get_call_briefing():
880
  logger.warning(f"Transcript fetch failed for '{profile_id}': {e}")
881
  memory_summary = _synthesize_history_summary(call_history)
882
 
883
- # 2) Admin holistic KPI snapshot
884
  engine = AdminAnalyticsEngine(profile_id or "admin", email, password, period)
885
  kpi_snapshot = engine.build_snapshot()
886
 
 
4
  # - Deterministic time windows (Harare); explicit start/end on API calls
5
  # - KPI engine never uses LLM for numbers (LLM is narration-only fallback)
6
  # - JSON-safe snapshot; deep DEBUG logs (optional mirror to Firebase)
7
+ # - Drop-in Firebase + AI wiring identical in spirit to prior server
 
8
  from __future__ import annotations
9
 
10
  import os, io, re, json, time, uuid, base64, logging
 
48
  credentials_json_string = os.environ.get("FIREBASE")
49
  if not credentials_json_string:
50
  raise ValueError("FIREBASE env var is not set")
 
51
  credentials_json = json.loads(credentials_json_string)
52
  firebase_db_url = os.environ.get("Firebase_DB")
53
  if not firebase_db_url:
 
111
  # -----------------------------------------------------------------------------
112
  # Admin API client (client-supplied credentials; holistic admin scope)
113
  # -----------------------------------------------------------------------------
 
 
 
 
 
114
  SC_BASE_URL = os.getenv("SC_BASE_URL", "https://delta-api.pricelyst.co.zw").rstrip("/")
115
 
116
  class SCAuth:
117
  """Caches a requests.Session per admin email; supports bearer or cookie sessions."""
118
+ _cache: Dict[str, Dict[str, Any]] = {}
119
 
120
  @classmethod
121
  def invalidate(cls, email: str) -> None:
 
130
  def _extract_token(cls, js: dict) -> Optional[str]:
131
  if not isinstance(js, dict):
132
  return None
 
133
  candidates = [
134
  js.get("token"),
135
  js.get("access_token"),
 
144
  return None
145
 
146
  @classmethod
147
+ def login(cls, email: str, password: str) -> Dict[str, Any]:
148
  s = requests.Session()
149
  s.headers.update({"Accept": "application/json"})
150
  url = f"{SC_BASE_URL}/api/auth/admin/login"
 
151
  resp = s.post(url, json={"email": email, "password": password}, timeout=30)
152
 
153
+ body_text, body_json = "", {}
 
 
154
  try:
155
  body_json = resp.json() or {}
156
  except Exception:
157
+ body_text = (resp.text or "")[:800]
 
158
  token = cls._extract_token(body_json)
159
 
160
  if token:
161
  s.headers.update({"Authorization": f"Bearer {token}"})
162
  entry = {"session": s, "auth": "bearer", "token": token}
163
  cls._cache[email] = entry
164
+ logger.debug("Admin login (bearer) OK")
165
  return entry
166
 
 
167
  if resp.cookies and (resp.status_code // 100) == 2:
168
  entry = {"session": s, "auth": "cookie"}
169
  cls._cache[email] = entry
170
+ logger.debug("Admin login (cookie) OK")
171
  return entry
172
 
 
173
  snippet = body_text or (str(body_json)[:800])
174
  raise RuntimeError(f"Login did not return a token or cookie session. HTTP {resp.status_code}. Body≈ {snippet}")
175
 
176
  def sc_request(method: str, path: str, email: str, password: str, *,
177
  params: dict = None, json_body: dict = None, timeout: int = 30):
178
+ """Authenticated request with 401 auto-refresh (once). Logs a compact sample on success."""
179
  if not path.startswith("/"):
180
  path = "/" + path
181
  url = f"{SC_BASE_URL}{path}"
 
183
  def _do(s: requests.Session):
184
  return s.request(method.upper(), url, params=params, json=json_body, timeout=timeout)
185
 
 
186
  entry = SCAuth._cache.get(email)
187
  if not entry:
188
  entry = SCAuth.login(email, password)
 
190
 
191
  resp = _do(s)
192
  if resp.status_code == 401:
 
193
  SCAuth.invalidate(email)
194
  entry = SCAuth.login(email, password)
195
  s = entry["session"]
196
  resp = _do(s)
197
 
 
198
  try:
199
  resp.raise_for_status()
200
  except Exception as e:
 
201
  snippet = (getattr(resp, "text", "") or "")[:800]
202
  raise RuntimeError(f"SC request error {method.upper()} {path}: HTTP {resp.status_code} – {snippet}") from e
203
 
204
+ payload: Any
205
  try:
206
+ payload = resp.json()
207
  except Exception:
208
+ payload = resp.text
209
+
210
+ # ---- Compact sample logging for every endpoint ----
211
+ sample = None
212
+ if isinstance(payload, dict):
213
+ d = payload.get("data", payload)
214
+ if isinstance(d, dict):
215
+ # try common array keys
216
+ for key in ("sales_over_time", "orders", "transactions", "items", "list", "rows", "data"):
217
+ v = d.get(key)
218
+ if isinstance(v, list) and v:
219
+ sample = {key: v[:2]} # first 2 rows
220
+ break
221
+ if sample is None:
222
+ # fallback: first 10 keys
223
+ sample = {k: ("[list]" if isinstance(v, list) else v) for k, v in list(d.items())[:10]}
224
+ elif isinstance(d, list):
225
+ sample = d[:2]
226
+ elif isinstance(payload, list):
227
+ sample = payload[:2]
228
+ else:
229
+ sample = str(payload)[:300]
230
+
231
+ logger.debug("SAMPLE %s %s -> %s", method.upper(), path, json.dumps(sample, default=str))
232
+ return payload
233
+
234
  # -----------------------------------------------------------------------------
235
+ # Timezone & temporal helpers
236
  # -----------------------------------------------------------------------------
 
 
 
 
237
  TZ = os.getenv("APP_TZ", "Africa/Harare")
238
+ _TZ = TZ # backward-compatible alias
 
 
239
 
240
  def now_harare() -> pd.Timestamp:
 
241
  return pd.Timestamp.now(tz=TZ)
242
 
243
+ def week_bounds_from(ts: pd.Timestamp) -> Tuple[pd.Timestamp, pd.Timestamp]:
 
244
  monday = ts.tz_convert(TZ).normalize() - pd.Timedelta(days=ts.weekday())
245
  sunday = monday + pd.Timedelta(days=6, hours=23, minutes=59, seconds=59)
246
  return monday, sunday
247
 
248
+ def this_month_bounds(ts: pd.Timestamp) -> Tuple[pd.Timestamp, pd.Timestamp]:
 
249
  first_this = ts.normalize().replace(day=1)
250
+ if first_this.month == 12:
251
+ first_next = first_this.replace(year=first_this.year + 1, month=1)
252
+ else:
253
+ first_next = first_this.replace(month=first_this.month + 1)
254
+ last_this = first_next - pd.Timedelta(seconds=1)
255
  return first_this, last_this
 
256
 
257
  def period_to_bounds(period: str) -> Tuple[pd.Timestamp, pd.Timestamp, str]:
258
  p = (period or "week").strip().lower()
259
  now = now_harare()
260
  if p == "today":
261
  start = now.normalize()
262
+ end = start + pd.Timedelta(hours=23, minutes=59, seconds=59); lbl = "Today"
 
263
  elif p in ("week", "this_week"):
264
+ start, end = week_bounds_from(now); lbl = "This Week"
 
265
  elif p in ("month", "this_month"):
266
+ start, end = this_month_bounds(now); lbl = "This Month"
 
267
  elif p in ("year", "this_year"):
268
  start = now.normalize().replace(month=1, day=1, hour=0, minute=0, second=0)
269
+ end = now.normalize().replace(month=12, day=31, hour=23, minute=59, second=59); lbl = "This Year"
 
270
  else:
271
  start, end = week_bounds_from(now); lbl = "This Week"
272
  return start, end, lbl
 
331
  if tb in s: s = s.split(tb, 1)[0]
332
  return (s or "").strip()
333
 
334
+ # -----------------------------------------------------------------------------
335
+ # Robust normalizers
336
+ # -----------------------------------------------------------------------------
 
 
 
 
 
337
  def _to_list(x: Any) -> List[Any]:
338
+ if x is None: return []
339
+ if isinstance(x, list): return x
340
+ if isinstance(x, dict): return [x]
 
 
 
 
341
  if isinstance(x, str):
 
342
  try:
343
  j = json.loads(x)
344
+ if isinstance(j, list): return j
345
+ if isinstance(j, dict): return [j]
 
 
346
  except Exception:
347
+ return [x]
348
  return [x]
349
 
350
  def _to_float(x: Any) -> Optional[float]:
 
363
  return None
364
 
365
  def _coerce_date(s: Any) -> Optional[pd.Timestamp]:
366
+ if s is None: return None
 
367
  try:
368
  dt = pd.to_datetime(s, errors="coerce")
369
+ if pd.isna(dt): return None
 
 
370
  try:
371
  return dt.tz_localize(TZ, nonexistent="shift_forward", ambiguous="NaT")
372
  except Exception:
 
374
  except Exception:
375
  return None
376
 
377
+ # -----------------------------------------------------------------------------
378
+ # Admin raw transactions extractor (row-level for PandasAI) + sample logging
379
+ # -----------------------------------------------------------------------------
380
+ def _paginate(sc_get, email, password, path, params=None, page_param="page", per_page=200, max_pages=50):
381
+ """Generic paginator for endpoints with page/per_page/meta"""
382
+ params = dict(params or {})
383
+ params.setdefault(page_param, 1)
384
+ params.setdefault("per_page", per_page)
385
+ page = 1
386
+ for _ in range(max_pages):
387
+ params[page_param] = page
388
+ raw = sc_get("GET", path, email, password, params=params)
389
+ yield raw
390
+ try:
391
+ meta = (raw or {}).get("meta") or {}
392
+ last_page = int(meta.get("last_page") or 0)
393
+ cur = int(meta.get("current_page") or page)
394
+ if last_page and cur >= last_page:
395
+ break
396
+ if not last_page and not raw:
397
+ break
398
+ except Exception:
399
+ break
400
+ page += 1
401
+
402
+ def _normalize_line(order, item, tz=TZ) -> dict:
403
+ g = lambda o, *ks, default=None: next((o[k] for k in ks if isinstance(o, dict) and k in o), default)
404
+ to_f = lambda x: _to_float(x) or 0.0
405
+ to_i = lambda x: _to_int(x) or 0
406
+
407
+ order_id = g(order, "id", "order_id", "uuid", "reference")
408
+ created_at = g(order, "created_at", "date", "ordered_at", "timestamp")
409
+ customer = g(order, "customer_name", "customer", "buyer_name", "customer_reference")
410
+ payment = g(order, "payment_method", "payment", "money_type")
411
+ branch = g(order, "shop_name", "shop", "branch", "store")
412
+ status = g(order, "status")
413
+ currency = g(order, "currency")
414
+
415
+ prod_id = g(item, "product_id", "item_id", "sku_id", "id")
416
+ prod_name = g(item, "product_name", "name", "title", "sku")
417
+ qty = to_i(g(item, "quantity", "qty", "units"))
418
+ unit_price = to_f(g(item, "unit_price", "price", "unitPrice"))
419
+ line_total = to_f(g(item, "line_total", "total", "amount", "revenue"))
420
+ cost_price = _to_float(g(item, "unit_cost", "cost_price", "cost")) # optional
421
+
422
+ dt = _coerce_date(created_at)
423
+ revenue = line_total if line_total else (qty * unit_price)
424
+ gp = None
425
+ if cost_price is not None:
426
+ gp = float(revenue - qty * (cost_price or 0.0))
427
+
428
+ return {
429
+ "order_id": order_id,
430
+ "datetime": dt,
431
+ "date": dt.tz_convert(tz).date().isoformat() if dt is not None else None,
432
+ "customer": customer,
433
+ "payment_method": payment,
434
+ "branch": branch,
435
+ "status": status,
436
+ "currency": currency,
437
+ "product_id": prod_id,
438
+ "product": prod_name,
439
+ "quantity": qty,
440
+ "unit_price": unit_price,
441
+ "line_total": revenue,
442
+ "unit_cost": float(cost_price) if cost_price is not None else None,
443
+ "gross_profit": float(gp) if gp is not None else None,
444
+ }
445
+
446
+ def fetch_transactions_df(email: str, password: str, t_start: pd.Timestamp, t_end: pd.Timestamp) -> pd.DataFrame:
447
+ """
448
+ Pull row-level order lines. Tries multiple likely endpoints, logs a sample for each,
449
+ flattens nested items, returns a clean DataFrame suitable for PandasAI.
450
+ """
451
+ CANDIDATES: Tuple[Tuple[str, str, str], ...] = (
452
+ ("/api/analytics/orders", "orders", "items"),
453
+ ("/api/orders", "data", "items"), # many APIs wrap orders under "data"
454
+ ("/api/analytics/transactions", "transactions", "items"),
455
+ ("/api/sales/transactions", "transactions", "lines"),
456
+ )
457
+ params = {
458
+ "start_date": t_start.strftime("%Y-%m-%d"),
459
+ "end_date": t_end.strftime("%Y-%m-%d"),
460
+ "include": "items",
461
+ "per_page": 200,
462
+ }
463
+
464
+ rows: List[dict] = []
465
+ for path, orders_key, items_key in CANDIDATES:
466
+ try:
467
+ # Non-paginated attempt
468
+ raw = sc_request("GET", path, email, password, params=params)
469
+ # Log a sharper sample for this endpoint (top-level)
470
+ logger.debug("TXN_PROBE_RAW %s -> keys=%s", path, list(raw.keys())[:10] if isinstance(raw, dict) else type(raw))
471
+
472
+ payload = raw.get("data") if isinstance(raw, dict) and isinstance(raw.get("data"), (dict, list)) else raw
473
+ orders = payload.get(orders_key) if isinstance(payload, dict) else payload
474
+ if orders:
475
+ orders_list = _to_list(orders)
476
+ if orders_list:
477
+ # sample one order + items
478
+ o0 = orders_list[0] if isinstance(orders_list[0], dict) else {}
479
+ i0 = _to_list((o0 or {}).get(items_key))
480
+ logger.debug("TXN_SAMPLE %s -> order_keys=%s; first_item_keys=%s",
481
+ path,
482
+ list(o0.keys())[:15] if isinstance(o0, dict) else type(o0),
483
+ (list(i0[0].keys())[:15] if i0 and isinstance(i0[0], dict) else "N/A"))
484
+ for o in orders_list:
485
+ for it in _to_list((o or {}).get(items_key)):
486
+ if isinstance(o, dict) and isinstance(it, dict):
487
+ rows.append(_normalize_line(o, it))
488
+ if rows:
489
+ break
490
+
491
+ # Try paginated shape
492
+ collected = 0
493
+ for page_raw in _paginate(sc_request, email, password, path, params=params):
494
+ logger.debug("TXN_PAGE %s meta=%s", path, (page_raw or {}).get("meta") if isinstance(page_raw, dict) else "N/A")
495
+ page_data = page_raw.get("data") if isinstance(page_raw, dict) and isinstance(page_raw.get("data"), (dict, list)) else page_raw
496
+ page_orders = page_data.get(orders_key) if isinstance(page_data, dict) else page_data
497
+ for o in _to_list(page_orders):
498
+ for it in _to_list((o or {}).get(items_key)):
499
+ if isinstance(o, dict) and isinstance(it, dict):
500
+ rows.append(_normalize_line(o, it))
501
+ collected += 1
502
+ if collected and collected >= 5000: # safety cap
503
+ break
504
+ if rows:
505
+ # Log a compact sample of flattened rows
506
+ logger.debug("TXN_FLAT_SAMPLE %s -> %s", path, json.dumps(rows[:2], default=str))
507
+ break
508
+ except Exception as e:
509
+ logger.debug(f"fetch_transactions_df: {path} probe failed: {e}")
510
+
511
+ if not rows:
512
+ logger.warning("No row-level endpoint found; returning an empty transactions frame (schema only).")
513
+ schema = {
514
+ "datetime": pd.Series(dtype="datetime64[ns]"),
515
+ "date": pd.Series(dtype="object"),
516
+ "order_id": pd.Series(dtype="object"),
517
+ "status": pd.Series(dtype="object"),
518
+ "customer": pd.Series(dtype="object"),
519
+ "branch": pd.Series(dtype="object"),
520
+ "payment_method": pd.Series(dtype="object"),
521
+ "currency": pd.Series(dtype="object"),
522
+ "product_id": pd.Series(dtype="object"),
523
+ "product": pd.Series(dtype="object"),
524
+ "quantity": pd.Series(dtype="float"),
525
+ "unit_price": pd.Series(dtype="float"),
526
+ "line_total": pd.Series(dtype="float"),
527
+ "unit_cost": pd.Series(dtype="float"),
528
+ "gross_profit": pd.Series(dtype="float"),
529
+ }
530
+ return pd.DataFrame(schema)
531
+
532
+ df = pd.DataFrame(rows)
533
+ df["datetime"] = pd.to_datetime(df["datetime"], errors="coerce")
534
+ try:
535
+ # Keep tz-naive for some plotting libs but deterministic in Harare
536
+ df["datetime"] = df["datetime"].dt.tz_convert(TZ).dt.tz_localize(None)
537
+ except Exception:
538
+ pass
539
+
540
+ for c in ("quantity", "unit_price", "line_total", "unit_cost", "gross_profit"):
541
+ if c in df.columns:
542
+ df[c] = pd.to_numeric(df[c], errors="coerce")
543
+
544
+ cols = [
545
+ "datetime", "date", "order_id", "status", "customer", "branch",
546
+ "payment_method", "currency", "product_id", "product",
547
+ "quantity", "unit_price", "line_total", "unit_cost", "gross_profit",
548
+ ]
549
+ df = df[[c for c in cols if c in df.columns]]
550
+
551
+ logger.debug("TXN_DF_COLUMNS %s", df.columns.tolist())
552
+ logger.debug("TXN_DF_HEAD %s", json.dumps(df.head(3).to_dict(orient="records"), default=str))
553
+ return df
554
 
555
  # -----------------------------------------------------------------------------
556
+ # Admin KPI Engine (holistic view) — logs sample after each endpoint
557
  # -----------------------------------------------------------------------------
558
  class AdminAnalyticsEngine:
559
  """Single-tenant holistic admin analytics. No shop/brand filters; admin sees entire dataset."""
 
564
  self.period = (period or "week").lower().strip()
565
  self.t_start, self.t_end, self.period_label = period_to_bounds(self.period)
566
 
 
567
  @staticmethod
568
  def _unwrap_data(payload: dict) -> dict:
569
  if isinstance(payload, dict):
 
570
  return payload.get("data") if isinstance(payload.get("data"), dict) else payload
571
  return {}
572
 
 
573
  def _dashboard(self) -> dict:
574
+ raw = sc_request("GET", "/api/analytics/dashboard", self.email, self.password, params={"period": self.period})
 
 
 
 
 
 
575
  data = self._unwrap_data(raw)
576
  emit_kpi_debug(self.tenant_key, "dashboard", data or raw or {})
577
+ # Log a friendly sample view:
578
+ logger.debug("SAMPLE /api/analytics/dashboard -> %s", json.dumps({k: data.get(k) for k in list(data.keys())[:10]}, default=str))
579
  return data or {}
580
 
581
  def _sales_series(self) -> pd.DataFrame:
 
 
 
 
 
 
 
582
  params = {
583
  "start_date": self.t_start.strftime("%Y-%m-%d"),
584
  "end_date": self.t_end.strftime("%Y-%m-%d"),
585
  "group_by": "day",
586
  }
587
  raw = sc_request("GET", "/api/analytics/sales", self.email, self.password, params=params)
 
 
 
588
  data = {}
589
  if isinstance(raw, dict):
590
  data = (raw.get("data") or raw) if isinstance(raw.get("data"), (dict, list)) else raw
 
595
  except Exception:
596
  data = {}
597
 
598
+ # log samples from top-level keys we expect
599
+ try:
600
+ so = data.get("sales_over_time")
601
+ pm = data.get("sales_by_payment_method")
602
+ cat = data.get("sales_by_category")
603
+ logger.debug("SAMPLE /api/analytics/sales -> sales_over_time[:2]=%s; sales_by_payment_method[:2]=%s; sales_by_category[:2]=%s",
604
+ json.dumps((so or [])[:2]), json.dumps((pm or [])[:2]), json.dumps((cat or [])[:2]))
605
+ except Exception:
606
+ pass
607
+
608
  series = []
609
+ for r in _to_list(data.get("sales_over_time")):
 
610
  if not isinstance(r, dict):
611
  continue
612
  date_str = r.get("date") or r.get("day") or r.get("period")
613
  dt = _coerce_date(date_str)
614
  if dt is None:
615
  continue
 
616
  total_sales = _to_float(r.get("total_sales") or r.get("total") or r.get("revenue"))
617
  total_orders = _to_int(r.get("total_orders") or r.get("orders") or r.get("count"))
618
  aov = _to_float(r.get("average_order_value") or r.get("aov"))
 
619
  if aov is None and total_sales is not None and (total_orders or 0) > 0:
620
  aov = float(total_sales) / int(total_orders)
 
621
  series.append({
622
  "_date": dt,
623
  "total_sales": float(total_sales) if total_sales is not None else 0.0,
624
  "total_orders": int(total_orders) if total_orders is not None else 0,
625
  "aov": float(aov) if aov is not None else None,
626
  })
 
627
  df = pd.DataFrame(series)
628
  if df.empty:
629
  return pd.DataFrame(columns=["_date", "total_sales", "total_orders", "aov"])
 
630
  df = df.sort_values("_date").reset_index(drop=True)
631
  emit_kpi_debug(self.tenant_key, "sales_series_raw", (raw if isinstance(raw, dict) else {"raw": raw}))
632
+ logger.debug("SAMPLE sales_series_df.head -> %s", json.dumps(df.head(3).to_dict(orient="records"), default=str))
633
+ return df
634
+
635
+ def transactions_df(self) -> pd.DataFrame:
636
+ df = fetch_transactions_df(self.email, self.password, self.t_start, self.t_end)
637
+ emit_kpi_debug(self.tenant_key, "transactions_df_meta", {
638
+ "rows": int(len(df)),
639
+ "cols": list(df.columns),
640
+ "period": {"start": self.t_start.isoformat(), "end": self.t_end.isoformat()}
641
+ })
642
+ # already logged columns + head in fetch_transactions_df()
643
  return df
644
 
645
  def _products(self) -> dict:
646
  raw = sc_request(
647
+ "GET", "/api/analytics/products", self.email, self.password,
648
+ params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")}
 
 
 
649
  )
650
  data = self._unwrap_data(raw)
651
  emit_kpi_debug(self.tenant_key, "products", data or raw or {})
652
+ # log sample leaderboards if present
653
+ keys = ["top_by_revenue","top_by_units","top_by_margin_value","top_by_margin_pct","bottom_by_revenue","loss_makers"]
654
+ sample = {k: (data.get(k) or [])[:2] for k in keys if isinstance(data.get(k), list)}
655
+ logger.debug("SAMPLE /api/analytics/products -> %s", json.dumps(sample))
656
  return data or {}
657
 
658
  def _customers(self) -> dict:
659
  raw = sc_request(
660
+ "GET", "/api/analytics/customers", self.email, self.password,
661
+ params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")}
 
 
 
662
  )
663
  data = self._unwrap_data(raw)
664
  emit_kpi_debug(self.tenant_key, "customers", data or raw or {})
665
+ # sample common shapes
666
+ sample = {
667
+ "top_customers_by_gp": (data.get("top_customers_by_gp") or [])[:2],
668
+ "at_risk": (data.get("at_risk") or [])[:2],
669
+ "new_customers": (data.get("new_customers") or [])[:2],
670
+ "summary": data.get("summary"),
671
+ }
672
+ logger.debug("SAMPLE /api/analytics/customers -> %s", json.dumps(sample))
673
  return data or {}
674
 
675
  def _inventory(self) -> dict:
676
  raw = sc_request("GET", "/api/analytics/inventory", self.email, self.password)
677
  data = self._unwrap_data(raw)
678
  emit_kpi_debug(self.tenant_key, "inventory", data or raw or {})
679
+ try:
680
+ items = data.get("products") or data.get("items") or data.get("snapshot") or []
681
+ logger.debug("SAMPLE /api/analytics/inventory -> %s", json.dumps((items or [])[:2], default=str))
682
+ except Exception:
683
+ pass
684
  return data or {}
685
 
686
  def _comparisons(self) -> dict:
687
  raw = sc_request(
688
+ "GET", "/api/analytics/comparisons", self.email, self.password,
689
+ params={"start_date": self.t_start.strftime("%Y-%m-%d"), "end_date": self.t_end.strftime("%Y-%m-%d")}
 
 
 
690
  )
691
  data = self._unwrap_data(raw)
692
  emit_kpi_debug(self.tenant_key, "comparisons", data or raw or {})
693
+ try:
694
+ logger.debug("SAMPLE /api/analytics/comparisons -> keys=%s", list(data.keys())[:15])
695
+ except Exception:
696
+ pass
697
  return data or {}
698
 
699
  # -------------------- deterministic snapshot --------------------
 
705
  inv = self._inventory()
706
  comps = self._comparisons()
707
 
 
 
708
  def _get_num(d: dict, *keys, default=0.0):
709
  for k in keys:
710
  v = d.get(k)
 
720
  transactions = int(_get_num(dash, "transactions", "orders", default=0.0))
721
 
722
  if (total_revenue == 0.0 or transactions == 0) and isinstance(sales_df, pd.DataFrame) and not sales_df.empty:
 
723
  total_revenue = float(sales_df["total_sales"].sum())
724
  transactions = int(sales_df["total_orders"].sum())
 
725
 
726
  product_lb = {
727
  "top_by_revenue": prods.get("top_by_revenue") or prods.get("topRevenue") or [],
 
782
  return json_safe(snapshot)
783
 
784
  def _temporal_patterns_from_sales(self, df: pd.DataFrame) -> Dict[str, Any]:
 
 
 
 
 
785
  if df is None or df.empty:
786
  return {"series": [], "best_day_by_sales": None}
 
787
  d = df.copy()
 
788
  d["dow"] = d["_date"].dt.day_name()
789
  d["date"] = d["_date"].dt.strftime("%Y-%m-%d")
 
 
790
  g = d.groupby("dow", dropna=False).agg(
791
  total_sales=("total_sales", "sum"),
792
  total_orders=("total_orders", "sum"),
793
  ).reset_index()
 
794
  best_row = None if g.empty else g.loc[g["total_sales"].idxmax()]
795
  best_day = None if g.empty else {
796
  "day": str(best_row["dow"]),
797
  "total_sales": float(best_row["total_sales"]),
798
  "total_orders": int(best_row["total_orders"]),
799
  }
 
800
  series = d[["date", "total_sales", "total_orders", "aov"]].to_dict(orient="records")
801
  return {"series": series, "best_day_by_sales": best_day}
802
 
 
813
  return sanitize_answer(text)
814
  except Exception:
815
  return "### Business Snapshot\n\n```\n" + json.dumps(json_safe(snapshot), indent=2) + "\n```"
816
+
817
  # -----------------------------------------------------------------------------
818
  # /chat — PandasAI first on sales series, else deterministic snapshot + narration
819
  # -----------------------------------------------------------------------------
 
836
  return jsonify({"error": "Missing 'email' or 'password'."}), 400
837
 
838
  engine = AdminAnalyticsEngine(tenant_key, email, password, period)
839
+
840
+ # Build transactions_df now and place it in meta logs (useful for PandasAI later)
841
+ tdf = engine.transactions_df()
842
+
843
+ # For simple Q&A we still start with sales_df (fast + stable)
844
  sales_df = engine._sales_series()
845
+ if sales_df.empty and tdf.empty:
846
  snapshot = engine.build_snapshot()
847
  answer = engine.narrate(snapshot, user_question)
848
  return jsonify({"answer": sanitize_answer(answer), "meta": {"source": "analyst_fallback"}})
849
 
850
  try:
851
  logger.info(f"[{rid}] PandasAI attempt …")
852
+ # If the question references products/items explicitly, switch to transactions_df
853
+ use_df = tdf if re.search(r"\b(product|sku|item|category|top\s*5|top\s*ten|by\s*revenue|by\s*units)\b", user_question, re.I) and not tdf.empty else sales_df
854
+ pandas_agent = SmartDataframe(use_df, config={
855
  "llm": llm,
856
  "response_parser": FlaskResponse,
857
  "security": "none",
 
883
  return jsonify({"answer": data_uri, "meta": {"source": "pandasai"}})
884
 
885
  return jsonify({"answer": sanitize_answer(answer), "meta": {"source": "pandasai"}})
 
886
  except Exception:
887
  snapshot = engine.build_snapshot()
888
  answer = engine.narrate(snapshot, user_question)
 
903
  payload = request.get_json() or {}
904
  tenant_key = str(payload.get("tenant_key") or "admin")
905
  period = (payload.get("period") or "week").strip().lower()
906
+ email = payload.get("email"); password = payload.get("password")
 
907
  if not email or not password:
908
  return jsonify({"error": "Missing 'email' or 'password'."}), 400
909
 
 
928
  payload = request.get_json() or {}
929
  tenant_key = str(payload.get("tenant_key") or "admin")
930
  period = (payload.get("period") or "week").strip().lower()
931
+ email = payload.get("email"); password = payload.get("password")
 
932
  if not email or not password:
933
  return jsonify({"error": "Missing 'email' or 'password'."}), 400
934
 
 
953
  payload = request.get_json() or {}
954
  tenant_key = str(payload.get("tenant_key") or "admin")
955
  period = (payload.get("period") or "week").strip().lower()
956
+ email = payload.get("email"); password = payload.get("password")
 
957
  if not email or not password:
958
  return jsonify({"error": "Missing 'email' or 'password'."}), 400
959
 
 
1024
  if not email or not password:
1025
  return jsonify({"error": "Missing 'email' or 'password'."}), 400
1026
  try:
 
1027
  call_history = []
1028
  try:
1029
  transcripts = db_ref.child(f"transcripts/{profile_id}").get()
 
1032
  logger.warning(f"Transcript fetch failed for '{profile_id}': {e}")
1033
  memory_summary = _synthesize_history_summary(call_history)
1034
 
 
1035
  engine = AdminAnalyticsEngine(profile_id or "admin", email, password, period)
1036
  kpi_snapshot = engine.build_snapshot()
1037