rairo commited on
Commit
9f8e288
·
verified ·
1 Parent(s): 2c5e6a5

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +177 -69
main.py CHANGED
@@ -1,11 +1,11 @@
1
  """
2
- main.py — Pricelyst Shopping Advisor (Jessica Edition 2026 - Upgrade v2.7)
3
 
4
- Fixed: Basket Comparison (Compares totals across ALL stores, showing missing items).
5
- Fixed: Brand Loyalty (Explicitly states if exact brand is missing & suggests closest).
6
- Logic: "Market Matrix" calculates basket cost for every retailer found.
7
- "Analyst Engine": Enhanced Data Flattening & Comparison Logic.
8
- "Visual Engine": Lists, Products, & Meal-to-Recipe recognition.
9
 
10
  ENV VARS:
11
  - GOOGLE_API_KEY=...
@@ -94,7 +94,7 @@ ZIM_CONTEXT = {
94
  "fuel_petrol": 1.58,
95
  "fuel_diesel": 1.65,
96
  "gas_lpg": 2.00,
97
- "bread_avg": 1.00,
98
  "zesa_step_1": {"limit": 50, "rate": 0.04},
99
  "zesa_step_2": {"limit": 150, "rate": 0.09},
100
  "zesa_step_3": {"limit": 9999, "rate": 0.14},
@@ -236,7 +236,7 @@ def get_market_index(force_refresh: bool = False) -> pd.DataFrame:
236
  return _data_cache["df"]
237
 
238
  # =========================
239
- # 2. Analyst Engine (Matrix & Fallbacks)
240
  # =========================
241
 
242
  def search_products_deep(df: pd.DataFrame, query: str, limit: int = 15) -> pd.DataFrame:
@@ -288,19 +288,16 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
288
  best_match = hits.iloc[0]
289
 
290
  # --- Brand Fidelity Check ---
291
- # Did the user ask for "Top Chef" but we got "Mega Basmati"?
292
  q_norm = _norm(item)
293
  res_norm = _norm(best_match['product_name'] + " " + best_match['brand'])
294
-
295
- # Simple heuristic: If query has 2+ words, and <50% of them are in result, it's a sub.
296
  q_tokens = q_norm.split()
297
  is_substitute = False
298
  if len(q_tokens) > 1:
299
  found_tokens = sum(1 for t in q_tokens if t in res_norm)
300
- if found_tokens < len(q_tokens) / 2: # Loose threshold
301
  is_substitute = True
302
 
303
- # Aggregate all offers for this specific product ID
304
  product_offers = hits[hits['product_name'] == best_match['product_name']].sort_values('price')
305
 
306
  offers_list = []
@@ -310,7 +307,7 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
310
  found_items.append({
311
  "query": item,
312
  "product_name": str(best_match['product_name']),
313
- "is_substitute": is_substitute, # KEY FEATURE
314
  "offers": offers_list,
315
  "best_price": offers_list[0]['price']
316
  })
@@ -319,7 +316,6 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
319
  return {"actionable": True, "found_items": [], "global_missing": missing_global}
320
 
321
  # 2. MARKET MATRIX (Comparison across all stores)
322
- # Get unique retailers involved in these products
323
  all_involved_retailers = set()
324
  for f in found_items:
325
  for o in f['offers']:
@@ -333,7 +329,6 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
333
  missing_in_store = []
334
 
335
  for item in found_items:
336
- # Find price at this retailer
337
  price = next((o['price'] for o in item['offers'] if o['retailer'] == retailer), None)
338
  if price:
339
  total_price += price
@@ -349,7 +344,6 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
349
  "missing_items": missing_in_store
350
  })
351
 
352
- # Sort Matrix: Most Items Found -> Lowest Price
353
  store_comparison.sort(key=lambda x: (-x['found_count'], x['total_price']))
354
 
355
  return {
@@ -357,7 +351,7 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
357
  "is_basket": len(found_items) > 1,
358
  "found_items": found_items,
359
  "global_missing": missing_global,
360
- "market_matrix": store_comparison[:4], # Top 4 comparison
361
  "best_store": store_comparison[0] if store_comparison else None,
362
  "preferred_retailer": preferred_retailer
363
  }
@@ -365,7 +359,6 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
365
  def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
366
  remaining = amount_usd / 1.06
367
  units = 0.0
368
- breakdown = []
369
 
370
  t1 = ZIM_CONTEXT["zesa_step_1"]
371
  cost_t1 = t1["limit"] * t1["rate"]
@@ -373,7 +366,6 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
373
  if remaining > cost_t1:
374
  units += t1["limit"]
375
  remaining -= cost_t1
376
- breakdown.append(f"First {t1['limit']}u @ ${t1['rate']}")
377
 
378
  t2 = ZIM_CONTEXT["zesa_step_2"]
379
  cost_t2 = t2["limit"] * t2["rate"]
@@ -381,30 +373,19 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
381
  if remaining > cost_t2:
382
  units += t2["limit"]
383
  remaining -= cost_t2
384
- breakdown.append(f"Next {t2['limit']}u @ ${t2['rate']}")
385
-
386
- t3 = ZIM_CONTEXT["zesa_step_3"]
387
- bought = remaining / t3["rate"]
388
- units += bought
389
- breakdown.append(f"Balance -> {bought:.1f}u @ ${t3['rate']}")
390
  else:
391
- bought = remaining / t2["rate"]
392
- units += bought
393
- breakdown.append(f"Balance -> {bought:.1f}u @ ${t2['rate']}")
394
  else:
395
- bought = remaining / t1["rate"]
396
- units += bought
397
- breakdown.append(f"All {bought:.1f}u @ ${t1['rate']}")
398
 
399
  return {
400
  "amount_usd": float(amount_usd),
401
- "est_units_kwh": float(round(units, 1)),
402
- "breakdown": breakdown,
403
- "note": "Includes approx 6% REA levy deduction."
404
  }
405
 
406
  # =========================
407
- # 3. Gemini Helpers
408
  # =========================
409
 
410
  def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
@@ -417,11 +398,13 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
417
  - SHOPPING_BASKET: Looking for prices, products, "cheapest X".
418
  - UTILITY_CALC: Electricity/ZESA questions.
419
  - STORE_DECISION: "Where should I buy?", "Which store is cheapest?".
 
420
 
421
  Extract:
422
- - items: list of products found.
423
  - utility_amount: number
424
  - store_preference: if a specific store is named (e.g. "at OK Mart").
 
425
 
426
  JSON Schema:
427
  {
@@ -429,7 +412,8 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
429
  "intent": "string",
430
  "items": ["string"],
431
  "utility_amount": number,
432
- "store_preference": "string"
 
433
  }
434
  """
