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,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
|
+
]
|