Benard John
feat: implement full-stack architecture with database models, authentication services, and web dashboard components
37b5223 | """ | |
| 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', | |
| } | |
| 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() | |
| 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) | |
| 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} | |