435
  try:
@@ -443,6 +427,29 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
443
  logger.error(f"Intent Detect Error: {e}")
444
  return {"actionable": False, "intent": "CASUAL_CHAT"}
445
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  def gemini_analyze_image(image_b64: str, caption: str = "") -> Dict[str, Any]:
447
  if not _gemini_client: return {"error": "AI Offline"}
448
 
@@ -497,18 +504,15 @@ def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict, chat
497
 
498
  LOGIC RULES:
499
 
500
- 1. **BASKET COMPARISON (Transparency)**:
501
- - If `market_matrix` has multiple stores, **COMPARE THEM**.
502
- - Example: "Spar is **$6.95** (All items). OK Mart is **$4.00**, but misses Cooking Oil."
503
- - Don't just show the winner. Show the ecosystem.
504
 
505
- 2. **BRAND LOYALTY (Graceful Fallback)**:
506
- - If `is_substitute` is TRUE for an item, say:
507
- "I couldn't find **[Query Brand]** exactly, so I've used **[Found Product]** ($Price) as a placeholder."
508
- - Be honest about brand mismatches.
509
 
510
  3. **SINGLE ITEMS**:
511
- - Best price first, then list 1-2 others.
512
 
513
  4. **CASUAL**:
514
  - Reset if user says "Hi".
