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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ GitHub Actions · ruff · Conventional Commits.
routes → services → repository → database
```

- **routes** — HTTP layer (FastAPI routers, request/response via Pydantic schemas).
- **routes** — HTTP layer (FastAPI routers, `*Request`/`*Response` Pydantic schemas).
- **services** — business logic, orchestration, ML/parsers.
- **repository** — data access; the only layer that talks to the ORM/session.
- **database** — SQLAlchemy models, migrations, session/engine.
Expand All @@ -38,6 +38,7 @@ Per-layer detail:
- [`agents/services.md`](agents/services.md)
- [`agents/repository.md`](agents/repository.md)
- [`agents/database.md`](agents/database.md)
- [`agents/schemas.md`](agents/schemas.md) — Pydantic standard (all layers)

## Commands (run from repo root)

Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ backend/
│ ├── main.py # FastAPI app entrypoint; routers registered here
│ ├── config.py # Settings (pydantic-settings)
│ ├── routes/ # API endpoints (one module per resource)
│ ├── schemas/ # Pydantic request/response models
│ ├── schemas/ # Pydantic *Request/*Response schemas (agents/schemas.md)
│ ├── services/ # Business logic
│ │ ├── ml/ # ML models (regression, bayesian, timeseries, ...)
│ │ └── parsers/ # Client/external data ingestion
Expand Down
3 changes: 2 additions & 1 deletion agents/repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ returns models/data back up. No business rules, no HTTP.
once per request; CLIs and jobs commit once at the end. Use `flush()` when you
need generated ids.
- Encapsulate all query/CRUD logic here so services stay persistence-agnostic.
- Return ORM models or simple data; let callers map to schemas.
- Take plain values (never schemas); return ORM models or simple data. Routes map
results to `*Response` schemas.

## Rules / invariants
- Only this layer imports `session` / ORM query APIs.
Expand Down
9 changes: 6 additions & 3 deletions agents/routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@ and no database access here.
- `backend/foresight/routes/` — one module per resource (e.g. `health.py`).
- `backend/foresight/main.py` — app setup; routers registered with
`app.include_router(...)`.
- `backend/foresight/schemas/` — Pydantic request/response models used here.
- `backend/foresight/schemas/` — Pydantic `*Request` / `*Response` models used
here. See [`schemas.md`](schemas.md) for the standard.

## Patterns & conventions
- One `APIRouter` per module, with `tags=[...]`; include it in `main.py`.
- Handlers are `async def`. Type request bodies and responses with schemas.
- Handlers are `async def`. Take bodies as `*Request` schemas; declare
`response_model=` with a matching `*Response` return type.
- Inject the session as `Depends(get_session, scope="function")` (see
`SessionDep` in the widget example). It commits once per request after the
handler returns; handlers and services never commit.
- Call into services; translate service results into responses. Keep handlers thin.
- Call into services; build the response with `XxxResponse.model_validate(result)`.
Keep handlers thin.

## Rules / invariants
- Never import the ORM, session, or repository directly — go through a service.
Expand Down
68 changes: 68 additions & 0 deletions agents/schemas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Schemas — Agent Context

**Updated:** 2026-10-12

## Purpose
Pydantic models that validate and convert data at the edges of the app: HTTP
requests and responses, and payloads from external APIs. They are not the
database models (those are SQLAlchemy, in `database/models/`) and they are not
for passing data between our own functions.

## Key files
- `backend/foresight/schemas/base.py` — `RequestSchema`, `ResponseSchema`,
`ExternalPayload` base classes.
- `backend/foresight/schemas/<resource>.py` — HTTP schemas, one module per resource.
- `backend/foresight/schemas/example_widget.py` — reference example.

## When to use Pydantic
| Data | Use |
|---|---|
| HTTP request body | `<Name>Request(RequestSchema)` |
| HTTP response body | `<Name>Response(ResponseSchema)` |
| Payload from an external API | `<Source><Name>Payload(ExternalPayload)` |
| Config | `pydantic-settings` (`config.py`) |
| Data passed between our own functions | ORM object or `@dataclass` — **not** Pydantic |

Create one schema per distinct shape that crosses a boundary — not one per table.
A table never sent over HTTP needs no schema.

## Patterns & conventions
- **Naming by direction:** `<Name>Request` for what a client sends,
`<Name>Response` for what an endpoint returns. Only create a `Request` if the
endpoint takes a body.
- **Base classes:**
- `RequestSchema` — `extra="forbid"`: unknown fields are rejected with a 422.
- `ResponseSchema` — `from_attributes=True`: build it from an ORM object with
`XxxResponse.model_validate(obj)`.
- `ExternalPayload` — `extra="ignore"`: external APIs add fields; we only
model what we use.
- **No inheritance between Request and Response.** Each lists its fields
explicitly. Duplication is fine; it keeps input fields from silently leaking
into output.
- **Nested items** follow the same naming (e.g. `ContributionResponse` inside
`RecommendationResponse`).
- **Fields:** `Decimal` for money, `date`/`datetime` for time. Use
`Field(description=..., ge=..., le=...)` for constraints and docs — they show
up in `/docs`. Use `@computed_field` for values derived from other fields.

## How each layer uses them
- **Routes** take `Request` schemas as parameters, declare `response_model=`,
and build the `Response` with `model_validate(...)`.
- **Services** may take a `Request` schema as input. They return ORM objects or
dataclasses — never `Response` schemas (services don't build HTTP responses).
- **Repository** never imports schemas; it takes plain values.
- **External API clients** (services) parse raw responses into `ExternalPayload`
models, then map them to domain objects/ORM models. Payload models live in the
module that calls the API, not in `schemas/`.

## Rules / invariants
- Every route declares `response_model=` and a matching return type.
- Never return an ORM object's sensitive/internal fields — only what the
`Response` declares is sent.
- No business logic in schemas beyond simple `@computed_field` derivations.

## Reference example
Copy the pattern in `backend/foresight/schemas/example_widget.py`.

## How to verify
`just dev`, check the schemas at http://localhost:8000/docs. Run `just lint`.
7 changes: 5 additions & 2 deletions agents/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ domain rules live — they're called by routes and call the repository.
through `repository/`.

## Patterns & conventions
- Pure-ish domain functions/classes; take plain inputs or schemas, return domain
data — not HTTP responses.
- Pure-ish domain functions/classes; take plain inputs or `*Request` schemas,
return domain data (ORM objects or dataclasses) — never `*Response` schemas.
See [`schemas.md`](schemas.md).
- Parse external API responses into `ExternalPayload` models in the module that
calls the API.
- Get data through the repository layer; don't query the ORM here.
- Keep ML, parsing, and orchestration in focused modules; prefer small units.

Expand Down
4 changes: 3 additions & 1 deletion backend/foresight/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from scalar_fastapi import Theme, get_scalar_api_reference

from foresight.config import settings
from foresight.routes import example_widget, health
from foresight.routes import example_widget, health, hotels, signals

app = FastAPI(
title=settings.app_name,
Expand All @@ -13,6 +13,8 @@
)

app.include_router(health.router)
app.include_router(hotels.router)
app.include_router(signals.router)
app.include_router(example_widget.router)


Expand Down
90 changes: 88 additions & 2 deletions backend/foresight/repository/hotel.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

from typing import TYPE_CHECKING, Any, cast

from sqlalchemy import func
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert

from foresight.database.models.hotel import DailyPerformance, Hotel, RoomType
from foresight.repository import Outcome, upsert_rows
from foresight.repository.pagination import fetch_page

if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from collections.abc import Iterable, Mapping, Sequence
from datetime import date
from decimal import Decimal

from sqlalchemy import Table
Expand Down Expand Up @@ -88,3 +90,87 @@ async def upsert_daily_performance(
mutable=_PERF_MEASURES,
rows=rows,
)


async def create_hotel(
session: AsyncSession,
*,
name: str,
city: str,
total_rooms: int,
latitude: float | None,
longitude: float | None,
) -> Hotel:
hotel = Hotel(
name=name,
city=city,
total_rooms=total_rooms,
latitude=latitude,
longitude=longitude,
)
session.add(hotel)
await session.flush()
await session.refresh(hotel)
return hotel


async def create_room_type(
session: AsyncSession,
*,
hotel_id: int,
name: str,
room_count: int,
base_rate: Decimal,
) -> RoomType:
room_type = RoomType(
hotel_id=hotel_id, name=name, room_count=room_count, base_rate=base_rate
)
session.add(room_type)
await session.flush()
await session.refresh(room_type)
return room_type


async def list_hotels(
session: AsyncSession, *, limit: int, offset: int
) -> tuple[Sequence[Hotel], int]:
return await fetch_page(
session, select(Hotel).order_by(Hotel.id), limit=limit, offset=offset
)


async def get_room_type(session: AsyncSession, room_type_id: int) -> RoomType | None:
return await session.get(RoomType, room_type_id)


async def list_room_types(
session: AsyncSession, hotel_id: int, *, limit: int, offset: int
) -> tuple[Sequence[RoomType], int]:
stmt = select(RoomType).where(RoomType.hotel_id == hotel_id).order_by(RoomType.id)
return await fetch_page(session, stmt, limit=limit, offset=offset)


async def list_performance(
session: AsyncSession,
hotel_id: int,
*,
start: date | None,
end: date | None,
room_type_id: int | None,
limit: int,
offset: int,
) -> tuple[Sequence[DailyPerformance], int]:
"""Nights for every room type of a hotel; `daily_performance` has no hotel_id."""
stmt = (
select(DailyPerformance)
.join(RoomType, DailyPerformance.room_type_id == RoomType.id)
.where(RoomType.hotel_id == hotel_id)
)
if room_type_id is not None:
stmt = stmt.where(DailyPerformance.room_type_id == room_type_id)
if start is not None:
stmt = stmt.where(DailyPerformance.stay_date >= start)
if end is not None:
stmt = stmt.where(DailyPerformance.stay_date <= end)
stmt = stmt.order_by(DailyPerformance.stay_date, DailyPerformance.room_type_id)
return await fetch_page(session, stmt, limit=limit, offset=offset)
21 changes: 21 additions & 0 deletions backend/foresight/repository/pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from collections.abc import Sequence
from typing import Any

from sqlalchemy import Select, func, select
from sqlalchemy.ext.asyncio import AsyncSession


async def fetch_page[T](
session: AsyncSession,
stmt: Select[tuple[T]],
*,
limit: int,
offset: int,
) -> tuple[Sequence[T], int]:
"""Run an ordered SELECT and return one page of it plus the unpaged row count."""
count_stmt: Select[Any] = select(func.count()).select_from(
stmt.order_by(None).subquery()
)
total = await session.scalar(count_stmt)
result = await session.scalars(stmt.limit(limit).offset(offset))
return result.all(), total or 0
68 changes: 68 additions & 0 deletions backend/foresight/repository/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from collections.abc import Collection, Sequence
from datetime import date

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from foresight.database.models.external import Event, FlightArrival, Weather
from foresight.ingest.normalize import EventStatus
from foresight.repository.pagination import fetch_page


async def list_weather(
session: AsyncSession,
*,
city: str,
start: date | None,
end: date | None,
limit: int,
offset: int,
) -> tuple[Sequence[Weather], int]:
# `weather.city` is free text, so compare without regard to case.
stmt = select(Weather).where(func.lower(Weather.city) == city.lower())
if start is not None:
stmt = stmt.where(Weather.weather_date >= start)
if end is not None:
stmt = stmt.where(Weather.weather_date <= end)
stmt = stmt.order_by(Weather.weather_date, Weather.id)
return await fetch_page(session, stmt, limit=limit, offset=offset)


async def list_events(
session: AsyncSession,
*,
city_slug: str,
start: date | None,
end: date | None,
exclude_statuses: Collection[EventStatus],
limit: int,
offset: int,
) -> tuple[Sequence[Event], int]:
"""Events whose inclusive local-date range overlaps [start, end]."""
stmt = select(Event).where(Event.city_slug == city_slug)
if exclude_statuses:
stmt = stmt.where(Event.status.not_in(exclude_statuses))
if start is not None:
stmt = stmt.where(Event.end_local_date >= start)
if end is not None:
stmt = stmt.where(Event.start_local_date <= end)
stmt = stmt.order_by(Event.start_local_date, Event.id)
return await fetch_page(session, stmt, limit=limit, offset=offset)


async def list_flight_arrivals(
session: AsyncSession,
*,
airport: str,
start: date | None,
end: date | None,
limit: int,
offset: int,
) -> tuple[Sequence[FlightArrival], int]:
stmt = select(FlightArrival).where(FlightArrival.airport == airport)
if start is not None:
stmt = stmt.where(FlightArrival.arrival_date >= start)
if end is not None:
stmt = stmt.where(FlightArrival.arrival_date <= end)
stmt = stmt.order_by(FlightArrival.arrival_date, FlightArrival.id)
return await fetch_page(session, stmt, limit=limit, offset=offset)
42 changes: 42 additions & 0 deletions backend/foresight/routes/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Annotated, Any

from fastapi import Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession

from foresight.database.session import get_session
from foresight.schemas.base import ResponseSchema
from foresight.schemas.common import PageResponse

SessionDep = Annotated[AsyncSession, Depends(get_session, scope="function")]


@dataclass
class Pagination:
"""`?limit=&offset=` query params shared by every list endpoint."""

limit: Annotated[int, Query(ge=1, le=200)] = 50
offset: Annotated[int, Query(ge=0)] = 0


PageDep = Annotated[Pagination, Depends()]


def paginate[T: ResponseSchema](
schema: type[T], rows: Sequence[Any], total: int, page: Pagination
) -> PageResponse[T]:
"""Map ORM rows to `schema` and wrap them in the shared page envelope."""
return PageResponse(
items=[schema.model_validate(row) for row in rows],
total=total,
limit=page.limit,
offset=page.offset,
)


def not_found(entity: str, entity_id: int) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"{entity} {entity_id} not found",
)
Loading
Loading