AuthorBot Cursor commited on
Commit
afad341
·
1 Parent(s): 6b1b2a7

feat: format-aware price table — Platform|Format|Price|Buy in bot chat

Browse files

Expand catalog offers to one row per format from format_prices so visitors
see which amount is Hardcover vs eBook vs Audiobook. Add format_clarify
short-circuit for follow-ups like 'are those hardcover or audiobook'.
Widget grid is 4 columns; Telegram labels include format; cheapest/how-much
still use a single Buy CTA but name the format in prose (R-PI-005/006).

Co-authored-by: Cursor <cursoragent@cursor.com>

.agent/BDD_SPECS.md CHANGED
@@ -1118,14 +1118,28 @@ Feature: Bot price intelligence (grounded catalog)
1118
  Given a book has verified Amazon and Kobo listings with URLs
1119
  When the visitor asks "list all platforms with prices"
1120
  Then intent is price_inquiry
1121
- And the response includes platform_offers with Platform Price Buy rows
1122
  And bot text has no markdown table
1123
 
1124
- Scenario: Cheapest highlights best offer
 
 
 
 
 
 
 
 
 
 
 
 
 
1125
  Given Amazon eBook is $14.99 and Kobo eBook is $9.99 both verified
1126
  When the visitor asks "where is it cheapest"
1127
- Then the reply names Kobo and $9.99
1128
- And the Kobo platform_offers row has is_best true
 
1129
 
1130
  Scenario: Soft too-expensive uses grounded PRICE_FACTS
1131
  Given a book has a grounded source price of $14.99
 
1118
  Given a book has verified Amazon and Kobo listings with URLs
1119
  When the visitor asks "list all platforms with prices"
1120
  Then intent is price_inquiry
1121
+ And the response includes platform_offers with Platform Format Price Buy rows
1122
  And bot text has no markdown table
1123
 
1124
+ Scenario: List expands one row per format
1125
+ Given Amazon has format_prices Hardcover $35 and eBook $14.99 and Apple has eBook $14.99
1126
+ When the visitor asks "list all platforms with prices"
1127
+ Then platform_offers includes Amazon Hardcover $35.00 and Amazon eBook $14.99 and Apple Books eBook $14.99
1128
+ And every priced row names its format
1129
+
1130
+ Scenario: Format clarify returns grounded table
1131
+ Given the visitor already saw a price list for the selected book
1132
+ When the visitor asks "are those hardcover or audiobook"
1133
+ Then intent is price_inquiry
1134
+ And the reply does not guess formats
1135
+ And platform_offers shows each stored format with its price
1136
+
1137
+ Scenario: Cheapest names format and uses single Buy CTA
1138
  Given Amazon eBook is $14.99 and Kobo eBook is $9.99 both verified
1139
  When the visitor asks "where is it cheapest"
1140
+ Then the reply names Kobo and $9.99 and eBook
1141
+ And platform_offers is absent
1142
+ And a Buy on Kobo link is shown
1143
 
1144
  Scenario: Soft too-expensive uses grounded PRICE_FACTS
1145
  Given a book has a grounded source price of $14.99
.agent/FILE_MAP.md CHANGED
@@ -110,7 +110,7 @@
110
  | `app/services/book_service.py` | Admin book CRUD, covers, URL import, platform presence |
111
  | `app/services/platform_catalog.py` | Canonical format prices + listing view for admin/bot |
112
  | `app/services/price_catalog_service.py` | Grounded visitor price answers + platform_offers payload |
113
- | `.agent/PRICE_INTELLIGENCE_RULES.md` | Bot price intelligence rules (R-PI-001–004) |
114
  | `app/services/admin_analytics_service.py` | Admin dashboard analytics (funnel, heatmap, visitors) |
115
  | `app/services/dashboard_service.py` | Dashboard stats, session moderation, transcripts |
116
  | `app/services/ingestion_service.py` | File + text ingestion pipeline, upload orchestration |
 
110
  | `app/services/book_service.py` | Admin book CRUD, covers, URL import, platform presence |
111
  | `app/services/platform_catalog.py` | Canonical format prices + listing view for admin/bot |
112
  | `app/services/price_catalog_service.py` | Grounded visitor price answers + platform_offers payload |
113
+ | `.agent/PRICE_INTELLIGENCE_RULES.md` | Bot price intelligence rules (R-PI-001–006, format-aware grid) |
114
  | `app/services/admin_analytics_service.py` | Admin dashboard analytics (funnel, heatmap, visitors) |
115
  | `app/services/dashboard_service.py` | Dashboard stats, session moderation, transcripts |
116
  | `app/services/ingestion_service.py` | File + text ingestion pipeline, upload orchestration |
.agent/PRICE_INTELLIGENCE_RULES.md CHANGED
@@ -1,11 +1,11 @@
1
  # Feature Rules — Bot Price Intelligence
2
 
3
  > Grounded retailer prices for visitor chat. Never invent dollars.
4
- > Structured Platform / Price / Buy UI — not markdown tables (R-068).
5
 
6
  ## 1. Problem & scope
7
 
8
- - **User story:** Visitors ask price questions (how much, cheapest, compare, list all platforms, buy link for X). The bot answers from stored `book_platform_listings` and shows a 3-column buy UI when listing retailers.
9
  - **In scope:**
10
  - `price_inquiry` intent for list / cheapest / expensive / compare / platform-link
11
  - Deterministic `PriceCatalogService` answers (no LLM for factual price turns)
@@ -46,10 +46,11 @@ No new endpoints.
46
 
47
  ## 5. UI
48
 
49
- - **Widget:** 3-column grid Platform | Price | Buy under bot bubble — **only** for list-all / compare turns
50
- - **Single-winner turns** (cheapest, most expensive, how much, buy link for X): text + one Buy button; no grid
 
51
  - **Empty price:** display `See site`; Buy uses that platform's `listing_url`
52
- - **Telegram:** stacked URL buttons (`Platform — $x.xx`), max 4
53
 
54
  ## 6. Priority / fallback
55
 
@@ -80,3 +81,5 @@ No new endpoints.
80
  - **R-PI-002:** List-all uses `platform_offers`, never markdown tables
81
  - **R-PI-003:** Factual price turns skip RAG/LLM
82
  - **R-PI-004:** Multi-book: book picker before price short-circuit
 
 
 
1
  # Feature Rules — Bot Price Intelligence
2
 
3
  > Grounded retailer prices for visitor chat. Never invent dollars.
4
+ > Structured Platform / Format / Price / Buy UI — not markdown tables (R-068).
5
 
6
  ## 1. Problem & scope
7
 
8
+ - **User story:** Visitors ask price questions (how much, cheapest, compare, list all platforms, format clarify, buy link for X). The bot answers from stored `book_platform_listings` and shows a 4-column buy UI when listing retailers.
9
  - **In scope:**