@@ -531,12 +535,32 @@ def gemini_generate_4step_plan(transcript: str, analyst_result: Dict) -> str:
531
 
532
  PROMPT = f"""
533
  Generate a formatted Markdown Shopping Plan.
 
 
534
  DATA: {json.dumps(analyst_result, indent=2, default=str)}
 
 
 
 
 
535
  SECTIONS:
536
- 1. **Catalogue Found ✅** (Table: Item | Store | Price)
537
- 2. **Missing/Substitutes ⚠️** (Be clear about brand swaps)
538
- 3. **Store Comparison 📊** (List the Top 3 stores totals)
539
- 4. **Recommendation 💡**
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  """
541
  try:
542
  resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=PROMPT)
@@ -555,7 +579,7 @@ def health():
555
  "ok": True,
556
  "offers_indexed": len(df),
557
  "api_source": PRICE_API_BASE,
558
- "persona": "Jessica v2.7 (Matrix & Loyalty)"
559
  })
560
 
561
  @app.post("/chat")
@@ -651,35 +675,87 @@ def analyze_image():
651
 
652
  @app.post("/api/call-briefing")
653
  def call_briefing():
654
- # ... (Same as before)
 
 
 
655
  body = request.get_json(silent=True) or {}
656
  pid = body.get("profile_id")
657
- username = body.get("username")
 
658
  if not pid: return jsonify({"ok": False}), 400
 
 
659
  prof = {}
660
  if db:
661
  ref = db.collection("pricelyst_profiles").document(pid)
662
  doc = ref.get()
663
  if doc.exists: prof = doc.to_dict()
664
  else: ref.set({"created_at": datetime.now(timezone.utc).isoformat()})
665
- if username and username != prof.get("username"):
 
666
  if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
 
 
667
  df = get_market_index()
668
- catalogue_str = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
669
  if not df.empty:
670
- top = df[df['is_offer']].sort_values('views', ascending=False).drop_duplicates('product_name').head(60)
671
- lines = [f"{r['product_name']} (~${r['price']:.2f})" for _, r in top.iterrows()]
672
- catalogue_str = ", ".join(lines)
673
- kpi_snapshot = {"market_rates": ZIM_CONTEXT, "popular_products": catalogue_str}
674
- return jsonify({"ok": True, "memory_summary": prof.get("memory_summary", ""), "kpi_snapshot": json.dumps(kpi_snapshot)})
 
 
 
 
 
 
 
 
 
 
675
 
676
  @app.post("/api/log-call-usage")
677
  def log_call_usage():
678
- # ... (Same as before)
 
 
 
679
  body = request.get_json(silent=True) or {}
680
  pid = body.get("profile_id")
681
  transcript = body.get("transcript", "")
 
682
  if not pid: return jsonify({"ok": False}), 400
 
 
683
  if len(transcript) > 20 and db:
684
  try:
685
  curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
@@ -687,27 +763,59 @@ def log_call_usage():
687
  mem_resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=mem_prompt)
688
  db.collection("pricelyst_profiles").document(pid).set({"memory_summary": mem_resp.text}, merge=True)
689
  except: pass
 
 
690
  intent_data = gemini_detect_intent(transcript)
691
  plan_data = {}
692
- if intent_data.get("actionable") and intent_data.get("items"):
693
- analyst_result = calculate_basket_optimization(intent_data["items"])
694
- if analyst_result.get("actionable"):
 
 
 
 
 
 
 
 
 
 
 
695
  md_content = gemini_generate_4step_plan(transcript, analyst_result)
696
- plan_data = {"is_actionable": True, "title": f"Plan {datetime.now().strftime('%d %b')}", "markdown_content": md_content, "items": intent_data["items"], "created_at": datetime.now(timezone.utc).isoformat()}
 
 
 
 
 
 
 
 
697
  if db:
698
  doc_ref = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document()
699
  plan_data["id"] = doc_ref.id
700
  doc_ref.set(plan_data)
 
701
  if db:
