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

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +308 -130
main.py CHANGED
@@ -1,12 +1,13 @@
1
  """
2
- main.py — Pricelyst Shopping Advisor (Jessica Edition 2026)
3
 
4
  ✅ Flask API
5
  ✅ Firebase Admin Persistence
6
- ✅ Gemini 2.5 Flash
7
- ✅ "Analyst Engine": Python Math for Baskets & ZESA
8
- ✅ "Creative Engine": 4-Step Hybrid Plans
9
- Type Safety: Explicit Casting for JSON Serialization (Fixes int64 error)
 
10
 
11
  ENV VARS:
12
  - GOOGLE_API_KEY=...
@@ -22,8 +23,9 @@ import json
22
  import time
23
  import math
24
  import logging
 
25
  from datetime import datetime, timezone
26
- from typing import Any, Dict, List, Optional
27
 
28
  import requests
29
  import pandas as pd
@@ -90,11 +92,11 @@ HTTP_TIMEOUT = 30
90
 
91
  # ––––– Static Data (Zim Context) –––––
92
 
93
- ZIM_UTILITIES = {
94
  "fuel_petrol": 1.58,
95
  "fuel_diesel": 1.65,
96
  "gas_lpg": 2.00,
97
- "bread": 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},
@@ -182,6 +184,7 @@ def fetch_and_flatten_data() -> pd.DataFrame:
182
 
183
  prices = p.get("prices") or []
184
 
 
185
  if not prices:
186
  rows.append({
187
  "product_id": p_id,
@@ -233,7 +236,7 @@ def get_market_index(force_refresh: bool = False) -> pd.DataFrame:
233
  return _data_cache["df"]
234
 
235
  # =========================
236
- # 2. Analyst Engine (Math Logic)
237
  # =========================
238
 
239
  def search_products_fuzzy(df: pd.DataFrame, query: str, limit: int = 10) -> pd.DataFrame:
@@ -260,38 +263,60 @@ def search_products_fuzzy(df: pd.DataFrame, query: str, limit: int = 10) -> pd.D
260
 
261
  if matches.empty: return matches
262
 
263
- # 3. Sort by Views (Popularity) + Price
264
  matches = matches.sort_values(by=['views', 'price'], ascending=[False, True])
265
  return matches.head(limit)
266
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
268
  """
269
- Determines the best store for a list of items.
270
- Explicitly casts int64/float64 to native Python types for JSON safety.
 
