From 3af41c06abb7c96f21ceb84a62246181860f2b5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 8 Oct 2026 10:49:38 +0200 Subject: [PATCH] feat: let get_trip resolve a date code to its trip and date --- docs/chatbot-mcp.md | 2 +- src/Chatbot/Mcp/Tool/GetTripTool.php | 6 +++--- src/Chatbot/TripCatalog.php | 28 +++++++++++++++++++++++++--- tests/Chatbot/TripCatalogTest.php | 18 ++++++++++++++++-- 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/docs/chatbot-mcp.md b/docs/chatbot-mcp.md index d1738e6e..5c0416de 100644 --- a/docs/chatbot-mcp.md +++ b/docs/chatbot-mcp.md @@ -35,7 +35,7 @@ that prices and availability are a snapshot (`dataAsOf`) and only the booking pa | Tool | Use | Returns | |---|---|---| | `search_trips` | Which trips exist, when, where, how much | Up to 10 trips with their next 3 matching dates, `priceFrom`, availability, `dataAsOf` | -| `get_trip` | One trip by `code` (optionally `hotelCode`) | Product text with FAQs, hotels with facts, all upcoming dates with bookable rooms, pickups for the first 3 dates | +| `get_trip` | One trip by product `code`, or one date of it by date code such as `SSLMP061226` (optionally `hotelCode`) | Product text with FAQs, hotels with facts, all upcoming dates with bookable rooms, pickups for the first 3 dates | | `search_content` | Questions not about dates or prices | Up to 8 hits with a ~600-character excerpt | | `get_document` | The full text of one `search_content` hit, by `url` | Markdown body, truncated at 20 000 characters | diff --git a/src/Chatbot/Mcp/Tool/GetTripTool.php b/src/Chatbot/Mcp/Tool/GetTripTool.php index 46d4b1aa..1cb8565f 100644 --- a/src/Chatbot/Mcp/Tool/GetTripTool.php +++ b/src/Chatbot/Mcp/Tool/GetTripTool.php @@ -14,7 +14,7 @@ use Mcp\Schema\ToolAnnotations; #[McpTool( name: 'get_trip', - description: 'Full details of one trip: description, included services, programme, hotel, all upcoming dates with rooms, prices per person in EUR and availability, pickup points and departure times, FAQs. Use after search_trips, or when the user names a specific trip. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.', + description: 'Full details of one trip: description, included services, programme, hotel, all upcoming dates with rooms, prices per person in EUR and availability, pickup points and departure times, FAQs. Use after search_trips, or when the user names a specific trip or a booked date (date codes such as SSLMP061226 narrow the answer to that date). Prices and availability are a snapshot as of dataAsOf; the booking page is binding.', annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false), )] final class GetTripTool @@ -26,7 +26,7 @@ final class GetTripTool } public function __invoke( - #[Schema(description: 'Trip code, as returned by search_trips.', minLength: 1)] + #[Schema(description: 'Trip code as returned by search_trips, or a date code (dateCode) for one date of a trip.', minLength: 1)] string $code, #[Schema(description: 'Narrows the dates to one hotel when a trip has several.')] ?string $hotelCode = null, @@ -35,7 +35,7 @@ final class GetTripTool $trip = $this->catalog->get(trim($code), $hotelCode); if (null === $trip) { - throw new ToolCallException(sprintf('No trip with code "%s". Use search_trips to find trip codes.', $code)); + throw new ToolCallException(sprintf('No trip or date with code "%s". Use search_trips to find trip codes.', $code)); } return $trip; diff --git a/src/Chatbot/TripCatalog.php b/src/Chatbot/TripCatalog.php index aaae0557..cfe2a35e 100644 --- a/src/Chatbot/TripCatalog.php +++ b/src/Chatbot/TripCatalog.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Chatbot; +use App\BusProNet\Utility\DateCodeUtility; use App\Chatbot\Search\ContentSearchQuery; use App\Entity\ContentDocument; use App\Model\Chatbot\TripCriteria; @@ -79,6 +80,7 @@ class TripCatalog $current = $dates[$dateId] ?? null; $dates[$dateId] = [ + 'dateCode' => $row['date']['code'], 'dateFrom' => $row['date']['from'], 'dateTo' => $row['date']['to'], 'priceFrom' => null === $current ? $summary['priceFrom'] : $this->minPrice($current['priceFrom'], $summary['priceFrom']), @@ -107,12 +109,29 @@ class TripCatalog /** * Everything about one trip, or null for a code no published product carries. * + * Takes a product code or a date code. A date code is what customers read on their + * booking documents (SSLMP061226 is product SSLMP on 06.12.26); it narrows the trip to + * that date, shown even when it has already started. + * * @return array|null */ public function get(string $code, ?string $hotelCode = null): ?array { $catalog = $this->load(); - $products = $catalog['productsByCode'][$this->normalize($code)] ?? []; + $code = (new DateCodeUtility())->sanitize(trim($code)); + $dateCode = null; + + if (!isset($catalog['productsByCode'][$code])) { + foreach ($catalog['export']->rows as $row) { + if ($this->normalize($row['date']['code'] ?? null) === $code) { + $dateCode = $code; + $code = $this->normalize($row['product']['code'] ?? null); + break; + } + } + } + + $products = $catalog['productsByCode'][$code] ?? []; if ([] === $products) { return null; @@ -123,8 +142,10 @@ class TripCatalog $today = $this->today(); $rows = array_values(array_filter( - $catalog['rowsByCode'][$this->normalize($code)] ?? [], - fn (array $row): bool => $row['date']['from'] >= $today, + $catalog['rowsByCode'][$code] ?? [], + fn (array $row): bool => null === $dateCode + ? $row['date']['from'] >= $today + : $this->normalize($row['date']['code'] ?? null) === $dateCode, )); if (null !== $hotelCode && '' !== trim($hotelCode)) { @@ -545,6 +566,7 @@ class TripCatalog } $dates[$dateId] ??= [ + 'dateCode' => $row['date']['code'], 'dateFrom' => $row['date']['from'], 'dateTo' => $row['date']['to'], 'hotels' => [], diff --git a/tests/Chatbot/TripCatalogTest.php b/tests/Chatbot/TripCatalogTest.php index 10842530..03902b19 100644 --- a/tests/Chatbot/TripCatalogTest.php +++ b/tests/Chatbot/TripCatalogTest.php @@ -33,8 +33,8 @@ class TripCatalogTest extends TestCase self::assertSame(['country' => 'Frankreich'], $trip['destination']); self::assertSame(['Hotel Eins'], $trip['hotels']); self::assertSame([ - ['dateFrom' => '2026-12-05', 'dateTo' => '2026-12-12', 'priceFrom' => 450.0, 'availability' => 'frei'], - ['dateFrom' => '2027-01-09', 'dateTo' => '2027-01-16', 'priceFrom' => 520.0, 'availability' => 'auf Anfrage'], + ['dateCode' => 'VTH2', 'dateFrom' => '2026-12-05', 'dateTo' => '2026-12-12', 'priceFrom' => 450.0, 'availability' => 'frei'], + ['dateCode' => 'VTH3', 'dateFrom' => '2027-01-09', 'dateTo' => '2027-01-16', 'priceFrom' => 520.0, 'availability' => 'auf Anfrage'], ], $trip['nextDates']); self::assertSame('2026-10-08T06:00:00+00:00', $result['dataAsOf']); } @@ -93,6 +93,20 @@ class TripCatalogTest extends TestCase self::assertSame('GRP', $this->catalog($rows)->get('GRP')['code'] ?? null); } + public function testGetTakesADateCodeAndNarrowsToThatDate(): void + { + $catalog = $this->catalog([ + $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450)]), + $this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(520)]), + ]); + + $trip = $catalog->get('vth-3'); + + self::assertSame('VTH', $trip['code'] ?? null); + self::assertSame(['VTH3'], array_column($trip['dates'], 'dateCode')); + self::assertNull($catalog->get('VTH9')); + } + public function testGetIsNullForACodeWithoutAPublishedProduct(): void { self::assertNull($this->catalog([$this->row('XYZ', 2, '2026-12-05', 'HOT1', [$this->room(1)])])->get('XYZ'));