702
- db.collection("pricelyst_profiles").document(pid).collection("call_logs").add({"transcript": transcript, "intent": intent_data, "plan_generated": bool(plan_data), "ts": datetime.now(timezone.utc).isoformat()})
703
- return jsonify({"ok": True, "shopping_plan": plan_data if plan_data.get("is_actionable") else None})
 
 
 
 
 
 
 
 
 
704
 
705
  @app.get("/api/shopping-plans")
706
  def list_plans():
707
  pid = request.args.get("profile_id")
708
  if not pid or not db: return jsonify({"ok": False}), 400
709
  try:
710
- docs = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").order_by("created_at", direction=firestore.Query.DESCENDING).limit(10).stream()
 
711
  return jsonify({"ok": True, "plans": [{"id": d.id, **d.to_dict()} for d in docs]})
712
  except: return jsonify({"ok": False}), 500
713
 
 
1
  """
2
+ main.py — Pricelyst Shopping Advisor (Jessica Edition 2026 - Upgrade v3.0)
3
 
4
+ Feature: "Concept Exploder" (Converts "Plan a Braai" -> Shopping List).
5
+ Feature: "Hybrid Valuation" (Estimates prices for missing items in Plans).
6
+ Feature: "Market Intelligence" (Pre-calculated Voice Context).
7
+ UI Match: Restored v1 Markdown Tables & Creative Tips.
8
+ Core: Deep Vector Search + Market Matrix + Store Preferences.
9
 
10
  ENV VARS:
11
  - GOOGLE_API_KEY=...
 
94
  "fuel_petrol": 1.58,
95
  "fuel_diesel": 1.65,
96
  "gas_lpg": 2.00,
97
+ "bread_avg": 1.10,
98
  "zesa_step_1": {"limit": 50, "rate": 0.04},
99
  "zesa_step_2": {"limit": 150, "rate": 0.09},
100
  "zesa_step_3": {"limit": 9999, "rate": 0.14},
 
236
  return _data_cache["df"]
237
 
238
  # =========================
239
+ # 2. Analyst Engine (Matrix & Calculations)
240
  # =========================
241
 
242
  def search_products_deep(df: pd.DataFrame, query: str, limit: int = 15) -> pd.DataFrame:
 
288
  best_match = hits.iloc[0]
289
 
290
  # --- Brand Fidelity Check ---
 
291
  q_norm = _norm(item)
292
  res_norm = _norm(best_match['product_name'] + " " + best_match['brand'])
 
 
293
  q_tokens = q_norm.split()
294
  is_substitute = False
295
  if len(q_tokens) > 1:
296
  found_tokens = sum(1 for t in q_tokens if t in res_norm)
297
+ if found_tokens < len(q_tokens) / 2:
298
  is_substitute = True
299
 
300
+ # Aggregate all offers
301
  product_offers = hits[hits['product_name'] == best_match['product_name']].sort_values('price')
302
 
303
  offers_list = []
 
307
  found_items.append({
308
  "query": item,
309
  "product_name": str(best_match['product_name']),
310
+ "is_substitute": is_substitute,
311
  "offers": offers_list,
312
  "best_price": offers_list[0]['price']
313
  })
 
316
  return {"actionable": True, "found_items": [], "global_missing": missing_global}
317
 
318
  # 2. MARKET MATRIX (Comparison across all stores)
 
319
  all_involved_retailers = set()
320
  for f in found_items:
321
  for o in f['offers']:
 
329
  missing_in_store = []
330
 
331
  for item in found_items:
 
332
  price = next((o['price'] for o in item['offers'] if o['retailer'] == retailer), None)
333
  if price:
334
  total_price += price
 
344
  "missing_items": missing_in_store
345
  })
346
 
 
347
  store_comparison.sort(key=lambda x: (-x['found_count'], x['total_price']))
348
 
349
  return {
 
351
  "is_basket": len(found_items) > 1,
352
  "found_items": found_items,
353
  "global_missing": missing_global,
354
+ "market_matrix": store_comparison[:4],
355
  "best_store": store_comparison[0] if store_comparison else None,
356
  "preferred_retailer": preferred_retailer
357
  }
 
359
  def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
360
  remaining = amount_usd / 1.06
361
  units = 0.0
 
362
 
363
  t1 = ZIM_CONTEXT["zesa_step_1"]
364
  cost_t1 = t1["limit"] * t1["rate"]
 
366
  if remaining > cost_t1:
367
  units += t1["limit"]
368
  remaining -= cost_t1
 
369
 
370
  t2 = ZIM_CONTEXT["zesa_step_2"]
371
  cost_t2 = t2["limit"] * t2["rate"]
 
373
  if remaining > cost_t2:
374
  units += t2["limit"]
375
  remaining -= cost_t2
376
+ units += remaining / ZIM_CONTEXT["zesa_step_3"]["rate"]
 
 
 
 
 
377
  else:
378
+ units += remaining / t2["rate"]
 
 
379
  else:
380
+ units += remaining / t1["rate"]
 
 
381
 
382
  return {
383
  "amount_usd": float(amount_usd),
384
+ "est_units_kwh": float(round(units, 1))
 
 
385
  }
386
 
387
  # =========================
388
+ # 3. Gemini Helpers (The Intelligence)
389
  # =========================
390
 
391
  def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
 
398
  - SHOPPING_BASKET: Looking for prices, products, "cheapest X".
399
  - UTILITY_CALC: Electricity/ZESA questions.
400
  - STORE_DECISION: "Where should I buy?", "Which store is cheapest?".
401
+ - EVENT_PLANNING: "Plan a braai", "Wedding list", "Dinner for 5" (Implicit lists).
402
 
403
  Extract:
404
+ - items: list of specific products found.
405
  - utility_amount: number
406
  - store_preference: if a specific store is named (e.g. "at OK Mart").
407
+ - is_event_planning: boolean (true if user asks to plan an event but lists no items).
408
 
409
  JSON Schema:
410
  {
 
412
  "intent": "string",
413
  "items": ["string"],
414
  "utility_amount": number,
415
+ "store_preference": "string",
416
+ "is_event_planning": boolean
417
  }
418
  """
