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,648 @@
|
|
|
1
|
+
"""Document loading for the LlamaIndex RAG pipeline.
|
|
2
|
+
|
|
3
|
+
Parser-backed files (PDF / Office / e-book) are converted through the shared
|
|
4
|
+
document-parse bridge (``deeptutor/services/parsing``), so the engine the user
|
|
5
|
+
picked in Settings → Document Parsing (text-only, MinerU, Docling, markitdown,
|
|
6
|
+
PyMuPDF4LLM) owns extraction. This is the same seam LightRAG and GraphRAG use;
|
|
7
|
+
routing LlamaIndex through it too means the parse-engine choice is honored by
|
|
8
|
+
every local retrieval engine, and image-capable engines' extracted images flow
|
|
9
|
+
into the multimodal ``ImageNode`` path below.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import base64
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
import logging
|
|
18
|
+
import mimetypes
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable, Iterable
|
|
21
|
+
|
|
22
|
+
from llama_index.core import Document
|
|
23
|
+
from llama_index.core.schema import ImageNode
|
|
24
|
+
|
|
25
|
+
from deeptutor.services.config.runtime_settings import DOCUMENT_PARSING_ENGINE_LITEPARSE
|
|
26
|
+
from deeptutor.services.embedding import get_embedding_client
|
|
27
|
+
from deeptutor.services.llm.image_caption_batch import ImageCaptionBatcher
|
|
28
|
+
from deeptutor.services.llm.image_caption_cache import complete_image_caption
|
|
29
|
+
from deeptutor.services.llm.image_description import get_image_description_client
|
|
30
|
+
from deeptutor.services.rag.source_pages import SourcePageError
|
|
31
|
+
from deeptutor.services.rag.visual_assets import VisualAssetCandidate, collect_visual_assets
|
|
32
|
+
from deeptutor.utils.document_validator import DocumentValidator
|
|
33
|
+
from deeptutor.utils.file_types import FileTypeRouter
|
|
34
|
+
|
|
35
|
+
from .config import image_description_batch_size, image_description_limits
|
|
36
|
+
|
|
37
|
+
IMAGE_DESCRIPTION_SYSTEM_PROMPT = (
|
|
38
|
+
"You describe images for a retrieval-augmented knowledge base. "
|
|
39
|
+
"Be factual, concise, and include any visible text, labels, diagrams, "
|
|
40
|
+
"tables, logos, or important visual relationships. Do not invent details."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
IMAGE_DESCRIPTION_PROMPT = (
|
|
44
|
+
"Describe this image so that a text-only answer generator can understand "
|
|
45
|
+
"and cite it later. Include visible text/OCR if present, the main subject, "
|
|
46
|
+
"and any educational or technical meaning. Keep the answer under 180 words."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# LiteParse is the only automatic fallback candidate: it performs local OCR,
|
|
50
|
+
# has no model download, and can run without changing the user's global parser.
|
|
51
|
+
_PDF_OCR_FALLBACK_ENGINE = DOCUMENT_PARSING_ENGINE_LITEPARSE
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class _ImageSource:
|
|
56
|
+
"""An image to embed as an ``ImageNode``, plus the document it came from.
|
|
57
|
+
|
|
58
|
+
``path`` is the image file on disk (what gets embedded and served).
|
|
59
|
+
``origin`` is the document it belongs to: the image itself for a standalone
|
|
60
|
+
image file, or the source PDF/e-book for an image extracted during parsing —
|
|
61
|
+
so retrieval cites the source document rather than an opaque cache asset.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
path: Path
|
|
65
|
+
origin: Path
|
|
66
|
+
visual: VisualAssetCandidate | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LlamaIndexDocumentLoader:
|
|
70
|
+
"""Convert source files into LlamaIndex ``Document`` / ``ImageNode`` objects."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, logger=None, image_concurrency: int = 6) -> None:
|
|
73
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
74
|
+
self.image_concurrency = max(1, int(image_concurrency))
|
|
75
|
+
self._pdf_documents: dict[str, list[Any]] = {}
|
|
76
|
+
|
|
77
|
+
async def load(
|
|
78
|
+
self,
|
|
79
|
+
file_paths: Iterable[str],
|
|
80
|
+
image_progress_callback: Callable[[int, int], None] | None = None,
|
|
81
|
+
*,
|
|
82
|
+
kb_dir: Path | None = None,
|
|
83
|
+
visual_candidates: list[VisualAssetCandidate] | None = None,
|
|
84
|
+
) -> list[Any]:
|
|
85
|
+
documents: list[Any] = []
|
|
86
|
+
image_sources: list[_ImageSource] = []
|
|
87
|
+
classification = FileTypeRouter.classify_files(list(file_paths))
|
|
88
|
+
|
|
89
|
+
for file_path_str in classification.parser_files:
|
|
90
|
+
file_path = Path(file_path_str)
|
|
91
|
+
self.logger.info(f"Parsing document: {file_path.name}")
|
|
92
|
+
# MinerU cloud parsing blocks end to end (upload + 300s polling +
|
|
93
|
+
# archive download) on a synchronous httpx.Client — running it on
|
|
94
|
+
# the event loop stalls every other request for the whole PDF
|
|
95
|
+
# (same class of bug as upstream #761/#777). Hand it to a thread.
|
|
96
|
+
try:
|
|
97
|
+
text, extracted_images, parse_engine = await asyncio.to_thread(
|
|
98
|
+
self._parse_document, file_path, kb_dir=kb_dir
|
|
99
|
+
)
|
|
100
|
+
scanned_pdf_needs_ocr = (
|
|
101
|
+
file_path.suffix.lower() == ".pdf"
|
|
102
|
+
and not text.strip()
|
|
103
|
+
and parse_engine != _PDF_OCR_FALLBACK_ENGINE
|
|
104
|
+
)
|
|
105
|
+
if scanned_pdf_needs_ocr:
|
|
106
|
+
fallback = await asyncio.to_thread(
|
|
107
|
+
self._parse_document,
|
|
108
|
+
file_path,
|
|
109
|
+
None,
|
|
110
|
+
_PDF_OCR_FALLBACK_ENGINE,
|
|
111
|
+
kb_dir=kb_dir,
|
|
112
|
+
)
|
|
113
|
+
if fallback[0].strip() or fallback[1]:
|
|
114
|
+
text, extracted_images, parse_engine = fallback
|
|
115
|
+
scanned_pdf_needs_ocr = False
|
|
116
|
+
else:
|
|
117
|
+
self._log_scanned_pdf_without_ocr(
|
|
118
|
+
file_path,
|
|
119
|
+
parse_engine,
|
|
120
|
+
len(extracted_images),
|
|
121
|
+
)
|
|
122
|
+
except SourcePageError as exc:
|
|
123
|
+
# Parsing can succeed while the original PDF is unreadable.
|
|
124
|
+
# Keep that document's failure isolated and visible (#1982).
|
|
125
|
+
from deeptutor.knowledge.indexing_run import current_run
|
|
126
|
+
|
|
127
|
+
self._pdf_documents.pop(str(file_path), None)
|
|
128
|
+
self.logger.warning("Skipped %s: %s", file_path.name, exc)
|
|
129
|
+
if run := current_run():
|
|
130
|
+
run.document(file_path, "failed", reason="invalid_source", detail=str(exc))
|
|
131
|
+
continue
|
|
132
|
+
if not scanned_pdf_needs_ocr:
|
|
133
|
+
page_documents = self._pdf_documents.pop(str(file_path), [])
|
|
134
|
+
if page_documents:
|
|
135
|
+
documents.extend(page_documents)
|
|
136
|
+
else:
|
|
137
|
+
self._append_if_nonempty(
|
|
138
|
+
documents,
|
|
139
|
+
file_path,
|
|
140
|
+
text,
|
|
141
|
+
parse_engine=parse_engine,
|
|
142
|
+
extracted_image_count=len(extracted_images),
|
|
143
|
+
)
|
|
144
|
+
image_sources.extend(extracted_images)
|
|
145
|
+
self._append_visual_documents(documents, extracted_images, visual_candidates)
|
|
146
|
+
|
|
147
|
+
for file_path_str in classification.text_files:
|
|
148
|
+
file_path = Path(file_path_str)
|
|
149
|
+
self.logger.info(f"Parsing text: {file_path.name}")
|
|
150
|
+
text = await FileTypeRouter.read_text_file(str(file_path))
|
|
151
|
+
self._append_if_nonempty(documents, file_path, text)
|
|
152
|
+
|
|
153
|
+
for file_path_str in classification.image_files:
|
|
154
|
+
path = Path(file_path_str)
|
|
155
|
+
from deeptutor.services.parsing import get_parse_service
|
|
156
|
+
|
|
157
|
+
parse_service = get_parse_service()
|
|
158
|
+
supports = getattr(parse_service, "supports", lambda _path: False)
|
|
159
|
+
if supports(path):
|
|
160
|
+
self.logger.info(f"Parsing image with active document parser: {path.name}")
|
|
161
|
+
text, extracted_images, parse_engine = await asyncio.to_thread(
|
|
162
|
+
self._parse_document, path, parse_service, kb_dir=kb_dir
|
|
163
|
+
)
|
|
164
|
+
if text.strip() or extracted_images:
|
|
165
|
+
self._append_if_nonempty(
|
|
166
|
+
documents,
|
|
167
|
+
path,
|
|
168
|
+
text,
|
|
169
|
+
parse_engine=parse_engine,
|
|
170
|
+
extracted_image_count=len(extracted_images),
|
|
171
|
+
)
|
|
172
|
+
image_sources.extend(extracted_images)
|
|
173
|
+
self._append_visual_documents(documents, extracted_images, visual_candidates)
|
|
174
|
+
else:
|
|
175
|
+
# Preserve the pre-parser behavior when an image-capable
|
|
176
|
+
# engine fails or yields no usable IR.
|
|
177
|
+
image_sources.append(_ImageSource(path=path, origin=path))
|
|
178
|
+
else:
|
|
179
|
+
image_sources.append(_ImageSource(path=path, origin=path))
|
|
180
|
+
|
|
181
|
+
legacy_image_sources = [source for source in image_sources if source.visual is None]
|
|
182
|
+
if legacy_image_sources:
|
|
183
|
+
documents.extend(
|
|
184
|
+
await self._load_image_nodes(
|
|
185
|
+
legacy_image_sources, image_progress_callback=image_progress_callback
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
for file_path_str in classification.unsupported:
|
|
190
|
+
self.logger.warning(f"Skipped unsupported file: {Path(file_path_str).name}")
|
|
191
|
+
|
|
192
|
+
from deeptutor.knowledge.indexing_run import current_run
|
|
193
|
+
|
|
194
|
+
if run := current_run():
|
|
195
|
+
accepted = {
|
|
196
|
+
str(Path(doc.metadata["file_path"]).resolve())
|
|
197
|
+
for doc in documents
|
|
198
|
+
if isinstance(getattr(doc, "metadata", None), dict)
|
|
199
|
+
and doc.metadata.get("file_path")
|
|
200
|
+
}
|
|
201
|
+
for name in (
|
|
202
|
+
classification.parser_files + classification.text_files + classification.image_files
|
|
203
|
+
):
|
|
204
|
+
source = Path(name)
|
|
205
|
+
if str(source.resolve()) in accepted:
|
|
206
|
+
run.document(source, "embedding")
|
|
207
|
+
elif (
|
|
208
|
+
run.data["documents"].get(run.source_key(source), {}).get("status") != "failed"
|
|
209
|
+
):
|
|
210
|
+
run.document(
|
|
211
|
+
source,
|
|
212
|
+
"failed",
|
|
213
|
+
reason="invalid_output",
|
|
214
|
+
detail="No usable document content.",
|
|
215
|
+
)
|
|
216
|
+
return documents
|
|
217
|
+
|
|
218
|
+
def _parse_document(
|
|
219
|
+
self,
|
|
220
|
+
file_path: Path,
|
|
221
|
+
parse_service=None, # noqa: ANN001
|
|
222
|
+
engine: str | None = None,
|
|
223
|
+
*,
|
|
224
|
+
kb_dir: Path | None = None,
|
|
225
|
+
) -> tuple[str, list[_ImageSource], str]:
|
|
226
|
+
"""Parse a document through the shared, engine-pluggable parse layer.
|
|
227
|
+
|
|
228
|
+
Returns ``(text, extracted_images, engine)``. A parse failure (engine
|
|
229
|
+
unavailable, unsupported format for the active engine, or models not
|
|
230
|
+
ready) is logged and the file is skipped — matching the sibling
|
|
231
|
+
LightRAG/GraphRAG pipelines — rather than aborting the whole batch.
|
|
232
|
+
"""
|
|
233
|
+
from deeptutor.services.parsing import ParserError, get_parse_service
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
parsed = (parse_service or get_parse_service()).parse(file_path, engine=engine)
|
|
237
|
+
except ParserError as exc:
|
|
238
|
+
if engine:
|
|
239
|
+
self.logger.warning(
|
|
240
|
+
"Automatic OCR fallback failed for %s with the %s engine: %s",
|
|
241
|
+
file_path.name,
|
|
242
|
+
engine,
|
|
243
|
+
exc,
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
self.logger.warning(
|
|
247
|
+
f"Skipped {file_path.name}: the active document-parsing engine could "
|
|
248
|
+
f"not handle it ({exc}). Change the engine in Settings → Document Parsing."
|
|
249
|
+
)
|
|
250
|
+
return "", [], ""
|
|
251
|
+
|
|
252
|
+
text = parsed.markdown.strip() or self._text_from_blocks(parsed.blocks)
|
|
253
|
+
if kb_dir is not None and file_path.suffix.lower() == ".pdf":
|
|
254
|
+
import pymupdf
|
|
255
|
+
|
|
256
|
+
from deeptutor.services.rag.source_pages import build_pdf_documents
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
self._pdf_documents[str(file_path)] = build_pdf_documents(file_path, parsed, kb_dir)
|
|
260
|
+
except (pymupdf.FileDataError, OSError) as exc:
|
|
261
|
+
raise SourcePageError(f"Cannot read PDF page evidence: {exc}") from exc
|
|
262
|
+
images = self._collect_asset_images(parsed.asset_dir, origin=file_path)
|
|
263
|
+
if kb_dir is not None:
|
|
264
|
+
from deeptutor.services.rag.visual_coverage import record_coverage
|
|
265
|
+
|
|
266
|
+
candidates = collect_visual_assets(parsed, file_path, kb_dir)
|
|
267
|
+
record_coverage(parsed, file_path, kb_dir, candidates)
|
|
268
|
+
by_path = {candidate.path.resolve(): candidate for candidate in candidates}
|
|
269
|
+
images = [
|
|
270
|
+
_ImageSource(
|
|
271
|
+
path=image.path, origin=image.origin, visual=by_path[image.path.resolve()]
|
|
272
|
+
)
|
|
273
|
+
for image in images
|
|
274
|
+
if image.path.resolve() in by_path
|
|
275
|
+
]
|
|
276
|
+
return text, images, str(parsed.engine or "")
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
def _append_visual_documents(
|
|
280
|
+
documents: list[Any],
|
|
281
|
+
images: list[_ImageSource],
|
|
282
|
+
candidates: list[VisualAssetCandidate] | None,
|
|
283
|
+
) -> None:
|
|
284
|
+
for image in images:
|
|
285
|
+
if image.visual is None:
|
|
286
|
+
continue
|
|
287
|
+
record = image.visual.record
|
|
288
|
+
if candidates is not None:
|
|
289
|
+
candidates.append(image.visual)
|
|
290
|
+
caption = record["caption"] or "Source figure"
|
|
291
|
+
context = record["context"]
|
|
292
|
+
text = f"[Source visual] {image.origin.name}: {caption}"
|
|
293
|
+
if context:
|
|
294
|
+
text += f"\nContext: {context}"
|
|
295
|
+
if record.get("table_html"):
|
|
296
|
+
text += f"\nStructured table: {record['table_html']}"
|
|
297
|
+
if record.get("section"):
|
|
298
|
+
text += f"\nSection: {record['section']}"
|
|
299
|
+
documents.append(
|
|
300
|
+
Document(
|
|
301
|
+
text=text,
|
|
302
|
+
metadata={
|
|
303
|
+
"file_name": image.origin.name,
|
|
304
|
+
"file_path": str(image.origin),
|
|
305
|
+
"content_type": "source_visual",
|
|
306
|
+
"visual_asset_id": record["asset_id"],
|
|
307
|
+
"source_document_id": record["source_document_id"],
|
|
308
|
+
"page": record["page_number"] or "",
|
|
309
|
+
"bbox": record["bbox"],
|
|
310
|
+
"caption": record["caption"],
|
|
311
|
+
"source_locator": record["source_locator"],
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def _log_scanned_pdf_without_ocr(
|
|
317
|
+
self,
|
|
318
|
+
file_path: Path,
|
|
319
|
+
parse_engine: str,
|
|
320
|
+
extracted_image_count: int,
|
|
321
|
+
) -> None:
|
|
322
|
+
engine_label = parse_engine or "the active parser"
|
|
323
|
+
self.logger.warning(
|
|
324
|
+
"Skipped scanned PDF: %s. The %s engine extracted %d image(s) but no text, "
|
|
325
|
+
"and no usable local OCR fallback was available. Install LiteParse under "
|
|
326
|
+
"Settings, Document Parsing, or switch to an OCR-capable engine with OCR enabled.",
|
|
327
|
+
file_path.name,
|
|
328
|
+
engine_label,
|
|
329
|
+
extracted_image_count,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
@staticmethod
|
|
333
|
+
def _text_from_blocks(blocks: list[dict] | None) -> str:
|
|
334
|
+
"""Fall back to concatenating block text when an engine emits no markdown."""
|
|
335
|
+
if not blocks:
|
|
336
|
+
return ""
|
|
337
|
+
parts = [
|
|
338
|
+
str(block.get("text") or block.get("content") or "").strip()
|
|
339
|
+
for block in blocks
|
|
340
|
+
if isinstance(block, dict)
|
|
341
|
+
]
|
|
342
|
+
return "\n\n".join(part for part in parts if part)
|
|
343
|
+
|
|
344
|
+
def _collect_asset_images(self, asset_dir: Path | None, *, origin: Path) -> list[_ImageSource]:
|
|
345
|
+
"""Gather images the parse engine extracted into ``asset_dir``.
|
|
346
|
+
|
|
347
|
+
Engines that don't extract images (text-only, markitdown) leave
|
|
348
|
+
``asset_dir`` empty, so this returns nothing and the document is indexed
|
|
349
|
+
as text alone.
|
|
350
|
+
"""
|
|
351
|
+
if not asset_dir or not Path(asset_dir).is_dir():
|
|
352
|
+
return []
|
|
353
|
+
images = [
|
|
354
|
+
_ImageSource(path=child, origin=origin)
|
|
355
|
+
for child in sorted(Path(asset_dir).iterdir())
|
|
356
|
+
if child.is_file() and child.suffix.lower() in FileTypeRouter.IMAGE_EXTENSIONS
|
|
357
|
+
]
|
|
358
|
+
if images:
|
|
359
|
+
self.logger.info(
|
|
360
|
+
f"Extracted {len(images)} image(s) from {origin.name} for multimodal indexing"
|
|
361
|
+
)
|
|
362
|
+
return images
|
|
363
|
+
|
|
364
|
+
async def _load_image_nodes(
|
|
365
|
+
self,
|
|
366
|
+
sources: list[_ImageSource],
|
|
367
|
+
*,
|
|
368
|
+
image_progress_callback: Callable[[int, int], None] | None = None,
|
|
369
|
+
) -> list[ImageNode]:
|
|
370
|
+
try:
|
|
371
|
+
embedding_client = get_embedding_client()
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
self._log_skipped_images(sources, f"embedding client is unavailable ({exc})")
|
|
374
|
+
return []
|
|
375
|
+
if not embedding_client.supports_multimodal_contents():
|
|
376
|
+
self._log_skipped_images(
|
|
377
|
+
sources,
|
|
378
|
+
"embedding provider/model does not support multimodal contents "
|
|
379
|
+
f"(binding={embedding_client.config.binding}, "
|
|
380
|
+
f"model={embedding_client.config.model})",
|
|
381
|
+
)
|
|
382
|
+
return []
|
|
383
|
+
|
|
384
|
+
# Resolve the LLM only after the embedding prerequisite passes. This
|
|
385
|
+
# keeps text-only embedding setups independent of LLM configuration and
|
|
386
|
+
# reuses one client for the whole image batch.
|
|
387
|
+
try:
|
|
388
|
+
llm_client = get_image_description_client()
|
|
389
|
+
except Exception as exc:
|
|
390
|
+
self._log_skipped_images(sources, f"LLM client is unavailable ({exc})")
|
|
391
|
+
return []
|
|
392
|
+
if not llm_client.supports_multimodal_images():
|
|
393
|
+
self._log_skipped_images(
|
|
394
|
+
sources,
|
|
395
|
+
"LLM provider/model does not support multimodal image input "
|
|
396
|
+
f"(binding={llm_client.config.binding}, model={llm_client.config.model})",
|
|
397
|
+
)
|
|
398
|
+
return []
|
|
399
|
+
|
|
400
|
+
embedded: list[_ImageSource] = []
|
|
401
|
+
descriptions: list[str] = []
|
|
402
|
+
contents: list[dict[str, str]] = []
|
|
403
|
+
completed = 0
|
|
404
|
+
total = len(sources)
|
|
405
|
+
concurrency, timeout_seconds = image_description_limits()
|
|
406
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
407
|
+
|
|
408
|
+
async def _describe_one(
|
|
409
|
+
source: _ImageSource,
|
|
410
|
+
) -> tuple[_ImageSource, str, dict[str, str]] | None:
|
|
411
|
+
nonlocal completed
|
|
412
|
+
result: tuple[_ImageSource, str, dict[str, str]] | None = None
|
|
413
|
+
try:
|
|
414
|
+
try:
|
|
415
|
+
async with semaphore:
|
|
416
|
+
image_payload = self._load_image_payload(source.path)
|
|
417
|
+
description = await asyncio.wait_for(
|
|
418
|
+
self._describe_image(
|
|
419
|
+
llm_client,
|
|
420
|
+
source.path,
|
|
421
|
+
image_payload["base64"],
|
|
422
|
+
image_payload["mimetype"],
|
|
423
|
+
),
|
|
424
|
+
timeout=timeout_seconds,
|
|
425
|
+
)
|
|
426
|
+
except asyncio.TimeoutError:
|
|
427
|
+
self.logger.error(
|
|
428
|
+
"Image description timed out after %ss: %s",
|
|
429
|
+
timeout_seconds,
|
|
430
|
+
source.path.name,
|
|
431
|
+
)
|
|
432
|
+
except OSError as exc:
|
|
433
|
+
self.logger.error(f"Failed to read image {source.path.name}: {exc}")
|
|
434
|
+
except Exception as exc:
|
|
435
|
+
self.logger.error(
|
|
436
|
+
"Failed to describe image %s with configured multimodal LLM "
|
|
437
|
+
"(binding=%s, model=%s): %s",
|
|
438
|
+
source.path.name,
|
|
439
|
+
llm_client.config.binding,
|
|
440
|
+
llm_client.config.model,
|
|
441
|
+
exc,
|
|
442
|
+
)
|
|
443
|
+
else:
|
|
444
|
+
if not description:
|
|
445
|
+
self.logger.warning(
|
|
446
|
+
"Skipped image because the configured multimodal LLM "
|
|
447
|
+
f"returned no description: {source.path.name}"
|
|
448
|
+
)
|
|
449
|
+
else:
|
|
450
|
+
result = (
|
|
451
|
+
source,
|
|
452
|
+
description,
|
|
453
|
+
{"image": image_payload["data_uri"]},
|
|
454
|
+
)
|
|
455
|
+
finally:
|
|
456
|
+
completed += 1
|
|
457
|
+
if image_progress_callback:
|
|
458
|
+
try:
|
|
459
|
+
image_progress_callback(completed, total)
|
|
460
|
+
except Exception as exc:
|
|
461
|
+
self.logger.warning(
|
|
462
|
+
"Image progress callback failed (completed=%s, total=%s): %s",
|
|
463
|
+
completed,
|
|
464
|
+
total,
|
|
465
|
+
exc,
|
|
466
|
+
)
|
|
467
|
+
return result
|
|
468
|
+
|
|
469
|
+
# gather preserves input order, so embedded/descriptions/contents stay
|
|
470
|
+
# aligned regardless of completion order.
|
|
471
|
+
batch_size = image_description_batch_size()
|
|
472
|
+
if batch_size == 1:
|
|
473
|
+
results = await asyncio.gather(*(_describe_one(source) for source in sources))
|
|
474
|
+
else:
|
|
475
|
+
batcher = ImageCaptionBatcher(
|
|
476
|
+
llm_client,
|
|
477
|
+
prompt=IMAGE_DESCRIPTION_PROMPT,
|
|
478
|
+
system_prompt=IMAGE_DESCRIPTION_SYSTEM_PROMPT,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
async def _describe_group(group: list[_ImageSource]):
|
|
482
|
+
nonlocal completed
|
|
483
|
+
prepared = []
|
|
484
|
+
try:
|
|
485
|
+
async with semaphore:
|
|
486
|
+
if batcher.halted:
|
|
487
|
+
return []
|
|
488
|
+
for source in group:
|
|
489
|
+
try:
|
|
490
|
+
payload = await asyncio.to_thread(
|
|
491
|
+
self._load_image_payload, source.path
|
|
492
|
+
)
|
|
493
|
+
except OSError:
|
|
494
|
+
self.logger.warning(
|
|
495
|
+
"Failed to read caption image: %s", source.path.name
|
|
496
|
+
)
|
|
497
|
+
continue
|
|
498
|
+
prepared.append((source, {**payload, "filename": source.path.name}))
|
|
499
|
+
descriptions = await asyncio.wait_for(
|
|
500
|
+
batcher.describe([payload for _, payload in prepared]),
|
|
501
|
+
timeout=timeout_seconds,
|
|
502
|
+
)
|
|
503
|
+
return [
|
|
504
|
+
(source, caption, {"image": payload["data_uri"]})
|
|
505
|
+
for (source, payload), caption in zip(prepared, descriptions)
|
|
506
|
+
if caption
|
|
507
|
+
]
|
|
508
|
+
except asyncio.TimeoutError:
|
|
509
|
+
self.logger.warning(
|
|
510
|
+
"Image caption batch exceeded its %ss deadline", timeout_seconds
|
|
511
|
+
)
|
|
512
|
+
return []
|
|
513
|
+
except Exception as exc: # noqa: BLE001 - captions are best-effort
|
|
514
|
+
# Every other image failure here skips and logs; a raising
|
|
515
|
+
# group must not abort the whole document ingest.
|
|
516
|
+
self.logger.warning(
|
|
517
|
+
"Image caption group failed (%s: %s); skipping %s image(s)",
|
|
518
|
+
type(exc).__name__,
|
|
519
|
+
exc,
|
|
520
|
+
len(group),
|
|
521
|
+
)
|
|
522
|
+
return []
|
|
523
|
+
finally:
|
|
524
|
+
for _ in group:
|
|
525
|
+
completed += 1
|
|
526
|
+
if image_progress_callback:
|
|
527
|
+
try:
|
|
528
|
+
image_progress_callback(completed, total)
|
|
529
|
+
except Exception:
|
|
530
|
+
pass
|
|
531
|
+
|
|
532
|
+
groups = await asyncio.gather(
|
|
533
|
+
*(
|
|
534
|
+
_describe_group(sources[start : start + batch_size])
|
|
535
|
+
for start in range(0, len(sources), batch_size)
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
results = [result for group in groups for result in group]
|
|
539
|
+
for result in results:
|
|
540
|
+
if result is None:
|
|
541
|
+
continue
|
|
542
|
+
embedded.append(result[0])
|
|
543
|
+
descriptions.append(result[1])
|
|
544
|
+
contents.append(result[2])
|
|
545
|
+
|
|
546
|
+
if not contents:
|
|
547
|
+
return []
|
|
548
|
+
|
|
549
|
+
try:
|
|
550
|
+
embeddings = await embedding_client.embed_contents(contents)
|
|
551
|
+
except Exception as exc:
|
|
552
|
+
self.logger.error(
|
|
553
|
+
"Failed to embed image contents with configured multimodal embedding "
|
|
554
|
+
"provider/model (binding=%s, model=%s): %s",
|
|
555
|
+
embedding_client.config.binding,
|
|
556
|
+
embedding_client.config.model,
|
|
557
|
+
exc,
|
|
558
|
+
)
|
|
559
|
+
return []
|
|
560
|
+
nodes: list[ImageNode] = []
|
|
561
|
+
for source, description, embedding in zip(embedded, descriptions, embeddings):
|
|
562
|
+
mimetype = mimetypes.guess_type(source.path.name)[0] or "application/octet-stream"
|
|
563
|
+
nodes.append(
|
|
564
|
+
ImageNode(
|
|
565
|
+
text=f"[Image] {source.origin.name}\n\n{description}",
|
|
566
|
+
image_path=str(source.path),
|
|
567
|
+
image_mimetype=mimetype,
|
|
568
|
+
metadata={
|
|
569
|
+
"file_name": source.origin.name,
|
|
570
|
+
"file_path": str(source.origin),
|
|
571
|
+
"content_type": "image",
|
|
572
|
+
"image_description": description,
|
|
573
|
+
},
|
|
574
|
+
embedding=embedding,
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
self.logger.info(f"Loaded image: {source.path.name} ({len(embedding)}D vector)")
|
|
578
|
+
return nodes
|
|
579
|
+
|
|
580
|
+
def _log_skipped_images(self, sources: list[_ImageSource], reason: str) -> None:
|
|
581
|
+
for source in sources:
|
|
582
|
+
self.logger.warning(
|
|
583
|
+
"Skipped image because image indexing requires both multimodal "
|
|
584
|
+
f"embedding and multimodal LLM support; {reason}: {source.path.name}"
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
async def _describe_image(
|
|
588
|
+
self, llm_client: Any, file_path: Path, image_base64: str, mimetype: str
|
|
589
|
+
) -> str:
|
|
590
|
+
response = await complete_image_caption(
|
|
591
|
+
llm_client,
|
|
592
|
+
IMAGE_DESCRIPTION_PROMPT,
|
|
593
|
+
system_prompt=IMAGE_DESCRIPTION_SYSTEM_PROMPT,
|
|
594
|
+
image_data=image_base64,
|
|
595
|
+
image_mime_type=mimetype,
|
|
596
|
+
image_filename=file_path.name,
|
|
597
|
+
)
|
|
598
|
+
return response.strip()
|
|
599
|
+
|
|
600
|
+
def _load_image_payload(self, file_path: Path) -> dict[str, str]:
|
|
601
|
+
size = file_path.stat().st_size
|
|
602
|
+
if size > DocumentValidator.MAX_FILE_SIZE:
|
|
603
|
+
raise OSError(
|
|
604
|
+
f"image file too large: {size} bytes; "
|
|
605
|
+
f"maximum allowed: {DocumentValidator.MAX_FILE_SIZE} bytes"
|
|
606
|
+
)
|
|
607
|
+
mimetype = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
|
|
608
|
+
encoded = base64.b64encode(file_path.read_bytes()).decode("ascii")
|
|
609
|
+
return {
|
|
610
|
+
"base64": encoded,
|
|
611
|
+
"data_uri": f"data:{mimetype};base64,{encoded}",
|
|
612
|
+
"mimetype": mimetype,
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
def _append_if_nonempty(
|
|
616
|
+
self,
|
|
617
|
+
documents: list[Any],
|
|
618
|
+
file_path: Path,
|
|
619
|
+
text: str,
|
|
620
|
+
*,
|
|
621
|
+
parse_engine: str = "",
|
|
622
|
+
extracted_image_count: int = 0,
|
|
623
|
+
) -> None:
|
|
624
|
+
if text.strip():
|
|
625
|
+
documents.append(
|
|
626
|
+
Document(
|
|
627
|
+
text=text,
|
|
628
|
+
metadata={
|
|
629
|
+
"file_name": file_path.name,
|
|
630
|
+
"file_path": str(file_path),
|
|
631
|
+
},
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
self.logger.info(f"Loaded: {file_path.name} ({len(text)} chars)")
|
|
635
|
+
else:
|
|
636
|
+
if file_path.suffix.lower() == ".pdf" and extracted_image_count:
|
|
637
|
+
engine_label = parse_engine or "the active parser"
|
|
638
|
+
self.logger.warning(
|
|
639
|
+
"Skipped empty document: %s. The %s engine extracted %d image(s) "
|
|
640
|
+
"but no text. This is usually a scanned PDF; use an OCR-capable "
|
|
641
|
+
"parsing engine such as MinerU or Docling with OCR enabled. "
|
|
642
|
+
"Change the engine in Settings, Document Parsing.",
|
|
643
|
+
file_path.name,
|
|
644
|
+
engine_label,
|
|
645
|
+
extracted_image_count,
|
|
646
|
+
)
|
|
647
|
+
else:
|
|
648
|
+
self.logger.warning(f"Skipped empty document: {file_path.name}")
|