Initial project setup: NF Hotel Data Analysis API

- 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
This commit is contained in:
2026-09-21 13:17:49 +08:00
commit 225d28feb7
39 changed files with 41693 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
import os
# Force (not setdefault): an API_KEY already set in the shell/IDE/.env must not
# leak into the tests, or the authenticated API tests fail with 401.
os.environ["API_KEY"] = "test-api-key"
import pandas as pd
import pytest
from nf_hotel_api.domain.metadata import HotelMetadata, RoomType
@pytest.fixture
def dirty_bookings_df() -> pd.DataFrame:
"""A small, deliberately messy DataFrame exercising every cleaning rule."""
return pd.DataFrame(
[
{
"booking_id": 1,
"hotel": "NF Hotel",
"is_canceled": 0,
"lead_time": "10",
"arrival_date_week_number": 1,
"booking_date": "2024-01-01",
"arrival_date": "2024-02-01",
"arrival_date_day_of_month": 1,
"stays_in_weekend_nights": 1,
"stays_in_week_nights": 2,
"adults": 2,
"children": 0,
"babies": 0,
"meal": "BB",
"country": "Portugal",
"market_segment": "Direct",
"is_repeated_guest": 0,
"previous_cancellations": 0,
"assigned_room_type": "A",
"booking_changes": 0,
"deposit_type": "No Deposit",
"agent": 0,
"customer_type": "Contract (Single)",
"required_car_parking_spaces": 0,
"total_of_special_requests": 0,
},
{
# Exact duplicate of booking 1 except the id -> must be removed.
"booking_id": 2,
"hotel": "NF Hotel",
"is_canceled": 0,
"lead_time": "10",
"arrival_date_week_number": 1,
"booking_date": "2024-01-01",
"arrival_date": "2024-02-01",
"arrival_date_day_of_month": 1,
"stays_in_weekend_nights": 1,
"stays_in_week_nights": 2,
"adults": 2,
"children": 0,
"babies": 0,
"meal": "BB",
"country": "Portugal",
"market_segment": "Direct",
"is_repeated_guest": 0,
"previous_cancellations": 0,
"assigned_room_type": "A",
"booking_changes": 0,
"deposit_type": "No Deposit",
"agent": 0,
"customer_type": "Contract (Single)",
"required_car_parking_spaces": 0,
"total_of_special_requests": 0,
},
{
# Wrong/empty data: bogus adults count, blank meal, zero guests overall.
"booking_id": 3,
"hotel": "NF Hotel",
"is_canceled": 1,
"lead_time": -5,
"arrival_date_week_number": 2,
"booking_date": "2024-01-05",
"arrival_date": "not-a-date",
"arrival_date_day_of_month": 5,
"stays_in_weekend_nights": 0,
"stays_in_week_nights": 1,
"adults": 55,
"children": 0,
"babies": 0,
"meal": "",
"country": "Unknown",
"market_segment": "Groups",
"is_repeated_guest": 0,
"previous_cancellations": 0,
"assigned_room_type": "C",
"booking_changes": 0,
"deposit_type": "Non Refund",
"agent": 9,
"customer_type": "Group Contract",
"required_car_parking_spaces": 0,
"total_of_special_requests": 1,
},
]
)
@pytest.fixture
def hotel_metadata() -> HotelMetadata:
return HotelMetadata(
hotel="NF Hotel",
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),
},
)
+56
View File
@@ -0,0 +1,56 @@
from fastapi.testclient import TestClient
from nf_hotel_api.api.deps import get_llm_service
from nf_hotel_api.main import app
API_KEY = "test-api-key"
class _FakeLLMService:
async def generate_report(self, descriptive_stats: dict) -> str:
return "## Fake Report"
app.dependency_overrides[get_llm_service] = lambda: _FakeLLMService()
client = TestClient(app)
def test_health_check_is_public():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_report_endpoint_rejects_missing_api_key():
response = client.post("/api/v1/report/from-file")
assert response.status_code == 401
def test_report_endpoint_rejects_wrong_api_key():
response = client.post(
"/api/v1/report/from-file", headers={"X-API-Key": "wrong-key"}
)
assert response.status_code == 401
def test_report_from_file_returns_stats_and_llm_report():
response = client.post(
"/api/v1/report/from-file", headers={"X-API-Key": API_KEY}
)
assert response.status_code == 200
body = response.json()
assert "booking_id" not in body["descriptive_stats"]
assert body["llm_report"] == "## Fake Report"
def test_report_from_json_returns_stats_and_llm_report(dirty_bookings_df):
records = dirty_bookings_df.to_dict(orient="records")
response = client.post(
"/api/v1/report/from-json",
headers={"X-API-Key": API_KEY},
json={"records": records},
)
assert response.status_code == 200
body = response.json()
assert "booking_id" not in body["descriptive_stats"]
assert body["llm_report"] == "## Fake Report"
+107
View File
@@ -0,0 +1,107 @@
import pandas as pd
from nf_hotel_api.services.cleaning import DataCleaningService
def test_clean_removes_duplicate_bookings(dirty_bookings_df, hotel_metadata):
result = DataCleaningService(hotel_metadata).clean(dirty_bookings_df)
# booking 2 is a duplicate of booking 1 (ignoring booking_id) and must go.
assert result["booking_id"].tolist() == [1]
def test_clean_coerces_dates_and_drops_unparseable_rows(hotel_metadata):
df = pd.DataFrame(
[
{"booking_id": 1, "booking_date": "2024-01-01", "arrival_date": "2024-02-01"},
{"booking_id": 2, "booking_date": "2024-01-01", "arrival_date": "garbage"},
]
)
result = DataCleaningService(hotel_metadata)._fix_wrong_format(df)
assert pd.api.types.is_datetime64_any_dtype(result["arrival_date"])
cleaned = DataCleaningService(hotel_metadata)._clean_empty_cells(result)
assert cleaned["booking_id"].tolist() == [1]
def test_clean_replaces_blank_categoricals_with_unknown_placeholder(hotel_metadata):
df = pd.DataFrame([{"hotel": "NF Hotel", "meal": "", "country": "Portugal"}])
result = DataCleaningService(hotel_metadata)._clean_empty_cells(df)
assert result.loc[0, "meal"] == "Unknown"
assert result.loc[0, "country"] == "Portugal"
def test_clean_caps_implausible_guest_counts_and_fixes_zero_guest_rows(hotel_metadata):
df = pd.DataFrame(
[
{"adults": 55, "children": 0, "babies": 0},
{"adults": 0, "children": 0, "babies": 0},
]
)
result = DataCleaningService(hotel_metadata)._fix_wrong_data(df)
assert result.loc[0, "adults"] <= 10
assert result.loc[1, "adults"] == 1
def test_clean_parses_day_first_and_iso_dates_without_dropping_rows(hotel_metadata):
df = pd.DataFrame(
[
{"booking_id": 1, "booking_date": "13-07-2018", "arrival_date": "02-06-2018"},
{"booking_id": 2, "booking_date": "2018-07-14", "arrival_date": "2018-06-03"},
]
)
result = DataCleaningService(hotel_metadata).clean(df)
assert result["booking_id"].tolist() == [1, 2]
assert result.loc[0, "booking_date"] == pd.Timestamp(2018, 7, 13)
assert result.loc[0, "arrival_date"] == pd.Timestamp(2018, 6, 2)
assert result.loc[1, "arrival_date"] == pd.Timestamp(2018, 6, 3)
def test_add_pricing_keeps_price_paid_and_fills_missing_from_standard_price(hotel_metadata):
df = pd.DataFrame(
[
{"assigned_room_type": "A", "prize_per_nigth": 15}, # discounted, kept
{"assigned_room_type": "B", "prize_per_nigth": None}, # missing -> 25
{"assigned_room_type": "A", "prize_per_nigth": -3}, # invalid -> 20
{"assigned_room_type": "A", "prize_per_nigth": 0}, # free stay, kept
{"assigned_room_type": "C", "prize_per_nigth": None}, # not in metadata
]
)
result = DataCleaningService(hotel_metadata)._add_pricing(df)
assert result["prize_per_nigth"].iloc[:4].tolist() == [15, 25, 20, 0]
assert pd.isna(result.loc[4, "prize_per_nigth"])
assert result["room_size"].tolist() == ["Small", "Large", "Small", "Small", "Unknown"]
def test_add_pricing_revenue_is_nights_times_price_paid_and_zero_when_canceled(hotel_metadata):
df = pd.DataFrame(
[
{"assigned_room_type": "A", "is_canceled": 0, "prize_per_nigth": 15,
"stays_in_weekend_nights": 1, "stays_in_week_nights": 2},
{"assigned_room_type": "B", "is_canceled": 0,
"stays_in_weekend_nights": 0, "stays_in_week_nights": 3},
{"assigned_room_type": "B", "is_canceled": 1,
"stays_in_weekend_nights": 2, "stays_in_week_nights": 2},
]
)
result = DataCleaningService(hotel_metadata)._add_pricing(df)
assert result["revenue"].tolist() == [45, 75, 0]
def test_clean_adds_pricing_columns_to_full_pipeline(dirty_bookings_df, hotel_metadata):
result = DataCleaningService(hotel_metadata).clean(dirty_bookings_df)
assert result.loc[0, "prize_per_nigth"] == 20
assert result.loc[0, "room_size"] == "Small"
assert result.loc[0, "revenue"] == 60 # 3 nights x $20
+80
View File
@@ -0,0 +1,80 @@
import httpx
import pytest
from nf_hotel_api.services.llm_report import LLMReportService, LLMServiceError
def test_strip_thinking_removes_think_block():
raw = "<think>internal reasoning that must not leak</think>## Report\nBody text"
assert LLMReportService._strip_thinking(raw) == "## Report\nBody text"
def test_strip_thinking_is_noop_when_no_think_block():
raw = "## Report\nBody text"
assert LLMReportService._strip_thinking(raw) == raw
class _FakeResponse:
def __init__(self, payload: dict, status_code: int = 200) -> None:
self._payload = payload
self.status_code = status_code
def raise_for_status(self) -> None:
if self.status_code >= 400:
request = httpx.Request("POST", "http://fake/chat/completions")
raise httpx.HTTPStatusError(
"error", request=request, response=httpx.Response(self.status_code, request=request)
)
def json(self) -> dict:
return self._payload
class _FakeAsyncClient:
def __init__(self, payload: dict, status_code: int = 200) -> None:
self._payload = payload
self._status_code = status_code
async def __aenter__(self) -> "_FakeAsyncClient":
return self
async def __aexit__(self, *args) -> None:
return None
async def post(self, *args, **kwargs) -> _FakeResponse:
return _FakeResponse(self._payload, self._status_code)
@pytest.mark.asyncio
async def test_generate_report_strips_thinking_and_returns_content(monkeypatch):
payload = {
"choices": [
{"message": {"content": "<think>hidden</think>## Insights\nBook more direct."}}
]
}
monkeypatch.setattr(
"nf_hotel_api.services.llm_report.httpx.AsyncClient",
lambda timeout: _FakeAsyncClient(payload),
)
service = LLMReportService(
base_url="http://fake/v1", api_key="k", model="m", timeout_seconds=1.0
)
report = await service.generate_report({"adults": {"mean": 2}})
assert report == "## Insights\nBook more direct."
@pytest.mark.asyncio
async def test_generate_report_raises_llm_service_error_on_bad_response(monkeypatch):
monkeypatch.setattr(
"nf_hotel_api.services.llm_report.httpx.AsyncClient",
lambda timeout: _FakeAsyncClient({"unexpected": "shape"}),
)
service = LLMReportService(
base_url="http://fake/v1", api_key="k", model="m", timeout_seconds=1.0
)
with pytest.raises(LLMServiceError):
await service.generate_report({"adults": {"mean": 2}})
+35
View File
@@ -0,0 +1,35 @@
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
+28
View File
@@ -0,0 +1,28 @@
import pytest
from nf_hotel_api.repositories.booking_repository import JsonBookingRepository
from nf_hotel_api.services.cleaning import DataCleaningService
from nf_hotel_api.services.report import ReportService
from nf_hotel_api.services.statistics import DescriptiveStatsService
class _FakeLLMService:
async def generate_report(self, descriptive_stats: dict) -> str:
assert "booking_id" not in descriptive_stats
return "## Fake Report"
@pytest.mark.asyncio
async def test_generate_produces_stats_and_llm_report(dirty_bookings_df, hotel_metadata):
repository = JsonBookingRepository(dirty_bookings_df.to_dict(orient="records"))
service = ReportService(
cleaning_service=DataCleaningService(hotel_metadata),
stats_service=DescriptiveStatsService(),
llm_service=_FakeLLMService(),
)
result = await service.generate(repository)
assert result.llm_report == "## Fake Report"
assert "adults" in result.descriptive_stats
assert "booking_id" not in result.descriptive_stats
+27
View File
@@ -0,0 +1,27 @@
import json
from nf_hotel_api.services.cleaning import DataCleaningService
from nf_hotel_api.services.statistics import DescriptiveStatsService
def test_compute_drops_booking_id(dirty_bookings_df, hotel_metadata):
clean_df = DataCleaningService(hotel_metadata).clean(dirty_bookings_df)
stats = DescriptiveStatsService().compute(clean_df)
assert "booking_id" not in stats
def test_compute_output_is_json_serializable(dirty_bookings_df, hotel_metadata):
clean_df = DataCleaningService(hotel_metadata).clean(dirty_bookings_df)
stats = DescriptiveStatsService().compute(clean_df)
# Must not raise: every value has to be a plain JSON-compatible type.
json.dumps(stats)
def test_compute_includes_numeric_and_categorical_columns(dirty_bookings_df, hotel_metadata):
clean_df = DataCleaningService(hotel_metadata).clean(dirty_bookings_df)
stats = DescriptiveStatsService().compute(clean_df)
assert "mean" in stats["adults"]
assert "top" in stats["meal"] or "unique" in stats["meal"]