419
  try:
 
427
  logger.error(f"Intent Detect Error: {e}")
428
  return {"actionable": False, "intent": "CASUAL_CHAT"}
429
 
430
+ def gemini_explode_concept(transcript: str) -> List[str]:
431
+ """
432
+ Converts a concept ("Braai for 10") into a concrete list ("Wors", "Charcoal").
433
+ """
434
+ if not _gemini_client: return []
435
+
436
+ PROMPT = f"""
437
+ User wants to plan an event: "{transcript}".
438
+ Generate a STRICT list of 10-15 essential Zimbabwean shopping items for this.
439
+ Use local terms (e.g., 'Boerewors', 'Maize Meal', 'Mazoe', 'Charcoal').
440
+ Return ONLY a JSON list of strings.
441
+ """
442
+ try:
443
+ resp = _gemini_client.models.generate_content(
444
+ model=GEMINI_MODEL,
445
+ contents=PROMPT,
446
+ config=types.GenerateContentConfig(response_mime_type="application/json")
447
+ )
448
+ return _safe_json_loads(resp.text, [])
449
+ except Exception as e:
450
+ logger.error(f"Explode Concept Error: {e}")
451
+ return []
452
+
453
  def gemini_analyze_image(image_b64: str, caption: str = "") -> Dict[str, Any]:
454
  if not _gemini_client: return {"error": "AI Offline"}
455
 
 
504
 
505
  LOGIC RULES:
506
 
507
+ 1. **BASKET COMPARISON**:
508
+ - If `market_matrix` has multiple stores, compare totals.
509
+ - "Spar is **$6.95**, OK Mart is **$4.00** (but missing Oil)."
 
510
 
511
+ 2. **BRAND SUBSTITUTES**:
512
+ - If `is_substitute` is TRUE: "I couldn't find **[Query]**, so I used **[Found]** ($Price) as a placeholder."
 
 
513
 
514
  3. **SINGLE ITEMS**:
515
+ - Best price first, then others.
516
 
517
  4. **CASUAL**:
518
  - Reset if user says "Hi".
 
