AuthorBot Cursor commited on
Commit
5543326
·
1 Parent(s): 9109863

feat: let visitors re-list books mid-chat and switch selection

Browse files

Expand catalog list phrases, clear session pin on catalog_response, always render book_selector in the widget, and clear Telegram durable pin when the picker is returned.

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

.agent/BDD_SPECS.md CHANGED
@@ -197,6 +197,14 @@ Feature: Book Selector Intelligence
197
  Then the next visitor message should receive a graceful redirect
198
  And the redirect should offer the remaining active books
199
 
 
 
 
 
 
 
 
 
200
 
201
  Feature: Hallucination Guardrail
202
 
 
197
  Then the next visitor message should receive a graceful redirect
198
  And the redirect should offer the remaining active books
199
 
200
+ Scenario: Pinned visitor asks for books again
201
+ Given a visitor has selected book "Mindset Mastery" and is mid-conversation
202
+ When the visitor asks "what books do you have" or "show books" or "change book"
203
+ Then the book selector popup should appear with all active books
204
+ And the previous book pin should be cleared
205
+ When the visitor selects a different book
206
+ Then subsequent answers should use that newly selected book only
207
+
208
 
209
  Feature: Hallucination Guardrail
210
 
app/channels/services/bot_bridge.py CHANGED
@@ -97,6 +97,7 @@ async def generate_channel_reply(
97
  import time
98
 
99
  from app.channels.services.telegram_book_select import (
 
100
  expand_this_book_query,
101
  load_selected_book_from_conversation,
102
  pin_selected_book_on_conversation,
@@ -324,7 +325,14 @@ async def generate_channel_reply(
324
  user_message=text,
325
  assistant_message=reply,
326
  )
327
- if session_ctx.selected_book_id:
 
 
 
 
 
 
 
328
  await session_mgr.set_selected_book(
329
  session_id, author_id, session_ctx.selected_book_id, is_cross=False
330
  )
 
97
  import time
98
 
99
  from app.channels.services.telegram_book_select import (
100
+ clear_selected_book_on_conversation,
101
  expand_this_book_query,
102
  load_selected_book_from_conversation,
103
  pin_selected_book_on_conversation,
 
325
  user_message=text,
326
  assistant_message=reply,
327
  )
328
+ if selector and not session_ctx.selected_book_id:
329
+ # Catalog re-list cleared the session pin — clear durable too.
330
+ await session_mgr.set_selected_book(
331
+ session_id, author_id, None, is_cross=False
332
+ )
333
+ await clear_selected_book_on_conversation(db, conversation_id)
334
+ await db.commit()
335
+ elif session_ctx.selected_book_id:
336
  await session_mgr.set_selected_book(
337
  session_id, author_id, session_ctx.selected_book_id, is_cross=False
338
  )
app/channels/services/telegram_book_select.py CHANGED
@@ -149,6 +149,24 @@ async def pin_selected_book_on_conversation(
149
  await db.flush()
150
 
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  async def load_selected_book_from_conversation(
153
  db: AsyncSession,
154
  conversation_id: str,
 
149
  await db.flush()
150
 
151
 
152
+ async def clear_selected_book_on_conversation(
153
+ db: AsyncSession,
154
+ conversation_id: str,
155
+ ) -> None:
156
+ """Remove durable selected-book pin so the visitor can re-pick from the list."""
157
+ from app.channels.models import ChannelConversation
158
+
159
+ conv = await db.get(ChannelConversation, conversation_id)
160
+ if not conv:
161
+ return
162
+ meta = _parse_conv_meta(conv.channel_metadata)
163
+ if _META_SELECTED not in meta:
164
+ return
165
+ meta.pop(_META_SELECTED, None)
166
+ conv.channel_metadata = json.dumps(meta)
167
+ await db.flush()
168
+
169
+
170
  async def load_selected_book_from_conversation(
171
  db: AsyncSession,
172
  conversation_id: str,
app/services/pipeline/guards.py CHANGED
@@ -72,6 +72,10 @@ def should_short_circuit_to_catalog(
72
  "about the books", "tell me about your book", "tell me about the books",
73
  "in the catalog", "in your catalog", "show me your book",
74
  "uploaded book",
 
 
 
 
75
  )
76
  if any(p in q for p in list_phrases) or q in _CATALOG_EXACT:
77
  return True
 
72
  "about the books", "tell me about your book", "tell me about the books",
73
  "in the catalog", "in your catalog", "show me your book",
74
  "uploaded book",
75
+ "show books", "show me books", "show the books",
76
+ "change book", "switch book", "another book", "other books",
77
+ "different book", "pick another", "choose another", "select another",
78
+ "book list", "list of books",
79
  )
80
  if any(p in q for p in list_phrases) or q in _CATALOG_EXACT:
81
  return True
app/services/pipeline/handlers.py CHANGED
@@ -197,7 +197,9 @@ async def catalog_response(
197
  db: AsyncSession,
198
  start_ms: float,
199
  ) -> PipelineResult:
200
- """Show the book catalog picker."""
 
 
201
  return books_list_response(CATALOG_RESPONSE, books, start_ms, intent="meta")
202
 
203
 
 
197
  db: AsyncSession,
198
  start_ms: float,
199
  ) -> PipelineResult:
200
+ """Show the book catalog picker and clear any prior book pin."""
201
+ session_context.selected_book_id = None
202
+ session_context.is_cross_book = False
203
  return books_list_response(CATALOG_RESPONSE, books, start_ms, intent="meta")
204
 
205
 
static/widget.js CHANGED
@@ -740,7 +740,10 @@
740
  }, { 'X-Subscription-Token': CONFIG.token });
741
 
742
  typingEl.remove();
743
- const bookSel = selectedBookId ? null : mapBooks(res.book_selector);
 
 
 
744
  addBotMessage(res.text, res.links || [], bookSel, res.follow_ups || [], res.platform_offers || null);
745
  if (res.links && res.links.length) {
746
  res.links.forEach(l => {
 
740
  }, { 'X-Subscription-Token': CONFIG.token });
741
 
742
  typingEl.remove();
743
+ if (res.book_selector && res.book_selector.length) {
744
+ selectedBookId = null;
745
+ }
746
+ const bookSel = mapBooks(res.book_selector);
747
  addBotMessage(res.text, res.links || [], bookSel, res.follow_ups || [], res.platform_offers || null);
748
  if (res.links && res.links.length) {
749
  res.links.forEach(l => {
tests/unit/test_catalog_routing.py CHANGED
@@ -29,6 +29,21 @@ def test_catalog_question_still_short_circuits_with_book_selected():
29
  ) is True
30
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def test_about_this_book_with_selection_reaches_rag():
33
  """Pinned book: 'tell me about the book' must not bounce to the picker."""
34
  assert should_short_circuit_to_catalog(
@@ -74,3 +89,39 @@ async def test_critique_debate_classified_as_question():
74
  )
75
  result = await classify_intent(query, history=[])
76
  assert result.intent == "question"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  ) is True
30
 
31
 
32
+ def test_relisht_phrases_short_circuit_with_book_selected():
33
+ """Pinned visitor asking to see / change books must reopen the picker."""
34
+ for query in (
35
+ "show books",
36
+ "show me books",
37
+ "change book",
38
+ "switch book",
39
+ "other books",
40
+ "pick another",
41
+ "list of books",
42
+ "book list",
43
+ ):
44
+ assert should_short_circuit_to_catalog(query, "question", "book-123") is True, query
45
+
46
+
47
  def test_about_this_book_with_selection_reaches_rag():
48
  """Pinned book: 'tell me about the book' must not bounce to the picker."""
49
  assert should_short_circuit_to_catalog(
 
89
  )
90
  result = await classify_intent(query, history=[])
91
  assert result.intent == "question"
92
+
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_catalog_response_clears_selected_book_pin():
96
+ """Re-listing books must unpin so the next pick becomes the active book."""
97
+ from types import SimpleNamespace
98
+ from unittest.mock import MagicMock
99
+
100
+ from app.services.pipeline.handlers import catalog_response
101
+ from app.services.session_core.manager import SessionContext
102
+
103
+ ctx = SessionContext(
104
+ session_id="s1",
105
+ author_id="a1",
106
+ selected_book_id="book-old",
107
+ is_cross_book=True,
108
+ )
109
+ books = [
110
+ SimpleNamespace(
111
+ id="b1",
112
+ title="One",
113
+ tagline="",
114
+ cover_path=None,
115
+ status="ready",
116
+ ),
117
+ ]
118
+ result = await catalog_response(
119
+ author=MagicMock(),
120
+ books=books,
121
+ session_context=ctx,
122
+ db=MagicMock(),
123
+ start_ms=0.0,
124
+ )
125
+ assert ctx.selected_book_id is None
126
+ assert ctx.is_cross_book is False
127
+ assert result.response.get("book_selector")