Artiste : heure de passage ; vœux d'artistes pris en compte par le solveur

Artist gagne start_time/end_time (end <= start ⇒ finit le lendemain,
même convention que Shift/DayPart/Availability). Migration 0008.

Le solveur (app/solver/) résout maintenant les fenêtres de passage des
artistes que chaque bénévole veut voir (VolunteerArtist) et pénalise
toute suggestion qui chevaucherait un passage souhaité — le bénévole
raterait l'artiste. Comme le reste du scoring, c'est un terme de
l'objectif, pas un blocage : pour un créneau urgent sans autre
candidat, le solveur propose quand même, quitte à ce que ce soit un
pis-aller.

- SolverVolunteer.wished_artist_windows (fenêtres ShiftInfo précalculées
  par le loader depuis Artist + VolunteerArtist).
- Nouveau malus _ARTIST_CONFLICT_PENALTY dans engine.py.
- Tests : moteur (conflit détecté/ignoré hors chevauchement/toujours
  suggéré en dernier recours) + intégration API bout en bout.

176 tests, ruff et alembic check au vert. CLAUDE.md mis à jour (§2 et §4).

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:13:54 +02:00
co-authored by Claude Sonnet 5
parent e47c29d35b
commit fea9d37019
12 changed files with 212 additions and 35 deletions
+7 -3
View File
@@ -1,8 +1,10 @@
"""CRUD de la programmation (`Artist`) d'une édition.
La coordination constitue la liste ; elle est présentée par jour de passage
(`performance_date`). Les bénévoles y choisissent les artistes qu'ils veulent
voir (vœux stockés dans `volunteer_artist`, exposés par la fiche bénévole)."""
La coordination constitue la liste ; elle est présentée par jour et heure de
passage (`performance_date`, `start_time`, `end_time`). Les bénévoles y
choisissent les artistes qu'ils veulent voir (vœux stockés dans
`volunteer_artist`, exposés par la fiche bénévole) — le solveur en tient
compte pour éviter de les affecter pendant le passage."""
import uuid
@@ -51,6 +53,8 @@ def create_artist(
edition_id=edition.id,
name=name,
performance_date=payload.performance_date,
start_time=payload.start_time,
end_time=payload.end_time,
)
session.add(artist)
try:
+7
View File
@@ -85,11 +85,16 @@ class VolunteerRead(ApiModel):
class ArtistCreate(ApiModel):
name: str
performance_date: dt.date
# end_time <= start_time ⇒ le passage finit le lendemain.
start_time: dt.time
end_time: dt.time
class ArtistUpdate(ApiModel):
name: str | None = None
performance_date: dt.date | None = None
start_time: dt.time | None = None
end_time: dt.time | None = None
class ArtistRead(ApiModel):
@@ -97,6 +102,8 @@ class ArtistRead(ApiModel):
edition_id: uuid.UUID
name: str
performance_date: dt.date
start_time: dt.time
end_time: dt.time
created_at: dt.datetime
updated_at: dt.datetime
@@ -0,0 +1,33 @@
"""Artiste : heure de passage
Revision ID: 0008_artist_performance_time
Revises: 0007_drop_skills_avail_windows
Create Date: 2026-09-11
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0008_artist_performance_time"
down_revision: str | None = "0007_drop_skills_avail_windows"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"artist",
sa.Column("start_time", sa.Time(), server_default="20:00:00", nullable=False),
)
op.add_column(
"artist",
sa.Column("end_time", sa.Time(), server_default="21:00:00", nullable=False),
)
def downgrade() -> None:
op.drop_column("artist", "end_time")
op.drop_column("artist", "start_time")
+8 -1
View File
@@ -9,7 +9,7 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
import datetime as dt
import uuid
from sqlalchemy import Boolean, Column, Integer
from sqlalchemy import Boolean, Column, Integer, Time
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
@@ -117,6 +117,13 @@ class Artist(TimestampMixin, table=True):
name: str
# Jour de passage, dans les dates de l'édition.
performance_date: dt.date = Field(index=True)
# Heure de passage. end_time <= start_time ⇒ le passage finit le lendemain.
start_time: dt.time = Field(
sa_column=Column(Time, nullable=False, server_default="20:00:00")
)
end_time: dt.time = Field(
sa_column=Column(Time, nullable=False, server_default="21:00:00")
)
fans: list["Volunteer"] = Relationship(
back_populates="wished_artists",
+9 -3
View File
@@ -7,9 +7,9 @@ contente de faire remonter ces candidats en tête de liste).
Contrainte dure : aucun chevauchement de créneaux pour un même bénévole (règle
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, cumul de
8 h, repos, vœu de poste, équité de charge) est un terme de l'objectif, pas
une contrainte — comme le moteur de validation, qui reste seul juge au moment
où la coordination confirme une affectation.
8 h, repos, vœu de poste, vœu d'artiste, équité de charge) est un terme de
l'objectif, pas une contrainte — comme le moteur de validation, qui reste seul
juge au moment où la coordination confirme une affectation.
"""
import datetime as dt
@@ -41,6 +41,8 @@ _AVAIL_PENALTY_PER_HALF_HOUR = 5
_DAILY_OVERAGE_PENALTY_PER_MIN = 2
_REST_PENALTY = 60
_FAIRNESS_PENALTY_PER_HOUR = 2
# Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir.
_ARTIST_CONFLICT_PENALTY = 80
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
@@ -114,6 +116,10 @@ def _score(
)
if _has_insufficient_rest(volunteer, shift):
score -= _REST_PENALTY
if any(
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
):
score -= _ARTIST_CONFLICT_PENALTY
score -= (_FAIRNESS_PENALTY_PER_HOUR * volunteer.existing_minutes) // 60
return score
+31
View File
@@ -1,23 +1,39 @@
"""Construit un `SolverInput` depuis la base pour une édition. Couche de
liaison : c'est le seul endroit du module `solver` qui connaît la base."""
import datetime as dt
from collections import defaultdict
from sqlmodel import Session, select
from app.models import (
Artist,
Assignment,
Availability,
DayPart,
Edition,
Shift,
Volunteer,
VolunteerArtist,
VolunteerPositionWish,
)
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.models import AvailabilityWindow, DayPartWindow, ShiftInfo
def _artist_window(artist: Artist) -> ShiftInfo:
"""Fenêtre datée du passage de l'artiste (même convention que les
créneaux : `end_time <= start_time` ⇒ le passage finit le lendemain)."""
starts_at = dt.datetime.combine(artist.performance_date, artist.start_time)
end_date = (
artist.performance_date
if artist.end_time > artist.start_time
else artist.performance_date + dt.timedelta(days=1)
)
ends_at = dt.datetime.combine(end_date, artist.end_time)
return ShiftInfo(id=str(artist.id), starts_at=starts_at, ends_at=ends_at)
def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
day_parts = list(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
@@ -67,6 +83,20 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
):
wishes[w.volunteer_id].add(str(w.position_id))
artists_by_id = {
a.id: a
for a in session.exec(select(Artist).where(Artist.edition_id == edition.id))
}
artist_wishes: dict = defaultdict(list)
for w in session.exec(
select(VolunteerArtist).join(
Volunteer, Volunteer.id == VolunteerArtist.volunteer_id
).where(Volunteer.edition_id == edition.id)
):
artist = artists_by_id.get(w.artist_id)
if artist is not None:
artist_wishes[w.volunteer_id].append(_artist_window(artist))
volunteers = list(
session.exec(select(Volunteer).where(Volunteer.edition_id == edition.id))
)
@@ -75,6 +105,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
id=str(v.id),
availability=tuple(availabilities.get(v.id, ())),
wished_position_ids=frozenset(wishes.get(v.id, ())),
wished_artist_windows=tuple(artist_wishes.get(v.id, ())),
existing_shifts=tuple(
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
for s in shifts_by_volunteer.get(v.id, ())
+4
View File
@@ -33,6 +33,10 @@ class SolverVolunteer:
id: str
availability: tuple[AvailabilityWindow, ...] = ()
wished_position_ids: frozenset[str] = frozenset()
# Passages des artistes que le bénévole veut voir (heure de passage),
# sous forme de fenêtres datées : un créneau qui chevauche l'une d'elles
# lui ferait manquer le passage.
wished_artist_windows: tuple[ShiftInfo, ...] = ()
# Créneaux déjà occupés (affectations existantes, décisions figées) :
# comptent pour chevauchement, cumul journalier, repos.
existing_shifts: tuple[ShiftInfo, ...] = ()