Spaces:
Running
Running
AuthorBot Cursor commited on
Commit ·
66a04fa
1
Parent(s): 9496fb0
fix: route format price questions to catalog and stop inventing formats
Browse files
app/services/intent.py
CHANGED
|
@@ -99,6 +99,10 @@ _PRICE_INQUIRY_SIGNALS: tuple[str, ...] = (
|
|
| 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, ...] = (
|
|
|
|
| 99 |
"are those hardcover", "are these hardcover",
|
| 100 |
"are those audiobook", "are these audiobook",
|
| 101 |
"are those ebook", "are these ebook",
|
| 102 |
+
"audiobook price", "audiobook prices", "audio book", "audio-book",
|
| 103 |
+
"ebook price", "ebook prices", "e-book price",
|
| 104 |
+
"hardcover price", "hardcover prices", "paperback price", "paperback prices",
|
| 105 |
+
"what about audio", "what about ebook", "what about hardcover",
|
| 106 |
)
|
| 107 |
|
| 108 |
_PURCHASE_SIGNALS: tuple[str, ...] = (
|
app/services/pipeline/core.py
CHANGED
|
@@ -228,22 +228,7 @@ async def run_pipeline(
|
|
| 228 |
)
|
| 229 |
|
| 230 |
# ── Step 2: Intent Classification ─────────────────────────────────────────
|
| 231 |
-
_t_intent = time.monotonic()
|
| 232 |
intent_result = await classify_intent(query, session_context.history)
|
| 233 |
-
# #region agent log
|
| 234 |
-
from app.debug_agent_log import agent_dbg
|
| 235 |
-
|
| 236 |
-
agent_dbg(
|
| 237 |
-
"L3",
|
| 238 |
-
"core.py:intent",
|
| 239 |
-
"intent step timing",
|
| 240 |
-
{
|
| 241 |
-
"ms": int((time.monotonic() - _t_intent) * 1000),
|
| 242 |
-
"source": intent_result.source,
|
| 243 |
-
"intent": intent_result.intent,
|
| 244 |
-
},
|
| 245 |
-
)
|
| 246 |
-
# #endregion
|
| 247 |
log.debug("Intent classified", intent=intent_result.intent, source=intent_result.source)
|
| 248 |
|
| 249 |
if intent_result.intent == "jailbreak_attempt":
|
|
@@ -318,10 +303,14 @@ async def run_pipeline(
|
|
| 318 |
# ── Price intelligence short-circuit (after book resolve; skip RAG/LLM) ──
|
| 319 |
from app.services.price_catalog_service import PriceCatalogService, classify_price_query
|
| 320 |
|
| 321 |
-
|
|
|
|
| 322 |
use_price_catalog = search_book_id and (
|
| 323 |
intent_result.intent == "price_inquiry"
|
| 324 |
-
or (
|
|
|
|
|
|
|
|
|
|
| 325 |
)
|
| 326 |
if use_price_catalog and price_kind != "soft_value":
|
| 327 |
book_title = selected_book_title(active_books, search_book_id) or "this book"
|
|
@@ -336,27 +325,6 @@ async def run_pipeline(
|
|
| 336 |
link_shown=bool(price_result.get("has_links") or price_result.get("platform_offers")),
|
| 337 |
)
|
| 338 |
|
| 339 |
-
# #region agent log
|
| 340 |
-
from app.debug_agent_log import agent_dbg
|
| 341 |
-
|
| 342 |
-
agent_dbg(
|
| 343 |
-
"H1-H3",
|
| 344 |
-
"core.py:search_book",
|
| 345 |
-
"pipeline book resolution",
|
| 346 |
-
{
|
| 347 |
-
"query": (query or "")[:160],
|
| 348 |
-
"selected_book_id": session_context.selected_book_id,
|
| 349 |
-
"target_book_id": target_book_id,
|
| 350 |
-
"search_book_id": search_book_id,
|
| 351 |
-
"book_reference": getattr(intent_result, "book_reference", None),
|
| 352 |
-
"book_confidence": getattr(intent_result, "book_confidence", None),
|
| 353 |
-
"cross_book_search": cross_book_search,
|
| 354 |
-
"history_len": len(session_context.history or []),
|
| 355 |
-
"active_titles": [(b.id[:8], (b.title or "")[:40]) for b in active_books[:8]],
|
| 356 |
-
},
|
| 357 |
-
)
|
| 358 |
-
# #endregion
|
| 359 |
-
|
| 360 |
# ── Objection Detection (persuasion modifier, never blocks) ──────────────
|
| 361 |
objection_type = detect_objection(query)
|
| 362 |
prior_objections = (
|
|
@@ -402,46 +370,24 @@ async def run_pipeline(
|
|
| 402 |
)
|
| 403 |
|
| 404 |
# ── Step 4: Query Rewriting ───────────────────────────────────────────────
|
| 405 |
-
_t_rewrite = time.monotonic()
|
| 406 |
query_variations = await rewrite_query(query, session_context.history)
|
| 407 |
|
| 408 |
# ── Step 5: Vector Retrieval ──────────────────────────────────────────────
|
| 409 |
-
_t_retrieve = time.monotonic()
|
| 410 |
raw_chunks = await retrieve_chunks(
|
| 411 |
queries=query_variations,
|
| 412 |
author_id=author.id,
|
| 413 |
book_id=search_book_id,
|
| 414 |
top_k=cfg.RAG_RETRIEVAL_TOP_K,
|
| 415 |
)
|
| 416 |
-
# #region agent log
|
| 417 |
-
from app.debug_agent_log import agent_dbg
|
| 418 |
-
|
| 419 |
-
agent_dbg(
|
| 420 |
-
"L4",
|
| 421 |
-
"core.py:retrieve_timing",
|
| 422 |
-
"rewrite+retrieve timing",
|
| 423 |
-
{
|
| 424 |
-
"rewrite_ms": int((_t_retrieve - _t_rewrite) * 1000),
|
| 425 |
-
"retrieve_ms": int((time.monotonic() - _t_retrieve) * 1000),
|
| 426 |
-
"search_book_id": search_book_id,
|
| 427 |
-
"chunk_count": len(raw_chunks or []),
|
| 428 |
-
"chunk_book_ids": list({c.book_id for c in (raw_chunks or [])})[:6],
|
| 429 |
-
"chunk_titles": list({(c.book_title or "")[:50] for c in (raw_chunks or [])})[:6],
|
| 430 |
-
"variations0": (query_variations[0][:120] if query_variations else None),
|
| 431 |
-
},
|
| 432 |
-
)
|
| 433 |
-
# #endregion
|
| 434 |
if not raw_chunks:
|
| 435 |
log.warning("No chunks retrieved")
|
| 436 |
return await no_context_response(query, author, active_books, session_context, db, start_ms)
|
| 437 |
|
| 438 |
# ── Step 6: Re-ranking ────────────────────────────────────────────────────
|
| 439 |
-
_t_rerank = time.monotonic()
|
| 440 |
top_chunks = await rerank_chunks(
|
| 441 |
query=query, chunks=raw_chunks,
|
| 442 |
top_n=cfg.RAG_RERANK_TOP_N, min_score=cfg.RAG_RERANK_MIN_SCORE,
|
| 443 |
)
|
| 444 |
-
_rerank_ms = int((time.monotonic() - _t_rerank) * 1000)
|
| 445 |
if not top_chunks:
|
| 446 |
top_chunks = raw_chunks[: cfg.RAG_RERANK_TOP_N]
|
| 447 |
if not top_chunks:
|
|
@@ -467,7 +413,6 @@ async def run_pipeline(
|
|
| 467 |
strategy = _upsell_engine.select_strategy(effective_int, upsell_ctx)
|
| 468 |
|
| 469 |
# ── Steps 8–10: LLM Generation + Faithfulness + Safety ───────────────────
|
| 470 |
-
_t_gen = time.monotonic()
|
| 471 |
price_facts = ""
|
| 472 |
if objection_type == "price" and search_book_id:
|
| 473 |
try:
|
|
@@ -498,19 +443,6 @@ async def run_pipeline(
|
|
| 498 |
price_facts=price_facts,
|
| 499 |
)
|
| 500 |
)
|
| 501 |
-
# #region agent log
|
| 502 |
-
agent_dbg(
|
| 503 |
-
"L1-L2",
|
| 504 |
-
"core.py:gen_timing",
|
| 505 |
-
"rerank+generate+faithfulness timing",
|
| 506 |
-
{
|
| 507 |
-
"rerank_ms": _rerank_ms,
|
| 508 |
-
"gen_faith_ms": int((time.monotonic() - _t_gen) * 1000),
|
| 509 |
-
"total_ms": int((time.monotonic() - start_ms) * 1000),
|
| 510 |
-
"faithfulness_score": faithfulness_score,
|
| 511 |
-
},
|
| 512 |
-
)
|
| 513 |
-
# #endregion
|
| 514 |
|
| 515 |
top_book_ids = list({c.book_id for c in top_chunks})
|
| 516 |
|
|
|
|
| 228 |
)
|
| 229 |
|
| 230 |
# ── Step 2: Intent Classification ─────────────────────────────────────────
|
|
|
|
| 231 |
intent_result = await classify_intent(query, session_context.history)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
log.debug("Intent classified", intent=intent_result.intent, source=intent_result.source)
|
| 233 |
|
| 234 |
if intent_result.intent == "jailbreak_attempt":
|
|
|
|
| 303 |
# ── Price intelligence short-circuit (after book resolve; skip RAG/LLM) ──
|
| 304 |
from app.services.price_catalog_service import PriceCatalogService, classify_price_query
|
| 305 |
|
| 306 |
+
price_q = classify_price_query(query)
|
| 307 |
+
price_kind = price_q.kind
|
| 308 |
use_price_catalog = search_book_id and (
|
| 309 |
intent_result.intent == "price_inquiry"
|
| 310 |
+
or (
|
| 311 |
+
intent_result.intent == "purchase_intent"
|
| 312 |
+
and price_kind == "how_much"
|
| 313 |
+
)
|
| 314 |
)
|
| 315 |
if use_price_catalog and price_kind != "soft_value":
|
| 316 |
book_title = selected_book_title(active_books, search_book_id) or "this book"
|
|
|
|
| 325 |
link_shown=bool(price_result.get("has_links") or price_result.get("platform_offers")),
|
| 326 |
)
|
| 327 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
# ── Objection Detection (persuasion modifier, never blocks) ──────────────
|
| 329 |
objection_type = detect_objection(query)
|
| 330 |
prior_objections = (
|
|
|
|
| 370 |
)
|
| 371 |
|
| 372 |
# ── Step 4: Query Rewriting ───────────────────────────────────────────────
|
|
|
|
| 373 |
query_variations = await rewrite_query(query, session_context.history)
|
| 374 |
|
| 375 |
# ── Step 5: Vector Retrieval ──────────────────────────────────────────────
|
|
|
|
| 376 |
raw_chunks = await retrieve_chunks(
|
| 377 |
queries=query_variations,
|
| 378 |
author_id=author.id,
|
| 379 |
book_id=search_book_id,
|
| 380 |
top_k=cfg.RAG_RETRIEVAL_TOP_K,
|
| 381 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
if not raw_chunks:
|
| 383 |
log.warning("No chunks retrieved")
|
| 384 |
return await no_context_response(query, author, active_books, session_context, db, start_ms)
|
| 385 |
|
| 386 |
# ── Step 6: Re-ranking ────────────────────────────────────────────────────
|
|
|
|
| 387 |
top_chunks = await rerank_chunks(
|
| 388 |
query=query, chunks=raw_chunks,
|
| 389 |
top_n=cfg.RAG_RERANK_TOP_N, min_score=cfg.RAG_RERANK_MIN_SCORE,
|
| 390 |
)
|
|
|
|
| 391 |
if not top_chunks:
|
| 392 |
top_chunks = raw_chunks[: cfg.RAG_RERANK_TOP_N]
|
| 393 |
if not top_chunks:
|
|
|
|
| 413 |
strategy = _upsell_engine.select_strategy(effective_int, upsell_ctx)
|
| 414 |
|
| 415 |
# ── Steps 8–10: LLM Generation + Faithfulness + Safety ───────────────────
|
|
|
|
| 416 |
price_facts = ""
|
| 417 |
if objection_type == "price" and search_book_id:
|
| 418 |
try:
|
|
|
|
| 443 |
price_facts=price_facts,
|
| 444 |
)
|
| 445 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
|
| 447 |
top_book_ids = list({c.book_id for c in top_chunks})
|
| 448 |
|
app/services/price_catalog_service.py
CHANGED
|
@@ -14,7 +14,7 @@ from typing import Any
|
|
| 14 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 15 |
|
| 16 |
from app.repositories.platform_listing_repo import PlatformListingRepository
|
| 17 |
-
from app.services.book_url_scraper import PLATFORM_DISPLAY
|
| 18 |
from app.services.platform_catalog import (
|
| 19 |
BOT_CHAT_EXCLUDED_PLATFORMS,
|
| 20 |
canonical_format_prices,
|
|
@@ -54,6 +54,9 @@ _FORMAT_HINTS: tuple[tuple[str, str], ...] = (
|
|
| 54 |
("e-book", "eBook"),
|
| 55 |
("kindle", "eBook"),
|
| 56 |
("audiobook", "Audiobook"),
|
|
|
|
|
|
|
|
|
|
| 57 |
("audible", "Audiobook"),
|
| 58 |
)
|
| 59 |
|
|
@@ -171,6 +174,12 @@ def classify_price_query(message: str) -> PriceQuery:
|
|
| 171 |
)):
|
| 172 |
return PriceQuery(kind="how_much", platform_ids=platforms, format_hint=fmt)
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
return PriceQuery(kind="none", platform_ids=platforms, format_hint=fmt)
|
| 175 |
|
| 176 |
|
|
@@ -261,8 +270,23 @@ class PriceCatalogService:
|
|
| 261 |
continue
|
| 262 |
|
| 263 |
list_price = (getattr(row, "list_price", None) or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
if format_hint:
|
| 265 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
continue
|
| 267 |
offers.append(Offer(
|
| 268 |
platform_id=pid,
|
|
@@ -271,7 +295,7 @@ class PriceCatalogService:
|
|
| 271 |
price_cents=parse_price_cents(list_price),
|
| 272 |
url=url,
|
| 273 |
is_source=is_source,
|
| 274 |
-
format=
|
| 275 |
))
|
| 276 |
return offers
|
| 277 |
|
|
@@ -290,6 +314,10 @@ class PriceCatalogService:
|
|
| 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
|
|
@@ -309,6 +337,36 @@ class PriceCatalogService:
|
|
| 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(
|
| 313 |
self, author_id: str, book_id: str, book_title: str,
|
| 314 |
) -> dict[str, Any]:
|
|
@@ -331,10 +389,16 @@ class PriceCatalogService:
|
|
| 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"{
|
| 337 |
-
"Pick a Buy button for the store
|
|
|
|
| 338 |
)
|
| 339 |
return {
|
| 340 |
"text": text,
|
|
|
|
| 14 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 15 |
|
| 16 |
from app.repositories.platform_listing_repo import PlatformListingRepository
|
| 17 |
+
from app.services.book_url_scraper import PLATFORM_DISPLAY, _normalize_retail_format_label
|
| 18 |
from app.services.platform_catalog import (
|
| 19 |
BOT_CHAT_EXCLUDED_PLATFORMS,
|
| 20 |
canonical_format_prices,
|
|
|
|
| 54 |
("e-book", "eBook"),
|
| 55 |
("kindle", "eBook"),
|
| 56 |
("audiobook", "Audiobook"),
|
| 57 |
+
("audio book", "Audiobook"),
|
| 58 |
+
("audio-book", "Audiobook"),
|
| 59 |
+
("audio prices", "Audiobook"),
|
| 60 |
("audible", "Audiobook"),
|
| 61 |
)
|
| 62 |
|
|
|
|
| 174 |
)):
|
| 175 |
return PriceQuery(kind="how_much", platform_ids=platforms, format_hint=fmt)
|
| 176 |
|
| 177 |
+
# Format-named price questions: "what about audio book prices", "ebook price"
|
| 178 |
+
if fmt and any(s in q for s in (
|
| 179 |
+
"price", "prices", "cost", "costs", "how much", "what about",
|
| 180 |
+
)):
|
| 181 |
+
return PriceQuery(kind="how_much", platform_ids=platforms, format_hint=fmt)
|
| 182 |
+
|
| 183 |
return PriceQuery(kind="none", platform_ids=platforms, format_hint=fmt)
|
| 184 |
|
| 185 |
|
|
|
|
| 270 |
continue
|
| 271 |
|
| 272 |
list_price = (getattr(row, "list_price", None) or "").strip()
|
| 273 |
+
raw_bf = (getattr(row, "book_format", None) or "").strip()
|
| 274 |
+
labeled = _normalize_retail_format_label(raw_bf) if raw_bf else ""
|
| 275 |
+
if labeled and labeled not in _FORMAT_ORDER:
|
| 276 |
+
labeled = ""
|
| 277 |
if format_hint:
|
| 278 |
+
# Only claim this format when book_format matches the hint.
|
| 279 |
+
if labeled != format_hint or not list_price:
|
| 280 |
+
continue
|
| 281 |
+
offers.append(Offer(
|
| 282 |
+
platform_id=pid,
|
| 283 |
+
platform=display_name,
|
| 284 |
+
price_display=list_price,
|
| 285 |
+
price_cents=parse_price_cents(list_price),
|
| 286 |
+
url=url,
|
| 287 |
+
is_source=is_source,
|
| 288 |
+
format=labeled,
|
| 289 |
+
))
|
| 290 |
continue
|
| 291 |
offers.append(Offer(
|
| 292 |
platform_id=pid,
|
|
|
|
| 295 |
price_cents=parse_price_cents(list_price),
|
| 296 |
url=url,
|
| 297 |
is_source=is_source,
|
| 298 |
+
format=labeled,
|
| 299 |
))
|
| 300 |
return offers
|
| 301 |
|
|
|
|
| 314 |
author_id, book_id, format_hint=hint,
|
| 315 |
)
|
| 316 |
if not offers:
|
| 317 |
+
if hint:
|
| 318 |
+
return await self._build_missing_format_answer(
|
| 319 |
+
author_id, book_id, book_title, hint,
|
| 320 |
+
)
|
| 321 |
return await self._build_empty_fallback(author_id, book_id, book_title)
|
| 322 |
|
| 323 |
kind = pq.kind
|
|
|
|
| 337 |
return self._build_how_much_answer(offers, book_title, pq.platform_ids)
|
| 338 |
return self._build_list_answer(offers, book_title)
|
| 339 |
|
| 340 |
+
async def _build_missing_format_answer(
|
| 341 |
+
self,
|
| 342 |
+
author_id: str,
|
| 343 |
+
book_id: str,
|
| 344 |
+
book_title: str,
|
| 345 |
+
format_hint: str,
|
| 346 |
+
) -> dict[str, Any]:
|
| 347 |
+
"""Honest reply when a named format has no stored prices; show what is stored."""
|
| 348 |
+
available = await self.catalog_for_book(author_id, book_id, format_hint="")
|
| 349 |
+
formats = sorted({o.format for o in available if o.format})
|
| 350 |
+
if formats:
|
| 351 |
+
named = ", ".join(formats)
|
| 352 |
+
text = (
|
| 353 |
+
f"I don't have {format_hint} prices stored for {book_title} yet. "
|
| 354 |
+
f"On file I have: {named}. Pick a Buy button for those."
|
| 355 |
+
)
|
| 356 |
+
else:
|
| 357 |
+
text = (
|
| 358 |
+
f"I don't have {format_hint} prices stored for {book_title} yet. "
|
| 359 |
+
"I only list formats I have on file — nothing invented."
|
| 360 |
+
)
|
| 361 |
+
return {
|
| 362 |
+
"text": text[:380],
|
| 363 |
+
"links": [],
|
| 364 |
+
"has_links": False,
|
| 365 |
+
"platform_offers": [
|
| 366 |
+
offer_to_platform_item(o) for o in available
|
| 367 |
+
] if available else None,
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
async def _build_empty_fallback(
|
| 371 |
self, author_id: str, book_id: str, book_title: str,
|
| 372 |
) -> dict[str, Any]:
|
|
|
|
| 389 |
def _build_list_answer(self, offers: list[Offer], book_title: str) -> dict[str, Any]:
|
| 390 |
"""All verified platform+format rows as platform_offers grid."""
|
| 391 |
priced = sum(1 for o in offers if o.price_cents is not None)
|
| 392 |
+
has_formats = any(o.format for o in offers)
|
| 393 |
+
if has_formats:
|
| 394 |
+
detail = f"{priced} with a listed price and format"
|
| 395 |
+
else:
|
| 396 |
+
detail = f"{priced} with a listed price"
|
| 397 |
text = (
|
| 398 |
f"Here are the places I know for {book_title} — "
|
| 399 |
+
f"{detail}. "
|
| 400 |
+
"Pick a Buy button for the store"
|
| 401 |
+
+ (" and format you prefer." if has_formats else " you prefer.")
|
| 402 |
)
|
| 403 |
return {
|
| 404 |
"text": text,
|
tests/unit/test_price_catalog_service.py
CHANGED
|
@@ -36,6 +36,10 @@ def test_classify_price_query_kinds():
|
|
| 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():
|
|
@@ -44,6 +48,7 @@ def test_price_inquiry_intent_before_purchase():
|
|
| 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 |
|
|
@@ -280,3 +285,59 @@ async def test_handle_format_clarify():
|
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
audio = classify_price_query("what about audio book prices")
|
| 40 |
+
assert audio.kind == "how_much"
|
| 41 |
+
assert audio.format_hint == "Audiobook"
|
| 42 |
+
assert classify_price_query("audio book").format_hint == "Audiobook"
|
| 43 |
|
| 44 |
|
| 45 |
def test_price_inquiry_intent_before_purchase():
|
|
|
|
| 48 |
assert _classify_by_rules("where is it cheapest") == "price_inquiry"
|
| 49 |
assert _classify_by_rules("list all platforms") == "price_inquiry"
|
| 50 |
assert _classify_by_rules("are those hardcover or audiobook") == "price_inquiry"
|
| 51 |
+
assert _classify_by_rules("what about audio book prices") == "price_inquiry"
|
| 52 |
assert _classify_by_rules("how much does it cost") == "purchase_intent"
|
| 53 |
|
| 54 |
|
|
|
|
| 285 |
assert result["platform_offers"] is not None
|
| 286 |
assert any(o["format"] == "Hardcover" for o in result["platform_offers"])
|
| 287 |
assert "invent" in result["text"].lower() or "format" in result["text"].lower()
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
@pytest.mark.asyncio
|
| 291 |
+
async def test_list_answer_omits_and_format_when_empty():
|
| 292 |
+
svc = PriceCatalogService.__new__(PriceCatalogService)
|
| 293 |
+
offers = [
|
| 294 |
+
Offer("amazon", "Amazon", "$35.00", 3500, "https://a.com", True, ""),
|
| 295 |
+
Offer("apple_books", "Apple Books", "$14.99", 1499, "https://apple.com", False, ""),
|
| 296 |
+
]
|
| 297 |
+
result = svc._build_list_answer(offers, "All We Say")
|
| 298 |
+
assert "and format" not in result["text"]
|
| 299 |
+
assert "listed price" in result["text"]
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
@pytest.mark.asyncio
|
| 303 |
+
async def test_missing_audiobook_is_honest_and_shows_available():
|
| 304 |
+
svc = PriceCatalogService(MagicMock())
|
| 305 |
+
|
| 306 |
+
async def _catalog(_a, _b, *, format_hint=""):
|
| 307 |
+
if format_hint == "Audiobook":
|
| 308 |
+
return []
|
| 309 |
+
return [
|
| 310 |
+
Offer("amazon", "Amazon", "$35.00", 3500, "https://a.com", True, "Hardcover"),
|
| 311 |
+
Offer("apple_books", "Apple Books", "$14.99", 1499, "https://apple.com", False, "eBook"),
|
| 312 |
+
]
|
| 313 |
+
|
| 314 |
+
svc.catalog_for_book = _catalog
|
| 315 |
+
result = await svc.handle(
|
| 316 |
+
"what about audio book prices", "a1", "b1", "All We Say",
|
| 317 |
+
)
|
| 318 |
+
assert "Audiobook" in result["text"]
|
| 319 |
+
assert "don't have" in result["text"].lower() or "do not have" in result["text"].lower()
|
| 320 |
+
assert result["platform_offers"] is not None
|
| 321 |
+
assert any(o["format"] == "eBook" for o in result["platform_offers"])
|
| 322 |
+
assert "Buy Book" not in (result.get("links") or [])
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
@pytest.mark.asyncio
|
| 326 |
+
async def test_catalog_uses_book_format_when_no_format_prices():
|
| 327 |
+
svc = PriceCatalogService(MagicMock())
|
| 328 |
+
svc._listings = MagicMock()
|
| 329 |
+
svc._listings.list_for_book = AsyncMock(return_value=[
|
| 330 |
+
SimpleNamespace(
|
| 331 |
+
status="verified",
|
| 332 |
+
listing_url="https://amazon.com/dp/B0X",
|
| 333 |
+
platform_id="amazon",
|
| 334 |
+
format_prices={},
|
| 335 |
+
list_price="$35.00",
|
| 336 |
+
book_format="Hardcover",
|
| 337 |
+
is_source=True,
|
| 338 |
+
),
|
| 339 |
+
])
|
| 340 |
+
offers = await svc.catalog_for_book("a1", "b1")
|
| 341 |
+
assert len(offers) == 1
|
| 342 |
+
assert offers[0].format == "Hardcover"
|
| 343 |
+
assert offers[0].price_display == "$35.00"
|