govbridge-api / ingestion /knowledge_extractor.py
harshrawat18's picture
Upload folder using huggingface_hub
aa68ee9 verified
Raw
History Blame Contribute Delete
15.7 kB
"""
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
@field_validator("predicate")
@classmethod
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
@field_validator("subject", "object")
@classmethod
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