- 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
7.0 KiB
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
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
.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 bundleddata/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):
| 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:
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:
{
"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:
- Wrong format - dates parsed to
datetime, numeric columns coerced to numeric, categorical columns trimmed of whitespace. - Empty cells - blank/placeholder values normalized to
"Unknown"for categoricals and0for numerics; rows with unparseable dates are dropped. - 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.
- Duplicates - exact duplicate bookings (ignoring
booking_id, which is just a row identifier) are removed, keeping the first occurrence. - 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 (path set by
METADATA_PATH, loaded by JsonHotelMetadataRepository):
{
"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_nigthin 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) xprize_per_nigthfor non-cancelled bookings,0for cancelled ones.room_size,prize_per_nigthandrevenueare 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_countisnulluntil 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
.venv/Scripts/python -m pytest