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:
+1
-1
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user