Spaces:
Sleeping
Sleeping
| """ | |
| GovBridge India β Two-Pass Knowledge Triplet Extraction Engine | |
| Sprint 30: PROJECT INDRA Phase 1.5 β Cascade Intelligence Ingestion | |
| ARCHITECTURE: | |
| Pass 1 (Global Context): Send first 2000 tokens to Groq to extract | |
| document title, primary body, and key definitions. This context is | |
| prepended to every chunk in Pass 2. | |
| Pass 2 (Reduce): Chunk the document into 800-1200 char windows with | |
| 150-char overlap. Fire all extraction tasks concurrently via | |
| asyncio.gather() with a Semaphore(GROQ_MAX_CONCURRENCY) gate. | |
| Aggregation: Deduplicate triplets by normalized (subject, predicate, object) | |
| tuple. Validate all predicates against the 10-predicate ontology. | |
| CONCURRENCY MODEL: | |
| - asyncio.Semaphore(settings.GROQ_MAX_CONCURRENCY) prevents Groq 429s | |
| - Exponential backoff (base 2s, max 32s, 4 retries) on rate limit errors | |
| - Individual chunk failures are logged and skipped (no pipeline crash) | |
| DEPENDENCIES (all already in requirements.txt): | |
| - groq (async client) | |
| - pydantic (response validation) | |
| - asyncio (concurrency) | |
| """ | |
| import sys | |
| import os | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| import asyncio | |
| import json | |
| import logging | |
| import re | |
| from typing import Optional | |
| from groq import AsyncGroq, RateLimitError, APIStatusError | |
| from pydantic import BaseModel, field_validator, ValidationError | |
| from prompts import ( | |
| GLOBAL_CONTEXT_SYSTEM_PROMPT, | |
| TRIPLET_EXTRACTION_SYSTEM_PROMPT, | |
| VALID_PREDICATES, | |
| build_chunk_user_prompt, | |
| ) | |
| logger = logging.getLogger("govbridge.knowledge_extractor") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 1: Pydantic Response Models (Type Safety Layer) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class KnowledgeTriplet(BaseModel): | |
| """A single validated knowledge triplet.""" | |
| subject: str | |
| predicate: str | |
| object: str | |
| def validate_predicate(cls, v: str) -> str: | |
| """Enforce the 10-predicate ontology. Reject hallucinated predicates.""" | |
| normalized = v.strip().upper() | |
| if normalized not in VALID_PREDICATES: | |
| raise ValueError( | |
| f"Invalid predicate '{v}'. Must be one of: {', '.join(sorted(VALID_PREDICATES))}" | |
| ) | |
| return normalized | |
| def validate_entity(cls, v: str) -> str: | |
| """Strip whitespace and reject empty entities.""" | |
| cleaned = v.strip() | |
| if not cleaned or len(cleaned) < 2: | |
| raise ValueError(f"Entity too short or empty: '{v}'") | |
| return cleaned | |
| class ExtractionResponse(BaseModel): | |
| """Validated LLM extraction response with cognitive exhaust.""" | |
| _reasoning: str = "" | |
| triplets: list[KnowledgeTriplet] = [] | |
| class GlobalContext(BaseModel): | |
| """Validated global context from Pass 1.""" | |
| document_title: Optional[str] = None | |
| primary_body: Optional[str] = None | |
| key_definitions: list[str] = [] | |
| document_date: Optional[str] = None | |
| document_type: Optional[str] = None | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 2: Text Chunking (Token-Aware Sliding Window) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def chunk_text_for_extraction( | |
| full_text: str, | |
| chunk_size: int = 1000, | |
| overlap: int = 150, | |
| ) -> list[str]: | |
| """ | |
| Split document text into overlapping chunks for LLM extraction. | |
| Uses character-level windowing (not token-level) because: | |
| 1. Groq's tokenizer is not locally available | |
| 2. 1000 chars β 250-300 tokens for English/Hindi legal text | |
| 3. Character windowing is deterministic and allocation-free | |
| Args: | |
| full_text: Complete document text | |
| chunk_size: Target characters per chunk (800-1200 range) | |
| overlap: Character overlap to prevent severing cross-boundary relationships | |
| Returns: | |
| List of text chunks with overlap applied | |
| """ | |
| if len(full_text) <= chunk_size: | |
| return [full_text] | |
| chunks: list[str] = [] | |
| start = 0 | |
| while start < len(full_text): | |
| end = start + chunk_size | |
| # Try to break at a paragraph or sentence boundary | |
| if end < len(full_text): | |
| # Look for paragraph break within last 200 chars | |
| para_break = full_text.rfind("\n\n", start + chunk_size - 200, end) | |
| if para_break > start: | |
| end = para_break | |
| else: | |
| # Fall back to sentence boundary (period + space) | |
| sentence_break = full_text.rfind(". ", start + chunk_size - 200, end) | |
| if sentence_break > start: | |
| end = sentence_break + 1 # Include the period | |
| chunk = full_text[start:end].strip() | |
| if chunk: | |
| chunks.append(chunk) | |
| # Advance with overlap | |
| start = end - overlap if end < len(full_text) else len(full_text) | |
| return chunks | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 3: Groq API Communication Layer | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def _call_groq_with_retry( | |
| client: AsyncGroq, | |
| model: str, | |
| system_prompt: str, | |
| user_prompt: str, | |
| semaphore: asyncio.Semaphore, | |
| max_retries: int = 4, | |
| base_delay: float = 2.0, | |
| max_delay: float = 32.0, | |
| ) -> Optional[dict]: | |
| """ | |
| Call Groq API with semaphore-gated concurrency and exponential backoff. | |
| Returns parsed JSON dict on success, None on unrecoverable failure. | |
| Rate Limit Strategy: | |
| - Semaphore prevents more than N concurrent requests | |
| - On 429 (rate limit): exponential backoff with jitter | |
| - On 400/500: log and skip (bad request or server error) | |
| - On JSON parse failure: log and skip (hallucinated output) | |
| """ | |
| delay = base_delay | |
| for attempt in range(max_retries + 1): | |
| async with semaphore: | |
| try: | |
| response = await client.chat.completions.create( | |
| model=model, | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| response_format={"type": "json_object"}, | |
| temperature=0.1, | |
| max_tokens=2048, | |
| stream=False, | |
| ) | |
| raw_content = response.choices[0].message.content or "" | |
| # Parse JSON β the response_format should guarantee valid JSON, | |
| # but we defend against edge cases. | |
| try: | |
| return json.loads(raw_content) | |
| except json.JSONDecodeError as e: | |
| logger.warning( | |
| f"JSON parse failed (attempt {attempt + 1}): {e}\n" | |
| f"Raw output: {raw_content[:200]}" | |
| ) | |
| return None | |
| except RateLimitError: | |
| if attempt < max_retries: | |
| # Add jitter: delay * (0.5 to 1.5) | |
| import random | |
| jittered = delay * (0.5 + random.random()) | |
| logger.warning( | |
| f"Groq 429 rate limit (attempt {attempt + 1}/{max_retries}). " | |
| f"Backing off {jittered:.1f}s" | |
| ) | |
| await asyncio.sleep(jittered) | |
| delay = min(delay * 2, max_delay) | |
| else: | |
| logger.error("Groq rate limit exceeded after all retries") | |
| return None | |
| except APIStatusError as e: | |
| logger.error(f"Groq API error {e.status_code}: {e.message}") | |
| return None | |
| except Exception as e: | |
| logger.error(f"Unexpected error calling Groq: {e}") | |
| return None | |
| return None | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SECTION 4: Two-Pass Extraction Pipeline | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def extract_global_context( | |
| client: AsyncGroq, | |
| model: str, | |
| full_text: str, | |
| semaphore: asyncio.Semaphore, | |
| ) -> GlobalContext: | |
| """ | |
| Pass 1: Extract document-level metadata from the first 2000 characters. | |
| This context is prepended to every chunk in Pass 2 to help the LLM | |
| resolve ambiguous references like "this Act" or "the said Ministry". | |
| """ | |
| # Take first 2000 chars (roughly first 500 tokens) | |
| preamble = full_text[:2000] | |
| result = await _call_groq_with_retry( | |
| client=client, | |
| model=model, | |
| system_prompt=GLOBAL_CONTEXT_SYSTEM_PROMPT, | |
| user_prompt=preamble, | |
| semaphore=semaphore, | |
| ) | |
| if result is None: | |
| logger.warning("Global context extraction failed β using empty context") | |
| return GlobalContext() | |
| try: | |
| return GlobalContext(**result) | |
| except ValidationError as e: | |
| logger.warning(f"Global context validation failed: {e}") | |
| # Partial extraction: take what we can | |
| return GlobalContext( | |
| document_title=result.get("document_title"), | |
| primary_body=result.get("primary_body"), | |
| ) | |
| async def extract_triplets_from_chunk( | |
| client: AsyncGroq, | |
| model: str, | |
| global_context: dict, | |
| chunk_text: str, | |
| chunk_index: int, | |
| semaphore: asyncio.Semaphore, | |
| ) -> list[KnowledgeTriplet]: | |
| """ | |
| Extract knowledge triplets from a single chunk using Groq. | |
| Returns validated triplets only. Invalid predicates or empty | |
| entities are silently dropped via Pydantic validation. | |
| """ | |
| user_prompt = build_chunk_user_prompt(global_context, chunk_text) | |
| result = await _call_groq_with_retry( | |
| client=client, | |
| model=model, | |
| system_prompt=TRIPLET_EXTRACTION_SYSTEM_PROMPT, | |
| user_prompt=user_prompt, | |
| semaphore=semaphore, | |
| ) | |
| if result is None: | |
| logger.warning(f"Chunk {chunk_index}: extraction returned None β skipping") | |
| return [] | |
| reasoning = result.get("_reasoning", "") | |
| raw_triplets = result.get("triplets", []) | |
| if reasoning: | |
| logger.debug(f"Chunk {chunk_index} reasoning: {reasoning}") | |
| validated: list[KnowledgeTriplet] = [] | |
| for i, raw in enumerate(raw_triplets): | |
| try: | |
| triplet = KnowledgeTriplet(**raw) | |
| validated.append(triplet) | |
| except (ValidationError, TypeError) as e: | |
| logger.warning( | |
| f"Chunk {chunk_index}, triplet {i}: validation failed β {e}" | |
| ) | |
| return validated | |
| def _normalize_entity(entity: str) -> str: | |
| """ | |
| Normalize an entity string for deduplication. | |
| Strategy: | |
| 1. Lowercase | |
| 2. Strip leading/trailing whitespace | |
| 3. Collapse multiple spaces | |
| 4. Remove common noise phrases ("the", "said", "hereby") | |
| """ | |
| s = entity.lower().strip() | |
| s = re.sub(r'\s+', ' ', s) | |
| # Remove articles and legal filler that cause false negatives | |
| for noise in ["the ", "said ", "hereby ", "aforesaid "]: | |
| if s.startswith(noise): | |
| s = s[len(noise):] | |
| return s | |
| def deduplicate_triplets( | |
| triplets: list[KnowledgeTriplet], | |
| ) -> list[KnowledgeTriplet]: | |
| """ | |
| Deduplicate triplets by normalized (subject, predicate, object) key. | |
| First occurrence wins (preserves original casing from earliest chunk). | |
| """ | |
| seen: set[tuple[str, str, str]] = set() | |
| unique: list[KnowledgeTriplet] = [] | |
| for t in triplets: | |
| key = ( | |
| _normalize_entity(t.subject), | |
| t.predicate, # Already normalized by validator | |
| _normalize_entity(t.object), | |
| ) | |
| if key not in seen: | |
| seen.add(key) | |
| unique.append(t) | |
| return unique | |
| async def extract_knowledge_graph( | |
| full_text: str, | |
| groq_api_key: str, | |
| model: str = "llama-3.3-70b-versatile", | |
| max_concurrency: int = 5, | |
| ) -> tuple[GlobalContext, list[KnowledgeTriplet]]: | |
| """ | |
| Full Two-Pass Knowledge Extraction Pipeline. | |
| Args: | |
| full_text: Complete document text (all pages concatenated) | |
| groq_api_key: Groq API key | |
| model: Groq model identifier | |
| max_concurrency: Maximum concurrent API calls | |
| Returns: | |
| Tuple of (GlobalContext, deduplicated list of KnowledgeTriplet) | |
| """ | |
| client = AsyncGroq(api_key=groq_api_key) | |
| semaphore = asyncio.Semaphore(max_concurrency) | |
| # ββ Pass 1: Global Context ββββββββββββββββββββββββββββββββ | |
| logger.info("Pass 1: Extracting global document context...") | |
| global_ctx = await extract_global_context( | |
| client, model, full_text, semaphore | |
| ) | |
| logger.info( | |
| f" Title: {global_ctx.document_title}\n" | |
| f" Body: {global_ctx.primary_body}\n" | |
| f" Definitions: {global_ctx.key_definitions}" | |
| ) | |
| global_ctx_dict = global_ctx.model_dump() | |
| # ββ Pass 2: Chunk and extract βββββββββββββββββββββββββββββ | |
| chunks = chunk_text_for_extraction(full_text, chunk_size=1000, overlap=150) | |
| logger.info(f"Pass 2: Extracting triplets from {len(chunks)} chunks (concurrency={max_concurrency})...") | |
| tasks = [ | |
| extract_triplets_from_chunk( | |
| client, model, global_ctx_dict, chunk, idx, semaphore | |
| ) | |
| for idx, chunk in enumerate(chunks) | |
| ] | |
| # Fire all tasks concurrently (semaphore gates actual API calls) | |
| results = await asyncio.gather(*tasks, return_exceptions=True) | |
| # Flatten results, skip exceptions | |
| all_triplets: list[KnowledgeTriplet] = [] | |
| for i, result in enumerate(results): | |
| if isinstance(result, Exception): | |
| logger.error(f"Chunk {i} raised exception: {result}") | |
| continue | |
| all_triplets.extend(result) | |
| logger.info(f" Raw triplets extracted: {len(all_triplets)}") | |
| # ββ Aggregation: Deduplicate ββββββββββββββββββββββββββββββ | |
| unique = deduplicate_triplets(all_triplets) | |
| logger.info(f" After deduplication: {len(unique)} unique triplets") | |
| return global_ctx, unique | |