@agent-cards/commerce-sdk 0.15.0 → 0.17.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/README.md +56 -9
- package/dist/ack.d.ts +40 -20
- package/dist/ack.js +93 -5
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +2 -0
- package/dist/buy-tool.d.ts +61 -15
- package/dist/buy-tool.js +491 -41
- package/dist/client.d.ts +38 -0
- package/dist/client.js +15 -1
- package/dist/definition.js +3 -0
- package/dist/direct.d.ts +16 -3
- package/dist/direct.js +34 -7
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/messages.d.ts +27 -6
- package/dist/messages.js +72 -12
- package/dist/model-view.d.ts +51 -4
- package/dist/model-view.js +392 -42
- package/dist/order.d.ts +119 -0
- package/dist/order.js +155 -0
- package/dist/sentences.d.ts +16 -0
- package/dist/sentences.js +57 -0
- package/dist/shopper-message.d.ts +10 -0
- package/dist/shopper-message.js +51 -11
- package/dist/texting.d.ts +83 -0
- package/dist/texting.js +352 -0
- package/package.json +1 -1
package/dist/model-view.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { SENTENCE_END, lastSentenceStart, leadsIn, withoutLeadIn } from './sentences.js';
|
|
2
|
+
import { toMessages } from './messages.js';
|
|
1
3
|
/** The steps the shopper takes on a page a link opens: the message that asks for one is one short line. */
|
|
2
4
|
const STEP_ACTIONS = new Set(['store_login', 'add_card', 'approve']);
|
|
3
5
|
const MODEL_STATUSES = new Set(['needs_input', 'needs_card', 'approval_sent', 'placing', 'order_placed', 'declined']);
|
|
@@ -237,6 +239,10 @@ export function forModel(result) {
|
|
|
237
239
|
status,
|
|
238
240
|
next_action: result.next_action ?? null,
|
|
239
241
|
...(result.cart_building ? { cart_building: { store: stripLinks(result.cart_building.store) } } : {}),
|
|
242
|
+
...(result.choice ? { choice: {
|
|
243
|
+
question: result.choice.question === null ? null : stripLinks(result.choice.question),
|
|
244
|
+
groups: result.choice.groups.map((group) => ({ name: stripLinks(group.name), required: group.required, options: group.options.map((option) => `${stripLinks(option.label)}${option.price_delta_cents ? ` (+$${(option.price_delta_cents / 100).toFixed(2)})` : ''}`) })),
|
|
245
|
+
} } : {}),
|
|
240
246
|
reply: replyWithoutLinks(result.reply, products.some(held) || stores.some(heldStore) || result.link_sent === true),
|
|
241
247
|
cart: result.cart ? cartView(result.cart) : null,
|
|
242
248
|
carts: (result.carts ?? []).map(cartView),
|
|
@@ -261,8 +267,73 @@ export function forModel(result) {
|
|
|
261
267
|
store_pick: result.store_pick && stripLinks(result.store_pick.store_id) === result.store_pick.store_id
|
|
262
268
|
? { store_id: result.store_pick.store_id, selected: result.store_pick.selected === true }
|
|
263
269
|
: null,
|
|
270
|
+
...(yesCarriedView(result) ? { yes_carried: yesCarriedView(result) } : {}),
|
|
264
271
|
};
|
|
265
272
|
}
|
|
273
|
+
/** The yes an answer went ahead on, as the model reads it, or null. */
|
|
274
|
+
function yesCarriedView(result) {
|
|
275
|
+
const yes = result.yes_carried;
|
|
276
|
+
if (!yes || typeof yes.said_yes_to_cents !== 'number' || typeof yes.total_cents !== 'number')
|
|
277
|
+
return null;
|
|
278
|
+
const said = money(yes.said_yes_to_cents, yes.currency);
|
|
279
|
+
const total = money(yes.total_cents, yes.currency);
|
|
280
|
+
return said && total ? { said_yes_to: said, total, total_changed: yes.said_yes_to_cents !== yes.total_cents, ...(yes.picked === true ? { picked: true } : {}) } : null;
|
|
281
|
+
}
|
|
282
|
+
/** The link each step's ask goes with. */
|
|
283
|
+
const STEP_LINK = { add_card: 'vault', approve: 'approval', store_login: 'store_login' };
|
|
284
|
+
/** The items of a cart in one line, such as `1 Chicken Salad` or `2 tacos and 1 horchata`. */
|
|
285
|
+
function itemsLine(cart) {
|
|
286
|
+
const lines = cart.items.map((line) => `${line.qty} ${line.name}`);
|
|
287
|
+
return lines.length <= 1 ? (lines[0] ?? '') : `${lines.slice(0, -1).join(', ')} and ${lines[lines.length - 1]}`;
|
|
288
|
+
}
|
|
289
|
+
/** A cart's total as the shopper reads it: `$17.43`, or `about $15.90 before tax and shipping or delivery` for an estimate. */
|
|
290
|
+
function totalLine(cart) {
|
|
291
|
+
if (!cart.total_is_estimate)
|
|
292
|
+
return cart.total;
|
|
293
|
+
const before = cart.total_leaves_out ? ` before ${cart.total_leaves_out.replace(/,.*$/, '')}` : '';
|
|
294
|
+
return `about ${cart.total}${before}`;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The fixed short line for an answer whose next step is a link the shopper already holds: `add your card to place the
|
|
298
|
+
* order`, `sign in to DoorDash to place your order`, or `approve the payment to place your order (up to $19.02)`, with
|
|
299
|
+
* the most the shopper can be charged in the parentheses when the cart names it. After the shopper's own words put the
|
|
300
|
+
* cart together, a line about the cart comes first, as its own message. After a yes the turn carried past a step outside
|
|
301
|
+
* the chat, a total that changed opens the ask: `it's $17.89 with fees and tax now. approve the payment ...`. Null for any
|
|
302
|
+
* other answer, when the link did not go out, when the shopper already read Agentcard's own ask through `onText`, when a
|
|
303
|
+
* card or approval step has no cart to place, and when the answer also showed products.
|
|
304
|
+
*/
|
|
305
|
+
export function stepAsWritten(result) {
|
|
306
|
+
if (result.shopper_already_read_reply === true || result.link_sent !== true)
|
|
307
|
+
return null;
|
|
308
|
+
const next = result.next_action ?? '';
|
|
309
|
+
const kind = STEP_LINK[next];
|
|
310
|
+
const link = kind ? (result.links ?? []).find((l) => l.kind === kind && typeof l.url === 'string' && l.url !== '') : undefined;
|
|
311
|
+
if (!kind || !link)
|
|
312
|
+
return null;
|
|
313
|
+
const view = forModel(result);
|
|
314
|
+
const cart = view.cart;
|
|
315
|
+
// The line is about placing an order: an answer with no cart to place, such as the card link the shopper asked for while
|
|
316
|
+
// browsing, or one that also showed products the reply talks about, is the model's to word.
|
|
317
|
+
if ((!cart && next !== 'store_login') || view.products.length > 0)
|
|
318
|
+
return null;
|
|
319
|
+
const store = stripLinks(link.store ?? '') || cart?.store || 'the store';
|
|
320
|
+
const ask = next === 'add_card'
|
|
321
|
+
? 'add your card to place the order'
|
|
322
|
+
: next === 'store_login'
|
|
323
|
+
? `sign in to ${store} to place your order`
|
|
324
|
+
: `approve the payment to place your order${cart?.max_charge ? ` (up to ${cart.max_charge})` : ''}`;
|
|
325
|
+
const yes = view.yes_carried;
|
|
326
|
+
if (yes?.total_changed && cart) {
|
|
327
|
+
const total = cart.total_is_estimate ? `about ${cart.total}` : `${cart.total} with fees and tax`;
|
|
328
|
+
return `it's ${total} now. ${ask}`;
|
|
329
|
+
}
|
|
330
|
+
// Only the shopper's own words that built the cart get a line about it first; after a yes or a saved card, the ask is the whole message.
|
|
331
|
+
const agentcards = typeof result.envelope?.reply === 'string' && result.envelope.reply === result.reply;
|
|
332
|
+
if (agentcards && result.confirm_sent !== true && !yes && cart && cart.items.length > 0 && next !== 'approve') {
|
|
333
|
+
return `${itemsLine(cart)} from ${cart.store}, ${totalLine(cart)}\n\n${ask}`;
|
|
334
|
+
}
|
|
335
|
+
return ask;
|
|
336
|
+
}
|
|
266
337
|
/**
|
|
267
338
|
* Agentcard's reply, ready for the shopper as written, when the answer only asks them something: a
|
|
268
339
|
* `needs_input` answer with a reply, such as a product list, a size or a store to pick, or a cart's
|
|
@@ -271,6 +342,8 @@ export function forModel(result) {
|
|
|
271
342
|
* itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
|
|
272
343
|
* shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
|
|
273
344
|
* Null as well once the shopper read the reply through `onText` (`shopper_already_read_reply`).
|
|
345
|
+
* An answer whose next step is a link the call handed over reads as that step's fixed short line
|
|
346
|
+
* instead (`stepAsWritten`), whoever wrote the reply.
|
|
274
347
|
*
|
|
275
348
|
* ```ts
|
|
276
349
|
* replyAsWritten(result); // 'found two at Amazon. want the first one?'
|
|
@@ -280,13 +353,20 @@ export function replyAsWritten(result) {
|
|
|
280
353
|
// The shopper already read every message of it through onText: handing it out again would say it twice.
|
|
281
354
|
if (result.shopper_already_read_reply === true)
|
|
282
355
|
return null;
|
|
356
|
+
// A step whose link the shopper already holds is asked for in the same short line every time, with no model.
|
|
357
|
+
const step = stepAsWritten(result);
|
|
358
|
+
if (step !== null)
|
|
359
|
+
return step;
|
|
283
360
|
// The tool's own lines tell the model what to say, so only Agentcard's reply, unchanged, goes out as is.
|
|
284
361
|
const agentcards = typeof result.envelope?.reply === 'string' && result.envelope.reply === result.reply;
|
|
285
362
|
const view = forModel(result);
|
|
286
363
|
const reply = view.reply.trim();
|
|
287
364
|
if (!agentcards || view.status !== 'needs_input' || reply === '')
|
|
288
365
|
return null;
|
|
289
|
-
const cards = [
|
|
366
|
+
const cards = [
|
|
367
|
+
...view.products.filter((product) => product.link_sent).map((product) => ({ name: product.name, price: product.price })),
|
|
368
|
+
...view.stores.filter((store) => store.link_sent).map((store) => ({ name: store.name })),
|
|
369
|
+
];
|
|
290
370
|
// A reply with nothing left once the cards' list comes out is your model's to word.
|
|
291
371
|
return withoutCardList(reply, cards) || null;
|
|
292
372
|
}
|
|
@@ -294,8 +374,29 @@ export function replyAsWritten(result) {
|
|
|
294
374
|
const LIST_ITEM = /^\s*(?:[-*•]|\d{1,2}[.)])\s+/;
|
|
295
375
|
/** A price, in any of the currencies a store writes one in. */
|
|
296
376
|
const PRICE = /[$£€]\s?\d/;
|
|
377
|
+
/**
|
|
378
|
+
* A mark that numbers or bullets an item in the middle of a line, such as the `2)` in `1) a mug; 2) a soap`, or a `•`.
|
|
379
|
+
* A number counts only in a run that goes 1, 2, 3, so a size such as `(Pack of 12) and` marks nothing.
|
|
380
|
+
*/
|
|
381
|
+
const INLINE_MARK = /(?<=^|[\s:;,(])(?:(\d{1,2})([.)])|•)[ \t]+/g;
|
|
382
|
+
/**
|
|
383
|
+
* A sentence that asks the shopper something: a question, `let me know ...` or `tell me ...` anywhere in it, as in
|
|
384
|
+
* `amazon has the best price, so let me know how many you want.`, or one that opens with `pick ...` or `reply ...`.
|
|
385
|
+
*/
|
|
386
|
+
const ASKS = /\?[\s"'*_)\]]*$|\b(?:let me know|tell me)\b|^[\s*_"'(]*(?:just (?:say|tell|reply|pick)|reply|pick|choose)\b/i;
|
|
387
|
+
/** A clause that turns from a product to the shopper, after a comma, a semicolon or a dash: `, which one would you like?`. */
|
|
388
|
+
const ASKING_CLAUSE = /(?:[,;]|\s[-–])\s+(?=(?:(?:and|or|so|but)\s+)?(?:which|what|want|would|do|does|should|shall|can|could|how|any|let me know|tell me|just)\b)/gi;
|
|
297
389
|
/** The place a sentence points at in a numbered list, such as `#8` or `number 3`, where `a #2 Phillips head` names a kind of thing. */
|
|
298
390
|
const LIST_NUMBER = /(?<!\ba )(?<!\ban )(?:#(\d{1,2})\b|\bnumber (\d{1,2})\b)/i;
|
|
391
|
+
/**
|
|
392
|
+
* A sentence that goes on to more of the list rather than recommending: `other 6-packs are ...`, `other options include ...`,
|
|
393
|
+
* `there's also ...`, `you could also ...`. One that names a card is the list, whatever the reply recommended before it.
|
|
394
|
+
*/
|
|
395
|
+
const MORE_OF_LIST = /^[\s*_"'(]*(?:(?:and|plus|but|or)\s+)?(?:others?\b[^.!?:]{0,40}?\b(?:are|is|include|includes|would be)\b|there(?:'s|’s|\s+is|\s+are)\s+also\b|(?:you|we)\s+(?:could|can|might)\s+also\b)/i;
|
|
396
|
+
/** An amount a text gives after a currency sign, such as `$22.89` or `¥1,331`. */
|
|
397
|
+
const AMOUNT = /[$£€¥]\s?(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?/g;
|
|
398
|
+
/** Words that tell no product from another: a card's brand or its distinctive words are never one of these. */
|
|
399
|
+
const FILLER = new Set(['the', 'and', 'with', 'for', 'from', 'of', 'by', 'in', 'on', 'to', 'or', 'at', 'an', 'new', 'pack', 'count', 'set', 'each', 'size']);
|
|
299
400
|
/** A unit a size or a count is written in, so `10 ft`, `10-ft` and `10ft` read as one word. */
|
|
300
401
|
const UNIT = /(\d)[\s-]+(ft|feet|foot|inch|inches|oz|fl|ml|l|g|kg|lb|lbs|gb|tb|mb|w|mah|v|cm|mm|m|pack|pk|ct|count|pcs|pieces)\b/gi;
|
|
301
402
|
/**
|
|
@@ -305,6 +406,8 @@ const UNIT = /(\d)[\s-]+(ft|feet|foot|inch|inches|oz|fl|ml|l|g|kg|lb|lbs|gb|tb|m
|
|
|
305
406
|
const SAME_UNIT = new Map([['feet', 'ft'], ['foot', 'ft'], ['in', ''], ['inch', ''], ['inches', ''], ['pk', 'pack'], ['ct', 'pack'], ['count', 'pack'], ['pcs', 'pack'], ['pieces', 'pack'], ['lbs', 'lb']]);
|
|
306
407
|
/** A size or a count written as one word, such as `24pack` or `10ft`. */
|
|
307
408
|
const SIZE = /^(\d+)(\p{L}+)$/u;
|
|
409
|
+
/** A size in inches, however a text writes it: `10 inch`, `10-inches`, `10in`, `10 in`. */
|
|
410
|
+
const INCHES = /(\d+)\s*-?\s*(?:inch(?:es)?|in)\b/gi;
|
|
308
411
|
/** A text's words, lowercased, letters and digits only, with markdown's bold marks gone and a size written as one word. */
|
|
309
412
|
function wordsOf(text) {
|
|
310
413
|
return text
|
|
@@ -314,29 +417,225 @@ function wordsOf(text) {
|
|
|
314
417
|
.filter((word) => word !== '')
|
|
315
418
|
.map((word) => word.replace(SIZE, (whole, count, unit) => `${count}${SAME_UNIT.get(unit) ?? unit}`));
|
|
316
419
|
}
|
|
420
|
+
/** An amount in hundredths, so `$26.46`, `from $26.46` and `26.46` all read 2646, and `¥1,331` reads 133100. */
|
|
421
|
+
function hundredths(whole, fraction) {
|
|
422
|
+
return Math.round(Number(`${whole.replace(/,/g, '')}${fraction ?? ''}`) * 100);
|
|
423
|
+
}
|
|
424
|
+
/** The amounts a text gives after a currency sign, in hundredths. */
|
|
425
|
+
function amountsIn(text) {
|
|
426
|
+
return new Set([...text.matchAll(AMOUNT)].map((match) => hundredths(match[1], match[2])));
|
|
427
|
+
}
|
|
317
428
|
/**
|
|
318
|
-
* The
|
|
319
|
-
* text
|
|
320
|
-
*
|
|
429
|
+
* The sizes among a text's words, by unit: `10ft` and `3ft` are two sizes in feet, `24pack` a count. Inches come from
|
|
430
|
+
* the text itself, because `wordsOf` leaves `10 inch` as the bare `10`, so the 10 inch skillet reads as one size in
|
|
431
|
+
* inches and the 12 inch one as another.
|
|
321
432
|
*/
|
|
322
|
-
function
|
|
323
|
-
const
|
|
324
|
-
for (const
|
|
325
|
-
const
|
|
326
|
-
if (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
433
|
+
function sizesOf(words, text) {
|
|
434
|
+
const sizes = new Map();
|
|
435
|
+
for (const word of words) {
|
|
436
|
+
const [, count, unit] = /^(\d+)(\p{L}+)$/u.exec(word) ?? [];
|
|
437
|
+
if (count && unit)
|
|
438
|
+
sizes.set(unit, (sizes.get(unit) ?? new Set()).add(count));
|
|
439
|
+
}
|
|
440
|
+
for (const [, count] of text.matchAll(INCHES))
|
|
441
|
+
sizes.set('inch', (sizes.get('inch') ?? new Set()).add(count));
|
|
442
|
+
return sizes;
|
|
443
|
+
}
|
|
444
|
+
function readCards(shown) {
|
|
445
|
+
const named = shown
|
|
446
|
+
.map((card) => (typeof card === 'string' ? { name: card, price: null } : { name: String(card?.name ?? ''), price: card?.price ?? null }))
|
|
447
|
+
.map((card) => ({ name: card.name, words: wordsOf(card.name), price: card.price }))
|
|
448
|
+
.filter((card) => card.words.length > 0);
|
|
449
|
+
const holders = new Map();
|
|
450
|
+
for (const card of named)
|
|
451
|
+
for (const word of new Set(card.words))
|
|
452
|
+
holders.set(word, (holders.get(word) ?? 0) + 1);
|
|
453
|
+
const telling = (word) => word.length >= 3 && !/\d/.test(word) && !FILLER.has(word);
|
|
454
|
+
return named.map(({ name, words, price }) => {
|
|
455
|
+
const brand = words.find((word) => /\p{L}/u.test(word) && word.length >= 2 && !FILLER.has(word)) ?? null;
|
|
456
|
+
const amount = typeof price === 'string' ? /(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?/.exec(price) : null;
|
|
457
|
+
return {
|
|
458
|
+
words,
|
|
459
|
+
head: words.slice(0, 6),
|
|
460
|
+
brand,
|
|
461
|
+
marks: new Set([...(brand ? [brand] : []), ...words.filter((word) => telling(word) && (holders.get(word) ?? 0) <= Math.max(1, named.length / 2))]),
|
|
462
|
+
price: amount ? hundredths(amount[1], amount[2]) : null,
|
|
463
|
+
sizes: sizesOf(words, name),
|
|
464
|
+
};
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* The cards a text names, by their indexes; empty when it names none. A text names a card in one of three ways:
|
|
469
|
+
*
|
|
470
|
+
* - By the first six words of the card's name: at least two of them, and most of them, in the text. A name of one word
|
|
471
|
+
* never counts in prose, where a generic word such as `soap` would. A size or a count among those words has to be in
|
|
472
|
+
* the text too, so the 3ft cable's line never reads as the 10ft card.
|
|
473
|
+
* - By the card's exact price with its brand or another word that tells it apart: `Planet Oat Extra Creamy ($22.89)`
|
|
474
|
+
* names the card `Planet Oat Extra Creamy Oatmilk, 32 Fl Oz (Pack of 6)` at $22.89. A price alone, as on a line of
|
|
475
|
+
* sizes, names nothing.
|
|
476
|
+
* - By its brand and two more of its first six words, when the text gives no price or gives the card's.
|
|
477
|
+
*
|
|
478
|
+
* The last two take a short name a reply writes for a long title, and neither counts when the text gives a size the card
|
|
479
|
+
* has in another number, such as 3ft for its 10ft. A text that gives prices, none of them the card's, names it only by
|
|
480
|
+
* the first six words, so a line for a product no card shows keeps its place beside the card of its brand.
|
|
481
|
+
* 2026-10-27, "Oat milk, shelf stable, a 6 pack": "Planet Oat Extra Creamy ($22.89)" shares too few of its title's first
|
|
482
|
+
* six words to match by them alone.
|
|
483
|
+
*/
|
|
484
|
+
function cardsNamed(text, cards, inList) {
|
|
485
|
+
const words = wordsOf(text);
|
|
486
|
+
// Each word with how often the text says it, so a name of one word twice over, `Miu Miu`, counts twice.
|
|
487
|
+
const said = new Map();
|
|
488
|
+
for (const word of words)
|
|
489
|
+
said.set(word, (said.get(word) ?? 0) + 1);
|
|
490
|
+
const amounts = amountsIn(text);
|
|
491
|
+
const sizes = sizesOf(words, text);
|
|
492
|
+
const found = [...cards.entries()].flatMap(([index, card]) => {
|
|
493
|
+
const { head } = card;
|
|
494
|
+
const shared = head.filter((word) => said.has(word));
|
|
495
|
+
const hit = new Set(shared);
|
|
496
|
+
if (head.every((word) => !/\d/.test(word) || said.has(word))) {
|
|
497
|
+
if (head.length === 1 ? inList && shared.length === 1 : shared.length >= Math.max(2, Math.ceil(head.length * 0.6)))
|
|
498
|
+
return [{ index, hit }];
|
|
499
|
+
}
|
|
500
|
+
const otherSize = [...card.sizes].some(([unit, counts]) => sizes.has(unit) && ![...counts].some((count) => sizes.get(unit).has(count)));
|
|
501
|
+
if (otherSize)
|
|
502
|
+
return [];
|
|
503
|
+
const ownPrice = card.price !== null && amounts.has(card.price);
|
|
504
|
+
if (ownPrice && [...card.marks].some((word) => said.has(word)))
|
|
505
|
+
return [{ index, hit }];
|
|
506
|
+
const otherPrice = amounts.size > 0 && !ownPrice;
|
|
507
|
+
const more = new Set(head.filter((word) => word !== card.brand && word.length >= 2 && !FILLER.has(word) && said.has(word)));
|
|
508
|
+
return !otherPrice && card.brand !== null && said.has(card.brand) && more.size >= 2 ? [{ index, hit }] : [];
|
|
509
|
+
});
|
|
510
|
+
// Cards whose names share their words read as several products where the text names one, as three Kirkland Signature
|
|
511
|
+
// Organic milks do for `Kirkland Signature Organic Almond Milk`: a card the text names in nothing but another card's
|
|
512
|
+
// words, each said once, is that card's mention, and counts for no product of its own. A text that says those words
|
|
513
|
+
// again names both products, as `Diamond Walnut Pieces ($5.99) and Diamond Walnut Pieces Halves ($7.99)` does.
|
|
514
|
+
const saidOnce = (hit) => [...hit].every((word) => said.get(word) === 1);
|
|
515
|
+
return found
|
|
516
|
+
.filter(({ hit }) => hit.size === 0 || !saidOnce(hit) || !found.some((other) => other.hit.size > hit.size && [...hit].every((word) => other.hit.has(word))))
|
|
517
|
+
.map(({ index }) => index);
|
|
518
|
+
}
|
|
519
|
+
/** The sentences of a text from `from` on, each with the place in the text where it starts. */
|
|
520
|
+
function sentencesAt(text, from) {
|
|
521
|
+
const out = [];
|
|
522
|
+
let at = from;
|
|
523
|
+
for (const part of text.slice(from).split(SENTENCE_END)) {
|
|
524
|
+
const start = text.indexOf(part, at);
|
|
525
|
+
if (part !== '')
|
|
526
|
+
out.push({ text: part, at: start });
|
|
527
|
+
at = start + part.length;
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Where the clause that asks the shopper starts in a sentence that also gives a product, as `which one would you like?`
|
|
533
|
+
* does in `2) Mini Soaps Gift Box ($58.00), which one would you like?`, or null when the sentence asks nothing apart.
|
|
534
|
+
*/
|
|
535
|
+
function askingClause(sentence) {
|
|
536
|
+
if (!ASKS.test(sentence))
|
|
537
|
+
return null;
|
|
538
|
+
for (const turn of sentence.matchAll(ASKING_CLAUSE)) {
|
|
539
|
+
const rest = sentence.slice(turn.index + turn[0].length);
|
|
540
|
+
if (ASKS.test(rest) && !PRICE.test(rest))
|
|
541
|
+
return turn.index + turn[0].length;
|
|
331
542
|
}
|
|
332
543
|
return null;
|
|
333
544
|
}
|
|
545
|
+
/** Where the sentence that holds `at` starts: right after the sentence before it, or at the line's start. */
|
|
546
|
+
function sentenceStart(line, at) {
|
|
547
|
+
return lastSentenceStart(line.slice(0, at));
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Where a list on one line stops, given where its last item starts: past that item's first period, through the
|
|
551
|
+
* sentences that only describe it, to the first that asks the shopper or names a card itself, so `8.4 Fl. Oz.` never
|
|
552
|
+
* cuts the item short. A question the item's own sentence asks after a comma, `..., which one?`, stays out of it.
|
|
553
|
+
*/
|
|
554
|
+
function inlineEnd(line, from, cards) {
|
|
555
|
+
const [own, ...rest] = sentencesAt(line, from);
|
|
556
|
+
const clause = own ? askingClause(own.text) : null;
|
|
557
|
+
if (own && clause !== null)
|
|
558
|
+
return own.at + clause;
|
|
559
|
+
const stop = rest.find((sentence) => ASKS.test(sentence.text) || cardsNamed(sentence.text, cards, false).length > 0);
|
|
560
|
+
return stop ? stop.at : line.length;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* A list a reply writes on one line, mid-sentence: `i found a few options: 1) a mug ($19.00); 2) a gift set ($115.00);
|
|
564
|
+
* 3) a soap box ($58.00). which one?`. Its items are numbered `1)` or `1.`, bulleted with `•`, or set apart by `;` with
|
|
565
|
+
* each naming a card, after a colon or three of them at least. The list may open the line, `1) a mug; 2) a candle`, and
|
|
566
|
+
* ends where inlineEnd says. `lead` is where the sentence that leads into the list starts, `end` where the list stops.
|
|
567
|
+
* Null when the line holds no such list.
|
|
568
|
+
*/
|
|
569
|
+
function inlineList(line, cards) {
|
|
570
|
+
const marks = [...line.matchAll(INLINE_MARK)];
|
|
571
|
+
// A numbered run, 1, 2, 3, each marked the same way, or two or more bullets.
|
|
572
|
+
const run = [];
|
|
573
|
+
for (const mark of marks) {
|
|
574
|
+
if (mark[1] !== undefined && Number(mark[1]) === run.length + 1 && (run.length === 0 || run[0][2] === mark[2]))
|
|
575
|
+
run.push(mark);
|
|
576
|
+
}
|
|
577
|
+
const bullets = marks.filter((mark) => mark[1] === undefined);
|
|
578
|
+
const chosen = run.length >= 2 ? run : bullets.length >= 2 ? bullets : [];
|
|
579
|
+
if (chosen.length >= 2) {
|
|
580
|
+
const last = chosen[chosen.length - 1];
|
|
581
|
+
const end = inlineEnd(line, last.index + last[0].length, cards);
|
|
582
|
+
const items = chosen.map((mark, k) => {
|
|
583
|
+
const start = mark.index;
|
|
584
|
+
const raw = line.slice(start, k + 1 < chosen.length ? chosen[k + 1].index : end);
|
|
585
|
+
return { start, raw, body: raw.slice(mark[0].length), number: mark[1] === undefined ? null : Number(mark[1]) };
|
|
586
|
+
});
|
|
587
|
+
return { lead: sentenceStart(line, items[0].start), items, end };
|
|
588
|
+
}
|
|
589
|
+
// Parts set apart by `;` run from the colon that leads into them, found by its place rather than by sentences, so a
|
|
590
|
+
// name such as `8.4 Fl. Oz.` before the first `;` stays whole.
|
|
591
|
+
const semicolon = line.indexOf(';');
|
|
592
|
+
if (semicolon === -1)
|
|
593
|
+
return null;
|
|
594
|
+
const colon = line.lastIndexOf(':', semicolon);
|
|
595
|
+
const led = colon !== -1 && !/[?!]/.test(line.slice(colon, semicolon));
|
|
596
|
+
const opens = led ? colon + 1 : sentenceStart(line, semicolon);
|
|
597
|
+
// The list's last `;` is the last one with an item on its right and no question between, found before the list's end
|
|
598
|
+
// is, so a sentence inside an item, `The Lavender Candle ships free;`, never ends the list early, and a `;` in a
|
|
599
|
+
// question after it, `which one would you like; or should i search again?`, is no part of it. An item names a card
|
|
600
|
+
// the list has not named yet, or names one again with a price, `Travel Mug ($19.00)`; a part that names a listed
|
|
601
|
+
// card with no price is a recommendation after the list, `i'd pick Travel Mug; Lavender Candle is nicer.`, and stays.
|
|
602
|
+
let last = semicolon;
|
|
603
|
+
for (let next = line.indexOf(';', last + 1); next !== -1; next = line.indexOf(';', last + 1)) {
|
|
604
|
+
const after = line.indexOf(';', next + 1);
|
|
605
|
+
const asks = sentencesAt(line.slice(0, next), last + 1).some((sentence) => ASKS.test(sentence.text));
|
|
606
|
+
const listed = new Set(cardsNamed(line.slice(opens, next), cards, true));
|
|
607
|
+
const text = line.slice(next + 1, after === -1 ? line.length : after);
|
|
608
|
+
const part = cardsNamed(text, cards, true);
|
|
609
|
+
const item = part.some((index) => !listed.has(index)) || (part.length > 0 && PRICE.test(sentencesAt(text, 0)[0]?.text ?? ''));
|
|
610
|
+
if (asks || !item)
|
|
611
|
+
break;
|
|
612
|
+
last = next;
|
|
613
|
+
}
|
|
614
|
+
const end = inlineEnd(line, last + 1, cards);
|
|
615
|
+
const parts = line.slice(opens, end).replace(/[\s,;]+$/, '').split(';');
|
|
616
|
+
if ((!led && parts.length < 3) || !parts.every((part) => cardsNamed(part, cards, true).length > 0))
|
|
617
|
+
return null;
|
|
618
|
+
let start = opens;
|
|
619
|
+
const items = parts.map((part) => {
|
|
620
|
+
const item = { start, raw: part, body: part, number: null };
|
|
621
|
+
start += part.length + 1;
|
|
622
|
+
return item;
|
|
623
|
+
});
|
|
624
|
+
return { lead: led ? sentenceStart(line, colon) : opens, items, end };
|
|
625
|
+
}
|
|
334
626
|
/**
|
|
335
627
|
* Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
|
|
336
628
|
* store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
|
|
337
|
-
* points at a number the list gave (`the dishwand in #8`),
|
|
338
|
-
*
|
|
339
|
-
*
|
|
629
|
+
* points at a number the list gave (`the dishwand in #8`), a sentence that goes on to more of the list (`other 6-packs
|
|
630
|
+
* are ...`, `there's also ...`) and names a card, and, past the one or two products a reply recommends in words, each
|
|
631
|
+
* further sentence that names another card. A list written on one line goes the same way, item by item (inlineList),
|
|
632
|
+
* and so does a list line written without a bullet, whole, up to the question on it. What is left is the reply around
|
|
633
|
+
* the list, such as `found these at amazon.` and `which one do you want?`. A reply whose cards went out names none of
|
|
634
|
+
* them twice over. A list that keeps an item for a product no card shows keeps its lead-in, and the two read as one
|
|
635
|
+
* message (toMessages), so the shopper never reads `two options came in under $20:` on its own.
|
|
636
|
+
*
|
|
637
|
+
* Each card is its name, or its name and its price as the model reads it (`{ name, price: '$22.89' }`): with the price,
|
|
638
|
+
* a reply's short name for the product, such as `Planet Oat Extra Creamy ($22.89)`, reads as its card (cardsNamed).
|
|
340
639
|
*
|
|
341
640
|
* ```ts
|
|
342
641
|
* withoutCardList('Two I'd suggest:\n- Duracell AA, 24 pack, $20.78\n\nWhich one?', ['Duracell AA Batteries, 24 Count']);
|
|
@@ -344,33 +643,69 @@ function cardNamed(text, cards, inList) {
|
|
|
344
643
|
* ```
|
|
345
644
|
*/
|
|
346
645
|
export function withoutCardList(reply, cardNames) {
|
|
347
|
-
const cards = cardNames
|
|
646
|
+
const cards = readCards(cardNames);
|
|
348
647
|
if (cards.length === 0)
|
|
349
648
|
return reply.trim();
|
|
350
649
|
// A text that opens with a card's name and gives its price is a list line, as a reply writes one without a bullet.
|
|
351
650
|
const opensCard = (text) => {
|
|
352
651
|
const words = wordsOf(text);
|
|
353
|
-
return PRICE.test(text) && cards.some((
|
|
652
|
+
return PRICE.test(text) && cards.some((card) => card.words.length >= 2 && card.words.slice(0, 3).every((word, i) => words[i] === word));
|
|
653
|
+
};
|
|
654
|
+
// The numbers the dropped list gave, so a number of a product's own, `a #2 Phillips head`, keeps its sentence.
|
|
655
|
+
const numbers = new Set();
|
|
656
|
+
// A list a line writes inline goes the way a list of lines does: each item that names a card, and the sentence that
|
|
657
|
+
// led into the list once every item went. An item for a product no card shows stays, with the lead, and so does a
|
|
658
|
+
// list that opens the line, `1) a mug; 2) a candle; 3) a soap box`, item by item rather than as one list line.
|
|
659
|
+
const unlisted = (line) => {
|
|
660
|
+
const list = inlineList(line, cards);
|
|
661
|
+
const gone = list?.items.map((item) => cardsNamed(item.body, cards, true).length > 0) ?? [];
|
|
662
|
+
if (!list || !gone.some(Boolean))
|
|
663
|
+
return line;
|
|
664
|
+
list.items.forEach((item, k) => {
|
|
665
|
+
if (gone[k] && item.number !== null)
|
|
666
|
+
numbers.add(item.number);
|
|
667
|
+
});
|
|
668
|
+
const after = line.slice(list.end).trimStart();
|
|
669
|
+
if (gone.every(Boolean))
|
|
670
|
+
return `${line.slice(0, list.lead).trimEnd()} ${after}`.trim();
|
|
671
|
+
const left = list.items.filter((_, k) => !gone[k]).map((item) => item.raw.trim().replace(/[\s,;]*(?:\b(?:and|or))?[\s,;]*$/i, ''));
|
|
672
|
+
const close = /[.!?]$/.test(left[left.length - 1]) ? '' : '.';
|
|
673
|
+
return [line.slice(0, list.items[0].start).trimEnd(), `${left.join('; ')}${close}`, after].filter((part) => part !== '').join(' ');
|
|
354
674
|
};
|
|
355
|
-
const lines = reply.split('\n');
|
|
675
|
+
const lines = reply.split('\n').map(unlisted);
|
|
356
676
|
const kind = lines.map((line) => {
|
|
357
677
|
if (LIST_ITEM.test(line))
|
|
358
|
-
return
|
|
678
|
+
return cardsNamed(line.replace(LIST_ITEM, ''), cards, true).length > 0 ? 'item' : 'kept';
|
|
359
679
|
return opensCard(line) ? 'named' : 'kept';
|
|
360
680
|
});
|
|
361
681
|
const dropped = kind.map((of) => of !== 'kept');
|
|
362
|
-
|
|
363
|
-
|
|
682
|
+
lines.forEach((line, i) => {
|
|
683
|
+
const number = dropped[i] ? Number(/^\s*(\d{1,2})[.)]\s/.exec(line)?.[1]) : NaN;
|
|
684
|
+
if (Number.isInteger(number))
|
|
685
|
+
numbers.add(number);
|
|
686
|
+
});
|
|
364
687
|
// The list a line leads into: the lines from the next one with words on it to the first that is not a list line, so a
|
|
365
|
-
// question on the line after the last item, `Which one?`, is no part of the list.
|
|
688
|
+
// question on the line after the last item, `Which one?`, is no part of the list. A blank line between two list lines
|
|
689
|
+
// is no end of it, as toMessages reads one, so a list that keeps an item past the blank keeps its lead.
|
|
690
|
+
const listLine = (j) => LIST_ITEM.test(lines[j]) || kind[j] === 'named';
|
|
366
691
|
const listAfter = (line) => {
|
|
367
692
|
const start = lines.findIndex((after, j) => j > line && after.trim() !== '');
|
|
368
693
|
if (start === -1)
|
|
369
694
|
return [];
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
695
|
+
const list = [];
|
|
696
|
+
for (let j = start; j < lines.length; j += 1) {
|
|
697
|
+
if (listLine(j)) {
|
|
698
|
+
list.push(dropped[j]);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (lines[j].trim() !== '')
|
|
702
|
+
break;
|
|
703
|
+
// A blank line: the list goes on when the next line with words on it is a list line.
|
|
704
|
+
const next = lines.findIndex((after, k) => k > j && after.trim() !== '');
|
|
705
|
+
if (next === -1 || !listLine(next))
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
return list;
|
|
374
709
|
};
|
|
375
710
|
const named = new Set();
|
|
376
711
|
const kept = [];
|
|
@@ -381,29 +716,44 @@ export function withoutCardList(reply, cardNames) {
|
|
|
381
716
|
// The line that leads into a list whose every line went loses the sentence that leads in: `Two I'd suggest:` or
|
|
382
717
|
// `Other choices:`. A list that keeps a line, for a product no card shows, keeps its lead too.
|
|
383
718
|
const list = listAfter(i);
|
|
384
|
-
if (text
|
|
385
|
-
text = text
|
|
386
|
-
|
|
719
|
+
if (leadsIn(text) && list.length > 0 && list.every(Boolean))
|
|
720
|
+
text = withoutLeadIn(text);
|
|
721
|
+
// A list line written without a bullet goes whole, with what trails its card's price (`$16.12. amazon, and the
|
|
722
|
+
// cheapest multipack.`), up to the sentence that asks the shopper something, which stays with what follows it. A
|
|
723
|
+
// question its card's own sentence asks after a comma, `..., $16.12, want it?`, stays too.
|
|
724
|
+
let asked = false;
|
|
725
|
+
const sentences = text.split(SENTENCE_END).flatMap((sentence) => {
|
|
387
726
|
const place = LIST_NUMBER.exec(sentence);
|
|
388
727
|
if (place && numbers.has(Number(place[1] ?? place[2])))
|
|
389
|
-
return
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
728
|
+
return [];
|
|
729
|
+
const found = cardsNamed(sentence, cards, false);
|
|
730
|
+
if (kind[i] === 'named') {
|
|
731
|
+
const card = found.length > 0 || opensCard(sentence);
|
|
732
|
+
asked ||= ASKS.test(sentence);
|
|
733
|
+
if (!card)
|
|
734
|
+
return asked ? [sentence] : [];
|
|
735
|
+
const clause = asked ? askingClause(sentence) : null;
|
|
736
|
+
return clause === null ? [] : [sentence.slice(clause)];
|
|
737
|
+
}
|
|
738
|
+
if (found.length === 0)
|
|
739
|
+
return [sentence];
|
|
740
|
+
// `other 6-packs are ...` and `there's also ...` recommend nothing: they are the list again, in a sentence.
|
|
741
|
+
if (MORE_OF_LIST.test(sentence))
|
|
742
|
+
return [];
|
|
743
|
+
const fresh = found.filter((card) => !named.has(card));
|
|
396
744
|
// A reply recommends one or two products in words; a sentence that names a third is the list again.
|
|
397
|
-
if (named.size
|
|
398
|
-
return
|
|
399
|
-
|
|
400
|
-
|
|
745
|
+
if (named.size + fresh.length > 2)
|
|
746
|
+
return [];
|
|
747
|
+
for (const card of fresh)
|
|
748
|
+
named.add(card);
|
|
749
|
+
return [sentence];
|
|
401
750
|
});
|
|
402
751
|
if (kind[i] === 'named' && sentences.length === 0)
|
|
403
752
|
return;
|
|
404
753
|
kept.push(sentences.join(' ').trimEnd());
|
|
405
754
|
});
|
|
406
|
-
|
|
755
|
+
// A lead-in kept for a list that lost an item to a card goes with what is left of the list, as one message (toMessages).
|
|
756
|
+
return toMessages(kept.join('\n')).join('\n\n');
|
|
407
757
|
}
|
|
408
758
|
/**
|
|
409
759
|
* A thrown error as the model may read it: the same message with every link
|