271
  """
272
  df = get_market_index()
273
  if df.empty:
274
- logger.warning("Basket Engine: DF is empty.")
275
  return {"actionable": False, "error": "No data"}
276
 
277
- logger.info(f"Basket Engine: Optimizing for: {item_names}")
278
-
279
  found_items = []
280
  missing_global = []
281
 
282
- # 1. Resolve Items to Real Products
283
  for item in item_names:
284
  hits = search_products_fuzzy(df[df['is_offer']==True], item, limit=5)
285
  if hits.empty:
286
  missing_global.append(item)
287
  continue
288
 
289
- # Best match based on popularity
290
  best_prod = hits.iloc[0]
 
 
 
 
291
  found_items.append({
292
  "query": str(item),
293
- "product_id": int(best_prod['product_id']), # Explicit int cast
294
- "name": str(best_prod['product_name']) # Explicit str cast
 
 
295
  })
296
 
297
  if not found_items:
@@ -299,10 +324,11 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
299
  "actionable": True,
300
  "basket_items": [],
301
  "global_missing": missing_global,
302
- "best_store": None
 
303
  }
304
 
305
- # 2. Calculate Retailer Totals
306
  target_pids = [x['product_id'] for x in found_items]
307
  relevant_offers = df[df['product_id'].isin(target_pids) & df['is_offer']]
308
 
@@ -319,83 +345,122 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
319
 
320
  retailer_stats.append({
321
  "retailer": str(retailer),
322
- "total_price": float(total_price), # Explicit float cast
323
- "item_count": int(found_count), # Explicit int cast
324
  "coverage_percent": float((found_count / len(found_items)) * 100),
325
  "found_items": found_names
326
  })
327
 
328
  retailer_stats.sort(key=lambda x: (-x['coverage_percent'], x['total_price']))
329
- best_option = retailer_stats[0] if retailer_stats else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
  return {
332
  "actionable": True,
333
  "basket_items": [x['name'] for x in found_items],
334
  "found_items_details": found_items,
335
  "global_missing": missing_global,
336
- "best_store": best_option,
 
337
  "all_stores": retailer_stats[:3]
338
  }
339
 
340
  def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
341
- remaining = amount_usd / 1.06
 
342
  units = 0.0
343
  breakdown = []
344
 
345
- t1 = ZIM_UTILITIES["zesa_step_1"]
346
  cost_t1 = t1["limit"] * t1["rate"]
347
 
348
  if remaining > cost_t1:
349
  units += t1["limit"]
350
  remaining -= cost_t1
351
- breakdown.append(f"First {t1['limit']}u @ ${t1['rate']}")
352
 
353
- t2 = ZIM_UTILITIES["zesa_step_2"]
354
  cost_t2 = t2["limit"] * t2["rate"]
355
 
356
  if remaining > cost_t2:
357
  units += t2["limit"]
358
  remaining -= cost_t2
359
- breakdown.append(f"Next {t2['limit']}u @ ${t2['rate']}")
360
 
361
- t3 = ZIM_UTILITIES["zesa_step_3"]
362
  bought = remaining / t3["rate"]
363
  units += bought
364
- breakdown.append(f"Balance -> {bought:.1f}u @ ${t3['rate']}")
365
  else:
366
  bought = remaining / t2["rate"]
367
  units += bought
368
- breakdown.append(f"Balance -> {bought:.1f}u @ ${t2['rate']}")
369
  else:
370
  bought = remaining / t1["rate"]
371
  units += bought
372
- breakdown.append(f"All {bought:.1f}u @ ${t1['rate']}")
373
 
374
  return {
375
  "amount_usd": float(amount_usd),
376
  "est_units_kwh": float(round(units, 1)),
377
- "breakdown": breakdown
 
378
  }
379
 
380
  # =========================
381
- # 3. Gemini Helpers
382
  # =========================
383
 
384
  def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
385
- """Strict Intent Classification."""
 
 
 
386
  if not _gemini_client: return {"actionable": False}
387
 
388
  PROMPT = """
389
  Analyze transcript. Return STRICT JSON.
390
- Is user asking for shopping/prices/basket/utility help? (Actionable)
391
- Or just saying hello/thanks? (Casual)
 
 
 
 
 
 
 
 
 
392
 
393
- Schema:
394
  {
395
  "actionable": boolean,
396
- "intent": "SHOPPING_BASKET" | "UTILITY_CALC" | "PRODUCT_SEARCH" | "CASUAL_CHAT",
397
- "items": ["list", "of", "items"],
398
- "utility_amount": number
 
399
  }
400
  """
401
  try:
@@ -409,42 +474,80 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
409
  logger.error(f"Intent Detect Error: {e}")
410
  return {"actionable": False, "intent": "CASUAL_CHAT"}
411
 
412
- def gemini_generate_4step_plan(transcript: str, analyst_result: Dict) -> str:
413
  """
414
- The Creative Engine: Generates the 4-Section Plan.
 
415
  """
416
- if not _gemini_client: return "# Error\nAI Offline."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
 
418
  PROMPT = f"""
