"""Live halal retail recommendation engine — diverse, actionable, data-driven.""" from __future__ import annotations import json from datetime import date, datetime, timedelta, timezone from typing import Any from app import retail_opportunities from app.db import execute, execute_returning, fetch_all, fetch_one, json_param from app.middleware import log_agent_event MIN_SCORE = 35.0 CANDIDATE_SQL = """ SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, s.partnership_status, s.halal_certified, s.has_halal_section, s.phone, s.email, ros.halal_opportunity_score, ros.market_potential_score, ros.factors, ros.computed_at, (a.religious_composition->>'muslim_proxy_pct')::float AS muslim_proxy_pct, (a.ethnic_composition->>'niet_westers_pct')::float AS niet_westers_pct, a.population, a.avg_income, sp.manager_email, sp.manager_phone, EXISTS(SELECT 1 FROM client_supermarket_links csl WHERE csl.supermarket_id = s.id) AS crm_linked FROM supermarkets s JOIN retail_opportunity_scores ros ON ros.supermarket_id = s.id LEFT JOIN area_analysis a ON a.postcode = s.postcode LEFT JOIN supermarket_profiles sp ON sp.supermarket_id = s.id WHERE COALESCE(s.partnership_status, 'none') NOT IN ('active', 'contract', 'won') AND COALESCE(s.has_halal_section, false) IS NOT TRUE AND ros.halal_opportunity_score >= %s AND s.postcode <> '0000AA' LIMIT 500 """ def _parse_factors(row: dict[str, Any]) -> dict[str, Any]: factors = row.get("factors") or {} if isinstance(factors, str): try: factors = json.loads(factors) except json.JSONDecodeError: factors = {} return factors if isinstance(factors, dict) else {} def _composite_score(row: dict[str, Any], day_seed: int) -> float: halal = float(row.get("halal_opportunity_score") or 0) market = float(row.get("market_potential_score") or 0) muslim = float(row.get("muslim_proxy_pct") or 0) factors = _parse_factors(row) halal_gap = float(factors.get("halal_gap") or 0) score = halal * 0.32 + halal_gap * 0.28 + muslim * 0.22 + market * 0.12 if row.get("phone") or row.get("email") or row.get("manager_email") or row.get("manager_phone"): score += 8 if row.get("crm_linked"): score -= 22 if row.get("halal_certified"): score -= 18 pop = int(row.get("population") or 0) if pop > 80000: score += 5 jitter = ((int(row["id"]) * 17 + day_seed) % 11) - 5 return round(score + jitter, 2) def _reasons(row: dict[str, Any]) -> list[str]: factors = _parse_factors(row) muslim = float(row.get("muslim_proxy_pct") or factors.get("muslim_proxy_pct") or 0) reasons: list[str] = [] if not row.get("has_halal_section"): reasons.append(f"Geen halal schap — {muslim:.0f}% moslim-demografie") gap = float(factors.get("halal_gap") or 0) if gap >= 25: reasons.append(f"Halal-gap {gap:.0f}/100") pop = int(row.get("population") or 0) if pop > 40000: reasons.append(f"Catchment {pop:,} inwoners") if row.get("phone") or row.get("manager_phone"): reasons.append("Telefoon beschikbaar") chain = row.get("chain") or "" if chain in ("Jumbo", "Albert Heijn", "PLUS", "Dirk"): reasons.append(f"Strategische keten: {chain}") status = row.get("partnership_status") or "none" if status in ("none", "prospect", "lead"): reasons.append("Nog geen actief partnership — eerste mover") return reasons[:4] or ["Halal kant-en-klaar listing kans"] def _diverse_pick(candidates: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]: picked: list[dict[str, Any]] = [] chains_seen: set[str] = set() cities_seen: set[str] = set() ids_seen: set[int] = set() for row in candidates: sid = int(row["id"]) if sid in ids_seen: continue chain = (row.get("chain") or "").lower() city = (row.get("city") or "").lower() name = (row.get("name") or "").lower() chain_city = f"{chain}|{city}|{name[:20]}" if chain_city in chains_seen and len(picked) < max(1, limit - 1): continue if chain in chains_seen and len(picked) < max(1, limit - 1): continue if city in cities_seen and len(cities_seen) >= 2 and len(picked) < max(1, limit - 1): continue picked.append(row) ids_seen.add(sid) chains_seen.add(chain) chains_seen.add(chain_city) cities_seen.add(city) if len(picked) >= limit: return picked for row in candidates: if row in picked: continue picked.append(row) if len(picked) >= limit: break return picked def get_dismissed_store_ids() -> set[int]: rows = fetch_all( """SELECT related_entity_id FROM ai_recommendations WHERE recommendation_type LIKE 'halal_%%' AND status = 'dismissed' AND related_entity_id IS NOT NULL AND updated_at >= NOW() - INTERVAL '30 days'""" ) return {int(r["related_entity_id"]) for r in rows if r.get("related_entity_id")} def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: out: dict[str, Any] = {} for k, v in row.items(): if k.startswith("_"): continue if hasattr(v, "isoformat"): out[k] = v.isoformat() elif type(v).__name__ == "Decimal": out[k] = float(v) else: out[k] = v return out def build_recommendation(row: dict[str, Any], rank: int) -> dict[str, Any]: score = row.get("_composite_score", row.get("halal_opportunity_score")) reasons = _reasons(row) title = f"{row.get('chain')} · {row.get('name')} ({row.get('city')})" priority = "high" if float(score) >= 62 else "medium" if float(score) >= 45 else "normal" return { "rank": rank, "store_id": int(row["id"]), "recommendation_type": "halal_retail_cucina", "brand": "cucina", "title": title, "description": " · ".join(reasons), "score": round(float(score), 1), "halal_opportunity_score": float(row.get("halal_opportunity_score") or 0), "market_potential_score": float(row.get("market_potential_score") or 0), "muslim_proxy_pct": float(row.get("muslim_proxy_pct") or 0), "priority": priority, "reasons": reasons, "chain": row.get("chain"), "city": row.get("city"), "province": row.get("province"), "partnership_status": row.get("partnership_status"), "actions": [ {"label": "Retail 360", "href": f"/retail?open={row['id']}"}, {"label": "Naar CRM", "href": f"/retail?open={row['id']}&crm=1"}, ], } def _upsert_recommendation(item: dict[str, Any]) -> None: store_id = item["store_id"] title = item["title"][:255] existing = fetch_one( """SELECT id FROM ai_recommendations WHERE recommendation_type = %s AND related_entity_id = %s AND status = 'pending'""", (item["recommendation_type"], store_id), ) payload = json_param({"score": item["score"], "reasons": item["reasons"]}) if existing: execute( """UPDATE ai_recommendations SET description = %s, impact_score = %s, priority = %s, data_sources = %s::jsonb, updated_at = NOW() WHERE id = %s""", ( item["description"][:2000], min(0.99, float(item["score"]) / 100), item["priority"], payload, existing["id"], ), ) return execute_returning( """INSERT INTO ai_recommendations (recommendation_type, title, description, priority, impact_score, confidence_score, data_sources, action_items, generated_by, related_entity_type, related_entity_id, status, expires_at) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'halal_engine','supermarket',%s,'pending',%s) RETURNING id""", ( item["recommendation_type"], title, item["description"][:2000], item["priority"], min(0.99, float(item["score"]) / 100), min(0.95, 0.55 + float(item.get("muslim_proxy_pct") or 0) / 200), payload, [a["label"] for a in item.get("actions", [])], store_id, datetime.now(timezone.utc) + timedelta(days=14), ), ) def _maybe_refresh_scores(force: bool = False) -> bool: """Full recompute is expensive (~60s) — only on explicit refresh or empty DB.""" if force: retail_opportunities.compute_all_scores(5000) return True row = fetch_one("SELECT COUNT(*) AS n FROM retail_opportunity_scores") if not row or int(row.get("n") or 0) == 0: retail_opportunities.compute_all_scores(5000) return True return False def live_halal_recommendations( limit: int = 5, refresh_scores: bool = False, brand: str = "cucina", ) -> dict[str, Any]: refreshed = _maybe_refresh_scores(refresh_scores) day_seed = int(date.today().strftime("%Y%m%d")) dismissed = get_dismissed_store_ids() rows = fetch_all(CANDIDATE_SQL, (MIN_SCORE,)) scored: list[dict[str, Any]] = [] for r in rows: sid = int(r["id"]) if sid in dismissed: continue r["_composite_score"] = _composite_score(r, day_seed) scored.append(r) scored.sort(key=lambda x: x["_composite_score"], reverse=True) picked = _diverse_pick(scored, limit) items = [build_recommendation(r, i + 1) for i, r in enumerate(picked)] for item in items: _upsert_recommendation(item) if items: log_agent_event( agent_name="halal_engine", event_type="recommendations", title=f"Halal kansen: {len(items)} live recommendations", metadata={"store_ids": [i["store_id"] for i in items], "top_score": items[0]["score"]}, ) return { "ok": True, "brand": brand, "generated_at": datetime.now(timezone.utc).isoformat(), "items": items, "meta": { "candidates_evaluated": len(rows), "dismissed_skipped": len(dismissed), "scores_refreshed": refreshed or refresh_scores, "engine": "halal_reco_v1", }, }