| import asyncio |
| from datetime import datetime |
| import logging |
| import os |
| import sqlite3 |
| from typing import Any, Dict, List, Optional, Tuple |
| import uuid |
|
|
| try: |
| from backend.core.lancedb_handler import LanceDBHandler |
| except ImportError: |
| |
| from core.lancedb_handler import LanceDBHandler |
|
|
| |
| try: |
| from backend.core.byok_endpoints import get_byok_manager |
|
|
| BYOK_AVAILABLE = True |
| except ImportError: |
| BYOK_AVAILABLE = False |
| get_byok_manager = None |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class PDFMemoryIntegration: |
| """ |
| Integration service for storing processed PDF content in Atom's memory system. |
| Handles vector storage, metadata management, and semantic search for PDF documents. |
| """ |
|
|
| def __init__( |
| self, lancedb_handler: Optional[LanceDBHandler] = None, use_byok: bool = True |
| ): |
| """ |
| Initialize PDF memory integration. |
| |
| Args: |
| lancedb_handler: LanceDB handler for vector storage |
| use_byok: Whether to use BYOK system for AI provider management |
| """ |
| self.lancedb_handler = lancedb_handler |
| self.table_name = "pdf_documents" |
| self.use_byok = use_byok and BYOK_AVAILABLE |
|
|
| |
| self.byok_manager = None |
| if self.use_byok: |
| try: |
| self.byok_manager = get_byok_manager() |
| logger.info("BYOK system initialized for PDF memory integration") |
| except Exception as e: |
| logger.warning(f"Failed to initialize BYOK system: {e}") |
| self.use_byok = False |
|
|
| |
| if self.lancedb_handler: |
| self._initialize_memory_tables() |
|
|
| |
| self._init_simple_db() |
|
|
| def _initialize_memory_tables(self): |
| """Initialize required tables in LanceDB for PDF storage.""" |
| try: |
| if self.table_name not in self.lancedb_handler.list_tables(): |
| schema = { |
| "doc_id": "string", |
| "user_id": "string", |
| "filename": "string", |
| "file_size": "int64", |
| "page_count": "int64", |
| "total_chars": "int64", |
| "processing_method": "string", |
| "pdf_type": "string", |
| "extracted_text": "string", |
| "embedding": "vector(768)", |
| "metadata": "string", |
| "created_at": "timestamp", |
| "updated_at": "timestamp", |
| "source_uri": "string", |
| "tags": "list<string>", |
| } |
| self.lancedb_handler.create_table(self.table_name, schema) |
| logger.info(f"Created PDF memory table: {self.table_name}") |
| else: |
| logger.info(f"PDF memory table already exists: {self.table_name}") |
| except Exception as e: |
| logger.warning(f"Failed to initialize PDF memory tables: {e}") |
|
|
| def _init_simple_db(self): |
| """Initialize SQLite database for fallback storage""" |
| try: |
| |
| backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| self._simple_db_path = os.path.join(backend_dir, "data", "pdf_simple.db") |
| os.makedirs(os.path.dirname(self._simple_db_path), exist_ok=True) |
|
|
| conn = sqlite3.connect(self._simple_db_path) |
| cursor = conn.cursor() |
|
|
| |
| cursor.execute(""" |
| CREATE TABLE IF NOT EXISTS pdf_documents ( |
| doc_id TEXT PRIMARY KEY, |
| user_id TEXT NOT NULL, |
| filename TEXT, |
| page_count INTEGER, |
| total_chars INTEGER, |
| pdf_type TEXT, |
| processing_method TEXT, |
| extracted_text TEXT, |
| created_at TEXT, |
| source_uri TEXT, |
| tags TEXT |
| ) |
| """) |
|
|
| |
| try: |
| cursor.execute("ALTER TABLE pdf_documents ADD COLUMN tags TEXT") |
| logger.info("Added tags column to existing pdf_documents table") |
| except sqlite3.OperationalError: |
| |
| pass |
|
|
| |
| cursor.execute(""" |
| CREATE INDEX IF NOT EXISTS idx_pdf_documents_tags |
| ON pdf_documents(tags) |
| """) |
|
|
| |
| cursor.execute(""" |
| CREATE VIRTUAL TABLE IF NOT EXISTS pdf_documents_fts |
| USING fts5(doc_id, extracted_text, content='pdf_documents', content_rowid='rowid') |
| """) |
|
|
| |
| cursor.execute(""" |
| CREATE TRIGGER IF NOT EXISTS pdf_documents_ai |
| AFTER INSERT ON pdf_documents BEGIN |
| INSERT INTO pdf_documents_fts(rowid, doc_id, extracted_text) |
| VALUES (new.rowid, new.doc_id, new.extracted_text); |
| END |
| """) |
|
|
| cursor.execute(""" |
| CREATE TRIGGER IF NOT EXISTS pdf_documents_ad |
| AFTER DELETE ON pdf_documents BEGIN |
| INSERT INTO pdf_documents_fts(pdf_documents_fts, doc_id, extracted_text) |
| VALUES ('delete', old.doc_id, old.extracted_text); |
| END |
| """) |
|
|
| conn.commit() |
| conn.close() |
| logger.info(f"SQLite fallback storage initialized at {self._simple_db_path}") |
| except Exception as e: |
| logger.warning(f"Failed to initialize SQLite fallback storage: {e}") |
| self._simple_db_path = None |
|
|
| async def store_processed_pdf( |
| self, |
| user_id: str, |
| processing_result: Dict[str, Any], |
| source_uri: Optional[str] = None, |
| tags: Optional[List[str]] = None, |
| metadata: Optional[Dict[str, Any]] = None, |
| ) -> Dict[str, Any]: |
| """ |
| Store processed PDF content in memory system. |
| |
| Args: |
| user_id: User identifier |
| processing_result: Output from PDF processing service |
| source_uri: Source URI of the PDF (file path, URL, etc.) |
| tags: Optional tags for categorization |
| metadata: Additional metadata |
| |
| Returns: |
| Storage result with success status and document info |
| """ |
|
|
| |
| if self.use_byok and self.byok_manager: |
| try: |
| |
| processing_summary = processing_result.get("processing_summary", {}) |
| best_method = processing_summary.get("best_method", "") |
| used_ocr = processing_summary.get("used_ocr", False) |
|
|
| |
| provider_id = self._map_processing_method_to_provider( |
| best_method, used_ocr |
| ) |
|
|
| if provider_id: |
| |
| total_chars = processing_summary.get("total_characters", 0) |
| estimated_tokens = max(total_chars // 4, 100) |
|
|
| |
| self.byok_manager.track_usage( |
| provider_id=provider_id, |
| success=True, |
| tokens_used=estimated_tokens, |
| ) |
|
|
| logger.debug( |
| f"Tracked BYOK usage for embedding: {provider_id}, {estimated_tokens} tokens" |
| ) |
| except Exception as e: |
| logger.warning(f"Failed to track BYOK usage during storage: {e}") |
| try: |
| doc_id = str(uuid.uuid4()) |
| now = datetime.now() |
|
|
| |
| extracted_content = processing_result.get("extracted_content", {}) |
| processing_summary = processing_result.get("processing_summary", {}) |
| file_metadata = processing_result.get("file_metadata", {}) |
|
|
| |
| document_data = { |
| "doc_id": doc_id, |
| "user_id": user_id, |
| "filename": file_metadata.get("filename", "unknown.pdf"), |
| "file_size": file_metadata.get("size_bytes", 0), |
| "page_count": processing_summary.get("total_pages", 0), |
| "total_chars": processing_summary.get("total_characters", 0), |
| "processing_method": processing_summary.get("best_method", "unknown"), |
| "pdf_type": self._determine_pdf_type(processing_result), |
| "extracted_text": extracted_content.get("text", ""), |
| "metadata": self._serialize_metadata(metadata or {}), |
| "created_at": now, |
| "updated_at": now, |
| "source_uri": source_uri or "", |
| "tags": tags or [], |
| } |
|
|
| |
| if self.lancedb_handler: |
| await self._store_in_lancedb(document_data) |
|
|
| |
| simple_storage_result = await self._store_simple_format(document_data) |
|
|
| logger.info(f"Stored PDF document {doc_id} for user {user_id}") |
|
|
| return { |
| "success": True, |
| "doc_id": doc_id, |
| "storage_methods": ["simple_format"] |
| + (["lancedb"] if self.lancedb_handler else []), |
| "document_info": { |
| "filename": document_data["filename"], |
| "pages": document_data["page_count"], |
| "characters": document_data["total_chars"], |
| "pdf_type": document_data["pdf_type"], |
| }, |
| } |
|
|
| except Exception as e: |
| logger.error(f"Failed to store processed PDF: {e}") |
| return {"success": False, "error": str(e), "doc_id": None} |
|
|
| async def _store_in_lancedb(self, document_data: Dict[str, Any]): |
| """Store document in LanceDB with chunked embeddings for better coverage.""" |
| try: |
| full_text = document_data["extracted_text"] |
| if not full_text: |
| logger.warning(f"No text extracted for document {document_data['doc_id']}") |
| return |
|
|
| |
| chunks = self._create_sliding_window_chunks(full_text, window_size=1000, overlap=200) |
|
|
| lancedb_chunks = [] |
| for i, chunk_text in enumerate(chunks): |
| |
| embedding = self.lancedb_handler.embed_text(chunk_text) |
| |
| |
| chunk_data = { |
| "doc_id": document_data["doc_id"], |
| "user_id": document_data["user_id"], |
| "filename": document_data["filename"], |
| "file_size": document_data["file_size"], |
| "page_count": document_data["page_count"], |
| "total_chars": document_data["total_chars"], |
| "processing_method": document_data["processing_method"], |
| "pdf_type": document_data["pdf_type"], |
| "extracted_text": chunk_text, |
| "embedding": embedding, |
| "metadata": document_data["metadata"], |
| "created_at": document_data["created_at"], |
| "updated_at": document_data["updated_at"], |
| "source_uri": document_data["source_uri"], |
| "tags": document_data["tags"], |
| } |
| lancedb_chunks.append(chunk_data) |
|
|
| |
| table = self.lancedb_handler.get_table(self.table_name) |
| table.add(lancedb_chunks) |
|
|
| logger.info(f"Stored document {document_data['doc_id']} in LanceDB with {len(chunks)} chunks") |
|
|
| except Exception as e: |
| logger.error(f"Failed to store in LanceDB: {e}") |
| raise |
|
|
| async def _store_simple_format( |
| self, document_data: Dict[str, Any] |
| ) -> Dict[str, Any]: |
| """Store document in SQLite fallback storage""" |
| if not self._simple_db_path: |
| logger.debug("SQLite fallback not available, skipping simple storage") |
| return {"success": False, "error": "SQLite fallback not initialized"} |
|
|
| try: |
| conn = sqlite3.connect(self._simple_db_path) |
| cursor = conn.cursor() |
|
|
| |
| extracted_text = document_data.get("extracted_text", "") |
|
|
| cursor.execute(""" |
| INSERT OR REPLACE INTO pdf_documents |
| (doc_id, user_id, filename, page_count, total_chars, pdf_type, |
| processing_method, extracted_text, created_at, source_uri) |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| """, ( |
| document_data["doc_id"], |
| document_data["user_id"], |
| document_data.get("filename", ""), |
| document_data.get("page_count", 0), |
| document_data.get("total_chars", 0), |
| document_data.get("pdf_type", "unknown"), |
| document_data.get("processing_method", "unknown"), |
| extracted_text[:10000], |
| document_data.get("created_at", datetime.now()).isoformat(), |
| document_data.get("source_uri", "") |
| )) |
|
|
| conn.commit() |
| conn.close() |
|
|
| logger.debug(f"Stored simple format for {document_data['doc_id']}") |
| return {"success": True, "storage_type": "sqlite"} |
|
|
| except Exception as e: |
| logger.error(f"Failed to store in simple format: {e}") |
| return {"success": False, "error": str(e)} |
|
|
| def _determine_pdf_type(self, processing_result: Dict[str, Any]) -> str: |
| """Determine PDF type based on processing results.""" |
| processing_summary = processing_result.get("processing_summary", {}) |
|
|
| if processing_summary.get("used_ocr", False): |
| return "scanned" |
| else: |
| text_ratio = processing_result.get("extracted_content", {}).get( |
| "text_ratio", 0 |
| ) |
| if text_ratio > 0.7: |
| return "searchable" |
| elif text_ratio > 0.3: |
| return "mixed" |
| else: |
| return "scanned" |
|
|
| def _serialize_metadata(self, metadata: Dict[str, Any]) -> str: |
| """Serialize metadata to JSON string.""" |
| import json |
|
|
| try: |
| return json.dumps(metadata) |
| except Exception as e: |
| logger.warning(f"Failed to serialize metadata: {e}") |
| return "{}" |
|
|
| async def search_pdfs( |
| self, |
| user_id: str, |
| query: str, |
| limit: int = 10, |
| similarity_threshold: float = 0.7, |
| filters: Optional[Dict[str, Any]] = None, |
| ) -> List[Dict[str, Any]]: |
| """ |
| Search PDF documents using semantic search. |
| |
| Args: |
| user_id: User identifier |
| query: Search query text |
| limit: Maximum number of results |
| similarity_threshold: Minimum similarity score (0.0-1.0) |
| filters: Optional filters for search |
| |
| Returns: |
| List of search results with similarity scores |
| """ |
|
|
| |
| if self.use_byok and self.byok_manager: |
| try: |
| |
| estimated_tokens = max(len(query) // 4, 50) |
|
|
| |
| try: |
| optimal_provider = self.byok_manager.get_optimal_provider( |
| "analysis" |
| ) |
| if optimal_provider: |
| self.byok_manager.track_usage( |
| provider_id=optimal_provider, |
| success=True, |
| tokens_used=estimated_tokens, |
| ) |
| logger.debug( |
| f"Tracked BYOK search usage: {optimal_provider}, {estimated_tokens} tokens" |
| ) |
| except Exception as e: |
| logger.debug(f"BYOK provider optimization for search failed: {e}") |
| except Exception as e: |
| logger.warning(f"Failed to track BYOK usage during search: {e}") |
| try: |
| results = [] |
|
|
| |
| if self.lancedb_handler: |
| lancedb_results = await self._search_in_lancedb( |
| user_id, query, limit, similarity_threshold, filters |
| ) |
| results.extend(lancedb_results) |
|
|
| |
| if not results: |
| simple_results = await self._simple_search( |
| user_id, query, limit, filters |
| ) |
| results.extend(simple_results) |
|
|
| return results |
|
|
| except Exception as e: |
| logger.error(f"PDF search failed: {e}") |
| return [] |
|
|
| async def _search_in_lancedb( |
| self, |
| user_id: str, |
| query: str, |
| limit: int, |
| similarity_threshold: float, |
| filters: Optional[Dict[str, Any]], |
| ) -> List[Dict[str, Any]]: |
| """Search PDFs using LanceDB semantic search.""" |
| try: |
| table = self.lancedb_handler.get_table(self.table_name) |
|
|
| |
| filter_expr = f"user_id = '{user_id}'" |
| if filters: |
| if filters.get("pdf_type"): |
| filter_expr += f" AND pdf_type = '{filters['pdf_type']}'" |
| if filters.get("tags"): |
| |
| tag_list = filters["tags"] |
| if isinstance(tag_list, list): |
| tag_conditions = [f"'{tag}' IN tags" for tag in tag_list] |
| filter_expr += f" AND ({' OR '.join(tag_conditions)})" |
|
|
| |
| search_results = self.lancedb_handler.search( |
| table=table, |
| query_text=query, |
| limit=limit * 2, |
| filter_expr=filter_expr, |
| similarity_threshold=similarity_threshold, |
| ) |
|
|
| |
| unique_docs = {} |
| for result in search_results: |
| doc_id = result.get("doc_id") |
| |
| score = result.get("_distance", float('inf')) |
| |
| |
| if doc_id not in unique_docs or score < unique_docs[doc_id]["similarity_score"]: |
| unique_docs[doc_id] = { |
| "doc_id": doc_id, |
| "filename": result.get("filename"), |
| "similarity_score": score, |
| "page_count": result.get("page_count", 0), |
| "total_chars": result.get("total_chars", 0), |
| "pdf_type": result.get("pdf_type"), |
| "excerpt": self._get_text_excerpt( |
| result.get("extracted_text", ""), query |
| ), |
| "created_at": result.get("created_at"), |
| "source_uri": result.get("source_uri"), |
| } |
|
|
| |
| formatted_results = sorted( |
| unique_docs.values(), |
| key=lambda x: x["similarity_score"] |
| )[:limit] |
|
|
| return formatted_results |
|
|
| except Exception as e: |
| logger.error(f"LanceDB search failed: {e}") |
| return [] |
|
|
| async def _simple_search( |
| self, user_id: str, query: str, limit: int, filters: Optional[Dict[str, Any]] |
| ) -> List[Dict[str, Any]]: |
| """Full-text search using SQLite FTS5""" |
| if not self._simple_db_path: |
| logger.debug("SQLite fallback not available, skipping simple search") |
| return [] |
|
|
| try: |
| conn = sqlite3.connect(self._simple_db_path) |
| cursor = conn.cursor() |
|
|
| |
| fts_query = query.replace('"', '""') |
|
|
| |
| filter_clause = "" |
| filter_params = [user_id, fts_query] |
|
|
| if filters: |
| if "pdf_type" in filters: |
| filter_clause += " AND pdf_type = ?" |
| filter_params.append(filters["pdf_type"]) |
| if "processing_method" in filters: |
| filter_clause += " AND processing_method = ?" |
| filter_params.append(filters["processing_method"]) |
|
|
| filter_params.append(limit) |
|
|
| sql = f""" |
| SELECT d.doc_id, d.filename, d.page_count, d.total_chars, |
| d.pdf_type, d.extracted_text, d.created_at, d.source_uri, |
| bm25(pdf_documents_fts) as rank |
| FROM pdf_documents d |
| JOIN pdf_documents_fts f ON d.rowid = f.rowid |
| WHERE d.user_id = ? AND pdf_documents_fts MATCH ?{filter_clause} |
| ORDER BY rank |
| LIMIT ? |
| """ |
|
|
| cursor.execute(sql, filter_params) |
| rows = cursor.fetchall() |
| conn.close() |
|
|
| results = [] |
| for row in rows: |
| results.append({ |
| "doc_id": row[0], |
| "filename": row[1], |
| "page_count": row[2], |
| "total_chars": row[3], |
| "pdf_type": row[4], |
| "excerpt": self._get_text_excerpt(row[5], query), |
| "similarity_score": row[8], |
| "created_at": row[6], |
| "source_uri": row[7] |
| }) |
|
|
| logger.info(f"Simple search found {len(results)} results for query: {query}") |
| return results |
|
|
| except Exception as e: |
| logger.error(f"Simple search failed: {e}") |
| return [] |
|
|
| def _get_text_excerpt( |
| self, text: str, query: str, excerpt_length: int = 200 |
| ) -> str: |
| """Get relevant excerpt from text containing query terms.""" |
| if not text or not query: |
| return text[:excerpt_length] + "..." if len(text) > excerpt_length else text |
|
|
| |
| query_words = query.lower().split() |
| text_lower = text.lower() |
|
|
| for word in query_words: |
| if len(word) > 3: |
| pos = text_lower.find(word) |
| if pos != -1: |
| start = max(0, pos - 50) |
| end = min(len(text), start + excerpt_length) |
| excerpt = text[start:end] |
| if start > 0: |
| excerpt = "..." + excerpt |
| if end < len(text): |
| excerpt = excerpt + "..." |
| return excerpt |
|
|
| |
| return text[:excerpt_length] + "..." if len(text) > excerpt_length else text |
|
|
| async def get_document(self, user_id: str, doc_id: str) -> Optional[Dict[str, Any]]: |
| """ |
| Retrieve a specific PDF document. |
| |
| Args: |
| user_id: User identifier |
| doc_id: Document ID |
| |
| Returns: |
| Document data or None if not found |
| """ |
| try: |
| |
| if self.lancedb_handler: |
| table = self.lancedb_handler.get_table(self.table_name) |
| result = ( |
| table.search() |
| .where(f"doc_id = '{doc_id}' AND user_id = '{user_id}'") |
| .to_list() |
| ) |
| if result: |
| return self._format_document_result(result[0]) |
|
|
| |
| simple_result = await self._get_simple_document(user_id, doc_id) |
| if simple_result: |
| return simple_result |
|
|
| return None |
|
|
| except Exception as e: |
| logger.error(f"Failed to get document {doc_id}: {e}") |
| return None |
|
|
| async def _get_simple_document( |
| self, user_id: str, doc_id: str |
| ) -> Optional[Dict[str, Any]]: |
| """Get document from SQLite storage""" |
| if not self._simple_db_path: |
| return None |
|
|
| try: |
| conn = sqlite3.connect(self._simple_db_path) |
| cursor = conn.cursor() |
|
|
| cursor.execute(""" |
| SELECT doc_id, user_id, filename, page_count, total_chars, |
| pdf_type, processing_method, extracted_text, created_at, source_uri |
| FROM pdf_documents |
| WHERE doc_id = ? AND user_id = ? |
| """, (doc_id, user_id)) |
|
|
| row = cursor.fetchone() |
| conn.close() |
|
|
| if row: |
| return { |
| "doc_id": row[0], |
| "user_id": row[1], |
| "filename": row[2], |
| "page_count": row[3], |
| "total_chars": row[4], |
| "pdf_type": row[5], |
| "processing_method": row[6], |
| "extracted_text": row[7], |
| "created_at": row[8], |
| "source_uri": row[9] |
| } |
| return None |
|
|
| except Exception as e: |
| logger.error(f"Failed to get simple document: {e}") |
| return None |
|
|
| def _format_document_result(self, document_data: Dict[str, Any]) -> Dict[str, Any]: |
| """Format document data for API response.""" |
| return { |
| "doc_id": document_data.get("doc_id"), |
| "filename": document_data.get("filename"), |
| "page_count": document_data.get("page_count", 0), |
| "total_chars": document_data.get("total_chars", 0), |
| "pdf_type": document_data.get("pdf_type"), |
| "processing_method": document_data.get("processing_method"), |
| "extracted_text": document_data.get("extracted_text", ""), |
| "source_uri": document_data.get("source_uri", ""), |
| "tags": document_data.get("tags", []), |
| "created_at": document_data.get("created_at"), |
| "file_size": document_data.get("file_size", 0), |
| "metadata": self._parse_metadata(document_data.get("metadata", "{}")), |
| } |
|
|
| def _parse_metadata(self, metadata_str: str) -> Dict[str, Any]: |
| """Parse metadata from JSON string.""" |
| import json |
|
|
| try: |
| return json.loads(metadata_str) |
| except Exception: |
| return {} |
|
|
| async def delete_document(self, user_id: str, doc_id: str) -> Dict[str, Any]: |
| """ |
| Delete a PDF document from memory. |
| |
| Args: |
| user_id: User identifier |
| doc_id: Document ID |
| |
| Returns: |
| Deletion result |
| """ |
| try: |
| deleted_from = [] |
|
|
| |
| if self.lancedb_handler: |
| try: |
| table = self.lancedb_handler.get_table(self.table_name) |
| table.delete(f"doc_id = '{doc_id}' AND user_id = '{user_id}'") |
| deleted_from.append("lancedb") |
| except Exception as e: |
| logger.warning(f"Failed to delete from LanceDB: {e}") |
|
|
| |
| simple_delete_result = await self._delete_simple_document(user_id, doc_id) |
| if simple_delete_result.get("success"): |
| deleted_from.append("simple_storage") |
|
|
| return { |
| "success": True, |
| "doc_id": doc_id, |
| "deleted_from": deleted_from, |
| "message": f"Document {doc_id} deleted from {len(deleted_from)} storage systems", |
| } |
|
|
| except Exception as e: |
| logger.error(f"Failed to delete document {doc_id}: {e}") |
| return {"success": False, "error": str(e), "doc_id": doc_id} |
|
|
| async def _delete_simple_document( |
| self, user_id: str, doc_id: str |
| ) -> Dict[str, Any]: |
| """Delete document from SQLite storage""" |
| if not self._simple_db_path: |
| return {"success": False, "error": "SQLite fallback not initialized"} |
|
|
| try: |
| conn = sqlite3.connect(self._simple_db_path) |
| cursor = conn.cursor() |
|
|
| cursor.execute(""" |
| DELETE FROM pdf_documents |
| WHERE doc_id = ? AND user_id = ? |
| """, (doc_id, user_id)) |
|
|
| deleted = cursor.rowcount > 0 |
| conn.commit() |
| conn.close() |
|
|
| if deleted: |
| logger.info(f"Deleted document {doc_id} from SQLite storage") |
|
|
| return {"success": True, "deleted": deleted} |
|
|
| except Exception as e: |
| logger.error(f"Failed to delete simple document: {e}") |
| return {"success": False, "error": str(e)} |
|
|
| async def list_documents( |
| self, |
| user_id: str, |
| limit: int = 50, |
| offset: int = 0, |
| pdf_type: Optional[str] = None, |
| tags: Optional[List[str]] = None, |
| date_from: Optional[str] = None, |
| date_to: Optional[str] = None, |
| ) -> Dict[str, Any]: |
| """ |
| List PDF documents for a user with pagination and filtering. |
| |
| Args: |
| user_id: User identifier |
| limit: Maximum number of results (1-200) |
| offset: Number of results to skip |
| pdf_type: Filter by PDF type (searchable, scanned, mixed) |
| tags: Filter by tags (documents must have at least one) |
| date_from: Filter by date start (ISO format) |
| date_to: Filter by date end (ISO format) |
| |
| Returns: |
| Dictionary with documents list and pagination info |
| """ |
| try: |
| documents = [] |
| total = 0 |
|
|
| |
| if self.lancedb_handler: |
| table = self.lancedb_handler.get_table(self.table_name) |
|
|
| |
| where_clause = f"user_id = '{user_id}'" |
| if pdf_type: |
| where_clause += f" AND pdf_type = '{pdf_type}'" |
| if date_from: |
| where_clause += f" AND created_at >= '{date_from}'" |
| if date_to: |
| where_clause += f" AND created_at <= '{date_to}'" |
| if tags: |
| |
| pass |
|
|
| |
| all_results = table.search().where(where_clause).to_list() |
| total = len(all_results) |
|
|
| |
| results = all_results[offset : offset + limit] |
| documents = [self._format_document_result(doc) for doc in results] |
|
|
| |
| elif self._simple_db_path: |
| conn = sqlite3.connect(self._simple_db_path) |
| cursor = conn.cursor() |
|
|
| |
| where_conditions = ["user_id = ?"] |
| params = [user_id] |
|
|
| if pdf_type: |
| where_conditions.append("pdf_type = ?") |
| params.append(pdf_type) |
| if date_from: |
| where_conditions.append("created_at >= ?") |
| params.append(date_from) |
| if date_to: |
| where_conditions.append("created_at <= ?") |
| params.append(date_to) |
|
|
| where_clause = " AND ".join(where_conditions) |
|
|
| |
| count_sql = f"SELECT COUNT(*) FROM pdf_documents WHERE {where_clause}" |
| cursor.execute(count_sql, params) |
| total = cursor.fetchone()[0] |
|
|
| |
| sql = f""" |
| SELECT doc_id, user_id, filename, page_count, total_chars, |
| pdf_type, processing_method, created_at, source_uri |
| FROM pdf_documents |
| WHERE {where_clause} |
| ORDER BY created_at DESC |
| LIMIT ? OFFSET ? |
| """ |
| params.extend([limit, offset]) |
| cursor.execute(sql, params) |
| rows = cursor.fetchall() |
| conn.close() |
|
|
| documents = [ |
| { |
| "doc_id": row[0], |
| "user_id": row[1], |
| "filename": row[2], |
| "page_count": row[3], |
| "total_chars": row[4], |
| "pdf_type": row[5], |
| "processing_method": row[6], |
| "created_at": row[7], |
| "source_uri": row[8], |
| "tags": [], |
| } |
| for row in rows |
| ] |
|
|
| |
| if tags: |
| filtered = [] |
| for doc in documents: |
| doc_tags = doc.get("tags", []) |
| if any(tag in doc_tags for tag in tags): |
| filtered.append(doc) |
| documents = filtered |
| total = len(documents) |
|
|
| return { |
| "success": True, |
| "documents": documents, |
| "total": total, |
| "limit": limit, |
| "offset": offset, |
| } |
|
|
| except Exception as e: |
| logger.error(f"Failed to list documents: {e}") |
| return { |
| "success": False, |
| "error": str(e), |
| "documents": [], |
| "total": 0, |
| "limit": limit, |
| "offset": offset, |
| } |
|
|
| async def update_document_tags( |
| self, user_id: str, doc_id: str, tags: List[str] |
| ) -> Dict[str, Any]: |
| """ |
| Update tags for a PDF document. |
| |
| Args: |
| user_id: User identifier |
| doc_id: Document ID |
| tags: New list of tags (replaces existing tags) |
| |
| Returns: |
| Success status with updated tag list |
| """ |
| try: |
| |
| if not isinstance(tags, list): |
| return {"success": False, "error": "Tags must be a list"} |
|
|
| |
| cleaned_tags = [tag.strip() for tag in tags if tag and tag.strip()] |
|
|
| |
| for tag in cleaned_tags: |
| if len(tag) > 50: |
| return {"success": False, "error": f"Tag too long: {tag[:20]}..."} |
|
|
| |
| if self.lancedb_handler: |
| table = self.lancedb_handler.get_table(self.table_name) |
|
|
| |
| results = ( |
| table.search() |
| .where(f"doc_id = '{doc_id}' AND user_id = '{user_id}'") |
| .to_list() |
| ) |
|
|
| if not results: |
| return {"success": False, "error": "Document not found"} |
|
|
| |
| |
| logger.warning( |
| f"LanceDB tag update not fully implemented for doc {doc_id}" |
| ) |
|
|
| |
| elif self._simple_db_path: |
| conn = sqlite3.connect(self._simple_db_path) |
| cursor = conn.cursor() |
|
|
| |
| cursor.execute( |
| "SELECT doc_id FROM pdf_documents WHERE doc_id = ? AND user_id = ?", |
| (doc_id, user_id), |
| ) |
| if not cursor.fetchone(): |
| conn.close() |
| return {"success": False, "error": "Document not found"} |
|
|
| |
| import json |
| tags_json = json.dumps(cleaned_tags) |
|
|
| cursor.execute( |
| "UPDATE pdf_documents SET tags = ? WHERE doc_id = ? AND user_id = ?", |
| (tags_json, doc_id, user_id), |
| ) |
|
|
| conn.commit() |
| conn.close() |
| logger.info( |
| f"Successfully updated {len(cleaned_tags)} tags for doc {doc_id}" |
| ) |
|
|
| return { |
| "success": True, |
| "doc_id": doc_id, |
| "tags": cleaned_tags, |
| "message": f"Successfully updated {len(cleaned_tags)} tags", |
| } |
|
|
| except Exception as e: |
| logger.error(f"Failed to update document tags: {e}") |
| return {"success": False, "error": str(e)} |
|
|
| async def get_document_tags(self, doc_id: str, user_id: str) -> Dict[str, Any]: |
| """ |
| Retrieve tags for a specific document. |
| |
| Args: |
| doc_id: Document ID |
| user_id: User ID for ownership verification |
| |
| Returns: |
| Dictionary with success status and tags list |
| """ |
| try: |
| if not self._simple_db_path: |
| return {"success": False, "error": "SQLite storage not available"} |
|
|
| import json |
| import sqlite3 |
|
|
| conn = sqlite3.connect(self._simple_db_path) |
| cursor = conn.cursor() |
|
|
| |
| cursor.execute( |
| "SELECT tags FROM pdf_documents WHERE doc_id = ? AND user_id = ?", |
| (doc_id, user_id), |
| ) |
| result = cursor.fetchone() |
| conn.close() |
|
|
| if not result: |
| return {"success": False, "error": "Document not found"} |
|
|
| tags_json = result[0] |
| tags = json.loads(tags_json) if tags_json else [] |
|
|
| return { |
| "success": True, |
| "doc_id": doc_id, |
| "tags": tags, |
| "count": len(tags), |
| } |
|
|
| except json.JSONDecodeError as e: |
| logger.error(f"Failed to parse tags JSON for doc {doc_id}: {e}") |
| return {"success": False, "error": f"Invalid tags format: {str(e)}"} |
| except Exception as e: |
| logger.error(f"Failed to get document tags: {e}") |
| return {"success": False, "error": str(e)} |
|
|
| async def delete_document_tags( |
| self, doc_id: str, user_id: str, tags_to_delete: list |
| ) -> Dict[str, Any]: |
| """ |
| Delete specific tags from a document. |
| |
| Args: |
| doc_id: Document ID |
| user_id: User ID for ownership verification |
| tags_to_delete: List of tag names to remove |
| |
| Returns: |
| Dictionary with success status and remaining tags |
| """ |
| try: |
| if not self._simple_db_path: |
| return {"success": False, "error": "SQLite storage not available"} |
|
|
| import json |
| import sqlite3 |
|
|
| conn = sqlite3.connect(self._simple_db_path) |
| cursor = conn.cursor() |
|
|
| |
| cursor.execute( |
| "SELECT tags FROM pdf_documents WHERE doc_id = ? AND user_id = ?", |
| (doc_id, user_id), |
| ) |
| result = cursor.fetchone() |
|
|
| if not result: |
| conn.close() |
| return {"success": False, "error": "Document not found"} |
|
|
| |
| current_tags = json.loads(result[0]) if result[0] else [] |
| remaining_tags = [t for t in current_tags if t not in tags_to_delete] |
|
|
| |
| tags_json = json.dumps(remaining_tags) |
| cursor.execute( |
| "UPDATE pdf_documents SET tags = ? WHERE doc_id = ? AND user_id = ?", |
| (tags_json, doc_id, user_id), |
| ) |
|
|
| conn.commit() |
| conn.close() |
|
|
| deleted_count = len(current_tags) - len(remaining_tags) |
| logger.info( |
| f"Deleted {deleted_count} tags from doc {doc_id}, {len(remaining_tags)} remaining" |
| ) |
|
|
| return { |
| "success": True, |
| "doc_id": doc_id, |
| "deleted_tags": tags_to_delete, |
| "deleted_count": deleted_count, |
| "remaining_tags": remaining_tags, |
| "message": f"Successfully deleted {deleted_count} tags", |
| } |
|
|
| except Exception as e: |
| logger.error(f"Failed to delete document tags: {e}") |
| return {"success": False, "error": str(e)} |
|
|
| async def search_by_tags( |
| self, user_id: str, tags: list, match_all: bool = False |
| ) -> Dict[str, Any]: |
| """ |
| Search for documents by tags. |
| |
| Args: |
| user_id: User ID |
| tags: List of tags to search for |
| match_all: If True, requires all tags to match; if False, any tag match is sufficient |
| |
| Returns: |
| Dictionary with matching documents |
| """ |
| try: |
| if not self._simple_db_path: |
| return {"success": False, "error": "SQLite storage not available"} |
|
|
| import json |
| import sqlite3 |
|
|
| conn = sqlite3.connect(self._simple_db_path) |
| cursor = conn.cursor() |
|
|
| |
| cursor.execute( |
| "SELECT doc_id, filename, tags FROM pdf_documents WHERE user_id = ? AND tags IS NOT NULL", |
| (user_id,), |
| ) |
| results = cursor.fetchall() |
| conn.close() |
|
|
| matching_docs = [] |
| for doc_id, filename, tags_json in results: |
| try: |
| doc_tags = json.loads(tags_json) if tags_json else [] |
|
|
| |
| if match_all: |
| |
| matches = all(tag in doc_tags for tag in tags) |
| else: |
| |
| matches = any(tag in doc_tags for tag in tags) |
|
|
| if matches: |
| matching_docs.append({ |
| "doc_id": doc_id, |
| "filename": filename, |
| "tags": doc_tags, |
| "matched_tags": [t for t in tags if t in doc_tags], |
| }) |
| except json.JSONDecodeError: |
| continue |
|
|
| return { |
| "success": True, |
| "user_id": user_id, |
| "search_tags": tags, |
| "match_all": match_all, |
| "count": len(matching_docs), |
| "documents": matching_docs, |
| } |
|
|
| except Exception as e: |
| logger.error(f"Failed to search by tags: {e}") |
| return {"success": False, "error": str(e)} |
|
|
| async def get_user_document_stats(self, user_id: str) -> Dict[str, Any]: |
| """ |
| Get statistics for user's PDF documents. |
| |
| Args: |
| user_id: User identifier |
| |
| Returns: |
| Document statistics |
| """ |
| try: |
| stats: Dict[str, Any] = { |
| "total_documents": 0, |
| "total_pages": 0, |
| "total_characters": 0, |
| "pdf_types": {}, |
| "storage_size_bytes": 0, |
| "by_month": {}, |
| } |
|
|
| |
| if self.lancedb_handler: |
| table = self.lancedb_handler.get_table(self.table_name) |
| user_docs = table.search().where(f"user_id = '{user_id}'").to_list() |
|
|
| stats["total_documents"] = len(user_docs) |
| for doc in user_docs: |
| stats["total_pages"] += doc.get("page_count", 0) |
| stats["total_characters"] += doc.get("total_chars", 0) |
| stats["storage_size_bytes"] += doc.get("file_size", 0) |
|
|
| |
| pdf_type = doc.get("pdf_type", "unknown") |
| stats["pdf_types"][pdf_type] = ( |
| stats["pdf_types"].get(pdf_type, 0) + 1 |
| ) |
|
|
| return stats |
|
|
| except Exception as e: |
| logger.error(f"Failed to get user document stats: {e}") |
| return { |
| "total_documents": 0, |
| "total_pages": 0, |
| "total_characters": 0, |
| "pdf_types": {}, |
| "storage_size_bytes": 0, |
| "by_month": {}, |
| "error": str(e), |
| } |
|
|
| def _map_processing_method_to_provider( |
| self, method: str, used_ocr: bool |
| ) -> Optional[str]: |
| """Map PDF processing method to BYOK provider ID.""" |
| if not method: |
| return None |
|
|
| method_to_provider = { |
| "openai_vision": "openai", |
| "tesseract": "openai", |
| "easyocr": "openai", |
| "basic_pdf": "openai", |
| } |
|
|
| provider = method_to_provider.get(method) |
|
|
| |
| if used_ocr and provider is None: |
| provider = "openai" |
|
|
| return provider |
|
|
| def get_byok_status(self) -> Dict[str, Any]: |
| """Get BYOK integration status.""" |
| return { |
| "byok_integrated": self.use_byok, |
| "byok_manager_available": self.byok_manager is not None, |
| "tracking_enabled": self.use_byok and self.byok_manager is not None, |
| } |
|
|
| def _create_sliding_window_chunks(self, text: str, window_size: int = 1000, overlap: int = 200) -> List[str]: |
| """Helper to create sliding-window chunks from text.""" |
| if not text: |
| return [] |
| |
| chunks = [] |
| start = 0 |
| while start < len(text): |
| end = min(start + window_size, len(text)) |
| chunks.append(text[start:end]) |
| if end == len(text): |
| break |
| |
| start += (window_size - overlap) |
| return chunks |
|
|