AuthorBot Cursor commited on
Commit
1cd05b8
ยท
1 Parent(s): 6ceb1da

fix: exclude Google Books from bot price/buy offers

Browse files

Catalog and discovery platforms (google_books, goodreads, open_library) stay in admin scans but are never shown as Buy rows in visitor chat.

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

.agent/PRICE_INTELLIGENCE_RULES.md CHANGED
@@ -56,6 +56,9 @@ No new endpoints.
56
  2. Verified listing with URL only โ†’ `See site` + that URL
57
  3. No verified listings โ†’ warm text + existing `get_book_links` Buy button
58
 
 
 
 
59
  ## 7. Tests
60
 
61
  - [x] Unit โ€” PriceCatalogService parse/rank/classify/build
 
56
  2. Verified listing with URL only โ†’ `See site` + that URL
57
  3. No verified listings โ†’ warm text + existing `get_book_links` Buy button
58
 
59
+ **Never show as Buy / price rows in chat:** `google_books`, `goodreads`, `open_library`
60
+ (`BOT_CHAT_EXCLUDED_PLATFORMS`). Google Books remains an internal enricher only.
61
+
62
  ## 7. Tests
63
 
64
  - [x] Unit โ€” PriceCatalogService parse/rank/classify/build
app/services/platform_catalog.py CHANGED
@@ -24,6 +24,13 @@ _DISCOVERY_PLATFORMS = frozenset({"goodreads", "open_library"})
24
  _USED_PRINT_PLATFORMS = frozenset({"thriftbooks", "abebooks"})
25
  _DIGITAL_PLATFORMS = frozenset({"apple_books", "kobo"})
26
 
 
 
 
 
 
 
 
27
 
