DOCS/outputs/mnaha/seed/step4_stats.py
Thomas Fransolet d14f151517 Livrables de presentation, seed de demo MNAHA et wireframes
- outputs/platform, outputs/prospect : scripts de construction des decks
  commerciaux + captures sources et .pptx generes.
- outputs/mnaha : seed complet de l'instance de demo luxembourgeoise
  (scripts par etape, contenus, images generees), le deck en version
  marque blanche et en version brandee, le PDF de presentation.
- outputs/Wireframes : maquettes app mobile, app web et manager.
- interview clients : notes de l'entretien Fourneau St Michel.

state.json et les __pycache__ sont ignores : le premier porte les cles
d'API de l'instance seedee, les scripts le regenerent.
2026-09-11 15:47:57 +02:00

166 lines
6.8 KiB
Python

# -*- coding: utf-8 -*-
"""Étape 4 — 90 jours de fréquentation, puis ré-indexation de l'assistant.
Le tableau de bord ne vaut comme argument que s'il montre une courbe crédible :
pics de week-end, saisonnalité douce, répartition par canal et par langue.
"""
import json
import random
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from pathlib import Path
from api import Api
STATE = Path(__file__).resolve().parent / "state.json"
DAYS = 90
SESSIONS_PER_DAY = 22 # moyenne en semaine
WEEKEND_FACTOR = 2.3
CHANNELS = [("Web", 0.45), ("Mobile", 0.35), ("Tablet", 0.20)]
LANGUAGES = [("FR", 0.45), ("DE", 0.25), ("EN", 0.20), ("NL", 0.10)]
random.seed(20260825) # même jeu de données à chaque exécution
def weighted(pairs):
r = random.random()
cumulative = 0.0
for value, weight in pairs:
cumulative += weight
if r <= cumulative:
return value
return pairs[-1][0]
def build_events(state, catalogue):
"""Produit la liste des événements à envoyer, sans appel réseau."""
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
events = []
for day_back in range(DAYS, 0, -1):
day = today - timedelta(days=day_back)
weekend = day.weekday() >= 5
base = SESSIONS_PER_DAY * (WEEKEND_FACTOR if weekend else 1.0)
# Légère montée en charge sur la période, plus un aléa quotidien.
trend = 0.75 + 0.5 * (DAYS - day_back) / DAYS
sessions = max(1, int(random.gauss(base * trend, base * 0.25)))
for _ in range(sessions):
channel = weighted(CHANNELS)
language = weighted(LANGUAGES)
session_id = f"s{day.strftime('%y%m%d')}-{random.randrange(10**6):06d}"
site = weighted([("feschmaart", 0.55), ("draieechelen", 0.28),
("echternach", 0.17)])
config_id = state["configs"][site]
sections = catalogue[site]
if not sections:
continue
start = day + timedelta(hours=random.randint(10, 17),
minutes=random.randint(0, 59))
visited = random.sample(sections, min(len(sections),
random.randint(2, 6)))
cursor = start
def add(event_type, section=None, duration=None, metadata=None):
events.append({
"instanceId": state["instanceId"],
"configurationId": config_id,
"sectionId": section["id"] if section else None,
"sessionId": session_id,
"eventType": event_type,
"appType": channel,
"language": language,
"durationSeconds": duration,
"metadata": json.dumps(metadata) if metadata else None,
"timestamp": cursor.strftime("%Y-%m-%dT%H:%M:%SZ"),
})
# Un visiteur sur cinq arrive par un code QR posé sur un cartel.
if random.random() < 0.20:
add("QrScan", visited[0], metadata={"valid": random.random() > 0.06})
for section in visited:
duration = int(random.triangular(25, 300, 90))
add("SectionView", section, duration)
cursor += timedelta(seconds=duration)
# La durée moyenne de visite se calcule en sommant les SectionLeave
# par session : sans eux, le tableau de bord affiche zéro minute.
add("SectionLeave", section, duration)
cursor += timedelta(seconds=random.randint(5, 40))
if section["type"] == "Article" and random.random() < 0.55:
add("ArticleRead", section, duration)
if section["type"] == "Menu":
add("MenuItemTap", section)
if section["type"] == "Map" and random.random() < 0.7:
poi = random.choice(state["poi"])
add("MapPoiTap", section,
metadata={"geoPointId": poi["id"],
"geoPointTitle": poi["title"]})
if section["type"] == "Quiz" and random.random() < 0.45:
total = 3
add("QuizComplete", section,
metadata={"score": float(random.choice([1, 2, 2, 3, 3, 3])),
"totalQuestions": total})
if section["type"] == "Agenda" and random.random() < 0.5 and state["agenda"]:
ev = random.choice(state["agenda"])
add("AgendaEventTap", section,
metadata={"eventId": str(ev["id"]), "eventTitle": ev["title"]})
if section["type"] == "Parcours" and random.random() < 0.3:
add("GameComplete", section, int(random.triangular(900, 3600, 1800)))
# L'assistant est sollicité sur une visite sur six, deux questions en moyenne.
if random.random() < 0.17:
for _ in range(random.randint(1, 3)):
cursor += timedelta(seconds=random.randint(20, 120))
add("AssistantMessage", random.choice(visited))
return events
def main():
api = Api()
state = json.loads(STATE.read_text(encoding="utf-8"))
catalogue = {}
for key, config_id in state["configs"].items():
sections = api.get(f"/api/Section/configuration/{config_id}") or []
catalogue[key] = [{"id": s["id"], "type": s["type"]} for s in sections]
print(f" {key}: {len(sections)} sections")
points = api.get(f"/api/SectionMap/{state['mapEchternach']}/points") or []
state["poi"] = [{"id": p["id"],
"title": next((t["value"] for t in p["title"]
if t["language"] == "FR"), "")}
for p in points]
agenda_section = next((s for s in catalogue["feschmaart"] if s["type"] == "Agenda"),
None)
state["agenda"] = []
if agenda_section:
for ev in api.get(f"/api/SectionAgenda/{agenda_section['id']}/events") or []:
title = next((t["value"] for t in ev["label"] if t["language"] == "FR"), "")
state["agenda"].append({"id": ev["id"], "title": title})
events = build_events(state, catalogue)
print(f"\n{len(events)} événements à envoyer…")
def send(event):
api.post("/api/Stats/event", event)
with ThreadPoolExecutor(max_workers=12) as pool:
for i, _ in enumerate(pool.map(send, events), 1):
if i % 2000 == 0:
print(f" {i}/{len(events)}")
print("\nré-indexation de l'assistant…")
api.post(f"/api/Ai/reindex/{state['instanceId']}")
print("étape 4 terminée")
if __name__ == "__main__":
main()