diff --git a/backend/foresight/database/migrations/versions/46b9cb210348_recommendation_snapshots.py b/backend/foresight/database/migrations/versions/46b9cb210348_recommendation_snapshots.py new file mode 100644 index 0000000..a9489d5 --- /dev/null +++ b/backend/foresight/database/migrations/versions/46b9cb210348_recommendation_snapshots.py @@ -0,0 +1,108 @@ +"""recommendation snapshots + +Every recommended rate is now saved: one row per room type and night each +time recommendations are produced, with the pricing model and version that +produced it, the rate, its interval and uncertainty, the factor breakdown, +and when it was made. Rows are only ever added, so a night keeps its full +history of rates. + +Without this, a recommendation was computed and discarded, so we couldn't +show how a night's rate changed since the last update, tie a manager's +feedback to the rate they saw, or check later how good a rate was. + +A new table; no existing data changes. A downgrade drops the table and +every snapshot in it. + +Revision ID: 46b9cb210348 +Revises: 4c1e7a9d2b10 +Create Date: 2026-10-24 20:12:30.793617 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "46b9cb210348" +down_revision: Union[str, None] = "4c1e7a9d2b10" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "recommendation_snapshots", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("hotel_id", sa.Integer(), nullable=False), + sa.Column("room_type_id", sa.Integer(), nullable=False), + sa.Column("stay_date", sa.Date(), nullable=False), + sa.Column("model_id", sa.Integer(), nullable=False), + sa.Column("model_version", sa.String(length=60), nullable=False), + sa.Column("rate", sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column("interval_low", sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column("interval_high", sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column("uncertainty", sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column("factors", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["hotel_id"], + ["hotels.id"], + name=op.f("fk_recommendation_snapshots_hotel_id_hotels"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["model_id"], + ["pricing_models.id"], + name=op.f("fk_recommendation_snapshots_model_id_pricing_models"), + ondelete="NO ACTION", + ), + sa.ForeignKeyConstraint( + ["room_type_id"], + ["room_types.id"], + name=op.f("fk_recommendation_snapshots_room_type_id_room_types"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_recommendation_snapshots")), + ) + op.create_index( + op.f("ix_recommendation_snapshots_hotel_id"), + "recommendation_snapshots", + ["hotel_id"], + unique=False, + ) + op.create_index( + op.f("ix_recommendation_snapshots_model_id"), + "recommendation_snapshots", + ["model_id"], + unique=False, + ) + op.create_index( + "ix_recommendation_snapshots_room_type_id_stay_date_created_at", + "recommendation_snapshots", + ["room_type_id", "stay_date", "created_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_recommendation_snapshots_room_type_id_stay_date_created_at", + table_name="recommendation_snapshots", + ) + op.drop_index( + op.f("ix_recommendation_snapshots_model_id"), + table_name="recommendation_snapshots", + ) + op.drop_index( + op.f("ix_recommendation_snapshots_hotel_id"), + table_name="recommendation_snapshots", + ) + op.drop_table("recommendation_snapshots") diff --git a/backend/foresight/database/models/__init__.py b/backend/foresight/database/models/__init__.py index b197b20..457250f 100644 --- a/backend/foresight/database/models/__init__.py +++ b/backend/foresight/database/models/__init__.py @@ -6,6 +6,7 @@ from foresight.database.models.hotel import DailyPerformance, Hotel, RoomType from foresight.database.models.job import Job from foresight.database.models.pricing_model import PricingModel +from foresight.database.models.recommendation import RecommendationSnapshot from foresight.database.models.signals.competitor_rate import CompetitorRate from foresight.database.models.signals.event import Event, EventObservation from foresight.database.models.signals.flight import FlightArrival @@ -24,5 +25,6 @@ "PricingModel", "RoomType", "Weather", + "RecommendationSnapshot", "Widget", ] diff --git a/backend/foresight/database/models/recommendation.py b/backend/foresight/database/models/recommendation.py new file mode 100644 index 0000000..a238389 --- /dev/null +++ b/backend/foresight/database/models/recommendation.py @@ -0,0 +1,57 @@ +"""Every recommended rate we produced, per night""" + +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import ( + Date, + DateTime, + ForeignKey, + Index, + Numeric, + String, + func, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from foresight.database.base import Base + + +class RecommendationSnapshot(Base): + """One recommended rate for one room type and night, as produced at one moment.""" + + __tablename__ = "recommendation_snapshots" + + id: Mapped[int] = mapped_column(primary_key=True) + hotel_id: Mapped[int] = mapped_column( + ForeignKey("hotels.id", ondelete="CASCADE"), index=True + ) + room_type_id: Mapped[int] = mapped_column( + ForeignKey("room_types.id", ondelete="CASCADE") + ) + stay_date: Mapped[date] = mapped_column(Date) + model_id: Mapped[int] = mapped_column( + # No cascade: a model with snapshots can't be deleted (history stays). + ForeignKey("pricing_models.id", ondelete="NO ACTION"), + index=True, + ) + model_version: Mapped[str] = mapped_column(String(60)) + rate: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + interval_low: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + interval_high: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + uncertainty: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + factors: Mapped[dict[str, Any]] = mapped_column(JSONB) # baseline + factor list + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + __table_args__ = ( + Index( + "ix_recommendation_snapshots_room_type_id_stay_date_created_at", + "room_type_id", + "stay_date", + "created_at", + ), + ) diff --git a/backend/foresight/repository/recommendation.py b/backend/foresight/repository/recommendation.py new file mode 100644 index 0000000..9a8294f --- /dev/null +++ b/backend/foresight/repository/recommendation.py @@ -0,0 +1,86 @@ +"""Data access for `recommendation_snapshots`: every rate recommended, per night.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sqlalchemy import select + +from foresight.database.models.recommendation import RecommendationSnapshot + +if TYPE_CHECKING: + from datetime import date + from decimal import Decimal + + from sqlalchemy import Select + from sqlalchemy.ext.asyncio import AsyncSession + + +async def create_recommendation_snapshot( + session: AsyncSession, + *, + hotel_id: int, + room_type_id: int, + stay_date: date, + model_id: int, + model_version: str, + rate: Decimal, + interval_low: Decimal, + interval_high: Decimal, + uncertainty: Decimal, + factors: dict[str, Any], +) -> RecommendationSnapshot: + """Save a new recommendation snapshot for a hotel. + + This captures the recommended rates and associated metadata at a specific + point in time. + """ + snapshot = RecommendationSnapshot( + hotel_id=hotel_id, + room_type_id=room_type_id, + stay_date=stay_date, + model_id=model_id, + model_version=model_version, + rate=rate, + interval_low=interval_low, + interval_high=interval_high, + uncertainty=uncertainty, + factors=factors, + ) + session.add(snapshot) + await session.flush() + await session.refresh(snapshot) + return snapshot + + +async def get_latest_snapshot( + session: AsyncSession, room_type_id: int, *, stay_date: date +) -> RecommendationSnapshot | None: + """The newest snapshot for this room type and night, or None if there isn't one.""" + return await session.scalar(_night_history(room_type_id, stay_date).limit(1)) + + +async def get_previous_snapshot( + session: AsyncSession, room_type_id: int, *, stay_date: date +) -> RecommendationSnapshot | None: + """The snapshot before the newest one for this room type and night, or None.""" + return await session.scalar( + _night_history(room_type_id, stay_date).offset(1).limit(1) + ) + + +def _night_history( + room_type_id: int, stay_date: date +) -> Select[tuple[RecommendationSnapshot]]: + """One night's snapshots, newest first. The id breaks ties: rows saved in + one transaction share the same `created_at`.""" + return ( + select(RecommendationSnapshot) + .where( + RecommendationSnapshot.room_type_id == room_type_id, + RecommendationSnapshot.stay_date == stay_date, + ) + .order_by( + RecommendationSnapshot.created_at.desc(), RecommendationSnapshot.id.desc() + ) + ) diff --git a/backend/foresight/routes/hotel.py b/backend/foresight/routes/hotel.py index 7147945..e88c7e7 100644 --- a/backend/foresight/routes/hotel.py +++ b/backend/foresight/routes/hotel.py @@ -1,7 +1,7 @@ """Hotels, their room types, nightly performance, the events on each night, and recommendations.""" -from datetime import date +from datetime import date, datetime from decimal import Decimal from typing import Annotated, Any @@ -199,6 +199,45 @@ class RecommendationResponse(ResponseSchema): is_placeholder: bool = Field(description="Always false; kept for older clients") +class SnapshotResponse(ResponseSchema): + """One saved recommendation: what the model said for a night, and when.""" + + id: int = Field(description="The snapshot's id") + room_type_id: int = Field(description="The room type priced") + stay_date: date = Field(description="The night priced, local to the city") + rate: Decimal = Field(description="The recommended rate, in EUR") + interval_low: Decimal = Field(description="Low end of the 80% range, in EUR") + interval_high: Decimal = Field(description="High end of the 80% range, in EUR") + uncertainty: Decimal = Field( + description="How much the model's sub-models disagree, in EUR. High " + "means an unusual night: review the rate" + ) + model_id: int = Field(description="The model version that priced it") + model_version: str = Field(description="Algorithm and version", examples=["ebm-v1"]) + factors: dict[str, Any] = Field( + description="Summary, baseline and each factor's pull on the rate; " + "amounts in EUR, as strings" + ) + created_at: datetime = Field(description="When the model produced this rate") + + +class NightSnapshotResponse(ResponseSchema): + """A night's latest recommendation and how it moved since the one before.""" + + latest: SnapshotResponse = Field(description="The newest snapshot for the night") + previous: SnapshotResponse | None = Field( + description="The snapshot before it; null if the night has only one" + ) + + @computed_field + @property + def change(self) -> Decimal | None: + """Latest rate minus previous rate, in EUR; null without a previous one.""" + if self.previous is None: + return None + return self.latest.rate - self.previous.rate + + class NightEventResponse(ResponseSchema): """An event as it falls on one night.""" @@ -436,3 +475,72 @@ async def get_recommendation_range( session, hotel_id, start=start, end=end, room_type_id=room_type_id ) return [NightRateResponse.model_validate(rate) for rate in rates] + + +@router.get( + "/hotels/{hotel_id}/recommendations/snapshot", + response_model=NightSnapshotResponse, +) +async def get_recommendation_snapshot( + hotel_id: HotelId, + session: SessionDep, + # Named `stay_date` in code because `date` is the type; the query key stays `date`. + stay_date: Annotated[ + date, + Query( + alias="date", + description="The night. YYYY-MM-DD", + examples=["2025-01-01"], + ), + ], + room_type_id: Annotated[ + int | None, + Query( + description="The room type; omit for the hotel's first", + examples=[1], + ), + ] = None, +) -> NightSnapshotResponse: + """The night's latest saved rate, the one before it, and the change. + + A future night with no snapshot is priced and saved now. 404 for a past + night with none, since history isn't created after the fact. + """ + latest, previous = await recommendation_service.get_snapshot( + session, hotel_id, stay_date, room_type_id=room_type_id + ) + return NightSnapshotResponse( + latest=SnapshotResponse.model_validate(latest), + previous=SnapshotResponse.model_validate(previous) if previous else None, + ) + + +@router.post( + "/hotels/{hotel_id}/recommendations/snapshots", + response_model=list[SnapshotResponse], +) +async def snapshot_recommendations( + hotel_id: HotelId, + session: SessionDep, + days: Annotated[ + int, + Query(ge=1, le=90, description="How many nights to price, starting today"), + ] = 30, + room_type_id: Annotated[ + int | None, + Query( + description="The room type; omit for every room type the active " + "model was trained on", + examples=[1], + ), + ] = None, +) -> list[SnapshotResponse]: + """Price each night from today and save the rates that changed. + + Returns only the snapshots saved by this call; a night whose rate didn't + change since its latest snapshot is skipped. + """ + saved = await recommendation_service.snapshot_recommendations( + session, hotel_id, days=days, room_type_id=room_type_id + ) + return [SnapshotResponse.model_validate(s) for s in saved] diff --git a/backend/foresight/services/domain/recommendation.py b/backend/foresight/services/domain/recommendation.py index df98b86..15b0365 100644 --- a/backend/foresight/services/domain/recommendation.py +++ b/backend/foresight/services/domain/recommendation.py @@ -10,13 +10,16 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime, timedelta from decimal import ROUND_HALF_UP, Decimal -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast +from zoneinfo import ZoneInfo import pandas as pd from foresight.repository import hotel as hotel_repo from foresight.repository import pricing_model as model_repo +from foresight.repository import recommendation as snapshot_repo from foresight.services.domain.hotel import get_hotel from foresight.services.domain.model_inputs import ( NO_EVENTS, @@ -32,6 +35,10 @@ from sqlalchemy.ext.asyncio import AsyncSession + from foresight.database.models.city import City + from foresight.database.models.recommendation import RecommendationSnapshot + + CURRENCY = "EUR" # decision 0002 # Terms worth naming in the explanation; smaller ones are summed as "other". _MIN_FACTOR_EUR = 1.0 @@ -69,6 +76,7 @@ class Recommendation: hotel_id: int stay_date: date room_type: str + room_type_id: int recommended_rate: Decimal currency: str explanation: Explanation @@ -127,6 +135,7 @@ async def get_recommendation( hotel_id=hotel.id, stay_date=stay_date, room_type=room_type.name, + room_type_id=room_type.id, recommended_rate=_money(pred), currency=CURRENCY, explanation=_explanation(model.intercept, contributions, row, pred), @@ -140,6 +149,87 @@ async def get_recommendation( ) +async def snapshot_recommendations( + session: AsyncSession, + hotel_id: int, + *, + days: int = 30, + room_type_id: int | None = None, +) -> list[RecommendationSnapshot]: + """Recommend each night from today for `days` nights and save the ones + that changed since their latest snapshot. Every room type the active model + was trained on unless one is given; the others are skipped, so one new room + type doesn't stop the rest being saved. Returns the snapshots saved. + + Raises: + NotFoundError: Unknown hotel or room type, or no active model. + InvalidRequestError: The given room type wasn't trained on. + """ + hotel = await get_hotel(session, hotel_id) + if room_type_id is None: + stored = await model_repo.get_active_pricing_model(session, hotel.id) + if stored is None: + raise NotFoundError("Active pricing model for hotel", hotel.id) + trained_on = stored.training_summary.get("room_types", []) + room_type_ids = [ + r.id + for r in await hotel_repo.list_room_types(session, hotel.id) + if not trained_on or r.name in trained_on + ] + else: + room_type_ids = [room_type_id] + + today = _today(hotel.city) + saved: list[RecommendationSnapshot] = [] + for rt_id in room_type_ids: + for offset in range(days): + night = today + timedelta(days=offset) + rec = await get_recommendation(session, hotel.id, night, room_type_id=rt_id) + latest = await snapshot_repo.get_latest_snapshot( + session, rt_id, stay_date=night + ) + if _changed(latest, rec): + saved.append(await _save(session, rec)) + return saved + + +async def get_snapshot( + session: AsyncSession, + hotel_id: int, + stay_date: date, + *, + room_type_id: int | None = None, +) -> tuple[RecommendationSnapshot, RecommendationSnapshot | None]: + """The latest and previous snapshot for one night of one room type. + + A future night with no snapshot gets one now. A past night is never + created after the fact, so it raises NotFoundError instead. + """ + hotel = await get_hotel(session, hotel_id) + room_types = await hotel_repo.list_room_types(session, hotel.id) + if room_type_id is None: + room_type = room_types[0] if room_types else None + else: + room_type = next((r for r in room_types if r.id == room_type_id), None) + if room_type is None: + raise NotFoundError("Room type", room_type_id or "for hotel") + + latest = await snapshot_repo.get_latest_snapshot( + session, room_type.id, stay_date=stay_date + ) + if latest is None: + if stay_date < _today(hotel.city): + raise NotFoundError("Snapshot for night", stay_date) + rec = await get_recommendation( + session, hotel.id, stay_date, room_type_id=room_type.id + ) + latest = await _save(session, rec) + previous = await snapshot_repo.get_previous_snapshot( + session, room_type.id, stay_date=stay_date + ) + return latest, previous + + def _explanation( intercept: float, contributions: pd.Series, row: pd.Series, pred: float ) -> Explanation: @@ -202,3 +292,48 @@ def _signed(amount: float) -> str: def _money(value: float) -> Decimal: return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + +def _changed(latest: RecommendationSnapshot | None, rec: Recommendation) -> bool: + """Whether the answer changed since the latest snapshot (decision 0032).""" + if latest is None: + return True + return ( + latest.rate != rec.recommended_rate + or latest.model_id != rec.model_id + or latest.interval_low != rec.confidence_interval.lower + or latest.interval_high != rec.confidence_interval.upper + ) + + +def _factors(explanation: Explanation) -> dict[str, Any]: + """The explanation as JSON: amounts as strings so cents stay exact.""" + return { + "summary": explanation.summary, + "baseline": str(explanation.baseline), + "factors": [ + {"detail": f.detail, "amount": str(f.amount)} for f in explanation.factors + ], + } + + +async def _save(session: AsyncSession, rec: Recommendation) -> RecommendationSnapshot: + """Save the robot's answer as a new snapshot.""" + return await snapshot_repo.create_recommendation_snapshot( + session, + hotel_id=rec.hotel_id, + room_type_id=rec.room_type_id, + stay_date=rec.stay_date, + model_id=rec.model_id, + model_version=rec.model_version, + rate=rec.recommended_rate, + interval_low=rec.confidence_interval.lower, + interval_high=rec.confidence_interval.upper, + uncertainty=rec.uncertainty, + factors=_factors(rec.explanation), + ) + + +def _today(city: City) -> date: + """Today where the city is: stay nights are local.""" + return datetime.now(ZoneInfo(city.timezone)).date() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..7da03a0 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,85 @@ +"""Shared fixtures: a rolled-back Postgres session and a hotel to price.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from typing import TYPE_CHECKING + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.pool import NullPool + +from foresight.config import settings +from foresight.database.models import City, Hotel, PricingModel, RoomType + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + +@pytest.fixture +async def session() -> AsyncIterator[AsyncSession]: + """A session whose transaction is always rolled back; skips with no Postgres.""" + # NullPool: a fresh connection per test, so none outlives its event loop. + engine = create_async_engine(settings.async_database_url, poolclass=NullPool) + try: + conn = await engine.connect() + except (OSError, ConnectionError) as exc: + await engine.dispose() + pytest.skip(f"Postgres unavailable: {exc}") + + trans = await conn.begin() + async with AsyncSession( + bind=conn, join_transaction_mode="create_savepoint", expire_on_commit=False + ) as session: + yield session + await trans.rollback() + await conn.close() + await engine.dispose() + + +@dataclass(frozen=True) +class Priced: + """Ids of a hotel set up for pricing: one room type, one active model.""" + + hotel_id: int + room_type_id: int + model_id: int + + +@pytest.fixture +async def priced(session: AsyncSession) -> Priced: + """A city, a hotel with one room type, and an active pricing model.""" + city = City( + name="Testville", + slug="testville", + timezone="Europe/Dublin", + latitude=53.35, + longitude=-6.26, + ) + session.add(city) + await session.flush() + + hotel = Hotel(name="The Snapshot", city_id=city.id, total_rooms=20) + session.add(hotel) + await session.flush() + + room_type = RoomType( + hotel_id=hotel.id, name="Standard", room_count=20, base_rate=Decimal("150") + ) + model = PricingModel( + hotel_id=hotel.id, + version=1, + algorithm="ebm", + artifact=b"not a real model", # the tests replace the model itself + features=[], + metrics={}, + lib_versions={}, + data_start=date(2025, 1, 1), + data_end=date(2025, 12, 31), + is_active=True, + ) + session.add_all([room_type, model]) + await session.flush() + return Priced(hotel.id, room_type.id, model.id) diff --git a/backend/tests/test_recommendation_snapshots.py b/backend/tests/test_recommendation_snapshots.py new file mode 100644 index 0000000..041810a --- /dev/null +++ b/backend/tests/test_recommendation_snapshots.py @@ -0,0 +1,184 @@ +"""Recommendation snapshots: saving, skipping unchanged rates, reading back.""" + +from __future__ import annotations + +from datetime import date, timedelta +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +from foresight.repository import recommendation as snapshot_repo +from foresight.services.domain import recommendation as service +from foresight.services.domain.recommendation import ( + ConfidenceInterval, + Explanation, + Recommendation, +) +from foresight.services.errors import NotFoundError + + +def _robot(priced, rates: dict[date, str] | None = None, default: str = "200.00"): + """A stand-in for `get_recommendation`: returns `rates[night]` (or the + default) for any night, with a range of ±20 around it.""" + + async def fake(session, hotel_id, stay_date, *, room_type_id=None): + rate = Decimal((rates or {}).get(stay_date, default)) + return Recommendation( + hotel_id=priced.hotel_id, + stay_date=stay_date, + room_type="Standard", + room_type_id=priced.room_type_id, + recommended_rate=rate, + currency="EUR", + explanation=Explanation("test", Decimal("180.00"), []), + confidence_interval=ConfidenceInterval(rate - 20, rate + 20, 0.8), + uncertainty=Decimal("5.00"), + model_id=priced.model_id, + model_version="ebm-v1", + is_placeholder=False, + ) + + return fake + + +async def test_a_range_saves_one_snapshot_per_night(session, priced, monkeypatch): + # ARRANGE: swap in the fake robot + monkeypatch.setattr(service, "get_recommendation", _robot(priced)) + + # ACT: refresh 3 nights + saved = await service.snapshot_recommendations(session, priced.hotel_id, days=3) + + # ASSERT: 3 snapshots, one per night, starting today + assert len(saved) == 3 + assert [s.stay_date for s in saved] == [ + saved[0].stay_date + timedelta(days=i) for i in range(3) + ] + + +async def test_rerunning_with_the_same_answers_saves_nothing( + session, priced, monkeypatch +): + # ARRANGE: a first refresh saves 3 nights + monkeypatch.setattr(service, "get_recommendation", _robot(priced)) + await service.snapshot_recommendations(session, priced.hotel_id, days=3) + + # ACT: refresh again with the same answers + saved = await service.snapshot_recommendations(session, priced.hotel_id, days=3) + + # ASSERT: nothing changed, so nothing saved + assert saved == [] + + +async def test_only_the_night_whose_rate_changed_is_saved(session, priced, monkeypatch): + # ARRANGE: a first refresh, then the robot changes its mind about tomorrow + monkeypatch.setattr(service, "get_recommendation", _robot(priced)) + first = await service.snapshot_recommendations(session, priced.hotel_id, days=3) + tomorrow = first[1].stay_date + monkeypatch.setattr( + service, "get_recommendation", _robot(priced, {tomorrow: "230.00"}) + ) + + # ACT + saved = await service.snapshot_recommendations(session, priced.hotel_id, days=3) + + # ASSERT: only tomorrow, at its new rate + assert [(s.stay_date, s.rate) for s in saved] == [(tomorrow, Decimal("230.00"))] + + +_LATEST = { + "rate": Decimal("200.00"), + "model_id": 1, + "interval_low": Decimal("180.00"), + "interval_high": Decimal("220.00"), + "uncertainty": Decimal("5.00"), +} + + +@pytest.mark.parametrize( + ("change", "changed"), + [ + ({}, False), + ({"uncertainty": Decimal("9.00")}, False), # not part of "unchanged" + ({"rate": Decimal("210.00")}, True), + ({"model_id": 2}, True), + ({"interval_low": Decimal("170.00")}, True), + ({"interval_high": Decimal("230.00")}, True), + ], +) +async def test_unchanged_means_same_model_rate_and_range(change, changed): + # ARRANGE: the robot says 200 (180–220) with model 1; the latest + # snapshot is the same except for `change` + ids = SimpleNamespace(hotel_id=1, room_type_id=1, model_id=1) + rec = await _robot(ids)(None, 1, date(2026, 11, 1)) + latest = SimpleNamespace(**{**_LATEST, **change}) + + # ACT + ASSERT + assert service._changed(latest, rec) is changed + + +async def test_a_future_night_with_no_snapshot_gets_one(session, priced, monkeypatch): + # ARRANGE: no snapshots yet + monkeypatch.setattr(service, "get_recommendation", _robot(priced)) + night = date.today() + timedelta(days=5) + + # ACT + latest, previous = await service.get_snapshot(session, priced.hotel_id, night) + + # ASSERT: one was created now, with nothing before it + assert (latest.stay_date, latest.rate) == (night, Decimal("200.00")) + assert previous is None + + +async def test_a_night_returns_its_latest_and_previous_snapshot( + session, priced, monkeypatch +): + # ARRANGE: tonight saved at 200, then again at 230 + monkeypatch.setattr(service, "get_recommendation", _robot(priced)) + first = await service.snapshot_recommendations(session, priced.hotel_id, days=1) + night = first[0].stay_date + monkeypatch.setattr( + service, "get_recommendation", _robot(priced, {night: "230.00"}) + ) + await service.snapshot_recommendations(session, priced.hotel_id, days=1) + + # ACT + latest, previous = await service.get_snapshot(session, priced.hotel_id, night) + + # ASSERT: newest first (same transaction, so the id breaks the tie) + assert (latest.rate, previous.rate) == (Decimal("230.00"), Decimal("200.00")) + + +async def test_a_past_night_with_no_snapshot_is_not_found(session, priced, monkeypatch): + monkeypatch.setattr(service, "get_recommendation", _robot(priced)) + + with pytest.raises(NotFoundError): + await service.get_snapshot(session, priced.hotel_id, date(2000, 1, 1)) + + +async def test_a_past_nights_snapshot_is_returned_as_it_was( + session, priced, monkeypatch +): + # ARRANGE: a past night saved at 200, and a robot that would now say 230 + night = date(2000, 1, 1) + await snapshot_repo.create_recommendation_snapshot( + session, + hotel_id=priced.hotel_id, + room_type_id=priced.room_type_id, + stay_date=night, + model_id=priced.model_id, + model_version="ebm-v1", + rate=Decimal("200.00"), + interval_low=Decimal("180.00"), + interval_high=Decimal("220.00"), + uncertainty=Decimal("5.00"), + factors={}, + ) + monkeypatch.setattr(service, "get_recommendation", _robot(priced, default="230.00")) + + # ACT + latest, previous = await service.get_snapshot(session, priced.hotel_id, night) + + # ASSERT: untouched, still 200 + assert latest.rate == Decimal("200.00") + assert previous is None diff --git a/docs/decisions/0034-snapshot-only-when-changed.md b/docs/decisions/0034-snapshot-only-when-changed.md new file mode 100644 index 0000000..5f5ecef --- /dev/null +++ b/docs/decisions/0034-snapshot-only-when-changed.md @@ -0,0 +1,37 @@ +# 0034. A snapshot is saved only when the model, rate or range changed + +**Status:** Accepted · **Date:** 2026-10-26 + +## Context +Recommendations are saved as snapshots, one row per room type and night, so +a night's rate history can be shown, tied to a manager's feedback, and +checked later. Refreshes run often and most nights don't move, so saving +every answer would fill the table with identical rows. We had to decide how +"unchanged" is judged: same model version and inputs, or same rate. + +## Decision +- A new snapshot is saved only when the answer differs from the night's + latest snapshot in any of `model_id`, `rate`, `interval_low` or + `interval_high`. Otherwise the night is skipped. +- Inputs aren't compared. A new event or forecast that moves the rate is + caught because the rate moves; one that doesn't move it isn't worth a row. + A retrain that gives the same rate still saves a row, so every rate is + tied to the model that produced it. +- `uncertainty` and the factor breakdown aren't compared; they're recorded + with the rate, not reasons to save on their own. +- Snapshots are only added, never updated (unlike the upserts in + [0018](0018-idempotent-upserts.md)). Past nights are never created after + the fact: reading a past night with no snapshot is a 404. +- A pricing model with snapshots can't be deleted (no cascade on + `model_id`). Deleting a hotel still removes its snapshots. + +## Consequences +- The table grows by the nights that changed, not by every refresh. +- A change only in uncertainty or the explanation isn't recorded; a night + shows the explanation from when its rate was last saved. +- Pruning old model versions ([0029](0029-every-training-run-is-a-new-version.md)) + must first decide what happens to their snapshots. +- Deleting a room type still removes its history; nothing deletes room types + today. +- Two refreshes running at the same time could both save the same change; + nothing prevents that yet. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index cf2e62f..a99b424 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -54,3 +54,4 @@ Number files sequentially (`0027-short-title.md`) and add them to the index. | [0031](0031-pms-reports-for-training.md) | Guestline reports used for training | data | | [0032](0032-events-on-a-night-follow-the-model.md) | The events shown for a night follow the model's rule | API | | [0033](0033-data-freshness-rules.md) | Freshness comes from jobs; the service decides what's stale | API | +| [0034](0034-snapshot-only-when-changed.md) | A snapshot is saved only when the model, rate or range changed | data model | diff --git a/docs/system-design.md b/docs/system-design.md index 294498b..9a66780 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -99,11 +99,11 @@ backend/foresight/ ml/ base, ebm, features, weighting repository/ upsert.py pagination.py - city.py hotel.py job.py pricing_model.py + city.py hotel.py job.py pricing_model.py recommendation.py signals/ weather, event, flight, competitor_rate database/ base.py enums.py session.py - models/ city, hotel, job, pricing_model + models/ city, hotel, job, pricing_model, recommendation signals/ weather, event, flight, competitor_rate migrations/ ``` @@ -240,6 +240,7 @@ turns its source's quota responses into a named error: ``` cities ─┬─< hotels ─┬─< room_types ─< daily_performance (the hotel's own history) │ ├─< pricing_models (trained models; one active) + │ ├─< recommendation_snapshots (every rate recommended; 0034) │ └─< jobs (ingest runs) │ ├─< weather ┐ @@ -257,8 +258,11 @@ hotel keeps its own coordinates for distance to venues. **Foreign keys** always state `ON DELETE`: - `CASCADE` from a city to its signals, and from a hotel to its room types, - nights, pricing models and jobs. + nights, pricing models, jobs and recommendation snapshots. - `RESTRICT` from a hotel to its city: a city with hotels can't be deleted. +- `NO ACTION` from a snapshot to its pricing model: a model with snapshots + can't be deleted, but deleting the hotel removes both + ([0034](decisions/0034-snapshot-only-when-changed.md)). - `SET NULL` from an observation to its event, so raw sightings survive. **Relationships are `lazy="raise"`.** An unloaded relationship fails at once @@ -422,8 +426,23 @@ GET /hotels/7/recommendations?date=2026-10-24[&room_type_id=] domain/model_inputs.weather_for recorded → forecast → typical week (0024) build_features → predict_interval, explain, predict_uncertainty ← rate, 80% interval, factors in € that sum to the rate + +GET /hotels/7/recommendations/snapshot?date=2026-10-24[&room_type_id=] + → domain/recommendation.get_snapshot + snapshot_repo.get_latest_snapshot none: price and save now; past night → 404 + snapshot_repo.get_previous_snapshot + ← latest, previous, change in € + +POST /hotels/7/recommendations/snapshots?days=30[&room_type_id=] + → domain/recommendation.snapshot_recommendations + every room type × night from today get_recommendation for each + saved only if model, rate or range changed (0034) + ← only the snapshots saved ``` +`GET /recommendations` itself saves nothing; it's a preview. A night's history +comes from the snapshot routes. + Every training run is a new version ([0029](decisions/0029-every-training-run-is-a-new-version.md)): `description` says what it tried, and `POST /models/{id}/activate` chooses @@ -448,6 +467,8 @@ which version serves. | GET | `/hotels/{id}/events` | paginated; withdrawn events hidden | | GET | `/hotels/{id}/flight-arrivals` | paginated; one row per source per day | | GET | `/hotels/{id}/recommendations?date=` | active model; optional `room_type_id` | +| GET | `/hotels/{id}/recommendations/snapshot?date=` | latest, previous and change; a future night with none is saved now | +| POST | `/hotels/{id}/recommendations/snapshots` | saves changed rates for the next `days` nights (default 30) | | GET | `/hotels/{id}/freshness` | last success per kind of data, the latest failure, and whether it's stale | | POST | `/hotels/{id}/train` | job, 202; optional description, window, settings | | GET | `/hotels/{id}/models` | paginated, newest version first | @@ -501,8 +522,8 @@ requested. Which flight source wins, and whether CSO's monthly totals are spread over days, isn't. 3. **Flights ingest.** Waiting on the CSO table and the choice in question 2. -4. **Scheduling.** The routine refreshes (forecast, recent events) only run - when called. +4. **Scheduling.** The routine refreshes (forecast, recent events, + recommendation snapshots) only run when called. 5. **Tests.** The suite predates this design and hasn't been updated. Nothing has been run against real PredictHQ, AskNews, AeroDataBox or OpenSky keys. 6. **Booking-level PMS data.** [0031](decisions/0031-pms-reports-for-training.md)