Spaces:
Running on Zero
Running on Zero
| """ | |
| 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 | |
| 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 | |