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 CHANGED
@@ -91,7 +91,7 @@ async def init_session(
91
  }
92
  for b in active_books
93
  ],
94
- show_book_selector=len(active_books) > 1,
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="Hi! I'm here to help you find your next great read. What are you looking for?",
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(self, books: list[dict]) -> dict:
 
 
 
 
103
  """Format a book selector prompt response.
104
 
105
- Shown when intent is ambiguous and the bot needs the user to pick a book.
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": "I can help with any of these — which one are you curious about?",
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 = """Hmm, I don't have that exact detail in front of me right now. \
155
- {book_hint}What would you like to know — the story, the themes, or who it's perfect for?"""
156
 
157
- HALLUCINATION_FALLBACK_RESPONSE = """I want to stay accurate rather than guess. \
158
- {book_hint}Ask me something specific — 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 = """Hey! I'm {bot_name}, {author_name}'s book advisor.
162
 
163
- {book_teaser}
164
 
165
- What are you in the mood for a quick recommendation, or curious about a specific book?"""
166
 
167
- CATALOG_RESPONSE = """Here's what's in the library right now:
168
 
169
- {book_list}
170
 
171
- Tell me which one catches your eye, or describe what you're looking for and I'll point you to the best fit."""
 
 
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 (no vector search needed)
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
- raw_response = HALLUCINATION_FALLBACK_RESPONSE.format(
279
- book_hint=_book_hint(active_books),
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 book", "what book", "which book", "list book",
443
- "books do you", "books you have", "books available", "about the book",
444
- "tell me about", "what is the book", "what's the book", "book 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
- return any(phrase in q for phrase in _CATALOG_PHRASES)
 
 
462
 
463
 
464
- def _format_book_list(books: list) -> str:
465
- lines = []
466
- for book in books:
467
- line = f"📖 **{book.title}**"
468
- if book.tagline:
469
- line += f" — {book.tagline}"
470
- elif book.ai_summary:
471
- summary = book.ai_summary.strip().replace("\n", " ")
472
- line += f" — {summary[:160]}{'…' if len(summary) > 160 else ''}"
473
- elif book.description:
474
- desc = book.description.strip().replace("\n", " ")
475
- line += f" {desc[:160]}{'…' if len(desc) > 160 else ''}"
476
- if book.status != "ready":
477
- line += " *(indexing in progress)*"
478
- lines.append(line)
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
- def _greeting_response(author: User, books: list, start_ms: float) -> PipelineResult:
491
- elapsed_ms = int((time.monotonic() - start_ms) * 1000)
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 _catalog_response(author: User, books: list, start_ms: float) -> PipelineResult:
511
  elapsed_ms = int((time.monotonic() - start_ms) * 1000)
512
- text = CATALOG_RESPONSE.format(book_list=_format_book_list(books))
 
 
 
513
  return PipelineResult(
514
  response={"text": text, "links": [], "has_links": False},
515
- intent="meta",
516
  response_ms=elapsed_ms,
517
  )
518
 
519
 
520
- def _no_context_response(query: str, author: User, books: list, start_ms: float) -> PipelineResult:
 
 
 
 
 
 
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"**{book.title}** is still being indexed — give it a minute and ask again. "
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 len(books) > 1 and _is_catalog_question(query):
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(book_hint=_book_hint(books))
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
- elapsed_ms = int((time.monotonic() - start_ms) * 1000)
573
- formatted = _formatter.format_book_selector([
574
- {"id": b.id, "title": b.title, "tagline": b.tagline, "cover_path": b.cover_path}
575
- for b in books
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: 8px 12px; border-radius: 12px; cursor: pointer;
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-thumb { font-size: 20px; flex-shrink: 0; }
 
 
 
 
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
- addBotMessage(res.welcome_message || 'Hi! How can I help you today?', [], null);
258
- if (res.show_book_selector && books.length > 1) {
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 || null);
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">${escHtml(text)}</div>`;
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">${escHtml(text)}</div>`;
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
- html += `<button class="ab-book-btn" data-book-id="${escAttr(b.id)}">
 
325
  <span class="ab-book-thumb">📖</span>
326
- <div style="text-align:left">
327
- <div style="font-weight:600;font-size:13px">${escHtml(b.title)}</div>
328
- ${b.tagline ? `<div style="font-size:11px;opacity:0.7;margin-top:1px">${escHtml(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';