Spaces:
Running
Running
AuthorBot Cursor commited on
Commit ·
5543326
1
Parent(s): 9109863
feat: let visitors re-list books mid-chat and switch selection
Browse filesExpand 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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")
|