Spaces:
Sleeping
Sleeping
| """ | |
| app/tasks/embed.py | |
| Celery tasks for background embedding. | |
| Tasks: | |
| auto_embed β triggered after each Excel upload (per seller, per date) | |
| nightly_embed_all β Celery Beat scheduled task, runs daily at 2 AM IST | |
| embed_single_product β embeds a single product summary (used by /ai/embed/product) | |
| """ | |
| import asyncio | |
| import logging | |
| from datetime import date, timedelta | |
| from celery import shared_task | |
| logger = logging.getLogger(__name__) | |
| # ββ Core async embedding logic βββββββββββββββββββββββββββββββββ | |
| async def _run_embed(seller_id: str, snap_date_str: str) -> int: | |
| """ | |
| Fetch product snapshot for seller + date, batch-encode summaries, | |
| bulk-upsert into Supabase product_embeddings. | |
| Returns number of products embedded. | |
| """ | |
| from sqlalchemy import text | |
| from sqlalchemy.dialects.postgresql import insert as pg_insert | |
| from app.db.session import AsyncSessionLocal | |
| from app.models.models import ProductEmbedding | |
| from app.services.embeddings import embed_batch | |
| snap_date = date.fromisoformat(snap_date_str) | |
| async with AsyncSessionLocal() as db: | |
| sql = text(""" | |
| SELECT | |
| p.product_id, p.sku, p.product_name, p.category, | |
| i.marketplace, | |
| i.available_stock, i.reorder_threshold, | |
| pr.selling_price, | |
| CASE WHEN pr.selling_price > 0 AND pr.cost_price IS NOT NULL | |
| THEN ((pr.selling_price - pr.cost_price - COALESCE(pr.commission_amount, 0)) / pr.selling_price) * 100 | |
| ELSE NULL END AS margin_pct, | |
| t.impressions, t.clicks, t.orders AS ad_orders, | |
| CASE WHEN t.ad_spend > 0 THEN t.revenue_from_ads / t.ad_spend ELSE 0 END AS roas | |
| FROM products p | |
| LEFT JOIN inventory_snapshots i | |
| ON i.product_id = p.product_id AND i.seller_id = :sid | |
| AND i.snapshot_date = :d | |
| LEFT JOIN pricing_snapshots pr | |
| ON pr.product_id = p.product_id AND pr.seller_id = :sid | |
| AND pr.snapshot_date = :d | |
| LEFT JOIN traffic_metrics t | |
| ON t.product_id = p.product_id AND t.seller_id = :sid | |
| AND t.metric_date = :d | |
| WHERE p.seller_id = :sid | |
| """) | |
| result = await db.execute(sql, {"sid": seller_id, "d": snap_date}) | |
| rows = result.mappings().all() | |
| if not rows: | |
| logger.warning("[Embed] No products for seller=%s date=%s", seller_id, snap_date) | |
| return 0 | |
| # Build summary strings | |
| summaries = [ | |
| ( | |
| f"Product: {r['product_name']} (SKU: {r['sku']}, Category: {r['category']}, " | |
| f"Marketplace: {r['marketplace'] or 'N/A'}). " | |
| f"Stock: {r['available_stock'] or 'N/A'} units " | |
| f"(threshold: {r['reorder_threshold'] or 10}). " | |
| f"Price: Rs.{r['selling_price'] or 'N/A'}, " | |
| f"Margin: {r['margin_pct'] or 'N/A'}%. " | |
| f"Traffic: {r['impressions'] or 0} impressions, " | |
| f"{r['clicks'] or 0} clicks, {r['ad_orders'] or 0} ad orders, " | |
| f"ROAS: {r['roas'] or 0}." | |
| ) | |
| for r in rows | |
| ] | |
| metas = [ | |
| { | |
| "available_stock": r["available_stock"], | |
| "selling_price": float(r["selling_price"]) if r["selling_price"] else None, | |
| "margin_pct": float(r["margin_pct"]) if r["margin_pct"] else None, | |
| "roas": float(r["roas"]) if r["roas"] else None, | |
| } | |
| for r in rows | |
| ] | |
| # ONE batch model call for all products | |
| vectors = await embed_batch(summaries) | |
| # Bulk upsert into Supabase pgvector | |
| ins = pg_insert(ProductEmbedding).values([ | |
| { | |
| "seller_id": seller_id, | |
| "product_id": str(rows[i]["product_id"]), | |
| "embed_date": snap_date, | |
| "embed_type": "daily_snapshot", | |
| "summary_text": summaries[i], | |
| "embedding": vectors[i], | |
| "meta": metas[i], | |
| } | |
| for i in range(len(rows)) | |
| ]) | |
| stmt = ins.on_conflict_do_update( | |
| index_elements=["seller_id", "product_id", "embed_date", "embed_type"], | |
| set_={ | |
| "summary_text": ins.excluded.summary_text, | |
| "embedding": ins.excluded.embedding, | |
| "meta": ins.excluded.meta, | |
| }, | |
| ) | |
| await db.execute(stmt) | |
| await db.commit() | |
| logger.info("[Embed] seller=%s date=%s embedded=%d", seller_id, snap_date, len(rows)) | |
| return len(rows) | |
| # ββ Task 1: Per-upload trigger βββββββββββββββββββββββββββββββββ | |
| def auto_embed(self, seller_id: str, snap_date: str): | |
| """ | |
| Triggered automatically after every Excel upload. | |
| Embeds all products for the given seller and date. | |
| Usage from FastAPI: | |
| from app.tasks.embed import auto_embed | |
| auto_embed.delay(seller_id, snap_date) | |
| """ | |
| try: | |
| # Publish "Task Started" | |
| import redis | |
| from app.core.config import settings | |
| import json | |
| r = redis.from_url(settings.REDIS_URL, decode_responses=True) | |
| r.publish(f"channel:{seller_id}", json.dumps({"event": "embedding_started", "message": f"Embedding products for {snap_date}..."})) | |
| logger.info("[Celery] auto_embed started seller=%s date=%s", seller_id, snap_date) | |
| count = asyncio.run(_run_embed(seller_id, snap_date)) | |
| logger.info("[Celery] auto_embed done embedded=%d", count) | |
| # Publish "Embedding Complete" | |
| r.publish(f"channel:{seller_id}", json.dumps({"event": "embedding_complete", "message": f"Successfully embedded {count} products.", "count": count})) | |
| # Trigger AI Agent simulation automatically after embedding | |
| from app.services.ai_agent_client import trigger_simulation | |
| try: | |
| r.publish(f"channel:{seller_id}", json.dumps({"event": "ai_started", "message": "Triggering AI Board of Directors..."})) | |
| logger.info("[Celery] Triggering AI multi-agent simulation for seller=%s", seller_id) | |
| # Create a simple snapshot summary payload | |
| snapshot_data = {"event": "auto_embed_complete", "date": snap_date, "embedded_count": count} | |
| # Use a slightly older date for time_window_start as a default | |
| from datetime import date as _date, timedelta | |
| end_date = _date.fromisoformat(snap_date) | |
| start_date = end_date - timedelta(days=7) | |
| ai_result = asyncio.run(trigger_simulation( | |
| seller_id=seller_id, | |
| time_window_start=str(start_date), | |
| time_window_end=str(end_date), | |
| snapshot_data=snapshot_data | |
| )) | |
| if ai_result: | |
| logger.info("[Celery] AI Simulation triggered successfully: %s", ai_result.get("status")) | |
| r.publish(f"channel:{seller_id}", json.dumps({"event": "ai_complete", "message": "Executive plan ready.", "result": "success"})) | |
| else: | |
| logger.warning("[Celery] AI Simulation triggered but returned no valid result.") | |
| r.publish(f"channel:{seller_id}", json.dumps({"event": "ai_error", "message": "AI failed to generate plan."})) | |
| except Exception as ai_exc: | |
| logger.error("[Celery] Failed to trigger AI Simulation: %s", ai_exc) | |
| r.publish(f"channel:{seller_id}", json.dumps({"event": "ai_error", "message": str(ai_exc)})) | |
| return {"status": "ok", "embedded": count, "seller_id": seller_id, "date": snap_date} | |
| except Exception as exc: | |
| logger.error("[Celery] auto_embed error: %s", exc, exc_info=True) | |
| raise self.retry(exc=exc) | |
| # ββ Task 2: Single product embed (for /ai/embed/product) βββββββ | |
| def embed_single_product(self, seller_id: str, product_id: str, summary: str, embed_date: str | None = None, embed_type: str = "daily_snapshot"): | |
| """ | |
| Embed a single product summary. | |
| Used by /ai/embed/product to offload embedding work to Celery. | |
| """ | |
| try: | |
| from datetime import date as _date | |
| from app.db.session import AsyncSessionLocal | |
| from app.services.embeddings import embedding_service | |
| async def _run(): | |
| d = _date.fromisoformat(embed_date) if embed_date else _date.today() | |
| async with AsyncSessionLocal() as db: | |
| await embedding_service.upsert_product_embedding( | |
| db, | |
| seller_id=seller_id, | |
| product_id=product_id, | |
| summary_text=summary, | |
| embed_date=d, | |
| embed_type=embed_type, | |
| ) | |
| return {"status": "ok", "embedded": True, "product_id": product_id, "date": str(d)} | |
| result = asyncio.run(_run()) | |
| logger.info("[Celery] embed_single_product seller=%s product=%s date=%s", seller_id, product_id, result["date"]) | |
| return result | |
| except Exception as exc: | |
| logger.error("[Celery] embed_single_product error: %s", exc, exc_info=True) | |
| raise self.retry(exc=exc) | |
| # ββ Task 3: Nightly batch for ALL sellers βββββββββββββββββββββ | |
| def nightly_embed_all(): | |
| """ | |
| Scheduled by Celery Beat at 2:00 AM IST daily. | |
| Re-embeds yesterday's snapshot for every seller in the database. | |
| This ensures the AI memory layer is always up-to-date. | |
| """ | |
| async def _run(): | |
| from sqlalchemy import text | |
| from app.db.session import AsyncSessionLocal | |
| yesterday = str(date.today() - timedelta(days=1)) | |
| async with AsyncSessionLocal() as db: | |
| result = await db.execute(text("SELECT seller_id FROM sellers")) | |
| seller_ids = [str(row.seller_id) for row in result.fetchall()] | |
| logger.info("[Celery] nightly_embed_all: %d sellers for date=%s", len(seller_ids), yesterday) | |
| results = [] | |
| for sid in seller_ids: | |
| try: | |
| count = await _run_embed(sid, yesterday) | |
| results.append({"seller_id": sid, "embedded": count}) | |
| except Exception as e: | |
| logger.error("[Celery] nightly embed failed seller=%s: %s", sid, e) | |
| results.append({"seller_id": sid, "error": str(e)}) | |
| return results | |
| return asyncio.run(_run()) | |
| # ββ Task 4: Weekly AI Action Plan (Health Check) βββββββββββββββ | |
| def weekly_health_check(): | |
| """ | |
| Scheduled by Celery Beat at 8:00 AM IST every Monday. | |
| Scans the database for all active sellers and triggers the AI Board of Directors | |
| to generate an Executive Action Plan for the previous week's performance. | |
| """ | |
| async def _run(): | |
| from sqlalchemy import text | |
| from datetime import date as _date, timedelta | |
| from app.db.session import AsyncSessionLocal | |
| from app.services.ai_agent_client import trigger_simulation | |
| today = _date.today() | |
| start_date = today - timedelta(days=7) | |
| end_date = today - timedelta(days=1) | |
| async with AsyncSessionLocal() as db: | |
| result = await db.execute(text("SELECT seller_id FROM sellers")) | |
| seller_ids = [str(row.seller_id) for row in result.fetchall()] | |
| logger.info("[Celery] weekly_health_check: Triggering AI for %d sellers", len(seller_ids)) | |
| results = [] | |
| for sid in seller_ids: | |
| try: | |
| # Mock a generic snapshot payload indicating this is a scheduled summary | |
| snapshot_data = { | |
| "event": "weekly_scheduled_review", | |
| "date_range": f"{start_date} to {end_date}", | |
| "context": "Automated weekly board review." | |
| } | |
| ai_result = await trigger_simulation( | |
| seller_id=sid, | |
| time_window_start=str(start_date), | |
| time_window_end=str(end_date), | |
| snapshot_data=snapshot_data | |
| ) | |
| if ai_result and ai_result.get("status") == "success": | |
| logger.info("[Celery] Weekly AI Plan generated successfully for seller=%s", sid) | |
| results.append({"seller_id": sid, "status": "success"}) | |
| else: | |
| logger.warning("[Celery] Weekly AI Plan failed for seller=%s", sid) | |
| results.append({"seller_id": sid, "status": "failed"}) | |
| except Exception as e: | |
| logger.error("[Celery] weekly_health_check failed for seller=%s: %s", sid, e) | |
| results.append({"seller_id": sid, "error": str(e)}) | |
| return results | |
| return asyncio.run(_run()) | |
| # ββ Task 5: Ping (for health checks) βββββββββββββββββββββββββββ | |
| def ping(): | |
| return "pong" | |
| # ββ Task 6: Analyze all products (batch AI analysis) βββββββββββ | |
| def analyze_all_products(seller_id: str, snap_date: str): | |
| """ | |
| Triggered after auto_embed. | |
| Analyzes each product using the AI agent, with throttling. | |
| """ | |
| async def _run(): | |
| from sqlalchemy import text | |
| from app.db.session import AsyncSessionLocal | |
| from app.services.ai_agent_client import trigger_product_analysis | |
| from app.models.models import AIProductAnalysis | |
| from sqlalchemy.dialects.postgresql import insert as pg_insert | |
| import asyncio | |
| # Publish task start | |
| import redis | |
| from app.core.config import settings | |
| import json | |
| r = redis.from_url(settings.REDIS_URL, decode_responses=True) | |
| r.publish(f"channel:{seller_id}", json.dumps({"event": "ai_product_analysis_started", "message": f"Starting per-product AI analysis for {snap_date}..."})) | |
| async with AsyncSessionLocal() as db: | |
| # 1. Fetch all unique products for the seller | |
| sql = text(""" | |
| SELECT p.product_id, p.sku, p.product_name, p.category, p.marketplace | |
| FROM products p | |
| WHERE p.seller_id = :seller_id AND p.is_active = TRUE | |
| """) | |
| result = await db.execute(sql, {"seller_id": seller_id}) | |
| products = result.mappings().all() | |
| logger.info("[Celery] analyze_all_products: found %d products for seller=%s", len(products), seller_id) | |
| analyzed_count = 0 | |
| # 2. Iterate and analyze each product | |
| for prod in products: | |
| prod_id = str(prod["product_id"]) | |
| product_data = dict(prod) | |
| product_data["product_id"] = prod_id | |
| try: | |
| logger.info("[Celery] Triggering analysis for product %s (%s)", prod_id, prod["product_name"]) | |
| ai_result = await trigger_product_analysis(seller_id, prod_id, product_data) | |
| if ai_result and ai_result.get("status") == "success": | |
| result_data = ai_result.get("result", {}) | |
| # Save to database | |
| stmt = pg_insert(AIProductAnalysis).values( | |
| seller_id=seller_id, | |
| product_id=prod_id, | |
| analysis_date=date.fromisoformat(snap_date), | |
| product_metrics=product_data, | |
| executive_summary=result_data, | |
| status="completed" | |
| ).on_conflict_do_update( | |
| index_elements=["seller_id", "product_id", "analysis_date"], | |
| set_={ | |
| "executive_summary": result_data, | |
| "status": "completed", | |
| "product_metrics": product_data, | |
| "updated_at": text("NOW()") | |
| } | |
| ) | |
| await db.execute(stmt) | |
| await db.commit() | |
| analyzed_count += 1 | |
| # Emit a granular event so the frontend can update live | |
| r.publish(f"channel:{seller_id}", json.dumps({ | |
| "event": "ai_product_analyzed", | |
| "product_id": prod_id, | |
| "product_name": prod["product_name"], | |
| "message": f"Analyzed {prod['product_name']}" | |
| })) | |
| except Exception as e: | |
| logger.error("[Celery] Failed to analyze product %s: %s", prod_id, e) | |
| # Throttle to avoid hitting Groq rate limits (500ms delay) | |
| await asyncio.sleep(0.5) | |
| r.publish(f"channel:{seller_id}", json.dumps({ | |
| "event": "ai_product_analysis_complete", | |
| "message": f"Completed product analysis for {analyzed_count}/{len(products)} products.", | |
| "count": analyzed_count | |
| })) | |
| return {"seller_id": seller_id, "analyzed_count": analyzed_count, "total_products": len(products)} | |
| return asyncio.run(_run()) | |