rairo commited on
Commit
3f86912
·
verified ·
1 Parent(s): 9e7ded1

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +116 -113
main.py CHANGED
@@ -1,19 +1,18 @@
1
  """
2
- main.py — Pricelyst Shopping Advisor (Analyst Edition - Full Context)
3
 
4
  ✅ Flask API
5
  ✅ Firebase Admin Persistence
6
- ✅ Gemini via google-genai SDK (Robust)
7
- ✅ "Analyst Engine": Python Math for Baskets, ZESA, & Fuel
8
- Ground Truth Data: Uses /api/v1/product-listing
9
- ✅ Jessica Context: Injects Top 60 Real Products into Voice Agent
10
- ✅ Intent Detection: Strict Casual vs Actionable separation
11
 
12
  ENV VARS:
13
  - GOOGLE_API_KEY=...
14
  - FIREBASE='{"type":"service_account", ...}'
15
  - PRICE_API_BASE=https://api.pricelyst.co.zw
16
- - GEMINI_MODEL=gemini-2.0-flash
17
  - PORT=5000
18
  """
19
 
@@ -49,7 +48,8 @@ except Exception as e:
49
  logger.error("google-genai not installed. pip install google-genai. Error=%s", e)
50
 
51
  GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "")
52
- GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.0-flash")
 
53
 
54
  _gemini_client = None
55
  if genai and GOOGLE_API_KEY:
@@ -165,7 +165,6 @@ def fetch_and_flatten_data() -> pd.DataFrame:
165
  logger.error(f"ETL Error on page {page}: {e}")
166
  break
167
 
168
- # Flattening Logic
169
  rows = []
170
  for p in all_products:
171
  try:
@@ -240,14 +239,13 @@ def get_market_index(force_refresh: bool = False) -> pd.DataFrame:
240
 
241
  def search_products_fuzzy(df: pd.DataFrame, query: str, limit: int = 10) -> pd.DataFrame:
242
  if df.empty or not query: return df
243
-
244
  q_norm = _norm(query)
245
 
246
- # 1. Broad Filter (Contains)
247
  mask_name = df['clean_name'].str.contains(q_norm, regex=False)
248
  matches = df[mask_name].copy()
249
 
250
- # 2. If no exact contains, try token overlap
251
  if matches.empty:
252
  q_tokens = set(q_norm.split())
253
  def token_score(text):
@@ -261,10 +259,9 @@ def search_products_fuzzy(df: pd.DataFrame, query: str, limit: int = 10) -> pd.D
261
  df_scored['score'] = df_scored['clean_name'].apply(token_score)
262
  matches = df_scored[df_scored['score'] > 0]
263
 
264
- if matches.empty:
265
- return matches
266
 
267
- # 3. Rank by Popularity (Views) + Price
268
  matches = matches.sort_values(by=['views', 'price'], ascending=[False, True])
269
  return matches.head(limit)
270
 
@@ -277,7 +274,7 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
277
  logger.warning("Basket Engine: DF is empty.")
278
  return {"actionable": False, "error": "No data"}
279
 
280
- logger.info(f"Basket Engine: Optimizing for {len(item_names)} items: {item_names}")
281
 
282
  found_items = []
283
  missing_global = []
@@ -289,7 +286,7 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
289
  missing_global.append(item)
290
  continue
291
 
292
- # Pick best match (First one is sorted by Views/Price)
293
  best_prod = hits.iloc[0]
294
  found_items.append({
295
  "query": item,
@@ -297,11 +294,16 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
297
  "name": best_prod['product_name']
298
  })
299
 
 
300
  if not found_items:
301
- logger.info("Basket Engine: No items matched in DB.")
302
- return {"actionable": False, "missing": missing_global}
303
-
304
- # 2. Calculate Totals Per Retailer
 
 
 
 
305
  target_pids = [x['product_id'] for x in found_items]
306
  relevant_offers = df[df['product_id'].isin(target_pids) & df['is_offer']]
307
 
@@ -310,11 +312,9 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
310
 
311
  for retailer in all_retailers:
312
  r_df = relevant_offers[relevant_offers['retailer'] == retailer]
313
-
314
  found_count = len(r_df)
315
  total_price = r_df['price'].sum()
316
 
317
- # Identify misses
318
  retailer_pids = r_df['product_id'].tolist()
319
  found_names = [x['name'] for x in found_items if x['product_id'] in retailer_pids]
320
 
@@ -326,25 +326,20 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
326
  "found_items": found_names
327
  })
328
 
329
- # 3. Sort: Coverage Desc, Price Asc
330
  retailer_stats.sort(key=lambda x: (-x['coverage_percent'], x['total_price']))
331
-
332
- if not retailer_stats:
333
- return {"actionable": False}
334
-
335
- best_option = retailer_stats[0]
336
- logger.info(f"Basket Engine: Best Store = {best_option['retailer']} (${best_option['total_price']})")
337
 
338
  return {
339
  "actionable": True,
340
  "basket_items": [x['name'] for x in found_items],
 
341
  "global_missing": missing_global,
342
  "best_store": best_option,
343
  "all_stores": retailer_stats[:3]
344
  }
345
 
346
  def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
347
- remaining = amount_usd / 1.06 # Remove 6% levy approx
348
  units = 0.0
349
  breakdown = []
350
 
@@ -367,7 +362,7 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
367
  t3 = ZIM_UTILITIES["zesa_step_3"]
368
  bought = remaining / t3["rate"]
369
  units += bought
370
- breakdown.append(f"Balance ${(remaining + cost_t1 + cost_t2):.2f} -> {bought:.1f}u @ ${t3['rate']}")
371
  else:
372
  bought = remaining / t2["rate"]
373
  units += bought
@@ -384,28 +379,26 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
384
  }
385
 
386
  # =========================
387
- # 3. Gemini Helpers (Strict)
388
  # =========================
389
 
390
  def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
391
- """
392
- Classifies if the conversation needs an Analyst action.
393
- """
394
  if not _gemini_client: return {"actionable": False}
395
 
396
  PROMPT = """
397
- Analyze this transcript. Return STRICT JSON.
398
- Is the user asking for shopping help (prices, basket, store advice, ZESA/Fuel)?
 
399
 
400
- Output Schema:
401
  {
402
  "actionable": boolean,
403
  "intent": "SHOPPING_BASKET" | "UTILITY_CALC" | "PRODUCT_SEARCH" | "CASUAL_CHAT",
404
- "items": ["item1", "item2"] (if applicable),
405
- "utility_amount": number (if applicable for ZESA/Fuel)
406
  }
407
  """
408
-
409
  try:
