Add data generation scripts and public holidays service

- 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
This commit is contained in:
2026-09-21 14:13:39 +08:00
parent 225d28feb7
commit 769c777b48
19 changed files with 50164 additions and 40297 deletions
+91 -18
View File
@@ -15,6 +15,7 @@ src/nf_hotel_api/
services/ # Business logic services/ # Business logic
cleaning.py # DataCleaningService - wrong format / empty cells / wrong data / duplicates / pricing cleaning.py # DataCleaningService - wrong format / empty cells / wrong data / duplicates / pricing
statistics.py # DescriptiveStatsService - df.describe() (booking_id dropped) 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 llm_report.py # LLMReportService - calls the local LLM, strips <think> blocks
report.py # ReportService - orchestrates the above use case report.py # ReportService - orchestrates the above use case
api/ # FastAPI routers, DI wiring, HTTP-only concerns api/ # FastAPI routers, DI wiring, HTTP-only concerns
@@ -140,21 +141,16 @@ Reference data that is not part of the bookings lives in
[data/hotel_metadata.json](data/hotel_metadata.json) (path set by [data/hotel_metadata.json](data/hotel_metadata.json) (path set by
`METADATA_PATH`, loaded by `JsonHotelMetadataRepository`): `METADATA_PATH`, loaded by `JsonHotelMetadataRepository`):
```json | Field | Content |
{ |---|---|
"hotel": "NF Hotel", | `hotel`, `address`, `currency` | NF Hotel, Street 172, Phnom Penh, Cambodia; prices in USD |
"currency": "USD", | `room_types` | per `assigned_room_type`: size, standard price per night, number of rooms |
"room_types": { | `nearby_events` | events in Phnom Penh 2022-2025 (name, dates, venue, `source_url`) |
"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 | | `assigned_room_type` | `room_size` | Standard price | Rooms in hotel |
|---|---|---|---| |---|---|---|---|
| `A` | Small | $20 | *to be filled in* | | `A` | Small | $20 | 10 |
| `B` | Large | $25 | *to be filled in* | | `B` | Large | $25 | 10 |
- **`prize_per_nigth` in the CSV is the price the customer actually paid** and - **`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 is never overwritten. Only a missing or negative value is replaced with the
@@ -164,13 +160,90 @@ Reference data that is not part of the bookings lives in
- `revenue` = (`stays_in_weekend_nights` + `stays_in_week_nights`) x - `revenue` = (`stays_in_weekend_nights` + `stays_in_week_nights`) x
`prize_per_nigth` for non-cancelled bookings, `0` for cancelled ones. `prize_per_nigth` for non-cancelled bookings, `0` for cancelled ones.
- `room_size`, `prize_per_nigth` and `revenue` are part of the descriptive - `room_size`, `prize_per_nigth` and `revenue` are part of the descriptive
statistics sent to the LLM, and the prompt includes the room catalogue statistics sent to the LLM. The prompt also includes the metadata (address,
(standard prices and room counts) from the metadata file. room types with prices and room counts, nearby events) and the public
- `room_count` is `null` until the real number of rooms is known. Occupancy holidays for the years in the data.
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` To change a price, a room count, a holiday or an event, edit
and restart the service (it is read once at startup). `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 ## Tests
+473
View File
@@ -0,0 +1,473 @@
{
"country": "KH",
"source": "python-holidays 0.104",
"first_year": 2022,
"last_year": 2027,
"holidays": [
{
"name": "International New Year Day",
"start_date": "2022-01-01",
"end_date": "2022-01-01"
},
{
"name": "Day of Victory over the Genocidal Regime",
"start_date": "2022-01-07",
"end_date": "2022-01-07"
},
{
"name": "International Women's Rights Day",
"start_date": "2022-03-08",
"end_date": "2022-03-08"
},
{
"name": "Khmer New Year's Day",
"start_date": "2022-04-14",
"end_date": "2022-04-16"
},
{
"name": "International Labor Day",
"start_date": "2022-05-01",
"end_date": "2022-05-01"
},
{
"name": "HM King Norodom Sihamoni's Birthday",
"start_date": "2022-05-14",
"end_date": "2022-05-14"
},
{
"name": "Visaka Bochea Day",
"start_date": "2022-05-15",
"end_date": "2022-05-15"
},
{
"name": "Royal Ploughing Ceremony",
"start_date": "2022-05-19",
"end_date": "2022-05-19"
},
{
"name": "HM Queen Norodom Monineath Sihanouk the Queen-Mother's Birthday",
"start_date": "2022-06-18",
"end_date": "2022-06-18"
},
{
"name": "Constitution Day; Pchum Ben Day",
"start_date": "2022-09-24",
"end_date": "2022-09-24"
},
{
"name": "Pchum Ben Day",
"start_date": "2022-09-25",
"end_date": "2022-09-26"
},
{
"name": "HM King Norodom Sihanouk Mourning Day",
"start_date": "2022-10-15",
"end_date": "2022-10-15"
},
{
"name": "HM King Norodom Sihamoni's Coronation Day",
"start_date": "2022-10-29",
"end_date": "2022-10-29"
},
{
"name": "Water Festival",
"start_date": "2022-11-07",
"end_date": "2022-11-08"
},
{
"name": "National Independence Day; Water Festival",
"start_date": "2022-11-09",
"end_date": "2022-11-09"
},
{
"name": "International New Year Day",
"start_date": "2023-01-01",
"end_date": "2023-01-01"
},
{
"name": "Day of Victory over the Genocidal Regime",
"start_date": "2023-01-07",
"end_date": "2023-01-07"
},
{
"name": "International Women's Rights Day",
"start_date": "2023-03-08",
"end_date": "2023-03-08"
},
{
"name": "Khmer New Year's Day",
"start_date": "2023-04-14",
"end_date": "2023-04-16"
},
{
"name": "International Labor Day",
"start_date": "2023-05-01",
"end_date": "2023-05-01"
},
{
"name": "Visaka Bochea Day",
"start_date": "2023-05-04",
"end_date": "2023-05-04"
},
{
"name": "Royal Ploughing Ceremony",
"start_date": "2023-05-08",
"end_date": "2023-05-08"
},
{
"name": "HM King Norodom Sihamoni's Birthday",
"start_date": "2023-05-14",
"end_date": "2023-05-14"
},
{
"name": "HM Queen Norodom Monineath Sihanouk the Queen-Mother's Birthday",
"start_date": "2023-06-18",
"end_date": "2023-06-18"
},
{
"name": "Constitution Day",
"start_date": "2023-09-24",
"end_date": "2023-09-24"
},
{
"name": "Pchum Ben Day",
"start_date": "2023-10-13",
"end_date": "2023-10-14"
},
{
"name": "HM King Norodom Sihanouk Mourning Day; Pchum Ben Day",
"start_date": "2023-10-15",
"end_date": "2023-10-15"
},
{
"name": "HM King Norodom Sihamoni's Coronation Day",
"start_date": "2023-10-29",
"end_date": "2023-10-29"
},
{
"name": "National Independence Day",
"start_date": "2023-11-09",
"end_date": "2023-11-09"
},
{
"name": "Water Festival",
"start_date": "2023-11-26",
"end_date": "2023-11-28"
},
{
"name": "International New Year Day",
"start_date": "2024-01-01",
"end_date": "2024-01-01"
},
{
"name": "Day of Victory over the Genocidal Regime",
"start_date": "2024-01-07",
"end_date": "2024-01-07"
},
{
"name": "International Women's Rights Day",
"start_date": "2024-03-08",
"end_date": "2024-03-08"
},
{
"name": "Khmer New Year's Day",
"start_date": "2024-04-13",
"end_date": "2024-04-16"
},
{
"name": "International Labor Day",
"start_date": "2024-05-01",
"end_date": "2024-05-01"
},
{
"name": "HM King Norodom Sihamoni's Birthday",
"start_date": "2024-05-14",
"end_date": "2024-05-14"
},
{
"name": "Visaka Bochea Day",
"start_date": "2024-05-22",
"end_date": "2024-05-22"
},
{
"name": "Royal Ploughing Ceremony",
"start_date": "2024-05-26",
"end_date": "2024-05-26"
},
{
"name": "HM Queen Norodom Monineath Sihanouk the Queen-Mother's Birthday",
"start_date": "2024-06-18",
"end_date": "2024-06-18"
},
{
"name": "Constitution Day",
"start_date": "2024-09-24",
"end_date": "2024-09-24"
},
{
"name": "Pchum Ben Day",
"start_date": "2024-10-01",
"end_date": "2024-10-03"
},
{
"name": "HM King Norodom Sihanouk Mourning Day",
"start_date": "2024-10-15",
"end_date": "2024-10-15"
},
{
"name": "HM King Norodom Sihamoni's Coronation Day",
"start_date": "2024-10-29",
"end_date": "2024-10-29"
},
{
"name": "National Independence Day",
"start_date": "2024-11-09",
"end_date": "2024-11-09"
},
{
"name": "Water Festival",
"start_date": "2024-11-14",
"end_date": "2024-11-16"
},
{
"name": "Peace Day in Cambodia",
"start_date": "2024-12-29",
"end_date": "2024-12-29"
},
{
"name": "International New Year Day",
"start_date": "2025-01-01",
"end_date": "2025-01-01"
},
{
"name": "Day of Victory over the Genocidal Regime",
"start_date": "2025-01-07",
"end_date": "2025-01-07"
},
{
"name": "International Women's Rights Day",
"start_date": "2025-03-08",
"end_date": "2025-03-08"
},
{
"name": "Khmer New Year's Day",
"start_date": "2025-04-14",
"end_date": "2025-04-16"
},
{
"name": "International Labor Day",
"start_date": "2025-05-01",
"end_date": "2025-05-01"
},
{
"name": "Visaka Bochea Day",
"start_date": "2025-05-11",
"end_date": "2025-05-11"
},
{
"name": "HM King Norodom Sihamoni's Birthday",
"start_date": "2025-05-14",
"end_date": "2025-05-14"
},
{
"name": "Royal Ploughing Ceremony",
"start_date": "2025-05-15",
"end_date": "2025-05-15"
},
{
"name": "HM Queen Norodom Monineath Sihanouk the Queen-Mother's Birthday",
"start_date": "2025-06-18",
"end_date": "2025-06-18"
},
{
"name": "Pchum Ben Day",
"start_date": "2025-09-21",
"end_date": "2025-09-23"
},
{
"name": "Constitution Day",
"start_date": "2025-09-24",
"end_date": "2025-09-24"
},
{
"name": "HM King Norodom Sihanouk Mourning Day",
"start_date": "2025-10-15",
"end_date": "2025-10-15"
},
{
"name": "HM King Norodom Sihamoni's Coronation Day",
"start_date": "2025-10-29",
"end_date": "2025-10-29"
},
{
"name": "Water Festival",
"start_date": "2025-11-04",
"end_date": "2025-11-06"
},
{
"name": "National Independence Day",
"start_date": "2025-11-09",
"end_date": "2025-11-09"
},
{
"name": "Peace Day in Cambodia",
"start_date": "2025-12-29",
"end_date": "2025-12-29"
},
{
"name": "International New Year Day",
"start_date": "2026-01-01",
"end_date": "2026-01-01"
},
{
"name": "Day of Victory over the Genocidal Regime",
"start_date": "2026-01-07",
"end_date": "2026-01-07"
},
{
"name": "International Women's Rights Day",
"start_date": "2026-03-08",
"end_date": "2026-03-08"
},
{
"name": "Khmer New Year's Day",
"start_date": "2026-04-14",
"end_date": "2026-04-16"
},
{
"name": "International Labor Day; Visaka Bochea Day",
"start_date": "2026-05-01",
"end_date": "2026-05-01"
},
{
"name": "Royal Ploughing Ceremony",
"start_date": "2026-05-05",
"end_date": "2026-05-05"
},
{
"name": "HM King Norodom Sihamoni's Birthday",
"start_date": "2026-05-14",
"end_date": "2026-05-14"
},
{
"name": "HM Queen Norodom Monineath Sihanouk the Queen-Mother's Birthday",
"start_date": "2026-06-18",
"end_date": "2026-06-18"
},
{
"name": "Constitution Day",
"start_date": "2026-09-24",
"end_date": "2026-09-24"
},
{
"name": "Pchum Ben Day",
"start_date": "2026-10-10",
"end_date": "2026-10-12"
},
{
"name": "HM King Norodom Sihanouk Mourning Day",
"start_date": "2026-10-15",
"end_date": "2026-10-15"
},
{
"name": "HM King Norodom Sihamoni's Coronation Day",
"start_date": "2026-10-29",
"end_date": "2026-10-29"
},
{
"name": "National Independence Day",
"start_date": "2026-11-09",
"end_date": "2026-11-09"
},
{
"name": "Water Festival",
"start_date": "2026-11-23",
"end_date": "2026-11-25"
},
{
"name": "Peace Day in Cambodia",
"start_date": "2026-12-29",
"end_date": "2026-12-29"
},
{
"name": "International New Year Day",
"start_date": "2027-01-01",
"end_date": "2027-01-01"
},
{
"name": "Day of Victory over the Genocidal Regime",
"start_date": "2027-01-07",
"end_date": "2027-01-07"
},
{
"name": "International Women's Rights Day",
"start_date": "2027-03-08",
"end_date": "2027-03-08"
},
{
"name": "Khmer New Year's Day",
"start_date": "2027-04-14",
"end_date": "2027-04-16"
},
{
"name": "International Labor Day",
"start_date": "2027-05-01",
"end_date": "2027-05-01"
},
{
"name": "HM King Norodom Sihamoni's Birthday",
"start_date": "2027-05-14",
"end_date": "2027-05-14"
},
{
"name": "Visaka Bochea Day",
"start_date": "2027-05-20",
"end_date": "2027-05-20"
},
{
"name": "Royal Ploughing Ceremony",
"start_date": "2027-05-24",
"end_date": "2027-05-24"
},
{
"name": "HM Queen Norodom Monineath Sihanouk the Queen-Mother's Birthday",
"start_date": "2027-06-18",
"end_date": "2027-06-18"
},
{
"name": "Constitution Day",
"start_date": "2027-09-24",
"end_date": "2027-09-24"
},
{
"name": "Pchum Ben Day",
"start_date": "2027-09-29",
"end_date": "2027-10-01"
},
{
"name": "HM King Norodom Sihanouk Mourning Day",
"start_date": "2027-10-15",
"end_date": "2027-10-15"
},
{
"name": "HM King Norodom Sihamoni's Coronation Day",
"start_date": "2027-10-29",
"end_date": "2027-10-29"
},
{
"name": "National Independence Day",
"start_date": "2027-11-09",
"end_date": "2027-11-09"
},
{
"name": "Water Festival",
"start_date": "2027-11-12",
"end_date": "2027-11-14"
},
{
"name": "Peace Day in Cambodia",
"start_date": "2027-12-29",
"end_date": "2027-12-29"
}
]
}
+109 -3
View File
@@ -1,8 +1,114 @@
{ {
"hotel": "NF Hotel", "hotel": "NF Hotel",
"address": {
"street": "Street 172",
"city": "Phnom Penh",
"country": "Cambodia"
},
"currency": "USD", "currency": "USD",
"room_types": { "room_types": {
"A": { "size": "Small", "standard_price_per_night": 20, "room_count": null }, "A": {
"B": { "size": "Large", "standard_price_per_night": 25, "room_count": null } "size": "Small",
} "standard_price_per_night": 20,
"room_count": 10
},
"B": {
"size": "Large",
"standard_price_per_night": 25,
"room_count": 10
}
},
"nearby_events": [
{
"name": "40th and 41st ASEAN Summits and Related Summits",
"start_date": "2022-11-10",
"end_date": "2022-11-13",
"venue": "Phnom Penh",
"source_url": "https://www.pmo.gov.sg/Newsroom/PM-Lee-Hsien-Loong-to-attend-the-40th-and-41st-ASEAN-Summits-and-Related-Summits-in-Cambodia-2022"
},
{
"name": "2023 SEA Games (Southeast Asian Games)",
"start_date": "2023-05-05",
"end_date": "2023-05-17",
"venue": "Morodok Techo Sports Complex (Chroy Changvar), Olympic Sports Complex, Chroy Changvar Convention Centre",
"source_url": "https://en.wikipedia.org/wiki/2023_SEA_Games"
},
{
"name": "12th ASEAN Para Games",
"start_date": "2023-06-03",
"end_date": "2023-06-09",
"venue": "Morodok Techo National Stadium",
"source_url": "https://en.wikipedia.org/wiki/2023_ASEAN_Para_Games"
},
{
"name": "Phnom Penh International Half Marathon",
"start_date": "2023-06-11",
"end_date": "2023-06-11",
"venue": "Phnom Penh",
"source_url": "https://aims-worldrunning.org/races/10059.html"
},
{
"name": "Phnom Penh International Half Marathon",
"start_date": "2024-06-16",
"end_date": "2024-06-16",
"venue": "Phnom Penh",
"source_url": "https://aims-worldrunning.org/races/10059.html"
},
{
"name": "Miss Grand Cambodia 2024 final",
"start_date": "2024-07-12",
"end_date": "2024-07-12",
"venue": "Koh Pich Theater",
"source_url": "https://en.wikipedia.org/wiki/Miss_Grand_Cambodia_2024"
},
{
"name": "CAMFOOD & CAMHOTEL 2024 trade fair",
"start_date": "2024-11-06",
"end_date": "2024-11-08",
"venue": "Diamond Island Convention & Exhibition Center",
"source_url": "https://www.tradeindia.com/tradeshows/venue/diamond-island-convention-exhibition-center-diecc/1173/"
},
{
"name": "Cambodia ASEAN Business Summit 2025",
"start_date": "2025-03-06",
"end_date": "2025-03-06",
"venue": "Sofitel Phnom Penh Phokeetra",
"source_url": "https://cambodiainvestmentreview.com/2025/01/30/cambodia-asean-business-summit-2025-set-for-march-6-at-sofitel-phnom-penh/"
},
{
"name": "2025 AFC Challenge League final",
"start_date": "2025-05-10",
"end_date": "2025-05-10",
"venue": "Phnom Penh",
"source_url": "https://en.wikipedia.org/wiki/2025_AFC_Challenge_League_final"
},
{
"name": "Phnom Penh International Half Marathon",
"start_date": "2025-06-15",
"end_date": "2025-06-15",
"venue": "Phnom Penh",
"source_url": "https://aims-worldrunning.org/races/10059.html"
},
{
"name": "Mekong Forum 2025",
"start_date": "2025-07-30",
"end_date": "2025-07-31",
"venue": "Shangri-La Hotel Phnom Penh",
"source_url": "https://mekonginstitute.org/mekong-forum-2025/"
},
{
"name": "CamboP&ELight 2025 trade fair",
"start_date": "2025-08-06",
"end_date": "2025-08-09",
"venue": "Diamond Island Convention & Exhibition Center",
"source_url": "https://www.tradeindia.com/tradeshows/venue/diamond-island-convention-exhibition-center-diecc/1173/"
},
{
"name": "Phnom Penh Design Festival 2025",
"start_date": "2025-10-31",
"end_date": "2025-11-02",
"venue": "Factory Phnom Penh",
"source_url": "https://ppua.edu.kh/events/ppdf/"
}
]
} }
+8538 -40044
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -30,11 +30,12 @@ requires a class to generate field validation, JSON (de)serialization, and
the OpenAPI schema FastAPI exposes at `/docs`. Each class is a **data the OpenAPI schema FastAPI exposes at `/docs`. Each class is a **data
contract**, not behavior - no methods, just typed fields. contract**, not behavior - no methods, just typed fields.
### `src/nf_hotel_api/domain/metadata.py` - classes: `RoomType`, `HotelMetadata` ### `src/nf_hotel_api/domain/metadata.py` - classes: `RoomType`, `Address`, `NearbyEvent`, `HotelMetadata`
Pydantic models for the reference data in `data/hotel_metadata.json`: per room Pydantic models for the reference data in `data/hotel_metadata.json`: address,
type its size, standard price per night and (optional) number of rooms. per room type its size, standard price per night and number of rooms, plus the
`HotelMetadata.describe()` renders the catalogue as text for the LLM prompt. nearby Phnom Penh events (date ranges are validated). Public holidays are not
stored; see `services/public_holidays.py`. `HotelMetadata.describe()` renders it all as text for the LLM prompt.
## Core (config & security) ## Core (config & security)
@@ -85,6 +86,13 @@ standard price, and derives `room_size` and `revenue`. The class keeps these ste
testable (see `tests/test_cleaning.py`), and lets the whole pipeline be testable (see `tests/test_cleaning.py`), and lets the whole pipeline be
swapped out in `ReportService`. swapped out in `ReportService`.
### `src/nf_hotel_api/services/public_holidays.py` - class `PublicHolidayCalendar`
Thin wrapper over the `holidays` package (country `KH`): returns holidays per
year, looks up a single day, merges consecutive days into `HolidayPeriod`s and
renders them as text for the LLM prompt. `data/holidays.json` is an export of
the periods, regenerated by `scripts/update_holidays.py`.
### `src/nf_hotel_api/services/statistics.py` - class `DescriptiveStatsService` ### `src/nf_hotel_api/services/statistics.py` - class `DescriptiveStatsService`
A single-purpose class computing and JSON-serializing `df.describe()`. It A single-purpose class computing and JSON-serializing `df.describe()`. It
+1
View File
@@ -10,6 +10,7 @@ dependencies = [
"pydantic>=2.9", "pydantic>=2.9",
"pydantic-settings>=2.6", "pydantic-settings>=2.6",
"httpx>=0.27", "httpx>=0.27",
"holidays>=0.60",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+66
View File
@@ -0,0 +1,66 @@
"""One-off helper that writes data/hotel_metadata.json.
Events were looked up on the web (source_url per event). Public holidays are
not stored here: they come from the `holidays` package at run time. Edit the
lists and re-run to change the file - or edit the JSON directly.
"""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def e(name, start, end, venue, url):
return {"name": name, "start_date": start, "end_date": end, "venue": venue, "source_url": url}
EVENTS = [
e("40th and 41st ASEAN Summits and Related Summits", "2022-11-10", "2022-11-13",
"Phnom Penh",
"https://www.pmo.gov.sg/Newsroom/PM-Lee-Hsien-Loong-to-attend-the-40th-and-41st-ASEAN-Summits-and-Related-Summits-in-Cambodia-2022"),
e("2023 SEA Games (Southeast Asian Games)", "2023-05-05", "2023-05-17",
"Morodok Techo Sports Complex (Chroy Changvar), Olympic Sports Complex, Chroy Changvar Convention Centre",
"https://en.wikipedia.org/wiki/2023_SEA_Games"),
e("12th ASEAN Para Games", "2023-06-03", "2023-06-09",
"Morodok Techo National Stadium",
"https://en.wikipedia.org/wiki/2023_ASEAN_Para_Games"),
e("Phnom Penh International Half Marathon", "2023-06-11", "2023-06-11",
"Phnom Penh", "https://aims-worldrunning.org/races/10059.html"),
e("Phnom Penh International Half Marathon", "2024-06-16", "2024-06-16",
"Phnom Penh", "https://aims-worldrunning.org/races/10059.html"),
e("Miss Grand Cambodia 2024 final", "2024-07-12", "2024-07-12",
"Koh Pich Theater", "https://en.wikipedia.org/wiki/Miss_Grand_Cambodia_2024"),
e("CAMFOOD & CAMHOTEL 2024 trade fair", "2024-11-06", "2024-11-08",
"Diamond Island Convention & Exhibition Center",
"https://www.tradeindia.com/tradeshows/venue/diamond-island-convention-exhibition-center-diecc/1173/"),
e("Cambodia ASEAN Business Summit 2025", "2025-03-06", "2025-03-06",
"Sofitel Phnom Penh Phokeetra",
"https://cambodiainvestmentreview.com/2025/01/30/cambodia-asean-business-summit-2025-set-for-march-6-at-sofitel-phnom-penh/"),
e("2025 AFC Challenge League final", "2025-05-10", "2025-05-10",
"Phnom Penh", "https://en.wikipedia.org/wiki/2025_AFC_Challenge_League_final"),
e("Phnom Penh International Half Marathon", "2025-06-15", "2025-06-15",
"Phnom Penh", "https://aims-worldrunning.org/races/10059.html"),
e("Mekong Forum 2025", "2025-07-30", "2025-07-31",
"Shangri-La Hotel Phnom Penh", "https://mekonginstitute.org/mekong-forum-2025/"),
e("CamboP&ELight 2025 trade fair", "2025-08-06", "2025-08-09",
"Diamond Island Convention & Exhibition Center",
"https://www.tradeindia.com/tradeshows/venue/diamond-island-convention-exhibition-center-diecc/1173/"),
e("Phnom Penh Design Festival 2025", "2025-10-31", "2025-11-02",
"Factory Phnom Penh", "https://ppua.edu.kh/events/ppdf/"),
]
METADATA = {
"hotel": "NF Hotel",
"address": {"street": "Street 172", "city": "Phnom Penh", "country": "Cambodia"},
"currency": "USD",
"room_types": {
"A": {"size": "Small", "standard_price_per_night": 20, "room_count": 10},
"B": {"size": "Large", "standard_price_per_night": 25, "room_count": 10},
},
"nearby_events": EVENTS,
}
if __name__ == "__main__":
path = ROOT / "data" / "hotel_metadata.json"
path.write_text(json.dumps(METADATA, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(f"wrote {path} ({len(EVENTS)} events)")
+219
View File
@@ -0,0 +1,219 @@
"""Generates the synthetic NF Hotel booking dataset (data/nf_hotel_bookings.csv).
These are NOT real bookings. The hotel is small (see room_count per room type
in data/hotel_metadata.json), so the simulation never sells more rooms of a
type than the hotel has on any night. Demand follows the calendar in the
metadata file (events near the hotel) and on Cambodian public holidays from
the `holidays` package.
Every multiplier below is a modelling ASSUMPTION, not a measurement - change
the constants and re-run to test other scenarios:
.venv/Scripts/python scripts/generate_bookings.py
The output is reproducible (fixed seed). A small share of deliberately dirty
rows (duplicates, impossible guest counts, blanks) is added at the end so the
cleaning pipeline has something to do; set DIRTY_SHARE = 0 to disable.
"""
import sys
from datetime import date, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from nf_hotel_api.domain.metadata import HotelMetadata # noqa: E402
from nf_hotel_api.services.public_holidays import PublicHolidayCalendar # noqa: E402
SEED = 2022
FIRST_ARRIVAL = date(2022, 1, 1)
LAST_ARRIVAL = date(2025, 12, 31)
OUTPUT = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "data" / "nf_hotel_bookings.csv"
METADATA_FILE = ROOT / "data" / "hotel_metadata.json"
# --- Demand model (assumptions) -------------------------------------------
BASE_REQUESTS_PER_DAY = 5.5 # booking requests arriving per day in a normal week
MONTH_FACTOR = { # cool/dry season busiest, rainy season quietest
1: 1.25, 2: 1.2, 3: 1.05, 4: 0.9, 5: 0.85, 6: 0.8,
7: 0.85, 8: 0.85, 9: 0.8, 10: 0.95, 11: 1.2, 12: 1.3,
}
WEEKDAY_FACTOR = {0: 0.95, 1: 0.95, 2: 0.95, 3: 1.0, 4: 1.15, 5: 1.2, 6: 1.0}
# Holiday effect on demand for a Phnom Penh hotel. Matched on the holiday name
# as spelled by the `holidays` package.
HOLIDAY_FACTORS = [
("Water Festival", 1.8), # Phnom Penh hosts the biggest celebrations
("Khmer New Year", 0.75), # capital empties as people travel to the provinces
("Pchum Ben", 0.8), # same: family and pagoda visits in home provinces
]
OTHER_HOLIDAY_FACTOR = 1.15
# Event effect, matched on the event name (first match wins).
EVENT_FACTORS = [
("ASEAN Summits", 2.2),
("SEA Games", 2.5),
("ASEAN Para Games", 1.7),
("Half Marathon", 1.3),
("AFC Challenge League", 1.3),
]
OTHER_EVENT_FACTOR = 1.2
# --- Guests and stays -----------------------------------------------------
STAY_NIGHTS = [1, 2, 3, 4, 5, 6, 7, 10]
STAY_WEIGHTS = [0.33, 0.30, 0.17, 0.08, 0.05, 0.03, 0.03, 0.01]
COUNTRIES = {
"Cambodia": 0.24, "China": 0.14, "Vietnam": 0.08, "United States": 0.08,
"France": 0.06, "United Kingdom": 0.06, "Australia": 0.05, "Japan": 0.05,
"South Korea": 0.05, "Thailand": 0.04, "Germany": 0.04, "Singapore": 0.03,
"Malaysia": 0.03, "Other": 0.05,
}
SEGMENTS = {"Online TA": 0.45, "Direct": 0.20, "Offline TA/TO": 0.12,
"Corporate": 0.09, "Groups": 0.08, "Complementary": 0.01}
# Price paid vs the standard price of the room type (assumption).
SEGMENT_PRICE_FACTOR = {"Corporate": 0.9, "Groups": 0.9, "Offline TA/TO": 0.95, "Complementary": 0.0}
PEAK_PRICE_FACTOR = 1.15 # applied when the arrival date has a demand factor >= 1.5
PEAK_DEMAND_THRESHOLD = 1.5
LARGE_ROOM_PREFERENCE = 0.85 # parties of 3+ or with children who ask for a large room
DIRTY_SHARE = 0.02
def demand_factor(day: date, metadata: HotelMetadata, holiday_names: dict[date, str]) -> float:
factor = MONTH_FACTOR[day.month] * WEEKDAY_FACTOR[day.weekday()]
holiday = holiday_names.get(day)
if holiday:
factor *= next((f for key, f in HOLIDAY_FACTORS if key in holiday), OTHER_HOLIDAY_FACTOR)
for event in metadata.nearby_events:
if event.covers(day):
factor *= next((f for key, f in EVENT_FACTORS if key in event.name), OTHER_EVENT_FACTOR)
break
return factor
def pick(rng: np.random.Generator, weights: dict[str, float]) -> str:
keys = list(weights)
probabilities = np.array(list(weights.values()), dtype=float)
return str(rng.choice(keys, p=probabilities / probabilities.sum()))
def simulate(metadata: HotelMetadata, rng: np.random.Generator) -> pd.DataFrame:
days = (LAST_ARRIVAL - FIRST_ARRIVAL).days + 1
horizon = days + 30 # stays that begin near the end run past LAST_ARRIVAL
capacity = {code: room.room_count for code, room in metadata.room_types.items()}
if any(count is None for count in capacity.values()):
raise SystemExit("room_count must be set for every room type in hotel_metadata.json")
booked = {code: np.zeros(horizon, dtype=int) for code in capacity}
holiday_names = PublicHolidayCalendar().for_years(FIRST_ARRIVAL.year, LAST_ARRIVAL.year)
small, large = "A", "B"
rows = []
for offset in range(days):
arrival = FIRST_ARRIVAL + timedelta(days=offset)
factor = demand_factor(arrival, metadata, holiday_names)
for _ in range(rng.poisson(BASE_REQUESTS_PER_DAY * factor)):
adults = int(rng.choice([1, 2, 3, 4], p=[0.25, 0.55, 0.12, 0.08]))
children = int(rng.choice([0, 1, 2], p=[0.85, 0.10, 0.05])) if adults >= 2 else 0
babies = int(rng.random() < 0.02)
wants_large = adults >= 3 or children > 0
preferred = large if rng.random() < (LARGE_ROOM_PREFERENCE if wants_large else 0.2) else small
nights = int(rng.choice(STAY_NIGHTS, p=STAY_WEIGHTS))
segment = pick(rng, SEGMENTS)
if segment == "Corporate":
nights = min(nights, 4)
deposit = pick(rng, {"No Deposit": 0.90, "Non Refund": 0.07, "Refundable": 0.03})
lead_mean = 45 if factor >= 1.3 else 22
lead_time = int(min(rng.exponential(lead_mean), 365))
cancel_probability = 0.20 * (0.3 if deposit == "Non Refund" else 1.0)
is_canceled = int(rng.random() < cancel_probability)
stay = slice(offset, offset + nights)
assigned = None
for code in (preferred, small if preferred == large else large):
if (booked[code][stay] < capacity[code]).all():
assigned = code
break
if assigned is None and not is_canceled:
continue # sold out on at least one night: request turned away
if assigned is None: # a cancelled request keeps its preferred type
assigned = preferred
if not is_canceled:
booked[assigned][stay] += 1
stay_dates = [arrival + timedelta(days=n) for n in range(nights)]
weekend_nights = sum(d.weekday() >= 5 for d in stay_dates)
standard = metadata.room_types[assigned].standard_price_per_night
price = standard * SEGMENT_PRICE_FACTOR.get(segment, 1.0)
if factor >= PEAK_DEMAND_THRESHOLD and segment != "Complementary":
price *= PEAK_PRICE_FACTOR
rows.append(
{
"hotel": metadata.hotel,
"is_canceled": is_canceled,
"lead_time": lead_time,
"arrival_date_week_number": arrival.isocalendar()[1],
"booking_date": arrival - timedelta(days=lead_time),
"arrival_date": arrival,
"arrival_date_day_of_month": arrival.day,
"stays_in_weekend_nights": weekend_nights,
"stays_in_week_nights": nights - weekend_nights,
"adults": adults,
"children": children,
"babies": babies,
"meal": pick(rng, {"BB": 0.70, "HB": 0.10, "FB": 0.02, "SC": 0.18}),
"country": pick(rng, COUNTRIES),
"market_segment": segment,
"is_repeated_guest": int(rng.random() < 0.08),
"previous_cancellations": int(rng.random() < 0.04),
"assigned_room_type": assigned,
"booking_changes": int(rng.choice([0, 1, 2], p=[0.88, 0.09, 0.03])),
"deposit_type": deposit,
"agent": int(rng.choice([0, 9, 14, 28, 40, 240], p=[0.35, 0.15, 0.15, 0.1, 0.1, 0.15])),
"customer_type": "Group Contract" if segment == "Groups" else pick(
rng, {"No Contract (Single)": 0.75, "No Contract (Group)": 0.17, "Contract (Single)": 0.08}
),
"required_car_parking_spaces": int(rng.random() < 0.05),
"total_of_special_requests": int(rng.choice([0, 1, 2, 3], p=[0.55, 0.28, 0.12, 0.05])),
"prize_per_nigth": round(price, 2),
}
)
df = pd.DataFrame(rows)
df = df.sort_values(["booking_date", "arrival_date"], kind="stable").reset_index(drop=True)
df.insert(0, "booking_id", np.arange(1, len(df) + 1))
return df
def add_dirty_rows(df: pd.DataFrame, rng: np.random.Generator) -> pd.DataFrame:
"""Append duplicates and corrupt a few values, like a real messy export."""
if DIRTY_SHARE <= 0:
return df
df = df.copy()
n = max(1, int(len(df) * DIRTY_SHARE))
corrupt = df.sample(n, random_state=SEED + 1).index
half = len(corrupt) // 2
df.loc[corrupt[:half], "adults"] = rng.choice([20, 55], size=half)
df.loc[corrupt[half:], ["adults", "children", "babies"]] = 0 # booking without guests
df["meal"] = df["meal"].astype(object)
df.loc[df.sample(n // 2, random_state=SEED + 2).index, "meal"] = ""
# Duplicates are copied last so they are exact copies (same values, new id)
# of rows as they appear in the export, including any corruption above.
duplicates = df.sample(n, random_state=SEED)
df = pd.concat([df, duplicates], ignore_index=True)
df = df.sort_values(["booking_date", "arrival_date"], kind="stable").reset_index(drop=True)
df["booking_id"] = np.arange(1, len(df) + 1)
return df
def main() -> None:
metadata = HotelMetadata.model_validate_json(METADATA_FILE.read_text(encoding="utf-8"))
rng = np.random.default_rng(SEED)
df = add_dirty_rows(simulate(metadata, rng), rng)
for column in ("booking_date", "arrival_date"):
df[column] = pd.to_datetime(df[column]).dt.strftime("%d-%m-%Y")
df.to_csv(OUTPUT, sep=";", index=False)
print(f"wrote {len(df)} rows to {OUTPUT}")
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
"""Regenerates data/holidays.json from the `holidays` package.
The file is an export, not a source of truth: the application reads holidays
straight from the package (services/public_holidays.py). Run this script to
refresh the export - after upgrading the package, or when a new year starts:
.venv/Scripts/python scripts/update_holidays.py
.venv/Scripts/python scripts/update_holidays.py --first-year 2022 --last-year 2028
By default it covers 2022 (start of the booking data) up to next year.
`tests/test_public_holidays.py` fails when the file no longer matches the
package, which is the signal to re-run this script.
"""
import argparse
import json
import sys
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from nf_hotel_api.services.public_holidays import PublicHolidayCalendar # noqa: E402
OUTPUT = ROOT / "data" / "holidays.json"
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--first-year", type=int, default=2022)
parser.add_argument("--last-year", type=int, default=date.today().year + 1)
parser.add_argument("--country", default="KH", help="ISO country code used by the holidays package")
args = parser.parse_args()
calendar = PublicHolidayCalendar(args.country)
document = {
"country": calendar.country_code,
"source": f"python-holidays {calendar.package_version()}",
"first_year": args.first_year,
"last_year": args.last_year,
"holidays": [
{
"name": period.name,
"start_date": period.start_date.isoformat(),
"end_date": period.end_date.isoformat(),
}
for period in calendar.periods(args.first_year, args.last_year)
],
}
OUTPUT.write_text(json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(f"wrote {OUTPUT} ({len(document['holidays'])} holiday periods, {args.first_year}-{args.last_year})")
if __name__ == "__main__":
main()
+2
View File
@@ -7,6 +7,7 @@ from nf_hotel_api.domain.metadata import HotelMetadata
from nf_hotel_api.repositories.metadata_repository import JsonHotelMetadataRepository from nf_hotel_api.repositories.metadata_repository import JsonHotelMetadataRepository
from nf_hotel_api.services.cleaning import DataCleaningService from nf_hotel_api.services.cleaning import DataCleaningService
from nf_hotel_api.services.llm_report import LLMReportService from nf_hotel_api.services.llm_report import LLMReportService
from nf_hotel_api.services.public_holidays import PublicHolidayCalendar
from nf_hotel_api.services.report import ReportService from nf_hotel_api.services.report import ReportService
from nf_hotel_api.services.statistics import DescriptiveStatsService from nf_hotel_api.services.statistics import DescriptiveStatsService
@@ -33,6 +34,7 @@ def get_llm_service(
) -> LLMReportService: ) -> LLMReportService:
return LLMReportService( return LLMReportService(
metadata=metadata, metadata=metadata,
holiday_calendar=PublicHolidayCalendar(),
base_url=settings.llm_base_url, base_url=settings.llm_base_url,
api_key=settings.llm_api_key, api_key=settings.llm_api_key,
model=settings.llm_model, model=settings.llm_model,
+48 -3
View File
@@ -1,4 +1,6 @@
from pydantic import BaseModel, Field from datetime import date
from pydantic import BaseModel, Field, model_validator
class RoomType(BaseModel): class RoomType(BaseModel):
@@ -11,19 +13,59 @@ class RoomType(BaseModel):
room_count: int | None = Field(default=None, ge=0) room_count: int | None = Field(default=None, ge=0)
class Address(BaseModel):
street: str
city: str
country: str
def __str__(self) -> str:
return f"{self.street}, {self.city}, {self.country}"
class NearbyEvent(BaseModel):
"""An event in Phnom Penh close enough to the hotel to affect bookings."""
name: str
start_date: date
end_date: date
venue: str
source_url: str
@model_validator(mode="after")
def _end_not_before_start(self) -> "NearbyEvent":
if self.end_date < self.start_date:
raise ValueError(f"{self.name}: end_date is before start_date")
return self
def covers(self, day: date) -> bool:
return self.start_date <= day <= self.end_date
def describe(self) -> str:
if self.start_date == self.end_date:
return f"{self.start_date.isoformat()}: {self.name} ({self.venue})"
return (
f"{self.start_date.isoformat()} to {self.end_date.isoformat()}: "
f"{self.name} ({self.venue})"
)
class HotelMetadata(BaseModel): class HotelMetadata(BaseModel):
"""Reference data about the hotel that is not part of the booking records. """Reference data about the hotel that is not part of the booking records.
``room_types`` is keyed by the ``assigned_room_type`` code used in the CSV. ``room_types`` is keyed by the ``assigned_room_type`` code used in the CSV.
Public holidays are deliberately not stored here; see
``services.public_holidays.PublicHolidayCalendar``.
""" """
hotel: str hotel: str
address: Address | None = None
currency: str = "USD" currency: str = "USD"
room_types: dict[str, RoomType] room_types: dict[str, RoomType]
nearby_events: list[NearbyEvent] = []
def describe(self) -> str: def describe(self) -> str:
"""Plain-text summary of the room catalogue, for the LLM prompt.""" """Plain-text summary of the hotel, its rooms and events, for the LLM prompt."""
lines = [] lines = [f"Hotel: {self.hotel}" + (f", {self.address}" if self.address else "")]
for code, room in self.room_types.items(): for code, room in self.room_types.items():
count = "unknown" if room.room_count is None else str(room.room_count) count = "unknown" if room.room_count is None else str(room.room_count)
lines.append( lines.append(
@@ -31,4 +73,7 @@ class HotelMetadata(BaseModel):
f"{room.standard_price_per_night:g} {self.currency} per night, " f"{room.standard_price_per_night:g} {self.currency} per night, "
f"number of rooms in hotel: {count}" f"number of rooms in hotel: {count}"
) )
if self.nearby_events:
lines.append("Events in Phnom Penh near the hotel:")
lines.extend(f"- {event.describe()}" for event in self.nearby_events)
return "\n".join(lines) return "\n".join(lines)
+31 -10
View File
@@ -5,6 +5,7 @@ from typing import Any
import httpx import httpx
from nf_hotel_api.domain.metadata import HotelMetadata from nf_hotel_api.domain.metadata import HotelMetadata
from nf_hotel_api.services.public_holidays import PublicHolidayCalendar
_PROMPT_TEMPLATE = """You work as a data analyst and in marketing to optimize hotel operations. _PROMPT_TEMPLATE = """You work as a data analyst and in marketing to optimize hotel operations.
User cannot interact with you so do not ask questions. User cannot interact with you so do not ask questions.
@@ -16,7 +17,7 @@ _HOTEL_CONTEXT_TEMPLATE = """Hotel reference data (room types the bookings refer
{room_catalogue} {room_catalogue}
The column prize_per_nigth is the price the customer actually paid per night. The column prize_per_nigth is the price the customer actually paid per night.
The column revenue is nights x prize_per_nigth for non-cancelled bookings. The column revenue is nights x prize_per_nigth for non-cancelled bookings.
""" {holidays}"""
# Reasoning models (e.g. Qwen3) may wrap their internal reasoning in # Reasoning models (e.g. Qwen3) may wrap their internal reasoning in
# <think>...</think>; that content must never reach the API response. # <think>...</think>; that content must never reach the API response.
@@ -37,23 +38,17 @@ class LLMReportService:
model: str, model: str,
timeout_seconds: float, timeout_seconds: float,
metadata: HotelMetadata | None = None, metadata: HotelMetadata | None = None,
holiday_calendar: PublicHolidayCalendar | None = None,
) -> None: ) -> None:
self._metadata = metadata self._metadata = metadata
self._holiday_calendar = holiday_calendar
self._base_url = base_url.rstrip("/") self._base_url = base_url.rstrip("/")
self._api_key = api_key self._api_key = api_key
self._model = model self._model = model
self._timeout_seconds = timeout_seconds self._timeout_seconds = timeout_seconds
async def generate_report(self, descriptive_stats: dict[str, Any]) -> str: async def generate_report(self, descriptive_stats: dict[str, Any]) -> str:
hotel_context = ( prompt = self._build_prompt(descriptive_stats)
_HOTEL_CONTEXT_TEMPLATE.format(room_catalogue=self._metadata.describe())
if self._metadata
else ""
)
prompt = _PROMPT_TEMPLATE.format(
hotel_context=hotel_context,
descriptive_analysis_data=json.dumps(descriptive_stats, indent=2),
)
payload = { payload = {
"model": self._model, "model": self._model,
@@ -81,6 +76,32 @@ class LLMReportService:
return self._strip_thinking(raw_content) return self._strip_thinking(raw_content)
def _build_prompt(self, descriptive_stats: dict[str, Any]) -> str:
hotel_context = (
_HOTEL_CONTEXT_TEMPLATE.format(
room_catalogue=self._metadata.describe(),
holidays=self._holiday_section(descriptive_stats),
)
if self._metadata
else ""
)
return _PROMPT_TEMPLATE.format(
hotel_context=hotel_context,
descriptive_analysis_data=json.dumps(descriptive_stats, indent=2),
)
def _holiday_section(self, descriptive_stats: dict[str, Any]) -> str:
"""Public holidays for the years the bookings' arrival dates span."""
if self._holiday_calendar is None:
return ""
arrival = descriptive_stats.get("arrival_date", {})
try:
first_year = int(str(arrival["min"])[:4])
last_year = int(str(arrival["max"])[:4])
except (KeyError, ValueError):
return ""
return f"Public holidays:\n{self._holiday_calendar.describe(first_year, last_year)}\n"
@staticmethod @staticmethod
def _strip_thinking(content: str) -> str: def _strip_thinking(content: str) -> str:
return _THINK_BLOCK_PATTERN.sub("", content).strip() return _THINK_BLOCK_PATTERN.sub("", content).strip()
@@ -0,0 +1,62 @@
from dataclasses import dataclass
from datetime import date, timedelta
import holidays
@dataclass(frozen=True)
class HolidayPeriod:
"""A holiday, or a run of consecutive days with the same holiday name."""
name: str
start_date: date
end_date: date
def describe(self) -> str:
if self.start_date == self.end_date:
return f"{self.start_date.isoformat()}: {self.name}"
return f"{self.start_date.isoformat()} to {self.end_date.isoformat()}: {self.name}"
class PublicHolidayCalendar:
"""Public holidays looked up in the ``holidays`` package (not stored in metadata).
Defaults to Cambodia (``KH``), where the hotel is. ``data/holidays.json`` is
an export of :meth:`periods`, refreshed by ``scripts/update_holidays.py``.
"""
def __init__(self, country_code: str = "KH") -> None:
self._country_code = country_code
@property
def country_code(self) -> str:
return self._country_code
@staticmethod
def package_version() -> str:
return holidays.__version__
def for_years(self, first_year: int, last_year: int) -> dict[date, str]:
"""Return ``{day: holiday name}`` for every holiday from first to last year."""
calendar = holidays.country_holidays(
self._country_code, years=range(first_year, last_year + 1), language="en_US"
)
return dict(sorted(calendar.items()))
def name_on(self, day: date) -> str | None:
return holidays.country_holidays(self._country_code, years=day.year, language="en_US").get(day)
def periods(self, first_year: int, last_year: int) -> list[HolidayPeriod]:
"""Consecutive days with the same name are merged into one period."""
periods: list[HolidayPeriod] = []
for day, name in self.for_years(first_year, last_year).items():
last = periods[-1] if periods else None
if last and last.name == name and day - last.end_date == timedelta(days=1):
periods[-1] = HolidayPeriod(name, last.start_date, day)
else:
periods.append(HolidayPeriod(name, day, day))
return periods
def describe(self, first_year: int, last_year: int) -> str:
"""One line per holiday period, for the LLM prompt."""
return "\n".join(f"- {p.describe()}" for p in self.periods(first_year, last_year))
+1 -1
View File
@@ -109,6 +109,6 @@ def hotel_metadata() -> HotelMetadata:
currency="USD", currency="USD",
room_types={ room_types={
"A": RoomType(size="Small", standard_price_per_night=20, room_count=10), "A": RoomType(size="Small", standard_price_per_night=20, room_count=10),
"B": RoomType(size="Large", standard_price_per_night=25, room_count=5), "B": RoomType(size="Large", standard_price_per_night=25, room_count=10),
}, },
) )
+49
View File
@@ -0,0 +1,49 @@
"""Guards the bundled synthetic dataset against the hotel's real constraints."""
from pathlib import Path
import pandas as pd
import pytest
from nf_hotel_api.core.config import get_settings
from nf_hotel_api.repositories.booking_repository import CsvBookingRepository
from nf_hotel_api.repositories.metadata_repository import JsonHotelMetadataRepository
from nf_hotel_api.services.cleaning import DataCleaningService
@pytest.fixture(scope="module")
def metadata():
return JsonHotelMetadataRepository(get_settings().metadata_path).load()
@pytest.fixture(scope="module")
def clean_bookings(metadata) -> pd.DataFrame:
settings = get_settings()
raw = CsvBookingRepository(settings.default_data_path, settings.csv_separator).load()
return DataCleaningService(metadata).clean(raw)
def test_arrivals_are_within_2022_to_2025(clean_bookings):
assert clean_bookings["arrival_date"].min() >= pd.Timestamp(2022, 1, 1)
assert clean_bookings["arrival_date"].max() <= pd.Timestamp(2025, 12, 31)
def test_only_known_room_types_are_used(clean_bookings, metadata):
assert set(clean_bookings["assigned_room_type"]) <= set(metadata.room_types)
def test_hotel_is_never_overbooked(clean_bookings, metadata):
"""Non-cancelled bookings must fit in the rooms the hotel actually has."""
stays = clean_bookings[clean_bookings["is_canceled"] == 0]
for code, room in metadata.room_types.items():
of_type = stays[stays["assigned_room_type"] == code]
nights = (of_type["stays_in_weekend_nights"] + of_type["stays_in_week_nights"]).astype(int)
# +1 on arrival, -1 on the day after the last night; the running sum is the occupancy.
starts = of_type["arrival_date"].value_counts()
ends = (of_type["arrival_date"] + pd.to_timedelta(nights, unit="D")).value_counts()
change = starts.sub(ends, fill_value=0).sort_index()
assert change.cumsum().max() <= room.room_count, f"room type {code} overbooked"
def test_prices_are_present_and_non_negative(clean_bookings):
assert clean_bookings["prize_per_nigth"].notna().all()
assert (clean_bookings["prize_per_nigth"] >= 0).all()
+31
View File
@@ -78,3 +78,34 @@ async def test_generate_report_raises_llm_service_error_on_bad_response(monkeypa
with pytest.raises(LLMServiceError): with pytest.raises(LLMServiceError):
await service.generate_report({"adults": {"mean": 2}}) await service.generate_report({"adults": {"mean": 2}})
def _service_with_context(**kwargs):
from nf_hotel_api.domain.metadata import HotelMetadata, RoomType
from nf_hotel_api.services.public_holidays import PublicHolidayCalendar
metadata = HotelMetadata(
hotel="NF Hotel",
room_types={"A": RoomType(size="Small", standard_price_per_night=20, room_count=10)},
)
return LLMReportService(
base_url="http://fake/v1", api_key="k", model="m", timeout_seconds=1.0,
metadata=metadata, holiday_calendar=PublicHolidayCalendar(), **kwargs,
)
def test_prompt_lists_public_holidays_for_the_years_in_the_data():
stats = {"arrival_date": {"min": "2024-01-03T00:00:00", "max": "2024-12-30T00:00:00"}}
prompt = _service_with_context()._build_prompt(stats)
assert "Public holidays:" in prompt
assert "2024-04-13 to 2024-04-16" in prompt
assert "2023-" not in prompt.split("We have extract")[0]
def test_prompt_skips_holidays_when_arrival_dates_are_unknown():
prompt = _service_with_context()._build_prompt({"adults": {"mean": 2}})
assert "Public holidays" not in prompt
assert "Room type A: Small" in prompt
+52
View File
@@ -33,3 +33,55 @@ def test_describe_lists_prices_and_marks_unknown_room_counts():
assert "Room type A: Small, standard price 20 USD per night, number of rooms in hotel: 10" in text assert "Room type A: Small, standard price 20 USD per night, number of rooms in hotel: 10" in text
assert "Room type B: Large, standard price 25 USD per night, number of rooms in hotel: unknown" in text assert "Room type B: Large, standard price 25 USD per night, number of rooms in hotel: unknown" in text
def test_bundled_metadata_has_address_room_counts_and_events():
from nf_hotel_api.core.config import get_settings
metadata = JsonHotelMetadataRepository(get_settings().metadata_path).load()
assert str(metadata.address) == "Street 172, Phnom Penh, Cambodia"
assert metadata.room_types["A"].room_count == 10
assert metadata.room_types["B"].room_count == 10
assert all(e.source_url.startswith("https://") for e in metadata.nearby_events)
def test_bundled_metadata_does_not_store_public_holidays():
from nf_hotel_api.core.config import get_settings
assert "public_holidays" not in get_settings().metadata_path.read_text(encoding="utf-8")
def test_event_rejects_end_before_start():
from datetime import date
from nf_hotel_api.domain.metadata import NearbyEvent
with pytest.raises(ValueError):
NearbyEvent(
name="Bad", start_date=date(2024, 5, 2), end_date=date(2024, 5, 1),
venue="x", source_url="https://example.com",
)
def test_describe_includes_address_and_events():
from datetime import date
from nf_hotel_api.domain.metadata import Address, NearbyEvent
metadata = HotelMetadata(
hotel="NF Hotel",
address=Address(street="Street 172", city="Phnom Penh", country="Cambodia"),
room_types={"A": RoomType(size="Small", standard_price_per_night=20, room_count=10)},
nearby_events=[
NearbyEvent(
name="Trade fair", start_date=date(2024, 11, 6), end_date=date(2024, 11, 6),
venue="DIECC", source_url="https://example.com",
)
],
)
text = metadata.describe()
assert "Street 172, Phnom Penh, Cambodia" in text
assert "2024-11-06: Trade fair (DIECC)" in text
+65
View File
@@ -0,0 +1,65 @@
from datetime import date
from nf_hotel_api.services.public_holidays import PublicHolidayCalendar
def test_for_years_covers_the_requested_years_in_date_order():
result = PublicHolidayCalendar().for_years(2022, 2023)
assert list(result) == sorted(result)
assert {d.year for d in result} == {2022, 2023}
def test_khmer_new_year_and_water_festival_2024_match_the_official_calendar():
calendar = PublicHolidayCalendar().for_years(2024, 2024)
for day in (date(2024, 4, 13), date(2024, 4, 16)):
assert "Khmer New Year" in calendar[day]
for day in (date(2024, 11, 14), date(2024, 11, 16)):
assert "Water Festival" in calendar[day]
assert "Peace Day" in calendar[date(2024, 12, 29)]
def test_name_on_returns_none_for_a_normal_day():
calendar = PublicHolidayCalendar()
assert calendar.name_on(date(2024, 8, 14)) is None
assert "Pchum Ben" in calendar.name_on(date(2025, 9, 22))
def test_describe_merges_consecutive_days_into_a_range():
text = PublicHolidayCalendar().describe(2024, 2024)
assert "- 2024-04-13 to 2024-04-16: Khmer New Year's Day" in text
assert "- 2024-11-14 to 2024-11-16: Water Festival" in text
assert "- 2024-01-01: International New Year Day" in text
def test_holidays_json_is_an_up_to_date_export_of_the_package():
"""Fails when the `holidays` package changed: re-run scripts/update_holidays.py."""
import json
from pathlib import Path
document = json.loads(
(Path(__file__).resolve().parents[1] / "data" / "holidays.json").read_text(encoding="utf-8")
)
calendar = PublicHolidayCalendar(document["country"])
expected = [
{"name": p.name, "start_date": p.start_date.isoformat(), "end_date": p.end_date.isoformat()}
for p in calendar.periods(document["first_year"], document["last_year"])
]
assert document["holidays"] == expected
def test_holidays_json_covers_the_booking_years():
import json
from pathlib import Path
document = json.loads(
(Path(__file__).resolve().parents[1] / "data" / "holidays.json").read_text(encoding="utf-8")
)
assert document["first_year"] <= 2022
assert document["last_year"] >= 2025