Spaces:
Running
Running
| """Author RAG Chatbot SaaS β Hybrid Query Rewriter. | |
| 3-Step Architecture (from RAG 1.2 proven pattern, adapted for book domain): | |
| Step 1 β Python follow-up resolver β resolves pronouns + prepositional follows | |
| Step 2 β Python keyword expansion β deterministic topic expansion map | |
| Step 3 β LLM rewrite (optional) β only for short/vague/ambiguous queries | |
| RULE: Python steps run first β zero API cost, microseconds. | |
| RULE: LLM step is skipped if query is already clear after Step 1+2. | |
| RULE: Always include original query in the returned list (first element). | |
| """ | |
| import re | |
| import structlog | |
| from app.config import get_settings | |
| logger = structlog.get_logger(__name__) | |
| cfg = get_settings() | |
| # ββ Step 1: Follow-Up Pronoun & Topic Resolver βββββββββββββββββββββββββββββββ | |
| # Resolves "them", "it", "the book", "that" in follow-up queries using last | |
| # user message from history. Used ONLY for retrieval β LLM gets original + history. | |
| # Source: adapted from RAG 1.2 _resolve_followup() | |
| _FOLLOWUP_PRONOUNS: set[str] = { | |
| "it", "them", "that", "this", "those", "these", "its", | |
| "the book", "the author", "the story", "the character", | |
| } | |
| _FOLLOWUP_STARTERS: tuple[str, ...] = ( | |
| "and ", "also ", "what about ", "how about ", | |
| # Prepositional β almost always continue the previous topic | |
| "during ", "after ", "before ", "about ", "for ", "without ", | |
| "on ", "in ", "if ", "when ", "where ", "who ", "how ", | |
| ) | |
| def _resolve_followup(query: str, history: list[dict]) -> str: | |
| """Resolve follow-up pronouns for better retrieval. | |
| Examples: | |
| "who is them?" + history["main characters"] β "who is main characters?" | |
| "and the villain?" β "main characters and the villain?" | |
| "how does it end?" β "how does [book title] end?" | |
| Only affects retrieval query β LLM receives original message + full history. | |
| Args: | |
| query: Current user message. | |
| history: Conversation history list. | |
| Returns: | |
| Resolved query string (may be same as input if no follow-up detected). | |
| """ | |
| if not history: | |
| return query | |
| q_lower = query.lower().strip() | |
| words = set(q_lower.split()) | |
| # Check for pronoun substitution | |
| has_pronoun = any(pron in q_lower for pron in _FOLLOWUP_PRONOUNS) | |
| # Check for short prepositional follow-up (e.g. "and the ending?") | |
| is_short_followup = ( | |
| len(words) <= 6 | |
| and any(q_lower.startswith(s) for s in _FOLLOWUP_STARTERS) | |
| ) | |
| # Check for ultra-short vague query with no book topic keywords | |
| _BOOK_TOPIC_WORDS = { | |
| "buy", "purchase", "price", "author", "character", "plot", "story", | |
| "theme", "book", "chapter", "ending", "beginning", "setting", "genre", | |
| "review", "recommend", "summary", "spoil", "scene", "happen", | |
| } | |
| is_vague_short = len(words) <= 4 and not any(w in _BOOK_TOPIC_WORDS for w in words) | |
| if not has_pronoun and not is_short_followup and not is_vague_short: | |
| return query # Nothing to resolve | |
| # Find the last user message for topic context | |
| last_user_msg = None | |
| for msg in reversed(history): | |
| if msg.get("role") == "user": | |
| last_user_msg = msg["content"].strip() | |
| break | |
| if not last_user_msg or last_user_msg.lower() == q_lower: | |
| return query # No useful history | |
| # Replace first matching pronoun | |
| if has_pronoun: | |
| for pron in _FOLLOWUP_PRONOUNS: | |
| if pron in q_lower: | |
| resolved = re.sub(r'\b' + re.escape(pron) + r'\b', last_user_msg, query, count=1, flags=re.IGNORECASE) | |
| logger.debug("Follow-up pronoun resolved", original=query[:40], resolved=resolved[:40]) | |
| return resolved | |
| # Prepend topic for short follow-ups | |
| if is_short_followup or is_vague_short: | |
| resolved = f"{last_user_msg} {query}" | |
| logger.debug("Follow-up prepended", original=query[:40], resolved=resolved[:40]) | |
| return resolved | |
| return query | |
| # ββ Step 2: Book-Domain Keyword Expansion Map βββββββββββββββββββββββββββββββββ | |
| # Deterministic keyword β search variant expansion. Runs in microseconds. | |
| # Ordered most-specific β least-specific. First match only (break on hit). | |
| # Source: original book-domain rules (adapted from RAG 1.2 _INTENT_MAP pattern) | |
| _BOOK_EXPANSION_MAP: list[tuple[str, list[str]]] = [ | |
| # ββ Purchase / Buy (most specific first) ββββββββββββββββββββββββββββββββββ | |
| ("how can i buy", ["buy purchase where to order link available"]), | |
| ("where can i buy", ["buy purchase where to order link available"]), | |
| ("where to buy", ["buy purchase order link available store"]), | |
| ("how do i get", ["buy purchase get copy order available"]), | |
| ("how to get", ["buy purchase get copy order link"]), | |
| ("buy now", ["buy purchase order link available"]), | |
| ("buy this", ["buy purchase order link available"]), | |
| ("purchase", ["buy purchase order link price available"]), | |
| ("how much", ["price cost buy purchase link"]), | |
| ("price", ["price cost buy purchase order link"]), | |
| ("buy", ["buy purchase order link price available store"]), | |
| # ββ Full Story / Spoiler ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ("tell me everything", ["full plot story summary complete overview spoiler ending"]), | |
| ("whole story", ["complete story full plot summary all events"]), | |
| ("full story", ["complete story full plot summary all events"]), | |
| ("complete summary", ["summary plot overview story themes characters"]), | |
| ("how does it end", ["ending conclusion resolution final plot"]), | |
| ("spoil", ["spoiler ending conclusion plot resolution"]), | |
| ("summary", ["summary overview plot story theme characters"]), | |
| # ββ Characters ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ("who is the main", ["main character protagonist hero role story"]), | |
| ("main character", ["protagonist main character hero role"]), | |
| ("protagonist", ["protagonist main character hero"]), | |
| ("villain", ["antagonist villain evil character role"]), | |
| ("antagonist", ["antagonist villain enemy character"]), | |
| ("who is", ["character role person story protagonist antagonist"]), | |
| ("character", ["character role person story protagonist antagonist"]), | |
| # ββ Plot / Events βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ("what happen", ["events plot story sequence what happens"]), | |
| ("what is it about", ["main theme plot overview story topic subject"]), | |
| ("what is the book about", ["theme plot overview story topic summary"]), | |
| ("what happens", ["plot events story sequence scene"]), | |
| ("plot", ["plot story events narrative sequence theme"]), | |
| ("story", ["story plot narrative events theme"]), | |
| # ββ Themes / Message ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ("what is the message", ["theme message moral lesson takeaway meaning"]), | |
| ("what does it mean", ["theme meaning message moral lesson"]), | |
| ("moral", ["moral lesson message theme takeaway"]), | |
| ("lesson", ["lesson moral theme message takeaway"]), | |
| ("theme", ["theme topic message moral meaning subject"]), | |
| ("message", ["message theme moral lesson meaning"]), | |
| # ββ Setting / World βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ("where does", ["setting location place world time period"]), | |
| ("when does", ["time period era historical setting when"]), | |
| ("setting", ["setting location time period world place"]), | |
| ("time period", ["historical time period era when setting"]), | |
| # ββ Recommendation / Audience βββββββββββββββββββββββββββββββββββββββββββββ | |
| ("who should read", ["target audience reader suitable recommend for"]), | |
| ("is it good for", ["suitable recommend audience who should read"]), | |
| ("recommend", ["recommend suitable audience reader who should read"]), | |
| ("who is it for", ["target audience reader suitable recommend"]), | |
| ("good for", ["suitable audience recommend reader who should"]), | |
| # ββ Review / Quality ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ("is it worth", ["worth reading review quality good recommend"]), | |
| ("worth reading", ["worth reading review quality recommend"]), | |
| ("is it good", ["review quality worth reading recommend"]), | |
| ("review", ["review quality opinion worth reading"]), | |
| # ββ Author βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ("who wrote", ["author writer creator who wrote"]), | |
| ("author", ["author writer creator wrote"]), | |
| # ββ Genre / Style ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ("genre", ["genre category type fiction nonfiction style"]), | |
| ("what type", ["genre category type fiction nonfiction style"]), | |
| ("what kind", ["genre category type style audience"]), | |
| ("similar", ["similar genre style comparable type"]), | |
| ("like this book", ["similar genre style comparable"]), | |
| ] | |
| def _expand_query(query: str) -> list[str]: | |
| """Expand query with domain-specific search variants. Deterministic, zero API cost. | |
| Inspired directly by RAG 1.2's _expand_query_locally(). On first match, breaks | |
| so only the most-specific expansion applies. | |
| Args: | |
| query: Query string (may already be follow-up resolved). | |
| Returns: | |
| List of query strings, original always first. Capped at 3. | |
| """ | |
| q_lower = query.lower() | |
| queries: list[str] = [query] | |
| for keyword, variants in _BOOK_EXPANSION_MAP: | |
| if keyword in q_lower: | |
| for v in variants: | |
| if v not in queries: | |
| queries.append(v) | |
| break # Only first (most specific) match | |
| return queries[:3] | |
| # ββ Step 3: LLM Rewrite (ambiguous queries only) ββββββββββββββββββββββββββββββ | |
| # Used for short/vague queries that Python cannot expand confidently. | |
| # Returns plain text (not JSON) β simpler, faster, no parse failure risk. | |
| _LLM_REWRITE_SYSTEM = """You are a book Q&A search query optimizer. | |
| TASK: Rewrite the user query into clear, specific search terms for retrieving book content. | |
| OUTPUT: A single line of search terms only β no explanation, no quotes, no prefix. | |
| RULES: | |
| - Resolve "it", "them", "that", "the book" using the conversation history | |
| - Expand vague phrases into specific book-domain terms: | |
| * "tell me more" β "book content details story themes characters" | |
| * "what else?" β "additional book content story details" | |
| * "interesting" β "book themes story highlights notable moments" | |
| - Keep output β€20 words | |
| - If query is already specific, output it unchanged""" | |
| async def _llm_rewrite(query: str, history: list[dict]) -> str | None: | |
| """Step 3: LLM rewrite for genuinely ambiguous queries. | |
| Args: | |
| query: Current query (after Python steps). | |
| history: Conversation history. | |
| Returns: | |
| Rewritten query string or None on failure. | |
| """ | |
| recent = history[-4:] if history else [] | |
| history_str = "\n".join( | |
| f"{m['role'].title()}: {m['content'][:150]}" | |
| for m in recent | |
| ) or "None" | |
| user_msg = f"History:\n{history_str}\n\nQuery: {query}" | |
| try: | |
| # BUG-2 fix: use the shared singleton client instead of creating a new | |
| # AsyncOpenAI() here β was spawning a new HTTP connection pool on every call. | |
| from app.services.pipeline.helpers import _get_openai_client | |
| client = _get_openai_client() | |
| response = await client.chat.completions.create( | |
| model=cfg.OPENAI_CHAT_MODEL, | |
| messages=[ | |
| {"role": "system", "content": _LLM_REWRITE_SYSTEM}, | |
| {"role": "user", "content": user_msg}, | |
| ], | |
| max_tokens=50, # Plain text, very short | |
| temperature=0.1, | |
| ) | |
| rewritten = response.choices[0].message.content.strip() | |
| # Strip any accidental quotes/prefixes from the model | |
| rewritten = re.sub(r'^["\']|["\']$', '', rewritten).strip() | |
| rewritten = re.sub(r'^(?:Rewritten|Search terms|Query)[:\s]+', '', rewritten, flags=re.IGNORECASE).strip() | |
| return rewritten if rewritten else None | |
| except Exception as e: | |
| logger.warning("LLM rewrite failed", error=str(e)) | |
| return None | |
| # ββ Vague Query Detector ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Determines if a query is too vague for Python expansion to help. | |
| _VAGUE_PATTERNS: tuple[str, ...] = ( | |
| "tell me more", "what else", "more about", "go on", "continue", | |
| "interesting", "cool", "nice", "wow", "really?", "and then?", | |
| "what do you think", "give me more", "elaborate", "explain more", | |
| ) | |
| def _is_vague(query: str) -> bool: | |
| """Detect vague queries that benefit from LLM rewriting.""" | |
| q = query.lower().strip() | |
| if len(q.split()) <= 3: | |
| return True | |
| return any(p in q for p in _VAGUE_PATTERNS) | |
| # ββ Public Interface ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def rewrite_query(query: str, history: list[dict]) -> list[str]: | |
| """Rewrite and expand a user query for maximum retrieval coverage. | |
| 3-step hybrid approach: | |
| 1. Python follow-up resolver (always runs, zero cost) | |
| 2. Python keyword expansion (always runs, zero cost) | |
| 3. LLM rewrite only if query is still vague (rarely needed) | |
| Args: | |
| query: Original user message text. | |
| history: Conversation history. | |
| Returns: | |
| List of query strings. Original always first. Maximum 4 total. | |
| """ | |
| if not query.strip(): | |
| return [query] | |
| # ββ Step 1: Resolve follow-up pronouns (Python, zero cost) βββββββββββββββ | |
| resolved = _resolve_followup(query, history) | |
| # ββ Step 2: Keyword expansion (Python, zero cost) βββββββββββββββββββββββββ | |
| queries = _expand_query(resolved) | |
| # Also expand the ORIGINAL query if it differs (the resolver may have mangled known terms) | |
| if resolved.lower() != query.lower(): | |
| original_expansions = _expand_query(query) | |
| for oq in original_expansions: | |
| if oq not in queries: | |
| queries.append(oq) | |
| # Always ensure original is present | |
| if query not in queries: | |
| queries.insert(0, query) | |
| # ββ Step 3: LLM rewrite for vague queries (rare API call) βββββββββββββββββ | |
| if _is_vague(resolved) and len(queries) <= 2: | |
| llm_rewritten = await _llm_rewrite(resolved, history) | |
| if llm_rewritten and llm_rewritten.lower() not in (q.lower() for q in queries): | |
| # Insert LLM rewrite as second item (after original, before expansions) | |
| queries.insert(1, llm_rewritten) | |
| logger.debug("LLM rewrite added", original=query[:40], rewritten=llm_rewritten[:40]) | |
| logger.debug("Rewrite complete", original=query[:40], total=len(queries), queries=queries[:3]) | |
| return queries[:4] # Max 4: original + rewrite + 2 expansions | |