Files
foodlinkk-command-center/tools-api/app/halal_reco_engine.py
T
Aissa 4dd1b8265f 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.
2026-07-19 18:25:21 +00:00

277 lines
10 KiB
Python

"""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",
},
}