10
  - `price_inquiry` intent for list / cheapest / expensive / compare / platform-link
11
  - Deterministic `PriceCatalogService` answers (no LLM for factual price turns)
 
46
 
47
  ## 5. UI
48
 
49
+ - **Widget:** 4-column grid Platform | Format | Price | Buy under bot bubble — for list-all / compare / format_clarify (and multi-format how-much on one platform)
50
+ - **Single-winner turns** (cheapest, most expensive, single-format how much / buy link): text + one Buy button; no grid; sentence **names format** when known
51
+ - **Empty format:** display `—` in Format column; never invent Hardcover/eBook/Audiobook
52
  - **Empty price:** display `See site`; Buy uses that platform's `listing_url`
53
+ - **Telegram:** stacked URL buttons (`Amazon Hardcover — $35.00`), max 4
54
 
55
  ## 6. Priority / fallback
56
 
 
81
  - **R-PI-002:** List-all uses `platform_offers`, never markdown tables
82
  - **R-PI-003:** Factual price turns skip RAG/LLM
83
  - **R-PI-004:** Multi-book: book picker before price short-circuit
84
+ - **R-PI-005:** Never quote a dollar without naming its format when `format_prices` has it; list grid is Platform | Format | Price | Buy
85
+ - **R-PI-006:** `catalog_for_book` emits one Offer per priced format; format_clarify is deterministic (no LLM)
app/channels/adapters/telegram.py CHANGED
@@ -157,15 +157,17 @@ def inline_keyboard_for_platform_offers(
157
  *,
158
  max_buttons: int = 4,
159
  ) -> dict[str, Any] | None:
160
- """Map platform_offers rows to stacked URL buttons (Platform — price)."""
161
  rows: list[list[dict[str, str]]] = []
162
  for offer in (offers or [])[:max_buttons]:
163
  url = (offer.get("url") or "").strip()
164
  if not url.startswith(("http://", "https://")):
165
  continue
166
  platform = (offer.get("platform") or "Store").strip() or "Store"
 
167
  price = (offer.get("price") or "").strip()
168
- label = f"{platform} {price}" if price else platform
 
169
  rows.append([{"text": label[:64], "url": url}])
170
  if not rows:
171
  return None
 
157
  *,
158
  max_buttons: int = 4,
159
  ) -> dict[str, Any] | None:
160
+ """Map platform_offers rows to stacked URL buttons (Platform Format — price)."""
161
  rows: list[list[dict[str, str]]] = []
162
  for offer in (offers or [])[:max_buttons]:
163
  url = (offer.get("url") or "").strip()
164
  if not url.startswith(("http://", "https://")):
165
  continue
166
  platform = (offer.get("platform") or "Store").strip() or "Store"
167
+ fmt = (offer.get("format") or "").strip()
168
  price = (offer.get("price") or "").strip()
169
+ name = f"{platform} {fmt}".strip() if fmt else platform
170
+ label = f"{name} — {price}" if price else name
171
  rows.append([{"text": label[:64], "url": url}])
172
  if not rows:
173
  return None
