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

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +149 -78
main.py CHANGED
@@ -1,12 +1,13 @@
1
  """
2
- main.py — Pricelyst Shopping Advisor (single-file server)
3
 
4
  ✅ Flask API
5
  ✅ Firebase Admin persistence (service account JSON via env var)
6
- ✅ Gemini via NEW google-genai SDK (text + multimodal)
7
- ✅ Product intelligence from Pricelyst API (/api/v1/products is open)
8
- ✅ Graceful conversational handling (don’t “force” shopping intent)
9
- ✅ Call briefing + call logging + optional actionable post-call report
 
10
 
11
  ENV VARS YOU NEED
12
  - GOOGLE_API_KEY=...
@@ -14,36 +15,6 @@ ENV VARS YOU NEED
14
  - PRICE_API_BASE=https://api.pricelyst.co.zw # optional
15
  - GEMINI_MODEL=gemini-2.0-flash # optional
16
  - PORT=5000 # optional
17
-
18
- REQUEST SHAPES
19
- 1) POST /chat
20
- {
21
- "profile_id": "demo123",
22
- "username": "Tinashe", # optional
23
- "message": "Where is cooking oil cheapest?",
24
- "images": ["data:image/png;base64,...", "https://..."], # optional
25
- "context": { "budget": 20, "location": "Harare" } # optional
26
- }
27
-
28
- 2) POST /api/call-briefing
29
- {
30
- "profile_id": "demo123",
31
- "username": "Tinashe"
32
- }
33
-
34
- 3) POST /api/log-call-usage
35
- {
36
- "profile_id": "demo123",
37
- "transcript": ".... full transcript ...",
38
- "call_id": "optional-client-id",
39
- "started_at": "2026-01-23T12:00:00Z",
40
- "ended_at": "2026-01-23T12:08:05Z",
41
- "stats": { "duration_sec": 485, "agent": "elevenlabs" }
42
- }
43
-
44
- NOTES
45
- - We DO NOT depend on upstream auth (you said products are open).
46
- - We keep our own "profile_id" for personalization; when integrated, the host app supplies real profile_id.
47
  """
48
 
49
  import os
@@ -73,6 +44,7 @@ logger = logging.getLogger("pricelyst-advisor")
73
  # pip install google-genai
74
  try:
75
  from google import genai
 
76
  except Exception as e:
77
  genai = None
78
  logger.error("google-genai not installed. pip install google-genai. Error=%s", e)
@@ -125,6 +97,16 @@ _product_cache: Dict[str, Any] = {
125
  "raw_count": 0,
126
  }
127
 
 
 
 
 
 
 
 
 
 
 
128
  # =========================
129
  # Helpers: time / strings
130
  # =========================
@@ -420,6 +402,27 @@ def gemini_generate_text(system: str, user: str, temperature: float = 0.4) -> st
420
  logger.error("Gemini text error: %s", e)
421
  return ""
422
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  def gemini_generate_multimodal(system: str, user: str, images: List[Dict[str, Any]]) -> str:
424
  """
425
  Uses Gemini multimodal:
@@ -478,6 +481,7 @@ Output schema:
478
  "product_discovery",
479
  "trust_check",
480
  "chit_chat",
 
481
  "other"
482
  ],
483
  "items": [{"name": "...", "quantity": 1}],
@@ -489,14 +493,31 @@ Rules:
489
  - If user is chatting/social (hi, jokes, thanks, how are you, etc) => actionable=false, intent="chit_chat".
490
  - If user asks about prices/stores/basket/what to buy => actionable=true.
491
  - If user provided a list, extract items + quantities if obvious.
 
492
  - Keep it conservative: if unclear, actionable=false.
493
  """
494
 
495
  def detect_intent(message: str, images_present: bool, context: Dict[str, Any]) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
496
  ctx_str = json.dumps(context or {}, ensure_ascii=False)
497
  user = f"Message: {message}\nImagesPresent: {images_present}\nContext: {ctx_str}"
498
- out = gemini_generate_text(INTENT_SYSTEM, user, temperature=0.1)
499
- data = _safe_json_loads(out, fallback={})
 
 
 
 
 
 
 
 
500
  if not isinstance(data, dict):
