agentic_rag / generation.py
Saint5's picture
Direct upload to ZeroGPU container
51e9502 verified
Raw
History Blame Contribute Delete
5.21 kB
"""
generation.py
-----------------------
Answer generation — ZeroGPU version.
"""
import spaces
from config import MAX_PROMPT_TOKENS, ZEROGPU_DURATION
from model_setup import text_gen_pipeline, tokenizer
from utils import format_prompt, clean_final_answer
from logging_config import get_logger
logger = get_logger(__name__)
# The ONLY GPU-touching function in the codebase
@spaces.GPU(duration=ZEROGPU_DURATION)
def _generate_on_gpu(prompt: str) -> str:
"""
Run the actual LLM inference. This is the single point in the
entire application where a real GPU is attached to the process.
Deliberately minimal: no logging of prompt content here (keep
the GPU-held window as short as possible), no retries, no
fallback logic — just the inference call. All the surrounding
orchestration (prompt building, history handling, streaming)
happens in generate_answer() below, OUTSIDE the GPU-decorated
boundary, where a real GPU is not needed and thus not consumed.
Returns the complete generated text (already stripped).
"""
output = text_gen_pipeline(prompt)
return output[0]["generated_text"].strip()
# Prompt building + streaming wrapper (no GPU here)
def generate_answer(state: dict):
"""
Generator that yields (token_text, prompt_token_count) tuples.
This function itself is NOT @spaces.GPU-decorated — it does no CUDA work directly.
It builds the prompt (CPU-side string operations), calls _generate_on_gpu() once to get the complete
response, then splits it into words and yields them one by one for the streaming UI effect in `app.py`.
Splitting the concerns this way means the GPU slot is held for the minimum possible time —
just the actual generation call — while all the prompt-assembly and history-formatting logic runs
on CPU without competing for the shared GPU resource.
"""
from ui_helpers import get_history_text, _safe_content
query = state["query"]
history = state.get("history", [])
retrieved_docs = _safe_content(state.get("retrieved_docs", ""))
web_results = _safe_content(state.get("web_results", ""))
# Safety valve: both retrieval phases returned nothing
if not retrieved_docs and not web_results:
yield (
"I could not retrieve relevant information for your question. "
"Please check that the TAVILY_API_KEY secret is set in the "
"Space settings, or try rephrasing your question.",
0,
)
return
# Build context block
context_parts = []
if retrieved_docs:
context_parts.append(f"## Internal Knowledge Base\n\n{retrieved_docs}")
if web_results:
context_parts.append(f"## Web Search Results\n\n{web_results}")
context = "\n\n".join(context_parts)
# System prompt
system_msg = (
"You are a precise, factual research assistant.\n\n"
"Rules:\n"
"1. Answer ONLY using the context and conversation history provided.\n"
"2. If the context is insufficient, say so clearly.\n"
"3. Cite factual claims with markdown links: [Title](URL).\n"
" Use ONLY URLs that appear explicitly in the context.\n"
"4. Be concise: 2-4 paragraphs.\n"
"5. Do NOT reproduce context verbatim. Synthesise and summarise.\n"
"6. For follow-up questions, use the conversation history to\n"
" understand what the user is referring to."
)
# User message: history (if any) + context + question
history_text = get_history_text(history, max_turns=3)
if history_text:
user_msg = (
f"Previous conversation:\n{history_text}\n\n"
f"Context:\n{context}\n\n"
f"---\n\n"
f"Current question: {query}\n\nAnswer:"
)
else:
user_msg = f"Context:\n{context}\n\n---\n\nQuestion: {query}\n\nAnswer:"
# format_prompt() uses tokenizer.apply_chat_template — CPU-side,
# no CUDA involved, safe to call outside the GPU-decorated function.
prompt = format_prompt(system=system_msg, user=user_msg)
# Token budget: trim from the LEFT if over limit (tokenizer.encode
# is also CPU-side — no GPU needed for this step).
token_ids = tokenizer.encode(prompt)
token_count = len(token_ids)
if token_count > MAX_PROMPT_TOKENS:
token_ids = token_ids[-MAX_PROMPT_TOKENS:]
token_count = len(token_ids) # MAX_PROMPT_TOKENS
prompt = tokenizer.decode(token_ids, skip_special_tokens=False)
logger.info(f"Prompt: {token_count} tokens | history turns: {len(history)}")
# --- The ONLY call into GPU-decorated code -----------------
# This is where the ZeroGPU backend actually attaches a real GPU
# to the process, runs inference, and releases the slot when done.
full_text = _generate_on_gpu(prompt)
# Post-process: fix Qwen's URL formatting quirks
full_text = clean_final_answer(full_text)
# --- Word-by-word yield (CPU-side, instant) -----------------
# Simulates streaming by splitting the already-complete response.
# This is identical to the CPU version's approach — the only
# difference is that _generate_on_gpu() ran on a real GPU instead
# of the CPU-bound text_gen_pipeline() call.
words = full_text.split()
for i, word in enumerate(words):
yield word + (" " if i < len(words) - 1 else ""), token_count