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)) {