419
- You are Jessica, Pricelyst's Shopping Advisor.
420
- Generate a formatted Markdown Shopping Plan.
421
-
422
- USER CONTEXT: "{transcript}"
423
-
424
- ANALYST DATA (REAL PRICES):
425
- {json.dumps(analyst_result, indent=2, default=str)}
426
-
427
- REQUIRED SECTIONS (Do not deviate):
428
-
429
- 1. **In Our Catalogue ✅**
430
- - List items found in 'found_items_details'.
431
- - Show product name, retailer, and exact price from data.
432
-
433
- 2. **Not in Catalogue (Estimates) 😔**
434
- - List items from 'global_missing'.
435
- - Provide a realistic USD estimate for Zimbabwe.
436
-
437
- 3. **Totals 💰**
438
- - Confirmed Total (Catalogue)
439
- - Estimated Total (Missing Items)
440
- - Grand Total Estimate
441
-
442
- 4. **Ideas & Tips 💡**
443
- - Based on the user's event (e.g. Braai, Dinner) or items, suggest 3 creative ideas or forgotten items.
444
- - Be fun but practical.
445
-
446
- Tone: Helpful, Zimbabwean. Use Markdown tables for lists.
447
  """
 
448
  try:
449
  resp = _gemini_client.models.generate_content(
450
  model=GEMINI_MODEL,
@@ -452,7 +555,33 @@ def gemini_generate_4step_plan(transcript: str, analyst_result: Dict) -> str:
452
  )
453
  return resp.text
454
  except Exception as e:
455
- logger.error(f"Creative Gen Error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  return "# Error\nCould not generate plan."
457
 
458
  # =========================
@@ -465,39 +594,47 @@ def health():
465
  return jsonify({
466
  "ok": True,
467
  "offers_indexed": len(df),
468
- "api_source": PRICE_API_BASE
 
469
  })
470
 
471
  @app.post("/chat")
472
  def chat():
473
- """Text Chat."""
 
 
 
474
  body = request.get_json(silent=True) or {}
475
  msg = body.get("message", "")
476
  pid = body.get("profile_id")
477
 
478
- if not pid: return jsonify({"ok": False}), 400
479
 
 
 
 
 
 
 
 
480
  intent_data = gemini_detect_intent(msg)
 
 
 
481
  analyst_data = {}
482
 
483
- if intent_data.get("actionable"):
484
- if intent_data["intent"] == "SHOPPING_BASKET" and intent_data.get("items"):
485
- analyst_data = calculate_basket_optimization(intent_data["items"])
486
- elif intent_data["intent"] == "UTILITY_CALC":
487
- analyst_data = calculate_zesa_units(intent_data.get("utility_amount", 20))
488
- elif intent_data["intent"] == "PRODUCT_SEARCH" and intent_data.get("items"):
489
- analyst_data = calculate_basket_optimization(intent_data["items"])
490
-
491
- reply = "I'm processing that for you."
492
- if analyst_data:
493
- try:
494
- resp = _gemini_client.models.generate_content(
495
- model=GEMINI_MODEL,
496
- contents=f"Summarize this shopping data for chat:\n{json.dumps(analyst_data, default=str)}"
497
- )
498
- reply = resp.text
499
- except: pass
500
-
501
  if db:
502
  db.collection("pricelyst_profiles").document(pid).collection("chat_logs").add({
503
  "message": msg,
@@ -506,12 +643,62 @@ def chat():
506
  "ts": datetime.now(timezone.utc).isoformat()
507
  })
508
 
509
- return jsonify({"ok": True, "data": {"message": reply, "analyst": analyst_data}})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
 
511
  @app.post("/api/call-briefing")
512
  def call_briefing():
513
  """
514
- Injects Memory + Top 60 Mini-Catalogue.
515
  """
516
  body = request.get_json(silent=True) or {}
517
  pid = body.get("profile_id")
@@ -531,19 +718,16 @@ def call_briefing():
531
  if username and username != prof.get("username"):
532
  if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
533
 
534
- # --- Mini-Catalogue Generation ---
535
  df = get_market_index()
536
  catalogue_str = ""
537
  if not df.empty:
538
- # Top 60 Popular items with Price
539
  top = df[df['is_offer']].sort_values('views', ascending=False).drop_duplicates('product_name').head(60)
540
- lines = []
541
- for _, r in top.iterrows():
542
- lines.append(f"{r['product_name']} (~${r['price']:.2f})")
543
  catalogue_str = ", ".join(lines)
544
 
545
  kpi_snapshot = {
546
- "market_rates": ZIM_UTILITIES,
547
  "popular_products": catalogue_str
548
  }
549
 
@@ -557,6 +741,7 @@ def call_briefing():
557
  def log_call_usage():
558
  """
559
  Post-Call Orchestrator.
 
