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,373 @@
1
+ """Read-only, structural exercise lookup ahead of semantic retrieval.
2
+
3
+ Only completed parses of PDFs actually present in the selected KB are read.
4
+ No OCR, model call, index write, or global cross-KB search is performed here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from functools import lru_cache
11
+ import hashlib
12
+ import json
13
+ from pathlib import Path
14
+ import re
15
+ from typing import Any
16
+ import unicodedata
17
+
18
+ from deeptutor.services.parsing.cache import source_hash_from_path
19
+
20
+ MAX_QUESTIONS = 30
21
+ MAX_CONTENT = 24000
22
+ _SKIP = {"header", "footer", "page_header", "page_footer", "page_number"}
23
+ _PAIR = re.compile(r"(?<![\dA-Za-z.])(\d{1,3})\s*[-.]\s*(\d{1,3})(?!\d|[.-]\d)")
24
+ _CHAPTER = re.compile(r"第\s*([零〇一二两三四五六七八九十百\d]+)\s*章")
25
+ _START = re.compile(r"^\s*(?:(\d{1,3})\s*[-.]\s*(\d{1,3})[.、]?\s*|(\d{1,3})[.、]\s+)(?=\S)")
26
+ _INTENT = re.compile(
27
+ r"习题|思考题|练习题|题干|题目|第.{0,8}题|解答|怎么做|怎么解|problem|exercise", re.I
28
+ )
29
+
30
+
31
+ def _normal(text: str) -> str:
32
+ return unicodedata.normalize("NFKC", text).translate(str.maketrans("—–−-", "----"))
33
+
34
+
35
+ def _number(text: str) -> int:
36
+ if text.isdigit():
37
+ return int(text)
38
+ digits = dict(zip("零〇一二两三四五六七八九", [0, 0, 1, 2, 2, 3, 4, 5, 6, 7, 8, 9]))
39
+ value = current = 0
40
+ for char in text:
41
+ if char in digits:
42
+ current = digits[char]
43
+ elif char in "十百":
44
+ value += (current or 1) * (10 if char == "十" else 100)
45
+ current = 0
46
+ return value + current
47
+
48
+
49
+ def requested_exercises(query: str) -> tuple[list[tuple[int, int]], str | None]:
50
+ """Do not interpret formula/figure/section queries or arithmetic as exercises."""
51
+ query = _normal(query)
52
+ explicit = bool(_INTENT.search(query))
53
+ if not explicit and re.search(
54
+ r"公式|方程|图\s*\d|式\s*\d|节|section|equation|figure", query, re.I
55
+ ):
56
+ return [], None
57
+ matches = list(_PAIR.finditer(query))
58
+ bare = re.sub(r"[\d\s.,、;:()\-]+", "", query) == ""
59
+ lookup_request = bool(re.search(r"找|查找|查一下|查下|搜索|lookup|find", query, re.I))
60
+ if not explicit and not bare and not lookup_request:
61
+ return [], None
62
+ keys = []
63
+ for match in matches:
64
+ key = (int(match[1]), int(match[2]))
65
+ if all(key) and key not in keys:
66
+ keys.append(key)
67
+ chapter = _CHAPTER.search(query)
68
+ if not keys and chapter:
69
+ for match in re.finditer(r"第?\s*(\d{1,3})\s*题", query[chapter.end() :]):
70
+ key = (_number(chapter[1]), int(match[1]))
71
+ if all(key) and key not in keys:
72
+ keys.append(key)
73
+ # Bounded explicit ranges, e.g. 2-13 至 2-15.
74
+ for left, right in zip(matches, matches[1:]):
75
+ if re.fullmatch(r"\s*(?:到|至|~|~)\s*", query[left.end() : right.start()]):
76
+ a, b = (int(left[1]), int(left[2])), (int(right[1]), int(right[2]))
77
+ if a[0] == b[0] and 0 < b[1] - a[1] < MAX_QUESTIONS:
78
+ pos = keys.index(a)
79
+ keys[pos : pos + 1] = [(a[0], n) for n in range(a[1], b[1])]
80
+ kind = (
81
+ "思考题"
82
+ if "思考题" in query
83
+ else "习题"
84
+ if re.search(r"习题|练习题|exercise", query, re.I)
85
+ else None
86
+ )
87
+ return list(dict.fromkeys(keys)), kind
88
+
89
+
90
+ def _text(block: dict[str, Any]) -> str:
91
+ parts = []
92
+ for key in (
93
+ "text",
94
+ "content",
95
+ "body",
96
+ "code_body",
97
+ "table_body",
98
+ "image_caption",
99
+ "image_footnote",
100
+ "table_caption",
101
+ "table_footnote",
102
+ ):
103
+ value = block.get(key)
104
+ if isinstance(value, list):
105
+ value = "\n".join(str(v) for v in value)
106
+ if isinstance(value, str) and value.strip() and value.strip() not in parts:
107
+ parts.append(value.strip())
108
+ return "\n\n".join(parts)
109
+
110
+
111
+ def _page(block: dict[str, Any]) -> int | None:
112
+ try:
113
+ return (
114
+ int(block["original_page"])
115
+ if block.get("original_page") is not None
116
+ else int(block["page_idx"]) + 1
117
+ )
118
+ except (KeyError, TypeError, ValueError):
119
+ return None
120
+
121
+
122
+ @dataclass
123
+ class Exercise:
124
+ """One numbered exercise and its contiguous parsed source blocks."""
125
+
126
+ chapter: int
127
+ number: int
128
+ kind: str
129
+ ordinal: int
130
+ parts: list[str] = field(default_factory=list)
131
+ pages: set[int] = field(default_factory=set)
132
+ images: bool = False
133
+
134
+
135
+ def collect_exercises(blocks: list[dict[str, Any]]) -> list[Exercise]:
136
+ """Join problem text, equations and tables within exercise sections."""
137
+ chapter = 0
138
+ section_chapter = None
139
+ kind = None
140
+ current = None
141
+ exercises = []
142
+ for ordinal, block in enumerate(blocks):
143
+ if not isinstance(block, dict) or block.get("type") in _SKIP:
144
+ continue
145
+ text = _text(block)
146
+ normal = _normal(text)
147
+ compact = re.sub(r"\s+", "", normal)
148
+ # TOC lines are not chapter boundaries or exercise headings.
149
+ toc = "……" in text or "..." in text or "…" in text
150
+ heading = _CHAPTER.match(normal)
151
+ chapter_title = heading and (
152
+ block.get("text_level") or not re.search(r"[\d。,$,]", normal[heading.end() :])
153
+ )
154
+ if chapter_title and not toc and len(text) < 100 and "\n" not in text:
155
+ chapter = _number(heading[1])
156
+ kind = None
157
+ current = None
158
+ continue
159
+ if compact in {"习题", "练习题", "思考题", "Exercises", "Problems"}:
160
+ kind = "思考题" if compact == "思考题" else "习题"
161
+ current = None
162
+ section_chapter = None
163
+ continue
164
+ if not kind:
165
+ continue
166
+ caption = re.fullmatch(r"(?:习题|思考题)\s*(\d+)\s*[-.]\s*(\d+)", normal)
167
+ if caption and block.get("type") != "image":
168
+ # OCR occasionally drops the next question's opening paragraph.
169
+ # A later question's figure caption still gives us a boundary;
170
+ # never append its continuation to the preceding problem.
171
+ if current and int(caption[1]) == current.chapter and int(caption[2]) > current.number:
172
+ current = None
173
+ continue
174
+ # A new numbered section ends the exercise area even when OCR omitted
175
+ # the intervening chapter title (common in image-based title pages).
176
+ if block.get("text_level") and _START.match(normal):
177
+ kind = None
178
+ current = None
179
+ continue
180
+ if block.get("type") == "image":
181
+ if current is not None:
182
+ labels = [(int(m[1]), int(m[2])) for m in _PAIR.finditer(normal)]
183
+ if labels and (current.chapter, current.number) not in labels:
184
+ if any(c == current.chapter and n > current.number for c, n in labels):
185
+ current = None
186
+ continue
187
+ current.images = True
188
+ if _page(block) is not None:
189
+ current.pages.add(_page(block))
190
+ # Captions are image metadata, never the start of another exercise.
191
+ continue
192
+ if toc:
193
+ current = None
194
+ kind = None
195
+ continue
196
+ if not text or re.fullmatch(r"\d+", text):
197
+ continue
198
+ # Some parsers put consecutive numbered problems in one text block.
199
+ pieces = (
200
+ re.split(r"\n(?=\s*\d{1,3}(?:[-.]\d{1,3}[.、]?\s*|[.、]\s+)\S)", text)
201
+ if block.get("type") == "text"
202
+ else [text]
203
+ )
204
+ for piece in pieces:
205
+ match = _START.match(_normal(piece)) if block.get("type") == "text" else None
206
+ if match:
207
+ c = int(match[1]) if match[1] else chapter
208
+ n = int(match[2] or match[3])
209
+ # A different chapter here is usually a decimal measurement.
210
+ if c and n and (section_chapter is None or c == section_chapter):
211
+ chapter = c
212
+ section_chapter = c
213
+ current = Exercise(c, n, kind, ordinal)
214
+ exercises.append(current)
215
+ if current is not None:
216
+ current.parts.append(piece)
217
+ if _page(block) is not None:
218
+ current.pages.add(_page(block))
219
+ return exercises
220
+
221
+
222
+ @lru_cache(maxsize=32)
223
+ def _source_hash(path: str, mtime: int, size: int) -> str:
224
+ # mtime and size invalidate the bounded cache when a source changes.
225
+ return source_hash_from_path(Path(path))
226
+
227
+
228
+ @lru_cache(maxsize=8)
229
+ def _parsed(path: str, mtime: int, size: int) -> tuple[Exercise, ...]:
230
+ blocks = json.loads(Path(path).read_text(encoding="utf-8"))
231
+ return tuple(collect_exercises(blocks)) if isinstance(blocks, list) else ()
232
+
233
+
234
+ def _parse_for(source: Path, cache_root: Path) -> Path | None:
235
+ stat = source.stat()
236
+ digest = _source_hash(str(source), stat.st_mtime_ns, stat.st_size)
237
+ root = cache_root / digest[:2] / digest
238
+ manifests = sorted(
239
+ root.glob("*/manifest.json"), key=lambda p: p.stat().st_mtime_ns, reverse=True
240
+ )
241
+ for manifest in manifests:
242
+ if manifest.parent.name.startswith(".") or not manifest.resolve().is_relative_to(
243
+ cache_root.resolve()
244
+ ):
245
+ continue
246
+ try:
247
+ meta = json.loads(manifest.read_text(encoding="utf-8"))
248
+ if not isinstance(meta, dict) or meta.get("source_hash") != digest:
249
+ continue
250
+ files = sorted(manifest.parent.rglob("*_content_list.json"))
251
+ files = [
252
+ p
253
+ for p in files
254
+ if not any(s.startswith(".") for s in p.relative_to(manifest.parent).parts)
255
+ and p.resolve().is_relative_to(manifest.parent.resolve())
256
+ ]
257
+ full = [p for p in files if p.name == "full_content_list.json"]
258
+ if full or files:
259
+ return (full or files)[0]
260
+ except (OSError, ValueError, TypeError):
261
+ continue
262
+ return None
263
+
264
+
265
+ def lookup_exercises(query: str, kb_dir: Path, cache_root: Path) -> dict[str, Any] | None:
266
+ """Return exact candidates from ready PDF parses, or allow normal retrieval.
267
+
268
+ Sources must belong to the selected KB. Missing structured parses return
269
+ ``None``; supported parses report each number as found, ambiguous or absent.
270
+ No parse, index, embedding or settings files are created or modified.
271
+ """
272
+ keys, kind = requested_exercises(query)
273
+ if not keys:
274
+ return None
275
+ if len(keys) > MAX_QUESTIONS:
276
+ content = f"本次包含 {len(keys)} 个题号,请分批检索,每批不超过 {MAX_QUESTIONS} 题,以免遗漏题干。"
277
+ return {
278
+ "query": query,
279
+ "answer": content,
280
+ "content": content,
281
+ "sources": [],
282
+ "provider": "llamaindex",
283
+ "retrieval_method": "exercise_exact",
284
+ "exercise_status": [],
285
+ }
286
+ candidates = {key: [] for key in keys}
287
+ supported = False
288
+ raw = kb_dir / "raw"
289
+ for source in sorted(raw.glob("*.pdf")):
290
+ if not source.resolve().is_relative_to(raw.resolve()):
291
+ continue
292
+ try:
293
+ parsed = _parse_for(source, cache_root)
294
+ if parsed is None:
295
+ continue
296
+ stat = parsed.stat()
297
+ exercises = _parsed(str(parsed), stat.st_mtime_ns, stat.st_size)
298
+ if not exercises:
299
+ continue
300
+ supported = True
301
+ for exercise in exercises:
302
+ key = (exercise.chapter, exercise.number)
303
+ if key in candidates and (kind is None or exercise.kind == kind):
304
+ candidates[key].append((source, exercise))
305
+ except (OSError, ValueError, TypeError):
306
+ continue
307
+ if not supported:
308
+ return None
309
+ parts = [
310
+ "以下按题号定位现有解析原文;未定位到的题目不代表原书不存在,不能用图号、式号或相似题替代。"
311
+ ]
312
+ sources = []
313
+ statuses = []
314
+ for key, matches in candidates.items():
315
+ label = f"{key[0]}-{key[1]}"
316
+ status = "ambiguous" if len(matches) > 1 else "found" if matches else "not_found"
317
+ statuses.append({"question": label, "status": status, "matches": len(matches)})
318
+ if not matches:
319
+ parts.append(
320
+ f"【{label}】未在当前解析的习题/思考题区域定位到;请核对书籍、章节、题型,必要时检查原页。"
321
+ )
322
+ continue
323
+ if len(matches) > 1:
324
+ parts.append(
325
+ f"【{label}】存在 {len(matches)} 个候选,下面分别标明书名与题型,请勿混合题干。"
326
+ )
327
+ for source, exercise in matches[:4]:
328
+ pages = sorted(exercise.pages)
329
+ page_label = ", ".join(map(str, pages)) or "未知"
330
+ text = "\n\n".join(exercise.parts)
331
+ title = f"{source.name}|{exercise.kind} {label}|PDF页 {page_label}"
332
+ note = (
333
+ "\n[本题涉及图示;文本不能代替图形条件,请核对上述原PDF页。]"
334
+ if exercise.images
335
+ else ""
336
+ )
337
+ excerpt = f"【{title}】\n{text}{note}"
338
+ if sum(map(len, parts)) + len(excerpt) > MAX_CONTENT:
339
+ parts.append(
340
+ f"【{label}】内容超出本次返回长度,请缩小题号范围或检查原PDF页 {page_label}。"
341
+ )
342
+ statuses[-1]["status"] = "needs_single_query"
343
+ continue
344
+ parts.append(excerpt)
345
+ chunk_id = (
346
+ "exercise-"
347
+ + hashlib.sha256(
348
+ f"{source}:{key}:{exercise.kind}:{exercise.ordinal}".encode()
349
+ ).hexdigest()[:20]
350
+ )
351
+ sources.append(
352
+ {
353
+ "title": title,
354
+ "content": text[:200],
355
+ "source": str(source),
356
+ "page": page_label,
357
+ "chunk_id": chunk_id,
358
+ "score": 1.0,
359
+ "question_number": label,
360
+ "exercise_kind": exercise.kind,
361
+ "requires_page_review": exercise.images,
362
+ }
363
+ )
364
+ content = "\n\n".join(parts)
365
+ return {
366
+ "query": query,
367
+ "answer": content,
368
+ "content": content,
369
+ "sources": sources,
370
+ "provider": "llamaindex",
371
+ "retrieval_method": "exercise_exact",
372
+ "exercise_status": statuses,
373
+ }
@@ -0,0 +1,129 @@
1
+ """LlamaIndex ingestion helpers.
2
+
3
+ This module keeps DeepTutor's indexing path thin by delegating parsing
4
+ transformations and embedding to LlamaIndex's official IngestionPipeline.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from llama_index.core import Document, Settings, VectorStoreIndex
13
+ from llama_index.core.ingestion import IngestionPipeline
14
+ from llama_index.core.node_parser import SentenceSplitter
15
+ from llama_index.core.schema import BaseNode
16
+
17
+ from . import vector_store
18
+ from .config import should_show_progress, vector_index_config_from_settings
19
+ from .embedding_adapter import current_embedding
20
+
21
+
22
+ def build_ingestion_pipeline() -> IngestionPipeline:
23
+ """Create the default DeepTutor ingestion pipeline.
24
+
25
+ The embedding step uses ``Settings.embed_model``, which is configured by
26
+ ``embedding_adapter.configure_llamaindex_settings`` to call DeepTutor's
27
+ configured embedding service rather than any local model.
28
+ """
29
+
30
+ return IngestionPipeline(
31
+ transformations=[
32
+ SentenceSplitter(
33
+ chunk_size=Settings.chunk_size,
34
+ chunk_overlap=Settings.chunk_overlap,
35
+ ),
36
+ current_embedding(),
37
+ ],
38
+ )
39
+
40
+
41
+ def _has_precomputed_embedding(document: Any) -> bool:
42
+ """Return True only for non-Document nodes that already carry a vector.
43
+
44
+ LlamaIndex's ``Document`` class inherits from ``BaseNode``, so a naive
45
+ ``isinstance(doc, BaseNode)`` check incorrectly classifies every Document
46
+ as pre-embedded, bypassing the chunking pipeline entirely. This helper
47
+ distinguishes genuinely pre-embedded nodes (e.g. ImageNode produced by
48
+ multimodal loaders) from regular Documents that still need splitting and
49
+ embedding. The embedding may be a list or a numpy array, so we check
50
+ ``len(...) > 0`` rather than ``bool(...)`` (ambiguous for ndarrays).
51
+ """
52
+ if isinstance(document, Document):
53
+ return False
54
+ if not isinstance(document, BaseNode):
55
+ return False
56
+ embedding = getattr(document, "embedding", None)
57
+ if embedding is None:
58
+ return False
59
+ try:
60
+ return len(embedding) > 0
61
+ except TypeError:
62
+ return True
63
+
64
+
65
+ def documents_to_nodes(documents: list[Any], *, show_progress: bool | None = None) -> list[Any]:
66
+ """Convert LlamaIndex documents into embedded nodes.
67
+
68
+ Pre-embedded nodes, such as ImageNode instances produced by the document
69
+ loader, pass through unchanged so they are not re-embedded as text.
70
+
71
+ ``show_progress=None`` resolves the tqdm decision at call time. It must not
72
+ be a default-argument call: Python evaluates those once at import, which
73
+ would freeze whatever ``sys.stdout`` happened to be when the module first
74
+ loaded.
75
+ """
76
+ if show_progress is None:
77
+ show_progress = should_show_progress()
78
+ text_documents = [
79
+ document for document in documents if not _has_precomputed_embedding(document)
80
+ ]
81
+ preembedded_nodes = [document for document in documents if _has_precomputed_embedding(document)]
82
+
83
+ nodes: list[Any] = []
84
+ if text_documents:
85
+ pipeline = build_ingestion_pipeline()
86
+ nodes.extend(pipeline.run(documents=text_documents, show_progress=show_progress))
87
+ nodes.extend(preembedded_nodes)
88
+ return nodes
89
+
90
+
91
+ def create_index_from_documents(
92
+ documents: list[Any], storage_dir: Path, *, show_progress: bool | None = None
93
+ ) -> tuple[VectorStoreIndex, int]:
94
+ """Create and persist a VectorStoreIndex from documents.
95
+
96
+ Uses a FAISS-backed store when available and all node embeddings share one
97
+ dimension; otherwise LlamaIndex's default SimpleVectorStore.
98
+ """
99
+ if show_progress is None:
100
+ show_progress = should_show_progress()
101
+ nodes = documents_to_nodes(documents, show_progress=show_progress)
102
+ storage_context = vector_store.storage_context_for_nodes(
103
+ nodes, vector_index_config_from_settings()
104
+ )
105
+ index = VectorStoreIndex(
106
+ nodes=nodes,
107
+ storage_context=storage_context,
108
+ show_progress=show_progress,
109
+ embed_model=current_embedding(),
110
+ )
111
+ index.storage_context.persist(persist_dir=str(storage_dir))
112
+ return index, len(documents)
113
+
114
+
115
+ def insert_documents_into_index(
116
+ index: Any, documents: list[Any], *, show_progress: bool | None = None
117
+ ) -> int:
118
+ """Transform documents once, then insert nodes into an existing index."""
119
+ nodes = documents_to_nodes(documents, show_progress=show_progress)
120
+ index.insert_nodes(nodes)
121
+ return len(documents)
122
+
123
+
124
+ __all__ = [
125
+ "build_ingestion_pipeline",
126
+ "create_index_from_documents",
127
+ "documents_to_nodes",
128
+ "insert_documents_into_index",
129
+ ]