501
  return {"actionable": False, "intent": "other", "items": [], "constraints": {}, "notes": "bad_json"}
502
  # normalize
@@ -506,6 +527,30 @@ def detect_intent(message: str, images_present: bool, context: Dict[str, Any]) -
506
  data.setdefault("constraints", {})
507
  return data
508
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
  # =========================
510
  # Matching + analytics
511
  # =========================
@@ -759,34 +804,6 @@ def extract_items_from_images(images: List[Dict[str, Any]]) -> Dict[str, Any]:
759
  data.setdefault("items", [])
760
  return data
761
 
762
- # =========================
763
- # Post-call report synthesis (only if actionable)
764
- # =========================
765
- CALL_REPORT_SYSTEM = """
766
- You are Pricelyst AI. You will receive a full call transcript.
767
- Decide whether there is an actionable request (party planning, shopping needs, budgeting, groceries, etc).
768
- If actionable, produce a concise MARKDOWN report that the client can turn into a PDF.
769
- If NOT actionable (just chatting), return an empty string.
770
-
771
- Rules:
772
- - Be practical and Zimbabwe-oriented.
773
- - If planning an event: include (1) Assumptions, (2) Shopping list with quantities, (3) Budget ranges, (4) Simple menu/recipe ideas, (5) Optional restaurant/catering suggestions (generic; do NOT invent addresses).
774
- - Only output Markdown or empty string. No code blocks.
775
- """
776
-
777
- def build_call_report_markdown(transcript: str) -> str:
778
- if not transcript or len(transcript.strip()) < 40:
779
- return ""
780
- md = gemini_generate_text(CALL_REPORT_SYSTEM, transcript, temperature=0.3)
781
- md = (md or "").strip()
782
- # Guardrail: if model returns JSON or obvious nonsense, drop it.
783
- if md.startswith("{") or md.startswith("["):
784
- return ""
785
- # Conservative: must contain at least one list bullet or heading to be “report-like”
786
- if ("#" not in md) and ("- " not in md) and ("* " not in md):
787
- return ""
788
- return md
789
-
790
  # =========================
791
  # Routes
792
  # =========================
@@ -854,7 +871,21 @@ def chat():
854
 
855
  response_payload: Dict[str, Any] = {"type": "unknown", "message": "No result."}
856
 
857
- if intent["intent"] in ("price_lookup", "trust_check", "product_discovery"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
858
  # pick first item or treat message as query
859
  query = ""
860
  if intent.get("items"):
@@ -961,18 +992,23 @@ def call_briefing():
961
  for ii in intents:
962
  intent_counts[ii] = intent_counts.get(ii, 0) + 1
963
 
964
- shopping_intelligence = {
 
 
 
965
  "username": prof.get("username") or "there",
966
  "last_best_store": last_store,
967
  "top_intents_last_25": sorted(intent_counts.items(), key=lambda x: x[1], reverse=True)[:5],
968
  "tone": "practical_zimbabwe",
 
969
  }
970
 
971
  return jsonify({
972
  "ok": True,
973
  "profile_id": profile_id,
974
  "memory_summary": prof.get("memory_summary", ""),
975
- "shopping_intelligence": shopping_intelligence
 
976
  })
977
 
978
  @app.post("/api/log-call-usage")
@@ -990,23 +1026,30 @@ def log_call_usage():
990
 
991
  prof = get_profile(profile_id)
992
 
993
- # Conservative “actionable report” generation:
994
- # - only generate if transcript has planning keywords
995
- # - and Gemini returns report-ish markdown
996
- planning_keywords = ["party", "birthday", "wedding", "braai", "grocer", "basket", "shopping", "budget", "ingredients", "recipe", "cook", "drinks", "snacks"]
997
- looks_planning = any(k in transcript.lower() for k in planning_keywords)
998
-
999
  report_md = ""
1000
- if looks_planning and _gemini_client:
1001
- report_md = build_call_report_markdown(transcript)
1002
 
 
 
 
 
 
 
 
 
 
 
 
1003
  doc_id = log_call(profile_id, {
1004
  "call_id": call_id,
1005
  "started_at": started_at,
1006
  "ended_at": ended_at,
1007
  "stats": stats,
1008
  "transcript": transcript,
1009
- "report_markdown": report_md,
 
1010
  })
1011
 
1012
  # update counters
@@ -1016,9 +1059,37 @@ def log_call_usage():
1016
  return jsonify({
1017
  "ok": True,
1018
  "logged_call_doc_id": doc_id,
1019
- "report_markdown": report_md # client turns this into PDF; empty if non-actionable
1020
  })
1021
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1022
  # =========================
1023
  # Run
1024
  # =========================
 
1
  """
2
+ main.py — Pricelyst Shopping Advisor (Jessica Edition)
3
 
4
  ✅ Flask API
5
  ✅ Firebase Admin persistence (service account JSON via env var)
6
+ ✅ Gemini via NEW google-genai SDK (text + multimodal + JSON Mode)
7
+ ✅ Product intelligence from Pricelyst API
8
+ ✅ Graceful conversational handling (Backwards Compatible)
9
+ ✅ Call briefing (Zim Essentials Injection)
10
+ ✅ Post-call Shopping Plan Generation (PDF-ready)
11
 
12
  ENV VARS YOU NEED
13
  - GOOGLE_API_KEY=...
 
15
  - PRICE_API_BASE=https://api.pricelyst.co.zw # optional
16
  - GEMINI_MODEL=gemini-2.0-flash # optional
17
  - PORT=5000 # optional
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  """
19
 
20
  import os
 
44
  # pip install google-genai
45
  try:
46
  from google import genai
47
+ from google.genai import types
48
  except Exception as e:
49
  genai = None
50
  logger.error("google-genai not installed. pip install google-genai. Error=%s", e)
 
97
  "raw_count": 0,
98
  }
