@agent-cards/commerce-sdk 0.15.0 → 0.16.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 +52 -5
- package/dist/ack.d.ts +26 -16
- package/dist/ack.js +64 -2
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +2 -0
- package/dist/buy-tool.d.ts +45 -8
- package/dist/buy-tool.js +228 -27
- package/dist/client.d.ts +30 -0
- package/dist/client.js +11 -0
- package/dist/definition.js +2 -0
- package/dist/direct.d.ts +5 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/messages.js +4 -4
- package/dist/model-view.d.ts +48 -4
- package/dist/model-view.js +208 -27
- package/dist/order.d.ts +119 -0
- package/dist/order.js +155 -0
- package/dist/sentences.d.ts +3 -0
- package/dist/sentences.js +29 -0
- package/dist/shopper-message.d.ts +10 -0
- package/dist/shopper-message.js +51 -11
- package/dist/texting.d.ts +81 -0
- package/dist/texting.js +347 -0
- package/package.json +1 -1
package/dist/model-view.d.ts
CHANGED
|
@@ -107,6 +107,18 @@ export interface BuyToolModelView {
|
|
|
107
107
|
product_id: string | null;
|
|
108
108
|
in_cart: boolean;
|
|
109
109
|
} | null;
|
|
110
|
+
/**
|
|
111
|
+
* The choice the reply asks the shopper to make, absent when it asks none: each group's name and its values with their
|
|
112
|
+
* extra price, such as `Gluten-free bread (+$2.46)`. List them one per line, each starting with `• `, under the question.
|
|
113
|
+
*/
|
|
114
|
+
choice?: {
|
|
115
|
+
question: string | null;
|
|
116
|
+
groups: Array<{
|
|
117
|
+
name: string;
|
|
118
|
+
required: boolean;
|
|
119
|
+
options: string[];
|
|
120
|
+
}>;
|
|
121
|
+
};
|
|
110
122
|
/** The stores the reply presents to choose from, in the order it names them. */
|
|
111
123
|
stores: BuyToolModelStore[];
|
|
112
124
|
/** How many store pages of the stores above the shopper holds, 0 when none went out. */
|
|
@@ -116,6 +128,17 @@ export interface BuyToolModelView {
|
|
|
116
128
|
store_id: string;
|
|
117
129
|
selected: boolean;
|
|
118
130
|
} | null;
|
|
131
|
+
/**
|
|
132
|
+
* Present when this answer went ahead on the yes the shopper already gave in the chat, after a step they finished
|
|
133
|
+
* outside it, or on their pick of what is in the cart (`picked`): the total they said yes to, or the chat last showed
|
|
134
|
+
* them, the cart's total now, and whether it changed. Never ask for their yes again.
|
|
135
|
+
*/
|
|
136
|
+
yes_carried?: {
|
|
137
|
+
said_yes_to: string;
|
|
138
|
+
total: string;
|
|
139
|
+
total_changed: boolean;
|
|
140
|
+
picked?: true;
|
|
141
|
+
};
|
|
119
142
|
}
|
|
120
143
|
/**
|
|
121
144
|
* The text with every link taken out: a Markdown link keeps its label, an address with a path or a scheme goes
|
|
@@ -183,6 +206,16 @@ export declare function expiresIn(iso: string | null | undefined, now?: number):
|
|
|
183
206
|
* reads as `needs_input`.
|
|
184
207
|
*/
|
|
185
208
|
export declare function forModel(result: BuyToolResult): BuyToolModelView;
|
|
209
|
+
/**
|
|
210
|
+
* The fixed short line for an answer whose next step is a link the shopper already holds: `add your card to place the
|
|
211
|
+
* order`, `sign in to DoorDash to place your order`, or `approve the payment to place your order (up to $19.02)`, with
|
|
212
|
+
* the most the shopper can be charged in the parentheses when the cart names it. After the shopper's own words put the
|
|
213
|
+
* cart together, a line about the cart comes first, as its own message. After a yes the turn carried past a step outside
|
|
214
|
+
* the chat, a total that changed opens the ask: `it's $17.89 with fees and tax now. approve the payment ...`. Null for any
|
|
215
|
+
* other answer, when the link did not go out, when the shopper already read Agentcard's own ask through `onText`, when a
|
|
216
|
+
* card or approval step has no cart to place, and when the answer also showed products.
|
|
217
|
+
*/
|
|
218
|
+
export declare function stepAsWritten(result: BuyToolResult): string | null;
|
|
186
219
|
/**
|
|
187
220
|
* Agentcard's reply, ready for the shopper as written, when the answer only asks them something: a
|
|
188
221
|
* `needs_input` answer with a reply, such as a product list, a size or a store to pick, or a cart's
|
|
@@ -191,25 +224,36 @@ export declare function forModel(result: BuyToolResult): BuyToolModelView;
|
|
|
191
224
|
* itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
|
|
192
225
|
* shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
|
|
193
226
|
* Null as well once the shopper read the reply through `onText` (`shopper_already_read_reply`).
|
|
227
|
+
* An answer whose next step is a link the call handed over reads as that step's fixed short line
|
|
228
|
+
* instead (`stepAsWritten`), whoever wrote the reply.
|
|
194
229
|
*
|
|
195
230
|
* ```ts
|
|
196
231
|
* replyAsWritten(result); // 'found two at Amazon. want the first one?'
|
|
197
232
|
* ```
|
|
198
233
|
*/
|
|
199
234
|
export declare function replyAsWritten(result: BuyToolResult): string | null;
|
|
235
|
+
/** A card the shopper holds: a product's or a store's name and, for a product, its price as the model reads it (`$22.89`, `from $10.69`). */
|
|
236
|
+
export interface ShownCard {
|
|
237
|
+
name: string;
|
|
238
|
+
price?: string | null;
|
|
239
|
+
}
|
|
200
240
|
/**
|
|
201
241
|
* Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
|
|
202
242
|
* store whose card went out, the line that led into that list (`here are a few:`, `other choices:`), a sentence that
|
|
203
|
-
* points at a number the list gave (`the dishwand in #8`),
|
|
204
|
-
*
|
|
205
|
-
*
|
|
243
|
+
* points at a number the list gave (`the dishwand in #8`), a sentence that goes on to more of the list (`other 6-packs
|
|
244
|
+
* are ...`, `there's also ...`) and names a card, and, past the one or two products a reply recommends in words, each
|
|
245
|
+
* further sentence that names another card. What is left is the reply around the list, such as `found these at
|
|
246
|
+
* amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
|
|
247
|
+
*
|
|
248
|
+
* Each card is its name, or its name and its price as the model reads it (`{ name, price: '$22.89' }`): with the price,
|
|
249
|
+
* a reply's short name for the product, such as `Planet Oat Extra Creamy ($22.89)`, reads as its card (cardsNamed).
|
|
206
250
|
*
|
|
207
251
|
* ```ts
|
|
208
252
|
* withoutCardList('Two I'd suggest:\n- Duracell AA, 24 pack, $20.78\n\nWhich one?', ['Duracell AA Batteries, 24 Count']);
|
|
209
253
|
* // 'Which one?'
|
|
210
254
|
* ```
|
|
211
255
|
*/
|
|
212
|
-
export declare function withoutCardList(reply: string, cardNames: readonly string[]): string;
|
|
256
|
+
export declare function withoutCardList(reply: string, cardNames: readonly (string | ShownCard)[]): string;
|
|
213
257
|
/**
|
|
214
258
|
* A thrown error as the model may read it: the same message with every link
|
|
215
259
|
* taken out, and the original error on `cause` for your logs. A TypeError
|
package/dist/model-view.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SENTENCE_END, lastSentenceStart } from './sentences.js';
|
|
1
2
|
/** The steps the shopper takes on a page a link opens: the message that asks for one is one short line. */
|
|
2
3
|
const STEP_ACTIONS = new Set(['store_login', 'add_card', 'approve']);
|
|
3
4
|
const MODEL_STATUSES = new Set(['needs_input', 'needs_card', 'approval_sent', 'placing', 'order_placed', 'declined']);
|
|
@@ -237,6 +238,10 @@ export function forModel(result) {
|
|
|
237
238
|
status,
|
|
238
239
|
next_action: result.next_action ?? null,
|
|
239
240
|
...(result.cart_building ? { cart_building: { store: stripLinks(result.cart_building.store) } } : {}),
|
|
241
|
+
...(result.choice ? { choice: {
|
|
242
|
+
question: result.choice.question === null ? null : stripLinks(result.choice.question),
|
|
243
|
+
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)})` : ''}`) })),
|
|
244
|
+
} } : {}),
|
|
240
245
|
reply: replyWithoutLinks(result.reply, products.some(held) || stores.some(heldStore) || result.link_sent === true),
|
|
241
246
|
cart: result.cart ? cartView(result.cart) : null,
|
|
242
247
|
carts: (result.carts ?? []).map(cartView),
|
|
@@ -261,8 +266,73 @@ export function forModel(result) {
|
|
|
261
266
|
store_pick: result.store_pick && stripLinks(result.store_pick.store_id) === result.store_pick.store_id
|
|
262
267
|
? { store_id: result.store_pick.store_id, selected: result.store_pick.selected === true }
|
|
263
268
|
: null,
|
|
269
|
+
...(yesCarriedView(result) ? { yes_carried: yesCarriedView(result) } : {}),
|
|
264
270
|
};
|
|
265
271
|
}
|
|
272
|
+
/** The yes an answer went ahead on, as the model reads it, or null. */
|
|
273
|
+
function yesCarriedView(result) {
|
|
274
|
+
const yes = result.yes_carried;
|
|
275
|
+
if (!yes || typeof yes.said_yes_to_cents !== 'number' || typeof yes.total_cents !== 'number')
|
|
276
|
+
return null;
|
|
277
|
+
const said = money(yes.said_yes_to_cents, yes.currency);
|
|
278
|
+
const total = money(yes.total_cents, yes.currency);
|
|
279
|
+
return said && total ? { said_yes_to: said, total, total_changed: yes.said_yes_to_cents !== yes.total_cents, ...(yes.picked === true ? { picked: true } : {}) } : null;
|
|
280
|
+
}
|
|
281
|
+
/** The link each step's ask goes with. */
|
|
282
|
+
const STEP_LINK = { add_card: 'vault', approve: 'approval', store_login: 'store_login' };
|
|
283
|
+
/** The items of a cart in one line, such as `1 Chicken Salad` or `2 tacos and 1 horchata`. */
|
|
284
|
+
function itemsLine(cart) {
|
|
285
|
+
const lines = cart.items.map((line) => `${line.qty} ${line.name}`);
|
|
286
|
+
return lines.length <= 1 ? (lines[0] ?? '') : `${lines.slice(0, -1).join(', ')} and ${lines[lines.length - 1]}`;
|
|
287
|
+
}
|
|
288
|
+
/** A cart's total as the shopper reads it: `$17.43`, or `about $15.90 before tax and shipping or delivery` for an estimate. */
|
|
289
|
+
function totalLine(cart) {
|
|
290
|
+
if (!cart.total_is_estimate)
|
|
291
|
+
return cart.total;
|
|
292
|
+
const before = cart.total_leaves_out ? ` before ${cart.total_leaves_out.replace(/,.*$/, '')}` : '';
|
|
293
|
+
return `about ${cart.total}${before}`;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The fixed short line for an answer whose next step is a link the shopper already holds: `add your card to place the
|
|
297
|
+
* order`, `sign in to DoorDash to place your order`, or `approve the payment to place your order (up to $19.02)`, with
|
|
298
|
+
* the most the shopper can be charged in the parentheses when the cart names it. After the shopper's own words put the
|
|
299
|
+
* cart together, a line about the cart comes first, as its own message. After a yes the turn carried past a step outside
|
|
300
|
+
* the chat, a total that changed opens the ask: `it's $17.89 with fees and tax now. approve the payment ...`. Null for any
|
|
301
|
+
* other answer, when the link did not go out, when the shopper already read Agentcard's own ask through `onText`, when a
|
|
302
|
+
* card or approval step has no cart to place, and when the answer also showed products.
|
|
303
|
+
*/
|
|
304
|
+
export function stepAsWritten(result) {
|
|
305
|
+
if (result.shopper_already_read_reply === true || result.link_sent !== true)
|
|
306
|
+
return null;
|
|
307
|
+
const next = result.next_action ?? '';
|
|
308
|
+
const kind = STEP_LINK[next];
|
|
309
|
+
const link = kind ? (result.links ?? []).find((l) => l.kind === kind && typeof l.url === 'string' && l.url !== '') : undefined;
|
|
310
|
+
if (!kind || !link)
|
|
311
|
+
return null;
|
|
312
|
+
const view = forModel(result);
|
|
313
|
+
const cart = view.cart;
|
|
314
|
+
// The line is about placing an order: an answer with no cart to place, such as the card link the shopper asked for while
|
|
315
|
+
// browsing, or one that also showed products the reply talks about, is the model's to word.
|
|
316
|
+
if ((!cart && next !== 'store_login') || view.products.length > 0)
|
|
317
|
+
return null;
|
|
318
|
+
const store = stripLinks(link.store ?? '') || cart?.store || 'the store';
|
|
319
|
+
const ask = next === 'add_card'
|
|
320
|
+
? 'add your card to place the order'
|
|
321
|
+
: next === 'store_login'
|
|
322
|
+
? `sign in to ${store} to place your order`
|
|
323
|
+
: `approve the payment to place your order${cart?.max_charge ? ` (up to ${cart.max_charge})` : ''}`;
|
|
324
|
+
const yes = view.yes_carried;
|
|
325
|
+
if (yes?.total_changed && cart) {
|
|
326
|
+
const total = cart.total_is_estimate ? `about ${cart.total}` : `${cart.total} with fees and tax`;
|
|
327
|
+
return `it's ${total} now. ${ask}`;
|
|
328
|
+
}
|
|
329
|
+
// 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.
|
|
330
|
+
const agentcards = typeof result.envelope?.reply === 'string' && result.envelope.reply === result.reply;
|
|
331
|
+
if (agentcards && result.confirm_sent !== true && !yes && cart && cart.items.length > 0 && next !== 'approve') {
|
|
332
|
+
return `${itemsLine(cart)} from ${cart.store}, ${totalLine(cart)}\n\n${ask}`;
|
|
333
|
+
}
|
|
334
|
+
return ask;
|
|
335
|
+
}
|
|
266
336
|
/**
|
|
267
337
|
* Agentcard's reply, ready for the shopper as written, when the answer only asks them something: a
|
|
268
338
|
* `needs_input` answer with a reply, such as a product list, a size or a store to pick, or a cart's
|
|
@@ -271,6 +341,8 @@ export function forModel(result) {
|
|
|
271
341
|
* itself for your model, such as the note that the cart changed or a `nextStep` turn's ask for the
|
|
272
342
|
* shopper's yes: only the reply Agentcard sent, as `envelope` holds it, is written for the shopper.
|
|
273
343
|
* Null as well once the shopper read the reply through `onText` (`shopper_already_read_reply`).
|
|
344
|
+
* An answer whose next step is a link the call handed over reads as that step's fixed short line
|
|
345
|
+
* instead (`stepAsWritten`), whoever wrote the reply.
|
|
274
346
|
*
|
|
275
347
|
* ```ts
|
|
276
348
|
* replyAsWritten(result); // 'found two at Amazon. want the first one?'
|
|
@@ -280,13 +352,20 @@ export function replyAsWritten(result) {
|
|
|
280
352
|
// The shopper already read every message of it through onText: handing it out again would say it twice.
|
|
281
353
|
if (result.shopper_already_read_reply === true)
|
|
282
354
|
return null;
|
|
355
|
+
// A step whose link the shopper already holds is asked for in the same short line every time, with no model.
|
|
356
|
+
const step = stepAsWritten(result);
|
|
357
|
+
if (step !== null)
|
|
358
|
+
return step;
|
|
283
359
|
// The tool's own lines tell the model what to say, so only Agentcard's reply, unchanged, goes out as is.
|
|
284
360
|
const agentcards = typeof result.envelope?.reply === 'string' && result.envelope.reply === result.reply;
|
|
285
361
|
const view = forModel(result);
|
|
286
362
|
const reply = view.reply.trim();
|
|
287
363
|
if (!agentcards || view.status !== 'needs_input' || reply === '')
|
|
288
364
|
return null;
|
|
289
|
-
const cards = [
|
|
365
|
+
const cards = [
|
|
366
|
+
...view.products.filter((product) => product.link_sent).map((product) => ({ name: product.name, price: product.price })),
|
|
367
|
+
...view.stores.filter((store) => store.link_sent).map((store) => ({ name: store.name })),
|
|
368
|
+
];
|
|
290
369
|
// A reply with nothing left once the cards' list comes out is your model's to word.
|
|
291
370
|
return withoutCardList(reply, cards) || null;
|
|
292
371
|
}
|
|
@@ -296,6 +375,15 @@ const LIST_ITEM = /^\s*(?:[-*•]|\d{1,2}[.)])\s+/;
|
|
|
296
375
|
const PRICE = /[$£€]\s?\d/;
|
|
297
376
|
/** 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
377
|
const LIST_NUMBER = /(?<!\ba )(?<!\ban )(?:#(\d{1,2})\b|\bnumber (\d{1,2})\b)/i;
|
|
378
|
+
/**
|
|
379
|
+
* A sentence that goes on to more of the list rather than recommending: `other 6-packs are ...`, `other options include ...`,
|
|
380
|
+
* `there's also ...`, `you could also ...`. One that names a card is the list, whatever the reply recommended before it.
|
|
381
|
+
*/
|
|
382
|
+
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;
|
|
383
|
+
/** An amount a text gives after a currency sign, such as `$22.89` or `¥1,331`. */
|
|
384
|
+
const AMOUNT = /[$£€¥]\s?(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?/g;
|
|
385
|
+
/** Words that tell no product from another: a card's brand or its distinctive words are never one of these. */
|
|
386
|
+
const FILLER = new Set(['the', 'and', 'with', 'for', 'from', 'of', 'by', 'in', 'on', 'to', 'or', 'at', 'an', 'new', 'pack', 'count', 'set', 'each', 'size']);
|
|
299
387
|
/** A unit a size or a count is written in, so `10 ft`, `10-ft` and `10ft` read as one word. */
|
|
300
388
|
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
389
|
/**
|
|
@@ -305,6 +393,8 @@ const UNIT = /(\d)[\s-]+(ft|feet|foot|inch|inches|oz|fl|ml|l|g|kg|lb|lbs|gb|tb|m
|
|
|
305
393
|
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
394
|
/** A size or a count written as one word, such as `24pack` or `10ft`. */
|
|
307
395
|
const SIZE = /^(\d+)(\p{L}+)$/u;
|
|
396
|
+
/** A size in inches, however a text writes it: `10 inch`, `10-inches`, `10in`, `10 in`. */
|
|
397
|
+
const INCHES = /(\d+)\s*-?\s*(?:inch(?:es)?|in)\b/gi;
|
|
308
398
|
/** A text's words, lowercased, letters and digits only, with markdown's bold marks gone and a size written as one word. */
|
|
309
399
|
function wordsOf(text) {
|
|
310
400
|
return text
|
|
@@ -314,29 +404,115 @@ function wordsOf(text) {
|
|
|
314
404
|
.filter((word) => word !== '')
|
|
315
405
|
.map((word) => word.replace(SIZE, (whole, count, unit) => `${count}${SAME_UNIT.get(unit) ?? unit}`));
|
|
316
406
|
}
|
|
407
|
+
/** An amount in hundredths, so `$26.46`, `from $26.46` and `26.46` all read 2646, and `¥1,331` reads 133100. */
|
|
408
|
+
function hundredths(whole, fraction) {
|
|
409
|
+
return Math.round(Number(`${whole.replace(/,/g, '')}${fraction ?? ''}`) * 100);
|
|
410
|
+
}
|
|
411
|
+
/** The amounts a text gives after a currency sign, in hundredths. */
|
|
412
|
+
function amountsIn(text) {
|
|
413
|
+
return new Set([...text.matchAll(AMOUNT)].map((match) => hundredths(match[1], match[2])));
|
|
414
|
+
}
|
|
317
415
|
/**
|
|
318
|
-
* The
|
|
319
|
-
* text
|
|
320
|
-
*
|
|
416
|
+
* The sizes among a text's words, by unit: `10ft` and `3ft` are two sizes in feet, `24pack` a count. Inches come from
|
|
417
|
+
* the text itself, because `wordsOf` leaves `10 inch` as the bare `10`, so the 10 inch skillet reads as one size in
|
|
418
|
+
* inches and the 12 inch one as another.
|
|
321
419
|
*/
|
|
322
|
-
function
|
|
323
|
-
const
|
|
324
|
-
for (const
|
|
325
|
-
const
|
|
326
|
-
if (
|
|
327
|
-
|
|
328
|
-
const hits = head.filter((word) => said.has(word)).length;
|
|
329
|
-
if (head.length === 1 ? inList && hits === 1 : hits >= Math.max(2, Math.ceil(head.length * 0.6)))
|
|
330
|
-
return index;
|
|
420
|
+
function sizesOf(words, text) {
|
|
421
|
+
const sizes = new Map();
|
|
422
|
+
for (const word of words) {
|
|
423
|
+
const [, count, unit] = /^(\d+)(\p{L}+)$/u.exec(word) ?? [];
|
|
424
|
+
if (count && unit)
|
|
425
|
+
sizes.set(unit, (sizes.get(unit) ?? new Set()).add(count));
|
|
331
426
|
}
|
|
332
|
-
|
|
427
|
+
for (const [, count] of text.matchAll(INCHES))
|
|
428
|
+
sizes.set('inch', (sizes.get('inch') ?? new Set()).add(count));
|
|
429
|
+
return sizes;
|
|
430
|
+
}
|
|
431
|
+
function readCards(shown) {
|
|
432
|
+
const named = shown
|
|
433
|
+
.map((card) => (typeof card === 'string' ? { name: card, price: null } : { name: String(card?.name ?? ''), price: card?.price ?? null }))
|
|
434
|
+
.map((card) => ({ name: card.name, words: wordsOf(card.name), price: card.price }))
|
|
435
|
+
.filter((card) => card.words.length > 0);
|
|
436
|
+
const holders = new Map();
|
|
437
|
+
for (const card of named)
|
|
438
|
+
for (const word of new Set(card.words))
|
|
439
|
+
holders.set(word, (holders.get(word) ?? 0) + 1);
|
|
440
|
+
const telling = (word) => word.length >= 3 && !/\d/.test(word) && !FILLER.has(word);
|
|
441
|
+
return named.map(({ name, words, price }) => {
|
|
442
|
+
const brand = words.find((word) => /\p{L}/u.test(word) && word.length >= 2 && !FILLER.has(word)) ?? null;
|
|
443
|
+
const amount = typeof price === 'string' ? /(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?/.exec(price) : null;
|
|
444
|
+
return {
|
|
445
|
+
words,
|
|
446
|
+
head: words.slice(0, 6),
|
|
447
|
+
brand,
|
|
448
|
+
marks: new Set([...(brand ? [brand] : []), ...words.filter((word) => telling(word) && (holders.get(word) ?? 0) <= Math.max(1, named.length / 2))]),
|
|
449
|
+
price: amount ? hundredths(amount[1], amount[2]) : null,
|
|
450
|
+
sizes: sizesOf(words, name),
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The cards a text names, by their indexes; empty when it names none. A text names a card in one of three ways:
|
|
456
|
+
*
|
|
457
|
+
* - 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
|
|
458
|
+
* never counts in prose, where a generic word such as `soap` would. A size or a count among those words has to be in
|
|
459
|
+
* the text too, so the 3ft cable's line never reads as the 10ft card.
|
|
460
|
+
* - By the card's exact price with its brand or another word that tells it apart: `Planet Oat Extra Creamy ($22.89)`
|
|
461
|
+
* 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
|
|
462
|
+
* sizes, names nothing.
|
|
463
|
+
* - By its brand and two more of its first six words, when the text gives no price or gives the card's.
|
|
464
|
+
*
|
|
465
|
+
* 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
|
|
466
|
+
* 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
|
|
467
|
+
* the first six words, so a line for a product no card shows keeps its place beside the card of its brand.
|
|
468
|
+
* 2026-10-27, "Oat milk, shelf stable, a 6 pack": "Planet Oat Extra Creamy ($22.89)" shares too few of its title's first
|
|
469
|
+
* six words to match by them alone.
|
|
470
|
+
*/
|
|
471
|
+
function cardsNamed(text, cards, inList) {
|
|
472
|
+
const words = wordsOf(text);
|
|
473
|
+
// Each word with how often the text says it, so a name of one word twice over, `Miu Miu`, counts twice.
|
|
474
|
+
const said = new Map();
|
|
475
|
+
for (const word of words)
|
|
476
|
+
said.set(word, (said.get(word) ?? 0) + 1);
|
|
477
|
+
const amounts = amountsIn(text);
|
|
478
|
+
const sizes = sizesOf(words, text);
|
|
479
|
+
const found = [...cards.entries()].flatMap(([index, card]) => {
|
|
480
|
+
const { head } = card;
|
|
481
|
+
const shared = head.filter((word) => said.has(word));
|
|
482
|
+
const hit = new Set(shared);
|
|
483
|
+
if (head.every((word) => !/\d/.test(word) || said.has(word))) {
|
|
484
|
+
if (head.length === 1 ? inList && shared.length === 1 : shared.length >= Math.max(2, Math.ceil(head.length * 0.6)))
|
|
485
|
+
return [{ index, hit }];
|
|
486
|
+
}
|
|
487
|
+
const otherSize = [...card.sizes].some(([unit, counts]) => sizes.has(unit) && ![...counts].some((count) => sizes.get(unit).has(count)));
|
|
488
|
+
if (otherSize)
|
|
489
|
+
return [];
|
|
490
|
+
const ownPrice = card.price !== null && amounts.has(card.price);
|
|
491
|
+
if (ownPrice && [...card.marks].some((word) => said.has(word)))
|
|
492
|
+
return [{ index, hit }];
|
|
493
|
+
const otherPrice = amounts.size > 0 && !ownPrice;
|
|
494
|
+
const more = new Set(head.filter((word) => word !== card.brand && word.length >= 2 && !FILLER.has(word) && said.has(word)));
|
|
495
|
+
return !otherPrice && card.brand !== null && said.has(card.brand) && more.size >= 2 ? [{ index, hit }] : [];
|
|
496
|
+
});
|
|
497
|
+
// Cards whose names share their words read as several products where the text names one, as three Kirkland Signature
|
|
498
|
+
// Organic milks do for `Kirkland Signature Organic Almond Milk`: a card the text names in nothing but another card's
|
|
499
|
+
// words, each said once, is that card's mention, and counts for no product of its own. A text that says those words
|
|
500
|
+
// again names both products, as `Diamond Walnut Pieces ($5.99) and Diamond Walnut Pieces Halves ($7.99)` does.
|
|
501
|
+
const saidOnce = (hit) => [...hit].every((word) => said.get(word) === 1);
|
|
502
|
+
return found
|
|
503
|
+
.filter(({ hit }) => hit.size === 0 || !saidOnce(hit) || !found.some((other) => other.hit.size > hit.size && [...hit].every((word) => other.hit.has(word))))
|
|
504
|
+
.map(({ index }) => index);
|
|
333
505
|
}
|
|
334
506
|
/**
|
|
335
507
|
* Agentcard's reply without the list its cards already show the shopper: each list line that names a product or
|
|
336
508
|
* 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
|
-
*
|
|
509
|
+
* points at a number the list gave (`the dishwand in #8`), a sentence that goes on to more of the list (`other 6-packs
|
|
510
|
+
* are ...`, `there's also ...`) and names a card, and, past the one or two products a reply recommends in words, each
|
|
511
|
+
* further sentence that names another card. What is left is the reply around the list, such as `found these at
|
|
512
|
+
* amazon.` and `which one do you want?`. A reply whose cards went out names none of them twice over.
|
|
513
|
+
*
|
|
514
|
+
* Each card is its name, or its name and its price as the model reads it (`{ name, price: '$22.89' }`): with the price,
|
|
515
|
+
* a reply's short name for the product, such as `Planet Oat Extra Creamy ($22.89)`, reads as its card (cardsNamed).
|
|
340
516
|
*
|
|
341
517
|
* ```ts
|
|
342
518
|
* withoutCardList('Two I'd suggest:\n- Duracell AA, 24 pack, $20.78\n\nWhich one?', ['Duracell AA Batteries, 24 Count']);
|
|
@@ -344,18 +520,18 @@ function cardNamed(text, cards, inList) {
|
|
|
344
520
|
* ```
|
|
345
521
|
*/
|
|
346
522
|
export function withoutCardList(reply, cardNames) {
|
|
347
|
-
const cards = cardNames
|
|
523
|
+
const cards = readCards(cardNames);
|
|
348
524
|
if (cards.length === 0)
|
|
349
525
|
return reply.trim();
|
|
350
526
|
// 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
527
|
const opensCard = (text) => {
|
|
352
528
|
const words = wordsOf(text);
|
|
353
|
-
return PRICE.test(text) && cards.some((
|
|
529
|
+
return PRICE.test(text) && cards.some((card) => card.words.length >= 2 && card.words.slice(0, 3).every((word, i) => words[i] === word));
|
|
354
530
|
};
|
|
355
531
|
const lines = reply.split('\n');
|
|
356
532
|
const kind = lines.map((line) => {
|
|
357
533
|
if (LIST_ITEM.test(line))
|
|
358
|
-
return
|
|
534
|
+
return cardsNamed(line.replace(LIST_ITEM, ''), cards, true).length > 0 ? 'item' : 'kept';
|
|
359
535
|
return opensCard(line) ? 'named' : 'kept';
|
|
360
536
|
});
|
|
361
537
|
const dropped = kind.map((of) => of !== 'kept');
|
|
@@ -382,21 +558,26 @@ export function withoutCardList(reply, cardNames) {
|
|
|
382
558
|
// `Other choices:`. A list that keeps a line, for a product no card shows, keeps its lead too.
|
|
383
559
|
const list = listAfter(i);
|
|
384
560
|
if (text.trim().endsWith(':') && list.length > 0 && list.every(Boolean))
|
|
385
|
-
text = text.trim().
|
|
386
|
-
const sentences = text.split(
|
|
561
|
+
text = text.trim().slice(0, lastSentenceStart(text.trim())).trimEnd();
|
|
562
|
+
const sentences = text.split(SENTENCE_END).filter((sentence) => {
|
|
387
563
|
const place = LIST_NUMBER.exec(sentence);
|
|
388
564
|
if (place && numbers.has(Number(place[1] ?? place[2])))
|
|
389
565
|
return false;
|
|
390
|
-
const
|
|
566
|
+
const found = cardsNamed(sentence, cards, false);
|
|
391
567
|
// A list line written without a bullet keeps the sentences its card does not say, such as the question after it.
|
|
392
568
|
if (kind[i] === 'named')
|
|
393
|
-
return
|
|
394
|
-
if (
|
|
569
|
+
return found.length === 0 && !opensCard(sentence);
|
|
570
|
+
if (found.length === 0)
|
|
395
571
|
return true;
|
|
572
|
+
// `other 6-packs are ...` and `there's also ...` recommend nothing: they are the list again, in a sentence.
|
|
573
|
+
if (MORE_OF_LIST.test(sentence))
|
|
574
|
+
return false;
|
|
575
|
+
const fresh = found.filter((card) => !named.has(card));
|
|
396
576
|
// A reply recommends one or two products in words; a sentence that names a third is the list again.
|
|
397
|
-
if (named.size
|
|
577
|
+
if (named.size + fresh.length > 2)
|
|
398
578
|
return false;
|
|
399
|
-
|
|
579
|
+
for (const card of fresh)
|
|
580
|
+
named.add(card);
|
|
400
581
|
return true;
|
|
401
582
|
});
|
|
402
583
|
if (kind[i] === 'named' && sentences.length === 0)
|
package/dist/order.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a shopper's order is, read from the messages Agentcard sends about it, for an app that draws
|
|
3
|
+
* an order tracker instead of a line of text. `readShopperMessage` puts it on `message.order`;
|
|
4
|
+
* `updateOrder` folds the next message about the same order into the one a tracker shows, and
|
|
5
|
+
* `arrivalText` writes the arrival line. No import reaches outside the package, so the browser entry
|
|
6
|
+
* carries this file too.
|
|
7
|
+
*/
|
|
8
|
+
/** The steps a tracker draws, left to right. An order that ends early reads `canceled` or `failed`. */
|
|
9
|
+
export declare const ORDER_STEPS: readonly ['placed', 'confirmed', 'preparing', 'on_the_way', 'delivered'];
|
|
10
|
+
/** One step a tracker draws. */
|
|
11
|
+
export type OrderPathStep = (typeof ORDER_STEPS)[number];
|
|
12
|
+
/** Where an order is: one of `ORDER_STEPS`, or `canceled` or `failed`, which end it before `delivered`. */
|
|
13
|
+
export type OrderStep = OrderPathStep | 'canceled' | 'failed';
|
|
14
|
+
/** One step of the tracker: the order went past it (`done`), is at it (`current`), or has not reached it (`upcoming`). */
|
|
15
|
+
export interface OrderStepState {
|
|
16
|
+
step: OrderPathStep;
|
|
17
|
+
/** The step's name as the tracker writes it, such as "Preparing". */
|
|
18
|
+
label: string;
|
|
19
|
+
state: 'done' | 'current' | 'upcoming';
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* An order as a tracker shows it. Every message about one order carries the same `orderId`, so an app
|
|
23
|
+
* keeps one tracker per `orderId` and passes each new message's order through `updateOrder`. A value
|
|
24
|
+
* Agentcard did not send is null; the package never guesses one.
|
|
25
|
+
*/
|
|
26
|
+
export interface ShopperOrder {
|
|
27
|
+
/** The store's id for the order, the same on every message about it. Null on a failure that placed no order. */
|
|
28
|
+
orderId: string | null;
|
|
29
|
+
/** The service the order went through, such as "DoorDash" or "Walmart". Null for an order across several retailers. */
|
|
30
|
+
merchantName: string | null;
|
|
31
|
+
/** The place the order comes from, such as the restaurant on DoorDash, when Agentcard names it. Show `merchantName` without it. */
|
|
32
|
+
storeName: string | null;
|
|
33
|
+
step: OrderStep;
|
|
34
|
+
/** The step's name as the tracker writes it: "Placed", "Accepted", "Preparing", "On the way", "Delivered", "Canceled" or "Failed". */
|
|
35
|
+
label: string;
|
|
36
|
+
/** The steps of `ORDER_STEPS` in order, each `done`, `current` or `upcoming`. After `canceled` or `failed` none is `current`. */
|
|
37
|
+
steps: OrderStepState[];
|
|
38
|
+
/** The store's own line for where the order is, such as "Preparing your order". */
|
|
39
|
+
title: string | null;
|
|
40
|
+
/** The store's own sentence under it, such as "Mendocino Farms is preparing your order." */
|
|
41
|
+
detail: string | null;
|
|
42
|
+
/** The earliest and the latest arrival the store estimates, as ISO 8601 timestamps. */
|
|
43
|
+
arrival: {
|
|
44
|
+
earliest: string | null;
|
|
45
|
+
latest: string | null;
|
|
46
|
+
} | null;
|
|
47
|
+
/** The delivery window the retailer quoted, in its own words, such as "Sat, Sep 6 - Tue, Sep 9". */
|
|
48
|
+
deliveryWindow: string | null;
|
|
49
|
+
/** The total in the currency's minor unit: 1789 is $17.89. */
|
|
50
|
+
totalCents: number | null;
|
|
51
|
+
/** The three-letter currency code, lowercase. */
|
|
52
|
+
currency: string | null;
|
|
53
|
+
/** The retailer's own order number, as on its confirmation, when it differs from `orderId`. */
|
|
54
|
+
retailerOrderId: string | null;
|
|
55
|
+
/** When the order was delivered, as an ISO 8601 timestamp. */
|
|
56
|
+
deliveredAt: string | null;
|
|
57
|
+
/** Why the order was canceled or failed, in the store's or Agentcard's words. */
|
|
58
|
+
reason: string | null;
|
|
59
|
+
/** When the order reached this step, as an ISO 8601 timestamp. */
|
|
60
|
+
updatedAt: string;
|
|
61
|
+
}
|
|
62
|
+
/** The step an `order.updated` stage reads as, or null for a stage the package does not know. */
|
|
63
|
+
export declare function orderStepOf(stage: string | null | undefined): OrderStep | null;
|
|
64
|
+
/** The values a message names about its order, as the package reads them. */
|
|
65
|
+
export interface OrderFields {
|
|
66
|
+
orderId?: string;
|
|
67
|
+
merchantName?: string;
|
|
68
|
+
storeName?: string;
|
|
69
|
+
step: OrderStep;
|
|
70
|
+
/** For a canceled order, the step it was at before. */
|
|
71
|
+
reached?: OrderStep | null;
|
|
72
|
+
title?: string;
|
|
73
|
+
detail?: string;
|
|
74
|
+
arrivalEarliest?: string;
|
|
75
|
+
arrivalLatest?: string;
|
|
76
|
+
deliveryWindow?: string;
|
|
77
|
+
totalCents?: number;
|
|
78
|
+
currency?: string;
|
|
79
|
+
retailerOrderId?: string;
|
|
80
|
+
deliveredAt?: string;
|
|
81
|
+
reason?: string;
|
|
82
|
+
updatedAt: string;
|
|
83
|
+
}
|
|
84
|
+
/** The order a message describes, from the values it names. */
|
|
85
|
+
export declare function orderFrom(fields: OrderFields): ShopperOrder;
|
|
86
|
+
/**
|
|
87
|
+
* The order a tracker shows once `next` arrives, for two messages about the same order. The order
|
|
88
|
+
* never moves back a step: Agentcard delivers each message at least once and in no set order, so a
|
|
89
|
+
* late `order.confirmed` after the driver picked the order up leaves the tracker at `on_the_way`. At
|
|
90
|
+
* the same step, the message with the later `updatedAt` wins. A value the shown message lacks, such
|
|
91
|
+
* as the total `order_placed` named or the restaurant `order.updated` named, comes from the other,
|
|
92
|
+
* and so does the store's line when both are at the same step. An arrival estimate holds until the
|
|
93
|
+
* order is delivered, canceled or failed.
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* trackers.set(order.orderId, updateOrder(trackers.get(order.orderId), order));
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export declare function updateOrder(previous: ShopperOrder | null | undefined, next: ShopperOrder): ShopperOrder;
|
|
100
|
+
export interface ArrivalTextOptions {
|
|
101
|
+
/** The shopper's IANA time zone, such as `America/Los_Angeles`. Default: the runtime's own. */
|
|
102
|
+
timeZone?: string;
|
|
103
|
+
/** The locale the times are written in. Default: `en-US`. */
|
|
104
|
+
locale?: string;
|
|
105
|
+
/** Unix milliseconds that count as now, to tell a time today from one on another day. Default: the system clock. */
|
|
106
|
+
now?: number;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The line a tracker writes under the steps about when the order arrives, in the shopper's time zone,
|
|
110
|
+
* or null when Agentcard sent no time for it:
|
|
111
|
+
*
|
|
112
|
+
* - `Arriving 12:04 – 12:22 PM` from the store's estimate, with the weekday when it is not today;
|
|
113
|
+
* - `Arriving by 12:22 PM` when the estimate names only its latest time;
|
|
114
|
+
* - `Expected Sat, Sep 6 - Tue, Sep 9` from a retailer's delivery window;
|
|
115
|
+
* - `Delivered at 12:20 PM` once delivered.
|
|
116
|
+
*
|
|
117
|
+
* A canceled or failed order has no arrival line.
|
|
118
|
+
*/
|
|
119
|
+
export declare function arrivalText(order: Pick<ShopperOrder, 'step' | 'arrival' | 'deliveryWindow' | 'deliveredAt'>, options?: ArrivalTextOptions): string | null;
|