- Add scripts for generating booking data, building metadata, and updating holidays - Implement public_holidays.py service with holiday lookup functionality - Update CSV data with expanded hotel booking records - Add holidays.json dataset for public holiday dates - Enhance LLM report service with improved formatting - Update metadata domain model and API dependencies - Add test coverage for dataset and public holidays
253 lines
11 KiB
Markdown
253 lines
11 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)
|
|
public_holidays.py # PublicHolidayCalendar - Cambodian holidays from the `holidays` package
|
|
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`):
|
|
|
|
| Field | Content |
|
|
|---|---|
|
|
| `hotel`, `address`, `currency` | NF Hotel, Street 172, Phnom Penh, Cambodia; prices in USD |
|
|
| `room_types` | per `assigned_room_type`: size, standard price per night, number of rooms |
|
|
| `nearby_events` | events in Phnom Penh 2022-2025 (name, dates, venue, `source_url`) |
|
|
|
|
| `assigned_room_type` | `room_size` | Standard price | Rooms in hotel |
|
|
|---|---|---|---|
|
|
| `A` | Small | $20 | 10 |
|
|
| `B` | Large | $25 | 10 |
|
|
|
|
- **`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. The prompt also includes the metadata (address,
|
|
room types with prices and room counts, nearby events) and the public
|
|
holidays for the years in the data.
|
|
|
|
To change a price, a room count, a holiday or an event, edit
|
|
`data/hotel_metadata.json` and restart the service (it is read once at
|
|
startup). `scripts/build_metadata.py` is the helper that produced the file.
|
|
|
|
### Public holidays
|
|
|
|
Public holidays are **not stored in the hotel metadata**. They are looked up at
|
|
run time in the Python [`holidays`](https://pypi.org/project/holidays/) package
|
|
(country `KH`, Cambodia) by `PublicHolidayCalendar`
|
|
([services/public_holidays.py](src/nf_hotel_api/services/public_holidays.py)).
|
|
The LLM prompt lists the holidays for the years the bookings' arrival dates
|
|
span, and the dataset generator uses them to shape demand.
|
|
|
|
[data/holidays.json](data/holidays.json) is a generated **export** of that
|
|
calendar (holiday name, start and end date, with the package version it came
|
|
from) for anyone who wants to read the holidays without running Python. The
|
|
application does not read it; the package stays the source of truth. Refresh it
|
|
after upgrading the package or when a new year starts:
|
|
|
|
```bash
|
|
.venv/Scripts/python scripts/update_holidays.py
|
|
```
|
|
|
|
By default it covers 2022 up to next year; use `--first-year` / `--last-year`
|
|
to change that. `tests/test_public_holidays.py` fails when the file no longer
|
|
matches the package, which is the signal to re-run the script.
|
|
|
|
### Nearby events
|
|
|
|
Looked up on the web; each entry carries its `source_url`. All venues are in
|
|
Phnom Penh, but the distance to Street 172 was **not measured**, so the
|
|
20 km radius is an assumption based on the venues being in the city.
|
|
|
|
| Event | Date | Venue |
|
|
|---|---|---|
|
|
| 40th and 41st ASEAN Summits | 10-13 Nov 2022 | Phnom Penh |
|
|
| 2023 SEA Games | 5-17 May 2023 | Morodok Techo Sports Complex, Olympic Sports Complex, Chroy Changvar Convention Centre |
|
|
| 12th ASEAN Para Games | 3-9 Jun 2023 | Morodok Techo National Stadium |
|
|
| Phnom Penh International Half Marathon | 11 Jun 2023, 16 Jun 2024, 15 Jun 2025 | Phnom Penh |
|
|
| Miss Grand Cambodia 2024 final | 12 Jul 2024 | Koh Pich Theater |
|
|
| CAMFOOD & CAMHOTEL 2024 | 6-8 Nov 2024 | Diamond Island Convention & Exhibition Center |
|
|
| Cambodia ASEAN Business Summit 2025 | 6 Mar 2025 | Sofitel Phnom Penh Phokeetra |
|
|
| 2025 AFC Challenge League final | 10 May 2025 | Phnom Penh |
|
|
| Mekong Forum 2025 | 30-31 Jul 2025 | Shangri-La Hotel Phnom Penh |
|
|
| CamboP&ELight 2025 | 6-9 Aug 2025 | Diamond Island Convention & Exhibition Center |
|
|
| Phnom Penh Design Festival 2025 | 31 Oct - 2 Nov 2025 | Factory Phnom Penh |
|
|
|
|
The list is not exhaustive (2022 and 2024 in particular have few entries).
|
|
|
|
## The dataset (`data/nf_hotel_bookings.csv`)
|
|
|
|
**The bookings are synthetic, not real reservations.** They are generated by
|
|
[scripts/generate_bookings.py](scripts/generate_bookings.py) (fixed seed, so the
|
|
file is reproducible) to fit this hotel:
|
|
|
|
- Arrivals 1 Jan 2022 - 31 Dec 2025, about 8,500 rows.
|
|
- The hotel has only 10 small and 10 large rooms. The simulation never sells
|
|
more rooms of a type than exist on any night; requests for a sold-out night
|
|
are turned away and do not appear in the file. `tests/test_dataset.py`
|
|
checks this.
|
|
- Demand follows the calendar in the metadata: it is higher on Fridays and
|
|
Saturdays and in the cool season, much higher during big events (SEA Games,
|
|
ASEAN Summit) and the Water Festival, and lower during Khmer New Year and
|
|
Pchum Ben, when people leave the capital. Average occupancy comes out around
|
|
55-60 %, close to full during the biggest events.
|
|
- `prize_per_nigth` is the price paid: the standard price with a discount for
|
|
Corporate/Groups/Offline TA bookings and a surcharge on peak days.
|
|
- About 2 % deliberately dirty rows (duplicates, impossible guest counts, blank
|
|
meals) are added so `DataCleaningService` has real work to do.
|
|
|
|
All multipliers (holiday and event effects, price rules, guest mix) are
|
|
**modelling assumptions**, not measurements. They are constants at the top of
|
|
the script; change them and regenerate:
|
|
|
|
```bash
|
|
.venv/Scripts/python scripts/generate_bookings.py
|
|
```
|
|
|
|
(Close the CSV in Excel first, otherwise Windows blocks the write.) Replace the
|
|
file with real bookings when they are available; nothing else needs to change.
|
|
|
|
## Tests
|
|
|
|
```bash
|
|
.venv/Scripts/python -m pytest
|
|
```
|