diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2f9b5f5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# AGENTS.md + +Entry point for AI coding agents working in **Foresight**. Read this first, then +read the per-layer context doc in [`agents/`](agents/) for whatever you're touching. +The goal: keep everyone's software practices aligned when they use AI. + +## What Foresight is + +AI revenue-intelligence platform for boutique hotels. A backend pricing engine +that ingests historical booking data + external demand signals (events, weather, +flights, competitor pricing) and produces explainable room-rate recommendations. + +## Stack + +Python · FastAPI · async SQLAlchemy 2.0 · Alembic · Postgres · uv · Docker · +GitHub Actions · ruff · Conventional Commits. + +## Architecture — n-tier, never skip a layer + +``` +routes → services → repository → database +``` + +- **routes** — HTTP layer (FastAPI routers, request/response via 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. + +Rule: a layer may only call the layer directly below it. Routes never touch the +ORM; services never build HTTP responses. Code lives in the installable package +`backend/foresight/`. + +There's a working reference slice to copy — the `Widget` example threads all +four layers (`example_widget.py` in each). Each doc below points to its file. + +Per-layer detail: +- [`agents/routes.md`](agents/routes.md) +- [`agents/services.md`](agents/services.md) +- [`agents/repository.md`](agents/repository.md) +- [`agents/database.md`](agents/database.md) + +## Commands (run from repo root) + +```bash +just dev # db + backend with hot reload +just migrate # apply migrations +just migrate-create "msg" # new Alembic migration after model changes +just migrate-down # roll back last migration +just lint # ruff checks +just format # ruff format +``` + +API: http://localhost:8000 · docs: http://localhost:8000/docs + +## Conventions + +- **Commits:** [Conventional Commits](https://www.conventionalcommits.org/) + (`feat:`, `fix:`, `docs:`, `refactor:`, `chore:`…). +- **Lint/format:** ruff must pass before commit; pre-commit hooks are enforced. +- **Migrations:** any model change needs an Alembic migration that applies *and* + rolls back cleanly. +- **Config:** add new settings to `config.py` and document them in `.env.example`. +- **Keep docs current:** if you change a layer's patterns, update that layer's + file in `agents/`. + +## Do / don't for agents + +- **Do** follow the existing pattern in the layer you're editing — read its + `agents/` doc before writing code. +- **Do** keep business logic in services and data access in the repository. +- **Don't** skip layers, add dependencies without updating `pyproject.toml`, or + change the schema without a migration. +- **Don't** invent new conventions; this file and `agents/` are the source of truth. diff --git a/agents/database.md b/agents/database.md new file mode 100644 index 0000000..788da37 --- /dev/null +++ b/agents/database.md @@ -0,0 +1,33 @@ +# Database — Agent Context + +**Updated:** 2026-10-06 + +## Purpose +The persistence foundation: SQLAlchemy models, the async engine/session, and +Alembic migrations. Defines *what* the data looks like; the repository layer +decides *how* it's queried. + +## Key files +- `backend/foresight/database/models/` — ORM models (`hotel.py`, `external.py`). +- `backend/foresight/database/base.py` — declarative base. +- `backend/foresight/database/session.py` — async engine + session factory. +- `backend/foresight/database/migrations/` — Alembic env + `versions/`. + +## Patterns & conventions +- Async SQLAlchemy 2.0 style (typed, `Mapped[...]`), all models on the shared base. +- Every schema change ships with an Alembic migration generated via + `just migrate-create "msg"`. +- Engine/session config is driven by `config.py` settings. + +## Rules / invariants +- No business logic or queries here — models and wiring only (queries live in + `repository/`). +- Never edit the DB schema without a migration that applies and rolls back. +- New models must be imported where Alembic autogenerate can see them. + +## Reference example +Copy the pattern in `backend/foresight/database/models/example_widget.py` plus +its migration in `database/migrations/versions/*_example_widget.py`. + +## How to verify +`just migrate` (apply) then `just migrate-down` (roll back) run cleanly; `just lint`. diff --git a/agents/repository.md b/agents/repository.md new file mode 100644 index 0000000..1613e13 --- /dev/null +++ b/agents/repository.md @@ -0,0 +1,30 @@ +# Repository — Agent Context + +**Updated:** 2026-10-06 + +## Purpose +The data-access layer. The **only** layer that talks to the ORM and the async +SQLAlchemy session. It turns domain requests from services into queries and +returns models/data back up. No business rules, no HTTP. + +## Key files +- `backend/foresight/repository/` — query/persistence functions per aggregate. +- `backend/foresight/database/session.py` — `get_session()` / session factory. +- `backend/foresight/database/models/` — the ORM models being queried. + +## Patterns & conventions +- Functions are `async` and receive an `AsyncSession` (don't open their own). +- Encapsulate all query/CRUD logic here so services stay persistence-agnostic. +- Return ORM models or simple data; let callers map to schemas. + +## Rules / invariants +- Only this layer imports `session` / ORM query APIs. +- No business logic and no HTTP concerns. +- Don't manage transactions ad hoc — use the session provided via `get_session()`. + +## Reference example +Copy the pattern in `backend/foresight/repository/example_widget.py` (the +canonical repository pattern). + +## How to verify +Exercise via a service + test, or through an endpoint with `just dev`. `just lint`. diff --git a/agents/routes.md b/agents/routes.md new file mode 100644 index 0000000..ad9d342 --- /dev/null +++ b/agents/routes.md @@ -0,0 +1,31 @@ +# Routes — Agent Context + +**Updated:** 2026-10-06 + +## Purpose +The HTTP layer. Defines FastAPI routers, validates input and shapes output via +Pydantic schemas, and delegates all real work to **services**. No business logic +and no database access here. + +## Key files +- `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. + +## Patterns & conventions +- One `APIRouter` per module, with `tags=[...]`; include it in `main.py`. +- Handlers are `async def`. Type request bodies and responses with schemas. +- Call into services; translate service results into responses. Keep handlers thin. + +## Rules / invariants +- Never import the ORM, session, or repository directly — go through a service. +- No SQL, no business rules in this layer. +- Every new router must be registered in `main.py`. + +## Reference example +Copy the pattern in `backend/foresight/routes/example_widget.py` (the `Widget` +vertical slice: `database -> repository -> service -> route`). + +## How to verify +`just dev`, then hit the endpoint via http://localhost:8000/docs. Run `just lint`. diff --git a/agents/services.md b/agents/services.md new file mode 100644 index 0000000..4372351 --- /dev/null +++ b/agents/services.md @@ -0,0 +1,30 @@ +# Services — Agent Context + +**Updated:** 2026-10-06 + +## Purpose +The business-logic layer. Orchestrates use cases, runs the pricing/ML logic and +data parsing, and composes repository calls. Services are the only place where +domain rules live — they're called by routes and call the repository. + +## Key files +- `backend/foresight/services/` — business logic modules. +- `backend/foresight/services/ml/` — modeling / recommendation logic. +- `backend/foresight/services/parsers/` — ingesting external + hotel data. + +## Patterns & conventions +- Pure-ish domain functions/classes; take plain inputs or schemas, return domain + data — not HTTP responses. +- Get data through the repository layer; don't query the ORM here. +- Keep ML, parsing, and orchestration in focused modules; prefer small units. + +## Rules / invariants +- No FastAPI/HTTP concerns (no `Request`, no status codes, no routers). +- No direct ORM/session use — all persistence goes through `repository/`. +- External API access and heavy logic belong here, not in routes. + +## Reference example +Copy the pattern in `backend/foresight/services/example_widget.py`. + +## How to verify +Add/adjust unit tests under `backend/tests/` and run the suite; `just lint`. diff --git a/backend/foresight/database/migrations/versions/a1b2c3d4e5f6_example_widget.py b/backend/foresight/database/migrations/versions/a1b2c3d4e5f6_example_widget.py new file mode 100644 index 0000000..9bfb9ae --- /dev/null +++ b/backend/foresight/database/migrations/versions/a1b2c3d4e5f6_example_widget.py @@ -0,0 +1,33 @@ +"""example widget reference slice + +Revision ID: a1b2c3d4e5f6 +Revises: 72e850f4fa7b +Create Date: 2026-10-06 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, None] = '72e850f4fa7b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'example_widgets', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('description', sa.String(length=500), nullable=True), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + ) + + +def downgrade() -> None: + op.drop_table('example_widgets') diff --git a/backend/foresight/database/models/__init__.py b/backend/foresight/database/models/__init__.py index 932cb8a..4ec0596 100644 --- a/backend/foresight/database/models/__init__.py +++ b/backend/foresight/database/models/__init__.py @@ -1,6 +1,7 @@ # Import all models here so Alembic can detect them from foresight.database.base import Base +from foresight.database.models.example_widget import Widget from foresight.database.models.external import ( CompetitorRate, Event, @@ -18,4 +19,5 @@ "Hotel", "RoomType", "Weather", + "Widget", ] diff --git a/backend/foresight/database/models/example_widget.py b/backend/foresight/database/models/example_widget.py new file mode 100644 index 0000000..11cc601 --- /dev/null +++ b/backend/foresight/database/models/example_widget.py @@ -0,0 +1,26 @@ +"""REFERENCE EXAMPLE — copy this pattern; do not use in production. + +The `Widget` model is a throwaway entity used to demonstrate a full vertical +slice (database -> repository -> service -> route). Delete it once real models +have replaced the need for a reference. + +Layer rules shown here: +- Models define *what* the data looks like; they contain no query logic. +- Use async SQLAlchemy 2.0 typed mappings (`Mapped[...]` + `mapped_column`). +- Every model must be importable from `database/models/__init__.py` so Alembic + autogenerate can see it. +""" + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from foresight.database.base import Base + + +class Widget(Base): + __tablename__ = "example_widgets" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(120)) + description: Mapped[str | None] = mapped_column(String(500)) + quantity: Mapped[int] = mapped_column(default=0) diff --git a/backend/foresight/main.py b/backend/foresight/main.py index e63e821..7d66cae 100644 --- a/backend/foresight/main.py +++ b/backend/foresight/main.py @@ -1,12 +1,25 @@ from fastapi import FastAPI +from scalar_fastapi import Theme, get_scalar_api_reference from foresight.config import settings -from foresight.routes import health +from foresight.routes import example_widget, health app = FastAPI( title=settings.app_name, description="AI revenue intelligence platform for boutique hotels", version="0.1.0", + # Use Scalar for the API reference instead of the built-in Swagger UI. + docs_url=None, ) app.include_router(health.router) +app.include_router(example_widget.router) + + +@app.get("/docs", include_in_schema=False) +async def scalar_docs(): + return get_scalar_api_reference( + openapi_url=app.openapi_url, + title=app.title, + theme=Theme.DEEP_SPACE, + ) diff --git a/backend/foresight/repository/example_widget.py b/backend/foresight/repository/example_widget.py new file mode 100644 index 0000000..cb8261c --- /dev/null +++ b/backend/foresight/repository/example_widget.py @@ -0,0 +1,43 @@ +"""REFERENCE EXAMPLE — copy this pattern; do not use in production. + +Data access for the `Widget` reference slice. This is the canonical repository +pattern for Foresight. + +Layer rules shown here: +- This is the ONLY layer that imports ORM models and uses the session. +- Functions are `async` and receive an `AsyncSession` — they never open their + own session or manage the engine. +- Construct/return ORM models here; callers pass plain values and map to schemas + themselves, so services stay persistence-agnostic. +- No business logic lives here — just queries and persistence. +""" + +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from foresight.database.models.example_widget import Widget + + +async def create_widget( + session: AsyncSession, + *, + name: str, + description: str | None, + quantity: int, +) -> Widget: + widget = Widget(name=name, description=description, quantity=quantity) + session.add(widget) + await session.commit() + await session.refresh(widget) + return widget + + +async def get_widget(session: AsyncSession, widget_id: int) -> Widget | None: + return await session.get(Widget, widget_id) + + +async def list_widgets(session: AsyncSession) -> Sequence[Widget]: + result = await session.execute(select(Widget).order_by(Widget.id)) + return result.scalars().all() diff --git a/backend/foresight/routes/example_widget.py b/backend/foresight/routes/example_widget.py new file mode 100644 index 0000000..dcf06ab --- /dev/null +++ b/backend/foresight/routes/example_widget.py @@ -0,0 +1,57 @@ +"""REFERENCE EXAMPLE — copy this pattern; do not use in production. + +HTTP layer for the `Widget` reference slice. + +Layer rules shown here: +- Routers are thin: validate input via schemas, call a service, shape the + response. No business logic and no ORM/session access here. +- Inject the session with `Depends(get_session)` and pass it down. +- Translate domain results into HTTP (e.g. a missing record -> 404). +- Register the router in `foresight/main.py`. +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from foresight.database.session import get_session +from foresight.schemas.example_widget import WidgetCreate, WidgetRead +from foresight.services import example_widget as widget_service + +router = APIRouter(prefix="/example/widgets", tags=["example"]) + +# Reusable session dependency. Using `Annotated` (instead of a `Depends(...)` +# default value) keeps the FastAPI DI pattern lint-clean. +SessionDep = Annotated[AsyncSession, Depends(get_session)] + + +@router.post("", response_model=WidgetRead, status_code=status.HTTP_201_CREATED) +async def create_widget( + data: WidgetCreate, + session: SessionDep, +) -> WidgetRead: + widget = await widget_service.create_widget(session, data) + return WidgetRead.model_validate(widget) + + +@router.get("", response_model=list[WidgetRead]) +async def list_widgets( + session: SessionDep, +) -> list[WidgetRead]: + widgets = await widget_service.list_widgets(session) + return [WidgetRead.model_validate(w) for w in widgets] + + +@router.get("/{widget_id}", response_model=WidgetRead) +async def get_widget( + widget_id: int, + session: SessionDep, +) -> WidgetRead: + widget = await widget_service.get_widget(session, widget_id) + if widget is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Widget {widget_id} not found", + ) + return WidgetRead.model_validate(widget) diff --git a/backend/foresight/schemas/example_widget.py b/backend/foresight/schemas/example_widget.py new file mode 100644 index 0000000..8efc319 --- /dev/null +++ b/backend/foresight/schemas/example_widget.py @@ -0,0 +1,23 @@ +"""REFERENCE EXAMPLE — copy this pattern; do not use in production. + +Pydantic shapes for the `Widget` reference slice. + +Layer rules shown here: +- `*Create` schemas describe the request body (no `id`, no server fields). +- `*Read` schemas describe the response and set `from_attributes=True` so they + can be built directly from an ORM model instance. +""" + +from pydantic import BaseModel, ConfigDict + + +class WidgetCreate(BaseModel): + name: str + description: str | None = None + quantity: int = 0 + + +class WidgetRead(WidgetCreate): + model_config = ConfigDict(from_attributes=True) + + id: int diff --git a/backend/foresight/services/example_widget.py b/backend/foresight/services/example_widget.py new file mode 100644 index 0000000..bcbe8e5 --- /dev/null +++ b/backend/foresight/services/example_widget.py @@ -0,0 +1,37 @@ +"""REFERENCE EXAMPLE — copy this pattern; do not use in production. + +Business logic for the `Widget` reference slice. + +Layer rules shown here: +- Services hold domain logic and orchestration; they call the repository for + persistence and never touch the ORM/session directly. +- Services take plain inputs or schemas and return domain data (ORM models or + simple values) — never HTTP responses or status codes. +- Map request schemas to repository calls here so the repository stays + schema-agnostic. +""" + +from collections.abc import Sequence + +from sqlalchemy.ext.asyncio import AsyncSession + +from foresight.database.models.example_widget import Widget +from foresight.repository import example_widget as widget_repo +from foresight.schemas.example_widget import WidgetCreate + + +async def create_widget(session: AsyncSession, data: WidgetCreate) -> Widget: + return await widget_repo.create_widget( + session, + name=data.name, + description=data.description, + quantity=data.quantity, + ) + + +async def get_widget(session: AsyncSession, widget_id: int) -> Widget | None: + return await widget_repo.get_widget(session, widget_id) + + +async def list_widgets(session: AsyncSession) -> Sequence[Widget]: + return await widget_repo.list_widgets(session) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 14bcc7f..ad73168 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "alembic>=1.15", "asyncpg>=0.30", "pydantic-settings>=2.9", + "scalar-fastapi>=1.4", "python-dotenv>=1.1", "pandas>=2.2", "psycopg2-binary>=2.9", diff --git a/backend/uv.lock b/backend/uv.lock index 87cadef..60a3dca 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -335,6 +335,7 @@ dependencies = [ { name = "psycopg2-binary" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, + { name = "scalar-fastapi" }, { name = "scikit-learn" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn", extra = ["standard"] }, @@ -366,6 +367,7 @@ requires-dist = [ { name = "psycopg2-binary", specifier = ">=2.9" }, { name = "pydantic-settings", specifier = ">=2.9" }, { name = "python-dotenv", specifier = ">=1.1" }, + { name = "scalar-fastapi", specifier = ">=1.4" }, { name = "scikit-learn", specifier = ">=1.9.1" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34" }, @@ -1427,6 +1429,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, ] +[[package]] +name = "scalar-fastapi" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/18/4314770c7a9a90171ef7fdae8dd100e69ceea7ffbff8e4e3c30b95f2bd2b/scalar_fastapi-1.9.1.tar.gz", hash = "sha256:a54104c434655c457fdbad3c181d3368be07c63ddbb7a5a084c83fdfa7239333", size = 10906, upload-time = "2026-09-25T10:47:41.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ed/9b927292c8e40fec09defb517ddc11c17f5a66a1129922521b7e81385ff6/scalar_fastapi-1.9.1-py3-none-any.whl", hash = "sha256:fc7fdba6e60fb016a57b3eca59621773179c133abb6d496611df9575f97555a8", size = 8594, upload-time = "2026-09-25T10:47:40.296Z" }, +] + [[package]] name = "scikit-learn" version = "1.9.1"