deeptutor-parsing 1.7.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.
- deeptutor/services/parsing/__init__.py +34 -0
- deeptutor/services/parsing/base.py +84 -0
- deeptutor/services/parsing/cache.py +203 -0
- deeptutor/services/parsing/engines/__init__.py +7 -0
- deeptutor/services/parsing/engines/_install.py +281 -0
- deeptutor/services/parsing/engines/_versions.py +38 -0
- deeptutor/services/parsing/engines/docling/__init__.py +6 -0
- deeptutor/services/parsing/engines/docling/config.py +59 -0
- deeptutor/services/parsing/engines/docling/engine.py +173 -0
- deeptutor/services/parsing/engines/docling/formats.py +137 -0
- deeptutor/services/parsing/engines/docling/local_worker.py +163 -0
- deeptutor/services/parsing/engines/docling/remote.py +192 -0
- deeptutor/services/parsing/engines/factory.py +216 -0
- deeptutor/services/parsing/engines/formats.py +29 -0
- deeptutor/services/parsing/engines/liteparse/__init__.py +7 -0
- deeptutor/services/parsing/engines/liteparse/config.py +65 -0
- deeptutor/services/parsing/engines/liteparse/engine.py +156 -0
- deeptutor/services/parsing/engines/liteparse/formats.py +58 -0
- deeptutor/services/parsing/engines/markitdown/__init__.py +6 -0
- deeptutor/services/parsing/engines/markitdown/config.py +31 -0
- deeptutor/services/parsing/engines/markitdown/engine.py +88 -0
- deeptutor/services/parsing/engines/markitdown/formats.py +102 -0
- deeptutor/services/parsing/engines/mineru/__init__.py +13 -0
- deeptutor/services/parsing/engines/mineru/_process.py +82 -0
- deeptutor/services/parsing/engines/mineru/backend.py +279 -0
- deeptutor/services/parsing/engines/mineru/checkpoints.py +98 -0
- deeptutor/services/parsing/engines/mineru/cloud.py +631 -0
- deeptutor/services/parsing/engines/mineru/config.py +143 -0
- deeptutor/services/parsing/engines/mineru/engine.py +87 -0
- deeptutor/services/parsing/engines/mineru/formats.py +46 -0
- deeptutor/services/parsing/engines/mineru/local.py +490 -0
- deeptutor/services/parsing/engines/mineru/models.py +228 -0
- deeptutor/services/parsing/engines/mineru/normalization.py +134 -0
- deeptutor/services/parsing/engines/mineru/readiness.py +121 -0
- deeptutor/services/parsing/engines/pymupdf4llm/__init__.py +8 -0
- deeptutor/services/parsing/engines/pymupdf4llm/config.py +36 -0
- deeptutor/services/parsing/engines/pymupdf4llm/engine.py +158 -0
- deeptutor/services/parsing/engines/pymupdf4llm/formats.py +60 -0
- deeptutor/services/parsing/engines/text_only/__init__.py +9 -0
- deeptutor/services/parsing/engines/text_only/engine.py +69 -0
- deeptutor/services/parsing/engines/tika/__init__.py +1 -0
- deeptutor/services/parsing/engines/tika/config.py +36 -0
- deeptutor/services/parsing/engines/tika/engine.py +68 -0
- deeptutor/services/parsing/engines/tika/formats.py +217 -0
- deeptutor/services/parsing/engines/tika/remote.py +143 -0
- deeptutor/services/parsing/progress.py +63 -0
- deeptutor/services/parsing/service.py +256 -0
- deeptutor/services/parsing/signature.py +50 -0
- deeptutor/services/parsing/types.py +52 -0
- deeptutor/textbook_struct/__init__.py +20 -0
- deeptutor/textbook_struct/chapter_rebuild.py +273 -0
- deeptutor/textbook_struct/column_blacklist.py +41 -0
- deeptutor/textbook_struct/page_headers.py +100 -0
- deeptutor_parsing-1.7.0.dist-info/METADATA +16 -0
- deeptutor_parsing-1.7.0.dist-info/RECORD +57 -0
- deeptutor_parsing-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_parsing-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared, engine-pluggable document-parsing layer (the "bridge").
|
|
2
|
+
|
|
3
|
+
Sits between input material and its consumers (question extraction, RAG
|
|
4
|
+
indexing, future LightRAG): one canonical IR (:class:`ParsedDocument`), one
|
|
5
|
+
content-addressed cache, a registry of pluggable engines (text-only, MinerU,
|
|
6
|
+
Docling, markitdown). Parsing is upstream of and independent from retrieval.
|
|
7
|
+
|
|
8
|
+
``ParseService`` / ``get_parse_service`` are the public entry; they are imported
|
|
9
|
+
lazily here to keep this package importable before the service module lands and
|
|
10
|
+
to avoid pulling engine deps at import time.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .base import Parser, ReadinessReport
|
|
16
|
+
from .signature import ParserSignature
|
|
17
|
+
from .types import ParsedDocument, ParserError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_parse_service():
|
|
21
|
+
"""Return the process-wide :class:`ParseService` singleton."""
|
|
22
|
+
from .service import get_parse_service as _get
|
|
23
|
+
|
|
24
|
+
return _get()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ParsedDocument",
|
|
29
|
+
"ParserError",
|
|
30
|
+
"Parser",
|
|
31
|
+
"ReadinessReport",
|
|
32
|
+
"ParserSignature",
|
|
33
|
+
"get_parse_service",
|
|
34
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Structural contract every document-parsing engine implements.
|
|
2
|
+
|
|
3
|
+
Parsing is deliberately kept OFF the ``RAGPipeline`` protocol
|
|
4
|
+
(``services/rag/pipelines/base.py``): it is an upstream, shared, *optional*
|
|
5
|
+
stage. An engine turns bytes/path into a :class:`~deeptutor.services.parsing.types.ParsedDocument`
|
|
6
|
+
and declares its availability + model readiness so the UI can gate local model
|
|
7
|
+
downloads (default: no silent multi-GB pull).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Optional, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from .signature import ParserSignature
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ReadinessReport:
|
|
21
|
+
"""Whether an engine can run a parse right now."""
|
|
22
|
+
|
|
23
|
+
ready: bool
|
|
24
|
+
# Machine code: "ready" | "models_missing" | "cli_missing" | "not_configured"
|
|
25
|
+
reason: str = "ready"
|
|
26
|
+
# User-facing guidance shown when not ready (which escape hatch to take).
|
|
27
|
+
message: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class Parser(Protocol):
|
|
32
|
+
"""A document-parsing engine: ``source_path`` in → ``ParsedDocument`` out.
|
|
33
|
+
|
|
34
|
+
Concrete engines also expose a ``classmethod is_available() -> bool`` (probed
|
|
35
|
+
by the factory before instantiation); it is intentionally not part of the
|
|
36
|
+
instance Protocol.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
# True when the engine downloads/needs local model weights (MinerU local,
|
|
41
|
+
# Docling). text-only, markitdown, and cloud backends are False.
|
|
42
|
+
needs_local_models: bool
|
|
43
|
+
|
|
44
|
+
def resolve_config(self) -> Any:
|
|
45
|
+
"""Load this engine's effective config slice from runtime settings."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
def supported_formats(self) -> frozenset[str]:
|
|
49
|
+
"""Lower-case file suffixes (including the dot) this engine can parse.
|
|
50
|
+
|
|
51
|
+
Compound suffixes such as ``.dclg.xml`` and ``.tar.gz`` are allowed.
|
|
52
|
+
An empty set means the engine performs its own content-based/runtime
|
|
53
|
+
detection (for example a remote Tika server with custom parsers).
|
|
54
|
+
"""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def signature(self, config: Any) -> ParserSignature:
|
|
58
|
+
"""Stable identity of ``(engine, version, output-affecting config)``."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def is_ready(self, config: Any) -> ReadinessReport:
|
|
62
|
+
"""Whether a parse can run now (models present / cloud configured)."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
def parse(
|
|
66
|
+
self,
|
|
67
|
+
source_path: Path,
|
|
68
|
+
workdir: Path,
|
|
69
|
+
*,
|
|
70
|
+
config: Any,
|
|
71
|
+
on_output: Optional[Callable[[str], None]] = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Write canonical artifacts for ``source_path`` into ``workdir``.
|
|
74
|
+
|
|
75
|
+
Engines emit ``<stem>.md`` (always) and may add
|
|
76
|
+
``<stem>_content_list.json`` and an ``images/`` dir. The caller
|
|
77
|
+
(``ParseService``) assembles the :class:`ParsedDocument` from the
|
|
78
|
+
workdir via :func:`deeptutor.services.parsing.cache.load_ir` and writes
|
|
79
|
+
the cache manifest. Raises :class:`ParserError` on failure.
|
|
80
|
+
"""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
__all__ = ["Parser", "ReadinessReport"]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Content-addressed parse cache + canonical IR loader.
|
|
2
|
+
|
|
3
|
+
The cache is keyed by ``(source_hash, parser_signature)`` and shared across all
|
|
4
|
+
consumers (question extraction, RAG indexing). Re-parsing the same bytes with
|
|
5
|
+
the same engine config is a directory lookup; a different engine/version/knob
|
|
6
|
+
lands in a different signature dir and re-parses. Layout::
|
|
7
|
+
|
|
8
|
+
parse_cache/<hash[:2]>/<source_hash>/<signature>/
|
|
9
|
+
manifest.json # atomically written last → completed
|
|
10
|
+
<stem>.md
|
|
11
|
+
<stem>_content_list.json # optional (engines that emit structure)
|
|
12
|
+
images/ # optional
|
|
13
|
+
|
|
14
|
+
``load_ir`` turns such a dir back into ``(markdown, blocks, asset_dir)``,
|
|
15
|
+
mirroring the question extractor's loader but free of stdout side effects so
|
|
16
|
+
both it and the engines share one loader.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
from uuid import uuid4
|
|
28
|
+
|
|
29
|
+
from deeptutor.services.file_io import atomic_write_json
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
MANIFEST_FILENAME = "manifest.json"
|
|
34
|
+
_HASH_PREFIX = "sha256"
|
|
35
|
+
_READ_CHUNK = 1 << 20 # 1 MiB
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def source_hash_from_path(path: Path) -> str:
|
|
39
|
+
"""Hash the *bytes* of ``path`` (not its name) so re-uploads of the same
|
|
40
|
+
document under a random temp name still hit cache."""
|
|
41
|
+
digest = hashlib.sha256()
|
|
42
|
+
with open(path, "rb") as handle:
|
|
43
|
+
for chunk in iter(lambda: handle.read(_READ_CHUNK), b""):
|
|
44
|
+
digest.update(chunk)
|
|
45
|
+
return digest.hexdigest()[:16]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def signature_dir(cache_root: Path, source_hash: str, sig_hash: str) -> Path:
|
|
49
|
+
return cache_root / source_hash[:2] / source_hash / sig_hash
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_ready(workdir: Optional[Path]) -> bool:
|
|
53
|
+
if workdir is None:
|
|
54
|
+
return False
|
|
55
|
+
try:
|
|
56
|
+
manifest = json.loads((workdir / MANIFEST_FILENAME).read_text(encoding="utf-8"))
|
|
57
|
+
return isinstance(manifest, dict) and bool(manifest.get("created_at"))
|
|
58
|
+
except (OSError, ValueError):
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def lookup(cache_root: Path, source_hash: str, sig_hash: str) -> Optional[Path]:
|
|
63
|
+
"""Return a ready cache dir for the key, or ``None`` on miss."""
|
|
64
|
+
target = signature_dir(cache_root, source_hash, sig_hash)
|
|
65
|
+
return target if is_ready(target) else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def reserve(cache_root: Path, source_hash: str, sig_hash: str) -> Path:
|
|
69
|
+
"""Create (or reuse) the signature dir the engine writes its artifacts into.
|
|
70
|
+
|
|
71
|
+
Incomplete attempts are retained beside the cache entry for diagnosis, but
|
|
72
|
+
never reused as completed parser output (#1612).
|
|
73
|
+
"""
|
|
74
|
+
target = signature_dir(cache_root, source_hash, sig_hash)
|
|
75
|
+
if target.exists() and not is_ready(target):
|
|
76
|
+
cleanup_failed(target)
|
|
77
|
+
if target.exists():
|
|
78
|
+
raise OSError(f"Could not retain incomplete parse output: {target}")
|
|
79
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
return target
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def write_manifest(workdir: Path, meta: dict[str, Any]) -> None:
|
|
84
|
+
"""Stamp the cache dir ready. Written last so a half-written dir never reads
|
|
85
|
+
as a cache hit."""
|
|
86
|
+
payload = {
|
|
87
|
+
**meta,
|
|
88
|
+
"created_at": datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + "Z",
|
|
89
|
+
}
|
|
90
|
+
atomic_write_json(workdir / MANIFEST_FILENAME, payload)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cleanup_failed(workdir: Path) -> None:
|
|
94
|
+
"""Retain unfinished work outside the active signature directory."""
|
|
95
|
+
try:
|
|
96
|
+
if workdir.is_dir() and not is_ready(workdir):
|
|
97
|
+
retained = workdir.with_name(f".{workdir.name}.failed-{uuid4().hex}")
|
|
98
|
+
workdir.rename(retained)
|
|
99
|
+
logger.warning("Retained incomplete parse output at %s", retained)
|
|
100
|
+
except Exception as exc: # pragma: no cover - best-effort
|
|
101
|
+
logger.warning("Could not clean up failed parse dir %s: %s", workdir, exc)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def find_content_dir(workdir: Path) -> Path:
|
|
105
|
+
"""Locate the dir holding parsed markdown artifacts.
|
|
106
|
+
|
|
107
|
+
Engines (MinerU especially) may nest output under ``auto/``/``hybrid_auto/``
|
|
108
|
+
or a per-document subdir. Mirrors the question extractor's resolver.
|
|
109
|
+
"""
|
|
110
|
+
candidate_dirs: list[Path] = []
|
|
111
|
+
|
|
112
|
+
for preferred_name in ("auto", "hybrid_auto"):
|
|
113
|
+
preferred = workdir / preferred_name
|
|
114
|
+
if preferred.is_dir():
|
|
115
|
+
candidate_dirs.append(preferred)
|
|
116
|
+
|
|
117
|
+
for child in sorted(workdir.iterdir()) if workdir.is_dir() else []:
|
|
118
|
+
if child.is_dir() and child not in candidate_dirs:
|
|
119
|
+
candidate_dirs.append(child)
|
|
120
|
+
|
|
121
|
+
nested = {
|
|
122
|
+
artifact.parent
|
|
123
|
+
for pattern in ("*.md", "*_content_list.json")
|
|
124
|
+
for artifact in (workdir.rglob(pattern) if workdir.is_dir() else [])
|
|
125
|
+
}
|
|
126
|
+
for artifact_dir in sorted(nested):
|
|
127
|
+
if artifact_dir not in candidate_dirs:
|
|
128
|
+
candidate_dirs.append(artifact_dir)
|
|
129
|
+
|
|
130
|
+
for candidate in candidate_dirs:
|
|
131
|
+
if list(candidate.glob("*.md")):
|
|
132
|
+
return candidate
|
|
133
|
+
|
|
134
|
+
return candidate_dirs[0] if candidate_dirs else workdir
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _absolutize_img_paths(blocks: list[dict], content_dir: Path) -> list[dict]:
|
|
138
|
+
"""Anchor relative ``img_path`` entries at ``content_dir``.
|
|
139
|
+
|
|
140
|
+
Engines (MinerU especially) emit ``img_path`` values like ``images/x.png``
|
|
141
|
+
relative to the content-list file. Consumers such as LightRAG resolve
|
|
142
|
+
them against their own working directory instead, so the images are never
|
|
143
|
+
found (issue #624). The cached JSON stays relative — paths are rewritten
|
|
144
|
+
only on load, keeping the cache dir relocatable.
|
|
145
|
+
"""
|
|
146
|
+
for block in blocks:
|
|
147
|
+
if not isinstance(block, dict):
|
|
148
|
+
continue
|
|
149
|
+
img_path = block.get("img_path")
|
|
150
|
+
if not isinstance(img_path, str) or not img_path:
|
|
151
|
+
continue
|
|
152
|
+
p = Path(img_path)
|
|
153
|
+
if p.is_absolute():
|
|
154
|
+
continue
|
|
155
|
+
if ".." in p.parts:
|
|
156
|
+
logger.warning("Skipping traversal img_path in content_list: %s", img_path)
|
|
157
|
+
continue
|
|
158
|
+
block["img_path"] = str(content_dir / img_path)
|
|
159
|
+
return blocks
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def load_ir(workdir: Path) -> tuple[str, Optional[list[dict]], Optional[Path]]:
|
|
163
|
+
"""Load ``(markdown, blocks, asset_dir)`` from a parsed/cached dir.
|
|
164
|
+
|
|
165
|
+
``markdown`` is "" if no ``.md`` exists; ``blocks`` is the parsed
|
|
166
|
+
``*_content_list.json`` or ``None``; ``asset_dir`` is the ``images/`` dir if
|
|
167
|
+
present.
|
|
168
|
+
"""
|
|
169
|
+
content_dir = find_content_dir(workdir)
|
|
170
|
+
|
|
171
|
+
markdown = ""
|
|
172
|
+
md_files = list(content_dir.glob("*.md"))
|
|
173
|
+
if md_files:
|
|
174
|
+
markdown = md_files[0].read_text(encoding="utf-8")
|
|
175
|
+
|
|
176
|
+
blocks: Optional[list[dict]] = None
|
|
177
|
+
json_files = list(content_dir.glob("*_content_list.json"))
|
|
178
|
+
if json_files:
|
|
179
|
+
try:
|
|
180
|
+
loaded = json.loads(json_files[0].read_text(encoding="utf-8"))
|
|
181
|
+
if isinstance(loaded, list):
|
|
182
|
+
blocks = _absolutize_img_paths(loaded, content_dir)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
logger.warning("Failed to read content_list %s: %s", json_files[0], exc)
|
|
185
|
+
|
|
186
|
+
images_dir = content_dir / "images"
|
|
187
|
+
asset_dir = images_dir if images_dir.is_dir() else None
|
|
188
|
+
|
|
189
|
+
return markdown, blocks, asset_dir
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
__all__ = [
|
|
193
|
+
"MANIFEST_FILENAME",
|
|
194
|
+
"source_hash_from_path",
|
|
195
|
+
"signature_dir",
|
|
196
|
+
"is_ready",
|
|
197
|
+
"lookup",
|
|
198
|
+
"reserve",
|
|
199
|
+
"write_manifest",
|
|
200
|
+
"cleanup_failed",
|
|
201
|
+
"find_content_dir",
|
|
202
|
+
"load_ir",
|
|
203
|
+
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Pluggable document-parsing engines.
|
|
2
|
+
|
|
3
|
+
Each engine implements the :class:`~deeptutor.services.parsing.base.Parser`
|
|
4
|
+
contract and is selected by name through :mod:`deeptutor.services.parsing.engines.factory`.
|
|
5
|
+
Third-party imports are lazy so an engine whose dependency is absent simply
|
|
6
|
+
reports ``is_available() is False`` instead of breaking import.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""One-click background jobs for optional parser engines.
|
|
2
|
+
|
|
3
|
+
The Document Parsing settings page runs two kinds of background job without the
|
|
4
|
+
user dropping to a shell:
|
|
5
|
+
|
|
6
|
+
* **install** — ``pip install`` an engine's optional PyPI package.
|
|
7
|
+
* **models** — download an engine's model weights (e.g. Docling's layout/table
|
|
8
|
+
models via ``docling-tools models download``).
|
|
9
|
+
|
|
10
|
+
Both mirror the MinerU model-download job (``mineru/models.py``): a single
|
|
11
|
+
background subprocess with a cursor-based line log the UI starts, polls, and
|
|
12
|
+
cancels. Commands come from fixed allow-lists (pip specs / downloader argv),
|
|
13
|
+
never user input, so the subprocess argv can't be injected. Package installs use
|
|
14
|
+
the bare package specs (the same strings as the ``[parse-*]`` extras) so pip
|
|
15
|
+
never re-resolves DeepTutor itself; after a successful install we invalidate the
|
|
16
|
+
import caches so the engine reports available in the same process (no restart).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import importlib
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess # nosec B404 - argv from fixed allow-lists, never user input
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from typing import Any, Callable, Optional
|
|
31
|
+
|
|
32
|
+
from ._versions import package_version
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# Engine id -> pip requirement specifiers. Mirrors the optional extras in
|
|
37
|
+
# pyproject.toml. Engines absent here (text_only built-in, mineru external
|
|
38
|
+
# CLI/API) have no one-click install.
|
|
39
|
+
from deeptutor.modules.catalog import manifests
|
|
40
|
+
|
|
41
|
+
ENGINE_PIP_SPECS: dict[str, list[str]] = {
|
|
42
|
+
row["parser_engine"]: list(row["requirements"])
|
|
43
|
+
for row in manifests().values()
|
|
44
|
+
if "parser_engine" in row
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Engine id -> console-script argv that downloads its model weights. The script
|
|
48
|
+
# is resolved next to the server's python first (same env), then on PATH.
|
|
49
|
+
ENGINE_MODEL_DOWNLOADERS: dict[str, list[str]] = {
|
|
50
|
+
"docling": ["docling-tools", "models", "download"],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Buffered log lines kept in memory; older lines are dropped (the cursor
|
|
54
|
+
# protocol keeps clients consistent across trims).
|
|
55
|
+
_MAX_LINES = 2000
|
|
56
|
+
_LINE_MIN_INTERVAL = 0.2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def installable_engines() -> frozenset[str]:
|
|
60
|
+
"""Engine ids that support one-click pip install."""
|
|
61
|
+
return frozenset(ENGINE_PIP_SPECS)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def model_downloadable_engines() -> frozenset[str]:
|
|
65
|
+
"""Engine ids that support one-click model-weight download."""
|
|
66
|
+
return frozenset(ENGINE_MODEL_DOWNLOADERS)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_model_downloader(engine: str) -> Optional[list[str]]:
|
|
70
|
+
"""Resolve the model-download argv for ``engine``.
|
|
71
|
+
|
|
72
|
+
Locates the console script next to the server's python first (same env so it
|
|
73
|
+
matches the installed engine), then falls back to PATH. Returns ``None`` if
|
|
74
|
+
the engine has no downloader or the script isn't found.
|
|
75
|
+
"""
|
|
76
|
+
parts = ENGINE_MODEL_DOWNLOADERS.get(engine)
|
|
77
|
+
if not parts:
|
|
78
|
+
return None
|
|
79
|
+
name = parts[0]
|
|
80
|
+
# Let the platform resolver search the active Python environment too. On
|
|
81
|
+
# Windows pip installs console scripts as ``<name>.exe`` and ``which``
|
|
82
|
+
# applies PATHEXT; probing the extensionless Path directly misses it.
|
|
83
|
+
sibling = shutil.which(name, path=str(Path(sys.executable).parent))
|
|
84
|
+
if sibling:
|
|
85
|
+
return [sibling, *parts[1:]]
|
|
86
|
+
found = shutil.which(name)
|
|
87
|
+
if found:
|
|
88
|
+
return [found, *parts[1:]]
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _invalidate_import_caches() -> None:
|
|
93
|
+
"""Make a freshly installed package importable in this process and bust the
|
|
94
|
+
cached "" version so readiness flips without a server restart."""
|
|
95
|
+
try:
|
|
96
|
+
importlib.invalidate_caches()
|
|
97
|
+
package_version.cache_clear()
|
|
98
|
+
from .markitdown.formats import markitdown_supported_formats
|
|
99
|
+
|
|
100
|
+
# Docling format discovery is intentionally static and has no cache to
|
|
101
|
+
# invalidate: importing its runtime would pull PyTorch's OpenMP runtime
|
|
102
|
+
# into the FAISS backend process on macOS.
|
|
103
|
+
markitdown_supported_formats.cache_clear()
|
|
104
|
+
except Exception: # noqa: BLE001 - best effort
|
|
105
|
+
logger.exception("Post-install cache invalidation failed")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class BackgroundJobManager:
|
|
109
|
+
"""At most one background subprocess (install or model download), with a
|
|
110
|
+
cursor-based line log.
|
|
111
|
+
|
|
112
|
+
States: ``idle`` → ``running`` → ``done`` / ``failed`` / ``cancelled``.
|
|
113
|
+
``status(cursor)`` returns lines after ``cursor`` plus ``next_cursor`` so the
|
|
114
|
+
UI can poll incrementally; ``kind`` (``install`` | ``models``) and ``engine``
|
|
115
|
+
tell the UI which job/card the log belongs to.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self) -> None:
|
|
119
|
+
self._lock = threading.Lock()
|
|
120
|
+
self._state = "idle"
|
|
121
|
+
self._kind = ""
|
|
122
|
+
self._engine = ""
|
|
123
|
+
self._lines: list[str] = []
|
|
124
|
+
self._base = 0
|
|
125
|
+
self._message = ""
|
|
126
|
+
self._process: subprocess.Popen | None = None
|
|
127
|
+
self._cancel_requested = False
|
|
128
|
+
|
|
129
|
+
def start_install(self, *, engine: str, specs: list[str]) -> dict[str, Any]:
|
|
130
|
+
# Host-package changes cannot be safe while turns/API workers run.
|
|
131
|
+
# Model downloads remain background jobs because they do not mutate code.
|
|
132
|
+
if engine not in ENGINE_PIP_SPECS or specs != ENGINE_PIP_SPECS[engine]:
|
|
133
|
+
return {"ok": False, "message": "Unknown parser installation request."}
|
|
134
|
+
return {
|
|
135
|
+
"ok": False,
|
|
136
|
+
"message": (
|
|
137
|
+
"Stop DeepTutor, then run `deeptutor module install parser-"
|
|
138
|
+
+ engine
|
|
139
|
+
+ "` in this environment and restart. User data is preserved."
|
|
140
|
+
),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def start_model_download(self, *, engine: str, cmd: list[str]) -> dict[str, Any]:
|
|
144
|
+
return self._launch(kind="models", engine=engine, cmd=cmd)
|
|
145
|
+
|
|
146
|
+
def status(self, cursor: int = 0) -> dict[str, Any]:
|
|
147
|
+
with self._lock:
|
|
148
|
+
start = max(int(cursor) - self._base, 0)
|
|
149
|
+
return {
|
|
150
|
+
"state": self._state,
|
|
151
|
+
"kind": self._kind,
|
|
152
|
+
"engine": self._engine,
|
|
153
|
+
"lines": list(self._lines[start:]),
|
|
154
|
+
"next_cursor": self._base + len(self._lines),
|
|
155
|
+
"message": self._message,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
def cancel(self) -> dict[str, Any]:
|
|
159
|
+
with self._lock:
|
|
160
|
+
process = self._process
|
|
161
|
+
running = self._state == "running"
|
|
162
|
+
if running:
|
|
163
|
+
self._cancel_requested = True
|
|
164
|
+
if not (running and process):
|
|
165
|
+
return {"ok": False, "message": "No job is running."}
|
|
166
|
+
if process.poll() is None:
|
|
167
|
+
try:
|
|
168
|
+
process.terminate()
|
|
169
|
+
except Exception as exc: # noqa: BLE001
|
|
170
|
+
return {"ok": False, "message": f"Failed to cancel: {exc}"}
|
|
171
|
+
return {"ok": True, "message": ""}
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def _launch(
|
|
176
|
+
self,
|
|
177
|
+
*,
|
|
178
|
+
kind: str,
|
|
179
|
+
engine: str,
|
|
180
|
+
cmd: list[str],
|
|
181
|
+
env: Optional[dict[str, str]] = None,
|
|
182
|
+
on_success: Optional[Callable[[], None]] = None,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
with self._lock:
|
|
185
|
+
if self._state == "running":
|
|
186
|
+
return {
|
|
187
|
+
"ok": False,
|
|
188
|
+
"message": f"A {self._kind or 'job'} is already running ({self._engine}).",
|
|
189
|
+
}
|
|
190
|
+
try:
|
|
191
|
+
process = subprocess.Popen( # nosec B603 - argv from fixed allow-lists
|
|
192
|
+
cmd,
|
|
193
|
+
stdout=subprocess.PIPE,
|
|
194
|
+
stderr=subprocess.STDOUT,
|
|
195
|
+
text=True,
|
|
196
|
+
shell=False,
|
|
197
|
+
env={**os.environ, **(env or {})},
|
|
198
|
+
)
|
|
199
|
+
except Exception as exc: # noqa: BLE001
|
|
200
|
+
self._state = "failed"
|
|
201
|
+
self._kind = kind
|
|
202
|
+
self._engine = engine
|
|
203
|
+
self._message = f"Failed to launch: {exc}"
|
|
204
|
+
return {"ok": False, "message": self._message}
|
|
205
|
+
self._state = "running"
|
|
206
|
+
self._kind = kind
|
|
207
|
+
self._engine = engine
|
|
208
|
+
self._lines = []
|
|
209
|
+
self._base = 0
|
|
210
|
+
self._message = ""
|
|
211
|
+
self._process = process
|
|
212
|
+
self._cancel_requested = False
|
|
213
|
+
thread = threading.Thread(
|
|
214
|
+
target=self._pump, args=(process, kind, engine, on_success), daemon=True
|
|
215
|
+
)
|
|
216
|
+
thread.start()
|
|
217
|
+
logger.info("Parser %s job started (%s): %s", kind, engine, " ".join(cmd))
|
|
218
|
+
return {"ok": True, "message": ""}
|
|
219
|
+
|
|
220
|
+
def _pump(
|
|
221
|
+
self,
|
|
222
|
+
process: subprocess.Popen,
|
|
223
|
+
kind: str,
|
|
224
|
+
engine: str,
|
|
225
|
+
on_success: Optional[Callable[[], None]],
|
|
226
|
+
) -> None:
|
|
227
|
+
last_emit = 0.0
|
|
228
|
+
try:
|
|
229
|
+
assert process.stdout is not None
|
|
230
|
+
for raw_line in process.stdout:
|
|
231
|
+
line = raw_line.strip()
|
|
232
|
+
if not line:
|
|
233
|
+
continue
|
|
234
|
+
now = time.monotonic()
|
|
235
|
+
if now - last_emit < _LINE_MIN_INTERVAL:
|
|
236
|
+
continue
|
|
237
|
+
last_emit = now
|
|
238
|
+
self._append(line[:300])
|
|
239
|
+
except Exception:
|
|
240
|
+
logger.exception("Background job output pump failed")
|
|
241
|
+
returncode = process.wait()
|
|
242
|
+
with self._lock:
|
|
243
|
+
if self._cancel_requested:
|
|
244
|
+
self._state = "cancelled"
|
|
245
|
+
self._message = "Cancelled."
|
|
246
|
+
elif returncode == 0:
|
|
247
|
+
self._state = "done"
|
|
248
|
+
self._message = "Finished."
|
|
249
|
+
else:
|
|
250
|
+
self._state = "failed"
|
|
251
|
+
self._message = f"Exited with code {returncode}."
|
|
252
|
+
self._process = None
|
|
253
|
+
if returncode == 0 and not self._cancel_requested and on_success is not None:
|
|
254
|
+
on_success()
|
|
255
|
+
logger.info("Parser %s job finished (%s): %s", kind, engine, self._state)
|
|
256
|
+
|
|
257
|
+
def _append(self, line: str) -> None:
|
|
258
|
+
with self._lock:
|
|
259
|
+
self._lines.append(line)
|
|
260
|
+
overflow = len(self._lines) - _MAX_LINES
|
|
261
|
+
if overflow > 0:
|
|
262
|
+
del self._lines[:overflow]
|
|
263
|
+
self._base += overflow
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
_manager = BackgroundJobManager()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def get_background_job_manager() -> BackgroundJobManager:
|
|
270
|
+
return _manager
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
__all__ = [
|
|
274
|
+
"ENGINE_MODEL_DOWNLOADERS",
|
|
275
|
+
"ENGINE_PIP_SPECS",
|
|
276
|
+
"BackgroundJobManager",
|
|
277
|
+
"get_background_job_manager",
|
|
278
|
+
"installable_engines",
|
|
279
|
+
"model_downloadable_engines",
|
|
280
|
+
"resolve_model_downloader",
|
|
281
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Cheap, cached package-version lookup for parser signatures.
|
|
2
|
+
|
|
3
|
+
Using the installed distribution version (not a CLI ``--version`` subprocess)
|
|
4
|
+
keeps signature computation cheap while still invalidating the parse cache when
|
|
5
|
+
an engine is upgraded.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
import importlib.metadata
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@lru_cache(maxsize=None)
|
|
16
|
+
def package_version(name: str) -> str:
|
|
17
|
+
try:
|
|
18
|
+
return importlib.metadata.version(name)
|
|
19
|
+
except Exception:
|
|
20
|
+
return ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def version_at_least(version: str | None, minimum: str) -> bool:
|
|
24
|
+
"""Compare numeric release components without importing packaging tooling."""
|
|
25
|
+
|
|
26
|
+
def release(value: str | None) -> tuple[int, ...]:
|
|
27
|
+
match = re.match(r"\d+(?:\.\d+)*", str(value or "").strip())
|
|
28
|
+
return tuple(int(part) for part in match.group(0).split(".")) if match else ()
|
|
29
|
+
|
|
30
|
+
current = release(version)
|
|
31
|
+
required = release(minimum)
|
|
32
|
+
width = max(len(current), len(required))
|
|
33
|
+
return bool(current and required) and current + (0,) * (width - len(current)) >= required + (
|
|
34
|
+
0,
|
|
35
|
+
) * (width - len(required))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = ["package_version", "version_at_least"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Docling engine — structured document conversion (downloads local models).
|
|
2
|
+
|
|
3
|
+
Converts every input format advertised by the supported Docling release into
|
|
4
|
+
Markdown. Downloads layout/table models on first run, so it shares the same
|
|
5
|
+
``allow_local_model_download`` gate as MinerU local.
|
|
6
|
+
"""
|