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.
Files changed (56) hide show
  1. documa/__init__.py +41 -0
  2. documa/adapters/__init__.py +7 -0
  3. documa/adapters/base.py +31 -0
  4. documa/adapters/markdown_adapter.py +258 -0
  5. documa/adapters/pymupdf_adapter.py +781 -0
  6. documa/cli.py +245 -0
  7. documa/core/__init__.py +2 -0
  8. documa/core/encoding.py +66 -0
  9. documa/core/errors.py +45 -0
  10. documa/core/image_filtering.py +71 -0
  11. documa/core/ir.py +254 -0
  12. documa/core/language.py +53 -0
  13. documa/core/serialization.py +201 -0
  14. documa/core/text_normalization.py +46 -0
  15. documa/demo/__init__.py +5 -0
  16. documa/demo/block_reading.py +494 -0
  17. documa/exporters/__init__.py +9 -0
  18. documa/exporters/base.py +30 -0
  19. documa/exporters/block_json.py +125 -0
  20. documa/exporters/json_exporter.py +18 -0
  21. documa/exporters/markdown.py +75 -0
  22. documa/exporters/rag_json.py +50 -0
  23. documa/interfaces/__init__.py +38 -0
  24. documa/interfaces/mcp_server.py +190 -0
  25. documa/interfaces/tool_schemas.py +300 -0
  26. documa/interfaces/tools.py +773 -0
  27. documa/pipeline/__init__.py +40 -0
  28. documa/pipeline/base.py +35 -0
  29. documa/pipeline/block_keywords.py +279 -0
  30. documa/pipeline/block_tree.py +290 -0
  31. documa/pipeline/captions.py +123 -0
  32. documa/pipeline/chunking.py +382 -0
  33. documa/pipeline/footnotes.py +97 -0
  34. documa/pipeline/images.py +66 -0
  35. documa/pipeline/inline_semantics.py +56 -0
  36. documa/pipeline/layout.py +177 -0
  37. documa/pipeline/page_refs.py +105 -0
  38. documa/pipeline/paragraphs.py +161 -0
  39. documa/pipeline/provenance.py +41 -0
  40. documa/pipeline/reading_order.py +110 -0
  41. documa/pipeline/relations.py +63 -0
  42. documa/pipeline/runner.py +84 -0
  43. documa/pipeline/tables.py +80 -0
  44. documa/pipeline/toc.py +125 -0
  45. documa/quality/__init__.py +16 -0
  46. documa/quality/benchmark.py +121 -0
  47. documa/quality/doctor.py +100 -0
  48. documa/quality/fixture_manifest.py +68 -0
  49. documa/storage/__init__.py +6 -0
  50. documa/storage/assets.py +49 -0
  51. documa-0.1.0.dist-info/METADATA +400 -0
  52. documa-0.1.0.dist-info/RECORD +56 -0
  53. documa-0.1.0.dist-info/WHEEL +5 -0
  54. documa-0.1.0.dist-info/entry_points.txt +3 -0
  55. documa-0.1.0.dist-info/licenses/LICENSE +21 -0
  56. 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"]
@@ -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()