feat: turn search_trips into the website's travel search

This commit is contained in:
fromme committed 2026-10-08 13:14:12 +02:00
1 parent bfdedefae3
commit 4ab2714bde
12 files changed
+693 -151

No files matched your search

+66 -16
View File
@@ -6,7 +6,10 @@ namespace App\Chatbot\Mcp\Tool;
use App\Chatbot\Mcp\Sites;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\TravelPeriods;
use App\Chatbot\TripCatalog;
use App\Enum\HotelType;
use App\Enum\RoomType;
use App\Model\Chatbot\TripCriteria;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
@@ -16,13 +19,14 @@ use Mcp\Schema\ToolAnnotations;
#[McpTool(
name: 'search_trips',
description: 'Find bookable trips by structured criteria: travel period, destination, trip type, price, group size. Use for any question about which trips exist, when, where, or how much. Returns trips with their next dates and lowest prices per person in EUR. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.',
description: 'The website\'s travel search: find bookable trips by travel period, destination, concept, price, group size, accommodation, board, room type, duration and bus. Use for any question about which trips exist, when, where or how much. Returns one result per trip, hotel and travel date with the "ab" price per person in EUR, nights, what is included, room types and a link; get_trip gives the details of one. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.',
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class SearchTripsTool
{
public function __construct(
private readonly TripCatalog $catalog,
private readonly TravelPeriods $periods,
private readonly ToolCallRunner $runner,
) {
}
@@ -30,34 +34,47 @@ final class SearchTripsTool
public function __invoke(
#[Schema(description: 'Free text matched against trip, hotel, destination and concept names and texts, e.g. "Val Thorens" or "Snowboardkurs".')]
?string $query = null,
#[Schema(description: 'Start of the travel period, YYYY-MM-DD. Trips overlapping the period match.', format: 'date')]
#[Schema(description: 'A named travel period of the website search: Vorsaison, um Weihnachten, Silvester, 1 Januarwoche, Ostern, Nachsaison. Use instead of dateFrom/dateTo.')]
?string $period = null,
#[Schema(description: 'Earliest start date, YYYY-MM-DD. Trips must start on or after it.', format: 'date')]
?string $dateFrom = null,
#[Schema(description: 'End of the travel period, YYYY-MM-DD.', format: 'date')]
#[Schema(description: 'Latest end date, YYYY-MM-DD. Trips must end on or before it.', format: 'date')]
?string $dateTo = null,
#[Schema(description: 'Country name, e.g. "Frankreich".')]
?string $country = null,
#[Schema(description: 'Region, ski area or town name, e.g. "Les 3 Vallées".')]
?string $region = null,
#[Schema(description: 'Trip concept code or name, e.g. ski, party, group.')]
#[Schema(description: 'Trip concept code or name, e.g. Sportclub, Ski-Kurztrips, Semesterende-Skireisen.')]
?string $concept = null,
#[Schema(description: 'Maximum price per person in EUR.', minimum: 0)]
?float $maxPrice = null,
#[Schema(description: 'Only dates with at least this many places available.', minimum: 1)]
#[Schema(description: 'Lowest "ab" price per person in EUR.', minimum: 0)]
?float $priceMin = null,
#[Schema(description: 'Highest "ab" price per person in EUR.', minimum: 0)]
?float $priceMax = null,
#[Schema(description: 'Group size: only dates bookable for this many people with enough places left.', minimum: 1)]
?int $persons = null,
#[Schema(description: 'Accommodation type.', enum: ['Ferienwohnung', 'Sportclub', 'Apartment', 'Gästehaus', 'Pension'])]
?string $accommodation = null,
#[Schema(description: 'Included meals, matched against the board items by word: "Abendessen" (dinner; with "Frühstück" this is half board), "Frühstück", "Lunchpaket", "Vollpension", "Selbstversorger". "Halbpension" itself does not occur.')]
?string $board = null,
#[Schema(description: 'Room type by capacity: EZ (single), DZ (double), 3er-8er, 9er+, or "mit anderen" (a bed shared with other guests).', enum: ['EZ', 'DZ', '3er', '4er', '5er', '6er', '7er', '8er', '9er+', 'mit anderen'])]
?string $roomType = null,
#[Schema(description: 'Minimum number of nights. The website\'s "long" trips are 5 or more.', minimum: 0)]
?int $nightsMin = null,
#[Schema(description: 'Maximum number of nights. The website\'s "short" trips are 4 or fewer.', minimum: 0)]
?int $nightsMax = null,
#[Schema(description: 'true for dates with a bookable bus, false for dates without one.')]
?bool $bus = null,
#[Schema(description: 'true for day trips only, false to exclude them.')]
?bool $daytrip = null,
#[Schema(description: Sites::DESCRIPTION, enum: Sites::IDENTIFIERS)]
?string $site = null,
#[Schema(description: 'Number of trips, default 5, at most 10.', minimum: 1, maximum: 10)]
#[Schema(description: 'Number of results, default 10, at most 25.', minimum: 1, maximum: 25)]
?int $limit = null,
): CallToolResult {
return $this->runner->run('search_trips', get_defined_vars(), function () use ($query, $dateFrom, $dateTo, $country, $region, $concept, $maxPrice, $persons, $daytrip, $site, $limit): array {
$from = $this->date($dateFrom, 'dateFrom');
$to = $this->date($dateTo, 'dateTo');
$arguments = get_defined_vars();
if (null !== $from && null !== $to && $from > $to) {
throw new ToolCallException('dateFrom must not be after dateTo.');
}
return $this->runner->run('search_trips', $arguments, function () use ($query, $period, $dateFrom, $dateTo, $country, $region, $concept, $priceMin, $priceMax, $persons, $accommodation, $board, $roomType, $nightsMin, $nightsMax, $bus, $daytrip, $site, $limit): array {
[$from, $to] = $this->period($period, $dateFrom, $dateTo);
return $this->catalog->search(new TripCriteria(
query: $this->text($query),
@@ -66,15 +83,48 @@ final class SearchTripsTool
country: $this->text($country),
region: $this->text($region),
concept: $this->text($concept),
maxPrice: $maxPrice,
priceMin: $priceMin,
priceMax: $priceMax,
persons: $persons,
accommodation: null === $accommodation ? null : (HotelType::fromLabel($accommodation) ?? throw new ToolCallException('Unknown accommodation type.')),
board: $this->text($board),
roomType: null === $roomType ? null : (RoomType::tryFrom($roomType) ?? throw new ToolCallException('Unknown room type.')),
nightsMin: $nightsMin,
nightsMax: $nightsMax,
bus: $bus,
daytrip: $daytrip,
site: $site,
limit: max(1, min(10, $limit ?? 5)),
limit: max(1, min(25, $limit ?? 10)),
));
});
}
/**
* @return array{?\DateTimeImmutable, ?\DateTimeImmutable}
*/
private function period(?string $period, ?string $dateFrom, ?string $dateTo): array
{
if (null !== $this->text($period)) {
if (null !== $this->text($dateFrom) || null !== $this->text($dateTo)) {
throw new ToolCallException('Use either period or dateFrom/dateTo, not both.');
}
$range = $this->periods->find($period)
?? throw new ToolCallException(sprintf('Unknown or past period "%s". Current periods: %s.', $period, implode(', ', $this->periods->labels()) ?: 'none'));
return [$range['from'], $range['to']];
}
$from = $this->date($dateFrom, 'dateFrom');
$to = $this->date($dateTo, 'dateTo');
if (null !== $from && null !== $to && $from > $to) {
throw new ToolCallException('dateFrom must not be after dateTo.');
}
return [$from, $to];
}
private function date(?string $value, string $name): ?\DateTimeImmutable
{
if (null === $value || '' === trim($value)) {
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Chatbot;
use Symfony\Component\Clock\ClockInterface;
/**
* The named travel periods of the website search ("Reisezeitraum"), from the
* `chatbot_travel_periods` parameter.
*
* Duplicated from the TYPO3 search configuration on purpose, and updated together with it
* each season. Periods may overlap. A period whose end has passed is no longer offered.
*/
class TravelPeriods
{
/**
* @param list<array{label: string, from: string, to: string}> $periods
*/
public function __construct(
private readonly array $periods,
private readonly ClockInterface $clock,
) {
}
/**
* The date range of a current period, matched case-insensitively, or null.
*
* @return array{from: \DateTimeImmutable, to: \DateTimeImmutable}|null
*/
public function find(string $label): ?array
{
foreach ($this->current() as $period) {
if (0 === strcasecmp($period['label'], trim($label))) {
return ['from' => new \DateTimeImmutable($period['from']), 'to' => new \DateTimeImmutable($period['to'])];
}
}
return null;
}
/**
* @return list<string>
*/
public function labels(): array
{
return array_column($this->current(), 'label');
}
/**
* @return list<array{label: string, from: string, to: string}>
*/
private function current(): array
{
$today = $this->clock->now()->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('Y-m-d');
return array_values(array_filter($this->periods, static fn (array $period): bool => $period['to'] >= $today));
}
}
+85 -102
View File
@@ -7,6 +7,9 @@ namespace App\Chatbot;
use App\BusProNet\Utility\DateCodeUtility;
use App\Chatbot\Search\ContentSearchQuery;
use App\Entity\ContentDocument;
use App\Enum\HotelType;
use App\Enum\RoomType;
use App\Model\Chatbot\TravelDateFacts;
use App\Model\Chatbot\TripCriteria;
use App\Model\Export;
use App\Repository\ContentDocumentRepository;
@@ -23,22 +26,18 @@ use Symfony\Component\Clock\ClockInterface;
* matchcode aliases; a code carried by more than one hotel resolves to the one whose own
* `code` it is.
*
* A room is bookable unless its status is `Buchungsstop`. `available` counts rooms, so the
* places of a date x hotel are the sum of `available x maxPax` over its bookable rooms.
* Room counts never leave this class: the website does not show them, so the tools say
* only whether a date is freely bookable or on request.
* search() mirrors the website's travel search, get() details one trip. A room is bookable
* unless its status is `Buchungsstop`; places and prices are derived per date x hotel by
* TravelDateFacts. Room counts never leave this class: the website does not show them, so
* the tools say only whether a date is freely bookable or on request.
*/
class TripCatalog
{
/** The feed's only language for now; trips are joined in it. */
public const LANGUAGE = 'de';
private const STATUS_BOOKING_STOP = 'Buchungsstop';
private const STATUS_FREE = 'Frei';
private const AVAILABILITY_FREE = 'frei';
private const AVAILABILITY_ON_REQUEST = 'auf Anfrage';
private const NEXT_DATES = 3;
private const DATES_WITH_PICKUPS = 3;
private const BODY_LIMIT = 20000;
private const HOTEL_BODY_LIMIT = 8000;
@@ -54,56 +53,70 @@ class TripCatalog
}
/**
* @return array{results: list<array<string, mixed>>, dataAsOf: ?string}
* The website's travel search: one result per product x hotel x travel date, filtered and
* priced as the search page does (see TravelDateFacts and TripCriteria), sorted by date,
* then price.
*
* @return array{results: list<array<string, mixed>>, total: int, dataAsOf: string}
*/
public function search(TripCriteria $criteria): array
{
$catalog = $this->load();
$products = $this->candidateProducts($catalog, $criteria);
$today = $this->today();
$results = [];
foreach ($products as $product) {
$data = $product->getData() ?? [];
$dates = [];
$hotelCodes = [];
foreach ($this->candidateProducts($catalog, $criteria) as $product) {
$header = null;
foreach ($catalog['rowsByCode'][$this->normalize($data['code'] ?? null)] ?? [] as $row) {
$summary = $this->summarize($row);
if (!$this->qualifies($row, $summary, $criteria, $today)) {
foreach ($catalog['rowsByCode'][$this->normalize($product->getData()['code'] ?? null)] ?? [] as $row) {
if ($row['date']['from'] < $today) {
continue;
}
$dateId = $row['date']['busProId'];
$hotelCodes[$this->normalize($row['hotel']['code'])] = true;
$current = $dates[$dateId] ?? null;
$hotel = $catalog['hotelsByCode'][$this->normalize($row['hotel']['code'] ?? null)][0] ?? null;
$hotelData = $hotel?->getData() ?? [];
$facts = TravelDateFacts::fromExportRow($row);
$dates[$dateId] = [
if (!$this->matches($row, $facts, HotelType::tryFrom((int) ($hotelData['type'] ?? 0)), $criteria)) {
continue;
}
$header ??= $this->tripHeader($catalog, $product);
$results[] = [
'product' => $header['name'],
'code' => $header['code'],
'dateCode' => $row['date']['code'],
'hotel' => $hotel?->getTitle() ?? $row['hotel']['code'],
'accommodation' => HotelType::tryFrom((int) ($hotelData['type'] ?? 0))?->label(),
'destination' => $header['destination'],
'concept' => $header['concept'],
'dateFrom' => $row['date']['from'],
'dateTo' => $row['date']['to'],
'priceFrom' => null === $current ? $summary['priceFrom'] : $this->minPrice($current['priceFrom'], $summary['priceFrom']),
'availability' => self::AVAILABILITY_FREE === ($current['availability'] ?? null) ? self::AVAILABILITY_FREE : $summary['availability'],
'nights' => $facts->nights,
'priceFrom' => $facts->priceFrom,
'includes' => $facts->includes(),
'roomTypes' => array_map(static fn (RoomType $type): string => $type->value, $facts->roomTypes),
'availability' => $facts->availability,
'flags' => array_values(array_filter([
true === ($hotelData['new'] ?? null) ? 'neu' : null,
true === ($hotelData['earlybird'] ?? null) ? 'frühbucher' : null,
$header['daytrip'] ? 'tagesfahrt' : null,
])),
'url' => $header['url'],
'site' => $header['site'],
];
}
if ([] === $dates) {
continue;
}
usort($dates, static fn (array $a, array $b): int => $a['dateFrom'] <=> $b['dateFrom']);
$results[] = $this->tripHeader($catalog, $product) + [
'hotels' => $this->hotelNames($catalog, array_keys($hotelCodes)),
'matchingDates' => count($dates),
'nextDates' => array_slice($dates, 0, self::NEXT_DATES),
];
}
usort($results, static fn (array $a, array $b): int => [$a['nextDates'][0]['dateFrom'], $a['name']] <=> [$b['nextDates'][0]['dateFrom'], $b['name']]);
usort($results, static fn (array $a, array $b): int => [$a['dateFrom'], $a['priceFrom'] ?? \PHP_FLOAT_MAX, $a['product'], $a['hotel']]
<=> [$b['dateFrom'], $b['priceFrom'] ?? \PHP_FLOAT_MAX, $b['product'], $b['hotel']]);
return ['results' => array_slice($results, 0, $criteria->limit), 'dataAsOf' => $this->dataAsOf($catalog['export'])];
return [
'results' => array_slice($results, 0, $criteria->limit),
'total' => count($results),
'dataAsOf' => $this->dataAsOf($catalog['export']),
];
}
/**
@@ -408,49 +421,47 @@ class TripCatalog
}
/**
* @param array<string, mixed> $row
* The search page's filters on one travel date at one hotel. `persons` checks both the
* page's condition (the smallest group a room takes) and that enough places are left.
*
* @return array{priceFrom: ?float, places: int, availability: ?string}
* @param array<string, mixed> $row
*/
private function summarize(array $row): array
private function matches(array $row, TravelDateFacts $facts, ?HotelType $hotelType, TripCriteria $criteria): bool
{
$priceFrom = null;
$places = 0;
$availability = null;
if (!$facts->isBookable()) {
return false;
}
foreach ($row['rooms'] ?? [] as $room) {
if (self::STATUS_BOOKING_STOP === ($room['status'] ?? null)) {
continue;
}
if ((null !== $criteria->dateFrom && $row['date']['from'] < $criteria->dateFrom->format('Y-m-d'))
|| (null !== $criteria->dateTo && $row['date']['to'] > $criteria->dateTo->format('Y-m-d'))) {
return false;
}
$places += (int) ($room['available'] ?? 0) * (int) ($room['maxPax'] ?? 0);
$availability = self::STATUS_FREE === ($room['status'] ?? null) || self::AVAILABILITY_FREE === $availability
? self::AVAILABILITY_FREE
: self::AVAILABILITY_ON_REQUEST;
if ((null !== $criteria->priceMin || null !== $criteria->priceMax)
&& (null === $facts->priceFrom
|| (null !== $criteria->priceMin && $facts->priceFrom < $criteria->priceMin)
|| (null !== $criteria->priceMax && $facts->priceFrom > $criteria->priceMax))) {
return false;
}
if (is_int($room['price'] ?? null) || is_float($room['price'] ?? null)) {
$priceFrom = $this->minPrice($priceFrom, (float) $room['price']);
if (null !== $criteria->persons && (($facts->minPax ?? 0) > $criteria->persons || $facts->places < $criteria->persons)) {
return false;
}
if (null !== $criteria->board) {
$wanted = mb_strtolower($criteria->board);
$boards = array_filter($facts->boards, static fn (string $board): bool => str_contains(mb_strtolower($board), $wanted));
if ([] === $boards) {
return false;
}
}
return ['priceFrom' => $priceFrom, 'places' => $places, 'availability' => $availability];
}
/**
* @param array<string, mixed> $row
* @param array{priceFrom: ?float, places: int, availability: ?string} $summary
*/
private function qualifies(array $row, array $summary, TripCriteria $criteria, string $today): bool
{
$from = $row['date']['from'];
$to = $row['date']['to'];
return $from >= $today
&& null !== $summary['availability']
&& (null === $criteria->dateFrom || $to >= $criteria->dateFrom->format('Y-m-d'))
&& (null === $criteria->dateTo || $from <= $criteria->dateTo->format('Y-m-d'))
&& (null === $criteria->maxPrice || (null !== $summary['priceFrom'] && $summary['priceFrom'] <= $criteria->maxPrice))
&& (null === $criteria->persons || $summary['places'] >= $criteria->persons);
return (null === $criteria->accommodation || $hotelType === $criteria->accommodation)
&& (null === $criteria->roomType || $facts->hasRoomType($criteria->roomType))
&& (null === $criteria->nightsMin || $facts->nights >= $criteria->nightsMin)
&& (null === $criteria->nightsMax || $facts->nights <= $criteria->nightsMax)
&& (null === $criteria->bus || $facts->busAvailable === $criteria->bus);
}
/**
@@ -478,25 +489,6 @@ class TripCatalog
];
}
/**
* @param array<string, mixed> $catalog
* @param list<string> $hotelCodes normalized
*
* @return list<string>
*/
private function hotelNames(array $catalog, array $hotelCodes): array
{
$names = [];
foreach ($hotelCodes as $code) {
$hotel = $catalog['hotelsByCode'][$code][0] ?? null;
if (null !== $hotel && '' !== $hotel->getTitle()) {
$names[$hotel->getTitle()] = true;
}
}
return array_keys($names);
}
/**
* @param array<string, mixed> $catalog
* @param list<string> $hotelCodes normalized codes of the hotels with dates
@@ -552,7 +544,7 @@ class TripCatalog
$rooms = [];
foreach ($row['rooms'] ?? [] as $room) {
if (self::STATUS_BOOKING_STOP === ($room['status'] ?? null)) {
if (TravelDateFacts::STATUS_BOOKING_STOP === ($room['status'] ?? null)) {
continue;
}
@@ -561,7 +553,7 @@ class TripCatalog
'board' => $room['boardCode'] ?? null,
'nights' => $room['nights'] ?? null,
'price' => $room['price'] ?? null,
'availability' => self::STATUS_FREE === ($room['status'] ?? null) ? self::AVAILABILITY_FREE : self::AVAILABILITY_ON_REQUEST,
'availability' => self::STATUS_FREE === ($room['status'] ?? null) ? TravelDateFacts::AVAILABILITY_FREE : TravelDateFacts::AVAILABILITY_ON_REQUEST,
], static fn (mixed $value): bool => null !== $value);
}
@@ -654,15 +646,6 @@ class TripCatalog
return mb_substr($text, 0, $limit)."\n\n[… gekürzt, vollständiger Text unter der URL]";
}
private function minPrice(?float $a, ?float $b): ?float
{
if (null === $a || null === $b) {
return $a ?? $b;
}
return min($a, $b);
}
private function today(): string
{
return $this->clock->now()->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('Y-m-d');