Spaces:
Running
Running
| """pipeline/generation.py β LLM generation + faithfulness + safety (Steps 8-10). | |
| Extracted from pipeline/core.py to keep the orchestrator readable. | |
| This module owns the full generation loop including: | |
| - System prompt assembly | |
| - LLM call | |
| - Hallucination detection + retry | |
| - Output safety scrub | |
| """ | |
| import structlog | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from app.config import get_settings | |
| from app.models.user import User | |
| from app.services.faithfulness import check_faithfulness | |
| from app.services.guardrails import is_response_safe, scrub_unsafe_response | |
| from app.services.prompter import ( | |
| HALLUCINATION_FALLBACK_RESPONSE, | |
| JAILBREAK_RESPONSE, | |
| NO_CONTEXT_RESPONSE, | |
| MASTER_SYSTEM_PROMPT, | |
| classify_response_complexity, | |
| get_conversation_stage_directive, | |
| get_engagement_directive, | |
| get_length_instruction, | |
| get_objection_instruction, | |
| get_reading_anchor, | |
| get_response_style_instruction, | |
| get_returning_visitor_instruction, | |
| get_upsell_strategy_instruction, | |
| ) | |
| from app.services.session_core.manager import SessionContext | |
| from app.services.pipeline.guards import is_full_story_request | |
| from app.services.pipeline.helpers import call_llm, format_history, selected_book_title | |
| logger = structlog.get_logger(__name__) | |
| cfg = get_settings() | |
| async def generate_response( | |
| query: str, | |
| author: User, | |
| active_books: list, | |
| session_context: SessionContext, | |
| top_chunks: list, | |
| context_str: str, | |
| strategy: str = "CURIOSITY_GAP", | |
| effective_interest_score: float | None = None, | |
| objection_type: str | None = None, | |
| prior_objections: int = 0, | |
| intent: str = "question", | |
| price_facts: str = "", | |
| *, | |
| skip_faithfulness_retry: bool = False, | |
| ) -> tuple[str, float, bool, int, int, int]: | |
| """Run Steps 8β10: assemble prompt, call LLM, check faithfulness, scrub output. | |
| Args: | |
| skip_faithfulness_retry: When True (Discord Interaction budget), run one | |
| NLI check only β no second LLM call on failure (use safe fallback). | |
| """ | |
| log = logger.bind(author_id=author.id) | |
| # ββ Step 8: Assemble Prompt and Call LLM βββββββββββββββββββββββββββββββββ | |
| history_str = format_history(session_context.history) | |
| interest_tags_str = ", ".join(session_context.interest_tags[:10]) or "None detected yet" | |
| book_title = selected_book_title(active_books, session_context.selected_book_id) | |
| style = author.response_style or "balanced" | |
| interest_score = ( | |
| effective_interest_score | |
| if effective_interest_score is not None | |
| else session_context.interest_score | |
| ) | |
| # Compose the persuasion block: strategy + objection counter + personal anchor. | |
| upsell_instruction = ( | |
| get_upsell_strategy_instruction(strategy) | |
| + get_objection_instruction(objection_type, prior_objections, price_facts=price_facts) | |
| + get_reading_anchor(session_context.interest_tags) | |
| ) | |
| # Compose engagement: assertiveness level + conversation stage posture + returning-visitor memory. | |
| engagement_directive = ( | |
| get_engagement_directive(interest_score) | |
| + " " | |
| + get_conversation_stage_directive(session_context.turn_count, interest_score) | |
| + get_returning_visitor_instruction(session_context.is_returning_visitor) | |
| ) | |
| # Adaptive response length: simple exchanges stay terse, complex questions | |
| # (comparisons, series, author background) earn more room. | |
| complexity = classify_response_complexity(intent, query, session_context.is_cross_book) | |
| length_instruction, max_response_chars = get_length_instruction(complexity) | |
| system_prompt = MASTER_SYSTEM_PROMPT.format( | |
| bot_name=author.bot_name, | |
| author_name=author.full_name or "the author", | |
| book_title=book_title, | |
| interest_score=f"{interest_score:.1f}", | |
| interest_tags=interest_tags_str, | |
| context=context_str, | |
| history=history_str, | |
| response_style=style, | |
| tone_instruction=get_response_style_instruction(style), | |
| upsell_instruction=upsell_instruction, | |
| engagement_directive=engagement_directive, | |
| length_instruction=length_instruction, | |
| ) | |
| user_content = query | |
| if is_full_story_request(query): | |
| user_content = ( | |
| f"{query}\n\n" | |
| "[Reminder: Max 2 sentences. Do NOT summarize the plot. Tease only.]" | |
| ) | |
| elif objection_type: | |
| user_content = ( | |
| f"{query}\n\n" | |
| "[Reminder: The reader raised a concern. Address it FIRST with genuine " | |
| "empathy, then follow the OBJECTION guidance. Never dismiss, never argue.]" | |
| ) | |
| messages = [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_content}, | |
| ] | |
| raw_response, prompt_tokens, completion_tokens = await call_llm(messages) | |
| # ββ Step 9: Faithfulness Check ββββββββββββββββββββββββββββββββββββββββββββ | |
| is_faithful, faithfulness_score = await check_faithfulness(raw_response, top_chunks) | |
| hallucination_detected = not is_faithful | |
| if hallucination_detected and not skip_faithfulness_retry: | |
| log.warning("Hallucination detected β retrying with stricter prompt", score=faithfulness_score) | |
| stricter = messages + [ | |
| {"role": "assistant", "content": raw_response}, | |
| {"role": "user", "content": "Reply using ONLY the retrieved context. Max 2 sentences."}, | |
| ] | |
| raw_response, p2, c2 = await call_llm(stricter, temperature=0.3) | |
| prompt_tokens += p2 | |
| completion_tokens += c2 | |
| is_faithful2, faithfulness_score = await check_faithfulness(raw_response, top_chunks) | |
| if not is_faithful2: | |
| raw_response = HALLUCINATION_FALLBACK_RESPONSE.format(book_title=book_title) | |
| elif hallucination_detected and skip_faithfulness_retry: | |
| log.info("faithfulness_retry_skipped_channel_mode", score=faithfulness_score) | |
| raw_response = HALLUCINATION_FALLBACK_RESPONSE.format(book_title=book_title) | |
| # ββ Step 10: Output Safety Check ββββββββββββββββββββββββββββββββββββββββββ | |
| safe_fallback = NO_CONTEXT_RESPONSE.format(book_title=book_title) | |
| raw_response = scrub_unsafe_response(raw_response, safe_fallback) | |
| if not is_response_safe(raw_response)[0]: | |
| raw_response = JAILBREAK_RESPONSE.format( | |
| bot_name=author.bot_name, | |
| author_name=author.full_name or "the author", | |
| ) | |
| return raw_response, faithfulness_score, hallucination_detected, prompt_tokens, completion_tokens, max_response_chars | |