rairo commited on
Commit
3d25258
·
verified ·
1 Parent(s): 9643373

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +226 -147
main.py CHANGED
@@ -9,7 +9,7 @@ main.py — Pricelyst Shopping Advisor (Jessica Edition)
9
  ✅ Call briefing (Zim Essentials Injection)
10
  ✅ Post-call Shopping Plan Generation (PDF-ready)
11
 
12
- ENV VARS YOU NEED
13
  - GOOGLE_API_KEY=...
14
  - FIREBASE='{"type":"service_account", ...}' # full JSON string
15
  - PRICE_API_BASE=https://api.pricelyst.co.zw # optional
@@ -33,15 +33,18 @@ import pandas as pd
33
  from flask import Flask, request, jsonify
34
  from flask_cors import CORS
35
 
36
- # ---------- Logging ----------
 
37
  logging.basicConfig(
38
  level=logging.INFO,
39
  format="%(asctime)s | %(levelname)s | %(message)s"
40
  )
41
  logger = logging.getLogger("pricelyst-advisor")
42
 
43
- # ---------- Gemini (NEW SDK) ----------
 
44
  # pip install google-genai
 
45
  try:
46
  from google import genai
47
  from google.genai import types
@@ -60,36 +63,53 @@ if genai and GOOGLE_API_KEY:
60
  except Exception as e:
61
  logger.error("Failed to init Gemini client: %s", e)
62
 
63
- # ---------- Firebase Admin ----------
 
64
  # pip install firebase-admin
 
65
  import firebase_admin
66
  from firebase_admin import credentials, firestore
67
 
68
  FIREBASE_ENV = os.environ.get("FIREBASE", "")
69
 
70
  def init_firestore_from_env() -> firestore.Client:
 
71
  if firebase_admin._apps:
72
  return firestore.client()
73
 
 
74
  if not FIREBASE_ENV:
 
75
  raise RuntimeError("FIREBASE env var missing. Provide full service account JSON string.")
76
 
77
- sa_info = json.loads(FIREBASE_ENV)
78
- cred = credentials.Certificate(sa_info)
79
- firebase_admin.initialize_app(cred)
80
- return firestore.client()
 
 
 
 
 
 
 
 
 
 
 
81
 
82
- db = init_firestore_from_env()
83
 
84
- # ---------- External API (Pricelyst) ----------
85
  PRICE_API_BASE = os.environ.get("PRICE_API_BASE", "https://api.pricelyst.co.zw").rstrip("/")
86
  HTTP_TIMEOUT = 20
87
 
88
- # ---------- Flask ----------
 
89
  app = Flask(__name__)
90
  CORS(app)
91
 
92
- # ---------- In-memory product cache ----------
 
93
  PRODUCT_CACHE_TTL_SEC = 60 * 10 # 10 minutes