99
 
100
+ # ---------- Static Data (New Feature) ----------
101
+ # Injected into Voice Context & used for Text Fallback
102
+ ZIM_ESSENTIALS = {
103
+ "fuel_petrol": "$1.58/L (Blend)",
104
+ "fuel_diesel": "$1.65/L (Diesel 50)",
105
+ "zesa_electricity": "Tiered: First 50 units cheap, then ~14c/kWh",
106
+ "bread_standard": "$1.00/loaf (Fixed)",
107
+ "gas_lpg": "$1.90 - $2.10 per kg"
108
+ }
109
+
110
  # =========================
111
  # Helpers: time / strings
112
  # =========================
 
402
  logger.error("Gemini text error: %s", e)
403
  return ""
404
 
405
+ def gemini_generate_json(system: str, user: str, images: List = None) -> Dict[str, Any]:
406
+ """NEW: Strict JSON generation for reliable Plan/Intent"""
407
+ if not _gemini_client: return {}
408
+ parts = [{"text": system + "\n\n" + user}]
409
+ for img in images or []:
410
+ if "bytes" in img:
411
+ b64 = base64.b64encode(img["bytes"]).decode("utf-8")
412
+ parts.append({"inline_data": {"mime_type": img["mime"], "data": b64}})
413
+ elif "url" in img:
414
+ parts.append({"text": f"Image URL: {img['url']}"})
415
+ try:
416
+ resp = _gemini_client.models.generate_content(
417
+ model=GEMINI_MODEL,
418
+ contents=[{"role": "user", "parts": parts}],
419
+ config={"temperature": 0.2, "response_mime_type": "application/json", "max_output_tokens": 2000}
420
+ )
421
+ return json.loads(resp.text)
422
+ except Exception as e:
423
+ logger.error("Gemini JSON error: %s", e)
424
+ return {}
425
+
426
  def gemini_generate_multimodal(system: str, user: str, images: List[Dict[str, Any]]) -> str:
427
  """
428
  Uses Gemini multimodal:
 
481
  "product_discovery",
482
  "trust_check",
483
  "chit_chat",
484
+ "lifestyle_lookup",
485
  "other"
486
  ],
487
  "items": [{"name": "...", "quantity": 1}],
 
493
  - If user is chatting/social (hi, jokes, thanks, how are you, etc) => actionable=false, intent="chit_chat".
494
  - If user asks about prices/stores/basket/what to buy => actionable=true.
495
  - If user provided a list, extract items + quantities if obvious.
496
+ - "How much is fuel/zesa/bread" -> lifestyle_lookup
497
  - Keep it conservative: if unclear, actionable=false.
498
  """
