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,430 @@
1
+ """Verified, parser-independent source images owned by a knowledge base.
2
+
3
+ The manifest is deliberately outside any vector index. Retrieval engines may
4
+ index ``asset_id`` as metadata, but only this store resolves it to image bytes.
5
+ The caller must first resolve the KB through the normal access guard.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from hashlib import sha256
12
+ from io import BytesIO
13
+ import json
14
+ import logging
15
+ import os
16
+ from pathlib import Path
17
+ import re
18
+ import tempfile
19
+ from typing import Any
20
+ import warnings
21
+
22
+ from deeptutor.services.parsing.types import ParsedDocument
23
+
24
+ MAX_IMAGE_BYTES = 5 * 1024 * 1024
25
+ MAX_MODEL_IMAGES = 2
26
+ MAX_IMAGE_PIXELS = 30_000_000
27
+ logger = logging.getLogger(__name__)
28
+ _ID_RE = re.compile(r"^[0-9a-f]{64}$")
29
+ _MARKDOWN_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
30
+ _MIME_EXT = {
31
+ "image/png": ".png",
32
+ "image/jpeg": ".jpg",
33
+ "image/gif": ".gif",
34
+ "image/webp": ".webp",
35
+ }
36
+
37
+
38
+ def _image_mime(data: bytes) -> str | None:
39
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
40
+ mime = "image/png"
41
+ elif data.startswith(b"\xff\xd8\xff"):
42
+ mime = "image/jpeg"
43
+ elif data.startswith((b"GIF87a", b"GIF89a")):
44
+ mime = "image/gif"
45
+ elif data.startswith(b"RIFF") and data[8:12] == b"WEBP":
46
+ mime = "image/webp"
47
+ else:
48
+ return None
49
+ # Check that the bytes actually decode as an image, rather than trusting
50
+ # an extension or a short magic prefix supplied by a parser.
51
+ try:
52
+ from PIL import Image
53
+
54
+ with warnings.catch_warnings():
55
+ warnings.simplefilter("error", Image.DecompressionBombWarning)
56
+ with Image.open(BytesIO(data)) as image:
57
+ if image.width * image.height > MAX_IMAGE_PIXELS:
58
+ return None
59
+ image.verify()
60
+ except Exception: # malformed or oversized images must not abort document indexing
61
+ return None
62
+ return mime
63
+
64
+
65
+ def _image_bytes(path: Path) -> tuple[bytes, str] | None:
66
+ if path.is_symlink() or not path.is_file():
67
+ return None
68
+ size = path.stat().st_size
69
+ if not 0 < size <= MAX_IMAGE_BYTES:
70
+ return None
71
+ data = path.read_bytes()
72
+ if len(data) > MAX_IMAGE_BYTES:
73
+ return None
74
+ mime = _image_mime(data)
75
+ return (data, mime) if mime is not None else None
76
+
77
+
78
+ def source_key_for(kb_dir: Path, source: Path) -> str:
79
+ try:
80
+ return source.resolve().relative_to((kb_dir / "raw").resolve()).as_posix()
81
+ except ValueError:
82
+ # Direct SDK callers can index paths outside raw/. Never persist the
83
+ # host path in an asset manifest or expose it through citations.
84
+ return source.name
85
+
86
+
87
+ def _sha256_file(path: Path) -> str:
88
+ digest = sha256()
89
+ with path.open("rb") as handle:
90
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
91
+ digest.update(chunk)
92
+ return digest.hexdigest()
93
+
94
+
95
+ def _managed_source(kb_dir: Path, source: Path) -> bool:
96
+ return source.resolve().is_relative_to((kb_dir / "raw").resolve())
97
+
98
+
99
+ def _text(value: Any) -> str:
100
+ if isinstance(value, str):
101
+ return value.strip()
102
+ if isinstance(value, list):
103
+ return " ".join(filter(None, (_text(item) for item in value))).strip()
104
+ return ""
105
+
106
+
107
+ def _block_details(
108
+ blocks: list[dict[str, Any]] | None, asset: Path
109
+ ) -> tuple[str, str, int | None, int | None, list[float] | None, str]:
110
+ if not blocks:
111
+ return "", "", None, None, None, ""
112
+ for index, block in enumerate(blocks):
113
+ if not isinstance(block, dict):
114
+ continue
115
+ raw = str(block.get("img_path") or block.get("path") or "")
116
+ if not raw or Path(raw).resolve() != asset.resolve():
117
+ continue
118
+ caption = _text(
119
+ block.get("caption")
120
+ or block.get("image_caption")
121
+ or block.get("chart_caption")
122
+ or block.get("table_caption")
123
+ or block.get("captions")
124
+ )
125
+ context = _text(block.get("text") or block.get("content"))
126
+ if not context:
127
+ neighbors = blocks[max(0, index - 1) : index] + blocks[index + 1 : index + 2]
128
+ context = " ".join(
129
+ _text(item.get("text") or item.get("content"))
130
+ for item in neighbors
131
+ if isinstance(item, dict)
132
+ ).strip()
133
+ has_page_index = block.get("page_idx") is not None
134
+ page_raw = block.get("page_idx") if has_page_index else block.get("page")
135
+ try:
136
+ page_value = int(page_raw) if page_raw is not None else None
137
+ except (ValueError, TypeError):
138
+ page_value = None
139
+ page_index = page_value if has_page_index else None
140
+ page_number = (
141
+ (page_value + 1 if has_page_index else page_value) if page_value is not None else None
142
+ )
143
+ raw_bbox = block.get("bbox")
144
+ try:
145
+ bbox = (
146
+ [float(value) for value in raw_bbox[:4]]
147
+ if isinstance(raw_bbox, (list, tuple)) and len(raw_bbox) >= 4
148
+ else None
149
+ )
150
+ except (ValueError, TypeError):
151
+ bbox = None
152
+ return caption[:500], context[:500], page_index, page_number, bbox, f"blocks.json#/{index}"
153
+ return "", "", None, None, None, ""
154
+
155
+
156
+ def _markdown_details(markdown: str, asset: Path) -> tuple[str, str, str]:
157
+ for match in _MARKDOWN_IMAGE_RE.finditer(markdown):
158
+ if Path(match.group(2).split("?", 1)[0]).name != asset.name:
159
+ continue
160
+ excerpt = markdown[max(0, match.start() - 250) : min(len(markdown), match.end() + 250)]
161
+ excerpt = _MARKDOWN_IMAGE_RE.sub(" ", excerpt)
162
+ return (
163
+ match.group(1).strip()[:500],
164
+ " ".join(excerpt.split())[:500],
165
+ f"markdown:{match.start()}",
166
+ )
167
+ return "", "", ""
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class VisualAssetCandidate:
172
+ path: Path
173
+ record: dict[str, Any]
174
+
175
+
176
+ def collect_visual_assets(
177
+ parsed: ParsedDocument, source: Path, kb_dir: Path
178
+ ) -> list[VisualAssetCandidate]:
179
+ """Normalize extracted PDF/EPUB images with provenance and byte identity."""
180
+ asset_dir = parsed.asset_dir
181
+ if asset_dir is None or asset_dir.is_symlink() or not asset_dir.is_dir():
182
+ return []
183
+ source_key = source_key_for(kb_dir, source)
184
+ source_hash = _sha256_file(source)
185
+ candidates: list[VisualAssetCandidate] = []
186
+ for path in sorted(asset_dir.iterdir()):
187
+ try:
188
+ loaded = _image_bytes(path)
189
+ except OSError:
190
+ logger.warning(
191
+ "Unable to read parser asset %s; coverage report will record it", path.name
192
+ )
193
+ continue
194
+ if loaded is None:
195
+ continue
196
+ image, mime = loaded
197
+ image_hash = sha256(image).hexdigest()
198
+ caption, context, page_index, page_number, bbox, locator = _block_details(
199
+ parsed.blocks, path
200
+ )
201
+ md_caption, md_context, md_locator = _markdown_details(parsed.markdown, path)
202
+ caption = caption or md_caption
203
+ context = context or md_context
204
+ locator = locator or md_locator or f"asset:{path.name}"
205
+ asset_id = sha256(
206
+ f"{source_key}\0{source_hash}\0{parsed.parser_signature}\0{locator}\0{image_hash}".encode()
207
+ ).hexdigest()
208
+ record = {
209
+ "asset_id": asset_id,
210
+ "source_document_id": source_hash,
211
+ "source_path": source_key,
212
+ "managed_source": _managed_source(kb_dir, source),
213
+ "parser_engine": parsed.engine,
214
+ "parser_signature": parsed.parser_signature,
215
+ "source_hash": parsed.source_hash,
216
+ "source_locator": locator,
217
+ "page_index": page_index,
218
+ "page_number": page_number,
219
+ "bbox": bbox,
220
+ "caption": caption,
221
+ "context": context,
222
+ "image_sha256": image_hash,
223
+ "mime_type": mime,
224
+ "size": len(image),
225
+ }
226
+ from deeptutor.services.rag.source_visuals import figure_labels
227
+
228
+ record["figure_labels"] = figure_labels(caption)
229
+ matching = [
230
+ block
231
+ for block in (parsed.blocks or [])
232
+ if isinstance(block, dict)
233
+ and str(block.get("img_path") or block.get("path") or "")
234
+ and Path(str(block.get("img_path") or block.get("path"))).resolve() == path.resolve()
235
+ ]
236
+ if matching:
237
+ block = matching[0]
238
+ record.update(
239
+ {
240
+ "kind": str(block.get("type") or "image"),
241
+ "section": _text(block.get("section") or block.get("section_title")),
242
+ "group_id": str(block.get("group_id") or block.get("figure_id") or ""),
243
+ "table_html": _text(block.get("table_body") or block.get("table_html")),
244
+ "notes": _text(block.get("notes") or block.get("table_footnote")),
245
+ }
246
+ )
247
+ candidates.append(VisualAssetCandidate(path=path, record=record))
248
+ return candidates
249
+
250
+
251
+ class VisualAssetStore:
252
+ """KB-scoped immutable image files plus an atomically replaced manifest."""
253
+
254
+ def __init__(self, kb_dir: Path):
255
+ self.kb_dir = Path(kb_dir)
256
+ self.root = self.kb_dir / "visual_assets"
257
+ self.manifest_path = self.root / "manifest.json"
258
+
259
+ def records(self) -> dict[str, dict[str, Any]]:
260
+ if self.root.is_symlink() or not self.root.resolve().is_relative_to(self.kb_dir.resolve()):
261
+ return {}
262
+ try:
263
+ return self._read_manifest()
264
+ except OSError as exc:
265
+ logger.warning("Cannot read source figures for KB '%s': %s", self.kb_dir.name, exc)
266
+ return {}
267
+
268
+ def _read_manifest(self) -> dict[str, dict[str, Any]]:
269
+ # #1802: the corpus grows independently of the per-image request budget.
270
+ # Never interpret a large or corrupt manifest as an empty store on a write.
271
+ try:
272
+ if self.root.is_symlink() or not self.root.resolve().is_relative_to(
273
+ self.kb_dir.resolve()
274
+ ):
275
+ raise OSError("Visual asset directory escapes knowledge base")
276
+ if self.manifest_path.is_symlink():
277
+ raise OSError("Visual asset manifest must not be a symbolic link")
278
+ payload = json.loads(self.manifest_path.read_text(encoding="utf-8"))
279
+ except FileNotFoundError:
280
+ return {}
281
+ except (OSError, ValueError) as exc:
282
+ raise OSError(f"Cannot read visual asset manifest {self.manifest_path}: {exc}") from exc
283
+ records = payload.get("assets") if isinstance(payload, dict) else None
284
+ if not isinstance(records, dict) or any(not isinstance(v, dict) for v in records.values()):
285
+ raise OSError(f"Invalid visual asset manifest {self.manifest_path}")
286
+ return records
287
+
288
+ def publish(
289
+ self,
290
+ candidates: list[VisualAssetCandidate],
291
+ *,
292
+ replace: bool = False,
293
+ source_paths: set[str] | None = None,
294
+ prune_missing: bool = False,
295
+ ) -> None:
296
+ if self.root.is_symlink() or not self.root.resolve().is_relative_to(self.kb_dir.resolve()):
297
+ raise OSError("Visual asset directory escapes knowledge base")
298
+ self.root.mkdir(parents=True, exist_ok=True)
299
+ prior = self._read_manifest()
300
+ incoming = {item.record["asset_id"]: item.record for item in candidates}
301
+ if replace:
302
+ remaining: dict[str, dict[str, Any]] = {}
303
+ else:
304
+ # Keep older index versions' asset IDs reachable when the same
305
+ # source is re-parsed with a new parser or embedding signature.
306
+ # Explicit raw-file deletion removes all IDs for that source.
307
+ replaced_sources = source_paths or set()
308
+ remaining = {
309
+ key: value
310
+ for key, value in prior.items()
311
+ if value.get("source_path") not in replaced_sources
312
+ }
313
+ if prune_missing:
314
+ remaining = {
315
+ key: value
316
+ for key, value in remaining.items()
317
+ if not value.get("managed_source")
318
+ or self._source_exists(value.get("source_path"))
319
+ }
320
+ updated = {**remaining, **incoming}
321
+ manifest = json.dumps(
322
+ {"version": 1, "assets": updated}, ensure_ascii=False, sort_keys=True
323
+ ).encode()
324
+ for candidate in candidates:
325
+ record = candidate.record
326
+ loaded = _image_bytes(candidate.path)
327
+ if (
328
+ loaded is None
329
+ or sha256(loaded[0]).hexdigest() != record["image_sha256"]
330
+ or loaded[1] != record["mime_type"]
331
+ ):
332
+ raise OSError(f"Source image changed during indexing: {candidate.path.name}")
333
+ target = self._path(record["asset_id"], record["mime_type"])
334
+ existing = _image_bytes(target)
335
+ if existing is None or sha256(existing[0]).hexdigest() != record["image_sha256"]:
336
+ self._atomic_write(target, loaded[0])
337
+ self._atomic_write(self.manifest_path, manifest)
338
+ for old_id, old_record in prior.items():
339
+ if old_id not in updated:
340
+ self._path(old_id, str(old_record.get("mime_type"))).unlink(missing_ok=True)
341
+
342
+ def read(self, asset_id: str) -> tuple[dict[str, Any], bytes] | None:
343
+ if not _ID_RE.fullmatch(asset_id):
344
+ return None
345
+ record = self.records().get(asset_id)
346
+ if not isinstance(record, dict) or record.get("asset_id") != asset_id:
347
+ return None
348
+ mime = record.get("mime_type")
349
+ if mime not in _MIME_EXT:
350
+ return None
351
+ path = self._path(asset_id, mime)
352
+ loaded = _image_bytes(path)
353
+ if loaded is None or loaded[1] != mime:
354
+ return None
355
+ data = loaded[0]
356
+ if len(data) != record.get("size") or sha256(data).hexdigest() != record.get(
357
+ "image_sha256"
358
+ ):
359
+ return None
360
+ return record, data
361
+
362
+ def remove_source(self, source_relative_path: str) -> None:
363
+ prior = self._read_manifest()
364
+ updated = {
365
+ key: value
366
+ for key, value in prior.items()
367
+ if value.get("source_path") != source_relative_path
368
+ }
369
+ if len(updated) == len(prior):
370
+ return
371
+ self._atomic_write(
372
+ self.manifest_path,
373
+ json.dumps(
374
+ {"version": 1, "assets": updated}, ensure_ascii=False, sort_keys=True
375
+ ).encode(),
376
+ )
377
+ for old_id, old_record in prior.items():
378
+ if old_id not in updated:
379
+ self._path(old_id, str(old_record.get("mime_type"))).unlink(missing_ok=True)
380
+
381
+ def move_source(self, old_relative_path: str, new_relative_path: str) -> None:
382
+ prior = self._read_manifest()
383
+ updated = {}
384
+ changed = False
385
+ for key, value in prior.items():
386
+ source = value.get("source_path")
387
+ if source == old_relative_path or (
388
+ isinstance(source, str) and source.startswith(old_relative_path + "/")
389
+ ):
390
+ value = {
391
+ **value,
392
+ "source_path": new_relative_path + source[len(old_relative_path) :],
393
+ }
394
+ changed = True
395
+ updated[key] = value
396
+ if changed:
397
+ self._atomic_write(
398
+ self.manifest_path,
399
+ json.dumps(
400
+ {"version": 1, "assets": updated}, ensure_ascii=False, sort_keys=True
401
+ ).encode(),
402
+ )
403
+
404
+ def _path(self, asset_id: str, mime: str) -> Path:
405
+ if not _ID_RE.fullmatch(asset_id) or mime not in _MIME_EXT:
406
+ raise ValueError("Invalid visual asset record")
407
+ return self.root / (asset_id + _MIME_EXT[mime])
408
+
409
+ def _source_exists(self, relative_path: Any) -> bool:
410
+ if not isinstance(relative_path, str):
411
+ return False
412
+ relative = Path(relative_path)
413
+ if relative.is_absolute() or ".." in relative.parts:
414
+ return False
415
+ raw = (self.kb_dir / "raw").resolve()
416
+ source = (raw / relative).resolve()
417
+ return source.is_relative_to(raw) and source.is_file()
418
+
419
+ @staticmethod
420
+ def _atomic_write(path: Path, data: bytes) -> None:
421
+ path.parent.mkdir(parents=True, exist_ok=True)
422
+ fd, temp_name = tempfile.mkstemp(prefix=".visual-", dir=path.parent)
423
+ try:
424
+ with os.fdopen(fd, "wb") as handle:
425
+ handle.write(data)
426
+ handle.flush()
427
+ os.fsync(handle.fileno())
428
+ os.replace(temp_name, path)
429
+ finally:
430
+ Path(temp_name).unlink(missing_ok=True)
@@ -0,0 +1,165 @@
1
+ """Inspect extraction limits without equating emitted assets with whole-book coverage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from hashlib import sha256
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from deeptutor.services.file_io import atomic_write_json
11
+ from deeptutor.services.parsing.types import ParsedDocument
12
+ from deeptutor.services.rag.visual_assets import (
13
+ MAX_IMAGE_BYTES,
14
+ VisualAssetCandidate,
15
+ VisualAssetStore,
16
+ _image_bytes,
17
+ _sha256_file,
18
+ source_key_for,
19
+ )
20
+
21
+
22
+ def record_coverage(
23
+ parsed: ParsedDocument, source: Path, kb_dir: Path, candidates: list[VisualAssetCandidate]
24
+ ) -> None:
25
+ source_path = source_key_for(kb_dir, source)
26
+ if not source.resolve().is_relative_to((kb_dir / "raw").resolve()):
27
+ return
28
+ issues = []
29
+ if parsed.asset_dir and parsed.asset_dir.is_dir() and not parsed.asset_dir.is_symlink():
30
+ for path in sorted(parsed.asset_dir.iterdir()):
31
+ if path.suffix.casefold() not in {
32
+ ".png",
33
+ ".jpg",
34
+ ".jpeg",
35
+ ".webp",
36
+ ".gif",
37
+ ".svg",
38
+ ".bmp",
39
+ ".tiff",
40
+ }:
41
+ continue
42
+ reason = ""
43
+ status = "skipped"
44
+ try:
45
+ if path.is_symlink():
46
+ reason = "symbolic_link"
47
+ elif path.stat().st_size > MAX_IMAGE_BYTES:
48
+ reason = "exceeds_5_mib"
49
+ elif _image_bytes(path) is None:
50
+ reason = "unsupported_or_invalid_pixels"
51
+ except OSError:
52
+ reason, status = "asset_read_failed", "failed"
53
+ if reason:
54
+ issues.append({"asset": path.name, "status": status, "reason": reason})
55
+ for index, block in enumerate(parsed.blocks or []):
56
+ if not isinstance(block, dict):
57
+ continue
58
+ raw_page = block.get("page_idx")
59
+ page = raw_page + 1 if isinstance(raw_page, int) else block.get("page")
60
+ if str(block.get("type") or "").casefold() == "table":
61
+ issues.append(
62
+ {
63
+ "page": page,
64
+ "locator": f"blocks.json#/{index}",
65
+ "status": "unverified",
66
+ "reason": "table_structure_requires_source_verification",
67
+ "structured_table_available": bool(
68
+ block.get("table_body") or block.get("table_html")
69
+ ),
70
+ }
71
+ )
72
+ raw_image = block.get("img_path") or block.get("path")
73
+ if raw_image and not any(
74
+ c.record.get("source_locator") == f"blocks.json#/{index}" for c in candidates
75
+ ):
76
+ issues.append(
77
+ {
78
+ "page": page,
79
+ "locator": f"blocks.json#/{index}",
80
+ "status": "unverified",
81
+ "reason": "referenced_image_not_retained",
82
+ }
83
+ )
84
+ report = {
85
+ "source_path": source_path,
86
+ "source_document_id": _sha256_file(source),
87
+ "parser_engine": parsed.engine,
88
+ "parser_signature": parsed.parser_signature,
89
+ "validated_count": len(candidates),
90
+ "issues": issues,
91
+ "whole_document_verified": False,
92
+ "coverage_reason": "Parser-emitted assets do not prove complete visual coverage. Inspect original pages for vector drawings, missing crops or unreadable labels.",
93
+ "page_fallback": source.suffix.casefold() == ".pdf",
94
+ }
95
+ root = VisualAssetStore(kb_dir).root
96
+ if root.is_symlink():
97
+ raise OSError("Visual coverage directory is unsafe")
98
+ report_dir = root / "coverage"
99
+ if report_dir.is_symlink():
100
+ raise OSError("Visual coverage directory is unsafe")
101
+ atomic_write_json(report_dir / (sha256(source_path.encode()).hexdigest() + ".json"), report)
102
+
103
+
104
+ def coverage_overview(
105
+ kb_dir: Path, *, source_path: str = "", offset: int = 0, limit: int = 100
106
+ ) -> dict[str, Any]:
107
+ store = VisualAssetStore(kb_dir)
108
+ records = store._read_manifest()
109
+ root = kb_dir / "raw"
110
+ if root.is_symlink():
111
+ raise OSError("Raw source directory is unsafe")
112
+ reports = []
113
+ files = (
114
+ [root / source_path] if source_path else sorted(root.rglob("*")) if root.is_dir() else []
115
+ )
116
+ for path in files:
117
+ if (
118
+ path.is_symlink()
119
+ or not path.is_file()
120
+ or not path.resolve().is_relative_to(root.resolve())
121
+ ):
122
+ continue
123
+ key = path.relative_to(root).as_posix()
124
+ digest = _sha256_file(path)
125
+ report_file = store.root / "coverage" / (sha256(key.encode()).hexdigest() + ".json")
126
+ try:
127
+ if (
128
+ store.root.is_symlink()
129
+ or report_file.parent.is_symlink()
130
+ or report_file.is_symlink()
131
+ ):
132
+ raise ValueError("unsafe coverage path")
133
+ report = json.loads(report_file.read_text())
134
+ if not isinstance(report, dict) or report.get("source_document_id") != digest:
135
+ raise ValueError("outdated coverage")
136
+ except (OSError, ValueError):
137
+ report = {
138
+ "source_path": key,
139
+ "source_document_id": digest,
140
+ "whole_document_verified": False,
141
+ "issues": [{"status": "unverified", "reason": "extraction_coverage_unknown"}],
142
+ "page_fallback": path.suffix.casefold() == ".pdf",
143
+ }
144
+ retained = [
145
+ r
146
+ for r in records.values()
147
+ if r.get("source_path") == key and r.get("source_document_id") == digest
148
+ ]
149
+ reports.append(
150
+ {
151
+ **report,
152
+ "retained_count": len({r.get("source_locator") for r in retained}),
153
+ "indexing_status": "not_verified_by_extraction_report",
154
+ }
155
+ )
156
+ assets = [r for r in records.values() if not source_path or r.get("source_path") == source_path]
157
+ # Return a bounded catalog and total; retention itself remains unbounded.
158
+ return {
159
+ "documents": reports,
160
+ "assets": assets[offset : offset + limit],
161
+ "total_assets": len(assets),
162
+ "offset": offset,
163
+ "limit": limit,
164
+ "model_image_limit": 2,
165
+ }
@@ -0,0 +1 @@
1
+ """Web-source sync subsystem: crawl doc sites into KBs."""