Add live CEO briefing, Wereldexport CRM, halal engine, and realtime cockpit.
Ship export intel with contact filters and CRM push, Herman live digest with Telegram briefing, halal recommendation engine, revenue cockpit, and dashboard polling/WebSocket fixes.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
"""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",
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user