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,292 @@
1
+ """Page-bound PDF evidence shared by indexing, tutoring and authorized previews."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import tempfile
10
+ from types import SimpleNamespace
11
+ from typing import Any
12
+ from urllib.parse import quote, urlencode
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field
15
+
16
+ SOURCE_SCHEMA_VERSION = 1
17
+ CATALOG_FILENAME = "source_pages.json"
18
+
19
+
20
+ class SourcePageError(ValueError):
21
+ """A safe, user-facing source resolution failure."""
22
+
23
+
24
+ class SourceRef(BaseModel):
25
+ model_config = ConfigDict(extra="forbid")
26
+ kb_ref: str
27
+ document_key: str
28
+ source_digest: str = Field(pattern=r"^[0-9a-f]{64}$")
29
+ page_index: int = Field(ge=0)
30
+ page_label: str = ""
31
+ parser_signature: str = ""
32
+ node_id: str = ""
33
+ crop: tuple[float, float, float, float] | None = None
34
+
35
+
36
+ def source_digest(path: Path) -> str:
37
+ digest = hashlib.sha256()
38
+ with path.open("rb") as handle:
39
+ for block in iter(lambda: handle.read(1 << 20), b""):
40
+ digest.update(block)
41
+ return digest.hexdigest()
42
+
43
+
44
+ def resolve_source(ref: SourceRef) -> Path:
45
+ from deeptutor.multi_user.knowledge_access import resolve_kb_document_path
46
+
47
+ path = resolve_kb_document_path(ref.kb_ref, ref.document_key)
48
+ if path is None or path.suffix.lower() != ".pdf":
49
+ raise SourcePageError("The selected PDF is unavailable or access was revoked.")
50
+ if source_digest(path) != ref.source_digest:
51
+ raise SourcePageError(
52
+ "The source document has changed. Refresh its index before continuing."
53
+ )
54
+ return path
55
+
56
+
57
+ def render_source_page(ref: SourceRef) -> bytes:
58
+ from deeptutor.multi_user.knowledge_access import resolve_for_rag
59
+ from deeptutor.services.rag.source_visuals import page_image
60
+
61
+ # Authorize through the same source resolver as existing lesson snapshots.
62
+ resolve_source(ref)
63
+ resource = resolve_for_rag(ref.kb_ref)
64
+ if resource is None:
65
+ raise SourcePageError("The selected PDF is unavailable or access was revoked.")
66
+ try:
67
+ _, pixels = page_image(
68
+ Path(resource.base_dir) / resource.name,
69
+ ref.document_key,
70
+ ref.page_index + 1,
71
+ expected_hash=ref.source_digest,
72
+ region=list(ref.crop) if ref.crop else None,
73
+ )
74
+ return pixels
75
+ except (OSError, ValueError) as exc:
76
+ raise SourcePageError(str(exc)) from exc
77
+
78
+
79
+ def preview_url(ref: SourceRef) -> str:
80
+ query = {
81
+ "source_path": ref.document_key,
82
+ "source_hash": ref.source_digest,
83
+ "page": str(ref.page_index + 1),
84
+ }
85
+ if ref.crop:
86
+ query["region"] = ",".join(str(v) for v in ref.crop)
87
+ return f"/api/knowledge-bases/{quote(ref.kb_ref, safe='')}/source-page?{urlencode(query)}"
88
+
89
+
90
+ def public_source(ref: SourceRef) -> dict[str, Any]:
91
+ return {
92
+ "type": "rag",
93
+ "kb_name": ref.kb_ref,
94
+ "title": f"{ref.document_key} · PDF page {ref.page_index + 1}",
95
+ "source": ref.document_key,
96
+ "page": ref.page_index + 1,
97
+ "url": (
98
+ f"/api/knowledge-bases/{quote(ref.kb_ref, safe='')}/files/"
99
+ f"{quote(ref.document_key, safe='/')}#page={ref.page_index + 1}"
100
+ ),
101
+ "preview_url": preview_url(ref),
102
+ "source_ref": ref.model_dump(),
103
+ }
104
+
105
+
106
+ def build_pdf_documents(path: Path, parsed: Any, kb_dir: Path) -> list[Any]:
107
+ """Prefer located IR; native PDF pages provide physical locators otherwise."""
108
+ from llama_index.core import Document
109
+ import pymupdf
110
+
111
+ resolved = path.resolve()
112
+ raw = (kb_dir / "raw").resolve()
113
+ if not resolved.is_relative_to(raw):
114
+ return []
115
+ key = resolved.relative_to(raw).as_posix()
116
+ digest = source_digest(path)
117
+ grouped: dict[int, list[str]] = {}
118
+ unlocated: list[str] = []
119
+ for block in parsed.blocks or []:
120
+ if not isinstance(block, dict):
121
+ continue
122
+ pieces = [str(block.get(k) or "") for k in ("text", "content", "table_body")]
123
+ for name in ("image_caption", "table_caption", "table_footnote"):
124
+ value = block.get(name) or []
125
+ if isinstance(value, list):
126
+ pieces.extend(str(v) for v in value)
127
+ else:
128
+ pieces.append(str(value))
129
+ text = "\n".join(p for p in pieces if p.strip())
130
+ index = block.get("page_idx", block.get("page_index"))
131
+ if type(index) is int and index >= 0:
132
+ grouped.setdefault(index, []).append(text)
133
+ elif text:
134
+ unlocated.append(text)
135
+ documents = []
136
+ with pymupdf.open(path) as pdf:
137
+ if pdf.needs_pass:
138
+ raise SourcePageError("Password protected PDFs cannot supply teaching evidence.")
139
+ for index, page in enumerate(pdf):
140
+ located = "\n\n".join(grouped.get(index, []))
141
+ native = page.get_text(sort=True).strip()
142
+ text = located.strip() or native
143
+ metadata = {
144
+ "file_name": path.name,
145
+ "file_path": str(path),
146
+ "document_key": key,
147
+ "source_digest": digest,
148
+ "source_schema_version": SOURCE_SCHEMA_VERSION,
149
+ "page_index": index,
150
+ "page": index + 1,
151
+ "page_label": page.get_label() or "",
152
+ "page_count": pdf.page_count,
153
+ "parser_signature": parsed.parser_signature,
154
+ "parse_engine": parsed.engine if located.strip() else "native_pdf",
155
+ "page_status": "searchable" if text else "needs_ocr",
156
+ "visual_status": "present"
157
+ if page.get_images() or page.get_drawings()
158
+ else "unknown",
159
+ }
160
+ documents.append(
161
+ Document(
162
+ id_=f"pdf-{digest}-{index}",
163
+ text=text or f"[Unsearchable PDF page {index + 1}]",
164
+ metadata=metadata,
165
+ excluded_embed_metadata_keys=list(metadata),
166
+ excluded_llm_metadata_keys=["file_path"],
167
+ )
168
+ )
169
+ for index in grouped:
170
+ if index >= len(documents):
171
+ unlocated.extend(grouped[index])
172
+ if unlocated or (not grouped and parsed.engine not in {"text_only", "native_pdf"}):
173
+ extra = "\n\n".join(unlocated) or parsed.markdown.strip()
174
+ if extra:
175
+ documents.append(
176
+ Document(
177
+ text=extra,
178
+ metadata={
179
+ "file_name": path.name,
180
+ "file_path": str(path),
181
+ "document_key": key,
182
+ "source_digest": digest,
183
+ "source_schema_version": SOURCE_SCHEMA_VERSION,
184
+ "page_status": "unlocated",
185
+ "parse_engine": parsed.engine,
186
+ },
187
+ )
188
+ )
189
+ return documents
190
+
191
+
192
+ def persist_page_catalog(documents: list[Any], storage_dir: Path) -> None:
193
+ pages = []
194
+ for document in documents:
195
+ meta = getattr(document, "metadata", {})
196
+ if meta.get("source_schema_version") == SOURCE_SCHEMA_VERSION and "page_index" in meta:
197
+ pages.append({k: v for k, v in meta.items() if k != "file_path"})
198
+ if not pages:
199
+ return
200
+ target = storage_dir / CATALOG_FILENAME
201
+ if target.exists():
202
+ old = json.loads(target.read_text(encoding="utf-8")).get("pages", [])
203
+ replaced = {p["document_key"] for p in pages}
204
+ pages = [p for p in old if p.get("document_key") not in replaced] + pages
205
+ temp = target.with_suffix(".tmp")
206
+ temp.write_text(
207
+ json.dumps({"schema_version": SOURCE_SCHEMA_VERSION, "pages": pages}, ensure_ascii=False),
208
+ encoding="utf-8",
209
+ )
210
+ temp.replace(target)
211
+
212
+
213
+ def legacy_page_index(
214
+ kb_dir: Path, storage_dir: Path, document_keys: list[str] | None, *, build: bool = False
215
+ ) -> Path:
216
+ """Compatibility sidecar: native page text + cached IR, no OCR or embedding.
217
+
218
+ Old vectors cannot acquire trustworthy page locators by guessing. Use the
219
+ BM25 engine over physical pages instead, without requiring optional stemming. Keep this
220
+ explicitly distinct from a freshly rebuilt, hybrid page-aware vector index.
221
+ """
222
+ from deeptutor.services.parsing import cache, get_parse_service
223
+ from deeptutor.services.parsing.engines.factory import get_parser
224
+ from deeptutor.services.path_service import get_path_service
225
+
226
+ raw = (kb_dir / "raw").resolve()
227
+ candidates = (
228
+ sorted(raw.rglob("*.pdf")) if document_keys is None else [raw / k for k in document_keys]
229
+ )
230
+ paths = [p for p in candidates if p.suffix.lower() == ".pdf" and p.is_file()]
231
+ if not paths:
232
+ raise SourcePageError("No selected PDF is available for page lookup.")
233
+ try:
234
+ engine = get_parse_service().active_engine()
235
+ parser = get_parser(engine)
236
+ signature = parser.signature(parser.resolve_config()).hash()
237
+ except Exception:
238
+ engine, signature = "native_pdf", "native-pdf-page-v1"
239
+ inventory = []
240
+ for path in paths:
241
+ if not path.resolve().is_relative_to(raw):
242
+ raise SourcePageError("A selected document is outside the knowledge-base directory.")
243
+ digest = source_digest(path)
244
+ hit = cache.lookup(get_path_service().get_parse_cache_root(), digest[:16], signature)
245
+ inventory.append((path, digest, hit))
246
+ identity = [
247
+ (p.relative_to(raw).as_posix(), digest, signature if hit else "native-pdf-page-v1")
248
+ for p, digest, hit in inventory
249
+ ]
250
+ key = hashlib.sha256(json.dumps(identity).encode()).hexdigest()[:24]
251
+ target = storage_dir / ".mastery-pages" / key
252
+ if (target / CATALOG_FILENAME).is_file() and (target / "docstore.json").is_file():
253
+ return target
254
+ if not build:
255
+ raise SourcePageError("The original-page lookup needs preparation. Retry the lesson.")
256
+ from llama_index.core.node_parser import SentenceSplitter
257
+ from llama_index.core.storage.docstore import SimpleDocumentStore
258
+
259
+ documents = []
260
+ for path, _, hit in inventory:
261
+ markdown, blocks, assets = cache.load_ir(hit) if hit else ("", None, None)
262
+ parsed = SimpleNamespace(
263
+ markdown=markdown,
264
+ blocks=blocks,
265
+ asset_dir=assets,
266
+ engine=engine if hit else "native_pdf",
267
+ parser_signature=signature if hit else "native-pdf-page-v1",
268
+ )
269
+ documents.extend(build_pdf_documents(path, parsed, kb_dir))
270
+ nodes = SentenceSplitter(chunk_size=512, chunk_overlap=50).get_nodes_from_documents(
271
+ [d for d in documents if d.metadata.get("page_status") == "searchable"]
272
+ )
273
+ if not nodes:
274
+ raise SourcePageError("These PDFs have no searchable page text. Finish their OCR first.")
275
+ target.parent.mkdir(parents=True, exist_ok=True)
276
+ with tempfile.TemporaryDirectory(prefix=".preparing-", dir=target.parent) as temporary:
277
+ stage = Path(temporary)
278
+ docstore = SimpleDocumentStore()
279
+ docstore.add_documents(nodes)
280
+ docstore.persist(str(stage / "docstore.json"))
281
+ persist_page_catalog(documents, stage)
282
+ # The publication is atomic. Concurrent identical preparations may
283
+ # reuse the first complete directory; neither changes the vector index.
284
+ try:
285
+ os.replace(stage, target)
286
+ except OSError:
287
+ if (
288
+ not (target / CATALOG_FILENAME).is_file()
289
+ or not (target / "docstore.json").is_file()
290
+ ):
291
+ raise
292
+ return target