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,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}")