Spaces:
Running
Running
AuthorBot Cursor commited on
Commit ยท
1cd05b8
1
Parent(s): 6ceb1da
fix: exclude Google Books from bot price/buy offers
Browse filesCatalog 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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())
|