94
  _product_cache: Dict[str, Any] = {
95
  "ts": 0,
@@ -97,8 +117,8 @@ _product_cache: Dict[str, Any] = {
97
  "raw_count": 0,
98
  }
99
 
100
- # ---------- Static Data (New Feature) ----------
101
- # Injected into Voice Context & used for Text Fallback
102
  ZIM_ESSENTIALS = {
103
  "fuel_petrol": "$1.58/L (Blend)",
104
  "fuel_diesel": "$1.65/L (Diesel 50)",
@@ -110,6 +130,7 @@ ZIM_ESSENTIALS = {
110
  # =========================
111
  # Helpers: time / strings
112
  # =========================
 
113
  def now_utc_iso() -> str:
114
  return datetime.now(timezone.utc).isoformat()
115
 
@@ -141,59 +162,88 @@ def _safe_json_loads(s: str, fallback: Any):
141
  # =========================
142
  # Firestore profile storage
143
  # =========================
 
144
  def profile_ref(profile_id: str):
 
145
  return db.collection("pricelyst_profiles").document(profile_id)
146
 
147
  def get_profile(profile_id: str) -> Dict[str, Any]:
148
- ref = profile_ref(profile_id)
149
- doc = ref.get()
150
- if doc.exists:
151
- return doc.to_dict() or {}
152
- # create default
153
- data = {
154
- "profile_id": profile_id,
155
- "created_at": now_utc_iso(),
156
- "updated_at": now_utc_iso(),
157
- "username": None,
158
- "memory_summary": "",
159
- "preferences": {},
160
- "last_actions": [],
161
- "counters": {
162
- "chats": 0,
163
- "calls": 0,
 
 
 
 
164
  }
165
- }
166
- ref.set(data)
167
- return data
 
 
168
 
169
  def update_profile(profile_id: str, patch: Dict[str, Any]) -> None:
170
- patch = dict(patch or {})
171
- patch["updated_at"] = now_utc_iso()
172
- profile_ref(profile_id).set(patch, merge=True)
 
 
 
 
173
 
174
  def log_chat(profile_id: str, payload: Dict[str, Any]) -> None:
175
- db.collection("pricelyst_profiles").document(profile_id).collection("chat_logs").add({
176
- **payload,
177
- "ts": now_utc_iso()
178
- })
 
 
 
 
 
 
 
179
 
180
  def log_call(profile_id: str, payload: Dict[str, Any]) -> str:
181
- doc_ref = db.collection("pricelyst_profiles").document(profile_id).collection("call_logs").document()
182
- doc_ref.set({
183
- **payload,
184
- "ts": now_utc_iso()
185
- })
186
- return doc_ref.id
 
 
 
 
 
 
 
 
 
187
 
188
  # =========================
189
  # Multimodal image handling
190
  # =========================
 
191
  def parse_images(images: List[str]) -> List[Dict[str, Any]]:
192
  """
193
  Accepts:
194
- - data URLs: data:image/png;base64,....
195
- - raw base64 strings
196
- - http(s) URLs
197
  Returns: list of { "mime": "...", "bytes": b"..." } or { "url": "..." }
198
  """
199
  out = []
@@ -201,7 +251,7 @@ def parse_images(images: List[str]) -> List[Dict[str, Any]]:
201
  if not item:
202
  continue
203
  item = item.strip()
204
-
205
  # URL
206
  if item.startswith("http://") or item.startswith("https://"):
207
  out.append({"url": item})
@@ -217,18 +267,19 @@ def parse_images(images: List[str]) -> List[Dict[str, Any]]:
217
  except Exception:
218
  continue
219
  continue
220
-
221
  # raw base64
222
  try:
223
  out.append({"mime": "image/png", "bytes": base64.b64decode(item)})
224
  except Exception:
225
  continue
226
-
227
  return out
228
 
229
  # =========================
230
  # Product fetching + offers DF
231
  # =========================
 
232
  def fetch_products_page(page: int, per_page: int = 50) -> Dict[str, Any]:
233
  url = f"{PRICE_API_BASE}/api/v1/products"
234
  params = {"page": page, "perPage": per_page}
@@ -237,10 +288,6 @@ def fetch_products_page(page: int, per_page: int = 50) -> Dict[str, Any]:
237
  return r.json()
238
 
239
  def fetch_products(max_pages: int = 6, per_page: int = 50) -> List[Dict[str, Any]]:
240
- """
241
- Pull a reasonable slice (you can increase pages later).
242
- API shape (common): {status, message, data, totalItemCount, currentPage, totalPages}
243
- """
244
  products: List[Dict[str, Any]] = []
245
  for p in range(1, max_pages + 1):
246
  payload = fetch_products_page(p, per_page=per_page)
@@ -255,17 +302,13 @@ def fetch_products(max_pages: int = 6, per_page: int = 50) -> List[Dict[str, Any
255
  return products
256
 
257
  def products_to_offers_df(products: List[Dict[str, Any]]) -> pd.DataFrame:
258
- """
259
- Each row = one product + one retailer offer.
260
- Your product object can include `prices[]` with nested `retailer`.
261
- """
262
  rows = []
263
  for p in products or []:
264
  try:
265
  product_id = p.get("id")
266
  name = p.get("name") or ""
267
  clean_name = _norm_str(name)
268
-
269
  brand_name = ((p.get("brand") or {}).get("brand_name")) if isinstance(p.get("brand"), dict) else None
270
  categories = p.get("categories") or []
271
  cat_names = []
@@ -273,22 +316,22 @@ def products_to_offers_df(products: List[Dict[str, Any]]) -> pd.DataFrame:
273
  if isinstance(c, dict) and c.get("name"):
274
  cat_names.append(c.get("name"))
275
  primary_category = cat_names[0] if cat_names else None
276
-
277
  stock_status = p.get("stock_status")
278
  on_promo = bool(p.get("on_promotion"))
279
  promo_badge = p.get("promo_badge")
280
  promo_name = p.get("promo_name")
281
  promo_price = _coerce_float(p.get("promo_price"))
282
  original_price = _coerce_float(p.get("original_price"))
283
-
284
  recommended_price = _coerce_float(p.get("recommended_price"))
285
  base_price = _coerce_float(p.get("price"))
286
  bulk_price = _coerce_float(p.get("bulk_price"))
287
  bulk_unit = p.get("bulk_unit")
288
-
289
  image = p.get("image")
290
  thumb = p.get("thumbnail")
291
-
292
  offers = p.get("prices") or []
293
  if not offers:
294
  rows.append({
@@ -353,7 +396,7 @@ def products_to_offers_df(products: List[Dict[str, Any]]) -> pd.DataFrame:
353
  df = pd.DataFrame(rows)
354
  if df.empty:
355
  return df
356
-
357
  df["offer_price"] = df["offer_price"].apply(_coerce_float)
358
  df["clean_name"] = df["clean_name"].fillna("").astype(str)
359
  df["product_name"] = df["product_name"].fillna("").astype(str)
@@ -383,6 +426,7 @@ def get_offers_df(force_refresh: bool = False) -> pd.DataFrame:
383
  # =========================
384
  # Gemini wrappers
385
  # =========================
 
386
  def gemini_generate_text(system: str, user: str, temperature: float = 0.4) -> str:
387
  if not _gemini_client:
388
  return ""
@@ -426,15 +470,15 @@ def gemini_generate_json(system: str, user: str, images: List = None) -> Dict[st
426
  def gemini_generate_multimodal(system: str, user: str, images: List[Dict[str, Any]]) -> str:
427
  """
428
  Uses Gemini multimodal:
429
- - if we have bytes -> inline_data
430
- - if we have url -> just paste the URL (server-side fetch is unreliable w/o whitelisting),
431
- so we prefer bytes from the client.
432
  """
433
  if not _gemini_client:
434
  return ""
435
 
436
  parts: List[Dict[str, Any]] = [{"text": system.strip() + "\n\n" + user.strip()}]
437
-
438
  for img in images or []:
439
  if "bytes" in img and img.get("mime"):
440
  b64 = base64.b64encode(img["bytes"]).decode("utf-8")
@@ -465,14 +509,15 @@ def gemini_generate_multimodal(system: str, user: str, images: List[Dict[str, An
465
  # =========================
466
  # Intent + actionability
467
  # =========================
 
468
  INTENT_SYSTEM = """
469
  You are Pricelyst AI. Your job: understand whether the user is asking for actionable shopping help.
470
  Return STRICT JSON only.
471
 
472
  Output schema:
473
  {
474
- "actionable": true|false,
475
- "intent": one of [
476
  "store_recommendation",
477
  "price_lookup",
478
  "price_compare",
@@ -483,10 +528,10 @@ Output schema:
483
  "chit_chat",
484
  "lifestyle_lookup",
485
  "other"
486
- ],
487
- "items": [{"name": "...", "quantity": 1}],
488
- "constraints": {"budget": number|null, "location": "... "|null, "time_context": "mid-month|month-end|weekend|today|unknown"},
489
- "notes": "short reasoning"
490
  }
491
 
492
  Rules:
@@ -504,11 +549,11 @@ def detect_intent(message: str, images_present: bool, context: Dict[str, Any]) -
504
  clean_k = k.split('_')[-1] # fuel_petrol -> petrol
505
  if clean_k in msg_lower and "price" in msg_lower:
506
  return {"actionable": True, "intent": "lifestyle_lookup", "items": [{"name": k}]}
507
-
508
  # 2. Gemini Detection
509
  ctx_str = json.dumps(context or {}, ensure_ascii=False)
510
  user = f"Message: {message}\nImagesPresent: {images_present}\nContext: {ctx_str}"
511
-
512
  # Try using the strict JSON helper first for better reliability
513
  try:
514
  data = gemini_generate_json(INTENT_SYSTEM, user)
@@ -517,7 +562,7 @@ def detect_intent(message: str, images_present: bool, context: Dict[str, Any]) -
517
  # Fallback to text parsing if JSON mode fails (Backward Compat)
518
  out = gemini_generate_text(INTENT_SYSTEM, user, temperature=0.1)
519
  data = _safe_json_loads(out, fallback={})
520
-
521
  if not isinstance(data, dict):
522
  return {"actionable": False, "intent": "other", "items": [], "constraints": {}, "notes": "bad_json"}
523
  # normalize
@@ -530,17 +575,18 @@ def detect_intent(message: str, images_present: bool, context: Dict[str, Any]) -
530
  # =========================
531
  # Shopping Plan Generator (NEW)
532
  # =========================
 
533
  PLAN_SYSTEM_PROMPT = """
534
  You are Jessica, the Pricelyst Shopping Advisor. Analyze the conversation transcript.
535
  If the user discussed a shopping list, budget plan, or event needs, create a structured plan.
536
 
537
  OUTPUT JSON SCHEMA:
538
  {
539
- "is_actionable": boolean,
540
- "title": "Short title (e.g. 'Weekend Braai List')",
541
- "summary": "1 sentence summary",
542
- "items": [{"name": "string", "qty": "string", "est_price": number|null}],
543
- "markdown_content": "A clean Markdown report for a PDF. Include headers (#), bullet points, and a budget summary table if applicable. Keep it professional."
544
  }
545
 
546
  If no shopping/planning occurred, set is_actionable=false.
@@ -554,6 +600,7 @@ def generate_shopping_plan(transcript: str) -> Dict[str, Any]:
554
  # =========================
555
  # Matching + analytics
556
  # =========================
 
557
  def search_products(df: pd.DataFrame, query: str, limit: int = 10) -> pd.DataFrame:
558
  """
559
  Simple search: contains on clean_name + fallback token overlap scoring.
@@ -564,17 +611,17 @@ def search_products(df: pd.DataFrame, query: str, limit: int = 10) -> pd.DataFra
564
  q = _norm_str(query)
565
  if not q:
566
  return df.head(0)
567
-
568
  # direct contains
569
  hit = df[df["clean_name"].str.contains(re.escape(q), na=False)]
570
  if len(hit) >= limit:
571
  return hit.head(limit)
572
-
573
  # token overlap (cheap scoring)
574
  q_tokens = set(q.split())
575
  if not q_tokens:
576
  return hit.head(limit)
577
-
578
  tmp = df.copy()
579
  tmp["score"] = tmp["clean_name"].apply(lambda s: len(q_tokens.intersection(set(str(s).split()))))
580
  tmp = tmp[tmp["score"] > 0].sort_values(["score"], ascending=False)
@@ -585,18 +632,18 @@ def summarize_offers(df_hits: pd.DataFrame) -> Dict[str, Any]:
585
  """
586
  For one product name, there can be multiple retailers (offers).
587
  We return:
588
- - cheapest offer
589
- - price range
590
- - top offers
591
  """
592
  if df_hits.empty:
593
  return {}
594
-
595
  # group by product_id (best is highest offer coverage)
596
  grp = df_hits.groupby("product_id").size().sort_values(ascending=False)
597
  best_pid = int(grp.index[0])
598
  prod_rows = df_hits[df_hits["product_id"] == best_pid].copy()
599
-
600
  prod_name = prod_rows["product_name"].iloc[0]
601
  brand = prod_rows["brand_name"].iloc[0]
602
  category = prod_rows["primary_category"].iloc[0]
@@ -604,10 +651,10 @@ def summarize_offers(df_hits: pd.DataFrame) -> Dict[str, Any]:
604
  on_promo = bool(prod_rows["on_promotion"].iloc[0])
605
  promo_badge = prod_rows["promo_badge"].iloc[0]
606
  image = prod_rows["thumbnail"].iloc[0] or prod_rows["image"].iloc[0]
607
-
608
  offers = prod_rows[prod_rows["offer_price"].notna()].copy()
609
  offers = offers.sort_values("offer_price", ascending=True)
610
-
611
  if offers.empty:
612
  return {
613
  "product_id": best_pid,
@@ -630,7 +677,7 @@ def summarize_offers(df_hits: pd.DataFrame) -> Dict[str, Any]:
630
  }
631
  lo = float(offers["offer_price"].min())
632
  hi = float(offers["offer_price"].max())
633
-
634
  top_offers = []
635
  for _, r in offers.head(5).iterrows():
636
  top_offers.append({
@@ -638,7 +685,7 @@ def summarize_offers(df_hits: pd.DataFrame) -> Dict[str, Any]:
638
  "price": float(r["offer_price"]),
639
  "retailer_logo": r["retailer_logo"],
640
  })
641
-
642
  return {
643
  "product_id": best_pid,
644
  "name": prod_name,
@@ -656,15 +703,15 @@ def summarize_offers(df_hits: pd.DataFrame) -> Dict[str, Any]:
656
  def basket_store_choice(df: pd.DataFrame, items: List[Dict[str, Any]]) -> Dict[str, Any]:
657
  """
658
  Given items, pick:
659
- - best single store to cover most items and minimize total
660
  Very pragmatic MVP: for each item, match the best product and take cheapest offer.
661
  """
662
  if df.empty or not items:
663
  return {"items": [], "best_store": None, "missing": []}
664
-
665
  results = []
666
  missing = []
667
-
668
  for it in items:
669
  name = it.get("name") or ""
670
  qty = int(it.get("quantity") or 1)
@@ -685,10 +732,10 @@ def basket_store_choice(df: pd.DataFrame, items: List[Dict[str, Any]]) -> Dict[s
685
  "offers": summary.get("offers", []),
686
  "image": summary.get("image"),
687
  })
688
-
689
  if not results:
690
  return {"items": [], "best_store": None, "missing": missing}
691
-
692
  # compute totals by retailer for "all cheapest per item"
693
  retailer_totals: Dict[str, float] = {}
694
  retailer_counts: Dict[str, int] = {}
@@ -696,7 +743,7 @@ def basket_store_choice(df: pd.DataFrame, items: List[Dict[str, Any]]) -> Dict[s
696
  k = r["cheapest_retailer"]
697
  retailer_totals[k] = retailer_totals.get(k, 0.0) + float(r["line_total"])
698
  retailer_counts[k] = retailer_counts.get(k, 0) + 1
699
-
700
  # Score: cover_count desc, then total asc
701
  best = sorted(retailer_totals.keys(), key=lambda k: (-retailer_counts.get(k, 0), retailer_totals.get(k, 0.0)))[0]
702
  return {
@@ -713,6 +760,7 @@ def basket_store_choice(df: pd.DataFrame, items: List[Dict[str, Any]]) -> Dict[s
713
  # =========================
714
  # Response rendering (informative)
715
  # =========================
 
716
  def render_price_answer(summary: Dict[str, Any]) -> Dict[str, Any]:
717
  """
718
  Returns structured payload for frontend to render nicely.
@@ -733,7 +781,7 @@ def render_price_answer(summary: Dict[str, Any]) -> Dict[str, Any]:
733
  image = summary.get("image")
734
  cheapest = summary.get("cheapest")
735
  pr = summary.get("price_range")
736
-
737
  lines = []
738
  if cheapest:
739
  lines.append(f"Cheapest right now: {cheapest['retailer']} — ${cheapest['price']:.2f}")
@@ -741,7 +789,7 @@ def render_price_answer(summary: Dict[str, Any]) -> Dict[str, Any]:
741
  lines.append(f"Price range: ${pr['min']:.2f} → ${pr['max']:.2f} (spread ${pr['spread']:.2f})")
742
  if on_promo:
743
  lines.append(f"Promo: {promo_badge or 'On promotion'}")
744
-
745
  return {
746
  "type": "product_price",
747
  "title": name,
@@ -768,28 +816,29 @@ def render_basket_answer(basket: Dict[str, Any]) -> Dict[str, Any]:
768
  "best_store": best,
769
  "items": basket["items"],
770
  "missing": missing,
771
- "notes": "If you want, tell me your budget and Ill suggest cheaper substitutes.",
772
  }
773
 
774
  # =========================
775
  # Multimodal extraction (lists / receipts)
776
  # =========================
 
777
  VISION_SYSTEM = """
778
  You are an expert shopping assistant. Extract actionable items and quantities from the user's image(s).
779
  Return STRICT JSON only.
780
 
781
  Output schema:
782
  {
783
- "actionable": true|false,
784
- "items": [{"name":"...", "quantity": 1}],
785
- "notes": "short"
786
  }
787
 
788
  Rules:
789
  - If it looks like a handwritten shopping list, extract items.
790
  - If it looks like a receipt, extract the purchased items (best-effort).
791
- - If its random (selfie, meme, etc), actionable=false and items=[].
792
- - Keep it conservative: only include items youre confident about.
793
  """
794
 
795
  def extract_items_from_images(images: List[Dict[str, Any]]) -> Dict[str, Any]:
@@ -807,12 +856,14 @@ def extract_items_from_images(images: List[Dict[str, Any]]) -> Dict[str, Any]:
807
  # =========================
808
  # Routes
809
  # =========================
 
810
  @app.get("/health")
811
  def health():
812
  return jsonify({
813
  "ok": True,
814
  "ts": now_utc_iso(),
815
  "gemini": bool(_gemini_client),
 
816
  "products_cached_rows": int(len(_product_cache["df_offers"])) if isinstance(_product_cache["df_offers"], pd.DataFrame) else 0,
817
  "products_raw_count": int(_product_cache.get("raw_count", 0)),
818
  })
@@ -868,7 +919,7 @@ def chat():
868
 
869
  # 4) Actionable: execute
870
  df = get_offers_df(force_refresh=False)
871
-
872
  response_payload: Dict[str, Any] = {"type": "unknown", "message": "No result."}
873
 
874
  # --- NEW: Check for Lifestyle/Essentials (Fuel/ZESA) ---
@@ -923,7 +974,7 @@ def chat():
923
  hits = search_products(df, it.get("name") or "", limit=60)
924
  summary = summarize_offers(hits)
925
  comparisons.append(summary)
926
-
927
  # compute cheapest for each
928
  rows = []
929
  for s in comparisons:
@@ -968,7 +1019,7 @@ def call_briefing():
968
 
969
  username = body.get("username")
970
  prof = get_profile(profile_id)
971
-
972
  if username and not prof.get("username"):
973
  update_profile(profile_id, {"username": username})
974
  prof["username"] = username
@@ -976,21 +1027,24 @@ def call_briefing():
976
  # Build lightweight "shopping intelligence" variables for ElevenLabs agent
977
  prefs = prof.get("preferences") or {}
978
  last_store = (prefs.get("last_best_store") or "").strip() or None
979
-
980
  # quick stats from recent chats (last 25)
981
- logs = db.collection("pricelyst_profiles").document(profile_id).collection("chat_logs") \
982
- .order_by("ts", direction=firestore.Query.DESCENDING).limit(25).stream()
983
-
984
- intents = []
985
- for d in logs:
986
- dd = d.to_dict() or {}
987
- ii = (dd.get("intent") or {}).get("intent")
988
- if ii:
989
- intents.append(ii)
990
-
991
  intent_counts: Dict[str, int] = {}
992
- for ii in intents:
993
- intent_counts[ii] = intent_counts.get(ii, 0) + 1
 
 
 
 
 
 
 
 
 
 
 
 
 
994
 
995
  # --- KPI Snapshot Logic ---
996
  # We construct a dictionary that the React client will pass as a JSON string
@@ -1023,23 +1077,36 @@ def log_call_usage():
1023
  started_at = body.get("started_at") or None
1024
  ended_at = body.get("ended_at") or None
1025
  stats = body.get("stats") or {}
 
 
1026
 
1027
  prof = get_profile(profile_id)
1028
 
1029
  # --- UPGRADE: Use Shopping Plan Generator (JSON + Markdown) ---
1030
- plan_data = generate_shopping_plan(transcript)
1031
  plan_id = None
1032
  report_md = ""
 
1033
 
1034
- if plan_data.get("is_actionable"):
1035
- # Save structured plan
1036
- plan_ref = db.collection("pricelyst_profiles").document(profile_id).collection("shopping_plans").document()
1037
- plan_data["id"] = plan_ref.id
1038
- plan_data["call_id"] = call_id
1039
- plan_data["created_at"] = now_utc_iso()
1040
- plan_ref.set(plan_data)
1041
- plan_id = plan_ref.id
1042
- report_md = plan_data.get("markdown_content", "")
 
 
 
 
 
 
 
 
 
 
 
1043
 
1044
  # Log the call (link the plan_id)
1045
  doc_id = log_call(profile_id, {
@@ -1051,10 +1118,13 @@ def log_call_usage():
1051
  "generated_plan_id": plan_id,
1052
  "report_markdown": report_md,
1053
  })
1054
-
1055
  # update counters
1056
- counters = prof.get("counters") or {}
1057
- update_profile(profile_id, {"counters": {"calls": int(counters.get("calls", 0)) + 1}})
 
 
 
1058
 
1059
  return jsonify({
1060
  "ok": True,
@@ -1062,37 +1132,46 @@ def log_call_usage():
1062
  "shopping_plan": plan_data if plan_id else None # Frontend uses this for PDF
1063
  })
1064
 
1065
- # --- NEW: Shopping Plans CRUD ---
 
1066
  @app.get("/api/shopping-plans")
1067
  def list_plans():
1068
  pid = request.args.get("profile_id")
1069
  if not pid: return jsonify({"ok": False}), 400
1070
  try:
1071
- docs = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans")\
1072
- .order_by("created_at", direction=firestore.Query.DESCENDING).limit(20).stream()
1073
  plans = [{"id": d.id, **d.to_dict()} for d in docs]
1074
  return jsonify({"ok": True, "plans": plans})
1075
  except Exception as e:
 
1076
  return jsonify({"ok": False, "error": str(e)}), 500
1077
 
1078
  @app.get("/api/shopping-plans/<plan_id>")
1079
  def get_plan(plan_id):
1080
  pid = request.args.get("profile_id")
1081
  if not pid: return jsonify({"ok": False}), 400
1082
- doc = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document(plan_id).get()
1083
- if not doc.exists: return jsonify({"ok": False, "error": "Not found"}), 404
1084
- return jsonify({"ok": True, "plan": doc.to_dict()})
 
 
 
1085
 
1086
  @app.delete("/api/shopping-plans/<plan_id>")
1087
  def delete_plan(plan_id):
1088
  pid = request.args.get("profile_id")
1089
  if not pid: return jsonify({"ok": False}), 400
1090
- db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document(plan_id).delete()
1091
- return jsonify({"ok": True})
 
 
 
1092
 
1093
  # =========================
1094
  # Run
1095
  # =========================
 
1096
  if __name__ == "__main__":
1097
  port = int(os.environ.get("PORT", "7860"))
1098
  app.run(host="0.0.0.0", port=port, debug=True)
 
9
  ✅ Call briefing (Zim Essentials Injection)
10
  ✅ Post-call Shopping Plan Generation (PDF-ready)
11
 
12
+ ENV VARS YOU NEED:
13
  - GOOGLE_API_KEY=...
14
  - FIREBASE='{"type":"service_account", ...}' # full JSON string
15
  - PRICE_API_BASE=https://api.pricelyst.co.zw # optional
 
33
  from flask import Flask, request, jsonify
34
  from flask_cors import CORS
35
 
36
+ # ––––– Logging –––––
37
+
38
  logging.basicConfig(
39
  level=logging.INFO,
40
  format="%(asctime)s | %(levelname)s | %(message)s"
41
  )
42
  logger = logging.getLogger("pricelyst-advisor")
43
 
44
+ # ––––– Gemini (NEW SDK) –––––
45
+
46
  # pip install google-genai
47
+
48
  try:
49
  from google import genai
50
  from google.genai import types
 
63
  except Exception as e:
64
  logger.error("Failed to init Gemini client: %s", e)
65
 
66
+ # ––––– Firebase Admin –––––
67
+
68
  # pip install firebase-admin
69
+
70
  import firebase_admin
71
  from firebase_admin import credentials, firestore
72
 
73
  FIREBASE_ENV = os.environ.get("FIREBASE", "")
74
 
75
  def init_firestore_from_env() -> firestore.Client:
76
+ # 1. Check if already initialized
77
  if firebase_admin._apps:
78
  return firestore.client()
79
 
80
+ # 2. Check for Creds
81
  if not FIREBASE_ENV:
82
+ logger.critical("FIREBASE env var missing. Persistence will fail.")
83
  raise RuntimeError("FIREBASE env var missing. Provide full service account JSON string.")
84
 
85
+ try:
86
+ sa_info = json.loads(FIREBASE_ENV)
87
+ cred = credentials.Certificate(sa_info)
88
+ firebase_admin.initialize_app(cred)
89
+ logger.info("Firebase initialized successfully.")
90
+ return firestore.client()
91
+ except Exception as e:
92
+ logger.critical("Failed to initialize Firebase: %s", e)
93
+ raise e
94
+
95
+ try:
96
+ db = init_firestore_from_env()
97
+ except Exception as e:
98
+ logger.error("DB Init failed: %s", e)
99
+ db = None
100
 
101
+ # ––––– External API (Pricelyst) –––––
102
 
 
103
  PRICE_API_BASE = os.environ.get("PRICE_API_BASE", "https://api.pricelyst.co.zw").rstrip("/")
104
  HTTP_TIMEOUT = 20
105
 
106
+ # ––––– Flask –––––
107
+
108
  app = Flask(__name__)
109
  CORS(app)
110
 
111
+ # ––––– In-memory product cache –––––
112
+
113
  PRODUCT_CACHE_TTL_SEC = 60 * 10 # 10 minutes
114
  _product_cache: Dict[str, Any] = {
115
  "ts": 0,
 
117
  "raw_count": 0,
118
  }
119
 
120
+ # ––––– Static Data (New Feature) –––––
121
+
122
  ZIM_ESSENTIALS = {
123
  "fuel_petrol": "$1.58/L (Blend)",
124
  "fuel_diesel": "$1.65/L (Diesel 50)",
 
130
  # =========================
131
  # Helpers: time / strings
132
  # =========================
133
+
134
  def now_utc_iso() -> str:
135
  return datetime.now(timezone.utc).isoformat()
136
 
 
162
  # =========================
163
  # Firestore profile storage
164
  # =========================
165
+
166
  def profile_ref(profile_id: str):
167
+ if not db: return None
168
  return db.collection("pricelyst_profiles").document(profile_id)
169
 
170
  def get_profile(profile_id: str) -> Dict[str, Any]:
171
+ if not db:
172
+ return {}
173
+ try:
174
+ ref = profile_ref(profile_id)
175
+ doc = ref.get()
176
+ if doc.exists:
177
+ return doc.to_dict() or {}
178
+ # create default
179
+ data = {
180
+ "profile_id": profile_id,
181
+ "created_at": now_utc_iso(),
182
+ "updated_at": now_utc_iso(),
183
+ "username": None,
184
+ "memory_summary": "",
185
+ "preferences": {},
186
+ "last_actions": [],
187
+ "counters": {
188
+ "chats": 0,
189
+ "calls": 0,
190
+ }
191
  }
192
+ ref.set(data)
193
+ return data
194
+ except Exception as e:
195
+ logger.error("get_profile error for %s: %s", profile_id, e)
196
+ return {}
197
 
198
  def update_profile(profile_id: str, patch: Dict[str, Any]) -> None:
199
+ if not db: return
200
+ try:
201
+ patch = dict(patch or {})
202
+ patch["updated_at"] = now_utc_iso()
203
+ profile_ref(profile_id).set(patch, merge=True)
204
+ except Exception as e:
205
+ logger.error("update_profile error: %s", e)
206
 
207
  def log_chat(profile_id: str, payload: Dict[str, Any]) -> None:
208
+ if not db:
209
+ logger.warning("DB not connected, skipping log_chat")
210
+ return
211
+ try:
212
+ logger.info("Logging chat for %s. Type: %s", profile_id, payload.get("response_type"))
213
+ db.collection("pricelyst_profiles").document(profile_id).collection("chat_logs").add({
214
+ **payload,
215
+ "ts": now_utc_iso()
216
+ })
217
+ except Exception as e:
218
+ logger.error("Failed to log chat: %s", e)
219
 
220
  def log_call(profile_id: str, payload: Dict[str, Any]) -> str:
221
+ if not db:
222
+ logger.warning("DB not connected, skipping log_call")
223
+ return ""
224
+ try:
225
+ logger.info("Logging call for %s. Transcript len: %s", profile_id, len(payload.get("transcript", "")))
226
+ doc_ref = db.collection("pricelyst_profiles").document(profile_id).collection("call_logs").document()
227
+ doc_ref.set({
228
+ **payload,
229
+ "ts": now_utc_iso()
230
+ })
231
+ logger.info("Call logged successfully. ID: %s", doc_ref.id)
232
+ return doc_ref.id
233
+ except Exception as e:
234
+ logger.error("Failed to log call: %s", e)
235
+ return ""
236
 
237
  # =========================
238
  # Multimodal image handling
239
  # =========================
240
+
241
  def parse_images(images: List[str]) -> List[Dict[str, Any]]:
242
  """
243
  Accepts:
244
+ - data URLs: data:image/png;base64,....
245
+ - raw base64 strings
246
+ - http(s) URLs
247
  Returns: list of { "mime": "...", "bytes": b"..." } or { "url": "..." }
248
  """
249
  out = []
 
251
  if not item:
252
  continue
253
  item = item.strip()
254
+
255
  # URL
256
  if item.startswith("http://") or item.startswith("https://"):
257
  out.append({"url": item})
 
267
  except Exception:
268
  continue
269
  continue
270
+
271
  # raw base64
272
  try:
273
  out.append({"mime": "image/png", "bytes": base64.b64decode(item)})
274
  except Exception:
275
  continue
276
+
277
  return out
278
 
279
  # =========================
280
  # Product fetching + offers DF
281
  # =========================
282
+
283
  def fetch_products_page(page: int, per_page: int = 50) -> Dict[str, Any]:
284
  url = f"{PRICE_API_BASE}/api/v1/products"
285
  params = {"page": page, "perPage": per_page}
 
288
  return r.json()
289
 
290
  def fetch_products(max_pages: int = 6, per_page: int = 50) -> List[Dict[str, Any]]:
 
 
 
 
291
  products: List[Dict[str, Any]] = []
292
  for p in range(1, max_pages + 1):
293
  payload = fetch_products_page(p, per_page=per_page)
 
302
  return products
303
 
304
  def products_to_offers_df(products: List[Dict[str, Any]]) -> pd.DataFrame:
 
 
 
 
305
  rows = []
306
  for p in products or []:
307
  try:
308
  product_id = p.get("id")
309
  name = p.get("name") or ""
310
  clean_name = _norm_str(name)
311
+
312
  brand_name = ((p.get("brand") or {}).get("brand_name")) if isinstance(p.get("brand"), dict) else None
313
  categories = p.get("categories") or []
314
  cat_names = []
 
316
  if isinstance(c, dict) and c.get("name"):
317
  cat_names.append(c.get("name"))
318
  primary_category = cat_names[0] if cat_names else None
319
+
320
  stock_status = p.get("stock_status")
321
  on_promo = bool(p.get("on_promotion"))
322
  promo_badge = p.get("promo_badge")
323
  promo_name = p.get("promo_name")
324
  promo_price = _coerce_float(p.get("promo_price"))
325
  original_price = _coerce_float(p.get("original_price"))
326
+
327
  recommended_price = _coerce_float(p.get("recommended_price"))
328
  base_price = _coerce_float(p.get("price"))
329
  bulk_price = _coerce_float(p.get("bulk_price"))
330
  bulk_unit = p.get("bulk_unit")
331
+
332
  image = p.get("image")
333
  thumb = p.get("thumbnail")
334
+
335
  offers = p.get("prices") or []
336
  if not offers:
337
  rows.append({
 
396
  df = pd.DataFrame(rows)
397
  if df.empty:
398
  return df
399
+
400
  df["offer_price"] = df["offer_price"].apply(_coerce_float)
401
  df["clean_name"] = df["clean_name"].fillna("").astype(str)
402
  df["product_name"] = df["product_name"].fillna("").astype(str)
 
426
  # =========================
427
  # Gemini wrappers
428
  # =========================
429
+
430
  def gemini_generate_text(system: str, user: str, temperature: float = 0.4) -> str:
431
  if not _gemini_client:
432
  return ""
 
470
  def gemini_generate_multimodal(system: str, user: str, images: List[Dict[str, Any]]) -> str:
471
  """
472
  Uses Gemini multimodal:
473
+ - if we have bytes -> inline_data
474
+ - if we have url -> just paste the URL (server-side fetch is unreliable w/o whitelisting),
475
+ so we prefer bytes from the client.
476
  """
477
  if not _gemini_client:
478
  return ""
479
 
480
  parts: List[Dict[str, Any]] = [{"text": system.strip() + "\n\n" + user.strip()}]
481
+
482
  for img in images or []:
483
  if "bytes" in img and img.get("mime"):
484
  b64 = base64.b64encode(img["bytes"]).decode("utf-8")
 
509
  # =========================
510
  # Intent + actionability
511
  # =========================
512
+
513
  INTENT_SYSTEM = """
514
  You are Pricelyst AI. Your job: understand whether the user is asking for actionable shopping help.
515
  Return STRICT JSON only.
516
 
517
  Output schema:
518
  {
519
+ "actionable": true|false,
520
+ "intent": one of [
521
  "store_recommendation",
522
  "price_lookup",
523
  "price_compare",
 
528
  "chit_chat",
529
  "lifestyle_lookup",
530
  "other"
531
+ ],
532
+ "items": [{"name": "...", "quantity": 1}],
533
+ "constraints": {"budget": number|null, "location": "... "|null, "time_context": "mid-month|month-end|weekend|today|unknown"},
534
+ "notes": "short reasoning"
535
  }
536
 
537
  Rules:
 
549
  clean_k = k.split('_')[-1] # fuel_petrol -> petrol
550
  if clean_k in msg_lower and "price" in msg_lower:
551
  return {"actionable": True, "intent": "lifestyle_lookup", "items": [{"name": k}]}
552
+
553
  # 2. Gemini Detection
554
  ctx_str = json.dumps(context or {}, ensure_ascii=False)
555
  user = f"Message: {message}\nImagesPresent: {images_present}\nContext: {ctx_str}"
556
+
557
  # Try using the strict JSON helper first for better reliability
558
  try:
559
  data = gemini_generate_json(INTENT_SYSTEM, user)
 
562
  # Fallback to text parsing if JSON mode fails (Backward Compat)
563
  out = gemini_generate_text(INTENT_SYSTEM, user, temperature=0.1)
564
  data = _safe_json_loads(out, fallback={})
565
+
566
  if not isinstance(data, dict):
567
  return {"actionable": False, "intent": "other", "items": [], "constraints": {}, "notes": "bad_json"}
568
  # normalize
 
575
  # =========================
576
  # Shopping Plan Generator (NEW)
577
  # =========================
578
+
579
  PLAN_SYSTEM_PROMPT = """
580
  You are Jessica, the Pricelyst Shopping Advisor. Analyze the conversation transcript.
581
  If the user discussed a shopping list, budget plan, or event needs, create a structured plan.
582
 
583
  OUTPUT JSON SCHEMA:
584
  {
585
+ "is_actionable": boolean,
586
+ "title": "Short title (e.g. 'Weekend Braai List')",
587
+ "summary": "1 sentence summary",
588
+ "items": [{"name": "string", "qty": "string", "est_price": number|null}],
589
+ "markdown_content": "A clean Markdown report for a PDF. Include headers (#), bullet points, and a budget summary table if applicable. Keep it professional."
590
  }
591
 
592
  If no shopping/planning occurred, set is_actionable=false.
 
600
  # =========================
601
  # Matching + analytics
602
  # =========================
603
+
604
  def search_products(df: pd.DataFrame, query: str, limit: int = 10) -> pd.DataFrame:
605
  """
606
  Simple search: contains on clean_name + fallback token overlap scoring.
 
611
  q = _norm_str(query)
612
  if not q:
613
  return df.head(0)
614
+
615
  # direct contains
616
  hit = df[df["clean_name"].str.contains(re.escape(q), na=False)]
617
  if len(hit) >= limit:
618
  return hit.head(limit)
619
+
620
  # token overlap (cheap scoring)
621
  q_tokens = set(q.split())
622
  if not q_tokens:
623
  return hit.head(limit)
624
+
625
  tmp = df.copy()
626
  tmp["score"] = tmp["clean_name"].apply(lambda s: len(q_tokens.intersection(set(str(s).split()))))
627
  tmp = tmp[tmp["score"] > 0].sort_values(["score"], ascending=False)
 
632
  """
633
  For one product name, there can be multiple retailers (offers).
634
  We return:
635
+ - cheapest offer
636
+ - price range
637
+ - top offers
638
  """
639
  if df_hits.empty:
640
  return {}
641
+
642
  # group by product_id (best is highest offer coverage)
643
  grp = df_hits.groupby("product_id").size().sort_values(ascending=False)
644
  best_pid = int(grp.index[0])
645
  prod_rows = df_hits[df_hits["product_id"] == best_pid].copy()
646
+
647
  prod_name = prod_rows["product_name"].iloc[0]
648
  brand = prod_rows["brand_name"].iloc[0]
649
  category = prod_rows["primary_category"].iloc[0]
 
651
  on_promo = bool(prod_rows["on_promotion"].iloc[0])
652
  promo_badge = prod_rows["promo_badge"].iloc[0]
653
  image = prod_rows["thumbnail"].iloc[0] or prod_rows["image"].iloc[0]
654
+
655
  offers = prod_rows[prod_rows["offer_price"].notna()].copy()
656
  offers = offers.sort_values("offer_price", ascending=True)
657
+
658
  if offers.empty:
659
  return {
660
  "product_id": best_pid,
 
677
  }
678
  lo = float(offers["offer_price"].min())
679
  hi = float(offers["offer_price"].max())
680
+
681
  top_offers = []
682
  for _, r in offers.head(5).iterrows():
683
  top_offers.append({
 
685
  "price": float(r["offer_price"]),
686
  "retailer_logo": r["retailer_logo"],
687
  })
688
+
689
  return {
690
  "product_id": best_pid,
691
  "name": prod_name,
 
703
  def basket_store_choice(df: pd.DataFrame, items: List[Dict[str, Any]]) -> Dict[str, Any]:
704
  """
705
  Given items, pick:
706
+ - best single store to cover most items and minimize total
707
  Very pragmatic MVP: for each item, match the best product and take cheapest offer.
708
  """
709
  if df.empty or not items:
710
  return {"items": [], "best_store": None, "missing": []}
711
+
712
  results = []
713
  missing = []
714
+
715
  for it in items:
716
  name = it.get("name") or ""
717
  qty = int(it.get("quantity") or 1)
 
732
  "offers": summary.get("offers", []),
733
  "image": summary.get("image"),
734
  })
735
+
736
  if not results:
737
  return {"items": [], "best_store": None, "missing": missing}
738
+
739
  # compute totals by retailer for "all cheapest per item"
740
  retailer_totals: Dict[str, float] = {}
741
  retailer_counts: Dict[str, int] = {}
 
743
  k = r["cheapest_retailer"]
744
  retailer_totals[k] = retailer_totals.get(k, 0.0) + float(r["line_total"])
745
  retailer_counts[k] = retailer_counts.get(k, 0) + 1
746
+
747
  # Score: cover_count desc, then total asc
748
  best = sorted(retailer_totals.keys(), key=lambda k: (-retailer_counts.get(k, 0), retailer_totals.get(k, 0.0)))[0]
749
  return {
 
760
  # =========================
761
  # Response rendering (informative)
762
  # =========================
763
+
764
  def render_price_answer(summary: Dict[str, Any]) -> Dict[str, Any]:
765
  """
766
  Returns structured payload for frontend to render nicely.
 
781
  image = summary.get("image")
782
  cheapest = summary.get("cheapest")
783
  pr = summary.get("price_range")
784
+
785
  lines = []
786
  if cheapest:
787
  lines.append(f"Cheapest right now: {cheapest['retailer']} — ${cheapest['price']:.2f}")
 
789
  lines.append(f"Price range: ${pr['min']:.2f} → ${pr['max']:.2f} (spread ${pr['spread']:.2f})")
790
  if on_promo:
791
  lines.append(f"Promo: {promo_badge or 'On promotion'}")
792
+
793
  return {
794
  "type": "product_price",
795
  "title": name,
 
816
  "best_store": best,
817
  "items": basket["items"],
818
  "missing": missing,
819
+ "notes": "If you want, tell me your budget and I'll suggest cheaper substitutes.",
820
  }
821
 
822
  # =========================
823
  # Multimodal extraction (lists / receipts)
824
  # =========================
825
+
826
  VISION_SYSTEM = """
827
  You are an expert shopping assistant. Extract actionable items and quantities from the user's image(s).
828
  Return STRICT JSON only.
829
 
830
  Output schema:
831
  {
832
+ "actionable": true|false,
833
+ "items": [{"name":"...", "quantity": 1}],
834
+ "notes": "short"
835
  }
836
 
837
  Rules:
838
  - If it looks like a handwritten shopping list, extract items.
839
  - If it looks like a receipt, extract the purchased items (best-effort).
840
+ - If it's random (selfie, meme, etc), actionable=false and items=[].
841
+ - Keep it conservative: only include items you're confident about.
842
  """
843
 
844
  def extract_items_from_images(images: List[Dict[str, Any]]) -> Dict[str, Any]:
 
856
  # =========================
857
  # Routes
858
  # =========================
859
+
860
  @app.get("/health")
861
  def health():
862
  return jsonify({
863
  "ok": True,
864
  "ts": now_utc_iso(),
865
  "gemini": bool(_gemini_client),
866
+ "firestore": bool(db),
867
  "products_cached_rows": int(len(_product_cache["df_offers"])) if isinstance(_product_cache["df_offers"], pd.DataFrame) else 0,
868
  "products_raw_count": int(_product_cache.get("raw_count", 0)),
869
  })
 
919
 
920
  # 4) Actionable: execute
921
  df = get_offers_df(force_refresh=False)
922
+
923
  response_payload: Dict[str, Any] = {"type": "unknown", "message": "No result."}
924
 
925
  # --- NEW: Check for Lifestyle/Essentials (Fuel/ZESA) ---
 
974
  hits = search_products(df, it.get("name") or "", limit=60)
975
  summary = summarize_offers(hits)
976
  comparisons.append(summary)
977
+
978
  # compute cheapest for each
979
  rows = []
980
  for s in comparisons:
 
1019
 
1020
  username = body.get("username")
1021
  prof = get_profile(profile_id)
1022
+
1023
  if username and not prof.get("username"):
1024
  update_profile(profile_id, {"username": username})
1025
  prof["username"] = username
 
1027
  # Build lightweight "shopping intelligence" variables for ElevenLabs agent
1028
  prefs = prof.get("preferences") or {}
1029
  last_store = (prefs.get("last_best_store") or "").strip() or None
1030
+
1031
  # quick stats from recent chats (last 25)
 
 
 
 
 
 
 
 
 
 
1032
  intent_counts: Dict[str, int] = {}
1033
+ try:
1034
+ logs = db.collection("pricelyst_profiles").document(profile_id).collection("chat_logs") \
1035
+ .order_by("ts", direction=firestore.Query.DESCENDING).limit(25).stream()
1036
+
1037
+ intents = []
1038
+ for d in logs:
1039
+ dd = d.to_dict() or {}
1040
+ ii = (dd.get("intent") or {}).get("intent")
1041
+ if ii:
1042
+ intents.append(ii)
1043
+
1044
+ for ii in intents:
1045
+ intent_counts[ii] = intent_counts.get(ii, 0) + 1
1046
+ except Exception as e:
1047
+ logger.error("Error fetching call briefing chat history: %s", e)
1048
 
1049
  # --- KPI Snapshot Logic ---
1050
  # We construct a dictionary that the React client will pass as a JSON string
 
1077
  started_at = body.get("started_at") or None
1078
  ended_at = body.get("ended_at") or None
1079
  stats = body.get("stats") or {}
1080
+
1081
+ logger.info("Received call usage for %s. Transcript len: %d", profile_id, len(transcript))
1082
 
1083
  prof = get_profile(profile_id)
1084
 
1085
  # --- UPGRADE: Use Shopping Plan Generator (JSON + Markdown) ---
 
1086
  plan_id = None
1087
  report_md = ""
1088
+ plan_data = {}
1089
 
1090
+ try:
1091
+ if transcript:
1092
+ logger.info("Generating shopping plan via Gemini...")
1093
+ plan_data = generate_shopping_plan(transcript)
1094
+ logger.info("Plan generated. actionable=%s, title=%s", plan_data.get("is_actionable"), plan_data.get("title"))
1095
+
1096
+ if plan_data.get("is_actionable"):
1097
+ # Save structured plan
1098
+ plan_ref = db.collection("pricelyst_profiles").document(profile_id).collection("shopping_plans").document()
1099
+ plan_data["id"] = plan_ref.id
1100
+ plan_data["call_id"] = call_id
1101
+ plan_data["created_at"] = now_utc_iso()
1102
+ plan_ref.set(plan_data)
1103
+ plan_id = plan_ref.id
1104
+ report_md = plan_data.get("markdown_content", "")
1105
+ logger.info("Shopping plan stored. ID=%s", plan_id)
1106
+ else:
1107
+ logger.info("No actionable shopping plan found in call.")
1108
+ except Exception as e:
1109
+ logger.error("Error generating/storing shopping plan: %s", e)
1110
 
1111
  # Log the call (link the plan_id)
1112
  doc_id = log_call(profile_id, {
 
1118
  "generated_plan_id": plan_id,
1119
  "report_markdown": report_md,
1120
  })
1121
+
1122
  # update counters
1123
+ try:
1124
+ counters = prof.get("counters") or {}
1125
+ update_profile(profile_id, {"counters": {"calls": int(counters.get("calls", 0)) + 1}})
1126
+ except Exception as e:
1127
+ logger.error("Error updating profile counters: %s", e)
1128
 
1129
  return jsonify({
1130
  "ok": True,
 
1132
  "shopping_plan": plan_data if plan_id else None # Frontend uses this for PDF
1133
  })
1134
 
1135
+ # NEW: Shopping Plans CRUD
1136
+
1137
  @app.get("/api/shopping-plans")
1138
  def list_plans():
1139
  pid = request.args.get("profile_id")
1140
  if not pid: return jsonify({"ok": False}), 400
1141
  try:
1142
+ docs = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans") \
1143
+ .order_by("created_at", direction=firestore.Query.DESCENDING).limit(20).stream()
1144
  plans = [{"id": d.id, **d.to_dict()} for d in docs]
1145
  return jsonify({"ok": True, "plans": plans})
1146
  except Exception as e:
1147
+ logger.error("list_plans error: %s", e)
1148
  return jsonify({"ok": False, "error": str(e)}), 500
1149
 
1150
  @app.get("/api/shopping-plans/<plan_id>")
1151
  def get_plan(plan_id):
1152
  pid = request.args.get("profile_id")
1153
  if not pid: return jsonify({"ok": False}), 400
1154
+ try:
1155
+ doc = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document(plan_id).get()
1156
+ if not doc.exists: return jsonify({"ok": False, "error": "Not found"}), 404
1157
+ return jsonify({"ok": True, "plan": doc.to_dict()})
1158
+ except Exception as e:
1159
+ return jsonify({"ok": False, "error": str(e)}), 500
1160
 
1161
  @app.delete("/api/shopping-plans/<plan_id>")
1162
  def delete_plan(plan_id):
1163
  pid = request.args.get("profile_id")
1164
  if not pid: return jsonify({"ok": False}), 400
1165
+ try:
1166
+ db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document(plan_id).delete()
1167
+ return jsonify({"ok": True})
1168
+ except Exception as e:
1169
+ return jsonify({"ok": False, "error": str(e)}), 500
1170
 
1171
  # =========================
1172
  # Run
1173
  # =========================
1174
+
1175
  if __name__ == "__main__":
1176
  port = int(os.environ.get("PORT", "7860"))
1177
  app.run(host="0.0.0.0", port=port, debug=True)