410
  resp = _gemini_client.models.generate_content(
411
  model=GEMINI_MODEL,
@@ -417,29 +410,51 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
417
  logger.error(f"Intent Detect Error: {e}")
418
  return {"actionable": False, "intent": "CASUAL_CHAT"}
419
 
420
- def gemini_chat_response(transcript: str, analyst_data: Dict) -> str:
421
- if not _gemini_client: return "System offline."
422
-
 
 
 
423
  PROMPT = f"""
424
- You are Jessica, Pricelyst Analyst.
425
- User asked: "{transcript}"
426
 
427
- DATA (Use this strictly):
428
- {json.dumps(analyst_data, indent=2)}
429
 
430
- If 'actionable' is true, summarize the Best Store and Total Cost.
431
- If ZESA data is present, give the units estimate.
432
- Keep it short, helpful, and Zimbabwean.
433
- """
434
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  try:
436
  resp = _gemini_client.models.generate_content(
437
  model=GEMINI_MODEL,
438
  contents=PROMPT
439
  )
440
  return resp.text
441
- except:
442
- return "I have the data but couldn't summarize it."
 
443
 
444
  # =========================
445
  # 4. Endpoints
@@ -456,35 +471,39 @@ def health():
456
 
457
  @app.post("/chat")
458
  def chat():
459
- """Text Chat Interface."""
460
  body = request.get_json(silent=True) or {}
461
  msg = body.get("message", "")
462
  pid = body.get("profile_id")
463
 
464
  if not pid: return jsonify({"ok": False}), 400
465
 
466
- # 1. Detect Intent
467
  intent_data = gemini_detect_intent(msg)
468
  analyst_data = {}
469
 
470
- # 2. Run Analyst (if actionable)
471
  if intent_data.get("actionable"):
472
  if intent_data["intent"] == "SHOPPING_BASKET" and intent_data.get("items"):
473
  analyst_data = calculate_basket_optimization(intent_data["items"])
474
  elif intent_data["intent"] == "UTILITY_CALC":
475
  analyst_data = calculate_zesa_units(intent_data.get("utility_amount", 20))
476
  elif intent_data["intent"] == "PRODUCT_SEARCH" and intent_data.get("items"):
477
- # Reuse basket logic for single item search to get best store
478
  analyst_data = calculate_basket_optimization(intent_data["items"])
479
 
480
- # 3. Generate Reply
481
- reply = gemini_chat_response(msg, analyst_data)
482
-
483
- # Log
 
 
 
 
 
 
 
484
  if db:
485
  db.collection("pricelyst_profiles").document(pid).collection("chat_logs").add({
486
  "message": msg,
487
- "response_text": reply,
488
  "intent": intent_data,
489
  "ts": datetime.now(timezone.utc).isoformat()
490
  })
@@ -494,7 +513,7 @@ def chat():
494
  @app.post("/api/call-briefing")
495
  def call_briefing():
496
  """
497
- Injects Memory + Top Products Catalogue for the Voice Agent.
498
  """
499
  body = request.get_json(silent=True) or {}
500
  pid = body.get("profile_id")
@@ -510,26 +529,24 @@ def call_briefing():
510
  prof = doc.to_dict()
511
  else:
512
  ref.set({"created_at": datetime.now(timezone.utc).isoformat()})
513
-
514
  if username and username != prof.get("username"):
515
  if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
516
 
517
- # --- Generate Mini-Catalogue (Top 60 popular items) ---
518
  df = get_market_index()
519
- top_products_str = ""
520
  if not df.empty:
521
- # Sort by views desc, take top 60 unique product names
522
- top_offers = df[df['is_offer']].sort_values('views', ascending=False).drop_duplicates('product_name').head(60)
523
- # Format: "Name ($AvgPrice)"
524
- items_list = []
525
- for _, r in top_offers.iterrows():
526
- items_list.append(f"{r['product_name']} (~${r['price']:.2f})")
527
- top_products_str = ", ".join(items_list)
528
-
529
- # Payload for ElevenLabs (Data Variables Only)
530
  kpi_snapshot = {
531
  "market_rates": ZIM_UTILITIES,
532
- "popular_products_catalogue": top_products_str
533
  }
534
 
535
  return jsonify({
@@ -541,10 +558,12 @@ def call_briefing():
541
  @app.post("/api/log-call-usage")
542
  def log_call_usage():
543
  """
544
- Post-Call Processor.
545
- 1. Intent Check (Strict).
546
- 2. Analyst Optimization.
547
- 3. Plan Gen & Persistence.
 
 
548
  """
549
  body = request.get_json(silent=True) or {}
550
  pid = body.get("profile_id")
@@ -554,68 +573,53 @@ def log_call_usage():
554
 
555
  logger.info(f"Log Call: Processing {pid}. Transcript Len: {len(transcript)}")
556
 
557
- # 1. Update Memory (Async-like)
558
  if len(transcript) > 20 and db:
559
  try:
560
  curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
561
- mem_prompt = f"Update user memory (concise) with new details:\nOLD: {curr_mem}\nTRANSCRIPT: {transcript}"
562
  mem_resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=mem_prompt)
563
  db.collection("pricelyst_profiles").document(pid).set({"memory_summary": mem_resp.text}, merge=True)
564
  except Exception as e:
565
- logger.error(f"Memory Update Failed: {e}")
566
 
567
- # 2. Intent Detection (The Gatekeeper)
568
  intent_data = gemini_detect_intent(transcript)
569
- logger.info(f"Log Call: Intent detected: {intent_data.get('intent')}")
570
 
571
  plan_data = {}
572
 
573
  # 3. Actionable Logic
574
  if intent_data.get("actionable"):
575
 
576
- # Handle Shopping List
577
  if intent_data.get("items"):
578
  analyst_result = calculate_basket_optimization(intent_data["items"])
579
 
 
 
580
  if analyst_result.get("actionable"):
581
- best = analyst_result["best_store"]
582
-
583
- # Markdown Generation
584
- md = f"# Shopping Plan\n\n"
585
- md += f"**Recommended Store:** {best['retailer']}\n"
586
- md += f"**Estimated Total:** ${best['total_price']:.2f}\n\n"
587
-
588
- md += "## Your Basket\n\n"
589
- md += "| Item | Found? |\n|---|---|\n"
590
- for it in analyst_result["basket_items"]:
591
- status = "✅ In Stock" if it in best["found_items"] else "❌ Not Found"
592
- md += f"| {it} | {status} |\n"
593
-
594
- if analyst_result["global_missing"]:
595
- md += "\n### Missing Items (Estimate Required)\n"
596
- for m in analyst_result["global_missing"]:
597
- md += f"- {m}\n"
598
 
599
  plan_data = {
600
  "is_actionable": True,
601
- "title": f"Plan: {best['retailer']} (${best['total_price']:.2f})",
602
- "markdown_content": md,
603
  "items": intent_data["items"],
604
  "created_at": datetime.now(timezone.utc).isoformat()
605
  }
606
 
607
- # Persist Plan
608
  if db:
609
  doc_ref = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document()
610
  plan_data["id"] = doc_ref.id
611
  doc_ref.set(plan_data)
612
- logger.info(f"Log Call: Plan Saved {doc_ref.id}")
613
 
614
- # 4. Log Call
615
  if db:
616
  db.collection("pricelyst_profiles").document(pid).collection("call_logs").add({
617
  "transcript": transcript,
618
- "intent_data": intent_data,
619
  "plan_generated": bool(plan_data),
620
  "ts": datetime.now(timezone.utc).isoformat()
621
  })
@@ -625,7 +629,7 @@ def log_call_usage():
625
  "shopping_plan": plan_data if plan_data.get("is_actionable") else None
626
  })