499
 
500
  def detect_intent(message: str, images_present: bool, context: Dict[str, Any]) -> Dict[str, Any]:
501
+ # 1. Fast path for ZIM_ESSENTIALS (Optimization)
502
+ msg_lower = message.lower()
503
+ for k in ZIM_ESSENTIALS:
504
+ clean_k = k.split('_')[-1] # fuel_petrol -> petrol
505
+ if clean_k in msg_lower and "price" in msg_lower:
506
+ return {"actionable": True, "intent": "lifestyle_lookup", "items": [{"name": k}]}
507
+
508
+ # 2. Gemini Detection
509
  ctx_str = json.dumps(context or {}, ensure_ascii=False)
510
  user = f"Message: {message}\nImagesPresent: {images_present}\nContext: {ctx_str}"
511
+
512
+ # Try using the strict JSON helper first for better reliability
513
+ try:
514
+ data = gemini_generate_json(INTENT_SYSTEM, user)
515
+ if not isinstance(data, dict): raise ValueError("Invalid JSON")
516
+ except:
517
+ # Fallback to text parsing if JSON mode fails (Backward Compat)
518
+ out = gemini_generate_text(INTENT_SYSTEM, user, temperature=0.1)
519
+ data = _safe_json_loads(out, fallback={})
520
+
521
  if not isinstance(data, dict):
522
  return {"actionable": False, "intent": "other", "items": [], "constraints": {}, "notes": "bad_json"}
523
  # normalize
 
527
  data.setdefault("constraints", {})
528
  return data
529
 
530
+ # =========================
531
+ # Shopping Plan Generator (NEW)
532
+ # =========================
533
+ PLAN_SYSTEM_PROMPT = """
534
+ You are Jessica, the Pricelyst Shopping Advisor. Analyze the conversation transcript.
535
+ If the user discussed a shopping list, budget plan, or event needs, create a structured plan.
536
+
537
+ OUTPUT JSON SCHEMA:
538
+ {
539
+ "is_actionable": boolean,
540
+ "title": "Short title (e.g. 'Weekend Braai List')",
541
+ "summary": "1 sentence summary",
542
+ "items": [{"name": "string", "qty": "string", "est_price": number|null}],
543
+ "markdown_content": "A clean Markdown report for a PDF. Include headers (#), bullet points, and a budget summary table if applicable. Keep it professional."
544
+ }
545
+
546
+ If no shopping/planning occurred, set is_actionable=false.
547
+ """
548
+
549
+ def generate_shopping_plan(transcript: str) -> Dict[str, Any]:
550
+ if not transcript or len(transcript) < 30:
551
+ return {"is_actionable": False}
552
+ return gemini_generate_json(PLAN_SYSTEM_PROMPT, f"TRANSCRIPT:\n{transcript}")
553
+
554
  # =========================
555
  # Matching + analytics
556
  # =========================
 
804
  data.setdefault("items", [])
805
  return data
806
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
807
  # =========================
808
  # Routes
809
  # =========================
 
871
 
872
  response_payload: Dict[str, Any] = {"type": "unknown", "message": "No result."}
873
 
874
+ # --- NEW: Check for Lifestyle/Essentials (Fuel/ZESA) ---
875
+ if intent["intent"] == "lifestyle_lookup":
876
+ # Items are auto-detected in detect_intent
877
+ key = intent["items"][0]["name"]
878
+ # Fuzzy match dict key
879
+ val = ZIM_ESSENTIALS.get(key) or ZIM_ESSENTIALS.get("fuel_petrol") # fallback
880
+ response_payload = {
881
+ "type": "info_card",
882
+ "title": f"Market Rate: {key.replace('_', ' ').title()}",
883
+ "message": str(val),
884
+ "highlights": [f"Current: {val}"]
885
+ }
886
+
887
+ # --- Original Logic ---
888
+ elif intent["intent"] in ("price_lookup", "trust_check", "product_discovery"):
889
  # pick first item or treat message as query
890
  query = ""
891
  if intent.get("items"):
 
992
  for ii in intents:
993
  intent_counts[ii] = intent_counts.get(ii, 0) + 1
994
 
995
+ # --- KPI Snapshot Logic ---
996
+ # We construct a dictionary that the React client will pass as a JSON string
997
+ # We inject ZIM_ESSENTIALS here so the Agent has knowledge of fuel/zesa prices
998
+ kpi_data = {
999
  "username": prof.get("username") or "there",
1000
  "last_best_store": last_store,
1001
  "top_intents_last_25": sorted(intent_counts.items(), key=lambda x: x[1], reverse=True)[:5],
1002
  "tone": "practical_zimbabwe",
1003
+ "market_rates_essentials": ZIM_ESSENTIALS # <--- INJECTED KNOWLEDGE
1004
  }
1005
 
1006
  return jsonify({
1007
  "ok": True,
1008
  "profile_id": profile_id,
1009
  "memory_summary": prof.get("memory_summary", ""),
1010
+ # This string is passed to ElevenLabs by the React Client
1011
+ "kpi_snapshot": json.dumps(kpi_data)
1012
  })
1013
 
1014
  @app.post("/api/log-call-usage")
 
1026
 
1027
  prof = get_profile(profile_id)
1028
 
1029
+ # --- UPGRADE: Use Shopping Plan Generator (JSON + Markdown) ---
1030
+ plan_data = generate_shopping_plan(transcript)
1031
+ plan_id = None
 
 
 
1032
  report_md = ""
 
 
1033
 
1034
+ if plan_data.get("is_actionable"):
1035
+ # Save structured plan
1036
+ plan_ref = db.collection("pricelyst_profiles").document(profile_id).collection("shopping_plans").document()
1037
+ plan_data["id"] = plan_ref.id
1038
+ plan_data["call_id"] = call_id
1039
+ plan_data["created_at"] = now_utc_iso()
1040
+ plan_ref.set(plan_data)
1041
+ plan_id = plan_ref.id
1042
+ report_md = plan_data.get("markdown_content", "")
1043
+
1044
+ # Log the call (link the plan_id)
1045
  doc_id = log_call(profile_id, {
1046
  "call_id": call_id,
1047
  "started_at": started_at,
1048
  "ended_at": ended_at,
1049
  "stats": stats,
1050
  "transcript": transcript,
1051
+ "generated_plan_id": plan_id,
1052
+ "report_markdown": report_md,
1053
  })
1054
 
1055
  # update counters
 
1059
  return jsonify({
1060
  "ok": True,
1061
  "logged_call_doc_id": doc_id,
1062
+ "shopping_plan": plan_data if plan_id else None # Frontend uses this for PDF
1063
  })
1064
 
1065
+ # --- NEW: Shopping Plans CRUD ---
1066
+ @app.get("/api/shopping-plans")
1067
+ def list_plans():
1068
+ pid = request.args.get("profile_id")
1069
+ if not pid: return jsonify({"ok": False}), 400
1070
+ try:
1071
+ docs = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans")\
1072
+ .order_by("created_at", direction=firestore.Query.DESCENDING).limit(20).stream()
1073
+ plans = [{"id": d.id, **d.to_dict()} for d in docs]
1074
+ return jsonify({"ok": True, "plans": plans})
1075
+ except Exception as e:
1076
+ return jsonify({"ok": False, "error": str(e)}), 500
1077
+
1078
+ @app.get("/api/shopping-plans/<plan_id>")
1079
+ def get_plan(plan_id):
1080
+ pid = request.args.get("profile_id")
1081
+ if not pid: return jsonify({"ok": False}), 400
1082
+ doc = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document(plan_id).get()
1083
+ if not doc.exists: return jsonify({"ok": False, "error": "Not found"}), 404
1084
+ return jsonify({"ok": True, "plan": doc.to_dict()})
1085
+
1086
+ @app.delete("/api/shopping-plans/<plan_id>")
1087
+ def delete_plan(plan_id):
1088
+ pid = request.args.get("profile_id")
1089
+ if not pid: return jsonify({"ok": False}), 400
1090
+ db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document(plan_id).delete()
1091
+ return jsonify({"ok": True})
1092
+
1093
  # =========================
1094
  # Run
1095
  # =========================