Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -9,7 +9,7 @@ main.py — Pricelyst Shopping Advisor (Jessica Edition)
|
|
| 9 |
✅ Call briefing (Zim Essentials Injection)
|
| 10 |
✅ Post-call Shopping Plan Generation (PDF-ready)
|
| 11 |
|
| 12 |
-
ENV VARS YOU NEED
|
| 13 |
- GOOGLE_API_KEY=...
|
| 14 |
- FIREBASE='{"type":"service_account", ...}' # full JSON string
|
| 15 |
- PRICE_API_BASE=https://api.pricelyst.co.zw # optional
|
|
@@ -33,15 +33,18 @@ import pandas as pd
|
|
| 33 |
from flask import Flask, request, jsonify
|
| 34 |
from flask_cors import CORS
|
| 35 |
|
| 36 |
-
#
|
|
|
|
| 37 |
logging.basicConfig(
|
| 38 |
level=logging.INFO,
|
| 39 |
format="%(asctime)s | %(levelname)s | %(message)s"
|
| 40 |
)
|
| 41 |
logger = logging.getLogger("pricelyst-advisor")
|
| 42 |
|
| 43 |
-
#
|
|
|
|
| 44 |
# pip install google-genai
|
|
|
|
| 45 |
try:
|
| 46 |
from google import genai
|
| 47 |
from google.genai import types
|
|
@@ -60,36 +63,53 @@ if genai and GOOGLE_API_KEY:
|
|
| 60 |
except Exception as e:
|
| 61 |
logger.error("Failed to init Gemini client: %s", e)
|
| 62 |
|
| 63 |
-
#
|
|
|
|
| 64 |
# pip install firebase-admin
|
|
|
|
| 65 |
import firebase_admin
|
| 66 |
from firebase_admin import credentials, firestore
|
| 67 |
|
| 68 |
FIREBASE_ENV = os.environ.get("FIREBASE", "")
|
| 69 |
|
| 70 |
def init_firestore_from_env() -> firestore.Client:
|
|
|
|
| 71 |
if firebase_admin._apps:
|
| 72 |
return firestore.client()
|
| 73 |
|
|
|
|
| 74 |
if not FIREBASE_ENV:
|
|
|
|
| 75 |
raise RuntimeError("FIREBASE env var missing. Provide full service account JSON string.")
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
-
|
| 83 |
|
| 84 |
-
# ---------- External API (Pricelyst) ----------
|
| 85 |
PRICE_API_BASE = os.environ.get("PRICE_API_BASE", "https://api.pricelyst.co.zw").rstrip("/")
|
| 86 |
HTTP_TIMEOUT = 20
|
| 87 |
|
| 88 |
-
#
|
|
|
|
| 89 |
app = Flask(__name__)
|
| 90 |
CORS(app)
|
| 91 |
|
| 92 |
-
#
|
|
|
|
| 93 |
PRODUCT_CACHE_TTL_SEC = 60 * 10 # 10 minutes
|
| 94 |
_product_cache: Dict[str, Any] = {
|
| 95 |
"ts": 0,
|
|
@@ -97,8 +117,8 @@ _product_cache: Dict[str, Any] = {
|
|
| 97 |
"raw_count": 0,
|
| 98 |
}
|
| 99 |
|
| 100 |
-
#
|
| 101 |
-
|
| 102 |
ZIM_ESSENTIALS = {
|
| 103 |
"fuel_petrol": "$1.58/L (Blend)",
|
| 104 |
"fuel_diesel": "$1.65/L (Diesel 50)",
|
|
@@ -110,6 +130,7 @@ ZIM_ESSENTIALS = {
|
|
| 110 |
# =========================
|
| 111 |
# Helpers: time / strings
|
| 112 |
# =========================
|
|
|
|
| 113 |
def now_utc_iso() -> str:
|
| 114 |
return datetime.now(timezone.utc).isoformat()
|
| 115 |
|
|
@@ -141,59 +162,88 @@ def _safe_json_loads(s: str, fallback: Any):
|
|
| 141 |
# =========================
|
| 142 |
# Firestore profile storage
|
| 143 |
# =========================
|
|
|
|
| 144 |
def profile_ref(profile_id: str):
|
|
|
|
| 145 |
return db.collection("pricelyst_profiles").document(profile_id)
|
| 146 |
|
| 147 |
def get_profile(profile_id: str) -> Dict[str, Any]:
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
"
|
| 163 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
}
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
| 168 |
|
| 169 |
def update_profile(profile_id: str, patch: Dict[str, Any]) -> None:
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
def log_chat(profile_id: str, payload: Dict[str, Any]) -> None:
|
| 175 |
-
db
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
def log_call(profile_id: str, payload: Dict[str, Any]) -> str:
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
# =========================
|
| 189 |
# Multimodal image handling
|
| 190 |
# =========================
|
|
|
|
| 191 |
def parse_images(images: List[str]) -> List[Dict[str, Any]]:
|
| 192 |
"""
|
| 193 |
Accepts:
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
Returns: list of { "mime": "...", "bytes": b"..." } or { "url": "..." }
|
| 198 |
"""
|
| 199 |
out = []
|
|
@@ -201,7 +251,7 @@ def parse_images(images: List[str]) -> List[Dict[str, Any]]:
|
|
| 201 |
if not item:
|
| 202 |
continue
|
| 203 |
item = item.strip()
|
| 204 |
-
|
| 205 |
# URL
|
| 206 |
if item.startswith("http://") or item.startswith("https://"):
|
| 207 |
out.append({"url": item})
|
|
@@ -217,18 +267,19 @@ def parse_images(images: List[str]) -> List[Dict[str, Any]]:
|
|
| 217 |
except Exception:
|
| 218 |
continue
|
| 219 |
continue
|
| 220 |
-
|
| 221 |
# raw base64
|
| 222 |
try:
|
| 223 |
out.append({"mime": "image/png", "bytes": base64.b64decode(item)})
|
| 224 |
except Exception:
|
| 225 |
continue
|
| 226 |
-
|
| 227 |
return out
|
| 228 |
|
| 229 |
# =========================
|
| 230 |
# Product fetching + offers DF
|
| 231 |
# =========================
|
|
|
|
| 232 |
def fetch_products_page(page: int, per_page: int = 50) -> Dict[str, Any]:
|
| 233 |
url = f"{PRICE_API_BASE}/api/v1/products"
|
| 234 |
params = {"page": page, "perPage": per_page}
|
|
@@ -237,10 +288,6 @@ def fetch_products_page(page: int, per_page: int = 50) -> Dict[str, Any]:
|
|
| 237 |
return r.json()
|
| 238 |
|
| 239 |
def fetch_products(max_pages: int = 6, per_page: int = 50) -> List[Dict[str, Any]]:
|
| 240 |
-
"""
|
| 241 |
-
Pull a reasonable slice (you can increase pages later).
|
| 242 |
-
API shape (common): {status, message, data, totalItemCount, currentPage, totalPages}
|
| 243 |
-
"""
|
| 244 |
products: List[Dict[str, Any]] = []
|
| 245 |
for p in range(1, max_pages + 1):
|
| 246 |
payload = fetch_products_page(p, per_page=per_page)
|
|
@@ -255,17 +302,13 @@ def fetch_products(max_pages: int = 6, per_page: int = 50) -> List[Dict[str, Any
|
|
| 255 |
return products
|
| 256 |
|
| 257 |
def products_to_offers_df(products: List[Dict[str, Any]]) -> pd.DataFrame:
|
| 258 |
-
"""
|
| 259 |
-
Each row = one product + one retailer offer.
|
| 260 |
-
Your product object can include `prices[]` with nested `retailer`.
|
| 261 |
-
"""
|
| 262 |
rows = []
|
| 263 |
for p in products or []:
|
| 264 |
try:
|
| 265 |
product_id = p.get("id")
|
| 266 |
name = p.get("name") or ""
|
| 267 |
clean_name = _norm_str(name)
|
| 268 |
-
|
| 269 |
brand_name = ((p.get("brand") or {}).get("brand_name")) if isinstance(p.get("brand"), dict) else None
|
| 270 |
categories = p.get("categories") or []
|
| 271 |
cat_names = []
|
|
@@ -273,22 +316,22 @@ def products_to_offers_df(products: List[Dict[str, Any]]) -> pd.DataFrame:
|
|
| 273 |
if isinstance(c, dict) and c.get("name"):
|
| 274 |
cat_names.append(c.get("name"))
|
| 275 |
primary_category = cat_names[0] if cat_names else None
|
| 276 |
-
|
| 277 |
stock_status = p.get("stock_status")
|
| 278 |
on_promo = bool(p.get("on_promotion"))
|
| 279 |
promo_badge = p.get("promo_badge")
|
| 280 |
promo_name = p.get("promo_name")
|
| 281 |
promo_price = _coerce_float(p.get("promo_price"))
|
| 282 |
original_price = _coerce_float(p.get("original_price"))
|
| 283 |
-
|
| 284 |
recommended_price = _coerce_float(p.get("recommended_price"))
|
| 285 |
base_price = _coerce_float(p.get("price"))
|
| 286 |
bulk_price = _coerce_float(p.get("bulk_price"))
|
| 287 |
bulk_unit = p.get("bulk_unit")
|
| 288 |
-
|
| 289 |
image = p.get("image")
|
| 290 |
thumb = p.get("thumbnail")
|
| 291 |
-
|
| 292 |
offers = p.get("prices") or []
|
| 293 |
if not offers:
|
| 294 |
rows.append({
|
|
@@ -353,7 +396,7 @@ def products_to_offers_df(products: List[Dict[str, Any]]) -> pd.DataFrame:
|
|
| 353 |
df = pd.DataFrame(rows)
|
| 354 |
if df.empty:
|
| 355 |
return df
|
| 356 |
-
|
| 357 |
df["offer_price"] = df["offer_price"].apply(_coerce_float)
|
| 358 |
df["clean_name"] = df["clean_name"].fillna("").astype(str)
|
| 359 |
df["product_name"] = df["product_name"].fillna("").astype(str)
|
|
@@ -383,6 +426,7 @@ def get_offers_df(force_refresh: bool = False) -> pd.DataFrame:
|
|
| 383 |
# =========================
|
| 384 |
# Gemini wrappers
|
| 385 |
# =========================
|
|
|
|
| 386 |
def gemini_generate_text(system: str, user: str, temperature: float = 0.4) -> str:
|
| 387 |
if not _gemini_client:
|
| 388 |
return ""
|
|
@@ -426,15 +470,15 @@ def gemini_generate_json(system: str, user: str, images: List = None) -> Dict[st
|
|
| 426 |
def gemini_generate_multimodal(system: str, user: str, images: List[Dict[str, Any]]) -> str:
|
| 427 |
"""
|
| 428 |
Uses Gemini multimodal:
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
"""
|
| 433 |
if not _gemini_client:
|
| 434 |
return ""
|
| 435 |
|
| 436 |
parts: List[Dict[str, Any]] = [{"text": system.strip() + "\n\n" + user.strip()}]
|
| 437 |
-
|
| 438 |
for img in images or []:
|
| 439 |
if "bytes" in img and img.get("mime"):
|
| 440 |
b64 = base64.b64encode(img["bytes"]).decode("utf-8")
|
|
@@ -465,14 +509,15 @@ def gemini_generate_multimodal(system: str, user: str, images: List[Dict[str, An
|
|
| 465 |
# =========================
|
| 466 |
# Intent + actionability
|
| 467 |
# =========================
|
|
|
|
| 468 |
INTENT_SYSTEM = """
|
| 469 |
You are Pricelyst AI. Your job: understand whether the user is asking for actionable shopping help.
|
| 470 |
Return STRICT JSON only.
|
| 471 |
|
| 472 |
Output schema:
|
| 473 |
{
|
| 474 |
-
|
| 475 |
-
|
| 476 |
"store_recommendation",
|
| 477 |
"price_lookup",
|
| 478 |
"price_compare",
|
|
@@ -483,10 +528,10 @@ Output schema:
|
|
| 483 |
"chit_chat",
|
| 484 |
"lifestyle_lookup",
|
| 485 |
"other"
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
}
|
| 491 |
|
| 492 |
Rules:
|
|
@@ -504,11 +549,11 @@ def detect_intent(message: str, images_present: bool, context: Dict[str, Any]) -
|
|
| 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)
|
|
@@ -517,7 +562,7 @@ def detect_intent(message: str, images_present: bool, context: Dict[str, Any]) -
|
|
| 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
|
|
@@ -530,17 +575,18 @@ def detect_intent(message: str, images_present: bool, context: Dict[str, Any]) -
|
|
| 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 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
}
|
| 545 |
|
| 546 |
If no shopping/planning occurred, set is_actionable=false.
|
|
@@ -554,6 +600,7 @@ def generate_shopping_plan(transcript: str) -> Dict[str, Any]:
|
|
| 554 |
# =========================
|
| 555 |
# Matching + analytics
|
| 556 |
# =========================
|
|
|
|
| 557 |
def search_products(df: pd.DataFrame, query: str, limit: int = 10) -> pd.DataFrame:
|
| 558 |
"""
|
| 559 |
Simple search: contains on clean_name + fallback token overlap scoring.
|
|
@@ -564,17 +611,17 @@ def search_products(df: pd.DataFrame, query: str, limit: int = 10) -> pd.DataFra
|
|
| 564 |
q = _norm_str(query)
|
| 565 |
if not q:
|
| 566 |
return df.head(0)
|
| 567 |
-
|
| 568 |
# direct contains
|
| 569 |
hit = df[df["clean_name"].str.contains(re.escape(q), na=False)]
|
| 570 |
if len(hit) >= limit:
|
| 571 |
return hit.head(limit)
|
| 572 |
-
|
| 573 |
# token overlap (cheap scoring)
|
| 574 |
q_tokens = set(q.split())
|
| 575 |
if not q_tokens:
|
| 576 |
return hit.head(limit)
|
| 577 |
-
|
| 578 |
tmp = df.copy()
|
| 579 |
tmp["score"] = tmp["clean_name"].apply(lambda s: len(q_tokens.intersection(set(str(s).split()))))
|
| 580 |
tmp = tmp[tmp["score"] > 0].sort_values(["score"], ascending=False)
|
|
@@ -585,18 +632,18 @@ def summarize_offers(df_hits: pd.DataFrame) -> Dict[str, Any]:
|
|
| 585 |
"""
|
| 586 |
For one product name, there can be multiple retailers (offers).
|
| 587 |
We return:
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
"""
|
| 592 |
if df_hits.empty:
|
| 593 |
return {}
|
| 594 |
-
|
| 595 |
# group by product_id (best is highest offer coverage)
|
| 596 |
grp = df_hits.groupby("product_id").size().sort_values(ascending=False)
|
| 597 |
best_pid = int(grp.index[0])
|
| 598 |
prod_rows = df_hits[df_hits["product_id"] == best_pid].copy()
|
| 599 |
-
|
| 600 |
prod_name = prod_rows["product_name"].iloc[0]
|
| 601 |
brand = prod_rows["brand_name"].iloc[0]
|
| 602 |
category = prod_rows["primary_category"].iloc[0]
|
|
@@ -604,10 +651,10 @@ def summarize_offers(df_hits: pd.DataFrame) -> Dict[str, Any]:
|
|
| 604 |
on_promo = bool(prod_rows["on_promotion"].iloc[0])
|
| 605 |
promo_badge = prod_rows["promo_badge"].iloc[0]
|
| 606 |
image = prod_rows["thumbnail"].iloc[0] or prod_rows["image"].iloc[0]
|
| 607 |
-
|
| 608 |
offers = prod_rows[prod_rows["offer_price"].notna()].copy()
|
| 609 |
offers = offers.sort_values("offer_price", ascending=True)
|
| 610 |
-
|
| 611 |
if offers.empty:
|
| 612 |
return {
|
| 613 |
"product_id": best_pid,
|
|
@@ -630,7 +677,7 @@ def summarize_offers(df_hits: pd.DataFrame) -> Dict[str, Any]:
|
|
| 630 |
}
|
| 631 |
lo = float(offers["offer_price"].min())
|
| 632 |
hi = float(offers["offer_price"].max())
|
| 633 |
-
|
| 634 |
top_offers = []
|
| 635 |
for _, r in offers.head(5).iterrows():
|
| 636 |
top_offers.append({
|
|
@@ -638,7 +685,7 @@ def summarize_offers(df_hits: pd.DataFrame) -> Dict[str, Any]:
|
|
| 638 |
"price": float(r["offer_price"]),
|
| 639 |
"retailer_logo": r["retailer_logo"],
|
| 640 |
})
|
| 641 |
-
|
| 642 |
return {
|
| 643 |
"product_id": best_pid,
|
| 644 |
"name": prod_name,
|
|
@@ -656,15 +703,15 @@ def summarize_offers(df_hits: pd.DataFrame) -> Dict[str, Any]:
|
|
| 656 |
def basket_store_choice(df: pd.DataFrame, items: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 657 |
"""
|
| 658 |
Given items, pick:
|
| 659 |
-
|
| 660 |
Very pragmatic MVP: for each item, match the best product and take cheapest offer.
|
| 661 |
"""
|
| 662 |
if df.empty or not items:
|
| 663 |
return {"items": [], "best_store": None, "missing": []}
|
| 664 |
-
|
| 665 |
results = []
|
| 666 |
missing = []
|
| 667 |
-
|
| 668 |
for it in items:
|
| 669 |
name = it.get("name") or ""
|
| 670 |
qty = int(it.get("quantity") or 1)
|
|
@@ -685,10 +732,10 @@ def basket_store_choice(df: pd.DataFrame, items: List[Dict[str, Any]]) -> Dict[s
|
|
| 685 |
"offers": summary.get("offers", []),
|
| 686 |
"image": summary.get("image"),
|
| 687 |
})
|
| 688 |
-
|
| 689 |
if not results:
|
| 690 |
return {"items": [], "best_store": None, "missing": missing}
|
| 691 |
-
|
| 692 |
# compute totals by retailer for "all cheapest per item"
|
| 693 |
retailer_totals: Dict[str, float] = {}
|
| 694 |
retailer_counts: Dict[str, int] = {}
|
|
@@ -696,7 +743,7 @@ def basket_store_choice(df: pd.DataFrame, items: List[Dict[str, Any]]) -> Dict[s
|
|
| 696 |
k = r["cheapest_retailer"]
|
| 697 |
retailer_totals[k] = retailer_totals.get(k, 0.0) + float(r["line_total"])
|
| 698 |
retailer_counts[k] = retailer_counts.get(k, 0) + 1
|
| 699 |
-
|
| 700 |
# Score: cover_count desc, then total asc
|
| 701 |
best = sorted(retailer_totals.keys(), key=lambda k: (-retailer_counts.get(k, 0), retailer_totals.get(k, 0.0)))[0]
|
| 702 |
return {
|
|
@@ -713,6 +760,7 @@ def basket_store_choice(df: pd.DataFrame, items: List[Dict[str, Any]]) -> Dict[s
|
|
| 713 |
# =========================
|
| 714 |
# Response rendering (informative)
|
| 715 |
# =========================
|
|
|
|
| 716 |
def render_price_answer(summary: Dict[str, Any]) -> Dict[str, Any]:
|
| 717 |
"""
|
| 718 |
Returns structured payload for frontend to render nicely.
|
|
@@ -733,7 +781,7 @@ def render_price_answer(summary: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 733 |
image = summary.get("image")
|
| 734 |
cheapest = summary.get("cheapest")
|
| 735 |
pr = summary.get("price_range")
|
| 736 |
-
|
| 737 |
lines = []
|
| 738 |
if cheapest:
|
| 739 |
lines.append(f"Cheapest right now: {cheapest['retailer']} — ${cheapest['price']:.2f}")
|
|
@@ -741,7 +789,7 @@ def render_price_answer(summary: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 741 |
lines.append(f"Price range: ${pr['min']:.2f} → ${pr['max']:.2f} (spread ${pr['spread']:.2f})")
|
| 742 |
if on_promo:
|
| 743 |
lines.append(f"Promo: {promo_badge or 'On promotion'}")
|
| 744 |
-
|
| 745 |
return {
|
| 746 |
"type": "product_price",
|
| 747 |
"title": name,
|
|
@@ -768,28 +816,29 @@ def render_basket_answer(basket: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 768 |
"best_store": best,
|
| 769 |
"items": basket["items"],
|
| 770 |
"missing": missing,
|
| 771 |
-
"notes": "If you want, tell me your budget and I
|
| 772 |
}
|
| 773 |
|
| 774 |
# =========================
|
| 775 |
# Multimodal extraction (lists / receipts)
|
| 776 |
# =========================
|
|
|
|
| 777 |
VISION_SYSTEM = """
|
| 778 |
You are an expert shopping assistant. Extract actionable items and quantities from the user's image(s).
|
| 779 |
Return STRICT JSON only.
|
| 780 |
|
| 781 |
Output schema:
|
| 782 |
{
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
}
|
| 787 |
|
| 788 |
Rules:
|
| 789 |
- If it looks like a handwritten shopping list, extract items.
|
| 790 |
- If it looks like a receipt, extract the purchased items (best-effort).
|
| 791 |
-
- If it
|
| 792 |
-
- Keep it conservative: only include items you
|
| 793 |
"""
|
| 794 |
|
| 795 |
def extract_items_from_images(images: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
@@ -807,12 +856,14 @@ def extract_items_from_images(images: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
| 807 |
# =========================
|
| 808 |
# Routes
|
| 809 |
# =========================
|
|
|
|
| 810 |
@app.get("/health")
|
| 811 |
def health():
|
| 812 |
return jsonify({
|
| 813 |
"ok": True,
|
| 814 |
"ts": now_utc_iso(),
|
| 815 |
"gemini": bool(_gemini_client),
|
|
|
|
| 816 |
"products_cached_rows": int(len(_product_cache["df_offers"])) if isinstance(_product_cache["df_offers"], pd.DataFrame) else 0,
|
| 817 |
"products_raw_count": int(_product_cache.get("raw_count", 0)),
|
| 818 |
})
|
|
@@ -868,7 +919,7 @@ def chat():
|
|
| 868 |
|
| 869 |
# 4) Actionable: execute
|
| 870 |
df = get_offers_df(force_refresh=False)
|
| 871 |
-
|
| 872 |
response_payload: Dict[str, Any] = {"type": "unknown", "message": "No result."}
|
| 873 |
|
| 874 |
# --- NEW: Check for Lifestyle/Essentials (Fuel/ZESA) ---
|
|
@@ -923,7 +974,7 @@ def chat():
|
|
| 923 |
hits = search_products(df, it.get("name") or "", limit=60)
|
| 924 |
summary = summarize_offers(hits)
|
| 925 |
comparisons.append(summary)
|
| 926 |
-
|
| 927 |
# compute cheapest for each
|
| 928 |
rows = []
|
| 929 |
for s in comparisons:
|
|
@@ -968,7 +1019,7 @@ def call_briefing():
|
|
| 968 |
|
| 969 |
username = body.get("username")
|
| 970 |
prof = get_profile(profile_id)
|
| 971 |
-
|
| 972 |
if username and not prof.get("username"):
|
| 973 |
update_profile(profile_id, {"username": username})
|
| 974 |
prof["username"] = username
|
|
@@ -976,21 +1027,24 @@ def call_briefing():
|
|
| 976 |
# Build lightweight "shopping intelligence" variables for ElevenLabs agent
|
| 977 |
prefs = prof.get("preferences") or {}
|
| 978 |
last_store = (prefs.get("last_best_store") or "").strip() or None
|
| 979 |
-
|
| 980 |
# quick stats from recent chats (last 25)
|
| 981 |
-
logs = db.collection("pricelyst_profiles").document(profile_id).collection("chat_logs") \
|
| 982 |
-
.order_by("ts", direction=firestore.Query.DESCENDING).limit(25).stream()
|
| 983 |
-
|
| 984 |
-
intents = []
|
| 985 |
-
for d in logs:
|
| 986 |
-
dd = d.to_dict() or {}
|
| 987 |
-
ii = (dd.get("intent") or {}).get("intent")
|
| 988 |
-
if ii:
|
| 989 |
-
intents.append(ii)
|
| 990 |
-
|
| 991 |
intent_counts: Dict[str, int] = {}
|
| 992 |
-
|
| 993 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 994 |
|
| 995 |
# --- KPI Snapshot Logic ---
|
| 996 |
# We construct a dictionary that the React client will pass as a JSON string
|
|
@@ -1023,23 +1077,36 @@ def log_call_usage():
|
|
| 1023 |
started_at = body.get("started_at") or None
|
| 1024 |
ended_at = body.get("ended_at") or None
|
| 1025 |
stats = body.get("stats") or {}
|
|
|
|
|
|
|
| 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 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1043 |
|
| 1044 |
# Log the call (link the plan_id)
|
| 1045 |
doc_id = log_call(profile_id, {
|
|
@@ -1051,10 +1118,13 @@ def log_call_usage():
|
|
| 1051 |
"generated_plan_id": plan_id,
|
| 1052 |
"report_markdown": report_md,
|
| 1053 |
})
|
| 1054 |
-
|
| 1055 |
# update counters
|
| 1056 |
-
|
| 1057 |
-
|
|
|
|
|
|
|
|
|
|
| 1058 |
|
| 1059 |
return jsonify({
|
| 1060 |
"ok": True,
|
|
@@ -1062,37 +1132,46 @@ def log_call_usage():
|
|
| 1062 |
"shopping_plan": plan_data if plan_id else None # Frontend uses this for PDF
|
| 1063 |
})
|
| 1064 |
|
| 1065 |
-
#
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 1083 |
-
|
| 1084 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1091 |
-
|
|
|
|
|
|
|
|
|
|
| 1092 |
|
| 1093 |
# =========================
|
| 1094 |
# Run
|
| 1095 |
# =========================
|
|
|
|
| 1096 |
if __name__ == "__main__":
|
| 1097 |
port = int(os.environ.get("PORT", "7860"))
|
| 1098 |
app.run(host="0.0.0.0", port=port, debug=True)
|
|
|
|
| 9 |
✅ Call briefing (Zim Essentials Injection)
|
| 10 |
✅ Post-call Shopping Plan Generation (PDF-ready)
|
| 11 |
|
| 12 |
+
ENV VARS YOU NEED:
|
| 13 |
- GOOGLE_API_KEY=...
|
| 14 |
- FIREBASE='{"type":"service_account", ...}' # full JSON string
|
| 15 |
- PRICE_API_BASE=https://api.pricelyst.co.zw # optional
|
|
|
|
| 33 |
from flask import Flask, request, jsonify
|
| 34 |
from flask_cors import CORS
|
| 35 |
|
| 36 |
+
# ––––– Logging –––––
|
| 37 |
+
|
| 38 |
logging.basicConfig(
|
| 39 |
level=logging.INFO,
|
| 40 |
format="%(asctime)s | %(levelname)s | %(message)s"
|
| 41 |
)
|
| 42 |
logger = logging.getLogger("pricelyst-advisor")
|
| 43 |
|
| 44 |
+
# ––––– Gemini (NEW SDK) –––––
|
| 45 |
+
|
| 46 |
# pip install google-genai
|
| 47 |
+
|
| 48 |
try:
|
| 49 |
from google import genai
|
| 50 |
from google.genai import types
|
|
|
|
| 63 |
except Exception as e:
|
| 64 |
logger.error("Failed to init Gemini client: %s", e)
|
| 65 |
|
| 66 |
+
# ––––– Firebase Admin –––––
|
| 67 |
+
|
| 68 |
# pip install firebase-admin
|
| 69 |
+
|
| 70 |
import firebase_admin
|
| 71 |
from firebase_admin import credentials, firestore
|
| 72 |
|
| 73 |
FIREBASE_ENV = os.environ.get("FIREBASE", "")
|
| 74 |
|
| 75 |
def init_firestore_from_env() -> firestore.Client:
|
| 76 |
+
# 1. Check if already initialized
|
| 77 |
if firebase_admin._apps:
|
| 78 |
return firestore.client()
|
| 79 |
|
| 80 |
+
# 2. Check for Creds
|
| 81 |
if not FIREBASE_ENV:
|
| 82 |
+
logger.critical("FIREBASE env var missing. Persistence will fail.")
|
| 83 |
raise RuntimeError("FIREBASE env var missing. Provide full service account JSON string.")
|
| 84 |
|
| 85 |
+
try:
|
| 86 |
+
sa_info = json.loads(FIREBASE_ENV)
|
| 87 |
+
cred = credentials.Certificate(sa_info)
|
| 88 |
+
firebase_admin.initialize_app(cred)
|
| 89 |
+
logger.info("Firebase initialized successfully.")
|
| 90 |
+
return firestore.client()
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.critical("Failed to initialize Firebase: %s", e)
|
| 93 |
+
raise e
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
db = init_firestore_from_env()
|
| 97 |
+
except Exception as e:
|
| 98 |
+
logger.error("DB Init failed: %s", e)
|
| 99 |
+
db = None
|
| 100 |
|
| 101 |
+
# ––––– External API (Pricelyst) –––––
|
| 102 |
|
|
|
|
| 103 |
PRICE_API_BASE = os.environ.get("PRICE_API_BASE", "https://api.pricelyst.co.zw").rstrip("/")
|
| 104 |
HTTP_TIMEOUT = 20
|
| 105 |
|
| 106 |
+
# ––––– Flask –––––
|
| 107 |
+
|
| 108 |
app = Flask(__name__)
|
| 109 |
CORS(app)
|
| 110 |
|
| 111 |
+
# ––––– In-memory product cache –––––
|
| 112 |
+
|
| 113 |
PRODUCT_CACHE_TTL_SEC = 60 * 10 # 10 minutes
|
| 114 |
_product_cache: Dict[str, Any] = {
|
| 115 |
"ts": 0,
|
|
|
|
| 117 |
"raw_count": 0,
|
| 118 |
}
|
| 119 |
|
| 120 |
+
# ––––– Static Data (New Feature) –––––
|
| 121 |
+
|
| 122 |
ZIM_ESSENTIALS = {
|
| 123 |
"fuel_petrol": "$1.58/L (Blend)",
|
| 124 |
"fuel_diesel": "$1.65/L (Diesel 50)",
|
|
|
|
| 130 |
# =========================
|
| 131 |
# Helpers: time / strings
|
| 132 |
# =========================
|
| 133 |
+
|
| 134 |
def now_utc_iso() -> str:
|
| 135 |
return datetime.now(timezone.utc).isoformat()
|
| 136 |
|
|
|
|
| 162 |
# =========================
|
| 163 |
# Firestore profile storage
|
| 164 |
# =========================
|
| 165 |
+
|
| 166 |
def profile_ref(profile_id: str):
|
| 167 |
+
if not db: return None
|
| 168 |
return db.collection("pricelyst_profiles").document(profile_id)
|
| 169 |
|
| 170 |
def get_profile(profile_id: str) -> Dict[str, Any]:
|
| 171 |
+
if not db:
|
| 172 |
+
return {}
|
| 173 |
+
try:
|
| 174 |
+
ref = profile_ref(profile_id)
|
| 175 |
+
doc = ref.get()
|
| 176 |
+
if doc.exists:
|
| 177 |
+
return doc.to_dict() or {}
|
| 178 |
+
# create default
|
| 179 |
+
data = {
|
| 180 |
+
"profile_id": profile_id,
|
| 181 |
+
"created_at": now_utc_iso(),
|
| 182 |
+
"updated_at": now_utc_iso(),
|
| 183 |
+
"username": None,
|
| 184 |
+
"memory_summary": "",
|
| 185 |
+
"preferences": {},
|
| 186 |
+
"last_actions": [],
|
| 187 |
+
"counters": {
|
| 188 |
+
"chats": 0,
|
| 189 |
+
"calls": 0,
|
| 190 |
+
}
|
| 191 |
}
|
| 192 |
+
ref.set(data)
|
| 193 |
+
return data
|
| 194 |
+
except Exception as e:
|
| 195 |
+
logger.error("get_profile error for %s: %s", profile_id, e)
|
| 196 |
+
return {}
|
| 197 |
|
| 198 |
def update_profile(profile_id: str, patch: Dict[str, Any]) -> None:
|
| 199 |
+
if not db: return
|
| 200 |
+
try:
|
| 201 |
+
patch = dict(patch or {})
|
| 202 |
+
patch["updated_at"] = now_utc_iso()
|
| 203 |
+
profile_ref(profile_id).set(patch, merge=True)
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logger.error("update_profile error: %s", e)
|
| 206 |
|
| 207 |
def log_chat(profile_id: str, payload: Dict[str, Any]) -> None:
|
| 208 |
+
if not db:
|
| 209 |
+
logger.warning("DB not connected, skipping log_chat")
|
| 210 |
+
return
|
| 211 |
+
try:
|
| 212 |
+
logger.info("Logging chat for %s. Type: %s", profile_id, payload.get("response_type"))
|
| 213 |
+
db.collection("pricelyst_profiles").document(profile_id).collection("chat_logs").add({
|
| 214 |
+
**payload,
|
| 215 |
+
"ts": now_utc_iso()
|
| 216 |
+
})
|
| 217 |
+
except Exception as e:
|
| 218 |
+
logger.error("Failed to log chat: %s", e)
|
| 219 |
|
| 220 |
def log_call(profile_id: str, payload: Dict[str, Any]) -> str:
|
| 221 |
+
if not db:
|
| 222 |
+
logger.warning("DB not connected, skipping log_call")
|
| 223 |
+
return ""
|
| 224 |
+
try:
|
| 225 |
+
logger.info("Logging call for %s. Transcript len: %s", profile_id, len(payload.get("transcript", "")))
|
| 226 |
+
doc_ref = db.collection("pricelyst_profiles").document(profile_id).collection("call_logs").document()
|
| 227 |
+
doc_ref.set({
|
| 228 |
+
**payload,
|
| 229 |
+
"ts": now_utc_iso()
|
| 230 |
+
})
|
| 231 |
+
logger.info("Call logged successfully. ID: %s", doc_ref.id)
|
| 232 |
+
return doc_ref.id
|
| 233 |
+
except Exception as e:
|
| 234 |
+
logger.error("Failed to log call: %s", e)
|
| 235 |
+
return ""
|
| 236 |
|
| 237 |
# =========================
|
| 238 |
# Multimodal image handling
|
| 239 |
# =========================
|
| 240 |
+
|
| 241 |
def parse_images(images: List[str]) -> List[Dict[str, Any]]:
|
| 242 |
"""
|
| 243 |
Accepts:
|
| 244 |
+
- data URLs: data:image/png;base64,....
|
| 245 |
+
- raw base64 strings
|
| 246 |
+
- http(s) URLs
|
| 247 |
Returns: list of { "mime": "...", "bytes": b"..." } or { "url": "..." }
|
| 248 |
"""
|
| 249 |
out = []
|
|
|
|
| 251 |
if not item:
|
| 252 |
continue
|
| 253 |
item = item.strip()
|
| 254 |
+
|
| 255 |
# URL
|
| 256 |
if item.startswith("http://") or item.startswith("https://"):
|
| 257 |
out.append({"url": item})
|
|
|
|
| 267 |
except Exception:
|
| 268 |
continue
|
| 269 |
continue
|
| 270 |
+
|
| 271 |
# raw base64
|
| 272 |
try:
|
| 273 |
out.append({"mime": "image/png", "bytes": base64.b64decode(item)})
|
| 274 |
except Exception:
|
| 275 |
continue
|
| 276 |
+
|
| 277 |
return out
|
| 278 |
|
| 279 |
# =========================
|
| 280 |
# Product fetching + offers DF
|
| 281 |
# =========================
|
| 282 |
+
|
| 283 |
def fetch_products_page(page: int, per_page: int = 50) -> Dict[str, Any]:
|
| 284 |
url = f"{PRICE_API_BASE}/api/v1/products"
|
| 285 |
params = {"page": page, "perPage": per_page}
|
|
|
|
| 288 |
return r.json()
|
| 289 |
|
| 290 |
def fetch_products(max_pages: int = 6, per_page: int = 50) -> List[Dict[str, Any]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
products: List[Dict[str, Any]] = []
|
| 292 |
for p in range(1, max_pages + 1):
|
| 293 |
payload = fetch_products_page(p, per_page=per_page)
|
|
|
|
| 302 |
return products
|
| 303 |
|
| 304 |
def products_to_offers_df(products: List[Dict[str, Any]]) -> pd.DataFrame:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
rows = []
|
| 306 |
for p in products or []:
|
| 307 |
try:
|
| 308 |
product_id = p.get("id")
|
| 309 |
name = p.get("name") or ""
|
| 310 |
clean_name = _norm_str(name)
|
| 311 |
+
|
| 312 |
brand_name = ((p.get("brand") or {}).get("brand_name")) if isinstance(p.get("brand"), dict) else None
|
| 313 |
categories = p.get("categories") or []
|
| 314 |
cat_names = []
|
|
|
|
| 316 |
if isinstance(c, dict) and c.get("name"):
|
| 317 |
cat_names.append(c.get("name"))
|
| 318 |
primary_category = cat_names[0] if cat_names else None
|
| 319 |
+
|
| 320 |
stock_status = p.get("stock_status")
|
| 321 |
on_promo = bool(p.get("on_promotion"))
|
| 322 |
promo_badge = p.get("promo_badge")
|
| 323 |
promo_name = p.get("promo_name")
|
| 324 |
promo_price = _coerce_float(p.get("promo_price"))
|
| 325 |
original_price = _coerce_float(p.get("original_price"))
|
| 326 |
+
|
| 327 |
recommended_price = _coerce_float(p.get("recommended_price"))
|
| 328 |
base_price = _coerce_float(p.get("price"))
|
| 329 |
bulk_price = _coerce_float(p.get("bulk_price"))
|
| 330 |
bulk_unit = p.get("bulk_unit")
|
| 331 |
+
|
| 332 |
image = p.get("image")
|
| 333 |
thumb = p.get("thumbnail")
|
| 334 |
+
|
| 335 |
offers = p.get("prices") or []
|
| 336 |
if not offers:
|
| 337 |
rows.append({
|
|
|
|
| 396 |
df = pd.DataFrame(rows)
|
| 397 |
if df.empty:
|
| 398 |
return df
|
| 399 |
+
|
| 400 |
df["offer_price"] = df["offer_price"].apply(_coerce_float)
|
| 401 |
df["clean_name"] = df["clean_name"].fillna("").astype(str)
|
| 402 |
df["product_name"] = df["product_name"].fillna("").astype(str)
|
|
|
|
| 426 |
# =========================
|
| 427 |
# Gemini wrappers
|
| 428 |
# =========================
|
| 429 |
+
|
| 430 |
def gemini_generate_text(system: str, user: str, temperature: float = 0.4) -> str:
|
| 431 |
if not _gemini_client:
|
| 432 |
return ""
|
|
|
|
| 470 |
def gemini_generate_multimodal(system: str, user: str, images: List[Dict[str, Any]]) -> str:
|
| 471 |
"""
|
| 472 |
Uses Gemini multimodal:
|
| 473 |
+
- if we have bytes -> inline_data
|
| 474 |
+
- if we have url -> just paste the URL (server-side fetch is unreliable w/o whitelisting),
|
| 475 |
+
so we prefer bytes from the client.
|
| 476 |
"""
|
| 477 |
if not _gemini_client:
|
| 478 |
return ""
|
| 479 |
|
| 480 |
parts: List[Dict[str, Any]] = [{"text": system.strip() + "\n\n" + user.strip()}]
|
| 481 |
+
|
| 482 |
for img in images or []:
|
| 483 |
if "bytes" in img and img.get("mime"):
|
| 484 |
b64 = base64.b64encode(img["bytes"]).decode("utf-8")
|
|
|
|
| 509 |
# =========================
|
| 510 |
# Intent + actionability
|
| 511 |
# =========================
|
| 512 |
+
|
| 513 |
INTENT_SYSTEM = """
|
| 514 |
You are Pricelyst AI. Your job: understand whether the user is asking for actionable shopping help.
|
| 515 |
Return STRICT JSON only.
|
| 516 |
|
| 517 |
Output schema:
|
| 518 |
{
|
| 519 |
+
"actionable": true|false,
|
| 520 |
+
"intent": one of [
|
| 521 |
"store_recommendation",
|
| 522 |
"price_lookup",
|
| 523 |
"price_compare",
|
|
|
|
| 528 |
"chit_chat",
|
| 529 |
"lifestyle_lookup",
|
| 530 |
"other"
|
| 531 |
+
],
|
| 532 |
+
"items": [{"name": "...", "quantity": 1}],
|
| 533 |
+
"constraints": {"budget": number|null, "location": "... "|null, "time_context": "mid-month|month-end|weekend|today|unknown"},
|
| 534 |
+
"notes": "short reasoning"
|
| 535 |
}
|
| 536 |
|
| 537 |
Rules:
|
|
|
|
| 549 |
clean_k = k.split('_')[-1] # fuel_petrol -> petrol
|
| 550 |
if clean_k in msg_lower and "price" in msg_lower:
|
| 551 |
return {"actionable": True, "intent": "lifestyle_lookup", "items": [{"name": k}]}
|
| 552 |
+
|
| 553 |
# 2. Gemini Detection
|
| 554 |
ctx_str = json.dumps(context or {}, ensure_ascii=False)
|
| 555 |
user = f"Message: {message}\nImagesPresent: {images_present}\nContext: {ctx_str}"
|
| 556 |
+
|
| 557 |
# Try using the strict JSON helper first for better reliability
|
| 558 |
try:
|
| 559 |
data = gemini_generate_json(INTENT_SYSTEM, user)
|
|
|
|
| 562 |
# Fallback to text parsing if JSON mode fails (Backward Compat)
|
| 563 |
out = gemini_generate_text(INTENT_SYSTEM, user, temperature=0.1)
|
| 564 |
data = _safe_json_loads(out, fallback={})
|
| 565 |
+
|
| 566 |
if not isinstance(data, dict):
|
| 567 |
return {"actionable": False, "intent": "other", "items": [], "constraints": {}, "notes": "bad_json"}
|
| 568 |
# normalize
|
|
|
|
| 575 |
# =========================
|
| 576 |
# Shopping Plan Generator (NEW)
|
| 577 |
# =========================
|
| 578 |
+
|
| 579 |
PLAN_SYSTEM_PROMPT = """
|
| 580 |
You are Jessica, the Pricelyst Shopping Advisor. Analyze the conversation transcript.
|
| 581 |
If the user discussed a shopping list, budget plan, or event needs, create a structured plan.
|
| 582 |
|
| 583 |
OUTPUT JSON SCHEMA:
|
| 584 |
{
|
| 585 |
+
"is_actionable": boolean,
|
| 586 |
+
"title": "Short title (e.g. 'Weekend Braai List')",
|
| 587 |
+
"summary": "1 sentence summary",
|
| 588 |
+
"items": [{"name": "string", "qty": "string", "est_price": number|null}],
|
| 589 |
+
"markdown_content": "A clean Markdown report for a PDF. Include headers (#), bullet points, and a budget summary table if applicable. Keep it professional."
|
| 590 |
}
|
| 591 |
|
| 592 |
If no shopping/planning occurred, set is_actionable=false.
|
|
|
|
| 600 |
# =========================
|
| 601 |
# Matching + analytics
|
| 602 |
# =========================
|
| 603 |
+
|
| 604 |
def search_products(df: pd.DataFrame, query: str, limit: int = 10) -> pd.DataFrame:
|
| 605 |
"""
|
| 606 |
Simple search: contains on clean_name + fallback token overlap scoring.
|
|
|
|
| 611 |
q = _norm_str(query)
|
| 612 |
if not q:
|
| 613 |
return df.head(0)
|
| 614 |
+
|
| 615 |
# direct contains
|
| 616 |
hit = df[df["clean_name"].str.contains(re.escape(q), na=False)]
|
| 617 |
if len(hit) >= limit:
|
| 618 |
return hit.head(limit)
|
| 619 |
+
|
| 620 |
# token overlap (cheap scoring)
|
| 621 |
q_tokens = set(q.split())
|
| 622 |
if not q_tokens:
|
| 623 |
return hit.head(limit)
|
| 624 |
+
|
| 625 |
tmp = df.copy()
|
| 626 |
tmp["score"] = tmp["clean_name"].apply(lambda s: len(q_tokens.intersection(set(str(s).split()))))
|
| 627 |
tmp = tmp[tmp["score"] > 0].sort_values(["score"], ascending=False)
|
|
|
|
| 632 |
"""
|
| 633 |
For one product name, there can be multiple retailers (offers).
|
| 634 |
We return:
|
| 635 |
+
- cheapest offer
|
| 636 |
+
- price range
|
| 637 |
+
- top offers
|
| 638 |
"""
|
| 639 |
if df_hits.empty:
|
| 640 |
return {}
|
| 641 |
+
|
| 642 |
# group by product_id (best is highest offer coverage)
|
| 643 |
grp = df_hits.groupby("product_id").size().sort_values(ascending=False)
|
| 644 |
best_pid = int(grp.index[0])
|
| 645 |
prod_rows = df_hits[df_hits["product_id"] == best_pid].copy()
|
| 646 |
+
|
| 647 |
prod_name = prod_rows["product_name"].iloc[0]
|
| 648 |
brand = prod_rows["brand_name"].iloc[0]
|
| 649 |
category = prod_rows["primary_category"].iloc[0]
|
|
|
|
| 651 |
on_promo = bool(prod_rows["on_promotion"].iloc[0])
|
| 652 |
promo_badge = prod_rows["promo_badge"].iloc[0]
|
| 653 |
image = prod_rows["thumbnail"].iloc[0] or prod_rows["image"].iloc[0]
|
| 654 |
+
|
| 655 |
offers = prod_rows[prod_rows["offer_price"].notna()].copy()
|
| 656 |
offers = offers.sort_values("offer_price", ascending=True)
|
| 657 |
+
|
| 658 |
if offers.empty:
|
| 659 |
return {
|
| 660 |
"product_id": best_pid,
|
|
|
|
| 677 |
}
|
| 678 |
lo = float(offers["offer_price"].min())
|
| 679 |
hi = float(offers["offer_price"].max())
|
| 680 |
+
|
| 681 |
top_offers = []
|
| 682 |
for _, r in offers.head(5).iterrows():
|
| 683 |
top_offers.append({
|
|
|
|
| 685 |
"price": float(r["offer_price"]),
|
| 686 |
"retailer_logo": r["retailer_logo"],
|
| 687 |
})
|
| 688 |
+
|
| 689 |
return {
|
| 690 |
"product_id": best_pid,
|
| 691 |
"name": prod_name,
|
|
|
|
| 703 |
def basket_store_choice(df: pd.DataFrame, items: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 704 |
"""
|
| 705 |
Given items, pick:
|
| 706 |
+
- best single store to cover most items and minimize total
|
| 707 |
Very pragmatic MVP: for each item, match the best product and take cheapest offer.
|
| 708 |
"""
|
| 709 |
if df.empty or not items:
|
| 710 |
return {"items": [], "best_store": None, "missing": []}
|
| 711 |
+
|
| 712 |
results = []
|
| 713 |
missing = []
|
| 714 |
+
|
| 715 |
for it in items:
|
| 716 |
name = it.get("name") or ""
|
| 717 |
qty = int(it.get("quantity") or 1)
|
|
|
|
| 732 |
"offers": summary.get("offers", []),
|
| 733 |
"image": summary.get("image"),
|
| 734 |
})
|
| 735 |
+
|
| 736 |
if not results:
|
| 737 |
return {"items": [], "best_store": None, "missing": missing}
|
| 738 |
+
|
| 739 |
# compute totals by retailer for "all cheapest per item"
|
| 740 |
retailer_totals: Dict[str, float] = {}
|
| 741 |
retailer_counts: Dict[str, int] = {}
|
|
|
|
| 743 |
k = r["cheapest_retailer"]
|
| 744 |
retailer_totals[k] = retailer_totals.get(k, 0.0) + float(r["line_total"])
|
| 745 |
retailer_counts[k] = retailer_counts.get(k, 0) + 1
|
| 746 |
+
|
| 747 |
# Score: cover_count desc, then total asc
|
| 748 |
best = sorted(retailer_totals.keys(), key=lambda k: (-retailer_counts.get(k, 0), retailer_totals.get(k, 0.0)))[0]
|
| 749 |
return {
|
|
|
|
| 760 |
# =========================
|
| 761 |
# Response rendering (informative)
|
| 762 |
# =========================
|
| 763 |
+
|
| 764 |
def render_price_answer(summary: Dict[str, Any]) -> Dict[str, Any]:
|
| 765 |
"""
|
| 766 |
Returns structured payload for frontend to render nicely.
|
|
|
|
| 781 |
image = summary.get("image")
|
| 782 |
cheapest = summary.get("cheapest")
|
| 783 |
pr = summary.get("price_range")
|
| 784 |
+
|
| 785 |
lines = []
|
| 786 |
if cheapest:
|
| 787 |
lines.append(f"Cheapest right now: {cheapest['retailer']} — ${cheapest['price']:.2f}")
|
|
|
|
| 789 |
lines.append(f"Price range: ${pr['min']:.2f} → ${pr['max']:.2f} (spread ${pr['spread']:.2f})")
|
| 790 |
if on_promo:
|
| 791 |
lines.append(f"Promo: {promo_badge or 'On promotion'}")
|
| 792 |
+
|
| 793 |
return {
|
| 794 |
"type": "product_price",
|
| 795 |
"title": name,
|
|
|
|
| 816 |
"best_store": best,
|
| 817 |
"items": basket["items"],
|
| 818 |
"missing": missing,
|
| 819 |
+
"notes": "If you want, tell me your budget and I'll suggest cheaper substitutes.",
|
| 820 |
}
|
| 821 |
|
| 822 |
# =========================
|
| 823 |
# Multimodal extraction (lists / receipts)
|
| 824 |
# =========================
|
| 825 |
+
|
| 826 |
VISION_SYSTEM = """
|
| 827 |
You are an expert shopping assistant. Extract actionable items and quantities from the user's image(s).
|
| 828 |
Return STRICT JSON only.
|
| 829 |
|
| 830 |
Output schema:
|
| 831 |
{
|
| 832 |
+
"actionable": true|false,
|
| 833 |
+
"items": [{"name":"...", "quantity": 1}],
|
| 834 |
+
"notes": "short"
|
| 835 |
}
|
| 836 |
|
| 837 |
Rules:
|
| 838 |
- If it looks like a handwritten shopping list, extract items.
|
| 839 |
- If it looks like a receipt, extract the purchased items (best-effort).
|
| 840 |
+
- If it's random (selfie, meme, etc), actionable=false and items=[].
|
| 841 |
+
- Keep it conservative: only include items you're confident about.
|
| 842 |
"""
|
| 843 |
|
| 844 |
def extract_items_from_images(images: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
|
| 856 |
# =========================
|
| 857 |
# Routes
|
| 858 |
# =========================
|
| 859 |
+
|
| 860 |
@app.get("/health")
|
| 861 |
def health():
|
| 862 |
return jsonify({
|
| 863 |
"ok": True,
|
| 864 |
"ts": now_utc_iso(),
|
| 865 |
"gemini": bool(_gemini_client),
|
| 866 |
+
"firestore": bool(db),
|
| 867 |
"products_cached_rows": int(len(_product_cache["df_offers"])) if isinstance(_product_cache["df_offers"], pd.DataFrame) else 0,
|
| 868 |
"products_raw_count": int(_product_cache.get("raw_count", 0)),
|
| 869 |
})
|
|
|
|
| 919 |
|
| 920 |
# 4) Actionable: execute
|
| 921 |
df = get_offers_df(force_refresh=False)
|
| 922 |
+
|
| 923 |
response_payload: Dict[str, Any] = {"type": "unknown", "message": "No result."}
|
| 924 |
|
| 925 |
# --- NEW: Check for Lifestyle/Essentials (Fuel/ZESA) ---
|
|
|
|
| 974 |
hits = search_products(df, it.get("name") or "", limit=60)
|
| 975 |
summary = summarize_offers(hits)
|
| 976 |
comparisons.append(summary)
|
| 977 |
+
|
| 978 |
# compute cheapest for each
|
| 979 |
rows = []
|
| 980 |
for s in comparisons:
|
|
|
|
| 1019 |
|
| 1020 |
username = body.get("username")
|
| 1021 |
prof = get_profile(profile_id)
|
| 1022 |
+
|
| 1023 |
if username and not prof.get("username"):
|
| 1024 |
update_profile(profile_id, {"username": username})
|
| 1025 |
prof["username"] = username
|
|
|
|
| 1027 |
# Build lightweight "shopping intelligence" variables for ElevenLabs agent
|
| 1028 |
prefs = prof.get("preferences") or {}
|
| 1029 |
last_store = (prefs.get("last_best_store") or "").strip() or None
|
| 1030 |
+
|
| 1031 |
# quick stats from recent chats (last 25)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1032 |
intent_counts: Dict[str, int] = {}
|
| 1033 |
+
try:
|
| 1034 |
+
logs = db.collection("pricelyst_profiles").document(profile_id).collection("chat_logs") \
|
| 1035 |
+
.order_by("ts", direction=firestore.Query.DESCENDING).limit(25).stream()
|
| 1036 |
+
|
| 1037 |
+
intents = []
|
| 1038 |
+
for d in logs:
|
| 1039 |
+
dd = d.to_dict() or {}
|
| 1040 |
+
ii = (dd.get("intent") or {}).get("intent")
|
| 1041 |
+
if ii:
|
| 1042 |
+
intents.append(ii)
|
| 1043 |
+
|
| 1044 |
+
for ii in intents:
|
| 1045 |
+
intent_counts[ii] = intent_counts.get(ii, 0) + 1
|
| 1046 |
+
except Exception as e:
|
| 1047 |
+
logger.error("Error fetching call briefing chat history: %s", e)
|
| 1048 |
|
| 1049 |
# --- KPI Snapshot Logic ---
|
| 1050 |
# We construct a dictionary that the React client will pass as a JSON string
|
|
|
|
| 1077 |
started_at = body.get("started_at") or None
|
| 1078 |
ended_at = body.get("ended_at") or None
|
| 1079 |
stats = body.get("stats") or {}
|
| 1080 |
+
|
| 1081 |
+
logger.info("Received call usage for %s. Transcript len: %d", profile_id, len(transcript))
|
| 1082 |
|
| 1083 |
prof = get_profile(profile_id)
|
| 1084 |
|
| 1085 |
# --- UPGRADE: Use Shopping Plan Generator (JSON + Markdown) ---
|
|
|
|
| 1086 |
plan_id = None
|
| 1087 |
report_md = ""
|
| 1088 |
+
plan_data = {}
|
| 1089 |
|
| 1090 |
+
try:
|
| 1091 |
+
if transcript:
|
| 1092 |
+
logger.info("Generating shopping plan via Gemini...")
|
| 1093 |
+
plan_data = generate_shopping_plan(transcript)
|
| 1094 |
+
logger.info("Plan generated. actionable=%s, title=%s", plan_data.get("is_actionable"), plan_data.get("title"))
|
| 1095 |
+
|
| 1096 |
+
if plan_data.get("is_actionable"):
|
| 1097 |
+
# Save structured plan
|
| 1098 |
+
plan_ref = db.collection("pricelyst_profiles").document(profile_id).collection("shopping_plans").document()
|
| 1099 |
+
plan_data["id"] = plan_ref.id
|
| 1100 |
+
plan_data["call_id"] = call_id
|
| 1101 |
+
plan_data["created_at"] = now_utc_iso()
|
| 1102 |
+
plan_ref.set(plan_data)
|
| 1103 |
+
plan_id = plan_ref.id
|
| 1104 |
+
report_md = plan_data.get("markdown_content", "")
|
| 1105 |
+
logger.info("Shopping plan stored. ID=%s", plan_id)
|
| 1106 |
+
else:
|
| 1107 |
+
logger.info("No actionable shopping plan found in call.")
|
| 1108 |
+
except Exception as e:
|
| 1109 |
+
logger.error("Error generating/storing shopping plan: %s", e)
|
| 1110 |
|
| 1111 |
# Log the call (link the plan_id)
|
| 1112 |
doc_id = log_call(profile_id, {
|
|
|
|
| 1118 |
"generated_plan_id": plan_id,
|
| 1119 |
"report_markdown": report_md,
|
| 1120 |
})
|
| 1121 |
+
|
| 1122 |
# update counters
|
| 1123 |
+
try:
|
| 1124 |
+
counters = prof.get("counters") or {}
|
| 1125 |
+
update_profile(profile_id, {"counters": {"calls": int(counters.get("calls", 0)) + 1}})
|
| 1126 |
+
except Exception as e:
|
| 1127 |
+
logger.error("Error updating profile counters: %s", e)
|
| 1128 |
|
| 1129 |
return jsonify({
|
| 1130 |
"ok": True,
|
|
|
|
| 1132 |
"shopping_plan": plan_data if plan_id else None # Frontend uses this for PDF
|
| 1133 |
})
|
| 1134 |
|
| 1135 |
+
# — NEW: Shopping Plans CRUD —
|
| 1136 |
+
|
| 1137 |
@app.get("/api/shopping-plans")
|
| 1138 |
def list_plans():
|
| 1139 |
pid = request.args.get("profile_id")
|
| 1140 |
if not pid: return jsonify({"ok": False}), 400
|
| 1141 |
try:
|
| 1142 |
+
docs = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans") \
|
| 1143 |
+
.order_by("created_at", direction=firestore.Query.DESCENDING).limit(20).stream()
|
| 1144 |
plans = [{"id": d.id, **d.to_dict()} for d in docs]
|
| 1145 |
return jsonify({"ok": True, "plans": plans})
|
| 1146 |
except Exception as e:
|
| 1147 |
+
logger.error("list_plans error: %s", e)
|
| 1148 |
return jsonify({"ok": False, "error": str(e)}), 500
|
| 1149 |
|
| 1150 |
@app.get("/api/shopping-plans/<plan_id>")
|
| 1151 |
def get_plan(plan_id):
|
| 1152 |
pid = request.args.get("profile_id")
|
| 1153 |
if not pid: return jsonify({"ok": False}), 400
|
| 1154 |
+
try:
|
| 1155 |
+
doc = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document(plan_id).get()
|
| 1156 |
+
if not doc.exists: return jsonify({"ok": False, "error": "Not found"}), 404
|
| 1157 |
+
return jsonify({"ok": True, "plan": doc.to_dict()})
|
| 1158 |
+
except Exception as e:
|
| 1159 |
+
return jsonify({"ok": False, "error": str(e)}), 500
|
| 1160 |
|
| 1161 |
@app.delete("/api/shopping-plans/<plan_id>")
|
| 1162 |
def delete_plan(plan_id):
|
| 1163 |
pid = request.args.get("profile_id")
|
| 1164 |
if not pid: return jsonify({"ok": False}), 400
|
| 1165 |
+
try:
|
| 1166 |
+
db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document(plan_id).delete()
|
| 1167 |
+
return jsonify({"ok": True})
|
| 1168 |
+
except Exception as e:
|
| 1169 |
+
return jsonify({"ok": False, "error": str(e)}), 500
|
| 1170 |
|
| 1171 |
# =========================
|
| 1172 |
# Run
|
| 1173 |
# =========================
|
| 1174 |
+
|
| 1175 |
if __name__ == "__main__":
|
| 1176 |
port = int(os.environ.get("PORT", "7860"))
|
| 1177 |
app.run(host="0.0.0.0", port=port, debug=True)
|