560
  """
561
  body = request.get_json(silent=True) or {}
562
  pid = body.get("profile_id")
@@ -564,43 +749,38 @@ def log_call_usage():
564
 
565
  if not pid: return jsonify({"ok": False}), 400
566
 
567
- logger.info(f"Log Call: Processing {pid}. Transcript Len: {len(transcript)}")
568
-
569
  if len(transcript) > 20 and db:
570
  try:
571
  curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
572
- mem_prompt = f"Update user memory with details from transcript:\nOLD: {curr_mem}\nTRANSCRIPT: {transcript}"
573
  mem_resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=mem_prompt)
574
  db.collection("pricelyst_profiles").document(pid).set({"memory_summary": mem_resp.text}, merge=True)
575
  except Exception as e:
576
  logger.error(f"Memory Update Error: {e}")
577
 
 
578
  intent_data = gemini_detect_intent(transcript)
579
- logger.info(f"Intent: {intent_data.get('intent')}")
580
-
581
  plan_data = {}
582
 
583
- if intent_data.get("actionable"):
 
584
 
585
- if intent_data.get("items"):
586
- analyst_result = calculate_basket_optimization(intent_data["items"])
587
 
588
- if analyst_result.get("actionable"):
589
- md_content = gemini_generate_4step_plan(transcript, analyst_result)
590
-
591
- plan_data = {
592
- "is_actionable": True,
593
- "title": f"Shopping Plan ({datetime.now().strftime('%d %b')})",
594
- "markdown_content": md_content,
595
- "items": intent_data["items"],
596
- "created_at": datetime.now(timezone.utc).isoformat()
597
- }
598
-
599
- if db:
600
- doc_ref = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document()
601
- plan_data["id"] = doc_ref.id
602
- doc_ref.set(plan_data)
603
- logger.info(f"Plan Saved: {doc_ref.id}")
604
 
605
  if db:
606
  db.collection("pricelyst_profiles").document(pid).collection("call_logs").add({
@@ -615,8 +795,6 @@ def log_call_usage():
615
  "shopping_plan": plan_data if plan_data.get("is_actionable") else None
616
  })
617
 
618
- # ––––– CRUD –––––
619
-
620
  @app.get("/api/shopping-plans")
621
  def list_plans():
622
  pid = request.args.get("profile_id")
 
1
  """
2
+ main.py — Pricelyst Shopping Advisor (Jessica Edition 2026 - Upgrade v2.0)
3
 
4
  ✅ Flask API
5
  ✅ Firebase Admin Persistence
6
+ ✅ Gemini 2.5 Flash (Multimodal)
7
+ ✅ "Analyst Engine": Enhanced Basket Math, Category Context, ZESA Logic
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=...
 
23
  import time
24
  import math
25
  import logging
26
+ import base64
27
  from datetime import datetime, timezone
28
+ from typing import Any, Dict, List, Optional, Tuple
29
 
30
  import requests
31
  import pandas as pd
 
92
 
93
  # ––––– Static Data (Zim Context) –––––
94
 
95
+ ZIM_CONTEXT = {
96
  "fuel_petrol": 1.58,
97
  "fuel_diesel": 1.65,
98
  "gas_lpg": 2.00,
99
+ "bread_avg": 1.00,
100
  "zesa_step_1": {"limit": 50, "rate": 0.04},
101
  "zesa_step_2": {"limit": 150, "rate": 0.09},
102
  "zesa_step_3": {"limit": 9999, "rate": 0.14},
 
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,
 
236
  return _data_cache["df"]
237
 
238
  # =========================
239
+ # 2. Analyst Engine (Enhanced Math Logic)
240
  # =========================
241
 
242
  def search_products_fuzzy(df: pd.DataFrame, query: str, limit: int = 10) -> pd.DataFrame:
 
263
 
264
  if matches.empty: return matches
265
 
266
+ # 3. Sort by Views + Price
267
  matches = matches.sort_values(by=['views', 'price'], ascending=[False, True])
268
  return matches.head(limit)
269
 
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 {}
280
+
281
+ return {
282
+ "category": category_name,
283
+ "min_price": float(cat_df['price'].min()),
284
+ "max_price": float(cat_df['price'].max()),
285
+ "avg_price": float(cat_df['price'].mean()),
286
+ "sample_size": int(len(cat_df))
287
+ }
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:
 
297
  return {"actionable": False, "error": "No data"}
298
 
 
 
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:
306
  missing_global.append(item)
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({
315
  "query": str(item),
316
+ "product_id": int(best_prod['product_id']),
317
+ "name": str(best_prod['product_name']),
318
+ "category": str(best_prod['category']),
319
+ "category_stats": cat_stats
320
  })
321
 
322
  if not found_items:
 
324
  "actionable": True,
325
  "basket_items": [],
326
  "global_missing": missing_global,
327
+ "best_store": None,
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
 
 
345
 
346
  retailer_stats.append({
347
  "retailer": str(retailer),
348
+ "total_price": float(total_price),
349
+ "item_count": int(found_count),
350
  "coverage_percent": float((found_count / len(found_items)) * 100),
351
  "found_items": found_names
352
  })
353
 
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]
367
+ split_total += best_offer['price']
368
+ split_basket.append({
369
+ "item": item['name'],
370
+ "retailer": str(best_offer['retailer']),
371
+ "price": float(best_offer['price'])
372
+ })
373
+
374
+ split_strategy = {
375
+ "total_price": float(split_total),
376
+ "breakdown": split_basket,
377
+ "store_count": len(set(x['retailer'] for x in split_basket))
378
+ }
379
 