535
 
536
  PROMPT = f"""
537
  Generate a formatted Markdown Shopping Plan.
538
+
539
+ USER REQUEST: "{transcript}"
540
  DATA: {json.dumps(analyst_result, indent=2, default=str)}
541
+
542
+ CRITICAL INSTRUCTION:
543
+ For items in 'global_missing', you MUST provide a Realistic USD Estimate (e.g. Chicken ~$6.00).
544
+ Do not leave them as "Unknown".
545
+
546
  SECTIONS:
547
+
548
+ 1. **In Our Catalogue ✅**
549
+ (Markdown Table: | Item | Retailer | Price (USD) |)
550
+
551
+ 2. **Not in Catalogue (Estimates) 😔**
552
+ (Markdown Table: | Item | Estimated Price (USD) |)
553
+ *Fill in estimated prices for missing items based on Zimbabwe market knowledge.*
554
+
555
+ 3. **Totals 💰**
556
+ - Confirmed Total (Catalogue)
557
+ - Estimated Total (Missing Items)
558
+ - **Grand Total Estimate**
559
+
560
+ 4. **Ideas & Tips 💡**
561
+ - 3 Creative ideas based on the specific event/meal (e.g. Braai tips, Cooking hacks).
562
+
563
+ Tone: Warm, Professional, Zimbabwean.
564
  """
565
  try:
566
  resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=PROMPT)
 
579
  "ok": True,
580
  "offers_indexed": len(df),
581
  "api_source": PRICE_API_BASE,
582
+ "persona": "Jessica v3.0 (Event Planner)"
583
  })
584
 
585
  @app.post("/chat")
 
675
 
676
  @app.post("/api/call-briefing")
677
  def call_briefing():
678
+ """
679
+ Injects INTELLIGENT Market Data into the Voice Bot's context.
680
+ Includes: Staples Index, ZESA/Fuel, Top 60 Catalogue.
681
+ """
682
  body = request.get_json(silent=True) or {}
683
  pid = body.get("profile_id")
684
+ username = body.get("username", "Friend")
685
+
686
  if not pid: return jsonify({"ok": False}), 400
687
+
688
+ # 1. Memory Profile
689
  prof = {}
690
  if db:
691
  ref = db.collection("pricelyst_profiles").document(pid)
692
  doc = ref.get()
693
  if doc.exists: prof = doc.to_dict()
694
  else: ref.set({"created_at": datetime.now(timezone.utc).isoformat()})
695
+
696
+ if username != "Friend" and username != prof.get("username"):
697
  if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
698
+
699
+ # 2. Market Intelligence Generation
700
  df = get_market_index()
701
+ market_intel = ""
702
+
703
+ # A. ZESA & Fuel
704
+ zesa_10 = calculate_zesa_units(10.0)
705
+ zesa_20 = calculate_zesa_units(20.0)
706
+
707
+ context_section = f"""
708
+ [CRITICAL CONTEXT - ZIMBABWE]
709
+ FUEL: Petrol=${ZIM_CONTEXT['fuel_petrol']}, Diesel=${ZIM_CONTEXT['fuel_diesel']}
710
+ BREAD: ~${ZIM_CONTEXT['bread_avg']}
711
+ ZESA (Electricity): $10 = {zesa_10['est_units_kwh']}u, $20 = {zesa_20['est_units_kwh']}u
712
+ """
713
+
714
+ # B. Staples Index
715
+ staples = ["Cooking Oil", "Maize Meal", "Sugar", "Rice"]
716
+ staple_summary = []
717
+
718
+ if not df.empty:
719
+ for s in staples:
720
+ hits = search_products_deep(df[df['is_offer']==True], s, limit=5)
721
+ if not hits.empty:
722
+ cheapest = hits.sort_values('price').iloc[0]
723
+ staple_summary.append(f"- {s}: ${cheapest['price']} @ {cheapest['retailer']}")
724
+
725
+ staples_section = "\n[STAPLES - LOWEST]\n" + "\n".join(staple_summary)
726
+
727
+ # C. Top 60 Catalogue
728
+ catalogue_lines = []
729
  if not df.empty:
