feat: chatbot trip search finds trips by trip name, hotel name and code

This commit is contained in:
fromme committed 2026-10-13 09:29:27 +02:00
1 parent 26d4140c18
commit fe40e1807e
8 files changed
+448 -52

No files matched your search

+194 -40
View File
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Chatbot;
use App\BusProNet\Utility\DateCodeUtility;
use App\BusProNet\XmlLoader\HotelLoader;
use App\Chatbot\Search\ContentSearchQuery;
use App\Chatbot\Search\NameQuery;
use App\Entity\ContentDocument;
use App\Enum\HotelType;
use App\Enum\RoomType;
@@ -24,9 +26,11 @@ use Symfony\Component\Clock\ClockInterface;
* The join is on the product code; several TYPO3 products may share one, and each is its
* own trip. Hotels are joined on any of the TYPO3 hotel's `codes`, because BusPro uses the
* matchcode aliases; a code carried by more than one hotel resolves to the one whose own
* `code` it is.
* `code` it is. BusPro's own hotel names (hotel.xml) are internal wording, so they are
* only searched, and shown just where TYPO3 has no hotel for a code.
*
* search() mirrors the website's travel search, get() details one trip. A room is bookable
* search() mirrors the website's travel search, get() details one trip. Free text is read
* as a code or a name before it is looked up in the page texts, see resolveQuery(). 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.
@@ -47,6 +51,7 @@ class TripCatalog
public function __construct(
private readonly ProductExportBuilder $exportBuilder,
private readonly ContentDocumentRepository $repository,
private readonly HotelLoader $hotelLoader,
private readonly ClockInterface $clock,
private readonly LoggerInterface $logger,
) {
@@ -63,6 +68,7 @@ class TripCatalog
{
$catalog = $this->load();
$today = $this->today();
$named = null === $criteria->query ? null : $this->resolveQuery($catalog, $criteria->query);
$results = [];
foreach ($this->candidateProducts($catalog, $criteria) as $product) {
@@ -75,6 +81,15 @@ class TripCatalog
$hotel = $catalog['hotelsByCode'][$this->normalize($row['hotel']['code'] ?? null)][0] ?? null;
$hotelData = $hotel?->getData() ?? [];
if (null !== $named
&& !isset($named['products'][$product->getUid()])
&& !isset($named['hotels'][$hotel?->getUid() ?? 0])
&& !isset($named['busProHotels'][$row['hotel']['busProId'] ?? 0])
&& (null === $named['dateCode'] || $named['dateCode'] !== $this->normalize($row['date']['code'] ?? null))) {
continue;
}
$facts = TravelDateFacts::fromExportRow($row);
if (!$this->matches($row, $facts, HotelType::tryFrom((int) ($hotelData['type'] ?? 0)), $criteria)) {
@@ -87,7 +102,8 @@ class TripCatalog
'product' => $header['name'],
'code' => $header['code'],
'dateCode' => $row['date']['code'],
'hotel' => $hotel?->getTitle() ?? $row['hotel']['code'],
'hotel' => $this->hotelName($catalog, $hotel, $row),
'hotelCode' => $row['hotel']['code'],
'accommodation' => HotelType::tryFrom((int) ($hotelData['type'] ?? 0))?->label(),
'destination' => $header['destination'],
'concept' => $header['concept'],
@@ -228,6 +244,7 @@ class TripCatalog
* productsByCode: array<string, list<ContentDocument>>,
* hotelsByCode: array<string, list<ContentDocument>>,
* rowsByCode: array<string, list<array<string, mixed>>>,
* busProHotels: array<int, array{code: string, name: ?string}>,
* }
*/
private function load(): array
@@ -264,11 +281,21 @@ class TripCatalog
}
$rowsByCode = [];
$busProHotels = [];
$masterData = $this->hotelLoader->loadAll();
foreach ($export->rows as $row) {
$code = $this->normalize($row['product']['code'] ?? null);
if ('' !== $code) {
$rowsByCode[$code][] = $row;
}
$hotelId = (int) ($row['hotel']['busProId'] ?? 0);
if (0 !== $hotelId && !isset($busProHotels[$hotelId])) {
// BusPro breaks names over lines ("Vacanceole\nRésidence L'Edelweiss").
$name = trim((string) preg_replace('/\s+/u', ' ', isset($masterData[$hotelId]) ? (string) $masterData[$hotelId]->name : ''));
$busProHotels[$hotelId] = ['code' => $this->normalize($row['hotel']['code'] ?? null), 'name' => '' === $name ? null : $name];
}
}
return [
@@ -277,12 +304,14 @@ class TripCatalog
'productsByCode' => $productsByCode,
'hotelsByCode' => $hotelsByCode,
'rowsByCode' => $rowsByCode,
'busProHotels' => $busProHotels,
];
}
/**
* Published products narrowed by everything the content index can resolve: site, day
* trip, concept, destination and free text.
* trip, concept and destination. Free text is applied per travel date, since it may
* name one hotel of a trip.
*
* @param array<string, mixed> $catalog
*
@@ -297,7 +326,6 @@ class TripCatalog
$countryUids = null === $criteria->country ? null : $this->matchingUids($records, ['country'], $criteria->country, matchCode: true);
$regionUids = null === $criteria->region ? null : $this->matchingUids($records, ['region'], $criteria->region);
$cityUids = null === $criteria->region ? null : $this->matchingUids($records, ['city'], $criteria->region);
$queryUids = null === $criteria->query ? null : $this->productUidsMatchingText($records, $criteria->query);
$products = [];
@@ -315,8 +343,7 @@ class TripCatalog
|| (null !== $criteria->site && $record->getSite() !== $criteria->site)
|| (null !== $criteria->daytrip && (bool) ($data['daytrip'] ?? false) !== $criteria->daytrip)
|| (null !== $conceptUids && !in_array($data['conceptUid'] ?? null, $conceptUids, true))
|| (null !== $countryUids && !in_array($data['countryUid'] ?? null, $countryUids, true))
|| (null !== $queryUids && !in_array($record->getUid(), $queryUids, true))) {
|| (null !== $countryUids && !in_array($data['countryUid'] ?? null, $countryUids, true))) {
continue;
}
@@ -366,27 +393,155 @@ class TripCatalog
}
/**
* Products reached by a full-text match on themselves or on a hotel, destination or
* concept they belong to.
* What a free-text query asks for: whole trips, single hotels of trips, or one travel
* date. Null when it restricts nothing.
*
* @param array<string, ContentDocument> $records
* A code or a name is taken at its word, so naming a trip or hotel returns that one and
* not every page that mentions it. In order, stopping at the first that finds anything:
* a product, hotel or date code; names holding every word of the query; names the query
* spells out among other words; and only then the page texts.
*
* @return list<int>
* @param array<string, mixed> $catalog
*
* Hotels are known twice: by their TYPO3 record and by BusPro's own name and code, which
* also covers a hotel TYPO3 has no record for
*
* @return array{products: array<int, true>, hotels: array<int, true>, busProHotels: array<int, true>, dateCode: ?string}|null
*/
private function productUidsMatchingText(array $records, string $text): array
private function resolveQuery(array $catalog, string $text): ?array
{
$booleanQuery = ContentSearchQuery::booleanQuery($text);
/** @var array<string, ContentDocument> $records */
$records = $catalog['records'];
if (null === $booleanQuery) {
return array_values(array_map(static fn (ContentDocument $record): int => $record->getUid(), array_filter($records, static fn (ContentDocument $record): bool => 'product' === $record->getType())));
$matched = $this->recordsWithCode($catalog, $text) ?? $this->recordsNamed($catalog, $text);
if (null !== $matched) {
return $this->tripsOf($records, $matched);
}
$dateCode = (new DateCodeUtility())->sanitize(trim($text));
foreach ($catalog['export']->rows as $row) {
if ($this->normalize($row['date']['code'] ?? null) === $dateCode) {
return ['products' => [], 'hotels' => [], 'busProHotels' => [], 'dateCode' => $dateCode];
}
}
$terms = ContentSearchQuery::terms($text);
if ([] === $terms) {
return null;
}
// Every term first: "Sauna Whirlpool" means both. A question rarely uses the page's
// own words for everything, so any term is the fallback.
foreach (count($terms) > 1 ? [true, false] : [false] as $allTerms) {
$matched = [];
foreach ($this->repository->matchRecords((string) ContentSearchQuery::booleanQuery($text, $allTerms), self::RECORD_TYPES, self::LANGUAGE) as $hit) {
$matched[$hit['type']][$hit['uid']] = true;
}
if ([] !== $matched) {
break;
}
}
return $this->tripsOf($records, $matched);
}
/**
* The products and the hotel carrying the text as their code, hotel aliases included.
* Matches are keyed by record type, BusPro's hotels as `busProHotel` by their id.
*
* @param array<string, mixed> $catalog
*
* @return array<string, array<int, true>>|null
*/
private function recordsWithCode(array $catalog, string $text): ?array
{
$code = (new DateCodeUtility())->sanitize(trim($text));
$matched = [];
foreach ($this->repository->matchRecords($booleanQuery, self::RECORD_TYPES, self::LANGUAGE) as $hit) {
$matched[$hit['type']][$hit['uid']] = true;
foreach ($catalog['productsByCode'][$code] ?? [] as $product) {
$matched['product'][$product->getUid()] = true;
}
$uids = [];
if (isset($catalog['hotelsByCode'][$code][0])) {
$matched['hotel'][$catalog['hotelsByCode'][$code][0]->getUid()] = true;
}
foreach ($catalog['busProHotels'] as $hotelId => $busProHotel) {
if ($busProHotel['code'] === $code) {
$matched['busProHotel'][$hotelId] = true;
}
}
return [] === $matched ? null : $matched;
}
/**
* The records the text names. A name holding every word of the query wins: "Lederer"
* is "Sportclub Lederer". Otherwise the query may spell a name out among other words,
* and there the most specific kind wins, so that "Sportclub Lederer Saalbach" is the
* hotel and not everything in Saalbach.
*
* @param array<string, mixed> $catalog
*
* @return array<string, array<int, true>>|null
*/
private function recordsNamed(array $catalog, string $text): ?array
{
$query = NameQuery::tokens($text);
$names = [];
foreach ($catalog['records'] as $record) {
$names[] = [$record->getType(), $record->getUid(), $record->getTitle()];
}
foreach ($catalog['busProHotels'] as $hotelId => $busProHotel) {
$names[] = ['busProHotel', $hotelId, $busProHotel['name'] ?? ''];
}
$within = [];
$contained = [];
foreach ($names as [$type, $id, $title]) {
$name = NameQuery::tokens($title);
if (NameQuery::within($query, $name)) {
$within[$type][$id] = true;
} elseif (NameQuery::contains($query, $name)) {
$contained[$type][$id] = true;
}
}
if ([] !== $within) {
return $within;
}
foreach ([['product', 'hotel', 'busProHotel'], ['city'], ['region'], ['country', 'concept']] as $types) {
$matched = array_intersect_key($contained, array_flip($types));
if ([] !== $matched) {
return $matched;
}
}
return null;
}
/**
* Matched records as trips: a product, destination or concept stands for the whole
* trips, a hotel only for itself, since a trip may have many.
*
* @param array<string, ContentDocument> $records
* @param array<string, array<int, true>> $matched by type and uid
*
* @return array{products: array<int, true>, hotels: array<int, true>, busProHotels: array<int, true>, dateCode: null}
*/
private function tripsOf(array $records, array $matched): array
{
$products = [];
foreach ($records as $record) {
if ('product' !== $record->getType()) {
@@ -394,33 +549,18 @@ class TripCatalog
}
$data = $record->getData() ?? [];
$hotelUids = is_array($data['hotelUids'] ?? null) ? $data['hotelUids'] : [];
$regionUids = array_merge([$data['regionUid'] ?? null], is_array($data['additionalRegionUids'] ?? null) ? $data['additionalRegionUids'] : []);
$hit = isset($matched['product'][$record->getUid()])
|| [] !== array_intersect($hotelUids, array_keys($matched['hotel'] ?? []))
if (isset($matched['product'][$record->getUid()])
|| [] !== array_intersect($regionUids, array_keys($matched['region'] ?? []))
|| isset($matched['country'][$data['countryUid'] ?? -1])
|| isset($matched['city'][$data['cityUid'] ?? -1])
|| isset($matched['concept'][$data['conceptUid'] ?? -1]);
// Hotels also name their products, for products whose hotelUids miss them.
if (!$hit) {
foreach (array_keys($matched['hotel'] ?? []) as $hotelUid) {
$hotelProducts = ($records['hotel:'.$hotelUid] ?? null)?->getData()['productUids'] ?? [];
if (is_array($hotelProducts) && in_array($record->getUid(), $hotelProducts, true)) {
$hit = true;
break;
}
}
}
if ($hit) {
$uids[] = $record->getUid();
|| isset($matched['concept'][$data['conceptUid'] ?? -1])) {
$products[$record->getUid()] = true;
}
}
return $uids;
return ['products' => $products, 'hotels' => $matched['hotel'] ?? [], 'busProHotels' => $matched['busProHotel'] ?? [], 'dateCode' => null];
}
/**
@@ -509,8 +649,9 @@ class TripCatalog
}
}
// A trip without upcoming dates still describes its hotels.
if ([] === $hotels) {
// A trip without upcoming dates still describes its hotels. One whose dates are at
// a hotel the website does not know has none to describe.
if ([] === $hotelCodes) {
foreach ((array) ($product->getData()['hotelUids'] ?? []) as $uid) {
$hotel = is_int($uid) ? ($catalog['records']['hotel:'.$uid] ?? null) : null;
if (null !== $hotel) {
@@ -568,7 +709,7 @@ class TripCatalog
];
$dates[$dateId]['hotels'][] = [
'hotel' => $hotel?->getTitle() ?? $row['hotel']['code'],
'hotel' => $this->hotelName($catalog, $hotel, $row),
'hotelCode' => $row['hotel']['code'],
'rooms' => $rooms,
'bookable' => [] !== $rooms,
@@ -644,6 +785,19 @@ class TripCatalog
return array_values(array_filter(array_unique(array_map($this->normalize(...), $codes))));
}
/**
* The website's name for the hotel of a travel date, else BusPro's, else its code.
*
* @param array<string, mixed> $catalog
* @param array<string, mixed> $row
*/
private function hotelName(array $catalog, ?ContentDocument $hotel, array $row): string
{
return $hotel?->getTitle()
?? $catalog['busProHotels'][$row['hotel']['busProId'] ?? 0]['name']
?? (string) $row['hotel']['code'];
}
private function dataAsOf(Export $export): string
{
return ($export->sourceGeneratedAt ?? $export->generatedAt)->format('c');