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,266 @@
1
+ """Versioned MinerU block policy for LightRAG indexing.
2
+
3
+ MinerU's legacy ``content_list.json`` mixes semantic document content with
4
+ page-layout helpers. DeepTutor retains the raw parser cache for audit and
5
+ derives an independent, deep-copied list before the native LightRAG Sidecar
6
+ bridge decides which parser blocks are semantic.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import Counter
12
+ from copy import deepcopy
13
+ from dataclasses import dataclass
14
+ import hashlib
15
+ from pathlib import Path
16
+ import re
17
+ from typing import Any
18
+ import uuid
19
+
20
+ from deeptutor.services.file_io import atomic_write_json
21
+
22
+ POLICY_ID = "mineru-legacy-content-list-v1"
23
+ LEDGER_DIRNAME = "deeptutor_ingestion_audit"
24
+ ATTEMPT_LEDGER_DIRNAME = "deeptutor_ingestion_attempts"
25
+
26
+ FILTERED_LAYOUT_TYPES = frozenset({"footer", "header", "page_number"})
27
+ PRESERVED_AUXILIARY_TYPES = frozenset({"aside_text", "page_footnote"})
28
+ PRESERVED_SEMANTIC_TYPES = frozenset(
29
+ {"chart", "code", "equation", "image", "list", "table", "text"}
30
+ )
31
+ PRESERVED_TYPES = PRESERVED_AUXILIARY_TYPES | PRESERVED_SEMANTIC_TYPES
32
+ MULTIMODAL_TYPES = PRESERVED_TYPES - {"text"}
33
+
34
+ # MinerU renamed its auxiliary block types between output schemas: what the
35
+ # legacy ``content_list.json`` calls ``header`` a newer parser emits as
36
+ # ``page_header``. Map the newer names back onto the vocabulary the policy is
37
+ # written in, so upgrading MinerU keeps classifying blocks instead of
38
+ # presenting every layout block as an unrecognized type.
39
+ V2_AUXILIARY_EQUIVALENTS = {
40
+ "aside_text": "page_aside_text",
41
+ "footer": "page_footer",
42
+ "header": "page_header",
43
+ "page_footnote": "page_footnote",
44
+ "page_number": "page_number",
45
+ }
46
+ _V2_TO_LEGACY_TYPE = {
47
+ v2_name: legacy_name
48
+ for legacy_name, v2_name in V2_AUXILIARY_EQUIVALENTS.items()
49
+ if v2_name != legacy_name
50
+ }
51
+
52
+ _SAFE_TYPE = re.compile(r"[a-z][a-z0-9_]{0,63}\Z")
53
+ _INVALID_TYPE = "<invalid>"
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class BlockPolicyDecision:
58
+ """Sanitized policy outcome plus an independent list for indexing."""
59
+
60
+ content_list: list[dict[str, Any]]
61
+ ledger: dict[str, Any] | None
62
+ unknown_type_counts: tuple[tuple[str, int], ...] = ()
63
+
64
+ def unknown_summary(self) -> str:
65
+ """``type=count`` rundown of block types the policy does not know."""
66
+ return ", ".join(
67
+ f"{content_type}={count}" for content_type, count in self.unknown_type_counts
68
+ )
69
+
70
+
71
+ def _sanitized_type(block: dict[str, Any]) -> str:
72
+ value = block.get("type")
73
+ if isinstance(value, str) and _SAFE_TYPE.fullmatch(value):
74
+ return value
75
+ return _INVALID_TYPE
76
+
77
+
78
+ def _sorted_counts(counter: Counter[str]) -> dict[str, int]:
79
+ return dict(sorted(counter.items()))
80
+
81
+
82
+ def _page_type_key(content_type: str, block: dict[str, Any]) -> str | None:
83
+ page_idx = block.get("page_idx")
84
+ if isinstance(page_idx, int) and page_idx >= 0:
85
+ return f"{content_type}:{page_idx}"
86
+ return None
87
+
88
+
89
+ def prepare_content_list(
90
+ blocks: list[dict[str, Any]],
91
+ *,
92
+ engine: str,
93
+ source_hash: str,
94
+ parser_signature: str,
95
+ ) -> BlockPolicyDecision:
96
+ """Derive the indexable content list without mutating parser-owned blocks.
97
+
98
+ Only MinerU's legacy structured output is classified. Other parse engines
99
+ retain their existing behavior because their block vocabularies have
100
+ independent contracts.
101
+ """
102
+ if engine != "mineru":
103
+ return BlockPolicyDecision(content_list=blocks, ledger=None)
104
+
105
+ raw_counts: Counter[str] = Counter()
106
+ filtered_counts: Counter[str] = Counter()
107
+ eligible_counts: Counter[str] = Counter()
108
+ eligible_multimodal_page_counts: Counter[str] = Counter()
109
+ unknown_counts: Counter[str] = Counter()
110
+ indexable: list[dict[str, Any]] = []
111
+
112
+ for raw_block in blocks:
113
+ if not isinstance(raw_block, dict):
114
+ raw_counts[_INVALID_TYPE] += 1
115
+ unknown_counts[_INVALID_TYPE] += 1
116
+ continue
117
+ block = raw_block
118
+ content_type = _sanitized_type(block)
119
+ content_type = _V2_TO_LEGACY_TYPE.get(content_type, content_type)
120
+ raw_counts[content_type] += 1
121
+ if content_type in FILTERED_LAYOUT_TYPES:
122
+ filtered_counts[content_type] += 1
123
+ continue
124
+ if content_type not in PRESERVED_TYPES:
125
+ # Filter only what we positively recognize as page chrome; index
126
+ # anything else. A MinerU release that adds a block type must not
127
+ # silently drop its content, nor abort the whole ingest — before
128
+ # this policy existed every block was indexed, and an unrecognized
129
+ # type is far more likely to be new content than new chrome.
130
+ unknown_counts[content_type] += 1
131
+
132
+ eligible_counts[content_type] += 1
133
+ if content_type in MULTIMODAL_TYPES:
134
+ page_type_key = _page_type_key(content_type, block)
135
+ if page_type_key is not None:
136
+ eligible_multimodal_page_counts[page_type_key] += 1
137
+ indexable.append(deepcopy(block))
138
+
139
+ ledger: dict[str, Any] = {
140
+ "schema_version": 1,
141
+ "policy": {
142
+ "id": POLICY_ID,
143
+ "input_schema": "legacy-content-list",
144
+ "filtered_layout_types": sorted(FILTERED_LAYOUT_TYPES),
145
+ "preserved_auxiliary_types": sorted(PRESERVED_AUXILIARY_TYPES),
146
+ "preserved_semantic_types": sorted(PRESERVED_SEMANTIC_TYPES),
147
+ "v2_auxiliary_equivalents": dict(sorted(V2_AUXILIARY_EQUIVALENTS.items())),
148
+ "v2_auxiliary_normalized": True,
149
+ },
150
+ "parser": {
151
+ "engine": engine,
152
+ "source_hash": source_hash,
153
+ "parser_signature": parser_signature,
154
+ },
155
+ "counts": {
156
+ "raw_total": len(blocks),
157
+ "raw_by_type": _sorted_counts(raw_counts),
158
+ "filtered_total": sum(filtered_counts.values()),
159
+ "filtered_by_type": _sorted_counts(filtered_counts),
160
+ "eligible_total": len(indexable),
161
+ "eligible_by_type": _sorted_counts(eligible_counts),
162
+ "eligible_multimodal_total": sum(
163
+ count
164
+ for content_type, count in eligible_counts.items()
165
+ if content_type in MULTIMODAL_TYPES
166
+ ),
167
+ "eligible_multimodal_by_type_and_page": _sorted_counts(eligible_multimodal_page_counts),
168
+ "unknown_total": sum(unknown_counts.values()),
169
+ "unknown_by_type": _sorted_counts(unknown_counts),
170
+ },
171
+ "invariants": {
172
+ "input_blocks_mutated": False,
173
+ "eligible_order_preserved": True,
174
+ "unknown_types_indexed": True,
175
+ "raw_parser_artifacts_mutated": False,
176
+ },
177
+ }
178
+ return BlockPolicyDecision(
179
+ content_list=indexable,
180
+ ledger=ledger,
181
+ unknown_type_counts=tuple(sorted(unknown_counts.items())),
182
+ )
183
+
184
+
185
+ def write_decision_ledger(
186
+ working_dir: Path,
187
+ document_id: str,
188
+ ledger: dict[str, Any],
189
+ *,
190
+ attempt_id: str | None = None,
191
+ ) -> Path:
192
+ """Persist the accepted decision that corresponds to the current index."""
193
+ document_id_sha256 = hashlib.sha256(document_id.encode()).hexdigest()
194
+ path = Path(working_dir) / LEDGER_DIRNAME / f"{document_id_sha256[:16]}.json"
195
+ decision = {
196
+ "ledger_role": "current-index",
197
+ "policy_outcome": "accepted",
198
+ }
199
+ if attempt_id is not None:
200
+ decision["attempt_id"] = attempt_id
201
+ atomic_write_json(
202
+ path,
203
+ {
204
+ **ledger,
205
+ "document_id_sha256": document_id_sha256,
206
+ "decision": decision,
207
+ },
208
+ )
209
+ return path
210
+
211
+
212
+ def write_attempt_ledger(
213
+ kb_dir: Path,
214
+ document_id: str,
215
+ ledger: dict[str, Any],
216
+ *,
217
+ outcome: str,
218
+ ) -> tuple[Path, str]:
219
+ """Persist one immutable policy attempt outside a candidate version.
220
+
221
+ ``unknown_types`` means the document indexed, but carried block types the
222
+ policy has no entry for — the signal to extend ``PRESERVED_TYPES`` or
223
+ ``FILTERED_LAYOUT_TYPES``, not an ingest failure.
224
+ """
225
+ if outcome not in {"accepted", "unknown_types"}:
226
+ raise ValueError(f"Unsupported policy outcome: {outcome}")
227
+
228
+ document_id_sha256 = hashlib.sha256(document_id.encode()).hexdigest()
229
+ attempt_id = uuid.uuid4().hex
230
+ policy = ledger.get("policy")
231
+ parser = ledger.get("parser")
232
+ policy_id = str(policy.get("id") or "") if isinstance(policy, dict) else ""
233
+ parser_signature = str(parser.get("parser_signature") or "") if isinstance(parser, dict) else ""
234
+ key_material = "\0".join((document_id_sha256, policy_id, parser_signature, outcome, attempt_id))
235
+ attempt_key_sha256 = hashlib.sha256(key_material.encode()).hexdigest()
236
+ path = Path(kb_dir) / ATTEMPT_LEDGER_DIRNAME / f"{attempt_key_sha256}.json"
237
+ atomic_write_json(
238
+ path,
239
+ {
240
+ **ledger,
241
+ "document_id_sha256": document_id_sha256,
242
+ "decision": {
243
+ "attempt_id": attempt_id,
244
+ "attempt_key_sha256": attempt_key_sha256,
245
+ "ledger_role": "attempt",
246
+ "policy_outcome": outcome,
247
+ },
248
+ },
249
+ )
250
+ return path, attempt_id
251
+
252
+
253
+ __all__ = [
254
+ "ATTEMPT_LEDGER_DIRNAME",
255
+ "BlockPolicyDecision",
256
+ "FILTERED_LAYOUT_TYPES",
257
+ "LEDGER_DIRNAME",
258
+ "MULTIMODAL_TYPES",
259
+ "POLICY_ID",
260
+ "PRESERVED_AUXILIARY_TYPES",
261
+ "PRESERVED_SEMANTIC_TYPES",
262
+ "PRESERVED_TYPES",
263
+ "prepare_content_list",
264
+ "write_attempt_ledger",
265
+ "write_decision_ledger",
266
+ ]
@@ -0,0 +1,137 @@
1
+ """Seed a rebuild with content-bound LightRAG indexing responses (#1577)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+ import re
9
+ from typing import Any
10
+
11
+ from deeptutor.services.file_io import atomic_write_json
12
+
13
+ from .engine import workspace_for
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _CACHE_FILE = "kv_store_llm_response_cache.json"
18
+ # Query answers and keyword extraction depend on the current graph and must
19
+ # never cross a full rebuild. These types are produced from source content by
20
+ # the pinned LightRAG SDK and include the model identity in their hash.
21
+ _INDEX_TYPES = frozenset({"extract", "summary", "analysis", "smartheading"})
22
+ _CACHE_KEY = re.compile(r"^[^:]+:([a-z]+):[0-9a-f]{32,64}$")
23
+
24
+
25
+ def _policy_identity(policy: Any) -> tuple[str, str, str] | None:
26
+ """Read both current nested role fingerprints and older flat policies."""
27
+ if not isinstance(policy, dict):
28
+ return None
29
+ extract = policy.get("extract")
30
+ if isinstance(extract, dict):
31
+ vlm = policy.get("vlm") if isinstance(policy.get("vlm"), dict) else {}
32
+ vlm_snapshot = vlm.get("snapshot") if isinstance(vlm.get("snapshot"), dict) else {}
33
+ fingerprint = extract.get("fingerprint")
34
+ if isinstance(fingerprint, str) and fingerprint:
35
+ return (
36
+ fingerprint,
37
+ str(vlm.get("mode") or "disabled"),
38
+ str(vlm_snapshot.get("fingerprint") or ""),
39
+ )
40
+ fingerprint = policy.get("fingerprint")
41
+ if isinstance(fingerprint, str) and fingerprint:
42
+ return (fingerprint, "", "")
43
+ return None
44
+
45
+
46
+ def _read_policy_identity(root: Path) -> tuple[str, str, str] | None:
47
+ try:
48
+ meta = json.loads((root / "meta.json").read_text(encoding="utf-8"))
49
+ except (OSError, ValueError):
50
+ # Interrupted versions have no publication marker but may already
51
+ # have a durable cache worth preserving.
52
+ return None
53
+ return _policy_identity(meta.get("indexing_policy")) if isinstance(meta, dict) else None
54
+
55
+
56
+ def _index_entries(path: Path) -> dict[str, dict[str, Any]]:
57
+ if path.is_symlink() or not path.is_file():
58
+ return {}
59
+ try:
60
+ payload = json.loads(path.read_text(encoding="utf-8"))
61
+ except (OSError, ValueError) as exc:
62
+ logger.warning("Ignoring unreadable LightRAG cache %s: %s", path, type(exc).__name__)
63
+ return {}
64
+ if not isinstance(payload, dict):
65
+ return {}
66
+ entries: dict[str, dict[str, Any]] = {}
67
+ for key, value in payload.items():
68
+ match = _CACHE_KEY.fullmatch(key) if isinstance(key, str) else None
69
+ if (
70
+ match
71
+ and match.group(1) in _INDEX_TYPES
72
+ and isinstance(value, dict)
73
+ and value.get("cache_type") == match.group(1)
74
+ and isinstance(value.get("return"), str)
75
+ and value["return"]
76
+ ):
77
+ entries[key] = value
78
+ return entries
79
+
80
+
81
+ def inherit_index_cache(kb_dir: Path, target_root: Path, policy: dict[str, Any]) -> bool:
82
+ """Carry only verified indexing responses into a fresh version workspace.
83
+
84
+ Merge matching published versions with the immediately preceding
85
+ interrupted version, which may have no ``meta.json``. A partial interrupted
86
+ cache must not hide the rest of an older published cache. Newer entries win
87
+ on duplicate content keys; query answers never enter the merge.
88
+ """
89
+ target = target_root / workspace_for(target_root) / _CACHE_FILE
90
+ if target.exists():
91
+ return False
92
+ target_identity = _policy_identity(policy)
93
+ target_version = int(target_root.name.removeprefix("version-"))
94
+ candidates: list[tuple[int, int, int, Path]] = []
95
+ for root in kb_dir.glob("version-*"):
96
+ if not root.is_dir() or root == target_root:
97
+ continue
98
+ try:
99
+ version = int(root.name.removeprefix("version-"))
100
+ except ValueError:
101
+ continue
102
+ if version >= target_version:
103
+ continue
104
+ donor_identity = _read_policy_identity(root)
105
+ # An unpublished predecessor may already have useful content-addressed
106
+ # extraction calls. Published versions must match the current roles.
107
+ if donor_identity is None and version == target_version - 1:
108
+ identity_rank = 3
109
+ elif donor_identity is not None and (
110
+ target_identity is None or donor_identity == target_identity
111
+ ):
112
+ identity_rank = 2
113
+ else:
114
+ continue
115
+ paths = [root / _CACHE_FILE, *root.glob(f"deeptutor_*/{_CACHE_FILE}")]
116
+ for path in paths:
117
+ if path.is_file() and not path.is_symlink():
118
+ candidates.append((identity_rank, version, int(path.parent != root), path))
119
+ if not candidates:
120
+ return False
121
+
122
+ merged: dict[str, dict[str, Any]] = {}
123
+ used = 0
124
+ for _, _, _, donor in sorted(candidates):
125
+ entries = _index_entries(donor)
126
+ if not entries:
127
+ continue
128
+ merged.update(entries)
129
+ used += 1
130
+ if not merged:
131
+ return False
132
+ atomic_write_json(target, merged)
133
+ logger.info("Inherited %d LightRAG indexing cache entries from %d donors", len(merged), used)
134
+ return True
135
+
136
+
137
+ __all__ = ["inherit_index_cache"]