app/channels/services/bot_bridge.py CHANGED
@@ -281,8 +281,10 @@ async def generate_channel_reply(
281
  if not url.startswith(("http://", "https://")):
282
  continue
283
  platform = (o.get("platform") or "Store").strip()
 
284
  price = (o.get("price") or "").strip()
285
- label = f"{platform} {price}" if price else f"Buy on {platform}"
 
286
  links.append({
287
  "label": label[:64],
288
  "url": url,
@@ -290,19 +292,24 @@ async def generate_channel_reply(
290
  "icon": "🛒",
291
  })
292
  elif offers and links:
293
- # Append remaining offer URLs (skip duplicates) up to 4 total buttons.
294
  for o in offers:
295
  if len(links) >= 4:
296
  break
297
  url = (o.get("url") or "").strip()
298
  if not url.startswith(("http://", "https://")):
299
  continue
300
- if any((lnk.get("url") or "") == url for lnk in links):
301
- continue
302
  platform = (o.get("platform") or "Store").strip()
 
303
  price = (o.get("price") or "").strip()
 
 
 
 
 
 
304
  links.append({
305
- "label": (f"{platform} — {price}" if price else platform)[:64],
306
  "url": url,
307
  "type": "platform_purchase",
308
  "icon": "🛒",
 
281
  if not url.startswith(("http://", "https://")):
282
  continue
283
  platform = (o.get("platform") or "Store").strip()
284
+ fmt = (o.get("format") or "").strip()
285
  price = (o.get("price") or "").strip()
286
+ name = f"{platform} {fmt}".strip() if fmt else platform
287
+ label = f"{name} — {price}" if price else f"Buy on {name}"
288
  links.append({
289
  "label": label[:64],
290
  "url": url,
 
292
  "icon": "🛒",
293
  })
294
  elif offers and links:
295
+ # Append remaining offer rows (allow same URL when format label differs).
296
  for o in offers:
297
  if len(links) >= 4:
298
  break
299
  url = (o.get("url") or "").strip()
300
  if not url.startswith(("http://", "https://")):
301
  continue
 
 
302
  platform = (o.get("platform") or "Store").strip()
303
+ fmt = (o.get("format") or "").strip()
304
  price = (o.get("price") or "").strip()
305
+ name = f"{platform} {fmt}".strip() if fmt else platform
306
+ label = (f"{name} — {price}" if price else name)[:64]
307
+ if any((lnk.get("label") or "") == label for lnk in links):
308
+ continue
309
+ if not fmt and any((lnk.get("url") or "") == url for lnk in links):
310
+ continue
311
  links.append({
312
+ "label": label,
313
  "url": url,
314
  "type": "platform_purchase",
315
  "icon": "🛒",
app/schemas/chatbot.py CHANGED
@@ -20,10 +20,11 @@ class LinkItem(BaseModel):
20
 
21
 
22
  class PlatformOfferItem(BaseModel):
23
- """One retailer row for the widget Platform | Price | Buy grid."""
24
 
25
  platform_id: str
26
  platform: str
 
27
  price: str
28
  url: str
29
  is_best: bool = False
 
20
 
21
 
22
  class PlatformOfferItem(BaseModel):
23
+ """One retailer+format row for the widget Platform | Format | Price | Buy grid."""
24
 
25
  platform_id: str
26
  platform: str
27
+ format: str = ""
28
  price: str
29
  url: str
30
  is_best: bool = False
app/services/intent.py CHANGED
@@ -94,6 +94,11 @@ _PRICE_INQUIRY_SIGNALS: tuple[str, ...] = (
94
  "buy link for", "purchase link for", "how much on amazon", "how much on kobo",
95
  "buy on amazon", "buy on kobo", "buy on barnes",
96
  "which platforms", "where can i get it cheapest",
 
 
 
 
 
97
  )
98
 
99
  _PURCHASE_SIGNALS: tuple[str, ...] = (
 
94
  "buy link for", "purchase link for", "how much on amazon", "how much on kobo",
95
  "buy on amazon", "buy on kobo", "buy on barnes",
96
  "which platforms", "where can i get it cheapest",
97
+ "which format", "what format", "hard copy",
98
+ "hardcover or", "audiobook or", "ebook or", "paperback or",
99
+ "are those hardcover", "are these hardcover",
100
+ "are those audiobook", "are these audiobook",
101
+ "are those ebook", "are these ebook",
102
  )
103
 
104
  _PURCHASE_SIGNALS: tuple[str, ...] = (
app/services/price_catalog_service.py CHANGED
@@ -1,7 +1,8 @@
1
- """Grounded retailer price answers for visitor chat (R-PI-001–004).
2
 
3
  Reads ``book_platform_listings`` only. Never invents dollar amounts.
4
- Factual price turns return structured ``platform_offers`` for the widget grid.
 
5
  """
6
 
7
  from __future__ import annotations
@@ -17,11 +18,18 @@ from app.services.book_url_scraper import PLATFORM_DISPLAY
17
  from app.services.platform_catalog import (
18
  BOT_CHAT_EXCLUDED_PLATFORMS,
19
  canonical_format_prices,
20
- primary_list_price,
21
  )
22
 
23
  _PRICE_RE = re.compile(r"\$?\s*([0-9]+(?:\.[0-9]{1,2})?)")
24
 
 
 
 
 
 
 
 
 
25
  _PLATFORM_ALIASES: dict[str, tuple[str, ...]] = {
26
  "amazon": ("amazon", "kindle", "audible"),
27
  "barnes_noble": ("barnes", "b&n", "bn.com", "nook"),
@@ -38,6 +46,8 @@ _PLATFORM_ALIASES: dict[str, tuple[str, ...]] = {
38
  _FORMAT_HINTS: tuple[tuple[str, str], ...] = (
39
  ("hardcover", "Hardcover"),
40
  ("hard cover", "Hardcover"),
 
 
41
  ("paperback", "Paperback"),
42
  ("mass market", "Mass Market Paperback"),
43
  ("ebook", "eBook"),
@@ -50,7 +60,7 @@ _FORMAT_HINTS: tuple[tuple[str, str], ...] = (
50
 
51
  @dataclass(frozen=True)
52
  class Offer:
53
- """One retailer offer for chat / widget."""
54
 
55
  platform_id: str
56
  platform: str
@@ -58,13 +68,14 @@ class Offer:
58
  price_cents: int | None
59
  url: str
60
  is_source: bool = False
 
61
 
62
 
63
  @dataclass(frozen=True)
64
  class PriceQuery:
65
  """Classified price question."""
66
 
67
- kind: str # list|cheapest|expensive|compare|platform_link|how_much|soft_value|none
68
  platform_ids: tuple[str, ...] = ()
69
  format_hint: str = ""
70
 
@@ -114,6 +125,16 @@ def classify_price_query(message: str) -> PriceQuery:
114
  if any(s in q for s in soft):
115
  return PriceQuery(kind="soft_value", platform_ids=platforms, format_hint=fmt)
116
 
 
 
 
 
 
 
 
 
 
 
117
  list_sigs = (
118
  "list all platforms", "all platforms", "all prices", "all retailers",
119
  "platform prices", "formats and prices", "every platform",
@@ -153,19 +174,25 @@ def classify_price_query(message: str) -> PriceQuery:
153
  return PriceQuery(kind="none", platform_ids=platforms, format_hint=fmt)
154
 
155
 
156
- def format_price_facts(offers: list[Offer], limit: int = 4) -> str:
 
 
 
 
 
157
  """Build PRICE_FACTS snippet for soft objection LLM turns."""
158
  lines: list[str] = []
159
  for o in offers[:limit]:
160
  if o.price_cents is None:
161
  continue
162
  src = " (source)" if o.is_source else ""
163
- lines.append(f"{o.platform}{src}: {o.price_display}")
 
164
  if not lines:
165
  return ""
166
  return (
167
- "PRICE_FACTS (cite ONLY these exact amounts; never invent other prices; "
168
- "never paste URLs — buttons handle purchase):\n- "
169
  + "\n- ".join(lines)
170
  )
171
 
@@ -175,6 +202,7 @@ def offer_to_platform_item(offer: Offer, *, is_best: bool = False) -> dict[str,
175
  return {
176
  "platform_id": offer.platform_id,
177
  "platform": offer.platform,
 
178
  "price": offer.price_display or "See site",
179
  "url": offer.url,
180
  "is_best": is_best,
@@ -195,7 +223,7 @@ class PriceCatalogService:
195
  *,
196
  format_hint: str = "",
197
  ) -> list[Offer]:
198
- """Return verified offers with a buy URL for one book."""
199
  rows = await self._listings.list_for_book(book_id, author_id)
200
  offers: list[Offer] = []
201
  for row in rows:
@@ -211,21 +239,39 @@ class PriceCatalogService:
211
  row.format_prices if isinstance(getattr(row, "format_prices", None), dict) else {},
212
  pid,
213
  )
214
- display = primary_list_price(
215
- fps,
216
- format_hint or (getattr(row, "book_format", None) or ""),
217
- getattr(row, "list_price", None) or "",
218
- )
219
- if not display and format_hint and fps.get(format_hint):
220
- display = fps[format_hint]
221
- cents = parse_price_cents(display)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  offers.append(Offer(
223
  platform_id=pid,
224
- platform=PLATFORM_DISPLAY.get(pid, pid.replace("_", " ").title()),
225
- price_display=display if display else "See site",
226
- price_cents=cents,
227
  url=url,
228
- is_source=bool(getattr(row, "is_source", False)),
 
229
  ))
230
  return offers
231
 
@@ -238,14 +284,18 @@ class PriceCatalogService:
238
  ) -> dict[str, Any]:
239
  """Build a full chat response dict for a factual price turn."""
240
  pq = classify_price_query(query)
 
 
241
  offers = await self.catalog_for_book(
242
- author_id, book_id, format_hint=pq.format_hint,
243
  )
244
  if not offers:
245
  return await self._build_empty_fallback(author_id, book_id, book_title)
246
 
247
  kind = pq.kind
248
- if kind == "list":
 
 
249
  return self._build_list_answer(offers, book_title)
250
  if kind == "cheapest":
251
  return self._build_cheapest_answer(offers, book_title)
@@ -257,7 +307,6 @@ class PriceCatalogService:
257
  return self._build_platform_link_answer(offers, book_title, pq.platform_ids)
258
  if kind == "how_much":
259
  return self._build_how_much_answer(offers, book_title, pq.platform_ids)
260
- # soft_value / none should not call handle — fall through to list as safe default
261
  return self._build_list_answer(offers, book_title)
262
 
263
  async def _build_empty_fallback(
@@ -280,11 +329,12 @@ class PriceCatalogService:
280
  )
281
 
282
  def _build_list_answer(self, offers: list[Offer], book_title: str) -> dict[str, Any]:
283
- """All verified platforms as platform_offers grid (no duplicate pills)."""
284
  priced = sum(1 for o in offers if o.price_cents is not None)
285
  text = (
286
  f"Here are the places I know for {book_title} — "
287
- f"{priced} with a listed price. Pick a Buy button for the store you prefer."
 
288
  )
289
  return {
290
  "text": text,
@@ -293,6 +343,30 @@ class PriceCatalogService:
293
  "platform_offers": [offer_to_platform_item(o) for o in offers],
294
  }
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  def _build_cheapest_answer(self, offers: list[Offer], book_title: str) -> dict[str, Any]:
297
  priced = [o for o in offers if o.price_cents is not None]
298
  if len(priced) < 1:
@@ -308,9 +382,8 @@ class PriceCatalogService:
308
  best = min(priced, key=lambda o: o.price_cents or 10**9)
309
  text = (
310
  f"The lowest listed price for {book_title} is {best.price_display} "
311
- f"on {best.platform}."
312
  )
313
- # Single winner + Buy button only — no full Platform/Price/Buy grid.
314
  return {
315
  "text": text,
316
  "links": [{
@@ -330,7 +403,7 @@ class PriceCatalogService:
330
  top = max(priced, key=lambda o: o.price_cents or 0)
331
  text = (
332
  f"The highest listed price for {book_title} is {top.price_display} "
333
- f"on {top.platform}."
334
  )
335
  return {
336
  "text": text,
@@ -352,10 +425,10 @@ class PriceCatalogService:
352
  ) -> dict[str, Any]:
353
  selected = [o for o in offers if o.platform_id in platform_ids] if platform_ids else list(offers)
354
  if len(selected) < 2:
355
- selected = list(offers)[:4]
356
  bits = []
357
  for o in selected:
358
- bits.append(f"{o.platform} at {o.price_display}")
359
  text = f"For {book_title}: " + "; ".join(bits) + "."
360
  return {
361
  "text": text[:380],
@@ -370,9 +443,23 @@ class PriceCatalogService:
370
  book_title: str,
371
  platform_ids: tuple[str, ...],
372
  ) -> dict[str, Any]:
373
- # Visitor named a catalog-only site (e.g. Google Books) — redirect to retailers.
374
  named_excluded = [p for p in platform_ids if p in BOT_CHAT_EXCLUDED_PLATFORMS]
375
  retail_ids = [p for p in platform_ids if p not in BOT_CHAT_EXCLUDED_PLATFORMS]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  target = None
377
  for pid in retail_ids:
378
  for o in offers:
@@ -388,7 +475,7 @@ class PriceCatalogService:
388
  target = next((o for o in offers if o.is_source), offers[0])
389
  text = (
390
  f"I don't use {excl_name} as a buy link for {book_title} — "
391
- f"here's {target.platform} instead"
392
  + (f" at {target.price_display}." if target.price_cents is not None else ".")
393
  )
394
  return {
@@ -411,7 +498,8 @@ class PriceCatalogService:
411
  )
412
  else:
413
  text = (
414
- f"{book_title} is listed at {target.price_display} on {target.platform}."
 
415
  )
416
  return {
417
  "text": text,
@@ -433,7 +521,21 @@ class PriceCatalogService:
433
  ) -> dict[str, Any]:
434
  if platform_ids:
435
  return self._build_platform_link_answer(offers, book_title, platform_ids)
436
- source = next((o for o in offers if o.is_source), None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  priced = [o for o in offers if o.price_cents is not None]
438
  target = source if source and source.price_cents is not None else (
439
  min(priced, key=lambda o: o.price_cents or 10**9) if priced else (source or offers[0])
@@ -446,7 +548,7 @@ class PriceCatalogService:
446
  else:
447
  text = (
448
  f"{book_title} is listed at {target.price_display} "
449
- f"on {target.platform}."
450
  )
451
  return {
452
  "text": text,
 
1
+ """Grounded retailer price answers for visitor chat (R-PI-001–006).
2
 
3
  Reads ``book_platform_listings`` only. Never invents dollar amounts.
4
+ Factual price turns return structured ``platform_offers`` for the widget grid
5
+ (Platform | Format | Price | Buy — one row per platform+format).
6
  """
7
 
8
  from __future__ import annotations
 
18
  from app.services.platform_catalog import (
19
  BOT_CHAT_EXCLUDED_PLATFORMS,
20
  canonical_format_prices,
 
21
  )
22
 
23
  _PRICE_RE = re.compile(r"\$?\s*([0-9]+(?:\.[0-9]{1,2})?)")
24
 
25
+ _FORMAT_ORDER = (
26
+ "Hardcover",
27
+ "Paperback",
28
+ "Mass Market Paperback",
29
+ "eBook",
30
+ "Audiobook",
31
+ )
32
+
33
  _PLATFORM_ALIASES: dict[str, tuple[str, ...]] = {
34
  "amazon": ("amazon", "kindle", "audible"),
35
  "barnes_noble": ("barnes", "b&n", "bn.com", "nook"),
 
46
  _FORMAT_HINTS: tuple[tuple[str, str], ...] = (
47
  ("hardcover", "Hardcover"),
48
  ("hard cover", "Hardcover"),
49
+ ("hard copy", "Hardcover"),
50
+ ("hardcopy", "Hardcover"),
51
  ("paperback", "Paperback"),
52
  ("mass market", "Mass Market Paperback"),
53
  ("ebook", "eBook"),
 
60
 
61
  @dataclass(frozen=True)
62
  class Offer:
63
+ """One retailer + format offer for chat / widget."""
64
 
65
  platform_id: str
66
  platform: str
 
68
  price_cents: int | None
69
  url: str
70
  is_source: bool = False
71
+ format: str = ""
72
 
73
 
74
  @dataclass(frozen=True)
75
  class PriceQuery:
76
  """Classified price question."""
77
 
78
+ kind: str # list|cheapest|expensive|compare|platform_link|how_much|format_clarify|soft_value|none
79
  platform_ids: tuple[str, ...] = ()
80
  format_hint: str = ""
81
 
 
125
  if any(s in q for s in soft):
126
  return PriceQuery(kind="soft_value", platform_ids=platforms, format_hint=fmt)
127
 
128
+ clarify = (
129
+ "hardcover or", "hard cover or", "hard copy or", "audiobook or",
130
+ "ebook or", "e-book or", "paperback or",
131
+ "which format", "what format", "are those", "are these",
132
+ "is that the", "is that hardcover", "is that audiobook",
133
+ "is that ebook", "format are", "those prices",
134
+ )
135
+ if any(s in q for s in clarify):
136
+ return PriceQuery(kind="format_clarify", platform_ids=platforms, format_hint=fmt)
137
+
138
  list_sigs = (
139
  "list all platforms", "all platforms", "all prices", "all retailers",
140
  "platform prices", "formats and prices", "every platform",
 
174
  return PriceQuery(kind="none", platform_ids=platforms, format_hint=fmt)
175
 
176
 
177
+ def _format_clause(offer: Offer) -> str:
178
+ """Parenthetical format for prose when known."""
179
+ return f" ({offer.format})" if offer.format else ""
180
+
181
+
182
+ def format_price_facts(offers: list[Offer], limit: int = 6) -> str:
183
  """Build PRICE_FACTS snippet for soft objection LLM turns."""
184
  lines: list[str] = []
185
  for o in offers[:limit]:
186
  if o.price_cents is None:
187
  continue
188
  src = " (source)" if o.is_source else ""
189
+ fmt = f" {o.format}" if o.format else ""
190
+ lines.append(f"{o.platform}{fmt}{src}: {o.price_display}")
191
  if not lines:
192
  return ""
193
  return (
194
+ "PRICE_FACTS (cite ONLY these exact amounts and formats; never invent "
195
+ "other prices or formats; never paste URLs — buttons handle purchase):\n- "
196
  + "\n- ".join(lines)
197
  )
198
 
 
202
  return {
203
  "platform_id": offer.platform_id,
204
  "platform": offer.platform,
205
+ "format": offer.format or "",
206
  "price": offer.price_display or "See site",
207
  "url": offer.url,
208
  "is_best": is_best,
 
223
  *,
224
  format_hint: str = "",
225
  ) -> list[Offer]:
226
+ """Return one offer per priced format (or one list_price fallback row)."""
227
  rows = await self._listings.list_for_book(book_id, author_id)
228
  offers: list[Offer] = []
229
  for row in rows:
 
239
  row.format_prices if isinstance(getattr(row, "format_prices", None), dict) else {},
240
  pid,
241
  )
242
+ display_name = PLATFORM_DISPLAY.get(pid, pid.replace("_", " ").title())
243
+ is_source = bool(getattr(row, "is_source", False))
244
+
245
+ if fps:
246
+ for fmt_key in _FORMAT_ORDER:
247
+ if fmt_key not in fps:
248
+ continue
249
+ if format_hint and fmt_key != format_hint:
250
+ continue
251
+ display = fps[fmt_key]
252
+ offers.append(Offer(
253
+ platform_id=pid,
254
+ platform=display_name,
255
+ price_display=display,
256
+ price_cents=parse_price_cents(display),
257
+ url=url,
258
+ is_source=is_source,
259
+ format=fmt_key,
260
+ ))
261
+ continue
262
+
263
+ list_price = (getattr(row, "list_price", None) or "").strip()
264
+ if format_hint:
265
+ # No format map — cannot claim the hint; skip this row.
266
+ continue
267
  offers.append(Offer(
268
  platform_id=pid,
269
+ platform=display_name,
270
+ price_display=list_price if list_price else "See site",
271
+ price_cents=parse_price_cents(list_price),
272
  url=url,
273
+ is_source=is_source,
274
+ format="",
275
  ))
276
  return offers
277
 
 
284
  ) -> dict[str, Any]:
285
  """Build a full chat response dict for a factual price turn."""
286
  pq = classify_price_query(query)
287
+ # format_clarify must show every stored format — ignore incidental hint words.
288
+ hint = "" if pq.kind == "format_clarify" else pq.format_hint
289
  offers = await self.catalog_for_book(
290
+ author_id, book_id, format_hint=hint,
291
  )
292
  if not offers:
293
  return await self._build_empty_fallback(author_id, book_id, book_title)
294
 
295
  kind = pq.kind
296
+ if kind in ("list", "format_clarify", "none"):
297
+ if kind == "format_clarify":
298
+ return self._build_format_clarify_answer(offers, book_title)
299
  return self._build_list_answer(offers, book_title)
300
  if kind == "cheapest":
301
  return self._build_cheapest_answer(offers, book_title)
 
307
  return self._build_platform_link_answer(offers, book_title, pq.platform_ids)
308
  if kind == "how_much":
309
  return self._build_how_much_answer(offers, book_title, pq.platform_ids)
 
310
  return self._build_list_answer(offers, book_title)
311
 
312
  async def _build_empty_fallback(
 
329
  )
330
 
331
  def _build_list_answer(self, offers: list[Offer], book_title: str) -> dict[str, Any]:
332
+ """All verified platform+format rows as platform_offers grid."""
333
  priced = sum(1 for o in offers if o.price_cents is not None)
334
  text = (
335
  f"Here are the places I know for {book_title} — "
336
+ f"{priced} with a listed price and format. "
337
+ "Pick a Buy button for the store and format you prefer."
338
  )
339
  return {
340
  "text": text,
 
343
  "platform_offers": [offer_to_platform_item(o) for o in offers],
344
  }
345
 
346
+ def _build_format_clarify_answer(
347
+ self, offers: list[Offer], book_title: str,
348
+ ) -> dict[str, Any]:
349
+ """Re-list every stored price with its format — no guessing."""
350
+ formats = sorted({o.format for o in offers if o.format})
351
+ if formats:
352
+ named = ", ".join(formats)
353
+ text = (
354
+ f"Here’s each listed price for {book_title} with its format "
355
+ f"({named}). I only show formats I have stored — nothing invented."
356
+ )
357
+ else:
358
+ text = (
359
+ f"Here are the listed prices I have for {book_title}. "
360
+ "I don��t have separate format labels for these yet — "
361
+ "check the retailer page for binding details."
362
+ )
363
+ return {
364
+ "text": text[:380],
365
+ "links": [],
366
+ "has_links": False,
367
+ "platform_offers": [offer_to_platform_item(o) for o in offers],
368
+ }
369
+
370
  def _build_cheapest_answer(self, offers: list[Offer], book_title: str) -> dict[str, Any]:
371
  priced = [o for o in offers if o.price_cents is not None]
372
  if len(priced) < 1:
 
382
  best = min(priced, key=lambda o: o.price_cents or 10**9)
383
  text = (
384
  f"The lowest listed price for {book_title} is {best.price_display} "
385
+ f"on {best.platform}{_format_clause(best)}."
386
  )
 
387
  return {
388
  "text": text,
389
  "links": [{
 
403
  top = max(priced, key=lambda o: o.price_cents or 0)
404
  text = (
405
  f"The highest listed price for {book_title} is {top.price_display} "
406
+ f"on {top.platform}{_format_clause(top)}."
407
  )
408
  return {
409
  "text": text,
 
425
  ) -> dict[str, Any]:
426
  selected = [o for o in offers if o.platform_id in platform_ids] if platform_ids else list(offers)
427
  if len(selected) < 2:
428
+ selected = list(offers)[:6]
429
  bits = []
430
  for o in selected:
431
+ bits.append(f"{o.platform}{_format_clause(o)} at {o.price_display}")
432
  text = f"For {book_title}: " + "; ".join(bits) + "."
433
  return {
434
  "text": text[:380],
 
443
  book_title: str,
444
  platform_ids: tuple[str, ...],
445
  ) -> dict[str, Any]:
 
446
  named_excluded = [p for p in platform_ids if p in BOT_CHAT_EXCLUDED_PLATFORMS]
447
  retail_ids = [p for p in platform_ids if p not in BOT_CHAT_EXCLUDED_PLATFORMS]
448
+
449
+ if retail_ids:
450
+ platform_rows = [o for o in offers if o.platform_id in retail_ids]
451
+ if len(platform_rows) > 1:
452
+ name = platform_rows[0].platform
453
+ text = (
454
+ f"On {name}, here are the format prices I have for {book_title}."
455
+ )
456
+ return {
457
+ "text": text[:380],
458
+ "links": [],
459
+ "has_links": False,
460
+ "platform_offers": [offer_to_platform_item(o) for o in platform_rows],
461
+ }
462
+
463
  target = None
464
  for pid in retail_ids:
465
  for o in offers:
 
475
  target = next((o for o in offers if o.is_source), offers[0])
476
  text = (
477
  f"I don't use {excl_name} as a buy link for {book_title} — "
478
+ f"here's {target.platform}{_format_clause(target)} instead"
479
  + (f" at {target.price_display}." if target.price_cents is not None else ".")
480
  )
481
  return {
 
498
  )
499
  else:
500
  text = (
501
+ f"{book_title} is listed at {target.price_display} "
502
+ f"on {target.platform}{_format_clause(target)}."
503
  )
504
  return {
505
  "text": text,
 
521
  ) -> dict[str, Any]:
522
  if platform_ids:
523
  return self._build_platform_link_answer(offers, book_title, platform_ids)
524
+
525
+ source_rows = [o for o in offers if o.is_source]
526
+ if len(source_rows) > 1:
527
+ name = source_rows[0].platform
528
+ text = (
529
+ f"On {name}, here are the format prices I have for {book_title}."
530
+ )
531
+ return {
532
+ "text": text[:380],
533
+ "links": [],
534
+ "has_links": False,
535
+ "platform_offers": [offer_to_platform_item(o) for o in source_rows],
536
+ }
537
+
538
+ source = source_rows[0] if source_rows else None
539
  priced = [o for o in offers if o.price_cents is not None]
540
  target = source if source and source.price_cents is not None else (
541
  min(priced, key=lambda o: o.price_cents or 10**9) if priced else (source or offers[0])
 
548
  else:
549
  text = (
550
  f"{book_title} is listed at {target.price_display} "
551
+ f"on {target.platform}{_format_clause(target)}."
552
  )
553
  return {
554
  "text": text,
static/widget.js CHANGED
@@ -199,8 +199,8 @@
199
  background: ${T.surface}; overflow: hidden;
200
  }
201
  .ab-po-header, .ab-po-row {
202
- display: grid; grid-template-columns: 1.2fr 0.8fr auto;
203
- gap: 8px; align-items: center; padding: 8px 10px;
204
  font-size: 12px;
205
  }
206
  .ab-po-header {
@@ -212,6 +212,7 @@
212
  .ab-po-row:last-child { border-bottom: none; }
213
  .ab-po-row.ab-po-best { background: ${T.brand}12; }
214
  .ab-po-row .ab-link-btn { padding: 4px 10px; font-size: 11px; }
 
215
 
216
  .ab-followups { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; max-width: 100%; }
217
  .ab-followup-chip {
@@ -788,11 +789,13 @@
788
  }
789
 
790
  if (platformOffers && platformOffers.length) {
791
- html += `<div class="ab-platform-offers"><div class="ab-po-header"><span>Platform</span><span>Price</span><span>Buy</span></div>`;
792
  platformOffers.forEach(o => {
793
  const best = o.is_best ? ' ab-po-best' : '';
 
794
  html += `<div class="ab-po-row${best}">
795
  <span>${escHtml(o.platform || '')}</span>
 
796
  <span>${escHtml(o.price || 'See site')}</span>
797
  <a class="ab-link-btn" href="${escAttr(o.url)}" target="_blank" rel="noopener"
798
  data-type="platform_purchase" data-platform="${escAttr(o.platform_id || '')}">Buy</a>
 
199
  background: ${T.surface}; overflow: hidden;
200
  }
201
  .ab-po-header, .ab-po-row {
202
+ display: grid; grid-template-columns: 1.1fr 0.9fr 0.7fr auto;
203
+ gap: 6px; align-items: center; padding: 8px 10px;
204
  font-size: 12px;
205
  }
206
  .ab-po-header {
 
212
  .ab-po-row:last-child { border-bottom: none; }
213
  .ab-po-row.ab-po-best { background: ${T.brand}12; }
214
  .ab-po-row .ab-link-btn { padding: 4px 10px; font-size: 11px; }
215
+ .ab-po-fmt { color: ${T.sub}; font-size: 11px; }
216
 
217
  .ab-followups { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; max-width: 100%; }
218
  .ab-followup-chip {
 
789
  }
790
 
791
  if (platformOffers && platformOffers.length) {
792
+ html += `<div class="ab-platform-offers"><div class="ab-po-header"><span>Platform</span><span>Format</span><span>Price</span><span>Buy</span></div>`;
793
  platformOffers.forEach(o => {
794
  const best = o.is_best ? ' ab-po-best' : '';
795
+ const fmt = (o.format || '').trim() || '—';
796
  html += `<div class="ab-po-row${best}">
797
  <span>${escHtml(o.platform || '')}</span>
798
+ <span class="ab-po-fmt">${escHtml(fmt)}</span>
799
  <span>${escHtml(o.price || 'See site')}</span>
800
  <a class="ab-link-btn" href="${escAttr(o.url)}" target="_blank" rel="noopener"
801
  data-type="platform_purchase" data-platform="${escAttr(o.platform_id || '')}">Buy</a>
tests/bdd/features/price_intelligence.feature CHANGED
@@ -8,3 +8,8 @@ Feature: Bot price intelligence
8
  Scenario: Soft too expensive stays purchase path
9
  Given a visitor message "that's too expensive"
10
  Then price query kind is soft_value
 
 
 
 
 
 
8
  Scenario: Soft too expensive stays purchase path
9
  Given a visitor message "that's too expensive"
10
  Then price query kind is soft_value
11
+
12
+ Scenario: Format clarify is price_inquiry
13
+ Given a visitor message "are those hardcover or audiobook"
14
+ Then intent classification returns price_inquiry
15
+ And price query kind is format_clarify
tests/bdd/steps/price_intelligence_steps.py CHANGED
@@ -21,3 +21,8 @@ def step_intent_price_inquiry(context):
21
  @then("price query kind is soft_value")
22
  def step_soft_value(context):
23
  assert classify_price_query(context.visitor_message).kind == "soft_value"
 
 
 
 
 
 
21
  @then("price query kind is soft_value")
22
  def step_soft_value(context):
23
  assert classify_price_query(context.visitor_message).kind == "soft_value"
24
+
25
+
26
+ @then("price query kind is format_clarify")
27
+ def step_format_clarify(context):
28
+ assert classify_price_query(context.visitor_message).kind == "format_clarify"
tests/unit/test_price_catalog_service.py CHANGED
@@ -33,6 +33,9 @@ def test_classify_price_query_kinds():
33
  assert classify_price_query("buy link for amazon").kind == "platform_link"
34
  assert classify_price_query("how much does it cost").kind == "how_much"
35
  assert classify_price_query("that's too expensive").kind == "soft_value"
 
 
 
36
 
37
 
38
  def test_price_inquiry_intent_before_purchase():
@@ -40,16 +43,18 @@ def test_price_inquiry_intent_before_purchase():
40
  assert "cheapest" not in _PURCHASE_SIGNALS
41
  assert _classify_by_rules("where is it cheapest") == "price_inquiry"
42
  assert _classify_by_rules("list all platforms") == "price_inquiry"
 
43
  assert _classify_by_rules("how much does it cost") == "purchase_intent"
44
 
45
 
46
  def test_format_price_facts_only_priced():
47
  offers = [
48
- Offer("amazon", "Amazon", "$14.99", 1499, "https://a", True),
49
- Offer("kobo", "Kobo", "See site", None, "https://k", False),
50
  ]
51
  facts = format_price_facts(offers)
52
  assert "$14.99" in facts
 
53
  assert "See site" not in facts
54
  assert "PRICE_FACTS" in facts
55
 
@@ -63,14 +68,16 @@ def test_chat_response_accepts_platform_offers():
63
  PlatformOfferItem(
64
  platform_id="amazon",
65
  platform="Amazon",
66
- price="$14.99",
 
67
  url="https://amazon.com/dp/X",
68
  is_best=True,
69
  ),
70
  ],
71
  )
72
  dumped = resp.model_dump()
73
- assert dumped["platform_offers"][0]["price"] == "$14.99"
 
74
  assert dumped["platform_offers"][0]["is_best"] is True
75
 
76
 
@@ -87,12 +94,13 @@ def test_get_book_links_still_ignores_platform_listings():
87
  async def test_build_cheapest_marks_is_best():
88
  svc = PriceCatalogService.__new__(PriceCatalogService)
89
  offers = [
90
- Offer("amazon", "Amazon", "$14.99", 1499, "https://a.com", True),
91
- Offer("kobo", "Kobo", "$9.99", 999, "https://k.com", False),
92
  ]
93
  result = svc._build_cheapest_answer(offers, "Lethal Lines")
94
  assert "Kobo" in result["text"]
95
  assert "$9.99" in result["text"]
 
96
  assert result["platform_offers"] is None
97
  assert result["links"][0]["url"] == "https://k.com"
98
  assert "Buy on Kobo" in result["links"][0]["label"]
@@ -102,7 +110,7 @@ async def test_build_cheapest_marks_is_best():
102
  async def test_google_books_link_ask_redirects_to_retail():
103
  svc = PriceCatalogService.__new__(PriceCatalogService)
104
  offers = [
105
- Offer("amazon", "Amazon", "$14.99", 1499, "https://a.com", True),
106
  ]
107
  result = svc._build_platform_link_answer(offers, "Lethal Lines", ("google_books",))
108
  assert "Google Books" in result["text"]
@@ -115,13 +123,65 @@ async def test_google_books_link_ask_redirects_to_retail():
115
  async def test_build_list_see_site_for_unpriced():
116
  svc = PriceCatalogService.__new__(PriceCatalogService)
117
  offers = [
118
- Offer("amazon", "Amazon", "See site", None, "https://a.com", True),
119
- Offer("kobo", "Kobo", "$9.99", 999, "https://k.com", False),
120
  ]
121
  result = svc._build_list_answer(offers, "Book")
122
  assert result["links"] == []
123
  assert len(result["platform_offers"]) == 2
124
  assert result["platform_offers"][0]["price"] == "See site"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
 
127
  @pytest.mark.asyncio
@@ -151,6 +211,7 @@ async def test_catalog_for_book_skips_non_verified():
151
  offers = await svc.catalog_for_book("a1", "b1")
152
  assert len(offers) == 1
153
  assert offers[0].price_display == "$14.99"
 
154
  assert offers[0].price_cents == 1499
155
 
156
 
@@ -195,11 +256,27 @@ async def test_catalog_for_book_excludes_google_books():
195
  async def test_handle_how_much_uses_source():
196
  svc = PriceCatalogService(MagicMock())
197
  svc.catalog_for_book = AsyncMock(return_value=[
198
- Offer("kobo", "Kobo", "$9.99", 999, "https://k.com", False),
199
- Offer("amazon", "Amazon", "$14.99", 1499, "https://a.com", True),
200
  ])
201
  result = await svc.handle("how much does it cost", "a1", "b1", "Lethal Lines")
202
  assert "$14.99" in result["text"]
203
  assert "Amazon" in result["text"]
 
204
  assert result["links"][0]["url"] == "https://a.com"
205
  assert result.get("platform_offers") is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  assert classify_price_query("buy link for amazon").kind == "platform_link"
34
  assert classify_price_query("how much does it cost").kind == "how_much"
35
  assert classify_price_query("that's too expensive").kind == "soft_value"
36
+ assert classify_price_query("are those hardcover or audiobook").kind == "format_clarify"
37
+ assert classify_price_query("which format is the $35").kind == "format_clarify"
38
+ assert classify_price_query("cheapest ebook").format_hint == "eBook"
39
 
40
 
41
  def test_price_inquiry_intent_before_purchase():
 
43
  assert "cheapest" not in _PURCHASE_SIGNALS
44
  assert _classify_by_rules("where is it cheapest") == "price_inquiry"
45
  assert _classify_by_rules("list all platforms") == "price_inquiry"
46
+ assert _classify_by_rules("are those hardcover or audiobook") == "price_inquiry"
47
  assert _classify_by_rules("how much does it cost") == "purchase_intent"
48
 
49
 
50
  def test_format_price_facts_only_priced():
51
  offers = [
52
+ Offer("amazon", "Amazon", "$14.99", 1499, "https://a", True, "eBook"),
53
+ Offer("kobo", "Kobo", "See site", None, "https://k", False, ""),
54
  ]
55
  facts = format_price_facts(offers)
56
  assert "$14.99" in facts
57
+ assert "eBook" in facts
58
  assert "See site" not in facts
59
  assert "PRICE_FACTS" in facts
60
 
 
68
  PlatformOfferItem(
69
  platform_id="amazon",
70
  platform="Amazon",
71
+ format="Hardcover",
72
+ price="$35.00",
73
  url="https://amazon.com/dp/X",
74
  is_best=True,
75
  ),
76
  ],
77
  )
78
  dumped = resp.model_dump()
79
+ assert dumped["platform_offers"][0]["price"] == "$35.00"
80
+ assert dumped["platform_offers"][0]["format"] == "Hardcover"
81
  assert dumped["platform_offers"][0]["is_best"] is True
82
 
83
 
 
94
  async def test_build_cheapest_marks_is_best():
95
  svc = PriceCatalogService.__new__(PriceCatalogService)
96
  offers = [
97
+ Offer("amazon", "Amazon", "$14.99", 1499, "https://a.com", True, "eBook"),
98
+ Offer("kobo", "Kobo", "$9.99", 999, "https://k.com", False, "eBook"),
99
  ]
100
  result = svc._build_cheapest_answer(offers, "Lethal Lines")
101
  assert "Kobo" in result["text"]
102
  assert "$9.99" in result["text"]
103
+ assert "eBook" in result["text"]
104
  assert result["platform_offers"] is None
105
  assert result["links"][0]["url"] == "https://k.com"
106
  assert "Buy on Kobo" in result["links"][0]["label"]
 
110
  async def test_google_books_link_ask_redirects_to_retail():
111
  svc = PriceCatalogService.__new__(PriceCatalogService)
112
  offers = [
113
+ Offer("amazon", "Amazon", "$14.99", 1499, "https://a.com", True, "eBook"),
114
  ]
115
  result = svc._build_platform_link_answer(offers, "Lethal Lines", ("google_books",))
116
  assert "Google Books" in result["text"]
 
123
  async def test_build_list_see_site_for_unpriced():
124
  svc = PriceCatalogService.__new__(PriceCatalogService)
125
  offers = [
126
+ Offer("amazon", "Amazon", "See site", None, "https://a.com", True, ""),
127
+ Offer("kobo", "Kobo", "$9.99", 999, "https://k.com", False, "eBook"),
128
  ]
129
  result = svc._build_list_answer(offers, "Book")
130
  assert result["links"] == []
131
  assert len(result["platform_offers"]) == 2
132
  assert result["platform_offers"][0]["price"] == "See site"
133
+ assert result["platform_offers"][1]["format"] == "eBook"
134
+
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_format_clarify_returns_grid():
138
+ svc = PriceCatalogService.__new__(PriceCatalogService)
139
+ offers = [
140
+ Offer("amazon", "Amazon", "$35.00", 3500, "https://a.com", True, "Hardcover"),
141
+ Offer("amazon", "Amazon", "$14.99", 1499, "https://a.com", True, "eBook"),
142
+ Offer("apple_books", "Apple Books", "$14.99", 1499, "https://apple.com", False, "eBook"),
143
+ ]
144
+ result = svc._build_format_clarify_answer(offers, "All We Say")
145
+ assert "format" in result["text"].lower()
146
+ assert len(result["platform_offers"]) == 3
147
+ assert result["platform_offers"][0]["format"] == "Hardcover"
148
+ assert result["links"] == []
149
+
150
+
151
+ @pytest.mark.asyncio
152
+ async def test_catalog_for_book_expands_formats():
153
+ svc = PriceCatalogService(MagicMock())
154
+ svc._listings = MagicMock()
155
+ svc._listings.list_for_book = AsyncMock(return_value=[
156
+ SimpleNamespace(
157
+ status="verified",
158
+ listing_url="https://amazon.com/dp/B0X",
159
+ platform_id="amazon",
160
+ format_prices={"Hardcover": "$35.00", "eBook": "$14.99"},
161
+ list_price="$35.00",
162
+ book_format="Hardcover",
163
+ is_source=True,
164
+ ),
165
+ SimpleNamespace(
166
+ status="verified",
167
+ listing_url="https://books.apple.com/us/book/x",
168
+ platform_id="apple_books",
169
+ format_prices={"eBook": "$14.99"},
170
+ list_price="$14.99",
171
+ book_format="eBook",
172
+ is_source=False,
173
+ ),
174
+ ])
175
+ offers = await svc.catalog_for_book("a1", "b1")
176
+ assert len(offers) == 3
177
+ assert {(o.platform_id, o.format, o.price_display) for o in offers} == {
178
+ ("amazon", "Hardcover", "$35.00"),
179
+ ("amazon", "eBook", "$14.99"),
180
+ ("apple_books", "eBook", "$14.99"),
181
+ }
182
+ ebook_only = await svc.catalog_for_book("a1", "b1", format_hint="eBook")
183
+ assert len(ebook_only) == 2
184
+ assert all(o.format == "eBook" for o in ebook_only)
185
 
186
 
187
  @pytest.mark.asyncio
 
211
  offers = await svc.catalog_for_book("a1", "b1")
212
  assert len(offers) == 1
213
  assert offers[0].price_display == "$14.99"
214
+ assert offers[0].format == "eBook"
215
  assert offers[0].price_cents == 1499
216
 
217
 
 
256
  async def test_handle_how_much_uses_source():
257
  svc = PriceCatalogService(MagicMock())
258
  svc.catalog_for_book = AsyncMock(return_value=[
259
+ Offer("kobo", "Kobo", "$9.99", 999, "https://k.com", False, "eBook"),
260
+ Offer("amazon", "Amazon", "$14.99", 1499, "https://a.com", True, "eBook"),
261
  ])
262
  result = await svc.handle("how much does it cost", "a1", "b1", "Lethal Lines")
263
  assert "$14.99" in result["text"]
264
  assert "Amazon" in result["text"]
265
+ assert "eBook" in result["text"]
266
  assert result["links"][0]["url"] == "https://a.com"
267
  assert result.get("platform_offers") is None
268
+
269
+
270
+ @pytest.mark.asyncio
271
+ async def test_handle_format_clarify():
272
+ svc = PriceCatalogService(MagicMock())
273
+ svc.catalog_for_book = AsyncMock(return_value=[
274
+ Offer("amazon", "Amazon", "$35.00", 3500, "https://a.com", True, "Hardcover"),
275
+ Offer("amazon", "Amazon", "$14.99", 1499, "https://a.com", True, "eBook"),
276
+ ])
277
+ result = await svc.handle(
278
+ "are those hardcover or audiobook", "a1", "b1", "All We Say",
279
+ )
280
+ assert result["platform_offers"] is not None
281
+ assert any(o["format"] == "Hardcover" for o in result["platform_offers"])
282
+ assert "invent" in result["text"].lower() or "format" in result["text"].lower()