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.
Files changed (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. 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"]