rairo commited on
Commit
486c74d
·
verified ·
1 Parent(s): c15c644

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +46 -58
main.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- main.py — Pricelyst Shopping Advisor (Jessica Edition 2026 - Upgrade v2.0)
3
 
4
  ✅ Flask API
5
  ✅ Firebase Admin Persistence
@@ -8,6 +8,7 @@ main.py — Pricelyst Shopping Advisor (Jessica Edition 2026 - Upgrade v2.0)
8
  ✅ "Jessica Engine": Natural Conversation, Chit-Chat, & Advisory
9
  ✅ "Visual Engine": Lists, Products, & Meal-to-Recipe recognition
10
  ✅ Type Safety: Explicit Casting for JSON Serialization
 
11
 
12
  ENV VARS:
13
  - GOOGLE_API_KEY=...
@@ -184,7 +185,6 @@ def fetch_and_flatten_data() -> pd.DataFrame:
184
 
185
  prices = p.get("prices") or []
186
 
187
- # If no prices, still index it for context but mark as no offer
188
  if not prices:
189
  rows.append({
190
  "product_id": p_id,
@@ -270,10 +270,8 @@ def search_products_fuzzy(df: pd.DataFrame, query: str, limit: int = 10) -> pd.D
270
  def get_category_stats(df: pd.DataFrame, category_name: str) -> Dict[str, Any]:
271
  """Returns min, max, avg price for a category to determine value."""
272
  if df.empty: return {}
273
- # Loose matching on category or name
274
  cat_df = df[df['category'].str.lower().str.contains(category_name.lower()) & df['is_offer']]
275
  if cat_df.empty:
276
- # Try name matching if category fails
277
  cat_df = df[df['clean_name'].str.contains(category_name.lower()) & df['is_offer']]
278
 
279
  if cat_df.empty: return {}
@@ -288,9 +286,7 @@ def get_category_stats(df: pd.DataFrame, category_name: str) -> Dict[str, Any]:
288
 
289
  def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
290
  """
291
- Optimizes for:
292
- 1. Best Single Store (Convenience)
293
- 2. Split Store (Cheapest possible mix)
294
  """
295
  df = get_market_index()
296
  if df.empty:
@@ -299,7 +295,6 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
299
  found_items = []
300
  missing_global = []
301
 
302
- # 1. Resolve Items
303
  for item in item_names:
304
  hits = search_products_fuzzy(df[df['is_offer']==True], item, limit=5)
305
  if hits.empty:
@@ -307,8 +302,6 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
307
  continue
308
 
309
  best_prod = hits.iloc[0]
310
-
311
- # Get category stats for the first item to help with "is this expensive" logic later
312
  cat_stats = get_category_stats(df, str(best_prod['category']))
313
 
314
  found_items.append({
@@ -328,7 +321,6 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
328
  "split_strategy": None
329
  }
330
 
331
- # 2. Calculate Retailer Totals (Single Store Strategy)
332
  target_pids = [x['product_id'] for x in found_items]
333
  relevant_offers = df[df['product_id'].isin(target_pids) & df['is_offer']]
334
 
@@ -354,13 +346,10 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
354
  retailer_stats.sort(key=lambda x: (-x['coverage_percent'], x['total_price']))
355
  best_single_store = retailer_stats[0] if retailer_stats else None
356
 
357
- # 3. Calculate Split Strategy (Cheapest Global)
358
- # For each found item, find the absolute minimum price across all stores
359
  split_basket = []
360
  split_total = 0.0
361
 
362
  for item in found_items:
363
- # Find offers for this specific product ID
364
  p_offers = relevant_offers[relevant_offers['product_id'] == item['product_id']]
365
  if not p_offers.empty:
366
  best_offer = p_offers.sort_values('price').iloc[0]
@@ -399,7 +388,7 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
399
  if remaining > cost_t1:
400
  units += t1["limit"]
401
  remaining -= cost_t1
402
- breakdown.append(f"First {t1['limit']} units at cheap rate (${t1['rate']}) ✅")
403
 
404
  t2 = ZIM_CONTEXT["zesa_step_2"]
405
  cost_t2 = t2["limit"] * t2["rate"]
@@ -407,20 +396,20 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
407
  if remaining > cost_t2:
408
  units += t2["limit"]
409
  remaining -= cost_t2
410
- breakdown.append(f"Next {t2['limit']} units at standard rate (${t2['rate']}) ⚡")
411
 
412
  t3 = ZIM_CONTEXT["zesa_step_3"]
413
  bought = remaining / t3["rate"]
414
  units += bought
415
- breakdown.append(f"Remainder bought at high tariff (${t3['rate']}) 💸")
416
  else:
417
  bought = remaining / t2["rate"]
418
  units += bought
419
- breakdown.append(f"Remainder bought at standard rate (${t2['rate']}) ⚡")
420
  else:
421
  bought = remaining / t1["rate"]
422
  units += bought
423
- breakdown.append(f"All units at cheapest 'Lifeline' rate (${t1['rate']}) 💚")
424
 
425
  return {
426
  "amount_usd": float(amount_usd),
@@ -434,10 +423,6 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
434
  # =========================
435
 
436
  def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
437
- """
438
- Expanded Intent Classification.
439
- Detects: CASUAL, SHOPPING_BASKET, UTILITY, STORE_DECISION, TRUST_CHECK
440
- """
441
  if not _gemini_client: return {"actionable": False}
442
 
443
  PROMPT = """
@@ -452,7 +437,7 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
452
  Extract:
453
  - items: list of products
454
  - utility_amount: number
455
- - context: "budget", "speed", "quality" (if mentioned)
456
 
457
  JSON Schema:
458
  {
@@ -475,10 +460,6 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
475
  return {"actionable": False, "intent": "CASUAL_CHAT"}
476
 
477
  def gemini_analyze_image(image_b64: str, caption: str = "") -> Dict[str, Any]:
478
- """
479
- Multimodal Image Analysis.
480
- Determines if image is a Shopping List, Product, or Meal.
481
- """
482
  if not _gemini_client: return {"error": "AI Offline"}
483
 
484
  PROMPT = f"""
@@ -505,20 +486,17 @@ def gemini_analyze_image(image_b64: str, caption: str = "") -> Dict[str, Any]:
505
  ],
506
  config=types.GenerateContentConfig(response_mime_type="application/json")
507
  )
508
- return _safe_json_loads(resp.text, {"type": "IRRELEVANT", "items": []})
 
 
509
  except Exception as e:
510
  logger.error(f"Vision Error: {e}")
511
  return {"type": "IRRELEVANT", "items": []}
512
 
513
- def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict, memory_summary: str = "") -> str:
514
- """
515
- The Persona Engine (Jessica).
516
- Synthesizes Analyst Data + ZESA Context + Memory into a natural response.
517
- """
518
  if not _gemini_client: return "I'm having trouble connecting to my brain right now."
519
 
520
- # Context Construction
521
- context_str = f"USER MEMORY: {memory_summary}\n" if memory_summary else ""
522
  context_str += f"ZIMBABWE CONTEXT: Fuel={ZIM_CONTEXT['fuel_petrol']}, ZESA Rate={ZIM_CONTEXT['zesa_step_1']['rate']}\n"
523
 
524
  if analyst_data:
@@ -534,7 +512,7 @@ def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict, memo
534
  {context_str}
535
 
536
  INSTRUCTIONS:
537
- 1. **Casual Chat**: If intent is CASUAL, be warm. "Makadii! How can I help you save today?"
538
  2. **Shopping Advice**:
539
  - If data exists, guide them. "I found XYZ at Store A for $5."
540
  - If 'best_store' exists, recommend it explicitly based on coverage.
@@ -544,7 +522,7 @@ def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict, memo
544
  - If price is > avg, say "That's a bit pricey, average is $X."
545
  4. **ZESA**: Explain the units naturally using the breakdown provided.
546
 
547
- TONE: Conversational, Zimbabwean English (use 'USD', maybe 'shame' or 'eish' rarely if bad news, but professional).
548
  Do NOT dump JSON. Write a natural message. Use Markdown for lists/prices.
549
  """
550
 
@@ -559,9 +537,6 @@ def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict, memo
559
  return "I found the data, but I'm struggling to summarize it. Please check the plan below."
560
 
561
  def gemini_generate_4step_plan(transcript: str, analyst_result: Dict) -> str:
562
- """
563
- Generates the Formal Shopping Plan (Markdown).
564
- """
565
  if not _gemini_client: return "# Error\nAI Offline."
566
 
567
  PROMPT = f"""
@@ -573,8 +548,8 @@ def gemini_generate_4step_plan(transcript: str, analyst_result: Dict) -> str:
573
  1. **In Our Catalogue ✅** (Table: Item | Store | Price)
574
  2. **Not in Catalogue 😔** (Estimates)
575
  3. **Recommendation 💡**
576
- - "Best Single Store" vs "Split & Save" (if split saves money).
577
- 4. **Budget Tips** (Based on items, e.g., generic brands).
578
 
579
  Make it look professional yet friendly.
580
  """
@@ -595,14 +570,14 @@ def health():
595
  "ok": True,
596
  "offers_indexed": len(df),
597
  "api_source": PRICE_API_BASE,
598
- "persona": "Jessica v2.0"
599
  })
600
 
601
  @app.post("/chat")
602
  def chat():
603
  """
604
  Unified Text Chat Endpoint.
605
- Handles Casual, Search, and Advisory intents.
606
  """
607
  body = request.get_json(silent=True) or {}
608
  msg = body.get("message", "")
@@ -610,11 +585,24 @@ def chat():
610
 
611
  if not pid: return jsonify({"ok": False, "error": "Missing profile_id"}), 400
612
 
613
- # 1. Memory Lookup
614
- memory_summary = ""
615
  if db:
616
- prof = db.collection("pricelyst_profiles").document(pid).get()
617
- if prof.exists: memory_summary = prof.to_dict().get("memory_summary", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
618
 
619
  # 2. Intent Detection
620
  intent_data = gemini_detect_intent(msg)
@@ -632,7 +620,7 @@ def chat():
632
  analyst_data = calculate_zesa_units(amount)
633
 
634
  # 4. Response Generation (The Persona)
635
- reply = gemini_chat_response(msg, intent_data, analyst_data, memory_summary)
636
 
637
  # 5. Async Logging
638
  if db:
@@ -654,7 +642,7 @@ def chat():
654
  @app.post("/api/analyze-image")
655
  def analyze_image():
656
  """
657
- New Endpoint: Handles Image -> List/Product/Meal -> Shopping Data
658
  """
659
  body = request.get_json(silent=True) or {}
660
  image_b64 = body.get("image_data") # Base64 string
@@ -676,10 +664,10 @@ def analyze_image():
676
  response_text = "I see the image, but I can't find any shopping items or meals in it. Try a receipt, a product, or a plate of food!"
677
 
678
  elif items:
679
- # Run the Analyst Engine on the extracted items
680
  analyst_data = calculate_basket_optimization(items)
681
 
682
- # Craft a specific prompt for image results
683
  intent_sim = {"intent": "SHOPPING_BASKET"}
684
  response_text = gemini_chat_response(
685
  f"User uploaded image of {img_type}: {vision_result.get('description')}. Items found: {items}",
@@ -698,7 +686,7 @@ def analyze_image():
698
  @app.post("/api/call-briefing")
699
  def call_briefing():
700
  """
701
- Injects Memory + Context for Voice Bot.
702
  """
703
  body = request.get_json(silent=True) or {}
704
  pid = body.get("profile_id")
@@ -718,7 +706,7 @@ def call_briefing():
718
  if username and username != prof.get("username"):
719
  if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
720
 
721
- # Mini-Catalogue (Top 60 items for context)
722
  df = get_market_index()
723
  catalogue_str = ""
724
  if not df.empty:
@@ -733,7 +721,7 @@ def call_briefing():
733
 
734
  return jsonify({
735
  "ok": True,
736
- "memory_summary": prof.get("memory_summary", ""),
737
  "kpi_snapshot": json.dumps(kpi_snapshot)
738
  })
739
 
@@ -741,7 +729,7 @@ def call_briefing():
741
  def log_call_usage():
742
  """
743
  Post-Call Orchestrator.
744
- Generates Plans & Updates Memory.
745
  """
746
  body = request.get_json(silent=True) or {}
747
  pid = body.get("profile_id")
@@ -749,7 +737,7 @@ def log_call_usage():
749
 
750
  if not pid: return jsonify({"ok": False}), 400
751
 
752
- # 1. Update Memory
753
  if len(transcript) > 20 and db:
754
  try:
755
  curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
 
1
  """
2
+ main.py — Pricelyst Shopping Advisor (Jessica Edition 2026 - Upgrade v2.1)
3
 
4
  ✅ Flask API
5
  ✅ Firebase Admin Persistence
 
8
  ✅ "Jessica Engine": Natural Conversation, Chit-Chat, & Advisory
9
  ✅ "Visual Engine": Lists, Products, & Meal-to-Recipe recognition
10
  ✅ Type Safety: Explicit Casting for JSON Serialization
11
+ ✅ Memory Logic: Separated Deep Memory (Calls) from Short-Term Context (Chat)
12
 
13
  ENV VARS:
14
  - GOOGLE_API_KEY=...
 
185
 
186
  prices = p.get("prices") or []
187
 
 
188
  if not prices:
189
  rows.append({
190
  "product_id": p_id,
 
270
  def get_category_stats(df: pd.DataFrame, category_name: str) -> Dict[str, Any]:
271
  """Returns min, max, avg price for a category to determine value."""
272
  if df.empty: return {}
 
273
  cat_df = df[df['category'].str.lower().str.contains(category_name.lower()) & df['is_offer']]
274
  if cat_df.empty:
 
275
  cat_df = df[df['clean_name'].str.contains(category_name.lower()) & df['is_offer']]
276
 
277
  if cat_df.empty: return {}
 
286
 
287
  def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
288
  """
289
+ Optimizes for: 1. Best Single Store, 2. Cheapest Split Mix.
 
 
290
  """
291
  df = get_market_index()
292
  if df.empty:
 
295
  found_items = []
296
  missing_global = []
297
 
 
298
  for item in item_names:
299
  hits = search_products_fuzzy(df[df['is_offer']==True], item, limit=5)
300
  if hits.empty:
 
302
  continue
303
 
304
  best_prod = hits.iloc[0]
 
 
305
  cat_stats = get_category_stats(df, str(best_prod['category']))
306
 
307
  found_items.append({
 
321
  "split_strategy": None
322
  }
323
 
 
324
  target_pids = [x['product_id'] for x in found_items]
325
  relevant_offers = df[df['product_id'].isin(target_pids) & df['is_offer']]
326
 
 
346
  retailer_stats.sort(key=lambda x: (-x['coverage_percent'], x['total_price']))
347
  best_single_store = retailer_stats[0] if retailer_stats else None
348
 
 
 
349
  split_basket = []
350
  split_total = 0.0
351
 
352
  for item in found_items:
 
353
  p_offers = relevant_offers[relevant_offers['product_id'] == item['product_id']]
354
  if not p_offers.empty:
355
  best_offer = p_offers.sort_values('price').iloc[0]
 
388
  if remaining > cost_t1:
389
  units += t1["limit"]
390
  remaining -= cost_t1
391
+ breakdown.append(f"First {t1['limit']}u @ ${t1['rate']}")
392
 
393
  t2 = ZIM_CONTEXT["zesa_step_2"]
394
  cost_t2 = t2["limit"] * t2["rate"]
 
396
  if remaining > cost_t2:
397
  units += t2["limit"]
398
  remaining -= cost_t2
399
+ breakdown.append(f"Next {t2['limit']}u @ ${t2['rate']}")
400
 
401
  t3 = ZIM_CONTEXT["zesa_step_3"]
402
  bought = remaining / t3["rate"]
403
  units += bought
404
+ breakdown.append(f"Balance -> {bought:.1f}u @ ${t3['rate']}")
405
  else:
406
  bought = remaining / t2["rate"]
407
  units += bought
408
+ breakdown.append(f"Balance -> {bought:.1f}u @ ${t2['rate']}")
409
  else:
410
  bought = remaining / t1["rate"]
411
  units += bought
412
+ breakdown.append(f"All {bought:.1f}u @ ${t1['rate']}")
413
 
414
  return {
415
  "amount_usd": float(amount_usd),
 
423
  # =========================
424
 
425
  def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
 
 
 
 
426
  if not _gemini_client: return {"actionable": False}
427
 
428
  PROMPT = """
 
437
  Extract:
438
  - items: list of products
439
  - utility_amount: number
440
+ - context: "budget", "speed", "quality"
441
 
442
  JSON Schema:
443
  {
 
460
  return {"actionable": False, "intent": "CASUAL_CHAT"}
461
 
462
  def gemini_analyze_image(image_b64: str, caption: str = "") -> Dict[str, Any]:
 
 
 
 
463
  if not _gemini_client: return {"error": "AI Offline"}
464
 
465
  PROMPT = f"""
 
486
  ],
487
  config=types.GenerateContentConfig(response_mime_type="application/json")
488
  )
489
+ result = _safe_json_loads(resp.text, {"type": "IRRELEVANT", "items": []})
490
+ logger.info(f"🔮 VISION RAW: {json.dumps(result)}") # Debug Logging
491
+ return result
492
  except Exception as e:
493
  logger.error(f"Vision Error: {e}")
494
  return {"type": "IRRELEVANT", "items": []}
495
 
496
+ def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict, chat_history: str = "") -> str:
 
 
 
 
497
  if not _gemini_client: return "I'm having trouble connecting to my brain right now."
498
 
499
+ context_str = f"RECENT CHAT HISTORY:\n{chat_history}\n" if chat_history else ""
 
500
  context_str += f"ZIMBABWE CONTEXT: Fuel={ZIM_CONTEXT['fuel_petrol']}, ZESA Rate={ZIM_CONTEXT['zesa_step_1']['rate']}\n"
501
 
502
  if analyst_data:
 
512
  {context_str}
513
 
514
  INSTRUCTIONS:
515
+ 1. **Casual Chat**: Use the chat history to reply naturally. If history is empty, be warm. "Makadii! How can I help?"
516
  2. **Shopping Advice**:
517
  - If data exists, guide them. "I found XYZ at Store A for $5."
518
  - If 'best_store' exists, recommend it explicitly based on coverage.
 
522
  - If price is > avg, say "That's a bit pricey, average is $X."
523
  4. **ZESA**: Explain the units naturally using the breakdown provided.
524
 
525
+ TONE: Conversational, Zimbabwean English.
526
  Do NOT dump JSON. Write a natural message. Use Markdown for lists/prices.
527
  """
528
 
 
537
  return "I found the data, but I'm struggling to summarize it. Please check the plan below."
538
 
539
  def gemini_generate_4step_plan(transcript: str, analyst_result: Dict) -> str:
 
 
 
540
  if not _gemini_client: return "# Error\nAI Offline."
541
 
542
  PROMPT = f"""
 
548
  1. **In Our Catalogue ✅** (Table: Item | Store | Price)
549
  2. **Not in Catalogue 😔** (Estimates)
550
  3. **Recommendation 💡**
551
+ - "Best Single Store" vs "Split & Save".
552
+ 4. **Budget Tips**
553
 
554
  Make it look professional yet friendly.
555
  """
 
570
  "ok": True,
571
  "offers_indexed": len(df),
572
  "api_source": PRICE_API_BASE,
573
+ "persona": "Jessica v2.1 (Memory Firewall)"
574
  })
575
 
576
  @app.post("/chat")
577
  def chat():
578
  """
579
  Unified Text Chat Endpoint.
580
+ Uses SHORT-TERM SLIDING WINDOW memory only.
581
  """
582
  body = request.get_json(silent=True) or {}
583
  msg = body.get("message", "")
 
585
 
586
  if not pid: return jsonify({"ok": False, "error": "Missing profile_id"}), 400
587
 
588
+ # 1. Fetch Short-Term History (Sliding Window)
589
+ history_str = ""
590
  if db:
591
+ try:
592
+ # Get last 6 messages, ordered by time descending (newest first)
593
+ docs = db.collection("pricelyst_profiles").document(pid).collection("chat_logs") \
594
+ .order_by("ts", direction=firestore.Query.DESCENDING).limit(6).stream()
595
+
596
+ # Reverse to Chronological order for the LLM
597
+ msgs = []
598
+ for d in docs:
599
+ data = d.to_dict()
600
+ msgs.append(f"User: {data.get('message')}\nJessica: {data.get('response')}")
601
+
602
+ if msgs:
603
+ history_str = "\n".join(reversed(msgs))
604
+ except Exception as e:
605
+ logger.error(f"History Fetch Error: {e}")
606
 
607
  # 2. Intent Detection
608
  intent_data = gemini_detect_intent(msg)
 
620
  analyst_data = calculate_zesa_units(amount)
621
 
622
  # 4. Response Generation (The Persona)
623
+ reply = gemini_chat_response(msg, intent_data, analyst_data, history_str)
624
 
625
  # 5. Async Logging
626
  if db:
 
642
  @app.post("/api/analyze-image")
643
  def analyze_image():
644
  """
645
+ Handles Image -> List/Product/Meal -> Shopping Data
646
  """
647
  body = request.get_json(silent=True) or {}
648
  image_b64 = body.get("image_data") # Base64 string
 
664
  response_text = "I see the image, but I can't find any shopping items or meals in it. Try a receipt, a product, or a plate of food!"
665
 
666
  elif items:
667
+ # Run the Analyst Engine
668
  analyst_data = calculate_basket_optimization(items)
669
 
670
+ # Craft a specific prompt for image results (No history needed here usually)
671
  intent_sim = {"intent": "SHOPPING_BASKET"}
672
  response_text = gemini_chat_response(
673
  f"User uploaded image of {img_type}: {vision_result.get('description')}. Items found: {items}",
 
686
  @app.post("/api/call-briefing")
687
  def call_briefing():
688
  """
689
+ Injects LONG-TERM Memory + Context for Voice Bot.
690
  """
691
  body = request.get_json(silent=True) or {}
692
  pid = body.get("profile_id")
 
706
  if username and username != prof.get("username"):
707
  if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
708
 
709
+ # Mini-Catalogue
710
  df = get_market_index()
711
  catalogue_str = ""
712
  if not df.empty:
 
721
 
722
  return jsonify({
723
  "ok": True,
724
+ "memory_summary": prof.get("memory_summary", ""), # Keep Long Term memory here
725
  "kpi_snapshot": json.dumps(kpi_snapshot)
726
  })
727
 
 
729
  def log_call_usage():
730
  """
731
  Post-Call Orchestrator.
732
+ Generates Plans & Updates Long-Term Memory.
733
  """
734
  body = request.get_json(silent=True) or {}
735
  pid = body.get("profile_id")
 
737
 
738
  if not pid: return jsonify({"ok": False}), 400
739
 
740
+ # 1. Update Long-Term Memory
741
  if len(transcript) > 20 and db:
742
  try:
743
  curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")