AuthorBot Cursor commited on
Commit
2bb94f4
Β·
1 Parent(s): 2e8c5a3

Humanize bot responses for greetings, catalog questions, and empty RAG retrieval.

Browse files
app/admin/templates/admin.html CHANGED
@@ -678,7 +678,7 @@
678
  <div class="form-group"><label class="form-label">Response Style</label>
679
  <select class="form-input" id="p-style"><option value="balanced">Balanced</option><option value="formal">Formal</option><option value="casual">Casual</option><option value="enthusiastic">Enthusiastic</option></select>
680
  </div>
681
- <div class="form-group"><label class="form-label">Fallback Message (when bot can't answer)</label><textarea class="form-input" id="p-fallback" placeholder="I want to give you the most accurate answer..."></textarea></div>
682
  <div class="form-group"><label class="form-label">Out-of-Scope Message (non-book questions)</label><textarea class="form-input" id="p-outofscope" placeholder="I'm specialized in discussing books..."></textarea></div>
683
  <button class="btn btn-primary" id="save-personality-btn">Save Personality Settings</button>
684
  </div>
 
678
  <div class="form-group"><label class="form-label">Response Style</label>
679
  <select class="form-input" id="p-style"><option value="balanced">Balanced</option><option value="formal">Formal</option><option value="casual">Casual</option><option value="enthusiastic">Enthusiastic</option></select>
680
  </div>
681
+ <div class="form-group"><label class="form-label">Fallback Message (when bot can't answer)</label><textarea class="form-input" id="p-fallback" placeholder="I'm not sure about that detail β€” but I can tell you plenty about the books. What would you like to know?"></textarea></div>
682
  <div class="form-group"><label class="form-label">Out-of-Scope Message (non-book questions)</label><textarea class="form-input" id="p-outofscope" placeholder="I'm specialized in discussing books..."></textarea></div>
683
  <button class="btn btn-primary" id="save-personality-btn">Save Personality Settings</button>
684
  </div>
app/models/user.py CHANGED
@@ -44,7 +44,7 @@ class User(Base, TimestampMixin):
44
  )
45
  fallback_message: Mapped[str] = mapped_column(
46
  String(500),
47
- default="I want to give you the most accurate answer. Could you rephrase that?",
48
  nullable=False,
49
  )
50
  response_style: Mapped[str] = mapped_column(
 
44
  )
45
  fallback_message: Mapped[str] = mapped_column(
46
  String(500),
47
+ default="I'm not sure about that specific detail β€” but I can tell you plenty about the books. What would you like to know?",
48
  nullable=False,
49
  )
50
  response_style: Mapped[str] = mapped_column(
app/services/prompter.py CHANGED
@@ -109,6 +109,9 @@ Rules:
109
 
110
  INTENT_CLASSIFICATION_PROMPT = """Classify this reader message for a book sales chatbot.
111
 
 
 
 
112
  MESSAGE: {query}
113
 
114
  Output ONLY a JSON object:
@@ -148,14 +151,24 @@ COMPETITOR_RESPONSE = """I'm specifically focused on {author_name}'s work, \
148
  so I can't speak to other authors. But I'd love to show you what makes \
149
  {author_name}'s approach different β€” what are you hoping a book will help you with?"""
150
 
151
- NO_CONTEXT_RESPONSE = """I want to give you the most accurate answer I can. \
152
- That specific detail isn't in what I have handy right now β€” but I'd love to \
153
- point you toward what *is* covered. What aspect of {topic} matters most to you?"""
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
- HALLUCINATION_FALLBACK_RESPONSE = """I want to make sure I'm giving you \
156
- accurate information. Let me point you to exactly where you can find this β€” \
157
- the answer is in {author_name}'s book, and I'd hate to paraphrase it poorly. \
158
- Is there something more specific I can help you find?"""
159
 
160
  TOKEN_EXHAUSTED_RESPONSE = "I'm taking a short break to recharge! Check back soon."
161
 
 
109
 
110
  INTENT_CLASSIFICATION_PROMPT = """Classify this reader message for a book sales chatbot.
111
 
112
+ RECENT CONVERSATION:
113
+ {history}
114
+
115
  MESSAGE: {query}
116
 
117
  Output ONLY a JSON object:
 
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
 
app/services/rag_pipeline.py CHANGED
@@ -41,6 +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
  )
45
  from app.services.reranker import rerank_chunks
46
  from app.services.vector_store import retrieve_chunks
@@ -178,6 +179,13 @@ async def run_pipeline(
178
  if not active_books:
179
  return _no_books_response(start_ms)
180
 
 
 
 
 
 
 
 
181
  # Resolve which book to search
182
  target_book_id = await _resolve_book(
183
  intent_result, session_context, active_books, author.id
@@ -208,7 +216,7 @@ async def run_pipeline(
208
 
209
  if not raw_chunks:
210
  log.warning("No chunks retrieved")
211
- return _no_context_response(query, author, start_ms)
212
 
213
  # ── Step 6: Re-ranking ────────────────────────────────────────────────────
214
  top_chunks = await rerank_chunks(
@@ -219,7 +227,11 @@ async def run_pipeline(
219
  )
220
 
221
  if not top_chunks:
222
- return _no_context_response(query, author, start_ms)
 
 
 
 
223
 
224
  # ── Step 7: Context Assembly ───────────────────────────────────────────────
225
  context_str, context_tokens = build_context(top_chunks)
@@ -264,7 +276,7 @@ async def run_pipeline(
264
  is_faithful2, faithfulness_score = await check_faithfulness(raw_response, top_chunks)
265
  if not is_faithful2:
266
  raw_response = HALLUCINATION_FALLBACK_RESPONSE.format(
267
- author_name=author.full_name or "the author"
268
  )
269
 
270
  # ── Step 10: Scope Check ──────────────────────────────────────────────────
@@ -426,9 +438,122 @@ def _boundary_response(text: str, start_ms: float, violation_type: str) -> Pipel
426
  )
427
 
428
 
429
- def _no_context_response(query: str, author: User, start_ms: float) -> PipelineResult:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  elapsed_ms = int((time.monotonic() - start_ms) * 1000)
431
- text = NO_CONTEXT_RESPONSE.format(topic=query[:50])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  return PipelineResult(
433
  response={"text": text, "links": [], "has_links": False},
434
  response_ms=elapsed_ms,
 
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
  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(
191
  intent_result, session_context, active_books, author.id
 
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(
 
227
  )
228
 
229
  if not top_chunks:
230
+ log.warning("Re-ranker filtered all chunks β€” using top retrieval results")
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)
 
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 ──────────────────────────────────────────────────
 
438
  )
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",
451
+ })
452
+
453
+
454
+ def _is_greeting(query: str) -> bool:
455
+ q = query.strip().lower().rstrip("!.?")
456
+ return q in _GREETINGS or (len(q) <= 12 and q.startswith(("hi ", "hey ", "hello ")))
457
+
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(
542
+ response={"text": text, "links": [], "has_links": False},
543
+ intent="question",
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},
559
  response_ms=elapsed_ms,