380
  return {
381
  "actionable": True,
382
  "basket_items": [x['name'] for x in found_items],
383
  "found_items_details": found_items,
384
  "global_missing": missing_global,
385
+ "best_store": best_single_store,
386
+ "split_strategy": split_strategy,
387
  "all_stores": retailer_stats[:3]
388
  }
389
 
390
  def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
391
+ """Calculates ZESA units with tiered logic explanation."""
392
+ remaining = amount_usd / 1.06 # Remove 6% REA Levy approx
393
  units = 0.0
394
  breakdown = []
395
 
396
+ t1 = ZIM_CONTEXT["zesa_step_1"]
397
  cost_t1 = t1["limit"] * t1["rate"]
398
 
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"]
406
 
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),
427
  "est_units_kwh": float(round(units, 1)),
428
+ "breakdown": breakdown,
429
+ "note": "Includes approx 6% REA levy deduction."
430
  }
431
 
432
  # =========================
433
+ # 3. Gemini Helpers (Persona & Vision)
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 = """
444
  Analyze transcript. Return STRICT JSON.
445
+ Classify intent:
446
+ - CASUAL_CHAT: Greetings, small talk, no shopping data needed.
447
+ - SHOPPING_BASKET: Looking for prices, creating a list.
448
+ - UTILITY_CALC: Electricity/ZESA questions.
449
+ - STORE_DECISION: "Where should I buy?", "Which store is cheapest?".
450
+ - TRUST_CHECK: "Is this expensive?", "Is this a good deal?".
451
+
452
+ Extract:
453
+ - items: list of products
454
+ - utility_amount: number
455
+ - context: "budget", "speed", "quality" (if mentioned)
456
 
457
+ JSON Schema:
458
  {
459
  "actionable": boolean,
460
+ "intent": "string",
461
+ "items": ["string"],
462
+ "utility_amount": number,
463
+ "context_tag": "string"
464
  }
465
  """
466
  try:
 