627
 
628
- # ––––– CRUD: Shopping Plans –––––
629
 
630
  @app.get("/api/shopping-plans")
631
  def list_plans():
@@ -655,7 +659,6 @@ def delete_plan(plan_id):
655
 
656
  if __name__ == "__main__":
657
  port = int(os.environ.get("PORT", 7860))
658
- # Pre-warm Cache
659
  try:
660
  get_market_index(force_refresh=True)
661
  except:
 
1
  """
2
+ main.py — Pricelyst Shopping Advisor (Jessica 2026 Edition)
3
 
4
  ✅ Flask API
5
  ✅ Firebase Admin Persistence
6
+ ✅ Gemini 2.5 Flash (Target Model)
7
+ ✅ "Analyst Engine": Python Math for Baskets & ZESA
8
+ "Creative Engine": 4-Step Hybrid Plans (Catalogue -> Estimates -> Totals -> Context Ideas)
9
+ ✅ Jessica Context: Live Mini-Catalogue Injection
 
10
 
11
  ENV VARS:
12
  - GOOGLE_API_KEY=...
13
  - FIREBASE='{"type":"service_account", ...}'
14
  - PRICE_API_BASE=https://api.pricelyst.co.zw
15
+ - GEMINI_MODEL=gemini-2.5-flash
16
  - PORT=5000
17
  """
18
 
 
48
  logger.error("google-genai not installed. pip install google-genai. Error=%s", e)
49
 
50
  GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "")
51
+ # Updated to 2.5 Flash as requested for 2026 timeline
52
+ GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
53
 
54
  _gemini_client = None
55
  if genai and GOOGLE_API_KEY:
 
165
  logger.error(f"ETL Error on page {page}: {e}")
