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,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."""
|