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
73 changes: 73 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions agents/database.md
Original file line number Diff line number Diff line change
@@ -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`.
30 changes: 30 additions & 0 deletions agents/repository.md
Original file line number Diff line number Diff line change
@@ -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`.
31 changes: 31 additions & 0 deletions agents/routes.md
Original file line number Diff line number Diff line change
@@ -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`.
30 changes: 30 additions & 0 deletions agents/services.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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')
2 changes: 2 additions & 0 deletions backend/foresight/database/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -18,4 +19,5 @@
"Hotel",
"RoomType",
"Weather",
"Widget",
]
26 changes: 26 additions & 0 deletions backend/foresight/database/models/example_widget.py
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 14 additions & 1 deletion backend/foresight/main.py
Original file line number Diff line number Diff line change
@@ -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,
)
43 changes: 43 additions & 0 deletions backend/foresight/repository/example_widget.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading