deeptutor-knowledge 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/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Freeze DeepTutor parse results into version-local LightRAG ingress bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import shutil
|
|
12
|
+
from typing import Any
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from deeptutor.services.file_io import atomic_write_json
|
|
16
|
+
from deeptutor.services.parsing.types import ParsedDocument
|
|
17
|
+
|
|
18
|
+
from . import block_policy
|
|
19
|
+
|
|
20
|
+
PARSER_BRIDGE_SCHEMA = 1
|
|
21
|
+
INGRESS_DIRNAME = "deeptutor_ingress"
|
|
22
|
+
PENDING_DIRNAME = "pending"
|
|
23
|
+
BUNDLES_DIRNAME = "bundles"
|
|
24
|
+
MANIFEST_FILENAME = "manifest.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IngressError(RuntimeError):
|
|
28
|
+
"""Raised when an ingress bundle crosses or violates its trust boundary."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class StagedDocument:
|
|
33
|
+
canonical_name: str
|
|
34
|
+
source_path: Path
|
|
35
|
+
bundle_dir: Path
|
|
36
|
+
manifest_path: Path
|
|
37
|
+
process_options: str
|
|
38
|
+
chunk_options: dict[str, Any]
|
|
39
|
+
audit_ledger: dict[str, Any] | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ingress_root(working_dir: Path) -> Path:
|
|
43
|
+
return Path(working_dir) / INGRESS_DIRNAME
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pending_root(working_dir: Path) -> Path:
|
|
47
|
+
return ingress_root(working_dir) / PENDING_DIRNAME
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def bundles_root(working_dir: Path) -> Path:
|
|
51
|
+
return ingress_root(working_dir) / BUNDLES_DIRNAME
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sha256(path: Path) -> str:
|
|
55
|
+
digest = hashlib.sha256()
|
|
56
|
+
with path.open("rb") as handle:
|
|
57
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
58
|
+
digest.update(chunk)
|
|
59
|
+
return digest.hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _contained_regular_file(root: Path, path: Path, *, label: str) -> Path:
|
|
63
|
+
if root.is_symlink() or not root.is_dir():
|
|
64
|
+
raise IngressError(f"Bundle root must be an ordinary directory: {root}")
|
|
65
|
+
try:
|
|
66
|
+
relative = path.relative_to(root)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
raise IngressError(f"{label} escapes its bundle root: {path}") from exc
|
|
69
|
+
current = root
|
|
70
|
+
for part in relative.parts:
|
|
71
|
+
current = current / part
|
|
72
|
+
if current.is_symlink():
|
|
73
|
+
raise IngressError(f"{label} must not traverse a symbolic link: {current}")
|
|
74
|
+
if path.is_symlink():
|
|
75
|
+
raise IngressError(f"{label} must not be a symbolic link: {path}")
|
|
76
|
+
try:
|
|
77
|
+
resolved_root = root.resolve(strict=True)
|
|
78
|
+
resolved = path.resolve(strict=True)
|
|
79
|
+
resolved.relative_to(resolved_root)
|
|
80
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
81
|
+
raise IngressError(f"{label} escapes its bundle root: {path}") from exc
|
|
82
|
+
stat = resolved.stat()
|
|
83
|
+
if not resolved.is_file() or stat.st_nlink != 1:
|
|
84
|
+
raise IngressError(f"{label} must be an ordinary, unlinked file: {path}")
|
|
85
|
+
return resolved
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _copy_regular(source: Path, target: Path, *, allowed_root: Path | None = None) -> str:
|
|
89
|
+
if source.is_symlink() or not source.is_file():
|
|
90
|
+
raise IngressError(f"Source is not an ordinary file: {source}")
|
|
91
|
+
resolved_source = source.resolve(strict=True)
|
|
92
|
+
if resolved_source.stat().st_nlink != 1:
|
|
93
|
+
raise IngressError(f"Source must not be hard-linked: {source}")
|
|
94
|
+
if allowed_root is not None:
|
|
95
|
+
try:
|
|
96
|
+
resolved_source.relative_to(allowed_root.resolve(strict=True))
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise IngressError(f"Source escapes allowed root: {source}") from exc
|
|
99
|
+
source_digest = _sha256(resolved_source)
|
|
100
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
with resolved_source.open("rb") as src, target.open("xb") as dst:
|
|
102
|
+
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
|
103
|
+
if target.is_symlink() or os.path.samefile(resolved_source, target):
|
|
104
|
+
raise IngressError(f"Frozen copy must not share the source file: {target}")
|
|
105
|
+
target_digest = _sha256(target)
|
|
106
|
+
if target_digest != source_digest:
|
|
107
|
+
raise IngressError(f"Digest drift while freezing {source.name}")
|
|
108
|
+
if target.stat().st_nlink != 1:
|
|
109
|
+
raise IngressError(f"Frozen copy has unexpected hard links: {target}")
|
|
110
|
+
return target_digest
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _write_new_bytes(path: Path, data: bytes) -> str:
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
with path.open("xb") as handle:
|
|
116
|
+
handle.write(data)
|
|
117
|
+
handle.flush()
|
|
118
|
+
os.fsync(handle.fileno())
|
|
119
|
+
return hashlib.sha256(data).hexdigest()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _asset_files(asset_dir: Path | None) -> list[tuple[Path, Path]]:
|
|
123
|
+
if asset_dir is None:
|
|
124
|
+
return []
|
|
125
|
+
root = Path(asset_dir)
|
|
126
|
+
if root.is_symlink() or not root.is_dir():
|
|
127
|
+
raise IngressError(f"Parser asset directory is not an ordinary directory: {root}")
|
|
128
|
+
files: list[tuple[Path, Path]] = []
|
|
129
|
+
for path in sorted(root.rglob("*")):
|
|
130
|
+
if path.is_symlink():
|
|
131
|
+
raise IngressError(f"Parser asset is a symbolic link: {path}")
|
|
132
|
+
if path.is_dir():
|
|
133
|
+
continue
|
|
134
|
+
if not path.is_file():
|
|
135
|
+
raise IngressError(f"Parser asset is not an ordinary file: {path}")
|
|
136
|
+
files.append((path, path.relative_to(root)))
|
|
137
|
+
return files
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _freeze_asset_paths(
|
|
141
|
+
blocks: list[dict[str, Any]],
|
|
142
|
+
asset_dir: Path | None,
|
|
143
|
+
asset_files: list[tuple[Path, Path]],
|
|
144
|
+
) -> list[dict[str, Any]]:
|
|
145
|
+
"""Rewrite parser-owned asset paths to bundle-relative copied paths."""
|
|
146
|
+
frozen = deepcopy(blocks)
|
|
147
|
+
if asset_dir is None:
|
|
148
|
+
return frozen
|
|
149
|
+
root = Path(asset_dir).resolve(strict=True)
|
|
150
|
+
by_source = {source.resolve(strict=True): relative for source, relative in asset_files}
|
|
151
|
+
by_relative = {relative: relative for _, relative in asset_files}
|
|
152
|
+
for index, block in enumerate(frozen):
|
|
153
|
+
if not isinstance(block, dict):
|
|
154
|
+
continue
|
|
155
|
+
kind = str(block.get("type") or block.get("label") or "").lower()
|
|
156
|
+
if kind not in {"image", "picture", "drawing", "chart"}:
|
|
157
|
+
continue
|
|
158
|
+
for key in ("img_path", "path"):
|
|
159
|
+
raw = block.get(key)
|
|
160
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
161
|
+
continue
|
|
162
|
+
path = Path(raw)
|
|
163
|
+
relative: Path | None = None
|
|
164
|
+
if path.is_absolute():
|
|
165
|
+
try:
|
|
166
|
+
resolved = path.resolve(strict=True)
|
|
167
|
+
except FileNotFoundError as exc:
|
|
168
|
+
raise IngressError(f"Block {index} asset does not exist: {raw}") from exc
|
|
169
|
+
relative = by_source.get(resolved)
|
|
170
|
+
elif ".." not in path.parts:
|
|
171
|
+
relative = by_relative.get(path)
|
|
172
|
+
if relative is None:
|
|
173
|
+
candidate = root / path
|
|
174
|
+
try:
|
|
175
|
+
relative = by_source.get(candidate.resolve(strict=True))
|
|
176
|
+
except FileNotFoundError:
|
|
177
|
+
relative = None
|
|
178
|
+
if relative is None:
|
|
179
|
+
raise IngressError(f"Block {index} asset is outside the frozen asset set: {raw}")
|
|
180
|
+
block[key] = relative.as_posix()
|
|
181
|
+
return frozen
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def freeze_document(working_dir: Path, source: Path, parsed: ParsedDocument) -> StagedDocument:
|
|
185
|
+
"""Copy one parsed document into an immutable, digest-verified bundle."""
|
|
186
|
+
source = Path(source)
|
|
187
|
+
canonical_name = source.name
|
|
188
|
+
if not canonical_name or canonical_name in {".", ".."}:
|
|
189
|
+
raise IngressError(f"Document has no canonical basename: {source}")
|
|
190
|
+
pending = pending_root(working_dir)
|
|
191
|
+
bundles = bundles_root(working_dir)
|
|
192
|
+
pending.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
bundles.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
staged_source = pending / canonical_name
|
|
195
|
+
archived_source = pending / "__parsed__" / canonical_name
|
|
196
|
+
final_bundle = bundles / f"{canonical_name}.bundle"
|
|
197
|
+
if staged_source.exists() or archived_source.exists() or final_bundle.exists():
|
|
198
|
+
raise IngressError(f"Canonical basename already exists in this version: {canonical_name}")
|
|
199
|
+
|
|
200
|
+
temp_bundle = bundles / f".{canonical_name}.{uuid.uuid4().hex}.tmp"
|
|
201
|
+
temp_bundle.mkdir()
|
|
202
|
+
try:
|
|
203
|
+
asset_files = _asset_files(parsed.asset_dir)
|
|
204
|
+
source_digest = _sha256(source.resolve(strict=True))
|
|
205
|
+
staged_digest = _copy_regular(source, staged_source)
|
|
206
|
+
if source_digest != staged_digest:
|
|
207
|
+
raise IngressError(f"Source changed while staging {canonical_name}")
|
|
208
|
+
|
|
209
|
+
markdown = parsed.markdown.encode("utf-8")
|
|
210
|
+
markdown_rel = Path("markdown.utf8")
|
|
211
|
+
markdown_digest = _write_new_bytes(temp_bundle / markdown_rel, markdown)
|
|
212
|
+
|
|
213
|
+
decision = None
|
|
214
|
+
blocks_rel: Path | None = None
|
|
215
|
+
blocks_digest: str | None = None
|
|
216
|
+
frozen_blocks: list[dict[str, Any]] | None = None
|
|
217
|
+
if parsed.blocks:
|
|
218
|
+
decision = block_policy.prepare_content_list(
|
|
219
|
+
parsed.blocks,
|
|
220
|
+
engine=parsed.engine,
|
|
221
|
+
source_hash=parsed.source_hash,
|
|
222
|
+
parser_signature=parsed.parser_signature,
|
|
223
|
+
)
|
|
224
|
+
frozen_blocks = _freeze_asset_paths(
|
|
225
|
+
decision.content_list, parsed.asset_dir, asset_files
|
|
226
|
+
)
|
|
227
|
+
blocks_rel = Path("blocks.json")
|
|
228
|
+
blocks_bytes = json.dumps(
|
|
229
|
+
frozen_blocks, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
230
|
+
).encode("utf-8")
|
|
231
|
+
blocks_digest = _write_new_bytes(temp_bundle / blocks_rel, blocks_bytes)
|
|
232
|
+
elif not parsed.markdown.strip():
|
|
233
|
+
raise IngressError(f"Parsed document is empty: {canonical_name}")
|
|
234
|
+
|
|
235
|
+
asset_records: list[dict[str, Any]] = []
|
|
236
|
+
for asset, relative in asset_files:
|
|
237
|
+
target = temp_bundle / "assets" / relative
|
|
238
|
+
digest = _copy_regular(asset, target, allowed_root=Path(parsed.asset_dir))
|
|
239
|
+
asset_records.append(
|
|
240
|
+
{"path": target.relative_to(temp_bundle).as_posix(), "sha256": digest}
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
kinds = {
|
|
244
|
+
str(block.get("type") or block.get("label") or "").lower()
|
|
245
|
+
for block in (frozen_blocks or [])
|
|
246
|
+
if isinstance(block, dict)
|
|
247
|
+
}
|
|
248
|
+
modalities = "".join(
|
|
249
|
+
flag
|
|
250
|
+
for flag, names in (
|
|
251
|
+
("i", {"image", "picture", "drawing", "chart"}),
|
|
252
|
+
("t", {"table"}),
|
|
253
|
+
("e", {"equation", "formula"}),
|
|
254
|
+
)
|
|
255
|
+
if kinds & names
|
|
256
|
+
)
|
|
257
|
+
process_options = f"P{modalities}" if frozen_blocks else "F"
|
|
258
|
+
chunk_options = (
|
|
259
|
+
{"paragraph_semantic": {"chunk_token_size": 1200}}
|
|
260
|
+
if frozen_blocks
|
|
261
|
+
else {"fixed_token": {}}
|
|
262
|
+
)
|
|
263
|
+
manifest = {
|
|
264
|
+
"parser_bridge_schema": PARSER_BRIDGE_SCHEMA,
|
|
265
|
+
"canonical_filename": canonical_name,
|
|
266
|
+
"parser": {
|
|
267
|
+
"engine": parsed.engine,
|
|
268
|
+
"source_hash": parsed.source_hash,
|
|
269
|
+
"parser_signature": parsed.parser_signature,
|
|
270
|
+
},
|
|
271
|
+
"source": {
|
|
272
|
+
"path": str(Path("..") / ".." / PENDING_DIRNAME / canonical_name),
|
|
273
|
+
"sha256": staged_digest,
|
|
274
|
+
},
|
|
275
|
+
"markdown": {"path": markdown_rel.as_posix(), "sha256": markdown_digest},
|
|
276
|
+
"blocks": (
|
|
277
|
+
{"path": blocks_rel.as_posix(), "sha256": blocks_digest}
|
|
278
|
+
if blocks_rel is not None
|
|
279
|
+
else None
|
|
280
|
+
),
|
|
281
|
+
"assets": asset_records,
|
|
282
|
+
"process_options": process_options,
|
|
283
|
+
"chunk_options": chunk_options,
|
|
284
|
+
"block_policy": decision.ledger if decision is not None else None,
|
|
285
|
+
}
|
|
286
|
+
atomic_write_json(temp_bundle / MANIFEST_FILENAME, manifest)
|
|
287
|
+
os.replace(temp_bundle, final_bundle)
|
|
288
|
+
return StagedDocument(
|
|
289
|
+
canonical_name=canonical_name,
|
|
290
|
+
source_path=staged_source,
|
|
291
|
+
bundle_dir=final_bundle,
|
|
292
|
+
manifest_path=final_bundle / MANIFEST_FILENAME,
|
|
293
|
+
process_options=process_options,
|
|
294
|
+
chunk_options=chunk_options,
|
|
295
|
+
audit_ledger=decision.ledger if decision is not None else None,
|
|
296
|
+
)
|
|
297
|
+
except BaseException:
|
|
298
|
+
shutil.rmtree(temp_bundle, ignore_errors=True)
|
|
299
|
+
if staged_source.exists() and not staged_source.is_symlink():
|
|
300
|
+
staged_source.unlink()
|
|
301
|
+
raise
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def load_verified_bundle(working_dir: Path, canonical_name: str) -> tuple[dict[str, Any], Path]:
|
|
305
|
+
"""Load and digest-verify one published bundle by canonical basename."""
|
|
306
|
+
if Path(canonical_name).name != canonical_name or Path(canonical_name).is_absolute():
|
|
307
|
+
raise IngressError(f"Invalid canonical basename: {canonical_name!r}")
|
|
308
|
+
bundle = bundles_root(working_dir) / f"{canonical_name}.bundle"
|
|
309
|
+
manifest_path = bundle / MANIFEST_FILENAME
|
|
310
|
+
manifest = json.loads(
|
|
311
|
+
_contained_regular_file(bundle, manifest_path, label="manifest").read_text(encoding="utf-8")
|
|
312
|
+
)
|
|
313
|
+
if (
|
|
314
|
+
not isinstance(manifest, dict)
|
|
315
|
+
or manifest.get("parser_bridge_schema") != PARSER_BRIDGE_SCHEMA
|
|
316
|
+
):
|
|
317
|
+
raise IngressError(f"Unsupported parser bridge manifest: {manifest_path}")
|
|
318
|
+
if manifest.get("canonical_filename") != canonical_name:
|
|
319
|
+
raise IngressError(f"Bundle basename mismatch: {canonical_name}")
|
|
320
|
+
|
|
321
|
+
records: list[tuple[str, dict[str, Any]]] = []
|
|
322
|
+
for key in ("markdown", "blocks"):
|
|
323
|
+
record = manifest.get(key)
|
|
324
|
+
if record is not None:
|
|
325
|
+
if not isinstance(record, dict):
|
|
326
|
+
raise IngressError(f"Malformed {key} record in {manifest_path}")
|
|
327
|
+
records.append((key, record))
|
|
328
|
+
assets = manifest.get("assets")
|
|
329
|
+
if not isinstance(assets, list):
|
|
330
|
+
raise IngressError(f"Malformed assets record in {manifest_path}")
|
|
331
|
+
records.extend(("asset", record) for record in assets if isinstance(record, dict))
|
|
332
|
+
if len(records) != 1 + int(manifest.get("blocks") is not None) + len(assets):
|
|
333
|
+
raise IngressError(f"Malformed asset entry in {manifest_path}")
|
|
334
|
+
for label, record in records:
|
|
335
|
+
rel = Path(str(record.get("path") or ""))
|
|
336
|
+
if rel.is_absolute() or ".." in rel.parts:
|
|
337
|
+
raise IngressError(f"Unsafe {label} path in {manifest_path}")
|
|
338
|
+
path = _contained_regular_file(bundle, bundle / rel, label=label)
|
|
339
|
+
if _sha256(path) != record.get("sha256"):
|
|
340
|
+
raise IngressError(f"Digest mismatch for {label}: {path}")
|
|
341
|
+
return manifest, bundle
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def remove_unaccepted(staged: StagedDocument) -> None:
|
|
345
|
+
"""Remove only a pre-enqueue source/bundle whose doc_status does not exist."""
|
|
346
|
+
if staged.source_path.exists() and not staged.source_path.is_symlink():
|
|
347
|
+
staged.source_path.unlink()
|
|
348
|
+
shutil.rmtree(staged.bundle_dir, ignore_errors=True)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
__all__ = [
|
|
352
|
+
"BUNDLES_DIRNAME",
|
|
353
|
+
"INGRESS_DIRNAME",
|
|
354
|
+
"IngressError",
|
|
355
|
+
"PARSER_BRIDGE_SCHEMA",
|
|
356
|
+
"PENDING_DIRNAME",
|
|
357
|
+
"StagedDocument",
|
|
358
|
+
"bundles_root",
|
|
359
|
+
"freeze_document",
|
|
360
|
+
"ingress_root",
|
|
361
|
+
"load_verified_bundle",
|
|
362
|
+
"pending_root",
|
|
363
|
+
"remove_unaccepted",
|
|
364
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""LightRAG parser that consumes frozen DeepTutor ingress bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from lightrag.parser.base import BaseParser
|
|
10
|
+
|
|
11
|
+
from .ingress import IngressError, load_verified_bundle
|
|
12
|
+
from .sidecar import build_ir
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DeepTutorParser(BaseParser):
|
|
16
|
+
"""Third-party parser registered as ``deeptutor`` in LightRAG rc2."""
|
|
17
|
+
|
|
18
|
+
engine_name = "deeptutor"
|
|
19
|
+
|
|
20
|
+
async def parse(self, ctx):
|
|
21
|
+
from lightrag.constants import (
|
|
22
|
+
FULL_DOCS_FORMAT_LIGHTRAG,
|
|
23
|
+
FULL_DOCS_FORMAT_RAW,
|
|
24
|
+
)
|
|
25
|
+
from lightrag.parser.base import ParseResult
|
|
26
|
+
from lightrag.sidecar.writer import write_sidecar
|
|
27
|
+
from lightrag.utils import strip_control_characters
|
|
28
|
+
from lightrag.utils_pipeline import make_lightrag_doc_content, sidecar_uri_for
|
|
29
|
+
|
|
30
|
+
resolved = ctx.resolve(self.engine_name)
|
|
31
|
+
source = resolved.source_path
|
|
32
|
+
if source.is_symlink() or not source.is_file():
|
|
33
|
+
raise IngressError(f"Frozen ingress source is not an ordinary file: {source}")
|
|
34
|
+
manifest, bundle = load_verified_bundle(Path(ctx.rag.working_dir), resolved.document_name)
|
|
35
|
+
|
|
36
|
+
digest = hashlib.sha256()
|
|
37
|
+
with source.open("rb") as handle:
|
|
38
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
39
|
+
digest.update(chunk)
|
|
40
|
+
source_record = manifest.get("source")
|
|
41
|
+
if not isinstance(source_record, dict) or digest.hexdigest() != source_record.get("sha256"):
|
|
42
|
+
raise IngressError(f"Frozen source digest mismatch: {resolved.document_name}")
|
|
43
|
+
|
|
44
|
+
markdown_record = manifest["markdown"]
|
|
45
|
+
markdown = strip_control_characters(
|
|
46
|
+
(bundle / markdown_record["path"]).read_text(encoding="utf-8")
|
|
47
|
+
)
|
|
48
|
+
if manifest.get("blocks") is None:
|
|
49
|
+
if not markdown.strip():
|
|
50
|
+
raise IngressError(f"Frozen RAW document is empty: {resolved.document_name}")
|
|
51
|
+
await ctx.rag._persist_parsed_full_docs(
|
|
52
|
+
ctx.doc_id,
|
|
53
|
+
{
|
|
54
|
+
"content": markdown,
|
|
55
|
+
"file_path": ctx.file_path,
|
|
56
|
+
"parse_format": FULL_DOCS_FORMAT_RAW,
|
|
57
|
+
"parse_engine": self.engine_name,
|
|
58
|
+
"update_time": int(time.time()),
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
await ctx.archive_source(str(source))
|
|
62
|
+
return ParseResult(
|
|
63
|
+
doc_id=ctx.doc_id,
|
|
64
|
+
file_path=ctx.file_path,
|
|
65
|
+
parse_format=FULL_DOCS_FORMAT_RAW,
|
|
66
|
+
content=markdown,
|
|
67
|
+
parse_engine=self.engine_name,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
ir = build_ir(manifest, bundle)
|
|
71
|
+
parsed_data = write_sidecar(
|
|
72
|
+
ir,
|
|
73
|
+
parsed_dir=resolved.parsed_dir,
|
|
74
|
+
doc_id=ctx.doc_id,
|
|
75
|
+
engine=self.engine_name,
|
|
76
|
+
)
|
|
77
|
+
await ctx.rag._persist_parsed_full_docs(
|
|
78
|
+
ctx.doc_id,
|
|
79
|
+
{
|
|
80
|
+
"content": make_lightrag_doc_content(parsed_data["content"]),
|
|
81
|
+
"file_path": ctx.file_path,
|
|
82
|
+
"parse_format": FULL_DOCS_FORMAT_LIGHTRAG,
|
|
83
|
+
"sidecar_location": sidecar_uri_for(resolved.parsed_dir),
|
|
84
|
+
"parse_engine": self.engine_name,
|
|
85
|
+
"update_time": int(time.time()),
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
await ctx.archive_source(str(source))
|
|
89
|
+
return ParseResult(
|
|
90
|
+
doc_id=ctx.doc_id,
|
|
91
|
+
file_path=ctx.file_path,
|
|
92
|
+
parse_format=FULL_DOCS_FORMAT_LIGHTRAG,
|
|
93
|
+
content=parsed_data["content"],
|
|
94
|
+
blocks_path=parsed_data["blocks_path"],
|
|
95
|
+
parse_engine=self.engine_name,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = ["DeepTutorParser"]
|