Jeu de démo : programmation et vœux des bénévoles (manquaient)

Le seed créait déjà des disponibilités, mais aucun Artist ni vœu
(VolunteerPositionWish, VolunteerArtist) — la démo ne montrait donc rien
du solveur ni des vœux : postes/artistes jamais choisis, programmation
vide.

- 3 artistes par jour de festival (pas montage/démontage), horaires
  échelonnés en soirée (18h-19h / 19h30-21h / 21h30-23h).
- ~40 % des bénévoles ont un vœu de poste ; ~15 % de chance par artiste
  d'être un vœu (~1 par bénévole en moyenne sur 6 artistes).
- Summary affiche désormais artists / position_wishes / artist_wishes ;
  tests étendus (présence + déterminisme).

Vérifié en conditions réelles (`python -m app.seed` + appels API) :
6 artistes, 47 vœux de poste, 95 vœux d'artiste sur 120 bénévoles ; le
solveur tourne toujours en ~0,3 s dessus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-11 18:25:18 +02:00
co-authored by Claude Sonnet 5
parent 304b3c1d9e
commit 282be85498
3 changed files with 66 additions and 1 deletions
+3 -1
View File
@@ -297,7 +297,9 @@ affectation n'est jamais persistée par cet endpoint.
5. Endpoint matrice d'une édition :
`[{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }]`.
6. Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours,
quelques trous d'effectif, 2–3 avertissements actifs.
quelques trous d'effectif, 2–3 avertissements actifs, programmation
(artistes les jours de festival) et vœux des bénévoles (postes, artistes)
pour que le solveur ait matière à arbitrer.
7. Solveur de suggestions OR-Tools CP-SAT (section 4) — en cours.
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
+56
View File
@@ -19,6 +19,7 @@ from app.db import engine
from app.importing.text import normalize
from app.models import (
Account,
Artist,
Assignment,
AssignmentSource,
Availability,
@@ -29,6 +30,8 @@ from app.models import (
Role,
Shift,
Volunteer,
VolunteerArtist,
VolunteerPositionWish,
)
from app.validation import (
AvailabilityWindow,
@@ -188,6 +191,17 @@ _STAFF: dict[PositionCategory, tuple[int, int]] = {
_C.OTHER: (1, 2),
}
# Programmation : trois passages par jour de festival (pas montage/démontage).
ARTIST_NAMES = [
"La Battante", "Orchestre Miroir", "Kntongo", "Louve",
"Les Frères Vent", "Nuage Bleu", "Sœur Machine", "Comète",
]
ARTIST_SLOTS: list[tuple[dt.time, dt.time]] = [
(dt.time(18, 0), dt.time(19, 0)),
(dt.time(19, 30), dt.time(21, 0)),
(dt.time(21, 30), dt.time(23, 0)),
]
@dataclass
class Summary:
@@ -197,6 +211,9 @@ class Summary:
shifts: int = 0
understaffed_shifts: int = 0
assignments: int = 0
artists: int = 0
position_wishes: int = 0
artist_wishes: int = 0
warnings: Counter = field(default_factory=Counter)
blocking: int = 0
@@ -209,6 +226,9 @@ class Summary:
f" Créneaux : {self.shifts}",
f" Créneaux sous l'effectif minimal (trous) : {self.understaffed_shifts}",
f" Affectations : {self.assignments}",
f" Artistes programmés : {self.artists}",
f" Vœux de poste déclarés : {self.position_wishes}",
f" Vœux d'artiste déclarés : {self.artist_wishes}",
f" Avertissements actifs : {sum(self.warnings.values())}",
]
for code, count in sorted(self.warnings.items()):
@@ -316,8 +336,26 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
dates[3]: "demontage",
}
artists: list[Artist] = []
for day in dates:
if phase_of[day] != "festival":
continue
for start, end in ARTIST_SLOTS:
artist = Artist(
edition_id=edition.id,
name=ARTIST_NAMES[len(artists) % len(ARTIST_NAMES)],
performance_date=day,
start_time=start,
end_time=end,
)
session.add(artist)
artists.append(artist)
session.flush()
volunteers: list[Volunteer] = []
used_names: set[tuple[str, str]] = set()
n_position_wishes = 0
n_artist_wishes = 0
for i in range(120):
while True:
first = rng.choice(FIRST_NAMES)
@@ -345,6 +383,21 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
volunteer_id=volunteer.id,
)
)
# Vœux : poste qu'il voudrait tenir, artistes qu'il veut voir.
if rng.random() < 0.4:
session.add(
VolunteerPositionWish(
volunteer_id=volunteer.id,
position_id=rng.choice(positions).id,
)
)
n_position_wishes += 1
for artist in artists:
if rng.random() < 0.15:
session.add(
VolunteerArtist(volunteer_id=volunteer.id, artist_id=artist.id)
)
n_artist_wishes += 1
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
@@ -463,6 +516,9 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
shifts=len(shifts),
assignments=n_assignments,
understaffed_shifts=sum(1 for shift in shifts if fill[shift.id] < shift.min_staff),
artists=len(artists),
position_wishes=n_position_wishes,
artist_wishes=n_artist_wishes,
)
_verify(session, edition, summary)
return summary
+7
View File
@@ -16,6 +16,10 @@ def test_build_creates_a_realistic_dataset(session):
# Au moins deux règles d'avertissement représentées, quelques alertes.
assert len(summary.warnings) >= 2
assert sum(summary.warnings.values()) >= 3
# Programmation et vœux des bénévoles : le mode démo doit les illustrer.
assert summary.artists > 0
assert summary.position_wishes > 0
assert summary.artist_wishes > 0
edition = session.exec(
select(Edition).where(Edition.name == DEMO_EDITION_NAME)
@@ -38,6 +42,9 @@ def test_build_is_deterministic_and_idempotent(session):
assert second.assignments == first.assignments
assert second.understaffed_shifts == first.understaffed_shifts
assert dict(second.warnings) == dict(first.warnings)
assert second.artists == first.artists
assert second.position_wishes == first.position_wishes
assert second.artist_wishes == first.artist_wishes
editions = session.exec(
select(Edition).where(Edition.name == DEMO_EDITION_NAME)