Download src/rag/generator.py from MHamdan/SPARKNET: direct link, hf CLI and curl.
- Browser
- Download file 11.5 kB
-
https://huggingface.co/spaces/MHamdan/SPARKNET/resolve/76c3b0a8cd44eb930c2ad41fc0e95acdb3cd2bab/src/rag/generator.py
- Command line
-
hf download hf://spaces/MHamdan/SPARKNET@76c3b0a8cd44eb930c2ad41fc0e95acdb3cd2bab/src/rag/generator.py
-
curl -L -o generator.py https://huggingface.co/spaces/MHamdan/SPARKNET/resolve/76c3b0a8cd44eb930c2ad41fc0e95acdb3cd2bab/src/rag/generator.py
11.5 kB
| """ | |
| Grounded Answer Generator | |
| Generates answers from retrieved context with citations. | |
| Uses local LLMs (Ollama) or cloud APIs. | |
| """ | |
| from typing import List, Optional, Dict, Any, Tuple | |
| from pydantic import BaseModel, Field | |
| from loguru import logger | |
| import json | |
| import re | |
| from .retriever import RetrievedChunk, DocumentRetriever, get_document_retriever | |
| try: | |
| import httpx | |
| HTTPX_AVAILABLE = True | |
| except ImportError: | |
| HTTPX_AVAILABLE = False | |
| class GeneratorConfig(BaseModel): | |
| """Configuration for grounded generator.""" | |
| # LLM settings | |
| llm_provider: str = Field( | |
| default="ollama", | |
| description="LLM provider: ollama, openai" | |
| ) | |
| ollama_base_url: str = Field( | |
| default="http://localhost:11434", | |
| description="Ollama API base URL" | |
| ) | |
| ollama_model: str = Field( | |
| default="llama3.2:3b", | |
| description="Ollama model for generation" | |
| ) | |
| # OpenAI settings | |
| openai_model: str = Field( | |
| default="gpt-4o-mini", | |
| description="OpenAI model for generation" | |
| ) | |
| openai_api_key: Optional[str] = Field( | |
| default=None, | |
| description="OpenAI API key" | |
| ) | |
| # Generation settings | |
| temperature: float = Field(default=0.1, ge=0.0, le=2.0) | |
| max_tokens: int = Field(default=1024, ge=1) | |
| timeout: float = Field(default=120.0, ge=1.0) | |
| # Citation settings | |
| require_citations: bool = Field( | |
| default=True, | |
| description="Require citations in answers" | |
| ) | |
| citation_format: str = Field( | |
| default="[{index}]", | |
| description="Citation format template" | |
| ) | |
| abstain_on_low_confidence: bool = Field( | |
| default=True, | |
| description="Abstain when confidence is low" | |
| ) | |
| confidence_threshold: float = Field( | |
| default=0.6, | |
| ge=0.0, | |
| le=1.0, | |
| description="Minimum confidence threshold" | |
| ) | |
| class Citation(BaseModel): | |
| """A citation reference.""" | |
| index: int | |
| chunk_id: str | |
| page: Optional[int] = None | |
| text_snippet: str | |
| confidence: float | |
| class GeneratedAnswer(BaseModel): | |
| """Generated answer with citations.""" | |
| answer: str | |
| citations: List[Citation] | |
| confidence: float | |
| abstained: bool = False | |
| abstain_reason: Optional[str] = None | |
| # Source information | |
| num_chunks_used: int | |
| query: str | |
| class GroundedGenerator: | |
| """ | |
| Generates grounded answers with citations. | |
| Features: | |
| - Uses retrieved chunks as context | |
| - Generates answers with inline citations | |
| - Confidence-based abstention | |
| - Support for Ollama and OpenAI | |
| """ | |
| SYSTEM_PROMPT = """You are a precise document question-answering assistant. | |
| Your task is to answer questions based ONLY on the provided context from documents. | |
| Rules: | |
| 1. Only use information from the provided context | |
| 2. Cite your sources using [N] notation where N is the chunk number | |
| 3. If the context doesn't contain enough information, say "I cannot answer this based on the available context" | |
| 4. Be precise and concise | |
| 5. If information is uncertain or partial, indicate this clearly | |
| Context format: Each chunk is numbered [1], [2], etc. with page numbers and content. | |
| """ | |
| def __init__( | |
| self, | |
| config: Optional[GeneratorConfig] = None, | |
| retriever: Optional[DocumentRetriever] = None, | |
| ): | |
| """ | |
| Initialize generator. | |
| Args: | |
| config: Generator configuration | |
| retriever: Document retriever instance | |
| """ | |
| self.config = config or GeneratorConfig() | |
| self._retriever = retriever | |
| def retriever(self) -> DocumentRetriever: | |
| """Get retriever (lazy initialization).""" | |
| if self._retriever is None: | |
| self._retriever = get_document_retriever() | |
| return self._retriever | |
| def generate( | |
| self, | |
| query: str, | |
| chunks: List[RetrievedChunk], | |
| additional_context: Optional[str] = None, | |
| ) -> GeneratedAnswer: | |
| """ | |
| Generate an answer from retrieved chunks. | |
| Args: | |
| query: User question | |
| chunks: Retrieved context chunks | |
| additional_context: Optional additional context | |
| Returns: | |
| GeneratedAnswer with citations | |
| """ | |
| # Check if we should abstain | |
| if self.config.abstain_on_low_confidence and chunks: | |
| avg_confidence = sum(c.similarity for c in chunks) / len(chunks) | |
| if avg_confidence < self.config.confidence_threshold: | |
| return GeneratedAnswer( | |
| answer="I cannot provide a confident answer based on the available context.", | |
| citations=[], | |
| confidence=avg_confidence, | |
| abstained=True, | |
| abstain_reason=f"Average confidence ({avg_confidence:.2f}) below threshold ({self.config.confidence_threshold})", | |
| num_chunks_used=len(chunks), | |
| query=query, | |
| ) | |
| # Build context | |
| context = self._build_context(chunks, additional_context) | |
| # Build prompt | |
| prompt = self._build_prompt(query, context) | |
| # Generate answer | |
| if self.config.llm_provider == "ollama": | |
| raw_answer = self._generate_ollama(prompt) | |
| elif self.config.llm_provider == "openai": | |
| raw_answer = self._generate_openai(prompt) | |
| else: | |
| raise ValueError(f"Unknown LLM provider: {self.config.llm_provider}") | |
| # Parse citations from answer | |
| citations = self._extract_citations(raw_answer, chunks) | |
| # Calculate confidence | |
| if citations: | |
| confidence = sum(c.confidence for c in citations) / len(citations) | |
| elif chunks: | |
| confidence = sum(c.similarity for c in chunks) / len(chunks) | |
| else: | |
| confidence = 0.0 | |
| return GeneratedAnswer( | |
| answer=raw_answer, | |
| citations=citations, | |
| confidence=confidence, | |
| abstained=False, | |
| num_chunks_used=len(chunks), | |
| query=query, | |
| ) | |
| def answer_question( | |
| self, | |
| query: str, | |
| top_k: int = 5, | |
| filters: Optional[Dict[str, Any]] = None, | |
| ) -> GeneratedAnswer: | |
| """ | |
| Retrieve context and generate answer. | |
| Args: | |
| query: User question | |
| top_k: Number of chunks to retrieve | |
| filters: Optional retrieval filters | |
| Returns: | |
| GeneratedAnswer with citations | |
| """ | |
| # Retrieve relevant chunks | |
| chunks = self.retriever.retrieve(query, top_k=top_k, filters=filters) | |
| if not chunks: | |
| return GeneratedAnswer( | |
| answer="I could not find any relevant information in the documents to answer this question.", | |
| citations=[], | |
| confidence=0.0, | |
| abstained=True, | |
| abstain_reason="No relevant chunks found", | |
| num_chunks_used=0, | |
| query=query, | |
| ) | |
| return self.generate(query, chunks) | |
| def _build_context( | |
| self, | |
| chunks: List[RetrievedChunk], | |
| additional_context: Optional[str] = None, | |
| ) -> str: | |
| """Build context string from chunks.""" | |
| parts = [] | |
| if additional_context: | |
| parts.append(f"Additional context:\n{additional_context}\n") | |
| parts.append("Document excerpts:") | |
| for i, chunk in enumerate(chunks, 1): | |
| header = f"\n[{i}]" | |
| if chunk.page is not None: | |
| header += f" (Page {chunk.page + 1}" | |
| if chunk.chunk_type: | |
| header += f", {chunk.chunk_type}" | |
| header += ")" | |
| parts.append(f"{header}:") | |
| parts.append(chunk.text) | |
| return "\n".join(parts) | |
| def _build_prompt(self, query: str, context: str) -> str: | |
| """Build the full prompt.""" | |
| return f"""Based on the following context, answer the question. | |
| {context} | |
| Question: {query} | |
| Answer (cite sources using [N] notation):""" | |
| def _generate_ollama(self, prompt: str) -> str: | |
| """Generate using Ollama.""" | |
| if not HTTPX_AVAILABLE: | |
| raise ImportError("httpx required for Ollama") | |
| with httpx.Client(timeout=self.config.timeout) as client: | |
| response = client.post( | |
| f"{self.config.ollama_base_url}/api/generate", | |
| json={ | |
| "model": self.config.ollama_model, | |
| "prompt": prompt, | |
| "system": self.SYSTEM_PROMPT, | |
| "stream": False, | |
| "options": { | |
| "temperature": self.config.temperature, | |
| "num_predict": self.config.max_tokens, | |
| }, | |
| }, | |
| ) | |
| response.raise_for_status() | |
| result = response.json() | |
| return result.get("response", "").strip() | |
| def _generate_openai(self, prompt: str) -> str: | |
| """Generate using OpenAI.""" | |
| try: | |
| import openai | |
| except ImportError: | |
| raise ImportError("openai package required") | |
| client = openai.OpenAI(api_key=self.config.openai_api_key) | |
| response = client.chat.completions.create( | |
| model=self.config.openai_model, | |
| messages=[ | |
| {"role": "system", "content": self.SYSTEM_PROMPT}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| temperature=self.config.temperature, | |
| max_tokens=self.config.max_tokens, | |
| ) | |
| return response.choices[0].message.content.strip() | |
| def _extract_citations( | |
| self, | |
| answer: str, | |
| chunks: List[RetrievedChunk], | |
| ) -> List[Citation]: | |
| """Extract citations from answer text.""" | |
| citations = [] | |
| seen_indices = set() | |
| # Find citation patterns like [1], [2], etc. | |
| pattern = r'\[(\d+)\]' | |
| matches = re.findall(pattern, answer) | |
| for match in matches: | |
| index = int(match) | |
| if index in seen_indices: | |
| continue | |
| if index < 1 or index > len(chunks): | |
| continue | |
| seen_indices.add(index) | |
| chunk = chunks[index - 1] | |
| citation = Citation( | |
| index=index, | |
| chunk_id=chunk.chunk_id, | |
| page=chunk.page, | |
| text_snippet=chunk.text[:150] + ("..." if len(chunk.text) > 150 else ""), | |
| confidence=chunk.similarity, | |
| ) | |
| citations.append(citation) | |
| return sorted(citations, key=lambda c: c.index) | |
| # Global instance and factory | |
| _grounded_generator: Optional[GroundedGenerator] = None | |
| def get_grounded_generator( | |
| config: Optional[GeneratorConfig] = None, | |
| retriever: Optional[DocumentRetriever] = None, | |
| ) -> GroundedGenerator: | |
| """ | |
| Get or create singleton grounded generator. | |
| Args: | |
| config: Generator configuration | |
| retriever: Optional retriever instance | |
| Returns: | |
| GroundedGenerator instance | |
| """ | |
| global _grounded_generator | |
| if _grounded_generator is None: | |
| _grounded_generator = GroundedGenerator( | |
| config=config, | |
| retriever=retriever, | |
| ) | |
| return _grounded_generator | |
| def reset_grounded_generator(): | |
| """Reset the global generator instance.""" | |
| global _grounded_generator | |
| _grounded_generator = None | |