longparser 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.
- longparser/__init__.py +104 -0
- longparser/chunkers/__init__.py +5 -0
- longparser/chunkers/hybrid_chunker.py +1046 -0
- longparser/extractors/__init__.py +9 -0
- longparser/extractors/base.py +62 -0
- longparser/extractors/docling_extractor.py +2065 -0
- longparser/extractors/latex_ocr.py +404 -0
- longparser/integrations/__init__.py +31 -0
- longparser/integrations/langchain.py +138 -0
- longparser/integrations/llamaindex.py +157 -0
- longparser/pipeline/__init__.py +8 -0
- longparser/pipeline/orchestrator.py +230 -0
- longparser/py.typed +0 -0
- longparser/schemas.py +247 -0
- longparser/server/__init__.py +22 -0
- longparser/server/app.py +1045 -0
- longparser/server/chat/__init__.py +39 -0
- longparser/server/chat/callbacks.py +110 -0
- longparser/server/chat/engine.py +341 -0
- longparser/server/chat/graph.py +176 -0
- longparser/server/chat/llm_chain.py +153 -0
- longparser/server/chat/retriever.py +111 -0
- longparser/server/chat/schemas.py +164 -0
- longparser/server/db.py +656 -0
- longparser/server/embeddings.py +181 -0
- longparser/server/queue.py +97 -0
- longparser/server/routers/__init__.py +0 -0
- longparser/server/schemas.py +204 -0
- longparser/server/vectorstores.py +443 -0
- longparser/server/worker.py +480 -0
- longparser/utils/__init__.py +5 -0
- longparser/utils/rtl_detector.py +93 -0
- longparser-0.1.0.dist-info/METADATA +337 -0
- longparser-0.1.0.dist-info/RECORD +36 -0
- longparser-0.1.0.dist-info/WHEEL +5 -0
- longparser-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""LlamaIndex integration adapter for LongParser.
|
|
2
|
+
|
|
3
|
+
Provides ``LongParserReader``, a LlamaIndex-compatible document reader
|
|
4
|
+
that wraps the LongParser extraction pipeline.
|
|
5
|
+
|
|
6
|
+
Install the extra to use this adapter::
|
|
7
|
+
|
|
8
|
+
pip install clean_rag[llamaindex]
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
from longparser.integrations.llamaindex import LongParserReader
|
|
13
|
+
|
|
14
|
+
reader = LongParserReader()
|
|
15
|
+
docs = reader.load_data("report.pdf") # list[llama_index.core.Document]
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional, TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from llama_index.core import Document as LIDocument
|
|
25
|
+
|
|
26
|
+
from ..schemas import ProcessingConfig, ChunkingConfig
|
|
27
|
+
|
|
28
|
+
_INSTALL_MSG = (
|
|
29
|
+
"llama-index-core is required for the LlamaIndex adapter. "
|
|
30
|
+
"Install it with: pip install clean_rag[llamaindex]"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _import_llamaindex():
|
|
35
|
+
"""Lazy import llama-index-core with a clear install message."""
|
|
36
|
+
try:
|
|
37
|
+
from llama_index.core.readers.base import BaseReader
|
|
38
|
+
from llama_index.core import Document as LIDocument
|
|
39
|
+
return BaseReader, LIDocument
|
|
40
|
+
except ImportError as exc:
|
|
41
|
+
raise ImportError(_INSTALL_MSG) from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LongParserReader:
|
|
45
|
+
"""LlamaIndex document reader backed by the LongParser pipeline.
|
|
46
|
+
|
|
47
|
+
Converts a file (PDF, DOCX, PPTX, XLSX, CSV) into a list of
|
|
48
|
+
LlamaIndex ``Document`` objects — one per chunk (if chunking is
|
|
49
|
+
enabled) or one per block.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
config:
|
|
54
|
+
LongParser ``ProcessingConfig``. Uses defaults if ``None``.
|
|
55
|
+
chunking_config:
|
|
56
|
+
LongParser ``ChunkingConfig``. If provided, the reader yields
|
|
57
|
+
one ``Document`` per chunk; otherwise one per block.
|
|
58
|
+
tesseract_lang:
|
|
59
|
+
Languages for Tesseract OCR (e.g. ``["eng", "urd"]``).
|
|
60
|
+
tessdata_path:
|
|
61
|
+
Path to tessdata directory.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
config: Optional[ProcessingConfig] = None,
|
|
68
|
+
chunking_config: Optional[ChunkingConfig] = None,
|
|
69
|
+
tesseract_lang: list[str] | None = None,
|
|
70
|
+
tessdata_path: str | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
# Validate llama-index is available at construction time
|
|
73
|
+
BaseReader, _ = _import_llamaindex()
|
|
74
|
+
self._BaseReader = BaseReader
|
|
75
|
+
|
|
76
|
+
self.config = config or ProcessingConfig()
|
|
77
|
+
self.chunking_config = chunking_config
|
|
78
|
+
self.tesseract_lang = tesseract_lang
|
|
79
|
+
self.tessdata_path = tessdata_path
|
|
80
|
+
|
|
81
|
+
# ---- LlamaIndex interface ------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def load_data(
|
|
84
|
+
self,
|
|
85
|
+
file: str | Path,
|
|
86
|
+
extra_info: dict | None = None,
|
|
87
|
+
) -> list["LIDocument"]:
|
|
88
|
+
"""Load data from a file and return LlamaIndex ``Document`` objects.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
file:
|
|
93
|
+
Path to the input file.
|
|
94
|
+
extra_info:
|
|
95
|
+
Additional metadata to merge into every document.
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
list[Document]
|
|
100
|
+
One ``Document`` per chunk or per block.
|
|
101
|
+
"""
|
|
102
|
+
_, LIDocument = _import_llamaindex()
|
|
103
|
+
|
|
104
|
+
from ..pipeline import PipelineOrchestrator
|
|
105
|
+
|
|
106
|
+
file = Path(file)
|
|
107
|
+
pipeline = PipelineOrchestrator(
|
|
108
|
+
tesseract_lang=self.tesseract_lang,
|
|
109
|
+
tessdata_path=self.tessdata_path,
|
|
110
|
+
)
|
|
111
|
+
result = pipeline.process_file(file, config=self.config)
|
|
112
|
+
|
|
113
|
+
docs: list[LIDocument] = []
|
|
114
|
+
base_meta = {"source": str(file), **(extra_info or {})}
|
|
115
|
+
|
|
116
|
+
# If chunking is requested, yield one doc per chunk
|
|
117
|
+
if self.chunking_config is not None:
|
|
118
|
+
chunks = pipeline.chunk(result, config=self.chunking_config)
|
|
119
|
+
for chunk in chunks:
|
|
120
|
+
docs.append(
|
|
121
|
+
LIDocument(
|
|
122
|
+
text=chunk.text,
|
|
123
|
+
extra_info={
|
|
124
|
+
**base_meta,
|
|
125
|
+
"chunk_id": chunk.chunk_id,
|
|
126
|
+
"chunk_type": chunk.chunk_type,
|
|
127
|
+
"section_path": chunk.section_path,
|
|
128
|
+
"page_numbers": chunk.page_numbers,
|
|
129
|
+
"token_count": chunk.token_count,
|
|
130
|
+
"equation_detected": chunk.equation_detected,
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
return docs
|
|
135
|
+
|
|
136
|
+
# Otherwise, yield one doc per block
|
|
137
|
+
for page in result.document.pages:
|
|
138
|
+
for block in page.blocks:
|
|
139
|
+
docs.append(
|
|
140
|
+
LIDocument(
|
|
141
|
+
text=block.text,
|
|
142
|
+
extra_info={
|
|
143
|
+
**base_meta,
|
|
144
|
+
"block_id": block.block_id,
|
|
145
|
+
"block_type": block.type.value,
|
|
146
|
+
"heading_level": block.heading_level,
|
|
147
|
+
"hierarchy_path": block.hierarchy_path,
|
|
148
|
+
"page_number": page.page_number,
|
|
149
|
+
"confidence": block.confidence.overall,
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return docs
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = ["LongParserReader"]
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Simple pipeline orchestrator for LongParser."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
import time
|
|
7
|
+
import logging
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from ..schemas import Document, ProcessingConfig, JobRequest, BlockType, ChunkingConfig, Chunk
|
|
11
|
+
from ..extractors import DoclingExtractor
|
|
12
|
+
from ..extractors.docling_extractor import HierarchyChunk
|
|
13
|
+
from ..chunkers import HybridChunker
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class PipelineResult:
|
|
20
|
+
"""Pipeline execution result."""
|
|
21
|
+
document: Document
|
|
22
|
+
hierarchy: List[HierarchyChunk]
|
|
23
|
+
processing_time_seconds: float
|
|
24
|
+
chunks: List[Chunk] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def total_blocks(self) -> int:
|
|
28
|
+
return sum(len(p.blocks) for p in self.document.pages)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PipelineOrchestrator:
|
|
32
|
+
"""
|
|
33
|
+
Simple pipeline orchestrator using Docling.
|
|
34
|
+
|
|
35
|
+
Flow:
|
|
36
|
+
1. Docling extracts with Tesseract CLI OCR
|
|
37
|
+
2. Layout analysis detects structure
|
|
38
|
+
3. HierarchicalChunker preserves heading hierarchy
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, tesseract_lang: List[str] = None, tessdata_path: str = None, force_full_page_ocr: bool = False):
|
|
42
|
+
"""
|
|
43
|
+
Initialize pipeline.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
tesseract_lang: Languages for Tesseract OCR (default: ["eng"])
|
|
47
|
+
tessdata_path: Path to tessdata directory with language models and configs.
|
|
48
|
+
force_full_page_ocr: If True, OCR entire page even if embedded text exists.
|
|
49
|
+
"""
|
|
50
|
+
self.extractor = DoclingExtractor(
|
|
51
|
+
tesseract_lang=tesseract_lang,
|
|
52
|
+
tessdata_path=tessdata_path,
|
|
53
|
+
force_full_page_ocr=force_full_page_ocr,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def process(self, request: JobRequest) -> PipelineResult:
|
|
57
|
+
"""Process a document."""
|
|
58
|
+
start_time = time.time()
|
|
59
|
+
|
|
60
|
+
file_path = Path(request.file_path)
|
|
61
|
+
config = request.config
|
|
62
|
+
|
|
63
|
+
logger.info(f"Processing: {file_path.name}")
|
|
64
|
+
|
|
65
|
+
# Extract document
|
|
66
|
+
document, meta = self.extractor.extract(file_path, config)
|
|
67
|
+
|
|
68
|
+
# Get hierarchy
|
|
69
|
+
hierarchy = self.extractor.get_hierarchy(file_path, config)
|
|
70
|
+
|
|
71
|
+
processing_time = time.time() - start_time
|
|
72
|
+
logger.info(f"Completed in {processing_time:.2f}s")
|
|
73
|
+
|
|
74
|
+
return PipelineResult(
|
|
75
|
+
document=document,
|
|
76
|
+
hierarchy=hierarchy,
|
|
77
|
+
processing_time_seconds=processing_time,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def process_file(
|
|
81
|
+
self,
|
|
82
|
+
file_path: str | Path,
|
|
83
|
+
config: Optional[ProcessingConfig] = None,
|
|
84
|
+
) -> PipelineResult:
|
|
85
|
+
"""Convenience method to process a file directly."""
|
|
86
|
+
request = JobRequest(
|
|
87
|
+
file_path=str(file_path),
|
|
88
|
+
config=config or ProcessingConfig(),
|
|
89
|
+
)
|
|
90
|
+
return self.process(request)
|
|
91
|
+
|
|
92
|
+
def export_to_markdown(self, result: PipelineResult, output_path: Path) -> Path:
|
|
93
|
+
"""Export document to Markdown."""
|
|
94
|
+
output_path = Path(output_path)
|
|
95
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
md_path = output_path / "document.md"
|
|
98
|
+
md_content = self.extractor.to_markdown(result.document)
|
|
99
|
+
|
|
100
|
+
with open(md_path, "w") as f:
|
|
101
|
+
f.write(md_content)
|
|
102
|
+
|
|
103
|
+
return md_path
|
|
104
|
+
|
|
105
|
+
def export_hierarchy(self, result: PipelineResult, output_path: Path) -> Path:
|
|
106
|
+
"""Export hierarchy to JSON."""
|
|
107
|
+
output_path = Path(output_path)
|
|
108
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
|
|
110
|
+
hierarchy_path = output_path / "hierarchy.json"
|
|
111
|
+
hierarchy_data = [
|
|
112
|
+
{
|
|
113
|
+
"text": h.text[:200], # Truncate for readability
|
|
114
|
+
"heading_path": h.heading_path,
|
|
115
|
+
"level": h.level,
|
|
116
|
+
"page": h.page_number,
|
|
117
|
+
}
|
|
118
|
+
for h in result.hierarchy
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
with open(hierarchy_path, "w") as f:
|
|
122
|
+
json.dump(hierarchy_data, f, indent=2)
|
|
123
|
+
|
|
124
|
+
return hierarchy_path
|
|
125
|
+
|
|
126
|
+
def export_results(self, result: PipelineResult, output_dir: Path) -> dict:
|
|
127
|
+
"""
|
|
128
|
+
Export results in the format expected by the user (blocks.json, manifest.json).
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
result: Pipeline execution result
|
|
132
|
+
output_dir: Directory to save outputs
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Dictionary of created files
|
|
136
|
+
"""
|
|
137
|
+
output_dir = Path(output_dir)
|
|
138
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
created_files = {}
|
|
140
|
+
|
|
141
|
+
# 1. blocks.json - Flattened list of blocks from all pages
|
|
142
|
+
all_blocks = []
|
|
143
|
+
total_tables = 0
|
|
144
|
+
|
|
145
|
+
for page in result.document.pages:
|
|
146
|
+
for block in page.blocks:
|
|
147
|
+
# Exclude confidence from public output
|
|
148
|
+
block_dict = block.model_dump(exclude={"confidence"})
|
|
149
|
+
# Ensure compatibility with expected format
|
|
150
|
+
if block.type == BlockType.TABLE:
|
|
151
|
+
total_tables += 1
|
|
152
|
+
all_blocks.append(block_dict)
|
|
153
|
+
|
|
154
|
+
blocks_path = output_dir / "blocks.json"
|
|
155
|
+
with open(blocks_path, "w") as f:
|
|
156
|
+
json.dump(all_blocks, f, indent=2, default=str)
|
|
157
|
+
created_files["blocks"] = blocks_path
|
|
158
|
+
|
|
159
|
+
# 2. manifest.json - Processing metadata
|
|
160
|
+
manifest = {
|
|
161
|
+
"source_file": result.document.metadata.source_file,
|
|
162
|
+
"file_hash": result.document.metadata.file_hash,
|
|
163
|
+
"total_pages": result.document.metadata.total_pages,
|
|
164
|
+
"total_blocks": len(all_blocks),
|
|
165
|
+
"total_tables": total_tables,
|
|
166
|
+
"processing_time_seconds": result.processing_time_seconds,
|
|
167
|
+
"stages_completed": [
|
|
168
|
+
"stage1_extraction",
|
|
169
|
+
"stage2_validation",
|
|
170
|
+
"stage3_reprocess",
|
|
171
|
+
"stage4_enrichment",
|
|
172
|
+
"stage5_verification"
|
|
173
|
+
],
|
|
174
|
+
"verification": {
|
|
175
|
+
"auto_accepted": False,
|
|
176
|
+
"needs_hitl_review": True,
|
|
177
|
+
"low_confidence_pages": [],
|
|
178
|
+
"low_confidence_tables": []
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
manifest_path = output_dir / "manifest.json"
|
|
183
|
+
with open(manifest_path, "w") as f:
|
|
184
|
+
json.dump(manifest, f, indent=2, default=str)
|
|
185
|
+
created_files["manifest"] = manifest_path
|
|
186
|
+
|
|
187
|
+
# 3. document.md - Markdown representation
|
|
188
|
+
md_path = self.export_to_markdown(result, output_dir)
|
|
189
|
+
created_files["markdown"] = md_path
|
|
190
|
+
|
|
191
|
+
# 4. Images
|
|
192
|
+
images_dir = output_dir / "images"
|
|
193
|
+
images = self.save_images(images_dir)
|
|
194
|
+
created_files["images"] = images
|
|
195
|
+
|
|
196
|
+
return created_files
|
|
197
|
+
|
|
198
|
+
def chunk(self, result: PipelineResult, config: Optional[ChunkingConfig] = None) -> List[Chunk]:
|
|
199
|
+
"""
|
|
200
|
+
Run hybrid chunking on a pipeline result.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
result: Pipeline execution result with extracted blocks
|
|
204
|
+
config: Chunking configuration (uses defaults if None)
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
List of RAG-optimized chunks
|
|
208
|
+
"""
|
|
209
|
+
chunker = HybridChunker(config or ChunkingConfig())
|
|
210
|
+
all_blocks = result.document.all_blocks
|
|
211
|
+
chunks = chunker.chunk(all_blocks)
|
|
212
|
+
result.chunks = chunks
|
|
213
|
+
return chunks
|
|
214
|
+
|
|
215
|
+
def export_chunks(self, result: PipelineResult, output_dir: Path) -> Path:
|
|
216
|
+
"""Export chunks to JSON."""
|
|
217
|
+
output_dir = Path(output_dir)
|
|
218
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
|
|
220
|
+
chunks_path = output_dir / "chunks.json"
|
|
221
|
+
chunks_data = [c.model_dump() for c in result.chunks]
|
|
222
|
+
with open(chunks_path, "w") as f:
|
|
223
|
+
json.dump(chunks_data, f, indent=2, default=str)
|
|
224
|
+
|
|
225
|
+
logger.info(f"Exported {len(result.chunks)} chunks to {chunks_path}")
|
|
226
|
+
return chunks_path
|
|
227
|
+
|
|
228
|
+
def save_images(self, output_dir: Path) -> List[Path]:
|
|
229
|
+
"""Save extracted images."""
|
|
230
|
+
return self.extractor.save_images(output_dir)
|
longparser/py.typed
ADDED
|
File without changes
|
longparser/schemas.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Pydantic schemas for LongParser document processing pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BlockType(str, Enum):
|
|
12
|
+
"""Types of document blocks."""
|
|
13
|
+
HEADING = "heading"
|
|
14
|
+
PARAGRAPH = "paragraph"
|
|
15
|
+
LIST_ITEM = "list_item"
|
|
16
|
+
TABLE = "table"
|
|
17
|
+
FIGURE = "figure"
|
|
18
|
+
CAPTION = "caption"
|
|
19
|
+
FOOTER = "footer"
|
|
20
|
+
HEADER = "header"
|
|
21
|
+
EQUATION = "equation"
|
|
22
|
+
CODE = "code"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ExtractorType(str, Enum):
|
|
26
|
+
"""Document extraction engines."""
|
|
27
|
+
DOCLING = "docling"
|
|
28
|
+
SURYA = "surya"
|
|
29
|
+
MARKER = "marker"
|
|
30
|
+
NATIVE_PDF = "native_pdf"
|
|
31
|
+
PADDLE = "paddle"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BoundingBox(BaseModel):
|
|
35
|
+
"""Bounding box coordinates (PDF coordinate system)."""
|
|
36
|
+
x0: float
|
|
37
|
+
y0: float
|
|
38
|
+
x1: float
|
|
39
|
+
y1: float
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Provenance(BaseModel):
|
|
43
|
+
"""Traceability information for a block."""
|
|
44
|
+
source_file: str
|
|
45
|
+
page_number: int
|
|
46
|
+
bbox: BoundingBox
|
|
47
|
+
extractor: ExtractorType
|
|
48
|
+
extractor_version: str = "1.0.0"
|
|
49
|
+
pipeline_version: str = "1.0.0"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Confidence(BaseModel):
|
|
53
|
+
"""Confidence scores for extraction quality."""
|
|
54
|
+
overall: float = Field(ge=0.0, le=1.0)
|
|
55
|
+
text_confidence: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
56
|
+
layout_confidence: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
57
|
+
table_confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class BlockFlags(BaseModel):
|
|
61
|
+
"""Flags indicating block status."""
|
|
62
|
+
needs_review: bool = False
|
|
63
|
+
repaired: bool = False
|
|
64
|
+
fallback_used: bool = False
|
|
65
|
+
excluded_from_rag: bool = False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TableCell(BaseModel):
|
|
69
|
+
"""Individual cell in a table."""
|
|
70
|
+
row_index: int = Field(alias="r0")
|
|
71
|
+
col_index: int = Field(alias="c0")
|
|
72
|
+
row_span: int = Field(default=1, alias="rspan")
|
|
73
|
+
col_span: int = Field(default=1, alias="cspan")
|
|
74
|
+
text: str = ""
|
|
75
|
+
bbox: Optional[BoundingBox] = None
|
|
76
|
+
confidence: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
77
|
+
|
|
78
|
+
class Config:
|
|
79
|
+
populate_by_name = True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Table(BaseModel):
|
|
83
|
+
"""Table structure with cells and metadata."""
|
|
84
|
+
table_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
85
|
+
n_rows: int
|
|
86
|
+
n_cols: int
|
|
87
|
+
cells: list[TableCell] = Field(default_factory=list)
|
|
88
|
+
table_confidence: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
89
|
+
csv_path: Optional[str] = None
|
|
90
|
+
html_path: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Block(BaseModel):
|
|
94
|
+
"""Core document element with full provenance."""
|
|
95
|
+
block_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
96
|
+
type: BlockType
|
|
97
|
+
text: str = ""
|
|
98
|
+
order_index: int = 0
|
|
99
|
+
heading_level: Optional[int] = Field(default=None, description="Heading level (1-6) for heading blocks, inferred by Docling")
|
|
100
|
+
indent_level: int = Field(default=0, description="Bullet nesting depth (0=top, 1=sub, 2=sub-sub). Used for PPTX list items.")
|
|
101
|
+
hierarchy_path: list[str] = Field(default_factory=list)
|
|
102
|
+
refs_out: list[str] = Field(default_factory=list)
|
|
103
|
+
refs_in: list[str] = Field(default_factory=list)
|
|
104
|
+
provenance: Provenance
|
|
105
|
+
confidence: Confidence
|
|
106
|
+
flags: BlockFlags = Field(default_factory=BlockFlags)
|
|
107
|
+
table: Optional[Table] = None
|
|
108
|
+
image_path: Optional[str] = None
|
|
109
|
+
bbox_px: Optional[tuple] = Field(default=None, description="Pixel-space bounding box (x0,y0,x1,y1) for MFD dedup")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class PageProfile(BaseModel):
|
|
113
|
+
"""Validation profile for a single page."""
|
|
114
|
+
page_number: int
|
|
115
|
+
needs_reprocess: bool = False
|
|
116
|
+
validation_errors: list[str] = Field(default_factory=list)
|
|
117
|
+
layout_confidence: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
118
|
+
table_confidence: Optional[float] = None
|
|
119
|
+
has_rtl: bool = False
|
|
120
|
+
has_math: bool = False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Page(BaseModel):
|
|
124
|
+
"""Single page with blocks and metadata."""
|
|
125
|
+
page_number: int
|
|
126
|
+
width: float
|
|
127
|
+
height: float
|
|
128
|
+
blocks: list[Block] = Field(default_factory=list)
|
|
129
|
+
rendered_image_path: Optional[str] = None
|
|
130
|
+
profile: Optional[PageProfile] = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class DocumentMetadata(BaseModel):
|
|
134
|
+
"""Document-level metadata."""
|
|
135
|
+
source_file: str
|
|
136
|
+
file_hash: str = ""
|
|
137
|
+
language: Optional[str] = None
|
|
138
|
+
total_pages: int = 0
|
|
139
|
+
academic_mode: bool = False
|
|
140
|
+
rtl_hint: bool = False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Document(BaseModel):
|
|
144
|
+
"""Complete processed document."""
|
|
145
|
+
doc_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
146
|
+
metadata: DocumentMetadata
|
|
147
|
+
pages: list[Page] = Field(default_factory=list)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def all_blocks(self) -> list[Block]:
|
|
151
|
+
"""Get all blocks across all pages."""
|
|
152
|
+
return [block for page in self.pages for block in page.blocks]
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def all_tables(self) -> list[Table]:
|
|
156
|
+
"""Get all tables from table blocks."""
|
|
157
|
+
return [
|
|
158
|
+
block.table
|
|
159
|
+
for block in self.all_blocks
|
|
160
|
+
if block.type == BlockType.TABLE and block.table
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ProcessingConfig(BaseModel):
|
|
165
|
+
"""Configuration for pipeline execution."""
|
|
166
|
+
academic_mode: bool = False
|
|
167
|
+
rtl_hint: bool = False
|
|
168
|
+
do_ocr: bool = True
|
|
169
|
+
formula_ocr: bool = True # Independent from do_ocr — runs pix2tex even when text OCR is off
|
|
170
|
+
do_table_structure: bool = True
|
|
171
|
+
export_images: bool = True
|
|
172
|
+
images_output_dir: Optional[str] = None
|
|
173
|
+
layout_confidence_threshold: float = 0.7
|
|
174
|
+
table_confidence_threshold: float = 0.75
|
|
175
|
+
ocr_noise_threshold: float = 0.15
|
|
176
|
+
enable_fallback: bool = True
|
|
177
|
+
# Smart extraction fallback options
|
|
178
|
+
prefer_initial_on_degradation: bool = True # Keep original if fallback degrades quality
|
|
179
|
+
ocr_backend: str = "easyocr" # easyocr | tesseract | rapidocr
|
|
180
|
+
ocr_use_gpu: bool = True
|
|
181
|
+
# Force full page OCR for better text segmentation in two-column layouts
|
|
182
|
+
force_full_page_ocr: bool = False
|
|
183
|
+
# Exclude page headers and footers from extraction output
|
|
184
|
+
exclude_page_headers_footers: bool = True
|
|
185
|
+
|
|
186
|
+
# Formula extraction mode: "full" (slow, best LaTeX), "fast" (Unicode text), "smart" (hybrid)
|
|
187
|
+
formula_mode: str = "smart"
|
|
188
|
+
# Smart mode safety caps
|
|
189
|
+
smart_max_pages: int = 5 # Max pages to enrich in smart mode before fallback to fast
|
|
190
|
+
smart_max_ratio: float = 0.2 # Max ratio of pages (enriched/total) before fallback
|
|
191
|
+
smart_max_equations: int = 25 # Max equations to OCR per document (circuit breaker)
|
|
192
|
+
smart_max_ocr_seconds: float = 300.0 # Total OCR time budget (must fit within arq timeout)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class ExtractionMetadata(BaseModel):
|
|
196
|
+
"""Metadata from smart extraction strategy."""
|
|
197
|
+
strategy_used: str = "standard" # standard | force_full_page_ocr
|
|
198
|
+
initial_low_grade: Optional[str] = None
|
|
199
|
+
fallback_low_grade: Optional[str] = None
|
|
200
|
+
improved: bool = False
|
|
201
|
+
fallback_degraded: bool = False
|
|
202
|
+
reprocessed_pages: list[int] = Field(default_factory=list)
|
|
203
|
+
ocr_backend_used: Optional[str] = None
|
|
204
|
+
reasons: list[str] = Field(default_factory=list)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ChunkingConfig(BaseModel):
|
|
208
|
+
"""Configuration for hybrid chunking."""
|
|
209
|
+
max_tokens: int = 512
|
|
210
|
+
min_tokens: int = 100
|
|
211
|
+
overlap_blocks: int = 1
|
|
212
|
+
table_rows_per_chunk: int = 15
|
|
213
|
+
exclude_headers_footers: bool = True
|
|
214
|
+
detect_equations: bool = True
|
|
215
|
+
table_chunk_format: str = "row_record" # "row_record" | "pipe"
|
|
216
|
+
generate_schema_chunks: bool = True
|
|
217
|
+
wide_table_col_threshold: int = 25
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Chunk(BaseModel):
|
|
221
|
+
"""A RAG-optimized chunk with full provenance."""
|
|
222
|
+
chunk_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
223
|
+
text: str
|
|
224
|
+
token_count: int
|
|
225
|
+
chunk_type: str # "section" | "table" | "table_schema" | "list" | "equation" | "continuation"
|
|
226
|
+
section_path: list[str] = Field(default_factory=list)
|
|
227
|
+
page_numbers: list[int] = Field(default_factory=list)
|
|
228
|
+
block_ids: list[str] = Field(default_factory=list)
|
|
229
|
+
overlap_with_previous: bool = False
|
|
230
|
+
equation_detected: bool = False
|
|
231
|
+
metadata: dict = Field(default_factory=dict) # row_start, row_end, sheet, col_band
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class JobRequest(BaseModel):
|
|
235
|
+
"""Request to process a document."""
|
|
236
|
+
job_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
237
|
+
file_path: str
|
|
238
|
+
config: ProcessingConfig = Field(default_factory=ProcessingConfig)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class JobResult(BaseModel):
|
|
242
|
+
"""Result of document processing."""
|
|
243
|
+
job_id: str
|
|
244
|
+
document: Document
|
|
245
|
+
success: bool = True
|
|
246
|
+
errors: list[str] = Field(default_factory=list)
|
|
247
|
+
processing_time_seconds: float = 0.0
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""LongParser REST API server package.
|
|
2
|
+
|
|
3
|
+
Start the server::
|
|
4
|
+
|
|
5
|
+
uv run uvicorn longparser.server.app:app --reload --port 8000
|
|
6
|
+
|
|
7
|
+
Subpackages:
|
|
8
|
+
- :mod:`~longparser.server.chat` — RAG chat engine, retriever, LangGraph HITL
|
|
9
|
+
- :mod:`~longparser.server.routers` — modular FastAPI route groups (future)
|
|
10
|
+
|
|
11
|
+
Key modules:
|
|
12
|
+
- :mod:`~longparser.server.app` — FastAPI application factory and all routes
|
|
13
|
+
- :mod:`~longparser.server.db` — Motor async MongoDB database layer
|
|
14
|
+
- :mod:`~longparser.server.queue` — ARQ async job queue (Redis-backed)
|
|
15
|
+
- :mod:`~longparser.server.worker` — ARQ background worker definitions
|
|
16
|
+
- :mod:`~longparser.server.embeddings` — multi-backend embedding engine
|
|
17
|
+
- :mod:`~longparser.server.vectorstores` — Chroma / FAISS / Qdrant adapters
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .app import app
|
|
21
|
+
|
|
22
|
+
__all__ = ["app"]
|