28
  def canonical_format_prices(
29
  raw: dict | None,
 
24
  _USED_PRINT_PLATFORMS = frozenset({"thriftbooks", "abebooks"})
25
  _DIGITAL_PLATFORMS = frozenset({"apple_books", "kobo"})
26
 
27
+ # Catalog / preview only โ€” never shown as Buy targets in visitor chat.
28
+ BOT_CHAT_EXCLUDED_PLATFORMS = frozenset({
29
+ "google_books", # API enricher only (not a URL-import / retail buy CTA)
30
+ "goodreads",
31
+ "open_library",
32
+ })
33
+
34
 
35
  def canonical_format_prices(
36
  raw: dict | None,
app/services/price_catalog_service.py CHANGED
@@ -14,7 +14,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
14
 
15
  from app.repositories.platform_listing_repo import PlatformListingRepository
16
  from app.services.book_url_scraper import PLATFORM_DISPLAY
17
- from app.services.platform_catalog import canonical_format_prices, primary_list_price
 
 
 
 
18
 
19
  _PRICE_RE = re.compile(r"\$?\s*([0-9]+(?:\.[0-9]{1,2})?)")
20
 
@@ -197,10 +201,12 @@ class PriceCatalogService:
197
  for row in rows:
198
  if getattr(row, "status", None) != "verified":
199
  continue
 
 
 
200
  url = (getattr(row, "listing_url", None) or "").strip()
201
  if not url.startswith(("http://", "https://")):
202
  continue
203
- pid = row.platform_id
204
  fps = canonical_format_prices(
205
  row.format_prices if isinstance(getattr(row, "format_prices", None), dict) else {},
206
  pid,
@@ -369,14 +375,38 @@ class PriceCatalogService:
369
  book_title: str,
370
  platform_ids: tuple[str, ...],
371
  ) -> dict[str, Any]:
 
 
 
372
  target = None
373
- for pid in platform_ids:
374
  for o in offers:
375
  if o.platform_id == pid:
376
  target = o
377
  break
378
  if target:
379
  break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  if target is None:
381
  target = next((o for o in offers if o.is_source), offers[0])
382
  if target.price_cents is None:
 
14
 
15
  from app.repositories.platform_listing_repo import PlatformListingRepository
16
  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
 
 
201
  for row in rows:
202
  if getattr(row, "status", None) != "verified":
203
  continue
204
+ pid = row.platform_id
205
+ if pid in BOT_CHAT_EXCLUDED_PLATFORMS:
206
+ continue
207
  url = (getattr(row, "listing_url", None) or "").strip()
208
  if not url.startswith(("http://", "https://")):
209
  continue
 
210
  fps = canonical_format_prices(
211
  row.format_prices if isinstance(getattr(row, "format_prices", None), dict) else {},
212
  pid,
 
375
  book_title: str,
376
  platform_ids: tuple[str, ...],
377
  ) -> dict[str, Any]:
378
+ # Visitor named a catalog-only site (e.g. Google Books) โ€” redirect to retailers.
379
+ named_excluded = [p for p in platform_ids if p in BOT_CHAT_EXCLUDED_PLATFORMS]
380
+ retail_ids = [p for p in platform_ids if p not in BOT_CHAT_EXCLUDED_PLATFORMS]
381
  target = None
382
+ for pid in retail_ids:
383
  for o in offers:
384
  if o.platform_id == pid:
385
  target = o
386
  break
387
  if target:
388
  break
389
+ if target is None and named_excluded and offers:
390
+ excl_name = PLATFORM_DISPLAY.get(
391
+ named_excluded[0], named_excluded[0].replace("_", " ").title(),
392
+ )
393
+ target = next((o for o in offers if o.is_source), offers[0])
394
+ text = (
395
+ f"I don't use {excl_name} as a buy link for {book_title} โ€” "
396
+ f"here's {target.platform} instead"
397
+ + (f" at {target.price_display}." if target.price_cents is not None else ".")
398
+ )
399
+ return {
400
+ "text": text[:380],
401
+ "links": [{
402
+ "label": f"Buy on {target.platform}",
403
+ "url": target.url,
404
+ "type": "platform_purchase",
405
+ "icon": "๐Ÿ›’",
406
+ }],
407
+ "has_links": True,
408
+ "platform_offers": [offer_to_platform_item(target, is_best=True)],
409
+ }
410
  if target is None:
411
  target = next((o for o in offers if o.is_source), offers[0])
412
  if target.price_cents is None:
tests/unit/test_price_catalog_service.py CHANGED
@@ -140,6 +140,56 @@ async def test_catalog_for_book_skips_non_verified():
140
  assert offers[0].price_cents == 1499
141
 
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  @pytest.mark.asyncio
144
  async def test_handle_how_much_uses_source():
145
  svc = PriceCatalogService(MagicMock())
 
140
  assert offers[0].price_cents == 1499
141
 
142
 
143
+ @pytest.mark.asyncio
144
+ async def test_catalog_for_book_excludes_google_books():
145
+ svc = PriceCatalogService(MagicMock())
146
+ svc._listings = MagicMock()
147
+ svc._listings.list_for_book = AsyncMock(return_value=[
148
+ SimpleNamespace(
149
+ status="verified",
150
+ listing_url="https://books.google.com/books?id=X",
151
+ platform_id="google_books",
152
+ format_prices={"eBook": "$0.00"},
153
+ list_price="$0.00",
154
+ book_format="eBook",
155
+ is_source=False,
156
+ ),
157
+ SimpleNamespace(
158
+ status="verified",
159
+ listing_url="https://www.goodreads.com/book/show/1",
160
+ platform_id="goodreads",
161
+ format_prices={},
162
+ list_price=None,
163
+ book_format=None,
164
+ is_source=False,
165
+ ),
166
+ SimpleNamespace(
167
+ status="verified",
168
+ listing_url="https://amazon.com/dp/B0X",
169
+ platform_id="amazon",
170
+ format_prices={"eBook": "$14.99"},
171
+ list_price="$14.99",
172
+ book_format="eBook",
173
+ is_source=True,
174
+ ),
175
+ ])
176
+ offers = await svc.catalog_for_book("a1", "b1")
177
+ assert [o.platform_id for o in offers] == ["amazon"]
178
+
179
+
180
+ @pytest.mark.asyncio
181
+ async def test_google_books_link_ask_redirects_to_retail():
182
+ svc = PriceCatalogService.__new__(PriceCatalogService)
183
+ offers = [
184
+ Offer("amazon", "Amazon", "$14.99", 1499, "https://a.com", True),
185
+ ]
186
+ result = svc._build_platform_link_answer(offers, "Lethal Lines", ("google_books",))
187
+ assert "Google Books" in result["text"]
188
+ assert "Amazon" in result["text"]
189
+ assert result["links"][0]["url"] == "https://a.com"
190
+ assert result["platform_offers"][0]["platform_id"] == "amazon"
191
+
192
+
193
  @pytest.mark.asyncio
194
  async def test_handle_how_much_uses_source():
195
  svc = PriceCatalogService(MagicMock())