"""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