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