From b56af7ecb23b1493449a0f096a1b4c96fb1d0184 Mon Sep 17 00:00:00 2001 From: heatherlarange Date: Mon, 26 Oct 2026 10:48:17 -0400 Subject: [PATCH 1/4] feat(jobs): report when each kind of data was last refreshed --- backend/foresight/main.py | 2 + backend/foresight/repository/job.py | 20 ++++ backend/foresight/routes/freshness.py | 54 ++++++++++ .../foresight/services/domain/freshness.py | 72 +++++++++++++ backend/tests/test_freshness.py | 100 ++++++++++++++++++ docs/decisions/0032-data-freshness-rules.md | 25 +++++ docs/decisions/README.md | 1 + docs/system-design.md | 5 +- 8 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 backend/foresight/routes/freshness.py create mode 100644 backend/foresight/services/domain/freshness.py create mode 100644 backend/tests/test_freshness.py create mode 100644 docs/decisions/0032-data-freshness-rules.md diff --git a/backend/foresight/main.py b/backend/foresight/main.py index 45d5d2e..bf34185 100644 --- a/backend/foresight/main.py +++ b/backend/foresight/main.py @@ -13,6 +13,7 @@ event, example_widget, flight, + freshness, health, hotel, ingest, @@ -47,6 +48,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: app.include_router(weather.router) app.include_router(event.router) app.include_router(flight.router) +app.include_router(freshness.router) app.include_router(ingest.router) app.include_router(job.router) app.include_router(pricing_model.router) diff --git a/backend/foresight/repository/job.py b/backend/foresight/repository/job.py index b06ecfc..6e5a35c 100644 --- a/backend/foresight/repository/job.py +++ b/backend/foresight/repository/job.py @@ -83,3 +83,23 @@ async def last_succeeded_start( Job.params.contains(params), ) ) + + +async def latest_finished_job( + session: AsyncSession, + *, + hotel_id: int, + kind: JobKind, + params: dict[str, Any], + status: JobStatus | None = None, +) -> Job | None: + """The newest finished job of this kind whose params contain `params`.""" + stmt = select(Job).where( + Job.hotel_id == hotel_id, + Job.kind == kind, + Job.finished_at.is_not(None), + Job.params.contains(params), + ) + if status is not None: + stmt = stmt.where(Job.status == status) + return await session.scalar(stmt.order_by(Job.finished_at.desc()).limit(1)) diff --git a/backend/foresight/routes/freshness.py b/backend/foresight/routes/freshness.py new file mode 100644 index 0000000..0e0c33d --- /dev/null +++ b/backend/foresight/routes/freshness.py @@ -0,0 +1,54 @@ +"""Freshness: when each kind of data last refreshed for a hotel.""" + +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Path +from pydantic import Field + +from foresight.routes.helpers import ResponseSchema, SessionDep +from foresight.services.domain import freshness as freshness_service + +router = APIRouter(tags=["Freshness"]) + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Schemas │ +# ╰──────────────────────────────────────────────────────────╯ + + +class KindFreshnessResponse(ResponseSchema): + last_success: datetime | None = Field( + description="When it last succeeded, UTC; null if it never has" + ) + last_failure: datetime | None = Field( + description="When the latest attempt failed or stopped, UTC; null if " + "the latest attempt succeeded or it never ran" + ) + error: str | None = Field(description="Why the latest attempt failed; null if not") + stale: bool = Field( + description="Never succeeded, or the last success is too old: 1 day for " + "events and weather, 7 for hotel data, 30 for training" + ) + + +class FreshnessResponse(ResponseSchema): + events: KindFreshnessResponse = Field(description="Routine events refresh") + weather: KindFreshnessResponse = Field(description="Routine forecast refresh") + hotel_data: KindFreshnessResponse = Field(description="Hotel data upload (CSV)") + training: KindFreshnessResponse = Field(description="Pricing model training") + + +HotelId = Annotated[int, Path(description="The hotel's id", examples=[1])] + + +# ╭──────────────────────────────────────────────────────────╮ +# │ Endpoints │ +# ╰──────────────────────────────────────────────────────────╯ + + +@router.get("/hotels/{hotel_id}/freshness", response_model=FreshnessResponse) +async def get_freshness(hotel_id: HotelId, session: SessionDep) -> FreshnessResponse: + """When each kind of data last refreshed, and whether it's stale.""" + result = await freshness_service.get_freshness(session, hotel_id) + return FreshnessResponse.model_validate(result) diff --git a/backend/foresight/services/domain/freshness.py b/backend/foresight/services/domain/freshness.py new file mode 100644 index 0000000..7956b24 --- /dev/null +++ b/backend/foresight/services/domain/freshness.py @@ -0,0 +1,72 @@ +"""When each kind of data last refreshed for a hotel, and whether it's stale. + +Read from `jobs`; nothing is stored. Only routine refreshes count for events +and weather: a backfill of last year doesn't make upcoming rates fresh. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from foresight.database.enums import JobKind, JobStatus +from foresight.repository import job as job_repo +from foresight.services.domain.hotel import get_hotel + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +# name: (job kind, params a run must have, how old it may get) +KINDS = { + "events": (JobKind.EVENTS, {"mode": "recent"}, timedelta(days=1)), + "weather": (JobKind.WEATHER, {"mode": "forecast"}, timedelta(days=1)), + "hotel_data": (JobKind.PERFORMANCE, {}, timedelta(days=7)), + "training": (JobKind.TRAIN, {}, timedelta(days=30)), +} + + +@dataclass(frozen=True, slots=True) +class Freshness: + last_success: datetime | None # None if it never succeeded + last_failure: datetime | None # set only if the latest attempt failed + error: str | None # why the latest attempt failed + stale: bool + + +@dataclass(frozen=True, slots=True) +class HotelFreshness: + """One hotel: every kind of data, keyed as in `KINDS`.""" + + events: Freshness + weather: Freshness + hotel_data: Freshness + training: Freshness + + +async def get_freshness(session: AsyncSession, hotel_id: int) -> HotelFreshness: + """Raises NotFoundError for an unknown hotel.""" + await get_hotel(session, hotel_id) + now = datetime.now(UTC) + result: dict[str, Freshness] = {} + for name, (kind, params, max_age) in KINDS.items(): + latest = await job_repo.latest_finished_job( + session, hotel_id=hotel_id, kind=kind, params=params + ) + failed = latest is not None and latest.status != JobStatus.SUCCEEDED + success = latest + if failed: + success = await job_repo.latest_finished_job( + session, + hotel_id=hotel_id, + kind=kind, + params=params, + status=JobStatus.SUCCEEDED, + ) + result[name] = Freshness( + last_success=success.finished_at if success else None, + last_failure=latest.finished_at if failed else None, + error=latest.error if failed else None, + stale=success is None or now - success.finished_at > max_age, + ) + return HotelFreshness(**result) diff --git a/backend/tests/test_freshness.py b/backend/tests/test_freshness.py new file mode 100644 index 0000000..186a4df --- /dev/null +++ b/backend/tests/test_freshness.py @@ -0,0 +1,100 @@ +"""Freshness for a hotel whose jobs are a list in memory (no database).""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock + +from foresight.database.enums import JobKind, JobStatus +from foresight.database.models.job import Job +from foresight.services.domain import freshness + +NOW = datetime.now(UTC) + + +def _job( + status: JobStatus, + hours_ago: float, + error: str | None = None, + params: dict | None = None, +) -> Job: + return Job( + kind=JobKind.EVENTS, + status=status, + params={"mode": "recent"} if params is None else params, + finished_at=NOW - timedelta(hours=hours_ago), + error=error, + ) + + +async def _events(monkeypatch, jobs: list[Job]) -> freshness.Freshness: + async def latest(_session, *, kind, params, status=None, **_): + found = [ + j + for j in jobs + if j.kind == kind + and status in (None, j.status) + and params.items() <= j.params.items() + ] + return max(found, key=lambda j: j.finished_at, default=None) + + + monkeypatch.setattr(freshness, "get_hotel", AsyncMock()) + monkeypatch.setattr(freshness.job_repo, "latest_finished_job", latest) + return (await freshness.get_freshness(None, 1)).events + + +async def test_never_run(monkeypatch) -> None: + events = await _events(monkeypatch, []) + assert events.last_success is None + assert events.last_failure is None + assert events.stale + + +async def test_succeeded(monkeypatch) -> None: + events = await _events(monkeypatch, [_job(JobStatus.SUCCEEDED, 2)]) + assert events.last_success is not None + assert events.last_failure is None + assert not events.stale + + +async def test_failed_after_success_shows_both(monkeypatch) -> None: + jobs = [_job(JobStatus.SUCCEEDED, 30), _job(JobStatus.FAILED, 1, "HTTP 500")] + events = await _events(monkeypatch, jobs) + assert events.last_success == jobs[0].finished_at + assert events.last_failure == jobs[1].finished_at + assert events.error == "HTTP 500" + assert events.stale # last success was 30 hours ago + + +async def test_failed_and_never_succeeded(monkeypatch) -> None: + failed = _job(JobStatus.FAILED, 1, "HTTP 500") + events = await _events(monkeypatch, [failed]) + assert events.last_success is None + assert events.last_failure == failed.finished_at + assert events.error == "HTTP 500" + assert events.stale # never succeeded + + +async def test_success_after_failure_clears_the_failure(monkeypatch) -> None: + jobs = [_job(JobStatus.FAILED, 5, "HTTP 500"), _job(JobStatus.SUCCEEDED, 1)] + events = await _events(monkeypatch, jobs) + assert events.last_success == jobs[1].finished_at + assert events.last_failure is None # the latest attempt didn't fail + assert events.error is None + assert not events.stale + + +async def test_succeeded_but_stale(monkeypatch) -> None: + old = _job(JobStatus.SUCCEEDED, 25) # events go stale after 24 hours + events = await _events(monkeypatch, [old]) + assert events.last_success == old.finished_at + assert events.last_failure is None + assert events.stale + + +async def test_backfill_does_not_count_as_a_refresh(monkeypatch) -> None: + backfill = _job(JobStatus.SUCCEEDED, 1, params={"mode": "window"}) + events = await _events(monkeypatch, [backfill]) + assert events.last_success is None # only routine refreshes count + assert events.stale diff --git a/docs/decisions/0032-data-freshness-rules.md b/docs/decisions/0032-data-freshness-rules.md new file mode 100644 index 0000000..44356f6 --- /dev/null +++ b/docs/decisions/0032-data-freshness-rules.md @@ -0,0 +1,25 @@ +# 0032. Freshness comes from jobs; the service decides what's stale + +**Status:** Accepted · **Date:** 2026-10-26 + +## Context +A manager can't tell whether rates are fresh. If an events refresh or an +upload failed, nothing said so, and the frontend shouldn't encode the rules +for how old each kind of data may get. + +## Decision +- `GET /hotels/{id}/freshness` reads the newest finished jobs of each kind; + there is no new table. +- Only routine refreshes count: events `mode: "recent"`, weather + `mode: "forecast"`. Backfills don't make upcoming rates fresh. Any + successful hotel data upload or training run counts. +- Stopped and failed both count as a failed attempt. A failure is shown only + when it is the latest attempt; a later success clears it. +- Stale: events and weather after 1 day, hotel data after 7, training after + 30. Never succeeding is stale. The rules are constants (`KINDS`) in + `services/domain/freshness.py`. + +## Consequences +- Changing a threshold is a one-line change, with no frontend release. +- A weather backfill that also fetched the forecast doesn't count as a refresh. +- A failure followed by a success no longer shows; the job list still has it. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 00e33f1..bf4c4b5 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -53,3 +53,4 @@ Number files sequentially (`0027-short-title.md`) and add them to the index. | [0030](0030-schema-descriptions-and-examples.md) | Every field is described; examples are built from the fields | API | | [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 | +| [0032](0032-data-freshness-rules.md) | Freshness comes from jobs; the service decides what's stale | API | diff --git a/docs/system-design.md b/docs/system-design.md index 6ca585c..c39cbb5 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -87,12 +87,12 @@ backend/foresight/ helpers.py SessionDep, schema bases, PageResponse, paginate errors.py domain error → status code city.py hotel.py weather.py event.py flight.py ingest.py job.py - pricing_model.py health.py + pricing_model.py health.py freshness.py services/ errors.py NotFound / Conflict / InvalidRequest / NotConfigured job.py job kinds, validation, handlers, lifecycle domain/ city, hotel, weather, weather_coverage, event, flight, - model_inputs, night, pricing_model, recommendation + model_inputs, night, pricing_model, recommendation, freshness ingest/ weather, event, performance, window adapters/ open_meteo, predicthq, asknews, aerodatabox, opensky, cso, hotel_csv, normalize, textdates @@ -448,6 +448,7 @@ 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}/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 | | GET | `/models/{id}` | one version, no artifact | From ddf38ce613f952226a5e6cb07d3ee3bbcbf35a02 Mon Sep 17 00:00:00 2001 From: heatherlarange Date: Mon, 26 Oct 2026 11:05:52 -0400 Subject: [PATCH 2/4] style: format freshness tests --- backend/tests/test_freshness.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/tests/test_freshness.py b/backend/tests/test_freshness.py index 186a4df..617744e 100644 --- a/backend/tests/test_freshness.py +++ b/backend/tests/test_freshness.py @@ -38,7 +38,6 @@ async def latest(_session, *, kind, params, status=None, **_): ] return max(found, key=lambda j: j.finished_at, default=None) - monkeypatch.setattr(freshness, "get_hotel", AsyncMock()) monkeypatch.setattr(freshness.job_repo, "latest_finished_job", latest) return (await freshness.get_freshness(None, 1)).events From fe42aabcea8c17b2ec51df8244bfd171dcb0e098 Mon Sep 17 00:00:00 2001 From: heatherlarange Date: Mon, 26 Oct 2026 11:21:37 -0400 Subject: [PATCH 3/4] feat(jobs): add recommendations to freshness (null until snapshots) --- backend/foresight/routes/freshness.py | 3 +++ backend/foresight/services/domain/freshness.py | 1 + backend/tests/test_freshness.py | 9 +++++++++ docs/decisions/0032-data-freshness-rules.md | 3 +++ 4 files changed, 16 insertions(+) diff --git a/backend/foresight/routes/freshness.py b/backend/foresight/routes/freshness.py index 0e0c33d..27174b0 100644 --- a/backend/foresight/routes/freshness.py +++ b/backend/foresight/routes/freshness.py @@ -37,6 +37,9 @@ class FreshnessResponse(ResponseSchema): weather: KindFreshnessResponse = Field(description="Routine forecast refresh") hotel_data: KindFreshnessResponse = Field(description="Hotel data upload (CSV)") training: KindFreshnessResponse = Field(description="Pricing model training") + recommendations: KindFreshnessResponse | None = Field( + description="Recommendation snapshots; always null until they're stored" + ) HotelId = Annotated[int, Path(description="The hotel's id", examples=[1])] diff --git a/backend/foresight/services/domain/freshness.py b/backend/foresight/services/domain/freshness.py index 7956b24..1d142bf 100644 --- a/backend/foresight/services/domain/freshness.py +++ b/backend/foresight/services/domain/freshness.py @@ -42,6 +42,7 @@ class HotelFreshness: weather: Freshness hotel_data: Freshness training: Freshness + recommendations: Freshness | None = None # not stored until snapshots (B3) async def get_freshness(session: AsyncSession, hotel_id: int) -> HotelFreshness: diff --git a/backend/tests/test_freshness.py b/backend/tests/test_freshness.py index 617744e..91f6856 100644 --- a/backend/tests/test_freshness.py +++ b/backend/tests/test_freshness.py @@ -97,3 +97,12 @@ async def test_backfill_does_not_count_as_a_refresh(monkeypatch) -> None: events = await _events(monkeypatch, [backfill]) assert events.last_success is None # only routine refreshes count assert events.stale + + +async def test_recommendations_are_not_tracked_yet(monkeypatch) -> None: + monkeypatch.setattr(freshness, "get_hotel", AsyncMock()) + monkeypatch.setattr( + freshness.job_repo, "latest_finished_job", AsyncMock(return_value=None) + ) + hotel = await freshness.get_freshness(None, 1) + assert hotel.recommendations is None diff --git a/docs/decisions/0032-data-freshness-rules.md b/docs/decisions/0032-data-freshness-rules.md index 44356f6..cf4800a 100644 --- a/docs/decisions/0032-data-freshness-rules.md +++ b/docs/decisions/0032-data-freshness-rules.md @@ -18,6 +18,9 @@ for how old each kind of data may get. - Stale: events and weather after 1 day, hotel data after 7, training after 30. Never succeeding is stale. The rules are constants (`KINDS`) in `services/domain/freshness.py`. +- `recommendations` is always null until snapshots are stored (MVP B3); then + it gets a row in `KINDS`. + ## Consequences - Changing a threshold is a one-line change, with no frontend release. From 6ec59b15c129b6c648e7750510abc7d67d6d2c60 Mon Sep 17 00:00:00 2001 From: Vichu Selvaraju Date: Tue, 27 Oct 2026 12:32:36 -0400 Subject: [PATCH 4/4] refactor(jobs): move freshness route into the jobs router Serve GET /hotels/{id}/freshness from routes/job.py next to the job list, drop the MVP planning label from comments, and renumber the decision record to 0033 now that 0032 is taken. --- backend/foresight/main.py | 2 - backend/foresight/routes/freshness.py | 57 ------------------- backend/foresight/routes/job.py | 36 ++++++++++++ .../foresight/services/domain/freshness.py | 2 +- ...-rules.md => 0033-data-freshness-rules.md} | 4 +- docs/decisions/README.md | 2 +- docs/system-design.md | 2 +- 7 files changed, 41 insertions(+), 64 deletions(-) delete mode 100644 backend/foresight/routes/freshness.py rename docs/decisions/{0032-data-freshness-rules.md => 0033-data-freshness-rules.md} (88%) diff --git a/backend/foresight/main.py b/backend/foresight/main.py index bf34185..45d5d2e 100644 --- a/backend/foresight/main.py +++ b/backend/foresight/main.py @@ -13,7 +13,6 @@ event, example_widget, flight, - freshness, health, hotel, ingest, @@ -48,7 +47,6 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: app.include_router(weather.router) app.include_router(event.router) app.include_router(flight.router) -app.include_router(freshness.router) app.include_router(ingest.router) app.include_router(job.router) app.include_router(pricing_model.router) diff --git a/backend/foresight/routes/freshness.py b/backend/foresight/routes/freshness.py deleted file mode 100644 index 27174b0..0000000 --- a/backend/foresight/routes/freshness.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Freshness: when each kind of data last refreshed for a hotel.""" - -from datetime import datetime -from typing import Annotated - -from fastapi import APIRouter, Path -from pydantic import Field - -from foresight.routes.helpers import ResponseSchema, SessionDep -from foresight.services.domain import freshness as freshness_service - -router = APIRouter(tags=["Freshness"]) - - -# ╭──────────────────────────────────────────────────────────╮ -# │ Schemas │ -# ╰──────────────────────────────────────────────────────────╯ - - -class KindFreshnessResponse(ResponseSchema): - last_success: datetime | None = Field( - description="When it last succeeded, UTC; null if it never has" - ) - last_failure: datetime | None = Field( - description="When the latest attempt failed or stopped, UTC; null if " - "the latest attempt succeeded or it never ran" - ) - error: str | None = Field(description="Why the latest attempt failed; null if not") - stale: bool = Field( - description="Never succeeded, or the last success is too old: 1 day for " - "events and weather, 7 for hotel data, 30 for training" - ) - - -class FreshnessResponse(ResponseSchema): - events: KindFreshnessResponse = Field(description="Routine events refresh") - weather: KindFreshnessResponse = Field(description="Routine forecast refresh") - hotel_data: KindFreshnessResponse = Field(description="Hotel data upload (CSV)") - training: KindFreshnessResponse = Field(description="Pricing model training") - recommendations: KindFreshnessResponse | None = Field( - description="Recommendation snapshots; always null until they're stored" - ) - - -HotelId = Annotated[int, Path(description="The hotel's id", examples=[1])] - - -# ╭──────────────────────────────────────────────────────────╮ -# │ Endpoints │ -# ╰──────────────────────────────────────────────────────────╯ - - -@router.get("/hotels/{hotel_id}/freshness", response_model=FreshnessResponse) -async def get_freshness(hotel_id: HotelId, session: SessionDep) -> FreshnessResponse: - """When each kind of data last refreshed, and whether it's stale.""" - result = await freshness_service.get_freshness(session, hotel_id) - return FreshnessResponse.model_validate(result) diff --git a/backend/foresight/routes/job.py b/backend/foresight/routes/job.py index 602d3e5..b8536eb 100644 --- a/backend/foresight/routes/job.py +++ b/backend/foresight/routes/job.py @@ -3,6 +3,9 @@ Poll `GET /jobs/{id}` for status and progress. If a job stops or fails part-way, `resume_from` says where to pick up: send it to the same ingest endpoint as `start` again (or as `end` for a run walking backwards). + +`GET /hotels/{id}/freshness` reads the same jobs to say when each kind of data +last refreshed and whether it's stale. """ from datetime import date, datetime @@ -20,6 +23,7 @@ paginate, ) from foresight.services import job as job_service +from foresight.services.domain import freshness as freshness_service router = APIRouter(tags=["Jobs"]) @@ -52,6 +56,31 @@ class JobResponse(ResponseSchema): ) +class KindFreshnessResponse(ResponseSchema): + last_success: datetime | None = Field( + description="When it last succeeded, UTC; null if it never has" + ) + last_failure: datetime | None = Field( + description="When the latest attempt failed or stopped, UTC; null if " + "the latest attempt succeeded or it never ran" + ) + error: str | None = Field(description="Why the latest attempt failed; null if not") + stale: bool = Field( + description="Never succeeded, or the last success is too old: 1 day for " + "events and weather, 7 for hotel data, 30 for training" + ) + + +class FreshnessResponse(ResponseSchema): + events: KindFreshnessResponse = Field(description="Routine events refresh") + weather: KindFreshnessResponse = Field(description="Routine forecast refresh") + hotel_data: KindFreshnessResponse = Field(description="Hotel data upload (CSV)") + training: KindFreshnessResponse = Field(description="Pricing model training") + recommendations: KindFreshnessResponse | None = Field( + description="Recommendation snapshots; always null until they're stored" + ) + + JobId = Annotated[int, Path(description="The job's id", examples=[1])] HotelId = Annotated[int, Path(description="The hotel's id", examples=[1])] @@ -76,3 +105,10 @@ async def list_jobs( session, hotel_id, limit=page.limit, offset=page.offset ) return paginate(JobResponse, jobs, total, page) + + +@router.get("/hotels/{hotel_id}/freshness", response_model=FreshnessResponse) +async def get_freshness(hotel_id: HotelId, session: SessionDep) -> FreshnessResponse: + """When each kind of data last refreshed, and whether it's stale.""" + result = await freshness_service.get_freshness(session, hotel_id) + return FreshnessResponse.model_validate(result) diff --git a/backend/foresight/services/domain/freshness.py b/backend/foresight/services/domain/freshness.py index 1d142bf..0dfbfec 100644 --- a/backend/foresight/services/domain/freshness.py +++ b/backend/foresight/services/domain/freshness.py @@ -42,7 +42,7 @@ class HotelFreshness: weather: Freshness hotel_data: Freshness training: Freshness - recommendations: Freshness | None = None # not stored until snapshots (B3) + recommendations: Freshness | None = None # null until snapshots are stored async def get_freshness(session: AsyncSession, hotel_id: int) -> HotelFreshness: diff --git a/docs/decisions/0032-data-freshness-rules.md b/docs/decisions/0033-data-freshness-rules.md similarity index 88% rename from docs/decisions/0032-data-freshness-rules.md rename to docs/decisions/0033-data-freshness-rules.md index cf4800a..3fd8673 100644 --- a/docs/decisions/0032-data-freshness-rules.md +++ b/docs/decisions/0033-data-freshness-rules.md @@ -1,4 +1,4 @@ -# 0032. Freshness comes from jobs; the service decides what's stale +# 0033. Freshness comes from jobs; the service decides what's stale **Status:** Accepted · **Date:** 2026-10-26 @@ -18,7 +18,7 @@ for how old each kind of data may get. - Stale: events and weather after 1 day, hotel data after 7, training after 30. Never succeeding is stale. The rules are constants (`KINDS`) in `services/domain/freshness.py`. -- `recommendations` is always null until snapshots are stored (MVP B3); then +- `recommendations` is always null until recommendation snapshots are stored; then it gets a row in `KINDS`. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index bf4c4b5..cf2e62f 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -53,4 +53,4 @@ Number files sequentially (`0027-short-title.md`) and add them to the index. | [0030](0030-schema-descriptions-and-examples.md) | Every field is described; examples are built from the fields | API | | [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 | -| [0032](0032-data-freshness-rules.md) | Freshness comes from jobs; the service decides what's stale | API | +| [0033](0033-data-freshness-rules.md) | Freshness comes from jobs; the service decides what's stale | API | diff --git a/docs/system-design.md b/docs/system-design.md index c39cbb5..294498b 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -87,7 +87,7 @@ backend/foresight/ helpers.py SessionDep, schema bases, PageResponse, paginate errors.py domain error → status code city.py hotel.py weather.py event.py flight.py ingest.py job.py - pricing_model.py health.py freshness.py + pricing_model.py health.py services/ errors.py NotFound / Conflict / InvalidRequest / NotConfigured job.py job kinds, validation, handlers, lifecycle