Arag / app /services /pipeline /generation.py
AuthorBot
Discord: answer most asks intelligently under the 3s Interaction budget.
88e7db9
Raw
History Blame Contribute Delete
6.82 kB
"""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