documa 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.
- documa/__init__.py +41 -0
- documa/adapters/__init__.py +7 -0
- documa/adapters/base.py +31 -0
- documa/adapters/markdown_adapter.py +258 -0
- documa/adapters/pymupdf_adapter.py +781 -0
- documa/cli.py +245 -0
- documa/core/__init__.py +2 -0
- documa/core/encoding.py +66 -0
- documa/core/errors.py +45 -0
- documa/core/image_filtering.py +71 -0
- documa/core/ir.py +254 -0
- documa/core/language.py +53 -0
- documa/core/serialization.py +201 -0
- documa/core/text_normalization.py +46 -0
- documa/demo/__init__.py +5 -0
- documa/demo/block_reading.py +494 -0
- documa/exporters/__init__.py +9 -0
- documa/exporters/base.py +30 -0
- documa/exporters/block_json.py +125 -0
- documa/exporters/json_exporter.py +18 -0
- documa/exporters/markdown.py +75 -0
- documa/exporters/rag_json.py +50 -0
- documa/interfaces/__init__.py +38 -0
- documa/interfaces/mcp_server.py +190 -0
- documa/interfaces/tool_schemas.py +300 -0
- documa/interfaces/tools.py +773 -0
- documa/pipeline/__init__.py +40 -0
- documa/pipeline/base.py +35 -0
- documa/pipeline/block_keywords.py +279 -0
- documa/pipeline/block_tree.py +290 -0
- documa/pipeline/captions.py +123 -0
- documa/pipeline/chunking.py +382 -0
- documa/pipeline/footnotes.py +97 -0
- documa/pipeline/images.py +66 -0
- documa/pipeline/inline_semantics.py +56 -0
- documa/pipeline/layout.py +177 -0
- documa/pipeline/page_refs.py +105 -0
- documa/pipeline/paragraphs.py +161 -0
- documa/pipeline/provenance.py +41 -0
- documa/pipeline/reading_order.py +110 -0
- documa/pipeline/relations.py +63 -0
- documa/pipeline/runner.py +84 -0
- documa/pipeline/tables.py +80 -0
- documa/pipeline/toc.py +125 -0
- documa/quality/__init__.py +16 -0
- documa/quality/benchmark.py +121 -0
- documa/quality/doctor.py +100 -0
- documa/quality/fixture_manifest.py +68 -0
- documa/storage/__init__.py +6 -0
- documa/storage/assets.py +49 -0
- documa-0.1.0.dist-info/METADATA +400 -0
- documa-0.1.0.dist-info/RECORD +56 -0
- documa-0.1.0.dist-info/WHEEL +5 -0
- documa-0.1.0.dist-info/entry_points.txt +3 -0
- documa-0.1.0.dist-info/licenses/LICENSE +21 -0
- documa-0.1.0.dist-info/top_level.txt +1 -0
documa/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Documa core public API."""
|
|
2
|
+
|
|
3
|
+
from documa.core.ir import (
|
|
4
|
+
BlockIR,
|
|
5
|
+
BlockType,
|
|
6
|
+
ChunkIR,
|
|
7
|
+
Confidence,
|
|
8
|
+
DocumentIR,
|
|
9
|
+
FixtureIssueType,
|
|
10
|
+
ImageIR,
|
|
11
|
+
JobState,
|
|
12
|
+
PageIR,
|
|
13
|
+
RelationIR,
|
|
14
|
+
RelationState,
|
|
15
|
+
RelationType,
|
|
16
|
+
SpanStyle,
|
|
17
|
+
SpanIR,
|
|
18
|
+
TableIR,
|
|
19
|
+
TextContent,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"BlockIR",
|
|
24
|
+
"BlockType",
|
|
25
|
+
"ChunkIR",
|
|
26
|
+
"Confidence",
|
|
27
|
+
"DocumentIR",
|
|
28
|
+
"FixtureIssueType",
|
|
29
|
+
"ImageIR",
|
|
30
|
+
"JobState",
|
|
31
|
+
"PageIR",
|
|
32
|
+
"RelationIR",
|
|
33
|
+
"RelationState",
|
|
34
|
+
"RelationType",
|
|
35
|
+
"SpanStyle",
|
|
36
|
+
"SpanIR",
|
|
37
|
+
"TableIR",
|
|
38
|
+
"TextContent",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Parser adapter interfaces and implementations."""
|
|
2
|
+
|
|
3
|
+
from documa.adapters.base import ParseOptions, ParserAdapter
|
|
4
|
+
from documa.adapters.markdown_adapter import MarkdownAdapter
|
|
5
|
+
from documa.adapters.pymupdf_adapter import PyMuPDFAdapter
|
|
6
|
+
|
|
7
|
+
__all__ = ["MarkdownAdapter", "ParseOptions", "ParserAdapter", "PyMuPDFAdapter"]
|
documa/adapters/base.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Base interface for parser adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from documa.core.ir import DocumentIR
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class ParseOptions:
|
|
15
|
+
languages: list[str] = field(default_factory=lambda: ["auto"])
|
|
16
|
+
normalize_unicode: bool = True
|
|
17
|
+
extract_images: bool = True
|
|
18
|
+
resolve_relations: bool = True
|
|
19
|
+
asset_dir: Path | None = None
|
|
20
|
+
preview_scale: float = 1.0
|
|
21
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ParserAdapter(ABC):
|
|
25
|
+
"""Parser-neutral adapter contract."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def parse(self, source: str | Path, options: ParseOptions | None = None) -> DocumentIR:
|
|
31
|
+
"""Parse a source document into Documa IR primitives."""
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Markdown parser adapter for structure-first block querying."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from documa.adapters.base import ParseOptions, ParserAdapter
|
|
11
|
+
from documa.core.ir import BlockIR, BlockType, Confidence, DocumentIR, PageIR, TableIR, TextContent
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_ATX_HEADING_RE = re.compile(r"^(?P<marks>#{1,6})\s+(?P<title>.*?)(?:\s+#+\s*)?$")
|
|
15
|
+
_MDP_BLOCK_HEADER_RE = re.compile(
|
|
16
|
+
r"^(?P<indent>\s*)-\s+\*\*#(?P<id>[a-z0-9][a-z0-9-]*[a-z0-9]|[a-z0-9])\*\*"
|
|
17
|
+
r"(?P<meta>(?:\s+`[^`]+`)*)\s*$"
|
|
18
|
+
)
|
|
19
|
+
_META_PAIR_RE = re.compile(r"`([a-z][a-z0-9-]*):([^`]+)`")
|
|
20
|
+
_TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$")
|
|
21
|
+
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _metadata(value: str) -> dict[str, str]:
|
|
25
|
+
return {match.group(1): match.group(2).strip() for match in _META_PAIR_RE.finditer(value)}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _clean_title(value: str, *, max_chars: int = 120) -> str:
|
|
29
|
+
value = re.sub(r"!\[[^\]]*\]\([^)]+\)", " ", value)
|
|
30
|
+
value = re.sub(r"\[[^\]]+\]\([^)]+\)", lambda match: match.group(0).split("](", 1)[0][1:], value)
|
|
31
|
+
value = re.sub(r"^[>\s#*\-`_]+|[>\s#*\-`_]+$", "", value)
|
|
32
|
+
value = re.sub(r"\s+", " ", value).strip()
|
|
33
|
+
if len(value) > max_chars:
|
|
34
|
+
return value[:max_chars].rstrip() + "…"
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _paragraph_title(lines: list[str], fallback: str | None = None) -> str | None:
|
|
39
|
+
for line in lines:
|
|
40
|
+
title = _clean_title(line)
|
|
41
|
+
if title:
|
|
42
|
+
return title
|
|
43
|
+
return fallback
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_table(lines: list[str]) -> bool:
|
|
47
|
+
compact = [line for line in lines if line.strip()]
|
|
48
|
+
return (
|
|
49
|
+
len(compact) >= 2
|
|
50
|
+
and _TABLE_ROW_RE.match(compact[0]) is not None
|
|
51
|
+
and _TABLE_SEP_RE.match(compact[1]) is not None
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _document_id(source_path: Path, text: str) -> str:
|
|
56
|
+
digest = hashlib.sha256(f"{source_path.resolve()}\n{text}".encode("utf-8")).hexdigest()[:16]
|
|
57
|
+
return f"doc_md_{digest}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True)
|
|
61
|
+
class _OpenMarkdownPlusBlock:
|
|
62
|
+
block: BlockIR
|
|
63
|
+
explicit_title: bool
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MarkdownAdapter(ParserAdapter):
|
|
67
|
+
"""Parse Markdown or Markdown+ text into Documa IR blocks.
|
|
68
|
+
|
|
69
|
+
The adapter intentionally stays conservative: headings and Markdown+ block
|
|
70
|
+
headers become structural heading blocks; prose paragraphs and tables become
|
|
71
|
+
leaf blocks. Downstream stages then build the queryable document block tree
|
|
72
|
+
and keyword metadata.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
name = "markdown"
|
|
76
|
+
|
|
77
|
+
def parse(self, source: str | Path, options: ParseOptions | None = None) -> DocumentIR:
|
|
78
|
+
options = options or ParseOptions()
|
|
79
|
+
source_path = Path(source)
|
|
80
|
+
text = source_path.read_text(encoding="utf-8")
|
|
81
|
+
document = DocumentIR(
|
|
82
|
+
id=_document_id(source_path, text),
|
|
83
|
+
source_name=str(source_path),
|
|
84
|
+
parser=self.name,
|
|
85
|
+
metadata={
|
|
86
|
+
"adapter": self.name,
|
|
87
|
+
"line_count": len(text.splitlines()),
|
|
88
|
+
"languages": list(options.languages),
|
|
89
|
+
"format": self._format_label(source_path),
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
page = PageIR(
|
|
93
|
+
id="page_1",
|
|
94
|
+
page_number=1,
|
|
95
|
+
width=0,
|
|
96
|
+
height=max(1, len(text.splitlines())),
|
|
97
|
+
metadata={"source": "markdown_text", "line_count": len(text.splitlines())},
|
|
98
|
+
)
|
|
99
|
+
document.pages.append(page)
|
|
100
|
+
|
|
101
|
+
self._parse_lines(text.splitlines(), document, page)
|
|
102
|
+
return document
|
|
103
|
+
|
|
104
|
+
def _format_label(self, source_path: Path) -> str:
|
|
105
|
+
suffix = source_path.suffix.lower()
|
|
106
|
+
suffixes = [item.lower() for item in source_path.suffixes]
|
|
107
|
+
if suffix in {".mdp"} or suffixes[-2:] == [".mdp", ".md"]:
|
|
108
|
+
return "markdown_plus"
|
|
109
|
+
if suffix in {".md", ".markdown"}:
|
|
110
|
+
return "markdown"
|
|
111
|
+
return "text"
|
|
112
|
+
|
|
113
|
+
def _parse_lines(self, lines: list[str], document: DocumentIR, page: PageIR) -> None:
|
|
114
|
+
order = 0
|
|
115
|
+
paragraph: list[tuple[int, str]] = []
|
|
116
|
+
in_fence = False
|
|
117
|
+
fence_marker = ""
|
|
118
|
+
heading_path: list[str] = []
|
|
119
|
+
open_mdp: list[_OpenMarkdownPlusBlock] = []
|
|
120
|
+
|
|
121
|
+
def add_block(block_type: BlockType, text: str, start_line: int, end_line: int, metadata: dict) -> BlockIR:
|
|
122
|
+
nonlocal order
|
|
123
|
+
order += 1
|
|
124
|
+
block = BlockIR(
|
|
125
|
+
id=f"md_b{order:04d}",
|
|
126
|
+
type=block_type,
|
|
127
|
+
page_number=1,
|
|
128
|
+
text=TextContent(text),
|
|
129
|
+
confidence=Confidence.HIGH if text.strip() else Confidence.UNKNOWN,
|
|
130
|
+
order_index=order,
|
|
131
|
+
source_refs=[f"markdown:line:{start_line}-{end_line}"],
|
|
132
|
+
metadata={
|
|
133
|
+
"source_type": "markdown",
|
|
134
|
+
"line_start": start_line,
|
|
135
|
+
"line_end": end_line,
|
|
136
|
+
**metadata,
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
page.blocks.append(block)
|
|
140
|
+
return block
|
|
141
|
+
|
|
142
|
+
def current_mdp_context() -> dict:
|
|
143
|
+
if not open_mdp:
|
|
144
|
+
return {}
|
|
145
|
+
return {
|
|
146
|
+
"markdown_plus_parent_id": open_mdp[-1].block.metadata.get("markdown_plus_id"),
|
|
147
|
+
"markdown_plus_path": [
|
|
148
|
+
str(item.block.metadata.get("markdown_plus_id"))
|
|
149
|
+
for item in open_mdp
|
|
150
|
+
if item.block.metadata.get("markdown_plus_id")
|
|
151
|
+
],
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
def flush_paragraph() -> None:
|
|
155
|
+
nonlocal paragraph
|
|
156
|
+
if not paragraph:
|
|
157
|
+
return
|
|
158
|
+
start_line = paragraph[0][0]
|
|
159
|
+
end_line = paragraph[-1][0]
|
|
160
|
+
body_lines = [line for _, line in paragraph]
|
|
161
|
+
body = "\n".join(body_lines).strip("\n")
|
|
162
|
+
if not body.strip():
|
|
163
|
+
paragraph = []
|
|
164
|
+
return
|
|
165
|
+
title = _paragraph_title(body_lines)
|
|
166
|
+
block_type = BlockType.TABLE if _is_table(body_lines) else BlockType.PARAGRAPH
|
|
167
|
+
metadata = {
|
|
168
|
+
"paragraph_title": title,
|
|
169
|
+
"heading_path": list(heading_path),
|
|
170
|
+
"build_strategy": "markdown_paragraph",
|
|
171
|
+
**current_mdp_context(),
|
|
172
|
+
}
|
|
173
|
+
if block_type == BlockType.TABLE:
|
|
174
|
+
metadata["table_title"] = title
|
|
175
|
+
block = add_block(block_type, body, start_line, end_line, metadata)
|
|
176
|
+
if block_type == BlockType.TABLE:
|
|
177
|
+
document.tables.append(
|
|
178
|
+
TableIR(
|
|
179
|
+
id=f"table_{block.id}",
|
|
180
|
+
block_id=block.id,
|
|
181
|
+
markdown=body,
|
|
182
|
+
confidence=Confidence.MEDIUM,
|
|
183
|
+
metadata={"source": "markdown_table"},
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
if open_mdp and not open_mdp[-1].explicit_title and title:
|
|
187
|
+
open_mdp[-1].block.text = TextContent(title)
|
|
188
|
+
open_mdp[-1].block.metadata["title_fallback_source_block_id"] = block.id
|
|
189
|
+
open_mdp[-1].explicit_title = True
|
|
190
|
+
paragraph = []
|
|
191
|
+
|
|
192
|
+
for line_number, raw in enumerate(lines, start=1):
|
|
193
|
+
stripped = raw.lstrip()
|
|
194
|
+
if stripped.startswith("```") or stripped.startswith("~~~"):
|
|
195
|
+
marker = stripped[:3]
|
|
196
|
+
if not in_fence:
|
|
197
|
+
in_fence = True
|
|
198
|
+
fence_marker = marker
|
|
199
|
+
elif stripped.startswith(fence_marker):
|
|
200
|
+
in_fence = False
|
|
201
|
+
fence_marker = ""
|
|
202
|
+
paragraph.append((line_number, raw))
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if not in_fence:
|
|
206
|
+
mdp_header = _MDP_BLOCK_HEADER_RE.match(raw)
|
|
207
|
+
if mdp_header:
|
|
208
|
+
flush_paragraph()
|
|
209
|
+
metadata = _metadata(mdp_header.group("meta") or "")
|
|
210
|
+
block_id = mdp_header.group("id")
|
|
211
|
+
indent = len(mdp_header.group("indent"))
|
|
212
|
+
level = max(1, min(indent // 2 + 1, 6))
|
|
213
|
+
while len(open_mdp) >= level:
|
|
214
|
+
open_mdp.pop()
|
|
215
|
+
title = metadata.get("title") or block_id.replace("-", " ")
|
|
216
|
+
block = add_block(
|
|
217
|
+
BlockType.HEADING,
|
|
218
|
+
title,
|
|
219
|
+
line_number,
|
|
220
|
+
line_number,
|
|
221
|
+
{
|
|
222
|
+
"heading_level": level,
|
|
223
|
+
"markdown_plus_id": block_id,
|
|
224
|
+
"markdown_plus_metadata": metadata,
|
|
225
|
+
"build_strategy": "markdown_plus_block_header",
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
open_mdp.append(_OpenMarkdownPlusBlock(block=block, explicit_title=bool(metadata.get("title"))))
|
|
229
|
+
heading_path[:] = [item.block.text.raw_text for item in open_mdp]
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
heading = _ATX_HEADING_RE.match(raw)
|
|
233
|
+
if heading:
|
|
234
|
+
flush_paragraph()
|
|
235
|
+
level = len(heading.group("marks"))
|
|
236
|
+
title = _clean_title(heading.group("title"))
|
|
237
|
+
heading_path[:] = heading_path[: level - 1] + [title]
|
|
238
|
+
open_mdp.clear()
|
|
239
|
+
add_block(
|
|
240
|
+
BlockType.HEADING,
|
|
241
|
+
title,
|
|
242
|
+
line_number,
|
|
243
|
+
line_number,
|
|
244
|
+
{
|
|
245
|
+
"heading_level": level,
|
|
246
|
+
"heading_path": list(heading_path),
|
|
247
|
+
"build_strategy": "markdown_atx_heading",
|
|
248
|
+
},
|
|
249
|
+
)
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
if not raw.strip():
|
|
253
|
+
flush_paragraph()
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
paragraph.append((line_number, raw))
|
|
257
|
+
|
|
258
|
+
flush_paragraph()
|