Ukweli / app /scraper /workers /pdf_processor.py
Benard John
feat: implement full-stack architecture with database models, authentication services, and web dashboard components
37b5223
Raw
History Blame Contribute Delete
16.7 kB
"""
Celery Worker - PDF Processing Pipeline
"""
import os
import sys
import json
import logging
import hashlib
import time
from datetime import datetime
from pathlib import Path
from celery import Celery
import redis
from minio import Minio
# Add project root to path to allow importing app configs
root_path = str(Path(__file__).resolve().parent.parent.parent)
if root_path not in sys.path:
sys.path.insert(0, root_path)
from app.config import get_settings
settings = get_settings()
celery_redis_url = settings.redis_url
if celery_redis_url.startswith("rediss://"):
if "ssl_cert_reqs=none" in celery_redis_url:
celery_redis_url = celery_redis_url.replace("ssl_cert_reqs=none", "ssl_cert_reqs=CERT_NONE")
elif "ssl_cert_reqs" not in celery_redis_url:
separator = "&" if "?" in celery_redis_url else "?"
celery_redis_url = f"{celery_redis_url}{separator}ssl_cert_reqs=CERT_NONE"
app = Celery('oag_pdf_processor')
app.config_from_object({
'broker_url': celery_redis_url,
'result_backend': celery_redis_url,
'task_serializer': 'json',
'accept_content': ['json'],
'result_serializer': 'json',
'timezone': 'Africa/Nairobi',
'enable_utc': True,
'task_track_started': True,
'task_time_limit': 600,
'worker_prefetch_multiplier': 1,
'beat_schedule': {
'run-scraper-daily': {
'task': 'app.scraper.workers.pdf_processor.run_scraper',
'schedule': 86400.0, # Run daily (every 24 hours)
'args': (None,),
},
},
})
logger = logging.getLogger(__name__)
redis_client = redis.Redis.from_url(settings.redis_url, decode_responses=True)
STREAMS = {
'pdf_parse': 'stream:pdf:parse',
'embed': 'stream:chunk:embed',
'graph': 'stream:graph:build',
}
@app.task(bind=True, max_retries=3)
def process_pdf(self, report_id, minio_path, s3_url, metadata):
try:
logger.info(f"Processing PDF: {report_id}")
pdf_data = download_from_minio(minio_path)
if not pdf_data:
raise Exception("Failed to download PDF")
text_content = extract_text_from_pdf(pdf_data, report_id)
chunks = chunk_document(text_content, metadata)
# Enrich chunks with entities
entities = extract_entities(text_content)
# Add basic entity mapping to chunks (can associate entities per chunk text)
for chunk in chunks:
chunk_entities = []
for ent in entities:
if ent['text'] in chunk['text']:
chunk_entities.append(ent['text'])
chunk['entities'] = list(set(chunk_entities))
embeddings = generate_embeddings(chunks)
# Save chunks in Qdrant (vector index)
store_in_qdrant(report_id, chunks, embeddings, metadata)
# Save chunks in PostgreSQL (FTS index & metadata database)
store_in_postgresql_chunks(report_id, chunks, metadata)
# Save optional Graph relationships
store_in_neo4j(report_id, entities, metadata, chunks)
# Update PostgreSQL Document status to ready
update_postgresql_status(report_id, 'embedded', len(chunks))
return {'status': 'success', 'chunks': len(chunks)}
except Exception as exc:
logger.error(f"PDF processing failed: {exc}")
update_postgresql_status(report_id, 'failed', error=str(exc))
raise self.retry(exc=exc, countdown=60)
def download_from_minio(minio_path):
client = Minio(
os.getenv('MINIO_ENDPOINT', 'localhost:9000'),
access_key=os.getenv('MINIO_ACCESS_KEY', 'minioadmin'),
secret_key=os.getenv('MINIO_SECRET_KEY', 'minioadmin'),
secure=os.getenv('MINIO_SECURE', 'false').lower() == 'true',
)
bucket = os.getenv('MINIO_BUCKET', 'oag-raw-pdfs')
try:
response = client.get_object(bucket, minio_path)
return response.read()
except Exception as e:
logger.error(f"MinIO download failed: {e}")
return None
def extract_text_from_pdf(pdf_data, report_id):
import pdfplumber
import io
text_parts = []
try:
with pdfplumber.open(io.BytesIO(pdf_data)) as pdf:
for i, page in enumerate(pdf.pages):
text = page.extract_text()
if text:
text_parts.append(f"--- Page {i+1} ---\n{text}")
except Exception as e:
logger.error(f"pdfplumber failed: {e}")
full_text = '\n'.join(text_parts)
if len(full_text) < 1000:
logger.warning(f"Low extraction, trying OCR for {report_id}")
full_text = ocr_pdf(pdf_data)
return full_text
def ocr_pdf(pdf_data):
try:
import pytesseract
from pdf2image import convert_from_bytes
images = convert_from_bytes(pdf_data)
text_parts = []
for i, image in enumerate(images):
text = pytesseract.image_to_string(image)
text_parts.append(f"--- Page {i+1} ---\n{text}")
return '\n'.join(text_parts)
except Exception as e:
logger.error(f"OCR failed: {e}")
return ""
def chunk_document(text, metadata):
import re
chunks = []
pages = re.split(r'--- Page (\d+) ---', text)
current_page = 0
for i, segment in enumerate(pages):
if segment.isdigit():
current_page = int(segment)
continue
paragraphs = [p.strip() for p in segment.split('\n\n') if p.strip()]
for para in paragraphs:
chunk_type = classify_chunk(para)
chunk = {
'chunk_id': hashlib.md5(f"{metadata['report_id']}:{current_page}:{para[:50]}".encode()).hexdigest(),
'report_id': metadata['report_id'],
'text': para,
'chunk_type': chunk_type,
'page': current_page,
'fiscal_year': metadata.get('fiscal_year'),
'auditee': metadata.get('auditee'),
'report_type': metadata.get('report_type'),
}
chunks.append(chunk)
return chunks
def classify_chunk(text):
text_lower = text.lower()
if any(w in text_lower for w in ['ksh', 'kes', 'million', 'billion', 'budget', 'expenditure']):
if any(w in text_lower for w in ['table', 'vote', 'head', 'item']):
return 'table'
if any(w in text_lower for w in ['finding', 'observed', 'noted that', 'audit revealed']):
return 'finding'
if any(w in text_lower for w in ['recommend', 'recommended', 'should', 'ought to']):
return 'recommendation'
if any(w in text_lower for w in ['constitution', 'article', 'section', 'public audit act', 'pfma']):
return 'legal_ref'
return 'narrative'
def extract_entities(text):
try:
import spacy
nlp = spacy.load('en_core_web_sm')
doc = nlp(text[:100000])
return [{'text': ent.text, 'label': ent.label_, 'start': ent.start_char, 'end': ent.end_char} for ent in doc.ents]
except Exception as e:
logger.error(f"NER failed: {e}")
return []
def generate_embeddings(chunks):
try:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('BAAI/bge-m3')
texts = [chunk['text'] for chunk in chunks]
embeddings = model.encode(texts, batch_size=32, show_progress_bar=False)
return embeddings.tolist()
except Exception as e:
logger.error(f"Embedding failed: {e}")
return []
def store_in_qdrant(report_id, chunks, embeddings, metadata):
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct, VectorParams, Distance
from app.config import get_settings
settings = get_settings()
if settings.qdrant_api_key:
client = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key, timeout=30)
else:
client = QdrantClient(url=settings.qdrant_url, timeout=30)
collection_name = settings.qdrant_collection_name
try:
client.create_collection(collection_name=collection_name, vectors_config=VectorParams(size=1024, distance=Distance.COSINE))
except Exception:
pass
points = []
for chunk, embedding in zip(chunks, embeddings):
# Classify chunk type to match the SQL enum value exactly
chunk_type = chunk['chunk_type']
if chunk_type == 'legal_ref':
chunk_type = 'legal_reference'
import uuid
try:
chunk_uuid = str(uuid.UUID(hex=chunk['chunk_id']))
except ValueError:
chunk_uuid = str(uuid.uuid4())
points.append(PointStruct(
id=chunk_uuid,
vector=embedding,
payload={
'document_id': report_id,
'content': chunk['text'],
'chunk_type': chunk_type,
'page_range_start': chunk['page'],
'page_range_end': chunk['page'],
'fiscal_year': metadata.get('fiscal_year'),
'audit_period': metadata.get('audit_period'),
'auditee': metadata.get('auditee'),
'report_type': metadata.get('report_type'),
'entities': chunk.get('entities', []),
}
))
client.upsert(collection_name=collection_name, points=points)
logger.info(f"Stored {len(points)} vectors in Qdrant")
def store_in_postgresql_chunks(report_id, chunks, metadata):
from app.config import get_settings
db_url = get_settings().database_url
if db_url.startswith("postgresql+asyncpg://"):
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
elif db_url.startswith("postgresql+async://"):
db_url = db_url.replace("postgresql+async://", "postgresql://")
import psycopg2
from psycopg2.extras import execute_values, Json
import uuid
conn = psycopg2.connect(db_url)
try:
with conn.cursor() as cursor:
# First, clean existing chunks for this report to maintain idempotency
cursor.execute("DELETE FROM chunks WHERE document_id = %s", (report_id,))
rows = []
for idx, chunk in enumerate(chunks):
# Classify chunk type to match the SQL enum value exactly
chunk_type = chunk['chunk_type']
if chunk_type == 'legal_ref':
chunk_type = 'legal_reference'
try:
chunk_uuid = str(uuid.UUID(hex=chunk['chunk_id']))
except ValueError:
chunk_uuid = str(uuid.uuid4())
token_count = len(chunk['text'].split())
rows.append((
chunk_uuid,
report_id,
chunk_type,
chunk['text'],
chunk['page'],
chunk['page'],
chunk.get('section_heading', ''),
Json(chunk.get('entities', [])),
'en',
metadata.get('audit_period', ''),
metadata.get('auditee', ''),
token_count,
idx,
datetime.utcnow()
))
insert_query = """
INSERT INTO chunks (
id, document_id, chunk_type, content, page_range_start, page_range_end,
section_heading, entities, language, audit_period, auditee, token_count, chunk_index, created_at
) VALUES %s
"""
execute_values(cursor, insert_query, rows)
conn.commit()
logger.info(f"Stored {len(chunks)} chunks in PostgreSQL chunks table.")
except Exception as e:
logger.error(f"Failed to store chunks in PostgreSQL: {e}")
conn.rollback()
raise e
finally:
conn.close()
def store_in_neo4j(report_id, entities, metadata, chunks):
from neo4j import GraphDatabase
uri = os.getenv('NEO4J_URI', 'bolt://localhost:7687')
user = os.getenv('NEO4J_USER', 'neo4j')
password = os.getenv('NEO4J_PASSWORD', 'password')
try:
driver = GraphDatabase.driver(uri, auth=(user, password))
with driver.session() as session:
session.run("MERGE (r:AuditReport {report_id: $rid}) SET r.title = $title, r.fiscal_year = $fy, r.report_type = $type, r.source_url = $url",
rid=report_id, title=metadata.get('title', ''), fy=metadata.get('fiscal_year', ''), type=metadata.get('report_type', ''), url=metadata.get('source_url', ''))
if metadata.get('auditee'):
session.run("MERGE (a:Auditee {name: $auditee}) MERGE (r:AuditReport {report_id: $rid}) MERGE (a)<-[:AUDITS]-(r)",
auditee=metadata['auditee'], rid=report_id)
for chunk in chunks:
if chunk['chunk_type'] == 'finding':
session.run("MERGE (f:Finding {finding_id: $cid}) SET f.description = $text, f.page = $page MERGE (r:AuditReport {report_id: $rid}) MERGE (r)-[:CONTAINS]->(f)",
cid=chunk['chunk_id'], text=chunk['text'][:500], page=chunk['page'], rid=report_id)
driver.close()
logger.info(f"Stored graph data for {report_id}")
except Exception as e:
logger.warning(f"Neo4j store failed (optional graph step): {e}")
def update_postgresql_status(report_id, status, chunks_count=0, error=None):
from app.config import get_settings
db_url = get_settings().database_url
if db_url.startswith("postgresql+asyncpg://"):
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
elif db_url.startswith("postgresql+async://"):
db_url = db_url.replace("postgresql+async://", "postgresql://")
import psycopg2
conn = psycopg2.connect(db_url)
# Map embedded status to ready matching database enums
db_status = 'ready' if status == 'embedded' else status
try:
with conn.cursor() as cursor:
cursor.execute(
"UPDATE documents SET status = %s, page_count = %s, error_message = %s, updated_at = NOW() WHERE id = %s",
(db_status, chunks_count, error, report_id)
)
conn.commit()
except Exception as e:
logger.error(f"Failed to update document status in Postgres: {e}")
conn.rollback()
finally:
conn.close()
@app.task
def consume_pdf_stream():
while True:
try:
messages = redis_client.xreadgroup(groupname='workers', consumername='pdf-worker-1', streams={STREAMS['pdf_parse']: '>'}, count=1, block=5000)
if not messages:
continue
for stream_name, entries in messages:
for entry_id, fields in entries:
payload = json.loads(fields.get('payload', '{}'))
process_pdf.delay(
report_id=payload.get('report_id'),
minio_path=payload.get('minio_path', ''),
s3_url=payload.get('s3_url', ''),
metadata=payload,
)
redis_client.xack(stream_name, 'workers', entry_id)
logger.info(f"Queued task: {payload.get('report_id')}")
except Exception as e:
logger.error(f"Stream consumer error: {e}")
time.sleep(5)
@app.task
def run_scraper(max_pages=None):
import subprocess
from pathlib import Path
# Path to app/scraper directory
scraper_dir = Path(__file__).resolve().parent.parent / "scraper"
if not scraper_dir.exists():
# Fallback to local workspace layout
scraper_dir = Path(__file__).resolve().parent.parent.parent / "app" / "scraper"
cmd = ["scrapy", "crawl", "oag_kenya"]
if max_pages is not None:
cmd.extend(["-a", f"max_pages={max_pages}"])
logger.info(f"Triggering Scrapy crawler: {' '.join(cmd)}")
# Execute scraper as subprocess in its directory
process = subprocess.Popen(
cmd,
cwd=str(scraper_dir),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
stdout, stderr = process.communicate()
logger.info(f"Scraper finished with exit code {process.returncode}")
if process.returncode != 0:
logger.error(f"Scraper error: {stderr}")
return {"status": "success", "returncode": process.returncode}