166
  break
167
 
 
168
  rows = []
169
  for p in all_products:
170
  try:
 
239
 
240
  def search_products_fuzzy(df: pd.DataFrame, query: str, limit: int = 10) -> pd.DataFrame:
241
  if df.empty or not query: return df
 
242
  q_norm = _norm(query)
243
 
244
+ # 1. Contains
245
  mask_name = df['clean_name'].str.contains(q_norm, regex=False)
246
  matches = df[mask_name].copy()
247
 
248
+ # 2. Token overlap fallback
249
  if matches.empty:
250
  q_tokens = set(q_norm.split())
251
  def token_score(text):
 
259
  df_scored['score'] = df_scored['clean_name'].apply(token_score)
260
  matches = df_scored[df_scored['score'] > 0]
261
 
262
+ if matches.empty: return matches
 
263
 
264
+ # 3. Sort by Views (Popularity) + Price
265
  matches = matches.sort_values(by=['views', 'price'], ascending=[False, True])
266
  return matches.head(limit)
267
 
 
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 = []
 
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": item,
 
294
  "name": best_prod['product_name']
295
  })
296
 
297
+ # Note: We return actionable=True even if products missing, so Gemini can estimate
298
  if not found_items:
299
+ return {
300
+ "actionable": True,
301
+ "basket_items": [],
302
+ "global_missing": missing_global,
303
+ "best_store": None
304
+ }
305
+
306
+ # 2. Calculate Retailer Totals
307
  target_pids = [x['product_id'] for x in found_items]
308
  relevant_offers = df[df['product_id'].isin(target_pids) & df['is_offer']]
309
 
 
312
 
313
  for retailer in all_retailers:
314
  r_df = relevant_offers[relevant_offers['retailer'] == retailer]
 
315
  found_count = len(r_df)
316
  total_price = r_df['price'].sum()
317
 
 
318
  retailer_pids = r_df['product_id'].tolist()
319
  found_names = [x['name'] for x in found_items if x['product_id'] in retailer_pids]
320
 
 
326
  "found_items": found_names
327
  })
328
 
 
329
  retailer_stats.sort(key=lambda x: (-x['coverage_percent'], x['total_price']))
330
+ best_option = retailer_stats[0] if retailer_stats else None
 
 
 
 
 
331
 
332
  return {
333
  "actionable": True,
334
  "basket_items": [x['name'] for x in found_items],
335
+ "found_items_details": found_items,
336
  "global_missing": missing_global,
337
  "best_store": best_option,
338
  "all_stores": retailer_stats[:3]
339
  }
340
 
341
  def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
342
+ remaining = amount_usd / 1.06
343
  units = 0.0
344
  breakdown = []
345
 
 
362
  t3 = ZIM_UTILITIES["zesa_step_3"]
363
  bought = remaining / t3["rate"]
364
  units += bought
365
+ breakdown.append(f"Balance -> {bought:.1f}u @ ${t3['rate']}")
366
  else:
367
  bought = remaining / t2["rate"]
368
  units += bought
 
379
  }
380
 
381
  # =========================
382
+ # 3. Gemini Helpers (Detect + Creative)
383
  # =========================
384
 
385
  def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
386
+ """Strict Intent Classification."""
 
 
387
  if not _gemini_client: return {"actionable": False}
388
 
389
  PROMPT = """
390
+ Analyze transcript. Return STRICT JSON.
391
+ Is user asking for shopping/prices/basket/utility help? (Actionable)
392
+ Or just saying hello/thanks? (Casual)
393
 
394
+ Schema:
395
  {
396
  "actionable": boolean,
397
  "intent": "SHOPPING_BASKET" | "UTILITY_CALC" | "PRODUCT_SEARCH" | "CASUAL_CHAT",
398
+ "items": ["list", "of", "items"],
399
+ "utility_amount": number
400
  }
401
  """
 
402
  try:
