LAD / modules /simple_db.py
ozeiri's picture
Deploy LAD as a Docker Space
857664f
Raw
History Blame Contribute Delete
28.9 kB
"""
Simple database client for artifact caching
"""
import os
import json
import hashlib
import logging
from typing import List, Dict, Optional
from datetime import datetime
from dotenv import load_dotenv
from pathlib import Path
# Conditional streamlit import to avoid issues when not available
try:
import streamlit as st
STREAMLIT_AVAILABLE = True
except ImportError:
st = None
STREAMLIT_AVAILABLE = False
# Load configuration using the configuration manager
try:
from .config_manager import load_configuration
load_configuration()
print("✅ Configuration loaded in simple_db.py")
except Exception as e:
print(f"⚠️ Error loading configuration in simple_db.py: {e}")
# Fallback to manual loading
project_root = Path(__file__).parent.parent
env_path = project_root / ".env"
load_dotenv(env_path, override=True)
logger = logging.getLogger(__name__)
class SimpleArtifactDB:
"""Simple database client for artifact storage and caching"""
def __init__(self):
self.supabase_client = None
self.enabled = False
# Try to initialize Supabase
try:
url = os.getenv("SUPABASE_URL")
key = os.getenv("SUPABASE_ANON_KEY")
enable = os.getenv("ENABLE_SUPABASE", "false").lower()
logger.info(f"🔍 Environment check - URL: {'SET' if url else 'NOT SET'}, "
f"KEY: {'SET' if key else 'NOT SET'}, "
f"ENABLE: {enable}")
if url and key and enable == "true":
from supabase import create_client
self.supabase_client = create_client(url, key)
self.enabled = True
logger.info("✅ Simple database client initialized")
else:
logger.info(f"📝 Database disabled - URL: {bool(url)}, KEY: {bool(key)}, ENABLE: {enable}")
except Exception as e:
logger.warning(f"⚠️ Database initialization failed: {e}")
self.enabled = False
def _hash_file(self, file_path: str) -> str:
"""Generate SHA-256 hash of file content"""
try:
hasher = hashlib.sha256()
with open(file_path, 'rb') as f:
for chunk in iter(lambda: f.read(4096), b""):
hasher.update(chunk)
return hasher.hexdigest()
except Exception as e:
logger.error(f"Error hashing file {file_path}: {e}")
return ""
def _create_content_fingerprint(self, file_path: str) -> str:
"""Create a fast content fingerprint using file start + end + size
This is much faster than full file hashing and sufficient for cache identification.
Combines file size, first 1KB, and last 1KB of content.
"""
try:
if not os.path.exists(file_path):
logger.warning(f"File does not exist for fingerprinting: {file_path}")
return ""
file_size = os.path.getsize(file_path)
with open(file_path, 'rb') as f:
# Read first 1KB
start_chunk = f.read(1024)
# Read last 1KB if file is large enough
if file_size > 2048:
f.seek(-1024, 2) # Seek to 1KB from end
end_chunk = f.read(1024)
else:
end_chunk = b""
# Combine: file_size + start_chunk + end_chunk
fingerprint_data = f"{file_size}".encode() + start_chunk + end_chunk
fingerprint = hashlib.sha256(fingerprint_data).hexdigest()
logger.debug(f"Created content fingerprint for {os.path.basename(file_path)}: {fingerprint[:16]}... (size: {file_size})")
return fingerprint
except Exception as e:
logger.error(f"Error creating content fingerprint for {file_path}: {e}")
return ""
def check_page_level_cache(self, doc_group: dict, start_page: int, end_page,
ocr_model: str, extraction_model: str, thresholds: dict) -> tuple:
"""
Check cache for each page in the requested range
Returns:
tuple: (cached_artifacts, missing_pages, cache_stats)
"""
# Handle None end_page by setting to a large number (will be handled by actual processing)
if end_page is None:
end_page = 9999 # Large number to indicate "till end"
if not self.enabled:
return [], list(range(start_page, end_page + 1)), {"cached_pages": 0, "missing_pages": end_page - start_page + 1}
# Validate inputs
if not doc_group or not doc_group.get("EN"):
logger.error("Invalid doc_group - missing English document")
return [], list(range(start_page, end_page + 1)), {"cached_pages": 0, "missing_pages": end_page - start_page + 1}
if start_page > end_page:
logger.error(f"Invalid page range: {start_page} > {end_page}")
return [], [], {"cached_pages": 0, "missing_pages": 0}
try:
# FINAL SAFETY CHECK: Handle None end_page even if it somehow got through
if end_page is None:
logger.warning(f"🚨 EMERGENCY: end_page is None in cache function, converting to 9999")
end_page = 9999
requested_pages = list(range(start_page, end_page + 1))
cached_artifacts = []
missing_pages = []
# Create processing params hash
processing_params_hash = hashlib.sha256(
json.dumps(thresholds, sort_keys=True).encode()
).hexdigest()
logger.info(f"🔍 Checking cache for pages {start_page}-{end_page}")
logger.info(f"🔍 Cache check parameters: OCR={ocr_model}, Extract={extraction_model}")
for page_num in requested_pages:
# Create page cache key
page_cache_key = self._create_page_cache_key(
doc_group, page_num, ocr_model, extraction_model, thresholds
)
# Check if this page exists in cache
try:
result = self.supabase_client.rpc("check_page_cache", {
"p_page_cache_key": page_cache_key
}).execute()
except Exception as rpc_error:
logger.warning(f"⚠️ RPC call failed for page {page_num}, falling back to direct query: {rpc_error}")
# Fallback to direct table query
result = self.supabase_client.table("artifacts").select("*").eq(
"page_cache_key", page_cache_key
).execute()
if result.data:
# Convert database format back to original format
page_artifacts = []
for record in result.data:
artifact = {
"Name_EN": record.get("name_en", ""),
"Name_AR": record.get("name_ar", ""),
"Name_FR": record.get("name_fr", ""),
"Creator": record.get("creator", ""),
"Creation Date": record.get("creation_date", ""),
"Materials": record.get("materials", ""),
"Origin": record.get("origin", ""),
"Description": record.get("description", ""),
"Category": record.get("category", ""),
"source_page": record.get("source_page", page_num),
"source_document": record.get("source_document", ""),
"Name_validation": record.get("name_validation", "")
}
page_artifacts.append(artifact)
cached_artifacts.extend(page_artifacts)
logger.info(f"✅ Cache HIT - Page {page_num}: Found {len(page_artifacts)} cached artifacts")
else:
missing_pages.append(page_num)
logger.info(f"❌ Cache MISS - Page {page_num}: Needs processing")
cache_stats = {
"cached_pages": len(requested_pages) - len(missing_pages),
"missing_pages": len(missing_pages),
"total_cached_artifacts": len(cached_artifacts)
}
logger.info(f"📊 Cache Summary: {cache_stats['cached_pages']} cached, {cache_stats['missing_pages']} need processing")
return cached_artifacts, missing_pages, cache_stats
except Exception as e:
logger.error(f"Error checking page-level cache: {e}")
# Fallback: treat all pages as missing
# Handle None end_page in fallback as well
if end_page is None:
end_page = 9999
return [], list(range(start_page, end_page + 1)), {"cached_pages": 0, "missing_pages": end_page - start_page + 1}
def _create_page_cache_key(self, doc_group: dict, page_num: int, ocr_model: str,
extraction_model: str, thresholds: dict) -> str:
"""Create unique cache key using content fingerprints instead of file paths"""
content_hashes = {}
for lang, file_path in doc_group.items():
if file_path and os.path.exists(file_path):
# Use content fingerprint for fast, reliable identification
content_hashes[lang] = self._create_content_fingerprint(file_path)
else:
# Fallback to original filename from session state
try:
if STREAMLIT_AVAILABLE and st and hasattr(st, 'session_state') and hasattr(st.session_state, 'uploaded_file_names'):
original_name = st.session_state.uploaded_file_names.get(lang, "")
if original_name:
content_hashes[lang] = hashlib.sha256(original_name.encode()).hexdigest()[:16]
else:
content_hashes[lang] = "missing"
else:
content_hashes[lang] = "missing"
except Exception:
content_hashes[lang] = "missing"
# Create parameter combination
params = {
'content_hashes': content_hashes,
'page': page_num,
'ocr_model': ocr_model,
'extraction_model': extraction_model,
'thresholds': json.dumps(thresholds, sort_keys=True)
}
# Generate SHA-256 hash
params_str = json.dumps(params, sort_keys=True)
cache_key = hashlib.sha256(params_str.encode()).hexdigest()
return cache_key
def _create_run_cache_key(self, doc_group: dict, start_page: int, end_page: int,
ocr_model: str, extraction_model: str, thresholds: dict) -> str:
"""Create unique cache key for an entire processing run using content"""
en_file = doc_group.get("EN")
if en_file and os.path.exists(en_file):
# Use content fingerprint for reliable identification
content_hash = self._create_content_fingerprint(en_file)
logger.debug(f"Using content fingerprint for run cache: {content_hash[:16]}...")
else:
# Fallback to original filename from session state
try:
if STREAMLIT_AVAILABLE and st and hasattr(st, 'session_state') and hasattr(st.session_state, 'uploaded_file_names'):
original_name = st.session_state.uploaded_file_names.get("EN", "")
if original_name:
content_hash = hashlib.sha256(original_name.encode()).hexdigest()
logger.debug(f"Using filename fallback for run cache: {original_name}")
else:
content_hash = "unknown_content"
logger.warning("No English file or filename available for run cache")
else:
content_hash = "unknown_content"
logger.debug("Session state not available for run cache, using 'unknown_content'")
except Exception as e:
logger.warning(f"Error accessing session state for run cache: {e}")
content_hash = "unknown_content"
params = {
'content_hash': content_hash,
'start_page': start_page,
'end_page': end_page,
'ocr_model': ocr_model,
'extraction_model': extraction_model,
'thresholds': json.dumps(thresholds, sort_keys=True)
}
params_str = json.dumps(params, sort_keys=True)
run_cache_key = hashlib.sha256(params_str.encode()).hexdigest()
logger.debug(f"🔍 Run cache key generated: {run_cache_key[:16]}...")
return run_cache_key
def _map_artifact_to_new_schema(self, artifact: dict, page_cache_key: str,
page_num: int, ocr_model: str, extraction_model: str,
processing_params_hash: str, actual_source_document: str = None) -> dict:
"""Map artifact fields from old format to new schema with cache keys"""
# Use actual source document name if provided, otherwise fall back to artifact data
source_doc = actual_source_document if actual_source_document else artifact.get("source_document", "")
return {
"page_cache_key": page_cache_key,
"page_number": page_num,
"name_en": artifact.get("Name_EN", artifact.get("Name", "")),
"name_ar": artifact.get("Name_AR", ""),
"name_fr": artifact.get("Name_FR", ""),
"creator": artifact.get("Creator", ""),
"creation_date": artifact.get("Creation Date", ""),
"materials": artifact.get("Materials", ""),
"origin": artifact.get("Origin", ""),
"description": artifact.get("Description", ""),
"category": artifact.get("Category", ""),
"source_page": artifact.get("source_page", page_num),
"source_document": source_doc,
"name_validation": artifact.get("Name_validation", ""),
"ocr_model": ocr_model,
"extraction_model": extraction_model,
"processing_params_hash": processing_params_hash
}
def save_page_artifacts(self, doc_group: dict, page_num: int, artifacts: List[Dict],
ocr_model: str, extraction_model: str, thresholds: dict,
provided_file_hash: str = None) -> bool:
"""Save artifacts from a specific page with cache key"""
if not self.enabled or not artifacts:
return False
# Debug: Log the models being saved
logger.info(f"💾 DB Save - OCR model: '{ocr_model}', Extraction model: '{extraction_model}'")
try:
# Get main file hash - use provided hash if available, otherwise create content fingerprint
if provided_file_hash:
file_hash = provided_file_hash
logger.info(f"💾 Using provided file hash: {file_hash[:16]}...")
else:
en_file = doc_group.get("EN", "")
if en_file and os.path.exists(en_file):
# Use content fingerprint for reliable, fast identification
file_hash = self._create_content_fingerprint(en_file)
logger.info(f"💾 Created content fingerprint: {file_hash[:16]}...")
else:
# Content-based fallback using original filename from session state
try:
if STREAMLIT_AVAILABLE and st and hasattr(st, 'session_state') and hasattr(st.session_state, 'uploaded_file_names'):
original_name = st.session_state.uploaded_file_names.get("EN", "")
if original_name:
file_hash = hashlib.sha256(original_name.encode()).hexdigest()
logger.info(f"💾 Using filename-based hash: {file_hash[:16]}... (from: {original_name})")
else:
logger.error("💾 Cannot create file hash - no file or filename available")
return False
else:
logger.error("💾 Cannot create file hash - session state not available and no file exists")
return False
except Exception as e:
logger.error(f"💾 Error accessing session state for file hash: {e}")
return False
if not file_hash:
logger.error("💾 Cannot create file hash for page artifacts")
return False
# Create cache keys
page_cache_key = self._create_page_cache_key(
doc_group, page_num, ocr_model, extraction_model, thresholds
)
processing_params_hash = hashlib.sha256(
json.dumps(thresholds, sort_keys=True).encode()
).hexdigest()
# Check if this page is already cached
existing_check = self.supabase_client.table("artifacts").select("id").eq(
"page_cache_key", page_cache_key
).limit(1).execute()
if existing_check.data:
logger.info(f"Page {page_num} already cached, skipping save")
return True
# Map and save each artifact
saved_count = 0
for artifact in artifacts:
# Use the source_document from the artifact itself (which is already correct in the UI)
artifact_source_document = artifact.get("source_document", "")
mapped_artifact = self._map_artifact_to_new_schema(
artifact, page_cache_key, page_num, ocr_model,
extraction_model, processing_params_hash, artifact_source_document
)
# Add file hash
mapped_artifact["file_hash"] = file_hash
try:
self.supabase_client.table("artifacts").insert(mapped_artifact).execute()
saved_count += 1
except Exception as e:
logger.error(f"Error saving individual artifact: {e}")
continue
logger.info(f"💾 Saved {saved_count} artifacts for page {page_num}")
return saved_count > 0
except Exception as e:
logger.error(f"Error saving page artifacts: {e}")
return False
def save_run_statistics(self, doc_group: dict, start_page: int, end_page,
ocr_model: str, extraction_model: str, thresholds: dict,
total_artifacts: int, cached_pages: int, processed_pages: int,
provided_file_hash: str = None) -> bool:
"""Save statistics for a complete processing run"""
if not self.enabled:
return False
# Handle None end_page
if end_page is None:
end_page = 9999
try:
# Get main file hash - use provided hash if available, otherwise try to hash the file
if provided_file_hash:
file_hash = provided_file_hash
logger.info(f"📊 Using provided file hash: {file_hash[:16]}...")
else:
en_file = doc_group.get("EN", "")
file_hash = self._hash_file(en_file) if en_file and os.path.exists(en_file) else ""
if not file_hash:
logger.warning(f"⚠️ Could not generate file hash for {en_file}")
# Create run cache key
run_cache_key = self._create_run_cache_key(
doc_group, start_page, end_page, ocr_model, extraction_model, thresholds
)
processing_params_hash = hashlib.sha256(
json.dumps(thresholds, sort_keys=True).encode()
).hexdigest()
# Save run statistics
run_record = {
"run_cache_key": run_cache_key,
"file_hash": file_hash,
"start_page": start_page,
"end_page": end_page,
"ocr_model": ocr_model,
"extraction_model": extraction_model,
"processing_params_hash": processing_params_hash,
"total_artifacts": total_artifacts,
"cached_pages": cached_pages,
"processed_pages": processed_pages,
"processing_status": "completed"
}
# Use upsert to avoid duplicates
result = self.supabase_client.table("processing_cache").upsert(run_record).execute()
logger.info(f"📊 Saved run statistics: {total_artifacts} artifacts, {cached_pages} cached pages, {processed_pages} processed pages")
return True
except Exception as e:
logger.error(f"Error saving run statistics: {e}")
return False
def save_artifacts_from_data(self, file_hash: str, artifacts_data) -> bool:
"""Legacy method for backward compatibility - converts to page-based saving"""
if not self.enabled:
logger.warning("Database not enabled - cannot save artifacts")
return False
try:
# Handle different data structures
artifacts = []
if isinstance(artifacts_data, list):
artifacts = artifacts_data
logger.info(f"Received artifacts as list with {len(artifacts)} items")
elif isinstance(artifacts_data, dict):
artifacts = artifacts_data.get('artifacts', [])
logger.info(f"Received artifacts as dict, extracted {len(artifacts)} artifacts")
if not artifacts and artifacts_data:
artifacts = [artifacts_data]
logger.info("Treating single dict as one artifact")
else:
logger.error(f"Unexpected data type: {type(artifacts_data)}")
return False
if not artifacts:
logger.warning(f"No artifacts found in data")
return False
# Group artifacts by page for proper caching
artifacts_by_page = {}
for artifact in artifacts:
page_num = artifact.get("source_page", 1)
if page_num not in artifacts_by_page:
artifacts_by_page[page_num] = []
artifacts_by_page[page_num].append(artifact)
# Save each page separately (using default parameters for legacy data)
doc_group = {"EN": f"legacy_file_{file_hash[:8]}.pdf"}
default_model = "gpt-4o"
default_thresholds = {"EN": 0.05, "AR": 0.10, "FR": 0.07}
total_saved = 0
for page_num, page_artifacts in artifacts_by_page.items():
success = self.save_page_artifacts(
doc_group, page_num, page_artifacts,
default_model, default_model, default_thresholds
)
if success:
total_saved += len(page_artifacts)
logger.info(f"💾 Successfully saved {total_saved} artifacts across {len(artifacts_by_page)} pages")
return total_saved > 0
except Exception as e:
logger.error(f"Error saving artifacts from data: {e}")
return False
def search_artifacts(self, query: str = None, limit: int = 100) -> List[Dict]:
"""Search artifacts using the new schema"""
if not self.enabled:
return []
try:
# Use the database function for search
if query:
result = self.supabase_client.rpc("search_artifacts", {
"search_term": query,
"limit_count": limit
}).execute()
else:
result = self.supabase_client.table("artifacts").select("*").order(
"created_at", desc=True
).limit(limit).execute()
# Convert back to original format for compatibility
artifacts = []
for record in result.data:
artifact = {
"Name_EN": record.get("name_en", ""),
"Name_AR": record.get("name_ar", ""),
"Name_FR": record.get("name_fr", ""),
"Creator": record.get("creator", ""),
"Creation Date": record.get("creation_date", ""),
"Materials": record.get("materials", ""),
"Origin": record.get("origin", ""),
"Description": record.get("description", ""),
"Category": record.get("category", ""),
"source_page": record.get("source_page", record.get("page_number", "")),
"source_document": record.get("source_document", ""),
"Name_validation": record.get("name_validation", "")
}
artifacts.append(artifact)
logger.info(f"🔍 Found {len(artifacts)} artifacts matching query")
return artifacts
except Exception as e:
logger.error(f"Error searching artifacts: {e}")
return []
def get_stats(self) -> Dict:
"""Get database statistics using the new schema"""
if not self.enabled:
return {"enabled": False}
try:
# Use the enhanced statistics function
try:
stats_result = self.supabase_client.rpc("get_artifact_statistics").execute()
except Exception as rpc_error:
logger.warning(f"RPC call for statistics failed, falling back to simple counting: {rpc_error}")
stats_result = None
if stats_result.data:
stats = stats_result.data[0]
return {
"enabled": True,
"total_artifacts": stats.get("total_artifacts", 0),
"unique_runs": stats.get("unique_runs", 0),
"unique_pages": stats.get("unique_pages", 0),
"categories_count": stats.get("categories_count", 0),
"creators_count": stats.get("creators_count", 0),
"origins_count": stats.get("origins_count", 0),
"unique_documents_count": stats.get("unique_documents_count", 0),
"last_updated": datetime.now().isoformat()
}
else:
# Fallback to simple counting
artifacts_count = self.supabase_client.table("artifacts").select(
"*", count="exact"
).execute()
runs_count = self.supabase_client.table("processing_cache").select(
"run_cache_key", count="exact"
).execute()
return {
"enabled": True,
"total_artifacts": artifacts_count.count,
"unique_runs": runs_count.count,
"last_updated": datetime.now().isoformat()
}
except Exception as e:
logger.error(f"Error getting stats: {e}")
return {"enabled": True, "error": str(e)}
# Global instance
_db_instance = None
def get_simple_db() -> SimpleArtifactDB:
"""Get the global database instance"""
global _db_instance
if _db_instance is None:
_db_instance = SimpleArtifactDB()
return _db_instance