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,29 @@
1
+ """Background services contributed by this installed module."""
2
+
3
+
4
+ async def start_github_sync(application_container) -> None:
5
+ from deeptutor.services.github_source.sync_service import get_sync_service
6
+
7
+ await get_sync_service().start()
8
+
9
+
10
+ async def stop_github_sync(application_container) -> None:
11
+ from deeptutor.services.github_source.sync_service import get_sync_service
12
+
13
+ await get_sync_service().stop()
14
+
15
+
16
+ async def start_web_source_sync(application_container) -> None:
17
+ from deeptutor.services.web_source.scheduler import (
18
+ start_web_source_sync_scheduler,
19
+ )
20
+
21
+ await start_web_source_sync_scheduler()
22
+
23
+
24
+ async def stop_web_source_sync(application_container) -> None:
25
+ from deeptutor.services.web_source.scheduler import (
26
+ stop_web_source_sync_scheduler,
27
+ )
28
+
29
+ await stop_web_source_sync_scheduler()
@@ -0,0 +1,40 @@
1
+ """Knowledge-base name validation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+
8
+ _CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
9
+ _FORBIDDEN_CHARS = set('<>:"/\\|?*#%')
10
+ _MAX_KB_NAME_LENGTH = 120
11
+
12
+
13
+ def validate_knowledge_base_name(name: str) -> str:
14
+ """Validate and normalize a user-facing knowledge-base name.
15
+
16
+ Names may contain Unicode letters, spaces, dots, hyphens, underscores, and
17
+ common punctuation, but must not contain filesystem or URL-reserved
18
+ separators that would break KB directories or API route paths.
19
+ """
20
+ normalized = unicodedata.normalize("NFC", str(name or "")).strip()
21
+ if not normalized:
22
+ raise ValueError("Knowledge base name is required")
23
+ if normalized in {".", ".."}:
24
+ raise ValueError("Knowledge base name cannot be '.' or '..'")
25
+ if len(normalized) > _MAX_KB_NAME_LENGTH:
26
+ raise ValueError(
27
+ f"Knowledge base name is too long; maximum length is {_MAX_KB_NAME_LENGTH}"
28
+ )
29
+ if _CONTROL_CHARS.search(normalized):
30
+ raise ValueError("Knowledge base name cannot contain control characters")
31
+
32
+ forbidden = sorted(ch for ch in _FORBIDDEN_CHARS if ch in normalized)
33
+ if forbidden:
34
+ joined = " ".join(forbidden)
35
+ raise ValueError(
36
+ "Knowledge base name contains reserved characters: "
37
+ f"{joined}. Avoid path or URL separators such as /, \\, ?, #, and %."
38
+ )
39
+
40
+ return normalized
@@ -0,0 +1,45 @@
1
+ """Application ports used by knowledge indexing to report live progress.
2
+
3
+ The knowledge domain owns progress facts but knows nothing about FastAPI,
4
+ WebSockets, or the API task-stream implementation. API adapters install the
5
+ two callables for the lifetime of a server process; CLI/SDK use the no-op
6
+ defaults.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Awaitable, Callable
12
+ from typing import Any
13
+
14
+ ProgressBroadcast = Callable[[str, dict[str, Any]], Awaitable[None]]
15
+ TaskEventEmitter = Callable[[str, str, dict[str, Any]], None]
16
+
17
+ _broadcast: ProgressBroadcast | None = None
18
+ _emit_task_event: TaskEventEmitter | None = None
19
+
20
+
21
+ def install_progress_ports(
22
+ *,
23
+ broadcast: ProgressBroadcast | None,
24
+ emit_task_event: TaskEventEmitter | None,
25
+ ) -> None:
26
+ global _broadcast, _emit_task_event
27
+ _broadcast = broadcast
28
+ _emit_task_event = emit_task_event
29
+
30
+
31
+ async def broadcast_progress(kb_name: str, progress: dict[str, Any]) -> None:
32
+ if _broadcast is not None:
33
+ await _broadcast(kb_name, progress)
34
+
35
+
36
+ def emit_task_progress(task_id: str, progress: dict[str, Any]) -> None:
37
+ if _emit_task_event is not None:
38
+ _emit_task_event(task_id, "progress", progress)
39
+
40
+
41
+ __all__ = [
42
+ "broadcast_progress",
43
+ "emit_task_progress",
44
+ "install_progress_ports",
45
+ ]
@@ -0,0 +1,355 @@
1
+ """
2
+ Progress Tracker - Tracks knowledge base initialization progress
3
+ """
4
+
5
+ import asyncio
6
+ from collections.abc import Callable
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ import json
10
+ import logging
11
+ from pathlib import Path
12
+
13
+ from deeptutor.services.file_io import atomic_write_json
14
+
15
+ # Use unified logging system
16
+
17
+ _logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _logger_instance():
21
+ return _logger
22
+
23
+
24
+ def render_message_template(template: str, params: dict[str, object]) -> str:
25
+ """Fill an i18next-style ``{{name}}`` template with *params*.
26
+
27
+ Progress lines are shown verbatim in the web log box, so they have to be
28
+ translatable — but the backend has no viewer language (indexing runs as a
29
+ detached task, and the language of record lives in the browser). So the
30
+ wire carries the English template plus its values and the frontend renders
31
+ it with ``t()``; this produces the English fallback for every other
32
+ consumer, from the same single string.
33
+ """
34
+ text = template
35
+ for name, value in params.items():
36
+ text = text.replace("{{" + name + "}}", str(value))
37
+ return text
38
+
39
+
40
+ class ProgressStage(Enum):
41
+ """Initialization stage"""
42
+
43
+ INITIALIZING = "initializing" # Initializing
44
+ PROCESSING_DOCUMENTS = "processing_documents" # Processing documents
45
+ PROCESSING_FILE = "processing_file" # Processing single file
46
+ COMPLETED = "completed" # Completed
47
+ ERROR = "error" # Error
48
+
49
+
50
+ def visible_progress(progress: dict, kb_dir: Path) -> dict:
51
+ """A prepared native terminal record is visible only after publication."""
52
+ version = progress.get("publication_version")
53
+ if progress.get("stage") != "completed" or not version:
54
+ return progress
55
+ from deeptutor.services.rag.pipelines.lightrag.storage import meta_is_native_published
56
+
57
+ if isinstance(version, str) and Path(version).name == version and version not in {".", ".."}:
58
+ if meta_is_native_published(kb_dir / version):
59
+ return progress
60
+ return {
61
+ **progress,
62
+ "stage": "processing_documents",
63
+ "message": "Finalizing index publication",
64
+ "message_key": "Finalizing index publication",
65
+ "message_params": {},
66
+ "progress_percent": 99,
67
+ "index_changed": False,
68
+ }
69
+
70
+
71
+ class ProgressTracker:
72
+ """Progress tracker"""
73
+
74
+ def __init__(self, kb_name: str, base_dir: Path):
75
+ self.kb_name = kb_name
76
+ self.base_dir = base_dir
77
+ self.kb_dir = base_dir / kb_name
78
+ self.progress_file = self.kb_dir / ".progress.json"
79
+ self._callbacks: list = [] # Support multiple callbacks
80
+ self.task_id: str | None = None # Task ID (for log identification)
81
+
82
+ def set_callback(self, callback: Callable[[dict], None]):
83
+ """Set progress callback function (can be called multiple times to add multiple callbacks)"""
84
+ if callback not in self._callbacks:
85
+ self._callbacks.append(callback)
86
+
87
+ def remove_callback(self, callback: Callable[[dict], None]):
88
+ """Remove progress callback function"""
89
+ if callback in self._callbacks:
90
+ self._callbacks.remove(callback)
91
+
92
+ def _notify(self, progress: dict):
93
+ """Notify progress update (call all callbacks)"""
94
+ from deeptutor.runtime.mode import is_server
95
+
96
+ if is_server():
97
+ try:
98
+ from deeptutor.knowledge.progress_events import broadcast_progress
99
+
100
+ try:
101
+ loop = asyncio.get_running_loop()
102
+ loop.create_task(broadcast_progress(self.kb_name, progress))
103
+ except RuntimeError:
104
+ pass
105
+ except (ImportError, Exception):
106
+ pass
107
+
108
+ for callback in self._callbacks:
109
+ try:
110
+ callback(progress)
111
+ except Exception as e:
112
+ _logger_instance().debug("Progress callback error: %s", e)
113
+
114
+ def _save_progress(self, progress: dict):
115
+ """Save progress to kb_config.json and local .progress.json file"""
116
+ # Save to kb_config.json (centralized config)
117
+ stored_progress = progress
118
+ try:
119
+ from deeptutor.knowledge.manager import KnowledgeBaseManager
120
+
121
+ manager = KnowledgeBaseManager(base_dir=str(self.base_dir))
122
+
123
+ # A prepared completion remains processing until its version is published.
124
+ progress = visible_progress(progress, self.kb_dir)
125
+ stage = progress.get("stage", "")
126
+ if stage == "completed":
127
+ status = "ready"
128
+ elif stage == "error":
129
+ status = "error"
130
+ elif stage in [
131
+ "initializing",
132
+ "processing_documents",
133
+ "processing_file",
134
+ ]:
135
+ status = "processing"
136
+ else:
137
+ status = "initializing"
138
+
139
+ # Update kb_config.json with status and progress
140
+ manager.update_kb_status(
141
+ name=self.kb_name,
142
+ status=status,
143
+ progress={
144
+ "stage": progress.get("stage"),
145
+ "message": progress.get("message"),
146
+ "percent": progress.get("progress_percent", 0),
147
+ "current": progress.get("current", 0),
148
+ "total": progress.get("total", 0),
149
+ "file_name": progress.get("file_name"),
150
+ "error": progress.get("error"),
151
+ "error_code": progress.get("error_code"),
152
+ "retryable": progress.get("retryable"),
153
+ "timestamp": progress.get("timestamp"),
154
+ "task_id": progress.get("task_id"),
155
+ "indexed_count": progress.get("indexed_count"),
156
+ "index_changed": progress.get("index_changed"),
157
+ "index_action": progress.get("index_action"),
158
+ # Carried alongside the rendered English so a page reload
159
+ # can still translate the last line it shows.
160
+ "message_key": progress.get("message_key"),
161
+ "message_params": progress.get("message_params"),
162
+ },
163
+ )
164
+ except Exception as e:
165
+ _logger_instance().warning("Failed to save progress to kb_config.json: %s", e)
166
+
167
+ # Persist the last seen progress snapshot so websocket subscribers and
168
+ # page reloads can recover the live state without relying on in-memory callbacks.
169
+ try:
170
+ atomic_write_json(self.progress_file, stored_progress)
171
+ except Exception as e:
172
+ _logger_instance().warning(
173
+ "Failed to persist progress snapshot for '%s': %s", self.kb_name, e
174
+ )
175
+
176
+ def update(
177
+ self,
178
+ stage: ProgressStage,
179
+ message: str = "",
180
+ current: int = 0,
181
+ total: int = 0,
182
+ file_name: str = "",
183
+ error: str | None = None,
184
+ error_code: str | None = None,
185
+ retryable: bool | None = None,
186
+ indexed_count: int | None = None,
187
+ index_changed: bool | None = None,
188
+ index_action: str | None = None,
189
+ message_key: str | None = None,
190
+ message_params: dict[str, object] | None = None,
191
+ publication_version: str | None = None,
192
+ ):
193
+ """Update progress.
194
+
195
+ Pass ``message_key`` (an English ``{{name}}`` template) plus
196
+ ``message_params`` instead of a pre-formatted ``message`` so the web log
197
+ box can translate the line; ``message`` is then rendered from the same
198
+ template for every consumer that has no i18n of its own.
199
+ """
200
+ params = message_params or {}
201
+ if message_key and not message:
202
+ message = render_message_template(message_key, params)
203
+ progress: dict[str, object] = {
204
+ "kb_name": self.kb_name,
205
+ "task_id": self.task_id,
206
+ "stage": stage.value,
207
+ "message": message,
208
+ "current": current,
209
+ "total": total,
210
+ "file_name": file_name,
211
+ "progress_percent": int(current / total * 100) if total > 0 else 0,
212
+ "timestamp": datetime.now().isoformat(),
213
+ }
214
+ if indexed_count is not None:
215
+ progress["indexed_count"] = indexed_count
216
+ if index_changed is not None:
217
+ progress["index_changed"] = index_changed
218
+ if index_action:
219
+ progress["index_action"] = index_action
220
+ if message_key:
221
+ progress["message_key"] = message_key
222
+ progress["message_params"] = params
223
+
224
+ if error:
225
+ progress["error"] = error
226
+ progress["stage"] = ProgressStage.ERROR.value
227
+ if error_code:
228
+ progress["error_code"] = error_code
229
+ if retryable is not None:
230
+ progress["retryable"] = retryable
231
+
232
+ if publication_version is not None:
233
+ progress["publication_version"] = publication_version
234
+ persisted_progress = progress
235
+ progress = visible_progress(progress, self.kb_dir)
236
+ message = str(progress["message"])
237
+
238
+ # Output to logger (terminal and log file)
239
+ try:
240
+ logger = _logger_instance()
241
+ prefix = f"[{self.task_id}]" if self.task_id else ""
242
+
243
+ if total > 0:
244
+ percent = progress["progress_percent"]
245
+ progress_msg = f"{prefix} {message} ({current}/{total}, {percent}%)"
246
+ if file_name:
247
+ progress_msg += f" - File: {file_name}"
248
+ else:
249
+ progress_msg = f"{prefix} {message}"
250
+ if file_name:
251
+ progress_msg += f" - File: {file_name}"
252
+
253
+ if error:
254
+ logger.error(f"{progress_msg} - Error: {error}")
255
+ else:
256
+ logger.info(progress_msg)
257
+ except Exception:
258
+ # If unified logging fails unexpectedly, use stdlib logger as fallback.
259
+ fallback_logger = logging.getLogger("deeptutor.ProgressTracker")
260
+ prefix = f"[{self.task_id}]" if self.task_id else ""
261
+ fallback_logger.warning(
262
+ "%s [ProgressTracker] %s (%s/%s)",
263
+ prefix,
264
+ message,
265
+ current,
266
+ total if total > 0 else "?",
267
+ )
268
+ if error:
269
+ fallback_logger.error("%s [ProgressTracker] Error: %s", prefix, error)
270
+
271
+ self._save_progress(persisted_progress)
272
+
273
+ if self.task_id:
274
+ try:
275
+ from deeptutor.knowledge.progress_events import emit_task_progress
276
+
277
+ emit_task_progress(self.task_id, progress)
278
+ except Exception as e:
279
+ _logger_instance().debug("Failed to emit task progress event: %s", e)
280
+
281
+ self._notify(progress)
282
+
283
+ def verify_terminal(
284
+ self,
285
+ *,
286
+ current: int,
287
+ total: int,
288
+ indexed_count: int,
289
+ index_action: str,
290
+ publication_version: str | None = None,
291
+ ) -> None:
292
+ """Fail publication if either required terminal persistence write was lost."""
293
+ expected = {
294
+ "stage": "completed",
295
+ "task_id": self.task_id,
296
+ "current": current,
297
+ "total": total,
298
+ "indexed_count": indexed_count,
299
+ "index_changed": True,
300
+ "index_action": index_action,
301
+ "progress_percent": 100,
302
+ }
303
+ if publication_version is not None:
304
+ expected["publication_version"] = publication_version
305
+ try:
306
+ with self.progress_file.open(encoding="utf-8") as handle:
307
+ persisted = json.load(handle)
308
+ if not isinstance(persisted, dict) or any(
309
+ persisted.get(key) != value for key, value in expected.items()
310
+ ):
311
+ raise ValueError("Terminal progress does not match this task.")
312
+ from deeptutor.knowledge.manager import KnowledgeBaseManager
313
+
314
+ manager = KnowledgeBaseManager(base_dir=str(self.base_dir))
315
+ entry = manager.get_kb_entry(self.kb_name) or {}
316
+ expected_status = "processing" if publication_version else "ready"
317
+ if entry.get("status") != expected_status:
318
+ raise ValueError("Knowledge-base status was not persisted.")
319
+ if publication_version and (entry.get("progress") or {}).get("task_id") != self.task_id:
320
+ raise ValueError("Knowledge-base progress belongs to another task.")
321
+ except Exception as exc:
322
+ raise RuntimeError("Index terminal state was not persisted.") from exc
323
+
324
+ def get_progress(self) -> dict | None:
325
+ """Get current progress"""
326
+ if self.progress_file.exists():
327
+ try:
328
+ with open(self.progress_file, encoding="utf-8") as f:
329
+ return visible_progress(json.load(f), self.kb_dir)
330
+ except Exception as e:
331
+ _logger_instance().debug(f"Failed to read progress file for '{self.kb_name}': {e}")
332
+
333
+ try:
334
+ from deeptutor.knowledge.manager import KnowledgeBaseManager
335
+
336
+ manager = KnowledgeBaseManager(base_dir=str(self.base_dir))
337
+ status = manager.get_kb_status(self.kb_name)
338
+ if status and status.get("progress"):
339
+ return status.get("progress")
340
+ except Exception as e:
341
+ _logger_instance().debug(
342
+ "Failed to recover progress snapshot from kb_config for '%s': %s",
343
+ self.kb_name,
344
+ e,
345
+ )
346
+
347
+ return None
348
+
349
+ def clear(self):
350
+ """Clear progress file"""
351
+ if self.progress_file.exists():
352
+ try:
353
+ self.progress_file.unlink()
354
+ except Exception as e:
355
+ _logger_instance().debug(f"Failed to clear progress file for '{self.kb_name}': {e}")
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python
2
+ """Shared staging rule for putting source documents into a KB's ``raw/`` tree.
3
+
4
+ Both ways of filling a knowledge base — creating one from a docs directory, and
5
+ adding documents to an existing one — flatten sources to their basename, so two
6
+ documents that share a name want the same path. Only one place should answer
7
+ what happens then, or the two entry points drift apart: the incremental path
8
+ kept both files while the create path copied straight onto the taken name and
9
+ indexed only the last one, silently losing the rest (#2041).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import itertools
16
+ from pathlib import Path
17
+ import shutil
18
+
19
+
20
+ def content_hash(file_path: Path) -> str:
21
+ """SHA-256 of a file, streamed in blocks so large documents stay off memory."""
22
+ sha256_hash = hashlib.sha256()
23
+ with open(file_path, "rb") as handle:
24
+ for block in iter(lambda: handle.read(65536), b""):
25
+ sha256_hash.update(block)
26
+ return sha256_hash.hexdigest()
27
+
28
+
29
+ def non_colliding(dest_path: Path) -> Path:
30
+ """A free sibling name for a *different* document that wants a taken one.
31
+
32
+ Two files can legitimately share a name and hold different content — a
33
+ docs tree with a README.md per folder is the ordinary case, and a book
34
+ series saved with one ``full.md`` per volume is the same shape. Dropping
35
+ the later one loses a document the user asked to index, silently.
36
+ """
37
+ if not dest_path.exists():
38
+ return dest_path
39
+ for index in itertools.count(2):
40
+ candidate = dest_path.with_name(f"{dest_path.stem} ({index}){dest_path.suffix}")
41
+ if not candidate.exists():
42
+ return candidate
43
+ raise AssertionError("unreachable") # pragma: no cover
44
+
45
+
46
+ def stage_source(
47
+ source_path: Path,
48
+ dest_path: Path,
49
+ *,
50
+ source_hash: str | None = None,
51
+ ) -> tuple[Path, bool]:
52
+ """Copy *source_path* to *dest_path* without replacing a different document.
53
+
54
+ Returns the path the document actually landed on, and whether a copy was
55
+ made. A name already holding the *same* content is reused rather than
56
+ written twice; a name holding a different document is left alone and the
57
+ newcomer takes a free sibling name, so both get indexed.
58
+ """
59
+ if dest_path.exists():
60
+ current = source_hash or content_hash(source_path)
61
+ if current == content_hash(dest_path):
62
+ return dest_path, False
63
+ staged = non_colliding(dest_path)
64
+ staged.parent.mkdir(parents=True, exist_ok=True)
65
+ shutil.copy2(source_path, staged)
66
+ return staged, True
67
+
68
+
69
+ __all__ = ["content_hash", "non_colliding", "stage_source"]