474
  logger.error(f"Intent Detect Error: {e}")
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"""
485
+ Analyze this image. Context: {caption}
486
+ 1. Is it a handwritten/printed SHOPPING LIST? -> Extract items.
487
+ 2. Is it a SINGLE PRODUCT? -> Identify the product name.
488
+ 3. Is it a MEAL/DISH? -> Identify the dish and list key ingredients to buy.
489
+ 4. IRRELEVANT? -> Return type "IRRELEVANT".
490
+
491
+ Return STRICT JSON:
492
+ {{
493
+ "type": "LIST" | "PRODUCT" | "MEAL" | "IRRELEVANT",
494
+ "items": ["item1", "item2"],
495
+ "description": "Short description of what is seen"
496
+ }}
497
+ """
498
+ try:
499
+ image_bytes = base64.b64decode(image_b64)
500
+ resp = _gemini_client.models.generate_content(
501
+ model=GEMINI_MODEL,
502
+ contents=[
503
+ types.Part.from_text(PROMPT),
504
+ types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
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:
525
+ context_str += f"ANALYST DATA: {json.dumps(analyst_data, default=str)}\n"
526
 
527
  PROMPT = f"""
528
+ You are Jessica, Pricelyst's Shopping Advisor (Zimbabwe).
529
+ Role: Helpful, thrifty, intelligent companion. Not a robot.
530
+
531
+ INPUT: "{transcript}"
532
+ INTENT: {intent.get('intent')}
533
+ CONTEXT:
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.
541
+ - If 'split_strategy' saves money (>10%), suggest splitting the shop.
542
+ 3. **Trust & Budget**:
543
+ - If user asks "Is this expensive?", check 'category_stats' in data.
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
+
551
  try:
552
  resp = _gemini_client.models.generate_content(
553
  model=GEMINI_MODEL,
 
555
  )
556
  return resp.text
557
  except Exception as e:
558
+ logger.error(f"Chat Gen Error: {e}")
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"""
568
+ Generate a formatted Markdown Shopping Plan (Jessica Edition).
569
+
570
+ DATA: {json.dumps(analyst_result, indent=2, default=str)}
571
+
572
+ SECTIONS:
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
+ """
581
+ try:
582
+ resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=PROMPT)
583
+ return resp.text
584
+ except Exception as e:
585
  return "# Error\nCould not generate plan."
586
 
587
  # =========================
 
594
  return jsonify({
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", "")
609
  pid = body.get("profile_id")
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)
621
+ intent_type = intent_data.get("intent", "CASUAL_CHAT")
622
+ items = intent_data.get("items", [])
623
+
624
  analyst_data = {}
625
 
626
+ # 3. Data Processing (The Analyst)
627
+ if intent_type in ["SHOPPING_BASKET", "STORE_DECISION", "TRUST_CHECK"] and items:
628
+ analyst_data = calculate_basket_optimization(items)
629
+
630
+ elif intent_type == "UTILITY_CALC":
631
+ amount = intent_data.get("utility_amount", 20)
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:
639
  db.collection("pricelyst_profiles").document(pid).collection("chat_logs").add({
640
  "message": msg,
 
643
  "ts": datetime.now(timezone.utc).isoformat()
644
  })
645
 
646
+ return jsonify({
647
+ "ok": True,
648
+ "data": {
649
+ "message": reply,
650
+ "analyst_debug": analyst_data if items else None
651
+ }
652
+ })
653
+
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
661
+ caption = body.get("caption", "")
662
+ pid = body.get("profile_id")
663
+
664
+ if not image_b64 or not pid: return jsonify({"ok": False}), 400
665
+
666
+ # 1. Vision Analysis
667
+ vision_result = gemini_analyze_image(image_b64, caption)
668
+ img_type = vision_result.get("type", "IRRELEVANT")
669
+ items = vision_result.get("items", [])
670
+
671
+ response_text = ""
672
+ analyst_data = {}
673
+
674
+ # 2. Logic Branching
675
+ if img_type == "IRRELEVANT":
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}",
686
+ intent_sim,
687
+ analyst_data
688
+ )
689
+
690
+ return jsonify({
691
+ "ok": True,
692
+ "image_type": img_type,
693
+ "items_identified": items,
694
+ "message": response_text,
695
+ "analyst_data": analyst_data
696
+ })
697
 
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
  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:
 
725
  top = df[df['is_offer']].sort_values('views', ascending=False).drop_duplicates('product_name').head(60)
726
+ lines = [f"{r['product_name']} (~${r['price']:.2f})" for _, r in top.iterrows()]
 
 
727
  catalogue_str = ", ".join(lines)
728
 
729
  kpi_snapshot = {
730
+ "market_rates": ZIM_CONTEXT,
731
  "popular_products": catalogue_str
732
  }
733
 
 
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
 
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", "")
756
+ mem_prompt = f"Update user memory (budget, family size, favorite stores) based on this transcript:\nOLD: {curr_mem}\nTRANSCRIPT: {transcript}"
757
  mem_resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=mem_prompt)
758
  db.collection("pricelyst_profiles").document(pid).set({"memory_summary": mem_resp.text}, merge=True)
759
  except Exception as e:
760
  logger.error(f"Memory Update Error: {e}")
761
 
762
+ # 2. Plan Generation
763
  intent_data = gemini_detect_intent(transcript)
 
 
764
  plan_data = {}
765
 
766
+ if intent_data.get("actionable") and intent_data.get("items"):
767
+ analyst_result = calculate_basket_optimization(intent_data["items"])
768
 
769
+ if analyst_result.get("actionable"):
770
+ md_content = gemini_generate_4step_plan(transcript, analyst_result)
771
 
772
+ plan_data = {
773
+ "is_actionable": True,
774
+ "title": f"Shopping Plan ({datetime.now().strftime('%d %b')})",
775
+ "markdown_content": md_content,
776
+ "items": intent_data["items"],
777
+ "created_at": datetime.now(timezone.utc).isoformat()
778
+ }
779
+
780
+ if db:
781
+ doc_ref = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document()
782
+ plan_data["id"] = doc_ref.id
783
+ doc_ref.set(plan_data)
 
 
 
 
784
 
785
  if db:
786
  db.collection("pricelyst_profiles").document(pid).collection("call_logs").add({
 
795
  "shopping_plan": plan_data if plan_data.get("is_actionable") else None
796
  })
797
 
 
 
798
  @app.get("/api/shopping-plans")
799
  def list_plans():
800
  pid = request.args.get("profile_id")