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
- price_kind = classify_price_query(query).kind
 
322
  use_price_catalog = search_book_id and (
323
  intent_result.intent == "price_inquiry"
324
- or (intent_result.intent == "purchase_intent" and price_kind == "how_much")
 
 
 
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
- # No format map cannot claim the hint; skip this row.
 
 
 
 
 
 
 
 
 
 
 
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"{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,
 
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"