403
  resp = _gemini_client.models.generate_content(
404
  model=GEMINI_MODEL,
 
410
  logger.error(f"Intent Detect Error: {e}")
411
  return {"actionable": False, "intent": "CASUAL_CHAT"}
412
 
413
+ def gemini_generate_4step_plan(transcript: str, analyst_result: Dict) -> str:
414
+ """
415
+ The Creative Engine: Generates the 4-Section Plan.
416
+ """
417
+ if not _gemini_client: return "# Error\nAI Offline."
418
+
419
  PROMPT = f"""
420
+ You are Jessica, Pricelyst's Shopping Advisor.
421
+ Generate a formatted Markdown Shopping Plan.
422
 
423
+ USER CONTEXT: "{transcript}"
 
424
 
425
+ ANALYST DATA (REAL PRICES):
426
+ {json.dumps(analyst_result, indent=2)}
427
+
428
+ REQUIRED SECTIONS (Do not deviate):
429
 
430
+ 1. **In Our Catalogue ✅**
431
+ - List items found in 'found_items_details'.
432
+ - Show product name, retailer, and exact price from data.
433
+
434
+ 2. **Not in Catalogue (Estimates) 😔**
435
+ - List items from 'global_missing' or items in the user list but not in 'found_items'.
436
+ - Provide a realistic USD estimate for Zimbabwe.
437
+
438
+ 3. **Totals 💰**
439
+ - Confirmed Total (Catalogue)
440
+ - Estimated Total (Missing Items)
441
+ - Grand Total Estimate
442
+
443
+ 4. **Ideas & Tips 💡**
444
+ - Based on the user's event (e.g. Braai, Dinner) or items, suggest 3 creative ideas or forgotten items.
445
+ - Be fun but practical.
446
+
447
+ Tone: Helpful, Zimbabwean. Use Markdown tables for lists.
448
+ """
449
  try:
450
  resp = _gemini_client.models.generate_content(
451
  model=GEMINI_MODEL,
452
  contents=PROMPT
453
  )
454
  return resp.text
455
+ except Exception as e:
456
+ logger.error(f"Creative Gen Error: {e}")
457
+ return "# Error\nCould not generate plan."
458
 
459
  # =========================
460
  # 4. Endpoints
 
471
 
472
  @app.post("/chat")
473
  def chat():
474
+ """Text Chat - Uses simple summary."""
475
  body = request.get_json(silent=True) or {}
476
  msg = body.get("message", "")
477
  pid = body.get("profile_id")
478
 
479
  if not pid: return jsonify({"ok": False}), 400
480
 
 
481
  intent_data = gemini_detect_intent(msg)
482
  analyst_data = {}
483
 
 
484
  if intent_data.get("actionable"):
485
  if intent_data["intent"] == "SHOPPING_BASKET" and intent_data.get("items"):
486
  analyst_data = calculate_basket_optimization(intent_data["items"])
487
  elif intent_data["intent"] == "UTILITY_CALC":
488
  analyst_data = calculate_zesa_units(intent_data.get("utility_amount", 20))
489
  elif intent_data["intent"] == "PRODUCT_SEARCH" and intent_data.get("items"):
 
490
  analyst_data = calculate_basket_optimization(intent_data["items"])
491
 
492
+ # Quick chat summary
493
+ reply = "I'm processing that for you."
494
+ if analyst_data:
495
+ try:
496
+ resp = _gemini_client.models.generate_content(
497
+ model=GEMINI_MODEL,
498
+ contents=f"Summarize this shopping data for chat:\n{json.dumps(analyst_data)}"
499
+ )
500
+ reply = resp.text
501
+ except: pass
502
+
503
  if db:
504
  db.collection("pricelyst_profiles").document(pid).collection("chat_logs").add({
505
  "message": msg,
506
+ "response": reply,
507
  "intent": intent_data,
508
  "ts": datetime.now(timezone.utc).isoformat()
509
  })
 
513
  @app.post("/api/call-briefing")
514
  def call_briefing():
515
  """
516
+ Injects Memory + Top 60 Mini-Catalogue.
517
  """
518
  body = request.get_json(silent=True) or {}
519
  pid = body.get("profile_id")
 
529
  prof = doc.to_dict()
530
  else:
531
  ref.set({"created_at": datetime.now(timezone.utc).isoformat()})
532
+
533
  if username and username != prof.get("username"):
534
  if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
535
 
536
+ # --- Mini-Catalogue Generation ---
537
  df = get_market_index()
538
+ catalogue_str = ""
539
  if not df.empty:
540
+ # Top 60 Popular items with Price
541
+ top = df[df['is_offer']].sort_values('views', ascending=False).drop_duplicates('product_name').head(60)
542
+ lines = []
543
+ for _, r in top.iterrows():
544
+ lines.append(f"{r['product_name']} (${r['price']:.2f})")
545
+ catalogue_str = ", ".join(lines)
546
+
 
 
547
  kpi_snapshot = {
548
  "market_rates": ZIM_UTILITIES,
549
+ "popular_products": catalogue_str
550
  }
551
 
552
  return jsonify({
 
558
  @app.post("/api/log-call-usage")
559
  def log_call_usage():
560
  """
561
+ The Post-Call Orchestrator.
562
+ 1. Update Memory.
563
+ 2. Detect Intent.
564
+ 3. Run Analyst.
565
+ 4. Generate 4-Step Creative Plan.
566
+ 5. Save.
567
  """
568
  body = request.get_json(silent=True) or {}
569
  pid = body.get("profile_id")
 
573
 
574
  logger.info(f"Log Call: Processing {pid}. Transcript Len: {len(transcript)}")
575
 
576
+ # 1. Update Memory
577
  if len(transcript) > 20 and db:
578
  try:
579
  curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
580
+ mem_prompt = f"Update user memory with details from transcript:\nOLD: {curr_mem}\nTRANSCRIPT: {transcript}"
581
  mem_resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=mem_prompt)
582
  db.collection("pricelyst_profiles").document(pid).set({"memory_summary": mem_resp.text}, merge=True)
583
  except Exception as e:
584
+ logger.error(f"Memory Update Error: {e}")
585
 
586
+ # 2. Intent Detection
587
  intent_data = gemini_detect_intent(transcript)
588
+ logger.info(f"Intent: {intent_data.get('intent')}")
589
 
590
  plan_data = {}
591
 
592
  # 3. Actionable Logic
593
  if intent_data.get("actionable"):
594
 
595
+ # Run Analyst
596
  if intent_data.get("items"):
597
  analyst_result = calculate_basket_optimization(intent_data["items"])
598
 
599
+ # 4. Generate Creative Plan (4-Step)
600
+ # We generate if we have actionable basket results OR missing items
601
  if analyst_result.get("actionable"):
602
+ md_content = gemini_generate_4step_plan(transcript, analyst_result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
 
604
  plan_data = {
605
  "is_actionable": True,
606
+ "title": f"Shopping Plan ({datetime.now().strftime('%d %b')})",
607
+ "markdown_content": md_content,
608
  "items": intent_data["items"],
609
  "created_at": datetime.now(timezone.utc).isoformat()
610
  }
611
 
 
612
  if db:
613
  doc_ref = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document()
614
  plan_data["id"] = doc_ref.id
615
  doc_ref.set(plan_data)
616
+ logger.info(f"Plan Saved: {doc_ref.id}")
617
 
618
+ # 5. Log Call
619
  if db:
620
  db.collection("pricelyst_profiles").document(pid).collection("call_logs").add({
621
  "transcript": transcript,
622
+ "intent": intent_data,
623
  "plan_generated": bool(plan_data),
624
  "ts": datetime.now(timezone.utc).isoformat()
625
  })
 
629
  "shopping_plan": plan_data if plan_data.get("is_actionable") else None
630
  })
631
 
632
+ # ––––– CRUD –––––
633
 
634
  @app.get("/api/shopping-plans")
635
  def list_plans():
 
659
 
660
  if __name__ == "__main__":
661
  port = int(os.environ.get("PORT", 7860))
 
662
  try:
663
  get_market_index(force_refresh=True)
664
  except: