- Add project structure with domain-driven design organization - Implement FastAPI endpoints for hotel booking reports - Add data cleaning, statistics, and LLM report generation services - Include configuration management and security utilities - Add repository layer for bookings and metadata access - Setup testing framework with conftest.py - Include example files and documentation
180 lines
7.0 KiB
Markdown
180 lines
7.0 KiB
Markdown
# NF Hotel Analytics API
|
|
|
|
Secure FastAPI service that cleans NF Hotel booking data, computes descriptive
|
|
statistics, and asks a local LLM to turn those statistics into a marketing /
|
|
operations report in markdown.
|
|
|
|
## Architecture
|
|
|
|
Clean-architecture layering, each concern isolated behind classes/interfaces:
|
|
|
|
```
|
|
src/nf_hotel_api/
|
|
domain/ # Pydantic models (Booking, ReportResponse, HotelMetadata) - no framework/IO deps
|
|
repositories/ # Where data comes from: CsvBookingRepository, JsonBookingRepository, JsonHotelMetadataRepository
|
|
services/ # Business logic
|
|
cleaning.py # DataCleaningService - wrong format / empty cells / wrong data / duplicates / pricing
|
|
statistics.py # DescriptiveStatsService - df.describe() (booking_id dropped)
|
|
llm_report.py # LLMReportService - calls the local LLM, strips <think> blocks
|
|
report.py # ReportService - orchestrates the above use case
|
|
api/ # FastAPI routers, DI wiring, HTTP-only concerns
|
|
core/ # Settings (env config) and API-key security
|
|
```
|
|
|
|
Dependencies point inward: `api` -> `services` -> `domain`, and `repositories`
|
|
implement an abstract interface consumed by `services`, so the analysis logic
|
|
never depends on FastAPI or pandas I/O directly.
|
|
|
|
## Setup
|
|
|
|
```bash
|
|
python -m venv .venv
|
|
.venv/Scripts/pip install -e ".[dev]"
|
|
cp .env.example .env
|
|
# edit .env: set API_KEY, and point LLM_BASE_URL at your local
|
|
# OpenAI-compatible inference server serving qwen3.8-whittle-moe-27b-a17.8b
|
|
```
|
|
|
|
## Run
|
|
|
|
```bash
|
|
.venv/Scripts/python -m uvicorn nf_hotel_api.main:app --app-dir src --reload
|
|
```
|
|
|
|
Docs: http://localhost:8000/docs
|
|
|
|
## Security
|
|
|
|
Every `/api/v1/*` route requires an `X-API-Key` header matching `API_KEY`
|
|
from the environment, checked with a constant-time comparison
|
|
(`secrets.compare_digest`). CORS is locked to `ALLOWED_ORIGINS`. Never commit
|
|
the real `.env`.
|
|
|
|
## Endpoints
|
|
|
|
- `GET /health` - unauthenticated liveness check.
|
|
- `POST /api/v1/report/from-file` - runs the report over the bundled
|
|
`data/nf_hotel_bookings.csv`.
|
|
- `POST /api/v1/report/from-json` - runs the report over booking records
|
|
supplied in the request body, same shape as the CSV columns.
|
|
|
|
### JSON format for `POST /api/v1/report/from-json`
|
|
|
|
Request body is `{"records": [<Booking>, ...]}`, at least one record. Each
|
|
`Booking` has these fields (mirrors [domain/schemas.py](src/nf_hotel_api/domain/schemas.py)):
|
|
|
|
| Field | Type | Example |
|
|
|---|---|---|
|
|
| `booking_id` | int | `1` |
|
|
| `hotel` | string | `"NF Hotel"` |
|
|
| `is_canceled` | int (0/1) | `0` |
|
|
| `lead_time` | int | `342` |
|
|
| `arrival_date_week_number` | int | `27` |
|
|
| `booking_date` | string (`YYYY-MM-DD` or `DD-MM-YYYY`) | `"2017-07-24"` |
|
|
| `arrival_date` | string (`YYYY-MM-DD` or `DD-MM-YYYY`) | `"2018-07-01"` |
|
|
| `arrival_date_day_of_month` | int | `1` |
|
|
| `stays_in_weekend_nights` | int | `0` |
|
|
| `stays_in_week_nights` | int | `0` |
|
|
| `adults` | int | `2` |
|
|
| `children` | int | `0` |
|
|
| `babies` | int | `0` |
|
|
| `meal` | string | `"BB"` |
|
|
| `country` | string | `"Portugal"` |
|
|
| `market_segment` | string | `"Direct"` |
|
|
| `is_repeated_guest` | int (0/1) | `0` |
|
|
| `previous_cancellations` | int | `0` |
|
|
| `assigned_room_type` | string (`"A"` small, `"B"` large) | `"A"` |
|
|
| `booking_changes` | int | `3` |
|
|
| `deposit_type` | string | `"No Deposit"` |
|
|
| `agent` | int | `0` |
|
|
| `customer_type` | string | `"No Contract (Single)"` |
|
|
| `required_car_parking_spaces` | int | `0` |
|
|
| `total_of_special_requests` | int | `0` |
|
|
| `prize_per_nigth` | number, optional (price paid per night; standard price if omitted) | `20` |
|
|
|
|
Fields are intentionally accepted as raw/untrusted input - values with the
|
|
wrong format, blanks, or implausible numbers are fine; `DataCleaningService`
|
|
fixes them before statistics are computed.
|
|
|
|
A ready-to-use example payload lives in
|
|
[examples/sample_booking_request.json](examples/sample_booking_request.json):
|
|
|
|
```bash
|
|
curl -X POST http://localhost:8000/api/v1/report/from-json \
|
|
-H "X-API-Key: $API_KEY" \
|
|
-H "Content-Type: application/json" \
|
|
--data @examples/sample_booking_request.json
|
|
```
|
|
|
|
Both report endpoints return:
|
|
|
|
```json
|
|
{
|
|
"descriptive_stats": { "<column>": { "mean": ..., "top": ..., "...": ... } },
|
|
"llm_report": "## Markdown report from the LLM, <think> blocks stripped"
|
|
}
|
|
```
|
|
|
|
## Data cleaning pipeline (`DataCleaningService`)
|
|
|
|
Applied in order, before any statistics are computed:
|
|
|
|
1. **Wrong format** - dates parsed to `datetime`, numeric columns coerced to
|
|
numeric, categorical columns trimmed of whitespace.
|
|
2. **Empty cells** - blank/placeholder values normalized to `"Unknown"` for
|
|
categoricals and `0` for numerics; rows with unparseable dates are dropped.
|
|
3. **Wrong data** - implausible guest counts (e.g. 55 adults) are clipped to
|
|
a sane maximum; bookings with zero total occupants are corrected to one
|
|
adult instead of being discarded.
|
|
4. **Duplicates** - exact duplicate bookings (ignoring `booking_id`, which is
|
|
just a row identifier) are removed, keeping the first occurrence.
|
|
5. **Pricing** - room size and revenue are derived, and missing prices are
|
|
filled from the hotel metadata (see below).
|
|
|
|
Dates are accepted as `YYYY-MM-DD` (JSON) and `DD-MM-YYYY` (the CSV export).
|
|
Each value is tried against both layouts, so a mixed column does not lose rows.
|
|
|
|
## Hotel metadata and pricing
|
|
|
|
Reference data that is not part of the bookings lives in
|
|
[data/hotel_metadata.json](data/hotel_metadata.json) (path set by
|
|
`METADATA_PATH`, loaded by `JsonHotelMetadataRepository`):
|
|
|
|
```json
|
|
{
|
|
"hotel": "NF Hotel",
|
|
"currency": "USD",
|
|
"room_types": {
|
|
"A": { "size": "Small", "standard_price_per_night": 20, "room_count": null },
|
|
"B": { "size": "Large", "standard_price_per_night": 25, "room_count": null }
|
|
}
|
|
}
|
|
```
|
|
|
|
| `assigned_room_type` | `room_size` | Standard price | Rooms in hotel |
|
|
|---|---|---|---|
|
|
| `A` | Small | $20 | *to be filled in* |
|
|
| `B` | Large | $25 | *to be filled in* |
|
|
|
|
- **`prize_per_nigth` in the CSV is the price the customer actually paid** and
|
|
is never overwritten. Only a missing or negative value is replaced with the
|
|
room type's standard price.
|
|
- Room types that are not in the metadata get `room_size = "Unknown"` and no
|
|
invented price.
|
|
- `revenue` = (`stays_in_weekend_nights` + `stays_in_week_nights`) x
|
|
`prize_per_nigth` for non-cancelled bookings, `0` for cancelled ones.
|
|
- `room_size`, `prize_per_nigth` and `revenue` are part of the descriptive
|
|
statistics sent to the LLM, and the prompt includes the room catalogue
|
|
(standard prices and room counts) from the metadata file.
|
|
- `room_count` is `null` until the real number of rooms is known. Occupancy
|
|
rate needs it, so it is not calculated yet. Events are also still missing.
|
|
|
|
To change a standard price or set a room count, edit `data/hotel_metadata.json`
|
|
and restart the service (it is read once at startup).
|
|
|
|
## Tests
|
|
|
|
```bash
|
|
.venv/Scripts/python -m pytest
|
|
```
|