Spaces:
Running
Running
AuthorBot Cursor commited on
Commit ·
0daa940
1
Parent(s): 2bb94f4
Restructure chat flow: hello shows clickable book list, then engaging Q&A.
Browse files- app/api/chat.py +1 -1
- app/models/user.py +1 -1
- app/services/formatter.py +10 -5
- app/services/prompter.py +12 -10
- app/services/rag_pipeline.py +159 -81
- static/widget.js +71 -29
app/api/chat.py
CHANGED
|
@@ -91,7 +91,7 @@ async def init_session(
|
|
| 91 |
}
|
| 92 |
for b in active_books
|
| 93 |
],
|
| 94 |
-
show_book_selector=len(active_books) >
|
| 95 |
)
|
| 96 |
except HTTPException:
|
| 97 |
raise
|
|
|
|
| 91 |
}
|
| 92 |
for b in active_books
|
| 93 |
],
|
| 94 |
+
show_book_selector=len(active_books) > 0,
|
| 95 |
)
|
| 96 |
except HTTPException:
|
| 97 |
raise
|
app/models/user.py
CHANGED
|
@@ -39,7 +39,7 @@ class User(Base, TimestampMixin):
|
|
| 39 |
bot_avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
| 40 |
welcome_message: Mapped[str] = mapped_column(
|
| 41 |
String(500),
|
| 42 |
-
default="
|
| 43 |
nullable=False,
|
| 44 |
)
|
| 45 |
fallback_message: Mapped[str] = mapped_column(
|
|
|
|
| 39 |
bot_avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
| 40 |
welcome_message: Mapped[str] = mapped_column(
|
| 41 |
String(500),
|
| 42 |
+
default="Hello! I'm here to help you explore the books. Pick one below to get started.",
|
| 43 |
nullable=False,
|
| 44 |
)
|
| 45 |
fallback_message: Mapped[str] = mapped_column(
|
app/services/formatter.py
CHANGED
|
@@ -99,25 +99,30 @@ class ResponseFormatter:
|
|
| 99 |
|
| 100 |
return text
|
| 101 |
|
| 102 |
-
def format_book_selector(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
"""Format a book selector prompt response.
|
| 104 |
|
| 105 |
-
Shown when
|
| 106 |
|
| 107 |
Args:
|
| 108 |
books: List of dicts with 'id', 'title', 'tagline', 'cover_path'.
|
|
|
|
| 109 |
|
| 110 |
Returns:
|
| 111 |
Dict with 'text' and 'book_selector' list.
|
| 112 |
"""
|
| 113 |
return {
|
| 114 |
-
"text":
|
| 115 |
"book_selector": [
|
| 116 |
{
|
| 117 |
"id": book["id"],
|
| 118 |
"title": book["title"],
|
| 119 |
-
"tagline": book.get("tagline"
|
| 120 |
-
"cover_url": book.get("cover_path"
|
| 121 |
}
|
| 122 |
for book in books
|
| 123 |
],
|
|
|
|
| 99 |
|
| 100 |
return text
|
| 101 |
|
| 102 |
+
def format_book_selector(
|
| 103 |
+
self,
|
| 104 |
+
books: list[dict],
|
| 105 |
+
intro: str = "Choose a book to get started:",
|
| 106 |
+
) -> dict:
|
| 107 |
"""Format a book selector prompt response.
|
| 108 |
|
| 109 |
+
Shown when the bot needs the user to pick a book before Q&A.
|
| 110 |
|
| 111 |
Args:
|
| 112 |
books: List of dicts with 'id', 'title', 'tagline', 'cover_path'.
|
| 113 |
+
intro: Short message shown above the clickable book list.
|
| 114 |
|
| 115 |
Returns:
|
| 116 |
Dict with 'text' and 'book_selector' list.
|
| 117 |
"""
|
| 118 |
return {
|
| 119 |
+
"text": intro,
|
| 120 |
"book_selector": [
|
| 121 |
{
|
| 122 |
"id": book["id"],
|
| 123 |
"title": book["title"],
|
| 124 |
+
"tagline": book.get("tagline") or "",
|
| 125 |
+
"cover_url": book.get("cover_path") or "",
|
| 126 |
}
|
| 127 |
for book in books
|
| 128 |
],
|
app/services/prompter.py
CHANGED
|
@@ -151,24 +151,26 @@ COMPETITOR_RESPONSE = """I'm specifically focused on {author_name}'s work, \
|
|
| 151 |
so I can't speak to other authors. But I'd love to show you what makes \
|
| 152 |
{author_name}'s approach different — what are you hoping a book will help you with?"""
|
| 153 |
|
| 154 |
-
NO_CONTEXT_RESPONSE = """
|
| 155 |
-
|
| 156 |
|
| 157 |
-
HALLUCINATION_FALLBACK_RESPONSE = """I
|
| 158 |
-
|
| 159 |
and I'll answer from what's actually in the text."""
|
| 160 |
|
| 161 |
-
GREETING_RESPONSE = """
|
| 162 |
|
| 163 |
-
|
| 164 |
|
| 165 |
-
|
| 166 |
|
| 167 |
-
|
| 168 |
|
| 169 |
-
{
|
| 170 |
|
| 171 |
-
|
|
|
|
|
|
|
| 172 |
|
| 173 |
TOKEN_EXHAUSTED_RESPONSE = "I'm taking a short break to recharge! Check back soon."
|
| 174 |
|
|
|
|
| 151 |
so I can't speak to other authors. But I'd love to show you what makes \
|
| 152 |
{author_name}'s approach different — what are you hoping a book will help you with?"""
|
| 153 |
|
| 154 |
+
NO_CONTEXT_RESPONSE = """I don't have that exact detail handy, but here's what I do know about {book_title}. \
|
| 155 |
+
What part interests you most — the story, the themes, or who it's perfect for?"""
|
| 156 |
|
| 157 |
+
HALLUCINATION_FALLBACK_RESPONSE = """I'd rather stay accurate than guess. \
|
| 158 |
+
Ask me something specific about {book_title} — a character, a theme, or what the book helps with — \
|
| 159 |
and I'll answer from what's actually in the text."""
|
| 160 |
|
| 161 |
+
GREETING_RESPONSE = """Hello! I'm {bot_name}, {author_name}'s book advisor.
|
| 162 |
|
| 163 |
+
Pick a book below and I'll help you explore it."""
|
| 164 |
|
| 165 |
+
CATALOG_RESPONSE = """Here are the books I can walk you through.
|
| 166 |
|
| 167 |
+
Tap one to get started."""
|
| 168 |
|
| 169 |
+
BOOK_SELECTED_RESPONSE = """Great choice — {book_title}.
|
| 170 |
|
| 171 |
+
{hook}
|
| 172 |
+
|
| 173 |
+
What would you like to know? Ask me anything about the story, characters, or themes."""
|
| 174 |
|
| 175 |
TOKEN_EXHAUSTED_RESPONSE = "I'm taking a short break to recharge! Check back soon."
|
| 176 |
|
app/services/rag_pipeline.py
CHANGED
|
@@ -41,7 +41,7 @@ from app.services.prompter import (
|
|
| 41 |
MASTER_SYSTEM_PROMPT,
|
| 42 |
JAILBREAK_RESPONSE, OFF_TOPIC_RESPONSE,
|
| 43 |
NO_CONTEXT_RESPONSE, HALLUCINATION_FALLBACK_RESPONSE,
|
| 44 |
-
GREETING_RESPONSE, CATALOG_RESPONSE,
|
| 45 |
)
|
| 46 |
from app.services.reranker import rerank_chunks
|
| 47 |
from app.services.vector_store import retrieve_chunks
|
|
@@ -179,12 +179,27 @@ async def run_pipeline(
|
|
| 179 |
if not active_books:
|
| 180 |
return _no_books_response(start_ms)
|
| 181 |
|
| 182 |
-
# Short-circuit: greetings and catalog questions
|
| 183 |
if intent_result.intent == "greeting" or _is_greeting(query):
|
| 184 |
-
return _greeting_response(author, active_books, start_ms)
|
| 185 |
|
| 186 |
if intent_result.intent in ("meta", "comparison") or _is_catalog_question(query):
|
| 187 |
-
return _catalog_response(author, active_books, start_ms)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
# Resolve which book to search
|
| 190 |
target_book_id = await _resolve_book(
|
|
@@ -216,7 +231,7 @@ async def run_pipeline(
|
|
| 216 |
|
| 217 |
if not raw_chunks:
|
| 218 |
log.warning("No chunks retrieved")
|
| 219 |
-
return _no_context_response(query, author, active_books, start_ms)
|
| 220 |
|
| 221 |
# ── Step 6: Re-ranking ────────────────────────────────────────────────────
|
| 222 |
top_chunks = await rerank_chunks(
|
|
@@ -231,7 +246,7 @@ async def run_pipeline(
|
|
| 231 |
top_chunks = raw_chunks[: cfg.RAG_RERANK_TOP_N]
|
| 232 |
|
| 233 |
if not top_chunks:
|
| 234 |
-
return _no_context_response(query, author, active_books, start_ms)
|
| 235 |
|
| 236 |
# ── Step 7: Context Assembly ───────────────────────────────────────────────
|
| 237 |
context_str, context_tokens = build_context(top_chunks)
|
|
@@ -275,9 +290,8 @@ async def run_pipeline(
|
|
| 275 |
|
| 276 |
is_faithful2, faithfulness_score = await check_faithfulness(raw_response, top_chunks)
|
| 277 |
if not is_faithful2:
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
)
|
| 281 |
|
| 282 |
# ── Step 10: Scope Check ──────────────────────────────────────────────────
|
| 283 |
if not is_response_in_scope(raw_response):
|
|
@@ -439,12 +453,90 @@ def _boundary_response(text: str, start_ms: float, violation_type: str) -> Pipel
|
|
| 439 |
|
| 440 |
|
| 441 |
_CATALOG_PHRASES = (
|
| 442 |
-
"uploaded book", "your
|
| 443 |
-
"books do you", "books you have", "books available", "about the
|
| 444 |
-
"tell me about", "
|
| 445 |
-
"in the catalog", "in your catalog",
|
|
|
|
| 446 |
)
|
| 447 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
_GREETINGS = frozenset({
|
| 449 |
"hi", "hello", "hey", "hiya", "howdy", "yo", "sup",
|
| 450 |
"good morning", "good afternoon", "good evening", "good day",
|
|
@@ -458,84 +550,74 @@ def _is_greeting(query: str) -> bool:
|
|
| 458 |
|
| 459 |
def _is_catalog_question(query: str) -> bool:
|
| 460 |
q = query.lower()
|
| 461 |
-
|
|
|
|
|
|
|
| 462 |
|
| 463 |
|
| 464 |
-
def
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
return "\n".join(lines)
|
| 480 |
|
|
|
|
|
|
|
| 481 |
|
| 482 |
-
def _book_hint(books: list) -> str:
|
| 483 |
-
if not books:
|
| 484 |
-
return ""
|
| 485 |
-
if len(books) == 1:
|
| 486 |
-
return f"I do know **{books[0].title}** well. "
|
| 487 |
-
return f"I have {len(books)} books I can walk you through. "
|
| 488 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
if len(books) == 1:
|
| 493 |
-
book_teaser = f"I can tell you everything about **{books[0].title}** — themes, characters, who it's for, all of it."
|
| 494 |
-
else:
|
| 495 |
-
titles = ", ".join(b.title for b in books[:3])
|
| 496 |
-
extra = f" and {len(books) - 3} more" if len(books) > 3 else ""
|
| 497 |
-
book_teaser = f"I know {len(books)} books inside out — including {titles}{extra}."
|
| 498 |
-
text = GREETING_RESPONSE.format(
|
| 499 |
-
bot_name=author.bot_name,
|
| 500 |
-
author_name=author.full_name or "the author",
|
| 501 |
-
book_teaser=book_teaser,
|
| 502 |
-
)
|
| 503 |
-
return PipelineResult(
|
| 504 |
-
response={"text": text, "links": [], "has_links": False},
|
| 505 |
-
intent="greeting",
|
| 506 |
-
response_ms=elapsed_ms,
|
| 507 |
-
)
|
| 508 |
|
| 509 |
|
| 510 |
-
def
|
| 511 |
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 512 |
-
text =
|
|
|
|
|
|
|
|
|
|
| 513 |
return PipelineResult(
|
| 514 |
response={"text": text, "links": [], "has_links": False},
|
| 515 |
-
intent="
|
| 516 |
response_ms=elapsed_ms,
|
| 517 |
)
|
| 518 |
|
| 519 |
|
| 520 |
-
def _no_context_response(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
|
|
|
| 522 |
|
| 523 |
-
# Prefer book metadata when vector search came up empty
|
| 524 |
if len(books) == 1:
|
| 525 |
book = books[0]
|
| 526 |
-
if book.ai_summary and _is_catalog_question(query):
|
| 527 |
-
text = (
|
| 528 |
-
f"**{book.title}** — here's the gist:\n\n{book.ai_summary.strip()}\n\n"
|
| 529 |
-
"Want to go deeper on characters, themes, or who it's best for?"
|
| 530 |
-
)
|
| 531 |
-
return PipelineResult(
|
| 532 |
-
response={"text": text, "links": [], "has_links": False},
|
| 533 |
-
intent="question",
|
| 534 |
-
response_ms=elapsed_ms,
|
| 535 |
-
)
|
| 536 |
if book.status != "ready":
|
| 537 |
text = (
|
| 538 |
-
f"
|
| 539 |
"I'll be able to answer detailed questions once processing finishes."
|
| 540 |
)
|
| 541 |
return PipelineResult(
|
|
@@ -544,15 +626,15 @@ def _no_context_response(query: str, author: User, books: list, start_ms: float)
|
|
| 544 |
response_ms=elapsed_ms,
|
| 545 |
)
|
| 546 |
|
| 547 |
-
if
|
| 548 |
-
return _catalog_response(author, books, start_ms)
|
| 549 |
|
| 550 |
default_robotic = "I want to give you the most accurate answer"
|
| 551 |
fallback = author.fallback_message or ""
|
| 552 |
if fallback and default_robotic not in fallback:
|
| 553 |
text = fallback
|
| 554 |
else:
|
| 555 |
-
text = NO_CONTEXT_RESPONSE.format(
|
| 556 |
|
| 557 |
return PipelineResult(
|
| 558 |
response={"text": text, "links": [], "has_links": False},
|
|
@@ -569,13 +651,9 @@ def _no_books_response(start_ms: float) -> PipelineResult:
|
|
| 569 |
|
| 570 |
|
| 571 |
def _book_selector_response(books: list, start_ms: float) -> PipelineResult:
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
])
|
| 577 |
-
return PipelineResult(
|
| 578 |
-
response=formatted,
|
| 579 |
intent="comparison",
|
| 580 |
-
response_ms=elapsed_ms,
|
| 581 |
)
|
|
|
|
| 41 |
MASTER_SYSTEM_PROMPT,
|
| 42 |
JAILBREAK_RESPONSE, OFF_TOPIC_RESPONSE,
|
| 43 |
NO_CONTEXT_RESPONSE, HALLUCINATION_FALLBACK_RESPONSE,
|
| 44 |
+
GREETING_RESPONSE, CATALOG_RESPONSE, BOOK_SELECTED_RESPONSE,
|
| 45 |
)
|
| 46 |
from app.services.reranker import rerank_chunks
|
| 47 |
from app.services.vector_store import retrieve_chunks
|
|
|
|
| 179 |
if not active_books:
|
| 180 |
return _no_books_response(start_ms)
|
| 181 |
|
| 182 |
+
# Short-circuit: greetings and catalog questions → show clickable book list
|
| 183 |
if intent_result.intent == "greeting" or _is_greeting(query):
|
| 184 |
+
return _greeting_response(author, active_books, session_context, start_ms)
|
| 185 |
|
| 186 |
if intent_result.intent in ("meta", "comparison") or _is_catalog_question(query):
|
| 187 |
+
return _catalog_response(author, active_books, session_context, start_ms)
|
| 188 |
+
|
| 189 |
+
# Book just selected — warm intro, then user asks freely
|
| 190 |
+
if _is_book_selection_turn(query, session_context.selected_book_id, active_books):
|
| 191 |
+
book = _find_book(active_books, session_context.selected_book_id)
|
| 192 |
+
if book:
|
| 193 |
+
return _book_selected_response(book, start_ms)
|
| 194 |
+
|
| 195 |
+
# Multiple books: require a selection before Q&A
|
| 196 |
+
if len(active_books) > 1 and not session_context.selected_book_id:
|
| 197 |
+
return _books_list_response(
|
| 198 |
+
"Pick a book below first — then ask me anything about it.",
|
| 199 |
+
active_books,
|
| 200 |
+
start_ms,
|
| 201 |
+
intent="comparison",
|
| 202 |
+
)
|
| 203 |
|
| 204 |
# Resolve which book to search
|
| 205 |
target_book_id = await _resolve_book(
|
|
|
|
| 231 |
|
| 232 |
if not raw_chunks:
|
| 233 |
log.warning("No chunks retrieved")
|
| 234 |
+
return _no_context_response(query, author, active_books, session_context, start_ms)
|
| 235 |
|
| 236 |
# ── Step 6: Re-ranking ────────────────────────────────────────────────────
|
| 237 |
top_chunks = await rerank_chunks(
|
|
|
|
| 246 |
top_chunks = raw_chunks[: cfg.RAG_RERANK_TOP_N]
|
| 247 |
|
| 248 |
if not top_chunks:
|
| 249 |
+
return _no_context_response(query, author, active_books, session_context, start_ms)
|
| 250 |
|
| 251 |
# ── Step 7: Context Assembly ───────────────────────────────────────────────
|
| 252 |
context_str, context_tokens = build_context(top_chunks)
|
|
|
|
| 290 |
|
| 291 |
is_faithful2, faithfulness_score = await check_faithfulness(raw_response, top_chunks)
|
| 292 |
if not is_faithful2:
|
| 293 |
+
book_title = _selected_book_title(active_books, session_context.selected_book_id)
|
| 294 |
+
raw_response = HALLUCINATION_FALLBACK_RESPONSE.format(book_title=book_title)
|
|
|
|
| 295 |
|
| 296 |
# ── Step 10: Scope Check ──────────────────────────────────────────────────
|
| 297 |
if not is_response_in_scope(raw_response):
|
|
|
|
| 453 |
|
| 454 |
|
| 455 |
_CATALOG_PHRASES = (
|
| 456 |
+
"uploaded book", "your books", "what books", "which books", "list book",
|
| 457 |
+
"books do you", "books you have", "books available", "about the books",
|
| 458 |
+
"tell me about book", "tell me about your book", "tell me about the books",
|
| 459 |
+
"what is the book", "what's the book", "in the catalog", "in your catalog",
|
| 460 |
+
"show me the book", "show me your book",
|
| 461 |
)
|
| 462 |
|
| 463 |
+
|
| 464 |
+
def _find_book(books: list, book_id: str | None):
|
| 465 |
+
if not book_id:
|
| 466 |
+
return None
|
| 467 |
+
return next((b for b in books if b.id == book_id), None)
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def _selected_book_title(books: list, selected_book_id: str | None) -> str:
|
| 471 |
+
book = _find_book(books, selected_book_id)
|
| 472 |
+
if book:
|
| 473 |
+
return book.title
|
| 474 |
+
if len(books) == 1:
|
| 475 |
+
return books[0].title
|
| 476 |
+
return "the books"
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def _book_selector_items(books: list) -> list[dict]:
|
| 480 |
+
items = []
|
| 481 |
+
for book in books:
|
| 482 |
+
tagline = book.tagline
|
| 483 |
+
if not tagline and book.status != "ready":
|
| 484 |
+
tagline = "Getting ready..."
|
| 485 |
+
items.append({
|
| 486 |
+
"id": book.id,
|
| 487 |
+
"title": book.title,
|
| 488 |
+
"tagline": tagline,
|
| 489 |
+
"cover_path": book.cover_path,
|
| 490 |
+
})
|
| 491 |
+
return items
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
def _books_list_response(
|
| 495 |
+
text: str,
|
| 496 |
+
books: list,
|
| 497 |
+
start_ms: float,
|
| 498 |
+
intent: str = "greeting",
|
| 499 |
+
) -> PipelineResult:
|
| 500 |
+
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 501 |
+
formatted = _formatter.format_book_selector(_book_selector_items(books), intro=text)
|
| 502 |
+
return PipelineResult(
|
| 503 |
+
response=formatted,
|
| 504 |
+
intent=intent,
|
| 505 |
+
response_ms=elapsed_ms,
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def _book_hook(book) -> str:
|
| 510 |
+
if book.tagline:
|
| 511 |
+
return book.tagline.strip()
|
| 512 |
+
if book.ai_summary:
|
| 513 |
+
summary = book.ai_summary.strip().replace("\n", " ")
|
| 514 |
+
if len(summary) > 220:
|
| 515 |
+
cut = summary[:220].rsplit(" ", 1)[0]
|
| 516 |
+
return cut + "..."
|
| 517 |
+
return summary
|
| 518 |
+
if book.description:
|
| 519 |
+
desc = book.description.strip().replace("\n", " ")
|
| 520 |
+
return desc[:220] + ("..." if len(desc) > 220 else "")
|
| 521 |
+
if book.status != "ready":
|
| 522 |
+
return "This one is still being indexed — you can ask general questions now, and I'll get sharper as it finishes."
|
| 523 |
+
return "Ask me anything — characters, themes, plot, or who it's perfect for."
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def _is_book_selection_turn(query: str, selected_book_id: str | None, books: list) -> bool:
|
| 527 |
+
if not selected_book_id:
|
| 528 |
+
return False
|
| 529 |
+
book = _find_book(books, selected_book_id)
|
| 530 |
+
if not book:
|
| 531 |
+
return False
|
| 532 |
+
q = query.lower().strip().strip("\"'")
|
| 533 |
+
title_lower = book.title.lower()
|
| 534 |
+
if q == title_lower:
|
| 535 |
+
return True
|
| 536 |
+
if q.startswith("tell me about") and title_lower in q:
|
| 537 |
+
return True
|
| 538 |
+
return q.startswith("i'm interested in") and title_lower in q
|
| 539 |
+
|
| 540 |
_GREETINGS = frozenset({
|
| 541 |
"hi", "hello", "hey", "hiya", "howdy", "yo", "sup",
|
| 542 |
"good morning", "good afternoon", "good evening", "good day",
|
|
|
|
| 550 |
|
| 551 |
def _is_catalog_question(query: str) -> bool:
|
| 552 |
q = query.lower()
|
| 553 |
+
if any(phrase in q for phrase in _CATALOG_PHRASES):
|
| 554 |
+
return True
|
| 555 |
+
return q in ("books", "tell me about books", "about books", "the books")
|
| 556 |
|
| 557 |
|
| 558 |
+
def _greeting_response(
|
| 559 |
+
author: User,
|
| 560 |
+
books: list,
|
| 561 |
+
session_context: SessionContext,
|
| 562 |
+
start_ms: float,
|
| 563 |
+
) -> PipelineResult:
|
| 564 |
+
if session_context.selected_book_id:
|
| 565 |
+
book = _find_book(books, session_context.selected_book_id)
|
| 566 |
+
title = book.title if book else "your book"
|
| 567 |
+
text = f"Hello again! Still exploring {title}? Ask me anything — I'm here for it."
|
| 568 |
+
return PipelineResult(
|
| 569 |
+
response={"text": text, "links": [], "has_links": False},
|
| 570 |
+
intent="greeting",
|
| 571 |
+
response_ms=int((time.monotonic() - start_ms) * 1000),
|
| 572 |
+
)
|
|
|
|
| 573 |
|
| 574 |
+
text = "Hello! Which book would you like to explore?"
|
| 575 |
+
return _books_list_response(text, books, start_ms, intent="greeting")
|
| 576 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
|
| 578 |
+
def _catalog_response(
|
| 579 |
+
author: User,
|
| 580 |
+
books: list,
|
| 581 |
+
session_context: SessionContext,
|
| 582 |
+
start_ms: float,
|
| 583 |
+
) -> PipelineResult:
|
| 584 |
+
if session_context.selected_book_id:
|
| 585 |
+
book = _find_book(books, session_context.selected_book_id)
|
| 586 |
+
if book:
|
| 587 |
+
return _book_selected_response(book, start_ms)
|
| 588 |
|
| 589 |
+
text = CATALOG_RESPONSE
|
| 590 |
+
return _books_list_response(text, books, start_ms, intent="meta")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
|
| 592 |
|
| 593 |
+
def _book_selected_response(book, start_ms: float) -> PipelineResult:
|
| 594 |
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 595 |
+
text = BOOK_SELECTED_RESPONSE.format(
|
| 596 |
+
book_title=book.title,
|
| 597 |
+
hook=_book_hook(book),
|
| 598 |
+
)
|
| 599 |
return PipelineResult(
|
| 600 |
response={"text": text, "links": [], "has_links": False},
|
| 601 |
+
intent="question",
|
| 602 |
response_ms=elapsed_ms,
|
| 603 |
)
|
| 604 |
|
| 605 |
|
| 606 |
+
def _no_context_response(
|
| 607 |
+
query: str,
|
| 608 |
+
author: User,
|
| 609 |
+
books: list,
|
| 610 |
+
session_context: SessionContext,
|
| 611 |
+
start_ms: float,
|
| 612 |
+
) -> PipelineResult:
|
| 613 |
elapsed_ms = int((time.monotonic() - start_ms) * 1000)
|
| 614 |
+
book_title = _selected_book_title(books, session_context.selected_book_id)
|
| 615 |
|
|
|
|
| 616 |
if len(books) == 1:
|
| 617 |
book = books[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
if book.status != "ready":
|
| 619 |
text = (
|
| 620 |
+
f"{book.title} is still being indexed — give it a minute and ask again. "
|
| 621 |
"I'll be able to answer detailed questions once processing finishes."
|
| 622 |
)
|
| 623 |
return PipelineResult(
|
|
|
|
| 626 |
response_ms=elapsed_ms,
|
| 627 |
)
|
| 628 |
|
| 629 |
+
if _is_catalog_question(query) and not session_context.selected_book_id:
|
| 630 |
+
return _catalog_response(author, books, session_context, start_ms)
|
| 631 |
|
| 632 |
default_robotic = "I want to give you the most accurate answer"
|
| 633 |
fallback = author.fallback_message or ""
|
| 634 |
if fallback and default_robotic not in fallback:
|
| 635 |
text = fallback
|
| 636 |
else:
|
| 637 |
+
text = NO_CONTEXT_RESPONSE.format(book_title=book_title)
|
| 638 |
|
| 639 |
return PipelineResult(
|
| 640 |
response={"text": text, "links": [], "has_links": False},
|
|
|
|
| 651 |
|
| 652 |
|
| 653 |
def _book_selector_response(books: list, start_ms: float) -> PipelineResult:
|
| 654 |
+
return _books_list_response(
|
| 655 |
+
"Which book are you curious about?",
|
| 656 |
+
books,
|
| 657 |
+
start_ms,
|
|
|
|
|
|
|
|
|
|
| 658 |
intent="comparison",
|
|
|
|
| 659 |
)
|
static/widget.js
CHANGED
|
@@ -127,6 +127,7 @@
|
|
| 127 |
|
| 128 |
.ab-bubble-text {
|
| 129 |
padding: 10px 14px; border-radius: 16px; font-size: 13.5px; line-height: 1.55;
|
|
|
|
| 130 |
}
|
| 131 |
.ab-bot .ab-bubble-text {
|
| 132 |
background: ${T.surface}; border: 1px solid ${T.border}; color: ${T.text};
|
|
@@ -148,12 +149,17 @@
|
|
| 148 |
.ab-book-selector { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
|
| 149 |
.ab-book-btn {
|
| 150 |
display: flex; align-items: center; gap: 10px;
|
| 151 |
-
padding:
|
| 152 |
background: ${T.surface}; border: 1px solid ${T.border};
|
| 153 |
transition: all 0.15s; font-size: 13px; color: ${T.text};
|
|
|
|
| 154 |
}
|
| 155 |
.ab-book-btn:hover { border-color: ${T.brand}; background: ${T.brand}15; }
|
| 156 |
-
.ab-book-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
.ab-typing { display: flex; gap: 4px; padding: 12px 14px; }
|
| 159 |
.ab-dot {
|
|
@@ -248,16 +254,22 @@
|
|
| 248 |
$bubble.addEventListener('click', openChat);
|
| 249 |
$close.addEventListener('click', closeChat);
|
| 250 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
async function initSession() {
|
| 252 |
try {
|
| 253 |
const res = await apiPost(`/chat/${CONFIG.slug}/session/init`, {}, { 'X-Subscription-Token': CONFIG.token });
|
| 254 |
sessionId = res.session_id;
|
| 255 |
-
books = res.books
|
| 256 |
$botName.textContent = res.bot_name || 'Book Advisor';
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
addBotMessage("Which book are you curious about?", [], books);
|
| 260 |
-
}
|
| 261 |
$input.focus();
|
| 262 |
} catch (e) {
|
| 263 |
addBotMessage("Sorry, I'm having trouble starting. Please refresh and try again.", [], null);
|
|
@@ -285,7 +297,7 @@
|
|
| 285 |
}, { 'X-Subscription-Token': CONFIG.token });
|
| 286 |
|
| 287 |
typingEl.remove();
|
| 288 |
-
addBotMessage(res.text, res.links || [], res.book_selector
|
| 289 |
} catch (e) {
|
| 290 |
typingEl.remove();
|
| 291 |
addBotMessage("Sorry, something went wrong. Please try again.", [], null);
|
|
@@ -296,10 +308,14 @@
|
|
| 296 |
}
|
| 297 |
}
|
| 298 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
function addUserMessage(text) {
|
| 300 |
const el = document.createElement('div');
|
| 301 |
el.className = 'ab-msg ab-user';
|
| 302 |
-
el.innerHTML = `<div class="ab-bubble-text">${
|
| 303 |
$messages.appendChild(el);
|
| 304 |
scrollToBottom();
|
| 305 |
}
|
|
@@ -308,7 +324,7 @@
|
|
| 308 |
const el = document.createElement('div');
|
| 309 |
el.className = 'ab-msg ab-bot';
|
| 310 |
|
| 311 |
-
let html = `<div class="ab-bubble-text">${
|
| 312 |
|
| 313 |
if (links && links.length) {
|
| 314 |
html += `<div class="ab-links">`;
|
|
@@ -321,11 +337,12 @@
|
|
| 321 |
if (bookSelector && bookSelector.length) {
|
| 322 |
html += `<div class="ab-book-selector">`;
|
| 323 |
bookSelector.forEach(b => {
|
| 324 |
-
|
|
|
|
| 325 |
<span class="ab-book-thumb">📖</span>
|
| 326 |
-
<div
|
| 327 |
-
<div
|
| 328 |
-
${b.tagline ? `<div
|
| 329 |
</div>
|
| 330 |
</button>`;
|
| 331 |
});
|
|
@@ -335,21 +352,7 @@
|
|
| 335 |
el.innerHTML = html;
|
| 336 |
|
| 337 |
el.querySelectorAll('.ab-book-btn').forEach(btn => {
|
| 338 |
-
btn.addEventListener('click', () =>
|
| 339 |
-
const bookId = btn.getAttribute('data-book-id');
|
| 340 |
-
selectedBookId = bookId;
|
| 341 |
-
const book = bookSelector.find(b => b.id === bookId);
|
| 342 |
-
addUserMessage(`Tell me about "${book?.title}"`);
|
| 343 |
-
apiPost(`/chat/${CONFIG.slug}`, {
|
| 344 |
-
session_id: sessionId,
|
| 345 |
-
message: `Tell me about "${book?.title}"`,
|
| 346 |
-
selected_book_id: bookId,
|
| 347 |
-
}, { 'X-Subscription-Token': CONFIG.token }).then(res => {
|
| 348 |
-
addBotMessage(res.text, res.links || [], null);
|
| 349 |
-
});
|
| 350 |
-
el.querySelectorAll('.ab-book-btn').forEach(b => b.style.opacity = '0.4');
|
| 351 |
-
btn.style.opacity = '1'; btn.style.borderColor = T.brand;
|
| 352 |
-
});
|
| 353 |
});
|
| 354 |
|
| 355 |
el.querySelectorAll('.ab-link-btn').forEach(a => {
|
|
@@ -367,6 +370,45 @@
|
|
| 367 |
scrollToBottom();
|
| 368 |
}
|
| 369 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
function addTypingIndicator() {
|
| 371 |
const el = document.createElement('div');
|
| 372 |
el.className = 'ab-msg ab-bot';
|
|
|
|
| 127 |
|
| 128 |
.ab-bubble-text {
|
| 129 |
padding: 10px 14px; border-radius: 16px; font-size: 13.5px; line-height: 1.55;
|
| 130 |
+
white-space: pre-wrap; word-break: break-word;
|
| 131 |
}
|
| 132 |
.ab-bot .ab-bubble-text {
|
| 133 |
background: ${T.surface}; border: 1px solid ${T.border}; color: ${T.text};
|
|
|
|
| 149 |
.ab-book-selector { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
|
| 150 |
.ab-book-btn {
|
| 151 |
display: flex; align-items: center; gap: 10px;
|
| 152 |
+
padding: 10px 12px; border-radius: 12px; cursor: pointer;
|
| 153 |
background: ${T.surface}; border: 1px solid ${T.border};
|
| 154 |
transition: all 0.15s; font-size: 13px; color: ${T.text};
|
| 155 |
+
text-align: left; width: 100%;
|
| 156 |
}
|
| 157 |
.ab-book-btn:hover { border-color: ${T.brand}; background: ${T.brand}15; }
|
| 158 |
+
.ab-book-btn.ab-selected { border-color: ${T.brand}; background: ${T.brand}15; opacity: 1 !important; }
|
| 159 |
+
.ab-book-btn:disabled { opacity: 0.45; cursor: default; }
|
| 160 |
+
.ab-book-thumb { font-size: 20px; flex-shrink: 0; width: 28px; text-align: center; }
|
| 161 |
+
.ab-book-title { font-weight: 600; font-size: 13px; line-height: 1.3; }
|
| 162 |
+
.ab-book-tagline { font-size: 11px; opacity: 0.75; margin-top: 2px; line-height: 1.35; }
|
| 163 |
|
| 164 |
.ab-typing { display: flex; gap: 4px; padding: 12px 14px; }
|
| 165 |
.ab-dot {
|
|
|
|
| 254 |
$bubble.addEventListener('click', openChat);
|
| 255 |
$close.addEventListener('click', closeChat);
|
| 256 |
|
| 257 |
+
function mapBooks(rawBooks) {
|
| 258 |
+
return (rawBooks || []).map(b => ({
|
| 259 |
+
id: b.id,
|
| 260 |
+
title: b.title,
|
| 261 |
+
tagline: b.tagline || (b.ai_summary ? '' : ''),
|
| 262 |
+
}));
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
async function initSession() {
|
| 266 |
try {
|
| 267 |
const res = await apiPost(`/chat/${CONFIG.slug}/session/init`, {}, { 'X-Subscription-Token': CONFIG.token });
|
| 268 |
sessionId = res.session_id;
|
| 269 |
+
books = mapBooks(res.books);
|
| 270 |
$botName.textContent = res.bot_name || 'Book Advisor';
|
| 271 |
+
const welcome = res.welcome_message || `Hello! I'm ${res.bot_name || 'your book advisor'}.`;
|
| 272 |
+
addBotMessage(welcome, [], books.length ? books : null);
|
|
|
|
|
|
|
| 273 |
$input.focus();
|
| 274 |
} catch (e) {
|
| 275 |
addBotMessage("Sorry, I'm having trouble starting. Please refresh and try again.", [], null);
|
|
|
|
| 297 |
}, { 'X-Subscription-Token': CONFIG.token });
|
| 298 |
|
| 299 |
typingEl.remove();
|
| 300 |
+
addBotMessage(res.text, res.links || [], mapBooks(res.book_selector));
|
| 301 |
} catch (e) {
|
| 302 |
typingEl.remove();
|
| 303 |
addBotMessage("Sorry, something went wrong. Please try again.", [], null);
|
|
|
|
| 308 |
}
|
| 309 |
}
|
| 310 |
|
| 311 |
+
function formatBotText(text) {
|
| 312 |
+
return escHtml(text);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
function addUserMessage(text) {
|
| 316 |
const el = document.createElement('div');
|
| 317 |
el.className = 'ab-msg ab-user';
|
| 318 |
+
el.innerHTML = `<div class="ab-bubble-text">${formatBotText(text)}</div>`;
|
| 319 |
$messages.appendChild(el);
|
| 320 |
scrollToBottom();
|
| 321 |
}
|
|
|
|
| 324 |
const el = document.createElement('div');
|
| 325 |
el.className = 'ab-msg ab-bot';
|
| 326 |
|
| 327 |
+
let html = `<div class="ab-bubble-text">${formatBotText(text)}</div>`;
|
| 328 |
|
| 329 |
if (links && links.length) {
|
| 330 |
html += `<div class="ab-links">`;
|
|
|
|
| 337 |
if (bookSelector && bookSelector.length) {
|
| 338 |
html += `<div class="ab-book-selector">`;
|
| 339 |
bookSelector.forEach(b => {
|
| 340 |
+
const selected = selectedBookId === b.id;
|
| 341 |
+
html += `<button type="button" class="ab-book-btn${selected ? ' ab-selected' : ''}" data-book-id="${escAttr(b.id)}"${selected ? ' disabled' : ''}>
|
| 342 |
<span class="ab-book-thumb">📖</span>
|
| 343 |
+
<div>
|
| 344 |
+
<div class="ab-book-title">${escHtml(b.title)}</div>
|
| 345 |
+
${b.tagline ? `<div class="ab-book-tagline">${escHtml(b.tagline)}</div>` : ''}
|
| 346 |
</div>
|
| 347 |
</button>`;
|
| 348 |
});
|
|
|
|
| 352 |
el.innerHTML = html;
|
| 353 |
|
| 354 |
el.querySelectorAll('.ab-book-btn').forEach(btn => {
|
| 355 |
+
btn.addEventListener('click', () => selectBook(btn, bookSelector));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
});
|
| 357 |
|
| 358 |
el.querySelectorAll('.ab-link-btn').forEach(a => {
|
|
|
|
| 370 |
scrollToBottom();
|
| 371 |
}
|
| 372 |
|
| 373 |
+
async function selectBook(btn, bookSelector) {
|
| 374 |
+
if (isLoading || btn.disabled) return;
|
| 375 |
+
|
| 376 |
+
const bookId = btn.getAttribute('data-book-id');
|
| 377 |
+
const book = bookSelector.find(b => b.id === bookId);
|
| 378 |
+
if (!book) return;
|
| 379 |
+
|
| 380 |
+
selectedBookId = bookId;
|
| 381 |
+
addUserMessage(book.title);
|
| 382 |
+
|
| 383 |
+
btn.closest('.ab-book-selector')?.querySelectorAll('.ab-book-btn').forEach(b => {
|
| 384 |
+
b.disabled = true;
|
| 385 |
+
b.style.opacity = '0.45';
|
| 386 |
+
});
|
| 387 |
+
btn.classList.add('ab-selected');
|
| 388 |
+
btn.style.opacity = '1';
|
| 389 |
+
|
| 390 |
+
const typingEl = addTypingIndicator();
|
| 391 |
+
isLoading = true;
|
| 392 |
+
$send.disabled = true;
|
| 393 |
+
|
| 394 |
+
try {
|
| 395 |
+
const res = await apiPost(`/chat/${CONFIG.slug}`, {
|
| 396 |
+
session_id: sessionId,
|
| 397 |
+
message: book.title,
|
| 398 |
+
selected_book_id: bookId,
|
| 399 |
+
}, { 'X-Subscription-Token': CONFIG.token });
|
| 400 |
+
typingEl.remove();
|
| 401 |
+
addBotMessage(res.text, res.links || [], null);
|
| 402 |
+
} catch (e) {
|
| 403 |
+
typingEl.remove();
|
| 404 |
+
addBotMessage("Sorry, something went wrong. Please try again.", [], null);
|
| 405 |
+
} finally {
|
| 406 |
+
isLoading = false;
|
| 407 |
+
$send.disabled = false;
|
| 408 |
+
$input.focus();
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
function addTypingIndicator() {
|
| 413 |
const el = document.createElement('div');
|
| 414 |
el.className = 'ab-msg ab-bot';
|