730
+ top_items = df[df['is_offer']==True].sort_values('views', ascending=False).drop_duplicates('product_name').head(60)
731
+ for _, r in top_items.iterrows():
732
+ p_name = r['product_name']
733
+ all_offers = df[(df['product_name'] == p_name) & df['is_offer']]
734
+ prices_str = ", ".join([f"${o['price']} ({o['retailer']})" for _, o in all_offers.iterrows()])
735
+ catalogue_lines.append(f"- {p_name}: {prices_str}")
736
+
737
+ catalogue_section = "\n[CATALOGUE - TOP 60]\n" + "\n".join(catalogue_lines)
738
+
739
+ return jsonify({
740
+ "ok": True,
741
+ "username": username,
742
+ "memory_summary": prof.get("memory_summary", ""),
743
+ "kpi_snapshot": context_section + staples_section + catalogue_section
744
+ })
745
 
746
  @app.post("/api/log-call-usage")
747
  def log_call_usage():
748
+ """
749
+ Post-Call Orchestrator.
750
+ v3.0 Upgrade: Handles Concept Explosion for Event Planning.
751
+ """
752
  body = request.get_json(silent=True) or {}
753
  pid = body.get("profile_id")
754
  transcript = body.get("transcript", "")
755
+
756
  if not pid: return jsonify({"ok": False}), 400
757
+
758
+ # 1. Update Long-Term Memory
759
  if len(transcript) > 20 and db:
760
  try:
761
  curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
 
763
  mem_resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=mem_prompt)
764
  db.collection("pricelyst_profiles").document(pid).set({"memory_summary": mem_resp.text}, merge=True)
765
  except: pass
766
+
767
+ # 2. Plan Generation Logic
768
  intent_data = gemini_detect_intent(transcript)
769
  plan_data = {}
770
+
771
+ # Check if ACTIONABLE (Shopping or Event)
772
+ if intent_data.get("actionable"):
773
+ target_items = intent_data.get("items", [])
774
+
775
+ # LOGIC: If Event Planning + No specific items -> EXPLODE CONCEPT
776
+ if intent_data.get("is_event_planning") and not target_items:
777
+ logger.info("💥 Exploding Concept for Event...")
778
+ target_items = gemini_explode_concept(transcript)
779
+
780
+ if target_items:
781
+ analyst_result = calculate_basket_optimization(target_items)
782
+
783
+ # v3.0: Even if missing items, we generate plan because prompt will ESTIMATE them
784
  md_content = gemini_generate_4step_plan(transcript, analyst_result)
785
+
786
+ plan_data = {
787
+ "is_actionable": True,
788
+ "title": f"Plan ({datetime.now().strftime('%d %b')})",
789
+ "markdown_content": md_content,
790
+ "items": target_items,
791
+ "created_at": datetime.now(timezone.utc).isoformat()
792
+ }
793
+
794
  if db:
795
  doc_ref = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document()
796
  plan_data["id"] = doc_ref.id
797
  doc_ref.set(plan_data)
798
+
799
  if db:
800
+ db.collection("pricelyst_profiles").document(pid).collection("call_logs").add({
801
+ "transcript": transcript,
802
+ "intent": intent_data,
803
+ "plan_generated": bool(plan_data),
804
+ "ts": datetime.now(timezone.utc).isoformat()
805
+ })
806
+
807
+ return jsonify({
808
+ "ok": True,
809
+ "shopping_plan": plan_data if plan_data.get("is_actionable") else None
810
+ })
811
 
812
  @app.get("/api/shopping-plans")
813
  def list_plans():
814
  pid = request.args.get("profile_id")
815
  if not pid or not db: return jsonify({"ok": False}), 400
816
  try:
817
+ docs = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans") \
818
+ .order_by("created_at", direction=firestore.Query.DESCENDING).limit(10).stream()
819
  return jsonify({"ok": True, "plans": [{"id": d.id, **d.to_dict()} for d in docs]})
820
  except: return jsonify({"ok": False}), 500
821