@curless/hotel-storefront 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/card-contract.test.js +13 -0
- package/dist/__tests__/card-contract.test.js.map +1 -1
- package/dist/__tests__/data.test.js +79 -3
- package/dist/__tests__/data.test.js.map +1 -1
- package/dist/__tests__/demo-hotels.test.js +52 -11
- package/dist/__tests__/demo-hotels.test.js.map +1 -1
- package/dist/__tests__/present.test.d.ts +2 -0
- package/dist/__tests__/present.test.d.ts.map +1 -0
- package/dist/__tests__/present.test.js +151 -0
- package/dist/__tests__/present.test.js.map +1 -0
- package/dist/__tests__/server.test.js +63 -4
- package/dist/__tests__/server.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +154 -0
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/backend.d.ts +40 -6
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +23 -1
- package/dist/backend.js.map +1 -1
- package/dist/card.generated.d.ts +1 -1
- package/dist/card.generated.js +2 -2
- package/dist/card.generated.js.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -0
- package/dist/config.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +118 -13
- package/dist/data.js.map +1 -1
- package/dist/demo-hotels.d.ts +40 -1
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +162 -13
- package/dist/demo-hotels.js.map +1 -1
- package/dist/fence.d.ts +9 -3
- package/dist/fence.d.ts.map +1 -1
- package/dist/fence.js +0 -0
- package/dist/fence.js.map +1 -1
- package/dist/index.js +0 -0
- package/dist/legal.d.ts.map +1 -1
- package/dist/legal.js +15 -0
- package/dist/legal.js.map +1 -1
- package/dist/members.d.ts +14 -0
- package/dist/members.d.ts.map +1 -0
- package/dist/members.js +56 -0
- package/dist/members.js.map +1 -0
- package/dist/present.d.ts +141 -0
- package/dist/present.d.ts.map +1 -0
- package/dist/present.js +197 -0
- package/dist/present.js.map +1 -0
- package/dist/server.d.ts +27 -10
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +27 -7
- package/dist/server.js.map +1 -1
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +27 -2
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +175 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +485 -51
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +68 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vocab.d.ts +1 -1
- package/dist/vocab.d.ts.map +1 -1
- package/dist/vocab.js +1 -1
- package/dist/vocab.js.map +1 -1
- package/package.json +11 -9
package/dist/tools.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { MAX_PAGE } from './backend.js';
|
|
1
2
|
import { isDay } from './dates.js';
|
|
2
3
|
import { DESCRIPTION_MAX, POLICY_MAX, hotelText } from './fence.js';
|
|
3
|
-
import { formatMoney } from './money.js';
|
|
4
|
+
import { formatMoney, multiplyMoney } from './money.js';
|
|
5
|
+
import { MAX_COMPARED, MAX_CONS, MAX_DAYS, MAX_ITEMS_PER_DAY, MAX_PROS, buildItinerary, compareHotels, } from './present.js';
|
|
4
6
|
import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, clampCount, tripKey, } from './trip.js';
|
|
5
7
|
import { DEMO_NOTE } from './vocab.js';
|
|
6
8
|
// The eleven things a guest can do: find hotels, look at one on dates, see what
|
|
@@ -77,13 +79,16 @@ export const buildTools = (n, v) => {
|
|
|
77
79
|
description: "Free text over the name, city, area, description and amenities - the guest's own words",
|
|
78
80
|
},
|
|
79
81
|
city: { type: 'string', description: 'One city, e.g. "Hong Kong"' },
|
|
80
|
-
limit: {
|
|
82
|
+
limit: {
|
|
83
|
+
type: 'number',
|
|
84
|
+
description: `How many to return, 1 to ${MAX_PAGE}. Default 20`,
|
|
85
|
+
},
|
|
81
86
|
},
|
|
82
87
|
},
|
|
83
88
|
},
|
|
84
89
|
{
|
|
85
90
|
name: n.getHotel,
|
|
86
|
-
description: `One ${v.noun} in full: description, amenities, address, check-in and check-out times. Given \`checkIn\` and \`checkOut\` it also returns the room types that can be booked for those nights, each with its nightly rate, the stay total including taxes and fees, whether it can be cancelled free and until when, and how many rooms are left. Without dates it returns the ${v.noun} only, because a room has no price until the nights are known.${v.hints.hotel ? ` ${v.hints.hotel}` : ''}`,
|
|
91
|
+
description: `One ${v.noun} in full: description, amenities, address, check-in and check-out times. Given \`checkIn\` and \`checkOut\` it also returns the room types that can be booked for those nights, each with its nightly rate, the stay total including taxes and fees, whether it can be cancelled free and until when, and how many rooms are left. \`rooms\` leaves out the types that have fewer than that many. Without dates it returns the ${v.noun} only, because a room has no price until the nights are known.${v.hints.hotel ? ` ${v.hints.hotel}` : ''}`,
|
|
87
92
|
annotations: reads(`View one ${v.brand} ${v.noun}`, open),
|
|
88
93
|
inputSchema: {
|
|
89
94
|
type: 'object',
|
|
@@ -93,6 +98,10 @@ export const buildTools = (n, v) => {
|
|
|
93
98
|
description: `The ${v.noun}'s name or id${v.hints.example ? `, e.g. "${v.hints.example}"` : ''}`,
|
|
94
99
|
},
|
|
95
100
|
...stayProps,
|
|
101
|
+
rooms: {
|
|
102
|
+
type: 'number',
|
|
103
|
+
description: `How many rooms of one type, 1 to ${MAX_ROOMS_PER_LINE}. Default 1`,
|
|
104
|
+
},
|
|
96
105
|
},
|
|
97
106
|
required: ['hotel'],
|
|
98
107
|
},
|
|
@@ -225,6 +234,101 @@ export const buildTools = (n, v) => {
|
|
|
225
234
|
required: ['booking'],
|
|
226
235
|
},
|
|
227
236
|
},
|
|
237
|
+
{
|
|
238
|
+
name: n.compare,
|
|
239
|
+
// ⚠️ NO PRICES IN, NO PRICES OUT OF THE MODEL. The arguments carry ids and
|
|
240
|
+
// prose; every figure on the card is read off the hotel's own record. A
|
|
241
|
+
// model that could send a price could one day send the wrong one, onto a
|
|
242
|
+
// card that looks official.
|
|
243
|
+
description: `Puts two to four ${v.nounPlural} side by side as one card. Each column's price, rate, cancellation terms, breakfast and rooms left come from the ${v.noun}'s own record; the arguments carry only the ids, one line on who each suits, and its pros and cons. With \`checkIn\`/\`checkOut\` the columns carry each ${v.noun}'s cheapest room for those nights; without them, a from-price.`,
|
|
244
|
+
annotations: reads(`Compare ${v.brand} ${v.nounPlural}`, open),
|
|
245
|
+
inputSchema: {
|
|
246
|
+
type: 'object',
|
|
247
|
+
properties: {
|
|
248
|
+
entries: {
|
|
249
|
+
type: 'array',
|
|
250
|
+
minItems: 2,
|
|
251
|
+
maxItems: MAX_COMPARED,
|
|
252
|
+
description: `The ${v.nounPlural} to set beside each other, in the order they should appear`,
|
|
253
|
+
items: {
|
|
254
|
+
type: 'object',
|
|
255
|
+
properties: {
|
|
256
|
+
hotel: { type: 'string', description: `The ${v.noun}'s name or id` },
|
|
257
|
+
bestFor: {
|
|
258
|
+
type: 'string',
|
|
259
|
+
description: 'One line on who this one suits, in your own words',
|
|
260
|
+
},
|
|
261
|
+
pros: { type: 'array', items: { type: 'string' }, maxItems: MAX_PROS },
|
|
262
|
+
cons: { type: 'array', items: { type: 'string' }, maxItems: MAX_CONS },
|
|
263
|
+
},
|
|
264
|
+
required: ['hotel'],
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
recommended: { type: 'string', description: `The one ${v.noun} to mark as the pick` },
|
|
268
|
+
title: { type: 'string', description: 'A heading for the card' },
|
|
269
|
+
...stayProps,
|
|
270
|
+
},
|
|
271
|
+
required: ['entries'],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: n.itinerary,
|
|
276
|
+
description: `A day-by-day plan as one card: each day carries its own label, an optional note, and the ${v.nounPlural} and add-ons attached to it, with their details filled in from their own records. Each item is marked with whether it is in the trip or only in the plan, and the card says how many of each. Nothing is added to the trip.`,
|
|
277
|
+
annotations: reads(`Lay out a ${v.brand} itinerary`, open),
|
|
278
|
+
inputSchema: {
|
|
279
|
+
type: 'object',
|
|
280
|
+
properties: {
|
|
281
|
+
title: { type: 'string', description: 'What to call the trip' },
|
|
282
|
+
days: {
|
|
283
|
+
type: 'array',
|
|
284
|
+
minItems: 1,
|
|
285
|
+
maxItems: MAX_DAYS,
|
|
286
|
+
items: {
|
|
287
|
+
type: 'object',
|
|
288
|
+
properties: {
|
|
289
|
+
label: { type: 'string', description: 'e.g. "Day 1 — Arrive in Hong Kong"' },
|
|
290
|
+
note: {
|
|
291
|
+
type: 'string',
|
|
292
|
+
description: 'One or two lines on the day, in your own words',
|
|
293
|
+
},
|
|
294
|
+
items: {
|
|
295
|
+
type: 'array',
|
|
296
|
+
items: { type: 'string' },
|
|
297
|
+
maxItems: MAX_ITEMS_PER_DAY,
|
|
298
|
+
description: `${v.nounPlural} and add-ons for that day, by name or id`,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
required: ['label'],
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
...stayProps,
|
|
305
|
+
},
|
|
306
|
+
required: ['title', 'days'],
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
name: n.signIn,
|
|
311
|
+
// ⚠️ A MEMBERSHIP NUMBER AND A SURNAME. No password appears in this
|
|
312
|
+
// schema because none exists: the connector has nowhere to put one, and
|
|
313
|
+
// a description that mentioned passwords would invite a model to ask for
|
|
314
|
+
// one in a chat transcript.
|
|
315
|
+
description: `Signs a ${v.brand} loyalty member in for this conversation with their membership number and the surname on it, and answers with their tier, their point balance and what the tier takes off the public rate. Room rates and trip totals are then quoted at the member rate until they are signed out.`,
|
|
316
|
+
annotations: edits(`Sign in to ${v.brand}`, open),
|
|
317
|
+
inputSchema: {
|
|
318
|
+
type: 'object',
|
|
319
|
+
properties: {
|
|
320
|
+
number: { type: 'string', description: 'The membership number, as it is printed' },
|
|
321
|
+
surname: { type: 'string', description: 'The surname on that membership' },
|
|
322
|
+
},
|
|
323
|
+
required: ['number', 'surname'],
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: n.signOut,
|
|
328
|
+
description: 'Ends the membership sign-in for this conversation. Rates go back to the public ones; the trip and any bookings are left as they are.',
|
|
329
|
+
annotations: edits(`Sign out of ${v.brand}`, open),
|
|
330
|
+
inputSchema: { type: 'object', properties: {} },
|
|
331
|
+
},
|
|
228
332
|
{
|
|
229
333
|
name: n.policies,
|
|
230
334
|
description: `What one ${v.noun} publishes about cancellation, check-in and check-out, children and extra beds, pets, deposits and accessibility. With \`topic\` it returns that topic; without, all of them.`,
|
|
@@ -269,6 +373,10 @@ const TRIP_SCHEMA = {
|
|
|
269
373
|
subtotal: STR,
|
|
270
374
|
taxesAndFees: STR,
|
|
271
375
|
total: STR,
|
|
376
|
+
/** Who is signed in, on every payload. */
|
|
377
|
+
member: OBJ,
|
|
378
|
+
/** What the tier took off THIS trip, and what it earns. */
|
|
379
|
+
memberSaving: OBJ,
|
|
272
380
|
days: ARR,
|
|
273
381
|
dropped: ARR,
|
|
274
382
|
},
|
|
@@ -302,7 +410,9 @@ export const buildOutputSchemas = (n) => ({
|
|
|
302
410
|
demo: BOOL,
|
|
303
411
|
demoNote: STR,
|
|
304
412
|
hotels: ARR,
|
|
413
|
+
/** How many MATCHED. `shown` is how many are in `hotels`. */
|
|
305
414
|
total: { type: 'number' },
|
|
415
|
+
shown: { type: 'number' },
|
|
306
416
|
cities: ARR,
|
|
307
417
|
query: STR,
|
|
308
418
|
},
|
|
@@ -322,6 +432,8 @@ export const buildOutputSchemas = (n) => ({
|
|
|
322
432
|
rooms: ARR,
|
|
323
433
|
/** True when no dates were given: the card asks for them instead of showing rooms. */
|
|
324
434
|
needsDates: BOOL,
|
|
435
|
+
/** How many rooms of one type the list was filtered to. */
|
|
436
|
+
rooms_wanted: { type: 'number' },
|
|
325
437
|
noRooms: STR,
|
|
326
438
|
extras: ARR,
|
|
327
439
|
policies: ARR,
|
|
@@ -347,7 +459,7 @@ export const buildOutputSchemas = (n) => ({
|
|
|
347
459
|
[n.requestBooking]: {
|
|
348
460
|
type: 'object',
|
|
349
461
|
properties: {
|
|
350
|
-
view: { enum: ['booking', 'empty-trip', 'error'] },
|
|
462
|
+
view: { enum: ['booking', 'needs-guest', 'empty-trip', 'error'] },
|
|
351
463
|
brand: STR,
|
|
352
464
|
toolNames: OBJ,
|
|
353
465
|
demo: BOOL,
|
|
@@ -375,6 +487,59 @@ export const buildOutputSchemas = (n) => ({
|
|
|
375
487
|
},
|
|
376
488
|
[n.previewCancellation]: CANCEL_SCHEMA,
|
|
377
489
|
[n.cancelBooking]: CANCEL_SCHEMA,
|
|
490
|
+
[n.compare]: {
|
|
491
|
+
type: 'object',
|
|
492
|
+
properties: {
|
|
493
|
+
view: { enum: ['comparison', 'nothing-to-compare'] },
|
|
494
|
+
brand: STR,
|
|
495
|
+
toolNames: OBJ,
|
|
496
|
+
demo: BOOL,
|
|
497
|
+
demoNote: STR,
|
|
498
|
+
title: STR,
|
|
499
|
+
stay: OBJ,
|
|
500
|
+
columns: ARR,
|
|
501
|
+
dropped: ARR,
|
|
502
|
+
/** Present only when the columns are priced in more than one currency. */
|
|
503
|
+
currencies: ARR,
|
|
504
|
+
rowLabels: ARR,
|
|
505
|
+
},
|
|
506
|
+
required: ['view'],
|
|
507
|
+
},
|
|
508
|
+
[n.itinerary]: {
|
|
509
|
+
type: 'object',
|
|
510
|
+
properties: {
|
|
511
|
+
view: { enum: ['itinerary', 'empty-itinerary'] },
|
|
512
|
+
brand: STR,
|
|
513
|
+
toolNames: OBJ,
|
|
514
|
+
demo: BOOL,
|
|
515
|
+
demoNote: STR,
|
|
516
|
+
title: STR,
|
|
517
|
+
stay: OBJ,
|
|
518
|
+
days: ARR,
|
|
519
|
+
/** How many of the planned items are actually in the trip. Computed, never written. */
|
|
520
|
+
inTrip: { type: 'number' },
|
|
521
|
+
planned: { type: 'number' },
|
|
522
|
+
dropped: ARR,
|
|
523
|
+
},
|
|
524
|
+
required: ['view'],
|
|
525
|
+
},
|
|
526
|
+
[n.signIn]: {
|
|
527
|
+
type: 'object',
|
|
528
|
+
properties: {
|
|
529
|
+
view: { enum: ['member', 'member-not-found'] },
|
|
530
|
+
brand: STR,
|
|
531
|
+
toolNames: OBJ,
|
|
532
|
+
demo: BOOL,
|
|
533
|
+
demoNote: STR,
|
|
534
|
+
member: OBJ,
|
|
535
|
+
},
|
|
536
|
+
required: ['view'],
|
|
537
|
+
},
|
|
538
|
+
[n.signOut]: {
|
|
539
|
+
type: 'object',
|
|
540
|
+
properties: { view: { const: 'signed-out' }, brand: STR, toolNames: OBJ },
|
|
541
|
+
required: ['view'],
|
|
542
|
+
},
|
|
378
543
|
[n.policies]: {
|
|
379
544
|
type: 'object',
|
|
380
545
|
properties: {
|
|
@@ -401,13 +566,30 @@ const asStay = (args) => {
|
|
|
401
566
|
const guests = typeof args.guests === 'number' ? Math.floor(args.guests) : 2;
|
|
402
567
|
return { checkIn, checkOut, guests };
|
|
403
568
|
};
|
|
404
|
-
/**
|
|
405
|
-
|
|
569
|
+
/**
|
|
570
|
+
* What every payload carries, so the card can draw a header and say whose data
|
|
571
|
+
* this is.
|
|
572
|
+
*
|
|
573
|
+
* ⚠️ THE MEMBER BELONGS HERE, not on the sign-in view alone. A member rate is a
|
|
574
|
+
* fact about EVERY price on the card, so the tier has to travel with every
|
|
575
|
+
* answer - otherwise the header says "sign in" over a list of discounted rates,
|
|
576
|
+
* which is what it did the first time (2026-09-18). The name and the number are
|
|
577
|
+
* left out: the chip needs a tier, and a payload that repeats somebody's
|
|
578
|
+
* membership number on every call is carrying it further than it has to go.
|
|
579
|
+
*/
|
|
580
|
+
const memberBadge = (deps, key) => {
|
|
581
|
+
const m = deps.members.read(key);
|
|
582
|
+
return m
|
|
583
|
+
? { member: { programme: m.programme, tier: { name: m.tier.name, discount: m.tier.discount } } }
|
|
584
|
+
: {};
|
|
585
|
+
};
|
|
586
|
+
const common = (deps, key) => ({
|
|
406
587
|
brand: deps.vocab.brand,
|
|
407
588
|
...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
|
|
408
589
|
toolNames: deps.tools,
|
|
409
590
|
vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
|
|
410
591
|
...(deps.vocab.demo ? { demo: true, demoNote: DEMO_NOTE } : { demo: false }),
|
|
592
|
+
...(key ? memberBadge(deps, key) : {}),
|
|
411
593
|
});
|
|
412
594
|
/** One line of a trip, flattened for the card. Prices are strings: the card never does arithmetic. */
|
|
413
595
|
const quoteForCard = (quote) => ({
|
|
@@ -431,6 +613,12 @@ const quoteForCard = (quote) => ({
|
|
|
431
613
|
subtotal: formatMoney(quote.subtotal),
|
|
432
614
|
taxesAndFees: formatMoney(quote.taxesAndFees),
|
|
433
615
|
total: formatMoney(quote.total),
|
|
616
|
+
// ⚠️ NOT `member`: that key means "who is signed in" on every payload, and
|
|
617
|
+
// the quote's block is a different shape (a tier NAME, a saving, points).
|
|
618
|
+
// Two shapes on one key is how a card reads `member.tier.name` off a string.
|
|
619
|
+
...(quote.member
|
|
620
|
+
? { memberSaving: { ...quote.member, saved: formatMoney(quote.member.saved) } }
|
|
621
|
+
: {}),
|
|
434
622
|
...(quote.stay ? { stay: quote.stay } : {}),
|
|
435
623
|
...(quote.dropped ? { dropped: quote.dropped } : {}),
|
|
436
624
|
});
|
|
@@ -450,16 +638,46 @@ const tripText = (quote, demo) => {
|
|
|
450
638
|
.join('\n');
|
|
451
639
|
};
|
|
452
640
|
const bookingLine = (b) => `${b.confirmationCode} · ${b.hotelName} · ${b.stay.checkIn} to ${b.stay.checkOut} · ${b.status}`;
|
|
453
|
-
const identity = (ctx, args) => {
|
|
641
|
+
const identity = (deps, ctx, key, args = {}) => {
|
|
454
642
|
const email = str(args.email);
|
|
455
|
-
|
|
643
|
+
const member = deps.members.read(key);
|
|
644
|
+
return {
|
|
645
|
+
sessionId: ctx.session,
|
|
646
|
+
...(email ? { email } : {}),
|
|
647
|
+
// ⚠️ THE MEMBER BUYS A RATE, NOT A LOOKUP. `listBookings` and the
|
|
648
|
+
// cancellation tools still key on the conversation and on the email the
|
|
649
|
+
// booking was made with - a membership number plus a surname is what a desk
|
|
650
|
+
// checks, not authentication, and it must never become a way to read
|
|
651
|
+
// somebody else's booking.
|
|
652
|
+
...(member ? { member } : {}),
|
|
653
|
+
};
|
|
456
654
|
};
|
|
457
|
-
/**
|
|
458
|
-
|
|
655
|
+
/**
|
|
656
|
+
* Why this hotel cannot join the trip that is already there - or null.
|
|
657
|
+
*
|
|
658
|
+
* One trip, one currency. Not a limitation to route around: converting HKD to
|
|
659
|
+
* CNY means picking a rate, and a rate we picked is a number the guest would
|
|
660
|
+
* read off a card as if the hotel had said it.
|
|
661
|
+
*/
|
|
662
|
+
const currencyClash = async (deps, key, hotel) => {
|
|
459
663
|
const lines = deps.trips.read(key);
|
|
664
|
+
if (lines.length === 0)
|
|
665
|
+
return null;
|
|
666
|
+
const theirs = hotel.fromNightly?.currency;
|
|
667
|
+
if (!theirs)
|
|
668
|
+
return null;
|
|
460
669
|
const quote = await deps.backend.quote({ lines });
|
|
670
|
+
const ours = quote.lines.length ? quote.total.currency : undefined;
|
|
671
|
+
if (!ours || ours === theirs)
|
|
672
|
+
return null;
|
|
673
|
+
return `This trip is priced in ${ours} and ${hotel.name} charges in ${theirs}. No exchange rate is invented here - book those nights as their own trip, or clear this one first.`;
|
|
674
|
+
};
|
|
675
|
+
/** The trip, priced, as both views need it. */
|
|
676
|
+
const currentTrip = async (deps, key, who) => {
|
|
677
|
+
const lines = deps.trips.read(key);
|
|
678
|
+
const quote = await deps.backend.quote({ lines }, who);
|
|
461
679
|
return {
|
|
462
|
-
structured: { view: 'trip', ...common(deps), ...quoteForCard(quote), count: lines.length },
|
|
680
|
+
structured: { view: 'trip', ...common(deps, key), ...quoteForCard(quote), count: lines.length },
|
|
463
681
|
text: tripText(quote, deps.vocab.demo),
|
|
464
682
|
};
|
|
465
683
|
};
|
|
@@ -469,15 +687,25 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
469
687
|
const key = ctx.tripKey ?? tripKey(ctx.session, deps.vocab.brand);
|
|
470
688
|
if (name === t.listHotels) {
|
|
471
689
|
const query = str(args.query);
|
|
472
|
-
const
|
|
690
|
+
const answer = await deps.backend.searchHotels({
|
|
473
691
|
query: query || undefined,
|
|
474
692
|
city: str(args.city) || undefined,
|
|
475
693
|
limit: typeof args.limit === 'number' ? args.limit : undefined,
|
|
476
694
|
});
|
|
695
|
+
// The ceiling again on this side of the seam. MAX_PAGE is part of the
|
|
696
|
+
// backend contract, but "the next backend honours it" is not something the
|
|
697
|
+
// card can be built on - and the cost of it being wrong is the whole group
|
|
698
|
+
// in the model's context.
|
|
699
|
+
const hotels = answer.hotels.slice(0, MAX_PAGE);
|
|
700
|
+
const total = Math.max(answer.total, hotels.length);
|
|
477
701
|
const cities = [...new Set(hotels.map((h) => h.city))];
|
|
702
|
+
// How many were left behind. The group has ninety-odd properties, so an
|
|
703
|
+
// unnarrowed browse is a page of a much longer list, and neither the card
|
|
704
|
+
// nor the model may present the page as the whole answer.
|
|
705
|
+
const more = Math.max(0, total - hotels.length);
|
|
478
706
|
if (hotels.length === 0) {
|
|
479
707
|
return {
|
|
480
|
-
structured: { view: 'no-hotels', ...common(deps), hotels: [], total: 0, query },
|
|
708
|
+
structured: { view: 'no-hotels', ...common(deps, key), hotels: [], total: 0, query },
|
|
481
709
|
text: `No ${deps.vocab.brand} ${deps.vocab.nounPlural} match that.`,
|
|
482
710
|
};
|
|
483
711
|
}
|
|
@@ -491,22 +719,28 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
491
719
|
return {
|
|
492
720
|
structured: {
|
|
493
721
|
view: 'hotels',
|
|
494
|
-
...common(deps),
|
|
722
|
+
...common(deps, key),
|
|
495
723
|
hotels: forCard,
|
|
496
|
-
total
|
|
724
|
+
total,
|
|
725
|
+
shown: hotels.length,
|
|
497
726
|
cities,
|
|
498
727
|
query,
|
|
499
728
|
},
|
|
500
|
-
text:
|
|
729
|
+
text: [
|
|
730
|
+
`${total} ${deps.vocab.brand} ${total === 1 ? deps.vocab.noun : deps.vocab.nounPlural}${where}`,
|
|
731
|
+
more > 0
|
|
732
|
+
? `— the ${hotels.length} below, and ${more} more. Narrow by city or country, or ask for a bigger \`limit\`.`
|
|
733
|
+
: '— tap one to see rooms.',
|
|
734
|
+
].join(' '),
|
|
501
735
|
};
|
|
502
736
|
}
|
|
503
737
|
if (name === t.getHotel) {
|
|
504
738
|
const wanted = str(args.hotel);
|
|
505
739
|
const stay = asStay(args);
|
|
506
|
-
const detail = await deps.backend.getHotel(wanted, stay);
|
|
740
|
+
const detail = await deps.backend.getHotel(wanted, stay, identity(deps, ctx, key));
|
|
507
741
|
if (!detail) {
|
|
508
742
|
return {
|
|
509
|
-
structured: { view: 'hotel-not-found', ...common(deps), query: wanted },
|
|
743
|
+
structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
|
|
510
744
|
text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name. ${t.listHotels} lists them.`,
|
|
511
745
|
};
|
|
512
746
|
}
|
|
@@ -515,7 +749,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
515
749
|
const policies = await deps.backend.policies(h.id);
|
|
516
750
|
const extras = await deps.backend.extrasFor(h.id, stay);
|
|
517
751
|
const base = {
|
|
518
|
-
...common(deps),
|
|
752
|
+
...common(deps, key),
|
|
519
753
|
hotel: {
|
|
520
754
|
...h,
|
|
521
755
|
summary: description.text,
|
|
@@ -542,17 +776,48 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
542
776
|
text: detail.noRooms ?? 'Nothing is available for those nights.',
|
|
543
777
|
};
|
|
544
778
|
}
|
|
545
|
-
|
|
779
|
+
// ⚠️ THE ROOM COUNT FILTERS THE LIST, it does not just ride along. A guest
|
|
780
|
+
// who needs two rooms and is shown a type with one left finds out when the
|
|
781
|
+
// trip drops the line, which is later and worse than not offering it.
|
|
782
|
+
const roomsWanted = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(Number(args.rooms) || 1)));
|
|
783
|
+
const enough = detail.rooms.filter((r) => r.roomsLeft >= roomsWanted);
|
|
784
|
+
if (enough.length === 0) {
|
|
785
|
+
return {
|
|
786
|
+
structured: {
|
|
787
|
+
view: 'no-rooms',
|
|
788
|
+
...base,
|
|
789
|
+
stay,
|
|
790
|
+
rooms_wanted: roomsWanted,
|
|
791
|
+
noRooms: `No room type here has ${roomsWanted} left for those nights.`,
|
|
792
|
+
},
|
|
793
|
+
text: `No room type at ${h.name} has ${roomsWanted} left for ${stay.checkIn} to ${stay.checkOut}.`,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
const rooms = enough.map((r) => ({
|
|
546
797
|
...r,
|
|
547
798
|
nightly: formatMoney(r.nightly),
|
|
548
799
|
stayTotal: formatMoney(r.stayTotal),
|
|
549
800
|
taxesAndFees: formatMoney(r.taxesAndFees),
|
|
801
|
+
// ⚠️ THE NUMBER THE GUEST IS LOOKING AT HAS TO BE THE ONE THEY ASKED FOR.
|
|
802
|
+
// With two rooms picked, the card was still showing one room's total and
|
|
803
|
+
// nothing said so - robin changed the count and watched the price not
|
|
804
|
+
// move (2026-09-18). Multiplied HERE, in minor units, because the card
|
|
805
|
+
// holds formatted strings and must never do arithmetic on money.
|
|
806
|
+
...(roomsWanted > 1
|
|
807
|
+
? { roomsTotal: formatMoney(multiplyMoney(r.stayTotal, roomsWanted)) }
|
|
808
|
+
: {}),
|
|
809
|
+
// ⚠️ THE PUBLIC RATE IS MONEY TOO. It went out as a raw
|
|
810
|
+
// `{ minor, currency }` and the card would have drawn "[object Object]"
|
|
811
|
+
// through the middle with a line - caught by the test that asserted the
|
|
812
|
+
// string, not by anyone looking at it.
|
|
813
|
+
...(r.publicNightly ? { publicNightly: formatMoney(r.publicNightly) } : {}),
|
|
814
|
+
...(r.publicStayTotal ? { publicStayTotal: formatMoney(r.publicStayTotal) } : {}),
|
|
550
815
|
}));
|
|
551
816
|
return {
|
|
552
|
-
structured: { view: 'hotel', ...base, stay, rooms },
|
|
817
|
+
structured: { view: 'hotel', ...base, stay, rooms, rooms_wanted: roomsWanted },
|
|
553
818
|
text: [
|
|
554
|
-
`**${h.name}** · ${
|
|
555
|
-
...
|
|
819
|
+
`**${h.name}** · ${enough.length} room ${enough.length === 1 ? 'type' : 'types'} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}.`,
|
|
820
|
+
...enough.map((r) => `- ${r.name} — ${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.refundable ? `, free to cancel${r.freeCancellationUntil ? ` until ${r.freeCancellationUntil.slice(0, 10)}` : ''}` : ', non-refundable'}`),
|
|
556
821
|
deps.vocab.demo ? DEMO_NOTE : '',
|
|
557
822
|
]
|
|
558
823
|
.filter(Boolean)
|
|
@@ -564,7 +829,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
564
829
|
const detail = await deps.backend.getHotel(wanted);
|
|
565
830
|
if (!detail) {
|
|
566
831
|
return {
|
|
567
|
-
structured: { view: 'hotel-not-found', ...common(deps), query: wanted },
|
|
832
|
+
structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
|
|
568
833
|
text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
|
|
569
834
|
};
|
|
570
835
|
}
|
|
@@ -572,7 +837,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
572
837
|
return {
|
|
573
838
|
structured: {
|
|
574
839
|
view: 'extras',
|
|
575
|
-
...common(deps),
|
|
840
|
+
...common(deps, key),
|
|
576
841
|
hotel: detail.hotel,
|
|
577
842
|
extras: extras.map((e) => ({
|
|
578
843
|
...e,
|
|
@@ -593,7 +858,12 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
593
858
|
const detail = await deps.backend.getHotel(wanted);
|
|
594
859
|
if (!detail) {
|
|
595
860
|
return {
|
|
596
|
-
structured: {
|
|
861
|
+
structured: {
|
|
862
|
+
view: 'error',
|
|
863
|
+
...common(deps, key),
|
|
864
|
+
error: 'hotel-not-found',
|
|
865
|
+
query: wanted,
|
|
866
|
+
},
|
|
597
867
|
text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
|
|
598
868
|
};
|
|
599
869
|
}
|
|
@@ -604,10 +874,27 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
604
874
|
const extra = extras.find((e) => e.id === extraRef || e.name.toLowerCase() === extraRef.toLowerCase());
|
|
605
875
|
if (!extra) {
|
|
606
876
|
return {
|
|
607
|
-
structured: {
|
|
877
|
+
structured: {
|
|
878
|
+
view: 'error',
|
|
879
|
+
...common(deps, key),
|
|
880
|
+
error: 'no-such-extra',
|
|
881
|
+
query: extraRef,
|
|
882
|
+
},
|
|
608
883
|
text: `${detail.hotel.name} does not offer that. ${t.listExtras} lists what it has.`,
|
|
609
884
|
};
|
|
610
885
|
}
|
|
886
|
+
const clash = await currencyClash(deps, key, detail.hotel);
|
|
887
|
+
if (clash) {
|
|
888
|
+
return {
|
|
889
|
+
structured: {
|
|
890
|
+
view: 'error',
|
|
891
|
+
...common(deps, key),
|
|
892
|
+
error: 'mixed-currency',
|
|
893
|
+
detail: clash,
|
|
894
|
+
},
|
|
895
|
+
text: clash,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
611
898
|
const line = {
|
|
612
899
|
kind: 'extra',
|
|
613
900
|
hotelId,
|
|
@@ -616,12 +903,12 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
616
903
|
...(isDay(str(args.day)) ? { day: str(args.day) } : {}),
|
|
617
904
|
};
|
|
618
905
|
deps.trips.set(key, line);
|
|
619
|
-
return currentTrip(deps, key);
|
|
906
|
+
return currentTrip(deps, key, identity(deps, ctx, key));
|
|
620
907
|
}
|
|
621
908
|
const roomRef = str(args.room);
|
|
622
909
|
if (!roomRef) {
|
|
623
910
|
return {
|
|
624
|
-
structured: { view: 'error', ...common(deps), error: 'no-room-named' },
|
|
911
|
+
structured: { view: 'error', ...common(deps, key), error: 'no-room-named' },
|
|
625
912
|
text: `Say which room type, or which add-on. ${t.getHotel} with the dates lists the room types.`,
|
|
626
913
|
};
|
|
627
914
|
}
|
|
@@ -631,19 +918,24 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
631
918
|
// desk. The trip stays as it was and the answer asks.
|
|
632
919
|
if (!stay) {
|
|
633
920
|
return {
|
|
634
|
-
structured: {
|
|
921
|
+
structured: {
|
|
922
|
+
view: 'needs-dates',
|
|
923
|
+
...common(deps, key),
|
|
924
|
+
hotel: detail.hotel,
|
|
925
|
+
room: roomRef,
|
|
926
|
+
},
|
|
635
927
|
text: 'Which nights? A room needs a check-in and a check-out date before it can go into the trip.',
|
|
636
928
|
};
|
|
637
929
|
}
|
|
638
930
|
// The backend owns the calendar - it knows each hotel's own timezone, and a
|
|
639
931
|
// second opinion computed from this process's clock would disagree with it
|
|
640
932
|
// for eight hours a day.
|
|
641
|
-
const dated = await deps.backend.getHotel(hotelId, stay);
|
|
933
|
+
const dated = await deps.backend.getHotel(hotelId, stay, identity(deps, ctx, key));
|
|
642
934
|
if (!dated?.rooms) {
|
|
643
935
|
return {
|
|
644
936
|
structured: {
|
|
645
937
|
view: 'needs-dates',
|
|
646
|
-
...common(deps),
|
|
938
|
+
...common(deps, key),
|
|
647
939
|
hotel: detail.hotel,
|
|
648
940
|
room: roomRef,
|
|
649
941
|
why: dated?.noRooms,
|
|
@@ -656,7 +948,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
656
948
|
return {
|
|
657
949
|
structured: {
|
|
658
950
|
view: 'error',
|
|
659
|
-
...common(deps),
|
|
951
|
+
...common(deps, key),
|
|
660
952
|
error: 'no-such-room',
|
|
661
953
|
query: roomRef,
|
|
662
954
|
rooms: dated.rooms.map((r) => ({ id: r.id, name: r.name })),
|
|
@@ -664,6 +956,24 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
664
956
|
text: `${detail.hotel.name} has no room type by that name for those nights. It has: ${dated.rooms.map((r) => r.name).join(', ')}.`,
|
|
665
957
|
};
|
|
666
958
|
}
|
|
959
|
+
// ⚠️ A LINE THAT WILL NEVER PRICE DOES NOT GO IN. The quote drops a hotel
|
|
960
|
+
// billing in another currency - no exchange rate is invented here - but
|
|
961
|
+
// until now the line still went into the trip, so the guest carried
|
|
962
|
+
// something that was silently dropped from every total from then on
|
|
963
|
+
// (2026-09-18). Refused here, with the trip left exactly as it was; the
|
|
964
|
+
// quote's own drop stays as the backstop for a hotel whose currency moves.
|
|
965
|
+
const wrongCurrency = await currencyClash(deps, key, detail.hotel);
|
|
966
|
+
if (wrongCurrency) {
|
|
967
|
+
return {
|
|
968
|
+
structured: {
|
|
969
|
+
view: 'error',
|
|
970
|
+
...common(deps, key),
|
|
971
|
+
error: 'mixed-currency',
|
|
972
|
+
detail: wrongCurrency,
|
|
973
|
+
},
|
|
974
|
+
text: wrongCurrency,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
667
977
|
deps.trips.set(key, {
|
|
668
978
|
kind: 'room',
|
|
669
979
|
hotelId,
|
|
@@ -671,14 +981,14 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
671
981
|
stay,
|
|
672
982
|
rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
|
|
673
983
|
});
|
|
674
|
-
return currentTrip(deps, key);
|
|
984
|
+
return currentTrip(deps, key, identity(deps, ctx, key));
|
|
675
985
|
}
|
|
676
986
|
if (name === t.viewTrip)
|
|
677
|
-
return currentTrip(deps, key);
|
|
987
|
+
return currentTrip(deps, key, identity(deps, ctx, key));
|
|
678
988
|
if (name === t.clearTrip) {
|
|
679
989
|
deps.trips.clear(key);
|
|
680
990
|
return {
|
|
681
|
-
structured: { view: 'trip', ...common(deps), lines: [], count: 0, total: null },
|
|
991
|
+
structured: { view: 'trip', ...common(deps, key), lines: [], count: 0, total: null },
|
|
682
992
|
text: 'The trip is empty.',
|
|
683
993
|
};
|
|
684
994
|
}
|
|
@@ -686,11 +996,18 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
686
996
|
const lines = deps.trips.read(key);
|
|
687
997
|
if (lines.length === 0) {
|
|
688
998
|
return {
|
|
689
|
-
structured: { view: 'empty-trip', ...common(deps) },
|
|
999
|
+
structured: { view: 'empty-trip', ...common(deps, key) },
|
|
690
1000
|
text: `There is nothing in the trip yet. ${t.addToTrip} puts room nights in it.`,
|
|
691
1001
|
};
|
|
692
1002
|
}
|
|
693
1003
|
const guest = { name: str(args.name), email: str(args.email) };
|
|
1004
|
+
const missing = [!guest.name && 'a name', !guest.email.includes('@') && 'an email'].filter((x) => typeof x === 'string');
|
|
1005
|
+
if (missing.length) {
|
|
1006
|
+
return {
|
|
1007
|
+
structured: { view: 'needs-guest', ...common(deps, key), missing },
|
|
1008
|
+
text: `The booking needs ${missing.join(' and ')}. Ask the guest for ${missing.length > 1 ? 'them' : 'it'} - do not use a name or email you know from anywhere else.`,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
694
1011
|
try {
|
|
695
1012
|
const booking = await deps.backend.requestBooking({ lines }, {
|
|
696
1013
|
...guest,
|
|
@@ -701,7 +1018,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
701
1018
|
return {
|
|
702
1019
|
structured: {
|
|
703
1020
|
view: 'booking',
|
|
704
|
-
...common(deps),
|
|
1021
|
+
...common(deps, key),
|
|
705
1022
|
booking: { ...booking, quote: quoteForCard(booking.quote) },
|
|
706
1023
|
paid: false,
|
|
707
1024
|
holdUntil: booking.holdUntil,
|
|
@@ -720,7 +1037,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
720
1037
|
return {
|
|
721
1038
|
structured: {
|
|
722
1039
|
view: 'error',
|
|
723
|
-
...common(deps),
|
|
1040
|
+
...common(deps, key),
|
|
724
1041
|
error: 'not-bookable',
|
|
725
1042
|
detail: err.message,
|
|
726
1043
|
},
|
|
@@ -729,11 +1046,11 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
729
1046
|
}
|
|
730
1047
|
}
|
|
731
1048
|
if (name === t.listBookings) {
|
|
732
|
-
const bookings = await deps.backend.listBookings(identity(ctx, args), typeof args.limit === 'number' ? args.limit : undefined);
|
|
1049
|
+
const bookings = await deps.backend.listBookings(identity(deps, ctx, key, args), typeof args.limit === 'number' ? args.limit : undefined);
|
|
733
1050
|
return {
|
|
734
1051
|
structured: {
|
|
735
1052
|
view: 'bookings',
|
|
736
|
-
...common(deps),
|
|
1053
|
+
...common(deps, key),
|
|
737
1054
|
bookings: bookings.map((b) => ({ ...b, quote: quoteForCard(b.quote) })),
|
|
738
1055
|
count: bookings.length,
|
|
739
1056
|
},
|
|
@@ -744,18 +1061,18 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
744
1061
|
}
|
|
745
1062
|
if (name === t.previewCancellation || name === t.cancelBooking) {
|
|
746
1063
|
const ref = str(args.booking);
|
|
747
|
-
const who = identity(ctx, args);
|
|
1064
|
+
const who = identity(deps, ctx, key, args);
|
|
748
1065
|
const preview = await deps.backend.previewCancellation(ref, who);
|
|
749
1066
|
if (!preview) {
|
|
750
1067
|
return {
|
|
751
|
-
structured: { view: 'booking-not-found', ...common(deps), query: ref },
|
|
1068
|
+
structured: { view: 'booking-not-found', ...common(deps, key), query: ref },
|
|
752
1069
|
text: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
|
|
753
1070
|
};
|
|
754
1071
|
}
|
|
755
1072
|
const asPreview = {
|
|
756
1073
|
structured: {
|
|
757
1074
|
view: 'cancellation-preview',
|
|
758
|
-
...common(deps),
|
|
1075
|
+
...common(deps, key),
|
|
759
1076
|
booking: { id: preview.bookingId, confirmationCode: preview.confirmationCode },
|
|
760
1077
|
fee: formatMoney(preview.fee),
|
|
761
1078
|
refund: formatMoney(preview.refund),
|
|
@@ -787,7 +1104,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
787
1104
|
return {
|
|
788
1105
|
structured: {
|
|
789
1106
|
view: 'cancelled',
|
|
790
|
-
...common(deps),
|
|
1107
|
+
...common(deps, key),
|
|
791
1108
|
booking: { ...cancelled, quote: quoteForCard(cancelled.quote) },
|
|
792
1109
|
fee: formatMoney(preview.fee),
|
|
793
1110
|
refund: formatMoney(preview.refund),
|
|
@@ -797,12 +1114,129 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
797
1114
|
text: `${cancelled.confirmationCode} is cancelled. ${preview.fee.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(preview.fee)}.`}`,
|
|
798
1115
|
};
|
|
799
1116
|
}
|
|
1117
|
+
if (name === t.compare) {
|
|
1118
|
+
const asks = Array.isArray(args.entries) ? args.entries : [];
|
|
1119
|
+
const comparison = await compareHotels(deps.backend, asks.map((e) => ({
|
|
1120
|
+
hotel: str(e.hotel),
|
|
1121
|
+
bestFor: typeof e.bestFor === 'string' ? e.bestFor : undefined,
|
|
1122
|
+
pros: e.pros,
|
|
1123
|
+
cons: e.cons,
|
|
1124
|
+
})), {
|
|
1125
|
+
stay: asStay(args),
|
|
1126
|
+
recommended: str(args.recommended) || undefined,
|
|
1127
|
+
title: str(args.title) || undefined,
|
|
1128
|
+
});
|
|
1129
|
+
if (comparison.columns.length === 0) {
|
|
1130
|
+
return {
|
|
1131
|
+
structured: {
|
|
1132
|
+
view: 'nothing-to-compare',
|
|
1133
|
+
...common(deps, key),
|
|
1134
|
+
dropped: comparison.dropped,
|
|
1135
|
+
},
|
|
1136
|
+
text: `None of those are ${deps.vocab.brand} ${deps.vocab.nounPlural}: ${comparison.dropped.map((d) => d.name).join(', ')}.`,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
structured: {
|
|
1141
|
+
view: 'comparison',
|
|
1142
|
+
...common(deps, key),
|
|
1143
|
+
...comparison,
|
|
1144
|
+
columns: comparison.columns.map((c) => ({
|
|
1145
|
+
...c,
|
|
1146
|
+
hotel: { ...c.hotel, fromNightly: undefined },
|
|
1147
|
+
})),
|
|
1148
|
+
},
|
|
1149
|
+
text: [
|
|
1150
|
+
// The card carries the table; the text carries only what the model
|
|
1151
|
+
// could not see before it asked - what was dropped, and why.
|
|
1152
|
+
`${comparison.columns.length} side by side${comparison.stay ? ` for ${comparison.stay.checkIn} to ${comparison.stay.checkOut}` : ''}.`,
|
|
1153
|
+
// ⚠️ SAY IT IN THE TEXT TOO. The card carries the warning for whoever is
|
|
1154
|
+
// looking at it; this line is for the model, which is about to describe
|
|
1155
|
+
// the table in prose and would otherwise read "¥67,500" against
|
|
1156
|
+
// "€1,060" as a price difference and say which one is cheaper.
|
|
1157
|
+
comparison.currencies
|
|
1158
|
+
? `These are priced in different currencies (${comparison.currencies.join(', ')}) and nothing here converts them — do not call one cheaper than another.`
|
|
1159
|
+
: '',
|
|
1160
|
+
...comparison.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
|
|
1161
|
+
deps.vocab.demo ? DEMO_NOTE : '',
|
|
1162
|
+
]
|
|
1163
|
+
.filter(Boolean)
|
|
1164
|
+
.join('\n'),
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
if (name === t.itinerary) {
|
|
1168
|
+
const days = Array.isArray(args.days) ? args.days : [];
|
|
1169
|
+
const plan = await buildItinerary(deps.backend, days.map((d) => ({
|
|
1170
|
+
label: str(d.label),
|
|
1171
|
+
note: typeof d.note === 'string' ? d.note : undefined,
|
|
1172
|
+
items: d.items ?? [],
|
|
1173
|
+
})), { title: str(args.title), stay: asStay(args), trip: deps.trips.read(key) });
|
|
1174
|
+
if (plan.days.length === 0) {
|
|
1175
|
+
return {
|
|
1176
|
+
structured: { view: 'empty-itinerary', ...common(deps, key) },
|
|
1177
|
+
text: 'An itinerary needs at least one day with a label.',
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
// ⚠️ THE SENTENCE THE PLAN MUST NOT BE ALLOWED TO SKIP. A four-day plan
|
|
1181
|
+
// with one room actually added looks exactly like a booking unless
|
|
1182
|
+
// somebody says otherwise, and this counts it rather than describing it.
|
|
1183
|
+
const gap = plan.planned === 0
|
|
1184
|
+
? 'Nothing on this plan is in the trip yet.'
|
|
1185
|
+
: plan.inTrip === plan.planned
|
|
1186
|
+
? `All ${plan.planned} are in the trip.`
|
|
1187
|
+
: `${plan.inTrip} of ${plan.planned} are in the trip; the rest are a plan, not a booking.`;
|
|
1188
|
+
return {
|
|
1189
|
+
structured: { view: 'itinerary', ...common(deps, key), ...plan, gap },
|
|
1190
|
+
text: [
|
|
1191
|
+
`**${plan.title}** · ${plan.days.length} ${plan.days.length === 1 ? 'day' : 'days'}. ${gap}`,
|
|
1192
|
+
...plan.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
|
|
1193
|
+
deps.vocab.demo ? DEMO_NOTE : '',
|
|
1194
|
+
]
|
|
1195
|
+
.filter(Boolean)
|
|
1196
|
+
.join('\n'),
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
if (name === t.signIn) {
|
|
1200
|
+
const number = str(args.number);
|
|
1201
|
+
const surname = str(args.surname);
|
|
1202
|
+
const member = await deps.backend.signInMember(number, surname);
|
|
1203
|
+
if (!member) {
|
|
1204
|
+
// ⚠️ NO TIER FOR A NUMBER THAT DID NOT MATCH. Being generous here - "close
|
|
1205
|
+
// enough, have the discount" - is inventing a membership, and the guest
|
|
1206
|
+
// would find out at the desk.
|
|
1207
|
+
return {
|
|
1208
|
+
structured: { view: 'member-not-found', ...common(deps, key), query: number },
|
|
1209
|
+
text: `No ${deps.vocab.brand} membership matches that number and surname. Check both as they are printed; nothing else is needed - this connector never asks for a password.`,
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
deps.members.set(key, member);
|
|
1213
|
+
return {
|
|
1214
|
+
structured: { view: 'member', ...common(deps, key), member },
|
|
1215
|
+
text: [
|
|
1216
|
+
`Signed in as ${member.name} · ${member.programme} ${member.tier.name} · ${member.points.toLocaleString('en-GB')} points.`,
|
|
1217
|
+
`Rates now show the ${member.tier.name} member rate, ${Math.round(member.tier.discount * 100)}% off the public one.`,
|
|
1218
|
+
deps.vocab.demo ? DEMO_NOTE : '',
|
|
1219
|
+
]
|
|
1220
|
+
.filter(Boolean)
|
|
1221
|
+
.join(' '),
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
if (name === t.signOut) {
|
|
1225
|
+
const was = deps.members.read(key);
|
|
1226
|
+
deps.members.clear(key);
|
|
1227
|
+
return {
|
|
1228
|
+
structured: { view: 'signed-out', ...common(deps, key) },
|
|
1229
|
+
text: was
|
|
1230
|
+
? `Signed out of ${was.programme}. Rates are the public ones again; the trip and any bookings are untouched.`
|
|
1231
|
+
: 'Nobody was signed in.',
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
800
1234
|
if (name === t.policies) {
|
|
801
1235
|
const wanted = str(args.hotel);
|
|
802
1236
|
const detail = await deps.backend.getHotel(wanted);
|
|
803
1237
|
if (!detail) {
|
|
804
1238
|
return {
|
|
805
|
-
structured: { view: 'hotel-not-found', ...common(deps), query: wanted },
|
|
1239
|
+
structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
|
|
806
1240
|
text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
|
|
807
1241
|
};
|
|
808
1242
|
}
|
|
@@ -814,7 +1248,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
814
1248
|
return {
|
|
815
1249
|
structured: {
|
|
816
1250
|
view: 'policy-missing',
|
|
817
|
-
...common(deps),
|
|
1251
|
+
...common(deps, key),
|
|
818
1252
|
hotel: detail.hotel,
|
|
819
1253
|
topic,
|
|
820
1254
|
topics: policies.map((p) => ({ topic: p.topic, title: p.title })),
|
|
@@ -825,11 +1259,11 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
825
1259
|
text: `${detail.hotel.name} does not publish anything about that here.${detail.hotel.url ? ` Its own page: ${detail.hotel.url}` : ''}`,
|
|
826
1260
|
};
|
|
827
1261
|
}
|
|
828
|
-
const fenced = hotelText(one.title, detail.hotel.name, one.text, POLICY_MAX);
|
|
1262
|
+
const fenced = hotelText(one.title, detail.hotel.name, one.text, POLICY_MAX, deps.vocab.demo);
|
|
829
1263
|
return {
|
|
830
1264
|
structured: {
|
|
831
1265
|
view: 'policy',
|
|
832
|
-
...common(deps),
|
|
1266
|
+
...common(deps, key),
|
|
833
1267
|
hotel: detail.hotel,
|
|
834
1268
|
topic: one.topic,
|
|
835
1269
|
title: one.title,
|
|
@@ -840,12 +1274,12 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
840
1274
|
};
|
|
841
1275
|
}
|
|
842
1276
|
const all = policies
|
|
843
|
-
.map((p) => hotelText(p.title, detail.hotel.name, p.text, POLICY_MAX).fenced)
|
|
1277
|
+
.map((p) => hotelText(p.title, detail.hotel.name, p.text, POLICY_MAX, deps.vocab.demo).fenced)
|
|
844
1278
|
.join('\n\n');
|
|
845
1279
|
return {
|
|
846
1280
|
structured: {
|
|
847
1281
|
view: 'policies',
|
|
848
|
-
...common(deps),
|
|
1282
|
+
...common(deps, key),
|
|
849
1283
|
hotel: detail.hotel,
|
|
850
1284
|
topics: policies.map((p) => ({
|
|
851
1285
|
topic: p.topic,
|