Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 2 additions & 0 deletions backend/foresight/database/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,5 +25,6 @@
"PricingModel",
"RoomType",
"Weather",
"RecommendationSnapshot",
"Widget",
]
57 changes: 57 additions & 0 deletions backend/foresight/database/models/recommendation.py
Original file line number Diff line number Diff line change
@@ -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",
),
)
86 changes: 86 additions & 0 deletions backend/foresight/repository/recommendation.py
Original file line number Diff line number Diff line change
@@ -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()
)
)
110 changes: 109 additions & 1 deletion backend/foresight/routes/hotel.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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]
Loading
Loading