Various improvements

This commit is contained in:
Edward Betts 2026-07-09 10:12:57 +01:00
parent b7d95f26ec
commit 442ae98463
3 changed files with 100 additions and 3 deletions

View file

@ -37,6 +37,8 @@ class BookingConfig:
booking_type: str booking_type: str
yaml_filename: str yaml_filename: str
spec_heading: str spec_heading: str
start_field: str
excluded_top_level_keys: tuple[str, ...]
json_key: str = "booking" json_key: str = "booking"
@ -45,11 +47,22 @@ BOOKING_CONFIGS: dict[str, BookingConfig] = {
booking_type="flight", booking_type="flight",
yaml_filename="flights.yaml", yaml_filename="flights.yaml",
spec_heading="flights.yaml", spec_heading="flights.yaml",
start_field="depart",
excluded_top_level_keys=("trip",),
), ),
"train": BookingConfig( "train": BookingConfig(
booking_type="train", booking_type="train",
yaml_filename="trains.yaml", yaml_filename="trains.yaml",
spec_heading="trains.yaml", spec_heading="trains.yaml",
start_field="depart",
excluded_top_level_keys=("trip",),
),
"accommodation": BookingConfig(
booking_type="accommodation",
yaml_filename="accommodation.yaml",
spec_heading="accommodation.yaml",
start_field="from",
excluded_top_level_keys=("trip", "latitude", "longitude"),
), ),
} }
@ -109,6 +122,7 @@ def build_prompt(
bookings = current_bookings bookings = current_bookings
if bookings is None: if bookings is None:
bookings = read_existing_bookings(config) bookings = read_existing_bookings(config)
excluded_keys = ", ".join(f'"{key}"' for key in config.excluded_top_level_keys)
return f""" return f"""
I keep a record of all my {config.booking_type} bookings in a YAML file. I keep a record of all my {config.booking_type} bookings in a YAML file.
@ -131,7 +145,7 @@ Rules:
- Wrap the response in a JSON object with a single key "{config.json_key}" that - Wrap the response in a JSON object with a single key "{config.json_key}" that
contains the booking in YAML. contains the booking in YAML.
- The value of "{config.json_key}" must be YAML text, not JSON. - The value of "{config.json_key}" must be YAML text, not JSON.
- Exclude the top-level "trip" key from the YAML. - Exclude these top-level keys from the YAML: {excluded_keys}.
- Do not invent details that are not present in the booking text. - Do not invent details that are not present in the booking text.
- Quote prices and identifiers that might otherwise be parsed as numbers. - Quote prices and identifiers that might otherwise be parsed as numbers.
@ -218,7 +232,7 @@ def first_departure(booking: dict[str, typing.Any], config: BookingConfig) -> da
assert isinstance(first_flight, dict) assert isinstance(first_flight, dict)
return datetime_from_yaml_value(first_flight["depart"]) return datetime_from_yaml_value(first_flight["depart"])
return datetime_from_yaml_value(booking["depart"]) return datetime_from_yaml_value(booking[config.start_field])
def comparable_departure( def comparable_departure(
@ -246,6 +260,13 @@ def trip_key_position(booking: dict[str, typing.Any], config: BookingConfig) ->
return keys.index("booking_reference") + 1 return keys.index("booking_reference") + 1
return 0 return 0
if config.booking_type == "accommodation":
if "country" in booking:
return keys.index("country") + 1
if "location" in booking:
return keys.index("location") + 1
return 0
if "to" in booking: if "to" in booking:
return keys.index("to") + 1 return keys.index("to") + 1
if "from" in booking: if "from" in booking:
@ -455,3 +476,8 @@ def train_main(argv: list[str] | None = None) -> int:
def flight_main(argv: list[str] | None = None) -> int: def flight_main(argv: list[str] | None = None) -> int:
"""CLI entrypoint for flight booking YAML generation.""" """CLI entrypoint for flight booking YAML generation."""
return main_for_type("flight", argv) return main_for_type("flight", argv)
def accommodation_main(argv: list[str] | None = None) -> int:
"""CLI entrypoint for accommodation booking YAML generation."""
return main_for_type("accommodation", argv)

View file

@ -13,6 +13,7 @@ This document describes the YAML files read from `../personal-data/`. It is inte
- Currencies must be in `config.CURRENCIES` or `GBP`. - Currencies must be in `config.CURRENCIES` or `GBP`.
- Travel and trip-related entries are grouped by the `trip` date. That date should match an entry in `trips.yaml` when a named trip is needed, but trip groups can also be created from travel/accommodation/conference entries. - Travel and trip-related entries are grouped by the `trip` date. That date should match an entry in `trips.yaml` when a named trip is needed, but trip groups can also be created from travel/accommodation/conference entries.
- Keep chronological files sorted by their natural start field. `validate_yaml.py` checks ordering for trips, flights, trains, ferries, conferences, and accommodation. - Keep chronological files sorted by their natural start field. `validate_yaml.py` checks ordering for trips, flights, trains, ferries, conferences, and accommodation.
- Preserve the existing whitespace style. Long top-level list files such as `accommodation.yaml`, `buses.yaml`, `car_journeys.yaml`, `coaches.yaml`, `conferences.yaml`, `ferries.yaml`, `flights.yaml`, `stations.yaml`, `trains.yaml`, and `trips.yaml` use one blank line between top-level items. Mapping files such as `airports.yaml` do not use this list-item spacing.
- Coordinates are `latitude` then `longitude`, both numeric. - Coordinates are `latitude` then `longitude`, both numeric.
## Cross-File References ## Cross-File References

View file

@ -40,10 +40,25 @@ def test_build_prompt_uses_spec_and_keeps_trip_exclusion() -> None:
assert "Use this YAML format specification" in prompt assert "Use this YAML format specification" in prompt
assert "## `trains.yaml`" in prompt assert "## `trains.yaml`" in prompt
assert 'Exclude the top-level "trip" key' in prompt assert 'Exclude these top-level keys from the YAML: "trip"' in prompt
assert "Eurostar booking details" in prompt assert "Eurostar booking details" in prompt
def test_build_prompt_for_accommodation_excludes_coordinates() -> None:
"""Accommodation prompt should exclude trip and coordinate fields."""
prompt = generate_booking_yaml.build_prompt(
"Airbnb booking details",
generate_booking_yaml.BOOKING_CONFIGS["accommodation"],
current_bookings="- type: airbnb\n",
)
assert "## `accommodation.yaml`" in prompt
assert (
'Exclude these top-level keys from the YAML: "trip", "latitude", "longitude"'
in prompt
)
def test_booking_text_from_args_fetches_url(monkeypatch: pytest.MonkeyPatch) -> None: def test_booking_text_from_args_fetches_url(monkeypatch: pytest.MonkeyPatch) -> None:
"""URL arguments should be fetched instead of reading stdin.""" """URL arguments should be fetched instead of reading stdin."""
monkeypatch.setattr( monkeypatch.setattr(
@ -185,3 +200,58 @@ flights:
assert [item["booking_reference"] for item in written] == ["OLD", "MID", "NEWER"] assert [item["booking_reference"] for item in written] == ["OLD", "MID", "NEWER"]
assert written[1]["trip"] == date(2026, 2, 6) assert written[1]["trip"] == date(2026, 2, 6)
assert list(written[1]).index("trip") == 1 assert list(written[1]).index("trip") == 1
def test_import_accommodation_booking_adds_trip_and_inserts_chronologically(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Generated accommodation should be written into accommodation.yaml in order."""
(tmp_path / "accommodation.yaml").write_text("""---
- type: hotel
name: Earlier Hotel
location: Brussels
country: be
trip: 2026-02-01
from: 2026-02-01 15:00:00+01:00
to: 2026-02-02 11:00:00+01:00
- type: hotel
name: Later Hotel
location: Paris
country: fr
trip: 2026-02-10
from: 2026-02-10 15:00:00+01:00
to: 2026-02-11 11:00:00+01:00
""")
monkeypatch.setattr(
generate_booking_yaml,
"matching_trip_date",
lambda depart, data_dir: date(2026, 2, 6),
)
count = generate_booking_yaml.import_booking_yaml(
"""type: airbnb
name: Paris Airbnb
location: Paris
country: fr
from: 2026-02-06 15:00:00+01:00
to: 2026-02-09 11:00:00+01:00
price: '300.00'
currency: EUR
""",
generate_booking_yaml.BOOKING_CONFIGS["accommodation"],
data_dir=tmp_path,
)
text = (tmp_path / "accommodation.yaml").read_text()
written = yaml.safe_load(text)
assert count == 1
assert [item["name"] for item in written] == [
"Earlier Hotel",
"Paris Airbnb",
"Later Hotel",
]
assert written[1]["trip"] == date(2026, 2, 6)
assert list(written[1]).index("trip") == 4
assert "\n\n- type: airbnb\n" in text