maktaba 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
maktaba/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """
2
+ Maktaba (EC*()) - The library for building libraries.
3
+
4
+ Production-ready RAG infrastructure for Arabic & multilingual applications.
5
+ By NuhaTech.
6
+ """
7
+
8
+ from .exceptions import (
9
+ ChunkingError,
10
+ ConfigurationError,
11
+ EmbeddingError,
12
+ MaktabaError,
13
+ PartitionAPIError,
14
+ StorageError,
15
+ )
16
+ from .models import (
17
+ EmbeddingConfig,
18
+ PartitionConfig,
19
+ SearchResult,
20
+ VectorChunk,
21
+ VectorStoreConfig,
22
+ )
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ __all__ = [
27
+ # Exceptions
28
+ "MaktabaError",
29
+ "EmbeddingError",
30
+ "StorageError",
31
+ "ChunkingError",
32
+ "ConfigurationError",
33
+ "PartitionAPIError",
34
+ # Models
35
+ "VectorChunk",
36
+ "SearchResult",
37
+ "EmbeddingConfig",
38
+ "VectorStoreConfig",
39
+ "PartitionConfig",
40
+ # Version
41
+ "__version__",
42
+ ]
@@ -0,0 +1,12 @@
1
+ """Document chunking module."""
2
+
3
+ from .base import BaseChunker
4
+ from .models import ChunkMetadata, ChunkResult
5
+ from .unstructured import UnstructuredChunker
6
+
7
+ __all__ = [
8
+ "BaseChunker",
9
+ "ChunkMetadata",
10
+ "ChunkResult",
11
+ "UnstructuredChunker",
12
+ ]
@@ -0,0 +1,83 @@
1
+ """Base interface for document chunkers."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Optional
6
+
7
+ from .models import ChunkResult
8
+
9
+
10
+ class BaseChunker(ABC):
11
+ """
12
+ Abstract base class for document chunking implementations.
13
+
14
+ All chunkers return LlamaIndex Document objects for compatibility
15
+ with the broader ecosystem.
16
+ """
17
+
18
+ @abstractmethod
19
+ async def chunk_text(
20
+ self,
21
+ text: str,
22
+ filename: str = "document.txt",
23
+ extra_metadata: Optional[Dict[str, Any]] = None,
24
+ **kwargs: Any,
25
+ ) -> ChunkResult:
26
+ """
27
+ Chunk raw text input.
28
+
29
+ Args:
30
+ text: Raw text to chunk
31
+ filename: Filename to use for metadata
32
+ extra_metadata: Additional metadata to attach to chunks
33
+ **kwargs: Implementation-specific options
34
+
35
+ Returns:
36
+ ChunkResult with documents and metadata
37
+ """
38
+ pass
39
+
40
+ @abstractmethod
41
+ async def chunk_file(
42
+ self,
43
+ file_path: Path | str,
44
+ extra_metadata: Optional[Dict[str, Any]] = None,
45
+ **kwargs: Any,
46
+ ) -> ChunkResult:
47
+ """
48
+ Chunk a local file.
49
+
50
+ Args:
51
+ file_path: Path to file to chunk
52
+ extra_metadata: Additional metadata to attach to chunks
53
+ **kwargs: Implementation-specific options
54
+
55
+ Returns:
56
+ ChunkResult with documents and metadata
57
+ """
58
+ pass
59
+
60
+ @abstractmethod
61
+ async def chunk_url(
62
+ self,
63
+ url: str,
64
+ filename: str,
65
+ extra_metadata: Optional[Dict[str, Any]] = None,
66
+ **kwargs: Any,
67
+ ) -> ChunkResult:
68
+ """
69
+ Download and chunk a file from URL.
70
+
71
+ Args:
72
+ url: URL to download file from
73
+ filename: Filename to use for metadata and type detection
74
+ extra_metadata: Additional metadata to attach to chunks
75
+ **kwargs: Implementation-specific options
76
+
77
+ Returns:
78
+ ChunkResult with documents and metadata
79
+
80
+ Raises:
81
+ ChunkingError: If download or chunking fails
82
+ """
83
+ pass
File without changes
@@ -0,0 +1,72 @@
1
+ """Data models for document chunking."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List, Optional
5
+
6
+
7
+ @dataclass
8
+ class ChunkMetadata:
9
+ """
10
+ Metadata about the chunked document.
11
+
12
+ Matches partition-api output format.
13
+ """
14
+
15
+ filename: str
16
+ """Original filename"""
17
+
18
+ filetype: str
19
+ """MIME type (e.g., 'application/pdf')"""
20
+
21
+ size_in_bytes: int
22
+ """File size in bytes"""
23
+
24
+
25
+ @dataclass
26
+ class ChunkResult:
27
+ """
28
+ Result of document chunking operation.
29
+
30
+ Contains chunks as LlamaIndex Document objects plus metadata.
31
+ Matches partition-api response structure.
32
+ """
33
+
34
+ documents: List[Any] # List[llama_index.core.schema.Document]
35
+ """Chunked documents in LlamaIndex format"""
36
+
37
+ metadata: ChunkMetadata
38
+ """File metadata"""
39
+
40
+ total_chunks: int
41
+ """Total number of chunks created"""
42
+
43
+ total_characters: int
44
+ """Total character count across all chunks"""
45
+
46
+ total_pages: Optional[int] = None
47
+ """Total pages (if applicable to document type)"""
48
+
49
+ extra_metadata: Dict[str, Any] = field(default_factory=dict)
50
+ """Additional metadata provided by user"""
51
+
52
+
53
+ def to_dict(self) -> Dict[str, Any]:
54
+ """Convert to dictionary format (matches partition-api response)."""
55
+ result = {
56
+ "metadata": {
57
+ "filename": self.metadata.filename,
58
+ "filetype": self.metadata.filetype,
59
+ "sizeInBytes": self.metadata.size_in_bytes,
60
+ },
61
+ "total_chunks": self.total_chunks,
62
+ "total_characters": self.total_characters,
63
+ "documents": [doc.to_dict() for doc in self.documents],
64
+ }
65
+
66
+ if self.total_pages is not None:
67
+ result["total_pages"] = self.total_pages
68
+
69
+ if self.extra_metadata:
70
+ result["extra_metadata"] = self.extra_metadata
71
+
72
+ return result
File without changes
@@ -0,0 +1,343 @@
1
+ """Unstructured document chunker - extracted from partition-api."""
2
+
3
+ from io import BytesIO
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Literal, Optional, Tuple
6
+
7
+ import httpx
8
+
9
+ from ..exceptions import ChunkingError
10
+ from .base import BaseChunker
11
+ from .models import ChunkMetadata, ChunkResult
12
+
13
+
14
+ class UnstructuredChunker(BaseChunker):
15
+ """
16
+ Document chunker using Unstructured.io library via LlamaIndex.
17
+
18
+ This implementation extracts the core logic from partition-api (main.py)
19
+ and integrates it directly into Maktaba for better performance and simplicity.
20
+
21
+ Supports:
22
+ - Multiple file formats (PDF, DOCX, TXT, HTML, etc.)
23
+ - Different parsing strategies (auto, fast, hi_res, ocr_only)
24
+ - Different chunking strategies (basic, by_title)
25
+ - Automatic file type detection
26
+ - Page number extraction (for PDFs)
27
+
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ strategy: Literal["auto", "fast", "hi_res", "ocr_only"] = "auto",
33
+ chunking_strategy: Literal["basic", "by_title"] = "basic",
34
+ allowed_metadata_types: Tuple[type, ...] = (str, int, float, list, dict, type(None)),
35
+ ):
36
+ """
37
+ Initialize UnstructuredChunker.
38
+
39
+ Args:
40
+ strategy: Parsing strategy (default: "auto")
41
+ - "auto": Automatically choose best strategy
42
+ - "fast": Fast parsing (no OCR)
43
+ - "hi_res": High-resolution parsing (with OCR)
44
+ - "ocr_only": OCR-only parsing
45
+ chunking_strategy: How to chunk documents (default: "basic")
46
+ - "basic": Simple text splitting
47
+ - "by_title": Chunk by document structure (headings)
48
+ allowed_metadata_types: Allowed metadata value types
49
+ """
50
+ self.strategy = strategy
51
+ self.chunking_strategy = chunking_strategy
52
+ self.allowed_metadata_types = allowed_metadata_types
53
+
54
+ async def chunk_text(
55
+ self,
56
+ text: str,
57
+ filename: str = "document.txt",
58
+ extra_metadata: Optional[Dict[str, Any]] = None,
59
+ **kwargs: Any,
60
+ ) -> ChunkResult:
61
+ """
62
+ Chunk raw text input.
63
+
64
+ Args:
65
+ text: Raw text to chunk
66
+ filename: Filename for metadata (default: "document.txt")
67
+ extra_metadata: Additional metadata to attach to chunks
68
+ **kwargs: Override strategy/chunking_strategy
69
+
70
+ Returns:
71
+ ChunkResult with documents and metadata
72
+ """
73
+ try:
74
+ # Import here to avoid requiring unstructured for other operations
75
+ from llama_index.readers.file import UnstructuredReader
76
+ from unstructured.file_utils.filetype import detect_filetype
77
+
78
+ # Convert text to bytes
79
+ text_bytes = text.encode("utf-8")
80
+ size_in_bytes = len(text_bytes)
81
+ file_stream = BytesIO(text_bytes)
82
+
83
+ # Detect file type
84
+ content_type = detect_filetype(
85
+ file=file_stream,
86
+ metadata_file_path=filename,
87
+ ).mime_type
88
+
89
+ # Reset stream position after detection
90
+ file_stream.seek(0)
91
+
92
+ # Build unstructured arguments
93
+ unstructured_args = {
94
+ "strategy": kwargs.get("strategy", self.strategy),
95
+ "chunking_strategy": kwargs.get(
96
+ "chunking_strategy", self.chunking_strategy
97
+ ),
98
+ }
99
+
100
+ # Load and chunk document
101
+ reader = UnstructuredReader(
102
+ allowed_metadata_types=self.allowed_metadata_types,
103
+ )
104
+ documents = reader.load_data(
105
+ unstructured_kwargs={
106
+ "file": file_stream,
107
+ "metadata_filename": filename,
108
+ "content_type": content_type,
109
+ **unstructured_args,
110
+ },
111
+ split_documents=True,
112
+ extra_info=extra_metadata or {},
113
+ )
114
+
115
+ if not documents:
116
+ raise ChunkingError("No chunks created from text")
117
+
118
+ # Calculate statistics
119
+ total_characters = sum(len(doc.text or "") for doc in documents)
120
+ total_chunks = len(documents)
121
+ total_pages = self._extract_total_pages(documents)
122
+
123
+ return ChunkResult(
124
+ documents=documents,
125
+ metadata=ChunkMetadata(
126
+ filename=filename,
127
+ filetype=content_type,
128
+ size_in_bytes=size_in_bytes,
129
+ ),
130
+ total_chunks=total_chunks,
131
+ total_characters=total_characters,
132
+ total_pages=total_pages,
133
+ extra_metadata=extra_metadata or {},
134
+ )
135
+
136
+ except Exception as e:
137
+ raise ChunkingError(f"Failed to chunk text: {str(e)}") from e
138
+
139
+ async def chunk_file(
140
+ self,
141
+ file_path: Path | str,
142
+ extra_metadata: Optional[Dict[str, Any]] = None,
143
+ **kwargs: Any,
144
+ ) -> ChunkResult:
145
+ """
146
+ Chunk a local file.
147
+
148
+ Args:
149
+ file_path: Path to file to chunk
150
+ extra_metadata: Additional metadata to attach to chunks
151
+ **kwargs: Override strategy/chunking_strategy
152
+
153
+ Returns:
154
+ ChunkResult with documents and metadata
155
+ """
156
+ try:
157
+ # Import here to avoid requiring unstructured for other operations
158
+ from llama_index.readers.file import UnstructuredReader
159
+ from unstructured.file_utils.filetype import detect_filetype
160
+
161
+ # Convert to Path object
162
+ path = Path(file_path) if isinstance(file_path, str) else file_path
163
+
164
+ if not path.exists():
165
+ raise ChunkingError(f"File not found: {file_path}")
166
+
167
+ # Read file
168
+ file_bytes = path.read_bytes()
169
+ size_in_bytes = len(file_bytes)
170
+ file_stream = BytesIO(file_bytes)
171
+
172
+ # Detect file type
173
+ content_type = detect_filetype(
174
+ file=file_stream,
175
+ metadata_file_path=str(path),
176
+ ).mime_type
177
+
178
+ # Reset stream position after detection
179
+ file_stream.seek(0)
180
+
181
+ # Build unstructured arguments
182
+ unstructured_args = {
183
+ "strategy": kwargs.get("strategy", self.strategy),
184
+ "chunking_strategy": kwargs.get(
185
+ "chunking_strategy", self.chunking_strategy
186
+ ),
187
+ }
188
+
189
+ # Load and chunk document
190
+ reader = UnstructuredReader(
191
+ allowed_metadata_types=self.allowed_metadata_types,
192
+ )
193
+ documents = reader.load_data(
194
+ unstructured_kwargs={
195
+ "file": file_stream,
196
+ "metadata_filename": path.name,
197
+ "content_type": content_type,
198
+ **unstructured_args,
199
+ },
200
+ split_documents=True,
201
+ extra_info=extra_metadata or {},
202
+ )
203
+
204
+ if not documents:
205
+ raise ChunkingError(f"No chunks created from file: {file_path}")
206
+
207
+ # Calculate statistics
208
+ total_characters = sum(len(doc.text or "") for doc in documents)
209
+ total_chunks = len(documents)
210
+ total_pages = self._extract_total_pages(documents)
211
+
212
+ return ChunkResult(
213
+ documents=documents,
214
+ metadata=ChunkMetadata(
215
+ filename=path.name,
216
+ filetype=content_type,
217
+ size_in_bytes=size_in_bytes,
218
+ ),
219
+ total_chunks=total_chunks,
220
+ total_characters=total_characters,
221
+ total_pages=total_pages,
222
+ extra_metadata=extra_metadata or {},
223
+ )
224
+
225
+ except ChunkingError:
226
+ raise
227
+ except Exception as e:
228
+ raise ChunkingError(f"Failed to chunk file: {str(e)}") from e
229
+
230
+ async def chunk_url(
231
+ self,
232
+ url: str,
233
+ filename: str,
234
+ extra_metadata: Optional[Dict[str, Any]] = None,
235
+ **kwargs: Any,
236
+ ) -> ChunkResult:
237
+ """
238
+ Download and chunk a file from URL.
239
+
240
+ Args:
241
+ url: URL to download file from
242
+ filename: Filename for metadata and type detection
243
+ extra_metadata: Additional metadata to attach to chunks
244
+ **kwargs: Override strategy/chunking_strategy
245
+
246
+ Returns:
247
+ ChunkResult with documents and metadata
248
+
249
+ Raises:
250
+ ChunkingError: If download or chunking fails
251
+ """
252
+ try:
253
+ # Import here to avoid requiring unstructured for other operations
254
+ from llama_index.readers.file import UnstructuredReader
255
+ from unstructured.file_utils.filetype import detect_filetype
256
+
257
+ # Download file
258
+ async with httpx.AsyncClient() as client:
259
+ response = await client.get(url, follow_redirects=True)
260
+ response.raise_for_status()
261
+ file_bytes = response.content
262
+
263
+ size_in_bytes = len(file_bytes)
264
+ file_stream = BytesIO(file_bytes)
265
+
266
+ # Detect file type
267
+ content_type = detect_filetype(
268
+ file=file_stream,
269
+ metadata_file_path=filename,
270
+ ).mime_type
271
+
272
+ # Reset stream position after detection
273
+ file_stream.seek(0)
274
+
275
+ # Build unstructured arguments
276
+ unstructured_args = {
277
+ "strategy": kwargs.get("strategy", self.strategy),
278
+ "chunking_strategy": kwargs.get(
279
+ "chunking_strategy", self.chunking_strategy
280
+ ),
281
+ }
282
+
283
+ # Load and chunk document
284
+ reader = UnstructuredReader(
285
+ allowed_metadata_types=self.allowed_metadata_types,
286
+ )
287
+ documents = reader.load_data(
288
+ unstructured_kwargs={
289
+ "file": file_stream,
290
+ "metadata_filename": filename,
291
+ "content_type": content_type,
292
+ **unstructured_args,
293
+ },
294
+ split_documents=True,
295
+ extra_info=extra_metadata or {},
296
+ )
297
+
298
+ if not documents:
299
+ raise ChunkingError(f"No chunks created from URL: {url}")
300
+
301
+ # Calculate statistics
302
+ total_characters = sum(len(doc.text or "") for doc in documents)
303
+ total_chunks = len(documents)
304
+ total_pages = self._extract_total_pages(documents)
305
+
306
+ return ChunkResult(
307
+ documents=documents,
308
+ metadata=ChunkMetadata(
309
+ filename=filename,
310
+ filetype=content_type,
311
+ size_in_bytes=size_in_bytes,
312
+ ),
313
+ total_chunks=total_chunks,
314
+ total_characters=total_characters,
315
+ total_pages=total_pages,
316
+ extra_metadata=extra_metadata or {},
317
+ )
318
+
319
+ except httpx.HTTPError as e:
320
+ raise ChunkingError(f"Failed to download file from URL: {str(e)}") from e
321
+ except ChunkingError:
322
+ raise
323
+ except Exception as e:
324
+ raise ChunkingError(f"Failed to chunk URL: {str(e)}") from e
325
+
326
+ def _extract_total_pages(self, documents: List[Any]) -> Optional[int]:
327
+ """
328
+ Extract total page count from documents.
329
+
330
+ Matches partition-api logic: finds max page_number in metadata.
331
+ """
332
+ total_pages = None
333
+
334
+ for doc in documents:
335
+ if hasattr(doc, "metadata") and isinstance(doc.metadata, dict):
336
+ page_number = doc.metadata.get("page_number")
337
+ if page_number is not None:
338
+ total_pages = max(
339
+ total_pages if total_pages is not None else 0,
340
+ page_number,
341
+ )
342
+
343
+ return total_pages
File without changes
@@ -0,0 +1,37 @@
1
+ """Citation formatter for search results."""
2
+
3
+ from typing import Dict, List
4
+
5
+ from ..models import SearchResult
6
+
7
+
8
+ def format_with_citations(
9
+ results: List[SearchResult], top_k: int = 10
10
+ ) -> Dict[str, object]:
11
+ """
12
+ Produce formatted context and citation entries from search results.
13
+
14
+ Returns a dict with:
15
+ - formatted_context: str
16
+ - citations: List[Dict]
17
+ """
18
+ limited = results[:top_k]
19
+
20
+ blocks: List[str] = []
21
+ citations: List[Dict[str, object]] = []
22
+
23
+ for idx, res in enumerate(limited, start=1):
24
+ text = res.text or ""
25
+ blocks.append(f"[{idx}]: {text}")
26
+ citations.append(
27
+ {
28
+ "index": idx,
29
+ "id": res.id,
30
+ "document_id": res.document_id,
31
+ "chunk_id": res.chunk_id,
32
+ "metadata": res.metadata,
33
+ }
34
+ )
35
+
36
+ formatted_context = "\n\n".join(blocks)
37
+ return {"formatted_context": formatted_context, "citations": citations}
maktaba/config.py ADDED
File without changes
@@ -0,0 +1,9 @@
1
+ """Embedding providers for Maktaba."""
2
+
3
+ from .base import BaseEmbedder
4
+ from .openai import OpenAIEmbedder
5
+
6
+ __all__ = [
7
+ "BaseEmbedder",
8
+ "OpenAIEmbedder",
9
+ ]
File without changes
@@ -0,0 +1,72 @@
1
+ """Base interface for embedding providers."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import List, Literal
5
+
6
+ from ..models import EmbeddingVector
7
+
8
+
9
+ class BaseEmbedder(ABC):
10
+ """
11
+ Abstract base class for text embedding providers.
12
+
13
+ Design principles:
14
+ 1. Batch-first: embed_batch is the primary method
15
+ 2. Input types: Support 'document' vs 'query' for Voyage AI
16
+ 3. Async-first: All methods are async
17
+ 4. Dimension aware: Each embedder knows its output dimension
18
+ """
19
+
20
+ @abstractmethod
21
+ async def embed_batch(
22
+ self,
23
+ texts: List[str],
24
+ input_type: Literal["document", "query"] = "document",
25
+ ) -> List[EmbeddingVector]:
26
+ """
27
+ Embed multiple texts in a single batch (primary method).
28
+
29
+ Args:
30
+ texts: List of texts to embed
31
+ input_type: Type of input - 'document' for indexing, 'query' for search
32
+ (required by Voyage AI, ignored by OpenAI/others)
33
+
34
+ Returns:
35
+ List of embedding vectors, one per input text
36
+
37
+ Raises:
38
+ EmbeddingError: If embedding fails
39
+ """
40
+ pass
41
+
42
+ async def embed_text(
43
+ self,
44
+ text: str,
45
+ input_type: Literal["document", "query"] = "document",
46
+ ) -> EmbeddingVector:
47
+ """
48
+ Embed a single text (convenience wrapper).
49
+
50
+ This is implemented as a wrapper around embed_batch for consistency.
51
+ """
52
+ results = await self.embed_batch([text], input_type=input_type)
53
+ return results[0]
54
+
55
+ @property
56
+ @abstractmethod
57
+ def dimension(self) -> int:
58
+ """
59
+ Get the dimension of embeddings produced by this embedder.
60
+
61
+ Examples:
62
+ - text-embedding-3-large: 3072
63
+ - text-embedding-3-small: 1536
64
+ - voyage-3-large: 1024
65
+ """
66
+ pass
67
+
68
+ @property
69
+ @abstractmethod
70
+ def model(self) -> str:
71
+ """Get the model name/identifier."""
72
+ pass
File without changes