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
+1 -1
View File
@@ -109,6 +109,6 @@ def hotel_metadata() -> HotelMetadata:
currency="USD",
room_types={
"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):
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
+87 -35
View File
@@ -1,35 +1,87 @@
import pytest
from nf_hotel_api.domain.metadata import HotelMetadata, RoomType
from nf_hotel_api.repositories.metadata_repository import JsonHotelMetadataRepository
def test_bundled_metadata_file_defines_small_and_large_room_prices():
from nf_hotel_api.core.config import get_settings
metadata = JsonHotelMetadataRepository(get_settings().metadata_path).load()
assert metadata.room_types["A"].size == "Small"
assert metadata.room_types["A"].standard_price_per_night == 20
assert metadata.room_types["B"].size == "Large"
assert metadata.room_types["B"].standard_price_per_night == 25
def test_repository_raises_when_file_is_missing(tmp_path):
with pytest.raises(FileNotFoundError):
JsonHotelMetadataRepository(tmp_path / "nope.json").load()
def test_describe_lists_prices_and_marks_unknown_room_counts():
metadata = HotelMetadata(
hotel="NF Hotel",
room_types={
"A": RoomType(size="Small", standard_price_per_night=20, room_count=10),
"B": RoomType(size="Large", standard_price_per_night=25),
},
)
text = metadata.describe()
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
import pytest
from nf_hotel_api.domain.metadata import HotelMetadata, RoomType
from nf_hotel_api.repositories.metadata_repository import JsonHotelMetadataRepository
def test_bundled_metadata_file_defines_small_and_large_room_prices():
from nf_hotel_api.core.config import get_settings
metadata = JsonHotelMetadataRepository(get_settings().metadata_path).load()
assert metadata.room_types["A"].size == "Small"
assert metadata.room_types["A"].standard_price_per_night == 20
assert metadata.room_types["B"].size == "Large"
assert metadata.room_types["B"].standard_price_per_night == 25
def test_repository_raises_when_file_is_missing(tmp_path):
with pytest.raises(FileNotFoundError):
JsonHotelMetadataRepository(tmp_path / "nope.json").load()
def test_describe_lists_prices_and_marks_unknown_room_counts():
metadata = HotelMetadata(
hotel="NF Hotel",
room_types={
"A": RoomType(size="Small", standard_price_per_night=20, room_count=10),
"B": RoomType(size="Large", standard_price_per_night=25),
},
)
text = metadata.describe()
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
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