Spaces:
Running
Running
AuthorBot Cursor commited on
Commit ·
afad341
1
Parent(s): 6b1b2a7
feat: format-aware price table — Platform|Format|Price|Buy in bot chat
Browse filesExpand 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 +18 -4
- .agent/FILE_MAP.md +1 -1
- .agent/PRICE_INTELLIGENCE_RULES.md +8 -5
- app/channels/adapters/telegram.py +4 -2
- app/channels/services/bot_bridge.py +12 -5
- app/schemas/chatbot.py +2 -1
- app/services/intent.py +5 -0
- app/services/price_catalog_service.py +139 -37
- static/widget.js +6 -3
- tests/bdd/features/price_intelligence.feature +5 -0
- tests/bdd/steps/price_intelligence_steps.py +5 -0
- tests/unit/test_price_catalog_service.py +88 -11
.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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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–
|
| 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
|
| 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:**
|
| 50 |
-
- **Single-winner turns** (cheapest, most expensive, how much
|
|
|
|
| 51 |
- **Empty price:** display `See site`; Buy uses that platform's `listing_url`
|
| 52 |
-
- **Telegram:** stacked URL buttons (`
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 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":
|
| 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–
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 164 |
if not lines:
|
| 165 |
return ""
|
| 166 |
return (
|
| 167 |
-
"PRICE_FACTS (cite ONLY these exact amounts; never invent
|
| 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
|
| 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 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
offers.append(Offer(
|
| 223 |
platform_id=pid,
|
| 224 |
-
platform=
|
| 225 |
-
price_display=
|
| 226 |
-
price_cents=
|
| 227 |
url=url,
|
| 228 |
-
is_source=
|
|
|
|
| 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=
|
| 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
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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)[:
|
| 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}
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 203 |
-
gap:
|
| 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 |
-
|
|
|
|
| 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"] == "$
|
|
|
|
| 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()
|